1. 缓存哲学:为什么"查一次、存结果"很诱人
一句话总结: 结果缓存把"昂贵的 SQL 查询"变成"一次 O(1) 的内存读取",收益极大,但代价是一致性与失效管理的复杂度——所有缓存问题本质都是失效问题。
数据库的查询成本来自三处:解析 SQL、执行计划、磁盘/内存页访问。如果同一查询被高频重复执行(例如首页榜单、热点详情页),每次重复计算都是浪费。结果缓存的核心思想:只算一次,后面直接取现成答案。
无缓存: SELECT ... → 解析 → 计划 → 读页 → 返回(每毫秒级)
有缓存: SELECT ... → 命中缓存 → 返回(微秒级)
命中缓存 ≈ 省掉 99% 的数据库开销。
但缓存的隐藏成本巨大:数据一变,缓存就过期。设计缓存,真正难点不是"怎么存",而是"怎么让它在数据变化时保持正确"。
2. MySQL 查询缓存的演进与消亡
2.1 它曾经是什么
MySQL 5.7 及之前提供服务端查询缓存:以 SQL 文本为 key 缓存结果集,表发生任何写入则整表缓存全部失效。
# my.cnf (MySQL 5.7 时代)
query_cache_type = DEMAND
query_cache_size = 64M
2.2 为什么 MySQL 8.0 把它删了
| 问题 | 说明 |
|---|---|
| 命中条件苛刻 | SQL 文本必须逐字节一致,大小写/空格不同即不命中 |
| 写放大严重 | 任何 INSERT/UPDATE 都要遍历并清空该表全部缓存 |
| 全局锁竞争 | 缓存操作有全局互斥锁,写入频繁时是负优化 |
| 命中率虚低 | 实际收益常 < 2%,反而拖慢写入 |
MySQL 8.0 正式移除了查询缓存。结论:服务端查询缓存已被判死刑,结果缓存应该做在应用层。
一句话总结: MySQL 查询缓存死于"失效代价过高 + 命中条件过苛刻"。现代方案是把缓存下沉到应用层/Redis,由业务掌控失效时机。
3. 应用层结果缓存:把 SQL 结果放进 Redis
3.1 缓存什么:key 设计
结果缓存的粒度通常细到一行一 key 或一个查询一 key:
行级 key: user:{userId}
列表 key: feed:list:{lastId}:{pageSize}
聚合 key: stat:orders:{date}:{status}
# Redis 结果缓存伪代码
def get_orders(user_id):
key = "orders:user:%d:list" % user_id
data = redis.get(key)
if data is not None:
return deserialize(data) # 命中
rows = db.query("SELECT ... WHERE user_id=%s", user_id)
redis.setex(key, ttl=300, serialize(rows)) # 未命中,回源并写入
return rows
3.2 序列化与过期
| 维度 | 建议 |
|---|---|
| 序列化 | 优先 JSON/MessagePack;大列表用压缩 |
| TTL | 短 TTL(秒~分钟级)做最终一致性兜底 |
| 空结果缓存 | 空列表也缓存 30~60s,防缓存穿透 |
| 热 key 刷新 | 快过期时异步续期,避免击穿 |
注意:缓存里存的是查询结果快照,不是数据库对象本身。快照天然有延迟,业务要接受"最多 TTL 秒的旧数据"。
4. 缓存一致性:失效与双写
4.1 四种常见模式
| 模式 | 流程 | 一致性 | 适用 |
|---|---|---|---|
| Cache Aside | 读 miss 回源写缓存;写时先更新 DB 再删缓存 | 好 | 最常用 |
| Read Through | 应用只读缓存,缓存自己回源 | 好 | 封装层 |
| Write Through | 写缓存也同步写 DB | 强 | 数据无丢失容忍 |
| Write Behind | 先写缓存,异步刷 DB | 弱 | 高吞吐可丢容忍 |
4.2 Cache Aside 与延迟双删
Cache Aside 的经典坑:先删缓存再更新 DB 时,删缓存与更新 DB 之间存在空窗,读到旧值会写回旧缓存。更稳的顺序:
正确顺序(Cache Aside):
1. 更新数据库
2. 删除缓存(下一次 miss 时回源新值)
延迟双删(解决并发读回写旧值):
1. 更新数据库
2. 删除缓存
3. sleep 50~100ms
4. 再次删除缓存(清掉期间被写回的旧值)
def update_user(user_id, payload):
db.update("UPDATE users SET ... WHERE id=%s", user_id, payload)
redis.delete("user:%d" % user_id) # 1. 先更新 DB
time.sleep(0.05) # 2. 等待可能的旧值回写窗口
redis.delete("user:%d" % user_id) # 3. 延迟双删
一句话总结: 更新 DB 后删缓存是底线;想彻底消除并发脏读,用"延迟双删"或引入版本号比对。核心原则是缓存永远以 DB 为准,DB 是唯一事实源。
4.3 Binlog 订阅:被动失效
不想在业务代码里到处删缓存,可以用 binlog 监听(Canal/Debezium):DB 变更 → 订阅到变更事件 → 程序删除对应缓存。
业务写库 → binlog → Canal/Debezium → 缓存失效服务 → Redis DEL
好处:业务代码无侵入,一个变更统一驱动所有缓存失效。
代价:引入消息链路,存在秒级延迟。
5. 命中率分析与调优
5.1 命中率公式与指标
命中率 = 缓存命中的请求数 / 总请求数
统计埋点:get 命中数 / get 总数(按 key 前缀聚合)
# Redis 侧总体指标
redis-cli info stats | grep -E "keyspace_hits|keyspace_misses"
# keyspace_hits 与 keyspace_misses 之比即整体命中率
5.2 命中率低的三板斧
| 症状 | 原因 | 对策 |
|---|---|---|
| 命中率 < 50% | key 设计过细、随机后缀过多 | key 归一化,去掉无意义随机参数 |
| 命中率波动大 | TTL 到期时间集中在同一时刻 | TTL 加随机抖动(±20%) |
| miss 暴增 | 缓存穿透 | 空值缓存 + 布隆过滤器 |
| 单 key 打爆 | 热点 key 击穿 | 逻辑过期 + 互斥重建 |
| 大面积不可用 | 缓存雪崩 | 多级缓存 + 熔断降级 |
5.3 穿透、击穿、雪崩速查
| 概念 | 场景 | 关键对策 |
|---|---|---|
| 穿透 | 查根本不存在的 key,每次都回源 DB | 布隆过滤器 + 空值缓存 |
| 击穿 | 单个热 key 过期瞬间,大量请求同时回源 | 互斥锁重建 + 逻辑过期 |
| 雪崩 | 大量 key 同时过期或缓存宕机 | TTL 抖动 + 集群高可用 + 降级 |
一句话总结: 命中率是结果缓存健康的晴雨表。命中率低先查 key 设计,再查失效节奏,最后用穿透/击穿/雪崩三件套兜底。
5.4 缓存监控与告警
缓存和数据库一样需要监控。核心指标与告警阈值:
| 指标 | 含义 | 建议阈值 |
|---|---|---|
| 命中率 | hits / (hits+misses) | 低于 80% 告警,高于 90% 健康 |
| 内存淘汰率 | evicted_keys / total | 淘汰率高说明容量不足 |
| 过期比例 | expired_keys 增速 | 突增说明 TTL 设置异常 |
| 重建延迟 | miss 后的 DB 回源耗时 | 峰值超过 500ms 需告警 |
| 后端 DB QPS | 缓存后端的实际 DB 负载 | 与缓存前对比评估收益 |
# Redis 侧
redis-cli --stat # 实时统计
redis-cli info stats | grep evicted # 淘汰监控
redis-cli monitor | head -50 # 短期采样分析 key 分布
上线缓存后必须给命中率配告警。命中率骤降往往不是缓存坏了,而是 key 结构被改、DB 数据大变更或 TTL 集体到期。
6. 缓存策略选型:什么时候值得缓存
| 场景 | 适合缓存 | 原因 |
|---|---|---|
| 读多写少 | ✅ | 命中率高、失效少 |
| 写多读少 | ❌ | 频繁失效,缓存形同虚设 |
| 强一致要求 | ❌ | 快照必然有延迟 |
| 昂贵计算 | ✅ | 聚合/榜单/大列表回源贵 |
| 实时性要求高 | ❌ | 用直查 DB + 短 TTL |
判断标准三连问:
1. 同一查询是否被高频重复? (频率)
2. 数据变更是否频繁? (写放大)
3. 业务能否容忍 TTL 内的旧数据? (一致性)
三问都通过,才值得上结果缓存。
结果缓存是"锦上添花"而非"雪中送炭"。SQL 本身烂(无索引、全表扫),缓存只会把烂 SQL 的延迟掩盖起来,数据一变就原形毕露。先优化查询,再上缓存。
6.1 缓存的反模式速查
| 反模式 | 表现 | 更优做法 |
|---|---|---|
| 缓存所有查询 | 命中率低,维护成本高 | 只缓存 Top 高频查询 |
| 缓存写热数据 | 频繁失效,负收益 | 直查 DB,或只缓存只读副本 |
| 无限 TTL | 数据长期陈旧 | 一律设 TTL + 主动失效 |
| 缓存大结果集 | 内存膨胀、序列化慢 | 分页/摘要缓存,不全量 |
| 代码里到处操作缓存 | 失效逻辑散落 | 封装缓存 DAO/注解式缓存 |
反模式的共同点:把缓存当「万能加速器」,而没想清楚「数据变了怎么办」。缓存永远服务查询,不能绑架数据正确性。
7. 实战案例:订单列表查询优化
场景: 订单列表接口 2000 QPS,SQL 含 5 张表 JOIN + 分页,P99 达 350ms。
分析: 列表数据按 user_id 隔离、热数据集中在近 7 天订单 → 天然适合行级/列表级缓存。
def list_orders(user_id, page, size):
key = "order:list:%d:%d:%d" % (user_id, page, size)
hit = redis.get(key)
if hit:
return deserialize(hit) # 命中:0.1ms
rows = db.query(JOIN_SQL, user_id, page, size)
# 只缓存近 7 天热点,老订单直查
if rows and rows[0].created_at > now() - timedelta(days=7):
redis.setex(key, ttl=60 + randint(-10, 10),
serialize(rows)) # 随机抖动 TTL
return rows
效果:
命中率 32% → 86%
P99 350ms → 42ms
数据库 QPS 2000 → 260
要点: 只缓存热窗口数据、TTL 加抖动、空列表也缓存。上线后要持续看命中率与 P99,防止缓存掩盖 SQL 退化。
7.1 热点 key 击穿的互斥重建
单飞模式(Single Flight):同一时刻只有一个请求回源,其余等待结果:
import threading
lock_by_key = {}
def get_orders_with_mutex(user_id):
key = "orders:user:%d" % user_id
data = redis.get(key)
if data is not None:
return deserialize(data)
lock = lock_by_key.setdefault(key, threading.Lock())
with lock:
data = redis.get(key) # 双检:避免重复回源
if data is not None:
return deserialize(data)
rows = db.query("SELECT ... WHERE user_id=%s", user_id)
redis.setex(key, ttl=60, serialize(rows))
return rows
效果:热 key 过期瞬间,回源 DB 的次数从 N 次收敛为 1 次。
配合「逻辑过期 + 异步续期」,可以让热数据永不过期:
1. 缓存里存逻辑过期时间(不依赖 Redis TTL)
2. 读到逻辑过期 → 返回旧值,同时异步重建
3. 下一个请求就拿到新值
7.2 收益评估公式
节省的 DB QPS ≈ 总请求数 × 命中率
平均查询耗时 ≈ (1 - 命中率) × 回源耗时 + 命中率 × 缓存耗时
示例:命中率 86%,回源 350ms,缓存 0.1ms
→ 平均查询耗时 ≈ 14% × 350 + 86% × 0.1 ≈ 49ms
用这个公式可以反推:命中率每提高 10%,P99 大约下降多少。这也是给业务方讲清楚「缓存值不值得上」的最佳论据。
8. 总结
| 环节 | 要点 |
|---|---|
| 定位 | 结果缓存缓存"查询快照",收益大、难点在失效 |
| 演进 | MySQL 服务端查询缓存已被移除,改在应用层做 |
| 一致性 | Cache Aside 更新 DB 后删缓存,并发用延迟双删 |
| 失效 | 业务代码删 / binlog 订阅被动删,二选一 |
| 命中率 | 先查 key 设计,再查 TTL 节奏 |
| 兜底 | 穿透空值缓存、击穿互斥重建、雪崩 TTL 抖动 |
结果缓存的价值是把数据库从重复劳动里解放出来,但它不是银弹。记住三条铁律:先优化 SQL 再上缓存、DB 是唯一事实源、接受 TTL 内的最终一致。做到这三点,缓存会成为你架构里最省钱的一层。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。