返回所有文章
2557 字9 分钟阅读访问统计加载中

客户端与服务器相差 0.65 秒导致任务卡在 99%:按时钟域拆分时间

postmortem

远程节点已经完成计算、上传结果并报告成功,服务端却始终把任务显示在 99%。每次重新对账都会得到同一条时间顺序错误。根因是服务端把自己的接单时间与远程节点的开工、完工时间放进同一条时间线比较;两台机器约 0.65 秒的时钟偏差足以让合法事件看起来因果倒置。

修复后的规则是:远程执行阶段只比较远程节点自身记录的时间,任务状态只使用服务端观测时间,跨机器的事件顺序由递增序号确定。NTP 继续用于减小偏差和监控机器健康,业务正确性不再依赖两台机器的墙上时钟完全一致。

问题详情

故障出现在第一次接入真实远程节点时。任务执行链路本身已经完成:

  • 计算工作正常结束;
  • 生成的 PNG 约 1.38MB;
  • 产物已经上传,字节数和 SHA-256 均与上报值一致;
  • 远程节点发出的最后一条事件为 succeeded;
  • 服务端状态仍停在 99%。

服务端没有丢失终态事件。它在读取终态、组装阶段耗时时拒绝了这组时间:

reconcile failed:
  invalid_argument: phase times must be ordered

故障现场保存了三条关键记录:

事实时间时间来源
服务端创建任务16:24:28.903服务端时钟
远程节点报告接单16:24:28.580节点时钟
远程节点报告开工16:24:28.638节点时钟

按真实因果关系,服务端先创建任务,远程节点随后接单和开工。按未经校正的时间戳排序,接单却比任务创建早 323 毫秒。任务数据没有穿越时间,两个时钟只存在偏差。

终态事件一经保存就不会改变。对账程序随后反复读取同一组时间、触发同一个校验错误,重试无法自行恢复,任务因此稳定停在最后一次进度上。

排查与原因

一条时间线混入了两个时钟

原有逻辑可以简化为:

queuedAt := serverAcceptedAt   // 服务端时钟
startedAt := workerStartedAt   // 远程节点时钟
finishedAt := workerFinishedAt // 远程节点时钟

validate(queuedAt <= startedAt && startedAt <= finishedAt)

startedAt <= finishedAt 有意义,因为两个值来自同一台远程节点。queuedAt <= startedAt 没有可靠含义:两端即使都使用 UTC,UTC 也只统一时间表示方式,不会让物理时钟同步到零误差。

0.65 秒看起来很小,任务交付到开工之间的间隔可能更小。只要节点时钟落后量超过这段间隔,节点报告的开工时间就会早于服务端记录的交付时间,严格顺序校验必然失败。

同一个错误连续出现在四层

修正阶段耗时后,任务又停在派发阶段。继续沿时间字段向上检查,发现四处都默认了跨机器时间可以直接排序:

位置表现处理方式
执行阶段时间终态无法生成,任务卡在 99%接单、开工、完工全部读取节点时间
任务状态投影执行记录成功,任务状态仍无法推进创建、受理、完成全部使用服务端观测时间
数据库约束节点时钟较快时,事件无法写入删除 recorded_at >= occurred_at 跨时钟约束
领域校验写数据库前已经拒绝相同事件删除同义的跨时钟先后校验

occurred_at 表示事件在远程节点上发生的时间,recorded_at 表示服务端收到并保存事件的时间。两列都应保留,但任何一列都不能被强制排在另一列之后。

一项旧补丁曾在服务端时间落后时,把 recorded_at 向前调整到 occurred_at。这能绕过约束,却会污染“服务端什么时候收到事件”这项事实。排障、延迟分析和审计随后都会读取一个来自远程时钟的伪服务端时间。

同机测试隐藏了问题

本地模拟中,执行程序和服务端运行在同一台机器上,所有时间都来自同一个系统时钟。跨时钟比较在这种环境中始终成立,普通成功、失败和重试测试无法暴露问题。

真实远程节点首次把时钟域拆开。网络延迟、NTP 调整和机器启动时间都会让偏差变化,因此“测试时两台机器刚好接近”也不能证明比较安全。

NTP 与 Go 单调时钟不能建立跨机器顺序

NTP 可以估算并修正时钟偏差,但同步仍受网络路径、轮询周期和系统调整影响。把 NTP 当成运行前提可以降低观测误差,把它当成事件顺序证明仍会留下竞态窗口。

Go 的 time.Time 在同一进程内可以携带单调时钟读数,适合计算本进程经过时长。单调读数在序列化、跨进程传输或重新构造时间时会被移除,也无法跨两台机器比较。这项机制可以保护本地耗时计算,不能修复远程时间戳排序。

解决办法

先为每个时间字段标明来源

时间模型需要同时回答“在哪里发生”和“我们何时得知”:

数据时钟域可以安全计算的内容
节点接单、开工、完工同一远程节点节点排队时间、执行时间、阶段顺序
服务端提交、确认接单、确认终态同一服务端系统排队时间、观测延迟、任务状态顺序
occurred_at事件来源节点与同一节点的其他发生时间比较
recorded_at接收服务端与同一服务端的其他接收时间比较
revision来源节点递增序号判断同一任务事件的新旧与因果顺序

字段命名仍可能被误用。代码层可以进一步把节点时间与服务端时间包装成不同类型,让跨域相减无法通过编译;即使暂时共用 time.Time,函数参数和数据结构也应按时钟域分组。

两条时间线分别计算

远程执行阶段只校验:

节点接单时间 <= 节点开工时间 <= 节点完工时间

如果节点的接单事件缺失,就把该时间保留为空。用服务端交付时间补位会再次混入另一个时钟域。

服务端任务状态只校验:

服务端提交时间 <= 服务端确认接单时间 <= 服务端确认终态时间

这样可以分别得到两个可信指标:排队时长由服务端提交到服务端确认接单计算;执行时长由节点开工到节点完工计算。把服务端提交时间减去节点开工时间得到的“总延迟”没有稳定含义。

数据库保存两种时间,删除跨域约束

事件表可以继续同时保存:

occurred_at timestamptz NOT NULL,
recorded_at timestamptz NOT NULL,
revision bigint NOT NULL

需要删除下面这类约束:

CHECK (recorded_at >= occurred_at)

仍应保留非空、合法范围、同源阶段顺序和递增序号校验。删除跨时钟约束不会降低数据质量,它只取消了一条无法由物理时钟保证的假设。

跨机器顺序使用事件序号

每个远程任务维护严格递增的 revision。服务端按任务身份和序号处理事件:

  • 相同事件身份和相同内容视为重复投递;
  • 更大的序号推进状态;
  • 更小的序号视为迟到事件,不允许状态倒退;
  • 相同事件身份携带不同内容视为冲突。

事件序号表达因果顺序,时间戳保留给观测、展示和同源耗时。服务端重启、网络重连和消息重放都不会改变这个判断。

时钟同步转为监控信号

机器仍应运行 NTP,并记录估算偏差。偏差过大可以触发告警、阻止新任务或标记指标可信度下降。任务终态是否可接受、事件是否更新,不应由跨机器时间戳的大小决定。

客户端提供的绝对截止时间属于另一类输入。它表达调用方的合同要求,服务端需要规定时区、精度和允许偏差;不要把它混进内部事件因果顺序。

验证结果

回归测试把远程节点时钟固定为比服务端慢 2 秒:节点在服务端交付记录之前报告接单、开工和完工。修复后,终态快照正常生成,状态为成功,三个阶段时间完整保留节点自身的顺序。撤销修复后,同一测试会复现 phase times must be ordered。

真机复验中,两组时间同时保留下来:

事实时间时钟域
节点接单17:12:52.908远程节点
节点开工17:12:52.954远程节点
节点完工17:12:53.308远程节点
服务端创建执行记录17:12:53.560服务端

远程节点的三个阶段保持有序,服务端时间可以晚于它们显示。系统不再跨列比较后,任务从提交推进到成功,产物可以取回,文件 SHA-256 与接口声明一致。

完整回归还应覆盖节点时钟快于服务端、事件乱序、重复投递、服务端重启后重放,以及接单事件缺失等情况。若业务必须把多个节点的事件排进一条全局时间线,需要引入显式的时钟偏差估计或逻辑时钟协议,不能直接排序原始墙上时间。

参考资料

喜欢这篇文章?

如果这篇文章帮到了你,可以请我喝杯咖啡,支持我继续写下去。

评论