1. 审批状态“倒带”了:现象、复现和第一反应
1.1 诡异状态:同意之后又变回审批中
在生产环境干过审批流的老哥应该都遇到过这种诡异场景:明明数据库里的审批单已经走完,业务方却拿着截图说状态是“审批中”,再次点同意又提示“该单据已被处理”。我接手这个项目时,第一反应是缓存过期时间设置不对,但把 Redis 缓存无脑失效后,问题依旧在特定并发窗口下复现。最后从事务和 MVCC 视角把整条链路拉通才发现,真正作恶的不是缓存本身,而是缓存更新时机和数据库快照读取叠加出来的并发覆盖。
这个项目本身不算复杂:一个 Spring Boot 服务,MySQL 存储单据,Redis 缓存审批链路的中间状态,MyBatis 作为 ORM 框架。审批实例表wf_instance大概长这样:
CREATE TABLE wf_instance ( id BIGINT NOT NULL COMMENT '主键', biz_type VARCHAR(32) NOT NULL COMMENT '业务类型', current_node VARCHAR(32) NOT NULL COMMENT '当前节点', status TINYINT NOT NULL COMMENT '0草稿 1审批中 2通过 3驳回 4撤销', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_biz_type_status (biz_type, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批实例表';并发覆盖的场景一般发生在多人同时处理同一张审批单时:两个人同时在各自的工作台看到“待审批”,A 点击通过,B 点击驳回。按业务预期,后提交的一方应当失败,或者先提交的生效、后提交的给出“已被处理”的提示。但生产上是另一副面孔:数据库状态一会儿是通过,一会儿是审批中,界面上的按钮和列表状态对不上。
1.2 用 JMeter 把并发窗口打出来
为了复现,我用 JMeter 起了 5 个线程模拟 5 个用户同时针对同一个审批实例操作。接口动作分别是“通过”和“驳回”,每次请求都带上审批实例 ID 和当前节点的审批意见。脚本跑了几轮之后,数据果然开始乱:
- 同一张审批单,通过接口返回成功,驳回接口也返回成功。
- 最终数据库里
status的值取决于最后一个提交的事务,而不是最后一个点击的人。 - 更隐蔽的是,有些请求在事务尚未提交、Redis 已更新时就被后一个线程读到了,读到的又是旧状态。
- 还有一类请求,查询走的 MyBatis 缓存或 Redis 缓存,读出来是“审批中”,但数据库实际已经是“通过”。
当时第一反应是并发量不够大,调线程数到 20、50,问题复现概率反而下降,因为高并发下请求排队多了,事务碰撞后直接报锁等待超时,反而掩盖了逻辑覆盖。真正致命的是低并发、间隔几百毫秒到几秒的“温和并发”——这个窗口下两个事务都能提交成功,却把一个状态字段来回踩。这个现象本身就是信号:问题不在锁竞争,而在更新条件和缓存读写机制上。
1.3 第一次定位时走的弯路
一开始定位方向是数据库锁。我一度以为有锁竞争,随后抓了SHOW ENGINE INNODB STATUS,也启用了performance_schema里的锁等待记录,结果发现LOCK WAIT占比并不高。真正提交成功的两个事务之间,没有出现互等的锁冲突。
又怀疑是 Redis 缓存更新时序,于是把所有写缓存的操作都调整到业务方法最后一行,问题还是存在。后来我才意识到,缓存更新在“方法最后一行”不等于“事务提交之后”。Spring 的@Transactional方法,事务提交发生在方法返回之后,由代理对象统一处理。业务代码里最后一行写的缓存,其实还在事务内——这时候数据库根本没提交,其他事务如果读到缓存,就会拿到一个“未来状态”,而后续操作基于这个状态去做数据库更新,自然就乱了。
同样耗了不少时间的是 MyBatis 一级缓存和二级缓存。二级缓存默认没开,一级缓存作用范围是 SqlSession,Spring 每次请求都新建 SqlSession 的话,一级缓存影响有限。但一旦事务方法内部同一个 Mapper 方法调用多次,比如先查询再更新再查询,一级缓存会直接命中,返回的是当前事务内第一次查到的旧值。当时不少诡异的状态回退就是它贡献的。
2. 把事务边界和 MVCC 快照放到同一张图里看
2.1 事务隔离级别不同,看到的世界就不同
要理解这个覆盖问题,得先回到数据库事务的实际执行机制。MySQL InnoDB 默认隔离级别是 Repeatable Read,PG 默认是 Read Committed。两者对“事务内读取到的快照”定义完全不同。
在 Repeatable Read 下,事务内第一次普通SELECT会生成一个一致性读视图(read view),整个事务生命周期内,后续普通SELECT都基于同一个视图。也就是说,事务启动后,即便其他事务提交了新状态,当前事务通过普通查询看到的仍然是最初那个版本。这个机制就是 MVCC 的核心之一。
Read Committed 则不同,每条语句执行前都会生成新的 read view,因此事务内多次查询可能看到不同版本的数据。很多开发同学以为“反正用 Read Committed/PG,就能看到最新提交”,这只对了一半——它是每条语句重新生成快照,不是每条查询都实时读到未提交数据,更不等于没有旧版本读取问题。
回到审批场景。假设事务 T1 在 10:00:00 开始时读取status=1,事务 T2 在 10:00:01 提交status=2。在 Repeatable Read 下,T1 后续所有查询看到的仍然是自己事务开启时那个版本,即status=1。如果 T1 在 10:00:02 执行UPDATE wf_instance SET status=3 WHERE id=123,它不会检查当前数据库里已经被 T2 更新成了 2,而是直接把整行设置为 3。这就是典型的丢失更新,本质是 write skew 的一种形态。
2.2 数据库版本链与快照读取的真相
InnoDB 每一行数据背后都有一条版本链。行记录里有隐藏的DB_TRX_ID,记录最近一次修改该行的事务 ID;还有DB_ROLL_PTR,指向 undo log 中的上一个版本。执行UPDATE时,InnoDB 会生成新版本,并把旧版本链接到 undo log 上。
普通查询通过 read view 判断可见性时,会沿着版本链向后找,直到看到一个符合规则的版本。这意味着,即使某一行已经被并发事务改成status=2,只要当前事务的 read view 认为产生那个修改的事务不可见,它找到的可能还是status=1的旧版本。这个机制本身是为了并发读性能,但副作用也很明显:基于旧快照做“判断 + 写入”的组合操作时,不加保护就会覆盖他人成果。
很多人在分析并发覆盖时只盯着缓存,忽略了数据库侧的 MVCC 也在放行旧值。其实缓存只是把旧状态提前暴露给了更多请求,真正允许覆盖发生的是数据库更新语句没有追加任何条件判断。两条路径叠加,形成了“读旧、写旧、盖新”的完整闭环。
2.3 为什么慢事务更容易踩中这个坑
从实践数据看,触发覆盖的事务往往具备一个共同特征:事务内有耗时的远程调用或者批量处理。常见情况是审批通过后需要调用外部系统同步数据,同步接口响应慢,拖长了事务的存活时间。事务活得越久,其他并发事务完成提交的概率越大,当前事务基于旧快照做更新的可能性也就越高。
我还遇到过一种情况:项目里用了读写分离,主库负责写,从库负责读。页面列表展示走从库,详情接口走主库。主从同步延迟几百毫秒时,业务人员看到的审批状态和实际状态错位,再加上缓存又存了一份状态,三层数据源各有各的时间点,状态不一致被成倍放大。这类问题如果不先把数据库视角理清楚,很容易被误判成简单的缓存覆盖。
3. 缓存写错了位置:从调用链里找到覆盖的真正时点
3.1 缓存更新放在 commit 前,等于对外广播了一个没提交的状态
这是整个排查过程中最值得复盘的一段。最初的业务代码写着:
@Transactional public void approve(Long id, String operator) { WfInstance instance = wfInstanceMapper.selectById(id); // 省略前置校验 instance.setStatus(WfStatus.APPROVED); wfInstanceMapper.updateById(instance); redisTemplate.opsForValue().set("wf:instance:" + id, instance); }表面上看起来没毛病:先改库,再写缓存。但事务提交发生在方法返回之后,redisTemplate.set执行时数据库事务还没有 commit。如果此时另一个线程发起驳回操作,它读取 Redis 时发现状态已经是“通过”,于是拿着这个并未持久化的状态去做了业务判断,返回“该单据已通过,不能驳回”。
更麻烦的是另一个分支:如果这个线程读到的缓存状态是“审批中”,但数据库实际已经是“通过”,它在事务内部又做了一次updateById,MySQL 的写操作会在主键上对行加排他锁,等前一个事务提交后,后一个事务才有可能执行更新。虽然行锁保证了最终只有一个事务能修改这行,但更新条件是updateById,它只关注主键,不关注状态字段,于是后提交的事务会用自己事务里读到的旧状态覆盖前一个事务的成果。
3.2 MyBatis 一级缓存和二级缓存带来的二次污染
这个项目里,二级缓存默认没开,但一级缓存的问题被隐藏得很深。MyBatis 一级缓存是 SqlSession 级别的本地缓存,默认开启。Spring 整合 MyBatis 后,每个事务通常会绑定一个 SqlSession,事务方法内同一个查询如果连参数都一样,第二次执行会直接命中一级缓存,不会发起数据库查询。
这就出现了一个隐蔽场景:事务 T1 先查询selectById(123),得到status=1,然后其他事务把数据库状态改成了 2,T1 因为 Repeatable Read 快照看不到这个变更,所以再次selectById(123)时,一级缓存命中,依然返回status=1。此时如果用updateById把状态改成“驳回”,因为更新语句只有主键条件,不会校验当前行的真实状态,覆盖就真实发生了。
有人会说,一级缓存不跨请求,影响没那么大。但在这个项目里,大量状态的判断不是直接查库,而是走了一个ApprovalStatusService,它先查 Redis,Redis 不存在再查库,查库后又写回 Redis。缓存链一长,旧值被反复搬运,最终覆盖问题的根因就散落在三处:数据库事务快照、MyBatis 一级缓存、Redis 缓存。
3.3 完整覆盖时序还原
把两个并发请求拉通,问题全貌是这样:
- 用户 A 发起“通过”,事务 T1 开始,读取实例 ID=123,状态为 1(审批中)。
- 用户 B 发起“驳回”,事务 T2 开始,读取实例 ID=123,状态为 1。
- T1 执行业务逻辑,调用外部系统成功后,把 Redis 缓存更新为
status=2,然后提交数据库。 - T1 提交成功,数据库状态变为 2。
- T2 此时因为 MVCC 快照,普通查询看到的仍是状态 1。它把 Redis 缓存更新为
status=3,再执行UPDATE wf_instance SET status=3 WHERE id=123。 - T2 提交成功,数据库状态被覆盖成 3(驳回),但用户 A 看到的界面还是“通过”。
- 列表接口读 Redis,发现状态是 3;详情接口查库也显示 3,两边一致,但业务语义已经错了——正确的行为应当是 T2 不能执行成功,因为审批单已经被 T1 处理了。
这个时序里,每一步单独看都“正常”:T1 和 T2 都基于自己看到的状态做事,数据库也没有死锁,两个事务都提交成功。问题在于更新语句没有把“状态”纳入条件,也没有使用版本号,形成了典型的 last-write-wins 覆盖。
4. 根因收敛:不是“并发覆盖”三个字能讲完的
4.1 并发覆盖的三种模式
排查到这里,我把并发覆盖拆成了三种模式:
第一种是缓存盲写覆盖。两个事务都把状态写进 Redis,后写的覆盖先写的,但后写不代表业务上后提交,因为缓存更新时机和事务提交时机没有对齐。
第二种是数据库盲更新覆盖。两个事务都读到status=1,各自执行UPDATE ... SET status=2/3 WHERE id=123。更新本身没有冲突检测,后提交的覆盖先提交的,属于典型的丢失更新。
第三种是状态机跳跃覆盖。审批单从“审批中”只能流转到“通过/驳回/撤销”,不应该出现“通过”变回“审批中”,但盲更新让它出现了。状态机没有真正落地为数据库约束,应用层也没有拦截。
这三种模式各自有对应的解法:缓存要在事务提交后失效或更新;数据库更新要带乐观锁条件;状态流转要在入口处做事件校验。只修其中一环都不够。
4.2 为什么悲观锁只解决了部分问题
排查过程中也有人提出SELECT ... FOR UPDATE或分布式锁一把梭的方案。加锁确实能把并发互斥掉,让 T2 等 T1 提交后再继续,但审批流里有很多操作不是只有同一把锁能覆盖的:
FOR UPDATE只能锁数据库行,不一定锁得住 Redis 缓存里的状态。- 分布式锁要覆盖所有操作路径,成本高,而且容易影响吞吐。
- 审批流程里还有“超时自动通过”“撤回重新提交”等后台任务,它们如果不走同一把锁,锁就形同虚设。
- 如果操作频繁且耗时,比如审批通过后调用外部系统同步,锁等待时间会非常长,最终损伤的是用户体验。
最终我们没有选择悲观锁作为主要手段,而是保留它作为数据库层面的兜底。真正解决问题的是一套组合拳:先让缓存不再提前暴露未提交状态,再让数据库更新具备冲突检测能力,最后在应用层做状态机校验。
4.3 修复原则:让每一步都可验证、可回退
修复的总原则是“用条件更新替代盲更新,用缓存失效替代缓存覆盖”。核心有三条:
- 任何写操作必须基于“当前数据库真实状态”做条件更新,而不是基于应用层读到的历史状态。
- 任何缓存更新不能早于数据库提交,最稳妥的方式是事务提交后直接删除缓存,下次读取时再回填。
- 状态变更必须符合预设流转规则,非法流转直接拒绝。
这三条原则看起来不复杂,但落到代码层面需要动手术。下面展开讲。
5. 修复落地:缓存失效时机、乐观锁、状态流转校验
5.1 从“先写缓存再提交”改为“提交后失效缓存”
第一处手术是缓存策略。业务方法中不再主动更新 Redis,改为在事务提交成功后删除缓存:
@Transactional public void approve(Long id, String operator) { WfInstance instance = wfInstanceMapper.selectById(id); // 校验当前状态是否允许审批通过 assertStatusTransition(instance, WfStatus.APPROVED); int updated = wfInstanceMapper.updateStatusIfVersion(id, WfStatus.APPROVED, instance.getStatus(), instance.getVersion()); if (updated == 0) { throw new BizException("审批单状态已变化,请刷新后重试"); } // 注册事务提交后的回调 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { String key = "wf:instance:" + id; redisTemplate.delete(key); } }); }为什么用“删除”而不是“更新”?因为删除简单可靠,下次读取时从数据库加载最新值再回填;更新则需要保证写入值就是数据库提交后的真实状态,而这个值在事务里很难提前拿到,尤其在更新涉及多个字段时。
事务提交后删除缓存的写法,依赖 Spring 的事务同步机制。registerSynchronization注册的回调,会在事务真正提交后执行。如果事务回滚,afterCommit不会触发,缓存不会被污染。如果项目里没有使用 Spring 事务管理器,也可以在主事务提交后显式清理缓存,但那样容易漏代码,还是用事务同步最省心。
这里要特别强调:不是所有项目都适合“提交后删缓存”。如果并发极高,删除缓存后瞬间涌入大量请求都去查库,可能打爆数据库。常见缓解手段是给缓存加非常短的过期时间,比如 1 到 3 秒,再配合删除操作。或者在删除之前把“新状态”写进一个短 TTL 的临时 key,读不到主 key 时从临时 key 过渡。不过审批流这个场景并发远没有秒杀那么夸张,直接删缓存就能撑住。
5.2 给审批单加上乐观锁与状态条件更新
第二处手术是数据库更新语句。原来updateById改成带版本号和状态条件的更新:
@Update("UPDATE wf_instance SET status = #{newStatus}, version = version + 1 " + "WHERE id = #{id} AND status = #{oldStatus} AND version = #{oldVersion}") int updateStatusWithLock(@Param("id") Long id, @Param("oldStatus") Integer oldStatus, @Param("newStatus") Integer newStatus, @Param("oldVersion") Integer oldVersion);核心逻辑是让数据库在更新前校验旧状态和版本号。如果另一个事务已经提交,status或version不匹配,影响行数为 0,应用层就抛异常,让用户重新查询最新状态。
为什么版本号和状态条件都要?因为状态条件能阻止状态机跳跃,比如“已通过”被更新成“审批中”;版本号能识别同状态下的重复更新,比如两个人都把“审批中”改成“通过”,第一个提交成功把版本从 0 变成 1,第二个提交时版本还是 0,更新失败,避免重复审批通过。
实际项目中,如果字段比较多,可以只更新需要变更的字段,不要让整个对象参与更新。这样能减少锁范围和 undo log 开销,也能避免把别的事务修改的字段误覆盖。
这里还要补充两点:第一,UPDATE语句本身会加行锁,两个并发事务同时更新同一行时,后执行的一方会等待,但等待结束后会因为版本号不匹配而返回 0,不会覆盖;第二,UPDATE语句读到的status是数据库当前真实值,不依赖应用的查询快照,天然规避了 MVCC 带来的旧读问题。这也是建议用条件更新而不是“先查再比”的原因。
5.3 状态流转校验:把非法跨步骤操作挡在入口
数据库条件更新能挡住覆盖,但应用层还是要给出明确提示,不能让用户看到“系统异常”。我在 Service 层加了一个状态流转换表:
private static final Map<Integer, Set<Integer>> TRANSITIONS = new HashMap<>(); static { TRANSITIONS.put(WfStatus.DRAFT, Set.of(WfStatus.PENDING)); TRANSITIONS.put(WfStatus.PENDING, Set.of(WfStatus.APPROVED, WfStatus.REJECTED, WfStatus.CANCELED)); TRANSITIONS.put(WfStatus.APPROVED, Set.of(WfStatus.CANCELED)); TRANSITIONS.put(WfStatus.REJECTED, Set.of(WfStatus.PENDING)); } private void assertStatusTransition(WfInstance instance, Integer targetStatus) { Set<Integer> allowed = TRANSITIONS.get(instance.getStatus()); if (allowed == null || !allowed.contains(targetStatus)) { throw new BizException("当前状态不允许执行该操作"); } }这个转换表根据实际业务定制。比如“驳回”后可以“重新提交”,“撤销”后可以“重新发起”,不同企业审批语义不一样,但要保证一点——状态不能跳跃到任意目标。配合数据库条件更新,形成双重校验。
边界情况也要考虑到。比如“重新提交”操作,如果审批单已经被归档,就不能再回到审批中;比如审批人之前已经驳回,再次提交需要更新节点信息并重置审批人,这些业务动作和状态校验要放在同一个事务里,不能拆开。
5.4 问题数据的订正脚本
生产环境已经出现脏数据,除了改代码,还要把错误状态修正回来。我写了一个只读的巡检脚本,找出“当前状态和操作记录状态不一致”的审批单,输出到告警平台,再由运维确认后修正。
SELECT i.id, i.status, a.operator, a.target_status, a.create_time FROM wf_instance i JOIN wf_approval_record a ON a.instance_id = i.id WHERE a.id = (SELECT MAX(a2.id) FROM wf_approval_record a2 WHERE a2.instance_id = i.id) AND i.status <> a.target_status AND a.create_time > DATE_SUB(NOW(), INTERVAL 1 DAY);这个 SQL 找出最后一次操作状态和当前状态不一致的实例。修正动作要谨慎,我会先备份,再按业务语义设置正确的最终状态。数据订正不是核心手段,但能帮业务方先把眼前的问题解决掉。
6. 上线后的验证与复盘经验
6.1 JMeter 并发验证结果
修复完成后,我重新用 JMeter 压了一轮。场景还是 5 个用户并发操作同一张审批单,但这次请求分布做了调整:50% 的操作是“通过”,50% 是“驳回”,每个线程执行 100 次,中间加 200ms 到 1s 的随机思考时间。
压测结果符合预期:
- 两条并发请求同时到达时,只有一个返回成功,另一个返回“审批单状态已变化,请刷新后重试”。
- 数据库
status不会出现从“通过”回退到“审批中”的情况。 - Redis 缓存中不会出现未提交的中间状态,事务提交后缓存被正确清理。
- 跑了 30 分钟,没有一次死锁,没有一次状态错误。
有一点需要留意:并发压测时有几个请求因为行锁等待出现了Lock wait timeout exceeded。这是因为UPDATE ... WHERE id=? AND status=? AND version=?在并发极高时,后一个事务会等待前一个事务的锁释放。如果前一个事务里有外部调用导致持锁时间过长,等待会超时。我在压测里把外部调用放到了事务外,或者改成异步回调,才彻底解决这类问题。
6.2 从一次故障里提炼的四条经验
这次经历给我的收获,远超“修好一个 bug”本身。第一条经验是缓存操作必须绑定事务生命周期,写缓存不是简单的“放在方法最后一行”就完事,而是要考虑数据库提交点。提交后失效缓存,虽然多一次查库,但换来的是强一致性,值得。
第二条经验是数据库更新必须有竞争检测。没有version和status条件,任何并发控制都是空谈。这也是很多老项目改不动的原因——历史代码全是updateById,想加条件发现影响面太大。但再难也得改,尤其是核心状态字段。
第三条经验是排查并发问题不能只盯锁。当时如果只去看死锁日志,问题永远找不到。锁是互斥机制,MVCC 是版本读取机制,缓存是加速机制,三者叠加之后的行为,不能靠直觉推断,得画时序图、打日志、一步步还原。
第四条经验是状态机要显式建模。审批流这种业务,天然是状态机,如果不把允许的流转规则写清楚,任何状态都可能被写入。加一个转换表,维护成本极低,却能挡住大量非法操作。
6.3 如果你遇到类似问题,建议按这个顺序排查
如果你们的系统也出现缓存和数据库状态不一致,我的建议是按照这个顺序排查:
第一,先看数据库更新语句是否带条件。不带条件的updateById先修正,这是根子上的问题。
第二,看缓存更新是否在事务提交之后。把业务方法里的缓存写操作改成事务提交后的缓存删除,或者至少保证缓存写入值来自数据库提交后的结果。
第三,看事务内是否有耗时操作。外部调用、消息发送、批量计算,全部挪到事务外,缩短事务持锁时间。
第四,看是否存在多路径读取。列表接口走从库、详情接口走主库、缓存中间还可能插一脚,多个数据源的短暂不一致会被业务感知为状态错乱。
第五,如果并发量真的很高,再考虑分布式锁、Redis 原子操作等措施。但不要一开始就上锁,锁解决的是互斥问题,解决不了条件覆盖。
6.4 关于分布式事务和缓存的延伸思考
很多团队在碰到订单、库存、审批这类场景时,想把缓存和数据库的一致性交给“分布式事务”来解决。这次实践让我更加确认一点:分布式事务不是万能的,它解决的是跨服务、跨数据源的一致性问题,而审批流这种单服务内的事务,核心是控制好数据库更新和缓存的时效性。
如果后续服务拆分,审批模块需要通过消息通知下游业务系统,我会优先考虑本地消息表或事务发件箱模式:在本地事务里写业务数据和待发送消息,事务提交后再把消息发到 MQ,消费方做幂等处理。这样做能避免“本地事务成功、MQ 发送失败”带来的数据不一致,也比强一致分布式事务简单可靠。
另外,高并发下缓存设计还要考虑穿透、击穿、雪崩的问题。肯德基能承受的并发不等于审批流需要承受的并发,量级不同,方案选型也不同。别为了炫技引入一堆中间件,能用数据库条件更新解决的,优先在数据库层解决;能用缓存失效解决的,别做复杂的缓存双写。
回到这次审批流故障,最根本的一点就是:状态是最核心的业务数据,任何地方都不应该脱离数据库约束去独立演进。缓存是影子,影子只能跟随实体,不能反过来定义实体。把这个原则想明白了,以后遇到类似的“并发覆盖”问题,就不会再靠猜了。