一、为什么需要关注一致性?
在互联网高并发场景中,Redis作为缓存层几乎成了标配。但引入缓存后,一个经典问题随之而来:如何保证Redis缓存与数据库中的数据保持一致?
这个问题之所以棘手,是因为:
独立存储:数据库和Redis是两套独立的存储系统,没有原生分布式事务支持。
并发交织:高并发下,读请求和写请求的操作顺序难以预测。
网络与故障:分布式环境下存在网络延迟、节点宕机、主从同步延迟等。
一个真实案例:某电商网站在大促期间,由于缓存与数据库不一致,导致商品价格显示错误,用户下单后实际扣款金额与显示不符,引发大量客诉。这就是不一致的代价。
二、一致性的三种级别(更精细的定义)
| 级别 | 说明 | 实现代价 | 适用场景 |
|---|---|---|---|
| 强一致性 | 任何时刻、任何节点读到的最新写入数据 | 需要分布式锁/同步协议,性能极低 | 银行余额、库存扣减(极少用缓存) |
| 最终一致性 | 允许短暂的不一致,但保证经过“有限时间”后数据一致 | 高吞吐,易实现 | 大部分业务:用户昵称、商品描述、文章阅读数 |
| 弱一致性 | 不保证最终一致,可能出现永久不一致 | 无代价 | 日志、排行榜(允许误差) |
实际开发中,我们追求的是最终一致性,并将不一致窗口控制在毫秒级。
三、常见更新策略详解(含并发时序图)
3.1 先更新数据库,再删除缓存(推荐策略)
原理流程
text
写请求:开始事务 → 更新数据库 → 提交事务 → 删除缓存 读请求:查缓存 → 未命中 → 查数据库 → 写缓存
为什么删除而不是更新?
更新缓存需要业务计算:例如缓存是一个复杂的聚合对象(用户+订单数+等级),每次更新都要重新计算,开销大。
删除是幂等的:多次删除效果相同,不会出错。
懒加载:只有真正被读取时才填充缓存,避免“写多读少”场景下浪费资源。
详细代码(含事务、异常处理、重试)
java
@Service @Slf4j public class UserService { @Autowired private UserMapper userMapper; @Autowired private RedisTemplate<String, String> redisTemplate; /** * 更新用户信息(保证最终一致性) * @param user 新用户信息 */ @Transactional(rollbackFor = Exception.class) public void updateUser(User user) { // 1. 更新数据库(业务核心) int rows = userMapper.updateById(user); if (rows == 0) { throw new BusinessException("更新失败,用户不存在"); } // 2. 删除缓存(允许失败,但要重试) String key = "user:" + user.getId(); try { redisTemplate.delete(key); log.info("删除缓存成功, key={}", key); } catch (Exception e) { log.error("删除缓存失败, key={}", key, e); // 这里可以发送MQ异步重试,或者记录到重试表 sendRetryMessage(key); } } /** * 查询用户(先读缓存,再读库,最后回填) */ public User getUser(Long id) { String key = "user:" + id; // 1. 尝试读缓存 String cached = redisTemplate.opsForValue().get(key); if (cached != null) { // 注意:需要处理空值缓存(防止穿透) if ("".equals(cached)) { return null; } return JSON.parseObject(cached, User.class); } // 2. 读数据库 User user = userMapper.selectById(id); // 3. 回填缓存(设置合理的过期时间,避免一直占用内存) if (user != null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 3600, TimeUnit.SECONDS); // 1小时过期,作为兜底 } else { // 缓存空对象,防止缓存穿透,过期时间短一些 redisTemplate.opsForValue().set(key, "", 60, TimeUnit.SECONDS); } return user; } }并发不一致窗口分析(重要!)
很多人认为这个策略是绝对安全的,但在读写分离+主从延迟场景下仍然有极短的不一致窗口:
text
时间轴: T1: 写线程A 更新主库 user=新值 (commit) T2: 写线程A 删除缓存 (成功) T3: 读线程B 缓存未命中,去读从库 T4: 从库尚未同步主库binlog,读到旧值 T5: 读线程B 将旧值写入缓存 T6: 主从同步完成,从库变为新值 结果:缓存中是旧值,数据库是新值
窗口时长≈ 主从延迟(通常0.1~2秒) + 读请求耗时(~10ms)。
发生概率:低并发下几乎为0,高并发+大事务+从库压力大时可能频发。
解决方案:采用“延迟双删”或“订阅binlog”方案(见第四章)。
什么时候该用这个策略?
✅ 读多写少(绝大多数业务)
✅ 可以容忍毫秒~秒级的不一致
✅ 数据库非读写分离,或读写分离但延迟很低
3.2 先删除缓存,再更新数据库(不推荐,但需理解)
并发问题详细时序
| 时间 | 线程A(写) | 线程B(读) | 数据库 | 缓存 |
|---|---|---|---|---|
| T1 | 删除缓存 (key=user:1) | - | V1(旧) | 空 |
| T2 | - | 读缓存未命中 | V1 | 空 |
| T3 | - | 读数据库得到V1 | V1 | 空 |
| T4 | - | 将V1写入缓存 | V1 | V1 |
| T5 | 更新数据库为V2 (commit) | - | V2 | V1(脏数据!) |
脏数据持续时间:直到下一次删除或过期。如果写入缓存时设置了TTL=1小时,那么脏数据会存在1小时。
为什么还有人用?
在写操作非常频繁且读操作很少的极端场景下,先删缓存可以减少一次缓存删除操作(因为后面可能再次覆盖)。
但弊远大于利,不推荐。
改进方案:延迟双删(Delayed Double Delete)
java
@Transactional public void updateUserWithDoubleDelete(User user) { String key = "user:" + user.getId(); // 第一次删除 redisTemplate.delete(key); // 更新数据库 userMapper.updateById(user); // 延迟一段时间(关键参数) int delayMs = calculateDelay(); // 动态计算 try { Thread.sleep(delayMs); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 第二次删除 redisTemplate.delete(key); } /** * 计算合理的延迟时间 * 公式:延迟 = 主从预估最大延迟 + 读请求平均耗时 + 缓冲 * 通常取值:500~1500ms */ private int calculateDelay() { // 可以从配置中心读取动态值,或根据监控自适应 return 800; // 毫秒 }延迟时间如何精确确定?
监控主从延迟的P99值(如通过
SHOW SLAVE STATUS的Seconds_Behind_Master)。加上业务侧读请求的平均耗时(例如50ms)。
再乘以1.5~2倍的安全系数。
缺点:延迟双删会导致写请求的响应时间增加(因为sleep),不适用于低延迟要求的接口。
3.3 先更新数据库,再更新缓存(强烈不推荐)
并发写导致缓存被旧值覆盖
| 时间 | 线程A | 线程B | 数据库 | 缓存 |
|---|---|---|---|---|
| T1 | 读旧值V1 | - | V1 | V1 |
| T2 | 更新DB为V2 | - | V2 | V1 |
| T3 | - | 读旧值V1 | V2 | V1 |
| T4 | - | 更新DB为V3 | V3 | V1 |
| T5 | 更新缓存为V2 | - | V3 | V2(旧值覆盖新值) |
另外的问题:
如果更新缓存需要关联查询,会拖慢写请求。
缓存可能被从未被读取的数据占用内存。
唯一适用场景:缓存的数据就是数据库中的原始值,且更新代价极低(如计数器),且业务允许覆盖。即便如此,也建议用删除代替更新。
四、极端场景下的解决方案(深度展开)
4.1 读写并发导致的不一致(针对“先更新DB再删缓存”的漏洞)
方案一:订阅binlog(Canal)—— 推荐
原理:MySQL的binlog记录了所有数据变更,Canal伪装成从库拉取binlog,然后异步删除缓存。
详细配置步骤(缩短版,但关键点保留):
MySQL开启binlog,格式设为
ROW。部署Canal Server,配置
canal.properties。编写Canal Client,解析Entry,提取表名、主键ID。
删除对应缓存。
java
// Canal Client核心代码(更详细的解析逻辑) @CanalEventListener public class BinlogCacheCleaner { @Autowired private RedisTemplate<String, String> redisTemplate; @ListenPoint(destination = "example", schema = "shop", table = {"user"}) public void onUserChange(CanalEntry.Entry entry) { // 获取变更类型 CanalEntry.EventType eventType = entry.getHeader().getEventType(); if (eventType != CanalEntry.EventType.UPDATE && eventType != CanalEntry.EventType.DELETE) { return; } for (CanalEntry.RowData rowData : entry.getRowDataList()) { // 对于UPDATE,获取修改后的ID;对于DELETE,获取删除前的ID List<CanalEntry.Column> columns = (eventType == CanalEntry.EventType.DELETE) ? rowData.getBeforeColumnsList() : rowData.getAfterColumnsList(); String userId = null; for (CanalEntry.Column col : columns) { if ("id".equals(col.getName())) { userId = col.getValue(); break; } } if (userId != null) { String cacheKey = "user:" + userId; redisTemplate.delete(cacheKey); log.info("通过binlog删除缓存, key={}, eventType={}", cacheKey, eventType); } } } }优势:
业务代码零侵入,更新数据库后无需手动删缓存。
天然解决主从延迟问题(因为binlog是从主库拉取的,已经是最新数据)。
劣势:
架构复杂,引入Canal和MQ。
删除缓存失败时需要重试机制(可配合MQ)。
方案二:设置极短的缓存过期时间
如果业务允许,将缓存TTL设为1~5秒,那么不一致窗口最多也就几秒。适合对一致性要求不极高、但要求简单的场景。
java
redisTemplate.opsForValue().set(key, value, 3, TimeUnit.SECONDS);
4.2 缓存穿透/击穿/雪崩(生产级实现)
缓存穿透(查询不存在的数据)
现象:大量请求查询一个不存在的ID,缓存没有,每次都打到数据库,可能压垮DB。
解决方案一:布隆过滤器
java
@Component public class BloomFilterService { private BloomFilter<String> bloomFilter; @PostConstruct public void init() { // 预计数据量100万,期望误判率0.01 bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 1000000, 0.01); // 从数据库加载所有存在的user id List<Long> allIds = userMapper.selectAllIds(); for (Long id : allIds) { bloomFilter.put("user:" + id); } } public boolean mightExist(String key) { return bloomFilter.mightContain(key); } } // 在查询前使用 if (!bloomFilterService.mightExist("user:" + id)) { return null; // 直接返回,不查库 }注意:布隆过滤器有误判(认为存在实际不存在),所以仍需配合空值缓存。
解决方案二:缓存空对象
java
User user = userMapper.selectById(id); if (user == null) { // 缓存空值,过期时间短(60秒) redisTemplate.opsForValue().set(key, "", 60, TimeUnit.SECONDS); return null; }缓存击穿(热点Key过期)
现象:一个热点Key(如秒杀商品)在过期瞬间,大量并发请求同时查询数据库。
解决方案:互斥锁(推荐)
java
public Product getHotProduct(Long id) { String key = "product:" + id; String lockKey = "lock:product:" + id; // 1. 先查缓存 String cached = redisTemplate.opsForValue().get(key); if (cached != null) { return JSON.parseObject(cached, Product.class); } // 2. 尝试获取分布式锁(使用setnx + 过期时间) Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { // 双重检查,避免其他线程已经填充了缓存 cached = redisTemplate.opsForValue().get(key); if (cached != null) { return JSON.parseObject(cached, Product.class); } // 查询数据库 Product product = productMapper.selectById(id); if (product != null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 3600, TimeUnit.SECONDS); } else { redisTemplate.opsForValue().set(key, "", 60, TimeUnit.SECONDS); } return product; } finally { // 释放锁(需要确保是自己的锁,用lua脚本或value判断) redisTemplate.delete(lockKey); } } else { // 未获取到锁,自旋等待一小段时间后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getHotProduct(id); // 递归重试 } }其他方案:逻辑过期(缓存不设置物理过期,而是存储一个逻辑过期时间,由后台异步刷新),但实现更复杂。
缓存雪崩
现象:大量缓存同时过期,或Redis宕机,导致所有请求直接打到DB。
解决方案:
过期时间加随机偏移:
java
int baseTtl = 3600; int randomOffset = new Random().nextInt(300); // 0~300秒随机 redisTemplate.opsForValue().set(key, value, baseTtl + randomOffset, TimeUnit.SECONDS);
Redis高可用:使用Redis Cluster或Sentinel。
限流降级:在应用层对DB请求进行限流(如Sentinel、Hystrix)。
4.3 强一致性场景方案(权衡性能)
如果业务真的要求强一致性(例如扣减库存、转账),建议不使用缓存,直接读写数据库。如果非要使用缓存,可以采用读写锁(JVM级别),但仅适用于单机部署,且会严重降低并发。
java
@Service public class ConsistentInventoryService { private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); public void deductStock(Long productId, int quantity) { lock.writeLock().lock(); try { // 读数据库最新库存 int stock = inventoryMapper.selectStock(productId); if (stock >= quantity) { inventoryMapper.updateStock(productId, stock - quantity); // 同步更新缓存 redisTemplate.opsForValue().set("stock:" + productId, String.valueOf(stock - quantity)); } else { throw new InsufficientStockException(); } } finally { lock.writeLock().unlock(); } } public Integer getStock(Long productId) { lock.readLock().lock(); try { String cached = redisTemplate.opsForValue().get("stock:" + productId); if (cached != null) { return Integer.parseInt(cached); } Integer stock = inventoryMapper.selectStock(productId); redisTemplate.opsForValue().set("stock:" + productId, String.valueOf(stock)); return stock; } finally { lock.readLock().unlock(); } } }缺点:写操作会阻塞所有读操作,吞吐量急剧下降,只适合极低并发场景。
五、终极方案:阿里巴巴Canal + 消息队列(原第五章深度细化)
为什么说是终极方案?
完全解耦:业务代码不需要关心缓存删除,只需正常更新数据库。
可靠:消息队列保证至少一次投递,删除失败可重试。
顺序性:binlog天然有序,可保证同一行的更新顺序正确。
完整架构组件
text
MySQL主库 (binlog) → Canal Server (模拟从库) → Canal Client (可选) → RocketMQ/Kafka (topic: binlog_topic) → 消费者服务 → Redis删除
关键配置细节(Canal + RocketMQ)
Canal 配置(conf/example/instance.properties):
properties
# 数据库连接 canal.instance.master.address=127.0.0.1:3306 canal.instance.dbUsername=canal canal.instance.dbPassword=canal # 只订阅需要的表 canal.instance.filter.regex=shop\\.user,shop\\.order # 开启MQ模式 canal.mq.topic=binlog_topic canal.mq.partitionHash=shop\\.user:id,shop\\.order:user_id
消费者实现(RocketMQ):
java
@Component @RocketMQMessageListener(topic = "binlog_topic", consumerGroup = "cache_consumer") public class BinlogConsumer implements RocketMQListener<MessageExt> { @Autowired private RedisTemplate<String, String> redisTemplate; @Override public void onMessage(MessageExt message) { byte[] body = message.getBody(); // 解析Canal的protobuf格式(可使用Canal提供的FlatMessage) CanalMessage canalMessage = CanalMessage.parseFrom(body); String table = canalMessage.getTable(); if ("user".equals(table)) { for (CanalRow row : canalMessage.getRows()) { String userId = row.getAfterColumns().get("id"); String cacheKey = "user:" + userId; // 删除操作带重试 retryDelete(cacheKey, 3); } } } private void retryDelete(String key, int maxRetries) { for (int i = 0; i < maxRetries; i++) { try { redisTemplate.delete(key); log.info("通过MQ删除缓存成功, key={}", key); return; } catch (Exception e) { log.error("删除失败,重试 {}/{}", i+1, maxRetries, e); try { Thread.sleep(100 * (i + 1)); } catch (InterruptedException ignored) {} } } // 超过重试次数,记录到死信队列或告警 log.error("删除缓存最终失败, key={}", key); } }注意事项
Canal的高可用:部署多个Canal Server,使用ZooKeeper协调。
消费幂等性:消息可能重复投递,删除操作本身幂等,没问题。
延迟监控:从binlog产生到缓存删除的端到端延迟,建议<500ms。
六、生产环境最佳实践总结(大幅细化)
策略选择决策树
text
是否允许最多1秒的不一致? ├─ 是 → 读写分离? │ ├─ 是 → Canal+MQ(或延迟双删) │ └─ 否 → 先更新DB再删缓存 └─ 否 → 强一致性要求? ├─ 是 → 直接读数据库,不用缓存 └─ 否 → 先更新DB再删缓存 + 短TTL
关键监控指标(必须落地)
| 指标 | 计算方式 | 告警阈值 | 处理方式 |
|---|---|---|---|
| 缓存命中率 | 缓存命中次数 / 总请求次数 | < 85% | 检查过期策略、是否存在穿透 |
| 不一致时长 | 定时任务对比最新DB和缓存的时间差 | > 2秒 | 检查主从延迟、Canal消费lag |
| 删除缓存失败率 | 删除异常次数 / 总删除次数 | > 0.1% | 检查Redis连接池、网络 |
| Canal消费延迟 | 当前binlog时间戳 - 最后消费时间戳 | > 3秒 | 增加消费者线程或扩容 |
降级方案(当缓存出问题时)
java
@Component public class CacheDegradeService { @Value("${cache.degrade.enabled:false}") private boolean degradeEnabled; public User getUser(Long id) { if (degradeEnabled) { // 降级:直接查数据库 return userMapper.selectById(id); } // 正常缓存流程 return getUserWithCache(id); } }面试高频追问与回答(新增)
Q1:为什么删缓存比更新缓存好?
A:更新缓存需要业务计算,且可能覆盖其他并发写操作;删除缓存是幂等的,下次读时懒加载,避免“写多读少”时浪费资源。
Q2:延迟双删的延迟时间怎么定?
A:延迟 = 主从最大延迟(P99) + 读请求平均耗时 + 200ms缓冲,一般取500~1000ms。可通过监控动态调整。
Q3:如果删除缓存失败了怎么办?
A:记录日志,发送MQ异步重试;同时可设置较短的缓存TTL(如60秒)作为兜底,即使删除失败,过期后也会自动刷新。
Q4:Canal方案会不会导致消息积压?
A:会,如果消费速度跟不上binlog产生速度。解决方案:增加消费者并行度(RocketMQ增加queue数),或者对不重要表过滤掉。
Q5:缓存和数据库完全一致能做到吗?
A:在分布式理论下做不到强一致性,除非牺牲性能和可用性(如使用分布式锁)。一般做到最终一致性即可。
七、写在最后
一句话总结:没有完美的方案,只有适合的方案。
大多数场景:先更新数据库,再删除缓存(简单可靠,覆盖99%需求)。
读写分离且对一致性要求稍高:先更新数据库 + 延迟双删。
追求极致性能和零侵入:Canal + MQ。
强一致性:直接读数据库,或用读写锁(性能极低)。
最后一条建议:一定要给缓存设置合理的过期时间,这是最后一道防线。即使所有删除操作都失败了,缓存也会自动淘汰,最终一致。
希望这篇文章能帮你搞懂Redis与数据库一致性问题。如有疑问,欢迎评论区交流!