MySQL并发扣库存防超卖:一条UPDATE语句的原子性原理与工程实践
做后端的朋友应该都见过这种写法扣库存时直接一条UPDATE ... SET stock stock - 1 WHERE stock 0甩过去。不少人也问过我同样的问题——这条语句到底是不是原子操作并发情况下会不会超卖如果把库存从1扣到负数是不是就翻车了我先把结论放这儿在MySQL默认的InnoDB存储引擎下这条UPDATE语句本身是原子操作能保证不会扣成负数。但要真把这四个字“原子操作”讲清楚背后涉及行锁原理、索引命中、事务隔离级别等一系列知识点很多人在面试或线上排查时就是栽在这些细节上。这篇文章我打算从原理到底层实现再到生产环境里的实操经验把这行UPDATE彻底拆开。不看亏的不是我是你后面线上真的出超卖问题时排查起来会非常被动。1. 先搞清楚MySQL里的“原子性”到底指什么1.1 别把“事务原子性”和“并发安全”混为一谈很多人一提原子性第一反应就是ACID里的Atomicity说“原子性就是要么全成功要么全失败”。这话没错但在并发扣库存的场景里我们真正关心的其实是另一个问题两个请求同时执行同一条UPDATE语句会不会互相覆盖、把数据写坏这两个概念容易搞混实际上它们属于不同维度。事务原子性解决的是“故障恢复”问题——事务执行到一半数据库宕机了重启后不能出现扣了库存但订单没生成这种半截状态。而并发安全解决的是“竞态条件”问题——两个事务同时操作同一行数据最终结果必须等价于它们按某种顺序串行执行。回到UPDATE stock stock - 1 WHERE stock 0这条语句它同时满足了两者。从故障恢复看单条语句天然是一个事务要么完整提交要么完整回滚从并发安全看InnoDB通过锁机制保证同一时间只有一个事务能修改这行记录。所以结论就是在正确使用InnoDB的前提下这条语句确实是原子操作也确实能挡住超卖。1.2 WHERE条件在这里的作用是什么这个WHEREstock 0很关键它不只是“过滤条件”更是并发控制的一部分。假设当前库存stock等于1两个请求同时执行这条UPDATE。第一个请求先拿到行锁执行stock 1 - 1把库存改成0并提交。第二个请求等锁释放后再执行时它读到的stock已经是0WHERE stock 0判断不成立影响行数为0库存不会变成-1。这就是“判断和修改在数据库内部一次完成”的意义。如果你把判断逻辑放到应用层——比如先用SELECT把stock查出来在Java/Python代码里判断是否大于0再执行UPDATE——那就会出现经典的“检查后写入”竞态两个请求都读到1都认为可以扣然后各自执行UPDATE最终库存变成-1。这也是为什么我一直强调能一条UPDATE解决的千万不要拆成SELECTUPDATE。2. 原理拆解一条UPDATE在InnoDB里到底怎么执行2.1 定位记录靠索引索引不命中就是灾难InnoDB是索引组织表数据本身存储在主键索引聚簇索引的叶子节点上。执行UPDATE时存储引擎第一步要做的是“定位到要修改的那一行”。这个过程必须通过索引完成——走主键、唯一索引、普通索引都行但前提是WHERE条件里用到的列能够命中索引。如果WHERE条件无法命中任何索引InnoDB会怎么办它会扫描主键索引的所有叶子节点把每一条记录都读出来逐条判断stock 0是否成立。这里有个容易忽略的细节在扫描过程中InnoDB会给扫过的每条记录都加上锁判断不满足条件后再释放。如果表里有100万条库存记录这条UPDATE就要给100万条记录加锁再释放产生的锁开销和持锁时间都极其恐怖。并发一高其他所有针对这张表的写操作都会被堵住表现就是“数据库卡死”“连接数打满”。所以在这个场景下我强烈建议给 stock 这种高频更新字段建索引或者至少保证WHERE条件能通过主键/唯一索引快速定位到那一条记录。如果业务上确实需要按商品维度扣库存那优先保证WHERE product_id ? AND stock 0中的 product_id 走了索引而不是只靠stock字段去过滤。2.2 加锁阶段记录锁、间隙锁与半一致性读定位到目标记录后InnoDB会对这条记录加锁。默认情况下加的是“记录锁Record Lock”也就是锁住这条索引记录本身。但这里有两个例外情况需要特别注意。第一个例外如果WHERE条件用到的索引是普通索引而且还存在“范围条件”或“未命中唯一索引”的情况InnoDB为了阻止幻读在REPEATABLE READ隔离级别下可能会额外加上间隙锁Gap Lock或临键锁Next-Key Lock。间隙锁锁住的不是一个具体记录而是索引记录之间的“间隙”目的是阻止其他事务在这个间隙里插入新记录。这在高并发插入场景下可能导致死锁后面我会专门讲。第二个例外在READ COMMITTED隔离级别下MySQL对UPDATE有一个“半一致性读semi-consistent read”优化。简单说在真正加锁之前InnoDB会先读一次该记录的最新已提交版本如果发现不满足WHERE条件比如stock已经是0就直接跳过不加锁。这样可以减少不必要的锁冲突。但在REPEATABLE READ级别下没有这个优化因为要保证基于快照的一致性判断。对大多数业务场景来说这条UPDATE的原子性不受隔离级别影响。但从“减少锁冲突”的角度看如果你用的是READ COMMITTED且没有间隙锁需求并发表现通常会比REPEATABLE READ更好。2.3 赋值表达式在引擎内部完成不回表也不算旧值这是很多人对SET stock stock - 1最大的误解。有朋友问我“MySQL在执行这条语句的时候会不会先读一次旧的stock值然后回表计算新值再写回去那不就是先读后写吗”其实不是。InnoDB在拿到行锁之后是在内存中的“当前读”版本上直接执行赋值运算的。所谓当前读指的就是“读取最新已提交版本的数据”而不是MVCC快照读那种是普通SELECT不加FOR UPDATE时走的一致性快照。也就是说stock - 1这个计算发生在存储引擎层拿到的stock值一定是“当前最新值”。同一时刻其他事务想改这行必须等当前事务提交或回滚释放锁。这保证了“判断stock大于0”和“执行stock-1”这两个动作之间没有任何缝隙不会出现“判断时是1真正减的时候已经变成0”的情况。这也解释了为什么用一条UPDATE就能解决超卖——数据库在内部替你完成了“判断修改”的原子组合。3. 实战中那些让你翻车的“伪原子性”场景3.1 手动事务里SELECT再UPDATE反而扩大了竞态窗口我在不少项目的代码评审里见过这种写法-- 伪代码 SELECT stock FROM product WHERE id 1; -- 应用层判断 stock 0 UPDATE product SET stock stock - 1 WHERE id 1;开发者的想法很简单先查出来判断一下再更新。但这样做不但多了一次网络交互而且判断和更新之间有一个时间窗口两个请求完全可能同时通过判断然后都执行UPDATE。表面上看UPDATE语句本身还是有行锁保护但应用层的“判断”已经不可靠了。还有人会把SELECT和UPDATE包在同一个事务里以为开了事务就安全。实际上MySQL默认的隔离级别是REPEATABLE READ事务里的普通SELECT是快照读读到的可能是旧版本数据。上次读过stock1另外一个事务已经把库存扣成0并提交了你这边事务里的SELECT还是可能看到旧值1取决于快照的建立时机。然后你以为还能扣去执行UPDATE结果影响行数是0但你的应用层可能已经在走“扣减成功”的业务分支了——订单创建了库存却扣失败。正确的做法是不要先SELECT判断直接执行UPDATE然后检查受影响行数。受影响行数为1说明扣减成功为0说明库存不足或记录不存在。3.2 影响行数不检查扣减失败等于没扣很多ORM框架默认把UPDATE的返回值丢弃了这让“影响行数检查”成了最容易漏掉的一环。我见过一个实际案例线上优惠券发放时用UPDATE coupon SET remaining remaining - 1 WHERE coupon_id ? AND remaining 0去扣减优惠券库存代码里完全没看影响行数。超发的优惠券导致用户下单时明明领到了券结算时却提示“券已被抢完”。排查到最后就是因为在并发高峰期部分UPDATE的影响行数其实是0但业务代码继续走了成功逻辑。所以用这条UPDATE做扣减影响行数必须读到并判断。这是一个硬性要求。不管你用JDBC的executeUpdate返回int还是用MyBatis的int返回值抑或是Spring Data JPA的Modifying注解方法返回值都得用它来判断业务是否成功。3.3 批量扣减没加事务部分成功部分失败另一种典型错误是一个订单里包含多个商品代码循环执行多条UPDATE stock stock - 1 WHERE stock 0但没把它们包在同一个事务里。第一条扣减成功了第二条因为库存不足影响行数为0业务直接抛异常但第一条的扣减已经自动提交了回不去了。最终结果就是用户下单失败库存却少了。正确做法是把多条UPDATE放到同一个事务里任何一个商品扣减失败整体回滚。同时要注意事务不能开太大——如果一次订单要更新几百个商品的库存事务持有锁的时间会很长非常容易引起连锁锁等待。建议在业务设计上控制单事务的更新记录数必要时拆分订单或异步扣减。3.4 “WHERE stock 0”之外的业务条件别指望数据库帮你兜底再来一个容易被忽略的点。WHERE stock 0只保证了“库存大于0”这个条件。如果你的业务还有其他要求比如“同一用户只能抢一次”“活动必须在有效期内”这些逻辑不能全部指望靠SQL里的WHERE条件搞定。数据库的原子性只针对它接收到的那条SQL它不会知道你的活动规则。复杂业务校验该在应用层做还是在数据库层做可以灵活设计但最终扣库存这一下务必用原子UPDATE作为最后一道防线。4. 技术选型对比UPDATE自减、SELECT...FOR UPDATE、乐观锁怎么选4.1 三种方案的差异扣库存的实现方案我见过的主要是这三种原子UPDATE自减、SELECT ... FOR UPDATE悲观锁、基于版本号的乐观锁。它们的核心差异可以放在一张表里看。方案核心写法锁粒度并发能力典型适合场景原子UPDATE自减UPDATE ... SET stock stock - 1 WHERE ... AND stock 0行锁修改时短暂持锁高秒杀、抢购、库存扣减SELECT...FOR UPDATE先SELECT * FROM ... WHERE id ? FOR UPDATE再在应用层计算并UPDATE行锁从SELECT到UPDATE全程持锁中需要先读取多个字段做判断的复杂业务乐观锁CASUPDATE ... SET stock stock - 1 WHERE ... AND version ?无锁靠版本号失败重试中低冲突概率较小的业务场景从并发能力角度看原子UPDATE自减通常表现最好因为它把持锁时间压缩到最短——从索引定位到执行赋值再到提交整个过程在数据库内部一次性完成。FOR UPDATE方案虽然也能保证安全但从SELECT到UPDATE之间锁一直持有还多了两次网络交互冲突概率和锁等待时间都会拉长。4.2 什么时候该用乐观锁乐观锁的思路是给表加一个version字段每次更新时带上版本号条件UPDATE ... SET stock stock - 1, version version 1 WHERE id ? AND version ?。如果影响行数为0说明期间有其他事务改过这条记录应用层需要重试或提示失败。乐观锁适合“冲突概率不高、重试成本低”的场景比如用户修改自己的个人资料、非热点数据的更新。但放在高并发秒杀场景里并不合适——库存就那么多大部分请求都来抢同一行冲突概率极高大量请求会在“更新失败→重试”中空转数据库压力反而更大。更麻烦的是如果重试逻辑写得不好还可能把原本一个UPDATE能解决的事情放大成多次数据库请求。4.3 什么时候离不开FOR UPDATE有些业务确实不能靠一条UPDATE解决。比如你要根据库存数量和商品价格计算订单金额再写订单表、更新库存、记录流水这中间应用层需要读到“最新的库存值”做业务判断。这时候可以考虑SELECT ... FOR UPDATE先把行锁住保证从读取到更新这段时间内其他事务不能修改这行数据。但记住持锁时间越短越好事务里除了必要的业务逻辑别做RPC调用、消息发送这些耗时操作否则锁被人为拉长并发能力会直线下降。4.4 “先查再判”并不等于悲观锁最后再纠正一个概念误区。很多人觉得自己事务里先SELECT再UPDATE就相当于用了悲观锁。其实不是——普通SELECT是快照读不加锁多事务之间可以并发读读到什么版本取决于快照建立时机完全不可控。真正的悲观锁是SELECT ... FOR UPDATE它读到的才是最新版本并且会加排他锁阻塞其他写事务。所以如果你真的需要“先读后写”要么用FOR UPDATE要么放弃快照读直接原子UPDATE千万别拿普通SELECT当锁用。5. 生产落地的硬性建议少踩一个坑是一个5.1 事务边界越小越好锁越短越稳原子UPDATE虽然持锁时间短但它仍然是一个需要事务保护的写操作。如果在它前后还包了其他业务操作比如创建订单、更新用户积分、扣减优惠券请务必控制事务边界不要在事务里做大数据量查询、远程调用、消息推送。我见过最离谱的一次事故就是事务里调了一个超时的外部接口事务迟迟不提交库存行锁一直被占着秒杀活动刚开始数据库连接池就被打满所有请求都在等锁。最后只能kill事务重启应用活动直接废了。5.2 建索引不是万能的但没索引一定不行前面说了WHERE条件必须命中索引。但索引也不是越多越好——每次UPDATE都要同步维护索引索引太多会拖慢写性能。对于库存扣减这个场景我建议主键或业务唯一ID查询最理想比如WHERE product_id ?product_id是主键或唯一索引。不要单独为stock字段建索引除非你还经常按库存量做排序或范围查询否则这个索引利用率极低。如果查询条件是多列组合比如WHERE shop_id ? AND sku_id ?可以考虑联合索引但要注意列顺序——区分度高的列放前面。5.3 扣减前先确认记录存在避免空值更新的隐性Bug写代码的时候经常有这种场景根据一个外部传入的ID去扣库存但这个ID在表里可能根本不存在。如果你写成UPDATE stock stock - 1 WHERE id 123 AND stock 0记录不存在时影响行数也是0。这时业务侧容易误判成“库存不足”排查半天才发现是ID传错了。一种常见的做法是在执行扣减前用SELECT EXISTS (SELECT 1 FROM product WHERE id ?)确认记录存在。如果记录存在再执行原子UPDATE并根据影响行数判断到底是“库存不足”还是“扣减成功”。虽然多了一次查询但能避免把“数据不存在”和“库存不足”这两种完全不同的错误混在一起。热词里提到的“建议添加WHERE EXISTS子句避免空值更新”在实际业务中确实是这个道理。5.4 幂等设计是最后的兜底在高并发场景下网络超时、重试、消息重复消费都可能让同一条扣减请求被执行两次。即使你的UPDATE本身是原子的也无法防止“重复请求”把库存多扣一次。解决思路是在业务层做幂等控制客户端生成唯一请求ID服务端在事务里校验这个请求ID是否已经处理过或者用唯一约束防重。比较常见的做法是建一张“扣减流水表”用请求ID或业务订单号作为唯一键成功扣减就插入流水后续重复请求因为唯一键冲突直接被拒绝。数据库层的原子性解决了“并发写同一行”的问题但“同一业务请求被重复执行”这个问题还需要幂等机制来兜底。5.5 不要在存储过程里循环逐条扣减还有一个热词提到了MySQL存储过程。我的态度是能用一条SQL完成的操作不要在存储过程里用循环逐条处理。我见过有人把库存扣减逻辑写进存储过程用游标遍历所有需要更新的商品一条一条执行UPDATE。结果就是事务里持有了大量行锁执行时间极长并发一高就是死锁和锁超时。存储过程适合做固定的批量数据处理不适合扛高并发的在线交易。在线扣减一条UPDATE就够了。6. 常见问题排查实录遇到这四种情况别慌6.1 更新一直卡住processlist里全是Waiting for lock这是最典型的并发锁等待现象。多个事务都在更新同一行先拿到锁的事务没提交后面的全部阻塞。排查命令SHOW ENGINE INNODB STATUS; SHOW PROCESSLIST;重点关注TRANSACTIONS部分的锁等待信息能看到哪个事务持有锁、哪个事务在等待。解决思路一般是缩短事务执行时间、避免在事务里做耗时操作、必要时对UPDATE语句加超时控制比如innodb_lock_wait_timeout。如果是死锁导致的问题MySQL会自动回滚其中一个事务应用层捕获到死锁异常后做重试即可。6.2 条件一样为什么影响行数有时是0如果复查SQL没有写错影响行数是0通常就两种可能一是stock已经为0WHERE条件不满足二是WHERE条件里的记录根本不存在比如ID传错、数据被删。排查时可以先不带stock 0条件执行一次UPDATE看影响行数再对比结果定位是哪种情况。但线上环境不建议直接执行不带条件的UPDATE可以先跑一条SELECT确认记录是否存在。6.3 秒杀一开数据库CPU飙高锁等待严重这种场景多半是SQL没走索引导致全表扫描加锁。也可能是同一时刻太多请求同时更新同一行行锁竞争本来就激烈。优化方向确认WHERE条件索引命中查看隔离级别配置如果用的是REPEATABLE READ且业务上可以接受更低隔离级别考虑改成READ COMMITTED减少间隙锁应用层做流量削峰把请求排队化而不是让所有请求直接打到数据库。RedisLua做前置扣减、异步同步到MySQL也是常规解法但那是另一个大话题了。6.4 UPDATE和INSERT同时执行时死锁死锁往往不是一条UPDATE引起的而是多个事务加锁顺序不一致导致。比如事务A先更新商品1再更新商品2事务B先更新商品2再更新商品1双方各持一把锁等对方释放就死锁了。解决办法固定加锁顺序比如按商品ID升序更新。减少事务持有锁的数量和时间。捕获死锁异常做有限次重试。另一个容易踩的场景是在REPEATABLE READ级别下普通索引上执行UPDATE时InnoDB可能给范围内不存在的记录加间隙锁同一时间其他事务在间隙里插入新记录就形成死锁。遇到这类问题优先排查SQL的WHERE条件是否走了唯一索引以及事务里是否同时存在UPDATE和INSERT。从我这几年跟库存、订单、优惠券这类高并发场景打交道的经验看核心就一句话数据库的原子性是底线不是万能药。一条UPDATE能解决并发写同一行的正确性问题但解决不了业务幂等、事务边界和索引设计上的疏忽。你把这条语句用对了能省掉90%的并发扣减Bug但想让它稳定扛住线上流量还得靠SQL之外的这些工程手段一起来。最后再提个建议任何涉及扣减的UPDATE上线前都压一遍并发测试用300个并发线程同时扣同一行看看有没有负库存、死锁或者响应时间暴涨比事后线上排查省心一百倍。