资讯动态

自研高性能消息队列:从存储设计到15万TPS的压测调优实录

发布时间:2026/10/20 3:31:22 来源:尧图企业网站定制
去年我负责一个内部告警系统时被一个实际问题反复折磨夜间流量高峰期一批批小体积监控消息在几十毫秒内暴涨每条约 150 字节但瞬间能涌进来几万条。现有的开源队列部署重、消费模型笨重延迟动不动就飙到几百毫秒运维上还得伺候一堆依赖。折腾了一轮之后我干脆给自己写了一个轻量级的高性能消息队列最终单机压测稳定在 15 万 TPS 以上P99 延迟控制在 12ms 左右。这篇东西就是想完整复盘一下这个“自研小轮子”的来龙去脉——存储怎么设计、批量协议怎么搞、位移怎么管理以及压测和上线之后踩到的那几个隐蔽坑。如果你也在考虑自建消息通路或者单纯想了解高吞吐队列背后的关键细节这篇文章应该能给你一些可落地的参考。1. 自研消息队列的起因现成方案在特定场景下真的不匹配先说清楚动机因为“自己写消息队列”这种事听起来就像“自己写数据库”一样第一反应都是劝退。我一开始也是这么想的但把需求摆到桌面上之后发现现成方案和我的场景之间有明显的错位。1.1 痛点海量小消息、低延迟、消费端要毫秒级响应我的典型流量是单条消息很小平均 180 字节左右绝大多数不超过 512 字节消息频率高峰值能到每秒几万条消费端是一个告警聚合服务要求消息从生产端发出到消费端拿到延迟尽量控制在几十毫秒以内。这种场景放在开源队列上会撞上几个现实问题。第一小消息在海量并发下网络报文开销占比极高。如果走逐条发送的协议每个 TCP 包的有效载荷可能只有几百字节包头和确认开销反而成了大头万级 TPS 下网卡中断和上下文切换就能把 CPU 吃满。第二很多现成队列的消费模型偏“重”——要么依赖额外的协调组件要么把位移提交、分区均衡、故障恢复做得过于完整结果就是部署成本高且在新环境下调优门槛不低。第三业务上对消息的要求是“尽量不丢、允许少量重复”并不需要强事务和完全精确一次这给实现省了很多麻烦。1.2 目标边界不追求通用只解决“高吞吐、低延迟”这一件事如果一上来就想着对标开源产品那这项目根本没法做。我给这个队列定的目标非常收敛核心只有三条单机即可运行部署时只有一个二进制无外部依赖吞吐目标单分区能支撑每秒 5000 条以上的顺序读写多分区线性扩展整体单机目标 10 万 TPS 以上延迟目标异步刷盘模式下生产到消费的端到端延迟 P99 不超过 50ms语义目标至少一次交付允许重复消息但绝对不能丢消息。我没有打算做一个分布式系统分片、多副本、故障自动转移这些全都砍掉。分布式的高可用和一致性是另一个量级的复杂度硬塞进来只会让核心的存储和网络路径混乱。确定了边界之后技术选型反而变得很简单单机、多分区、顺序写磁盘外加一个高效的网络层。1.3 技术选型Java 与 Netty 的理由选 Java Netty 不是因为 Java 性能最强而是在“性能足够”和“开发效率”之间取了一个平衡点。Netty 处理海量连接的 IO 多路复用很成熟内存池和 ByteBuf 的设计也避免了反复分配对象的开销。关键路径上可以用FileChannel.transferTo()走零拷贝减少一次用户态到内核态的数据复制这在发送大段消息文件时收益非常明显。存储层没有用第三方嵌入式数据库核心数据文件就是自己设计的二进制日志格式外加稀疏索引。这样写路径完全可控顺序 IO 的比例可以做得很高。唯一的外部依赖是 JDK 17部署时复制过去就能跑。提示如果你的消息体平均超过 10KB或者需要复杂查询、消息堆积后按时间回溯那么自研的性价比就会断崖式下降。这个项目成立的隐含前提是“小消息、高并发、短链路”。2. 存储引擎设计日志分段、稀疏索引与磁盘顺序写消息队列最核心的资产不是网络协议是存储。生产端再快如果落盘逻辑一塌糊涂消费端看到的还是一堆乱序、缺失的数据。所以我在设计时把存储引擎当成整个项目的重心前后重构了两次直到写入路径简单到“只剩顺序 append”才满意。2.1 分区与分段文件的布局存储的顶层概念是 topic每个 topic 下面按分区拆分。分区在单机上逻辑上就是一个目录目录下按“段”组织文件。每个段固定大小默认 512MB包含两个文件.log数据文件和.index稀疏索引文件。分段的好处是让清理变得极其简单——删除过期数据就是删掉最老的整个段目录顺手释放磁盘空间避免了在单个超大文件里做碎片整理。文件命名直接用起始偏移量比如00000000000000000000.log表示这个段从偏移 0 开始下一个段可能叫00000000000524288000.log。这样恢复扫描时只要找到最小偏移的段就能从头开始重建全部索引。.log文件里的消息格式是定长头加变长体8 字节消息在分区内的逻辑偏移量4 字节消息体长度4 字节CRC32 校验值N 字节消息体整个设计追求的是“一次顺序写 极少的随机读”。消费时如果给定位移先查索引得到物理位置再在日志里顺序读。索引不是每条消息都记一条而是每 4096 字节记一条用来做粗粒度定位之后在数据文件里顺序扫到准确位置。抽样索引虽然会多读少量数据但节省了大量内存配合 Page Cache 命中代价基本可以忽略。2.2 写入路径的页缓存依赖与刷盘方式搞队列存储一定绕不开操作系统页缓存。我的写入策略并不复杂生产消息先进入进程内的内存缓冲刷盘线程把缓冲里的数据整块写到FileChannel本质是先写进页缓存。由操作系统在后台把脏页刷到磁盘这跟正常文件写入是一样的。这里的关键决策是刷盘的触发条件。我做了三档配置按可靠性要求切换档位触发条件性能表现可靠性异步页缓存刷盘内存缓冲满 512KB 或每 100ms 触发一次最高压测可达 15 万 TPS进程崩溃或断电可能丢最近若干秒数据定时force刷盘每 1s 调用一次FileChannel.force(false)中等约 8 万 TPS崩溃最多丢 1s 数据每条force刷盘每条消息写入后同步force(true)最低不到 3 万 TPS基本不丢元数据也强制写盘大多数内部场景我用第一档。这里有一个从操作系统获取的教训force(false)只刷文件数据不刷元数据但文件长度的变化本身要更新目录项所以如果只调用force(false)且后来发生断电文件大小可能不是最新值。真要追求“少丢”得用force(true)但代价是每次 fsync 的延迟直接打进主链路。2.3 消息顺序保证的粒度与实现约束顺序是消息队列的敏感话题。我的实现里顺序保证限制在“同一个分区内”。也就是说生产端往同一个分区连续发送的多条消息消费端看到时顺序一致。跨分区的消息没有全局顺序承诺这也是几乎所有人对消息队列的默认认知。实现上同一分区的写入由单个线程负责或者说对应一个“写通道”。如果有多个业务线程同时向同一分区发消息它们会先排队竞争分区锁由写线程统一追加到日志。这个串行化的过程看起来是瓶颈其实不是——因为瓶颈从来不在追加内存数据或顺序写日志而在网络解析和用户态锁竞争。只要分区的写线程能把一批消息合并成一次系统调用吞吐立刻就能上一个台阶。注意把多条消息合并成一次 write 调用的前提是这批消息在内存里是连续的。所以消息缓冲必须用“分配一大块连续 ByteBuffer往里塞多条消息”的攒批模式而不是先用链表收集再逐条写文件。我在早期版本吃过这个亏后面会详细说。3. 生产消费链路的核心实现批量发送协议与消费位移管理网络层是消息队列吞吐的第二战场。一开始我走的是“客户端一条一条发服务端一条一条回”的朴素模型结果压测数据惨不忍睹。后来我彻底重构了通信协议和消费端的拉取机制才把性能真正拉开。3.1 批式协议一次 RTT 传一批消息解读“高吞吐”的关键之一是减少网络上的消息数。批式协议的做法是客户端把多条消息攒成一个批次在同一个 TCP 包里发给服务端。服务端解析完再批量写入存储返回的确认也只针对整个批次而不是逐条确认。帧格式是这样设计的4 字节该批次总字节数包含下面的所有字段4 字节批次内消息条数4 字节消息体总长度每条消息4 字节长度 4 字节 CRC 消息体服务端用 Netty 的LengthFieldBasedFrameDecoder做粘包拆包解码出一个完整的 ByteBuf再交给后面的业务线程处理。这个框架解码时有个技巧要指定“初始字节偏移”和“长度字段偏移”不要让 Netty 把整个内容再复制一遍直接复用接收缓冲区里的那一段数据。配合 Netty 的PooledByteBufAllocator大量小消息场景下 GC 压力会小很多。生产端还有一个“攒批”参数一般是攒够 64 条消息或者 4KB 数据再触发一次发送。延迟敏感度高的场景可以把阈值调低到 16 条但批量太小会明显增加网络包数。3.2 消费端选择拉模式用一次拉取换取吞吐消费端我最终选择了“拉模式”而不是由服务端主动推送。推模式理论上延迟更低但问题在于服务端不知道消费者的处理能力流量一上来就容易把消费端打爆而且推模式下需要维护大量长连接上的发送状态服务端内存开销很大。拉模式的协议设计客户端带参数请求一个分区的数据参数包括当前已消费的偏移量、最大拉取条数和最大字节数。服务端从内存索引或者日志文件里定位位置尽量返回“与请求大小最匹配”的一整批数据。为了减少请求次数一次拉取的条数通常设到 500 条或者 1MB 数据哪个先到算哪个。对比推模型拉模式有一个额外好处消费端可以完全掌控自己的节奏。处理慢的消费端拉少一点处理快的消费端拉大一点服务端不用关心客户端消费能力。3.3 消费位移的持久化与滚动提交每个消费组都要记录“当前消费到哪个偏移量”。这块一开始我做得比较简单直接定期把最新位移写在一个独立的小文件里每 5 秒或每消费 1000 条写一次。问题在于消费者往往是在本地内存维护一个“下一条要拉的偏移量”提交动作是异步的。如果消费者进程在提交前崩溃重启后就会从旧的位移继续拉于是出现大量重复消费。后来我改成了“提交日志 快照”的机制。每个消费组在磁盘上维护一个尾部追加的提交日志每次提交只是追加一条“某分区某偏移量”的记录后台再做快照合并。重启时先读快照再回放提交日志能得到最近一次提交的位移。这是我和“重复消费”妥协的结果因为语义本来就是至少一次少量重复可以接受但绝不能把已经消费到后面很远的位移错误地回退成很久以前的位置那会导致大量重复甚至积压。提交日志要定期 truncate否则组多了文件会无限膨胀。3.4 消费组负载均衡简单版的手动 rebalance分布式队列的 rebalance 是个复杂度陷阱我直接绕开了每个消费组可以配置固定数量的消费者实例每个实例认领一部分分区全手工指定。项目初期没有动态均衡的需求这块做自动化反而不利于排查问题。如果某个消费者实例下线它的分区会暂停消费直到管理端手动把分区分配给其他实例。这个设计在单队列规模不大、消费实例稳定时非常省心。如果你的场景是消费者频繁增删那么在自研之前务必想清楚这里可能比存储还费劲。4. 性能从2万到15万TPS的三次优化问题、改法、验证记录这段主要是想说明性能不是一步到位的瓶颈往往在你想不到的地方。第一版只跑了 2 万 TPS最后稳定在 15 万以上中间经历了三次关键优化每一次都有明确的问题定位和验证数据。4.1 第一版瓶颈逐条写入、全局锁和网络小包第一版实现思路很直白Netty 收到消息解析后用一个全局锁保护整个存储写入口每条消息都单独调用一次channel.write。压测结果很快暴露问题——TPS 大约 2.1 万CPU 却先打满了。用jstack看线程状态大部分线程都在锁等待再用perf top看内核热点发现大量页面分配和锁竞争网络收包软中断也占了很高比例。问题总结下来就三条全局锁让所有分区串行存写入吞吐被锁粒度卡死每条消息当作独立事务处理一次网络请求只带来一条消息的写入系统调用次数过高每次文件写入都伴随一次上下文切换。当时的优化思路很明确把“每条消息一次处理”改成“一批消息一次处理”把“全局锁”改成“分区级锁”。4.2 第二次优化分区锁、双缓冲和批量提交改成“分区级锁”之后不同分区的写入可以并行瓶颈立刻松动。紧接着做“双缓冲”每个分区维护两个 ByteBuffer一个叫 active生产线程往里面追加消息另一个叫 ready积累到阈值后交给刷盘线程批量写入文件。active 满了就和 ready 交换指针本质是把“收集消息”和“写磁盘”两个节奏解耦。双缓冲的收益不在于减少写入次数而在于它让写线程可以一次性提交一个大块连续数据刷盘线程也只需要做一次系统调用。实测改完这一版TPS 从 2.1 万跳到 4.8 万左右。但离 10 万目标还差得远接下来瓶颈转移到了网络和消费端。4.3 第三次优化零拷贝发送和消费端拉取节奏继续压测时发现吞吐卡在 8 万附近上不去。这次的问题在消费端消费者一次拉 100 条时服务端每响应一次都要把日志文件里的数据从内核缓冲复制到用户态内存再从用户态复制到 Socket 发送缓冲次数多了 CPU 拷贝开销非常大。解法的核心是用FileChannel.transferTo()做零拷贝如果拉取范围正好落在某个段文件内服务端直接让内核把文件数据发到 Socket不经过用户态。代价是 Netty 不能再直接操作 ByteBuf需要走一个专门的处理逻辑但在“读大段日志”这个场景下收益非常大。同时我把消费端默认拉取条数从 100 调到 500拉取字节上限从 256KB 调到 1MB。这个调整会让单次拉取的处理时间变长但单位时间内的有效吞吐显著上升。三次优化后压测最终成绩稳定在 15.2 万 TPS端到端 P99 延迟约 12ms。5. 踩坑实录压测不能只看峰值这三个坑差点带偏方向性能调优阶段最能迷惑人的不是代码本身而是压测方法。我第一次报出“15 万 TPS”时心里是有数的因为这套数据经过了反复验证中间踩过三个特别典型的坑写出来供你避雷。5.1 压测客户端和服务端不能放在同一台机器第一轮压测我图省事把压测工具、服务端都放在同一台 8 核机器上。结果数据虚高因为客户端和服务端共享 CPU 和页缓存很多“写入成功”的消息其实还留在客户端进程的写入缓冲区里没有真正到达服务端。后来把压测客户端挪到另一台机器同一版本的吞吐数字立刻缩水了 30%。做消息队列压测至少要三台机器一台压测生产者、一台服务端、一台压测消费者。如果只有两台机器也要确保压测客户端和服务端不在同一台物理机上否则所有跟网络和 IO 相关的指标都不可信。5.2 消息体大小分布会影响结论我的业务场景是平均 180 字节的小消息所以整套协议和攒批参数都是围绕小消息调的。但有一次我想看看“大消息表现如何”把消息体改成了 8KB结果吞吐断崖式下跌到 3 万 TPS。原因很简单8KB 消息使得单批次的网络包变大、文件写入变慢攒批阈值也提前触发批量效应被削弱。这提醒我记录压测结果时必须同时注明消息体大小分布、批大小、网络延迟和磁盘型号否则“15 万 TPS”这个数字没有任何可比性。我的最终压测数据都是基于“平均 180 字节、最大 512 字节、客户端自定义生成随机内容”的基准。5.3 消费积压测试比峰值吞吐更能暴露问题单看高吞吐会给人一种虚假的安全感。后来我做了一个“消费积压恢复”测试让消费者暂停 10 秒生产端继续灌数据然后恢复消费观察积压数据多久被追平。这个测试立刻暴露了消费端拉取逻辑里的一个 bug——消费者在积压状态下仍然按固定批次大小拉取导致追赶速度跟不上生产速度积压永远追不平。修复方案是让消费者在发现本批已消费偏移和生产端最新偏移差距大时动态调大拉取批量和提交间隔。测试前要记录初始积压量暂停消费期间持续监控生产端速率恢复后观察积压量的下降曲线而不是只看最终“追平”结果。6. 上线运行半年后修复的三个隐蔽问题存储和协议都稳定之后工作重心转向了上线后的真实运行。半年里修复了三个隐蔽问题每一个都值得拿出来单独复盘因为它们都不是“代码写错”而是设计假设在极端情况下不成立。6.1 崩溃恢复时索引重建导致的消息重复消费系统运行几周后有一次机房意外断电重启后消费端出现了大批重复消息量大到直接触发告警。排查后定位到索引重建逻辑消息是先写进.log文件然后才更新.index索引。正常关闭时索引是完整的但断电时最后一批写入的索引更新可能还没落盘重启扫描重建索引时会把日志尾部那些“其实已经被消费者拉走且提交位移”的数据重新扫进来。根因是“索引文件”和“位移提交文件”的持久化顺序不一致。我当时的修复很直接重建索引时同时读取消费位移快照只重建“所有消费组已提交位移之前”的数据并在加载完成后把每个分区消费起点对齐到“最早的未提交位移”。这套逻辑上线之后再也没有出现过断电后大规模重复消费。提示只要你的系统是“至少一次”语义重复消费不可能完全消除但重复的范围可以被压缩到“单次崩溃窗口内”。如果你的设计是“精确一次”那必须引入类似事务日志的机制复杂度会显著上升。6.2 空闲连接被误判为超时引发的消费中断有一段时间消费者会随机“卡住”过几分钟自己恢复。查日志发现服务端设了一个 30 秒空闲连接清理机制如果一个连接 30 秒没有任何读写会被当作死连接关闭。但拉模式消费端在业务低峰期可能超过 30 秒没有请求连接就被服务端主动断开。消费者端当时没有做自动重连导致消费中断。修复分两层服务端把“空闲”的定义从“无任何 IO”改成“无数据且超过 120 秒无心跳”消费端增加自动重连和退避逻辑。这个坑说明连接管理策略必须和消费模型匹配推模型能用的超时参数直接搬到拉模型上就会出问题。6.3 单分区顺序约束下的写入优化误区最后一个是关于“顺序到底怎么保证”的反复。有段时间我收到反馈说某个分区在 5000 条消息内出现了乱序。代码排查后发现是我为了让该分区写入更快提前做了“并行追加”的优化多个线程各自往不同的内存缓冲写再异步合并到日志。这确实提升了吞吐但也破坏了分区顺序因为合并时消息之间的相对顺序变得不确定。最终我狠心把并行追加的逻辑全部移除恢复到“一个分区一个写线程”的老方案。提升吞吐靠的是增加分区数而不是破坏分区内顺序。这是消息队列里最容易走偏的地方性能优化不能以牺牲语义为代价否则后面一定会在某个时刻付出十倍代价来修。写到这里基本上把实现高性能消息队列的关键路径都过了一遍。如果只让我给出一条经验那就是存储层面尽量简单顺序写很好用不要轻易引入随机 IO网络层面一切靠批量设计协议时优先考虑一次调用能扛多少有效数据消费模型用拉模式把节奏控制权留在消费端。至于压测别迷信峰值多跑积压恢复和断电恢复场景那些才是暴露设计缺陷的时机。这个队列目前还在继续跑后续我打算把多副本同步和自动 rebalance 补上那是另一个复杂的故事了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑