接着转账死锁那篇留的一个问题:单进程里用 sync.Mutex 按账户 ID 排序加锁能避免死锁,但分布式场景下不同服务实例各有各的内存,进程内的锁形同虚设,只能把"排序加锁"这件事搬到数据库层——用 SELECT ... FOR UPDATE。但这样做会不会把整张表锁死、拖垮性能?用 MySQL 跑了几组实验找答案。
数据库不会替你排序,谁先锁谁后锁,完全看你的 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 排序加锁的方案,条件是主键等值查询,不会踩到这个坑。
两种写法锁的顺序完全一样,应用层排序更直观,但有个容易踩的坑。
应用层先排序,再按顺序发两条独立的 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 不会报错,是那种悄悄把钱转错方向的隐患。
用 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 加锁一定按主键升序处理"这件事,这只是当前这个查询计划下观察到的行为,换个版本、换个更复杂的查询完全可能不成立。真正靠谱的做法还是显式控制顺序——应用层排序后发两条独立语句,不依赖数据库没写进文档的内部行为。
A 转给 B、同时 B 转给 A,两边都遵守"先锁小 ID"的规则,看会发生什么。
| 事务 | 操作 | 结果 |
|---|
事务甲要把账户1的钱转给账户2,事务乙同时要把账户2的钱转给账户1——方向完全相反。但因为两边都按"先锁 id=1、再锁 id=2"的规则走,乙只是老老实实排队等甲用完,不会出现"甲等乙同时乙也在等甲"这种循环等待。