缓存
缓存是性能优化的第一手段,也是一致性问题的第一来源。本文从"缓存在哪一层"讲起,落到 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 |
# 计数器:原子自增,天然抗并发
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 万次访问:
| 缓存容量 | FIFO | LRU | LFU |
|---|---|---|---|
| 100 | 60.3% | 65.4% | 72.8% |
| 500 | 77.0% | 80.6% | 84.3% |
| 1000 | 83.0% | 85.8% | 88.1% |
| 2000 | 88.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 缓存穿透:查询根本不存在的数据
攻击者用大量不存在的 id 查询 → 缓存永远不命中 → 每次都打到数据库| 解法 | 做法 | 代价 |
|---|---|---|
| 空值缓存 | 查不到也写入一个空值(TTL 较短,如 60s) | 内存里会有一批空 key |
| 布隆过滤器 | 写入时把 key 加入过滤器,查询前先判存在 | 有误判率、不支持删除(可用 Counting Bloom) |
| 参数校验 | 拦截明显非法的 id(负数、超长、非数字) | 只能挡最低级的攻击 |
布隆过滤器实测(md5 双哈希,插入 1 万条,5 万次不存在的 key 测试):
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 失效瞬间
// 单飞(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):
无单飞:回源 100 次(每个并发请求都打到数据库)
有单飞:回源 1 次(只放一个请求回源,其余共享结果)其他手段:热点 key 永不过期(用逻辑过期 + 异步刷新)、热点探测后自动延长 TTL。
5.3 缓存雪崩:大批 key 同时失效
实测(1 万个 key 的过期分布):
固定 TTL(无抖动) → 同一秒内过期 10000 个 key,全部同时回源
TTL + 0~5 分钟随机抖动 → 同一秒内最多过期 50 个 key
TTL + 0~30 分钟随机抖动 → 同一秒内最多过期 16 个 key// 加一行随机抖动,成本几乎为零
const ttl = 3600 + Math.floor(Math.random() * 600) // 基础 1 小时 ± 10 分钟
await redis.set(key, value, 'EX', ttl)再叠加多层防护:多级缓存(本地缓存兜底)、缓存集群高可用、回源限流与熔断(见 微服务)。
六、分布式锁
6.1 正确姿势
# 加锁: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
end6.2 为什么必须校验 value:实测时间线
【错误做法:直接 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 = B6.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 当数据库用要非常谨慎:主从切换时异步复制会丢数据,缓存场景可以接受,账务场景不行。缓存集群重启后要预热(否则瞬间全量回源打垮数据库),可用缓存预热脚本或"空值保护 + 逐步放量"。
八、检查清单
maxmemory与淘汰策略已显式配置(默认noeviction会让写入报错);- 缓存 key 有统一命名规范与业务前缀,含版本号便于批量失效;
- 所有缓存都有 TTL(包括"永不过期"的逻辑过期 key 也有兜底清理);
- TTL 加了随机抖动,避免集中过期;
- 热点 key 有单飞或逻辑过期保护;
- 存在大量不存在 key 的查询路径有空值缓存或布隆过滤器;
- 分布式锁用了
SET NX PX+ 唯一 value + Lua 释放,TTL 大于业务最大耗时; - 更新数据库后删除缓存,且删除失败有重试(binlog 订阅或消息队列);
- 有缓存命中率监控(低于 90% 要排查),有 Big Key 与热 Key 分析;
- 集群重启有预热方案,压测前先跑一遍;
- Redis 不当数据库用,关键数据以 DB 为准。
九、常见坑速查
- 缓存和 DB 更新顺序反了:先删缓存再更新 DB 会留下长期脏数据——先更新 DB 再删缓存;
- 删除缓存失败没重试:删失败后缓存里一直是旧值,需要重试机制(binlog/MQ);
- TTL 完全一致:同时过期引发雪崩(实测同一秒 1 万个 key 同时失效)——加随机抖动;
- 热点 key 无保护:失效瞬间全量回源(实测 100 并发回源 100 次)——单飞 + 逻辑过期;
- 穿透不设防:恶意刷不存在的 id 直接打穿数据库——空值缓存 + 布隆过滤器;
- 分布式锁直接
DEL:会删掉别人的锁(实测时间线见 §6.2)——必须 value 唯一 + Lua 比较删除; - 锁 TTL 小于业务耗时:业务没执行完锁就过期,两个线程同时进入临界区;
- 把
SETNX和EXPIRE分两条命令:中间崩溃 → 死锁,必须用SET ... NX PX一条命令; - Big Key:单个 key 几十 MB(大 Hash/List/ZSet),导致网络阻塞、删除卡顿、迁移失败——拆分或压缩;
- 热 Key 集中在单分片:Cluster 下某个槽流量远高于其他,需要拆分 key 或本地缓存兜底;
KEYS *上生产:单线程阻塞整个实例,用SCAN分批遍历;- 用 Redis 做消息队列却不当真:List 的
BRPOP没有 ACK 机制,消费者崩溃即丢消息——需要可靠投递就用 Stream 或专业 MQ(见 消息队列); - 把缓存当存储:主从切换丢数据,重启后缓存空导致数据库被打垮。
状态与参考
- 状态:已收录(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 订阅"的对比实验
写作规范请参阅领域概览。