单条 update 语句本身原子,但业务原子性需靠 where 条件校验+唯一约束+原子操作组合实现,而非单纯依赖 select for update 锁多行。

不能靠单个 SELECT FOR UPDATE 语句锁住多行就认为操作原子了——锁的范围、持有时机、事务边界三者不匹配,必然出现竞态或覆盖。
为什么 SELECT FOR UPDATE 锁多行后仍可能丢数据
很多人写这样的逻辑:开启事务 → SELECT id FROM orders WHERE status = 'pending' LIMIT 2 FOR UPDATE → 拿到两个 id → UPDATE orders SET status = 'processing' WHERE id IN (?, ?) → 提交。看起来锁了两行,但问题出在「锁只保留在当前事务内,且只对被选中的索引记录生效」。
常见错误现象:A 事务查到 id=101,102 并加锁,B 事务紧接着插入 id=101.5(比如用 UUID 或非连续主键),或更新另一条未被 A 锁住但满足 WHERE status = 'pending' 的记录,A 提交后 B 也提交,结果状态不一致。
- 若查询条件没走索引,
SELECT FOR UPDATE会升级为临键锁(Next-Key Lock),锁住整个范围,看似“更安全”,实则阻塞面过大,容易引发死锁或雪崩等待 - 若用非唯一索引(如
status字段无索引),InnoDB 会先锁辅助索引项,再回表锁主键,锁的行数远超预期,且间隙也被锁定,其他事务插入同范围值会被堵住 - 锁只在事务生命周期内有效;一旦事务提交或回滚,锁立即释放——无法跨事务保证“查-改”中间态不被干扰
UPDATE ... WHERE 带条件更新本身是否原子?
是的,单条 UPDATE 语句在 InnoDB 中天然具备原子性:它会在执行前对匹配的所有行加 X 锁(排他锁),并生成 undo log,失败时可回滚。但「原子」仅指这条 SQL 内部不被拆开,不等于「业务逻辑原子」。
使用场景:库存扣减、状态机跃迁等单点决策类操作。例如:UPDATE products SET stock = stock - 1 WHERE id = 123 AND stock >= 1,这个语句本身不会出现“查到有库存→扣减→发现已为负”的中间态。
- 必须确保
WHERE条件列有有效索引,否则全表扫描 + 临键锁 → 性能崩、锁表风险高 - 不要在
UPDATE前另起一次SELECT判断——这制造了竞态窗口;所有校验应压进UPDATE的WHERE子句 - 影响行数返回值(
affected rows)是关键信号:若为 0,说明条件不满足(如库存不足),不是失败,而是业务拒绝
真正需要跨行原子性的场景该怎么处理
典型如「从待处理队列中取出 N 条任务分发给 worker」,要求:取和标为处理中这两个动作不可分割,且不能重复分配。这时不能依赖单语句,得靠显式锁 + 唯一约束闭环。
推荐做法:用带过期时间的分布式锁表模拟原子出队,例如:
INSERT INTO task_lock (task_id, worker_id, expire_at) SELECT id, 'w-456', DATE_ADD(NOW(), INTERVAL 30 SECOND) FROM tasks WHERE status = 'pending' ORDER BY created_at LIMIT 1 ON DUPLICATE KEY UPDATE status = 'processing';
这个方案的关键在于:task_id 是唯一键,INSERT ... SELECT ... ON DUPLICATE KEY UPDATE 是原子操作,失败即表示已被抢走。
- 不能把锁逻辑放在事务里——否则连接断开或异常回滚会导致锁丢失,但业务可能已部分执行
- 释放锁必须用
DELETE FROM task_lock WHERE task_id = ? AND worker_id = ? AND expire_at > NOW(),防止误删别人持有的过期锁 - 避免用
GET_LOCK(),它绑定连接,连接池环境下完全不可控;也不要用SELECT FOR UPDATE加锁后延迟更新,窗口期就是漏洞
最易被忽略的一点:**跨行原子性从来不是靠“锁得多”实现的,而是靠“动作合并+条件前置+失败可判”来规避竞态**。哪怕只锁一行,只要后续所有修改都基于该行的最新快照(通过 WHERE 显式校验),就能守住业务原子性边界。











