29 · 缓存、消息队列与事件系统选型
一句话点题:缓存不是数据库,消息队列不是银弹,事件系统也不是「加个 Kafka」就完事。它们解决的是三类压力:读热点、写洪峰、跨边界协作。选之前先问:我是在降低延迟、削平峰值,还是解耦状态推进?
🧰 技术栈选型篇第 3 章 · 本章只练一件事
28 章 讲事实源和读模型。本章讲事实源周围最常见的三类中间层:缓存(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 | 少运维的可靠队列 | 云上业务、团队不想自运维 |
选型时问四件事:
- 需要消息可回放吗?需要就偏事件流。
- 需要复杂路由和投递确认吗?任务队列更合适。
- 团队能不能运维集群?不能就优先托管。
- 消息是不是核心审计事实?是的话,持久化、保留周期、schema 治理都要严肃对待。
五、Outbox:别让数据库事务和消息发送各干各的
最经典的坑:
1. 写订单成功
2. 发送 OrderCreated 消息失败
结果:主库里有订单,下游永远不知道或反过来:
1. 消息发出成功
2. 写订单失败
结果:下游收到一个不存在的订单Outbox(发件箱模式)的做法是:
同一个本地事务:
写业务表 + 写 outbox 表
│
▼
后台投递器扫描 outbox → 发消息 → 标记已投递它不让「写事实」和「发事件」分裂。代价是多了一张表、一个投递器、幂等和重试逻辑,但换来的是跨服务一致性可控。这正是 11 章 的核心套路。
六、一个选型模板
### ADR-029:文档入库使用队列削峰,索引事件使用 Kafka
- 背景:企业知识库上传高峰会同时触发解析、切块、向量化和索引,同步处理导致上传接口超时。
- 选择:上传接口只保存原文和元数据,写入任务队列;解析完成后发布 DocumentIndexed 事件到 Kafka,供搜索、审计和报表订阅。
- 放弃:用户不能立刻搜索到刚上传文档,允许 1-3 分钟索引延迟。
- 换来:上传链路稳定,后台处理可限速、重试、扩容,下游通过事件解耦。
- 风险:队列积压会影响可搜索时间;需要积压告警、死信处理和幂等消费者。🎯 随堂检验
- A缓存越多越好,能放缓存就不要查数据库
- B缓存丢了系统应该变慢,而不是变错;主库仍应是事实源
- C缓存可以替代所有数据库事务
- A事件名字不够好听
- B写数据库和发消息不是一个原子动作,可能出现主库有事实但消息没发出,需要 Outbox 等模式兜底
- C只要用了 Kafka 就天然 exactly-once
本章小结
- 缓存解决读热点:它应该可重建,不能偷偷变成事实源。
- 消息队列解决洪峰和异步任务:它把同步延迟问题换成异步一致性、积压和恢复问题。
- 事件系统传播事实变化:事件是「发生了什么」,不是「命令别人做什么」。
- 选产品先选语义:任务队列、事件流、轻量消息、云托管队列解决的问题不同。
- Outbox 是跨服务一致性的基本功:写业务事实和写待发送事件要在同一个本地事务里完成。
承上启下:缓存、队列、事件解决的是服务背后的压力与协作。下一章 30 · API 与服务通信选型,我们看服务之间正面怎么说话:REST、gRPC、GraphQL、Webhook、事件 API,到底各自适合什么边界。
相关链接
- 方法论本体:11 · 数据一致性工程 · 12 · 为失败而设计 · 13 · 规模化的力学
- 模板对照:通知 / 推送系统 · 在线票务 / 抢票 · RAG 知识库
- 案例对照:StarArena · DocuMind · FeedStream
💬 评论