并发更新冲突是数据库开发里绕不开的坑。典型场景是:库存表只剩一件商品,两个用户同时下单,两个事务都读到库存为1,各自判断够扣减,然后分别执行UPDATE,最终库存变成负数。又或者后台管理系统中,运营A和运营B同时编辑同一条商品信息,A先保存,B后保存,A修改的字段被B的旧数据悄悄覆盖,这种问题叫丢失更新,等发现时往往已经造成实际损失。本文就来系统地聊聊这类问题怎么处理。

为什么会发生并发更新冲突
要理解冲突的根源,得先看数据库的隔离机制。在MySQL默认的REPEATABLE READ隔离级别下,事务读取到的数据是一致性快照。也就是说,事务A在9点开启时读到的库存是10,那么即使其他事务在这期间把它改成5,A在这个事务里读到的依然是10。快照读保证了读的一致性,但也意味着你基于旧数据做的判断,在真正提交时可能已经不成立了。
更关键的是,普通的UPDATE语句本身不会校验数据有没有被别人改过。你执行UPDATE product SET stock = stock - 1 WHERE id = 1时,数据库只会机械地执行减一操作,它不知道你在应用层曾经读过stock=10并基于这个值做过业务判断。所以如果应用层的逻辑是先SELECT读出来,在代码里判断,再UPDATE写回一个计算后的值,中间这一段时间窗口就是冲突的温床。
还有一种容易被忽视的情况是字段级覆盖。比如A修改了商品的标题,B修改了商品的价格,两个人各自提交了一个包含全部字段的UPDATE语句,结果B把A改的标题又改回旧值了。这不是数据库层面的并发问题,而是应用层的更新粒度太粗导致的,但同样属于并发更新冲突的范畴,解决思路也离不开下面要讲的锁机制。
方案一:用乐观锁避免丢失更新
乐观锁的核心假设是:冲突发生的概率不高,所以不在读取时加锁,而是在更新提交的那一刻校验数据有没有被别人动过。最常用的实现是版本号机制:表里加一个version字段,每次更新时版本号加一,同时UPDATE语句的WHERE条件里带上读取时的版本号。如果数据在这期间被别的事务改过,版本号对不上,这条UPDATE会影响0行,应用层据此感知到冲突并做重试或报错。
首先给表加上版本字段:
ALTER TABLE product ADD COLUMN version INT NOT NULL DEFAULT 0;
读取和更新的完整流程如下:
-- 第一步:读取数据,记下当前版本号
SELECT stock, version FROM product WHERE id = 1;
-- 假设读到 stock = 10, version = 3
-- 第二步:更新时校验版本号,只有版本没变才允许更新
UPDATE product
SET stock = 9,
version = version + 1
WHERE id = 1
AND version = 3;
-- 如果上面这条语句影响行数为 0,说明数据已被其他事务修改,
-- 应用层需要重新读取数据并重试整个流程
也可以用时间戳代替版本号,原理完全一样,但版本号是纯整数比较,性能更好,而且不怕时钟回拨,实践中更推荐版本号方案。如果不想加字段,某些场景下可以用业务字段本身做校验,比如UPDATE product SET stock = 9 WHERE id = 1 AND stock = 10,这其实是条件更新的一种简化形式,适合字段本身就能判断新旧的场景。
乐观锁的优点很明显:数据库层面没有真正的锁开销,读操作完全不受影响,吞吐量高。缺点是冲突频繁时应用层要不断重试,反而降低性能,所以它适合读多写少、冲突概率低的业务,比如用户资料编辑、后台内容管理等。写重试逻辑时要注意设置最大重试次数,避免在高并发下打转。另外,扣库存这种热点行场景,如果用乐观锁配合重试,失败率可能高得离谱,这时就该考虑悲观锁了。
方案二:用悲观锁直接锁住数据行
悲观锁的思路相反:它假设冲突一定会发生,所以读取数据时就直接加锁,别的事务想读这行数据的锁定版本都得排队等着。在MySQL和PostgreSQL中,标准做法是在SELECT语句后面加上FOR UPDATE,这样读到的行会被加上排他锁,直到事务提交或回滚才释放。
-- 事务一:锁定要修改的行 BEGIN; SELECT stock FROM product WHERE id = 1 FOR UPDATE; -- 这一行被当前事务独占,其他事务的 FOR UPDATE 查询会阻塞等待 -- 在应用层判断库存是否充足 UPDATE product SET stock = stock - 1 WHERE id = 1; COMMIT;
使用FOR UPDATE有几个必须注意的点。第一,它必须在事务中才生效,如果没有显式开启事务,单条语句的锁随语句结束就释放了,起不到保护作用。第二,WHERE条件尽量命中主键或唯一索引,如果走了全表扫描,在InnoDB下可能把扫描过的行都锁住,甚至升级为锁表,严重拖垮并发性能。第三,锁的粒度要控制好,只锁真正要修改的行,不要在一个大事务里锁定大量数据长时间不放。
悲观锁适合写冲突频繁的场景,典型例子就是秒杀扣库存。因为冲突概率高,乐观锁的重试成本反而更大,直接锁行排队虽然牺牲了一点吞吐,但逻辑简单清晰,不会有失败重试的复杂度。需要注意的是,等待锁的事务如果长时间拿不到锁会超时报错,所以要合理设置锁等待超时时间,MySQL里可以通过innodb_lock_wait_timeout参数调整,默认50秒,线上业务一般建议调低一些,快速失败快速重试比长时间阻塞更健康。
两种方案怎么选以及其他补充手段
选型的核心依据是冲突概率和性能要求的权衡。简单总结:读多写少、冲突概率低,用乐观锁;写多冲突频繁、数据一致性要求极高,用悲观锁。下面这个表格可以帮你快速判断:
| 对比维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 实现方式 | 版本号或条件更新,无真实锁 | SELECT FOR UPDATE 加排他锁 |
| 读性能 | 不受影响 | 锁定读会阻塞其他锁定读 |
| 写冲突处理 | 失败后应用层重试 | 数据库排队等待 |
| 适用场景 | 后台编辑、用户资料等低冲突业务 | 库存扣减、余额支付等高冲突业务 |
| 主要风险 | 高并发下重试风暴 | 锁等待超时、死锁 |
除了这两种主流方案,还有一些补充手段值得一提。其一是原子更新,也就是把业务判断完全下推到SQL里,让数据库单条语句完成读和写的原子操作,比如UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock >= 1,通过影响行数判断是否成功,这其实是数据库内部的行锁在起作用,天然避免了应用层的竞态窗口,特别适合简单的增减类操作。其二是提高事务隔离级别到SERIALIZABLE,让数据库自动把读操作加共享锁,理论上能杜绝丢失更新,但并发性能损失太大,一般不推荐在互联网业务中使用。其三是热点数据缓冲方案,比如秒杀场景把库存放到Redis里用原子操作扣减,异步落库,从根本上绕开了单行热点锁竞争。
最后提醒一点,悲观锁使用中要警惕死锁。两个事务分别以不同顺序锁定两行数据就可能互相等待。虽然InnoDB有死锁检测机制会主动回滚其中一个事务,但应用层最好保证加锁顺序一致,比如统一按主键升序加锁,从源头减少死锁发生的概率。乐观锁则要注意重试的幂等性,重试前必须重新读取最新数据再计算,而不是拿旧值反复提交。掌握了这些原则,绝大多数并发更新冲突问题都能妥善解决。