远程节点已经完成计算、上传结果并报告成功,服务端却始终把任务显示在 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 与接口声明一致。
完整回归还应覆盖节点时钟快于服务端、事件乱序、重复投递、服务端重启后重放,以及接单事件缺失等情况。若业务必须把多个节点的事件排进一条全局时间线,需要引入显式的时钟偏差估计或逻辑时钟协议,不能直接排序原始墙上时间。
参考资料
喜欢这篇文章?
如果这篇文章帮到了你,可以请我喝杯咖啡,支持我继续写下去。
评论