回滚之后,对象里的 done 仍然存在

一次任务更新在事务中把状态改为 done,随后抛出异常。数据库回滚到 todo,Ruby Hash 里的状态却仍是 done。换成 ActiveRecord 模型后,本篇实验也观察到相同差异:查询数据库得到 todo,原模型对象的属性仍为 done,调用 reload 后才重新对齐。

事务保护的状态范围因此需要明确。一个 Ruby 方法同时修改数据库、内存对象和外部系统,并不会因为套上事务 block 就获得整体撤销能力。先识别真正参与事务的资源,才能正确解释成功与失败。

本篇以前面的异常、依赖、测试和数据边界为基础。实验使用真实临时 SQLite 文件、两个独立连接和一个最小 ActiveRecord 模型;不需要 Rails 应用。完整源码位于 examples/ruby/labs/E06/,附件为 本篇实验源码。

固定的是驱动加载的数据库版本

基线为 CRuby 3.4.11、sqlite3 gem 2.7.3、SQLite 3.50.3、ActiveRecord 8.0.2。SQLite 版本来自连接执行 SELECT sqlite_version(),不是系统 sqlite3 命令。不同构件可能链接不同库,所以实验直接断言实际版本,偏离基线时立即失败。

依赖锁定在本篇自己的 Gemfile 与 lock 中,安装目录在临时目录,避免与主线或其他选修混用。使用 Ruby 3.4 从仓库根目录运行:

1
2
ruby examples/ruby/labs/E06/run-isolated.rb --install
ruby examples/ruby/labs/E06/run.rb

第一条安装锁定依赖,需要访问 RubyGems;第二条只执行实验,不自动安装。可通过 E06_BUNDLE_ROOT 指定独立目录。入口启动干净子进程,移除父进程的 Bundler 和 Ruby 注入环境后,再加载本篇锁文件。这里锁定的是教学复现条件,不代表这些版本是部署建议或当前最新版。

数据库位于 Dir.mktmpdir 创建的目录。两个原生连接和 ORM 连接池在退出路径关闭,目录随后清理。若把实验迁到真实数据库,建表、写入和清理必须重新定义作用域,不能照搬临时库的生命周期。

约束应由数据库再次检查

任务表采用如下结构,实验还建立引用任务 ID 的审计表:

1
2
3
4
5
6
7
CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL UNIQUE CHECK (length(trim(title)) > 0),
priority INTEGER NOT NULL CHECK (priority BETWEEN 1 AND 5),
status TEXT NOT NULL CHECK (status IN ('todo','done')),
version INTEGER NOT NULL DEFAULT 0
) STRICT;

本篇选择标题唯一只是为了得到稳定的冲突反例,不表示所有任务系统都应禁止同名任务。表约束应来自领域规则,不能从示例中继承一个未经讨论的业务限制。

实验分别插入重复标题、优先级 9、空值标题、字符串优先级 high 和不存在的外键 ID,五条写入都必须失败。每个连接在事务外显式设置 PRAGMA foreign_keys = ON,再执行外键反例。只声明外键而不确认连接配置,不足以证明约束已执行。SQLite 外键

STRICT 增加列类型约束,但仍可能接受可以无损转换的值;它不等同于 Ruby 的 instance_of?(Integer)。应用层负责解释输入格式,数据库负责持久状态的不变量,两层检查的责任不同。STRICT 表

CHECK 与 NOT NULL 也不应混为一项。优先级既不能缺失,也必须位于允许范围。这里组合声明两者,是为了让任何写入路径都遵守规则,包括绕过模型验证的批量更新。CREATE TABLE

参数化查询把内容留在值的位置

数据库预先存在 write、review 两项任务。实验使用下面的输入做只读对照:

1
2
3
4
payload = "missing' OR 1=1 --"
unsafe = db.execute("SELECT id FROM tasks WHERE title = '#{payload}'")
safe = db.execute('SELECT id FROM tasks WHERE title = ?', [payload])
raise unless unsafe.size == 2 && safe.empty?

拼接版本把输入解释成 SQL 条件,结果变成两行;绑定版本查找包含引号、空格和注释符号的完整标题,结果为零行。检查返回行数比“没有报错”更有意义,因为注入成功的查询往往正好能够正常执行。

占位符适合绑定值,表名、列名和排序方向仍属于 SQL 结构。需要动态排序时,应把外部选项映射到程序内预先允许的片段,而不是把字符串塞进占位符后假定它成为标识符。绑定机制的适用位置由 SQL 语法决定。SQLite 参数绑定

这个实验不执行删表,也不触及真实数据。它验证的是同一输入经过两条构造路径后的查询含义,不是对任意 SQL 构造器作安全认证。

异常传播决定事务包装器怎样收尾

sqlite3 驱动提供事务 block。实验先正常插入第二项任务,再由另一个连接查询数量为 2,验证提交后的可见状态。回滚路径则故意让领域异常离开 block:

1
2
3
4
5
6
7
8
9
10
memory = { status: 'todo' }
begin
db.transaction do
db.execute("UPDATE tasks SET status='done' WHERE id=1")
memory[:status] = 'done'
raise AbortDemo, 'reject rest of unit of work'
end
rescue AbortDemo
puts 'PASS explicit exception escaped transaction'
end

结果是数据库 todo、内存 done。本例的普通异常被驱动捕获,触发回滚后继续传播;内存赋值不属于数据库事务。调用者收到错误后应重新读取、显式恢复或放弃对象,不能继续把它当成已提交快照。

此结论对应固定驱动的 transaction 实现。不要把它扩大成所有控制流和所有异常都具有同样语义;提前返回、提交自身失败等路径需要独立验收。

另一个反例直接执行 BEGIN:先成功插入第三项,再捕获重复标题错误。此时同连接仍能查到第三项;执行显式 ROLLBACK 后,另一连接才确认它不存在。SQLite 默认的 ABORT 冲突处理撤销当前失败语句,并不自动丢弃此前的全部事务写入。冲突处理

把异常在事务内部吞掉,可能让外层包装器看到“正常结束”并提交剩余内容。决定回滚边界时,要同时检查数据库冲突策略与 Ruby 异常传播位置。

实验另设一条内部捕获路径:事务先插入名为 rescued 的任务,再尝试重复标题写入,并在 block 内捕获约束异常。外部连接最终能查到 rescued,证明剩余事务被提交。它与外部捕获的回滚案例使用同一驱动,差异来自异常是否越过包装器的边界。

这条写入还把优先级作为字符串 '3' 传给 SQLite。读取结果是整数 3,typeof(priority) 也是 integer,说明 STRICT 保留了无损转换规则。若接口必须拒绝字符串形式的数字,应在进入数据库之前检查类型。数据库接受的数据不一定满足更窄的应用输入约定。

重试失败事务时也应重新确定操作边界。仅重放最后一条 SQL,与重新读取条件后执行整个工作单元,可能具有不同含义。本篇只在明确释放锁后重试独立写入,并通过版本条件识别旧状态,没有提供可以自动套用的通用事务重试器。

两个连接暴露锁和旧状态

实验固定 DELETE journal,并让连接的 busy timeout 为零。连接 A 开启 transaction(:immediate),把优先级从 3 改成 4。提交前,连接 B 查询仍为 3;B 尝试另一项写入则得到 SQLite3::BusyException。A 提交释放写事务后,B 的同类写入成功。

sequenceDiagram
  participant A as 连接 A
  participant D as SQLite 文件
  participant B as 连接 B
  A->>D: BEGIN IMMEDIATE,更新优先级
  B->>D: SELECT
  D-->>B: 旧值 3
  B->>D: UPDATE
  D-->>B: BusyException
  A->>D: COMMIT
  B->>D: 再次 UPDATE
  D-->>B: 成功

两连接通过明确的调用顺序构造重叠事务,不依赖线程恰好撞上。这个结果说明本次锁边界,不等于测出了并发吞吐量。WAL、隔离级别和其他数据库有自己的行为,不能直接复制这个时间图。SQLite 事务

锁释放也不代表先前读取的业务状态仍然有效。实验额外执行带版本条件的更新:第一条 WHERE id=1 AND version=0 成功并递增版本;第二个连接仍使用版本 0,影响行数为零。第二条没有 SQL 异常,却没有完成业务更新,因此需要检查 changes。版本条件用于识别旧状态,是否重新读取后重试则由业务决定。

ORM 减少映射代码,不消除状态边界

最小模型 SqlTask 指向同一张表,并增加标题存在性验证。find 把持久数据映射为对象,update! 发出更新,事务仍落在数据库连接上:

1
2
3
4
5
6
7
8
9
item = SqlTask.find(1)
SqlTask.transaction do
item.update!(status: 'done')
raise ActiveRecord::Rollback
end
raise unless item.status == 'done'
raise unless SqlTask.find(1).status == 'todo'
item.reload
raise unless item.status == 'todo'

ActiveRecord::Rollback 在本例中触发事务回滚,但不作为普通异常继续抛给外层;原对象保留改过的属性。reload 读取数据库后更新对象。固定版本源码明确提示不能把数据库回滚等同于恢复全部模型实例状态。ActiveRecord 事务源码

模型验证失败与数据库约束失败也由不同路径产生。空标题的 create! 得到 RecordInvalid;绕过逐对象验证的 update_all(priority: 9) 仍被表约束阻止,得到 StatementInvalid。用 ORM 可以表达关系与对象操作,但不能用“模型里有 validation”代替数据库不变量。更新接口

验收与练习

通过时日志包含 injection_rows=2/0 ruby_memory=done database=todo,最后是 PASS lab E06。约束反例只有捕获指定错误才通过;没有抛错时脚本主动失败。全部版本、原始输出和锁文件随工程保存。

观察到的现象 应检查的位置
查询成功却返回过多行 SQL 结构与值是否分离
抛异常后仍有早先写入 冲突策略和事务边界
对象属性与查询结果不同 缓存实例与 reload 时机
更新无异常但影响零行 版本条件和业务冲突

练习一:把回滚示例中的 rescue AbortDemo 移到事务 block 内部,分别检查数据库和内存状态,再恢复外部捕获。解释为什么异常类型相同,提交结果却改变。

练习二:增加两个连接先读同一个版本的场景,再依次执行版本更新。要求恰有一次影响一行,另一次明确返回业务冲突;不要把零行更新输出成成功。

本篇没有覆盖连接池容量、跨数据库事务、迁移或进程崩溃恢复。事务也不会自动撤回已发送的网络请求。需要把数据库状态与外部副作用协调时,应先定义确认、重试与幂等协议,再选择相应实现。

参考资料

系列导航

导读 · 上一篇:E05:gem 维护与版本升级的兼容证据 · 下一篇:E07:受限规则语言:从 block DSL 到独立解析器 · 完整源码包