先说结论:这条 SQL 在很多面试题里出现的频率极高,私下讨论度也一直没降过温。如果你写过订单系统、库存系统,大概率在代码里见过这一行:
UPDATE goods SET stock = stock - 1 WHERE id = ? AND stock > 0;MySQL 面试官也喜欢追问一句:“它是原子性的吗?”不少人一听“原子性”就开始背“要么全部成功、要么全部失败”,但真到了并发场景下,这条 SQL 能不能防止超卖、有没有隐藏的锁陷阱、为什么有人还是建议你用乐观锁,这些问题就不是一句话能答完的了。
这篇文章我打算把这件事彻底拆开:先讲清“原子性”到底指哪一层,再解释 InnoDB 底层靠什么保证,接着带你实际验证并发时的现象,最后给出一套可以直接抄进生产的写法。如果你正在处理库存扣减、秒杀、订单防超卖,或者正准备应付 MySQL 面试,建议把这篇看完,能节省你不少排查时间。
1. 先给结论:这条 UPDATE 在 InnoDB 里确实是“语句级原子”
1.1 原子性有三层,别把概念搅在一起
很多人对“原子性”的理解只有一个模糊轮廓,所以先把这个词拆开,否则后面越聊越乱。
第一层是 SQL 语句级原子性。对 InnoDB 来说,单条 UPDATE 语句在执行过程中如果出错,比如行锁等待超时、死锁被选中回滚、磁盘写入失败,InnoDB 会把这条语句已经做的修改全部撤销,不会出现“更新了前半部分,后半部分没更新”的中间状态。这一层是数据库引擎保证的,不需要你写任何额外代码。
第二层是事务级原子性。一个事务里有多条语句,比如先扣库存,再生成订单,最后写支付流水,只有全部成功并 COMMIT,改动才永久生效;任何一步失败,可以 ROLLBACK 把之前成功的操作也全部回滚。注意,SQL 语句级原子性不等于事务级原子性,前者只管单条语句,后者才是业务上说的“要么都做,要么都不做”。
第三层是业务逻辑级原子性。这是最容易被忽视的。你写了一个流程,先 SELECT 查库存,再判断库存够不够,最后 UPDATE 扣减,如果这几步之间没有合适的并发控制,数据库再“原子”也救不了你。因为数据库只保证单条语句内部不乱,不保证你多步操作之间的逻辑不被并发打破。
回到标题这句 SQL,它能直接防住的场景是:两个请求同时执行这条 UPDATE,InnoDB 会让它们排队,不会让你把最后一件库存同时卖给两个人。但如果你把它拆成“先查再改”,或者更新后不检查受影响行数,业务还是会出现库存负数、订单超卖这类问题。这正好对应了后面要讲的重点:原子性解决的是“数据不会被写坏”,不等于“业务流程一定正确”。
1.2 底层靠什么保证:行锁、Undo 日志和当前读
InnoDB 能保证单条 UPDATE 原子性,靠的是三样东西:锁、日志、以及执行方式。
锁很好理解。一条 UPDATE 执行时,InnoDB 会先定位到 WHERE 条件命中的那些行,然后给它们加上排他锁,也就是 X 锁。在并发场景下,其他事务如果也想修改同一行,就必须等当前事务释放锁;如果只是想读,MVCC 机制下还能读到旧版本快照,不会被阻塞。所以同一行库存的扣减,本质上是串行执行的。
日志方面,InnoDB 在修改数据前会先写 Undo Log,记录修改前的旧值,这样一旦语句或事务需要回滚,就能用 Undo Log 把数据恢复成原样。同时还有 Redo Log 保证提交后的数据即使遇到宕机也不会丢。这两个日志配合 Doublewrite Buffer,构成了 InnoDB 崩溃恢复的基础。这部分不用背源码,你只需要知道:数据库在更新时不是“直接改一下就行”,而是先记账,再改动,这样出了任何问题都能把账翻回来。
还有一个关键点是“当前读”。这句 UPDATE 在执行时不是先读取一个旧快照再判断 stock > 0,而是在锁定行之后,读取该行当前已提交的最新版本,然后进行条件判断和扣减。这个“判断条件”和“扣减”是在持锁状态下连续完成的,中间不会插进来别的事务。用生活场景类比,就像 ATM 机给你取钱时,会先锁住你的账户余额,判断余额够不够,再扣款、出钞,这一整串动作不会被另一个人打断。
1.3 自动提交模式下,一条 UPDATE 就是一个隐式小事务
MySQL 默认的autocommit是开启的,也就是说,你不用手写BEGIN/COMMIT,每一条语句执行完都会自动提交。此时这条 UPDATE 本身就是一个事务,执行成功立即生效并释放锁,执行失败自动回滚。
不过要注意,如果你在代码里手动开启了事务,比如START TRANSACTION或BEGIN,那么这条 UPDATE 结束之后并不会立刻释放 X 锁,锁会一直保持到你执行COMMIT或ROLLBACK。这时候锁的持有时间就变长了,其他并发事务可能会排队等待。这也是为什么很多生产事故都发生在“长事务”上——有人在事务里扣完库存后还做了一大堆网络调用,结果整行库存被锁了好几分钟,后面的请求全部堆积。
所以在实际项目里,当你听到有人问“这条 UPDATE 是原子的吗”,真正要往下追问的其实是两个问题:它有没有锁住其他事务?锁多久?这两件事决定了它在高并发场景下的表现。
2. 真正的大坑不在原子性,而在“受影响行数”
2.1 库存为 0 时 SQL 不会报错,但也没更新
这条 UPDATE 有一个非常经典的坑:当库存已经是 0 时,WHERE stock > 0条件不成立,所以不会有任何行被更新,但它也不会返回错误。数据库只会告诉你“影响 0 行”。
如果你在代码里没有判断受影响行数,而是把“执行成功”当作“扣库存成功”,就会出现一个诡异的现象:用户下单成功了,订单也生成了,但库存根本没扣。等库存对账的时候,账面库存和实际库存差了一大截,你排查半天可能都想不到问题出在这。
正确的做法一定是检查受影响行数。以 Python 的 pymysql 为例,大概是这样的写法:
cursor = conn.cursor() cursor.execute( "UPDATE goods SET stock = stock - 1 WHERE id = %s AND stock > 0", (goods_id,) ) if cursor.rowcount == 1: # 扣减成功,继续生成订单 pass else: # 扣减失败,说明库存不足或商品不存在 conn.rollback() raise BizException("库存不足")Java JDBC 里对应的就是int rows = statement.executeUpdate(...),判断rows == 1。这里还有个容易被坑到的细节:MySQL 的 JDBC 驱动默认返回的是“实际被修改的行数”,而不是“匹配到 WHERE 条件的行数”。如果你用的写法是SET stock = stock - 1,只要行的值真的变化了,返回 1;但如果某次你写的是SET stock = 0而它本来就是 0,匹配到了却不会真实修改,返回 0。这种情况下你需要根据自己的需求决定是否开启useAffectedRows=true,否则可能出现“明明条件满足,却判断成失败”的乌龙。
2.2 为什么“先 SELECT 再 UPDATE”一定会有并发漏洞
这个场景太经典了。很多人刚接触订单系统时,会把扣库存写成三步:
SELECT stock FROM goods WHERE id = 1; -- 业务代码判断 stock 是否大于 0 UPDATE goods SET stock = stock - 1 WHERE id = 1;问题在于,SELECT 和 UPDATE 之间是存在时间窗口的。假设库存只剩 1 件,用户 A 和用户 B 同时发起购买请求。两个请求可能都先执行 SELECT,都读到了 stock = 1,业务代码判断都通过了,然后两个请求都执行 UPDATE,最终库存变成 -1。这在并发环境下几乎必然发生,哪怕概率很小,一旦碰上就是超卖事故。
所以防超卖的核心思路,不是“先查再判断再改”,而是把“判断库存是否大于 0”和“扣减库存”合并成一个数据库层面的原子操作。也就是标题里这句话:stock = stock - 1 ... WHERE stock > 0,让数据库在持锁的状态下完成条件检查与更新。这里要注意,这条 SQL 能防超卖的前提是你真的检查了受影响行数,否则“条件不满足”和“更新成功”在程序眼里没有差别。
2.3 事务内怎么配合:ROW_COUNT 判断 + COMMIT / ROLLBACK
如果你不只是扣库存,还要同时创建订单,就必须把它放进一个事务里。一个比较稳妥的伪代码如下:
START TRANSACTION; UPDATE goods SET stock = stock - 1 WHERE id = 1 AND stock > 0; -- 检查受影响行数,如果等于 0,说明库存不足 -- 注意:这段检查要在应用层做,不是 SQL 层继续往下走 -- 如果影响行数 = 0,则直接 ROLLBACK 终止 INSERT INTO orders(goods_id, user_id, amount) VALUES(1, 1001, 99.00); COMMIT;实际开发中,通常是在应用层执行完 UPDATE 后读取受影响行数,再决定是继续执行后面的 INSERT 还是回滚事务。这里有个底层细节:InnoDB 里 UPDATE 加的排他锁会一直保持到事务提交或回滚,所以整个事务越短越好。千万别在事务里写耗时操作,比如远程调用、HTTP 请求、等待外部回调,这些都会把数据库锁越拖越长。
另外,如果你的业务要求更严格,比如一个订单可能同时买多件商品,你可以写成:
UPDATE goods SET stock = stock - 5 WHERE id = 1 AND stock >= 5;这里条件从stock > 0变成stock >= 5,本质上是把“购买数量”也放进条件里。很多人在面试时只会写stock > 0,其实能想到stock >= 下单数量会更贴合真实需求。
3. 动手验证:两个并发会话同时扣库存,到底会发生什么
3.1 准备测试表和数据
理论讲再多,不如亲手验证一遍。先在本地 MySQL 建一张最简单的商品表:
CREATE TABLE goods ( id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, stock INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB; INSERT INTO goods(id, name, stock) VALUES (1, '限量T恤', 1);这里我故意把库存只初始化成 1,方便模拟“最后一件商品被两个人同时抢”的场景。
3.2 一个事务不提交,另一个 UPDATE 会怎样
打开两个 MySQL 命令行窗口,模拟两个并发请求。
窗口 A 执行:
BEGIN; UPDATE goods SET stock = stock - 1 WHERE id = 1 AND stock > 0;此时窗口 A 的事务还没有提交,库存行的排他锁仍然被持有。接着在窗口 B 执行同一条 UPDATE:
UPDATE goods SET stock = stock - 1 WHERE id = 1 AND stock > 0;你会发现窗口 B 的 SQL 卡住了,一直处于等待状态。这就是 InnoDB 行锁的最直观体现:窗口 B 的 UPDATE 需要给这一行加排他锁,但锁正被窗口 A 持有,于是只能等待。等到窗口 A 执行COMMIT或ROLLBACK之后,窗口 B 才会继续执行。
如果窗口 A 一直不提交,窗口 B 默认等待 50 秒(innodb_lock_wait_timeout默认值)后,会报出这样的错误:
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction这个实验能证明两件事:第一,同一行数据的两个 UPDATE 是串行执行的;第二,锁的释放时机取决于事务何时提交或回滚,不是语句本身执行完就算完。
3.3 用报错演示“语句级原子”和“事务级原子”
再做一个实验,把“扣库存”和“插入一条非法订单”放进同一个事务,看看原子性如何发挥作用。
窗口 A 执行:
BEGIN; UPDATE goods SET stock = stock - 1 WHERE id = 1 AND stock > 0; -- 故意插入一条会造成主键冲突的订单 INSERT INTO orders(id, goods_id, user_id) VALUES (1, 1, 1001); ROLLBACK;当 INSERT 因为主键冲突报错时,如果你什么都不做直接执行ROLLBACK,刚才 UPDATE 把库存从 1 改成 0 的操作也会一起被撤销,库存恢复成 1。这就是事务级原子性。
如果你想看语句级原子性,可以换个方式:不在事务里,直接执行一条会因为中途报错而失败的 UPDATE。比如把stock更新成一个超出范围的数值,或者触发某个触发器报错,这条 UPDATE 自身产生的修改就会被回滚。生产环境里更常见的是“行锁等待超时”或“死锁回滚”,这时 InnoDB 会自动撤销当前语句已经做的修改,不会留下脏数据。
3.4 用 EXPLAIN 看执行路径,别让小查询变成全表锁
很多人在缓存了这个结论后只顾着写 SQL,忽略了执行计划对锁范围的影响。我们还是用这张表,先看一眼执行计划:
EXPLAIN UPDATE goods SET stock = stock - 1 WHERE id = 1 AND stock > 0;如果id是主键,执行计划里type一般是const或eq_ref,扫描行数只有 1,InnoDB 只需要锁这一行。但如果你把 WHERE 条件改成WHERE stock > 0,而stock上没有索引,MySQL 只能走全表扫描,InnoDB 会对扫描过程中遇到的所有行加锁。在默认的 REPEATABLE READ 隔离级别下,甚至可能加上间隙锁(gap lock),导致其他事务插入新商品时也被阻塞。
这就是为什么我强烈建议:UPDATE 语句的 WHERE 条件一定要有合适的索引,最好能精确命中一行。否则“一条原子 UPDATE”可能会退化成“锁住整张表”,性能直接崩掉。
4. 面试追问与生产落地:几种订单扣库存写法的对比
4.1 version 字段 + 条件更新:乐观锁解法
直接扣库存的写法能防超卖,但防不了另一种问题:业务上需要确认“我操作期间这条数据有没有被别人改过”。比如典型的编辑场景,用户打开一个表单,读到了商品的当前信息,过一会儿提交时,希望基于旧版本做更新,而不是覆盖别人的修改。
解决办法是给表加一个版本号字段:
ALTER TABLE goods ADD COLUMN version INT NOT NULL DEFAULT 0;更新时把版本号带进去:
UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = 1 AND version = 0;如果影响行数为 0,说明版本号已经变了,也就是在你读取之后、更新之前,有别的事务先改过这行数据。这种做法叫乐观锁,适合读多写少、冲突概率低的场景。它的优势在于不需要长时间持锁,事务范围可以更短;劣势是冲突发生时需要业务自行重试,或者直接告诉用户“数据已过期,请刷新”。
如果你的真实需求只是“保证库存不为负”,那么WHERE stock > 0已经够用,不必额外加 version。这里不要为了设计而设计。
4.2 什么时候反而要 SELECT ... FOR UPDATE
有些业务场景比较复杂,比如下单时要先读取库存、运费、促销信息,进行一系列计算,最后才决定怎么扣库存。这种情况下,你不可能用一条 UPDATE 代替所有计算逻辑,于是需要在事务里先把相关行锁住,避免计算过程中被别人改掉。
常规做法是:
BEGIN; SELECT stock FROM goods WHERE id = 1 FOR UPDATE; -- 应用层拿到 stock 后做一系列计算 -- 计算出最终购买数量,再更新库存 UPDATE goods SET stock = stock - 5 WHERE id = 1; COMMIT;SELECT ... FOR UPDATE会对命中的记录加排他锁,效果和 UPDATE 加锁一样。但它必须放在事务里,否则执行完 SELECT 锁就释放了,等于白锁。这个方案适合“需要先读出来参与复杂计算”的场景,代价是行锁持有时间更长,并发能力下降。用它的时候,事务里千万少做网络请求和外部依赖。
4.3 多商品批量扣减的死锁案例与统一加锁顺序
扣库存如果涉及多个商品,比如购物车结算,一次要扣 A、B、C 三件商品,这时候加锁顺序非常重要。
设想两个事务同时执行:
事务 1 先扣商品 A,再扣商品 B; 事务 2 先扣商品 B,再扣商品 A。
当事务 1 锁住 A、事务 2 锁住 B 之后,两边都等着对方释放自己需要的锁,死锁就出现了。MySQL 检测到死锁后,会选择一个事务作为牺牲者回滚,报错信息大概是:
ERROR 1213 (HY000): Deadlock found when trying to get lock; try restarting transaction解决办法很简单:多个事务更新多行时,保持相同的加锁顺序。最简单的一种做法是在应用层先把商品 ID 排序,然后按顺序执行 UPDATE。这样所有事务都会先去锁 ID 较小的商品,再去锁 ID 较大的商品,就不会出现互相等待的环。
如果你用的是批量 UPDATE,比如UPDATE goods SET stock = stock - 1 WHERE id IN (3, 1, 2),MySQL 内部虽然是按索引顺序加锁的,但为了减少不确定性,还是建议在代码里统一排序。
4.4 热点库存行高并发:行锁串行的本质与常见扩容方案
讲了这么多,你会发现同一行库存的扣减本质上是串行的。InnoDB 的行锁保证正确性,但也限制了吞吐量。如果某个爆款商品一秒有几千个请求同时抢购,所有请求都在同一行上排队,数据库的锁等待会越来越严重,响应时间也会被拖垮。
这时候常见的扩容思路有几种:
第一,库存分桶。把一件商品拆成多行库存,比如总库存 1000 件分散到 10 行,每行 100 件,扣减时随机或哈希选中一行。这样并发压力会被分流到不同行上,但实现复杂度会增加,还要处理“某一行扣完但其他行还有库存”的情况。
第二,前置拦截。在 Redis 里维护库存计数,先通过DECR等原子操作快速扣减,扣减成功后再异步落库到 MySQL。这能挡掉大部分瞬时流量,但 Redis 和 MySQL 之间的一致性需要额外设计,通常还会配合对账任务。
第三,消息队列削峰。把下单请求全部丢进队列,后端消费者串行或小批量并发处理,数据库压力会平滑很多。
这些方案各有取舍,如果你的系统还没到大数据量并发阶段,建议先用单条 UPDATE 加行数判断的写法,简单可靠,排障也容易。不要过度设计,库存拆桶带来的复杂度比想象中高得多。
4.5 关于“UPDATE 建议加 WHERE EXISTS”的补充
题外话:网上还有一种经验总结,说“对于 UPDATE 操作,建议添加 WHERE EXISTS 子句避免空值更新”。它主要针对的是带子查询赋值的 UPDATE。比如:
UPDATE goods g SET g.category_name = ( SELECT c.name FROM category c WHERE c.id = g.category_id );如果某个商品对应的分类不存在,右侧子查询返回 NULL,UPDATE 会把category_name更新成 NULL,这可能不是你想要的。更稳妥的写法是加一层WHERE EXISTS,只有当子查询确实能查到结果时才更新:
UPDATE goods g SET g.category_name = ( SELECT c.name FROM category c WHERE c.id = g.category_id ) WHERE EXISTS ( SELECT 1 FROM category c WHERE c.id = g.category_id );这和我们聊的主题不是一回事,但既然搜 UPDATE 原子性问题经常会顺藤摸瓜看到这条建议,干脆一起说了,免得被误导。
5. 日常问题排查速查:遇到这五种情况怎么处理
5.1 常见问题速查表
我整理了一张表,基本覆盖了日常使用这条 UPDATE 时最常踩到的坑:
| 问题现象 | 根本原因 | 推荐处理方式 |
|---|---|---|
| UPDATE 一直卡住,最终报 Lock wait timeout | 同一行被其他事务持有排他锁,本事务等待超时 | 查找持有锁的事务并让其尽快提交;优化事务长度;必要时调整innodb_lock_wait_timeout |
| 报错 Deadlock found | 两个以上事务加锁顺序不一致,形成锁环 | 统一加锁顺序;对多行更新时先排序;捕获死锁错误后重试 |
| 程序提示操作成功,但库存没变化 | 没有检查受影响行数,或受affectedRows配置影响 | 更新后必须判断rowcount/executeUpdate返回值 |
| UPDATE 把整张表锁住,查询也变慢 | WHERE 条件没有可用索引,导致全表扫描加锁 | 给 WHERE 条件建立合适索引;用 EXPLAIN 确认扫描行数 |
| 某列莫名被更新成 NULL | UPDATE 里的子查询查不到数据,返回 NULL 并覆盖原值 | 使用WHERE EXISTS或COALESCE防御空值 |
这里面最容易被忽略的其实是第二行。遇到死锁时,很多人的第一反应是“数据库出 bug 了”,其实大部分死锁都是业务代码的加锁顺序问题。捕获到死锁错误后,不要直接返回失败,应该设计成“整个事务重试”,但重试次数要有限制,否则可能连续冲突。
5.2 一套可以直接抄的扣库存模板
最后给你一个我在项目里常用的模板,算是多年踩坑后沉淀下来的版本:
-- 伪代码,语言层结合具体框架 BEGIN; -- 1. 条件里带上“购买数量”,只有库存充足才更新成功 UPDATE goods SET stock = stock - #{quantity} WHERE id = #{goodsId} AND stock >= #{quantity}; -- 2. 判断受影响行数 -- 如果受影响行数为 0,说明库存不足或商品不存在 -- 此时直接 ROLLBACK,并提示用户“库存不足” -- 3. 插入订单明细 INSERT INTO orders(goods_id, user_id, quantity, status) VALUES(#{goodsId}, #{userId}, #{quantity}, 'CREATED'); -- 4. 提交 COMMIT;几个细节再强调一下:
- 确保
goods.id是主键或有唯一索引,让锁粒度最小化。 - 事务里不要夹带远程调用、消息发送、外部 API 请求。
- 更新后第一时间检查影响行数,不要在业务层隔了好几行再判断。
- 如果同一个事务还要更新多件商品,先对商品 ID 排序,统一加锁顺序。
我自己在实际项目里用过很久这种写法,大多数库存类需求都能扛住。再往上走,如果遇到高并发秒杀场景,再考虑 Redis 预扣、库存分桶或消息队列削峰也不迟。判断方案要不要换,不要听别人一句“单条 UPDATE 性能不行”就慌,先看看你自己的并发量级和数据一致性要求,再决定要不要引入更复杂的架构。
最后再分享一个我比较个人的习惯:任何关于锁、事务、原子性的问题,我都会先在本地用两个 MySQL 会话亲手复现一遍,而不是只在文档里看结论。因为锁等待、死锁这类问题,真到了生产环境再排查,看到的日志往往已经隔了好几层,远不如自己动手复现一次来得直观。希望这篇能帮你把这条 UPDATE 的原子性边界彻底想明白,以后写扣库存代码时,少一点侥幸,多一点把握。