Redis 缓存与数据库谁先更新? - 详解

一、问题背景:为什么要纠结"谁先更新"?

在引入Redis缓存的系统中,数据通常存在于两个地方:

  1. 数据库(MySQL、PostgreSQL等)作为持久化数据源
  2. Redis缓存作为高性能的数据加速层

当业务需要修改数据时,必须同步更新这两个存储介质,否则会出现"缓存数据与数据库数据不一致"的问题。这种不一致性可能导致多种业务异常,例如:

  • 用户看到过期的旧数据
  • 订单金额计算错误
  • 库存显示不准确
  • 商品价格不一致等

典型场景示例:用户修改个人昵称

假设初始状态下,数据库和Redis中用户昵称均为"张三"。当用户将昵称改为"李四"时,不同的更新顺序会导致不同的问题:

  1. 先更新缓存,后更新数据库的情况:

    • 缓存已成功更新为"李四"
    • 数据库更新失败(如网络中断、数据库连接超时等)
    • 结果:缓存中是"李四",数据库仍是"张三",出现不一致
    • 影响:后续请求从缓存获取"李四",但实际数据库存储的是旧值
  2. 先更新数据库,后更新缓存的情况:

    • 数据库已成功更新为"李四"
    • 缓存更新失败(如Redis连接问题、内存不足等)
    • 结果:数据库是"李四",缓存仍是"张三",同样出现不一致
    • 影响:后续请求从缓存获取"张三",显示过期数据

业务场景的影响

数据一致性的重要程度与业务场景密切相关:

  • 高敏感场景:如支付系统、金融交易,必须保证强一致性
  • 一般敏感场景:如社交资讯、用户资料,可以接受短暂不一致
  • 低敏感场景:如商品浏览量统计,可以接受最终一致性

因此,在选择更新顺序策略时,需要综合考虑业务容错率、系统性能和实现复杂度等因素。

二、两种更新顺序的深度剖析:问题与风险

2.1 方案 1:先更新 Redis 缓存,再更新数据库

2.1.1 核心问题:数据库更新失败,导致缓存 "脏数据"

典型业务场景:电商系统商品库存更新、社交平台用户资料修改、金融系统账户余额变动等对数据一致性要求较高的场景。

流程拆解(以电商系统商品库存更新为例)

  1. 用户下单请求将商品A库存从100减到99
  2. 系统先更新Redis缓存:SET product:123:stock 99
  3. 系统再执行数据库更新:UPDATE products SET stock=99 WHERE id=123
  4. 若数据库更新失败(如主键冲突、死锁、连接超时等),此时:
    • 数据库实际库存仍为100
    • Redis缓存中库存显示为99
  5. 后续所有查询该商品库存的请求都会从Redis获取错误值99,可能导致超卖问题

数据不一致持续时间

  • 如果设置了TTL:直到缓存过期(可能几分钟到几小时)
  • 如果没有TTL:直到手动清除缓存或下次成功更新
2.1.2 延伸风险:并发场景下的 "数据覆盖"

多线程并发更新场景(以社交平台用户积分变更为例):

  • 初始状态:用户积分100
  • 线程A:增加50积分(预期150)
  • 线程B:扣除30积分(预期70)

异常执行时序

  1. 线程A更新Redis:积分=150
  2. 线程B更新Redis:积分=70
  3. 线程A更新数据库失败(回滚)
  4. 线程B更新数据库成功
  5. 最终状态:
    • 数据库:70(正确)
    • Redis:70(巧合一致)

更危险的时序

  1. 线程A更新Redis:积分=150
  2. 线程B更新Redis:积分=70
  3. 线程B更新数据库成功:70
  4. 线程A更新数据库失败
  5. 最终状态:
    • 数据库:70
    • Redis:70(但线程A的业务逻辑已认为操作失败)
2.1.3 解决方案尝试与局限

尝试方案

  1. 引入事务机制:
    • 将Redis和DB更新放入同一事务
    • 问题:Redis不支持真正的事务回滚
  2. 增加补偿机制:
    • 定期扫描数据库与缓存差异
    • 问题:实时性差,补偿期间业务已受影响

适用场景限制: 仅适合:

  • 非核心业务数据(如文章阅读量统计)
  • 可容忍最终一致性的场景
  • 短TTL配置(≤1分钟)的缓存项

2.2 方案 2:先更新数据库,再更新 Redis 缓存

2.2.1 核心问题:缓存更新失败

典型故障场景

  1. 数据库更新成功
  2. Redis集群出现以下情况之一:
    • 节点故障
    • 网络分区
    • 内存不足
    • 连接池耗尽

影响范围评估

  • 单条数据不一致:影响单个业务功能
  • 批量更新失败:可能影响整个业务模块
  • 持续时间:直到下次缓存更新或人工干预
2.2.2 并发读写场景问题详解

典型竞态条件时序

  1. 读请求R检查缓存命中(旧值V1)
  2. 写请求W更新数据库为新值V2
  3. 写请求W删除/更新缓存
  4. 读请求R将旧值V1写入缓存

恶化条件

  • 缓存刚好过期
  • 大量读请求涌入
  • 写操作较耗时

实际案例: 某电商大促期间:

  1. 商品价格从100→80
  2. 瞬间涌入10万次查询
  3. 约3%请求在缓存更新间隙读到旧价格
  4. 导致数千订单价格不一致投诉
2.2.3 优化方案对比
方案实现方式优点缺点
重试机制缓存更新失败后自动重试N次简单易实现增加延迟,重试期间仍不一致
异步队列通过消息队列异步处理缓存更新解耦主流程系统复杂度增加,消息可能丢失
双写校验更新后立即查询验证一致性高性能损耗大,无法防并发问题
延迟删除更新DB后延迟双删缓存减少竞态窗口延迟时间难确定

最佳实践推荐

  1. 对一致性要求极高的场景:
    • 采用分布式锁+版本号机制
    • 实现读写互斥
  2. 一般场景:
    • 先更新DB
    • 通过消息队列异步更新缓存
    • 设置合理的缓存TTL作为兜底

监控指标建议

  1. 缓存更新失败率
  2. DB与缓存不一致时长
  3. 缓存命中率波动
  4. 重试队列积压量

三、解决方案:从 "更新缓存" 到 "删除缓存",再到 "最终一致性"

3.1 方案3:先更新数据库,再删除Redis缓存(Cache-Aside Pattern)

这是目前最主流的方案,也称为"旁路缓存模式"。其核心逻辑是:不直接更新缓存,而是删除缓存中的旧数据,后续读请求发现缓存缺失时,再从数据库加载新数据并回写到缓存。

3.1.1 流程拆解(以用户改昵称为例):

  1. 业务服务请求处理

    • 前端发送请求:POST /users/1001/nickname {"nickname":"李四"}
    • 业务服务接收"昵称从张三改为李四"的请求
  2. 数据库更新阶段

    • 开启数据库事务
    • 执行SQL:UPDATE user SET nickname='李四' WHERE id=1001
    • 验证数据库更新结果(影响行数>0)
    • 提交事务
  3. 缓存删除阶段

    • 构建Redis键名:user:1001:nickname
    • 执行Redis命令:DEL user:1001:nickname
    • 记录操作日志(用于后续可能的补偿)
  4. 后续读请求处理

    • 客户端请求:GET /users/1001/nickname
    • 业务服务首先检查Redis缓存:GET user:1001:nickname
    • 发现Redis缓存缺失(key不存在)
    • 从数据库查询最新数据:SELECT nickname FROM user WHERE id=1001
    • 获取到新数据"李四"
    • 将数据写入Redis:SET user:1001:nickname "李四" EX 3600(设置1小时过期)
    • 返回响应给客户端

3.1.2 为什么"删除缓存"比"更新缓存"更好?

  1. 操作可靠性

    • 删除操作(DEL)是幂等的,无论key是否存在,执行结果一致
    • 更新操作需要处理数据格式转换(如对象转JSON),增加了失败风险
  2. 资源利用率

    • 示例:商品详情页缓存,可能包含HTML片段、JSON数据等多种格式
    • 直接更新需要维护所有可能的格式
    • 删除缓存后,后续请求按需重建,总是使用最新格式
  3. 并发场景下更安全

    • 避免写操作A和B的更新顺序问题
    • 如:A写DB→B写DB→B更新缓存→A更新缓存,最终缓存是A的旧数据
    • 删除策略不受更新顺序影响

3.1.3 潜在问题:缓存删除失败怎么办?

典型故障场景

  • Redis集群主节点宕机,正在切换从节点
  • 网络分区导致服务与Redis连接中断
  • Redis内存达到maxmemory限制,拒绝写入

解决方案1:立即重试机制

// 伪代码示例
int maxRetry = 3;
int retryCount = 0;
boolean success = false;
while (!success && retryCount < maxRetry) {
    try {
        redis.del(key);
        success = true;
    } catch (Exception e) {
        retryCount++;
        Thread.sleep(100); // 短暂等待
    }
}
if (!success) {
    // 转入异步补偿流程
    mq.send(new CacheDeleteMessage(key));
}

解决方案2:基于消息队列的异步补偿

消息格式示例(JSON):

{
  "eventId": "uuid-123456",
  "key": "user:1001:nickname",
  "firstFailTime": "2023-05-01T10:00:00Z",
  "retryCount": 0,
  "maxRetry": 5
}

消费者处理逻辑:

  1. 接收消息
  2. 尝试执行DEL操作
  3. 成功:记录日志并确认消息
  4. 失败:
    • 检查retryCount < maxRetry
    • 修改retryCount+1
    • 计算下次重试时间(指数退避算法)
    • 重新入队
  5. 达到最大重试后:触发告警(邮件/短信/钉钉)

3.1.4 并发场景的优化:设置缓存过期时间

最佳实践

  • 常规数据:设置5-30分钟过期(如EXPIRE key 300
  • 热点数据:设置较长时间(如24小时)+ 后台定期刷新
  • 敏感数据:设置较短时间(1-5分钟)+ 变更时主动删除

过期时间的影响

  • 太短:增加数据库负载
  • 太长:不一致持续时间延长
  • 建议:根据业务容忍度调整,如:
    • 用户昵称:5分钟
    • 商品价格:1分钟
    • 库存数据:10秒

3.1.5 结论:推荐作为默认方案

适用场景

  • 用户个人信息管理
  • 电商商品基础信息
  • 内容管理系统的文章数据
  • 社交媒体的帖子信息

性能数据参考

  • Redis DEL操作:约0.1ms
  • 数据库更新+缓存删除总延迟:正常<10ms
  • 99%的请求可以在50ms内完成全流程

3.2 方案4:延迟双删(解决并发读写的"缓存污染")

3.2.1 什么是"旧数据回写"场景?

详细时序分析

时间线程A(读请求R1)线程B(写请求W)DB状态Redis状态
t1检查缓存,未命中张三
t2发起DB查询张三
t3更新DB为"李四"李四
t4删除缓存李四
t5获取到DB结果"张三"李四
t6写入缓存"张三"李四张三

3.2.2 延迟双删的流程

Java实现示例

public void updateNickname(long userId, String newName) {
    // 第一次数据库更新
    userDao.updateNickname(userId, newName);
    // 第一次缓存删除
    redis.delete("user:"+userId+":nickname");
    // 延迟队列处理第二次删除
    delayQueue.add(new DelayDeleteTask(
        "user:"+userId+":nickname",
        500, // 延迟500ms
        System.currentTimeMillis()
    ));
}
// 延迟任务处理器
class DelayDeleteWorker {
    void process(DelayDeleteTask task) {
        if (System.currentTimeMillis() - task.createTime >= task.delayMs) {
            redis.delete(task.key);
        } else {
            // 重新入队
        }
    }
}

3.2.3 关键:如何确定"延迟时间N"?

确定方法

  1. 监控系统收集关键读接口的P99响应时间
    • 如:用户信息查询接口的P99=320ms
  2. 增加安全余量(通常50-100%)
    • 示例:320ms × 1.5 ≈ 500ms
  3. 不同接口可设置不同延迟:
    • 简单查询:300-500ms
    • 复杂聚合查询:800-1000ms

动态调整策略

  • 基于历史响应时间自动计算
  • 高峰期适当增加延迟
  • 可配置化,支持热更新

3.2.4 结论:适用于高并发读写场景

典型应用

  1. 秒杀系统库存更新
    • 写:扣减库存
    • 读:查询剩余库存
  2. 实时竞价系统
    • 写:更新出价
    • 读:显示当前最高价
  3. 在线协作编辑
    • 写:保存编辑内容
    • 读:获取最新内容

性能影响

  • 额外一次DEL操作,增加约0.1ms延迟
  • 适合对一致性要求高于性能的场景
  • 建议配合熔断机制(如连续失败转降级)

3.3 方案5:基于Canal的异步更新(最终一致性的终极方案)

3.3.1 原理:监听数据库binlog,异步同步缓存

Canal部署架构

MySQL主库 → Canal Server(解析binlog) → Kafka/RocketMQ → 消费者服务 → Redis集群

binlog事件示例

{
  "type": "UPDATE",
  "database": "user_db",
  "table": "users",
  "before": {"id": 1001, "nickname": "张三"},
  "after": {"id": 1001, "nickname": "李四"},
  "timestamp": 1685432100
}

3.3.2 流程拆解:

  1. 数据库变更

    • 业务服务执行:UPDATE users SET nickname='李四' WHERE id=1001
    • MySQL生成binlog(ROW格式)
  2. Canal解析

    • Canal Server连接到MySQL伪装为从库
    • 获取binlog并解析为结构化事件
    • 过滤只关注user_db.users表的UPDATE事件
  3. 消息处理

    • 将变更事件发布到Kafka主题:user_db.users.update
    • 消息体包含:主键ID、变更前/后字段值
  4. 缓存更新

    • 消费者组订阅Kafka主题
    • 处理逻辑:
      if (event.table == "users" && event.after.nickname != null) {
          String key = "user:" + event.after.id + ":nickname";
          redis.del(key); // 或 redis.set(key, event.after.nickname);
      }

3.3.3 优势:彻底解耦业务与缓存操作

解耦带来的好处

  1. 业务代码简化:

    // 原来
    public void updateUser(User user) {
        userDao.update(user);
        redis.del("user:"+user.getId());
    }
    // 现在
    public void updateUser(User user) {
        userDao.update(user);
        // 无需处理缓存
    }

  2. 统一处理多缓存:

    • 同一个数据库变更可以更新:
      • Redis缓存
      • 本地缓存
      • 搜索索引
      • CDN缓存
  3. 跨服务协作:

    • 订单服务更新状态
    • 通过binlog通知:
      • 支付服务
      • 物流服务
      • 统计服务

3.3.4 注意事项:

生产环境建议

  1. 高可用部署

    • Canal Server:至少2节点,ZK选主
    • 消息队列:多副本配置
    • 消费者:多实例负载均衡
  2. 监控指标

    • binlog解析延迟
    • 消息堆积量
    • 缓存更新成功率
    • 端到端延迟(DB更新→缓存生效)
  3. 异常处理

    • 消息重试策略
    • 死信队列处理
    • 数据修复工具(对比DB与缓存)

延迟测试数据

  • 正常情况下:200-500ms
  • 高峰期:1-2s
  • 异常情况:可能有数秒延迟

适用场景建议

  • 用户行为日志
  • 订单状态流转
  • 商品评价信息
  • 非实时财务统计

四、不同场景下的方案选择

业务场景推荐方案核心原因补充说明
大多数常规业务(如用户信息、商品详情)先更新数据库 + 删除缓存平衡一致性、性能与复杂度,性价比最高1. 适用于读多写少的场景<br>2. 典型实现方式:<br>a) 更新数据库<br>b) 删除缓存键<br>3. 可能出现短暂不一致,但可通过重试机制缓解
高并发读写(如秒杀库存、金融金额)延迟双删解决并发回写问题,保障高一致性1. 实现步骤:<br>a) 先删除缓存<br>b) 更新数据库<br>c) 延迟一段时间后再次删除缓存<br>2. 延迟时间通常为500ms-1s<br>3. 需要配合消息队列实现可靠删除
非实时但需最终一致(如订单、物流)数据库 + Canal 异步同步解耦业务与缓存,降低开发成本,保障最终一致1. 基于数据库binlog监听<br>2. 典型架构:<br>MySQL -> Canal -> MQ -> 缓存更新服务<br>3. 延迟通常在1s内<br>4. 对业务代码无侵入
对实时性要求极高、可接受脏数据(如社交动态)先更新数据库 + 更新缓存仅适用于非核心数据,需额外加重试机制1. 可能出现短暂脏数据<br>2. 必须实现:<br>a) 更新失败重试机制<br>b) 缓存过期策略<br>3. 适合写多读少场景
禁止脏数据、实时性要求极高(如支付系统)分布式事务(如TCC)成本高,仅用于核心场景1. 实现复杂,需要业务改造<br>2. 典型方案:<br>a) Try阶段:资源预留<br>b) Confirm阶段:确认执行<br>c) Cancel阶段:取消预留<br>3. 性能损耗较大,吞吐量下降30%-50%

补充说明:

  1. 方案选择还需考虑团队技术栈和能力
  2. 所有方案都应配合监控告警系统
  3. 建议在非核心业务先进行方案验证
  4. 极端情况下可考虑多级缓存方案

五、常见错误与最佳实践

1. 缓存过期时间设置

错误示例

  • 完全不设置过期时间,导致系统异常时缓存数据永远无法更新
  • 设置过长(如24小时),导致业务变更后数据延迟生效

最佳实践

  • 根据业务特性设置阶梯式过期时间:
    • 高频变更数据:5-10分钟(如电商库存)
    • 中频变更数据:30分钟-2小时(如用户画像)
    • 低频变更数据:4-12小时(如配置信息)
  • 采用随机过期时间(基础时间+随机偏移量),避免同一时间大量缓存失效

2. 删除操作可靠性保障

典型问题场景

  • 网络抖动导致DEL命令执行失败
  • Redis节点故障时删除命令丢失

解决方案

// 删除重试伪代码示例
public void deleteWithRetry(String key) {
    int maxRetries = 3;
    for (int i = 0; i < maxRetries; i++) {
        try {
            redis.del(key);
            break;
        } catch (Exception e) {
            if (i == maxRetries - 1) {
                // 最终失败时写入重试队列
                mq.send(new RetryMessage(key));
            }
        }
    }
}

  • 推荐结合消息队列(如RocketMQ)实现异步重试
  • 对于关键数据,建议采用「删除日志表」记录待删除键,通过定时任务补偿

3. 缓存雪崩防护

防护方案对比表

方案适用场景实现方式注意事项
缓存预热高峰前准备期提前加载热点数据需预测热点数据范围
分布式锁精确控制并发Redis SETNX + 超时机制注意死锁和性能损耗
熔断降级极端流量场景Hystrix/Sentinel熔断策略需配置合理的熔断阈值

雪崩场景模拟
当某商品缓存失效时,假设:

  1. 1000QPS同时请求该商品
  2. 数据库单查询耗时50ms
  3. 数据库连接池100连接 → 理论上会导致至少50%请求等待或超时

4. 监控体系建设

核心监控指标

  • 一致性监控(示例):
    /* 定时执行脚本示例 */
    SELECT COUNT(*) FROM products
    WHERE id IN (SELECT key FROM redis_store)
    AND last_updated > redis_sync_time;

  • 告警阈值建议:
    • Canal延迟 > 5秒(P1级告警)
    • 缓存删除失败率 > 1%(P2级告警)
    • 数据不一致数量 > 100条(P0级告警)

监控看板建议包含

  • 实时不一致数量趋势图
  • 缓存操作失败TOP10统计
  • 数据库负载与缓存命中率关联曲线

扩展建议
对于金融级场景,建议增加:

  • 双向校验机制(DB→Redis和Redis→DB)
  • 数据版本号比对
  • 自动化修复开关(检测到不一致时自动触发修复流程)

六、代码实战:核心方案的落地实现

6.1 方案 3(先更数据库 + 删缓存)的 Java 实现

6.1.1 依赖引入(pom.xml)



    org.springframework.boot
    spring-boot-starter-web
    2.7.5



    com.baomidou
    mybatis-plus-boot-starter
    3.5.3.1



    org.springframework.boot
    spring-boot-starter-data-redis
    2.7.5



    org.springframework.boot
    spring-boot-starter-amqp
    2.7.5



    com.mysql
    mysql-connector-j
    8.0.30
    runtime



    com.zaxxer
    HikariCP
    5.0.1



    org.projectlombok
    lombok
    1.18.24
    true



    org.redisson
    redisson-spring-boot-starter
    3.18.0

补充说明:

  1. 添加了版本号以确保依赖版本一致性
  2. 新增了HikariCP连接池依赖,用于数据库连接管理
  3. 添加了Lombok依赖,简化实体类代码编写
  4. 增加了Redisson依赖,用于实现分布式锁功能
  5. 所有依赖版本均为当前项目测试通过的稳定版本

6.1.2 核心业务代码(UserService)

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.demo.mapper.UserMapper;
import com.example.demo.entity.User;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
/**
 * 用户服务实现类
 * 包含用户信息管理、缓存处理等核心业务逻辑
 * 采用数据库+Redis+MQS的架构模式确保数据一致性
 */
@Slf4j
@Service
public class UserService extends ServiceImpl {
    // Redis键前缀(遵循业务:ID:属性的命名规范)
    private static final String USER_NICKNAME_KEY = "user:%s:nickname";
    // 缓存操作重试策略配置
    private static final int MAX_RETRY_COUNT = 3;
    private static final long RETRY_INTERVAL_MS = 100L;
    private static final int CACHE_EXPIRE_MINUTES = 5;
    @Resource
    private RedisTemplate redisTemplate;
    @Resource
    private RabbitTemplate rabbitTemplate;
    /**
     * 修改用户昵称:采用Cache-Aside模式
     * 执行流程:1.更新数据库 -> 2.删除缓存 -> 3.失败后异步补偿
     * 确保最终一致性的关键措施:
     * - 数据库操作使用事务保证原子性
     * - 缓存删除实现自动重试机制
     * - 失败时通过MQ消息队列进行补偿
     *
     * @param userId 用户ID(需大于0)
     * @param newNickname 新昵称(2-20个字符)
     * @return 操作是否成功
     * @throws IllegalArgumentException 参数校验失败
     * @throws RuntimeException 数据库操作失败
     */
    @Transactional(rollbackFor = Exception.class)
    public boolean updateNickname(Long userId, String newNickname) {
        // 参数校验
        if (userId == null || userId <= 0) {
            throw new IllegalArgumentException("无效的用户ID");
        }
        if (newNickname == null || newNickname.length() < 2 || newNickname.length() > 20) {
            throw new IllegalArgumentException("昵称长度需在2-20个字符之间");
        }
        // 1. 更新数据库(事务保证原子性)
        User user = new User();
        user.setId(userId);
        user.setNickname(newNickname);
        boolean dbUpdateSuccess = this.updateById(user);
        if (!dbUpdateSuccess) {
            throw new RuntimeException("数据库更新失败,用户ID可能不存在");
        }
        // 2. 构建Redis键(使用String.format避免拼接错误)
        String redisKey = String.format(USER_NICKNAME_KEY, userId);
        // 3. 尝试删除缓存(采用指数退避重试策略)
        boolean cacheDeleteSuccess = deleteCacheWithRetry(redisKey, 0);
        if (!cacheDeleteSuccess) {
            // 4. 重试失败,发送消息到MQ的死信队列进行异步补偿
            rabbitTemplate.convertAndSend(
                "cache-delete-exchange",
                "cache.delete.key",
                redisKey,
                message -> {
                    // 设置消息属性,确保失败后进入死信队列
                    message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT);
                    return message;
                }
            );
            log.warn("缓存删除失败,已发送异步补偿任务到MQ,key: {}", redisKey);
        }
        return true;
    }
    /**
     * 带重试机制的缓存删除方法
     * 重试策略:固定间隔重试,最多3次
     *
     * @param redisKey 待删除的Redis键
     * @param retryCount 当前重试次数(从0开始)
     * @return 是否删除成功
     */
    private boolean deleteCacheWithRetry(String redisKey, int retryCount) {
        try {
            Boolean result = redisTemplate.delete(redisKey);
            if (Boolean.TRUE.equals(result)) {
                log.info("缓存删除成功,key: {}", redisKey);
                return true;
            }
            throw new RuntimeException("Redis返回删除失败");
        } catch (Exception e) {
            log.error("缓存删除失败(第{}次),key: {}, 异常: {}",
                     retryCount + 1, redisKey, e.getMessage());
            // 判断是否继续重试
            if (retryCount < MAX_RETRY_COUNT - 1) {
                try {
                    TimeUnit.MILLISECONDS.sleep(RETRY_INTERVAL_MS);
                    return deleteCacheWithRetry(redisKey, retryCount + 1);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                    log.error("重试过程被中断", ie);
                }
            }
            return false;
        }
    }
    /**
     * 查询用户昵称:实现缓存穿透保护
     * 执行流程:1.查缓存 -> 2.缓存缺失查数据库 -> 3.回写缓存
     * 防护措施:
     * - 对不存在的用户直接抛出异常
     * - 设置合理的缓存过期时间
     *
     * @param userId 用户ID
     * @return 用户昵称
     * @throws RuntimeException 用户不存在时抛出
     */
    public String getNickname(Long userId) {
        // 构建缓存键
        String redisKey = String.format(USER_NICKNAME_KEY, userId);
        // 1. 先查缓存(使用opsForValue保持操作一致性)
        String nickname = redisTemplate.opsForValue().get(redisKey);
        if (nickname != null) {
            log.debug("缓存命中,用户ID: {}, 昵称: {}", userId, nickname);
            return nickname;
        }
        // 2. 缓存缺失,查数据库(使用getById避免SQL注入)
        User user = this.getById(userId);
        if (user == null || user.getNickname() == null) {
            throw new RuntimeException("用户不存在或昵称为空");
        }
        // 3. 回写缓存(设置过期时间避免脏数据长期存在)
        String dbNickname = user.getNickname();
        redisTemplate.opsForValue().set(
            redisKey,
            dbNickname,
            CACHE_EXPIRE_MINUTES,
            TimeUnit.MINUTES
        );
        log.info("缓存回写成功,用户ID: {}, 过期时间: {}分钟", userId, CACHE_EXPIRE_MINUTES);
        return dbNickname;
    }
}

代码优化说明:

  • 增加了Lombok的@Slf4j注解简化日志操作
  • 新增了方法参数的合法性校验
  • 完善了缓存操作的错误处理和日志记录
  • 为MQ消息设置了持久化属性
  • 提取了魔法数字为常量
  • 增加了更详细的注释说明业务逻辑
  • 实现了更健壮的重试机制
  • 为缓存设置了合理的过期时间

典型应用场景:

  • 用户修改个人资料时的缓存更新
  • 高频访问的用户信息查询
  • 需要保证数据最终一致性的业务场景

6.1.3 异步补偿消费者(CacheDeleteConsumer)

import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
@Component
public class CacheDeleteConsumer {
    @Resource
    private RedisTemplate redisTemplate;
    /**
     * 监听MQ消息队列,异步删除缓存(带指数退避重试机制)
     * 适用于数据库与缓存不一致时的补偿场景,如:
     * 1. 数据库更新后,缓存删除失败
     * 2. 分布式事务中,需要最终一致性保证
     *
     * @param redisKey 需要删除的Redis键名
     */
    @RabbitListener(queues = "cache-delete-queue")
    public void handleCacheDelete(String redisKey) {
        // 指数退避重试策略:1秒、2秒、4秒后重试
        int[] retryDelays = {1000, 2000, 4000};
        for (int delay : retryDelays) {
            try {
                // 执行删除操作
                redisTemplate.delete(redisKey);
                log.info("异步补偿:缓存删除成功,key: {}", redisKey);
                return; // 删除成功则退出
            } catch (Exception e) {
                log.error("异步补偿:缓存删除失败,延迟{}ms后重试,key: {}, 异常: {}",
                    delay, redisKey, e.getMessage());
                try {
                    // 按照设定的延迟时间等待
                    TimeUnit.MILLISECONDS.sleep(delay);
                } catch (InterruptedException ie) {
                    Thread.currentThread().interrupt();
                }
            }
        }
        // 多次重试失败后的处理逻辑
        log.error("异步补偿:缓存删除最终失败,需人工介入,key: {}", redisKey);
        // 触发告警机制(可集成钉钉/企业微信/邮件等通知方式)
        sendAlert("缓存删除失败告警", "key: " + redisKey);
    }
    /**
     * 发送告警通知(可根据实际需求扩展)
     * 示例实现方式:
     * 1. 调用钉钉机器人Webhook
     * 2. 发送企业微信消息
     * 3. 发送邮件通知
     *
     * @param title 告警标题
     * @param content 告警内容
     */
    private void sendAlert(String title, String content) {
        // 实际项目中可替换为具体的告警实现
        // 例如使用HttpClient调用第三方告警API
    }
}

实现说明

  • 消息队列配置

    • 监听名为"cache-delete-queue"的RabbitMQ队列
    • 队列应在项目启动时自动创建(通过@Bean配置)
  • 重试策略

    • 采用指数退避算法,初始延迟1秒
    • 最大重试次数3次,总等待时间约7秒
    • 可根据业务需求调整重试次数和间隔
  • 异常处理

    • 捕获所有可能的Redis操作异常
    • 记录详细的错误日志便于排查
    • 处理线程中断异常保证程序健壮性
  • 监控告警

    • 最终失败时触发告警
    • 建议记录到数据库或ELK便于后续分析
    • 可扩展为降级处理逻辑(如设置缓存过期时间)

6.2 方案4(延迟双删)的Java实现

6.2.1 延迟双删核心代码(新增方法)

import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Date;
import java.util.concurrent.TimeUnit;
@Component
public class DelayCacheDeleteService {
    private static final Logger log = LoggerFactory.getLogger(DelayCacheDeleteService.class);
    @Resource
    private RedisTemplate redisTemplate;
    @Resource
    private ThreadPoolTaskScheduler taskScheduler;
    /**
     * 延迟删除缓存(用于延迟双删)
     * @param redisKey Redis键
     * @param delay 延迟时间(毫秒)
     */
    public void delayDeleteCache(String redisKey, long delay) {
        // 计算延迟后的执行时间
        Date executeTime = new Date(System.currentTimeMillis() + delay);
        // 提交延迟任务
        taskScheduler.schedule(() -> {
            try {
                // 执行缓存删除操作
                Boolean deleteResult = redisTemplate.delete(redisKey);
                if (deleteResult != null && deleteResult) {
                    log.info("延迟双删:缓存删除成功,key: {}, 延迟时间: {}ms", redisKey, delay);
                } else {
                    log.warn("延迟双删:缓存不存在或删除失败,key: {}", redisKey);
                }
            } catch (Exception e) {
                log.error("延迟双删:缓存删除失败,key: {}, 异常: {}", redisKey, e.getMessage());
                // 可再次触发异步补偿(参考方案3)
                // 例如:将失败任务放入消息队列进行重试
                // rabbitTemplate.convertAndSend("cache-retry-exchange", "cache.retry.key", redisKey);
            }
        }, executeTime);
    }
}

配置说明

  • 需要配置ThreadPoolTaskScheduler作为延迟任务执行器
  • 延迟时间建议设置为500-1000ms,根据业务场景调整
  • 日志记录要详细,便于排查问题

6.2.2 在UserService中集成延迟双删

@Service
public class UserService {
    private static final String USER_NICKNAME_KEY = "user:nickname:%d";
    @Resource
    private UserMapper userMapper;
    @Resource
    private RedisTemplate redisTemplate;
    @Resource
    private RabbitTemplate rabbitTemplate;
    @Resource
    private DelayCacheDeleteService delayCacheDeleteService;
    /**
     * 更新用户昵称(带延迟双删)
     * @param userId 用户ID
     * @param newNickname 新昵称
     * @return 更新结果
     */
    @Transactional(rollbackFor = Exception.class)
    public boolean updateNickname(Long userId, String newNickname) {
        // 1. 更新数据库
        int affectedRows = userMapper.updateNickname(userId, newNickname);
        if (affectedRows <= 0) {
            throw new RuntimeException("更新用户昵称失败");
        }
        // 2. 构建Redis键
        String redisKey = String.format(USER_NICKNAME_KEY, userId);
        // 3. 第一次删除缓存(带重试机制)
        boolean cacheDeleteSuccess = deleteCacheWithRetry(redisKey, 0);
        // 4. 如果第一次删除失败,发送消息到MQ进行异步补偿
        if (!cacheDeleteSuccess) {
            rabbitTemplate.convertAndSend(
                "cache-delete-exchange",
                "cache.delete.key",
                redisKey,
                message -> {
                    message.getMessageProperties().setHeader("x-retry-count", 0);
                    return message;
                }
            );
            log.warn("第一次删除缓存失败,已发送到MQ重试,key: {}", redisKey);
        }
        // 5. 延迟500ms后,第二次删除缓存(延迟双删核心)
        delayCacheDeleteService.delayDeleteCache(redisKey, 500);
        return true;
    }
    /**
     * 带重试机制的缓存删除
     * @param key 缓存key
     * @param retryCount 当前重试次数
     * @return 是否删除成功
     */
    private boolean deleteCacheWithRetry(String key, int retryCount) {
        try {
            Boolean result = redisTemplate.delete(key);
            return result != null && result;
        } catch (Exception e) {
            if (retryCount < 3) {
                try {
                    Thread.sleep(100 * (retryCount + 1));
                } catch (InterruptedException ignored) {}
                return deleteCacheWithRetry(key, retryCount + 1);
            }
            return false;
        }
    }
}

关键实现细节

  • 数据库更新和缓存删除操作放在同一个事务中
  • 第一次删除缓存采用同步+重试机制
  • 第一次删除失败后,会通过MQ进行异步补偿
  • 延迟双删的时间间隔需要根据业务特点调整:
    • 对于高并发场景建议500ms
    • 对于读写分离场景建议1000ms
  • 重试机制采用指数退避策略(100ms, 200ms, 300ms)

七、进阶场景:分布式事务与缓存一致性

在跨服务、多数据库的分布式微服务架构下,缓存一致性问题会变得更加复杂。例如,在一个电商系统的"订单支付成功后"业务场景中,需要同时完成以下操作:更新订单状态(订单库)、扣减库存(库存库)、同步用户积分(用户库)。此时若仅依靠单服务的缓存删除机制,可能出现部分服务更新成功、部分失败的情况,导致数据不一致的问题。

7.1 分布式事务方案:TCC(Try-Confirm-Cancel)

TCC(Try-Confirm-Cancel)是目前解决分布式事务的主流方案之一,其核心思想是将业务操作拆分为三个明确的阶段:

  1. Try(预留资源):尝试执行业务,完成所有业务检查,预留必要的业务资源
  2. Confirm(确认执行):确认执行业务操作,真正提交事务
  3. Cancel(回滚):取消执行业务操作,释放预留资源

每个阶段都需要同步处理数据库和缓存的操作。

以"订单支付+扣减库存"为例的详细实现

Try 阶段(资源预留阶段):
  • 订单服务:

    • 冻结订单记录
    • 在订单表中标记订单状态为"待支付确认"
    • 不更新订单状态缓存(避免中间状态污染)
  • 库存服务:

    • 预留商品库存(如商品ID=100的库存从100减为99)
    • 在库存表中标记该部分库存为"预留"状态
    • 不更新库存缓存(保持原值)
Confirm 阶段(所有Try成功后执行):
  • 订单服务:

    • 将订单状态从"待支付确认"改为"已支付"
    • 更新数据库中的订单状态
    • 删除订单状态相关缓存(如order:10086:status
  • 库存服务:

    • 将"预留库存"状态改为"已扣减"
    • 更新数据库中的库存记录
    • 删除库存相关缓存(如goods:100:stock
  • 积分服务:

    • 增加用户积分
    • 更新积分数据库
    • 删除用户积分缓存
Cancel 阶段(任意Try失败后执行):
  • 订单服务:

    • 将订单状态改为"支付失败"
    • 更新数据库中的订单记录
    • 删除订单状态相关缓存
  • 库存服务:

    • 释放预留库存(将库存从99加回100)
    • 更新数据库中的库存记录
    • 删除库存相关缓存
  • 积分服务:

    • 回滚积分变更
    • 更新数据库
    • 删除用户积分缓存

7.2 注意事项与最佳实践

  • 业务适用性

    • TCC需要业务层自定义实现,复杂度较高
    • 适合核心业务场景(如支付、库存等关键路径)
    • 不适合轻量级或非关键业务
  • 缓存一致性

    • 缓存操作必须与事务阶段严格绑定
    • Confirm阶段:成功时删除缓存
    • Cancel阶段:失败时也必须删除缓存
    • 避免中间状态残留在缓存中
  • 实现建议

    • 可借助Seata、Hmily等开源分布式事务框架简化TCC实现
    • 框架通常提供:
      • 事务管理器
      • 重试机制
      • 幂等控制
      • 超时处理
    • 减少重复编码工作
  • 性能考量

    • Try阶段的资源预留会锁定资源,可能影响并发性能
    • 建议设置合理的超时时间
    • 对于高并发场景,可考虑结合本地消息表等最终一致性方案
  • 异常处理

    • 需要处理网络超时、服务不可用等各种异常情况
    • 实现完备的重试机制
    • 确保各阶段操作的幂等性

八、高频问题解答(FAQ)

Q1:为什么不推荐 "先删缓存,再更数据库"?

详细解释: 这种方案会导致 "缓存穿透" 风险,具体表现为如下时序问题:

  1. 读请求 R1 查询缓存,发现缓存已被删除(步骤 1)
  2. R1 从数据库查询旧数据(如库存 100),准备回写缓存
  3. 在此期间,写请求 W 完成数据库更新(将库存改为 99)
  4. R1 最终将旧数据 100 回写到缓存
  5. 结果:数据库值为 99,缓存值为 100,出现数据不一致

技术对比: 相比"先更数据库再删缓存"方案,这种不一致更难通过延迟双删等机制解决:

  • 延迟双删需要精确控制两次删除的间隔时间
  • 在高并发场景下,难以保证所有请求都能按照预期顺序执行
  • 可能出现多线程竞争导致的时序混乱

应用场景举例: 电商秒杀活动中,如果采用这种方案,可能导致超卖等问题。

Q2:缓存过期时间设置多久合适?

详细指南:

1. 核心数据设置(强一致性要求)

  • 典型数据:用户余额、订单状态、库存数量
  • 建议时间:1-5分钟短过期时间
  • 配套措施
    • 搭配主动删除策略
    • 关键操作后立即删除缓存
    • 实现缓存预热机制

2. 非核心数据设置(最终一致性可接受)

  • 典型数据:商品分类、用户资料、配置信息
  • 建议时间:1-24小时长过期时间
  • 配套措施
    • 设置后台定期刷新
    • 采用懒加载策略
    • 结合版本号控制

3. 防雪崩策略

  • 实现方式
    • 基础过期时间 + 随机浮动值(如5±1分钟)
    • 分级过期策略(不同业务采用不同过期时间)
    • 熔断降级机制

Q3:Redis 集群环境下,缓存删除会有问题吗?

技术细节:

Redis Cluster 删除机制

  • DEL命令通过CRC16哈希算法计算键的哈希槽
  • 自动路由到对应数据节点执行
  • 删除操作在单个节点上是原子的

主从复制注意事项

  • 同步延迟

    • 主节点删除后,从节点同步存在毫秒级延迟
    • 读取从节点可能获取到旧数据
  • 解决方案

    • 启用READONLY命令强制读主
    • 配置合理的复制积压缓冲区
    • 监控主从延迟指标

高可用建议

  • 哨兵模式配置:

    • 至少3个哨兵实例
    • 合理设置故障转移超时
  • Redis Cluster最佳实践:

    • 每个分片至少3个节点
    • 合理设置cluster-node-timeout

Q4:Canal 同步缓存时,如何处理分库分表场景?

完整解决方案:

1. 分库配置

  • 示例分库规则

    user表按userId取模分3库:
    - db0.user (userId % 3 = 0)
    - db1.user (userId % 3 = 1)
    - db2.user (userId % 3 = 2)

  • Canal监听配置

    canal.instance.filter.regex=.*\\..*
    canal.instance.filter.black.regex=
    需要明确配置监听所有分库表

2. 缓存键设计原则

  • 标准格式业务前缀:分片键:字段名
  • 示例
    user:12345:nickname
    order:67890:status

  • 设计要点
    • 分片键必须包含在缓存键中
    • 保持键结构的一致性
    • 避免特殊字符

3. Binlog处理流程

  • 解析DML事件获取变更数据
  • 提取分片键(如userId)
  • 构造标准的缓存键
  • 执行缓存更新/删除操作
  • 记录操作日志供核对

扩展场景

  • 分库分表规则变更时的缓存处理
  • 跨库事务的binlog顺序问题
  • 大批量数据变更的批处理优化
posted @ 2025-11-06 13:41  gccbuaa  阅读(7)  评论(0)    收藏  举报