两个线程各往账户里加 10 块钱。加起来应该是 120。跑完是 110——少了 10 块,而且没有任何报错。

一、先看它是怎么少的

初始余额 100,两个线程都做同一件事:读余额、加 10、写回。

=== A. 不加任何锁 ===T1 读到 balance=100T2 读到 balance=100T2 写入 balance=110T1 写入 balance=110最终 balance = 110   ✗ 错了

两个人读到的是同一个 100,各自算出 110,然后一前一后写回去。后写的那个把先写的覆盖了——先写的那 10 块凭空消失。

这就是丢更新(lost update)。它的可怕之处在于:两次写入都成功了,没有任何异常。

二、窗口在哪

「读-改-写」不是一个原子动作,中间有三步:

1. SELECT balance   → 100
2. 在内存里算 100+10 = 110
3. UPDATE balance   → 110

丢更新发生在第 1 步和第 3 步之间。只要在那段时间里有别人也走了 1→3,两边就会基于同一个旧值算出结果。

修法的思路也就两条:要么让第 3 步发现"我读到的值已经变了"(乐观),要么干脆不让别人进这段(悲观)。

三、乐观锁:提交时校验版本号

给表加一个 version 字段,更新时带上它:

UPDATE accountSET balance = ?, version = version + 1WHERE id = 1 AND version = ?      -- 我读到的那个版本还在吗

版本对不上,影响行数就是 0,说明中途有人改过,那就重新读、重新算。

=== B. 乐观锁(带 version 校验 + 重试) ===T1 第 1 次:读到 balance=100 version=0T2 第 1 次:读到 balance=100 version=0T2 更新成功 balance=110T1 影响 0 行 —— 版本已被别人改过,重试T1 第 2 次:读到 balance=110 version=1T1 更新成功 balance=120最终 balance = 120   ✓ 对

注意 T1 的第二次读:它读到了 110 和 version=1——不是复用旧值,而是重新走了一遍。所以最终是 120。

这条路上没有任何锁。数据库不会阻塞 T1 的更新,只是告诉它"影响 0 行"。

四、悲观锁:先占住再读

另一种思路是根本不让人进来。SQLite 里是 BEGIN IMMEDIATE,MySQL 里是 SELECT ... FOR UPDATE:

=== C. 悲观锁(BEGIN IMMEDIATE 先占写锁) ===T1 拿到写锁,读到 balance=100T1 提交 balance=110T2 拿到写锁,读到 balance=110T2 提交 balance=120最终 balance = 120   ✓ 对

T2 根本没机会和 T1 交错——它等 T1 提交完才拿到锁,读到的是 110。

五、两种锁怎么选

不是"哪个更好",是看冲突概率和临界区长度。

冲突少、重试便宜 → 乐观锁。 大部分请求第一次就成功,不会阻塞任何人。代价是失败时要做一次重试,而且重试逻辑必须自己写。

冲突多、临界区长 → 悲观锁。 与其让一堆请求反复重试,不如排队。代价是并发度下降,还有死锁风险。

还有一个没写进这两个例子的选择:把"读-改-写"压成一条 SQL:

UPDATE account SET balance = balance + 10 WHERE id = 1

这样数据库在一条语句里完成,天然原子。上面整套锁的讨论都可以绕开。我的经验是——能不跨语句解决,就不要跨语句解决。

六、必须说清楚的一个坑

我第一次写这个实验时,用 BEGIN 包住了读操作,结果两个场景都报:

OperationalError: database is locked

而且丢更新反而"没复现出来"——因为读事务持有共享锁,等到要升级成写锁时,另一边已经占了写锁,SQLite 直接判定冲突。

这是锁冲突,不是丢更新。 两件事很容易混:一个是"我被拦住了",一个是"我畅通无阻但写错了"。我把 BEGIN 去掉、改用自动提交,才看到真正的丢更新。

这条经验值得记:复现并发 bug 时,先确认你复现的是想复现的那个,而不是顺手撞上的另一个。

七、诚实边界

这个实验用的是 SQLite,两个连接 + 两个线程。它证明了「读-改-写」之间有窗口这个原理,但SQLite 的锁机制跟 MySQL 不是一回事。

MySQL 的 InnoDB 有 MVCC,普通 SELECT 走的是快照读,不加锁;SELECT ... FOR UPDATE 才加行锁。所以同样的代码在 MySQL 上,表现会不一样——具体差在哪,我没法在这台机器上验证(本机没装 MySQL)。

原理部分(窗口、版本校验、重试)是通用的,实现细节要按具体数据库的文档来。

八、结论

丢更新是「读-改-写」三步之间的窗口造成的,两次写都成功,所以不报错。

乐观锁靠版本号在提交时发现冲突,冲突就重试;悲观锁靠锁把临界区串起来,别人进不来。前者不阻塞但可能白干,后者要排队但没有无效功。

最好的办法通常是避开这个模式本身——用一条原子 SQL 把读改写合并。

附:核心代码

# A. 不加锁:读、算、写,三步分开
b, v = con.execute("SELECT balance, version FROM account WHERE id=1").fetchone()
barrier.wait()                                    # 两个线程都读到之后再写
con.execute("UPDATE account SET balance=? WHERE id=1", (b + 10,))# B. 乐观锁:带版本号的条件更新,影响 0 行就重试
while True:b, v = con.execute("SELECT balance, version FROM account WHERE id=1").fetchone()cur = con.execute("UPDATE account SET balance=?, version=version+1 WHERE id=1 AND version=?",(b + 10, v))if cur.rowcount == 1:break# 版本变了,重读再来# C. 悲观锁:先占写锁,别人排队
con.execute("BEGIN IMMEDIATE")
b, _ = con.execute("SELECT balance, version FROM account WHERE id=1").fetchone()
con.execute("UPDATE account SET balance=? WHERE id=1", (b + 10,))
con.execute("COMMIT")