跳转到内容

优雅让位耗时调优

计划内的 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 的端到端时间。

四个角色,共用一个 chrony 对齐的时钟。每个色块是一段实测阶段,色块内部的分段是实测子阶段, 按这几毫秒花在哪上色 —— 烧 CPU、等一个可调定时器、走网络、还是 park 在轮询里。调一个参数,它管的那几段就会跟着动。

值得留意的一点:这里几乎没有 CPU 或线路开销。真正占 CPU 的只有约 2 ms(logBuffers 分配),网络往返也就几趟、每趟不到 2 ms。其余全是定时器和 park 轮询 —— 这正是配置能换来这么多收益的原因,也是它到某个点就走不下去的原因。

预设: 缩放 8 px/ms 响应 4 ms → 客户端中断 81 ms · 端到端 85 ms
node2 新 leader node0 旧 leader(正在让位) WAIT 等待段 node1 follower 客户端 客户端感知到的中断
子阶段类别(这几毫秒花在哪): CPU 占用 可调定时器等待 网络往返 网络单程 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=noop
aeron.conductor.idle.strategy=noop
aeron.sender.idle.strategy=noop
aeron.receiver.idle.strategy=noop
aeron.client.idle.sleep.duration=1ms # 服务容器的 client-conductor,不是 idle.strategy
aeron.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 ms260 ms
推荐配置,含探针开销约 64 ms约 51 ms(最好一次 46 ms)
推荐配置,扣掉探针开销约 57 ms ← 这才是真实数字没做过关探针对照

按收益从大到小排。所有数字都是 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 的那几轮就躲在检测的阴影里跑完了,把它们缩短客户端根本感觉不到。 这两个参数是协同的,不是相加的 —— 永远一起改。

多 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),而且完整落在 leaderInitawaitServicesReadyawaitImage 上(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 是谁。

参数拐点依据
heartbeat.timeout10 ms100 → 20 → 10 → 5 ms 对应检测 115 → 34 → 22 → 16 ms(到 5 ms 还是线性的),但一旦加上 slow.tick=1 ms,hb=5 实测毫无收益(64/65 对 64.7,被遮蔽了)。实用值就是 10 ms。
setup.timeout10 ms再往下就撞到”每轮至少一个 RTT”的地板,SETUP 流量翻倍却只换回不到 1 ms
slow.tick.interval多 AZ 1 ms / 单 AZ 500 µs4 个取值 × 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.timeout1000 → 50 ms82.0 → 76.3,区间重叠这是重试槽位的截止时间;帧级追踪显示第一次 SETUP ↔ SM 总是成功,所以它根本没触发过
aeron.cluster.election.timeout1 s → 500 ms84.0 ≈ 82.0这是故障检测的上限;优雅让位的快路径约 110–130 ms 就走完了,压根碰不到它。调到 200 ms 会直接搞坏交接 —— 别去。
aeron.cluster.election.status.interval100 → 1 ms平的这次投票走的是全票一致的快路径
aeron.cluster.leader.heartbeat.interval200 → 20 ms平的各家的 ack 是靠物理追平送到的,不是等某个 tick 等出来的
aeron.cluster.leader.heartbeat.timeout10 s不在热路径上这是兜底的故障判定时限 —— 它主导意外 failover,在这里完全不相干
aeron.rcv.status.message.timeout(节点客户端都试了)200 → 20 ms平的它管的是稳态保活 SM 的节奏,不是那个用来触发 setup 的
aeron.archive.idle.strategy(连 recorder/replayer 一起)Backoff → busyspin平的见下面两条结构性规律
aeron.archive.control.idle.strategyBackoff → noop81 → 79(噪声)三阶效应;只在 startLogRecording 上省了约 385 µs
aeron.client.awaiting.idle.sleep.duration1 ms → 0(自旋)65.3 → 63.0它只管着约 8 ms 里的大约 1 ms
aeron.driver.async.executor.threads1 → 0(内联)64.7 → 64.3结构性规律第 1 条
aeron.timer.interval1 s → 10 ms64.7 → 63.3低负载下 decRef() 走”已读干”的快路径,那个 1 秒定时器根本卡不到 end-of-stream。⚠️ 有负载下的潜在风险,见下面的坑。
客户端侧的 aeron.publication.{heartbeat,setup}.timeout100 → 20 ms反而 +2 ms客户端重新建连不是这两个定时器管的那种冷连接
aeron.term.buffer.sparse.file / 日志通道的 term-lengthtrue ↔ false / 64m → 1m平的缓冲区分配约 1 ms,从来不是瓶颈
aeron.cluster.clock = Nanosecond平的
aeron.spies.simulate.connectionfalse → true平的spy 本来就立刻连上,没什么可提前的

先 SUSPEND 再 RESIGN 值得单独说一句:实测白干,甚至略微变差。它是正确性杠杆(能给你一个确定追平了的赢家), 不是延迟杠杆 —— 它只是把那次读取搬进了客户端中断窗口,并没有消掉。

  1. aeron.publication.{heartbeat,setup}.timeoutaeron.pending.setups.timeout 必须写成纯纳秒整数。 它们通过 Long.getLongLong.decode 读取,而后者解析不了 "10ms" 这种后缀,会不报错地退回 100 ms 默认值。曾经有一整轮 JFR 就这么白跑在默认定时器上。集群和空转类属性(slow.tick.intervalelection.status.intervalclient.idle.sleep.duration)走 getDurationInNanos,带后缀没问题。
  2. 要验证参数的效果,不是它有没有传进去 一个属性可以老老实实躺在 JVM 命令行上却不生效(就是上面那个坑)。用探针或停留时间确认行为真的变了。
  3. System.out.println 探针不是免费的 —— 这里就值约 9 ms。 绝对时长要从无锁 tracer、JFR 或客户端的 nanoTime 取;printlns 只用来判定先后顺序和跨节点对齐纪元。
  4. aeron.timer.interval(默认 1 秒)是负载下的潜在风险。 decRef() 只在 publication 已读干时才置上 isEndOfStream;否则 end-of-stream 在 onTimeEvent 里置,而那里卡着这个 1 秒定时器。1 msg/ms 的压测台永远走”已读干”的快路径 —— 这正是它实测无效的原因。一旦有负载、或让位时还有积压,检测就可能被那 1 秒卡住。 没有在负载下测过;对有负载的部署来说,把它设成 10 ms 是笔便宜的保险。
  5. 参数不能简单相加(见上)。你打算上线哪个组合,就去实测哪个组合。

剩下那约 57 ms 是:至少一个网络往返(投票、appendPosition ack、第一次提交)+ 每一跳至少一个轮询周期(而且线程本来就在自旋)+串行的跨 agent 跳数+约 2 ms 真正的 CPU(logBuffers 分配,整个 failover 里唯一一块真 CPU)+一些被重叠掉的阶段(最后的法定人数确认是 max(follower 追平, node2 replay+init) − node2 replay+init)。

只有两个改动能压到它以下,都是代码,都是冲着跳数去的,不是冲着定时器:

  1. 把每一任的日志 publication 预热或复用起来。 现在每次选举都新建全新的 publication(新 sessionId,所以必然是冷的)。去掉这一步,就同时去掉了 follower 建冷 image 的那几轮 SETUP、node2 那约 2 ms 的 logBuffers 分配,以及 joinLogAsLeader 的大部分(tracer 实测约 4.3 ms)。
  2. 客户端的 ingress 提前预热连上。 在交接之前就对所有成员保持热的 ingress publication,这样 onNewLeader 就不需要重新发布(今天这块是约 1.4 ms 的 Aeron 实测工作量,后面还压着约 6.7 ms 的驱动占空周期等待)。
事件代价由什么主导
计划内让位,调优后约 57 ms省不掉的往返+跳数
计划内让位,默认配置约 266 mspublication.heartbeat.timeout驱动定时器)
leader 意外死亡,均衡档约 1.5 sleader.heartbeat.timeout集群定时器)
leader 意外死亡,默认配置约 11 sleader.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 事件和客户端重连)。