Redis 缓存与数据库谁先更新?
一、问题背景:为什么要纠结"谁先更新"?
在引入Redis缓存的系统中,数据通常存在于两个地方:
- 数据库(MySQL、PostgreSQL等)作为持久化数据源
- Redis缓存作为高性能的数据加速层
当业务需要修改数据时,必须同步更新这两个存储介质,否则会出现"缓存数据与数据库数据不一致"的问题。这种不一致性可能导致多种业务异常,例如:
- 用户看到过期的旧数据
- 订单金额计算错误
- 库存显示不准确
- 商品价格不一致等
典型场景示例:用户修改个人昵称
假设初始状态下,数据库和Redis中用户昵称均为"张三"。当用户将昵称改为"李四"时,不同的更新顺序会导致不同的问题:
先更新缓存,后更新数据库的情况:
- 缓存已成功更新为"李四"
- 数据库更新失败(如网络中断、数据库连接超时等)
- 结果:缓存中是"李四",数据库仍是"张三",出现不一致
- 影响:后续请求从缓存获取"李四",但实际数据库存储的是旧值
先更新数据库,后更新缓存的情况:
- 数据库已成功更新为"李四"
- 缓存更新失败(如Redis连接问题、内存不足等)
- 结果:数据库是"李四",缓存仍是"张三",同样出现不一致
- 影响:后续请求从缓存获取"张三",显示过期数据
业务场景的影响
数据一致性的重要程度与业务场景密切相关:
- 高敏感场景:如支付系统、金融交易,必须保证强一致性
- 一般敏感场景:如社交资讯、用户资料,可以接受短暂不一致
- 低敏感场景:如商品浏览量统计,可以接受最终一致性
因此,在选择更新顺序策略时,需要综合考虑业务容错率、系统性能和实现复杂度等因素。
二、两种更新顺序的深度剖析:问题与风险
2.1 方案 1:先更新 Redis 缓存,再更新数据库
2.1.1 核心问题:数据库更新失败,导致缓存 "脏数据"
典型业务场景:电商系统商品库存更新、社交平台用户资料修改、金融系统账户余额变动等对数据一致性要求较高的场景。
流程拆解(以电商系统商品库存更新为例):
- 用户下单请求将商品A库存从100减到99
- 系统先更新Redis缓存:
SET product:123:stock 99
- 系统再执行数据库更新:
UPDATE products SET stock=99 WHERE id=123
- 若数据库更新失败(如主键冲突、死锁、连接超时等),此时:
- 数据库实际库存仍为100
- Redis缓存中库存显示为99
- 后续所有查询该商品库存的请求都会从Redis获取错误值99,可能导致超卖问题
数据不一致持续时间:
- 如果设置了TTL:直到缓存过期(可能几分钟到几小时)
- 如果没有TTL:直到手动清除缓存或下次成功更新
2.1.2 延伸风险:并发场景下的 "数据覆盖"
多线程并发更新场景(以社交平台用户积分变更为例):
- 初始状态:用户积分100
- 线程A:增加50积分(预期150)
- 线程B:扣除30积分(预期70)
异常执行时序:
- 线程A更新Redis:积分=150
- 线程B更新Redis:积分=70
- 线程A更新数据库失败(回滚)
- 线程B更新数据库成功
- 最终状态:
- 数据库:70(正确)
- Redis:70(巧合一致)
更危险的时序:
- 线程A更新Redis:积分=150
- 线程B更新Redis:积分=70
- 线程B更新数据库成功:70
- 线程A更新数据库失败
- 最终状态:
- 数据库:70
- Redis:70(但线程A的业务逻辑已认为操作失败)
2.1.3 解决方案尝试与局限
尝试方案:
- 引入事务机制:
- 将Redis和DB更新放入同一事务
- 问题:Redis不支持真正的事务回滚
- 增加补偿机制:
- 定期扫描数据库与缓存差异
- 问题:实时性差,补偿期间业务已受影响
适用场景限制: 仅适合:
- 非核心业务数据(如文章阅读量统计)
- 可容忍最终一致性的场景
- 短TTL配置(≤1分钟)的缓存项
2.2 方案 2:先更新数据库,再更新 Redis 缓存
2.2.1 核心问题:缓存更新失败
典型故障场景:
- 数据库更新成功
- Redis集群出现以下情况之一:
- 节点故障
- 网络分区
- 内存不足
- 连接池耗尽
影响范围评估:
- 单条数据不一致:影响单个业务功能
- 批量更新失败:可能影响整个业务模块
- 持续时间:直到下次缓存更新或人工干预
2.2.2 并发读写场景问题详解
典型竞态条件时序:
- 读请求R检查缓存命中(旧值V1)
- 写请求W更新数据库为新值V2
- 写请求W删除/更新缓存
- 读请求R将旧值V1写入缓存
恶化条件:
- 缓存刚好过期
- 大量读请求涌入
- 写操作较耗时
实际案例: 某电商大促期间:
- 商品价格从100→80
- 瞬间涌入10万次查询
- 约3%请求在缓存更新间隙读到旧价格
- 导致数千订单价格不一致投诉
2.2.3 优化方案对比
方案 | 实现方式 | 优点 | 缺点 |
---|---|---|---|
重试机制 | 缓存更新失败后自动重试N次 | 简单易实现 | 增加延迟,重试期间仍不一致 |
异步队列 | 通过消息队列异步处理缓存更新 | 解耦主流程 | 系统复杂度增加,消息可能丢失 |
双写校验 | 更新后立即查询验证 | 一致性高 | 性能损耗大,无法防并发问题 |
延迟删除 | 更新DB后延迟双删缓存 | 减少竞态窗口 | 延迟时间难确定 |
最佳实践推荐:
- 对一致性要求极高的场景:
- 采用分布式锁+版本号机制
- 实现读写互斥
- 一般场景:
- 先更新DB
- 通过消息队列异步更新缓存
- 设置合理的缓存TTL作为兜底
监控指标建议:
- 缓存更新失败率
- DB与缓存不一致时长
- 缓存命中率波动
- 重试队列积压量
三、解决方案:从 "更新缓存" 到 "删除缓存",再到 "最终一致性"
3.1 方案3:先更新数据库,再删除Redis缓存(Cache-Aside Pattern)
这是目前最主流的方案,也称为"旁路缓存模式"。其核心逻辑是:不直接更新缓存,而是删除缓存中的旧数据,后续读请求发现缓存缺失时,再从数据库加载新数据并回写到缓存。
3.1.1 流程拆解(以用户改昵称为例):
业务服务请求处理:
- 前端发送请求:POST /users/1001/nickname {"nickname":"李四"}
- 业务服务接收"昵称从张三改为李四"的请求
数据库更新阶段:
- 开启数据库事务
- 执行SQL:
UPDATE user SET nickname='李四' WHERE id=1001
- 验证数据库更新结果(影响行数>0)
- 提交事务
缓存删除阶段:
- 构建Redis键名:
user:1001:nickname
- 执行Redis命令:
DEL user:1001:nickname
- 记录操作日志(用于后续可能的补偿)
- 构建Redis键名:
后续读请求处理:
- 客户端请求: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 为什么"删除缓存"比"更新缓存"更好?
操作可靠性:
- 删除操作(DEL)是幂等的,无论key是否存在,执行结果一致
- 更新操作需要处理数据格式转换(如对象转JSON),增加了失败风险
资源利用率:
- 示例:商品详情页缓存,可能包含HTML片段、JSON数据等多种格式
- 直接更新需要维护所有可能的格式
- 删除缓存后,后续请求按需重建,总是使用最新格式
并发场景下更安全:
- 避免写操作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
}
消费者处理逻辑:
- 接收消息
- 尝试执行DEL操作
- 成功:记录日志并确认消息
- 失败:
- 检查retryCount < maxRetry
- 修改retryCount+1
- 计算下次重试时间(指数退避算法)
- 重新入队
- 达到最大重试后:触发告警(邮件/短信/钉钉)
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, // 延迟500msSystem.currentTimeMillis()));
}// 延迟任务处理器
class DelayDeleteWorker {void process(DelayDeleteTask task) {if (System.currentTimeMillis() - task.createTime >= task.delayMs) {redis.delete(task.key);} else {// 重新入队}}
}
3.2.3 关键:如何确定"延迟时间N"?
确定方法:
- 监控系统收集关键读接口的P99响应时间
- 如:用户信息查询接口的P99=320ms
- 增加安全余量(通常50-100%)
- 示例:320ms × 1.5 ≈ 500ms
- 不同接口可设置不同延迟:
- 简单查询:300-500ms
- 复杂聚合查询:800-1000ms
动态调整策略:
- 基于历史响应时间自动计算
- 高峰期适当增加延迟
- 可配置化,支持热更新
3.2.4 结论:适用于高并发读写场景
典型应用:
- 秒杀系统库存更新
- 写:扣减库存
- 读:查询剩余库存
- 实时竞价系统
- 写:更新出价
- 读:显示当前最高价
- 在线协作编辑
- 写:保存编辑内容
- 读:获取最新内容
性能影响:
- 额外一次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 流程拆解:
数据库变更:
- 业务服务执行:
UPDATE users SET nickname='李四' WHERE id=1001
- MySQL生成binlog(ROW格式)
- 业务服务执行:
Canal解析:
- Canal Server连接到MySQL伪装为从库
- 获取binlog并解析为结构化事件
- 过滤只关注user_db.users表的UPDATE事件
消息处理:
- 将变更事件发布到Kafka主题:
user_db.users.update
- 消息体包含:主键ID、变更前/后字段值
- 将变更事件发布到Kafka主题:
缓存更新:
- 消费者组订阅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 优势:彻底解耦业务与缓存操作
解耦带来的好处:
业务代码简化:
// 原来 public void updateUser(User user) {userDao.update(user);redis.del("user:"+user.getId()); }// 现在 public void updateUser(User user) {userDao.update(user);// 无需处理缓存 }
统一处理多缓存:
- 同一个数据库变更可以更新:
- Redis缓存
- 本地缓存
- 搜索索引
- CDN缓存
- 同一个数据库变更可以更新:
跨服务协作:
- 订单服务更新状态
- 通过binlog通知:
- 支付服务
- 物流服务
- 统计服务
3.3.4 注意事项:
生产环境建议:
高可用部署:
- Canal Server:至少2节点,ZK选主
- 消息队列:多副本配置
- 消费者:多实例负载均衡
监控指标:
- binlog解析延迟
- 消息堆积量
- 缓存更新成功率
- 端到端延迟(DB更新→缓存生效)
异常处理:
- 消息重试策略
- 死信队列处理
- 数据修复工具(对比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. 缓存过期时间设置
错误示例:
- 完全不设置过期时间,导致系统异常时缓存数据永远无法更新
- 设置过长(如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熔断策略 | 需配置合理的熔断阈值 |
雪崩场景模拟:
当某商品缓存失效时,假设:
- 1000QPS同时请求该商品
- 数据库单查询耗时50ms
- 数据库连接池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)
<!-- Spring Boot Starter Web - 提供基础的Web开发能力 -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><version>2.7.5</version>
</dependency><!-- MyBatis-Plus - 简化数据库操作 -->
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId><version>3.5.3.1</version>
</dependency><!-- Redis Starter - 提供Redis客户端支持 -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId><version>2.7.5</version>
</dependency><!-- 消息队列(RabbitMQ,用于异步重试) -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId><version>2.7.5</version>
</dependency><!-- 数据库驱动(MySQL) -->
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><version>8.0.30</version><scope>runtime</scope>
</dependency><!-- 连接池(HikariCP) -->
<dependency><groupId>com.zaxxer</groupId><artifactId>HikariCP</artifactId><version>5.0.1</version>
</dependency><!-- Lombok - 简化代码编写 -->
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.24</version><optional>true</optional>
</dependency><!-- 分布式锁(Redisson) -->
<dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.18.0</version>
</dependency>
补充说明:
- 添加了版本号以确保依赖版本一致性
- 新增了HikariCP连接池依赖,用于数据库连接管理
- 添加了Lombok依赖,简化实体类代码编写
- 增加了Redisson依赖,用于实现分布式锁功能
- 所有依赖版本均为当前项目测试通过的稳定版本
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<UserMapper, User> {// 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;@Resourceprivate RedisTemplate<String, String> redisTemplate;@Resourceprivate 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 {@Resourceprivate RedisTemplate<String, String> 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);@Resourceprivate RedisTemplate<String, String> redisTemplate;@Resourceprivate 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";@Resourceprivate UserMapper userMapper;@Resourceprivate RedisTemplate<String, String> redisTemplate;@Resourceprivate RabbitTemplate rabbitTemplate;@Resourceprivate 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)是目前解决分布式事务的主流方案之一,其核心思想是将业务操作拆分为三个明确的阶段:
- Try(预留资源):尝试执行业务,完成所有业务检查,预留必要的业务资源
- Confirm(确认执行):确认执行业务操作,真正提交事务
- 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:为什么不推荐 "先删缓存,再更数据库"?
详细解释: 这种方案会导致 "缓存穿透" 风险,具体表现为如下时序问题:
- 读请求 R1 查询缓存,发现缓存已被删除(步骤 1)
- R1 从数据库查询旧数据(如库存 100),准备回写缓存
- 在此期间,写请求 W 完成数据库更新(将库存改为 99)
- R1 最终将旧数据 100 回写到缓存
- 结果:数据库值为 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顺序问题
- 大批量数据变更的批处理优化