分布式时间与时钟:NTP、Deadline、租约、Fencing与时钟回拨
时间在单机程序里像一个普通数值,在分布式系统里却同时承担日志时间、过期判断、超时预算、租约、ID、Token 校验和事件排序。机器之间没有完全同步的时钟,系统时间还可能被校正;如果不区分时间语义,代码即使“多数时候能跑”,也会在时钟回拨、长时间 GC、网络分区或跨地域部署时产生严重错误。
一、学习目标
学完后应能:
- 区分墙上时钟、单调时钟、逻辑时钟和业务版本。
- 解释 NTP 的 Offset、Delay、Slew、Step 与 Stratum,不把校时理解成完全同步。
- 正确计算进程内持续时间和跨服务 Deadline。
- 解释 TTL、JWT、数据库时间和 Trace 时间戳的误差边界。
- 解释租约过期为什么不能阻止旧持有者继续产生副作用,以及 Fencing Token 怎样补上安全边界。
- 处理 Snowflake 时钟回拨、WorkerId 冲突和序列耗尽。
- 按证据排查时钟漂移导致的认证失败、ID 冲突、锁失效和调用超时。
二、四种“时间”先分清
| 时间类型 | 回答的问题 | Java/工程示例 | 不能做什么 |
|---|---|---|---|
| 墙上时钟 | 现实世界大约几点 | System.currentTimeMillis()、Instant.now() | 不能可靠计算进程内经过时长或证明因果 |
| 单调时钟 | 从某时刻起经过多久 | System.nanoTime() | 不能转成日期,不能跨进程比较 |
| 逻辑时钟 | 事件在因果/逻辑上的先后 | Lamport、Vector Clock、HLC | 不自动等于真实业务胜负 |
| 业务版本 | 某条业务状态的演进顺序 | version、Epoch、流水号、LSN | 需要明确分配者和持久化规则 |
flowchart TD
A["需要处理时间问题"] --> B{"要表达现实日期吗"}
B -- "是" --> C["墙上时钟并记录时区"]
B -- "否" --> D{"要计算本进程经过时长吗"}
D -- "是" --> E["使用单调时钟"]
D -- "否" --> F{"要判断因果或业务新旧吗"}
F -- "因果" --> G["逻辑时钟"]
F -- "业务状态" --> H["业务版本或Fencing Token"]三、墙上时钟为什么会跳
System.currentTimeMillis() 读取的是系统墙上时间。管理员校时、虚拟机恢复、宿主机问题或时间同步服务都可能让它向前或向后调整。因此下面代码并不适合测量持续时间:
long start = System.currentTimeMillis();
doWork();
long elapsed = System.currentTimeMillis() - start;若中途系统时间向后校正,elapsed 可能变小甚至为负;向前跳会让尚未超时的操作被判定为超时。
墙上时钟适合:
- 记录审计时间和业务发生日期。
- 生成面向人的日志时间。
- 校验需要现实时间窗口的证书、JWT 和业务日界线。
即使这样也必须记录时区。2026-07-18 10:00:00 没有时区就不是完整瞬间;跨地域系统存储瞬时时间通常使用 UTC/Instant,展示时再转换业务时区。
四、单调时钟怎样计算持续时间
Java 的 System.nanoTime() 用于测量同一 JVM 内的经过时长。它的起点没有现实含义,只能做差:
long startNanos = System.nanoTime();
doWork();
long elapsedNanos = System.nanoTime() - startNanos;正确的 Deadline 判断也使用差值或把相对预算转换成同一单调时间域中的截止点。不能把 nanoTime() 写入数据库后让另一台机器比较,因为不同进程的起点没有可比性。
nanoTime() 可能发生整数环绕,但 Java 有符号减法在合理间隔内仍能正确比较;不要直接用 now > deadline 这种无法正确处理环绕的写法,优先使用 deadline - now <= 0 或经过时长差值。
五、NTP 校时原理与边界
客户端与时间服务器交换四个时间戳,可估算网络往返延迟与本机相对偏移。简化表示:
delay = (t4 - t1) - (t3 - t2)
offset = ((t2 - t1) + (t3 - t4)) / 2这里假设网络往返大致对称,但真实网络可能不对称,所以 Offset 只是估计值。
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。接收方再映射到自己的单调时钟域。
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 恢复后并不知道自己停顿过,仍可能继续写数据库。
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:
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 的 exp、nbf、iat 使用 NumericDate,即 UTC 时间线上从 Epoch 起的秒数。验证节点需要合理的时钟同步,并只允许小而明确的 Clock Skew 容忍。容忍窗口过大会延长已过期 Token 的可用时间,不能为了消除告警无限放宽。
9.3 TLS 证书
证书的 NotBefore/NotAfter 依赖墙上时钟。节点时钟严重漂移会产生“证书尚未生效”或“证书已经过期”,排查 TLS 握手失败时必须同时查看证书区间和两端系统时间。
9.4 数据库时间
多个应用节点分别写 new Date(),同一业务事件的时间可能倒序。需要统一事实时间时,可由数据库生成提交时间、使用业务版本/流水或记录来源节点;但数据库时间也不自动等于事务提交顺序,必须根据数据库具体语义判断。
十、Snowflake ID 与时钟回拨
典型 Snowflake 把 ID 位划分为:相对 Epoch 的毫秒时间、WorkerId 和毫秒内序列。它具有高吞吐、趋势递增和本地生成优势,但依赖三个条件:
- 同一时刻 WorkerId 不重复。
- 同一 Worker 在同一毫秒内序列不重复。
- 时间不能无保护地回到已经使用过的毫秒。
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 预算
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 恢复后仍可能下发旧命令。
生产方案:
- 协调服务分配租约和单调 Token。
- 每条控制命令携带设备 ID、业务流水和 Token。
- 网关为每台设备持久化最大 Token。
- Token 小于最大值的命令直接拒绝并记录指标。
- 相同业务流水使用唯一键幂等。
- 租约续期失败时客户端主动停止,但最终安全仍由资源端 Fencing 保证。
十四、时间异常生产 Runbook
- 明确异常依赖墙上时间、经过时长、租约还是业务版本。
- 收集受影响节点 UTC 时间、时区、NTP/Chrony 状态、Offset、Jitter 和时间源。
- 对比宿主机、容器、数据库、Redis、注册中心和外部接口时间。
- 查看异常窗口是否发生 Step、虚拟机恢复、宿主机迁移或长 GC。
- ID 冲突时核对 WorkerId、lastTimestamp、序列和回拨日志。
- JWT/TLS 失败时核对 Token/证书时间字段和验证节点时间,禁止盲目放宽容忍窗口。
- 租约双写时核对服务端授予记录、Token、旧持有者暂停时间和资源端 Fencing。
- 调用超时时区分单调持续时间、队列等待和绝对 Deadline 转换。
- Trace 时间倒置时比较各主机 Offset,并以单调 Duration 和协议事件重建链路。
- 修复后增加时钟偏差告警、回拨演练和拒绝策略测试。
常用 Linux 证据示例:
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 和协议事件 |
