# 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)