ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

RocketMQ大消息处理实战:4MB限制排查与优化方案

RocketMQ大消息处理实战:4MB限制排查与优化方案 1. 4MB限制不是传说客户端和Broker各卡一道先搞清楚“谁说了算”上周有个同事跑来找我说线上给下游推送客户画像消息突然开始报错后台一看发送端直接抛了MQClientException: message body size over maxMessageSize。他一脸懵问我RocketMQ发送消息最大值到底是多少是不是哪里配置坏了。这里先给你一个明确的答案RocketMQ单条消息默认上限是4MB这个限制同时存在于生产者和Broker两端。客户端有一个maxMessageSize参数默认值是1024 * 1024 * 4Broker端的MessageStoreConfig里也有一个messageMaxSize默认同样是4MB。你发的消息只要超过这个值要么在客户端本地就被拦下来要么到了Broker再被拒收。1.1 两个拦截点要分清很多人只听说过“RocketMQ限制4MB”但不知道限制其实有两道排查的时候少看一处都会觉得莫名其妙。第一道拦在客户端。DefaultMQProducer在发送前会校验消息体长度源码里对应Validators.checkMessage这个方法逻辑很简单如果msg.getBody().length producer.getMaxMessageSize()直接抛MQClientException消息根本没出本机。这就是我同事遇到的情况因为他的生产者从来没改过默认值。第二道拦在Broker。就算你绕过了客户端校验比如用了老版本客户端、或手动改了客户端参数Broker端收到消息后还会再查一次。CommitLog写入之前会拿消息体长度和messageMaxSize比超了就写一条WARN日志然后返回写入失败。Broker是最终裁决者所有消息都逃不过这一关。1.2 我建议你把这两个参数当成一组来管既然两端都有4MB限制那么调大配置时就必须同步改不能只改一边。# broker.conf messageMaxSize8388608// 客户端对应设置 DefaultMQProducer producer new DefaultMQProducer(prod_group); producer.setNamesrvAddr(10.0.0.2:9876); producer.setMaxMessageSize(8 * 1024 * 1024); producer.start();只改Broker、不改客户端客户端本地校验就会把你挡在门外只改客户端、不改Broker消息发到Broker后照样被拒。我见过不止一个团队因为漏改一端折腾了大半天最后发现是“改一半”的问题。1.3 为什么默认值设计成4MB而不是更大理解这个设计你才不会动不动就想把它改成100MB。RocketMQ的CommitLog是内存映射文件mmap加顺序写Broker写入时会对CommitLog加锁消息越大持锁时间越长同一时刻能并发写入的小消息就越少。再加上同步刷盘、主从同步复制、页缓存加载一条大消息的代价是整个Broker在买单。4MB不是拍脑袋定的它兼顾了业务消息的典型体积、JVM堆内存压力、网络传输带宽和GC停顿。在绝大多数场景下一条消息超过4MB本身就意味着设计可能需要调整。2. 踩坑实录大消息发送失败时我都是按这套链路去查超限报错不是只有一种长相你以为的“配置问题”有时候其实是“计算方式”问题。下面把排查链路完整走一遍你可以直接照着做。2.1 客户端报错的三种长相第一种本地直接抛异常提示message body size over maxMessageSize。这种最干脆说明是客户端校验没过消息压根没发出去。第二种发送时网络异常或Broker返回了异常码你拿到的SendResult不是SEND_OK而是FLUSH_DISK_TIMEOUT、SLAVE_NOT_AVAILABLE一类或者直接抛RemotingException。这种情况要重点怀疑Broker端拦截但也可能是消息太大导致Broker处理超时。第三种发送返回正常但消费端收到后解析失败。这种情况最隐蔽常见于消息体里塞了二进制大对象生产者没改配置但侥幸通过或者Broker端配置被改过消息是发出去了却把消费端内存直接撑爆。2.2 Broker日志才是最终裁决客户端日志只能帮你判断“卡在哪一端”真正做最终判定的一定是Broker日志。在Broker节点上打开store.log或broker.log搜这几个关键词message body size exceededMESSAGE_ILLEGALmaxMessageSize搜到基本就实锤了。我自己排查时习惯用时间窗口缩小范围先记录客户端报错的时间点再去Broker对应时间点抓WARN和ERROR日志比全量搜索快得多。2.3 消息体总大小计算body和properties都会占额度有个特别容易踩的坑你以为消息体只有3.8MB没超4MB但实际计算时body加上properties的长度才是最终参与判断的值。setKeys、setTags、setUserProperty、消息重试次数、延时等级这些系统属性最终都会序列化进消息属性区。举一个真实场景某系统发一条业务消息body只有3.9MB理论上没超。但生产者在消息属性里塞了好几个业务字段总长度加上去超过了4MBBroker直接拒收。所以我在发送前会先打印一下实际长度int bodyLen msg.getBody().length; int propsLen msg.getProperties().toString().getBytes(StandardCharsets.UTF_8).length; log.info(send message, bodyLen{}, propsLen{}, total{}, bodyLen, propsLen, bodyLen propsLen);这行日志帮我排掉过大量“我以为没超”的疑难杂症。2.4 一次5MB画像消息的完整排查过程再说回我同事那个案例过程很有代表性。他发的是客户画像数据body是一个JSON字符串里有一个字段是大段的Base64头像图序列化后5.2MB。第一步客户端日志看到message body size over maxMessageSize确认是客户端校验拦截。第二步在代码里加打印发现5.2MB的body里头像Base64占了4.1MB。第三步确认生产者和Broker都是默认4MB限制。当时我的处理方案是先让现场用压缩临时缓解把JSON用GZIP压一遍5.2MB压缩后只剩1.4MB发送成功。后续把头像挪到对象存储消息里只带文件路径彻底解决。这个顺序也代表了我处理大消息的默认思路先压缩止血再架构根治。3. 改配置能解一时之急但背后的账要算清楚很多人的第一反应是“限制4MB是吧那我改成100MB”。能改但改完的代价往往比想象中高。3.1 broker.conf如何改客户端如何配合如果只是偶尔有几条10MB以内的消息临时调大是合理操作。Broker端改broker.confmessageMaxSize16777216 maxTransferBytesOnMessageInMemory16777216messageMaxSize控制单条消息上限maxTransferBytesOnMessageInMemory控制从内存拉取消息时单次传输的最大字节数。如果你把消息上限调到16MB但没调后者消费端从内存拉取时仍可能被另一个限制卡住。多数默认情况下后者够用但消息超过几十MB时必须同步调。改完Broker配置要重启Broker进程才能生效没有热加载。客户端同步设置producer.setMaxMessageSize(16 * 1024 * 1024);改完先用一条之前失败的5MB消息做验证别一上来就跑全量压测。3.2 调大后的连锁反应比你想的更贵先是大锁问题。CommitLog写入是串行化的一条16MB消息写入需要的时间远大于四条4MB消息的累加因为大消息的序列化、内存复制、刷盘都是一个整体操作。你发一条大消息相当于让所有小消息在它后面排队等待。然后是内存问题。Broker的页缓存、堆内缓冲、消费端拉取缓冲都要给大消息预留空间。消息越大GC压力越大老年代频繁回收最终表现为Broker CPU飙升、发送RT变高。消费端如果是Java应用拉下来一条16MB消息去反序列化年轻代直接放不下直接进老年代卡顿肉眼可见。我见过一个极端案例团队把messageMaxSize调成了64MB然后每天几百条大数据量消息Broker的JVM频繁FullGC发送可用性跌到99%以下最后不得不再调回去。3.3 我建议的临时调大边界根据我自己的经验如果只是临时应急调到8MB到16MB是相对安全的范围超过16MB就要非常谨慎超过32MB基本是在给Broker埋雷。任何打算长期以超过4MB的消息为主线业务的都不应该把宝押在调配置上而是应该从消息设计层面换方案也就是下面要说的压缩和引用式投递。4. 压缩后再投递是我处理超大消息时性价比最高的第一步压缩是解决超大消息最简单、最便宜、见效最快的手段没有之一。大部分超限消息是文本类数据比如JSON、XML、日志、Base64串这些内容压缩率非常可观。4.1 什么样的内容适合压缩我通常按这个标准判断如果消息体是可读文本或者看起来像纯文本的序列化数据就先压一把试试。JSON序列化后的数据通常能压掉70%到90%Base64字符串本质上也是文本压缩率也不错。但如果是本身就压缩过的文件比如JPG图片、MP4视频、ZIP包那再压一遍就是浪费时间甚至可能让体积变大。遇到这种老实走后面的引用式投递方案。4.2 GZIP压缩与还原示例GZIP是Java标准库自带方案零依赖适合大多数场景。我封装过一套工具发送端压缩消费端解压注意消息属性里记录算法类型避免以后换算法时老消息解不出来public static byte[] compress(byte[] data) throws IOException { if (data null || data.length 1024) { return data; } ByteArrayOutputStream bos new ByteArrayOutputStream(data.length / 3); try (GZIPOutputStream gzip new GZIPOutputStream(bos)) { gzip.write(data); } return bos.toByteArray(); } public static byte[] decompress(byte[] data) throws IOException { ByteArrayInputStream bis new ByteArrayInputStream(data); ByteArrayOutputStream bos new ByteArrayOutputStream(); try (GZIPInputStream gzip new GZIPInputStream(bis)) { byte[] buffer new byte[8192]; int len; while ((len gzip.read(buffer)) ! -1) { bos.write(buffer, 0, len); } } return bos.toByteArray(); }生产端发送前先压缩消费端收到后按算法字段解压。5MB的JSON压到1MB以内很常见压缩和解压的CPU开销在百毫秒级别相比消息体积带来的网络和内存收益完全可以接受。4.3 压缩不是万能的注意阈值和压缩率小于1KB的数据不建议压缩。GZIP本身有固定开销小数据压完反而可能更大。我一般只在消息体超过4KB时才考虑压缩。还有一个坑压缩率取决于内容特征。同是JSON纯数字和短字段的压缩率低长文本和高重复内容压缩率高。上线前最好拿真实业务数据试一遍别拿一段重复的假数据测出来90%压缩率上线后一压只有30%又超限了。4.4 压缩后仍超限怎么办如果一条20MB的JSON压缩后还有6MB那说明压缩这条路线已经到头了。这时候不要继续死磕直接跳转到下一层方案。记住一个判断准则先压缩压到4MB以内算赢压不到就不要再在“消息本体”上做文章了该换思路了。5. 只发引用不发本体把文件交给存储把“线索”交给RocketMQ这是业界最推荐的大消息处理方式也是我最终给同事落地的方案。核心理念一句话RocketMQ传的是业务线索不是文件本身。5.1 为什么绕一圈反而最稳当业务里出现图片、音视频、大报表、大附件这类动辄几十MB甚至几百MB的数据时它们本质上是文件不是消息。文件就该进对象存储或文件系统消息队列只负责告诉下游“有个文件需要处理去哪里拿”。这样做的收益很直接RocketMQ永远是轻量消息Broker不会因为大对象被拖垮消费端可以按需拉取文件处理失败还能重新下载重试文件的生命周期由存储系统管理和消息解耦。5.2 落地链路上传、签名URL、消息带引用我用的链路是这样的业务系统先把完整文件上传到对象存储OSS、MinIO、S3都行拿到文件唯一标识或URL。计算文件MD5连同业务字段一起组装成一条小消息发送。消费端收到消息后根据文件标识去存储系统下载文件。下载后校验MD5再做后续业务处理。如果存储是私有的URL要带签名和过期时间消息里可以只传objectKey消费端拿到后自己生成签名URL再下载这样URL过期也不影响。// 发送端示例 String objectKey fileService.upload(body); MapString, String data new HashMap(); data.put(objectKey, objectKey); data.put(md5, md5); data.put(bizId, bizId); Message msg new Message(FILE_TOPIC, JSON.toJSONBytes(data)); producer.send(msg);5.3 消费端要做哪些额外工作消费端不能拿到引用就闷头处理至少要考虑三点。第一文件下载失败必须有重试策略。RocketMQ本身有消费重试默认重试16次但下载超时时间要设置合理避免每次重试都是等超时。第二要处理文件已过期或已被清理的情况。第三业务成功后要触发文件清理。我见过因为没有清理策略OSS存储费用一个月翻了几倍的案例。5.4 什么时候不适用引用式投递也不是银弹。如果消费端和存储之间的网络很差下载耗时很长或者业务对实时性要求极高等不了文件下载这一步又或者存储系统不可用而你不能接受。这些场景下就要考虑其他路径或者接受MQ直接投递大文件带来的稳定性代价。但话说回来如果让我给一个通用建议凡是超过10MB的内容我基本不会考虑直接塞进RocketMQ。6. 分片拆分与主键兜底附一份选型对照表除了压缩和引用式投递还有两种方案也值得掌握分片拆分和数据库主键。它们适用场景不同但都是绕开单条消息大小限制的成熟做法。6.1 分片拆分适合必须走消息链路的二进制数据有些场景文件无法上传到外部存储或者下游服务只认MQ那分片是最后的选择。思路是把一个大的二进制数据按3MB左右切块每块作为一条普通消息发送带上业务ID、分片序号、总分片数。byte[] whole ...; int chunkSize 3 * 1024 * 1024; int totalChunks (whole.length chunkSize - 1) / chunkSize; for (int i 0; i totalChunks; i) { byte[] part Arrays.copyOfRange(whole, i * chunkSize, Math.min(whole.length, (i 1) * chunkSize)); Message msg new Message(topic, part); msg.putUserProperty(bizId, bizId); msg.putUserProperty(chunkIndex, String.valueOf(i)); msg.putUserProperty(totalChunks, String.valueOf(totalChunks)); producer.send(msg); }消费端的聚合逻辑一定要设计好。我会用本地缓存或Redis按bizId聚合分片达到totalChunks后再拼装。聚合过程要设置超时窗口防止某个分片永远不来了导致内存泄漏。这套方案的复杂度主要在消费端能不用就不用。6.2 数据库主键数据已经入库时的最小改动方案如果业务数据本来就存在数据库里只是下游需要获取全量记录那最简单的方式就是消息里只发主键ID消费端收到后查库拿数据。这个方案最大的优势是改动量小、实现快。但要注意数据库查询压力如果消费端处理不过来你等于是把所有数据查询压力从业务系统转移到了消费端。最好在消费端加本地缓存或者让业务系统查询接口支持批量主键查询。6.3 四种方案选型对照方案上手难度可承载消息大小性能影响适用场景临时调大配置低建议16MB以内全局影响容易拖累小消息偶尔几条大消息应急压缩后发送低取决于压缩率极小额外CPU开销JSON、文本、日志、Base64引用式投递中理论上不限最小MQ始终轻量图片、音视频、大附件、文件类分片拆分高原则上不限消费端聚合复杂二进制大对象且必须走MQ6.4 我的最终建议碰到“RocketMQ发送消息最大值”这个问题不要第一反应就是改配置。我的处理顺序永远是先量消息体到底多大再看内容能不能压缩压缩后还超就上引用或主键分片放在最后。这样既能快速止血也不会给整个集群留下长期隐患。最后再分享一条个人经验我在团队里定过一条规则业务消息体超过4MB必须走评审设计方案必须说明“为什么MQ要承载这个数据量”。这条规则看起来很粗暴但真的逼着大家去想有没有更合理的设计也因此拦下了好几个拍脑袋塞大对象的方案。消息队列是高速公路上的小货车别把它当成搬家公司的卡车用。
返回列表