这次也来分享我们一位客户的经历,相信也能给你带来一点启发。
许多人一直以来都在用黑盒的思路看待 Redis。只是设置一个 key,然后从上面读 key,就开始抱怨为什么 p99 延迟此般夸张。曾经好几个夜晚,抱怨着包括数据库、网络在内的各种组件,不断排查瓶颈,最后才意识到根因出在 Redis: 缓存策略设置得太不成熟了。
在高并发时让系统资源枯竭、抖动不止,反过来一旦缓存冷掉,数据库就被打得压力飙升。如果这些情况,看起来眼熟,不妨接着看看我们的分享。了解下我们是如何让系统从只是纸面性能好变成真正的线上稳定。
让我吃足苦头的错误
许多人所犯下的最大的错误,无外乎别的,就是**但凡是能缓存的都缓存,毫无策略地用 cache-aside。**没有限界、没不设TTL、没有 miss 保护机制,一切交给 Redis,系统在看起来干练简洁,一旦上线,在真实流量下又惨不忍睹。
-
现象: 流量高峰时 miss 暴涨
-
根因: 裸用 cache-aside,没有任何保护
-
结果: 数据库被打穿、请求超时、用户体验崩溃
# 典型的 cache-aside
value = r.get("user:42")if value is None:
value = db_get_user(42)
r.set("user:42", value)return value
-
问题: 没命中的每个请求都会直奔数据库。
-
解决: 统一加 TTL,并对过期策略做柔性处理。
-
效果: 避免冲击、降低峰值抖动、内存更稳定。
决定什么值得被缓存
缓存不等同于数据库的副本,它是一个需要规则的加速层。
哪些数据适合放入缓存?
-
热数据且稳定: 商品元数据、feature flags、枚举表
-
热数据,但多变: 会话、购物车、推荐 feed
-
冷数据,但成本高: 汇总、报表、搜索提示
提升性能最快的方式不是缓存得越多越好,而是只缓存回报最高的那部分,同时为不同波动频率选择不同策略。
最常用的三种缓存模式
Cache-aside + 合理的 TTL(读多更新少)
适用于:读多写少、偶尔更新的数据。
# 带合理 TTL 的 cache-aside
value = r.get("item:123")if value is None:
value = db_get_item(123)
r.set("item:123", value, ex=300) # 5 分钟return value
-
问题: 无限期缓存导致旧数据堆积、内存占用膨胀
-
解决: TTL 与数据更新频率一致
-
效果: 命中率稳定,可接受的过期窗口
Read-through + 背景刷新(一致的读取性能)
适用于:需要稳定响应延迟、能接受极短时窗口 stale。
# read-through + 后台异步刷新def get_item(item_id):
value = r.get(f"item:{item_id}")if value is None:
value = db_get_item(item_id)
r.set(f"item:{item_id}", value, ex=600)return value
# 接近过期时触发异步刷新
ttl = r.ttl(f"item:{item_id}")if ttl is not None and ttl < 60:
enqueue_refresh(item_id)return value
-
问题: 过期点上的同步 miss 形成山峰效应
-
解决: 提前刷、后台刷
-
效果: miss 风暴消失,p99 延迟收敛
Write-through(对一致性要求极高)
用于:必须确保缓存不出现 stale 的写操作。
# write-through 写操作def update_user(user_id, payload):
db_update_user(user_id, payload)
r.set(f"user:{user_id}", payload, ex=900)
-
问题: 缓存与数据库出现竞态
-
解决: 写数据库后立即同步写缓存
-
效果: 数据读回始终一致
从源头阻止缓存击穿
热点 key 一旦过期,在高并发流量下所有请求都会打到数据库。使用以下三个组件避免缓存击穿:
-
single flight(互斥填充):只有 1 个请求负责更新,其他请求暂时返回(可能)过期的缓存值。
-
随机 TTL(jitter):避免大量key同时过期。
-
soft TTL(柔性过期):允许短暂带过期返回,后台更新。
# soft TTL + single flight 示例def get_hot(key):
value = r.get(key)if value is not None:
exp_ts = r.hget(f"meta:{key}", "exp")if exp_ts and int(exp_ts) > now():return value
# 允许短时间的 stale,单飞刷新if r.setnx(f"lock:{key}", "1"):
r.expire(f"lock:{key}", 30)
enqueue_refresh(key)return value
# 空缓存情况下的单飞填充if r.setnx(f"lock:{key}", "1"):
r.expire(f"lock:{key}", 30)
value = db_get(key)
r.set(key, value, ex=300)
r.hset(f"meta:{key}", mapping={"exp": now() + 240})
r.delete(f"lock:{key}")return value
return fallback_value()
能抗流量波峰的缓存架构
+-----------+ +---------+
| Users | ----> | API |
+-----------+ +---------+
|
v
+-------------+
| Redis |
| Cache |
+-------------+
|
miss / refresh
|
v
+-------------+
| DB |
+-------------+
-
核心路径: API 优先读 Redis,只有有限情况才落到 DB
-
防护: single flight
-
保持热度: 后台任务定期刷新热键
别啥都往里扔,内存不是垃圾桶
Redis 的淘汰策略本质上决定了谁必须让位。
-
allkeys-lru: 最近最少使用
-
volatile-ttl: 只淘汰有 TTL 的 key(计划性缓存常用)
-
allkeys-lfu: 基于访问频率,适合突发访问
# redis.conf
maxmemory 4gb
maxmemory-policy allkeys-lfu
-
问题: 高频 churn 的 key 把真正有价值的热点数据挤掉
-
解决: LFU 更适合突发流量
-
效果: 命中率提升、内存抖动减少
避免代价高昂的错误
-
不要在错误的层做缓存
-
缓存的数据必须能被正确失效(write-through / event-driven)
-
必须监控命中率、miss 风暴、p99、memory、evictions
# 简单的命中率监控
redis-cli INFO stats | grep keyspace_hits
redis-cli INFO stats | grep keyspace_misses
CheckList
-
TTL 必须与更新频率一致
-
TTL 加 jitter
-
填充路径使用 single flight
-
热点 key 在发布前预热
-
淘汰策略需与访问模式匹配
-
每天监控 hit rate / p99 / evictions
架构示意
Users
|
v
+------+ hit
| API | ------------+
+------+ |
| v
v +--------+
+------+ | Redis |
| Auth | | Cache |
+------+ +--------+
| |
| miss/refresh
v |
+-------------------------+
| Database |
+-------------------------+
Deploy warmup
|
v
+--------+ +--------+ +-----------+
| Jobs |->| Redis |-> | API Read |
+--------+ +--------+ +-----------+
^ | |
| v v
| +-------+ +-------+
| | DB | | Users |
| +-------+ +-------+
|
Metrics
结语
Redis 是性能优化的利器,但只有策略正确,它才能真正发挥威力。
-
合理的 TTL 胜过永不过期
-
single flight 胜过硬扛流量
-
策略优化胜过盲目堆缓存
如果这篇分享能帮助你更清晰地理解系统行为,欢迎继续关注后续文章。
350

被折叠的 条评论
为什么被折叠?



