RocketMQ 书架

中文技术书 · 开放阅读

RocketMQ

从消息模型到可靠系统

原理、Java 实战与生产运维

Aias00 · 17 章正文与附录 · 实验基线 5.3.2

下载 EPUB,离线阅读

前言:把消息当作一份承诺

一次下单请求成功,不意味着库存、支付、积分、通知已经全部完成。分布式系统把工作交给不同服务,消息队列负责传递这些工作的线索。困难在于:网络会超时,进程会重启,同一件工作可能被执行两次,一件本来顺利的事可能在几分钟后才完成。

这本书围绕一个订单系统展开:订单服务发布事件,库存服务扣减库存,支付结果驱动履约,超时任务关闭未支付订单。我们的目标不是记住参数,而是回答三个问题:一条消息何时算可靠交付;业务如何在重试中保持正确;系统故障时用什么证据恢复服务。

读者需要基本的 Java、SQL、Linux 命令行知识。读完前六章,可以建立完整的发送、存储、消费模型;读完第七至十二章,可以实现常见业务模式;最后几章提供运维、容量和故障演练方法。书中的“练习”都是可独立思考的设计题,附录给出参考方向。

版本与实验约定

本文以 Apache RocketMQ 5.3.2 的经典 Java Remoting 客户端作为可复现实验基线,Maven 坐标是 org.apache.rocketmq:rocketmq-client:5.3.2。不是所有 5.x 客户端都使用同一条协议链路:rocketmq-client-java 是另一套基于 gRPC 的新客户端,通常通过 Proxy 连接服务端,API 与本书示例不通用。

资料核对日为 2026 年 10 月 20 日。核对时 Apache GitHub 最新发布为 5.5.1。本书固定较早的 5.3.2,是为了让命令、配置和源码定位一致;这一选择不是生产版本推荐。部署生产前,应核对当前维护分支、安全公告、Java 支持范围及组织升级策略。官方网站会随发布更新,涉及精确默认值时以固定版本源码为准。

所有实验使用本机单节点、明文连接,目的是观察机制。它不具备生产高可用性。示例明确区分“发送返回”“消息持久化”“消费者业务提交”,不把任何一个局部成功扩大成全系统承诺。涉及外部数据库的 SQL 是设计示例,需要按实际数据库事务语法实现;可执行 Java 示例则保存在仓库 examples/。

如何阅读和使用本书

在线版可以按章跳转、搜索正文,也可以保存完整单页。EPUB 版包含封面、目录和正文,可导入 Apple Books、Calibre 等阅读器离线阅读。代码、书稿、构建脚本同在仓库;正文的引用链接指向官方资料,research/ 保存核对快照和实验记录。

本书为独立技术写作,与 Apache 软件基金会无隶属关系。Apache、RocketMQ 及相关标识属于其各自权利人。正文采用 CC BY 4.0,仓库自编示例代码采用 MIT;上游引用和资料快照仍遵守原始许可。

第一章 从订单事件建立消息模型

命令与事件

“请为订单 O100 扣库存”是命令,表达希望对方执行的工作;“订单 O100 已创建”是事件,表达已经发生的事实。两者都可以放入 RocketMQ,但接口契约不同。命令需要明确唯一负责方和失败处理;事件允许多个独立订阅者响应。

假设订单服务发布 OrderCreated。库存、积分、通知分别使用自己的 Consumer Group。这样每个业务都能独立推进进度;如果三者共用一个集群消费组,一条消息通常只会交给其中一个消费实例,其他业务就会漏掉自己的工作。这是初学者最容易犯的建模错误之一。

建议消息体采用明确的信封:

{
  "eventId": "evt-20261020-0001",
  "eventType": "OrderCreated",
  "schemaVersion": 1,
  "orderId": "O100",
  "occurredAt": "2026-10-20T08:00:00Z",
  "items": [{"sku": "S01", "quantity": 2}]
}

eventId 用于幂等、审计和重放;orderId 用于业务关联和顺序分组;schemaVersion 用于兼容演进。Broker 的 Message ID 可以辅助查消息,但应用应拥有跨重试稳定的业务标识。不要每次重试都重新生成 eventId,否则幂等表会把重试看作新业务。

Topic、Queue 与 Group

Topic 是一类消息的逻辑集合。Queue 是实际分配和推进消费进度的单位。一个 Topic 可以分布在多个 Broker 上,每个 Broker 又可以有多个队列。发送者通过路由信息选择目标队列;消费者在组内分担这些队列或消息。

经典客户端的集群消费模式下,同一组内可分配的队列数约束有效消费并行度。假设 Topic 共 8 个可读队列,部署 16 个相同组实例,不代表吞吐能翻倍,一部分实例可能没有队列。5.x 新客户端还支持消息粒度负载均衡,不能把旧客户端的队列分配结论不加区分地套用过去。官方领域模型区分了这些概念。

Consumer Group 表示同一种消费逻辑的实例集合。组名不仅是标签,还关联订阅关系和进度。一个组的所有实例应保持一致的 Topic 与过滤规则,否则重平衡和消息分配可能出现令人困惑的行为。开发、预发、生产必须隔离组名和集群,避免本地程序抢走线上消费任务。

异步化改变了什么

同步调用中,订单服务等待下游结果后再返回;异步化后,订单服务通常只等待本地订单和待投递事件持久化。用户看到“订单已受理”,库存处理可能稍后完成。产品需要显示处理中、失败和补偿状态,而不是把最终一致性隐藏起来。

队列可以吸收短时流量差,但不能消除长期供需不平衡。生产每秒 10,000 条、消费每秒 8,000 条,积压每秒增加 2,000 条。延迟最终耗尽磁盘或超过业务时限。消息队列是一种缓冲和解耦机制,不是无限容量的保险箱。

练习

订单创建之后,短信失败是否应该撤销订单?给库存、短信、风控设计组名,说明为什么不共享同一个组。再列出哪些状态允许用户重试、哪些状态必须等待后台补偿。

第二章 请求走过哪些组件

NameServer 不承载消息正文

经典链路可以写为:

Producer ──查询路由──> NameServer
Producer ──发送消息──> Broker ──存储──> CommitLog
Consumer ──查询路由──> NameServer
Consumer <──拉取消息── Broker

Broker 向 NameServer 注册路由。客户端获取路由并缓存,与 Broker 直接通信。NameServer 不代理每一条发送或消费请求,因此 NameServer 恢复不等于消息数据恢复。多 NameServer 提供多个路由入口;客户端地址列表使用分号分隔。它们不是用来选举 Broker 主节点的通用共识系统。

路由缓存意味着控制面故障的影响有时间差:已有连接和缓存可能让业务暂时继续,但新 Topic 发现、Broker 变化和客户端冷启动会受影响。排障时必须同时看客户端缓存、NameServer 返回和 Broker 自报地址。

Broker 与持久化

Broker 接收发送请求、写入消息、维护消费队列索引、处理拉取与重试。消息正文主要保存在 CommitLog,消费队列通过索引定位正文。可以把 CommitLog 理解为仓库流水账,把 ConsumeQueue 理解为按 Topic 和队列整理的检索目录;两者用途不同,不应按业务想象直接删除文件。

Broker 的数据路径、磁盘余量、刷盘策略、复制模式决定不同故障下能保留什么。仅看进程存活不足以判断服务健康。磁盘满、写入拒绝、复制滞后、线程池排队,都可能让一个“活着”的进程无法满足业务。

Proxy 与两套客户端

RocketMQ 5.x 引入 Proxy,面向新客户端提供协议适配等能力。使用 rocketmq-client-java 时,常见链路是客户端 → Proxy → Broker;使用本书经典 DefaultMQProducer 时,链路是客户端 → Broker。不要把 NameServer 的 9876 端口填入 gRPC 客户端的 endpoints,也不要把 Proxy gRPC 地址填入经典客户端的 NameServer 配置。

对比项 本书实验 新 Java 客户端常见方式
Maven artifact rocketmq-client rocketmq-client-java
代表 API DefaultMQProducer ClientServiceProvider
协议 Remoting gRPC
常见入口 NameServer 发现 Broker Proxy endpoint
顺序分组 队列选择与顺序监听器 MessageGroup 与 FIFO 消息

新旧链路的能力、消息类型校验和重试行为存在差异。迁移时要验证认证、过滤、顺序、重试和运维工具的实际表现,不能只替换依赖名。

网络排障先画地址图

NameServer 可达而发送超时,常见原因是 Broker 注册了客户端不可达的地址。容器内 IP、宿主机 IP、云内网地址不是同一个访问空间。经典 Broker 通常涉及 10911 和 VIP 通道 10909;开启 HA 复制还涉及 10912。具体开放哪些端口应以部署模式和实际配置为准。本书本机实验关闭客户端 VIP 通道,减少排查变量。

练习:NameServer 正常、Broker 本地发送正常、远程客户端发送失败,如何区分路由地址错误、端口未开放和认证失败?先取路由,再从客户端所在网络测试目标地址,最后核对请求日志,比反复重启更有效。

第三章 搭建第一个可复现实验

准备环境

需要 JDK 17、Maven 3.9+、Python 3、curl、unzip。书籍构建另外需要 Pandoc 3.x。生产版本的 JDK 支持范围要另行确认;这里的版本组合用于本地练习。若机器上只有系统 Java 启动器,先设置 JAVA_HOME,并确认 java -version 指向真实 JDK。

从 Apache 归档获取固定版本:

curl -fLO https://archive.apache.org/dist/rocketmq/5.3.2/rocketmq-all-5.3.2-bin-release.zip
curl -fLO https://archive.apache.org/dist/rocketmq/5.3.2/rocketmq-all-5.3.2-bin-release.zip.sha512
shasum -a 512 rocketmq-all-5.3.2-bin-release.zip
cat rocketmq-all-5.3.2-bin-release.zip.sha512
unzip rocketmq-all-5.3.2-bin-release.zip

比较计算值与官方 SHA-512 文件;需要更强发布者验证时再核对 Apache 签名与公钥指纹。不要把随机镜像中的二进制当作同等来源。归档版本下载较慢时,可以先阅读后续模型章节,不必更换到未知构建。

启动单机 Broker

仓库 scripts/lab.sh 管理一个与其他项目隔离的本机实验。它使用回环地址、独立端口和仓库 .lab/ 数据目录。设置解压目录后执行:

export ROCKETMQ_HOME=/absolute/path/to/rocketmq-all-5.3.2-bin-release
bash scripts/lab.sh start

脚本会启动 NameServer 和异步刷盘的单个 Master,再显式创建实验 Topic。它不会配置多副本,因此不能用于验证机器损坏时的数据可靠性。脚本针对本书学习环境减少 JVM 堆大小;默认发行脚本的堆参数通常不适合小内存笔记本。

配置重点如下,实际端口以脚本生成配置为准:

brokerClusterName=BookLab
brokerName=book-broker
brokerId=0
brokerIP1=127.0.0.1
brokerRole=ASYNC_MASTER
flushDiskType=ASYNC_FLUSH
autoCreateTopicEnable=false

关闭自动建 Topic,让拼写错误及时失败;生产也应经过容量、权限和消息类型审核后显式创建资源。自动创建订阅组、Topic 是否允许,要按环境统一管理。

运行发送与消费

mvn -f examples/pom.xml -q package
mvn -f examples/pom.xml -q exec:java \
  -Dexec.mainClass=book.rocketmq.RoundTrip

程序为每轮生成独立事件,先启动消费者,再同步发送,等待收到同一 eventId。成功输出包括发送状态、消息 ID、队列信息和 ROUND_TRIP_OK。超时会以失败结束,不会把“程序能运行”当作收发成功。测试结束会关闭客户端;Broker 由实验脚本管理。

默认 NameServer 为 127.0.0.1:19876,可以使用环境变量 BOOK_NAMESRV 覆盖。由于 Broker 使用回环地址,这套配置只适合本机。如果换成远程或容器实验,应同时调整注册地址、路由可达性与端口映射。

停止实验:

bash scripts/lab.sh stop

停止不会删除消息数据。需要从头重做时,先停止,再手动移走 .lab/。不要在 Broker 运行时删除存储目录。

观察而不是猜测

执行 mqadmin topicRoute 查看 Topic 的队列分布,执行 consumerProgress 观察消费组进度。不同工具命令需要 NameServer 和组名等参数,可用 sh bin/mqadmin help <command> 查看。记录每次测试使用的版本、配置、组名和时间,才能把日志与一次具体实验对应。

练习:故意把客户端 NameServer 改成一个不存在的端口;恢复后再把 Topic 拼错。比较两种失败的异常和耗时,建立“路由不可达”与“资源不存在”的区别。

第四章 生产者:正确理解发送结果

一次发送的时间线

经典生产者通常经历路由查找、队列选择、编码与压缩、网络发送、Broker 处理、等待响应。客户端超时只说明在期限内没有拿到确定响应。Broker 可能尚未收到,也可能已经存下消息而响应丢失。因此超时后的业务状态应当是“结果未知”,不能直接归类为“肯定未发送”。

生产者通常在应用启动时创建并复用,应用退出时关闭。不要每次 HTTP 请求都构建一个 Producer,那会反复创建连接和后台线程。Group 名应表达应用身份;消息 Keys 可以放订单号以便查询,但 Keys 并不自动实现去重。

DefaultMQProducer producer = new DefaultMQProducer("order-producer");
producer.setNamesrvAddr("127.0.0.1:19876");
producer.setVipChannelEnabled(false);
producer.start();
Message m = new Message("BookOrders", "Created", "O100",
        json.getBytes(StandardCharsets.UTF_8));
SendResult result = producer.send(m);

此片段展示 API,完整可运行代码见仓库。应在应用生命周期结束时 shutdown(),并在异常路径记录稳定事件 ID、目标 Topic、调用耗时与结果。

发送方式和确认强度

同步发送让调用方等待 SendResult;异步发送通过回调获取结果,必须限制并发数并处理失败;单向发送不等待结果,适合可接受丢失的辅助数据,不适合订单事实。异步回调失败时把消息直接丢进内存队列,进程重启仍会丢;需要可靠交付就必须有持久化补发来源。

经典 SendStatus 包括 SEND_OK、FLUSH_DISK_TIMEOUT、FLUSH_SLAVE_TIMEOUT、SLAVE_NOT_AVAILABLE。后三者不能解释为消息一定未存储。SEND_OK 的含义也受 Broker 刷盘、复制配置约束。在异步刷盘单副本实验里,它不等于断电后一定可恢复。

5.3.2 经典 DefaultMQProducer 的源码默认同步失败重试次数为 2,发送超时为 3000 毫秒;异步发送另有配置和执行路径。默认值不能替代预算设计:一次用户请求若只允许 500 毫秒,不应该让调用链在后台无限重试。重试次数、每次耗时、调用总预算要一起计算。

应用重试与 SDK 重试叠加

假设 SDK 最多尝试三次,应用又重试三轮,一次业务事件可能触发九次发送尝试。SDK 某次已经成功写入但响应丢失,之后再次发送就可能产生重复消息。无论使用哪个版本,都应按“可能重复”设计消费者。

可靠生产的一种做法是 Outbox:在业务数据库事务里同时保存订单和待发送事件,后台任务负责投递,拿到确认后更新状态。即使确认更新失败,后台再次发送也只会造成重复,由消费端幂等化解决。第十一章会给出完整事务边界。

批量与大消息

批量发送减少网络往返,但受单次请求大小、Topic 一致性及消息类型限制。不要按“条数固定”分批,应按序列化后的字节数分批,并把属性和协议开销留出余量。最大消息大小有客户端与服务端两侧约束,不能只改其中一个参数。

图片、压缩包、完整报表一般放对象存储,消息只带引用、摘要、访问范围和过期策略。这样减少 CommitLog 压力,也避免一次消费把内存顶满。代价是对象与消息的生命周期需要协调:对象过期时间必须覆盖最大重试、积压和重放窗口。

练习:发送超时后,订单接口应该向用户返回什么?设计一个能查询业务最终状态的接口,并说明如何在重复点击时复用同一个业务幂等键。

第五章 消息在磁盘上的一生

从正文到消费索引

经典文件存储中,CommitLog 顺序追加多个 Topic 的消息。ConsumeQueue 保存按 Topic、Queue 排列的位置索引;索引条目帮助 Broker 从逻辑队列偏移找到正文。按 Key 查询还涉及独立索引结构。三者不是三份等价正文副本。

写入之后,Broker 的分发过程构建消费所需索引。于是发送成功、消息可被消费和索引可查之间存在处理步骤。排查短暂不可见时,应核对写入时间、分发进度和路由,而不是立即判定永久丢失。异常持久不可见则需要检查存储错误和消息类型。

队列 offset 表示队列内逻辑位置,CommitLog offset 表示存储文件位置;Message ID 和业务 ID 又是不同层次。日志里若混用这些名字,回放很容易选错边界。跨队列 offset 没有全局大小意义,不能比较两个队列的数字来判断哪条业务事件更早。

Page Cache 与刷盘

操作系统 Page Cache 可以让顺序写高效完成。数据进入缓存并不意味着已进入可抗断电的介质。异步刷盘把刷盘工作放到后台;同步刷盘使发送处理等待刷盘条件,但依然受硬件、文件系统和故障模型约束。

可靠性讨论必须写清故障:只杀 JVM、操作系统崩溃、主机断电、磁盘损坏、整个可用区失效,需要不同保护机制。只做 kill -9 能验证进程崩溃恢复,不能据此宣称通过断电或磁盘损坏测试。消费端数据库提交也不在 Broker 刷盘承诺里。

复制不是万能备份

复制可以降低单节点故障风险,但复制延迟、确认策略和选主约束决定实际 RPO。异步复制时,生产者已收到成功的尾部消息可能尚未到从节点。同步复制相关策略也必须与切主机制共同评估。双副本位于同一块物理盘,不提供磁盘故障域隔离。

备份和复制解决不同问题。误删、恶意操作、错误业务事件可能迅速复制到其他节点;业务还需要审计、保留策略和修复工具。不要把“有从节点”作为唯一数据保护方案。

保留、清理与最慢消费者

RocketMQ 不是等所有组确认后才删除每条正文。文件清理主要受保留时间、磁盘空间及实现策略影响。一个组长时间积压,可能在追赶前就遇到旧数据被清理。消费进度并不是永久保留消息的锁。

因此必须同时监控最老积压年龄、保留窗口和磁盘剩余。设允许最长停机 12 小时、最大追赶时间 6 小时、故障调查 4 小时,仅配置 12 小时保留就不够。还要考虑流量波动、批量回放和安全余量。保留时间也不是无条件最短保证,磁盘压力下的清理需要单独评估。

冷读与性能拐点

消费紧跟生产时,读取常能命中缓存;严重落后时需要冷读磁盘,与写入竞争 I/O。积压恢复阶段可能比平稳阶段更慢。压测不能只测“新消息即时消费”,还应测一段大积压追赶,并观察生产延迟是否受影响。

练习:把一个 Topic 保留窗口从 24 小时改成 72 小时之前,需要增加哪些资源?为什么只按正文大小乘三仍可能低估占用?

第六章 消费者:处理完成才确认

Push 的工作并不是业务回调那么简单

经典 DefaultMQPushConsumer 将拉取、缓存、调度封装在客户端中,业务通过监听器处理消息。名字里的 Push 不应让人误以为每条消息完全由 Broker 直接推入业务线程。理解本地缓存和拉取行为,才能解释扩容、停机和消费延迟。

一个并发监听器的简化形式如下:

consumer.registerMessageListener((MessageListenerConcurrently) (messages, context) -> {
    try {
        for (MessageExt m : messages) {
            processInDatabase(m); // 必须在返回前完成持久化事务
        }
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    } catch (Exception e) {
        return ConsumeConcurrentlyStatus.RECONSUME_LATER;
    }
});

不要在监听器里把消息投进另一个内存线程池后立即返回成功。进程崩溃时,Broker 认为工作已经完成,而新线程还没有提交业务。若确实要异步化,必须先持久化转交任务,并明确由谁承担后续重试。

提交业务和提交进度存在缝隙

典型过程是读取消息、执行数据库事务、返回成功、提交消费进度。数据库已提交而进度尚未生效时进程崩溃,消息会再次被处理。这是重复消费的重要来源。反过来,先确认消息再写数据库,会产生业务永久漏处理的窗口。

正确方向是先提交业务,再确认消息,同时让业务写入幂等。它把风险集中成可控制的重复,而不是不可恢复的漏执行。第七章给出具体 SQL 模型。

起点、进度与重置

新消费组第一次启动的起点由消费策略、Topic 类型等决定。经典客户端的 CONSUME_FROM_LAST_OFFSET 不能被理解成对所有历史场景永远从当前最新位置开始。已有组通常使用已存储进度,修改启动策略并不会自动把组回到最早。

本书实验先启动消费者再发送,并为每轮设置可辨识事件,避免依赖新组起点的细节。生产重置进度时,必须记录旧进度、选定时间或位置、暂停相关消费者,并确认重放范围内的外部副作用可重复。重置是业务操作,不是随手修复积压的通用办法。

组内扩容和重平衡

经典集群消费把队列在实例间重新分配。实例上下线会触发重平衡,短时出现暂停、负载变化和重复处理并不奇怪。扩容前应先看:是否还有足够队列、是否存在热点队列、数据库是否已经满负荷。

优雅停机时先从入口流量移除应用,再停止消费并给在途事务留出完成时间。实际 shutdown 行为和等待时间受客户端配置约束,必须在部署平台的终止宽限期内验证。滚动发布若每次同时下线过多实例,会让重平衡持续扰动吞吐。

慢消费与并发控制

增加线程可隐藏 I/O 等待,却也可能把数据库连接池打满。假设一个实例配置 64 个消费线程,数据库只分配 16 个连接,大部分线程可能只是在等待连接。应把线程数、单条耗时、连接池、下游限流放在同一个预算里。

批量消费失败时,要确认所用客户端如何处理部分成功与重试。最稳妥的初始实现是小批量、逐条幂等、清晰错误分类。以后再根据实际瓶颈扩大批量。

练习:数据库提交成功后故意让监听器返回重试。第二次处理时业务表应发生什么变化?消费日志怎样证明是同一个事件的再次交付?

第七章 幂等、重试与死信

幂等的单位是业务效果

同一条消息收到两次,日志出现两行并不一定是问题;库存扣了两次才是问题。幂等要求重复输入不会产生额外业务效果。不能只凭消息中有 ID 就说系统具备幂等能力,必须有并发安全的约束或状态机。

下面是关系数据库的设计示意:

CREATE TABLE processed_event (
    consumer_name VARCHAR(100) NOT NULL,
    event_id VARCHAR(100) NOT NULL,
    processed_at TIMESTAMP NOT NULL,
    PRIMARY KEY (consumer_name, event_id)
);

同一个数据库事务内,先插入幂等记录,再执行库存变更,最后一起提交。并发重复处理时,唯一约束只允许一个事务成功。如果插入冲突,应区分“该唯一键已经处理”与其他数据库错误;不能把所有 SQL 异常都当成重复而返回成功。

BEGIN
  INSERT processed_event(consumer_name, event_id, processed_at)
  UPDATE stock SET available = available - :n
    WHERE sku = :sku AND available >= :n
  检查更新行数,写入业务流水
COMMIT

库存不足是业务决策,可能记录拒绝并发布失败事件;数据库断线是可重试故障。两者不能用同一段无限重试处理。若事务失败,幂等记录也必须回滚,否则后续重试会把未完成业务误认为已完成。

外部副作用更难

支付网关、短信和第三方 HTTP 调用不在本地事务里。数据库写了“已发送”而外部请求没执行会漏发;外部请求成功而本地状态更新失败会重发。优先使用对方提供的幂等键或查询接口;否则设计发送状态、对账与人工处理流程,并明确剩余风险。

Redis SET NX 可以做短期去重或并发协调,但若先写缓存再更新业务,缓存成功、业务失败会造成漏执行。若 TTL 到期,旧事件又可以执行。缓存是否足够,取决于业务损失和重放窗口,不能替代数据库事务的论证。

重试是有限资源

瞬时网络问题适合带退避的重试;格式非法、必填字段缺失、已停用的协议版本通常不会因为再试一次而变好。区分瞬时失败、永久失败和业务拒绝,分别使用自动重试、隔离及补偿。

经典并发消费通过返回 RECONSUME_LATER 进入重试流程。顺序消费的暂停与重试机制不同,不能直接套用同一监听器状态。新客户端的重试策略又与消息类型、消费方式有关,具体见官方消费重试文档。

一次下游故障可能把所有消息都变成重试流量。应监控新消息速率、重试速率、重试次数分布和下游恢复情况。恢复时限制并发,避免“系统刚恢复又被重试击穿”。

死信必须有人接住

经典集群消费中,超过允许重试次数的消息可进入对应组的死信 Topic,常见名字为 %DLQ% 加组名。存在死信并不等于已经完成补偿:要告警、定位失败原因、修复数据或程序、选择性重放,并保留审计。

死信重放应保留原始 eventId,另加新的重放任务 ID、操作者、原因和时间。否则重放会绕过幂等约束。对于业务确实需要再执行的动作,应生成一个有明确语义的新命令,而不是偷偷修改旧事件 ID。

幂等记录保留时间必须覆盖所有允许的消息重放窗口。若记录 7 天后删除,运维又允许重放一个月前的消息,重复效果就会重新出现。消息保留、审计保留和幂等保留需要一起设计。

练习:一个事件包含三个商品,处理第二个时失败。是整单事务重试,还是每个商品单独幂等?分别说明一致性、锁时间和恢复复杂度。

第八章 顺序消息:把顺序缩小到业务实体

先确定需要哪一种顺序

“所有订单全局有序”会把系统压缩成一个串行瓶颈。多数业务只要求同一订单的创建、支付、发货按顺序处理,不同订单可以并行。因此应把顺序范围定义为订单 ID,而不是整个系统。

消息队列不能自动修复上游已经发错的顺序。两个生产者同时发送同一订单事件,网络和线程调度决定实际到达先后。如果业务必须先支付后发货,需要由数据库状态机、单写入者或可验证的事件序号建立顺序来源。

经典客户端如何选择队列

经典 Producer 可用 MessageQueueSelector 把同一业务键映射到同一队列:

SendResult r = producer.send(message, (queues, msg, arg) -> {
    String orderId = (String) arg;
    int index = Math.floorMod(orderId.hashCode(), queues.size());
    return queues.get(index);
}, orderId);

消费者使用 MessageListenerOrderly,由客户端按分配队列的顺序消费机制处理。只做固定队列选择而仍用并发监听器,不构成完整顺序保证。还需控制生产端发送并发,不能让同一业务实体的多个线程同时竞速发送。

Topic 队列数量变化会改变取模映射,旧消息和新消息可能落到不同队列。扩容顺序 Topic 前,需要排空、冻结业务键迁移,或引入能处理版本和序号的路由机制。把队列翻倍当作毫无代价的操作,会破坏既有顺序边界。

消费失败与队头阻塞

一个订单处理失败,顺序消费通常需要暂停或重试,否则后续事件先执行就失去顺序意义。经典队列里可能有很多订单共享同一队列,一个坏订单就可能拖慢其他订单。这是队头阻塞,需要可观测的失败原因和隔离流程。

隔离时不能简单跳过失败事件并宣称顺序仍成立。应根据业务决定:拒绝该订单全部后续事件、把该实体转入待修复状态、或用状态机缓冲后续序号。错误格式与暂时依赖故障,处理方法也应不同。

新客户端 FIFO 的边界

5.x 新客户端使用 MessageGroup 表达分组顺序,要求匹配的 FIFO Topic 与消费 API。官方文档明确区分生产顺序、存储顺序和消费顺序;消息到达顺序仍需要生产端合理约束。不要把经典队列选择代码复制进 gRPC API,也不要把 FIFO 描述理解为任意多生产者自动形成业务因果顺序。

序号和状态机是最后一道约束

给订单事件加 aggregateVersion,数据库记录当前已应用版本。当收到版本 8 而当前是 6,可以暂存、回查源服务或等待版本 7;收到版本 6 或更早,判定为重复或过期。RocketMQ 顺序能力降低乱序概率和处理成本,业务状态机负责拒绝不合法转换。

例如 PAID -> SHIPPED 合法,CLOSED -> SHIPPED 应拒绝并告警。不能因为消息“有序”就取消数据库条件更新。

练习:同一个订单迁移到另一个生产者进程后,怎样防止旧进程迟到的事件覆盖新进程状态?为状态版本设计一个单调校验规则。

第九章 延时与定时:到期之后仍要检查业务

超时关单不是无条件关闭

下单 30 分钟后,如果仍未支付就关闭订单。消息只是提醒“现在可以检查”,真正判断应在数据库里完成:订单可能刚支付、已取消、已关闭,甚至事件因积压而晚到了几个小时。

安全的写法类似:

UPDATE orders
SET status = 'CLOSED', version = version + 1
WHERE order_id = :id
  AND status = 'PENDING_PAYMENT'
  AND expires_at <= CURRENT_TIMESTAMP;

只有更新成功才释放对应预留库存,并通过事务或 Outbox 继续传播结果。支付和关单并发时,要用条件更新、事务锁或业务仲裁确保状态不矛盾。消息到达不能替代业务条件。

经典延迟等级

本书固定版本经典 API 使用 message.setDelayTimeLevel(level)。5.3.2 默认等级从 1 开始,对应:

1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m
9m 10m 20m 30m 1h 2h

例如等级 3 是 10 秒,等级 16 是 30 分钟。此列表来自固定版本 MessageStoreConfig,管理员可改变配置,应用不能在不核对环境的情况下永久依赖数字含义。延迟不是业务线程 sleep,消息在 Broker 内部调度后才进入目标队列供消费。

5.x 定时时间戳

5.x 提供基于定时存储的能力,新客户端可设置投递时间。使用前要核对 Broker 定时功能开关、Topic 类型、允许时间范围和客户端支持;不能因为服务端版本号是 5.x 就假设每个部署都能直接使用所有定时功能。

官方当前文档描述默认最长 24 小时,而本书固定版本 MessageStoreConfig.timerMaxDelaySec 为三天。这说明在线文档和旧版本默认值可能不同。生产契约应以目标版本、实际配置和边界实验为准。本书延时实验使用经典等级,不依赖这一有差异的默认范围。

定时消息通常承诺在目标时间后具备投递条件,不能保证业务恰好在某一毫秒执行。调度、网络、积压、重试和业务耗时都会增加延迟。若关单 SLA 是到期后一分钟内处理,需要监控实际处理时间与业务到期时间的差值。

取消、修改和长周期任务

已发送的延时消息通常不能作为通用可修改任务记录使用。修改订单过期时间时,可以在消息中携带原计划版本,消费时回查最新状态并忽略旧版本。取消订单之后,旧提醒仍可能到达,但状态检查应让它安全失效。

超过允许延迟范围的长周期任务,常见方案是数据库保存任务表,近期开窗扫描并投递短期消息。扫描器也要做到幂等、租约或抢占保护,并有漏扫补偿。不要把消息系统当作唯一长期日历数据库。

练习:用户申请把订单有效期延长十分钟。旧关单消息仍会到达,设计一个无需撤回旧消息的处理规则。

第十章 事务消息:把本地结果告诉 Broker

它协调的是哪一个边界

事务消息帮助协调“生产者本地事务是否成功”和“消息是否对消费者可见”。它不会让消费者数据库事务、外部支付和所有下游一起原子提交,也不能消除消费重复。

经典流程包括:发送半消息;Broker 接收但暂不对普通消费可见;生产者执行本地事务;根据结果提交或回滚消息;结果不确定时,Broker 发起事务状态回查。具体行为见官方事务消息说明。

Producer       Broker          Business DB
   |--半消息----->|                  |
   |<--接收结果---|                  |
   |-----------执行本地事务---------->|
   |<------------提交结果------------|
   |--提交/回滚-->|                  |
   |              |--提交后可消费    |

第二阶段通知可能丢失,回查就是为此存在。把回查视为不该发生的异常,会导致可靠性设计缺掉最关键的一部分。

回查需要可证明的业务结果

回查函数必须依据持久化事实回答。例如本地事务同时写入订单和 transaction_marker(tx_id, result),回查按稳定 tx_id 读取结果。标记和订单应在同一个数据库事务提交,否则标记可能证明了错误状态。

查不到记录不一定代表失败:事务可能还在运行、数据库连接异常、读到了延迟副本。无法确定时返回未知,让系统按配置再次检查;不能把数据库超时直接变成“提交消息”。最终如何处理长时间未知的事务,需要重试预算、告警与业务对账。

经典 TransactionListener 包含 executeLocalTransaction 和 checkLocalTransaction。逻辑可概括为:

executeLocalTransaction(message):
  根据 txId 幂等地执行数据库事务
  能证明提交 -> COMMIT_MESSAGE
  能证明回滚 -> ROLLBACK_MESSAGE
  无法确定   -> UNKNOW

checkLocalTransaction(message):
  查询持久化事务结果
  只按证据返回状态,不重复随意执行业务

这里是接口逻辑说明,不是完整生产代码。尤其不要照搬用内存 Map 记录事务结果的演示程序:进程一重启,回查依据就消失了。

进程、身份和部署

Broker 发起回查时,必须有具备对应逻辑与数据访问能力的生产者实例在线。组身份、事务回查线程池、数据库连接池和部署顺序都影响恢复。代码版本升级后,旧半消息可能仍需由新实例判断,因此事务标记格式必须向后兼容。

回查不得执行无界慢查询。它与业务请求争用数据库时,可能把一次消息异常放大为数据库故障。记录回查次数、未知时长和最终决定,对异常长事务建立人工核对流程。

不要忽略消费者幂等

事务消息提交后,消费链路仍可能重复交付。库存服务依旧需要第七章的幂等事务。事务消息解决生产侧的协调窗口,幂等解决消费侧重复效果,业务状态机解决冲突转换,三者互补。

练习:本地数据库提交成功,生产者立刻崩溃,第二阶段消息没有送出。写出回查恢复所需的最小数据集合,并解释如果事务标记只保留一天会产生什么问题。

第十一章 Outbox:用数据库保存发送义务

为什么“先写库再发消息”不够

先写库后发消息,写库成功、发送前进程崩溃,就没有事件;先发消息后写库,消息成功、写库失败,下游会处理一个不存在的订单。仅仅把两行代码换个位置,无法消除两套持久化系统之间的间隙。

Outbox 把订单与待投递事件放进同一个本地数据库事务。事件行就是一份可恢复的发送义务,后台投递器不断把它发送到 RocketMQ。订单事务不需要同时持有 Broker 的分布式锁。

CREATE TABLE outbox_event (
    event_id VARCHAR(100) PRIMARY KEY,
    aggregate_id VARCHAR(100) NOT NULL,
    payload TEXT NOT NULL,
    state VARCHAR(20) NOT NULL,
    attempts INTEGER NOT NULL DEFAULT 0,
    next_attempt_at TIMESTAMP NOT NULL,
    lease_until TIMESTAMP,
    created_at TIMESTAMP NOT NULL
);

以上是通用示意。生产实现还需要索引、租约持有者、错误摘要、投递时间,以及数据库类型对应的锁语法。

投递器的状态机

订单事务写入 NEW。投递器按到期时间领取一批,把任务租给当前实例;发送成功后标记 SENT;失败则增加次数并设置下一次尝试。进程崩溃留下的任务在租约到期后可以重新领取。

关键点是领取要原子化。多个实例先 SELECT 再无条件 UPDATE,会同时拿到同一批事件。可以使用条件更新、数据库的行锁与 SKIP LOCKED,或其他经过验证的领取机制。数据库差异很大,示例不能跨库照抄。

不要在持有数据库长事务锁时等待网络发送。可以短事务领取、事务外投递、短事务确认。这样产生一个可重复发送窗口:消息已经成功,SENT 更新失败。这个窗口是有意接受的,必须由消费端幂等处理。

发送成功的判定

投递器应检查发送返回状态,针对无法证明成功的情况保留待核对或重试状态。不要捕获所有异常后仍标记 SENT。至少记录 eventId、消息 ID、发送状态、尝试次数、耗时、最后错误。

延迟重试需要抖动,避免所有任务在数据库或 Broker 恢复的一秒内同时冲出。永远重试而不告警会让坏消息长期隐藏;有限尝试后转人工处理也必须保持事件可恢复,而不是删除记录。

清理与对账

已发送事件可以归档,但应保留足够审计窗口。对账可比较订单状态、Outbox 状态和下游处理流水,发现“订单存在但事件缺失”“事件已发但下游长期未生效”。只有有独立事实来源,才能发现链路里未被告警捕获的遗漏。

需要严格同一实体顺序时,多个投递器抢占不能随意改变该实体事件次序。可按实体分区串行投递或依序号做领取约束;Broker 顺序能力无法挽救投递器先发版本 3、后发版本 2 的错误。

与事务消息怎么选

维度 事务消息 Outbox
恢复依据 Broker 半消息与本地事务结果 数据库事件行
关键能力 正确回查、生产者在线 原子写入、可靠领取与投递
延迟 通常较短,异常时等待回查 受投递扫描/订阅延迟影响
改造代价 接入事务客户端与回查 表结构、投递器、归档与监控
仍需解决 消费幂等与业务补偿 消费幂等与业务补偿

选择依据是团队现有数据库、运维能力和恢复要求。不要为了追求某个名词而同时叠加两套复杂协调流程。

练习:Outbox 投递器在成功发送之后、更新 SENT 之前崩溃。下一次任务怎样运行,怎样证明订单库存只扣一次?

第十二章 过滤、消息契约与兼容演进

Topic 与 Tag 的职责

Topic 适合表达一类独立资源边界,例如订单事件;Tag 适合在同一类消息里做轻量分类,例如 Created、Paid、Closed。不要为每个订单创建一个 Topic,也不要把需要不同权限、保留或容量策略的业务全塞进同一 Topic,仅靠 Tag 区分。

经典订阅表达式可使用 Created || Paid 等 Tag 组合。Tag 过滤不能替代权限控制:能否读取 Topic 和是否只想处理其中一部分,是不同问题。同一消费组应保持订阅一致,不能让一个实例只订阅 Created,另一个只订阅 Paid,再期待组内自然分工。

SQL92 属性过滤

某些场景需要按地区、业务线或优先级筛选,可以把可过滤字段放入消息用户属性,并使用 SQL92 表达式。服务端通常需要开启属性过滤支持,客户端也必须匹配能力。示意:

message.putUserProperty("region", "east");
consumer.subscribe("BookOrders",
    MessageSelector.bySql("region = 'east'"));

不要假设 Broker 会解析任意 JSON 消息体做过滤。还应核对属性缺失、字符串与数值类型、空值处理,以及过滤成本。过滤不是通用数据仓库查询引擎,也不是跨消息关联计算。

过滤后不消费的消息也仍然消耗存储。若同一个 Topic 上大量消费组使用复杂过滤,实际开销需要通过目标负载验证。热点属性、表达式变更和历史数据属性缺失都应进入迁移计划。

契约是生产者和消费者的共同接口

至少约定:事件 ID 是否稳定、时间格式和时区、金额最小单位、枚举未知值怎么处理、字段是否可空、个人信息是否允许进入消息。金额使用分或明确精度的十进制,避免二进制浮点产生账务差异。

新增可选字段通常比删除或改变字段类型安全。消费者应容忍未知字段,并对缺失字段有定义好的默认或拒绝策略。把 quantity 从整数改成字符串、把时间从 UTC 改成本地无时区格式,都可能破坏尚未升级的消费者。

迁移可分三步:先发布同时兼容新旧格式的消费者;再发布新生产者;确认旧格式积压、重试、死信和允许的重放窗口结束后,最后移除旧逻辑。只看线上即时流量已切换,并不足以证明旧格式不再出现。

消息体中的敏感数据

消息会进入日志、死信、备份和排障工具。将完整身份证、银行卡或访问令牌放进正文,会扩大数据暴露范围。能传业务引用就不传秘密;需要个人信息时限定字段、权限、保留和脱敏方式。

链路追踪 ID 可以放在用户属性中传递。追踪采样会让某些 span 缺失,因此必须保留独立业务 ID,不能把“查不到 trace”当作“消息没发送”的证据。

练习:给事件增加一个 discount 字段,如何让仍运行旧版本的库存消费者安全处理?如果某个字段原来以元表示,现在改成分,为什么只改字段注释不够?

第十三章 高可用:故障模型先于节点数量

明确 RPO 和 RTO

RPO 是最多能接受丢失多少数据或多长时间窗口的数据,RTO 是多久恢复服务。订单事实、推荐曝光、缓存失效通知往往需要不同目标,不能统一用“高可用”三个字替代。

将故障分成客户端进程退出、Broker 进程退出、主机故障、存储损坏、网络分区、可用区失效。对每一类写出检测机制、切换条件、数据边界和恢复流程。只有明确故障假设,副本数量才有意义。

常见部署机制不要混在一起

传统主从、DLedger、Controller 模式都与 RocketMQ 的高可用演进有关,但它们的元数据、复制与选主机制不相同。传统异步主从不能自动等同于具有共识约束的自动切主;NameServer 也不承担所有这些模式的选主职责。

RocketMQ 5.x 的 Controller 模式利用 Controller 维护相关元数据并参与选主决策,Broker 侧负责对应复制机制。配置时应遵循官方自动故障转移文档,明确 SyncStateSet、选主条件和失去多数派时的行为,而不是从不同教程拼参数。

本书单机实验刻意不模拟生产多副本。要证明高可用,必须搭建真实独立节点或故障域,运行持续生产、消费与业务对账,并记录故障前后的确认和恢复结果。

网络分区与旧主隔离

主节点与控制面失联,但仍能被部分客户端访问,是危险情形。如果没有可靠的主身份约束和隔离机制,就可能出现不同客户端向不同节点写入的风险。切主设计需要回答旧主何时停止服务、恢复后怎样追赶、未确认尾部如何处理。

不能把客户端超时全部交给重试解决。重试只能切换请求尝试,无法决定两个冲突历史哪个正确。真正的数据一致性依赖服务端协议和故障恢复约束。

升级与回滚

先在接近真实数据规模的环境测试升级,备份配置与必要元数据,检查磁盘格式、配置兼容、客户端兼容和滚动升级支持。先少量节点,再逐步扩大;每一步观察发送状态、复制进度、消费延迟和错误率。

回滚不总是“把二进制换回去”。新版本若改写了旧版本不能识别的格式或元数据,降级可能不安全。执行前核对发行说明和官方迁移限制,必要时通过旁路集群迁移或经过验证的数据恢复路径处理。

灾备与跨地域

跨地域复制会增加网络成本、延迟和恢复复杂度。异步灾备必须接受一定 RPO,并有切换后对账;同步跨地域则可能显著影响写入延迟与可用性。消费幂等、生产流量切换、组进度和业务数据库灾备也要一起考虑。

练习:设计一个“主机断电但同机磁盘仍完好”和一个“整块盘永久损坏”的演练。两者分别能证明什么,不能证明什么?

第十四章 可观测性与日常运维

从用户目标选择指标

用户关心订单多久完成,不只关心 Broker 每秒处理多少消息。建议从端到端业务延迟开始,再拆分生产确认时间、Broker 可见延迟、积压年龄、消费执行时间和下游提交耗时。

层次 关键观察 常见判断
生产端 成功率、未知结果、延迟分位数、重试数 网络、路由或 Broker 写入是否异常
Broker TPS、磁盘余量、写入延迟、线程池队列、分发落后 存储和调度是否饱和
复制 复制差距、同步集合、主身份变化 故障后可保留哪些数据
消费端 最老积压年龄、队列分布、重试、死信、处理耗时 是不均衡还是整体能力不足
业务 订单状态滞留、对账差异、重复效果 技术成功是否转化成业务成功

消息条数适合估算工作量,积压年龄更接近业务 SLA。十万条很小消息可能几秒追完,一万条昂贵任务可能需要几个小时。两种指标应一起保留。

一条消息的排查路径

先取得稳定事件 ID、订单号、发送时间范围、Topic、Tag、生产者日志。确认发送结果;用消息 Key 或 Message ID 查询时注意查询能力、索引延迟和数据保留。然后检查消费组订阅、进度、消费者实例和失败日志,最后核对业务数据库。

消息“查得到”不能证明已消费,“消费成功日志”不能证明数据库提交正确,“数据库有一行”也不能证明只执行一次。完整证据链应连接发送、存储、投递、事务提交与幂等记录。

日志应避免完整打印敏感正文。建议打印 eventId、业务 ID、组、Topic、队列、offset、处理次数、错误分类、耗时,并对高频重复错误做采样或聚合。

有界操作与审计

常见管理动作包括创建 Topic、修改权限、扩队列、重置进度、死信重放。执行前记录目标集群、资源、影响范围、预期结果与回退方式。批量脚本必须先列出目标,再执行带边界的操作,避免组名前缀拼错波及无关服务。

例如重置进度之前应暂停目标组、保存旧位置、验证幂等、估计重放量;完成后低速恢复并观察业务重复拒绝、下游容量和积压变化。仅看到命令返回成功,不代表运行中所有实例已经采用新进度。

值班手册的结构

一份可用的手册应包含:症状和用户影响;如何确认集群与业务身份;前五分钟的只读检查;允许自动执行的缓解;需要业务判断的恢复动作;联系与复盘记录。告警阈值按容量和 SLA 设置,不要直接复制某篇文章的数字。

故障后记录时间线、影响范围、丢失或重复数据的对账结论、检测和恢复耗时。改进项应有明确验证方法,例如增加“积压年龄超过十分钟”告警,而不是只写“加强监控”。

练习:Broker 吞吐正常但订单完成延迟持续升高。给出至少四个可能原因,并为每个原因指定能够证伪它的指标或日志。

第十五章 容量规划与性能实验

先计算数量级

假设平均每秒 20,000 条,平均正文 1 KiB,保留 72 小时,两份副本。只算正文:

每天正文 = 20,000 × 1,024 × 86,400 ≈ 1.77 TB
三天正文 ≈ 5.31 TB
两份副本正文 ≈ 10.62 TB

这里使用十进制 TB;换成 TiB 数值会不同。实际还要加消息属性、协议和文件开销、ConsumeQueue、索引、重试、延时存储、文件清理余量和复制追赶空间。平均大小也不代表峰值分布,大消息比例会影响内存和网络。

把两个副本都放在一台机器上,磁盘需求可能满足,但高可用目标仍不成立。资源计算和故障域规划是两个必须同时满足的维度。

吞吐、并发和延迟

用 Little 定律作初步估算:稳定系统中,在途数量约等于到达率乘平均耗时。每秒 2,000 个任务、每个平均 50 毫秒,大约需要 100 个在途处理。它是稳态平均关系,不能预测尾延迟、突发流量或锁竞争。

提高并发前,分别检查 CPU、磁盘、网络、数据库连接与锁。若瓶颈在下游串行锁,增加消费线程可能只让排队变长。若 CPU 很低但线程都在等远程服务,适度并发可能有效,但应遵守对方配额。

积压恢复时间

已有积压 B,当前生产速率 λ,消费能力 μ。只有 μ > λ 时,才有持续追赶能力;理想追赶时间约为 B / (μ - λ)。

例如积压 3,600 万条,生产每秒 10,000 条,消费每秒 15,000 条,理想追赶需要 7,200 秒,即两小时。若业务要求一小时恢复,至少需要每秒额外 10,000 条净消化能力,还要考虑冷读、重试与下游限速。

这个公式假设速率稳定。真实恢复应测一段足够长的窗口,使用实际积压下降曲线估计,而不是拿某一分钟峰值直接承诺完成时间。

压测矩阵

至少覆盖小消息和大消息、正常实时消费和冷积压追赶、同步与异步发送、目标刷盘复制策略、不同队列数和消费组数、节点故障与恢复。业务处理不能只是空回调,否则无法说明真实下游能承受同样吞吐。

分别报告 p50、p95、p99、错误率和持续时长。若负载生成器自身 CPU 满了,观察到的吞吐上限可能来自客户端。记录机器、JDK、版本、网络、磁盘、配置与数据分布,让结论可复核。

保护机制

流量入口限速、生产者有界缓冲、消费端线程与批次上限、下游熔断和重试退避共同防止雪崩。队列的积压容量是最后缓冲,不应成为无限接收的理由。达到危险水位时,业务可能需要降级辅助事件、延后非核心任务或拒绝新请求。

练习:凌晨停机三小时后恢复,白天生产速率翻倍。用分时速率估算恢复窗口,说明为什么只用全天平均值会低估风险。

第十六章 安全与多环境治理

网络和身份是两道边界

NameServer、Broker、Proxy 和管理入口只开放给必要网络。把端口藏在内网并不代表身份可信:同网段的其他应用仍可能误操作资源。按生产者、消费者和运维职责分配最小权限,并隔离开发与生产凭据。

RocketMQ 各版本的 ACL、认证与授权机制存在演进。5.3.2 及后续版本应按对应版本文档选择和验证机制,不能把旧版 ACL YAML 与新版认证配置混用。本书明文本机实验没有接入生产权限系统。

传输加密的配置取决于经典 Remoting、Proxy gRPC、客户端以及证书管理方式。要验证服务器证书、主机名和客户端信任链,不能通过“关闭所有校验”解决连接问题。证书续期和轮换也应纳入演练。

凭据与资源管理

凭据通过部署平台的秘密管理系统注入,不提交进 Git。配置仓库可保留变量名和模板,不保留真实 Access Key、私钥和用户信息。日志、异常栈与截图也可能泄露秘密,排障时同样需要控制。

Topic、Group 和权限变更应能追溯到应用与负责人。命名可以包含业务和环境,但不要把无限高基数数据放进资源名。过期消费组和 Topic 需要经过确认再清理,不能因为短期无流量就直接删除。

隔离与配额

多租户共享集群时,一个大消息生产者或异常重试组可能占满磁盘与 CPU。根据组织边界选择独立集群、独立 Broker 或逻辑资源隔离,并验证服务端和外围治理工具实际提供的限流能力。

配额不只是每秒条数,还包括字节数、连接数、Topic 数、消费组数、保留与重放量。设置配额后,应给应用明确的失败处理方式,否则限流会被无限重试放大。

安全更新

版本固定有助于复现,但不能成为长期不更新的理由。定期检查 Apache RocketMQ 安全公告、依赖漏洞与发行说明,判断漏洞是否影响实际暴露面,然后在隔离环境验证升级路径。对管理入口、配置修改权限和公网暴露优先做清查。

练习:测试环境需要读取生产消息样本来复现问题。设计一条经过脱敏、授权和审计的数据提取路径,避免直接给测试程序生产消费者凭据。

第十七章 故障演练与上线验收

演练需要明确可观察结果

每次演练先写下假设、操作范围、停止条件、预期告警和数据对账方法。只在隔离实验或获准环境执行故障注入。操作完成不是演练成功,恢复后业务状态正确才是。

建议为事件建立三份可比证据:生产侧持久化事件清单、Broker 或客户端投递证据、消费侧业务流水。Broker 查询受保留与索引条件限制,不能作为唯一永久账本。

三个仓库可运行实验

# 正常收发
mvn -f examples/pom.xml -q exec:java \
  -Dexec.mainClass=book.rocketmq.RoundTrip

# 第一次处理主动失败,确认之后发生重新投递
mvn -f examples/pom.xml -q exec:java \
  -Dexec.mainClass=book.rocketmq.RoundTrip -Dexec.args=retry

# 默认等级 3 的延时投递
mvn -f examples/pom.xml -q exec:java \
  -Dexec.mainClass=book.rocketmq.RoundTrip -Dexec.args=delay

重试实验会打印 attempt=1 和后续次数。它验证重投递,不验证库存幂等;要证明幂等,必须另加真实数据库事务并检查业务流水只有一份。延时实验验证没有过早收到消息,并给出实际总耗时;它不证明所有压力下都能精确十秒执行。

本仓库 research/runtime-validation.txt 保存本次实际运行结果;环境与失败情况如实记录,不把未执行的演练写成通过。

故障矩阵

故障 应观察 验收关注点
发送成功前后断开网络 超时、重复、稳定 eventId 不把未知结果当成确定失败
消费提交业务后进程退出 再次投递与幂等拒绝 业务效果只有一次
下游数据库暂时不可用 重试退避、积压、告警 不提前确认、不击穿恢复节点
慢消费者与冷积压 积压年龄、磁盘读与追赶速率 旧消息在清理前处理完
主 Broker 故障 主身份、复制差距、客户端恢复 实际达到既定 RPO/RTO
非法契约消息 失败隔离、死信与告警 不长期阻塞所有正常业务
重置进度 重放量、重复拒绝、外部副作用 可审计、可控制、业务可核对

上线验收是一份证据清单

确认版本、部署模式、客户端协议与连接地址一致;确认 Topic、Group、权限和保留策略已审批;确认生产侧有可靠补发来源,消费侧有并发安全幂等;确认业务状态机、死信处理和对账存在;确认容量、积压恢复时间与故障切换经过目标环境验证。

还要确认值班人员能拿到日志、指标和手册,凭据可轮换,安全更新有人跟踪。一个只有作者笔记本上能启动的系统,不等于团队已经能够维护。

一个最终设计题

设计每天一千万笔订单的消息链路,要求订单创建事件不因应用重启丢失,库存重复扣减为零,支付后不可被超时关单覆盖,单 Broker 故障后十分钟内恢复,允许重放最近七天事件。

先写业务不变量,再选 Outbox 或事务消息,再设计库存幂等和订单状态机,最后选择复制与容量方案。若只先画出三个 Broker,你尚未回答最核心的问题。

附录 A 练习参考与术语

设计题参考方向

第一章:短信失败通常进入独立重试,不撤销已经成立的订单;库存失败可能触发取消或人工处理,取决于业务承诺。建议用 inventory-order-events、notification-order-events、risk-order-events 表达独立逻辑,各组内部再水平扩容。

第四章:对未知发送结果返回“处理中”并提供按业务请求 ID 查询状态的接口。相同幂等键只能建立一份订单与待投递事件,不能每次点击都创建新订单。

第六、七章:业务提交后重投递应命中幂等键,库存不再改变。订单原子事务易于保证整单一致性,但锁持有更久;逐商品处理需要每项独立键、汇总状态和部分失败补偿。

第八章:依靠数据库版本和写入者代际约束拒绝旧写入者。版本并不是随意由每个进程从零开始计数,而应来自权威状态。

第九章:消息携带计划版本,消费端读取最新到期时间,用状态、版本和当前时间做条件更新。旧消息到达时只做一次无副作用的检查。

第十章:最少需要稳定事务 ID、业务关联、可证明提交或回滚的持久化事实,以及足够长的保留窗口。没有证据时应保持未知并调查,不能猜测。

第十一章:租约到期重投相同事件 ID;消费端唯一键与库存更新在同一事务。若发送确认更新失败,允许重复投递但不允许重复业务效果。

第十四章:可能是某个队列热点、下游变慢、重试流量升高、消费者频繁重平衡、定时消息集中到期。分别核对队列级延迟、数据库耗时、重试指标、实例变更和到期流量分布。

最终设计题没有唯一拓扑。首先把不变量落到数据库约束和可恢复发送义务,再按峰值、字节数和七天重放计算容量。十分钟恢复需要故障演练的持续流量证据,不能只依赖静态架构图。

术语速查

术语 在本书中的含义
Topic 消息类别与资源管理边界
Queue 经典客户端分配与顺序推进的重要单位
Consumer Group 同一种消费逻辑的实例集合与进度身份
CommitLog 经典存储中的消息正文追加日志
ConsumeQueue 按 Topic 和 Queue 组织的消费位置索引
Offset 特定队列或日志内的位置,需说明是哪一种
At-least-once 可能重复交付,需要业务幂等
幂等 同一业务输入重复处理不增加业务效果
DLQ 达到失败条件后隔离待处理消息的死信队列
Outbox 与本地业务同事务保存待投递事件的模式
RPO / RTO 可接受数据损失目标 / 恢复时间目标
FIFO / MessageGroup 新客户端中用于表达分组顺序的相关概念

附录 B 资料、源码与阅读路径

本书参考官方文档和 Apache RocketMQ 源码,文字为独立编写。在线文档会变化,research/sources.json 记录取证地址、时间与内容哈希;文本快照用于复核,不将网页视为固定版本规范。

官方资料

  1. Quick Start:服务启动与基本客户端使用。
  2. Domain Model:Topic、Group、Queue 及消息模型。
  3. FIFO Message:分组顺序与顺序保证边界。
  4. Delay Message:定时交付语义与约束。
  5. Transaction Message:半消息、本地事务与回查。
  6. Consumer Retry Policy:不同消费类型的重试行为。
  7. Automatic Failover:Controller 相关部署与故障切换。
  8. Apache RocketMQ Security:安全通告与报告入口。
  9. 5.5.1 发布说明:资料核对日所见最新发布,不是实验基线。

固定源码入口

实验基线 tag 为 rocketmq-all-5.3.2,提交为 2baaf044ea9b12a73a89433242b7b7c56a1b89c2。阅读顺序建议从 API 到请求处理,再到存储:

阅读源码时先沿一次正常请求走通,再检查超时、异常和恢复分支。方法名相同不代表不同版本实现相同;源码定位、实验日志和业务不变量结合起来,才构成可靠理解。

↑