说实话,第一次听到“悲观锁”这个名字,我差点以为数据库要抑郁了。后来才懂,它简直是个强迫症患者——每次读数据都假设别人会来改,所以先一把锁扣上去。这心态,够悲观吧?但它就是靠这种“总有刁民想害朕”的预设,活成了并发控制里的硬核门神。

锁的本质:互斥就是一场交通管制

想象一个十字路口没有红绿灯,车全挤在中间,谁也动不了——这就是没有锁的并发。悲观锁就是那个红绿灯,但是极端版:它只允许一辆车通过整个路口,其他的都等着。技术上怎么实现?靠的是两阶段锁协议(Two-Phase Locking)。别被名字唬住,其实就是:事务里面,加锁阶段只加锁,解锁阶段只解锁,两个阶段不交叉。这样一来,事务的串行化就保证了。数据库里最常见的 SELECT ... FOR UPDATE,就是个典型悲观锁动作。MySQL InnoDB引擎下,它会对扫描到的行加排他锁(X锁),其他事务想改?门都没有。
但这里有个坑:如果WHERE条件没走索引,InnoDB会直接锁全表!我亲历过一次事故,一个看似无害的 SELECT ... FOR UPDATE 没命中索引,结果整个表被锁住,所有写入操作都挂起,监控图立马拉成一条平直线。那个瞬间,后背发凉。
数据不会说谎:一场压测暴露的真面目
空谈理论没意思,直接上压测结果。我们模拟了一个电商扣库存场景,用JMeter 500并发线程,对比悲观锁和乐观锁。悲观锁使用 SELECT ... FOR UPDATE 然后更新;乐观锁用版本号 WHERE version = old_version。MySQL 5.7,RR隔离级别,InnoDB表。
结果非常刺激:悲观锁方案,平均响应时间 230ms,但成功率 100%,库存扣减准确无超卖。乐观锁方案,平均响应时间只要 45ms,轻快得很,但成功率掉到 79%!因为大量并发冲突导致CAS重试循环,甚至有的请求重试5次后放弃,直接报错。更可怕的是,高峰期乐观锁引发的CPU飙升——那些重试都是开销。所以别光看响应时间,悲观锁在争抢激烈的场景下就是定海神针。不过,吞吐量方面,悲观锁只有 200 TPS,乐观锁能到 800 TPS。取舍很明确:你要的是数据绝对一致,还是追求高吞吐?

三个血泪坑,踩过了才敢说懂了

坑1:死锁——你以为顺序加锁就安全?
教科书上说避免死锁就按固定顺序访问资源,但现实往往险恶。有一次,两个事务分别更新订单表和库存表,顺序明明相反,却因为一个隐式的二级索引间隙锁,活生生卡死。排查半天,发现是 SHOW ENGINE INNODB STATUS 里的 LATEST DETECTED DEADLOCK 救了我。解决方案:把涉及多表的更新封装成存储过程,内部用 GET_LOCK() 强制执行应用级锁协调,绕开数据库的死锁检测——虽然有点挫,但有效。或者干脆改造业务,把关联更新拆成异步消息队列,前提是能接受最终一致性。
坑2:长事务——锁不释放,整个系统被拖垮
悲观锁最怕长事务。一个事务开启后,拿着锁去做外部RPC调用,或者做复杂计算,锁就一直不释放,其他事务堆积成山。我们吃过一次大亏:一个报表统计事务需要扫描大量数据并加锁,结果跑了半小时,导致线上业务全部阻塞。后来把统计拆分到只读从库,并设置 innodb_lock_wait_timeout = 5,超时就回滚,绝不拖泥带水。此外,强制所有悲观锁事务包裹在极小的函数内,禁止跨服务调用。
坑3:范围锁扩大——行锁升级为间隙锁的惊悚瞬间
在RR隔离级别下,SELECT ... FOR UPDATE 如果查询条件是范围查询,会加上间隙锁(Gap Lock),锁住一个区间。这意味着插入新数据也被卡死。有次我们想锁住订单号大于1000的数据,结果新插入订单号999的请求也被阻塞,因为间隙锁锁住了(1000, +∞)之前的那个间隙。应对方法:要么改用RC隔离级别(但会丢失间隙锁,需要评估幻读风险),要么对唯一索引使用等值查询,避免范围扫描。我们在代码层做了一次重构:将悲观锁限定在单行操作,通过提前计算要锁定的主键列表,用 WHERE id IN (...) 逐个点锁,虽然多轮交互,但锁粒度精确,并发度反升。
说到这,想起有个架构师朋友曾吐槽:“悲观锁就像安全气囊,平时占地方,出事真能救命。” 话糙理不糙。