跳转到内容

Aeron® Cluster 快照性能:一个撮合引擎的实战案例

撮合引擎以 Aeron® Cluster ClusteredService 的形式运行时是单线程的:负责执行每条已排序命令的那个 service 线程,同时也要跑 onTakeSnapshot。集群打快照期间,订单簿无法交易,期间到达的订单全都排在后面。 这是你亲手安排的一次尾延迟(p99/p999)事件。衡量它要看两个数字,二者并不相同:

  • 快照耗时(take):service 线程写快照所花的时间,第 1–9 节压的就是它;
  • 客户端停顿:客户端实际要等多久(第 10 节)。快照耗时只是停顿的一部分,不是全部。

本文案例中的引擎,最初连 16 笔左右的挂单都打不出快照。到了真实的 3 节点集群上,按生产环境的持久化级别 (file sync level 2)测,决定客户端停顿的是 Archive 按块执行的 fsync,而不是编码,直到把 Archive 块 从 1 MB 调到 16 MB 为止。

每一行都是 200 万挂单的订单簿,在真实单节点集群上用 G1 测得,Archive 放在 tmpfs 上: 快照 → Archive → 截掉快照之前的 log → 重启 → 恢复。生产环境 ZGC 下的表现和并行编码, 见第 8 节和第 9 节。

阶段快照耗时触发 → recording log¹重启 → LEADER²
原始代码挂单超过约 16 笔即失败——
分块,DataOutputStream 序列化器977 ms(759 序列化 + 218 发布)1,166 ms1,906 ms
ByteBuffer 序列化器417 ms(186–232 + 182–231)~600 ms~1.9 s
SBE、零拷贝、单次遍历366 ms536 ms1.2 s
+ 64 KB 快照通道 MTU332 ms495 ms1.2 s

¹ 从 ClusterTool.snapshot 开始,到 recording log 中出现 SNAPSHOT 条目为止(每 20 ms 轮询一次)。 ² 从在同一目录上启动节点开始,经过 Archive 回放和恢复,到成为 LEADER 为止。

参考引擎(TreeSet 订单簿)的其他实测数据:

  • 堆内存:从 100 万到 1000 万笔实测,G1 下每笔挂单约 201–209 B,生产环境的 ZGC 下约 238–245 B(见第 7 节)。 ZGC 用不了压缩指针,所以要多用约 18%。
  • wire 大小:最终格式下每笔订单约 82 B。
  • 内存分配:无论订单簿多大,一次快照总共只分配 1 KB。

1. 整个订单簿只用一次 claim,上限就是 1,376 字节

Section titled “1. 整个订单簿只用一次 claim,上限就是 1,376 字节”

原来的 onTakeSnapshot 先把整个订单簿序列化成一个 byte[],再拷进第二块同样大的缓冲区,最后用一次 tryClaim 写出去:

final byte[] payload = me.snapshot(); // DataOutputStream over a ByteArrayOutputStream
buf = new UnsafeBuffer(new byte[12 + payload.length]); // second full-size copy
publication.tryClaim(len, claim) // ONE claim for the whole book

长度超过 maxPayloadLength 时,ExclusivePublication.tryClaim 会直接抛异常,而这个值等于 MTU − 32。 集群默认的 service 快照通道是 aeron:ipc?alias=snapshot,IPC MTU 默认 1,408 字节,于是上限是 1,376 字节。每笔订单 85 B,挂单到 16 笔左右快照就会抛异常。唯一一个跑真实 Aeron 的快照测试只用了 8 笔订单,所以一直没人发现。

改用一次 offer() 也只是把天花板挪到 maxMessageLength(term length / 8),离数百万笔订单仍差得很远, 而且恢复时还得加一个分片重组器(fragment assembler)。

2. 先分块,再揪出藏在别处的 O(n²)

Section titled “2. 先分块,再揪出藏在别处的 O(n²)”

第一步先解决正确性:把快照切成一帧帧,每帧都能装进一次 claim,各自带上 {magic, totalLength, offset}, 恢复时再拼回去。同时配套改了三处:

  • 背压:遇到 BACK_PRESSURED 和 ADMIN_ACTION 无限重试,其他结果一律抛异常。原来的有限次重试会 悄悄丢掉一部分快照。
  • 客户端保活:service 容器里的 Aeron 客户端运行在 invoker 模式。快照循环要跑好几秒,如果期间从不 驱动 conductor 的 AgentInvoker,就会错过发给 driver 的保活,最后被判超时。等待时以及每 256 次 claim 都要调用一次;Aeron 自己的 ServiceSnapshotTaker 也是这么做的。
  • 失败要响亮:快照不完整时,现在重启会直接失败。以前遇到无法识别的快照,节点会带着空订单簿启动, 而且不报任何错。

另有两个问题,直到订单簿真正变大才暴露出来:

发现途径问题修正效果
线上集群:50 s 内连 20 万笔订单都灌不完每条命令执行后,service 都会通过 displayedLadder() 发布前 10 档深度,而这个方法会遍历全部挂单,先建出完整的价格阶梯再截断。每条命令都是 O(book),灌满订单簿就成了 O(n²)。遍历到 depth 档就停>50 s(一直没跑完)→ 2.7 s
bench数组实现的引擎里,ArrayBook.add 从头开始扫描,按顺序重建订单簿就成了 O(n²)如果新订单排在末尾之后,直接追加20 万笔时的恢复:45 s → 线性

3. 序列化器:两个糟糕的常数因子,加一个糟糕的复杂度

Section titled “3. 序列化器:两个糟糕的常数因子,加一个糟糕的复杂度”

在 100 万笔订单下用 jstack 采样旧的 snapshot(),发现它的瓶颈既不在 I/O,也不在遍历。时间花在三个地方:

  1. resting.sort(comparingLong(o -> o.orderId)) 对所有订单排序:O(n log n),而且每次比较都会 cache miss。
  2. 每笔订单都调用 bookInstrumentOf(o):拿它去每个订单簿里做 TreeSet.contains,只为了重新查出 这笔订单属于哪个交易对。
  3. DataOutputStream 套在 ByteArrayOutputStream 上:每个字段一次带锁的 write,扩容时成倍增长 并拷贝,最后 toByteArray 还要把整个结果再拷一遍。

修正方法:

  • 用遍历代替排序。按订单簿逐个输出(交易对升序),先买后卖,沿用订单簿本身的价格-时间顺序。输出仍然 是确定的,不用排序,也不用反查。恢复时按同一个比较器重新插入 TreeSet,所以 快照 → 恢复 → 快照 的 字节始终一致。100 万笔订单下(bench),序列化从 6.5 s 降到 491 ms。6.5 s 是在一台争用严重的机器上 测的(8 核上 load average 9–54),只能算上限;491 ms 来自空闲机器。 最干净的空闲机器数据来自线上:200 万笔订单(load average 0.3)下,做完这项修正、尚未换成 ByteBuffer 时,序列化耗时 759 ms,也就是结果表中 977 ms 那一行。
  • 精确预分配。所有字段都是定长,缓冲区可以一开始就分配到准确大小。100 万笔订单下的内存分配从 344 MB 降到 81 MB。
  • 用堆上 ByteBuffer 替换 DataOutputStream。它同样是大端序,所以字节内容和恢复代码都不用改。在 200 万笔订单下单独拆分测量,各部分开销如下:
200 万笔订单耗时
用 DataOutputStream 的 snapshot()~850 ms
只遍历订单簿~110 ms
遍历 + 用 ByteBuffer 写同样的 85 B~170 ms

序列化时间里约 80% 都是流的开销。在线上集群中,序列化从 759 ms 降到 186–232 ms。

4. 照搬 Aeron 自己的模式:Begin / 批次 / End,零拷贝,单次遍历

Section titled “4. 照搬 Aeron 自己的模式:Begin / 批次 / End,零拷贝,单次遍历”

序列化器快了以后,快照在 service 线程上仍然要走两遍:先把订单簿遍历进一个 170 MB 的 byte[],再把它 拷进 125,000 次 claim。Aeron Cluster 自己的 ServiceSnapshotTaker 则是把状态拆成许多带类型的小消息, 前后用 begin 和 end 标记框住,以流的方式写出。引擎照搬了这个结构:

  • sink 接口不依赖 Aeron 或 SBE。SnapshotSink 为每种记录类型提供一个回调,参数全是基本类型,每笔 订单都不会装箱:onHeader、onSourceHighWater、onMark、onResting、onArmedTrigger、onEnd。 每个 matcher 实现 snapshotTo(sink) 和 restorer();后者先重置 matcher,再返回一个用来重建它的 sink。
  • SBE wire 格式。共六种消息:MeSnapshotBegin、SourceHwm、Mark、Orders、Triggers、End。 订单和触发单都用大小恰好填满一次 tryClaim 的 repeating group。每个批次按满额 claim,边遍历边就地 编码;记录类型切换或快照结束时,用 resetCountToIndex() 提前收尾。任何消息都不会超过一次 claim,所以 不会分片,恢复时也不需要重组器。
  • publisher 本身就是 sink。matcher 只遍历一次订单簿,每次 onResting 都通过 SBE flyweight 把订单的 13 个字段直接写进已 claim 的 term buffer 区域。没有快照 byte[],也没有拷贝。用 ThreadMXBean.getThreadAllocatedBytes 在 1 万到 400 万笔订单之间测量,无论订单簿多大,参考引擎一次 快照只分配 1 KB。
  • 严格恢复。loader 直接在回放的 image 上就地解码每条消息,交给 restorer。它要求 Begin 在最前、End 在最后,而且 End 里的计数必须和实际收到的一致。
  • 同一套记录 API。进程内的 snapshot() / restore(byte[]) 改成了基于同一记录流的默认方法,于是 现有的进程内测试和跨引擎差分测试框架,用的正是集群所用的同一套记录 API。

线上 200 万笔订单:快照耗时 417 → 366 ms。重启从 1.9 s 降到 1.2 s,因为恢复时不用再先拼出一个 170 MB 的数组才能解码。

5. 64 KB 快照 MTU:消息数少 50 倍,速度只快约 10%

Section titled “5. 64 KB 快照 MTU:消息数少 50 倍,速度只快约 10%”

maxPayloadLength 等于 MTU − 32,所以在默认 1,408 字节的 MTU 下,一帧 Orders 只能装 16 笔 82 字节的 订单。把 service 快照通道调到 IPC 允许的最大值后,每帧大约能装 800 笔:

ClusteredServiceContainer.Context
context.snapshotChannel("aeron:ipc?alias=snapshot|mtu=65504");

bench,参考引擎,只消费不处理的 reader(drain-only):

订单数快照耗时,默认 MTU(16 笔/消息)快照耗时,64 KB MTU(约 800 笔/消息)消息数wire 字节数
1M218 ms183 ms62,503 → 1,25786.0 → 82.2 MB
2M449 ms402 ms125,003 → 2,510172.0 → 164.3 MB
4M753 ms704 ms250,003 → 5,016344.0 → 328.5 MB

线上 200 万笔订单:快照耗时 366 → 332 ms,从触发到写入 recording log 由 536 ms 降到 495 ms。大 MTU 的录制经 Archive 回放 也完全正确。

更大的 MTU 省下了这些:

  • tryClaim/commit 循环少了 50 倍(读取 position limit、写帧头、有序 commit)。
  • SBE 消息头和 group 头的编码、批次的重新 wrap 也少了 50 倍。
  • 约 4.5% 的字节:帧头,以及未填满批次里的空余。

为什么只快了约 10%:剩下的约 400 ms(bench,G1)是每笔订单不管帧多大都逃不掉的工作:

  • 在 TreeSet 里追指针,遍历 200 万个分别分配的对象:约 110 ms。
  • 每笔订单 13 次字段写入。
  • 新映射的 term buffer 上的首次访问缺页——每次快照都会新建一个 publication。

越过这个下限,单线程就只能靠更扁平的订单簿布局或更紧凑的记录格式,传输层已经没什么可挖了。第 9 节换了条路绕过它: 用多个线程遍历订单簿。

  • 机器被争用。有一次 bench 恰好碰上 8 核机器 load average 348,报出的序列化耗时是每笔 11 µs, 而正常是 0.5 µs。一次长达 353 s 的 CPU 停顿甚至触发了 Aeron 客户端的 ConductorServiceTimeoutException, 把整个运行打断。现在 bench 每次输出报告都会附上 load average,并把 driver/客户端的存活超时设得很长。load 超过 CPU 数时测得的耗时,一律只能当上限看。后来在这台共享机器上, 同样的噪声还掩盖了一个 JVM 参数 60% 的效果(见第 8 节)。
  • 测的是恢复,却当成了快照耗时。第一版零拷贝 bench 报出 200 万笔订单快照耗时 2.25 s,而线上只有 366 ms。原因是它的帧由恢复 loader 消费,每笔订单要做一次约 1.4 µs 的 TreeSet 插入。快照一旦超出 term 窗口,publisher 就被恢复速度背压住了。而在集群里,消费方是只拷贝字节的 Archive recorder。快照耗时 要用只消费不处理的 reader 来测,恢复另开一轮单独测。
  • bench 与单节点线上的差距。bench 比单节点集群慢:200 万笔订单、64 KB MTU 下是 402 ms 对 332 ms。但这两个都不是 客户端看到的数字:单节点那几轮把 Archive 写到了 tmpfs,掩盖了第 10 节在 gp3 上发现的 fsync 瓶颈。bench 用来看扩展 趋势,面向生产的结论用第 10 节的客户端停顿。
  • 样本太少。每个点只取 15 次时,1 万笔的单线程中位数混进了预热噪声,看上去并行编码快 1.6 倍;取 1,200 次后两者 打平(见第 9 节)。
  • GC 选错了。测试 JVM 默认用 G1,生产环境跑的却是 ZGC,而在这段遍历上 ZGC 要慢约 2 倍。第 1–7 节的 数字全是 G1 的数字,详见第 8 节。
  • 参数根本没传进去。-D 参数只有在构建脚本显式转发时,才会传到 Gradle fork 出来的测试 JVM。没转发的 -Dbench.snapshotChannel 悄无声息地跑成了默认通道,shell 循环里一个空的 "" 参数也是同样下场。要看运行 时打印出的配置行,而不是你敲下的命令。

这类陷阱更通用的讨论见诚实解读 Aeron® 基准测试。

7. 扩展性:不同订单簿规模下的堆内存、wire 大小和快照耗时

Section titled “7. 扩展性:不同订单簿规模下的堆内存、wire 大小和快照耗时”

参考引擎。这张表最初是在共享机器上,对 G1 下 20 万–400 万笔订单做的线性拟合;如今除了 10M 时 G1 的快照耗时, 每一格都已实测。

每分片订单数堆内存,ZGC堆内存,G1wire 大小单线程快照耗时,G1单线程快照耗时,ZGC×8 快照耗时,ZGC
1M245 MB209 MB82.2 MB183 ms¹298 ms46.5 ms
5M1,193 MB1,012 MB410.6 MB650 ms1,486 ms179 ms
10M2,382 MB2,006 MB821.3 MB未测2,981 ms361 ms

¹ 共享的 8 vCPU 机器,64 KB MTU(第 5 节)。其余数据都来自空闲的 c7i.4xlarge:快照耗时取中位数(5M 单线程见 第 8 节,其余见第 9 节);堆内存是 5 次强制 GC 之后的已用堆,减去空引擎的部分,-Xmx20g。

  • ZGC 每笔订单要多用约 18% 的堆:238–245 B,G1 是 201–209 B,原因是 ZGC 用不了压缩指针(见第 8 节)。 堆内存包括订单对象、它的 TreeSet 节点、按 id 索引的 HashMap 节点和装箱的 key,以及分摊下来的表槽位。 之前按 G1 推算的 5M 约 1.0 GB、10M 约 2.0 GB 得到了证实。
  • wire 大小与拟合一致。从 100 万到 1000 万笔,一直是每笔约 82 B。
  • G1 拟合推算的快照耗时套不到别处。拟合在共享机器上给出 5M 约 0.9 s;空闲机器上实测 G1 为 650 ms,ZGC 为 1,486 ms。每笔订单的快照耗时也不是常数:ZGC 下从 10 万笔时的约 82 ns,涨到 50 万笔起的约 300 ns(见第 9 节)。
  • 这些都不是客户端停顿。生产环境持久化级别下客户端要等多久,见第 10 节。

8. 换成生产环境的 GC,结论就变了

Section titled “8. 换成生产环境的 GC,结论就变了”

上面的一切都跑在 G1 上,而生产环境的引擎用的是 -XX:+UseZGC。在一台空闲的独占 c7i.4xlarge(Xeon Platinum 8488C,8 核 / 16 vCPU,Corretto 25.0.4,load ≤ 2.9)上重新测量:500 万笔订单的单线程快照,取 15 次交错运行的中位数。

配置ZGCG1
基线1,486 ms650 ms
关闭 Agrona 边界检查(-Dagrona.disable.bounds.checks=true)927 ms(1.60×)439 ms(1.48×)
堆使用透明大页1,492 ms(1.00×)564 ms(1.15×)
4 MB 快照 term(每次快照的缺页 98k → 6k)1,423 ms(1.04×)565 ms(1.15×)
三项全开855 ms(1.74×)377 ms(1.73×)
  • 快照速度卡在内存延迟上。ZGC 下的 JFR profile 显示,约 67% 的时间花在遍历 TreeSet 和读取每笔订单上, 约 27% 花在写入上,claim 和 SBE 的开销小到可以忽略。
  • 缺页不是瓶颈。用 4 MB term 把缺页减少 16 倍,只省了约 4%。
  • 争用的机器掩盖了 60% 的效果。同一个边界检查参数,在共享机器上(load 8)测出 0.96×,在空闲机器上是 1.60×。

为什么在这段遍历上 ZGC 慢约 2.3 倍(JDK 25):

  • ZGC 用不了压缩指针。ZGC 把 GC 元数据放在 64 位引用里(colored pointers,JEP 333),所以 -XX:+UseCompressedOops 会被悄悄改回 false。HotSpot 的 zArguments.cpp 里写的是 “CompressedOops not supported”。
  • 对象变大只能解释一部分。没有压缩指针,对象大小增加约 15%(每个 TreeMap 条目 183 B 对 160 B;按整个订单簿算,每笔订单的堆内存多约 18%,见第 7 节),但 大小只能解释约 15–30% 的差距。对象大小相同时(G1 关掉压缩指针),G1 仍然快约 1.5–1.75 倍。剩下的 差距来自 ZGC 的 load barrier 和/或压缩后的对象布局。那次测试没能把两者分开,而且是在争用的机器上跑的,所以 这个归因只能作参考。
  • 紧凑对象头对遍历没帮助。-XX:+UseCompactObjectHeaders(JDK 25 中的正式参数,ZGC 也支持)每个对象能省 约 16 B,但遍历并没有变快。

9. 并行编码:订单簿已冻结,空闲的核正好用上

Section titled “9. 并行编码:订单簿已冻结,空闲的核正好用上”

service 打快照期间,集群不执行任何命令,所以订单簿是只读的,其他线程可以安全地遍历它。这个设计默认 关闭,通过线程数配置开启:

  1. 分区。matcher 把有序的分区交给 publisher。参考引擎在每一侧的红黑树上按固定深度切开,得到约 1.6 万 笔订单一棵的子树,以及子树之间切割路径上的节点;按中序读出来,恰好就是订单簿的顺序。代价是 O(2^depth) 次节点访问,与订单数无关。数组实现的引擎则直接按下标切分列表。
  2. 编码。固定大小的 daemon 线程池,把每个分区的订单按生成的 SBE 偏移量写成 82 字节的 MeSnapshotOrders group 条目,放进池化的堆外缓冲区。
  3. 拼接。service 线程按顺序等待每个分区完成,再把条目拷进帧里,claim 的方式和单线程 publisher 完全一样。

因此输出与单线程快照逐字节一致。CI 对两种引擎都做逐字节校验,覆盖批次边界上的各种规模和每一种记录类型。 任何一个 worker 失败,这次快照就算失败。

分区器要读 TreeMap 的内部结构,所以需要 --add-opens java.base/java.util=ALL-UNNAMED。没有这个参数时, 引擎只打一次日志,然后继续走单线程:会慢一些,但绝不会出错。

单线程与并行对比,bench(64 KB MTU 的 IPC 快照通道,只消费不处理的 reader),ZGC 生产参数、没有其他调优, 空闲的 c7i.4xlarge。单位 ms,取 15 次(5 次 × 3 轮,交错运行)的中位数,单线程列附观测到的范围。全部 420 次快照都与单线程输出逐字节一致。

订单数单线程×2×4×8最佳
10K1.15 [0.74–1.35]0.740.740.74打平³
100K8.18 [7.7–9.2]4.404.374.381.9×
500K139 [132–147]53.131.425.05.6×
1M298 [295–302]11663.246.56.4×
2M608 [607–613]23212287.57.0×
5M1,490 [1,476–1,493]5852981798.3×
10M2,981 [2,941–2,997]1,1996173618.3×

³ 只取 15 次的中位数看上去是 1.6 倍,但那是预热噪声;下面取 1,200 次的尾延迟测试显示两者打平。

  • 1 万笔时打平,2.5 万笔起并行更快。每个点只取 15 次时,1 万笔下并行看上去快 1.6 倍,但这 15 次的单线程中位数 混进了预热噪声。取 1,200 次后两者打平:p50 单线程 0.66 ms,并行 0.67–0.69 ms。从 10 万笔起,并行在每个百分位上 都更快;2.5 万到 10 万笔之间的情况见下面的交叉点测试。
  • 10 万笔以下,×2 和 ×8 一样快。每个分区约 1.6 万笔,小订单簿只切得出几个分区(少于约 3.2 万笔时每侧一个), 多于 2 个的线程无事可做。这时无论哪种方式都不到 10 ms。
  • 大订单簿收益最大。单线程在 10 万笔时约每笔 82 ns,这时订单簿还装得进 105 MB 的 L3;从 50 万笔起跳到约 300 ns,遍历开始等内存。这正是多个遍历线程同时在途能发挥作用的地方:8 线程做到每笔约 36–46 ns。
  • 两个线程快了约 2.5 倍,而不是 2 倍。worker 直接写原始字节,跳过了 SBE flyweight 的边界检查,而且拼接 和编码是重叠进行的。
  • ZGC 下达到了目标。500 万笔订单在 4 线程(298 ms)和 8 线程(179 ms)下都低于 500 ms 的目标,1000 万笔 在 8 线程下也达标(361 ms)。
  • 这里也是 G1 更快。同一思路的早期原型在两种 GC 上都打开了第 8 节的三项调优,测得在 1–12 线程下 G1 比 ZGC 快约 2–2.7 倍。

机器和参数不变,3 轮 × 400 次 = 每个点 1,200 次快照,全部与单线程输出逐字节一致,load ≤ 2.53。表中是 p50,单位 ms; 加速比为单线程 ÷ ×2。

订单数单线程×2×8加速比(p50)p99,单线程 → ×2
10K0.6610.6530.662打平0.91 → 0.86
25K1.5531.2631.2511.23×1.74 → 1.46
50K3.1102.3542.3441.32×3.77 → 2.68
75K4.7152.8502.8751.65×5.05 → 3.29
100K7.4903.9824.0921.88×8.91 → 4.43

10 万笔以下,绝对节省不到 4 ms。1 万到 2.5 万笔之间没有测。

中位数说明一次普通的快照要花多少时间,p99 说明运气不好的那次要花多少;而对排在快照后面的每笔订单来说,快照停顿都是 一次 p99 事件。机器和参数不变,交错跑 3 轮,每个点的快照次数随订单簿大小缩放:共 22,080 次快照,全部与单线程输出 逐字节一致,load ≤ 4.8。百分位按所有轮次合并后取 nearest-rank。n ≥ 1,000 时才给 p999,n ≥ 100 时才给 p99, 否则记为「—」。单位 ms。

订单数模式np50p99p999max
10K单线程1,2000.660.831.261.29
10K×21,2000.690.900.950.99
10K×81,2000.670.891.401.57
100K单线程1,2007.478.729.6211.8
100K×81,2004.324.895.275.47
500K单线程1,200132140144146
500K×41,20031.035.536.836.8
500K×81,20023.425.927.027.1
1M单线程1,200297304306306
1M×41,20060.167.470.073.2
1M×81,20044.849.853.356.9
2M单线程450611619—622
2M×4450120128—132
2M×845080.0109—115
5M单线程1801,4751,505—1,509
5M×4180298309—310
5M×8180180196—196
10M单线程902,985——3,119
10M×490610——630
10M×890360——382
  • 分布很集中,没有尖峰。50 万笔起,除 2M ×8(1.44)外,每格的 max/p50 都在 1.02–1.3 之间。小订单簿的相对 波动更大,100K 最高 1.90,10K 最高 2.35,但那里每次快照都不到 12 ms。没有 GC 或缺页造成的断崖。
  • 500 万笔低于 500 ms 的目标在尾部同样成立:4 线程 p99 309 ms,8 线程 196 ms;max 分别是 310 ms 和 196 ms。
  • 2M 时 8 线程的尾部比 4 线程略宽(p99/p50 为 1.37 对 1.07)。8 个忙碌的 worker 跑在超线程上,要和 driver、reader 线程共用物理核。

订单簿达到 2.5 万笔挂单或以上、且打快照时有空闲的核,就用它:16 vCPU 的机器用 8 线程, 空闲约 4 个核时用 4 线程。不过在真实集群里, 只有当 Archive 不再是瓶颈时它才有收益(见第 10 节)。2.5 万笔这个阈值,是实测中并行胜出的最小规模;1 万笔时两者打平,1 万到 2.5 万笔之间没有测。 没有空闲核时(worker 会和 Aeron 自己的线程抢 CPU),或者缺少 java.util 的 opens 参数时,就继续用单线程。 它默认关闭,是因为打快照时有没有空闲核,取决于部署环境。代价是内存: 池化缓冲区在两次快照之间会一直占着大约一次快照的订单字节量,500 万笔时约 410 MB。

10. 3 节点集群端到端:停顿由 Archive 的 fsync 决定

Section titled “10. 3 节点集群端到端:停顿由 Archive 的 fsync 决定”

环境。3 个 voter 跑在 c6in.16xlarge 上(64 vCPU,同一个 cluster placement group),另加一台 c6in.4xlarge 作压测机。节点用生产环境的配置:ZGC、DEDICATED 线程模式、FUNCTIONAL profile、file sync level 2,集群目录(Archive、cluster log 和 media driver 目录)放在 XFS 格式化的 gp3 EBS 卷上。

测量方法。压测机先通过真实的集群入口灌入 N 笔挂单,再以固定速率发送探测流:每秒 5,000 笔不会成交的 IOC 订单,每笔返回一条 Release。延迟从计划发送时间算起,所以停顿期间连发都发不出去的探测也会计入停顿(已修正 coordinated omission)。每次启动后在 leader 上用 ClusterTool 触发 3 次快照。客户端停顿指每次触发前后这段 窗口里最差的探测延迟。表中每个场景都发送了 285,000 次探测,无一丢失,每次快照的 stateHash 在 3 个节点上都一致。

下表的范围取自热快照(每次启动后的第 2、3 次):

订单簿编码磁盘(gp3)sync levelArchive 块ME 快照耗时客户端停顿平时客户端 p50
1M×8125 MB/s21 MB(Aeron 默认)241–270 ms457–474 ms4.3 ms
5M单线程125 MB/s21 MB~1.94 s2.43–2.60 s4.3 ms
5M×8125 MB/s21 MB~1.93 s2.45–2.60 s4.3 ms
5M×81,000 MB/s21 MB~1.9 s2.37–2.43 s4.4 ms
5M×81,000 MB/s(driver 目录在 /dev/shm)21 MB~1.9 s2.38–2.43 s4.3 ms
5M×8125 MB/s216 MB~1.95 s2.43–2.58 s4.2 ms
5M×81,000 MB/s216 MB226–256 ms747–847 ms4.3 ms
5M×81,000 MB/s216 MB,代码默认值222–261 ms747–763 ms4.1 ms
5M单线程1,000 MB/s216 MB,代码默认值774–826 ms1.28–1.30 s4.1 ms
5M×81,000 MB/s11 MB~1.9 s2.38–2.42 s3.6 ms
5M单线程1,000 MB/s01 MB544–602 ms940–986 ms0.4 ms
5M×81,000 MB/s01 MB90–108 ms403–411 ms0.3 ms
5M×8125 MB/s016 MB,代码默认值85–149 ms396–401 ms0.3 ms
10M×8125 MB/s21 MB~5.1 s5.9–6.1 s4.8 ms
10M×81,000 MB/s(driver 目录在 /dev/shm)21 MB~3.8–4.0 s4.73–4.77 s4.5 ms
10M×8125 MB/s216 MB~5.0 s6.0 s4.9 ms
10M×81,000 MB/s216 MB464–496 ms1.34–1.36 s4.4 ms
10M×81,000 MB/s216 MB,代码默认值450–533 ms1.33–1.40 s4.2 ms

表中各行先按订单簿大小分组,再按 sync level 和块大小排列,加粗的两行是面向生产的结论。标「代码默认值」的几行没有加 -D 参数,用的是引擎代码里 16 MB 的默认块大小。其中 1,000 MB/s 上的两行 ×8 与之前用参数设成 16 MB 的结果一致;1,000 MB/s 上的单线程一行和 125 MB/s 上的 sync level 0 一行是新做的测试。

  • sync level 大于 0 时,快照受 Archive 按块执行的 fsync 所限。Aeron 的 RecordingWriter.onBlock 每写完一个块 就调用一次 FileChannel.force(),而块大小(aeron.archive.file.io.max.length)默认是 1 MB。快照的录制也是这样 写的:500 万笔订单约 410 MB,就是约 410 次 sync,gp3 上每次约 4.7 ms,加起来约 1.9 s。这就是为什么在 sync level 2 下,单线程和 ×8 一样快,125 MB/s 和 1,000 MB/s 也一样快。sync level 降到 0,bench 里并行编码的收益就在集群里回来了: 快照耗时约 100 ms,单线程约 570 ms。
  • 16 MB 的 Archive 块保留了 sync level 2 的完整持久性,又省掉了大部分 sync,前提是磁盘吃得下这些字节。 在 1,000 MB/s 的 gp3 上,500 万笔的快照耗时从 1.9 s 降到 0.24 s,客户端停顿从 2.4 s 降到 0.8 s;1000 万笔的停顿从约 4.7 s 降到 1.35 s(1000 万笔的这组基线把 driver 目录放在了 /dev/shm 上, 而这在 500 万笔时没有可测出的影响)。而在 gp3 默认的 125 MB/s 基线吞吐上,它毫无作用: 瓶颈转到了磁盘吞吐(实测约 200 MB/s),500 万笔仍要约 2 s。
  • 客户端停顿跟着 Archive 写完走,而不是跟着引擎的快照耗时走。即使在 sync level 0,集群也会在快照耗时之外再加 约 300–500 ms:consensus module 的快照、停止录制和写 recording log。并行编码去不掉这段下限。
  • 同一个 fsync 也决定了日常的延迟和吞吐。客户端 p50 在 sync level 2 下是 4.3 ms,在 level 0 下是 0.3 ms;经 3 节点 共识灌单的速度分别是每秒 6 万笔和 46.4 万笔。
  • media driver 目录放在 EBS 上,和放在 /dev/shm 上相比没有可测出的代价。
  • 每次启动后的第一次快照通常最慢。快照耗时越短,差距越明显:125 MB/s 上 100 万笔订单,第一次 1,158 ms,热快照约 460 ms。而在受 sync level 2 所限的单线程场景里,第一次快照并不比热快照慢,或者只慢一点点。
  • Archive 块的问题解决后,并行编码在生产环境的持久化级别下也有收益。500 万笔订单、sync level 2、16 MB 块、1,000 MB/s 的 gp3:8 线程的快照耗时约 0.24 s,单线程约 0.8 s(3.4 倍);客户端停顿约 0.75 s 对约 1.29 s(1.7 倍)。
  • sync level 0 时,磁盘速度不再处于快照的关键路径上。125 MB/s 的 gp3 上 500 万笔 ×8 让客户端停顿 396–401 ms, 和 1,000 MB/s 时(403–411 ms)一样。Archive 写进的是 page cache,iostat 显示磁盘写入发生在快照结束 7–13 s 之后,也就是内核回写的时候。不过磁盘速度对重启时的回放、长期的写入速率,以及脏页超过 dirty_ratio(约 20% 内存)之后的表现仍然重要。

这次测试给出的建议:

  • 使用 16 MB 的 Archive 块。它已合入引擎主分支,成为 cluster node 和 backup node 代码的默认值, 但还没有哪个环境用它重新部署过;显式指定的 -Daeron.archive.file.io.max.length 仍然优先。 其他场合可以在 Archive context 上设置 archiveCtx.fileIoMaxLength(16 MiB), 或者用 -Daeron.archive.file.io.max.length=16777216。
  • 给 Archive 所在的 gp3 卷配置吞吐。部署用的 Terraform 设了 IOPS(6,000),吞吐却留在 125 MB/s 的基线;测试用的 是 1,000 MB/s。另一个选择是 io2。
  • 等 Archive 不再是瓶颈后,再打开并行编码。在 1,000 MB/s 的 gp3、16 MB 块、sync level 2 下,它让 500 万笔的 客户端停顿缩短了 1.7 倍。挡在 1 MB 块的 fsync 后面时,它没有任何作用。

这次测试还暴露了三个生产风险:

  • 盘口前几档挤满时,深度发布会卡住吞吐。第 2 节的修正让遍历在 10 档后停下,但每条命令执行后,引擎仍要把这 10 档 里面的每笔订单重新走一遍。每档约 500 笔订单时,3 节点集群每秒只能处理约 1 万条命令。修法是按价格档增量维护汇总量。
  • ClusterTool 也需要 Agrona 2.x 要求的 --add-exports java.base/jdk.internal.misc,和部署里的其他 JVM 一样。
  • 集群处理积压期间只 poll、不发消息的客户端会被判超时(session 超时 10 s),必须发送 keepalive。

11. follower 重启:快照只占恢复的一小部分

Section titled “11. follower 重启:快照只占恢复的一小部分”

快照的代价还有另一面:节点重启后,要先把快照加载进来才能重新加入集群。为了不让选举掺进这个数字,测试时 leader 保持不动,只重启 follower。如果是 leader 崩溃,在这一切开始之前还要先等 10 s 的 leader 心跳超时(Aeron 的默认值, 这里没有改),再加一次选举。

环境。沿用第 10 节的集群和生产配置:ZGC、sync level 2、1,000 MB/s 的 gp3、16 MB 的 Archive 块、8 线程并行编码, -Xmx24g,不预先 touch 堆。每次运行只启动一次:+10 s 时打一次快照,然后对 follower 做三轮 kill -9 / 重启(在 +25、+85、+145 s 时杀掉,15 s 后重启,两个 follower 轮流),+215 s 时再打最后一次快照。探测流不变,已修正 coordinated omission,每次探测的延迟都有记录;另有一个 counter 观察程序,为每个重启节点的选举状态变化打上时间戳。 每次运行中,leader 都没有换过,也没有重新选举(它的选举状态始终是 CLOSED),没有丢失任何探测,最后一次快照的 stateHash 在 3 个节点上都一致。

订单簿探测速率启动 → 开始选举节点内快照恢复本地 log 回放追赶到 CLOSED启动 → 重新加入
5M5,000/s4.05–4.20 s2.84–3.01 s31 ms–1.64 s49.8–111 s;第一次重启在 105 s 内一直没结束56–116 s
10M5,000/s7.29–7.58 s5.80–6.10 s41 ms–1.69 s47–61 s;第一次重启在 105 s 内一直没结束56–69 s
5M500/s4.04–4.23 s2.88–3.05 s31–41 ms0.20–0.44 s4.55–4.64 s

5,000/s 下的 5M 那一行合并了两次运行(共六次重启),其余各来自一次运行,每次重启三次。快照之后回放的 log,在 5,000/s 下分别是 33、100 和 167 MB(500/s 下是 3.3、10 和 16.7 MB),对应快照之后 29、89 和 149 s 写入的内容。

  • 快照在恢复中只占约 4 s(5M)和约 7 s(10M)。其中约 0.9 s 是 JVM 和 driver 启动,其余是节点内的快照恢复, 分别为 2.9 s 和 5.9 s(每笔约 0.58 µs,为推算值),与 bench 恢复的结果相符。本地 log 回放很快:约 167 MB 的 log, 1.3–1.7 s 就回放完了。
  • 有流量时,耗时的大头是追赶阶段的收尾,而且问题不在数据上。重启 10–15 s 后,follower 自己的 Archive 录制 位置(rec-pos)已经和 leader 的提交位置只差约 0.1–0.3 MB,这只是采样时差,相当于约 0.1–0.3 s 的流量。可它的 选举状态仍在 FOLLOWER_CATCHUP 里又停了 40–100 多秒;每次 5,000/s 运行中的第一次重启,直到这个节点再次被杀都 没有结束。而在 500/s 下,追赶每次都在 0.5 s 内结束。追赶多久才能收尾,取决于实时流量;Aeron 内部的机制尚未确认。
  • follower 上的 Cluster commit-pos counter 反映的是 leader 的提交位置,是 leader 发给它的,并不是 follower 自己的进度。要看进度,应该看它的 Archive rec-pos。

客户端受到的影响(来自每一次探测,除特别注明外均为 5,000/s):

订单簿时间窗口p50p99max丢失
5M3 个节点都在4.05–4.07 ms6.18–6.23 ms9.6–10.9 ms0
5M一个 follower 宕机(15 s)4.52–4.83 ms7.17–7.79 ms9.5–33.3 ms0
5M重启 → 重新加入(65.9 s)¹4.33 ms6.96 ms15.4 ms0
10M3 个节点都在4.13 ms6.27 ms8.0 ms0
10M一个 follower 宕机(15 s)4.74–4.87 ms7.20–7.47 ms9.8–12.7 ms0
10M重启 → 重新加入(69.2 s)¹4.59 ms7.15 ms14.5 ms0
5M,500/s3 个节点都在 / 一个 follower 宕机4.88 / 5.46–5.48 ms8.90 / 9.56–9.72 ms10.9 / 11.2–11.6 ms0
5M,500/s重启 → 重新加入(4.5–4.6 s)5.41–5.81 ms9.74–10.04 ms12.9–18.0 ms0

¹ 5,000/s 下其余的重新加入窗口里都包含了 +215 s 时的最后一次快照,测到的是第 10 节的快照停顿,而不是重启,所以没有列出。 一直没有结束的第一次重启,窗口没有终点。

一个 follower 宕机时,每次提交都要等剩下的那个 follower:p50 上升约 0.5–0.7 ms,p99 上升约 1 ms。没有丢失任何探测, follower 重启也从未断开过客户端。

  • 恢复没有做优化。在空闲机器上通过生产环境的 loader 测量,ZGC 下单线程恢复 100 万笔用了 520 ms(每笔 0.52 µs), 500 万笔 3.38 s(0.68 µs),1000 万笔 7.55 s(0.76 µs);G1 几乎一样(0.52 / 0.65 / 0.74 µs)。恢复几乎不受 GC 影响, 而每笔订单的开销随订单簿变大而上升:树更深,cache miss 更多。这是从 IPC publication 做的 bench 恢复,不读磁盘, 也不回放 log;在重启的节点里,这一步 500 万笔用了 2.9 s,1000 万笔用了 5.9 s(见第 11 节)。每笔订单要做一次 TreeSet 插入(200 万笔时约 21 次比较),外加一个 RestingOrder 对象、一个树节点、一个 HashMap 节点和一个 装箱的 Long。有两处可能的优化,尚未实测:利用已经有序的输入,一次线性遍历重建每个 TreeSet(通过 SortedSet 来源走 TreeMap.buildFromSorted);以及预先设好 id 映射表的容量。
  • OMS 快照:大状态的 bug 已经修复,但还不是零拷贝。它原来只用一次 offer() 写出,恢复时只 poll 恰好一个 分片,没有重组器,所以状态一旦超过一帧,就会打出 “bad oms payload length” 日志并恢复成空状态;64 KB MTU 也只是把上限从约 1.3 KB 提高到约 64 KB。现在它也做了同样的 Begin / 批次 / End 改造:状态按每块一次 claim 分块写出,loader 从 Begin 读到 End 并核对计数,宁可抛异常,也不会恢复出不完整的状态。旧的单消息快照会被明确 拒绝,而不是尝试解码。剩下的问题是,OMS 的状态镜像在分块之前仍然要先构建成 byte[]。
  • 单线程的下限。单线程快照要想远低于每笔订单 ~0.15 µs(G1)或 ~0.3 µs(ZGC),就需要更扁平的 订单簿(基于数组或堆外内存的价格档位)或紧凑的记录格式。第 9 节的并行编码不靠这两样也做到了。
  • 第 10 节的建议都还没在生产环境运行。16 MB 的 Archive 块(默认开启)和并行编码(不设线程数就不开启,阈值 2.5 万笔) 都已合入引擎的主分支,但还没有任何环境用它们重新部署。为 gp3 配置吞吐也还只是建议:Terraform 没有改动,sync level 仍是 2。
  • file sync level 2 下的单条命令延迟(gp3 上 p50 约 4 ms)是另一个生产问题;提到的方案有 io2、实例存储加复制, 或 group commit。
  • 端到端结果只覆盖一个集群、一种机型,每个场景 3 次快照或 3 次重启,只能给出范围,给不出百分位。
  • 负载下 follower 追赶为什么这么慢,还没有答案。每秒 5,000 次探测时要 1–2 分钟,每秒 500 次时不到 0.5 s(见第 11 节)。 Aeron 追赶阶段内部的机制尚未确认;负载下追赶阶段如何结束,以及 LOW_LATENCY profile 的 idle 策略,是目前的两条线索, 都还没有测试。
  • leader 崩溃后的恢复没有测。它要在第 11 节的一切之上,再加 10 s 的 leader 心跳超时和一次选举(见 调优集群故障切换时间)。在早先一次后来放弃的 leader 宕机测试里, 崩溃关闭了客户端的会话,所以客户端必须能够重连。
  • 停顿本身。要彻底消除集群范围的停顿,仍然得改设计,比如增量快照或在 standby 节点上打快照,调参做不到。 参见 Cluster Standby 与多 AZ 高可用设计。

本站与 Adaptive Financial Consulting Limited 或 Aeron 项目无任何关联,未获其背书或赞助。 Aeron 是 Adaptive Financial Consulting Limited 的注册商标。

Aeron 是 Adaptive Financial Consulting Limited 在英国及其他国家/地区的商标。