程序员加悲观锁锁数据结果系统卡死?掌握这4个顺序技巧彻底告别死锁
我猜你现在可能正盯着屏幕上那条永远跑不完的日志发呆。
明明只是加了几个悲观锁,怎么数据库就卡死了?事务不提交,连接池爆满,整个服务像被冻住了一样。作为过来人,我太懂这种痛了。
三年前我刚入行那会儿,也干过这种事——在一个高并发的电商库存系统里,给商品表加了悲观锁,结果高峰期直接瘫痪,被组长骂得狗血淋头。后来花了大半年时间,我才真正把死锁这个问题琢磨透。
今天就跟你们聊聊,悲观锁是怎么把系统搞死的,以及怎么用四个简单但极其有效的顺序技巧,彻底告别这个噩梦。
悲观锁到底是个啥?为啥会出事儿?
先别急着骂自己菜,咱们得先搞清楚问题出在哪儿。
悲观锁,说白了就是”我不信任别人,我先锁住再说”。在数据库层面,比如MySQL的SELECT ... FOR UPDATE,就是你查某行数据的时候,直接给这行加上写锁,别人想改?门都没有。
听起来挺安全的对吧?但问题就在于——“太安全了”。
想象一下这个场景:
// 伪代码场景:两个事务同时操作
// 事务A
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE; // 锁住了账户1
// ... 一些业务逻辑 ...
SELECT * FROM account WHERE id = 2 FOR UPDATE; // 锁住了账户2
COMMIT;
// 事务B
BEGIN;
SELECT * FROM account WHERE id = 2 FOR UPDATE; // 锁住了账户2
// ... 一些业务逻辑 ...
SELECT * FROM account WHERE id = 1 FOR UPDATE; // 想要账户1,但被事务A锁着!
COMMIT;
看出来了吗?事务A锁了1要2,事务B锁了2要1。两个事务都在等对方释放锁,结果就是——双方永远等下去,谁也别想跑。这就是经典的死锁。
在真实的生产环境里,这种场景比你想的要多得多。特别是那些涉及多表操作、多行更新的业务,死锁概率呈指数级上升。
我见过最离谱的一个案例,是一个物流系统的订单状态流转功能。三个不同的业务场景,各自加锁的顺序完全不同,结果高峰期每天产生几百个死锁,运维同学每天上班第一件事就是手动清理卡死的事务。
所以问题来了:怎么避免?
答案就是下面这4个顺序技巧。
技巧一:全局约定加锁顺序——让所有人按同一个规矩来
这是最基础、也最有效的一招。
核心思想:所有事务在获取多把锁时,必须按照同一个顺序来。
还是刚才那个账户转账的例子,我们规定:所有事务都必须先锁id小的,再锁id大的。
// 修复后的代码:统一加锁顺序
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 确保先锁id小的,再锁id大的
Long firstId = Math.min(fromId, toId);
Long secondId = Math.max(fromId, toId);
BEGIN;
SELECT * FROM account WHERE id = firstId FOR UPDATE;
SELECT * FROM account WHERE id = secondId FOR UPDATE;
// 业务逻辑:扣款、入账
updateBalance(firstId, -amount);
updateBalance(secondId, +amount);
COMMIT;
}
这样不管事务A还是事务B,它们获取锁的顺序都是一致的。即使两个事务同时操作相同的两个账户,也不会有死锁——因为大家排队等锁的顺序是一样的,不会出现”A等B,B等A”的循环等待。
但这里有个坑要注意:这个顺序必须是全局一致的,不能A服务用id顺序,B服务用名字顺序,C服务随机顺序。你们团队得有一个明确的约定,并且写进代码规范里。
我见过一个团队,数据库团队规定按主键顺序加锁,但业务团队有人图省事直接按业务逻辑顺序加,结果还是死锁了。后来他们搞了一个代码审查机制,专门检查加锁顺序,才彻底解决这个问题。
技巧二:尽量缩短持锁时间——锁得越久,风险越大
这一条看似简单,但很多程序员就是改不掉这个坏习惯。
核心思想:不要在持有锁的情况下做耗时操作。
常见错误示范:
// 错误示例:在持锁期间做各种耗时操作
BEGIN;
SELECT * FROM order WHERE id = 10086 FOR UPDATE;
// 以下操作都在持锁状态下执行,极其危险!
sendSMSToUser(order.userId); // 调用外部短信接口
calculateDiscount(order); // 复杂计算
syncToExternalSystem(order); // 同步到外部系统
logOperation(order); // 写日志
updateOrderStatus(10086, "SHIPPED");
COMMIT;
你看,这一串操作,随便哪个慢一点,锁就持有很长时间。其他事务想拿到这把锁?等着吧。
正确做法应该是:先查出来,释放锁,再做耗时操作,最后再锁住更新。
// 正确示例:缩短持锁时间
// 第一步:只锁住数据,快速查出结果
Order order;
BEGIN;
SELECT * FROM order WHERE id = 10086 FOR UPDATE;
order = fetchResult();
COMMIT; // 锁住了很短时间就释放了
// 第二步:释放锁之后再做耗时操作
sendSMSToUser(order.userId); // 现在不怕阻塞别人了
calculateDiscount(order);
syncToExternalSystem(order);
// 第三步:如果需要再次更新,重新加锁
BEGIN;
SELECT * FROM order WHERE id = 10086 FOR UPDATE;
updateOrderStatus(10086, "SHIPPED");
COMMIT;
这样每个锁的持有时间都缩短到了最小,其他事务等待的时间也大大减少,死锁概率自然大幅下降。
不过这里要注意一个边界情况:如果两步操作之间的数据可能被其他事务修改,怎么办?
那就需要引入版本号或者乐观锁机制来保证一致性,这个话题我们后面再说。
技巧三:统一锁粒度——别一会儿锁行一会儿锁表
这一条很多人容易忽视。
核心思想:同一个业务场景下,锁的粒度应该保持一致。
举个例子,你们有一个库存扣减功能。有的地方用行锁(锁某一行库存记录),有的地方用表锁(锁整张库存表),还有的地方用间隙锁(锁一个范围)。
结果就是:事务A用行锁拿了某行数据,事务B用表锁把整张表锁了,两个事务互相卡住,死锁就是这么来的。
// 错误的做法:锁粒度不一致
// 事务A:只锁某一行
SELECT * FROM inventory WHERE product_id = 123 FOR UPDATE;
// 事务B:锁整张表
SELECT * FROM inventory WHERE product_id IN (123, 456, 789) FOR UPDATE;
// 这个查询在InnoDB里可能会升级为表锁!
正确的做法是:明确业务需求,统一锁的粒度。如果确实需要不同的粒度,也要确保加锁顺序一致。
// 统一行锁的做法
// 所有涉及库存的操作都只锁具体行
SELECT * FROM inventory WHERE product_id = 123 FOR UPDATE;
// 或者
SELECT * FROM inventory WHERE product_id = 456 FOR UPDATE;
还有一点要注意:锁的粒度不是越细越好,也不是越粗越好,而是要根据业务场景来决定。 对于高频小范围操作,行锁就够了;对于批量操作,可能需要范围锁,但这时候加锁顺序就更要严格统一了。
技巧四:设置合理的锁等待超时——别让大家无限等下去
前面三条都是”预防”,这一条是”兜底”。
核心思想:给锁等待设置一个合理的超时时间,超时后自动回滚,避免死锁堆积。
MySQL里可以通过innodb_lock_wait_timeout参数设置全局的锁等待超时时间,默认是50秒。对于大多数业务来说,这个时间太长了——50秒都够喝十杯咖啡了。
-- 查看当前的锁等待超时设置
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 设置更合理的超时时间(比如5秒)
SET GLOBAL innodb_lock_wait_timeout = 5;
但光靠全局设置还不够,你还需要在代码层面处理超时异常:
// Java代码层面处理锁超时
try {
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE;
SELECT * FROM account WHERE id = 2 FOR UPDATE;
// 业务逻辑
transfer(1, 2, amount);
COMMIT;
} catch (LockWaitTimeoutException e) {
// 锁等待超时,回滚并重试
ROLLBACK;
log.warn("锁等待超时,事务id: {}, 稍后重试", transactionId);
// 可以选择重试,或者直接返回给用户
retryWithBackoff(); // 带退避的重试机制
} catch (DeadlockFoundException e) {
// 检测到死锁,回滚
ROLLBACK;
log.error("检测到死锁,事务id: {}", transactionId);
// 死锁后的重试策略
retryWithBackoff();
}
这里有个细节很重要:重试的时候要有退避策略,不要立即重试。 否则两个事务可能一直在死锁-重试-死锁的循环里打转,消耗资源还不解决问题。
// 带退避的重试机制
public void retryWithBackoff() {
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
try {
// 带随机退避的重试
Thread.sleep((long) Math.pow(2, i) * 100 + ThreadLocalRandom.current().nextLong(100));
executeTransaction();
return;
} catch (Exception e) {
if (i == maxRetries - 1) {
throw e;
}
}
}
}
退避策略的好处是:让重试的事务随机错开,减少再次冲突的概率。
实操案例:一个真实的死锁排查与修复过程
光说不练假把式,给你们讲一个真实案例。
我们公司有一个订单履约系统,高峰期经常报死锁。最初排查的时候,日志里全是这种报错:
Deadlock found when trying to get lock; try restarting transaction
通过SHOW ENGINE INNODB STATUS查看死锁详情,发现涉及两个表:order表和inventory表。
原始代码(有问题):
// 场景一:下单扣库存
@Transactional
public void createOrder(Long orderId, Long inventoryId) {
// 先锁订单
Order order = orderMapper.selectForUpdate(orderId);
// 再锁库存
Inventory inventory = inventoryMapper.selectForUpdate(inventoryId);
// 业务逻辑
inventory.setStock(inventory.getStock() - order.getQuantity());
inventoryMapper.update(inventory);
order.setStatus("PAID");
orderMapper.update(order);
}
// 场景二:取消订单释放库存
@Transactional
public void cancelOrder(Long orderId, Long inventoryId) {
// 先锁库存(顺序反了!)
Inventory inventory = inventoryMapper.selectForUpdate(inventoryId);
// 再锁订单
Order order = orderMapper.selectForUpdate(orderId);
// 业务逻辑
inventory.setStock(inventory.getStock() + order.getQuantity());
inventoryMapper.update(inventory);
order.setStatus("CANCELLED");
orderMapper.update(order);
}
看出来问题了吗?两个场景加锁顺序相反。场景一先锁order再锁inventory,场景二先锁inventory再锁order。这就是死锁的根源。
修复方案:
// 修复后:统一加锁顺序(按表名的字母顺序)
@Transactional
public void createOrder(Long orderId, Long inventoryId) {
// 统一先锁inventory,再锁order(inventory在order前面)
Inventory inventory = inventoryMapper.selectForUpdate(inventoryId);
Order order = orderMapper.selectForUpdate(orderId);
// 业务逻辑
inventory.setStock(inventory.getStock() - order.getQuantity());
inventoryMapper.update(inventory);
order.setStatus("PAID");
orderMapper.update(order);
}
@Transactional
public void cancelOrder(Long orderId, Long inventoryId) {
// 同样先锁inventory,再锁order
Inventory inventory = inventoryMapper.selectForUpdate(inventoryId);
Order order = orderMapper.selectForUpdate(orderId);
// 业务逻辑
inventory.setStock(inventory.getStock() + order.getQuantity());
inventoryMapper.update(inventory);
order.setStatus("CANCELLED");
orderMapper.update(order);
}
修复之后,死锁问题彻底消失。高峰期系统稳定运行,再也没报过类似错误。
几个常见的误区
在讲技巧的时候,顺便帮你们澄清几个常见的误解。
误区一:乐观锁能解决所有死锁问题
乐观锁(比如用版本号)确实能在很多场景下避免死锁,但它不是银弹。乐观锁在冲突率高(也就是很多人同时改同一行数据)的时候,会导致大量重试,性能反而更差。正确做法是:高冲突场景用乐观锁,低冲突场景用悲观锁,两者结合使用。
误区二:锁越多越安全
这个想法很危险。锁太多会导致并发度下降,系统吞吐量降低,还可能引发新的死锁。正确的思路是:锁能少则少,能粗则粗,能短则短。
误区三:死锁检测可以完全避免死锁
MySQL的InnoDB引擎有死锁检测机制,会自动检测并杀死某个事务来解除死锁。但这只是”事后处理”,不是”事前预防”。最好的方式还是通过前面说的四个技巧,从根本上避免死锁的发生。
最后想说几句
死锁这个问题,说难不难,说简单也不简单。难就难在它往往在系统上线之后、流量起来之后才暴露出来,排查起来比较麻烦。简单就简单在,只要掌握了正确的加锁顺序和方法,完全可以把它控制在萌芽状态。
四个技巧的核心思想其实就一句话:让所有事务按同一个规矩来,尽量缩短锁的时间,别无限等下去。
如果你在项目中遇到了死锁问题,别慌,先排查加锁顺序,再检查锁的持有时间,最后看看有没有设置合理的超时。按这个思路走,基本都能解决。
希望这篇文章能帮到你们。如果还有什么问题,随时交流。记住,写代码的时候多花一分钟想想锁的顺序,生产环境就能少哭十次。
