Skip to content

分布式时间与时钟:NTP、Deadline、租约、Fencing与时钟回拨

时间在单机程序里像一个普通数值,在分布式系统里却同时承担日志时间、过期判断、超时预算、租约、ID、Token 校验和事件排序。机器之间没有完全同步的时钟,系统时间还可能被校正;如果不区分时间语义,代码即使“多数时候能跑”,也会在时钟回拨、长时间 GC、网络分区或跨地域部署时产生严重错误。

一、学习目标

学完后应能:

  1. 区分墙上时钟、单调时钟、逻辑时钟和业务版本。
  2. 解释 NTP 的 Offset、Delay、Slew、Step 与 Stratum,不把校时理解成完全同步。
  3. 正确计算进程内持续时间和跨服务 Deadline。
  4. 解释 TTL、JWT、数据库时间和 Trace 时间戳的误差边界。
  5. 解释租约过期为什么不能阻止旧持有者继续产生副作用,以及 Fencing Token 怎样补上安全边界。
  6. 处理 Snowflake 时钟回拨、WorkerId 冲突和序列耗尽。
  7. 按证据排查时钟漂移导致的认证失败、ID 冲突、锁失效和调用超时。

二、四种“时间”先分清

时间类型回答的问题Java/工程示例不能做什么
墙上时钟现实世界大约几点System.currentTimeMillis()Instant.now()不能可靠计算进程内经过时长或证明因果
单调时钟从某时刻起经过多久System.nanoTime()不能转成日期,不能跨进程比较
逻辑时钟事件在因果/逻辑上的先后Lamport、Vector Clock、HLC不自动等于真实业务胜负
业务版本某条业务状态的演进顺序version、Epoch、流水号、LSN需要明确分配者和持久化规则
mermaid
flowchart TD
    A["需要处理时间问题"] --> B{"要表达现实日期吗"}
    B -- "是" --> C["墙上时钟并记录时区"]
    B -- "否" --> D{"要计算本进程经过时长吗"}
    D -- "是" --> E["使用单调时钟"]
    D -- "否" --> F{"要判断因果或业务新旧吗"}
    F -- "因果" --> G["逻辑时钟"]
    F -- "业务状态" --> H["业务版本或Fencing Token"]

三、墙上时钟为什么会跳

System.currentTimeMillis() 读取的是系统墙上时间。管理员校时、虚拟机恢复、宿主机问题或时间同步服务都可能让它向前或向后调整。因此下面代码并不适合测量持续时间:

java
long start = System.currentTimeMillis();
doWork();
long elapsed = System.currentTimeMillis() - start;

若中途系统时间向后校正,elapsed 可能变小甚至为负;向前跳会让尚未超时的操作被判定为超时。

墙上时钟适合:

  • 记录审计时间和业务发生日期。
  • 生成面向人的日志时间。
  • 校验需要现实时间窗口的证书、JWT 和业务日界线。

即使这样也必须记录时区。2026-07-18 10:00:00 没有时区就不是完整瞬间;跨地域系统存储瞬时时间通常使用 UTC/Instant,展示时再转换业务时区。

四、单调时钟怎样计算持续时间

Java 的 System.nanoTime() 用于测量同一 JVM 内的经过时长。它的起点没有现实含义,只能做差:

java
long startNanos = System.nanoTime();
doWork();
long elapsedNanos = System.nanoTime() - startNanos;

正确的 Deadline 判断也使用差值或把相对预算转换成同一单调时间域中的截止点。不能把 nanoTime() 写入数据库后让另一台机器比较,因为不同进程的起点没有可比性。

nanoTime() 可能发生整数环绕,但 Java 有符号减法在合理间隔内仍能正确比较;不要直接用 now > deadline 这种无法正确处理环绕的写法,优先使用 deadline - now <= 0 或经过时长差值。

五、NTP 校时原理与边界

客户端与时间服务器交换四个时间戳,可估算网络往返延迟与本机相对偏移。简化表示:

text
delay  = (t4 - t1) - (t3 - t2)
offset = ((t2 - t1) + (t3 - t4)) / 2

这里假设网络往返大致对称,但真实网络可能不对称,所以 Offset 只是估计值。

mermaid
flowchart TD
    A["客户端在t1发送请求"] --> B["服务器在t2收到"]
    B --> C["服务器在t3发送响应"]
    C --> D["客户端在t4收到"]
    D --> E["估算Delay和Offset"]
    E --> F["选择可信时间源并校正本机"]

5.1 Slew 与 Step

  • Slew:轻微调整时钟运行速率,让偏差逐渐收敛,避免明显跳变。
  • Step:直接把时间跳到目标值,适合启动期或偏差过大场景,但运行中的应用会观察到跳跃。

具体行为由操作系统、NTP/Chrony 配置和偏差大小决定,不能假设所有环境只 Slew 或只 Step。

5.2 Stratum 不是精度等级

Stratum 表示距离参考时钟的层级,不等价于“数字越小当前误差一定越小”。生产判断还要看 Offset、Root Dispersion、Jitter、Reachability 和时间源健康。

5.3 NTP 不能提供的保证

  • 不能让所有节点在任意时刻完全同一时间。
  • 不能仅凭时间戳证明两个跨节点事件的因果关系。
  • 不能把租约安全完全交给客户端时钟。
  • 不能替代业务版本、共识日志或 Fencing Token。

六、闰秒、时区和夏令时

UTC 为了与地球自转保持接近可能引入闰秒。不同系统可能采用 Step、Smear 或其他策略,短窗口内跨系统时间表现可能不同。业务代码不应假定每一天严格等于 86400 个 SI 秒来处理法律/业务日期。

需要区分:

  • Instant:时间线上的瞬间。
  • LocalDate:不带时区的日历日期。
  • ZonedDateTime:某时区规则下的日期时间。
  • Duration:基于秒/纳秒的经过时长。
  • Period:按年月日表达的日历周期。

“账单下月同日”和“30×24 小时后”不是同一语义;定时任务跨夏令时地区还可能遇到某个本地时间不存在或出现两次。

七、跨服务 Deadline 为什么优先传播剩余时长

如果 A 把绝对墙上截止时间 12:00:02.000 发给 B,而 B 的时钟比 A 快 500ms,B 会少获得 500ms;若 B 慢,则可能在用户早已放弃后继续工作。

更稳妥的做法是:入口根据本地单调时钟维护总预算,每一跳计算剩余时长并扣除返回余量,协议层传递受限的剩余 Duration。接收方再映射到自己的单调时钟域。

mermaid
flowchart TD
    A["入口获得总预算2000ms"] --> B["本地处理消耗180ms"]
    B --> C["预留响应和清理300ms"]
    C --> D["向下游传播剩余1520ms"]
    D --> E["下游映射到本机单调Deadline"]
    E --> F{"剩余预算是否足够"}
    F -- "足够" --> G["执行并继续向下传播"]
    F -- "不足" --> H["不再发起昂贵操作"]

协议实现仍可能携带绝对 Deadline,但应明确其时钟假设和转换方式。gRPC 等框架通常把 Deadline 转换为剩余 Timeout 传播,以减小时钟偏差影响。

八、租约为什么不是“到期就自动安全”

租约表示某个权利在有限时间内有效。客户端 A 获得 10 秒租约后可能发生 30 秒 Stop-The-World、进程暂停或网络隔离;租约服务端已经把权利授予 B,但 A 恢复后并不知道自己停顿过,仍可能继续写数据库。

mermaid
flowchart TD
    A["客户端A获得租约token=41"] --> B["A发生长GC或网络暂停"]
    B --> C["服务端判定租约到期"]
    C --> D["客户端B获得新租约token=42"]
    D --> E["A恢复并继续写资源"]
    E --> F{"资源端是否检查Fencing Token"}
    F -- "不检查" --> G["旧持有者可能覆盖新持有者"]
    F -- "检查" --> H["拒绝token=41的过期写"]

8.1 Fencing Token

每次成功获得租约时,由可靠裁决点分配单调递增 Token。真正产生副作用的资源端保存已见最大 Token,只接受更大的 Token:

sql
update t_device_command
set payload = ?, fencing_token = ?
where device_id = ?
  and fencing_token < ?;

仅在客户端执行 if (lock.isHeldByCurrentThread()) 不够,因为检查后仍可能暂停;Fencing 必须落到数据库、存储、设备网关等最终副作用资源上。

8.2 服务端时间优于客户端报时

租约是否过期应由租约裁决服务依据自己的时间域判断,客户端只使用服务端返回的 TTL 做保守本地判断。若让每个客户端提交自己的 now,时钟快的客户端可能提前抢占,时钟慢的客户端可能长期认为租约有效。

九、TTL、JWT、证书和缓存的时间边界

9.1 TTL

TTL 表达“从某个裁决点看还剩多久”,但过期数据不一定在那个纳秒被物理删除。例如缓存可能惰性删除或后台扫描;业务读取语义必须由组件命令保证,而不能通过观察磁盘上是否仍有 key 判断。

9.2 JWT

JWT 的 expnbfiat 使用 NumericDate,即 UTC 时间线上从 Epoch 起的秒数。验证节点需要合理的时钟同步,并只允许小而明确的 Clock Skew 容忍。容忍窗口过大会延长已过期 Token 的可用时间,不能为了消除告警无限放宽。

9.3 TLS 证书

证书的 NotBefore/NotAfter 依赖墙上时钟。节点时钟严重漂移会产生“证书尚未生效”或“证书已经过期”,排查 TLS 握手失败时必须同时查看证书区间和两端系统时间。

9.4 数据库时间

多个应用节点分别写 new Date(),同一业务事件的时间可能倒序。需要统一事实时间时,可由数据库生成提交时间、使用业务版本/流水或记录来源节点;但数据库时间也不自动等于事务提交顺序,必须根据数据库具体语义判断。

十、Snowflake ID 与时钟回拨

典型 Snowflake 把 ID 位划分为:相对 Epoch 的毫秒时间、WorkerId 和毫秒内序列。它具有高吞吐、趋势递增和本地生成优势,但依赖三个条件:

  1. 同一时刻 WorkerId 不重复。
  2. 同一 Worker 在同一毫秒内序列不重复。
  3. 时间不能无保护地回到已经使用过的毫秒。
mermaid
flowchart TD
    A["读取当前毫秒"] --> B{"是否小于lastTimestamp"}
    B -- "否" --> C{"是否等于lastTimestamp"}
    C -- "否" --> D["序列归零并生成ID"]
    C -- "是" --> E["序列递增"]
    E --> F{"序列是否溢出"}
    F -- "否" --> D
    F -- "是" --> G["等待下一毫秒或拒绝"]
    B -- "是" --> H["按回拨策略等待、逻辑补偿或拒绝并告警"]

短回拨可以有上限地等待;长回拨应拒绝服务、切换经过证明不会冲突的备用节点空间,或使用持久化逻辑时间。直接把 lastTimestamp 改小继续生成,可能与历史 ID 冲突。Kubernetes 中若用 Pod 序号作为 WorkerId,还要确保 StatefulSet、地域、集群和重建生命周期内全局不重复。

十一、Trace 时间为什么可能出现“子 Span 早于父 Span”

不同服务用各自墙上时钟记录 Span 开始时间,时钟偏差可能让下游 Span 看起来早于上游发送。Trace 后端可以做展示层校正,但不能把校正后的图当作精确物理因果证据。

排查延迟应结合:

  • 每个进程用单调时钟计算的 Span Duration。
  • 客户端与服务端各自的发送/接收事件。
  • 网络耗时和队列等待。
  • 主机 NTP Offset/Jitter。
  • Trace Context 和同一请求的日志。

十二、JDK 8 Demo:单调 Deadline 预算

java
import java.util.concurrent.TimeUnit;

public final class DeadlineBudget {
    private final long deadlineNanos;

    private DeadlineBudget(long timeout, TimeUnit unit) {
        long timeoutNanos = unit.toNanos(timeout);
        this.deadlineNanos = System.nanoTime() + timeoutNanos;
    }

    public static DeadlineBudget after(long timeout, TimeUnit unit) {
        if (timeout < 0) {
            throw new IllegalArgumentException("timeout不能为负数");
        }
        return new DeadlineBudget(timeout, unit);
    }

    public long remaining(TimeUnit unit) {
        long nanos = deadlineNanos - System.nanoTime();
        return unit.convert(Math.max(0L, nanos), TimeUnit.NANOSECONDS);
    }

    public boolean isExpired() {
        return deadlineNanos - System.nanoTime() <= 0L;
    }

    public static void main(String[] args) throws Exception {
        DeadlineBudget budget = DeadlineBudget.after(80, TimeUnit.MILLISECONDS);
        Thread.sleep(30L);
        System.out.println("remainingPositive=" + (budget.remaining(TimeUnit.MILLISECONDS) > 0));
        Thread.sleep(70L);
        System.out.println("expired=" + budget.isExpired());
    }
}

这是单 JVM 持续时间 Demo。跨服务传递时应发送有上限的剩余 Duration,并在接收端重新建立本地单调 Deadline;不要发送 nanoTime() 数值。

十三、商业场景:设备采集主节点租约

设备只能由一个采集节点下发控制命令。节点 A 获得租约和 Token 88,随后长时间 GC;节点 B 获得 Token 89。若设备网关只检查“请求里有锁标记”,A 恢复后仍可能下发旧命令。

生产方案:

  1. 协调服务分配租约和单调 Token。
  2. 每条控制命令携带设备 ID、业务流水和 Token。
  3. 网关为每台设备持久化最大 Token。
  4. Token 小于最大值的命令直接拒绝并记录指标。
  5. 相同业务流水使用唯一键幂等。
  6. 租约续期失败时客户端主动停止,但最终安全仍由资源端 Fencing 保证。

十四、时间异常生产 Runbook

  1. 明确异常依赖墙上时间、经过时长、租约还是业务版本。
  2. 收集受影响节点 UTC 时间、时区、NTP/Chrony 状态、Offset、Jitter 和时间源。
  3. 对比宿主机、容器、数据库、Redis、注册中心和外部接口时间。
  4. 查看异常窗口是否发生 Step、虚拟机恢复、宿主机迁移或长 GC。
  5. ID 冲突时核对 WorkerId、lastTimestamp、序列和回拨日志。
  6. JWT/TLS 失败时核对 Token/证书时间字段和验证节点时间,禁止盲目放宽容忍窗口。
  7. 租约双写时核对服务端授予记录、Token、旧持有者暂停时间和资源端 Fencing。
  8. 调用超时时区分单调持续时间、队列等待和绝对 Deadline 转换。
  9. Trace 时间倒置时比较各主机 Offset,并以单调 Duration 和协议事件重建链路。
  10. 修复后增加时钟偏差告警、回拨演练和拒绝策略测试。

常用 Linux 证据示例:

bash
date -u
timedatectl status
chronyc tracking
chronyc sources -v

不同发行版可能使用 ntpq、Chrony 或云厂商时间服务,应按实际环境取证。

十五、常见错误

错误后果正确方向
currentTimeMillis() 测耗时校时后时长异常同进程使用单调时钟
跨进程比较 nanoTime()数值起点不可比传播剩余 Duration 或业务版本
NTP 已启用就认为无偏差仍存在误差和故障窗口监控 Offset、Jitter、Reachability
租约过期就认为旧客户端停止GC/暂停后仍可能写资源端 Fencing Token
按最大 update_time 合并余额时钟漂移导致资金写丢失账本、单写入点或严格事务
Snowflake 回拨时继续生成可能 ID 重复等待、逻辑时间、拒绝并告警
无限放大 JWT Clock Skew延长过期 Token 有效期修复时钟并设置小而明确窗口
Trace 图时间就是绝对真相跨节点偏差导致错误归因结合单调 Duration 和协议事件

十六、关联知识点