周末去超市买菜,那叫一个热闹。我排在我前面有个大爷,推着满满一车东西,慢悠悠地在那儿掏零钱。我看着后面排队的长龙,心里就开始犯嘀咕:为什么不能大家都快点儿?如果每个人都只管自己,那队伍岂不是永远都排不完?
其实啊,你每次在高并发的系统里看到“卡顿”、“超时”,大概率就是遇到了这种“超市排队”的混乱场面。今天咱们不聊那些枯燥的教科书定义,我就带你从去超市取号开始,一路走进数据库的最底层,看看那些让系统崩溃的“死锁”到底是怎么发生的,以及我们如何用“悲观锁”这个聪明的办法,既保护了数据,又让系统跑得飞快。
超市门口的那个“取号机”:并发控制的直觉
想象一下,如果没有取号机,所有人一拥而上抢那一台收银机,结果会怎样?两个人同时伸出手去抓同一个苹果,或者为了付钱撞在一起,最后谁也没买成,还大吵一架。这就是并发问题的微观缩影。
在数据库里,数据就是那个“苹果”,事务就是那个“买苹果的人”。当1000个人同时来买同一个苹果时,数据库也需要一个“取号机”,这个取号机在数据库术语里叫做锁(Lock)。
悲观锁(Pessimistic Locking)的思想,恰恰就来源于这种“先占住,再操作”的心态。就像你去超市,虽然还没结账,但你先把商品抱在怀里,心里想着“这是我的,你们别动”。在数据库里,当你执行 SELECT ... FOR UPDATE 的时候,你就相当于在数据库里“抱住了”那行数据。
为什么要“悲观”?因为世界不太平
你可能会问:“为什么不用‘乐观锁’呢?那样不是更快吗?”
这是个非常棒的问题。乐观锁就像是你去图书馆借书,你在书里夹了一张纸条,写着“我在看这本书,你们别动,我看完了再还”。如果借书的人多,大家同时夹纸条,就会打架(冲突),这时候还得回滚重来。
而悲观锁的逻辑更直接:我假设一定会发生冲突,所以我先把门关上,钥匙攥手里,谁也别想进。
在电商大促、秒杀场景,或者金融转账这种高并发、高风险领域,悲观锁是绝对的王者。因为它能保证数据的绝对一致性,哪怕慢一点,也不能错。
现实中的“死锁”:当两个人互不相让
说到悲观锁,就不得不提那个让所有后端工程师头疼的噩梦——死锁(Deadlock)。
死锁是什么?简单来说,就是“互相等待,谁也不让谁”。
我给你讲个真实的代码案例,让你秒懂。假设我们有两个用户,A和B,他们要交换银行账户里的钱。
-- 用户A的转账逻辑(简化版)
BEGIN;
-- 第一步:锁定用户A的账户,扣钱
SELECT * FROM accounts WHERE user_id = 'A' FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 'A';
-- 就在这一刻,用户B也开始了他的操作...
-- 第二步:锁定用户B的账户,加钱
-- 但是!用户A此时去请求锁定用户B的账户
SELECT * FROM accounts WHERE user_id = 'B' FOR UPDATE;
-- 此时,用户B已经锁定了'B'的账户,正在请求锁定'A'的账户
-- 结果:A等B,B等A。永无止境。死锁发生了!
COMMIT;
这就是死锁。数据库引擎虽然会检测到这种局面并强制杀掉其中一个事务,但每一次死锁检测都是CPU的开销,每一次事务回滚都是性能的损失。在高并发下,如果死锁频繁发生,系统就会像超市里那个互相推搡的人群一样,彻底瘫痪。
如何通过顺序锁定避免死锁?
既然死锁是因为“顺序不一致”导致的,那解决办法也很简单:让所有人都按照相同的顺序去“取号”排队。
在上面的例子中,如果我们规定:所有转账操作,都必须先锁定金额小的账户,再锁定金额大的账户。
-- 改进后的转账逻辑:统一按照 user_id 的字母/数字顺序锁定
-- 假设 A < B
BEGIN;
-- 先锁定较小的账户
SELECT * FROM accounts WHERE user_id = 'A' FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 'A';
-- 再锁定较大的账户
SELECT * FROM accounts WHERE user_id = 'B' FOR UPDATE;
UPDATE accounts SET balance = balance + 100 WHERE user_id = 'B';
COMMIT;
这样,无论用户A还是用户B发起转账,他们锁定的顺序永远是 A -> B。当用户B执行时,他尝试锁定A,但A已经被用户A锁住了,所以B会排队等待,而不会去锁定B再去请求A。顺序一致,死锁消除。
这就像超市里,规定所有人都只能从1号窗口开始排队,不能插队,也不能逆向流动,队伍自然就顺畅了。
悲观锁的实战技巧:缩短锁的持有时间
锁住数据虽然安全,但也意味着其他人在这段时间内没法操作。如果锁的时间太长,系统照样会卡。所以,悲观锁的核心心法就四个字:快进快出。
1. 不要在锁内做耗时操作
很多人写代码习惯在查询出数据后,进行复杂的业务计算,比如调用外部API、进行大规模数据整理等。如果你把这些操作放在 BEGIN 和 COMMIT 之间,锁会一直持有,其他事务只能干等着。
错误示范:
BEGIN;
SELECT * FROM orders WHERE id = 1 FOR UPDATE; -- 锁住了!
// 耗时3秒:调用第三方物流接口
// 耗时2秒:处理复杂的状态机逻辑
UPDATE orders SET status = 'SHIPPED' WHERE id = 1;
COMMIT; -- 此时其他人都等了5秒了,早就超时了
正确示范:
-- 先查出来,不锁
SELECT * FROM orders WHERE id = 1; -- 无锁查询,瞬间完成
// 在业务代码里(不受数据库锁影响)
// 耗时3秒:调用第三方物流接口
// 耗时2秒:处理复杂的状态机逻辑
BEGIN;
-- 再次查询并锁定,此时数据可能已变,需校验
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
-- 检查状态是否匹配(防止并发覆盖)
IF current_status != 'PENDING' THEN
RAISE EXCEPTION 'Order not pending';
END IF;
UPDATE orders SET status = 'SHIPPED' WHERE id = 1;
COMMIT; -- 锁只持有了最后这一下,非常快
2. 使用短事务,尽早提交
除非必要,不要在一个事务里做太多的事情。每完成一个独立的逻辑单元,就 COMMIT 一次。
3. 合理设置超时时间
在配置数据库连接时,一定要设置 innodb_lock_wait_timeout(MySQL)或等效参数。这样,当某个事务锁得太久,其他等待的事务会自动报错退出,而不是无限等待拖垮整个服务器。
-- 设置锁等待超时为5秒
SET innodb_lock_wait_timeout = 5;
悲观锁 vs 乐观锁:到底该选谁?
很多同学问我:“Agnes,我到底该用悲观锁还是乐观锁?”
这里给你一个简单的判断标准:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 写多读少,竞争激烈 | 悲观锁 | 冲突概率高,乐观锁反复重试成本高,不如直接锁住。 |
| 读多写少,冲突概率低 | 乐观锁 | 大部分情况不会冲突,乐观锁无需加锁,性能极高。 |
| 关键资金操作(如转账) | 悲观锁 | 数据一致性高于一切,哪怕慢一点也要保证不错。 |
| 一般状态更新(如点赞数) | 乐观锁 | 允许偶尔的覆盖,追求高并发下的吞吐量。 |
记住,没有最好的锁,只有最适合场景的锁。
给小朋友的比喻:教室里的“独占笔”
最后,我用一个给小朋友讲的例子来总结一下今天的核心内容。
想象一下,教室里只有一支珍贵的红色钢笔(这就是共享数据)。
悲观锁:小明想用水笔写字,他直接把钢笔抓在手里,说:“我现在用,谁也别想碰!” 小红也想用,她只能站在旁边等,直到小明用完还给她。这样做的好处是,钢笔不会被两个人同时乱涂乱画(数据错误),但缺点是,如果小明一直拿着钢笔去上厕所(事务时间过长),小红就永远写不了字(系统卡顿)。
死锁:小明拿着钢笔要去小红那里签字,小红拿着作业本要去小明那里签字。两人都说:“你把那个给我,我再给你!” 结果两人站在原地不动,谁也写不了。这就是死锁。解决办法是约定:所有人都先找小明签字,再找小红签字,这样就不会僵持了。
避免死锁的关键:统一顺序。不管是先锁A还是先锁B,全公司、全系统必须有一个统一的规矩。
结语
从超市取号到数据库行锁,本质上都是为了解决同一个问题:如何在资源有限的情况下,公平、有序、高效地处理并发请求。
悲观锁不是银弹,它需要谨慎的设计——统一的加锁顺序、尽量短的事务、合理的超时设置。但当你掌握了这些技巧,你的高并发系统就能像早高峰时井然有序的超市通道一样,虽然人流如织,却秩序井然,不再卡顿。
希望这篇文章能帮你理清思路。下次再看到数据库锁等待告警,别慌,想想超市里排队的大爷,想想你该怎么引导大家“有序取号”吧。
