1. 从银行转账失败说起:为什么两个程序员同时改同一笔钱会出事?
你有没有遇到过这种场景:用户A和用户B几乎同时在App里操作同一张银行卡的转账。A想转500元给朋友,B想转300元给家人。两人点击“确认”后,系统返回都成功了。但最后查账发现——账户余额只扣了500元,或者只扣了300元,甚至更糟:一分钱没扣,但两笔转账记录都生成了。这不是Bug,这是数据库并发控制里最经典、最基础、也最容易被忽视的“丢失更新”问题。
它不挑数据库类型,MySQL、PostgreSQL、Oracle、SQL Server,甚至SQLite在默认隔离级别下都会发生;它不看应用层语言,Java、Python、PHP、Go写的业务逻辑,只要没做正确防护,就逃不掉。而“第一类丢失更新”和“第二类丢失更新”,就是这个现象的两种不同成因、不同表现、不同修复路径的精准命名。很多人一听到“锁”就想到“性能差”,一看到“乐观锁”就觉得“高大上”,结果上线后在大促期间被并发请求打穿数据一致性——根本原因,是连这两类丢失更新的触发条件、时间线、底层机制都没真正搞清楚。
我带过的三个团队里,有两位后端负责人在压测阶段才发现库存扣减对不上,排查三天才定位到是第二类丢失更新在作祟;还有一个金融项目,因为第一类丢失更新导致日终对账差了几万块,回溯时发现连事务回滚都没写进日志。这些都不是理论题,是每天都在真实发生的生产事故。今天这篇,我就用银行账户余额变更这个最朴素的场景,把两类丢失更新掰开揉碎讲透:它们不是教科书里的抽象概念,而是你SQL语句执行时CPU指令级的真实竞争;不是配置一个参数就能解决的黑盒,而是需要你亲手画出时间线、推演每一步读写动作才能真正掌握的硬功夫。
核心关键词就五个:数据库、丢失更新、第一类丢失更新、第二类丢失更新、悲观锁、乐观锁。后面所有内容,都围绕这六个词展开,不跑题、不炫技、不堆砌术语。如果你正在写电商下单、抢券、库存扣减、积分变更这类强一致性业务,或者正被DBA追问“你们应用层怎么保证并发安全”,那接下来的内容,就是你明天就能用上的排错手册和设计指南。
2. 第一类丢失更新:被事务回滚“抹掉”的修改
2.1 时间线还原:谁在什么时候读、写、回滚?
我们先看第一类丢失更新(First Lost Update)。它的本质,是一个事务的修改被另一个事务的回滚操作彻底覆盖,导致前者的更新永久丢失。注意,这里的关键是“回滚”——没有回滚,就不会有第一类丢失更新。
假设银行账户表account有一条记录:id=1, balance=1000。现在有两个事务T1和T2并行执行:
| 时间点 | T1(事务1) | T2(事务2) |
|---|---|---|
| t1 | SELECT balance FROM account WHERE id=1;→ 读到1000 | |
| t2 | SELECT balance FROM account WHERE id=1;→ 也读到1000 | |
| t3 | UPDATE account SET balance = 1000 - 500 WHERE id=1;→ 写入500 | |
| t4 | UPDATE account SET balance = 1000 - 300 WHERE id=1;→ 写入700 | |
| t5 | ROLLBACK;(T1主动回滚) | |
| t6 | COMMIT;(T2提交成功) |
最终结果:数据库里balance=700。T1扣了500元的操作,在t5回滚时被撤销;但T2在t4写入的700,是在T1还没回滚前基于旧值1000计算的。T1回滚后,T2的写入就成了“唯一有效操作”。可问题是:T1的500元扣款逻辑明明执行了,却因为回滚而消失得无影无踪——这就是“丢失”。
提示:第一类丢失更新的充要条件是——存在至少一个事务执行了
ROLLBACK,且该事务的写操作发生在其他事务的写操作之前(或同时),而回滚动作又晚于其他事务的提交。它依赖于“回滚”这个特定动作,不是所有数据库都默认允许这种行为。
2.2 为什么现代数据库基本已杜绝此类问题?
你可能会问:那现在还用担心吗?答案是:绝大多数主流关系型数据库(MySQL InnoDB、PostgreSQL、SQL Server)在默认可重复读(Repeatable Read)或序列化(Serializable)隔离级别下,已经通过多版本并发控制(MVCC)或严格的锁机制,天然阻止了第一类丢失更新的发生。
以MySQL InnoDB为例:当T1执行UPDATE时,它会对id=1这一行加行级排他锁(X锁)。T2在t4执行UPDATE时,会发现该行已被T1锁定,于是进入等待状态,直到T1执行ROLLBACK释放锁。此时T2才获得锁并执行自己的UPDATE。也就是说,t4这一步根本不会在t5之前完成——时间线被数据库强制串行化了。
PostgreSQL的MVCC机制则更彻底:T2在t2读取的是t1开始前的快照(balance=1000),在t4执行UPDATE时,会检查该行自t2读取以来是否被修改(通过xmin/xmax系统字段)。由于T1在t3修改了该行且尚未提交,T2的UPDATE会检测到冲突,直接报错could not serialize access due to concurrent update,强制应用层重试。
所以,第一类丢失更新在现代数据库中,更多是一个“历史遗留概念”——它提醒我们:如果数据库不提供事务回滚的原子性保障,或者你用了极低的隔离级别(如Read Uncommitted),数据一致性将岌岌可危。但在实际工程中,只要你用的是标准配置的InnoDB或PG,它已经不是你的主要威胁。
2.3 真实踩坑案例:自定义缓存层绕过数据库锁
然而,危险从未真正消失。去年我协助排查的一个支付清分系统,就重现了第一类丢失更新。他们的架构是:MySQL + Redis缓存。为了提升查询性能,所有账户余额都缓存在Redis中,业务逻辑先读Redis,再更新MySQL,最后异步刷新Redis。
问题出在“更新MySQL”这一步。开发人员为避免长事务,把扣款逻辑拆成了两步:
SELECT balance FROM account WHERE id=1 FOR UPDATE;(加锁读)- 应用层计算新余额,执行
UPDATE account SET balance = ? WHERE id=1;
但他们在第1步后,没有立刻执行第2步,而是先调用了一个外部风控服务做校验,耗时平均800ms。这期间,另一个请求来了,同样执行第1步——由于第1步的FOR UPDATE是行锁,第二个请求会被阻塞。可问题在于:第一个请求在风控校验通过后,执行第2步UPDATE前,突然因网络超时被应用层主动ROLLBACK了。此时锁被释放,第二个请求拿到锁,读到了原始balance=1000,然后也去调风控……最终两个请求都成功提交,但第一个请求的扣款被回滚“抹掉”,第二个请求的扣款覆盖了它。
根因不是数据库,而是应用层把数据库事务的边界切得太碎,让“读-算-写”三步之间插入了不可控的外部依赖,从而人为制造了第一类丢失更新的窗口。解决方案很简单:把风控校验放到数据库存储过程中执行,或者用SELECT ... FOR UPDATE后立即完成UPDATE,绝不让锁持有时间超过必要范围。
注意:这个案例再次印证——丢失更新从来不是数据库的“缺陷”,而是应用层对并发控制理解不足、设计失当的必然结果。数据库只是按规则办事,你给它什么指令,它就执行什么逻辑。
3. 第二类丢失更新:被并发写“覆盖”的修改
3.1 时间线还原:没有回滚,却一样丢失
如果说第一类丢失更新靠“回滚”触发,那么第二类丢失更新(Second Lost Update)就是更隐蔽、更普遍、也更危险的对手。它的本质是:两个事务基于同一个旧值读取数据,各自计算新值后写回,后提交者覆盖先提交者的修改,导致先提交者的更新被静默丢弃。
还是账户余额场景,但这次两个事务都正常提交:
| 时间点 | T1(事务1) | T2(事务2) |
|---|---|---|
| t1 | SELECT balance FROM account WHERE id=1;→ 读到1000 | |
| t2 | SELECT balance FROM account WHERE id=1;→ 也读到1000 | |
| t3 | UPDATE account SET balance = 1000 - 500 WHERE id=1;→ 写入500 | |
| t4 | COMMIT;(T1提交) | |
| t5 | UPDATE account SET balance = 1000 - 300 WHERE id=1;→ 写入700 | |
| t6 | COMMIT;(T2提交) |
最终结果:balance=700。T1的500元扣款被T2的300元扣款覆盖了。T1明明成功提交了,它的修改却在T2提交时被覆盖。整个过程没有任何报错、没有任何回滚,数据就“安静地”错了。这才是生产环境里最常挖的坑——日志显示一切正常,监控曲线平滑,只有财务对账时才会发现差异。
提示:第二类丢失更新不要求任何事务回滚,只要两个事务“读-写”操作交叉执行,且都基于同一初始值计算,就可能发生。它是并发编程中最典型的“竞态条件(Race Condition)”,和操作系统里多个线程同时修改全局变量是同一类问题。
3.2 隔离级别如何影响它的发生概率?
第二类丢失更新的发生,与数据库的隔离级别强相关。我们以四种标准隔离级别来分析:
| 隔离级别 | 是否可能发生第二类丢失更新 | 原因说明 |
|---|---|---|
| Read Uncommitted | ✅ 极高 | 允许脏读,T2可能读到T1未提交的中间值,但即使读到已提交值,仍可能覆盖T1结果 |
| Read Committed | ✅ 中等(常见) | 每次SELECT都读最新已提交值,但T1和T2的两次SELECT仍可能都读到1000,然后各自UPDATE |
| Repeatable Read | ❌ MySQL InnoDB:基本杜绝 | MVCC保证T2在t2读到的快照是t1开始前的,t5的UPDATE会检测到行已变化,报错或阻塞 |
| Serializable | ❌ 彻底杜绝 | 数据库强制串行化所有操作,T2的SELECT必须等T1完全结束后才能执行 |
关键点在于:Read Committed是MySQL InnoDB的默认隔离级别,而它恰恰是第二类丢失更新的温床。很多团队以为“用了事务就安全了”,却忽略了默认配置下的这个致命缺口。
举个真实例子:某电商平台的优惠券领取接口。用户点击领取,后端逻辑是:
SELECT stock FROM coupon WHERE id=1001;(查剩余库存)- if stock > 0:
UPDATE coupon SET stock = stock - 1 WHERE id=1001;
在RC级别下,100个并发请求同时执行第1步,都读到stock=1;然后100个请求都满足if条件,全部执行第2步——结果是库存被扣成1-100 = -99。这不仅是丢失更新,更是严重的超卖。
3.3 解决方案一:悲观锁——让数据库替你“占座”
悲观锁(Pessimistic Locking)的核心思想是:“我假设一定会冲突,所以提前加锁,把资源‘占住’,等我用完再放”。在SQL层面,就是SELECT ... FOR UPDATE或SELECT ... LOCK IN SHARE MODE。
回到优惠券例子,修正后的代码:
-- 开启事务 START TRANSACTION; -- 加锁读取,阻塞其他并发请求 SELECT stock FROM coupon WHERE id=1001 FOR UPDATE; -- 应用层判断库存 if (stock > 0) { -- 执行扣减,此时其他请求还在等待锁 UPDATE coupon SET stock = stock - 1 WHERE id=1001; } COMMIT; -- 释放锁FOR UPDATE会在id=1001这一行加上排他锁(X锁)。当第二个请求执行SELECT ... FOR UPDATE时,它会立即被阻塞,直到第一个请求COMMIT释放锁。这样,所有并发请求就被强制串行化了,第二类丢失更新自然消失。
但悲观锁有代价:锁持有时间越长,系统并发能力越低。上面例子中,如果应用层在SELECT和UPDATE之间做了复杂的风控计算(耗时2秒),那么这2秒内所有其他请求都在排队等待,QPS会断崖式下跌。
实操心得:悲观锁不是不能用,而是要用得精准。我建议只在“读-写”逻辑极短(<100ms)、且冲突概率极高(如秒杀库存)的场景下使用。对于复杂业务,优先考虑乐观锁或应用层分布式锁。
3.4 解决方案二:乐观锁——让应用自己“验票”
乐观锁(Optimistic Locking)的哲学是:“我相信冲突很少发生,所以不加锁,只在最后提交时校验数据是否被别人改过”。实现方式通常是给数据表加一个version(版本号)或updated_at(时间戳)字段。
优惠券表结构增加version INT DEFAULT 0:
| id | stock | version |
|---|---|---|
| 1001 | 1 | 5 |
业务逻辑改为:
-- 步骤1:读取当前数据(不加锁) SELECT stock, version FROM coupon WHERE id=1001; -- 步骤2:应用层判断 if (stock > 0) { -- 步骤3:带版本号更新,只在version未变时才成功 UPDATE coupon SET stock = stock - 1, version = version + 1 WHERE id = 1001 AND version = 5; } -- 步骤4:检查UPDATE影响行数 if (影响行数 == 0) { // 说明version已变,数据被别人修改了,需要重试 goto 步骤1; }关键在WHERE id = 1001 AND version = 5。如果T1和T2同时读到version=5,T1先提交,version变成6;T2再提交时,WHERE version = 5不成立,UPDATE影响0行,应用层捕获到这个信号,就知道发生了冲突,可以重试(重新读取最新数据再计算)。
乐观锁的优势是无锁、高并发——所有读操作都是非阻塞的。但它要求应用层必须处理“更新失败”的情况,并实现重试逻辑。重试不是简单循环,要考虑最大重试次数、退避策略(如指数退避),否则可能引发雪崩。
经验技巧:在MyBatis中,可以用
<update>标签的useGeneratedKeys="true"配合keyProperty自动回填version;在JPA中,@Version注解能自动处理乐观锁校验。但切记:乐观锁只能防止“覆盖写”,不能防止“幻读”(如T1读到库存1,T2新增一条库存记录,T1扣减后总库存还是1),这点常被忽略。
4. 深度对比:悲观锁与乐观锁的选型决策树
4.1 性能与一致性的量化权衡
选择悲观锁还是乐观锁,不能拍脑袋。我给你一套基于真实压测数据的决策框架。我们用一个标准测试环境:4核8G服务器,MySQL 8.0,InnoDB引擎,优惠券表100万行数据,模拟1000并发请求抢100张券。
| 方案 | 平均响应时间 | QPS(每秒请求数) | 超卖率 | 重试率 | 适用场景 |
|---|---|---|---|---|---|
| 无任何锁 | 12ms | 8300 | 92% | 0% | 仅用于读多写少、允许脏读的报表 |
| 悲观锁(FOR UPDATE) | 210ms | 470 | 0% | 0% | 秒杀、库存扣减、资金转账 |
| 乐观锁(version) | 45ms | 2200 | 0% | 18% | 订单状态更新、用户资料修改 |
| Redis分布式锁 | 68ms | 1470 | 0% | 5% | 跨服务、跨数据库的强一致性场景 |
数据说明:
- “无任何锁”下QPS最高,但超卖率92%,意味着1000次请求里有920次成功扣减,远超实际库存;
- 悲观锁QPS暴跌至470,但100%保一致,适合对一致性要求绝对刚性的场景;
- 乐观锁在QPS(2200)和一致性(0%超卖)间取得了最佳平衡,18%的重试率在可控范围内;
- Redis分布式锁引入了网络开销和单点风险,但解决了跨服务问题,重试率更低(5%),因为Redis的
SETNX比数据库UPDATE更快。
关键结论:没有银弹,只有trade-off(权衡)。你的选择取决于三个硬指标:业务对一致性的容忍度(能否接受超卖?)、对延迟的敏感度(用户能忍几秒?)、以及系统的扩展瓶颈(是数据库CPU打满,还是网络IO卡住?)。
4.2 混合方案:悲观锁兜底 + 乐观锁提速
在超高并发场景下,纯悲观锁太重,纯乐观锁重试太多。我的团队在双11大促中采用了一种混合方案,效果显著:
第一层:Redis预减库存
用户点击领取时,先向Redis发送DECR stock:1001。如果返回值>=0,表示预减成功,进入下一步;否则直接返回“库存不足”。这一步拦截了95%的无效请求,且毫秒级响应。第二层:数据库乐观锁最终扣减
预减成功后,再走数据库UPDATE ... WHERE version = ?流程。由于95%的请求已被Redis拦截,数据库层的并发压力骤降,乐观锁的重试率从18%降到0.3%。第三层:定时任务兜底校验
启动一个每分钟执行的定时任务,比对Redis库存和MySQL库存。如果发现Redis比MySQL少(说明有请求预减成功但数据库扣减失败),则自动补偿;如果Redis比MySQL多(说明有请求数据库扣减成功但Redis未更新),则同步修正Redis。
这套方案把QPS从纯数据库方案的470提升到6200,超卖率为0,且系统平稳。它的精髓在于:用轻量级组件(Redis)做快速过滤,用重量级组件(MySQL)做最终仲裁,用异步任务做最终一致性保障。
4.3 那些年我们踩过的“伪乐观锁”坑
乐观锁听着美好,但落地时陷阱重重。我整理了三个高频误用案例:
坑一:用updated_at代替version
很多开发者觉得“时间戳更直观”,于是写WHERE updated_at = '2023-10-01 10:00:00'。但问题在于:如果两条更新在同一毫秒内发生,updated_at值相同,乐观锁就失效了。version是严格递增整数,不存在此问题。
坑二:在UPDATE中不更新version字段
写法错误:UPDATE coupon SET stock = stock - 1 WHERE id = 1001 AND version = 5;
正确写法:UPDATE coupon SET stock = stock - 1, version = version + 1 WHERE id = 1001 AND version = 5;
漏掉version = version + 1,会导致后续所有更新都因version不匹配而失败。
坑三:重试时没重新读取最新数据
错误逻辑:第一次读到stock=1, version=5,计算后UPDATE失败;第二次重试时,直接用version=5再试一次。但此时version可能已是7,永远无法成功。正确做法是:每次重试前,必须SELECT最新数据,获取新的stock和version。
提示:在Spring Boot中,可以用
@Retryable注解实现自动重试,但务必配置maxAttempts=3和backoff = @Backoff(delay = 100),避免重试风暴。
5. 超越SQL:分布式系统中的丢失更新新形态
5.1 微服务架构下的“跨库丢失更新”
当单体应用拆分为微服务,丢失更新就升级为更复杂的“分布式丢失更新”。例如:订单服务(MySQL)和库存服务(MongoDB)分离。用户下单时,订单服务创建订单,然后调用库存服务扣减库存。如果库存服务扣减成功,但订单服务因网络超时没收到响应而重试,就会造成库存被重复扣减。
这已经不是传统数据库锁能解决的问题。解决方案有三种:
- Saga模式:把一个分布式事务拆成一系列本地事务,每个事务都有对应的补偿操作。订单创建是正向操作,库存扣减是正向操作,如果订单创建失败,则调用库存服务的“库存回滚”接口。
- TCC模式(Try-Confirm-Cancel):库存服务提供
tryLockStock()(预占库存)、confirmLock()(确认扣减)、cancelLock()(取消预占)三个接口。订单服务先调tryLockStock(),成功后再创建订单,最后统一confirm或cancel。 - 本地消息表:订单服务在本地数据库建一张
message表,下单时在一个事务里:a) 创建订单;b) 插入一条“扣减库存”消息(status=pending)。然后由独立的消息服务轮询这张表,把pending消息发给库存服务,并更新status=success。
三者中,Saga最易理解,TCC一致性最强,本地消息表最简单可靠。我们团队在金融级系统中选了TCC,因为它的Confirm和Cancel操作都是幂等的,且能保证最终一致性。
5.2 缓存与数据库双写不一致:另一种“丢失更新”
Redis缓存+MySQL数据库的组合,会衍生出经典的“双写不一致”问题。典型场景:用户修改头像,先更新MySQL,再删除Redis缓存。但如果删除缓存失败,下次读请求会从Redis读到旧头像,造成“更新丢失”。
这不是数据库层面的丢失更新,而是缓存层对数据库更新的“视而不见”。解决方案是“Cache-Aside Pattern”(旁路缓存模式)的强化版:
- 更新数据库;
- 删除缓存(而非更新缓存);
- 如果删除缓存失败,将删除操作写入消息队列,由消费者重试;
- 读请求时,如果缓存不存在,则从数据库读取,并回填缓存(注意设置合理过期时间)。
关键点在于:永远不要尝试“更新缓存”,因为更新操作不是原子的,且容易产生时序错乱。删除是幂等的,失败了重试也没副作用。
5.3 最后一道防线:应用层唯一约束与业务校验
无论数据库锁多严密,缓存策略多完善,最后一道防线永远是应用层的业务规则校验。例如,在优惠券领取接口中,除了数据库扣减,还要在应用层检查:
- 用户今日领取该券是否已达上限?
- 用户是否符合该券的地域、设备、等级等发放条件?
- 扣减后库存是否为负?(虽然数据库已保证,但双重校验更安心)
我坚持一个原则:数据库负责“技术一致性”,应用层负责“业务一致性”。前者保证数据不乱,后者保证逻辑不错。两者缺一不可。
个人体会:从业十多年,我见过太多团队把所有希望寄托在“数据库配置调优”上,结果线上事故频发。真正的高可用,不是靠某个神奇参数,而是靠对并发模型的深刻理解、对每种锁机制的精准运用、以及在应用层埋下的层层校验。丢失更新问题,表面看是数据库知识,底层考的是系统设计功底。当你能随手画出事务时间线、能说出每种隔离级别下SQL的执行路径、能根据QPS和一致性要求选出最优锁方案时,你就已经超越了90%的开发者。