1. 为什么是RocketMQ?从定时任务到消息驱动的演化之路
做了多年后端,你有没有遇到过这种场景:用户下单成功后,系统要同步去扣库存、发短信通知、更新积分、推送物流信息。如果其中一个环节比较慢,整个请求被拖住;如果其中有一个系统挂了,下单接口直接报错,用户看到的是“系统繁忙”。
早期很多团队靠定时任务轮询数据库来缓解,比如每隔几秒扫一次订单表,把待处理的记录拿出来做后续操作。这种方式在小规模时勉强能跑,但一旦业务量上来,问题就特别明显:扫描周期有时间差、把数据库当消息队列用压力很大、多实例部署时还要防重复扫描、任务堆积后很难跟踪顺序。这时候引入消息中间件就成了一件顺理成章的事。
RocketMQ就是国内某大型电商平台开源的一款分布式消息中间件,后来捐给了开源社区。它是用纯Java写的,在Java技术栈的团队里落地成本非常低——团队里随便一个后端工程师都能看懂核心逻辑,出了问题也能自己翻源码排查,这比维护一个Erlang或Scala写的东西省心太多。
拿它和其他消息中间件横向对比一下,选型时会更清楚:
- RocketMQ:纯Java,功能相当全,顺序消息、事务消息、延迟消息、消息过滤这些高阶特性都是内置的;单机吞吐量能到十万级,加上批量发送还能更高;数据可以做到0丢失。
- Kafka:吞吐量确实猛,几十万到百万级TPS都很常见,但它的定位更偏向日志采集和流数据处理;想实现事务消息、延迟消息这些业务特性,需要自己封装很多逻辑;而且Kafka的Partition和Consumer的协调机制在业务消费场景下配置起来略显繁琐。
- RabbitMQ:功能完整、社区活跃,基于Erlang开发,吞吐量通常在万级左右,消息堆积能力也不如RocketMQ;适合中小型系统,但遇到大规模堆积场景,处理能力会显得吃力。
我当时选RocketMQ,核心原因是三个:一是团队全是Java背景,二是项目里需要事务消息保证订单状态的最终一致,三是有过大规模消息堆积的硬性需求。你在选型时如果只追求超高峰值吞吐,Kafka可能更合适;如果是纯Java团队、业务消息多、对可靠性要求高,RocketMQ是更顺手的那个。
2. 一条消息从发送到消费的完整链路拆解
2.1 四个核心角色各管什么事
RocketMQ的运行模型不复杂,核心就是四个角色:Producer发消息、Consumer收消息、Broker存消息、NameServer管路由。其中最容易被忽略的其实是NameServer,很多人刚接触时把它类比成Kafka里的ZooKeeper,其实两者设计思路很不一样。
NameServer是一个轻量级的注册中心,Broker启动后会主动向所有NameServer注册自己的地址和Topic路由信息。生产者和消费者不需要直接记住Broker的地址,只需要连上NameServer,拿到路由表就能找到对应的Broker。
它和ZooKeeper最大的区别是:ZooKeeper部署了一套强一致性的选举机制来保证元数据可靠,NameServer则根本不保证每个节点之间的数据强一致,每个NameServer都是独立保存全量路由信息的。Broker每30秒向NameServer发送一次心跳,NameServer如果检测到Broker超过120秒没有心跳就直接把它从列表里剔除。这个设计牺牲了强一致,但换来了极低的部署成本和超高的可用性——Producer和Consumer会同时连接多个NameServer,挂了一个还能从另一个拿路由。
Producer发消息的过程也很直接。默认走同步发送,就是发完等Broker返回一个结果,成功继续发下一条,失败则抛异常让你处理。对数据可靠性要求高的场景,比如订单创建、支付回调,建议用同步发送。还有异步发送,通过回调接收结果,适合对延时敏感但不想阻塞主线程的场景。单向发送则完全不关心结果,适合日志采集这类允许丢失的数据。
Consumer消费消息走的是“长轮询”模式,不是Broker主动推数据。客户端每5秒向Broker请求一次新消息,Broker如果没有新消息,会保持这个请求最长挂起15秒,有消息了立即返回。这个机制的好处是Consumer能够自己控制消费速度,挂了也不会给Broker造成推送压力。
表:RocketMQ核心角色分工
| 角色 | 核心职责 | 关键设计 |
|---|---|---|
| Producer | 消息生产与容错重试 | 自动故障转移、多种发送模式 |
| NameServer | 路由注册与发现 | 无状态、最终一致、全量路由 |
| Broker | 消息存储与消费调度 | 主从架构、顺序写盘 |
| Consumer | 拉取消息与业务处理 | 长轮询、集群/广播消费 |
2.2 集群消费和广播消费,以及消息过滤的坑
消费模式上,Consumer默认走集群消费模式,也就是说同一个消费组里的多个消费者共同分担这些消息,一条消息只会被组内一个消费者处理一次。这个模式天然支持水平扩展,消息量大就多部署几个Consumer实例,每个实例处理一部分。这也是最常见的使用方式。
广播消费模式则是一条消息推给组内每一个消费者,每个消费者都能收到全量消息。这个模式用的场景相对少,一般是每个节点都需要本地缓存一份全量数据时才会用,比如每个机房都需要维护一份配置快照。要注意的是,广播模式下消息没有消费进度可控性的概念,重启就会重复,所以一定要配合幂等设计来用。
消息过滤这块有必要展开说,因为用错的人不在少数。RocketMQ支持两种方式:Tag过滤和SQL属性过滤。Tag过滤最简单,比如一个Topic里混着订单创建、订单取消、订单支付三种事件,发送时给消息标上不同的Tag,Consumer订阅时只拉自己关心的Tag。SQL属性过滤则是在发送时给消息附带自定义属性,订阅时用SQL表达式做更细粒度的过滤,比如age > 18 AND sex = 'male'。要注意:SQL过滤在Broker端做,会增加Broker的CPU开销;而Tag过滤是纯字符串匹配,性能更高。能用Tag搞定的事,尽量不要上SQL。
3. 存储和性能的核心:CommitLog顺序写盘机制
3.1 为什么所有Topic共用一个CommitLog文件
RocketMQ存储设计最反直觉的一个点是:它没有为每个Topic单独建文件目录,而是所有消息不管属于哪个Topic,全部写到同一个CommitLog文件里。文件按大小切分,默认1GB一个,写满一个再写下一个。
按每个Topic单独建文件的想法初看很合理,但仔细想就发现性能瓶颈很大:Topic一多,对应的文件就多,写入就会变成随机写。磁盘随机写和顺序写的速率不在一个量级,机械硬盘上顺序写在100MB/s以上,随机写可能连10MB/s都不到;即使是SSD,顺序写的表现也好得多。RocketMQ把消息写盘设计成单文件顺序追加,这是它吞吐量高的根本原因。
那Consumer消费时怎么定位到属于自己的消息?这就要说到ConsumeQueue。CommitLog存的是原始消息体,ConsumeQueue相当于逻辑队列:它按消费组和Topic组合建目录,每隔一段时间把CommitLog里的消息索引同步过去。每个条目是定长的20字节,分别记录消息在CommitLog中的物理偏移量、消息长度、消息Tag哈希。消费者消费时先读ConsumeQueue拿到物理偏移量,再去CommitLog里精准定位。
这个设计的核心逻辑是:写入只需顺序写一个文件,读取借助页缓存和内存映射文件加速。Consumer读数据时命中PageCache的概率很高,因为最近写入的消息大概率还在内存里。这就使得这个系统既能保持很高的写入吞吐,又能让消费的随机读取尽量落在缓存里。
3.2 消息索引和文件刷盘策略
除了ConsumeQueue,RocketMQ还有一个IndexFile索引文件,专门用来支持按消息Key来查询消息。你可以给每条消息设置一个业务Key,比如订单号,之后就可以通过这个Key快速查询这条消息的完整内容和处理轨迹。生产环境排查线上问题时,这个功能非常有用——你怀疑某条消息没发出去,直接按Key查一下Broker上的消息详情,立刻就知道了。
刷盘策略是存储层面另一个关键点。默认情况下RocketMQ走的是异步刷盘,也就是消息先写入PageCache缓冲区,后台线程再定期刷到物理磁盘。这个模式下性能最好,因为写入操作只写内存,但极端情况——比如机器断电——可能丢失最近一小段时间的数据。
如果业务对数据可靠性要求极其苛刻,比如交易流水类的消息,可以改成同步刷盘模式。同步刷盘模式下,消息写入内存后必须等到数据真正落到物理磁盘,Broker才返回成功给Producer。性能会有所下降,但换来的是消息零丢失。
我们线上的配置经验是:普通业务消息用异步刷盘,金融级别的核心链路用同步刷盘,同时打开Broker的主从同步复制。两套搭配下来,既保证了大部分消息的性能,又保住了核心链路的数据安全。消息的落盘机制是必须理解透彻的,因为你后续做压测、调优、扩容,都要围绕这个存储模型来思考。
4. 顺序消息、事务消息和延迟消息的正确用法
4.1 顺序消息:全局顺序看似简单,分区顺序才是正解
很多业务场景要求消息按顺序消费。典型的就是订单状态流转,订单创建消息必须先于订单支付消息被处理,如果顺序颠倒了,下游系统可能以为支付了一笔不存在的订单。
RocketMQ支持两种粒度的顺序:全局顺序和分区顺序。全局顺序是指一个Topic下所有消息都严格有序,实现方式是把Topic的消息收敛到一个Queue里、只用一个Consumer实例来消费。这种方案几乎把吞吐量降到最低,实际生产环境用得非常少。真正常用的是分区顺序:把相同业务标识的消息发送到同一个Queue,比如同一个订单ID的所有状态消息都发到Queue 0,而不同订单可以分散到不同的Queue上并行处理。
发送端可以用MessageQueueSelector自己选择Queue,通常按业务ID哈希取模。消费端要保证单线程消费,并且消费逻辑里不要因为某条消息处理失败就把它丢回消息中间件重新排队,否则很可能会把后面的消息堵住,打破顺序。
4.2 事务消息:本地消息表之外的另一种最终一致方案
事务消息是RocketMQ最有价值的特性,很多人在架构设计时把它当作分布式事务的解法之一。它的执行机制概括为“两阶段提交+事务回查”。
以我们之前的订单场景为例:A系统创建订单,同时需要通知B系统扣减库存。如果不引入消息中间件,就需要分布式事务框架或者本地消息表方案。用RocketMQ事务消息后,流程是这样的:
- 订单系统先发送一条“半消息”(对消费者不可见)到Broker。
- 订单系统执行本地事务,把订单数据写入业务数据库。
- 本地事务提交后,向Broker发送确认指令,半消息变成消费者可见的正常消息。
- 如果本地事务没有返回结果,Broker会定时回调订单系统的接口查询事务状态,由业务方告诉Broker这单事务是提交还是回滚。
这就绕开了数据库和消息中间件之间的原子性问题。不需要自己维护“本地消息表+定时任务扫描发送”那一套复杂度,代码结构也干净得多。用事务消息时,事务回查接口必须做到幂等,因为Broker可能会多次回调;另外,业务方要控制好事务执行时间,如果本地事务长时间不结束,Broker端一直在等待确认,消息链路就会被拉长。
4.3 延迟消息的18个级别
延迟消息实际上就是定时消息的简化版。RocketMQ内置了18个延迟级别,分别对应1秒、5秒、10秒、30秒、1分钟、2分钟等时长。发送时给消息设置一个延迟级别,Broker会把消息投递到对应等级的延迟队列,时间到了再转投到真正的Topic。下单后15分钟未支付自动取消订单,这类常见业务可以直接用它实现,不用自己写定时任务轮询。
需要注意的一点是:延迟级别是固定的18个档位,不支持任意秒数的延迟。如果你需要精确到某一天的定时任务,它并不合适。这种场景建议自己实现延时调度,或者用专业的任务调度框架来处理。另外,延迟消息对Consumer来说也是普通消息,消费端不需要做任何特殊处理。
事务消息、顺序消息、延迟消息,这三类特性合在一起,其实把业务系统里很多分布式场景的复杂度都吸收掉了。这也是RocketMQ在众多消息中间件里独树一帜的地方。
5. 线上消费堆积排查实录:一次真实故障排除链路
5.1 现象出现:消费进度告警
有一次线上监控发出告警,称某消费组积压的消息数量超过20万条,消费延迟从几百毫秒飙到了十几分钟。当时我们的第一反应不是去调整消费者,而是先确认是哪个Topic、哪些Consumer Group、积压的具体数值。
用管理控制台或者命令行工具mqadmin consumerProgress -g 消费组名可以快速查看到每个消费者的消费位置和Broker里消息的最大偏移量,差距就是积压量。同时还需要看消费者的在线情况,比如机器列表里是不是有某个节点已经挂掉了,导致消费能力少了一截。
我当时排查的第一步就是去看消费组实例的在线数量。正常我们有8台机器在跑消费任务,那次只剩7台在线——有一台机器在凌晨被系统更新重启了,但它注册到Broker上的消费组信息没有自动剔除干净,导致部分消息仍然被路由到一台已经不存在的机器上。这是非常典型的由服务下线不干净引起的“伪积压”。
5.2 定位消费逻辑中的异常热点
把机器的问题解决后,积压量降了一些,但消费延迟还是比平时高。这时候我开始查每台机器上消费者的处理日志,发现某个消费者在拉取到消息后,处理单条消息的耗时极不均衡:80%的消息处理只要50毫秒,但偶尔有几条消息的耗时高达3秒。
继续往下翻,找到耗时高的消息特征,发现它们都带同一个特殊标记的商品ID——该商品在数据库里被大量并发更新,导致消费线程在等数据库行锁。消费者本身没问题,反而是数据库的竞争拖慢了消费速度。消费线程数本来是20个,但所有线程都在等数据库锁的时候,消息自然就卡住了。
这类问题通常要从两个方向处理。第一就是加索引、优化SQL,把数据库这头的瓶颈消掉。第二就是在消费逻辑里做防御性设计:比如用读写分离或者本地缓存降低对数据库的实时访问频率;再比如把消费线程数从20调整到50,但注意不能无脑调大,调大了如果下游数据库扛不住反而会引起更大的连锁故障。
5.3 从根上防住重复消费
消息堆积背后还有一个隐藏的坑,就是消费重试会放大重复消费的概率。RocketMQ默认在Consumer处理消息抛出异常并且没有在代码里捕获时,Broker会按照延迟级别对这个消息进行重试投递,重试超过一定次数后才进入死信队列。如果业务代码没有做好消费幂等,每次重试都是一次重复处理,可能引发数据错乱、重复发券、重复扣费这些问题。
消费幂等最常用的方案是使用唯一业务键+去重表:在消息体里带一个业务唯一ID,消费时先去去重表里查这个ID有没有处理过,没处理过就正常处理并落库,重复的ID直接忽略。也可以用Redis的setNx做原子占位,或者通过数据库的唯一索引来拦截重复。
这次排查的收获有两个:一是确认了消费积压不等于性能不够,很多时候是某个下游依赖出现了长尾耗时;二是在消费端代码里统一增加了业务幂等校验,把重复消费的破坏力降到最低。排查消息积压问题,核心思路是“先看在线消费实例数和消费进度,再看消费耗时分布和日志异常,最后检查下游依赖的健康状态”,不要一上来就动消费者线程数和机器数量。
6. 性能调优里那些硬核参数与实操经验
6.1 发送端的几点性能优化
RocketMQ的使用门槛不高,但想把它用出高性能,还是有一些参数和用法必须吃透。先从Producer发送端说起。
第一是批量发送。RocketMQ支持在同一个批次里发送最多4MB的消息,批量发送对吞吐率的提升几乎是成倍的。前提是把业务逻辑整理成可以打包的消息集合,比如同时通知用户的多条站内信、多条日志记录。
第二是合理使用压缩。大消息体——比如超过4KB的JSON——建议发送前先做压缩,GZIP或者Snappy都行,Broker端存储占用小了,网络传输时间也短了。但压缩也要看场景,消息体小于1KB时压缩收益不大,白白浪费CPU。
第三是发送超时设置。同步发送默认超时是3秒,如果你的Broker负载不低,建议调大到5秒或者8秒,不然高峰期容易误报发送失败。但也不要设太大,否则一条消息卡住会拖住整个发送线程。异步发送的话要设置合理的回调线程池,避免回调逻辑阻塞网络IO线程。
6.2 Broker端和JVM的调优要点
Broker端的配置项众多,但真正对性能影响最大的就那么几条。
第一是存储路径。CommitLog文件一定要放在SSD盘上,并且最好是独立挂载的目录,不要和操作系统日志、应用日志共用一块磁盘。我们曾经因为磁盘IO争抢出现周期性毛刺,把存储目录单独迁移后才消除。
第二是sendMessageThreadPoolNums这个参数,控制Broker处理消息写入请求的线程数。默认是CPU核数,如果机器是8核但写入压力很大,可以考虑加大到16或者24,但前提是磁盘能跟上。线程数调大以后,CPU和磁盘IO的使用率都要同步观察,别把机器打满。
第三是JVM参数。Broker是Java进程,它的堆内存设置直接影响缓存命中率。RocketMQ主要依赖操作系统的PageCache来缓存数据,堆内存本身不需要开得太大,建议堆内存设置在8GB到16GB之间,把更多内存留给系统去做文件缓存。如果堆设置太大,频繁触发GC反而会导致处理线程停顿;堆设置太小,某些查询场景又会频繁FullGC。另外还要打开-XX:+UseG1GC和-XX:MaxGCPauseMillis=50,把GC停顿压到最低。
最后是操作系统层面。需要把vm.swappiness调到 1 或 10,避免内存回收时把PageCache里的热点数据提前置换到磁盘;文件描述符上限要调高,比如设置成655350。消费和发送都是网络密集型的,文件句柄不够会出现大量“Too many open files”报错,这个问题其实非常常见。
6.3 消费端限流与扩展
消费端的调优相对简单,但要和业务特点匹配。Consumer实例数量决定并发度,单个实例内部还有线程数控制。如果一个Topic的消息量是每分钟20万条,单条消息处理耗时100毫秒,用默认的20个消费线程算下来理论上能处理20 * 10 = 200条/秒,也就是每分钟1.2万条,远跟不上20万的量。这种场景就需要横向扩展Consumer实例,或者调大消费线程数。
但这里有一个比较容易踩的坑:消费端性能和下游系统的承载能力必须匹配。假设你的消费逻辑是写入MySQL,调大消费线程数会让数据库的写入并发直线上升,如果数据库扛不住,反而引发慢查询、锁等待,消费耗时上涨,最后消息还是积压。所以每次加线程数或加实例后,都应该同时观察下游数据库、缓存、接口的响应时间和错误率,直到找到最优平衡点。
我们最终配置的经验值是:单Consumer实例消费线程数设置在20到40之间,下线扩容优先于调线程数,每台机器承载的消费者数量不要超过3个,防止同一台机器上的多个Consumer抢占CPU和内存资源。
7. 最后补充几个平时容易踩到的细节
写到这里,再分享几个我在实际使用中经常会踩的坑。
第一个是关于消息体大小的限制。RocketMQ默认限制单条消息最大4MB,超过会被拒绝。如果确实有大消息体需求,比如同步一份较大的配置快照,可以在Broker端调大maxMessageSize配置,但请谨慎操作,因为大消息对存储和网络都是一种压力。更好的处理方式是拆分成多个小消息,接收方聚合成完整数据。
第二个是关于Topic数量。Topic建得多,ConsumeQueue目录就多,虽然写盘还是顺序写,但文件句柄和目录操作的负担会上升。建议Topic数量控制在几百个以内,一个项目尽量复用已有Topic,用Tag语义去做业务区分,而不是每个业务建一个Topic。
第三个是关于消费组命名和Topic命名的规范。这个小问题经常被忽略,但生产环境一旦跑起来,几十个Topic、上百个消费组,找问题全靠名字区分。我们规定Topic命名统一为“项目-场景-事件类型”,消费组命名统一为“项目-场景-consumer”,这么简单的规矩省了非常多排查时的判断时间。
第四个是关于消息的乱序。如果你没有特意使用顺序消息特性,那么RocketMQ不保证消息顺序,即使发送是一条一条发的。很多系统因为默认它是顺序的,结果掉进坑里。如果业务必须有序,那就在设计时就明确使用分区顺序方案。
RocketMQ这套体系整体学下来并不难,难的是理解它每个设计背后的取舍。清楚它的存储模型,你就理解了为什么它能扛住大数据量;清楚它的消费模型,你就知道积压时该从哪里入手;清楚它的高级特性,你就能在分布式系统设计时多一个趁手的工具。希望这一篇的拆解对你有帮助。