ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL并发控制实战:悲观锁、乐观锁与库存超卖防重

MySQL并发控制实战:悲观锁、乐观锁与库存超卖防重 库存扣成负数这件事在电商、票务、积分兑换这类场景里几乎绕不过去。我第一次碰到是在一个限时抢购活动上商品总共 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_refrows的值越小越好。这是一个成本极低、收益极高的检查习惯。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 区间插入的操作这种间隙锁很容易造成意料之外的阻塞。如果你确实被间隙锁困扰有两个选择一是把隔离级别降到读已提交RCRC 下间隙锁基本被关闭二是保证查询条件命中唯一索引且记录存在。但降隔离级别这个动作不能拍脑袋做它会影响主从复制的数据一致性需要先确认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; }对应的 Mapperupdate iddeductWithVersion 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 的子类但不是 RuntimeExceptionSpring 默认也不会回滚需要显式配置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(); } }对应的 SQLupdate iddeductStock 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 本身而言把悲观锁和乐观锁这两条路走扎实已经足够应对绝大多数线上场景。
返回列表