乐观锁冲突率接近 0,脏写却仍在发生:一条没推进 version 的 UPDATE 让整套 CAS 静默失效
库存表加了 version 字段,写的是标准 CAS:WHERE id = ? AND version = ?,SET version = version + 1。上线之后冲突率指标长期贴着 0,每万次请求只有个位数。但业务侧依然偶发「两个请求读到同一份库存、各自算出结果、最后一个覆盖另一个」,对账时库存和流水对不上。日志里一条冲突记录都没有。
冲突率低本来是好指标,所以没人会去怀疑它「低得可疑」。这类 bug 不报错,只是数据慢慢不对,最后通常被归到「并发问题」或者「业务逻辑写错了」里去。
根因:乐观锁不是数据库的机制,是应用层的约定
数据库没有替你实现乐观锁。所谓约定,就是这张表的任何一次写入都必须满足两件事:
- (a)
WHERE里带version校验; - (b)
SET里把version推进。
只要有一条写入路径只做了 (a) 没做 (b),或者两个都没做,这次写入对别人来说就是「看不见的修改」——版本号没变,下一个拿着旧 version 的请求照样 CAS 成功,直接把刚写进去的数据覆盖掉。锁没坏,是有人从锁外面走进来了。
漏推进的路径,按出现频率排(工程观察,不是统计数据):
- 运维/DBA 手工 UPDATE 修数,改数据不改版本。
- 数据修复脚本、回滚脚本、初始化脚本,一次性执行,review 覆盖率最低。
- 老服务、或者另一个语言写的服务直连同一个库,那段代码是加
version之前写的。 - ORM 的全量 update /
saveOrUpdate:把实体所有字段整体写回,如果实体里的version是读出来的旧值,可能把 version 写回旧值(覆盖而非推进)。这比不推进更糟,会让版本倒退。 - 批量
UPDATE ... WHERE(多行命中),顺手脚本化的那种。 - 数据仓库/BI 回流、CDC 写回。
- 迁移工具、影子表切换之后忘了重建触发器或代码路径。
MySQL:用 BEFORE UPDATE 触发器强制推进
BEFORE UPDATE 触发器不能用 UPDATE 语句改本表(会报错),但可以用 SET NEW.col 改「要写进去的值」。手册 27.3.1 Trigger Syntax and Examples 明确写了:在 BEFORE trigger 中,如果有该列的 UPDATE 权限,可以 SET NEW.col_name = value;AFTER trigger 里无效,因为行已经改完了。
强制推进的写法:
delimiter //
CREATE TRIGGER trg_stock_version BEFORE UPDATE ON stock
FOR EACH ROW
BEGIN
SET NEW.version = OLD.version + 1;
END//
delimiter ;
细节一:必须写 OLD.version + 1,不能写 NEW.version + 1
应用层那条 UPDATE 已经写了 version = version + 1,此时 NEW.version 已经是 OLD + 1,触发器再 +1 就变成 OLD + 2,版本号凭空跳两格。
跳格本身不破坏正确性(只要单调递增就行),但会让「版本号 = 修改次数」这个约定失效,排查时对不上账。用 OLD.version + 1 是幂等覆盖:应用层推进过了,写进去的就是同一个值;没推进,触发器补上。
细节二:触发器不破坏 CAS 判定,但也不替你做冲突检测
InnoDB 的 UPDATE 是先按 WHERE 找行(这一步会读当前行的 version 做比较),命中之后才触发 BEFORE trigger。所以 WHERE version = ? 的校验发生在触发器之前,触发器改的是「要写进去的值」。
它解决的是版本停滞,不是检测冲突。这两件事必须分开:
WHERE带 version → 检测冲突(别人的写入被你发现)SET version = version + 1→ 让冲突可被检测(你的写入被别人发现)
只做前者,别人改了不推进,你检测不到;只做后者,你自己不校验,你覆盖别人。所以「加了触发器就安全了」是错的,触发器只替你做后半件事。真要用行版本做并发控制,也不该再叠 SELECT FOR UPDATE,二选一即可,叠加只会把两套语义搅在一起(相关讨论:)。
细节三:触发器会掩盖漏推进的证据,所以先诊断、再强制
一旦强制触发器上线,所有写入都会 +1,你再也没法靠「version 没变」发现哪条 SQL 忘了推进。漏网的路径全部被抹平,问题从「可观测」变成「已消失但不保证修好」。
所以顺序是先挂审计触发器,只记录、不改值,验证完记得删:
delimiter //
CREATE TRIGGER trg_stock_ver_audit BEFORE UPDATE ON stock
FOR EACH ROW
BEGIN
IF NEW.version OLD.version THEN
INSERT INTO _ver_miss(tbl, row_id, ts) VALUES ('stock', OLD.id, NOW(6));
END IF;
END//
delimiter ;
`` 是 NULL 安全等值,避免 version 可空时判断漏掉。触发器体内可以 INSERT 到别的表,手册里的 testref 示例就是这么做的。注意这张审计表要小、要定期清,别把写放大做成新问题。
多个 BEFORE UPDATE 触发器同时存在时,默认按创建顺序执行,可以用 FOLLOWS / PRECEDES 指定顺序(手册同页)。如果你已经上了强制触发器、又想临时加审计,审计触发器必须 PRECEDES 到强制触发器之前,否则永远看不到 version 没变的那一版。
跑一轮的方式:把审计触发器挂到测试/预发库,跑完整回归 + 一轮回放的真实写流量,然后
SELECT tbl, count(*) FROM _ver_miss GROUP BY tbl ORDER BY 2 DESC;
命中的就是漏推进的路径。生产库也可以短时间挂,但要接受额外的写入开销。
PostgreSQL:在触发器里直接抛异常
PostgreSQL 的对照做法不依赖客户端判据,触发器里直接校验并报错:
CREATE OR REPLACE FUNCTION handle_occ() RETURNS TRIGGER AS $occ$
BEGIN
IF (NEW.version != OLD.version) THEN
RAISE EXCEPTION 'Version mismatch: expected % given %', OLD.version, NEW.version USING ERRCODE='P0002';
ELSE
NEW.version := NEW.version + 1;
END IF;
RETURN NEW;
END;
$occ$ LANGUAGE plpgsql;
这个版本更强:调用方必须显式把 version 传进来,传错直接报错,连「忘了写 version 列」都会被拦下(因为传入值和 OLD.version 相等才放行)。代价是应用层每条 UPDATE 都必须显式带 version 列,否则一律失败,迁移成本高。参考:
.NET / EF 场景的常见组合
BIGINT RowVersion + BEFORE UPDATE 触发器自增,ORM 侧把该列标成 Computed。mysql-connector-net 的 PR #23 就是这个思路,同时提醒了一件事:判据要用 ROW_COUNT() = 1 而不是 > 0,等于 1 顺带校验了主键唯一。
简单的版本自增触发器在 StackOverflow 上也有现成例子:
触发器不是免费的
- 每行执行一次,批量 UPDATE 时是行级开销。批量脚本要单独评估。
- 隐式行为:新人看 SQL 完全看不出 version 会被改,必须写进表注释/README,否则下一个人会把它当历史遗留物删掉。
- 主从:row-based replication 下从库不重复执行触发器(官方文档有明确说明);如果混用 statement-based,行为不同,先确认
binlog_format。 - 权限:建触发器需要 TRIGGER 权限,很多生产账号没有。
- 触发器里的错误会让整条语句失败并回滚(手册:「An error during either a BEFORE or AFTER trigger results in failure of the entire statement」),所以要确认逻辑不会因为 NULL 或类型问题炸掉正常写入。
取舍
- 团队小、只有一套代码能写这张表、有强 review:单一 repository 入口 + 一个「所有 UPDATE 必须带 version」的检查脚本(比如 CI 里 grep mapper XML / 迁移 SQL)成本最低,触发器可以不上。
- 只要有 DBA 能直连生产、有第二个系统写过这张表、有历史遗留服务:应用层约定守不住,数据库层必须有防线。这种情况下触发器是唯一能拦住手工 SQL 的地方。
- 一个务实的折中:不在业务表上挂强制触发器,而是把 version 推进收敛到 repository 的唯一出口 + 定期跑审计(对比 binlog 里该表的 UPDATE 是否都带 version 列)。不改变运行时行为,但能持续发现漏网的路径。
参考链接
- MySQL 8.2 Trigger Syntax and Examples:
- Implementing OCC on the database(PostgreSQL 触发器抛异常):
- mysql-connector-net PR #23(RowVersion + 触发器自增 + Computed 列):
- StackOverflow:increment version column on update:
- MySQL 乐观锁的一般流程:
- SELECT FOR UPDATE 与行版本二选一(不要叠加):