接着上一篇提过一句的乐观锁,这次用 SQLite + 多线程跑出完整对照组:裸读改写在并发下会不会真的丢更新、只做版本校验不重试会怎样、正确的重试循环要重试多少次才能追平、以及"重试时偷懒不重新读"这个常见错误写法到底错在哪。四组数据放在一起看,乐观锁这套东西才不是一句"加个 version 字段"就完了。
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,写的时候把"我读到的那个版本号"也放进 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 校验解决的是"正确性"(不会覆盖别人的修改),但只有校验、没有重试,解决不了"完整性"(该做的事情有没有真的做完)。
冲突之后,回到最开始重新读一遍——不是重试那条 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,一个先写成功、另一个冲突后重新读到新版本号再写,最终两次自增都生效,和压测的正确性结论一致。