资讯动态

低延迟消息传输优化:用Aeron和SBE替代Kafka与JSON实现微秒级通信

发布时间:2026/10/19 8:56:25 来源:尧图企业网站定制
1. 从一次真实的性能事故说起大概是一年多前我接手了一个行情转发系统的压测任务。业务场景不复杂上游推送行情快照中间做一轮清洗和增强再广播给下游一堆消费端。当时的架构很常见——Kafka 做消息总线消费端用 JSON 序列化。压测到每秒五万笔左右的时候端到端延迟开始出现明显毛刺从平均 2 毫秒直接飙到 80 毫秒以上甚至偶尔冲破 200 毫秒。这个抖动频率非常糟心。行情数据不像日志晚到 50 毫秒就意味着策略信号已经失真。我当时的思路是先优化 Kafka 的生产者和消费者参数比如拉取批次大小、linger.ms、ack 模式折腾了一周多P99 从 80 毫秒降到了 35 毫秒左右然后就再也压不下去了。每次加吞吐延迟曲线就像一根随时会崩的弦。后来我做了个当时觉得有点极端的决定把 Kafka 换掉消息格式从 JSON 换成二进制直接用 Aeron 做传输、搭配 SBE 做编码。这个组合在圈子里经常被贴上“低延迟专用”的标签但我一直觉得它学习曲线陡、运维复杂不到万不得已不想碰。直到被延迟毛刺逼到没办法我才认真去啃这两个库的实现原理然后把整个方案落地。最终的结果是端到端 P99 从 35 毫秒降到了 700 微秒左右吞吐稳定在每秒 10 万笔以上GC 停顿带来的延迟尖刺也基本消失了。这篇文章不是 Aeron 或 SBE 的官方文档翻译更像是我把这次技术选型、踩坑、调优的过程做一次完整复盘。如果你也在做行情分发、高频交易系统、实时风控或者任何对延迟极度敏感的服务这篇文章应该能给你一个非常具体的参考路径。如果你只是听说过 Aeron 和 SBE 的名字想搞清楚它们到底凭什么快、以及能从它们身上借鉴什么设计思路同样值得往下看。2. 延迟到底去哪了三个容易被忽略的“隐形开销”在说 Aeron 和 SBE 之前我觉得有必要先把问题拆清楚为什么传统的消息队列加 JSON 方案在低延迟场景下会失败你可能会说“因为 Kafka 是磁盘日志、Kafka 的架构重”这当然没错但不完整。我实际压测下来发现延迟的元凶分散在三个完全不同的层面非常隐蔽。第一个层面是序列化和反序列化的开销。使用 JSON 时每条消息都要经过“对象转字符串、Unicode 编码、字节数组拷贝、解析时字符串创建、字段类型转换”这一整套流程。JVM 里这叫分配和装箱压力。一次序列化动辄几微秒看起来单次不贵但在每秒几万条消息的场景下CPU 的 allocate 指令和垃圾回收器会被喂得饱饱的。实际上光 JSON 的序列化开销就能吃掉总延迟的 30% 以上这个比例会随着对象字段变多而继续上升。第二个层面是网络传输的调度模式。Kafka 默认的客户端模型下生产者发送消息、消费者拉取消息每一步都涉及线程切换、缓冲区拷贝、系统调用send/recv。你盯着监控面板一看线程频繁在 RUNNABLE 和 BLOCKED 之间切换CPU 时间片大量耗散在操作系统调度上。说难听一点这不是在“传数据”而是在“搬书”——每过一道手就要把书从书架上拿下来、放到推车上、再放到另一个书架上。第三个层面也是让我真正决定换方案的层面内存拷贝的路径太长。Kafka 生产者和消费者之间数据至少要经过用户态缓冲区、内核态 Socket 缓冲区、网络协议栈、消费端的 Socket 缓冲区、堆内对象、堆外临时缓冲——每一层都在拷贝字节。哪怕 Kafka 本身做了零拷贝比如 sendfile端到端链路里仍然是“几处完全不必要的复制”。理解了这三层开销就能明白 Aeron SBE 解决的核心问题是让消息在传输前后尽可能减少用户态与内核态的切换、减少字节复制次数、减少对象分配。它们提供的不是“神话式的快”而是把每一微秒的开销都抠出来让整个链路变得极度精简。2.1 拆解 Aeron 的“无锁”设计到底指什么Aeron 是一个基于 UDP 的单播和组播消息传输库它的核心模型是多个生产者和多个消费者通过共享内存映射文件进行通信而不是像传统消息队列那样通过 Socket 或者网络代理中转。这里最容易被误解的词是“无锁”。它并不是说完全没有锁而是说它在最关键的数据交换路径上避免使用传统的互斥锁synchronized/ReentrantLock采用无锁队列的 CAS 算法来控制并发。这样写的好处是在多核高并发的场景下线程不会因为等待锁而睡眠也就不会触发上下文切换和线程调度的开销。Aeron 内部有一个环形缓冲区Ring Buffer设计生产者和消费者各自维护独立的游标cursor通过内存屏障和 CAS 原子变量来协调读写位置。这里我提一个被很多技术分享一笔带过但非常关键的点Aeron 的大部分缓冲区通过 Memory-mapped File内存映射文件直接映射到堆外内存避免数据在堆内堆外反复复制。映射文件的方式带来两个好处一是数据写入之后操作系统自己负责刷盘不用 JVM 介入二是消费者可以从图中直接读取字节而不必等待数据“到达”。当然这里说“无锁”不是说完全不做任何加锁而是在高频读写路径上没有锁锁只在资源生命周期管理比如初始化、关闭连接、归档日志切换几个低频场景下才会出现。真正驱动流量的是 CAS 自旋循环非常贴合高并发低竞争的模型。2.2 SBE 不是“另一种序列化框架”而是一种编码哲学如果你搜过 SBE大概率会看到它的全称 Simple Binary Encoding。很多人把它类比成 Protobuf、Thrift 一类的东西这其实是个大误解。Protobuf 走的是“先定义 IDL再生成代码字段用 Varint 编码tag 写在流里”的路线它确实比 JSON 高效但仍然有字段自描述带来的额外信息。SBE 的设计出发点比这更极端它干掉了所有不必要的元数据只保留业务字段本身并按固定偏移量排列。举个例子。一条订单消息包含方向买/卖、价格int64、数量int64、时间戳int64、客户端IDint64。用 JSON 表示你需要写字段名和冒号需要引号用 Protobuf每条消息开头是 tag wire type而用 SBE它的编码表就是一张“每个字段在哪个字节偏移量处、占多少位”的刚性模板。消费者解析时不需要遍历字段不需要判定类型直接用内存偏移量 Unsafe 类读取字节。这种编码方式的解析速度接近“直接读内存里的一段二进制数据”的极限。SBE 的另一个精髓是“不可变消息”设计。在 SBE 的世界里Message 在编码时被声明成 Decoder 和 Encoder 两种类型分别用于只读解析和写入。Decoder 不会为业务字段创建任何对象所有字段都是直接通过访问底层 ByteBuffer 得到Encoder 也不会把数据暂存在临时对象里而是直接写入目标缓冲区。这意味着整个编解码过程几乎不产生 GC 压力。这在 JVM 世界里是非常奢侈的品质——你能想象你的行情系统运行一整天新生代 GC 的次数从每秒几十次降到每分钟一次左右吗SBE 帮我把这件事变成了现实。3. 搭建 Aeron SBE 传输链路的具体步骤到这里我相信你应该对 Aeron 和 SBE 各自的价值有了基本概念接下来是实操环节。我会还原我当时搭建方案时的步骤以及每个环节我为什么这样做。3.1 准备工作依赖引入和网络拓扑Aeron 和 SBE 都是 Java 生态的项目Maven 坐标如下dependency groupIdio.aeron/groupId artifactIdaeron-all/artifactId version1.40.0/version /dependency dependency groupIduk.co.real-logic/groupId artifactIdsbe-all/artifactId version1.29.0/version /dependency网络拓扑方面我选择了双机直连的模式一台发布端、一台消费端中间不经过任何交换机节点因为压测环境就在同一机柜里。链路使用 UDP 单播端口 40123 和 40124 分别作为数据面和控制面。之所以用 UDP 而不是 TCP是因为 Aeron 自己在应用层实现了可靠传输和重传机制UDP 可以避免 TCP 的 Nagle 算法延迟、头部开销以及队头阻塞问题。这也是低延迟领域非常常见的设计取舍。3.2 配置 Aeron 的出版端和订阅端Aeron 的配置入口是Aeron.Context需要指定目录、驱动模式和线程模型。我给出一个最小可用的配置Aeron.Context context new Aeron.Context() .aeronDirectoryName(/dev/shm/aeron) .driverTimeoutNs(10 * 1000 * 1000 * 1000L); Aeron aeron Aeron.connect(context); String channel aeron:udp?endpoint192.168.1.20:40123; Publication publication aeron.addPublication(channel, 1001); // 消费端 Subscription subscription aeron.addSubscription(channel, 1001);这里的1001是 Stream ID相当于逻辑通道号两端必须一致。/dev/shm/aeron是 Linux 下的共享内存目录Aeron 会在其中创建内存映射文件这个目录必须具有足够的空间和权限。另外如果对时延要求更高可以增加mtu参数比如把 MTU 直接配置到 9000 字节巨型帧减少分片数量。3.3 用 SBE 定义消息结构并生成 Java 代码SBE 使用 XML 定义消息 schema官方把它叫作“消息描述符”。我以订单消息为例写一个典型 schema?xml version1.0 encodingUTF-8? sbe:messageSchema xmlns:sbehttp://fixprotocol.io/2016/sbe packagecom.example.sbe id1000 version0 semanticVersion0.1.0 descriptionOrder message schema byteOrderlittleEndian types composite namemessageHeader descriptionMessage header type nameblockLength primitiveTypeuint16/ type nametemplateId primitiveTypeuint16/ type nameschemaId primitiveTypeuint16/ type nameversion primitiveTypeuint16/ /composite type namesideType primitiveTypeuint8 semanticTypeSide/ type namepriceType primitiveTypeint64/ type nameqtyType primitiveTypeuint64/ /types sbe:message nameOrder id1 descriptionOrder entry field nameside id1 typesideType sinceVersion0/ field nameprice id2 typepriceType sinceVersion0/ field nameqty id3 typeqtyType sinceVersion0/ /sbe:message /sbe:messageSchema然后通过 SBE 的代码生成器工具java -Dsbe.target.dirsrc/main/java -Dsbe.schemaorder-schema.xml -jar sbe-all-1.29.0.jar这会生成一组OrderEncoder、OrderDecoder、MessageHeaderEncoder等类。它们的代码量很小、且没有反射非常适合嵌入性能敏感路径。3.4 编写生产者和消费者的完整链路生产者端编码并发布的代码大概是这样的// 获取一个空闲的缓冲区Aeron 的 claim 机制避免额外拷贝 final long result publication.tryClaim(buffer, encodedLength); if (result 0) { DirectBuffer claimedBuffer new UnsafeBuffer(buffer); MemoryBuffer memoryBuffer claimedBuffer.wrap(result, encodedLength); OrderEncoder encoder new OrderEncoder() .wrap((org.agrona.concurrent.UnsafeBuffer) claimedBuffer, headerOffset); headerEncoder .blockLength(OrderEncoder.BLOCK_LENGTH) .templateId(OrderEncoder.TEMPLATE_ID) .schemaId(OrderEncoder.SCHEMA_ID) .version(OrderEncoder.SCHEMA_VERSION); encoder .side((short) 1) .price(345678) .qty(1000); publication.commit(); }消费者端final DirectBuffer buffer fragmentHandler.getBuffer(); final int offset fragmentHandler.getOffset(); MessageHeaderDecoder headerDecoder new MessageHeaderDecoder(); headerDecoder.wrap(buffer, offset); OrderDecoder orderDecoder new OrderDecoder(); orderDecoder.wrap(buffer, offset headerDecoder.encodedLength(), headerDecoder.blockLength(), headerDecoder.version()); int side orderDecoder.side(); long price orderDecoder.price(); long qty orderDecoder.qty();这里有几个重点想跟你强调一下。第一个是tryClaim和commit这两个方法它们是 Aeron 减少内存拷贝的关键所在通过直接“租借”底层缓冲区发布端无需把数据先从堆内数组拷贝到 ByteBuffer再传给 Aeron整条链路少了一次拷贝。第二个是 SBE 的wrap方法它接收缓冲区、偏移量、blockLength、version 四个参数直接完成解码器的绑定整个过程没有任何字符串操作和对象分配。3.5 从 “能通” 到 “稳定低延迟”三个关键调优点这条链路如果只是“能通”那其实远没有发挥出 Aeron SBE 的全部潜能。我实测下来至少有三个调优点直接影响最终的延迟表现。第一个是 CPU 内核绑定affinity。Aeron 的驱动程序和业务线程默认是 JVM 调度的这就会有线程迁移、缓存失效的问题。通过Thread.onSpinWait()结合自旋策略能减少阻塞但最有效的还是把一个业务线程固定到一个物理核心上。Linux 下可以用taskset -c 3 java -jar app.jar实现。如果你希望代码里控制可以用 JNA 或直接调用sched_setaffinity系统调用。我当时把发布线程绑到 2 号核心、Aeron 驱动绑定到 3 号核心、订阅线程绑定到 4 号核心延迟稳定性提升非常明显。第二是调整 Aeron 的空闲策略。默认情况下如果无消息可处理Aeron 驱动会进入 sleep 等待这会带来几十微秒到几百微秒不等的唤醒延迟。在低延迟场景里建议把 idleStrategy 设为BusySpinIdleStrategy也就是忙轮询。代价是 CPU 会持续占用一个核心。别心疼这一点 CPU延迟敏感型服务本来就应该接受这种取舍。Aeron.Context context new Aeron.Context() .idleStrategy(new BusySpinIdleStrategy());第三个是 JVM 的 GC 配置。因为 SBE 和 Aeron 的核心路径几乎不产生堆内对象所以你可以把堆内存设置得足够大以减少 GC 频率甚至可以用-XX:UseZGC或-XX:UseShenandoahGC取决于 JDK 版本。不过在我当时的压测里老年代几乎没有增长新生代每次 GC 在几十毫秒内完成这个频率已经可以接受。如果你想完全避免 GC 干扰可以考虑使用-XX:UnlockExperimentalVMOptions -XX:UseEpsilonGC只分配不回收但前提是你真的能保证没有对象泄漏。4. 压测数据与真实链路的表现纳秒级延迟的现实边界配置全部落地之后我开始了为期两天的压测迭代。我先给出一组真实的数据表它来自同一台服务器的双核直连场景消息负载 64 字节发布频率 10 万条/秒。表Aeron SBE 与 Kafka JSON 的延迟对比单位微秒指标Kafka JSONAeron SBE平均端到端延迟2300450P99 延迟3800680P999 延迟120001250最大延迟860004800吞吐量52000 msg/s105000 msg/sGC 停顿次数/分钟约 7 次0压测期间需要诚实说明的是“纳秒级延迟”这个说法在真实工程里是很容易误导人的。我们说 Aeron SBE 可以达到纳秒级往往指的是编解码器层面的单次解组操作可以达到几十纳秒不是说端到端一条消息从进程 A 到进程 B 的延迟能做到纳秒。端到端延迟受物理链路、网卡中断、驱动调度影响单机上能稳定压到 450 微秒的均值多机同机房大概在 700 微秒到 1.2 毫秒之间已经是相当极限的水平。所以如果谁告诉你他实现了“纳秒级端到端”可以直接判定为脱离物理常识。4.1 高吞吐压力下暴露出的伪共享问题压测到每分钟 30 万条消息时延迟突然出现周期性尖峰P99 从 680 微秒涨到 5 毫秒左右而且这个尖峰非常规律——大约每 64 次消息出现一次。我用 JFRJava Flight Recorder抓了一下 CPU 采样发现热点不在业务代码而在 Aeron 驱动中的游标更新逻辑。这时我明白了问题指向缓存伪共享False Sharing。具体来说Aeron 的环形缓冲区中生产者游标和消费者游标被放到同一个缓存行Cache Line通常是 64 字节里。当生产线程更新生产者游标时这个缓存行被标记为“脏”消费者线程读取自己的消费者游标时发现缓存行失效被迫从主内存重新加载。哪怕消费者游标本身没有被更新这个“一脏即全废”的效应也会让内存访问性能暴跌。Aeron 的官方实现其实已经做了 padding 处理但在某些版本、某些架构上仍可能因为配置问题撞上伪共享。解决方法是显式给游标对象加 padding 字段保证不同线程访问的变量占用不同的缓存行。我当时用了一个非常粗暴但有效的方法把游标对象从默认的 64 字节布局扩展到 128 字节人为分成两个缓存行问题立刻消失。延迟曲线重新变得平滑。这个经历让我非常深刻地体会到一件事高性能方案的调优更多是在“跟硬件讲道理”而不是“跟框架较劲”。4.2 出现重传风暴大面积丢包时的表现在压测结束后第三天我换到另一台机器上做验证测试。这台机器的网卡驱动版本比较老开启了硬件校验和卸载checksum offload但固件有 bug导致偶发丢包。Aeron 的可靠传输机制会触发 NAK 重传这本应是好事但意外地引发了一个“重传风暴”一旦丢包重传请求不是按单个包粒度发送而是整个传输窗口的包都会被请求一次导致带宽被重传数据占满正常的新消息延迟迅速恶化。排查方式是我在 Aeron 驱动日志里看到了大量的 NAK 计数以及Retransmit字样。处理方案有两个第一升级网卡固件治本第二在 Aeron 的 Channel 参数里把确认应答模式和重传间隔调得更保守String channel aeron:udp?endpoint192.168.1.20:40123|term-length4m|rcv-wnd1m;这里term-length是 терминальный缓冲长度rcv-wnd是接收窗口。调低接收窗口虽然会降低吞吐上限但能避免窗口过大导致的重传风暴。我在实际生产环境里最终采用了 2m 的 term-length 和 512k 的 rcv-wnd吞吐依然足够延迟也更稳。5. 生产环境落地会遇到的一些非功能性陷阱技术选型和压测只是一部分真正让人头疼的往往是接入现有系统时暴露出来的工程问题。Aeron SBE 的学习曲线不在 API而在运维意识。下面几个坑我觉得非常值得分享。5.1 日志归档与媒体驱动别把 Aeron 当成纯内存工具很多人以为 Aeron 既然是共享内存传输那么进程重启之后数据就没了。这其实是个误解。Aeron 的 Media Driver 会把每个 Stream 的日志数据写入磁盘上的映射文件即使进程崩溃数据文件还在。这带来的好处是恢复能力但代价是磁盘占用。我见过一个团队在生产环境忘记清理 Aeron 日志目录两周之后磁盘 100% 被打满服务直接瘫痪。解决办法是在部署脚本里加入磁盘空间告警并根据业务需求设置介质驱动日志的清理策略比如保留最近 1 小时的数据超出即清理。与此同时也要注意/dev/shm的文件系统大小。默认的/dev/shm通常只有系统内存的一半如果你的映射文件过大它会直接导致分配失败。我当时用mount -o remount,size4G /dev/shm把它扩到了 4GB。5.2 快速诊断三板斧系统视角、Aeron 视角、消息视角线上运行一旦延迟飙高你至少要有三个层面的快速诊断手段而不能只盯着业务日志。系统视角看 CPU 调度和软中断分布可以用pidstat和/proc/interrupts快速确认是不是有网卡队列绑核不均的问题Aeron 视角看启动日志里的ErrorLog和计数器用aeron-stat命令实时查看消息视角则是看消费端的消息序号是否有空洞、是否有乱序。我最常用的是aeron-stat命令行工具它可以直接打印出 Publications、Subscriptions 和 Driver 的各类计数。比如下面这样java -cp aeron-all-1.40.0.jar io.aeron.samples.AeronStat -d /dev/shm/aeron输出里重点看bytes-sent、bytes-received、nak-count和retransmit-count。如果 nak-count 在快速增长说明网络质量出了问题如果 retransmit 增长而 nak 稳定可能是接收端处理太慢导致缓冲区溢出。5.3 多消费者场景Aeron 自带负载均衡吗这是另一个高频误区。Aeron 的 Subscription 在同一个 Stream 上可以有多个消费者但它默认不是负载均衡而是每个消费者会收到完整的数据流。如果你想让多个消费者分摊负载需要自己在应用层做分区比如按 clientId 哈希而不是指望 Aeron 帮你分发。这点和 Kafka 的 Consumer Group 模型完全不同。我当时设计了一套“按客户端 ID 取模路由到不同 Stream ID”的方案才跑通多消费实例的水平扩展。6. 我踩过的三个坑以及给后来者的优化清单复盘整个项目我想把最有价值的三个坑放在最后讲。它们不是 Aeron 特有的而是在任何高性能系统里都容易出现的问题。第一个坑测试环境太“干净”导致结果虚高。我在实验室双机直连环境里P99 延迟数据非常漂亮一度只有 300 微秒。但部署到生产机柜后P99 直接翻了三倍。原因是生产环境里机器上跑着各种监控 Agent、日志采集器、定时备份任务它们都会抢占 CPU 核心和中断。所以大家在评估方案时一定要用“脏环境”压测且把监控 Agent 的 CPU 占用考虑进去。第二个坑Java 的伪共享感受和 C 一样强烈但更容易被忽视。因为 JVM 是托管运行时的对象在堆里的内存布局不像 C/C 那样直观。SBE 生成的代码本身很干净但你的业务代码如果在一个热门对象的相邻字段上读读写写性能依然会恶化。我后来给团队定了一条规矩核心热路径上的状态对象尽量用long数组而不是多个独立的long字段。第三个坑性能测试的时长太短。只压 10 分钟根本看不出长时间运行后的内存碎片化问题和 socket 缓冲区泄漏。我建议至少压 48 小时并且重点观察 GC 日志、文件句柄数、内存映射文件数量。Aeron 这种基于映射文件的设计进程长跑之后如果出现句柄泄漏问题会非常隐蔽。6.1 给后来者的优化清单按优先级排序正如我前面所说Aeron SBE 的组合不是银弹但它确实是一个值得投入学习的技术栈。如果让我给一个刚接触这个方案的人列一份可执行的优化清单大概是这样的优先级优化项具体操作P0合理使用 BusySpin 空闲策略对时延要求极高时启用接受 CPU 单核占用P0线程 CPU 绑核用taskset或sched_setaffinity把发布、订阅、驱动线程绑到不同物理核P0配置 SBE 的 Flyweight 模式解码禁止在校解码路径上创建对象或使用反射P1调整 MTU 到 9000减少分片数量提升吞吐P1监控 Aeron 日志目录占用配置日志保留策略加入磁盘告警P1使用 ZGC 或 EpsilonGC降低 GC 停顿带来的延迟尖刺P2压测时长不小于 48h暴露长期运行的隐藏问题6.2 这套方案适用的边界在哪里最后想多说一句关于“适用范围”的话题。Aeron SBE 并不是要取代 Kafka 这类消息系统它们解决的问题维度完全不同。如果你追求的是“海量消息堆积、多消费者回溯、生态集成丰富”Kafka 依然是最好的选择之一。但如果你追求的是“极低延迟、极高吞吐、锁竞争最小化”那么 Aeron SBE 是 JVM 生态里几乎是唯一能同时满足这三点的组合。我把这套组合落地到行情系统之后最大的感受不是说“延迟数字变好看了”而是系统的可预测性变强了。之前压测看到的那些毛刺和抖动大部分来自框架本身的架构设计而不是业务逻辑。换到 Aeron SBE 之后延迟曲线的形态变得非常稳定这让我在做容量评估、集群规划的时候能非常放胆地去计算资源配额。如果你正在用 JSON、Protobuf 或者 Kafka 做低延迟服务并且已经触摸到了延迟瓶颈的天花板我强烈建议你花两周时间用真实业务数据做一次 POC跑一次对比压测。你会非常直观地看到序列化格式和传输模型对端到端延迟的影响远大于业务代码本身。希望这篇文章的复盘和经验能让你在评估这个方案的时候省掉一些弯路。

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

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

免费获取报价 →
↑