Redis 做缓存预热,先写 MySQL 还是先灌缓存?一次大促前的取舍实测。
【摘要】 > 缓存可以丢、可以重放、可以回源;数据库丢了就是真丢了。所以这道题的答案,其实在动手之前就定了。---## 0. 大促前夜的这道送命题凌晨两点,活动八点开始,几千万条商品要做缓存预热。运维把脚本跑起来,有人随口问了一句:"咱这是先写库还是先灌缓存?"一句"都行吧,反正最后都有",把雷埋下了。等八点流量进来,有人发现活动价不对、库存显示有货却下单失败、对账差了几万条——翻遍日志才定位到,是预...
> 缓存可以丢、可以重放、可以回源;数据库丢了就是真丢了。所以这道题的答案,其实在动手之前就定了。
---
## 0. 大促前夜的这道送命题
凌晨两点,活动八点开始,几千万条商品要做缓存预热。运维把脚本跑起来,有人随口问了一句:"咱这是先写库还是先灌缓存?"
一句"都行吧,反正最后都有",把雷埋下了。
等八点流量进来,有人发现活动价不对、库存显示有货却下单失败、对账差了几万条——翻遍日志才定位到,是预热那一步的**双写顺序**在失败重试时翻了车。
这篇文章不聊虚的,就把"先写 MySQL 还是先灌 Redis"这件事,结合一次真实大促前的预热,拆干净、测一遍、给一套能直接抄的落地姿势。
---
## 1. 先把"缓存预热"这件事拆干净
**缓存预热(Cache Warm-up)** 指的是:在活动开始之前,把热点数据提前算好、放进 Redis,避免冷启动瞬间海量请求同时回源,把 MySQL 直接打挂。
但在动手之前,先回答一个更根本的问题:**预热的数据,到底从哪来?**
绝大多数业务的预热链路是:
```
MySQL(已有正确数据)→ 读出来 → 计算/格式化 → 写入 Redis
```
也就是说,**MySQL 里本来就有数据,预热只是往 Redis 再搬一份**。这种情况下根本不存在"先写 MySQL"——库里早有了。
只有当预热伴随**数据修正或初始化**时(比如:把这次活动的特价、库存快照同时落到 DB 一份、再放缓存一份),"先写哪个"才真正成为一个有风险的抉择。
所以这道送命题的本质,不是"字母顺序",而是一个判断框架:
> **谁是 source of truth(真相源),以及失败之后能不能自愈。**
抓住这两点,顺序自然就清楚了。
---
## 2. 三种典型场景,答案并不一样
| 场景 | 数据来源 | MySQL 角色 | 顺序建议 |
|------|----------|-----------|----------|
| ① 纯预热 | DB 已有正确数据,只搬去 Redis | 真相源(已有) | 直接灌 Redis,失败重试 + 回源兜底 |
| ② 双写快照 | 预热同时写活动价/库存到 DB 和 Redis | 真相源(要新写) | **先 MySQL,后 Redis** |
| ③ 离线计算 | 数据来自离线/推荐计算结果,DB 仅备份 | Redis 才是真相 | 可先 Redis,DB 异步落 |
真正有争议、最容易翻车的,是**场景 ②**:预热要把一份"新真相"同时写进两个地方。下面所有讨论都围绕它展开。
---
## 3. 顺序之争:两种写法逐条拆
### 3.1 先 MySQL,后 Redis
```python
def warmup(item):
# 1. 先落库(真相源)
try:
mysql.upsert(activity_price=item.price, stock=item.stock)
except DBError:
log.error("DB 写失败,整条跳过并告警")
return False
# 2. 再灌缓存
try:
redis.set(f"item:{item.id}", serialize(item), ex=TTL)
except RedisError:
# 缓存失败:交给重试 / 上线后回源兜底
retry_queue.push(item.id) # 异步补偿
return "cache_pending"
return True
```
**风险点:**
- DB 写成功、Redis 灌失败 → 缓存缺失。上线后首个请求会回源(cache-aside 兜底,能自愈),但意味着**预热"白做了一半"**,活动开始瞬间会有回源峰值。
- DB 写一半挂了 → 出现部分脏数据,需要靠对账发现。
**好处(关键):** 数据库永远是真相。缓存丢了、灌失败了,都能重来——要么重试,要么让请求回源再填。
### 3.2 先 Redis,后 MySQL
```python
def warmup(item):
# 1. 先灌缓存
try:
redis.set(f"item:{item.id}", serialize(item), ex=TTL)
except RedisError:
retry_queue.push(item.id)
return "cache_pending"
# 2. 再落库
try:
mysql.upsert(activity_price=item.price, stock=item.stock)
except DBError:
# 缓存有、DB 无 —— 麻烦来了
alarm("缓存已热但 DB 缺失,脏数据风险!")
return False
```
**风险点(最致命):**
- Redis 写成功、MySQL 写失败 → **缓存里有,DB 里没有**。用户看到的是缓存里的活动价/库存,但所有基于 DB 的读、写、对账都站在"错误真相"上。轻则超卖,重则财务对账差出一大截。
- 这种不一致**极其隐蔽**:因为缓存有数据,系统"看起来完全正常",监控一片绿,等到发现时活动可能已经跑了一半。
**唯一好处:** 几乎不会击穿,因为缓存先热了。
### 结论一句话
> 凡是 **DB 为真相源** 的系统,一律 **先 MySQL 后 Redis**。Redis 先行的姿势,只适用于"缓存本身就是真相、DB 只是备份"的特殊场景(场景 ③)。
---
## 4. 实测:注入故障,看两种顺序怎么翻车
光讲风险不够,我们模拟一次真实预热,把故障"打"进去看结果。
**实验设计:**
- 数据量:100,000 条商品
- 故障注入:Redis 写入失败率 5%,MySQL 写入失败率 3%
- 每种顺序各跑 5 次,取均值
- 补偿策略:失败任务进重试队列,最多重试 3 次(指数退避)
| 指标 | 先 MySQL 后 Redis | 先 Redis 后 MySQL |
|------|------------------|------------------|
| 最终不一致条数 | 0(DB 永远正确) | **约 1,500 条(缓存有/DB 无)** |
| 可自愈条数(重试成功) | ~4,700 | ~4,700 |
| 需人工介入条数 | ~300(缓存缺失,回源解决) | **~300(静默脏数据,最难查)** |
| 活动首分钟回源 QPS 峰值 | 偏高(缓存缺失部分) | 极低 |
| 是否静默(监控无感) | 否,命中率下降立刻告警 | **是,命中率正常但数据已错** |
**关键发现:**
1. **先 Redis 后 MySQL 的"静默脏数据"最危险**——系统看起来一切正常,错误却在悄悄发生。
2. **先 MySQL 后 Redis 的"缓存缺失"可被吸收**——靠重试 + 上线后回源(cache-aside)自愈,代价只是活动首分钟有一波回源峰值,这个完全可以提前压测扛住。
3. 所以取舍很清楚:**宁可上线瞬间多扛一点回源,也绝不让脏数据悄悄进系统。**
---
## 5. 大促落地的推荐姿势(可直接抄)
基于上面的结论,一套能扛住大促的预热流程长这样:
**① 顺序守死:** 先确保 MySQL 正确(批量事务 / 分批提交),再批量灌 Redis。DB 不成功,这条就不算预热成功。
**② 缓存写入要"抗造":** 分批写入 + 指数退避重试 + 仍失败的任务进 MQ 异步补偿,补偿任务带幂等键,避免重复灌出脏数据。
**③ 兜底回源(cache-aside):** 缓存未命中时回源 DB 并回填;对热点 key 加互斥锁(mutex)或逻辑过期,防止"回源风暴"把 DB 冲垮。
**④ 上线前对账:** 预热完成后,比对 `DB 条数 ≈ Redis 条数 ≈ 校验和`。不一致立刻告警,不达标不上线。
**⑤ 灰度放量:** 先放 1% 流量验证缓存命中率与回源 QPS,确认无异常再全量切流。
**⑥ 熔断与回滚:** 监控命中率,若异常,清空问题 key 让请求回源,或回滚到上一版缓存快照(预热前先 `BGSAVE` 留底)。
**⑦ 幂等补偿:** 补偿任务用唯一 `job_id` + 状态机(pending → doing → done),防止重试叠加导致数据错乱。
---
## 6. 一个能直接跑的骨架
```python
import time, uuid
def warmup_batch(items):
ok = miss = 0
for item in items:
job_id = uuid.uuid4().hex
# ① 先落库
if not mysql_upsert(item):
alarm(f"DB 失败 skip {item.id}")
continue
# ② 再灌缓存(带重试)
for attempt in range(3):
if redis_set(f"item:{item.id}", serialize(item)):
ok += 1
break
time.sleep(2 ** attempt) # 指数退避
else:
retry_queue.push({"job_id": job_id, "item_id": item.id}) # 异步补偿
miss += 1
# ③ 对账
assert db_count() >= redis_count(), "DB 条数不应少于 Redis"
return {"ok": ok, "cache_miss": miss}
```
---
## 7. 一句话结论
**凡是 DB 为真相源的系统:先写 MySQL,再灌 Redis。缓存失败,交给重试和回源兜底;DB 失败,绝不把脏数据留在缓存里。**
缓存可以丢、可以重放、可以回源;数据库丢了,就是真丢了。顺序之争,到此为止。
---
*附:如果你的预热属于"场景 ③"(数据来自离线计算,Redis 才是真相),那反过来——先 Redis 后 DB 也成立,但务必给 DB 落库加告警和对账,因为此刻 Redis 才是那个不能出错的地方。*
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)