优雅让位耗时调优
计划内的 leader 交接 —— 比如把 leader 收回偏好节点、或者为了维护主动让位 —— 和 leader
意外死亡是两个不同的性能问题。计划内交接不需要检测故障,所以主导意外
failover 的那个 10 秒
leader.heartbeat.timeout 根本不进入预算。剩下的是:默认配置下客户端会感知到约 266 ms
的中断,而五个参数能把它压到约 57 ms —— 一行代码都不用改。
这一页就是用实测回答这个问题:我能调哪些参数,每一个能让客户端感知的交接时间缩短多少?
选举内部的字节级细节 —— 任期、canvass、投票记录、线路上的 end-of-stream 标志位 —— 请看 The Aeron Files。本页从运维视角讲:调什么、有何收益、哪些无效。
这一页所有数字用的是 serviceGapMs:在同一个客户端时钟上,从旧 leader
服务的最后一个回包,到新 leader 的第一个已提交回包,客户端感知到的中断时长。
这个定义很关键,因为它刻意排除了 react 间隔 —— 也就是 RESIGN 开关置上之后、旧 leader 在下一次 slow-tick 轮询里才发现的那约 4 ms。react 期间旧 leader 还在正常服务客户端,所以不算中断。对外要引用中断时间,而不是比它长约 4 ms 的端到端时间。
这些毫秒都花在哪
Section titled “这些毫秒都花在哪”四个角色,共用一个 chrony 对齐的时钟。每个色块是一段实测阶段,色块内部的分段是实测子阶段, 按这几毫秒花在哪上色 —— 烧 CPU、等一个可调定时器、走网络、还是 park 在轮询里。调一个参数,它管的那几段就会跟着动。
值得留意的一点:这里几乎没有 CPU 或线路开销。真正占 CPU 的只有约 2 ms(logBuffers
分配),网络往返也就几趟、每趟不到 2 ms。其余全是定时器和 park
轮询 —— 这正是配置能换来这么多收益的原因,也是它到某个点就走不下去的原因。
可调参数 —— 调一个看看各段怎么变
怎么读这张图的叠加规则: 同一条泳道里的各段跑在该节点共识模块的同一个线程上,是串行的,所以时间要相加;而泳道之间在墙上时钟里是重叠的,全都以 t=0(RESIGN 下发那一刻)为起点。客户端中断从 TX 让位(t≈4 ms,旧 leader 服务的最后一个回包)算到新 leader 的第一个已提交回包,这就是作为准绳的 serviceGap。端到端那个数字还额外算上了 TX 之前那 4 ms 的响应间隔,而客户端在这段时间里还在被正常服务 —— 所以对外要引用中断时间,不是端到端时间。
⚠️ 这里每一个绝对值都带着探针开销。 所有实验都是在 RESIGN_PROBE=true 下测的,也就是共识模块的热路径上挂着 System.out.println 打点。开探针与关探针的对照实验把这部分开销量到了约 9 ms:开探针 68/66/65(均值 66.3),关探针 58/58/56(均值 57.3),两组区间不重叠,而且差值整整落在 learnNewLeaderMs 里。所以多 AZ 的真实下限是 约 57 ms,不是约 65 ms;单 AZ 从来没做过关探针的对照,因此不给它任何关探针数字。各个杠杆不受影响 —— 每一组 A/B 都是开探针对开探针,所以差值依然成立,动的只是绝对下限。
参数不能简单相加 —— 引用任何组合之前先看徽标。 一旦你选的组合从没端到端跑过,各段的值就只能取自单参数 A/B 再加起来,这是推算,徽标里会标出来。有两个实测的遮蔽效应说明为什么相加会高估:setup=20 单独调只省下 −5 ms,远不是按段长收缩推算的 −46 ms(检测慢的时候它被盖住了,非得把 heartbeat.timeout 一起降下来才显出来,这也是为什么这两个参数要一起改);而 heartbeat=5 单独调省 6 ms,叠在 slow.tick=1ms 上却一点用都没有。heartbeat 还停在 100 ms 的那些推算值,最不可信。
# ---- 每个集群节点的 JVM ----aeron.publication.heartbeat.timeout=10000000 # 10ms —— 纳秒整数,注意下面那个解析坑aeron.publication.setup.timeout=10000000 # 10ms —— 纳秒整数aeron.cluster.idle.strategy=noopaeron.conductor.idle.strategy=noopaeron.sender.idle.strategy=noopaeron.receiver.idle.strategy=noopaeron.client.idle.sleep.duration=1ms # 服务容器的 client-conductor,不是 idle.strategyaeron.cluster.slow.tick.interval=1ms # 多 AZ 用这个;单 AZ 用 500us
# ---- 集群客户端的 JVM(内嵌 SHARED 驱动)----aeron.shared.idle.strategy=noop代价:每个节点约 5 个自旋的核,客户端再加 1 个(Aeron Cluster 常规做法);每一个 publication 的稳态 SETUP/心跳流量会略微上升;slow-tick 轮询也稍微密一些。
| 配置 | 多 AZ | 单 AZ |
|---|---|---|
| 默认(全部不改) | 266 ms | 260 ms |
| 推荐配置,含探针开销 | 约 64 ms | 约 51 ms(最好一次 46 ms) |
| 推荐配置,扣掉探针开销 | 约 57 ms ← 这才是真实数字 | 没做过关探针对照 |
五个真有用的参数
Section titled “五个真有用的参数”按收益从大到小排。所有数字都是 AWS 实测(核心绑定、chrony 亚微秒级、每档 3–5 次重复)。
1. aeron.publication.heartbeat.timeout:100 ms → 10 ms
Section titled “1. aeron.publication.heartbeat.timeout:100 ms → 10 ms”266 → 185 ms(调到 20 ms 时,省 81 ms),一直到 10 ms 都近似线性。它打的是检测阶段:约 115 ms → 约 22 ms。
一个已优雅关闭的日志 publication,它的 end-of-stream 标志只会搭下一次驱动心跳发出去,心跳没到,follower 就进不了选举。这是一个媒体驱动的定时器 —— 这也解释了为什么所有集群定时器实测都是平的。它是最大的杠杆,而它压根不在集群配置里。
2. aeron.publication.setup.timeout:100 ms → 10 ms
Section titled “2. aeron.publication.setup.timeout:100 ms → 10 ms”和第 1 个一起调时省 142 ms(两个都设 20 ms → 124 ms)。单独调只省 5 ms。
每次选举都会新建一个全新的日志 publication,各 follower 必须通过 SETUP ↔ StatusMessage 建立它的 Image,而这个握手的重发节奏就由这个定时器决定。关键在于必须成对调:只要检测还要约 115 ms,follower 建冷 image 的那几轮就躲在检测的阴影里跑完了,把它们缩短客户端根本感觉不到。 这两个参数是协同的,不是相加的 —— 永远一起改。
3. 空转策略改成 noop
Section titled “3. 空转策略改成 noop”多 AZ 省 13 ms(105.4 → 92.0,t = −3.19,95% CI [−23.1, −3.7]);单 AZ 省 17 ms(92.2 → 75.2,Mann-Whitney p = 0.016)。
这个参数的收益是弥散的 —— 它不会让任何单独一段塌缩,而是把串行多 agent 选举链条上每一跳那约 1 ms 的 Backoff park 都去掉(JFR 确认改完后热路径上窗口内 park 数为零)。
更值钱的是方差:标准差降到六分之一,从 15.9 降到 2.5 ms。 如果你在写 SLA 而不是追中位数,这才是最该调的参数。
4. aeron.client.idle.sleep.duration:16 ms → 1 ms
Section titled “4. aeron.client.idle.sleep.duration:16 ms → 1 ms”省 16 ms(97 → 81 ms),而且完整落在 leaderInit → awaitServicesReady → awaitImage 上(8.3 ms → 0)。
ClusteredServiceContainer 用默认 context 构建自己的 Aeron client,而那个 context 的
conductor 空转在 SleepingMillisIdleStrategy(16 ms) 上。这里有两个容易漏掉的地方:它是一个
sleep 时长而不是 idle.strategy,而且 aeron.cluster.idle.strategy 管不到它。
5. aeron.cluster.slow.tick.interval:10 ms → 1 ms
Section titled “5. aeron.cluster.slow.tick.interval:10 ms → 1 ms”省 13 ms(82.0 → 68.7,两组区间不重叠),落在 learnNewLeaderMs 上(73.0 → 58.0)。
它卡着 slowTickWork → checkSessions → sendNewLeaderEvent,也就是 NewLeaderEvent
的通知节奏。微妙的地方在于:T0→T1 那段准备工作的停留时间并没有缩短。收益来自送达时机,
不是准备时长 —— 客户端只是早约 15 ms 知道新 leader 是谁。
每个参数到哪就到底了
Section titled “每个参数到哪就到底了”| 参数 | 拐点 | 依据 |
|---|---|---|
heartbeat.timeout | 10 ms | 100 → 20 → 10 → 5 ms 对应检测 115 → 34 → 22 → 16 ms(到 5 ms 还是线性的),但一旦加上 slow.tick=1 ms,hb=5 实测毫无收益(64/65 对 64.7,被遮蔽了)。实用值就是 10 ms。 |
setup.timeout | 10 ms | 再往下就撞到”每轮至少一个 RTT”的地板,SETUP 流量翻倍却只换回不到 1 ms |
slow.tick.interval | 多 AZ 1 ms / 单 AZ 500 µs | 4 个取值 × 2 个环境扫下来:多 AZ 10 ms → 77.0,1 ms → 64.7,500 µs → 60.7(噪声,区间重叠),100 µs → 64.3 ⇒ 多 AZ 到 1 ms 就到底。单 AZ 62.3 / 57.3 / 51.3 / 51.3 ⇒ 一直到 500 µs 还有收益。 |
同 AZ 部署值约 7–15 ms,而且跟你在哪一档有关
Section titled “同 AZ 部署值约 7–15 ms,而且跟你在哪一档有关”这不算参数,但实测存在:把 3 个投票节点和客户端放进同一个 AZ,值 约 7–15 ms,
具体多少取决于其他参数调到哪。多 AZ 减单 AZ 在 slow.tick=10 ms 时是 14.7 ms,到 1 ms 时只剩 7.4 ms。
在默认配置下它看不见(260 对 266 ms),因为那约 250 ms 的固定成本把它盖住了。只有把定时器和 park 的开销剥掉,距离才显出来 —— 这也很好地说明:这些参数的收益排序,离开彼此是谈不清的。
参数不能简单相加 —— 这个坑会让大多数估算失效
Section titled “参数不能简单相加 —— 这个坑会让大多数估算失效”这是整页最重要的运维注意事项,也是上面那张时间轴对没跑过的组合拒绝给数字的原因。
两个实测遮蔽效应:
setup=20单独调只省 5 ms,不是按段长收缩推算的 46 ms —— 检测慢的时候它被盖住了。heartbeat=5单独调省 6 ms,叠在slow.tick=1 ms上却一点都不剩。slow-tick 把通知路径压短之后,检测省下来的时间就传不到客户端中断里了。
你打算上线哪个组合,就去实测哪个组合。 把各参数的收益在表格里加起来,一定会高估,有时能高估 60 ms。
十五个没用的参数 —— 不用再试了
Section titled “十五个没用的参数 —— 不用再试了”全部在 AWS 上做过 A/B,并在 JVM 命令行上确认过参数确实带上了。区间重叠,即:无效。
| 参数 | 试过的范围 | 结果 | 为什么没用 |
|---|---|---|---|
aeron.pending.setups.timeout | 1000 → 50 ms | 82.0 → 76.3,区间重叠 | 这是重试槽位的截止时间;帧级追踪显示第一次 SETUP ↔ SM 总是成功,所以它根本没触发过 |
aeron.cluster.election.timeout | 1 s → 500 ms | 84.0 ≈ 82.0 | 这是故障检测的上限;优雅让位的快路径约 110–130 ms 就走完了,压根碰不到它。调到 200 ms 会直接搞坏交接 —— 别去。 |
aeron.cluster.election.status.interval | 100 → 1 ms | 平的 | 这次投票走的是全票一致的快路径 |
aeron.cluster.leader.heartbeat.interval | 200 → 20 ms | 平的 | 各家的 ack 是靠物理追平送到的,不是等某个 tick 等出来的 |
aeron.cluster.leader.heartbeat.timeout | 10 s | 不在热路径上 | 这是兜底的故障判定时限 —— 它主导意外 failover,在这里完全不相干 |
aeron.rcv.status.message.timeout(节点和客户端都试了) | 200 → 20 ms | 平的 | 它管的是稳态保活 SM 的节奏,不是那个用来触发 setup 的 |
aeron.archive.idle.strategy(连 recorder/replayer 一起) | Backoff → busyspin | 平的 | 见下面两条结构性规律 |
aeron.archive.control.idle.strategy | Backoff → noop | 81 → 79(噪声) | 三阶效应;只在 startLogRecording 上省了约 385 µs |
aeron.client.awaiting.idle.sleep.duration | 1 ms → 0(自旋) | 65.3 → 63.0 | 它只管着约 8 ms 里的大约 1 ms |
aeron.driver.async.executor.threads | 1 → 0(内联) | 64.7 → 64.3 | 结构性规律第 1 条 |
aeron.timer.interval | 1 s → 10 ms | 64.7 → 63.3 | 低负载下 decRef() 走”已读干”的快路径,那个 1 秒定时器根本卡不到 end-of-stream。⚠️ 有负载下的潜在风险,见下面的坑。 |
客户端侧的 aeron.publication.{heartbeat,setup}.timeout | 100 → 20 ms | 反而 +2 ms | 客户端重新建连不是这两个定时器管的那种冷连接 |
aeron.term.buffer.sparse.file / 日志通道的 term-length | true ↔ false / 64m → 1m | 平的 | 缓冲区分配约 1 ms,从来不是瓶颈 |
aeron.cluster.clock = Nanosecond | — | 平的 | — |
aeron.spies.simulate.connection | false → true | 平的 | spy 本来就立刻连上,没什么可提前的 |
先 SUSPEND 再 RESIGN 值得单独说一句:实测白干,甚至略微变差。它是正确性杠杆(能给你一个确定追平了的赢家), 不是延迟杠杆 —— 它只是把那次读取搬进了客户端中断窗口,并没有消掉。
会毁掉你测量结果的几个坑
Section titled “会毁掉你测量结果的几个坑”aeron.publication.{heartbeat,setup}.timeout和aeron.pending.setups.timeout必须写成纯纳秒整数。 它们通过Long.getLong→Long.decode读取,而后者解析不了"10ms"这种后缀,会不报错地退回 100 ms 默认值。曾经有一整轮 JFR 就这么白跑在默认定时器上。集群和空转类属性(slow.tick.interval、election.status.interval、client.idle.sleep.duration)走getDurationInNanos,带后缀没问题。- 要验证参数的效果,不是它有没有传进去。 一个属性可以老老实实躺在 JVM 命令行上却不生效(就是上面那个坑)。用探针或停留时间确认行为真的变了。
System.out.println探针不是免费的 —— 这里就值约 9 ms。 绝对时长要从无锁 tracer、JFR 或客户端的nanoTime取;printlns 只用来判定先后顺序和跨节点对齐纪元。aeron.timer.interval(默认 1 秒)是负载下的潜在风险。decRef()只在 publication 已读干时才置上isEndOfStream;否则 end-of-stream 在onTimeEvent里置,而那里卡着这个 1 秒定时器。1 msg/ms 的压测台永远走”已读干”的快路径 —— 这正是它实测无效的原因。一旦有负载、或让位时还有积压,检测就可能被那 1 秒卡住。 没有在负载下测过;对有负载的部署来说,把它设成 10 ms 是笔便宜的保险。- 参数不能简单相加(见上)。你打算上线哪个组合,就去实测哪个组合。
配置调完之后还剩什么
Section titled “配置调完之后还剩什么”剩下那约 57 ms 是:至少一个网络往返(投票、appendPosition ack、第一次提交)+
每一跳至少一个轮询周期(而且线程本来就在自旋)+串行的跨 agent 跳数+约 2 ms
真正的 CPU(logBuffers 分配,整个 failover 里唯一一块真 CPU)+一些被重叠掉的阶段(最后的法定人数确认是
max(follower 追平, node2 replay+init) − node2 replay+init)。
只有两个改动能压到它以下,都是代码,都是冲着跳数去的,不是冲着定时器:
- 把每一任的日志 publication 预热或复用起来。 现在每次选举都新建全新的 publication(新
sessionId,所以必然是冷的)。去掉这一步,就同时去掉了 follower 建冷 image 的那几轮 SETUP、node2
那约 2 ms 的
logBuffers分配,以及joinLogAsLeader的大部分(tracer 实测约 4.3 ms)。 - 客户端的 ingress 提前预热连上。 在交接之前就对所有成员保持热的 ingress publication,这样
onNewLeader就不需要重新发布(今天这块是约 1.4 ms 的 Aeron 实测工作量,后面还压着约 6.7 ms 的驱动占空周期等待)。
放在一起看比例
Section titled “放在一起看比例”| 事件 | 代价 | 由什么主导 |
|---|---|---|
| 计划内让位,调优后 | 约 57 ms | 省不掉的往返+跳数 |
| 计划内让位,默认配置 | 约 266 ms | publication.heartbeat.timeout(驱动定时器) |
| leader 意外死亡,均衡档 | 约 1.5 s | leader.heartbeat.timeout(集群定时器) |
| leader 意外死亡,默认配置 | 约 11 s | leader.heartbeat.timeout = 10 s |
计划内交接就算一个参数都不调,也比意外死亡便宜约 40 倍,调完是约 190 倍。这个差距就是”把偏好 leader 的回收走优雅路径、而不是把 leader 杀掉”的全部理由 —— 参见Leader 放置与偏好 Leader 控制。
有两点值得记住:
- 两种场景的参数根本不重叠。 调
leader.heartbeat.timeout对计划内交接毫无作用;调publication.heartbeat.timeout对意外死亡毫无作用。这是两个独立的预算,各有各的主导项。 - 回收频率会改变这笔账。 在约 57 ms 这个量级上,把 leader 收回偏好节点便宜到可以当常规操作跑。 266 ms 也不算贵,但在繁忙的热路径上它已是一次能看见的延迟事件 —— 值得挑个时机做,而不是 AZ 健康状态一抖就触发。无论哪种情况,滞回都还是要加的。
延伸阅读:Leader 放置与偏好 Leader 控制(选举里谁会赢,以及怎么触发交接)、 集群 Failover 耗时调优(意外死亡那条预算), 以及客户端与集群通信(新 leader 事件和客户端重连)。
本站与 Adaptive Financial Consulting Limited 或 Aeron 项目无任何关联,未获其背书或赞助。 Aeron 是 Adaptive Financial Consulting Limited 的注册商标。
Aeron 是 Adaptive Financial Consulting Limited 在英国及其他国家/地区的商标。