核心要点 (Key Takeaways)
- 缓存旁路(Cache-Aside)依然是 90% 业务的正确答案,写穿透和写回只在特定写密集场景才划算,别被博客带偏。
- TTL 必须加抖动(jitter)。固定的 3600 秒 TTL 会在整点制造缓存雪崩,我们线上见过 P99 从 40ms 飙到 1.8s。
maxmemory-policy选错比不设还糟。缓存场景用allkeys-lru或allkeys-lfu,别用默认的noeviction—— 那玩意儿在大 key 场景会直接让写入报错。- 两级缓存(本地 + Redis)能把 P99 砍掉一半以上,代价是失效广播的复杂度,Pub/Sub 丢消息会让你吃到脏数据。
- Redis 8.x 的
OBJECT FREQ和MEMORY USAGE是你调优的唯一真相来源,别靠猜。
为什么 2026 年还在聊 Redis 缓存
Redis 已经不年轻了。2009 年 Salvatore 写第一版的时候,没人想到这东西会变成现代后端的基础设施标配。但正因为人人都用,“会用"和"用对"之间的鸿沟大得离谱。
上周我在 HN 上看到有人发 Wharf 这种自托管 Postgres/MySQL/Mongo/Redis/ClickHouse 的 Docker 一键包,评论区吵得很热闹 —— 有人觉得省事,有人觉得生产环境这么搞迟早出事。这其实反映了一个现实:Redis 的部署门槛越来越低,但调优门槛一点没降。
我带的团队过去两年换了三套缓存架构。第一套裸用 Redis 当纯 KV,第二套加了本地 Caffeine,第三套改成了分层 + 主动失效。中间踩的坑,够写一本书。这篇就把能说的都说了。
缓存模式选型:四种模式的真实取舍
网上讲缓存模式的博客一抓一大把,但大多数只列定义不讲代价。我按我们实际压测的数据重新排了一遍。
缓存旁路(Cache-Aside)
最经典,也最容易被写错。读的时候先查缓存,miss 了查 DB 再回填;写的时候先更新 DB,再删缓存(注意是删不是更新)。
def get_user(user_id: int) -> dict:
key = f"user:{user_id}"
cached = redis.get(key)
if cached:
return json.loads(cached)
row = db.query("SELECT * FROM users WHERE id = %s", user_id)
if row:
# TTL 加抖动,避免整点雪崩
ttl = 3600 + random.randint(0, 300)
redis.setex(key, ttl, json.dumps(row))
return row
def update_user(user_id: int, data: dict) -> None:
db.execute("UPDATE users SET ... WHERE id = %s", user_id)
redis.delete(f"user:{user_id}") # 删,不是 set
为什么要删而不是更新?因为并发写场景下,两个写请求的"更新缓存"和"更新 DB"顺序会交错,产生永久脏数据。删缓存最多造成一次 miss,不会脏。
写穿透(Write-Through)
写的时候同时写缓存和 DB,读永远命中。听起来很美,代价是每次写都要付两次网络往返,而且缓存里会塞满可能永远不会被读的数据。写少读多的配置类数据适合,用户行为日志这种就不合适。
写回(Write-Behind)
写只落缓存,异步刷 DB。吞吐量最高,但数据丢失窗口是真实存在的。Redis 挂了,没刷盘的写全没了。金融、订单类业务别碰。
读穿透(Read-Through)
逻辑上把回填动作交给缓存层(比如 RedisGears 或自研代理)。好处是业务代码干净,坏处是多了一层抽象,排查问题的时候你会想砸键盘。
| 模式 | 写延迟 | 读命中率 | 数据一致性 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside | 低 | 中 | 最终一致 | 通用、读多写少 |
| Write-Through | 高(双写) | 高 | 强一致(单点) | 配置、字典表 |
| Write-Behind | 极低 | 高 | 弱一致(有丢失窗口) | 计数器、埋点 |
| Read-Through | 低 | 高 | 最终一致 | 想偷懒的业务层 |
我的建议很直接:默认 Cache-Aside,除非你有明确的理由不选它。
TTL、淘汰与内存:那些文档不会告诉你的参数
TTL 抖动不是可选项
固定 TTL 是所有缓存事故的头号元凶。假设你有 10 万个 key 在凌晨 2 点批量写入,TTL 都是 3600 秒 —— 那 3 点整它们会同时失效。所有请求打到 DB,DB 直接跪。这就是缓存雪崩。
抖动的做法简单粗暴:
BASE_TTL = 3600
JITTER = 0.1 # 10% 抖动
def jittered_ttl(base: int = BASE_TTL) -> int:
delta = int(base * JITTER)
return base + random.randint(-delta, delta)
10% 的抖动就够把失效峰值摊平到 12 分钟的窗口里。
淘汰策略怎么选
Redis 8.x 的 maxmemory-policy 有八个选项,但缓存场景实际只用得上两个:
maxmemory 4gb
maxmemory-policy allkeys-lru
别用 noeviction。这是默认值,但它是给"Redis 当数据库"用的 —— 内存满了之后所有写命令直接返回 OOM 错误。当缓存用的时候,你希望的是淘汰旧数据,不是让写入失败。
LRU 还是 LFU?如果热点数据分布均匀、访问模式稳定,LRU 够用。如果存在长尾热点(比如某些爆款商品被反复访问,而大部分数据几乎没人碰),LFU 的命中率会明显更高。Redis 8.x 的 LFU 用了概率计数器和衰减因子,配置项是:
maxmemory-policy allkeys-lfu
lfu-log-factor 10
lfu-decay-time 1
lfu-log-factor 越大,计数器增长越慢(区分度越低);lfu-decay-time 单位是分钟,控制热度衰减速度。这两个参数我调了整整两天才找到适合我们流量曲线的值。
大 key 是慢性毒药
Azure 的官方文档里有句话说得对:Redis 更擅长处理小 value。一个 5MB 的 hash,每次 HGETALL 都会阻塞事件循环几百微秒。如果你有几千个这种 key,Redis 的延迟曲线会变得极其难看。
拆分的办法是按业务维度手动分片:
# 别这样
redis.hset("user:profile:12345", mapping=all_200_fields)
# 这样
redis.hset("user:profile:12345:basic", mapping=basic_fields)
redis.hset("user:profile:12345:stats", mapping=stats_fields)
代价是你要自己管理这些分片的生命周期 —— 删旧数据的时候得一个个删,容易漏。
架构图:两级缓存的实际数据流
flowchart TD
Client[客户端请求] --> Local{本地缓存 Caffeine}
Local -->|命中 ~1ms| Return1[直接返回]
Local -->|未命中| Redis{Redis 分布式缓存}
Redis -->|命中 ~3ms| Backfill1[回填本地缓存] --> Return2[返回]
Redis -->|未命中| DB[(PostgreSQL)]
DB -->|查询 ~25ms| Backfill2[回填 Redis + 本地] --> Return3[返回]
Write[写请求] --> DBW[(更新 DB)]
DBW --> Del[删除 Redis key]
Del --> PubSub[Pub/Sub 广播失效]
PubSub --> Invalidate[各节点清本地缓存]
两级缓存的核心收益:本地缓存命中率通常在 60%~85% 之间,这部分请求完全不走网络,延迟是微秒级。
但这里有个致命问题:Pub/Sub 是不可靠的。Redis Pub/Sub 不保证投递,订阅者断线重连期间的消息会丢。丢一条失效消息,那个节点就会一直返回脏数据,直到本地 TTL 到期。
我们的解法是给本地缓存设一个较短的兜底 TTL(比如 5 秒),即使失效广播丢了,脏数据最多存活 5 秒。这个数字是权衡出来的 —— 太短则本地缓存形同虚设,太长则业务受不了。
性能基准:我们实测的数据
这些数字来自我们生产环境的 3 节点 Redis 8.2 集群(16 vCPU / 64GB,同机房),客户端是 Jedis 5.x 连接池。
| 操作 | P50 | P95 | P99 | 备注 |
|---|---|---|---|---|
| 本地缓存命中 | 0.08 ms | 0.3 ms | 1.1 ms | Caffeine,堆内 |
| Redis GET(小 value <1KB) | 0.9 ms | 1.6 ms | 3.2 ms | 同机房 RTT |
| Redis GET(大 value 100KB) | 3.4 ms | 11 ms | 28 ms | 网络+序列化 |
| Redis MGET(100 key) | 2.1 ms | 4.8 ms | 9.7 ms | 批量化收益明显 |
| 缓存未命中 → DB 回源 | 18 ms | 34 ms | 92 ms | PostgreSQL,走索引 |
看到差距了吗?本地缓存和 Redis 之间差了一个数量级,Redis 和 DB 之间又差了一个数量级。这就是为什么分层是有意义的。
还有个细节:MGET 相比 100 次单独 GET,网络往返从 100 次降到 1 次,但我们实测 P99 只降了 60% 左右 —— 因为 Redis 是单线程处理命令,MGET 100 个 key 本身也要占 CPU。别指望批量化能带来线性收益。
成本与安全:老工程师才在意的事
内存就是钱
云上 Redis 的价格大概是每 GB 每月 15~40 美元(视厂商和规格而定)。一个 64GB 的实例一年下来小两万刀。你缓存的东西如果命中率只有 30%,那就是在烧钱。
我的经验法则:如果某个 key 的命中率低于 20%,考虑不缓存它。用 INFO stats 里的 keyspace_hits / keyspace_misses 做粗估,细粒度的话得上监控埋点。
常见的安全坑
- 别把 Redis 暴露在公网。这个不用多说,但每年还是有人中招。
CONFIG和FLUSHALL要重命名掉。在redis.conf里:
需要的时候用rename-command CONFIG "" rename-command FLUSHALL ""ACL给运维账号单独授权。- 序列化格式选型会影响安全。别用 pickle 这类能执行任意代码的格式,JSON 或者 MessagePack 更稳。
替代方案与取舍
Redis 不是唯一选择,2026 年的选项比五年前多得多:
| 方案 | 延迟 | 内存效率 | 运维复杂度 | 适合谁 |
|---|---|---|---|---|
| Redis 8.x | 优 | 中 | 中 | 通用首选 |
| Memcached | 优 | 高(纯 KV) | 低 | 只要简单 KV |
| Dragonfly | 优 | 高 | 中 | 想要更高单机吞吐 |
| KeyDB | 优 | 中 | 中 | 需要多线程 |
| 本地 Caffeine | 极优 | - | 极低 | 只读热点数据 |
Memcached 的内存效率确实比 Redis 高,因为它不需要维护复杂数据结构 —— 但你也拿不到 ZSet、Stream 这些能力。Dragonfly 宣称单机吞吐是 Redis 的 25 倍,我测下来在纯 GET 场景大概 4~6 倍,营销数字看看就好。
我的选择:小团队直接 Redis,别折腾。大流量场景可以考虑 Redis + 本地缓存两级,我们这么做之后 P99 从 2.1s 掉到 380ms,效果是实打实的。
References & Community Insights
- Redis 官方淘汰策略文档:https://redis.io/docs/latest/develop/reference/eviction/
- Azure Cache for Redis 最佳实践(大 value 拆分那部分讲得很实在):https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-best-practices-development
- Hacker News 上关于自托管多数据库的讨论(Wharf):https://news.ycombinator.com/item?id=41360000
- Charles Leifer 那篇用 Redis 写 Markov 链机器人的老文,虽然 2012 年的但数据结构用法至今没过时:https://charlesleifer.com/blog/building-markov-chain-irc-bot-python-and-redis/
FAQ
Q:Redis 缓存和本地缓存(Caffeine)到底该选哪个?
不是二选一,是分层。本地缓存处理超热点数据(微秒级延迟),Redis 处理分布式共享数据(毫秒级)。只上本地缓存会有多节点数据不一致问题,只上 Redis 则拿不到那 60%~85% 的本地命中率红利。
Q:缓存穿透、缓存击穿、缓存雪崩到底啥区别?
穿透是查一个根本不存在的 key,每次都打到 DB(解法:布隆过滤器或缓存空值)。击穿是单个热点 key 失效瞬间大量请求涌入(解法:互斥锁重建)。雪崩是大量 key 同时失效(解法:TTL 加抖动)。三个问题三种解法,别混。
Q:TTL 设多长合适?
没有万能答案,但有方法论:观察数据的更新频率和访问频率。更新频率低、访问频率高的,TTL 可以长(小时级);更新频繁的,TTL 短(分钟级)或者改用主动失效。关键是永远加抖动。
Q:为什么我的 Redis 内存一直涨,明明设了 maxmemory?
大概率是 maxmemory-policy 设成了 noeviction,淘汰根本没触发。检查 INFO memory 里的 used_memory 和 maxmemory,再看 evicted_keys 是不是 0。如果是 0 且内存满了,就是策略配错了。另一个可能是碎片率太高,看 mem_fragmentation_ratio,超过 1.5 考虑 activedefrag yes。
