bigywater.tech
并发控制实战

乐观锁的 version 检查失败之后,为什么必须重新查一次,不能拿着旧版本号硬撞

接着上一篇提过一句的乐观锁,这次用 SQLite + 多线程跑出完整对照组:裸读改写在并发下会不会真的丢更新、只做版本校验不重试会怎样、正确的重试循环要重试多少次才能追平、以及"重试时偷懒不重新读"这个常见错误写法到底错在哪。四组数据放在一起看,乐观锁这套东西才不是一句"加个 version 字段"就完了。

180/200 次丢失
本机真实实测:裸读改写(没有任何保护),10 个线程并发自增,丢了 180 次更新
200/200 正确
本机真实实测:完整的"重新读+重新算+重新写"重试循环,平均重试 3.04 次,结果分毫不差

真实实测:裸读改写,并发下丢失更新

10 个线程,每个线程对同一行做 20 次"读余额 → 加 1 → 写回去",中间故意留一点点延迟拉大竞争窗口。

真实实测
def naive_increment(n):
    for _ in range(n):
        balance = conn.execute("SELECT balance FROM accounts WHERE id=1").fetchone()[0]
        time.sleep(0.001)
        conn.execute("UPDATE accounts SET balance=? WHERE id=1", (balance + 1,))
        conn.commit()

# 10 个线程,每个线程跑 20 次
期望最终余额: 200 最终余额: 20 丢失的更新次数: 180

200 次自增,只有 20 次真正生效。问题出在"读"和"写"之间那个窗口——线程 A 读到 5,线程 B 也读到 5(A 还没来得及写回去),两个线程各自在自己的内存里算出"应该是 6",分别写回去,后写的那个直接覆盖掉先写的那个,相当于两次自增只留下了一次的效果。这就是经典的"丢失更新"(lost update),而且数据库不会报任何错——两条 UPDATE 语句各自都执行成功了,只是它们都不知道对方的存在。

源码 / 机制:version 字段怎么让"覆盖"变成"察觉"

思路很朴素:给每一行加一个 version,写的时候把"我读到的那个版本号"也放进 WHERE 里。

乐观锁的核心 SQL UPDATE accounts SET balance = ?, version = version + 1 WHERE id = ? AND version = ? -- 最后这个 version,必须是"这一轮读到的"那个值

这条语句的返回值(受影响行数)是判断依据:

场景WHERE 条件受影响行数含义
没人抢先改过version 还是我读到的那个1更新成功,version 自增
有人抢先改过version 已经变了,匹配不上0更新失败,但不报错、不阻塞

受影响行数是 0 这件事本身,就是"发生冲突"的全部信号——不需要额外查一次锁表,不需要等谁释放什么,一条 UPDATE 就把"检测"和"占用"合并成了一个原子操作。

真实实测:只检测不重试,冲突的那部分直接被放弃

加上 version 校验,但冲突之后不做任何补救——受影响行数是 0 就当这次自增没发生过。

真实实测
cur = conn.execute(
    "UPDATE accounts SET balance=?, version=version+1 WHERE id=1 AND version=?",
    (balance + 1, version),
)
conn.commit()
if cur.rowcount == 1:
    success_count += 1
else:
    conflict_count += 1   # 冲突了,但这一版没有重试,直接放弃
成功次数: 79, 冲突(被放弃)次数: 121 成功+冲突 = 200(应该等于 200)

这次余额不会再被错误覆盖——79 次成功的自增,每一次都对应生效的一次 +1,没有任何一次被悄悄吞掉或者重复计算。但代价是 121 次原本该发生的自增,因为撞上了版本冲突,就这么被扔掉了。version 校验解决的是"正确性"(不会覆盖别人的修改),但只有校验、没有重试,解决不了"完整性"(该做的事情有没有真的做完)。

真实实测:补上完整的重试循环,200/200 分毫不差

冲突之后,回到最开始重新读一遍——不是重试那条 UPDATE,是重新走一遍"读、算、写"整个流程。

真实实测
while True:
    balance, version = conn.execute(
        "SELECT balance, version FROM accounts WHERE id=1").fetchone()
    time.sleep(0.001)
    cur = conn.execute(
        "UPDATE accounts SET balance=?, version=version+1 WHERE id=1 AND version=?",
        (balance + 1, version),
    )
    conn.commit()
    if cur.rowcount == 1:
        break   # 成功,退出重试循环
    # 失败:什么都不用手动回滚,直接回到 while 开头重新 SELECT
最终余额: 200 总尝试次数: 608(含重试) 平均每次自增重试次数: 3.04 单次自增最多重试到第 50 次 总耗时: 0.40s

200 次自增,一次不多一次不少。代价是多花了 608 - 200 = 408 次"白跑"的尝试(平均每次自增要撞 3 次墙才能成功一次),运气最差的那一次撞了 50 次才成功——这就是乐观锁在高竞争场景下的成本:不会死锁,不会丢数据,但热点行争用激烈的时候,重试次数会地涨上去,浪费掉的数据库往返也是的开销。

真实实测:重试时偷懒用旧版本号硬撞,卡死近一半

一个常见的错误写法:重试时嫌重新 SELECT 麻烦,直接拿第一次读到的 balance/version 反复撞。

真实实测
balance, version = conn.execute("SELECT balance, version FROM accounts WHERE id=1").fetchone()
newBalance = balance + 1

for attempt in range(5):
    cur = conn.execute(
        "UPDATE accounts SET balance=?, version=version+1 WHERE id=1 AND version=?",
        (newBalance, version),   # 5 次重试,用的是同一份 stale 的 version
    )
    conn.commit()
    if cur.rowcount == 1:
        break
else:
    give_up_count += 1
最终余额: 118 彻底放弃的次数(重试 5 次都用同一份旧 version,全部失败): 82

200 次里有 82 次彻底失败,不是因为运气差撞了 5 次都不巧,而是这份写法只要第一次撞上冲突,后面 5 次重试就注定全部失败——version 只会越来越新,从来不会变回你手里这份旧值,拿着一个永远不可能再匹配上的条件反复执行同一条 UPDATE,跟完全不重试没有本质区别,只是徒增了几次无意义的数据库往返。这也是"必须重新查一次"这句话背后的原因:重试的意义在于拿到新的版本号,而不是"再给旧版本号一次机会"。

演示:两个并发写者,一次冲突和一次成功重试

简化到两个线程、一次冲突,看 version 从 0 涨到 2 的完整过程。

乐观锁冲突与重试 · 时间线
步骤操作结果

对应上面"完整重试循环"那组实验的最小复现:两个线程同时读到 version=0,一个先写成功、另一个冲突后重新读到新版本号再写,最终两次自增都生效,和压测的正确性结论一致。

参考与说明

  • 本文全部实验(裸读改写的丢失更新、只校验不重试的成功/冲突计数、完整重试循环的正确性与重试次数统计、错误的"旧版本号硬撞"重试的失败率)均在本机运行的 Python 3 + SQLite 上用多线程(10 线程 × 20 次/线程)完成,每次自增之间插入的毫秒级延迟以稳定复现竞争窗口,数据未做删改。
  • 死锁与乐观锁的对照参见转账系统的死锁那篇;数据库(InnoDB)自己的悲观锁 + 死锁检测机制参见InnoDB 是怎么发现并解开死锁的——三篇合起来覆盖了"悲观锁排序""数据库自己兜底""应用层乐观锁"这三条并发控制的主流思路。
  • 演示动画的冲突与重试时间线直接对应实验里"完整重试循环"这组代码的执行逻辑,version 从 0 到 2 的变化过程和的 SQLite rowcount 判断结果一致。
  • 没有涉及:乐观锁在多行(比如两账户转账)场景下的原子性权衡(拆成独立提交换取无死锁、但需要额外补偿机制,已在上一篇的现实系统延伸里讨论过)、重试之间加随机退避对吞吐量的实际影响、SQLite 自身的文件级锁与生产数据库(行级锁)在高并发写入下行为的差异。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号