SQL 并发更新冲突处理策略

2次阅读

应使用带预期旧值的update语句(如where id=123 and balance=500)实现乐观锁,若row_count()为0则说明数据被并发修改,需重试或报错;避免先查后更新、浮点数直接比对、滥用select for update,高并发任务分发优选skip locked,insert on duplicate key update中累加需用values(score),version字段推荐bigint防溢出。

SQL 并发更新冲突处理策略

UPDATE 时被其他事务抢改了数据,怎么避免覆盖

直接用 UPDATE 更新一行,不加条件检查,就容易让后提交的事务无声覆盖前一个事务的修改——比如两个请求同时给用户余额加 100,结果只加了一次。这不是数据库坏了,是没做并发控制。

最常用也最可控的方式,是在 WHERE 子句里带上“预期旧值”做校验:

UPDATE accounts SET balance = balance + 100 WHERE id = 123 AND balance = 500;

如果执行后 ROW_COUNT()mysql)或 pg_affected_rows()postgresql)返回 0,说明这行数据在你读完之后已被别人改过,当前更新失效,需要重试或报错。

  • 别只靠应用层“先查再更新”,查和更新之间存在时间窗口,中间可能被篡改
  • 如果字段类型是浮点数或有精度问题,避免直接比对原始值;可改用版本号(version)或时间戳(updated_at)字段
  • 使用 SELECT ... FOR UPDATE 能锁住行,但会阻塞其他事务,高并发下容易引发锁等待甚至死锁,不是万能解法

PostgreSQL 的 SELECT FOR UPDATE 和 SKIP LOCKED 怎么选

SELECT ... FOR UPDATE 是行级写锁,适合“读-改-写”强一致性场景,比如库存扣减;但它会让并发请求排队,吞吐上不去。

当你要从队列表(如任务队列)里安全取一条未处理的任务,并且多个 worker 同时拉取时,SKIP LOCKED 就很关键:

SELECT * FROM jobs WHERE status = 'pending' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;

它会让被其他事务锁住的行直接跳过,而不是等锁释放,避免 worker 卡住。

  • 没有 SKIP LOCKED 时,多个 FOR UPDATE 可能争同一行,造成无意义等待
  • SKIP LOCKED 只在 PostgreSQL 9.5+ 和 MySQL 8.0.1+ 支持,老版本只能靠应用层加分布式锁或乐观锁兜底
  • 不能和 GROUP BY、聚合函数混用;也不能在可重复读(RR)隔离级别下保证“绝对不重复取”,需配合状态字段二次校验

MySQL 的 INSERT ON DUPLICATE KEY UPDATE 真的安全吗

它常被用来做“存在则更新,不存在则插入”,看起来像原子操作,但只对 UNIQUEPRIMARY KEY 冲突生效,而且更新逻辑只触发一次——这点容易误判。

例如:INSERT INTO points (user_id, score) VALUES (123, 10) ON DUPLICATE KEY UPDATE score = score + 10; 表面上是“加 10”,但如果两条并发插入同时触发冲突,它们都读到旧值(比如 50),然后各自加 10,最终变成 60 而不是 70。

  • 这不是 bug,是语义限制:表达式里的 score 是语句开始时的快照值,不是实时值
  • 想真正累加,得用 VALUES(score) 引用 VALUES 子句中的值,或者改用 UPDATE ... WHERE + 乐观锁
  • 注意 ON DUPLICATE KEY UPDATE 会影响 AUTO_INCREMENT 计数器,即使没插入新行,自增 ID 也可能跳号

乐观锁 version 字段为什么经常被设成 BIGINT

因为 INT 最大值才 21 亿,高频更新表(比如订单状态变更每秒几百次)几个月就可能溢出;一旦 version 溢出变负数,后续所有更新都会因 WHERE version = ? 不匹配而失败。

  • BIGINT 不是为了“以后扩展”,而是防止真实生产中踩到整数溢出这个静默陷阱
  • 别把 version 当时间戳用——有人会写 version = UNIX_TIMESTAMP(),但这会导致并发更新时时间相同、校验失效
  • 如果业务允许,version 可以简化为布尔型(如 is_dirty),但仅适用于“只允许改一次”的极简场景,通用性差

并发更新不是非得选一种策略包打天下。实际项目里,账户余额用带旧值校验的 UPDATE,任务分发用 SKIP LOCKED,幂等写入用 INSERT ... ON DUPLICATE KEY UPDATE 配合 VALUES(),version 字段默认上 BIGINT——这些组合背后都是具体冲突模式和性能权衡。漏掉任意一环,线上就容易出现“数据莫名不对”这种最难复现的问题。

text=ZqhQzanResources