一个定时器从链表里消失,集群停了 10 秒
先说结论:共识的活性不能押在运行时的定时器队列上。
这不是一句口号,是我们被一次”全集群 10 秒真空”逼出来的判断。这篇讲三件事:一次真实的丢表项事故、我们最终选的替代方案(以及为什么不是时间轮)、还有 Windows 那道 15.6ms 的时钟墙。
事故:一台机器造成的 10 秒真空
现象很怪:混跑压测中偶发(约 1-2/60 轮)出现”选举冻结”——三个节点全部停在 Follower,任期号为零,整整 10 秒零选举。或者更怪的三态僵持:一个 Follower、一个 PreCandidate、一个 Candidate 各自卡着。
排查路径是标准的两步:先抓活体线程栈,再上堆转储。答案是堆转储给的,三行字段:
TimerQueueTimer: _next=0, _prev=0 ← 链表指针全是 0:它是孤儿
_everQueued=1, _canceled=0 ← 曾入队、没被取消
_callbacksRunning=0 ← 也没在跑
一个曾经入队、既没取消也没在执行的定时器,链表指针却是空的——它从定时器的链表里被弄丢了。那个 5ms 周期的定时器是共识循环的节拍源:tick 不来,循环就泊死在一次读等待上,整个节点不再参与选举。
有意思的是同案的对照组:测试自己用 Task.Delay 的轮询计时器全程存活,只有那个”高频周期重入队”的定时器丢了。定性很明确——这是运行时定时器队列在高频重挂(re-arm)竞态下的丢表项问题,不是我们的逻辑 bug。
但结论也很明确:这不是我们能修的(在运行时里),而共识的活性不能押在一个可能丢表项的设施上。解药只能在引擎侧。
为什么”自己 await”救不了
第一反应通常是”那我自己写个循环 await 就行了”。没用。
关键点在这里:定时器的回调永远在线程池上执行——丢表项发生在定时器队列内部的链表维护里(高频重挂的竞态窗口),跟你”谁在等待、怎么等待”毫无关系。你把 await 写在哪里,都改变不了你依赖的还是那个队列。
推而广之:时间轮、最小堆、有序链表——不管你用多聪明的数据结构组织自己的定时器,只要每个 tick 最终都要落到运行时提供的那个共享定时器设施上,你就在赌它不丢表项。丢一个表项 = 一次永久停摆,而这类竞态的特征恰恰是”偶发但不可自愈”。
(我们确实认真评估过时间轮:它在”海量定时器 + 大量取消”的场景里很漂亮——增删 O(1)、摊还成本低。但它解决的是自己这一层的数据结构效率,解决不了”跨过运行时共享队列”这一跳的风险;而且在真正高压的并发重挂下,任何基于链表/桶的定时器结构都有同类竞态要处理。所以我们最终没有选它——这一点在下面还会再展开。)
我们的答案:一个拉取模型的 deadline 注册表
方案的核心是一句话:根本不进 TimerQueue,改用内核同步等待。
一个专用线程,用同步阻塞等待(WaitHandle.WaitOne,底层是 futex/条件变量——零定时器队列参与)做节拍;所有需要定时唤醒的地方向它注册一个条目:
public IDisposable Subscribe(Func<long> deadlineTicks, Action wake);
// 节拍线程的等待:(int)Math.Clamp(earliest - now, 1, _capMs)
return ct.WaitHandle.WaitOne(milliseconds);
两个设计决策值得说清楚:
一、注册表不存 deadline 值,只存”算 deadline 的函数”。 条目持有两个东西:一个 Func<long>(deadline 计算委托,由持有者读自己的原子字段)+ 一个唤醒回调。节拍线程每轮醒来重扫全部条目:到期的回调、没到期的取最早值去睡。
loop:
now = 单调时钟
earliest = ∞
foreach entry in 条目快照:
deadline = entry.DeadlineFunc() // 现场计算,不存值
if deadline <= now: 安全唤醒(entry); continue
earliest = min(earliest, deadline)
等待( min(earliest - now, 100ms) ) // 同步阻塞,不进定时器队列
这个”拉取模型”一次消掉了三件复杂度:不需要堆(不用维护有序结构)、不需要重排协议(deadline 改了不用通知)、不需要”提前唤醒”通知(每次都是现场重算)。代价是每轮 O(N) 扫描——但 N 在几千以内时是微秒级,而我们混跑峰值只有几百个条目,差两个数量级。
二、唤醒封顶 100ms。 睡眠最多睡 100ms 就醒一次重扫。为什么封顶:deadline 只会被事件重置,如果一个条目的 deadline 往后推了,节拍线程可能睡在旧目标上——封顶保证”晚醒”有上界;而 100ms 的晚点对 150ms 起的选举窗和 50ms 的心跳完全没有感觉。空闲时唤醒率 ≤10 次/秒,代价可忽略。
配套还有几条工程细节:懒启动(零订阅 = 零线程,接入后峰值一两百个实例从”加一两百个线程”变成”加一条线程”)、回调异常隔离(单条回调抛异常不毁掉节拍线程)、慢回调观测(回调超过 10ms 记警告)、以及一个取证快照接口(节拍器存活、落后多少、每条条目的 deadline 与上次唤醒延迟——正是这些数字在后续排查里救了命)。
拉取模型自己的三条反模式
这套机制不是万灵药,它有三条很隐蔽的反模式——我觉得这是这篇最有价值的部分:
一、deadline 只会被推后 → 节拍器”永远正确地不醒”。 这是最反直觉的一条。我们遇到过一个现场:某个 follower 的条目显示 deadline=622ms、而”上次唤醒延迟”是 15686ms。看起来像节拍器死了,其实它活得很好——那个条目的 deadline 被”到达的心跳”持续往后推,节拍器每次都正确地判定”还没到期”,于是永远不唤醒它。而循环只被这个条目唤醒:心跳到了,认知更新了,但循环还在睡。这是”拉取模型 + 旁路重置”之间的活性缺口。
二、亚毫秒级的批窗,物理上不可达。 我们后来想用同一个节拍器承载”1-5ms 的组提交批窗”——实测证明不行:节拍线程的唤醒延迟下限就是它的睡眠余量。这类亚 tick 精度的需求,最终必须回落到一次性的精确定时器(one-shot timer,每个周期重新挂)。对照测试很干脆:三个候选方案里,折入节拍器的两个都在停顿轮复现问题,只有一次性定时器在所有轮次稳定。
三、活性安全边界要显式划。 上面那条的后果是:那个一次性定时器必须不在共识活性路径上——它停摆或精度偏差,最坏只是退回 50ms 的心跳粒度,不会冻结。这是允许用运行时定时器的唯一条件。
Raft 场景的第二类”不准”:墙钟 vs 观测时间
上面讲的是”定时器不触发”。还有一类更微妙的:定时器触发了,但它读的时间是错的。
我们的选举超时原本按墙钟计时。问题出现在调度饥饿时:节点的共识循环因为 CPU 被抢,长时间没被调度;等它终于跑起来,“检查截止时间”这一看——墙钟早就过了选举超时。于是它误判”leader 失联”,触发选举。
可真相是:节点只是”自己没被调度”,不是”leader 死了”。后果是多节点同时自认为超时:多个 PreCandidate(提交索引为零——从未提交过任何东西)+ 多个陈旧 Leader 持续震荡,集群命令永远推不进。
修法是两条:
- 加”被饿”判定:如果”距离上次循环推进”的间隔已经超过选举窗下界,那这次超时不算数——重置选举计时器,先把积压的心跳处理掉,而不是发起选举;
- 加周期观测订阅:节拍器只在 deadline 到期时投递 tick,所以还要一个固定周期(选举窗下界的一半,夹在 5-100ms 之间)的观测点,让”被饿”判定有观测粒度。
第二条有个很值得一提的陷阱:这个观测订阅不是优化,是语义前提。没有它的时候,“没有被饿”的正常节点也测不出间隔——已有的假时钟测试全部挂掉,正好实锤了这一点。
附带一个工程边界:选举窗不能无限压小。我们的验收档是 150-300ms(明确不放宽),在”运行队列长度超过核数”的重负载下——任何紧凑的选举窗都会失守,这是排队的物理结果,不是参数调优能解决的。
Windows 的 15.6ms 时钟墙
最后一件平台差异,值得单独一节:Windows 默认的定时器分辨率是 15.625ms(64Hz)。
这意味着:Sleep / Delay / 定时器唤醒都会被取整到这个节拍上,系统时钟的步进也是。后果用数字说话(都是实测口径):
- 要求 50ms 的心跳,实际会到 ~62ms;
- 选举窗下界被整体抬高约 5ms;
- 组提交的等待门从”0 或几个微秒”变成 0→15.6ms 的跳变。
Linux 侧是原生高精度(hrtimer,微秒级),没有这个问题。
解法是 Windows 的一个老办法:timeBeginPeriod(1),把进程的定时器分辨率提到 1ms。我们把它做成了装配期的一个开关(默认开),日志里会留一行”分辨率 15.6ms → 1ms”的记录。
但这里有四条纪律,都是踩出来或推演出来的:
- 每次部署要看那行日志——没有它或返回值非零,说明精度没生效,那次部署的所有时序验证数据都不可信;
- “一致”是 1ms 尺度的一致——跨平台对比时,不要把小于 1ms 的差异当回归;
- 它是进程级请求,有共置污染——同一个虚机上跑多个节点时,一个节点拉高精度会改变其他节点的计时环境(新版 Windows 正在往”每进程生效”演进,换镜像代次要重验);
- 测量侧不受影响:延迟直方图用的是高精度计数器(与 15.6ms 无关);受影响的是时序行为面——
TickCount64、DateTime和定时器的触发间隔。分不清这两者,就会把测量方法的问题当成系统问题。
对标 dotNext:三条不同的路线
dotNext 是 .NET 生态里成熟的一致性库(MIT 许可)。我把它的源码拉下来读过一遍——它的选择很明确:不离开定时器设施,而是解决”定时器复用”的安全问题。
它的按调用超时建立在一个可复用的完成源上(ManualResetCompletionSource,池化的 IValueTaskSource 实现),定时器逻辑在它的 Timer 分部里。机制的核心是两个方法:
挂定时器(Arm):已经有实例就重臂(Change),没有实例时——注意这一步——它会先尝试按类型名直接定位运行时内部的计时器类型来创建,失败才回落到公开的创建入口。也就是说,它一上来就承认”公开 API 不够用”。
复用前的安全判定(TryReset):先解除武装,然后读运行时内部的”是否曾经入队”字段,分三种情况:
- 从未入队 → 安全,直接用;
- 触发过,且这次完成就是它自己的超时 → 可以复用(超时回调在入口处就把版本号取走了,因果上早于这次复用,不可能再观察到新的版本);
- 触发过,但这次完成来自别的路径(取消、正常完成)→ 拒绝复用——因为那个回调可能还在队列里、或还在跑,还没读版本号;此时复用会让这个陈旧回调把下一次等待误判成超时。
所以”版本令牌”不是传说:超时回调携带一个版本盒子,复用前必须证明陈旧回调不可能再读到新版本——而判据一半来自运行时内部状态(反射读),一半来自完成原因(是不是我们自己的超时)。
三条路线的对照:
| 路线 | 做法 | 代价 |
|---|---|---|
| 时间轮类结构 | 用自己的数据结构组织海量定时器 | 解决本层效率,但每个 tick 仍要跨运行时共享队列;高频重挂下仍有同类竞态 |
| 留在队列内(dotNext) | 池化完成源 + 重臂协议 + 反射读运行时内部状态判定复用安全 | 复杂度藏在协议与运行时内部细节里(换运行时版本要重新验证);但复用面广——池化定时器服务所有按调用超时 |
| 搬出去(我们的选择) | 专用线程 + 同步等待 + 拉取模型;共识循环也移出线程池 | 隔离度最高;代价是自建一层(O(N) 扫描、100ms 封顶、封顶带来亚毫秒不可达) |
Raft 参数上两家是一致的:选举超时是区间(随机化),RPC 超时取区间上界的一半——心跳远小于选举窗,都是同一条常识。
性能同级:同机交替 3 轮,dotNext 的提交吞吐中位 191k,我们 183k——0.96×,同一水平线。
“谁对谁错”没有意义:它是通用库,要把定时器服务复用给所有按调用超时,留在队列里再用协议兜住更划算;我们是固定拓扑的共识节点,把活性路径彻底搬离共享设施更安全。取舍取决于你把”偶发但不可自愈”的容忍度定在哪里。
收尾:一条可迁移的判据
如果只能留一句给未来的自己:
活性路径上的定时器只有两种合法形态——要么根本不在活性路径上(停摆只是降级),要么完全不依赖运行时定时器队列(自己掌握唤醒)。
“时间轮更快”、“堆更省内存”这类理由,都不构成把活性押上去的理由——因为在这个语境里,丢一个表项就是永久停摆,而性能只是快慢。
本文事实来自 TC.Tier 的定时器设计文档、对标分析、选举时序设计稿、故障取证材料与相关实现代码。
评论