bigywater.tech
并发控制实战

分布式转账加锁:SELECT FOR UPDATE 是不是表锁,验证过了

接着转账死锁那篇留的一个问题:单进程里用 sync.Mutex 按账户 ID 排序加锁能避免死锁,但分布式场景下不同服务实例各有各的内存,进程内的锁形同虚设,只能把"排序加锁"这件事搬到数据库层——用 SELECT ... FOR UPDATE。但这样做会不会把整张表锁死、拖垮性能?用 MySQL 跑了几组实验找答案。

3.7ms vs 1209.7ms
本机真实实测:主键索引下,锁不同行几乎不用等;锁同一行被阻塞超过 1 秒
1204.7ms 阻塞
本机真实实测:条件没走索引时,全表扫描把毫不相关的一行也卡住了 1.2 秒

思路:排序规则从"进程内的锁"搬到"SQL 语句的顺序"

数据库不会替你排序,谁先锁谁后锁,完全看你的 SQL 语句按什么顺序发出去。

按 ID 从小到大加锁,不管转账方向是哪边 BEGIN; SELECT balance FROM accounts WHERE id = LEAST(:from_id, :to_id) FOR UPDATE; SELECT balance FROM accounts WHERE id = GREATEST(:from_id, :to_id) FOR UPDATE; UPDATE accounts SET balance = balance - :amount WHERE id = :from_id; UPDATE accounts SET balance = balance + :amount WHERE id = :to_id; COMMIT;

不管这笔转账是"A转给B"还是"B转给A",LEAST()/GREATEST() 拼出来的 FOR UPDATE 顺序永远是"先小 ID、后大 ID"。只要所有服务实例都遵守这同一条规则,大家对同一对账户加锁的顺序就永远一致,循环等待这个死锁的必要条件就被从根上掐掉了——这跟进程内 sync.Mutex 按 ID 排序是同一个原理,只是共享的"锁"从内存里的一个对象,换成了数据库里的行锁。

验证:走主键索引,锁的确实只是那一行

常见的顾虑是"FOR UPDATE 是不是表锁",建了一张表、开三个并发事务测一下。

真实实测
def txn1_lock_row1_hold():
    cur.execute("SELECT * FROM accounts WHERE id=1 FOR UPDATE")
    time.sleep(1.5)   # 持有锁不放
    conn.commit()

def txn2_lock_row2():
    cur.execute("SELECT * FROM accounts WHERE id=2 FOR UPDATE")   # 不同的行

def txn3_lock_row1_same():
    cur.execute("SELECT * FROM accounts WHERE id=1 FOR UPDATE")   # 同一行
锁不同行(id=2)耗时: 3.7 ms ← 几乎不用等,证明不是表锁 锁同一行(id=1)耗时: 1209.7 ms ← 真的被卡住,证明锁是生效的

txn2 去锁另一行,3.7 毫秒就拿到了,完全没受 txn1 影响;txn3 去锁同一行,老老实实被卡了 1.2 秒,直到 txn1 提交才放行。EXPLAIN 也能确认这条语句走的是主键常量查找,不是扫表:

EXPLAIN SELECT * FROM accounts WHERE id=1 FOR UPDATE -> Rows fetched before execution (常量查找,不扫表)

验证:条件没走索引,才是真正的"事实上的表锁"

InnoDB 没法提前知道哪些行符合一个没有索引的条件,只能把扫到的每一行都锁上。

真实实测
-- balance 字段没有索引
EXPLAIN SELECT * FROM accounts WHERE balance > 500 FOR UPDATE
-> Table scan on accounts

def txn1_no_index_scan_hold():
    cur.execute("SELECT * FROM accounts WHERE balance > 500 FOR UPDATE")
    time.sleep(1.5)

def txn2_lock_row5():
    # id=5 跟 txn1 的过滤条件(balance)毫无关系
    cur.execute("SELECT * FROM accounts WHERE id=5 FOR UPDATE")
txn2 通过主键锁一个跟 txn1 过滤条件无关的行(id=5),耗时: 1204.7 ms

txn2 明明是按主键去锁一个跟 balance>500 毫不相干的行,但因为 txn1 走的是无索引全表扫描,扫到的每一行都被锁住了,txn2 照样被卡了 1.2 秒。这才是真正会拖垮性能的场景——根子不在 FOR UPDATE 本身,是缺索引导致的。前面那套按主键 id 排序加锁的方案,条件是主键等值查询,不会踩到这个坑。

应用层排序 vs SQL 里的 LEAST/GREATEST

两种写法锁的顺序完全一样,应用层排序更直观,但有个容易踩的坑。

应用层先排序,再按顺序发两条独立的 SQL smaller_id, larger_id = sorted([from_id, to_id]) cur.execute("SELECT balance FROM accounts WHERE id=%s FOR UPDATE", (smaller_id,)) cur.execute("SELECT balance FROM accounts WHERE id=%s FOR UPDATE", (larger_id,)) # 注意:smaller_id/larger_id 只是"加锁顺序", # 真正扣钱/加钱还是得按 from_id/to_id 原本的语义走,不能搞混

排序逻辑摆在代码里,谁都能一眼看出"这里在保证加锁顺序",不用记 LEAST/GREATEST 这种藏在 SQL 里的写法。但要注意:排序决定的是"先锁谁",不代表这两个 ID 谁是转出方、谁是转入方——如果直接拿排序后的变量去做扣款加款,很容易把转账方向搞反,而且这个 bug 不会报错,是那种悄悄把钱转错方向的隐患。

能不能偷懒,一条 SQL 用 IN(...) 把两行一起锁,不用排序? 测过,不建议。

用 IN (2,1)(故意把大的写在前面)试了一下,同时用一个"探针"事务去试探 id=1 有没有被提前锁住:

真实实测
def txn_hold_id2():
    cur.execute("SELECT * FROM accounts WHERE id=2 FOR UPDATE")
    time.sleep(1.2)

def txn_in_list_reversed():
    cur.execute("SELECT * FROM accounts WHERE id IN (2,1) FOR UPDATE")   # 大的写在前面

def txn_probe_id1():
    cur.execute("SELECT * FROM accounts WHERE id=1 FOR UPDATE")   # 探针
IN(2,1) 整条语句耗时: 899.9 ms 探针尝试锁 id=1,耗时: 603.2 ms ← 被明显卡住了

探针被卡住了,说明哪怕 IN 列表写成 (2,1),MySQL 实际执行时还是先按主键升序把 id=1 锁到手,再去等 id=2——这个结果看起来很安全,但 MySQL 官方文档并没有承诺"多值 IN 加锁一定按主键升序处理"这件事,这只是当前这个查询计划下观察到的行为,换个版本、换个更复杂的查询完全可能不成立。真正靠谱的做法还是显式控制顺序——应用层排序后发两条独立语句,不依赖数据库没写进文档的内部行为。

演示:两个方向相反的转账,按 ID 顺序加锁,完全不会死锁

A 转给 B、同时 B 转给 A,两边都遵守"先锁小 ID"的规则,看会发生什么。

两笔方向相反的转账 · 加锁时间线
事务操作结果

事务甲要把账户1的钱转给账户2,事务乙同时要把账户2的钱转给账户1——方向完全相反。但因为两边都按"先锁 id=1、再锁 id=2"的规则走,乙只是老老实实排队等甲用完,不会出现"甲等乙同时乙也在等甲"这种循环等待。

参考与说明

  • 本文全部实验(行锁 vs 全表扫描退化、IN 列表加锁顺序)均在本机运行的 MySQL(InnoDB 引擎)上用 Python + PyMySQL 开多线程/多连接完成,数据未做删改。
  • 单进程内用 sync.Mutex 按 ID 排序加锁避免死锁的原理和压测,参见转账死锁那篇;数据库自己的死锁检测机制参见InnoDB 是怎么发现并解开死锁的;不用悲观锁、改用版本号重试的思路参见乐观锁重试那篇。
  • 没有涉及:跨多个数据库实例(而不是同一个数据库的不同行)的分布式事务场景,那属于两阶段提交/Saga 补偿的范畴,不是单纯靠行锁顺序能解决的;不同隔离级别下 gap lock/next-key lock 对范围查询的额外影响。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号