Redis 做缓存预热,先写 MySQL 还是先灌缓存?一次大促前的取舍实测。

举报
slchy 发表于 2026/10/29 03:19:31 2026/10/29
【摘要】 > 缓存可以丢、可以重放、可以回源;数据库丢了就是真丢了。所以这道题的答案,其实在动手之前就定了。---## 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)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。