# 29 · 缓存、消息队列与事件系统选型
> 一句话点题:**缓存不是数据库,消息队列不是银弹,事件系统也不是「加个 Kafka」就完事。它们解决的是三类压力:读热点、写洪峰、跨边界协作。选之前先问:我是在降低延迟、削平峰值,还是解耦状态推进?**
---
> **🧰 技术栈选型篇第 3 章 · 本章只练一件事**
>
> [28 章](28-数据库与存储选型.md) 讲事实源和读模型。本章讲事实源周围最常见的三类中间层:缓存(Cache,临时加速读)、消息队列(Message Queue,异步排队)、事件系统(Event System,用事件表达事实变化)。它们能救系统,也能把系统变得更难懂。
---
## 开场:这三个东西经常被混用
很多架构图里会出现:
```
App ──▶ Redis ──▶ MQ ──▶ Kafka ──▶ Worker
```
然后大家说:有缓存、有队列、有事件驱动,很高级。但真正要问的是:
- Redis 里放的是可丢的缓存,还是不该丢的业务状态?
- MQ 是为了削峰,还是为了让两个服务异步协作?
- Kafka 里的消息是命令(Command,让别人做事),还是事件(Event,告诉别人发生了什么)?
- 消费失败、重复消费、乱序、积压怎么办?
> **架构判断:**中间层的价值不在名字,而在它改变了什么质量属性:延迟、吞吐、可用性、耦合度、一致性、恢复成本。
---
## 一、缓存:只加速,不篡位
缓存适合解决**读热点**:
```
用户请求 ──▶ 应用 ──▶ 缓存命中 → 快速返回
└─ 缓存未命中 → 查主库 → 写缓存 → 返回
```
但缓存最容易犯三个错:
| 错误 | 后果 | 正确姿势 |
|---|---|---|
| 把缓存当事实源 | 缓存丢了数据就丢 | 主库是事实源,缓存可重建 |
| 不设计失效策略 | 用户看到旧数据或脏数据 | TTL(过期时间)、主动失效、版本号 |
| 所有请求一起穿透 | 主库被打爆 | 空值缓存、请求合并、限流、预热 |
缓存选型也要看数据形态:
| 缓存类型 | 适合 | 注意 |
|---|---|---|
| **本地缓存** | 配置、字典、低频变化数据 | 多实例不一致,更新慢 |
| **分布式缓存**(如 Redis) | 热点对象、会话、计数、限流 | 网络开销、容量、淘汰策略 |
| **CDN**(内容分发网络) | 图片、视频、静态资源、公开页面 | 失效延迟、边缘缓存一致性 |
> 判断句:如果缓存丢了,系统应该变慢,而不是变错。变错,说明你把业务状态偷偷放进缓存了。
---
## 二、消息队列:把「现在必须做」改成「可以排队做」
消息队列最常见的价值是**削峰填谷**:
```
洪峰请求 ──▶ 入口限流 ──▶ 队列 ──▶ Worker 按能力消费 ──▶ 主库
```
它把瞬时压力变成可控排队,常见于:
- 抢票锁座后的出票通知。
- 下单成功后的发券、短信、邮件。
- 文档上传后的解析、切块、索引。
- 视频上传后的转码。
但队列引入后,同步世界变成异步世界:
| 新问题 | 你必须回答 |
|---|---|
| **重复消息** | 消费者是否幂等?同一条消息处理两次会怎样? |
| **消息丢失** | 生产、存储、消费确认链路怎么保证? |
| **消息乱序** | 是否需要同一业务键内有序? |
| **队列积压** | 用户看到什么?系统如何降级? |
| **死信**(Dead Letter,处理失败的消息) | 失败消息去哪里?谁来修? |
所以,队列不是「加了就稳定」,而是把问题从**请求延迟**转成了**异步一致性与恢复**。
---
## 三、事件系统:记录发生了什么,而不是命令别人做什么
事件(Event)和命令(Command)很容易混:
| 类型 | 含义 | 例子 | 谁负责结果 |
|---|---|---|---|
| **命令 Command** | 请你做某事 | `CreateOrder`、`SendEmail` | 接收方要执行成功或失败 |
| **事件 Event** | 某事已经发生 | `OrderPaid`、`TicketLocked` | 订阅方按需反应 |
事件系统适合跨边界传播事实:
```
订单服务:订单已支付(OrderPaid)
│
├─ 库存服务:确认扣减
├─ 通知服务:发短信
├─ 数据平台:更新报表
└─ 风控服务:记录行为
```
事件的好处是解耦:订单服务不需要知道所有下游。但代价是:
- 事件 schema(结构定义)一旦发布,下游会依赖,升级要兼容。
- 下游处理失败时,事实已经发生,不能简单回滚。
- 事件太细会淹没系统,太粗又表达不清。
- 事件链太长,排障会变难,必须有 trace(链路追踪)。
---
## 四、Kafka、RabbitMQ、Redis Streams、NATS 该怎么理解
别先背产品名,先看通信语义:
| 类型 | 常见代表 | 更像什么 | 适合 |
|---|---|---|---|
| **任务队列** | RabbitMQ、Celery、Sidekiq | 派活给 worker | 后台任务、邮件、图片处理 |
| **日志型事件流** | Kafka、Pulsar | 可回放的事实日志 | 事件总线、数据同步、审计、流处理 |
| **轻量消息/流** | Redis Streams、NATS | 简单快速的异步通道 | 中小规模异步、低延迟内部消息 |
| **云托管队列** | SQS、Pub/Sub | 少运维的可靠队列 | 云上业务、团队不想自运维 |
选型时问四件事:
1. 需要消息**可回放**吗?需要就偏事件流。
2. 需要复杂路由和投递确认吗?任务队列更合适。
3. 团队能不能运维集群?不能就优先托管。
4. 消息是不是核心审计事实?是的话,持久化、保留周期、schema 治理都要严肃对待。
---
## 五、Outbox:别让数据库事务和消息发送各干各的
最经典的坑:
```
1. 写订单成功
2. 发送 OrderCreated 消息失败
结果:主库里有订单,下游永远不知道
```
或反过来:
```
1. 消息发出成功
2. 写订单失败
结果:下游收到一个不存在的订单
```
Outbox(发件箱模式)的做法是:
```
同一个本地事务:
写业务表 + 写 outbox 表
│
▼
后台投递器扫描 outbox → 发消息 → 标记已投递
```
它不让「写事实」和「发事件」分裂。代价是多了一张表、一个投递器、幂等和重试逻辑,但换来的是跨服务一致性可控。这正是 [11 章](11-数据一致性工程.md) 的核心套路。
---
## 六、一个选型模板
```md
### ADR-029:文档入库使用队列削峰,索引事件使用 Kafka
- 背景:企业知识库上传高峰会同时触发解析、切块、向量化和索引,同步处理导致上传接口超时。
- 选择:上传接口只保存原文和元数据,写入任务队列;解析完成后发布 DocumentIndexed 事件到 Kafka,供搜索、审计和报表订阅。
- 放弃:用户不能立刻搜索到刚上传文档,允许 1-3 分钟索引延迟。
- 换来:上传链路稳定,后台处理可限速、重试、扩容,下游通过事件解耦。
- 风险:队列积压会影响可搜索时间;需要积压告警、死信处理和幂等消费者。
```
---
## 🎯 随堂检验
---
## 本章小结
- **缓存解决读热点**:它应该可重建,不能偷偷变成事实源。
- **消息队列解决洪峰和异步任务**:它把同步延迟问题换成异步一致性、积压和恢复问题。
- **事件系统传播事实变化**:事件是「发生了什么」,不是「命令别人做什么」。
- **选产品先选语义**:任务队列、事件流、轻量消息、云托管队列解决的问题不同。
- **Outbox 是跨服务一致性的基本功**:写业务事实和写待发送事件要在同一个本地事务里完成。
> **承上启下**:缓存、队列、事件解决的是服务背后的压力与协作。下一章 [30 · API 与服务通信选型](30-API与服务通信选型.md),我们看服务之间正面怎么说话:REST、gRPC、GraphQL、Webhook、事件 API,到底各自适合什么边界。
---
## 相关链接
- 方法论本体:[11 · 数据一致性工程](11-数据一致性工程.md) · [12 · 为失败而设计](12-为失败而设计.md) · [13 · 规模化的力学](13-规模化的力学.md)
- 模板对照:[通知 / 推送系统](../templates/notification-system/README.md) · [在线票务 / 抢票](../templates/online-ticketing/README.md) · [RAG 知识库](../templates/rag-knowledge-base/README.md)
- 案例对照:[StarArena](../cases/stararena-ticketing/README.md) · [DocuMind](../cases/documind-rag/README.md) · [FeedStream](../cases/feedstream-content/README.md)