查询缓存与结果缓存设计

梳理 MySQL 查询缓存的演进与消亡、应用层结果缓存的落地方式、缓存一致性的失效与双写方案,以及命中率分析与穿透击穿雪崩治理。

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 内的最终一致。做到这三点,缓存会成为你架构里最省钱的一层。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. Supabase 平台与 PostgreSQL 边缘函数实践
  2. PostgreSQL 事件触发器与审计日志实现
  3. Kubernetes 上 PostgreSQL 运维与 CloudNativePG 实战