Skip to content

缓存

缓存是性能优化的第一手段,也是一致性问题的第一来源。本文从"缓存在哪一层"讲起,落到 Redis 的数据结构选型、内存淘汰与三大问题的工程解法,最后给出分布式锁与高可用的正确姿势——关键的取舍都有实测数据支撑(Node v22.22.1 最小实现)。

一、缓存在哪一层

层级位置典型延迟解决什么
浏览器缓存客户端0ms静态资源重复请求
CDN边缘节点10~50ms就近分发、降低源站压力
反向代理缓存Nginx / 网关1~5ms热点接口、整页缓存
应用本地缓存进程内存(Caffeine)纳秒~微秒极热数据、字典表、避免网络跳数
分布式缓存Redis / Memcached亚毫秒共享缓存、会话、分布式锁
数据库缓冲Buffer Pool微秒~毫秒数据库内部自动管理

层级越低(离 CPU 越近)越快,但共享性越差。典型组合:本地缓存挡极热 key + Redis 挡绝大多数读 + CDN 挡静态资源。注意本地缓存在多副本下不一致窗口更大,只放变更极少的数据。

二、Redis 数据结构与使用场景

类型特点典型场景注意
String最基础,可存文本/数字/二进制缓存对象(JSON)、计数器、分布式锁单 key 最大 512MB,别放超大对象
Hash字段-值映射对象部分字段存取(购物车、用户属性)小 Hash 用 ziplist 编码省内存
List有序可重复,双向消息队列(简单场景)、最新列表大数据量用 Stream 更合适
Set无序唯一标签、去重、交集(共同好友)支持 SINTER/SUNION 集合运算
ZSet按 score 排序排行榜、延迟队列、优先级队列范围查询 O(log n + m)
Bitmap位操作签到、活跃用户标记极省空间,1 亿用户签到约 12.5MB
HyperLogLog概率计数UV 统计(误差约 0.81%)只计数不存明细
Geo地理位置附近的人、门店距离底层是 ZSet
Stream可持久化的消息流轻量消息队列、事件溯源支持消费组与 ACK
redis
# 计数器:原子自增,天然抗并发
INCR article:1001:pv
EXPIRE article:1001:pv 86400

# Hash:只更新一个字段,避免整对象序列化覆盖
HSET user:1001 name "Tom" age 30
HINCRBY user:1001 balance -100

# ZSet:排行榜(score 为分数)
ZADD leaderboard 98 "player:A" 87 "player:B"
ZREVRANGE leaderboard 0 9 WITHSCORES          # Top 10

# Bitmap:用户 1001 在 9 月 2 日签到
SETBIT sign:2026-09-02 1001 1
BITCOUNT sign:2026-09-02                      # 当日签到总数

# HyperLogLog:UV 统计(可合并多个日期)
PFADD uv:2026-09-02 "uid:1" "uid:2" "uid:3"
PFCOUNT uv:2026-09-02

选型直觉:能用 Hash 就别把整个对象序列化成 String(部分更新更省带宽);需要排序就用 ZSet;大数据量去重计数用 HyperLogLog 而非 Set(Set 存明细会爆内存)。

三、读写与过期策略

策略做法适用
Cache Aside(旁路)读:命中返回,未命中回源并写入;写:更新 DB,删除缓存默认选择
Write Through写操作由缓存层同步落库写多读少、一致性要求高
Write Back只写缓存,异步刷盘极高写入量、可容忍丢失(如计数)

过期键的删除(Redis 采用组合策略):

  • 惰性删除:访问时才发现过期再删(省 CPU,但过期键可能长期占内存);
  • 定期删除:每秒多次随机抽查部分 key 删除(控制内存,但有漏网);
  • 内存淘汰:达到 maxmemory 时按策略驱逐(见 §四)。

四、内存淘汰:算法实测

缓存容量有限,淘汰谁决定命中率。Zipf 分布(α=1.2,互联网访问的典型形态——少数热点占大部分流量)下实测,1 万个 key、10 万次访问:

缓存容量FIFOLRULFU
10060.3%65.4%72.8%
50077.0%80.6%84.3%
100083.0%85.8%88.1%
200088.1%90.1%91.1%

结论:

  • LFU > LRU > FIFO:访问分布越倾斜(Zipf 越陡),LFU 优势越明显(容量 100 时比 FIFO 高 12.5 个百分点);
  • 容量越小,算法差距越大:容量接近数据全集时三者趋同(2000 容量对 1 万 key,差距收窄到 3 个百分点);
  • 因此扩容比换算法更有效:容量从 100 提到 500,FIFO 也能涨 17 个百分点。

Redis 的 8 种淘汰策略:

策略行为适用
noeviction不淘汰,写满直接报错缓存不允许丢失(默认,需显式改)
allkeys-lru全体 key 中淘汰最久未用通用缓存首选
allkeys-lfu全体 key 中淘汰访问频次最低有明显长期热点
allkeys-random随机淘汰无明确访问规律
volatile-lru/lfu/random只在设置了 TTL 的 key 中淘汰缓存与持久数据混存
volatile-ttl优先淘汰剩余 TTL 最短的有明确过期优先级的场景

maxmemory 必须设置(否则内存无上限,会被 OOM Killer 干掉);建议设为物理内存的 60~70%,留出 fork(RDB)与客户端缓冲的余量。

五、三大问题与实测解法

三大问题的架构层原理见 系统设计,这里聚焦可落地的实现与实测效果。

5.1 缓存穿透:查询根本不存在的数据

text
攻击者用大量不存在的 id 查询 → 缓存永远不命中 → 每次都打到数据库
解法做法代价
空值缓存查不到也写入一个空值(TTL 较短,如 60s)内存里会有一批空 key
布隆过滤器写入时把 key 加入过滤器,查询前先判存在有误判率、不支持删除(可用 Counting Bloom)
参数校验拦截明显非法的 id(负数、超长、非数字)只能挡最低级的攻击

布隆过滤器实测(md5 双哈希,插入 1 万条,5 万次不存在的 key 测试):

text
bits/元素   k    理论误判率    实测误判率
     8      6      2.16%        0.91%
    10      7      0.82%        0.30%
    16     11      0.05%        0.01%
    20     14      0.01%        0.00%

空间代价(n = 1000 万条):
 8 bits/元素 →  9.5 MB(理论误判 2.16%)
10 bits/元素 → 11.9 MB(理论误判 0.82%)
16 bits/元素 → 19.1 MB(理论误判 0.05%)

误判率公式是理论上界,实测优于理论;工程上取 10 bits/元素性价比最高(1000 万条仅 12MB,误判约 1%)。记住布隆过滤器的特性:说不存在就一定不存在(不会漏),说存在则可能误判——所以它挡住的正是穿透攻击,误判只是让极小部分请求多查一次。

5.2 缓存击穿:热点 key 失效瞬间

js
// 单飞(single flight):同一个 key 的并发回源只放一个请求出去
const inflight = new Map()

async function getWithSingleFlight(key) {
  if (cache.has(key)) return cache.get(key)
  if (inflight.has(key)) return inflight.get(key)          // 复用同一个 Promise
  const p = loadFromDB(key).then((v) => {
    cache.set(key, v)
    inflight.delete(key)
    return v
  })
  inflight.set(key, p)
  return p
}

实测(100 个并发请求同一个失效 key):

text
无单飞:回源 100 次(每个并发请求都打到数据库)
有单飞:回源 1 次(只放一个请求回源,其余共享结果)

其他手段:热点 key 永不过期(用逻辑过期 + 异步刷新)、热点探测后自动延长 TTL。

5.3 缓存雪崩:大批 key 同时失效

实测(1 万个 key 的过期分布):

text
固定 TTL(无抖动)      → 同一秒内过期 10000 个 key,全部同时回源
TTL + 0~5 分钟随机抖动  → 同一秒内最多过期 50 个 key
TTL + 0~30 分钟随机抖动 → 同一秒内最多过期 16 个 key
js
// 加一行随机抖动,成本几乎为零
const ttl = 3600 + Math.floor(Math.random() * 600)   // 基础 1 小时 ± 10 分钟
await redis.set(key, value, 'EX', ttl)

再叠加多层防护:多级缓存(本地缓存兜底)、缓存集群高可用、回源限流与熔断(见 微服务)。

六、分布式锁

6.1 正确姿势

redis
# 加锁:NX(不存在才设)+ PX(毫秒过期)必须一条命令,value 用全局唯一值
SET lock:order:1 "550e8400-e29b-41d4" NX PX 30000

# 释放:先比较 value 再删除(Lua 保证原子性,不能分两条命令)
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
else
  return 0
end

6.2 为什么必须校验 value:实测时间线

text
【错误做法:直接 DEL】
+   0ms  A 加锁成功 value=A ttl=1000ms
+1100ms  A 的锁已过期(业务还没执行完)
+1100ms  B 加锁成功 value=B
+1100ms  A 执行 DEL → 删除的是 B 的锁!当前锁 = null(锁丢失,C 可以立刻再拿到锁)

【正确做法:value 唯一 + 比较后删除】
+   0ms  A 加锁成功 value=A ttl=1000ms
+1101ms  B 加锁成功 value=B
+1101ms  A 尝试用 value=A 释放 → 失败(不匹配,拒绝删除),当前锁 value = B

6.3 还有两件事

  • 续期(watchdog):业务执行时间不确定时,起一个后台线程在 TTL 过半时续期(Redisson 的看门狗)——锁的 TTL 必须大于"业务最大耗时",否则锁会在业务中途失效;
  • Redlock 的争议:Redis 作者提出的 Redlock(向多个独立实例依次申请,超过半数成功才算拿到锁)在分布式系统领域有长期争论(时钟跳跃、GC 停顿、网络延迟都会破坏它的前提)。需要强一致锁就用数据库/ZooKeeper/etcd,Redis 锁适合"允许极小概率失效、但能靠业务兜底"的场景(如防重复提交)。

七、高可用与持久化

方案机制特点
主从复制主写从读,异步同步扩展读能力,故障需人工切换
哨兵 Sentinel监控 + 自动故障转移高可用,但仍单主(写入不分片)
Cluster 集群16384 个哈希槽分片,多主多从数据量或写入超过单机时选它

持久化三选一:

方式原理取舍
RDB定时快照(fork 子进程写二进制文件)文件小、恢复快;可能丢最后一次快照后的数据
AOF记录每条写命令,重启重放数据更安全(可配 everysec,最多丢 1 秒);文件大、恢复慢
混合持久化RDB 头部 + AOF 增量(Redis 4.0+)推荐:兼顾恢复速度与安全性

把 Redis 当数据库用要非常谨慎:主从切换时异步复制会丢数据,缓存场景可以接受,账务场景不行。缓存集群重启后要预热(否则瞬间全量回源打垮数据库),可用缓存预热脚本或"空值保护 + 逐步放量"。

八、检查清单

  1. maxmemory 与淘汰策略已显式配置(默认 noeviction 会让写入报错);
  2. 缓存 key 有统一命名规范与业务前缀,含版本号便于批量失效;
  3. 所有缓存都有 TTL(包括"永不过期"的逻辑过期 key 也有兜底清理);
  4. TTL 加了随机抖动,避免集中过期;
  5. 热点 key 有单飞或逻辑过期保护;
  6. 存在大量不存在 key 的查询路径有空值缓存或布隆过滤器;
  7. 分布式锁用了 SET NX PX + 唯一 value + Lua 释放,TTL 大于业务最大耗时;
  8. 更新数据库后删除缓存,且删除失败有重试(binlog 订阅或消息队列);
  9. 有缓存命中率监控(低于 90% 要排查),有 Big Key 与热 Key 分析;
  10. 集群重启有预热方案,压测前先跑一遍;
  11. Redis 不当数据库用,关键数据以 DB 为准。

九、常见坑速查

  1. 缓存和 DB 更新顺序反了:先删缓存再更新 DB 会留下长期脏数据——先更新 DB 再删缓存;
  2. 删除缓存失败没重试:删失败后缓存里一直是旧值,需要重试机制(binlog/MQ);
  3. TTL 完全一致:同时过期引发雪崩(实测同一秒 1 万个 key 同时失效)——加随机抖动;
  4. 热点 key 无保护:失效瞬间全量回源(实测 100 并发回源 100 次)——单飞 + 逻辑过期;
  5. 穿透不设防:恶意刷不存在的 id 直接打穿数据库——空值缓存 + 布隆过滤器;
  6. 分布式锁直接 DEL:会删掉别人的锁(实测时间线见 §6.2)——必须 value 唯一 + Lua 比较删除;
  7. 锁 TTL 小于业务耗时:业务没执行完锁就过期,两个线程同时进入临界区;
  8. SETNXEXPIRE 分两条命令:中间崩溃 → 死锁,必须用 SET ... NX PX 一条命令;
  9. Big Key:单个 key 几十 MB(大 Hash/List/ZSet),导致网络阻塞、删除卡顿、迁移失败——拆分或压缩;
  10. 热 Key 集中在单分片:Cluster 下某个槽流量远高于其他,需要拆分 key 或本地缓存兜底;
  11. KEYS * 上生产:单线程阻塞整个实例,用 SCAN 分批遍历;
  12. 用 Redis 做消息队列却不当真:List 的 BRPOP 没有 ACK 机制,消费者崩溃即丢消息——需要可靠投递就用 Stream 或专业 MQ(见 消息队列);
  13. 把缓存当存储:主从切换丢数据,重启后缓存空导致数据库被打垮。

状态与参考

  • 状态:已收录(2026-09-02,由后端领域规划清单「缓存|Redis 数据结构、缓存策略(穿透/击穿/雪崩)、集群」由占位页转为正式内容)。
  • 版本:淘汰算法命中率、布隆过滤器误判率与空间代价、单飞效果、TTL 抖动分布、分布式锁时间线均为 Node v22.22.1 最小实现实测(输出可直接复现);Redis 命令与集群/持久化机制为 Redis 7.x 标准用法——本机未安装 Redis,命令部分未做实机验证,请以运行环境为准。
  • 参考Redis 官方文档Redis 键值淘汰策略Redis 分布式锁Redlock 的争议(Martin Kleppmann)布隆过滤器原理
  • 阅读联动:缓存三大问题的架构视角与一致性 CAP 见 系统设计微服务;回源时的索引与 SQL 优化见 数据库;可靠的异步解耦见 消息队列

下一步

  • [ ] 安装 Redis 实例,把淘汰策略、Big Key 分析与 Cluster 分片的表现实测补入本文
  • [ ] 用 Redis + Lua 实测"库存扣减 + 幂等去重"组合,对比数据库乐观锁方案
  • [ ] 写一篇"缓存与数据库一致性:延迟双删 vs binlog 订阅"的对比实验

写作规范请参阅领域概览

基于 VitePress 构建 · 内容以知识共享方式沉淀