缓存技术
#缓存 · #Redis · #MySQL · #一致性 · #穿透 · #击穿 · #雪崩
缓存是提升系统性能的核心手段,但引入缓存也带来了数据一致性、可用性等一系列挑战。本专题聚焦缓存的设计模式、常见问题与解决方案。
为什么需要缓存
- 降低延迟:内存访问 ~100ns vs 磁盘 ~10ms,差距 5 个数量级
- 减轻数据库压力:热点数据由缓存承担,DB 只处理冷数据和写入
- 提升吞吐:单机 Redis 可达 10w+ QPS,远超 MySQL 的数千 QPS
缓存读写模式
Cache Aside(旁路缓存)
最常用的模式——也是绝大多数业务系统默认采用的方案:
mermaid
sequenceDiagram
participant App as "应用"
participant Cache as "缓存 (Redis)"
participant DB as "数据库"
Note over App,DB: 读流程
App->>Cache: 1. 查缓存
alt 命中
Cache-->>App: 返回数据 ✅
else 未命中
Cache-->>App: nil
App->>DB: 2. 查数据库
DB-->>App: 返回数据
App->>Cache: 3. 写回缓存
end
Note over App,DB: 写流程
App->>DB: 1. 更新数据库
DB-->>App: OK
App->>Cache: 2. 删除缓存(而非更新)为什么是"删除缓存"而非"更新缓存":更新缓存有两个问题——(1) 并发写顺序不确定可能导致缓存和 DB 不一致;(2) 有些数据是计算出来的(聚合/Join),更新开销大。删除缓存让它"按需加载"是最简单的正确方案。
Read Through / Write Through
缓存层作为代理,应用只与缓存交互:
读:缓存未命中时,缓存层自动从 DB 加载
写:缓存层同步写入 DB + 缓存优点:应用逻辑简单 缺点:需要缓存层支持(如 Guava LoadingCache)
Write Behind(异步写回)
写:只写缓存,异步批量刷入 DB优点:写入性能极高 缺点:数据丢失风险,一致性最弱
缓存常见问题
缓存穿透(Cache Penetration)
现象:查询一个不存在的数据,缓存和 DB 都没有,每次请求都打到 DB。
解决方案:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 缓存空值 | 将 null 结果也缓存,设置较短 TTL | 偶发性穿透 |
| 布隆过滤器 | 在缓存前加一层 BloomFilter 拦截 | 大量恶意请求 |
| 参数校验 | 在入口层拦截非法参数 | 可预判的非法请求 |
布隆过滤器的误判率推导——为什么能用极少内存拦截大量无效请求?
布隆过滤器原理:
- m 位的 bit 数组(初始全 0)
- k 个独立哈希函数
- 插入元素: 计算 k 个哈希值 → 对应 k 个位置置 1
- 查询元素: 计算 k 个哈希值 → 如果所有位置都是 1 → "可能存在"
→ 如果任一位置是 0 → "一定不存在"
误判率推导:
设已插入 n 个元素,bit 数组大小 m,哈希函数 k 个
单次哈希后某个特定位仍为 0 的概率:
P(某位=0) = (1 - 1/m)^{kn}
当 m 较大时:
(1 - 1/m)^{kn} ≈ e^{-kn/m}
误判率 = 所有 k 个位都恰好为 1 的概率:
FPR = (1 - e^{-kn/m})^k
最优哈希函数个数(使 FPR 最小):
k_opt = (m/n) × ln2 ≈ 0.693 × (m/n)
代入最优 k 后:
FPR_min = (1/2)^k = (0.6185)^{m/n}
实际工程参数选择:
| 目标误判率 | m/n (每元素bit数) | k (哈希函数数) | 内存开销 |
|-----------|:-:|:-:|---------|
| 1% | 9.6 bits | 7 | 1.2 MB / 百万元素 |
| 0.1% | 14.4 bits | 10 | 1.8 MB / 百万元素 |
| 0.01% | 19.2 bits | 13 | 2.4 MB / 百万元素 |
对比: 存储 100 万个 key(平均 32 字节)需要 32MB
布隆过滤器只需 1.2MB 就能达到 1% 误判率 → 节省 96% 内存!
Redis 中的布隆过滤器:
RedisBloom 模块: BF.ADD / BF.EXISTS
默认参数: error_rate=0.01, capacity=100
底层: 多层 sub-filter(可动态扩容)python
# 布隆过滤器 + 缓存空值组合方案
def get_data(key):
# 第一层:布隆过滤器快速判断
if not bloom_filter.might_contain(key):
return None
# 第二层:查缓存
value = redis.get(key)
if value == "NULL_PLACEHOLDER":
return None
if value is not None:
return value
# 第三层:查 DB
value = db.query(key)
if value is None:
redis.setex(key, 60, "NULL_PLACEHOLDER") # 缓存空值 60s
else:
redis.setex(key, 3600, value)
return value缓存击穿(Cache Breakdown)
现象:某个热点 Key 过期瞬间,大量并发请求同时打到 DB。
解决方案:
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 互斥锁 | 只允许一个线程回源,其他等待 | 保证一致性,但有等待开销 |
| 逻辑过期 | 不设 TTL,由业务判断是否过期并异步刷新 | 无等待,但有短暂脏数据 |
| 热点预加载 | 提前续期或永不过期 | 简单,但需识别热点 |
singleflight 实现原理——互斥锁方案的进化版:
go
// singleflight 的核心思想:同一时刻对同一个 key 的并发请求,只执行一次实际操作
// 其他请求等待并共享结果
import "golang.org/x/sync/singleflight"
var g singleflight.Group
func getHotData(key string) (interface{}, error) {
// Do 方法:如果 key 已经有 goroutine 在执行 fn,
// 当前 goroutine 会阻塞等待,共享第一个 goroutine 的返回值
v, err, _ := g.Do(key, func() (interface{}, error) {
return db.Query(key) // 只有第一个 goroutine 真正执行
})
return v, err
}singleflight 的内部原理:
type Group struct {
mu sync.Mutex
m map[string]*call // key → 正在进行中的调用
}
type call struct {
wg sync.WaitGroup // 用于等待结果
val interface{} // 调用结果
err error
}
Do(key, fn) 的工作流程:
goroutine-1 调用 Do("user:123", fn):
mu.Lock()
检查 m["user:123"] → 不存在
创建 call{val: nil, err: nil}, wg.Add(1)
m["user:123"] = call
mu.Unlock()
执行 fn() → 得到结果 → call.val, call.err
wg.Done() ← 唤醒等待者
goroutine-2 同时调用 Do("user:123", fn):
mu.Lock()
检查 m["user:123"] → 已存在 → 返回 call
mu.Unlock()
call.wg.Wait() ← 阻塞在这里,等 goroutine-1 完成
→ call.wg 被 Done() 唤醒 → 直接返回 call.val, call.err
← fn 没有被执行!
关键优势:
- 比 Redis 分布式锁更轻量(纯内存操作,无网络 IO,~50ns vs ~1ms+)
- 自动共享结果,无需手动编码"等待-重试"逻辑
- 内置 forget 机制(g.Forget(key)),防止 key 永久占用
- 但仅在单个进程内有效,多实例仍需配合 Redis 锁python
# 互斥锁方案
def get_hot_data(key):
value = redis.get(key)
if value is not None:
return value
# 尝试获取分布式锁
lock_key = f"lock:{key}"
if redis.set(lock_key, "1", nx=True, ex=10):
try:
value = db.query(key)
redis.setex(key, 3600, value)
finally:
redis.delete(lock_key)
return value
else:
# 等待后重试
time.sleep(0.05)
return get_hot_data(key)缓存雪崩(Cache Avalanche)
现象:大量 Key 同时过期,或缓存服务宕机,请求全部涌向 DB。
解决方案:
- 随机过期时间:在基础 TTL 上加随机偏移,避免集中过期
- 多级缓存:L1(本地)+ L2(Redis),即使 Redis 挂了本地缓存仍可用
- 熔断降级:DB 压力过大时触发熔断,返回默认值或缓存旧数据
- 集群高可用:Redis Sentinel / Cluster 保证缓存层不宕机
python
import random
# 随机过期时间
base_ttl = 3600
random_ttl = base_ttl + random.randint(0, 600) # 3600~4200s
redis.setex(key, random_ttl, value)回源风暴:为什么缓存问题最后常常炸的是 MySQL
很多线上事故不是“Redis 慢了一点”,而是缓存问题沿着依赖链一路放大:
mermaid
flowchart LR
U["用户流量"] --> APP["应用"]
APP --> C["缓存 Redis/L1"]
C -->|"miss / 超时 / 宕机"| DB["MySQL"]
DB -->|"慢查询 / 连接池打满"| APP
APP -->|"线程 / goroutine 堆积"| FAIL["超时、502、雪崩"]text
热 Key 失效 / Redis 抖动
→ 大量请求同时回源 DB
→ DB QPS 暴涨、连接池打满
→ SQL 变慢、事务堆积、锁等待增加
→ 应用线程/goroutine 堆积
→ 更多请求超时重试
→ 整个链路雪崩所以缓存设计本质上是在保护下游数据库,不是只为了“让读更快”。
| 层面 | 防护动作 |
|---|---|
| 应用层 | singleflight / 分布式锁 / 本地缓存 / 限流 |
| 缓存层 | TTL 打散、热点预热、Cluster 高可用 |
| DB 层 | 连接池上限、只读副本、慢查询治理 |
| 架构层 | 熔断、降级、隔离热点、异步化 |
缓存与数据库一致性
经典问题:先更新 DB 还是先删缓存?
| 策略 | 问题 |
|---|---|
| 先删缓存,再更新 DB | 并发读可能将旧值重新写入缓存 |
| 先更新 DB,再删缓存 | 删缓存失败则不一致(概率较低) |
延迟双删 — 为什么需要删两次
单次删除的并发问题:
mermaid
sequenceDiagram
participant A as "写请求"
participant Cache as "Redis"
participant DB as "MySQL"
participant B as "并发读请求"
A->>Cache: 删除缓存
B->>Cache: 读缓存 (miss)
B->>DB: 读 DB (此时 A 还没写完!)
DB-->>B: 返回旧数据
A->>DB: 更新 DB (新数据写入)
B->>Cache: 将旧数据写回缓存
Note over Cache: 🔴 缓存里是旧数据!<br/>直到下次写操作或 TTL 过期
Note over A,B: 延迟双删修复:
A->>A: sleep(200ms)
A->>Cache: 第二次删除
Note over Cache: ✅ 旧数据被清理python
def update_with_double_delete(key, value):
redis.delete(key) # 第 1 次删除
db.update(key, value) # 更新 DB
time.sleep(0.2) # 等待并发读完成
redis.delete(key) # 第 2 次删除(兜底清理)
# 更可靠的做法:异步延迟删除
# threading.Thread(target=lambda: (
# time.sleep(0.5), redis.delete(key)
# )).start()sleep 时间怎么定:通常取"一次读请求从 DB 加载到写回缓存的最大耗时"。如果业务可接受短暂不一致(如个人设置页),单次删除 + 短 TTL 足够;如果是支付/库存,延迟双删 + 异步补偿是底线。
延迟双删的 sleep 时间分析——为什么不能太短也不能太长:
时间轴分析:
T0: 写请求删除缓存
T1: 并发读请求查缓存(miss)→ 查 DB
T2: 写请求更新 DB 完成
T3: 并发读请求将 DB 查到的值写回缓存
如果 sleep < T3 - T0:
第二次删除发生在读请求写回缓存之前 → 旧数据仍会进入缓存 → 删除无效 ❌
如果 sleep >> T3 - T0:
不一致窗口拉长 → 用户在一段时间内看到旧数据
最佳 sleep = 读请求"查DB → 写回缓存"的最大耗时 × 安全系数
≈ (DB查询P99 + 网络RTT + 序列化) × 1.5
≈ 通常在 200ms ~ 1s 之间不一致窗口的量化分析——先更新 DB 再删缓存的并发窗口:
不一致发生的精确条件:
设 T_read = 读请求查DB到写回缓存的耗时
设 T_write = 写请求更新DB的耗时
不一致序列(概率极低):
1. 读请求查缓存 → miss
2. 写请求更新 DB → 成功
3. 写请求删除缓存 → 成功(但读请求还没写回!)
4. 读请求查 DB → 拿到旧数据?
→ 等等!步骤 2 已经更新了 DB → 读请求读到的是新数据 → 没有不一致
真正导致不一致的序列:
1. 缓存过期/被删除
2. 读请求查缓存 → miss
3. 读请求查 DB → 此时写请求还没更新完 → 拿到旧数据 ← 关键!
4. 写请求更新 DB 完成
5. 写请求删除缓存(此时缓存为空,删除无效果)
6. 读请求将旧数据写回缓存 → 不一致!
这个窗口 = P(缓存miss) × P(读查DB期间有写操作) × P(写完成时间 > 读查DB时间)
在大部分场景下:
P(缓存命中率高) → P(miss) 很小
读查DB在毫秒级完成 → 窗口极窄
→ 实际不一致概率 < 0.001%binlog + MQ 异步删缓存——生产级一致性的最终方案:
延迟双删仍然有失败的可能(第二次删除失败、服务重启)。binlog 异步方案通过监听数据库变更日志,保证了最终一致性:
mermaid
sequenceDiagram
participant App as 应用
participant DB as MySQL
participant Canal as Canal (binlog监听)
participant MQ as Kafka/RocketMQ
participant Consumer as 缓存更新消费者
participant Redis as Redis
App->>DB: UPDATE user SET name='new' WHERE id=123
DB->>DB: 写入 binlog (row 格式)
Canal->>DB: 伪装成 slave,拉取 binlog
Canal->>MQ: 发送消息 {table:user, id:123, type:UPDATE, old:{name:'old'}, new:{name:'new'}}
MQ->>Consumer: 消费消息
Consumer->>Redis: DEL user:123 (或 SET user:123 new_data)
Note over Consumer,Redis: 消费失败?MQ 自动重试 → 最终一定删除成功 ✅
应用也可以同时删除(双保险):
App->>Redis: DEL user:123 (同步删除,减少不一致窗口)go
// Canal 方案的关键实现要点
// 1. 消费者幂等性:同一条 binlog 可能被重复消费
func handleBinlogEvent(event BinlogEvent) error {
// 用 binlog 的 (filename, position) 做去重 key
dedupKey := fmt.Sprintf("binlog:%s:%d", event.FileName, event.Position)
if !redis.SetNX(dedupKey, "1", 10*time.Minute) {
return nil // 已处理过
}
// 删除或更新缓存
switch event.Type {
case "UPDATE", "DELETE":
redis.Del(cacheKey(event.Table, event.Row["id"]))
case "INSERT":
// 不删缓存,等读时自然加载
}
return nil
}
// 2. 顺序性保证:同一行的操作必须按顺序处理
// Kafka: 用 主键ID 做分区 key → 同一行的变更进入同一分区 → 顺序消费
// RocketMQ: 用 MessageQueueSelector 按 ID 选择队列
// 3. 重试机制
func consumeWithRetry(event BinlogEvent) {
for i := 0; i < maxRetries; i++ {
if err := handleBinlogEvent(event); err == nil {
return
}
time.Sleep(backoff(i)) // 指数退避
}
log.Error("finally failed", event) // 进入死信队列,人工处理
}各一致性方案的对比总结:
| 方案 | 不一致窗口 | 复杂度 | 失败处理 | 适用 |
|---|---|---|---|---|
| 先更新DB,后删缓存 | 极窄 (~ms) | ⭐ 低 | 删失败需等 TTL 过期 | 大多数场景 |
| 延迟双删 | 200ms~1s | ⭐⭐ 中 | 第二次删失败仍有残留 | 中等一致性 |
| binlog + MQ | 秒级 | ⭐⭐⭐ 高 | MQ 重试 → 最终必成功 | 支付/库存 |
| 分布式事务 (Seata) | 0 (强一致) | ⭐⭐⭐⭐⭐ | 两阶段提交 | 金融核心 |
一致性方案怎么选:不要只会“延迟双删”
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 用户资料、商品详情 | 更新 DB 后删缓存 + 短 TTL | 允许短暂不一致,方案最简单 |
| 高并发热点读 | 删缓存 + singleflight/互斥回源 | 防止热点击穿 |
| 库存、优惠券余量 | 缓存只做展示,加写路径串行化/原子扣减 | 不能只依赖最终一致性 |
| 搜索索引、推荐结果 | MQ 异步更新缓存/索引 | 接受秒级延迟 |
| 强一致要求 | 读写都走主存储,缓存只做旁路加速 | 不要把缓存当真相来源 |
一个很重要的判断原则:
text
缓存适合加速“读模型”
不适合承担“最终正确性来源”如果业务的正确性依赖“缓存一定和 DB 完全一致”,通常说明架构职责已经混乱。
热 Key 问题
识别热 Key
- Redis 4.0+
redis-cli --hotkeys - 客户端统计(如 Lettuce metrics)
- 代理层统计(如 Codis/Twemproxy)
解决方案
| 方案 | 原理 |
|---|---|
| 本地缓存 | 热 Key 提升到 L2,减少 Redis 压力 |
| Key 分片 | 将 hot_key 拆为 hot_key_1 ~ hot_key_N,分散到不同节点 |
| 读写分离 | 读请求走从节点 |
Key 分片的一致性问题:
分片方案让读请求随机选一个分片读取,写请求写入所有分片。但写入所有分片不是原子操作:
python
# 写入所有分片
for i in range(SHARD_COUNT):
redis.setex(f"{key}:{i}", ttl, value)写入时可能的异常:
[分片0] ✅ 写入成功
[分片1] ✅ 写入成功
[分片2] ❌ 网络超时 → 没写入
[分片3] ✅ 写入成功
...
后果:
- 客户端随机读到一个分片,可能拿到旧值 → 短暂不一致
- 分片 2 上的旧值一直存在,直到 TTL 过期
缓解方案:
1. 客户端做"读修复":读到旧值后,检查版本号,如果不匹配则写回新值
2. 仅用于"低一致要求"的场景(如推荐列表、排行榜)
3. 对一致性敏感的数据,用本地缓存(单实例内一致)代替分片
4. Redis Cluster 模式的 hash slot 自动分片,无需应用层分片python
# Key 分片方案
import random
SHARD_COUNT = 10
def get_hot_key(key):
# 随机选择一个分片读取
shard_key = f"{key}:{random.randint(0, SHARD_COUNT - 1)}"
return redis.get(shard_key)
def set_hot_key(key, value, ttl):
# 写入所有分片
for i in range(SHARD_COUNT):
shard_key = f"{key}:{i}"
redis.setex(shard_key, ttl, value)大 Key 问题
危害
- 网络带宽占用大,阻塞其他请求
- 单次操作耗时长,触发慢查询
- 集群数据倾斜,内存不均衡
- 过期删除时阻塞主线程
解决方案
- 拆分:Hash 大 Key 按字段分组拆为多个小 Hash
- 压缩:对 Value 进行 gzip/snappy 压缩
- 异步删除:Redis 4.0+
UNLINK替代DEL - 渐进式处理:
HSCAN/SSCAN分批处理
缓存预热与降级
预热策略
- 启动预热:服务启动时主动加载热点数据
- 定时预热:定时任务刷新即将过期的热点
- 流量预热:大促前通过压测流量预热缓存
降级策略
python
def get_with_fallback(key):
try:
value = redis.get(key)
if value:
return value
except RedisException:
pass # Redis 不可用,降级
# 降级方案:查本地缓存或返回默认值
value = local_cache.get(key)
if value:
return value
return default_value(key)本地缓存实现对比
本地缓存(L1)是多级缓存架构中延迟最低的一层(纳秒级),但容量有限且存在进程间不一致问题。选型需要权衡淘汰策略、并发性能和内存效率:
| 维度 | Go sync.Map | Go Ristretto | Java Caffeine | Java Guava Cache |
|---|---|---|---|---|
| 淘汰策略 | 无(只增不删) | TinyLFU + 采样 | W-TinyLFU | LRU |
| 并发模型 | 读写分离 Map | 分片 + 无锁 ring buffer | 分段锁 + 异步写 | 分段锁 |
| 命中率 | — | 高(接近最优) | 极高(业界最优) | 中等 |
| 内存开销 | 高(双 Map) | 低(Bloom Filter 计数) | 低 | 中 |
| 过期支持 | ❌ | ✅ TTL | ✅ TTL + 访问过期 | ✅ TTL + 访问过期 |
| 统计/监控 | ❌ | ✅ | ✅ 命中率/淘汰数 | ✅ |
| 适用场景 | 读多写少的简单缓存 | Go 通用本地缓存 | Java 通用本地缓存 | 旧项目兼容 |
W-TinyLFU 算法原理
Caffeine 的核心创新——结合了 LRU 的"新鲜度"和 LFU 的"频率"优势:
mermaid
flowchart LR
New["新元素"] --> Window["Window Cache<br/>(1% 容量, LRU)<br/>保护新元素"]
Window -->|"淘汰"| Filter{"TinyLFU<br/>频率过滤器"}
Filter -->|"频率高于 Main 淘汰候选"| Main["Main Cache<br/>(99% 容量, 分段 LRU)"]
Filter -->|"频率低"| Discard["丢弃"]
Main -->|"淘汰候选"| Filter为什么 Caffeine 命中率最高:传统 LRU 对突发扫描(一次性大量访问)很脆弱——扫描数据会把热点数据挤出去。TinyLFU 用 Count-Min Sketch(4 bit 计数器 + 4 个哈希函数)以极低内存(每元素 8 字节)估算访问频率,只有频率足够高的元素才能进入主缓存。
Go Ristretto 使用示例
go
import "github.com/dgraph-io/ristretto"
cache, _ := ristretto.NewCache(&ristretto.Config{
NumCounters: 1e7, // 跟踪频率的计数器数量(10x 最大条目数)
MaxCost: 1 << 30, // 最大内存 1GB
BufferItems: 64, // 每个 Get buffer 大小
})
// 写入(cost = 对象大小估算)
cache.Set("user:123", user, int64(unsafe.Sizeof(user)))
cache.Wait() // 等待异步写入完成
// 读取
if val, found := cache.Get("user:123"); found {
user := val.(*User)
}缓存容量规划
缓存不是越大越好——过大浪费内存,过小命中率低。容量规划的核心公式:
所需缓存容量 = 热点数据量 × 单对象大小 × 安全系数
其中:
热点数据量 = 总数据量 × 热点比例(通常 20/80 法则)
安全系数 = 1.2 ~ 1.5(预留碎片和元数据开销)实战估算示例
场景:电商商品缓存
- 总商品数:1000 万
- 热点商品(80% 流量集中在 20% 商品):200 万
- 单商品缓存大小:~2KB(JSON 序列化)
- 安全系数:1.3
所需 Redis 内存 = 200万 × 2KB × 1.3 ≈ 5.2 GB
→ 选择 8GB Redis 实例(预留 GC 和 fork 开销)命中率与容量的关系
命中率
│
│ ╭─────────────────── 接近 100%
│ ╱
│ ╱ ← 边际收益递减点(通常 85-95%)
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
│ ╱
└──────────────────────────── 缓存容量
0 热点20% 50% 100%
经验值:
- 缓存 20% 数据 → 命中率 ~80%
- 缓存 30% 数据 → 命中率 ~90%
- 缓存 50% 数据 → 命中率 ~95%
- 超过 50% 后边际收益极低QPS 容量验证
验证公式:
Redis 实例 QPS 上限 = 单实例 QPS × 节点数
业务所需 QPS = 总 QPS × 缓存命中率
例:总 QPS = 10万,命中率 90%
→ Redis 需承受 9万 QPS
→ 单实例 10万 QPS → 1 个主节点即可
→ 考虑读写分离:1 主 2 从,读 QPS 分摊到从节点多级缓存一致性
多级缓存(L1 本地 + L2 Redis + L3 DB)的核心挑战是:L1 是进程内缓存,多个实例的 L1 之间无法直接通信。当数据变更时,如何让所有实例的 L1 失效?
失效传播方案
mermaid
sequenceDiagram
participant App1 as "实例 A"
participant App2 as "实例 B"
participant Redis as "Redis (L2)"
participant PubSub as "Redis Pub/Sub"
participant DB as "MySQL"
Note over App1: 数据变更
App1->>DB: 1. 更新 DB
App1->>Redis: 2. 删除 L2 缓存
App1->>App1: 3. 删除本地 L1
App1->>PubSub: 4. 发布失效消息<br/>channel: cache_invalidate<br/>payload: {key: "user:123"}
PubSub->>App2: 5. 收到失效通知
App2->>App2: 6. 删除本地 L1 中的 user:123
Note over App2: 下次访问时
App2->>Redis: 7. L1 miss → 查 L2
Redis-->>App2: miss
App2->>DB: 8. 查 DB
DB-->>App2: 返回最新数据
App2->>Redis: 9. 写回 L2
App2->>App2: 10. 写入 L1实现代码
go
// 多级缓存失效传播
type MultiLevelCache struct {
local *ristretto.Cache // L1: 本地缓存
redis *redis.Client // L2: Redis
pubsub *redis.PubSub // 失效通知
}
func (c *MultiLevelCache) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
// 写 L2
data, _ := json.Marshal(value)
if err := c.redis.Set(ctx, key, data, ttl).Err(); err != nil {
return err
}
// 写 L1(较短 TTL,兜底过期)
c.local.SetWithTTL(key, value, 1, ttl/10)
return nil
}
func (c *MultiLevelCache) Invalidate(ctx context.Context, key string) error {
// 1. 删 L1
c.local.Del(key)
// 2. 删 L2
c.redis.Del(ctx, key)
// 3. 广播失效(通知其他实例删 L1)
c.redis.Publish(ctx, "cache:invalidate", key)
return nil
}
// 订阅失效通知(每个实例启动时调用)
func (c *MultiLevelCache) SubscribeInvalidation(ctx context.Context) {
ch := c.pubsub.Channel()
for msg := range ch {
c.local.Del(msg.Payload) // 收到通知,删除本地缓存
}
}各方案对比
| 方案 | 实时性 | 可靠性 | 复杂度 | 适用 |
|---|---|---|---|---|
| Redis Pub/Sub | 毫秒级 | 弱(消息可能丢失) | 低 | 大多数场景 |
| Kafka/RocketMQ | 秒级 | 强(持久化) | 中 | 金融/支付 |
| 短 TTL 兜底 | TTL 时间 | 强 | 最低 | 容忍短暂不一致 |
| 版本号比对 | 每次请求 | 强 | 中 | 强一致性要求 |
最佳实践:Redis Pub/Sub 做主动失效 + 短 TTL(如 30s)做兜底。即使 Pub/Sub 消息丢失,最多 30s 后 L1 也会自动过期。
线上缓存问题排查 Runbook
先判断是哪一类问题
| 症状 | 高概率问题 | 第一反应 |
|---|---|---|
| Redis QPS 突降,DB QPS 暴涨 | 缓存 miss 激增 / Redis 不可用 | 先保护 DB |
| 某个接口 RT 飙高 | 热点 key 失效 / 回源慢 | 查 key 命中率和回源次数 |
| Redis CPU 很高 | 热 key / 大 key / 命令复杂度高 | 查 hotkeys、slowlog |
| Redis 内存持续涨 | 大 key、淘汰不生效、TTL 设置不合理 | 查内存、key 大小分布 |
| 数据不一致投诉 | 删缓存失败、L1/L2 失效传播丢失 | 查写链路和消息链路 |
排查顺序
bash
# 1. Redis 自身状态
redis-cli info stats
redis-cli info memory
redis-cli info commandstats
redis-cli slowlog get 20
# 2. 命中率 / 淘汰 / 过期
redis-cli info keyspace
redis-cli info stats | egrep 'keyspace_hits|keyspace_misses|expired_keys|evicted_keys'
# 3. 热 key(Redis 4.0+)
redis-cli --hotkeys
# 4. 大 key 抽样
redis-cli --bigkeys
# 5. 应用侧是否回源风暴
# 看接口 QPS、DB QPS、连接池等待、singleflight 命中、线程/goroutine 数client → cache → db 故障传播模板
mermaid
flowchart TD
A["接口超时 / RT 飙高"] --> B{"缓存层表现?"}
B -->|"命中率骤降"| C["大量回源 DB"]
B -->|"Redis CPU 高"| D["热 key / 大 key / 慢命令"]
B -->|"Redis 不可用"| E["进入降级 / 熔断"]
C --> F{"DB 是否顶住?"}
F -->|"否"| G["连接池满、慢查询、锁等待"]
G --> H["应用 goroutine/线程堆积"]
H --> I["全链路超时、重试、雪崩"]
D --> J["拆 key / 本地缓存 / 分片 / 优化命令"]
E --> K["启用本地缓存 / 默认值 / 限流 / 熔断"]止血与根治
| 类型 | 动作 | 说明 |
|---|---|---|
| 止血 | 打开降级开关 | 避免所有请求直冲 DB |
| 止血 | 给热点 key 手工预热 | 降低瞬时回源 |
| 止血 | 限流 + 熔断 | 不让慢下游拖垮全链路 |
| 根治 | singleflight / 分布式锁 | 控制并发回源 |
| 根治 | 多级缓存 | Redis 抖动时还有 L1 兜底 |
| 根治 | TTL 打散 + 热点永续刷新 | 避免同时过期 |
| 根治 | 建立命中率 / 回源率 / 热 key / 大 key 监控 | 提前发现趋势 |
生产级 binlog 方案:削峰、降级与双写补偿
为什么 MQ 会积压?
基于 binlog + MQ 的缓存更新链路:
MySQL → Canal → Kafka → Consumer → Redis当发生大批量数据订正(如运营批量更新百万用户的标签、数据清洗重跑)时:
正常 QPS: 1000 条/秒 → Consumer 轻松消费
订正 QPS: 50000 条/秒 → MQ 瞬间堆积 → Consumer 跟不上问题链:
- MQ 堆积 → 消费延迟从毫秒级变成分钟级甚至小时级
- 缓存清理命令阻塞在 MQ 中 → 用户读到的是旧缓存
- 应用层也做了同步删缓存(只是删了一次),但读回源时 DB 压力不大
- 真正的风险是:用户持续看到旧数据,但以为"系统正常"——因为没有报错
积压时的降级架构
mermaid
flowchart TB
subgraph Normal["正常链路"]
A["App 写入 DB"] --> B["Canal 监听 binlog"]
B --> C["Kafka Topic"]
C --> D["Consumer 更新 Redis"]
A --> E["同步删 Redis(双保险)"]
end
subgraph Burst["订正链路(百万行)"]
F["批量 UPDATE"] --> G["binlog 瞬间 N 万条"]
G --> H["Kafka 积压"]
end
subgraph Fallback["降级策略"]
H --> I{"Consumer 滞后 > 阈值?"}
I -->|"否"| D
I -->|"是"| J["⚠️ 触发降级"]
J --> K["策略1: 跳过中间状态<br/>同 key 只保留最新一条"]
J --> L["策略2: 全量失效<br/>直接 DEL 整个 key 前缀"]
J --> M["策略3: 暂停 MQ 消费<br/>改走全量重建缓存"]
K --> N["Redis DEL 最新版本"]
L --> O["SCAN + DEL pattern:*"]
M --> P["mysqldump → 批量写 Redis<br/>(绕过 MQ 直写)"]
end三种积压处理策略
策略 1:消息合并 — 同一 key 只取最新
go
// 思路:消费到积压消息时,对同一主键的消息做"去重合并"
// 如果 user:123 在 1 秒内被修改了 100 次,只处理最后一次
type DedupConsumer struct {
recentKeys *ristretto.Cache // 记录最近处理的 key + 最新 offset
window time.Duration // 合并窗口(如 1s)
}
func (c *DedupConsumer) Consume(msg *kafka.Message) {
key := extractPK(msg) // 提取主键,如 "user:123"
// 记录该 key 的最新消息 offset
c.recentKeys.SetWithTTL(key, msg.Offset, 1, c.window)
// 延迟处理:等合并窗口结束后,才处理该 key 的最新消息
// 窗口内的中间版本全部跳过 → 大幅减少 Redis 操作
}局限性:需要 Consumer 能承受积压期间的额外内存;窗口内的中间版本被丢弃,窗口期内 Redis 可能读到旧值。
策略 2:全量失效 — 整个前缀直接清空
go
// 当某张表发生大面积变更时,直接失效整个缓存前缀
// 比逐条 DEL 快 N 个数量级
func bulkInvalidate(rdb *redis.Client, tableName string) error {
// 方案 A: Redis SCAN + DEL(逐个 key)
var cursor uint64
pattern := fmt.Sprintf("cache:%s:*", tableName)
for {
keys, nextCursor, _ := rdb.Scan(ctx, cursor, pattern, 100).Result()
if len(keys) > 0 {
rdb.Del(ctx, keys...) // 批量删除
}
cursor = nextCursor
if cursor == 0 {
break
}
}
// 方案 B: 使用版本号前缀(推荐)
// key 设计: cache:user:v{version}:123
// 全量失效 = version++ → 新版本的所有 key 自然 miss
// 无需 DEL(旧版本等 TTL 自然过期)
return nil
}text
版本号前缀方案详解:
缓存 key 格式: cache:{table}:v{version}:{pk}
正常读取:
version = getCurrentVersion("user") // Redis 存当前版本号
key = fmt.Sprintf("cache:user:v%d:%d", version, userID)
data = redis.Get(key)
全量失效:
INCR cache:user:version // 版本号 +1
→ 所有读请求自然使用新版本号的 key
→ 旧版本 key 不会有新数据写入
→ 旧版本 key 等 TTL(如 3600s)自然过期
优点:
- 零 DEL 开销
- 瞬间完成(一个 INCR)
- 不会阻塞 Redis
缺点:
- 所有缓存重新从 DB 加载(可能引起短暂的 DB 压力上升)
- 需配合 singleflight 防止缓存击穿策略 3:暂停 MQ 消费,走全量重建
go
// 极端情况:MQ 积压数千万条,消费速度远跟不上生产速度
// → 暂停 Consumer,直接用 DB 数据全量重建缓存
func fullRebuild(rdb *redis.Client, db *sql.DB, tableConfig TableConfig) error {
version := rdb.Incr(ctx, fmt.Sprintf("cache:%s:version", tableConfig.Name)).Val()
// 分批从 DB 读取并写入 Redis
offset := 0
batchSize := 1000
for {
rows, _ := db.Query(
fmt.Sprintf("SELECT %s FROM %s LIMIT %d OFFSET %d",
tableConfig.PK, tableConfig.Name, batchSize, offset))
pipe := rdb.Pipeline()
count := 0
for rows.Next() {
// 写入新版本号的 key
key := fmt.Sprintf("cache:%s:v%d:%s",
tableConfig.Name, version, rows.PKValue())
pipe.Set(ctx, key, rows.CacheValue(), tableConfig.TTL)
count++
}
pipe.Exec(ctx)
if count < batchSize {
break // 最后一批
}
offset += batchSize
}
// 重建完成后,更新版本号(激活新缓存)
rdb.Set(ctx, fmt.Sprintf("cache:%s:version", tableConfig.Name), version, 0)
return nil
}双写补偿通道 — MQ 消费 + 应用同步双路径
生产环境最佳实践是"应用层同步删缓存 + MQ 异步兜底"两条路径同时存在:
mermaid
flowchart TB
App["应用更新 DB"] --> SyncDel["同步删缓存<br/>(毫秒级,最佳努力)"]
App --> Binlog["binlog 产生"]
Binlog --> Canal["Canal"]
Canal --> MQ["Kafka"]
MQ --> Consumer["Consumer 消费"]
Consumer --> AsyncDel["异步删缓存<br/>(秒级,保证最终一致)"]
SyncDel -->|"失败"| AsyncDel
AsyncDel -->|"积压"| Fallback["降级:全量失效/重建"]go
// 双路径实现
func updateWithDualPath(db *sql.DB, rdb *redis.Client, key string, value interface{}) error {
// 1. 更新 DB
if err := db.Exec("UPDATE ...", value); err != nil {
return err
}
// 2. 同步删缓存(主路径 — 毫秒级)
delErr := rdb.Del(ctx, key).Err()
// 3. 异步补偿(兜底 — 秒级)
// binlog → Canal → MQ → Consumer 自动处理
// 如果同步删除失败,MQ 链路会兜底
if delErr != nil {
log.Warn("sync delete failed, relying on MQ fallback", "key", key, "err", delErr)
// 可选:写入一个"补偿任务"到优先级更高的队列
}
return nil
}监控告警
bash
# 关键指标
1. MQ 消费滞后量(Consumer Lag)→ 超过阈值告警
2. binlog 产生速率 vs 消费速率 → 持续落后则告警
3. Redis key 命中率 → 突降说明缓存失效有问题
4. DB QPS → 突增说明回源压力大| 指标 | 正常阈值 | 告警阈值 | 处理 |
|---|---|---|---|
| Consumer Lag | < 100 | > 1000 | 检查消费能力,考虑扩 Consumer |
| 消费速率 | > 产生速率 | < 产生速率 × 0.8 | 触发降级策略 |
| 缓存命中率 | > 95% | < 90% | 检查失效链路 |
| DB QPS | 基线 | > 基线 × 2 | singleflight 兜底 |
登录后即可发表评论 👇