库存扣成负数这件事,在电商、票务、积分兑换这类场景里几乎绕不过去。我第一次碰到是在一个限时抢购活动上,商品总共 200 件,活动结束后账面卖出 213 件,事后查日志,没有任何一条 SQL 报错,每一笔扣减都是"成功"的。问题出在两个请求同时读到stock = 1,各自在内存里算完1 - 1 = 0,然后分别执行了一次UPDATE,两次都成功,库存就变成了 -1。这件事让我意识到,并发问题不是靠"写 SQL 的时候小心一点"能解决的,它需要你在动手写代码之前,就把悲观锁和乐观锁的选择定下来。
这篇内容会围绕 MySQL 里这两种并发控制思路展开,重点讲清楚三件事:InnoDB 的悲观锁(尤其是行锁)到底锁住了什么、乐观锁的几种实现方式各自在什么条件下会失效、以及线上真的出现锁等待和死锁时,怎么一步步把现场还原出来。内容偏向实战,适合已经会写基本增删改查、但被并发问题咬过一次的开发者,也适合正在准备面试、想把锁这块讲明白的同学。
1. 从库存扣成负数的现场说起
先把问题摆清楚,后面的所有讨论才有落点。绝大多数并发事故都不是"代码写错了",而是"代码里的两条语句之间有一条看不见的缝"。
1.1 超卖的本质:读和改之间那条缝
很多人以为超卖是因为两个线程同时执行UPDATE导致的,其实不是。UPDATE本身在 InnoDB 里是原子的,真正的漏洞在于业务逻辑把一次扣减拆成了"先读后写"两段:
-- 第一个请求和第二个请求都执行了这一段 SELECT stock FROM goods WHERE id = 100; -- 两边都读到 1 -- 应用层判断 1 > 0,允许下单 UPDATE goods SET stock = stock - 1 WHERE id = 100; -- 两边都执行成功问题就出在SELECT到UPDATE之间。这段时间里,另一个请求完全可以完整地跑完它自己的一整套逻辑。你可以把它想象成两个人在同一个窗口买最后一张票,售票员先把票拿出来给他们看(SELECT),再各自去抽屉里拿钱(业务处理),等回来交钱的时候,票还在,但已经被两个人各买了一次。
这里的关键在于:数据库保证的是"单条语句"的原子性,它不会替你把"多条语句组成的一段业务逻辑"也保护起来。要保护这段逻辑,只能靠锁。
1.2 悲观锁和乐观锁:两种截然相反的假设
悲观锁和乐观锁最本质的区别,不是实现方式,而是它们对"冲突会不会发生"这个问题的默认假设完全相反。
悲观锁的假设是:冲突一定会发生,所以我先把资源占住,别人谁也别动,等我干完再说。换成 MySQL 的写法就是SELECT ... FOR UPDATE,一条语句就把那行记录锁上了,其它事务想改这行,只能在后面排队。
乐观锁的假设是:冲突很少发生,正常情况下大家各干各的,只有在真正提交修改的时候才去校验"这行数据从我读到它之后有没有被别人改过"。如果改过,就说明我这次操作作废,重新来一遍。它不加数据库层面的锁,靠的是一个额外的判断条件。
用一个生活化的类比:悲观锁像是进试衣间之前先把门锁上,别人只能在外面等;乐观锁像是拿了衣服去试,回来结账的时候收银员问一句"这件衣服还是你拿的那件吗",如果货号变了,就得重新选。
这个假设差异,直接决定了它们在什么场景下表现好。冲突频繁的场景用乐观锁,会陷入"不断失败不断重试"的循环,CPU 和连接资源全耗在重试上;冲突稀疏的场景用悲观锁,则等于白白让每个请求都去排队,吞吐量平白无故掉一大截。
2. 悲观锁在 InnoDB 里的真实形态
很多人对悲观锁的理解停在"FOR UPDATE会把那行锁住"这个层面,但真正踩坑的地方在于:你锁住的到底是不是你以为的那行。
2.1 SELECT ... FOR UPDATE 究竟锁了什么
InnoDB 的行锁是加在索引记录上的,不是加在物理行上的。这句话是整个悲观锁部分最重要的一句,后面所有的坑都是从它派生出来的。
当你执行SELECT * FROM goods WHERE id = 100 FOR UPDATE的时候,InnoDB 会沿着主键索引树找到id = 100这条索引记录,在它上面加一把排他锁(X 锁)。在事务提交或者回滚之前,任何其它事务想对这条记录加锁,都会被阻塞。
这里还有一个容易被忽略的细节:锁的释放时机是事务结束,不是语句结束。InnoDB 采用的是两阶段锁协议,语句执行过程中加锁,但所有的锁都等到事务COMMIT或ROLLBACK才统一释放。所以如果你在SELECT ... FOR UPDATE之后又做了一堆耗时操作,比如调用第三方接口、发送消息、做复杂的计算,那把锁就会一直被占着。
我见过最典型的一个反例是:有人在FOR UPDATE之后调了一次远程接口做风控校验,接口超时三秒,这三秒里所有想改这行数据的请求全部卡死。并发稍微大一点,几十个请求堵在那里,数据库连接池很快就被占满,整个服务直接雪崩。这不是锁本身的问题,是持锁时间太长的问题。
2.2 没走索引的 FOR UPDATE,锁的可能是整张表
这是新手最容易踩、也最难自己发现的一个坑。
InnoDB 加锁的方式是:先扫描,扫到符合条件的记录就加锁。如果你的WHERE条件走了索引,那它只需要扫很少的记录,锁的范围自然就小。但如果条件列上根本没有索引,InnoDB 就只能做全表扫描,扫描到哪一条就给哪一条加锁。结果是:你本来只想锁一行,实际上整张表里所有记录都被锁上了,效果等同于表锁。
举个具体的例子,假设goods表在order_no这个字段上没有建索引:
-- order_no 没有索引 SELECT * FROM goods WHERE order_no = 'A2024' FOR UPDATE;这条语句执行时,InnoDB 会逐行扫描整张表,对每一行都尝试加锁。另一个事务哪怕是查完全不相干的一条记录,只要它落在被扫描的范围内,同样会被阻塞。表面上你在用行锁,实际拿到的是表锁的代价。
我在一个项目里就吃过这个亏。当时有个订单表,运维反馈"偶尔会有大批请求卡住",排查之后发现是一条FOR UPDATE的语句用了一个没建索引的字段。加上索引之后,锁等待的告警立刻就消失了。所以每次写FOR UPDATE之前,你务必先做一件事:
EXPLAIN SELECT * FROM goods WHERE order_no = 'A2024' FOR UPDATE;看type这一列,如果是ALL,说明走的全表扫描,这条FOR UPDATE就是在给整张表上锁。理想情况下应该是ref或者eq_ref,rows的值越小越好。这是一个成本极低、收益极高的检查习惯。
2.3 唯一索引、非唯一索引与间隙锁的差异
即使走了索引,锁的范围也会因为索引类型的不同而不同。这也是为什么很多人会疑惑"明明都是等值查询,为什么锁的行为不一样"。
在 InnoDB 默认的可重复读(RR)隔离级别下:
| 查询条件 | 索引类型 | 实际加的锁 |
|---|---|---|
id = 100(主键/唯一索引,记录存在) | 唯一索引 | 记录锁(Record Lock),只锁这一行 |
id = 100(主键/唯一索引,记录不存在) | 唯一索引 | 间隙锁(Gap Lock),锁住记录不存在的那个区间 |
age = 25(普通索引) | 非唯一索引 | 临键锁(Next-Key Lock),记录锁 + 前面的间隙 |
age > 20(范围查询) | 任意索引 | 临键锁,锁住扫描到的所有记录及其间隙 |
这里要重点说清楚间隙锁。间隙锁锁的不是某条记录,而是两条记录之间的"空隙"。它的作用是防止其它事务在这个空隙里插入新数据,从而避免幻读。但它的副作用也很明显:即使你要操作的行不存在,也会锁住一个区间,把这个区间里的插入操作全部阻塞掉。
举个很常见的场景。假设表里现在有id = 5和id = 10两条记录,你执行:
SELECT * FROM goods WHERE id = 7 FOR UPDATE;id = 7不存在,InnoDB 会锁住(5, 10)这个间隙。此时另一个事务想插入id = 6、id = 7、id = 8都会失败,必须等前一个事务提交。如果你的业务里有大量按 ID 区间插入的操作,这种间隙锁很容易造成意料之外的阻塞。
如果你确实被间隙锁困扰,有两个选择:一是把隔离级别降到读已提交(RC),RC 下间隙锁基本被关闭;二是保证查询条件命中唯一索引且记录存在。但降隔离级别这个动作不能拍脑袋做,它会影响主从复制的数据一致性,需要先确认binlog_format的配置和业务对幻读的容忍度,再决定。这个改动的评估成本不低,别为了省事就直接改。
2.4 共享锁不是"白拿"的:FOR SHARE 引发死锁的经典路径
悲观锁里除了排他锁,还有共享锁,8.0 之后的写法是FOR SHARE,早期版本是LOCK IN SHARE MODE。
共享锁的特点是:多个事务可以同时持有同一行的共享锁,彼此不冲突;但只要有一个事务要加排他锁,就必须等所有共享锁释放。这个特性很容易造成一种典型的死锁。
场景是这样的:事务 A 持有了id = 1的共享锁,想再去升级成排他锁;事务 B 也持有id = 1的共享锁,也想升级成排他锁。两边都想把对方的共享锁挤掉,但谁也挤不掉,于是互相等待,形成死锁。这就是经典的"锁升级死锁"。
所以我在实际使用中总结出来的一条经验是:如果你拿到共享锁之后大概率还要写这行数据,那就别用共享锁,直接用FOR UPDATE。共享锁适合那种"我读了之后确定不会写,但要求读的时候别人也不能写"的场景,比如某些对账、报表的中间态读取。用错了地方,它带来的麻烦比排他锁多得多。
3. 乐观锁的几种落地方案
乐观锁不加数据库的行锁,所以它的实现完全在应用层和 SQL 层完成。听起来简单,但真正写对、写稳,要处理不少细节。
3.1 version 字段方案:最通用也最容易写错
最常见的乐观锁实现是给表加一个version字段:
ALTER TABLE goods ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';读取的时候把version一起读出来:
SELECT id, stock, version FROM goods WHERE id = 100;更新的时候把读到的version作为条件带上:
UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = 100 AND version = 3;然后检查影响行数。如果返回 1,说明没人改过,操作成功;如果返回 0,说明在你读完之后有别人改过这行数据,这次操作作废。
这里有几个必须注意的点:
第一,version的递增必须放在SET子句里,由数据库完成,绝对不能写成SET version = 4。原因很简单,如果你把新版本号在应用层算好再写进去,那在你算的这一刻到执行 SQL 之间,又有窗口了,乐观锁的意义就没了。让数据库在同一条UPDATE里完成"判断旧值 + 写入新值",这才是原子的。
第二,判断成功与否必须看影响行数,不能看是否抛异常。乐观锁失败是正常流程,不会报错,只是affectedRows为 0。如果你的代码没有检查返回值,那乐观锁等于没加。
第三,version字段不要用TIMESTAMP来代替。用时间戳做乐观锁看起来很优雅,但在高并发下会失效:如果同一毫秒内有多次更新,时间戳可能一样,判断就失效了。而且很多场景下datetime的精度只到秒,问题更严重。用自增整数最稳妥。
3.2 条件更新:把判断压进一条 UPDATE 里
还有一种更简洁的思路,本质上是乐观锁的变形,但更贴近实际业务:把业务判断直接写进WHERE条件里。
UPDATE goods SET stock = stock - 1 WHERE id = 100 AND stock >= 1;这条语句执行之后,如果影响行数是 1,说明扣减成功,库存足够;如果影响行数是 0,说明库存不足,或者这行数据不存在。
这种写法的好处是显而易见的:整个"判断库存是否充足"和"扣减库存"的过程被压缩成了一条语句,天然不存在并发窗口。应用层只需要看影响行数做后续处理,代码非常干净。
不过这里有个认知上的细节需要澄清:这条UPDATE在 InnoDB 内部仍然会加排他锁,只是这个锁的生命周期极短——语句执行完就进入事务提交阶段,锁马上释放。所以你完全可以把它理解成"用极短的悲观锁实现了一次乐观判断"。它之所以在实际项目中表现好,恰恰是因为持锁时间短,而不是因为它真的"没锁"。
同样地,这条UPDATE的WHERE条件也必须是走索引的。如果id是主键那没问题;但如果你的条件是WHERE order_no = ? AND stock >= 1而order_no没索引,那又会退化成大范围加锁,前面说的问题会原封不动地重现。
3.3 重试策略与 ABA:乐观锁真正难的地方
乐观锁失败之后要怎么办?这部分的处理质量,直接决定了乐观锁方案能不能用。
最朴素的写法是循环重试,直到成功为止。但这在实践中是有风险的,因为你不知道失败的真正原因。如果是因为热点数据被高频争抢,那无限重试就是在制造雪崩。我推荐的做法是:
- 设置最大重试次数,一般 3 次左右就够了,超过就返回失败或者降级处理;
- 每次重试之间加一个短暂的随机退避,避免所有失败请求同一时间再来一次,形成"重试风暴";
- 如果重试次数用尽仍然失败,要给出明确的业务提示,而不是默默吞掉。
用 Java 写大致是这样:
public boolean deductStock(Long goodsId, int maxRetry) { for (int i = 0; i < maxRetry; i++) { Goods goods = goodsMapper.selectById(goodsId); if (goods == null || goods.getStock() <= 0) { return false; // 库存不足,直接失败,不重试 } int affected = goodsMapper.deductWithVersion( goodsId, goods.getVersion()); if (affected == 1) { return true; // 成功 } // 失败说明版本被改过,退避后重试 try { Thread.sleep(10L + (long) (Math.random() * 20)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } return false; }对应的 Mapper:
<update id="deductWithVersion"> UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = #{goodsId} AND version = #{version} AND stock >= 1 </update>注意我在WHERE里额外加了stock >= 1。这是一个很实用的保险:即使版本号判断通过了,库存也不一定够,两个条件一起判断,能减少一次无意义的失败重试。
另外提一下 ABA 问题。用version版本号方案其实不会遇到 ABA,因为版本号是单调递增的。但如果有人用"比较某个业务字段的值有没有变"来做乐观锁,比如判断status字段,那就可能遇到 ABA:值从 A 变成 B 又变回 A,判断看起来没变,但实际上中间发生过修改。涉及状态流转的业务,一定要用独立的version字段,不要去比较业务字段。
4. 选型:用冲突频率和事务长度做决策
讲完了两种方案,接下来的问题是:到底该用哪个?这个问题的答案不取决于个人偏好,而取决于你的业务特征。
4.1 四个维度决定你的选择
我把选型时需要考虑的因素归纳成四个维度,你可以对照自己的业务打个分。
第一个维度是冲突概率。这是最关键的。如果同一行数据在一秒钟内会被几十上百个请求同时争抢,比如秒杀商品的库存,那乐观锁会非常痛苦——大量请求拿到旧版本号,更新失败,然后重试,重试又失败,CPU 全耗在空转上。这种场景下悲观锁反而更稳妥,因为它会让请求老老实实排队,虽然慢,但结果确定。
反过来,如果是用户修改自己的个人资料、编辑一篇文章这类操作,冲突概率极低,乐观锁几乎不会失败,而且能省掉加锁的开销。
第二个维度是事务长度。如果你的业务逻辑里,拿到数据之后要做一堆耗时操作——比如调用外部接口、生成报表、发消息——那绝对不能用悲观锁,因为持锁时间会非常长。这种情况下应该把长逻辑拆开,只在最核心的更新那一步使用锁,而且尽量用条件更新的方式,把锁定时间压缩到毫秒级。
第三个维度是失败代价。乐观锁失败是可以重试的,但如果重试的代价很高,比如每失败一次都要重新走一遍复杂的计算,那就要慎重。反过来,如果失败只是简单地返回"请重试",那乐观锁的成本就很低。
第四个维度是吞吐量要求。悲观锁会串行化,理论上吞吐量有上限;乐观锁在冲突少的时候吞吐量可以很高,但冲突多的时候会断崖式下跌。
用一张表总结一下:
| 场景特征 | 推荐方案 | 核心理由 |
|---|---|---|
| 超高并发抢同一行(秒杀库存) | 条件更新或悲观锁 | 乐观锁重试成本过高 |
| 低频修改,读写都多(用户资料) | 乐观锁 version | 冲突少,省去加锁开销 |
| 需要读取后做复杂计算再写 | 拆事务 + 条件更新 | 缩短持锁时间 |
| 多行更新,涉及余额转账 | 悲观锁 + 固定加锁顺序 | 保证强一致,避免死锁 |
4.2 混合使用:一个更贴近现实的方案
实际项目里很少纯粹只用一种。我自己比较常用的组合是"读的时候不做任何锁定,写的时候用条件更新兜底,同时给关键字段加版本号作为二次保障"。
具体来说:查询商品详情走普通SELECT,不加任何锁,页面展示速度不受影响。真正下单的时候,走一条带条件的UPDATE,把库存判断和扣减合并。同时表上保留version字段,用于那些需要"基于读到的数据做修改"的场景,比如修改商品标题。这两种场景的并发特征完全不同,用不同的手段处理,比强行统一要合理得多。
还有一点值得强调:能不加锁就不加锁。很多并发问题看起来必须靠锁解决,实际上换个思路就绕过去了。比如把"先查余额再扣减"改成"直接扣减并判断影响行数",本质上就把一次加锁变成了零次显式加锁。这种改写往往比调优锁的参数更有价值。
5. 代码落地:事务边界比锁本身更容易翻车
锁的语法就那么几行,但真正导致线上事故的,往往是事务边界的处理。这一节讲几个我实际遇到过的高频错误。
5.1 这几种写法会让你的锁和事务失效
第一种:事务方法内部调用另一个事务方法。Spring 的事务是基于代理实现的,如果你在一个@Transactional方法里用this.otherMethod()去调用同类的另一个方法,那个方法上的事务注解是不会生效的。因为调用根本没有经过代理对象。它会被当成普通方法调用,和调用方共用同一个事务,或者干脆没有事务。如果你指望它开启一个独立事务,行为会和预期完全不同。
第二种:捕获异常之后不抛出。这是最常见的一种。如果你在事务方法里try-catch了异常,但没有继续往外抛,Spring 就认为方法正常执行完了,会执行COMMIT而不是ROLLBACK。结果是数据改了一半,另一半该回滚的没回滚。更隐蔽的是,如果异常类型是受检异常(Exception 的子类但不是 RuntimeException),Spring 默认也不会回滚,需要显式配置rollbackFor。
第三种:锁加在了事务外面。这个错误比较隐晦。比如你的代码是先在一个没有事务的方法里执行FOR UPDATE,然后才进入事务方法去更新。这种情况下,FOR UPDATE加的锁会在它所在的语句结束后立刻释放(因为 autocommit 模式下每条语句就是独立事务),锁根本没起到保护作用。锁和更新必须在同一个事务里。
第四种:在持锁期间做远程调用。前面已经提过,这里再强调一下。持锁期间做的每一件事都在延长锁的生命周期,风险成倍放大。原则很简单:锁住的区间里只做数据库操作,任何 IO 都放到锁外面。
5.2 一段可以直接抄的库存扣减实现
下面这段是"条件更新 + 事务"的组合,可以直接拿去改改用在项目里。核心逻辑是:把库存判断和扣减压进一条 SQL,靠影响行数判断结果。
@Service public class StockService { @Resource private GoodsMapper goodsMapper; @Resource private OrderMapper orderMapper; /** * 扣减库存并创建订单 * 用条件更新避免超卖,不需要显式加锁 */ @Transactional(rollbackFor = Exception.class) public Long createOrder(Long goodsId, Long userId) { // 第一步:条件更新扣减库存,影响行数为 0 说明库存不足 int affected = goodsMapper.deductStock(goodsId); if (affected == 0) { throw new BizException("库存不足或商品不存在"); } // 第二步:创建订单,库存已经在同一事务内锁定 Order order = new Order(); order.setGoodsId(goodsId); order.setUserId(userId); order.setStatus(OrderStatus.CREATED); orderMapper.insert(order); return order.getId(); } }对应的 SQL:
<update id="deductStock"> UPDATE goods SET stock = stock - 1 WHERE id = #{goodsId} AND stock >= 1 </update>这段代码有几个设计上的考虑值得说明。第一,扣减和建单在同一个事务里,如果建单失败,库存会自动回滚,不会出现"扣了库存没有订单"的情况。第二,deductStock这条UPDATE是在事务里执行的,它加的排他锁会一直持有到整个事务提交,所以在这期间其它请求的扣减会被阻塞——这正是我们要的效果。第三,如果业务需要记录扣减流水,流水表的插入也放在同一个事务里,保证一致性。
如果要追求更高的吞吐,还可以在这个基础上做一层"预扣减":先用一条独立的UPDATE把库存预占,标记一条预扣记录,订单创建成功后再确认。这样做的好处是预扣减的持锁时间可以做得更短,但代价是引入了中间状态,需要额外的对账逻辑来清理超时未确认的记录。
6. 线上真实排查:1205 与 1213 到底在说什么
锁用多了,总会遇到等待和死锁。这时候能不能看懂报错、能不能快速定位,直接决定了故障恢复的时间。
6.1 两个错误码的含义差别
线上最常见的两个报错是:
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction1205 是锁等待超时。意思是这个事务在等一把锁,等了innodb_lock_wait_timeout秒(默认 50 秒)还没等到,MySQL 主动放弃并返回错误。它说明有另一个事务长期占着锁不放,你要做的事情是被动失败的。
1213 是死锁。意思是 InnoDB 检测到事务之间形成了循环等待,判断再等下去也不会有结果,于是主动选择"牺牲"其中一个事务——通常是修改数据量小、回滚代价低的那个——让它报错回滚,从而打破循环,让其它事务继续执行。
这两个错误的处理思路完全不同。遇到 1205,你要找的是"谁把锁占着不放",重点查长事务;遇到 1213,你要找的是"为什么两个事务的加锁顺序会交叉",重点查业务代码里的操作顺序。
补充一个知识点:死锁检测本身是有代价的,在高并发场景下innodb_deadlock_detect的检测开销可能很明显。如果确实遇到性能瓶颈,可以考虑关闭死锁检测,改用innodb_lock_wait_timeout来控制,但这属于比较激进的优化,需要充分压测之后再做。
6.2 把锁现场还原出来
排查的核心是找到"谁持有了锁""谁在等锁""这些事务在干什么"。
如果是 MySQL 8.0 之前,主要看information_schema下的这三张表:
-- 当前正在运行的事务 SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query, trx_rows_locked, trx_rows_modified FROM information_schema.INNODB_TRX; -- 当前的锁信息 SELECT * FROM information_schema.INNODB_LOCKS; -- 锁的等待关系 SELECT * FROM information_schema.INNODB_LOCK_WAITS;MySQL 8.0 之后INNODB_LOCKS和INNODB_LOCK_WAITS被移除了,改用 performance_schema:
-- 当前持有的锁 SELECT * FROM performance_schema.data_locks; -- 锁的等待关系,可以看到谁在等谁的锁 SELECT * FROM performance_schema.data_lock_waits;把data_locks和data_lock_waits关联起来查,就能得到一张很清楚的表:哪个事务持有锁、哪个事务在等、等的是哪张表的哪条索引记录。把BLOCKING_ENGINE_TRANSACTION_ID和REQUESTING_ENGINE_TRANSACTION_ID这两个字段对上,因果关系就一目了然。
如果只是想知道最近一次死锁的详情,最直接的命令是:
SHOW ENGINE INNODB STATUS\G输出里的LATEST DETECTED DEADLOCK段落会告诉你:哪两个事务参与了死锁、各自持有什么锁、在等待什么锁、最终哪个事务被回滚、回滚时正在执行的 SQL 是什么。这段信息的信息量非常大,遇到死锁先看它,基本能定位到问题 SQL。
找到问题之后,如果确认是某个事务卡住了,可以通过KILL掉对应的连接来释放锁:
-- trx_mysql_thread_id 从 INNODB_TRX 里查出来 KILL 12345;但这个操作要谨慎,杀掉连接会回滚该事务的所有修改,必须确认影响范围之后再执行。
6.3 一个真实的死锁复盘:加锁顺序不一致
最后讲一个我实际处理过的案例。业务是积分转账,A 给 B 转积分,代码逻辑是"先扣 A 的积分,再给 B 加积分"。两个事务同时执行,一个执行 A 转 B,一个执行 B 转 A,于是:
- 事务 1 持有 A 的锁,等待 B 的锁;
- 事务 2 持有 B 的锁,等待 A 的锁;
- 循环等待形成,InnoDB 检测到死锁,回滚其中一个。
这个问题的根因不是锁用错了,而是加锁顺序不一致。修复方式有两种:一是把两个账户 ID 排序,永远按从小到大的顺序加锁;二是在业务层做串行化,同一个用户的操作放到同一个队列里处理。我选择了第一种,改动最小,效果立竿见影——死锁报错立刻消失了。
这个案例给我的启发是:死锁往往不是技术问题,而是业务逻辑的顺序问题。只要涉及多行数据更新,就应该养成立下一条规则的习惯:所有事务按同一个顺序访问数据。这条规则看似简单,但能避免绝大多数死锁。
7. 高并发下更进一步的优化思路
锁的话题讲到这里,其实还可以再往上走一层。当单靠 SQL 层面的锁已经顶不住压力时,需要换思路。
7.1 短事务、小粒度、快进快出
这三个词是我对悲观锁优化的全部总结。
短事务指的是事务里只放必要的数据库操作。任何能在事务外做的准备工作,全部挪出去。比如参数校验、权限判断、日志记录,这些都不需要放在事务里。事务越短,锁持有时间越短,冲突概率越低。
小粒度指的是尽量锁索引记录,不要扫表。前面已经反复强调过索引的重要性,这里再补充一点:即使是范围查询,也要尽量缩小范围。WHERE id BETWEEN 1 AND 10000和WHERE id = 100的锁范围差了几个数量级,能精确就不要模糊。
快进快出指的是拿到锁之后立刻完成操作然后提交,不要有任何犹豫。如果你发现自己需要长时间持有锁,那就说明这个业务场景本身不适合用悲观锁,应该换成别的方案。
7.2 分段与串行化:把冲突从数据库挪走
当单行数据的冲突频率高到一定程度时,无论用什么锁方案都会遇到瓶颈,因为本质上是所有请求都在争抢同一个资源。这时候的思路是把冲突分散开。
一种做法是库存分段,把一个商品的库存拆成若干份,每份独立扣减,请求落到哪一段就扣哪一段。这样锁的粒度从"一个商品"变成"一个分段",并发度直接提升数倍。代价是可能会出现某一段扣完了、其它段还有库存的情况,需要额外的调度逻辑来处理,实现复杂度不低。
另一种做法是在数据库前面加一层串行化。比如把同一个商品的所有下单请求路由到同一个处理队列,由单线程依次处理。这样做的好处是彻底消除了并发争抢,实现简单、结果确定;坏处是这个队列的处理能力就是整条链路的瓶颈上限。这个方案适合那些 QPS 不是特别高、但绝对不能出错的场景。
我在实际项目里的体会是:先保证正确,再谈性能。大多数业务根本达不到需要分段库存的量级,把事务边界处理好、把索引建对、把重试逻辑写稳,就能解决绝大部分问题。过早地引入复杂的分段方案,带来的维护成本往往超过它节省的那点并发开销。
至于再往上,用缓存层做预扣减、用消息队列削峰、把热点数据拆到不同的分片,这些都属于分布式层面的方案了,涉及一致性保证和故障恢复的设计,那就是另一个话题了。就 MySQL 本身而言,把悲观锁和乐观锁这两条路走扎实,已经足够应对绝大多数线上场景。