2022 年底我所在的电商平台搞了一次大促零点峰值流量预期是平时的 30 倍后端架构基本是常规配置 MySQL 主从 + Redis 缓存 + 应用层水平扩容 我们提前把 Redis 实例扩到 16 个分片应用扩到 200 个 pod 心里很笃定缓存命中率平时一直是 98% 应该撑得住可零点一过整个监控大屏炸了第一个先挂的是商品详情接口 RT 从 30 毫秒涨到了 8 秒 MySQL 的 QPS 直接打到 50000 主库 CPU 100% 我冲到日志里去看应用全部在 db 连接池等待 Redis 命中率从 98% 掉到了 12% 我心里咯噔一下意识到出大事了第二种最难缠某个昨天新上的商品被某个网红推荐成了爆款查询量突然飙到 50 万 QPS 全部 hit 同一个 Redis key 这个 Redis 实例 CPU 直接 100% 单机扛不住整个分片瘫痪第三种最离谱有个搞流量的人发现我们的商品详情接口对不存在的商品 ID 返回 404 而且数据库每次都被穿透他用脚本一秒发 2000 个随机商品 ID 把 MySQL 拖到几乎不可用第四种最莫名其妙我们用了 EXPIRE 给缓存设了 1 小时 TTL 大批量商品几乎同一秒入的缓存所以 1 小时后几乎同一秒过期下一秒所有请求集体 miss 涌入数据库直接打挂第五种最致命我们对所有商品都加了缓存以为高枕无忧某天有个商品被运营更新了价格更新代码先 delete 缓存再写 db 在删完缓存还没写完 db 这个间隙读请求把旧值又写回了缓存于是 1 小时内所有用户都看到了错价格我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为缓存就是 Redis 加 TTL 命中率高就万事大吉可这个认知是错的真正能在生产用的缓存方案是一个"穿透防御 + 击穿防御 + 雪崩防御 + 一致性策略 + 监控降级"的完整体系少任何一环都会在峰值时刻把你的 db 打挂本文从头梳理缓存穿透 击穿 雪崩三种场景到底是什么 各自的防御手段是什么 缓存与数据库一致性怎么权衡 热点 key 怎么处理 以及一些把 Redis 缓存做扎实要避开的工程坑
问题背景
缓存是后端最基础的性能优化手段,大多数团队都会用 Redis 给数据库挡掉 80%-99% 的读请求。但只要业务流量上来一些,几乎每个团队都会撞上"缓存失守 + 数据库被打挂"的事故。这些事故的特征很相似:平时一切正常,某一刻突然 RT 飙升、DB CPU 100%、缓存命中率断崖式下降。根因不在 Redis 本身,在你对"缓存怎么会失守"的理解不到位。常见的几类失败模式:
- 缓存穿透:查询根本不存在的 key,缓存里没有,每次都打到 db,db 被无效查询拖垮。
- 缓存击穿:某个超级热点 key 过期瞬间,大量并发请求同时打到 db。
- 缓存雪崩:大量 key 同一时刻过期,或者 Redis 整体故障,所有请求涌入 db。
- 不一致:db 更新和缓存更新顺序错位,导致缓存里长期存的是旧值。
- 热点 key:某个 key 流量极高,单分片 Redis 扛不住,瓶颈在 Redis 不在 db。
- 无降级:Redis 挂了应用直接报错,没有兜底机制让业务降级运行。
一、缓存穿透:别让无效请求打垮 DB
缓存穿透指的是"查询根本不存在的数据"。攻击者或者爬虫故意构造大量不存在的 ID 请求接口,这些 ID 在缓存里查不到(因为根本没缓存过),全部落到数据库,而数据库查完也没有结果,什么都返回不了。最危险的是,常规的"查到才缓存"逻辑下,这些无效查询永远不会被缓存,每次都打 db,流量越大 db 越惨。
典型的有问题的代码长这样:
def get_product(product_id: int) -> dict:
# 1. 查缓存
cached = redis.get(f"product:{product_id}")
if cached:
return json.loads(cached)
# 2. 缓存 miss, 查 db
product = db.query("SELECT * FROM products WHERE id = %s", (product_id,))
if not product:
return None # 致命问题:不存在的 ID 不缓存, 下次还会打 db
# 3. 写缓存
redis.setex(f"product:{product_id}", 3600, json.dumps(product))
return product
# 攻击者:for i in range(10000000): get_product(random_negative_id())
# 后果:db 被全量 SELECT 没命中, CPU 打爆
防御穿透有两种主流方案,根据场景选用:
方案一:空值缓存。查不到的 ID 也写一条"空"标记到缓存,设一个较短的 TTL(比如 60 秒),下次再查直接返回不存在,不再打 db。这是最简单的方案,适合 ID 空间相对有限的场景。
NULL_PLACEHOLDER = "__NULL__"
def get_product(product_id: int) -> dict:
cached = redis.get(f"product:{product_id}")
if cached == NULL_PLACEHOLDER:
return None # 已知不存在
if cached:
return json.loads(cached)
product = db.query("SELECT * FROM products WHERE id = %s", (product_id,))
if not product:
# 空值也缓存, TTL 短一些避免长期占位
redis.setex(f"product:{product_id}", 60, NULL_PLACEHOLDER)
return None
redis.setex(f"product:{product_id}", 3600, json.dumps(product))
return product
方案一的局限是攻击者如果用海量随机 ID(比如 2^31 个可能的 int),空值缓存会占满 Redis 内存。这时候要上方案二:布隆过滤器。
方案二:布隆过滤器。在 Redis 前面加一个布隆过滤器,所有合法的 ID 入库时也写入布隆过滤器。查询时先问布隆过滤器"这个 ID 有可能存在吗?",布隆过滤器说"绝对不存在"就直接返回,说"可能存在"再走缓存 + db。布隆过滤器有假阳性(说存在但其实不存在)但不会有假阴性(说不存在就一定不存在),所以拦截穿透很合适。
# 用 Redis 自带的布隆过滤器模块(RedisBloom)
# BF.RESERVE key error_rate capacity
# BF.ADD key item
# BF.EXISTS key item
import redis
r = redis.Redis()
# 初始化:1% 假阳性率, 容量 1000 万
r.execute_command("BF.RESERVE", "product_ids_bloom", "0.01", "10000000")
def init_bloom_from_db():
# 启动时把所有商品 ID 灌进布隆
cursor = 0
while True:
ids = db.query("SELECT id FROM products WHERE id > %s "
"ORDER BY id LIMIT 10000", (cursor,))
if not ids:
break
for row in ids:
r.execute_command("BF.ADD", "product_ids_bloom", row["id"])
cursor = ids[-1]["id"]
def get_product(product_id: int) -> dict:
# 先问布隆过滤器
exists = r.execute_command("BF.EXISTS", "product_ids_bloom", product_id)
if not exists:
return None # 一定不存在, 不打 db
# 可能存在, 走正常缓存 + db 流程
cached = redis.get(f"product:{product_id}")
if cached:
return json.loads(cached)
product = db.query("SELECT * FROM products WHERE id = %s", (product_id,))
if product:
redis.setex(f"product:{product_id}", 3600, json.dumps(product))
return product
# 商品创建时同步写布隆:
def create_product(data):
pid = db.insert(data)
r.execute_command("BF.ADD", "product_ids_bloom", pid)
return pid
认知翻转:缓存穿透不是"用户不小心",而是"被攻击或被烂客户端打"的场景,生产里不做防御就是裸奔。空值缓存适合 ID 空间小、热点查询多的场景;布隆过滤器适合 ID 空间大、需要长期保护的场景。两者可以叠加用——布隆过滤器粗筛 + 空值缓存细挡——形成两层防御。生产 Redis 上线前必须先想清楚"如果有人故意构造不存在的 ID 怎么办",没想清楚就别上线。
二、缓存击穿:热点 key 过期瞬间的雷
缓存击穿指的是"某个热点 key 过期瞬间,大量并发请求同时打到 db"。这个场景特别坑——平时一切正常,某个商品被推荐成爆款后流量飙升,缓存的 TTL 一到,几万个 QPS 同时发现缓存 miss,全部冲到 db 去查同一个商品,db 瞬间被打挂。
击穿和穿透的区别:穿透是查不存在的数据,击穿是查存在但缓存失效的数据。击穿的破坏力更大,因为命中的是真正的热点。
防御击穿有几种典型方案:
方案一:互斥锁(单飞 single-flight)。第一个发现缓存 miss 的请求去查 db 并回填缓存,其他并发请求等待这个请求的结果,而不是各自打 db。Go 标准库的 singleflight 就是这个思路,Python/Java 用分布式锁实现。
import time, uuid
def get_with_singleflight(key: str, fetch_fn, ttl: int = 3600,
lock_timeout: int = 5) -> dict:
# 1. 查缓存
cached = redis.get(key)
if cached:
return json.loads(cached)
# 2. 缓存 miss, 尝试获取互斥锁
lock_key = f"lock:{key}"
lock_value = str(uuid.uuid4())
got_lock = redis.set(lock_key, lock_value, nx=True, ex=lock_timeout)
if got_lock:
try:
# 双重检查(可能在等锁时别人已经填好缓存)
cached = redis.get(key)
if cached:
return json.loads(cached)
# 真的要查 db
value = fetch_fn()
redis.setex(key, ttl, json.dumps(value))
return value
finally:
# 释放锁(只有自己持有的锁才能释放)
if redis.get(lock_key) == lock_value:
redis.delete(lock_key)
else:
# 没拿到锁, 等待一小段时间后重试读缓存
for _ in range(10):
time.sleep(0.05)
cached = redis.get(key)
if cached:
return json.loads(cached)
# 实在等不到, 直接查 db(降级, 避免阻塞用户)
return fetch_fn()
方案二:逻辑过期。物理 TTL 不设(或者设很长),在 value 里嵌一个"逻辑过期时间"。请求拿到数据后判断逻辑过期了就异步刷新缓存,但当前请求仍返回旧数据。这样无论何时缓存里都有数据,不会出现 miss,db 也不会被打。
import threading, time
def get_with_logical_expire(key: str, fetch_fn,
logical_ttl: int = 3600) -> dict:
cached = redis.get(key)
if cached:
entry = json.loads(cached)
if entry["expire_at"] > time.time():
return entry["data"] # 没过期, 直接返回
# 逻辑过期, 触发异步刷新, 但本次仍返回旧值
if redis.set(f"refresh_lock:{key}", "1", nx=True, ex=30):
threading.Thread(target=_refresh, args=(key, fetch_fn,
logical_ttl)).start()
return entry["data"]
# 缓存里完全没有(极端情况, 例如服务首次启动)
return _refresh(key, fetch_fn, logical_ttl)
def _refresh(key: str, fetch_fn, logical_ttl: int):
try:
data = fetch_fn()
entry = {"data": data, "expire_at": time.time() + logical_ttl}
# 物理 TTL 设很长, 让数据永远在缓存里
redis.setex(key, logical_ttl * 3, json.dumps(entry))
return data
finally:
redis.delete(f"refresh_lock:{key}")
方案一(互斥锁)实现简单,适合大多数业务。方案二(逻辑过期)效果更好但代码复杂,适合"绝对不能让用户感知 miss"的核心场景(比如商品详情、首页推荐)。生产里推荐先用方案一,出问题再升级到方案二。
认知翻转:击穿是缓存设计里最容易被忽视的场景,因为它平时不出问题,只在"热点 key + 过期瞬间 + 大并发"三个条件同时满足时爆炸。这三个条件在生产里恰恰是经常出现的(大促、爆款、推荐位)。预防击穿不需要等出事——所有可能成为热点的 key 都应该走互斥锁或逻辑过期模式,这是"防御性编程",而不是"出事再加"。
三、缓存雪崩:大批 key 同时失效或 Redis 整挂
缓存雪崩有两种类型:
类型一:大量 key 同时过期。最常见的触发场景是某个上游批处理一次性写入了几百万条数据,所有 key 用同样的 TTL,1 小时后几乎同一秒过期,下一秒所有请求 miss 涌入 db。这种雪崩跟击穿的区别是"不是某一个热点 key 挂了,而是一大批 key 集体挂了"。
防御方案是给 TTL 加随机抖动,不要所有 key 都用同一个 TTL:
import random
def set_with_jitter(key: str, value: dict, base_ttl: int = 3600,
jitter_ratio: float = 0.2):
# 在 base_ttl 的 ±20% 范围内随机
ttl = int(base_ttl * (1 + random.uniform(-jitter_ratio, jitter_ratio)))
redis.setex(key, ttl, json.dumps(value))
# 批量入缓存时尤其重要
def batch_warm_cache(items: list):
for item in items:
# 千万别这样:同一秒写入, 1 小时后同一秒过期 = 雪崩
# redis.setex(f"item:{item['id']}", 3600, json.dumps(item))
# 应该这样:TTL 在 2880-4320 秒之间随机
set_with_jitter(f"item:{item['id']}", item, base_ttl=3600)
类型二:Redis 集群挂掉。某个 Redis 分片故障切换 / 网络分区 / 主从切换期间,大量 key 不可访问。这一类雪崩比 TTL 雪崩更严重,因为整个分片的请求都打到 db。
防御 Redis 集群挂掉需要做多层兜底:
第一,多级缓存。应用层加一层本地缓存(进程内 LRU),即使 Redis 挂了热点数据还能从本地缓存返回。本地缓存 TTL 短(几秒到几十秒),容量小(几千条热点 key),只挡瞬间洪水。
from cachetools import TTLCache
from threading import Lock
local_cache = TTLCache(maxsize=5000, ttl=10) # 本地 10 秒
local_lock = Lock()
def get_product_multilevel(product_id: int) -> dict:
# L1: 本地缓存
with local_lock:
if product_id in local_cache:
return local_cache[product_id]
# L2: Redis
try:
cached = redis.get(f"product:{product_id}")
if cached:
data = json.loads(cached)
with local_lock:
local_cache[product_id] = data
return data
except RedisError as e:
log.warning(f"redis down, fallback to db: {e}")
# Redis 挂了, 走 db, 但要限流避免打死 db
if not db_fallback_limiter.acquire():
raise ServiceUnavailableError("缓存不可用且 db 已限流")
# L3: DB
product = db.query("SELECT * FROM products WHERE id = %s", (product_id,))
if product:
with local_lock:
local_cache[product_id] = product
try:
redis.setex(f"product:{product_id}", 3600, json.dumps(product))
except RedisError:
pass # Redis 写失败也不影响返回
return product
第二,熔断 + 限流降级。检测到 Redis 异常率超过阈值就熔断,直接走数据库 + 本地缓存,但数据库前必须加限流避免打死。常用工具是 resilience4j / Sentinel / Hystrix。
第三,Redis 高可用部署。Redis Cluster 模式(3 主 3 从 起步)或 Sentinel + 主从,任何一个节点挂掉自动切换。但要意识到切换期间(秒级)仍可能有请求失败,本地缓存能扛过这个窗口。
第四,业务降级开关。最坏情况下能配置开关让某些非核心功能直接返回降级数据(如"详情暂不可用,稍后再试"),保护核心交易链路。
[mermaid]
flowchart TD
A[请求进来] --> B{本地缓存命中?}
B -->|是| C[直接返回]
B -->|否| D{Redis 健康?}
D -->|是| E[查 Redis]
E -->|命中| F[回填本地缓存 返回]
E -->|miss| G[查 db 互斥锁]
D -->|否 熔断| H[限流后查 db]
H -->|限流通过| G
H -->|限流拒绝| I[返回降级响应]
G --> J[回填 Redis 本地缓存]
J --> C
认知翻转:缓存雪崩不是"小概率事件",大促/批处理/Redis 节点故障是生产里每年都会撞上的常规场景。TTL 随机化、多级缓存、熔断降级、Redis 高可用,这四件事不是"可选优化",是"上线前必做"。先做这些再上线,即使 Redis 整挂业务也能撑住 90% 流量,真正的缓存雪崩事故几乎都是这四件事漏做了一两件。
四、缓存一致性:更新策略的 5 种姿势
缓存一致性是缓存设计里最难的问题——db 更新了,缓存怎么同步?常见的几种策略各有取舍,选错了就是"用户看到旧数据"的事故。五种主流策略:
策略 1:Cache-Aside(旁路缓存)。最常用,业务代码自己管理缓存。读时先查缓存,miss 查 db 并回填;写时先更新 db,再删除缓存。优点是简单清晰,缺点是"先删缓存再写 db"和"先写 db 再删缓存"都有不一致窗口。
# Cache-Aside 写入的两种顺序
# 方案 A:先删缓存再更新 db
def update_product_v1(pid, new_data):
redis.delete(f"product:{pid}")
db.update("UPDATE products SET ... WHERE id = %s", (..., pid))
# 问题:删完缓存到写完 db 这个间隙,
# 有读请求 miss 后会把"旧的"db 数据写回缓存, 缓存里又是旧的
# 方案 B:先更新 db 再删缓存(更推荐)
def update_product_v2(pid, new_data):
db.update("UPDATE products SET ... WHERE id = %s", (..., pid))
redis.delete(f"product:{pid}")
# 问题:写完 db 到删完缓存这个间隙, 读请求会读到缓存里的旧值
# 但持续时间极短(几毫秒), 比方案 A 安全
# 推荐:延迟双删(进一步降低不一致窗口)
def update_product_v3(pid, new_data):
redis.delete(f"product:{pid}") # 第一次删
db.update("UPDATE products SET ... WHERE id = %s", (..., pid))
redis.delete(f"product:{pid}") # 第二次删
# 还可以异步再延迟 1 秒删一次, 应对主从延迟
策略 2:Write-Through(写穿)。写时同时写 db 和缓存,业务层不感知。缺点是双写要保证原子性(分布式事务),实现复杂,且如果业务不需要读这个 key,缓存就白写了。
策略 3:Write-Behind(写回)。写时只写缓存,异步批量写 db。优点是写延迟极低,缺点是缓存挂了数据就丢了。适合可容忍少量丢失的场景(如计数器、点赞数),不适合强一致场景(如订单)。
策略 4:订阅 binlog 同步。MySQL 的 binlog 被 canal / debezium 监听,数据变更事件推送到消息队列,消费者根据事件删除或更新缓存。优点是业务代码完全不感知缓存,缺点是引入额外组件,运维复杂。适合多写源、多缓存系统的复杂场景。
策略 5:版本号 / 时间戳判断。缓存 value 带版本号,db 也维护版本号,读到缓存时跟 db 对比版本号,不一致就重新查 db。这是一种"读时校验",可以解决一致性但每次读要查 db 一次(可以批量),成本不低。
五种缓存策略对比
| 策略 | 一致性 | 性能 | 复杂度 | 适用场景 |
|----------------|----------|------|--------|------------------------|
| Cache-Aside | 弱一致 | 高 | 低 | 大多数业务首选 |
| Write-Through | 强一致 | 中 | 中 | 写少读多 + 强一致需求 |
| Write-Behind | 最终一致 | 极高 | 中 | 计数器 / 可丢失场景 |
| binlog 订阅 | 最终一致 | 高 | 高 | 多源多缓存 / 复杂架构 |
| 版本号校验 | 强一致 | 低 | 中 | 金融 / 对账 / 不能错 |
实践建议:
- 80% 业务用 Cache-Aside + 先写 db 后删缓存 + 延迟双删
- 5% 强一致场景(支付状态等)用版本号校验或干脆不缓存
- 5% 高写入低一致场景(点赞)用 Write-Behind
- 10% 复杂多缓存场景用 binlog 订阅
另一个容易踩的坑是"主从延迟下的不一致"。写完主库,立刻删缓存,但下一个读请求走了从库,从库还没同步到新数据,读到的旧数据又被写回缓存。延迟双删(第一次删完后等 1-2 秒再删一次)就是为了解决这个场景。
认知翻转:缓存一致性没有"完美方案",所有方案都是 trade-off。强一致就慢、快就弱一致、最终一致需要额外组件。大多数业务用 Cache-Aside + 先写 db 后删缓存 + 延迟双删就够,这是"在简单和一致之间"的平衡点。追求绝对强一致前先问自己:这个数据真的不能有几秒延迟吗?如果能容忍,就别上重武器;如果不能(支付/对账),那干脆别用缓存。在缓存里追求强一致是缘木求鱼。
五、热点 key:单分片打爆的解法
Redis 即使分了多个分片,某个分片承载的 QPS 也有上限(通常 10 万级)。如果一个 key 的 QPS 超过单分片上限,Redis 自己就成为瓶颈,跟 db 没关系。这种场景在电商秒杀、热门商品、爆款内容里特别常见。
识别热点 key 有几种方式:Redis 4.0+ 自带 redis-cli --hotkeys 命令,可以扫描出当前的热点;也可以用 monitor 命令实时监控(性能开销大,只能短时间用);生产推荐用 Redis 的 latency monitor 或第三方工具(如 RedisInsight)。
解决热点 key 的几种方案:
方案一:本地缓存兜上层。前面讲过的多级缓存,本地 TTL 1-5 秒,挡住 99% 的热点请求,Redis 实际承载的就是"每个应用实例每几秒一次"的请求。这是最简单有效的方案。
方案二:key 拆分(分片再分片)。把一个热点 key 拆成 N 个副本(product:123:0、product:123:1、...、product:123:N),每个副本存同样的数据,读时随机选一个副本。N 个副本分散到 N 个 Redis 实例,QPS 就被打散了。
HOTKEY_SHARDS = 16 # 把热点 key 拆 16 份
def get_hot_product(product_id: int) -> dict:
# 读时随机选一个副本
shard = random.randint(0, HOTKEY_SHARDS - 1)
key = f"product:{product_id}:{shard}"
cached = redis.get(key)
if cached:
return json.loads(cached)
# miss 走互斥锁回源
return _refresh_hot_product(product_id)
def _refresh_hot_product(product_id: int) -> dict:
lock_key = f"lock:hot:product:{product_id}"
if not redis.set(lock_key, "1", nx=True, ex=5):
time.sleep(0.05)
return get_hot_product(product_id)
try:
data = db.query("SELECT * FROM products WHERE id = %s", (product_id,))
# 写所有副本
pipe = redis.pipeline()
for i in range(HOTKEY_SHARDS):
pipe.setex(f"product:{product_id}:{i}", 3600, json.dumps(data))
pipe.execute()
return data
finally:
redis.delete(lock_key)
方案三:用 Redis 集群的客户端缓存(Redis 6.0+ Client-Side Caching)。Redis 6 引入了"Tracking"机制,客户端可以订阅自己读过的 key 的变更,Redis 数据变化时主动通知客户端清理本地缓存。这是 Redis 官方对热点 key 的解法。
方案四:把热点拆到独立的 Redis 实例。在客户端做路由,识别为热点 key 时打到专门的"热点 Redis"实例(独立部署、独立扩容、独立监控)。这种方案运维复杂,适合超大规模场景。
预防性的做法是"预判热点"——大促前把可能成为热点的 SKU 预热到本地缓存 + 拆分 key,大促开始后流量直接被本地缓存吃掉,不会成为热点 key 问题。运营提前把"主推 SKU"清单告诉技术,技术按这个清单做预热,这是大促保活的标准动作。
认知翻转:热点 key 是 Redis 自己的瓶颈,跟 db 无关。这意味着光"加 Redis 分片"解决不了热点问题——热点 key 永远只落到一个分片上。生产里防热点的根本手段是"本地缓存挡上层",次要手段是"key 拆分散到多分片"。运营层面要建立"重点 SKU 提前预热"的流程,把可预测的热点扼杀在大促前。
六、生产工程坑:那些"上线后才发现"的细节
除了上面五节讲的主要话题,真实生产里还有一堆"教程不教但你一定撞上"的细节。挑几个最常见最坑的:
第一,Redis 的 keys / smembers / hgetall 在大 key 上是 O(N) 操作,会阻塞整个 Redis 实例。生产里禁止 keys *(用 scan 代替),禁止对超过 1000 元素的 hash / set / zset 直接 hgetall(用 hscan / sscan 分批)。一个错误的 keys * 能让 Redis 卡住几秒,所有请求超时。
第二,大 value 危害极大。单个 value 超过 100KB 就要警惕,超过 1MB 几乎肯定有问题。大 value 序列化反序列化都耗时,网络传输也慢。解决办法是拆分(把一个大 hash 拆成多个小 hash),或者只缓存 ID 列表,详情按需查。
第三,Pipeline 一定要用。批量操作如果一条条发,网络 RTT 是性能瓶颈;用 pipeline 把一批命令一次发出去,性能能涨 10 倍以上。Python 的 redis.pipeline()、Go 的 redis.Pipeline()、Java 的 jedis.pipelined()。
# 反例:逐条 set, 100 次网络 RTT
for item in items:
redis.setex(f"item:{item['id']}", 3600, json.dumps(item))
# 正例:pipeline 一次发完, 1 次网络 RTT
pipe = redis.pipeline()
for item in items:
pipe.setex(f"item:{item['id']}", 3600, json.dumps(item))
pipe.execute()
第四,集群模式下 multi-key 操作受限。Redis Cluster 要求 multi-key 操作的所有 key 必须在同一个 slot,否则报 CROSSSLOT 错误。用 hashtag {} 包住"路由 key"可以强制多 key 落同 slot,比如 user:{1001}:profile 和 user:{1001}:orders 会落到同一个 slot。
第五,过期策略对内存的影响。Redis 的过期不是精确的——它是"被动过期(访问时检查) + 主动过期(定期采样)",意味着过期了的 key 在没被访问 + 没被采样到时仍占内存。如果有大量短期 TTL 的 key,会出现"逻辑上过期但内存没释放"的状况,内存使用率虚高。生产监控要看 used_memory 和 used_memory_human,异常时检查 expired_keys / evicted_keys 比例。
第六,maxmemory + 驱逐策略要配。生产 Redis 必须设 maxmemory 上限,避免吃完机器内存导致 OS 杀进程。驱逐策略选 allkeys-lru(LRU 淘汰所有 key)或 volatile-lru(LRU 淘汰有 TTL 的 key),不要用默认的 noeviction(满了就拒绝写入)。
第七,持久化策略影响延迟。RDB 快照在 fork 子进程时主进程会短暂卡顿(几百毫秒),大实例可能影响业务;AOF 每秒 fsync 一般问题不大,但 always 模式延迟高。生产推荐 RDB + AOF 混合,RDB 用于快速恢复,AOF 兜底数据完整性。
第八,客户端连接池要配好。连接数太少瓶颈,太多会让 Redis 维护连接的成本升高。一般每个应用实例 50-100 连接,根据 QPS 调整。要监控连接的等待时间,长期等待说明连接池不够。
第九,慢查询日志必看。Redis 自带 slowlog,默认记录超过 10ms 的命令。生产应该定期检查慢查询,找出大 key 操作、复杂 lua 脚本、网络问题导致的慢命令。
第十,做缓存监控大盘。命中率、QPS、内存使用率、连接数、慢查询数、淘汰数、阻塞客户端数,这些指标要建大盘 + 告警。命中率突然下降就是事故的早期信号,内存使用率持续升高就是泄漏。
认知翻转:Redis 看着简单实际坑深。大 key、慢命令、错误的 multi-key、不当的过期策略、错误的驱逐策略,任何一个都可能在生产里把 Redis 拖垮。把 Redis 当成"高性能 key-value 黑盒"是新手心态,把它当成"需要精细维护的内存数据库"才是生产姿势。前面讲的所有缓存策略再完美,这些底层细节没做好都会前功尽弃。
关键概念速查
| 概念 | 含义 | 常见误区 | 正确做法 |
|---|---|---|---|
| 缓存穿透 | 查不存在的 key 击穿到 db | 不做防御 | 空值缓存 或 布隆过滤器 |
| 缓存击穿 | 热点 key 过期瞬间打 db | 不防热点 | 互斥锁 或 逻辑过期 |
| 缓存雪崩 | 大批 key 同时失效或 Redis 挂 | TTL 用同值 | TTL 随机化 + 多级缓存 + 熔断 |
| Cache-Aside | 业务代码管缓存 | 先删缓存后写 db | 先写 db 后删缓存 + 延迟双删 |
| 热点 key | 单 key 流量超分片上限 | 只靠 Redis 分片 | 本地缓存挡上层 + key 拆分 |
| 大 key | 单 value 过大 | 不拆分 | 超 100KB 警惕 超 1MB 必拆 |
| keys 命令 | O(N) 阻塞 | 生产里用 | 禁用 改 scan |
| maxmemory | 内存上限 | 不设 | 必设 + 选 allkeys-lru |
| Pipeline | 批量发命令 | 不用 | 批量操作必须 pipeline |
| Cluster CROSSSLOT | 多 key 跨槽 | 不知道 | 用 hashtag 强制同槽 |
避坑清单
- 不要对不存在的查询返回 None 就完事,要写空值缓存或上布隆过滤器,防止 db 被穿透。
- 不要让热点 key 自然过期,要用互斥锁或逻辑过期防击穿,生产可预测的热点必须预热。
- 不要批量写缓存时所有 key 用同一 TTL,加 ±20% 随机抖动,防止集体过期引发雪崩。
- 不要只靠 Redis,本地缓存 + 熔断 + db 限流 三件套,Redis 挂时业务能撑住。
- 不要先删缓存后写 db,改成先写 db 后删缓存 + 延迟双删,降低不一致窗口。
- 不要追求绝对一致,大多数业务最终一致就够,真要强一致的场景考虑别用缓存。
- 不要让单个 key 流量超分片上限,本地缓存挡上层 + key 拆分散到多分片。
- 不要在生产用 keys *,用 scan;不要 hgetall 大 hash,用 hscan 分批。
- 不要不设 maxmemory + 驱逐策略,内存满了让 Redis 自己淘汰,别让 OS 把它 kill 掉。
- 不要不监控缓存大盘,命中率、QPS、内存、慢查询、淘汰数全要看,异常自动告警。
总结
Redis 缓存是后端工程里"看似入门简单实际深不见底"的那一类技能。简单是因为 SET / GET 谁都会,启动一个 Redis 实例不超过五分钟,加几行业务代码就能"看起来在用缓存"。深是因为缓存背后是一整套包含穿透防御、击穿防御、雪崩防御、一致性策略、热点处理、高可用、监控降级的系统工程。一份生产可用的缓存方案是否扎实,不取决于代码多少行,取决于"每一种可能让 db 被打挂的场景你都预先想到并防住了"。
另一层被严重低估的是,缓存的稳定性主要由"边界场景"决定,而不是"平常表现"。平常 98% 命中率漂亮归漂亮,真正决定生死的是"那 2% miss 的瞬间会不会形成洪峰"、"那一刻 Redis 节点切换业务能不能撑住"、"那一秒大批 key 集体过期 db 会不会被打挂"。这些边界场景每年都会出现,而它们的处理方案都跟"业务功能本身"无关——是纯工程基本功的较量。
打个不太严谨的比方,做缓存有点像给一座大坝设计防洪体系。Redis 是主大坝(平时挡住 98% 的水),本地缓存是一道前置堤坝(挡住瞬间洪峰),布隆过滤器是上游过滤器(拦掉无效水流),互斥锁是限流闸门(避免某一处突然决口),TTL 随机化是错峰泄洪(避免所有水同时来),熔断降级是紧急放水通道(实在挡不住至少不全淹)。一座好大坝这些环节缺一不可,任何一个不做就会在某场大雨时决堤。
所以做缓存,本地起一个 Redis 跑跑业务永远暴露不了真正的问题。它暴露不了大促零点全站缓存命中率突然掉到 12% 的恐怖,暴露不了一个被推成爆款的 SKU 把单分片 Redis CPU 打爆,暴露不了攻击者用随机 ID 一秒 2000 请求把 db 拖到不可用,暴露不了一次主从切换期间整段时间业务报错,暴露不了一个看似简单的 keys * 让线上所有请求超时。真正的检验在生产环境,在第一次大促的零点,在一次 Redis 节点抖动的下午,在一次爬虫扫库的早晨,在一次运营全量刷数据的晚上。把上面六节里的功夫提前做扎实,等那些时刻到来时,你会感谢自己当初没图省事。如果你正在维护或者准备搭一套缓存体系,请把它当成一个"穿透 + 击穿 + 雪崩 + 一致性 + 热点 + 监控"的多层防御工程,而不是"用 setex 加 get 的两行业务代码"——这是从能跑到生产稳定最关键也最容易被忽略的认知差。
—— 别看了 · 2026