欢迎光临
我们一直在努力

实战复盘:审批流程缓存并发覆盖?从事务与MVCC视角拆解根因与修复

在高并发场景下,缓存与数据库的一致性问题极易暴露,尤其是涉及“事务内刷新缓存 + 并发写入”的场景,往往会出现数据覆盖、读取未提交数据等非确定性异常。本文将复盘一次职称审批流程缓存并发覆盖问题,从业务现象、事务行为、MySQL底层MVCC机制三个维度拆解根因,并给出可落地的解决方案。

一、问题现象:缓存数据“随机丢失”的诡异现象

1. 核心异常表现

调用 /biz/proTitleFlow/save 接口维护聘任职称审批流程时,出现以下异常:

  • 触发条件:短时间内并发调用(1次新增 add + 1次更新 update);
  • 现象:Redis缓存中最终只保留部分审批流程数据(有时是新增的3条,有时是更新的1条);
  • 非确定性:结果完全依赖两个请求的执行时序,无固定规律。

2. 关键日志证据

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 条明细
… 刷新审批流程缓存成功

日志核心特征:

  • 两次缓存初始化几乎同时完成;
  • 第一次初始化仅读取到1条明细(未看到并发事务的未提交数据);
  • 第二次初始化读取到更多数据并覆盖前者,最终缓存数据不完整。

二、根因拆解:从业务到底层的三层分析

1. 业务逻辑层:并发刷新的竞态覆盖

每个保存请求的核心逻辑是:

#mermaid-svg-DH3VxRJoZDDR4WZY{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DH3VxRJoZDDR4WZY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DH3VxRJoZDDR4WZY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DH3VxRJoZDDR4WZY .error-icon{fill:#552222;}#mermaid-svg-DH3VxRJoZDDR4WZY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DH3VxRJoZDDR4WZY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DH3VxRJoZDDR4WZY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DH3VxRJoZDDR4WZY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DH3VxRJoZDDR4WZY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DH3VxRJoZDDR4WZY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DH3VxRJoZDDR4WZY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DH3VxRJoZDDR4WZY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DH3VxRJoZDDR4WZY .marker.cross{stroke:#333333;}#mermaid-svg-DH3VxRJoZDDR4WZY svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DH3VxRJoZDDR4WZY p{margin:0;}#mermaid-svg-DH3VxRJoZDDR4WZY .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-DH3VxRJoZDDR4WZY .cluster-label text{fill:#333;}#mermaid-svg-DH3VxRJoZDDR4WZY .cluster-label span{color:#333;}#mermaid-svg-DH3VxRJoZDDR4WZY .cluster-label span p{background-color:transparent;}#mermaid-svg-DH3VxRJoZDDR4WZY .label text,#mermaid-svg-DH3VxRJoZDDR4WZY span{fill:#333;color:#333;}#mermaid-svg-DH3VxRJoZDDR4WZY .node rect,#mermaid-svg-DH3VxRJoZDDR4WZY .node circle,#mermaid-svg-DH3VxRJoZDDR4WZY .node ellipse,#mermaid-svg-DH3VxRJoZDDR4WZY .node polygon,#mermaid-svg-DH3VxRJoZDDR4WZY .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DH3VxRJoZDDR4WZY .rough-node .label text,#mermaid-svg-DH3VxRJoZDDR4WZY .node .label text,#mermaid-svg-DH3VxRJoZDDR4WZY .image-shape .label,#mermaid-svg-DH3VxRJoZDDR4WZY .icon-shape .label{text-anchor:middle;}#mermaid-svg-DH3VxRJoZDDR4WZY .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DH3VxRJoZDDR4WZY .rough-node .label,#mermaid-svg-DH3VxRJoZDDR4WZY .node .label,#mermaid-svg-DH3VxRJoZDDR4WZY .image-shape .label,#mermaid-svg-DH3VxRJoZDDR4WZY .icon-shape .label{text-align:center;}#mermaid-svg-DH3VxRJoZDDR4WZY .node.clickable{cursor:pointer;}#mermaid-svg-DH3VxRJoZDDR4WZY .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DH3VxRJoZDDR4WZY .arrowheadPath{fill:#333333;}#mermaid-svg-DH3VxRJoZDDR4WZY .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DH3VxRJoZDDR4WZY .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DH3VxRJoZDDR4WZY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DH3VxRJoZDDR4WZY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DH3VxRJoZDDR4WZY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DH3VxRJoZDDR4WZY .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DH3VxRJoZDDR4WZY .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DH3VxRJoZDDR4WZY .cluster text{fill:#333;}#mermaid-svg-DH3VxRJoZDDR4WZY .cluster span{color:#333;}#mermaid-svg-DH3VxRJoZDDR4WZY div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DH3VxRJoZDDR4WZY .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DH3VxRJoZDDR4WZY rect.text{fill:none;stroke-width:0;}#mermaid-svg-DH3VxRJoZDDR4WZY .icon-shape,#mermaid-svg-DH3VxRJoZDDR4WZY .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DH3VxRJoZDDR4WZY .icon-shape p,#mermaid-svg-DH3VxRJoZDDR4WZY .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DH3VxRJoZDDR4WZY .icon-shape rect,#mermaid-svg-DH3VxRJoZDDR4WZY .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DH3VxRJoZDDR4WZY .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DH3VxRJoZDDR4WZY .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DH3VxRJoZDDR4WZY :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

接收save请求

开启事务

执行INSERT/UPDATE

删除Redis缓存前缀

查询数据库全量流程数据

写入Redis缓存

提交事务

并发时的竞态过程:

  • 请求A(update):删缓存 → 查数据(仅看到自身变更) → 写缓存(1条明细);
  • 请求B(add):删缓存 → 查数据(仅看到自身变更) → 写缓存(3条明细);
  • 最终结果:最后执行“写缓存”的请求覆盖前者,导致缓存数据不完整。

2. 事务层:Spring @Transactional的行为陷阱

核心行为分析
  • 事务边界:saveBatch 标注 @Transactional,方法内所有数据库操作共享同一事务和JDBC连接;
  • 线程绑定:Spring将JDBC连接绑定到当前线程,即使跨Bean调用(如initApprovalFlow.init()),查询仍属于当前事务;
  • 隔离级别:默认采用MySQL InnoDB的REPEATABLE READ(可重复读);
  • 可见性限制:事务内的查询仅能看到“历史已提交数据 + 本事务自身变更”,无法看到并发事务未提交的变更。
关键结论

事务内执行“缓存初始化查询”,本质是读取事务快照而非数据库最新状态,必然导致查询结果不完整。

3. MySQL底层:MVCC与一致性读的底层约束

InnoDB的MVCC(多版本并发控制)在REPEATABLE READ隔离级别下的核心规则:

操作类型可见性说明
自身未提交的写 可见 事务内可看到自己插入/更新的数据
其他事务未提交的写 不可见 即使对方已执行SQL但未提交,当前事务仍读取不到
其他事务已提交的写 不可见(事务内) 同一事务内多次查询结果一致,不会读取到事务启动后的其他提交数据

简单来说:事务内的普通SELECT是“一致性读”,读取的是事务启动时的快照,而非实时数据。这也是为什么请求A的初始化查询看不到请求B刚插入的3条明细。

三、解决方案:从根源上消除竞态与数据不一致

设计原则

  • 缓存刷新脱离事务边界,确保读取到已提交的完整数据;
  • 串行化缓存刷新过程,避免并发覆盖;
  • 缩短缓存空窗期,减少读取到空/不完整数据的风险。
  • 具体落地方案

    步骤1:将缓存刷新移至事务提交后

    通过Spring的TransactionSynchronizationManager注册事务提交后的回调,确保刷新时能读取到所有已提交数据:

    // ProTitleApprovalFlowServiceImpl.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();
    }
    });
    }

    步骤2:分布式锁串行化缓存刷新

    使用Redisson实现分布式锁,确保同一时间只有一个线程执行缓存刷新:

    // InitApprovalFlowService.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;
    }

    // 3. 执行缓存刷新逻辑(删+查+写)
    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:增强日志便于问题溯源

    在刷新逻辑中增加关键节点日志:

    // 刷新开始
    log.info("缓存刷新触发源:操作类型={},流程数={}", operationType, flowCount);
    // 跳过刷新
    log.warn("缓存刷新跳过:已有刷新任务执行中,锁等待超时");
    // 刷新完成
    log.info("缓存刷新完成:Redis key=approval:flow:details,数据量={}", flowList.size());

    可选优化:双Key原子切换缩短空窗期

    为解决“删缓存→写缓存”过程中读取到空数据的问题,可采用双Key切换策略:

    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();
    }
    }
    }

    四、验证与风险评估

    1. 验证要点

    • 并发验证:模拟10次并发add/update请求,检查Redis缓存数据量与数据库一致;
    • 时序验证:确认缓存刷新日志出现在事务提交日志之后;
    • 锁机制验证:日志中仅能看到一个线程获取锁执行刷新,其他线程跳过;
    • 数据一致性:审批权限判定、页面展示数据与数据库完全一致。

    2. 风险与回滚策略

    风险点影响回滚策略
    事务提交后刷新延迟 短时间内缓存与数据库不一致 临时关闭异步刷新,恢复事务内刷新(应急)
    分布式锁不可用 缓存刷新失败 手动调用刷新接口:POST /biz/proTitleFlow/refreshCache
    双Key切换异常 临时Key残留 增加定时任务清理以:temp结尾的Key

    五、总结:高并发缓存刷新的核心原则

    1. 核心避坑要点

    • 事务内不做缓存全量刷新:避免读取未提交快照,务必在事务提交后执行;
    • 并发刷新必须加锁:分布式场景下使用分布式锁,单机场景下使用本地锁;
    • 缩短缓存空窗期:优先使用双Key原子切换,避免“删→写”过程中读取到空数据。

    2. 通用解决方案对比

    方案优点缺点适用场景
    事务内刷新 实时性高 读取未提交数据,并发覆盖 低并发、无竞态场景
    提交后+分布式锁 数据一致,无覆盖 少量性能损耗,有刷新延迟 高并发核心业务
    双Key原子切换 空窗期极短 实现稍复杂 对缓存可用性要求极高的场景

    3. 底层认知提升

    理解MySQL InnoDB的MVCC机制是解决此类问题的关键:

    • REPEATABLE READ下的一致性读是“快照读”,而非“实时读”;
    • 事务内只能看到自身变更和已提交的历史数据;
    • 普通SELECT不加锁,不会阻塞并发写,容易放大竞态问题。

    最后

    缓存与数据库的一致性问题,本质是“并发”与“事务隔离”的博弈。解决这类问题的核心不是追求“绝对实时”,而是在“一致性”“性能”“复杂度”之间找到平衡。本次复盘的方案既保证了数据一致性,又通过锁机制避免了并发覆盖,是高并发场景下缓存刷新的通用解决方案。

    赞(0)
    未经允许不得转载:171主机测评 » 实战复盘:审批流程缓存并发覆盖?从事务与MVCC视角拆解根因与修复
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址