返回文章列表
面试考点
Redis-缓存一致性
Redis-缓存一致性
KKiana
2025年10月26日29 次阅读

Redis缓存一致性策略
系统化学习Redis与MySQL数据一致性解决方案
技术栈: Redis、MySQL、Canal、消息队列
📚 目录
缓存一致性策略
策略概览
📊 五种策略快速对比
| 策略 | 操作方式 | 一致性 | 性能 | 适用场景 | 推荐度 |
|---|---|---|---|---|---|
| Cache-Aside | 应用直接操作 | 强 | 中 | 通用业务(用户信息、商品详情、文章内容) | ⭐⭐⭐⭐⭐ |
| Read-Through | 缓存层自动读 | 中 | 读快 | 读密集(配置中心、字典数据、热门榜单) | ⭐⭐⭐⭐ |
| Write-Through | 缓存层同步写 | 强 | 写慢 | 强一致(账户余额、库存、订单状态) | ⭐⭐⭐ |
| Write-Behind | 缓存层异步写 | 弱 | 写快 | 高并发写(点赞数、浏览量、在线人数) | ⭐⭐⭐ |
| Write-Around | 跳过缓存 | 弱 | 中 | 冷数据(操作日志、历史归档、批量导入) | ⭐⭐ |
🎯 推荐方案
通用场景(90%):
Cache-Aside + 延迟双删 + Canal监听从库binlog多层防护:
- 应用DELETE cache(覆盖70%)
- 延迟双删500ms(+25%)
- Canal监听从库(+4.9%)
- TTL自动过期(兜底100%)
特殊场景:
- 强一致性 → Write-Through
- 极致性能 → Write-Behind
- 冷数据 → Write-Around
策略详解
1. Cache-Aside(旁路缓存)⭐ 最常用
定义: 应用程序直接操作缓存和数据库
操作流程:
- 读: 查缓存 → 未命中查DB → 写缓存 → 返回
- 写: 更新DB → 删除缓存
特点:
- ✅ 应用完全控制
- ✅ 实现简单灵活
- ⚠️ 需处理并发问题
适用: 读多写少的通用业务
2. Read-Through(读穿)
定义: 缓存层自动负责数据加载
操作流程:
- 读: 应用调用缓存层 → 缓存层自动查DB并写缓存
- 写: 直接写DB(需配合其他策略)
特点:
- ✅ 应用逻辑简单
- ✅ 缓存加载统一管理
- ⚠️ 需防止缓存击穿
适用: 读密集型应用,使用缓存库(如go-cache、groupcache)
3. Write-Through(写穿)
定义: 同步更新缓存和数据库
操作流程:
- 读: 同Read-Through
- 写: 缓存层同步写DB → 成功后更新缓存 → 返回
特点:
- ✅ 强一致性
- ✅ 数据不丢失
- ❌ 写性能差(同步阻塞)
- ⚠️ 需要加锁(防止并发更新缓存乱序)
适用: 强一致性要求,写操作不频繁
三种加锁方案:
| 方案 | 原理 | 优点 | 缺点 | 适用场景 | 推荐度 |
|---|---|---|---|---|---|
| 分布式锁 | Redis锁保护整个写流程 | 跨服务器生效 | 性能开销大 | 金融账户、库存 | ⭐⭐⭐⭐⭐ |
| 数据库行锁 | SELECT FOR UPDATE | 利用DB机制 | 长事务影响并发 | 中等并发 | ⭐⭐⭐⭐ |
| 乐观锁 | 版本号CAS更新 | 无锁高性能 | 冲突时需重试 | 读多写少 | ⭐⭐⭐ |
💡 为什么Cache-Aside不需要加锁?
- Cache-Aside是删除缓存(幂等操作,重复删除无影响)
- Write-Through是更新缓存(顺序敏感,旧值可能覆盖新值)
4. Write-Behind(写回)
定义: 异步批量写数据库
操作流程:
- 读: 同Read-Through
- 写: 立即更新缓存 → 加入队列 → 立即返回 → 后台批量写DB
特点:
- ✅ 写性能极高
- ✅ 减少DB压力
- ❌ 可能丢数据
适用: 高并发写入,可容忍数据丢失(点赞、统计)
5. Write-Around(绕写)
定义: 写操作跳过缓存,依赖TTL过期
操作流程:
- 读: 查缓存 → 未命中/过期查DB → 写缓存
- 写: 只写DB,不管缓存
特点:
- ✅ 避免缓存污染
- ✅ 写操作简单
- ❌ 短期数据不一致
适用: 冷数据、批量导入、写多读少
补偿机制
📋 三种补偿方式对比
| 方式 | 流程 | 延迟 | 可靠性 | 复杂度 | 成本 | 推荐 |
|---|---|---|---|---|---|---|
| 延迟双删 | DELETE → UPDATE DB → 延迟500ms → DELETE | 低 | 中 | 低 | 低 | ⭐⭐⭐ |
| Canal监听binlog | Canal订阅binlog → 解析变更 → DELETE cache | 中 | 高 | 中 | 中 | ⭐⭐⭐⭐⭐ |
| MQ异步 | UPDATE DB → 发MQ → 消费者DELETE cache | 中 | 高 | 高 | 高 | ⭐⭐⭐⭐ |
1. 延迟双删
实现:
- 第1次删除缓存
- 更新数据库
- 延迟N毫秒(通常500ms)
- 第2次删除缓存
关键: 延迟时间 > 读请求最大耗时 + 主从延迟
2. Canal监听binlog ⭐ 推荐
实现:
- Canal订阅MySQL binlog(推荐从库)
- 解析数据变更事件
- 配合版本号判断:
if (cacheVersion < dbVersion) { DELETE cache }
关键配置:
- 监听从库binlog(利用主从延迟作为保护窗口)
- 只需监听一个从库(选延迟最大的最保守)
优势:
- 从库同步后才删除,避免"Canal删除后又有旧值写入"
3. MQ异步
实现:
- 更新数据库后发送MQ消息
- 消费者异步删除缓存
- 失败重试机制
适用: 已有MQ基础设施,需要解耦
并发问题
🔴 Cache-Aside并发场景
问题:
T1: 读请求查DB(旧值)
T2: 写请求更新DB
T3: 写请求DELETE cache
T4: 读请求SET cache(旧值)← 在DELETE之后!发生条件:
- 时序:T1 < T2 < T3 < T4
- 网络延迟导致Redis命令到达顺序不确定
- 主从延迟导致读从库获取旧值
发生概率: 1-5%(正常)/ 10-30%(主从延迟大/高并发)
✅ 多层防护方案
| 方案 | 原理 | 覆盖率 | 成本 |
|---|---|---|---|
| 延迟双删 | 500ms后再删一次 | 90% | 低 |
| Canal监听从库 | 从库同步后删除 | 95% | 中 |
| 版本号机制 | Canal判断版本号 | 99% | 中 |
| Lua脚本 | 写缓存时拒绝旧版本 | 99.9% | 中 |
| TTL | 自动过期 | 100% | 低 |
💡 版本号机制(推荐)
1. 数据库设计
ALTER TABLE user ADD COLUMN version BIGINT DEFAULT 0;
UPDATE user SET name=?, version=version+1 WHERE id=?;2. 缓存数据
{
"id": 1,
"name": "张三",
"version": 15
}3. Canal判断
if (cacheVersion < dbVersion) {
DELETE cache; // 旧值,删除
}4. Lua脚本防护
-- 写缓存时拒绝旧版本
if existingVersion >= newVersion then
return 0 -- 拒绝写入
end
SET key value常见问题Q&A
❓ Write-Around vs Cache-Aside 区别?
| 策略 | 写操作 | 一致性 |
|---|---|---|
| Write-Around | 只写DB,不管缓存 | 依赖TTL过期 |
| Cache-Aside | 写DB + 删除缓存 | 立即失效 |
❓ 先更新DB还是先删Cache?
推荐:先更新DB,再删Cache
| 顺序 | 优点 | 缺点 |
|---|---|---|
| 先DB后删Cache | 并发窗口小,DB优先持久化 | 主从延迟可能导致旧值覆盖 |
| 先删Cache后DB | 缓存先失效 | 并发窗口大,DB失败导致穿透 |
❓ 事务与缓存删除时机?
错误:
func (s *Service) Update(user *User) error {
tx, _ := s.db.Begin()
defer tx.Rollback() // 回滚保护
if err := updateDB(tx, user); err != nil {
return err
}
deleteCache(user.ID) // ❌ 事务可能回滚,但缓存已删
return tx.Commit()
}正确方案1:事务提交后删除
func (s *Service) Update(user *User) error {
tx, _ := s.db.Begin()
defer tx.Rollback()
if err := updateDB(tx, user); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
// ✅ 事务提交成功后再删除缓存
deleteCache(user.ID)
return nil
}正确方案2:defer + 检查error
func (s *Service) Update(user *User) (err error) {
tx, _ := s.db.Begin()
defer tx.Rollback()
// ✅ 使用defer,只在成功时删除
defer func() {
if err == nil {
deleteCache(user.ID)
}
}()
if err = updateDB(tx, user); err != nil {
return err
}
return tx.Commit()
}❓ Canal监听主库还是从库?
| 监听对象 | 优点 | 缺点 | 推荐 |
|---|---|---|---|
| 从库 | 利用主从延迟保护,Canal删除时从库已同步 | 延迟稍长 | ✅ |
| 主库 | 延迟短 | 可能在从库同步前删除,读从库仍写旧值 | ❌ |
❓ Canal需要监听多个从库吗?
答案:否,只需监听一个
原因:
- 所有从库同步相同的binlog
- 监听任一从库都能获取完整变更
- 配合版本号机制保护其他从库
选择策略:
- 小型系统:任意从库
- 中型系统:延迟最大的从库(最保守)
- 大型系统:专用Canal从库(不接业务流量)
❓ 为什么DELETE在SET之前发送,却可能后到达Redis?
原因:
- 网络延迟差异 - 不同TCP连接延迟不同
- IO多路复用 - Redis epoll随机处理就绪连接
- TCP重传/拥塞 - 导致顺序变化
结论: 分布式系统中网络不可靠,消息顺序不确定
❓ 如何选择缓存策略?
决策树:
业务场景?
├─ 读多写少 → Cache-Aside(最常用)
├─ 读密集 → Read-Through(go-cache/groupcache)
├─ 强一致 → Write-Through(金融、账户)
├─ 高并发写 → Write-Behind(点赞、统计)
└─ 冷数据 → Write-Around(日志、归档)
是否需要补偿?
├─ 是 → 延迟双删 + Canal + 版本号
└─ 否 → 依赖TTL(可容忍不一致)📝 知识库持续更新中...
面试考点
评论 (0)
后参与评论
// 暂无评论,来说点什么吧