说到悲观锁,很多开发者脑子里蹦出来的第一个画面可能就是数据库里那张被红叉圈出来的“死锁错误日志”。确实,悲观锁(Pessimistic Locking)在并发编程界有个外号叫“保守派”,它的核心逻辑是:“我觉得肯定会有人来抢,所以我先锁上,谁也别想动。”
听起来挺有安全感?但在高并发的真实业务里,这种安全感往往是用系统的吞吐量换来的。今天咱们不聊枯燥的定义,我就把你拉进一个真实的电商库存扣减场景,看看悲观锁到底是怎么把系统拖慢的,又是如何让你陷入死锁泥潭的,最后咱们再聊聊,作为专家的我,是怎么帮团队把这块“硬骨头”啃下来,既防住了并发乱象,又没有把系统性能按在地上摩擦的。
悲观锁的本质:一种“过度防御”的艺术
首先,咱得把悲观锁的底层逻辑捋清楚。悲观锁假设最坏的情况,所以在执行任何操作之前,它先假定数据会被修改,因此先对数据加锁。在数据库层面,这通常体现为 SELECT ... FOR UPDATE;在代码层面(比如 Java 的 synchronized 或 ReentrantLock),就是互斥访问。
这种机制的优点显而易见:绝对的一致性。只要锁住了,数据就不会被其他事务篡改,你看到的就是最新的、正确的状态。
但缺点也很致命:性能损耗巨大。锁不仅占用 CPU 资源(用于获取和释放锁的操作),更糟糕的是它会阻塞线程。想象一下,早高峰的地铁安检口,每进一个人都要查验一遍,队伍能不长吗?在并发量高的时候,悲观锁会让大量请求排队等待,响应时间(RT)直线上升,TPS(每秒事务处理量)断崖式下跌。
所以,悲观锁的边界在哪里?边界在于“写多读少”且“冲突概率低”的场景。如果两个事务同时修改同一行数据,悲观锁能保住数据不错乱;但如果它们修改的是完全不同的行,悲观锁却依然可能因为锁粒度太粗而互相等待,这就造成了不必要的性能浪费。
真实业务复盘:一次差点线上崩盘的库存扣减
记得去年双十一前夕,我们团队负责的一个核心服务——秒杀库存扣减,差点把数据库打挂。
最初,为了数据绝对正确,我们直接在 Service 层加了一个 synchronized 锁,覆盖整个扣减逻辑:
// 伪代码:早期的错误实现
public void deductStock(Long itemId, int quantity) {
// 悲观锁:整个方法加锁,高并发下所有线程排队
synchronized (this) {
// 1. 查询当前库存
int currentStock = stockMapper.getStock(itemId);
// 2. 检查库存是否充足
if (currentStock < quantity) {
throw new BusinessException("库存不足");
}
// 3. 扣减库存
stockMapper.deduct(itemId, quantity);
// 4. 记录订单日志
orderMapper.insert(order);
}
}
这段代码在本地压测时,QPS 只有 100,完全没问题。但一旦放到生产环境,随着并发量飙升到 5000 QPS,系统直接卡死。DBA 报警说数据库连接池满了,线程全部 WAITING。
问题出在哪?两个地方:
- 锁粒度太大:
synchronized锁住了整个方法,包括查询、业务逻辑判断、写库、记录日志。其实只有“查询+扣减”这一段需要保护。 - 锁竞争过于集中:所有用户都在抢同一个
itemId的锁,导致大量线程阻塞,CPU 空转在等待锁上,而不是处理业务。
更可怕的是,我们还发现了一个潜在的死锁风险。如果另一个业务(比如库存回滚)以相反的顺序获取 itemId 和 orderId 的锁,两个事务互相等待对方释放锁,死锁就发生了。
如何避免死锁:统一锁顺序与短事务
死锁的产生需要四个必要条件:互斥、占有并等待、非剥夺、循环等待。要打破死锁,最简单有效的方法就是打破“循环等待”,也就是统一锁的获取顺序。
在我们的秒杀场景中,我们决定不再使用应用层的 synchronized,而是将锁下沉到数据库层面,使用 SELECT ... FOR UPDATE,并且严格规定所有事务都必须先锁 stock 表,再锁 order 表,顺序固定,这样循环等待就不可能形成。
同时,我们把事务控制得非常“短”:只包含查询和扣减,日志记录放在事务外。
-- 优化后的 SQL 逻辑
BEGIN;
-- 1. 加悲观锁,查询并锁定库存行,防止其他事务修改
SELECT stock FROM item_stock WHERE item_id = #{itemId} FOR UPDATE;
-- 2. 在 Java 中判断库存是否充足(此时锁已持有,其他事务会等待)
-- 如果库存不足,直接回滚,释放锁
-- 如果充足,执行扣减
UPDATE item_stock SET stock = stock - #{quantity} WHERE item_id = #{itemId};
-- 3. 快速提交事务,释放锁
COMMIT;
-- 4. 事务外异步记录日志,不阻塞锁的释放
orderService.asyncInsertOrder(order);
这里有个关键点:悲观锁的持有时间越短越好。原来的代码把日志记录也包在锁里,导致锁持有时间很长。优化后,事务只在数据库层面快速完成“查-改-提”,大大减少了锁的占用时间,其他线程等待的时间也相应缩短。
如何不拖慢系统:锁细化与本地锁结合
虽然数据库悲观锁解决了数据一致性问题,但 5000 QPS 还是让数据库扛不住。这时,我们需要结合应用层锁进行优化,核心思路是“本地锁防内卷,数据库锁保底线”。
1. 使用 Redis 分布式锁进行粗粒度拦截
在请求进入数据库之前,我们先在 Redis 中加一个分布式锁,锁的粒度是 itemId。但这里有个技巧:锁的有效期设得非常短,且只用于计数防超卖,而不是保护整个业务逻辑。
// 伪代码:优化后的并发控制策略
public void deductStockOptimized(Long itemId, int quantity) {
// 1. 尝试获取 Redis 分布式锁,过期时间 200ms
boolean locked = redisTemplate.opsForValue().setIfAbsent(
"lock:stock:" + itemId, "1", 200, TimeUnit.MILLISECONDS
);
if (!locked) {
// 2. 获取锁失败,说明并发极高,直接降级返回“排队中”或“稍后重试”
// 这一步是为了防止海量请求打到数据库,把数据库“压死”
throw new BusinessException("系统繁忙,请稍后重试");
}
try {
// 3. 获取锁成功后,再执行数据库悲观锁操作
// 此时并发量已经大幅降低,数据库压力骤减
deductStockWithPessimisticLock(itemId, quantity);
} finally {
// 4. 务必释放锁
redisTemplate.delete("lock:stock:" + itemId);
}
}
这个策略的精髓在于:Redis 锁作为“门卫”,数据库悲观锁作为“保险”。90% 的请求在 Redis 阶段就被拦截了,只有极少数请求能到达数据库层,这样数据库的锁竞争就变得非常温和,性能自然上来了。
2. 利用数据库的行锁特性,避免表锁
很多人误以为 SELECT ... FOR UPDATE 会锁住整张表,其实不然。MySQL 的 InnoDB 引擎在满足某些条件(如命中索引)时会使用行锁。如果我们的 item_id 是主键或唯一索引,那么锁只会锁定这一行数据,其他商品的查询和扣减不会受到影响。
但这里有个坑:如果查询条件没命中索引,InnoDB 会升级锁为表锁,那可就灾难了。所以,务必确保你的 WHERE 子句中的字段有索引。
3. 设置锁等待超时时间,避免线程无限等待
即使做了上述优化,极端情况下还是可能出现大量线程等待锁。为了防止线程堆积导致 OOM 或连接池耗尽,我们给数据库连接设置了 innodb_lock_wait_timeout,默认 50 秒,但在应用层,我们通过 Statement.setQueryTimeout() 设置了更短的超时时间(比如 3 秒)。
// 设置 SQL 执行超时,防止线程长时间阻塞
stmt.setQueryTimeout(3);
try {
stmt.executeUpdate("UPDATE item_stock SET stock = stock - " + quantity +
" WHERE item_id = " + itemId);
} catch (SQLException e) {
if (e.getErrorCode() == 1205) { // MySQL 死锁或锁等待超时错误码
log.warn("Lock wait timeout exceeded for itemId: {}", itemId);
throw new BusinessException("系统繁忙,请重试");
}
throw e;
}
这样,即使有线程因为锁等待超时,也会快速失败,释放连接和 CPU 资源,而不是卡死在那里。
总结:悲观锁的使用哲学
回到最初的问题:悲观锁的代价与边界在哪里?
代价是性能和并发能力。锁越重,等待越长,系统吞吐量越低。 边界在于你需要权衡数据一致性和系统性能。如果你的业务允许最终一致性(比如用户余额查询后展示可能有几秒延迟),那乐观锁或无锁方案可能更合适;但如果你的业务是金融级交易、库存扣减,数据绝对不能出错,那悲观锁就是你的“最后一道防线”。
在真实业务中,如何用悲观锁既防死锁又不拖慢系统?我的经验是:
- 锁粒度尽量细:只锁必要的行,不要锁表;只锁必要的代码段,不要锁整个方法。
- 锁持有时间尽量短:事务要小,快速提交,把耗时操作(如日志、通知)移到事务外。
- 统一锁顺序:所有事务以相同的顺序获取锁,从根本上杜绝死锁。
- 分层防御:用 Redis 等轻量级锁做第一道拦截,减少到达数据库的并发量,再用数据库悲观锁做最终一致性保障。
- 设置超时与降级:不要让线程无限等待,超时后快速失败,保护系统整体可用性。
悲观锁不是洪水猛兽,用对了地方,它是保障数据安全的定海神针。但用得不对,它就是压垮系统的最后一根稻草。作为工程师,我们要做的就是在“安全”与“性能”之间找到那个微妙的平衡点。
