1. 问题背景与现象分析
职称审批流程系统是我们团队负责的核心业务模块之一,最近在生产环境遇到了一个诡异的缓存问题:在高并发场景下,审批流程数据在Redis缓存中会出现"随机丢失"现象。具体表现为:
- 当用户同时发起新增(add)和更新(update)操作时
- Redis中最终只保留部分审批流程数据
- 有时是新增的3条数据,有时是更新的1条数据
- 问题出现完全随机,没有固定规律
这个问题最令人头疼的地方在于它的非确定性。作为开发者,我们最怕的就是这种"时好时坏"的问题,因为排查和复现都极其困难。
1.1 关键日志证据分析
通过仔细审查生产日志,我们发现了以下关键线索:
log复制2026-03-04 11:29:24 [XNIO-1 task-3] ... 开始请求 => POST /biz/proTitleFlow/save 参数: add ...
2026-03-04 11:29:24 [XNIO-1 task-1] ... 开始请求 => POST /biz/proTitleFlow/save 参数: update ...
# 并行执行数据库操作后,几乎同时刷新缓存
2026-03-04 11:29:27 [XNIO-1 task-1] INFO InitApprovalFlow - 开始初始化审批流程到Redis...
... 查询到 1 条流程明细数据
... 审批流程初始化完成,共加载 1 个流程,1 条明细
... 刷新审批流程缓存成功
2026-03-04 11:29:27 [XNIO-1 task-3] INFO InitApprovalFlow - 开始初始化审批流程到Redis...
... 查询到 3 条流程明细数据
... 审批流程初始化完成,共加载 4 个流程,3 条明细
... 刷新审批流程缓存成功
从日志中可以明显看出两个关键特征:
- 两个请求几乎同时完成了缓存初始化
- 每个请求在初始化缓存时,都只能看到自己操作的数据,而看不到对方操作的数据
- 最终缓存被后完成的请求覆盖,导致数据不完整
2. 根因深度剖析
2.1 业务逻辑层的竞态条件
我们先从最上层的业务逻辑开始分析。每个保存请求的核心处理流程如下:
- 接收save请求
- 开启事务
- 执行INSERT/UPDATE操作
- 删除Redis缓存前缀
- 查询数据库全量流程数据
- 写入Redis缓存
- 提交事务
在并发场景下,这个流程会出现典型的竞态条件问题:
- 请求A(update):删缓存 → 查数据(仅看到自身变更) → 写缓存(1条明细)
- 请求B(add):删缓存 → 查数据(仅看到自身变更) → 写缓存(3条明细)
- 最终结果:最后执行"写缓存"的请求会覆盖前者,导致缓存数据不完整
2.2 Spring事务的行为陷阱
问题看似简单,但为什么每个事务只能看到自己的变更,而看不到并发事务的变更呢?这就要深入理解Spring事务和MySQL的事务隔离机制了。
在我们的代码中,saveBatch方法标注了@Transactional注解,这意味着:
- 事务边界:方法内所有数据库操作共享同一事务和JDBC连接
- 线程绑定:Spring将JDBC连接绑定到当前线程,即使跨Bean调用(如
initApprovalFlow.init()),查询仍属于当前事务 - 隔离级别:默认采用MySQL InnoDB的
REPEATABLE READ(可重复读) - 可见性限制:事务内的查询仅能看到"历史已提交数据 + 本事务自身变更",无法看到并发事务未提交的变更
这里的关键结论是:事务内执行"缓存初始化查询",本质是读取事务快照而非数据库最新状态,这必然导致查询结果不完整。
2.3 MySQL MVCC机制详解
要真正理解这个问题,我们需要深入MySQL InnoDB的MVCC(多版本并发控制)机制。在REPEATABLE READ隔离级别下,MVCC的核心规则如下:
| 操作类型 | 可见性 | 说明 |
|---|---|---|
| 自身未提交的写 | 可见 | 事务内可看到自己插入/更新的数据 |
| 其他事务未提交的写 | 不可见 | 即使对方已执行SQL但未提交,当前事务仍读取不到 |
| 其他事务已提交的写 | 不可见(事务内) | 同一事务内多次查询结果一致,不会读取到事务启动后的其他提交数据 |
简单来说:事务内的普通SELECT是"一致性读",读取的是事务启动时的快照,而非实时数据。这也是为什么请求A的初始化查询看不到请求B刚插入的3条明细。
3. 解决方案设计与实现
3.1 设计原则
基于上述分析,我们制定了以下解决方案设计原则:
- 缓存刷新脱离事务边界:确保能读取到所有已提交的完整数据
- 串行化缓存刷新过程:避免并发覆盖问题
- 缩短缓存空窗期:减少读取到空/不完整数据的风险
3.2 具体实现方案
3.2.1 将缓存刷新移至事务提交后
我们使用Spring的TransactionSynchronizationManager注册事务提交后的回调:
java复制@Transactional(rollbackFor = Exception.class)
public void saveBatch(ProTitleApprovalFlowDTO dto) {
// 1. 执行数据库新增/更新逻辑
if ("add".equals(dto.getOperationType())) {
proTitleApprovalFlowMapper.insertBatch(dto.getFlowList());
} else if ("update".equals(dto.getOperationType())) {
proTitleApprovalFlowMapper.updateBatch(dto.getFlowList());
}
// 2. 注册事务提交后刷新缓存的回调
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
// 事务提交后执行缓存刷新
initApprovalFlowService.refreshCache();
}
});
}
这个方案的关键点在于:
- 缓存刷新操作被移到了事务提交之后
- 确保刷新时能读取到所有已提交的数据
- 如果事务回滚,则不会执行缓存刷新
3.2.2 分布式锁串行化缓存刷新
为了防止多个事务提交后并发刷新缓存,我们使用Redisson实现分布式锁:
java复制@Resource
private RedissonClient redissonClient;
public void refreshCache() {
// 定义分布式锁,锁名与缓存业务绑定
RLock lock = redissonClient.getLock("approval:flow:refresh:lock");
try {
// 尝试获取锁(最多等待5秒,持有10秒)
boolean locked = lock.tryLock(5, 10, TimeUnit.SECONDS);
if (!locked) {
log.warn("获取缓存刷新锁失败,跳过本次刷新");
return;
}
// 执行缓存刷新逻辑(删+查+写)
log.info("开始初始化审批流程到Redis...");
// 先删除旧缓存
redisTemplate.delete("approval:flow:details");
// 查询全量已提交数据
List<ProTitleApprovalFlowVO> flowList = proTitleApprovalFlowMapper.listAll();
// 写入新缓存
redisTemplate.opsForValue().set("approval:flow:details", JSON.toJSONString(flowList));
log.info("审批流程初始化完成,共加载 {} 个流程", flowList.size());
} catch (InterruptedException e) {
log.error("获取缓存刷新锁被中断", e);
Thread.currentThread().interrupt();
} finally {
// 释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
这个实现有几个关键考虑:
- 锁的粒度要适中 - 我们选择与业务绑定的锁名
- 设置合理的等待时间和持有时间
- 确保锁一定会被释放,防止死锁
- 获取锁失败时要有降级处理
3.2.3 双Key原子切换优化
为了解决"删缓存→写缓存"过程中的空窗期问题,我们实现了双Key切换策略:
java复制public void refreshCacheWithDoubleKey() {
RLock lock = redissonClient.getLock("approval:flow:refresh:lock");
try {
if (!lock.tryLock(5, 10, TimeUnit.SECONDS)) {
return;
}
// 1. 先写入临时Key
String tempKey = "approval:flow:details:temp";
redisTemplate.opsForValue().set(tempKey, JSON.toJSONString(flowList));
// 2. 原子切换到正式Key(删除旧Key + 重命名临时Key)
redisTemplate.delete("approval:flow:details");
redisTemplate.rename(tempKey, "approval:flow:details");
// 3. 清理可能的残留临时Key
redisTemplate.delete(tempKey);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
这种方案的优点是:
- 几乎消除了缓存空窗期
- 切换过程是原子的,不会出现中间状态
- 即使刷新过程中出现异常,也只会影响临时Key
3.3 验证与监控
为了确保解决方案的有效性,我们设计了以下验证方案:
- 并发验证:使用JMeter模拟10次并发add/update请求,检查Redis缓存数据量与数据库是否一致
- 时序验证:通过日志确认缓存刷新确实发生在事务提交之后
- 锁机制验证:检查日志确保同一时间只有一个线程在执行缓存刷新
- 数据一致性验证:对比审批权限判定、页面展示数据与数据库记录
我们还增加了监控指标:
- 缓存刷新成功率
- 缓存刷新延迟
- 锁等待时间
- 缓存与数据库一致性检查定时任务
4. 经验总结与避坑指南
4.1 高并发缓存刷新的核心原则
通过这次问题的解决,我们总结了以下核心原则:
- 事务内不做缓存全量刷新:这是问题的根源,务必在事务提交后执行
- 并发刷新必须加锁:无论是分布式锁还是本地锁,必须确保串行化
- 缩短缓存空窗期:优先使用双Key原子切换等方案
- 监控与告警:对缓存一致性建立完善的监控体系
4.2 不同场景下的解决方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 事务内刷新 | 实时性高 | 读取未提交数据,并发覆盖 | 低并发、无竞态场景 |
| 提交后+分布式锁 | 数据一致,无覆盖 | 少量性能损耗,有刷新延迟 | 高并发核心业务 |
| 双Key原子切换 | 空窗期极短 | 实现稍复杂 | 对缓存可用性要求极高的场景 |
4.3 性能优化建议
在实际应用中,我们还发现了一些可以进一步优化的点:
- 增量刷新:对于大数据量的缓存,可以考虑增量刷新而非全量刷新
- 异步刷新:对于非关键路径,可以使用消息队列异步刷新缓存
- 本地缓存:结合Caffeine等本地缓存,减少Redis访问压力
- TTL策略:为缓存设置合理的过期时间,作为最后一道防线
5. 技术深度思考
这个问题的本质其实是分布式系统的一致性问题。在CAP理论的框架下,我们必须在一致性、可用性和分区容错性之间做出权衡。我们的解决方案选择了强一致性,通过锁机制牺牲了一定的可用性。
另一个深层次的思考是关于事务边界的设计。现代分布式系统往往需要重新思考传统的事务模型,在某些场景下,事件溯源、Saga模式等可能更适合。
最后,我想强调的是,理解底层原理的重要性。只有深入理解了MySQL的MVCC机制,才能真正理解这个问题的本质并找到正确的解决方案。这也是为什么我一直鼓励团队成员要"知其然,更要知其所以然"。