凌晨两点被电话叫起来,说订单库里出现了一条“创建时间比支付时间还晚”的记录。查应用、查消息队列、查数据库主从,全都没结论,最后翻到时间服务的日志才发现:系统时钟在半小时前被往前拨了 11 秒。这件事让我把 NTP 的step与slew两种同步模式重新捋了一遍——它们决定时间服务到底是“一口气把表拨到位”,还是“慢慢地往前赶”,而这中间差出来的每一秒,都可能在业务侧变成一笔对不上的账。
这篇东西写给所有需要跟时间打交道的人:管服务器的、写分布式服务的、做过金融对账的、被证书过期坑过的、以及单纯想搞明白chronyc tracking里那几行数字到底在说什么的人。我会把两种模式的触发条件、内核里真实发生的事情、参数怎么算、配置怎么写、怎么验证、怎么排错,一条一条拆开讲,能直接抄的部分我尽量给出可复制的片段。
1. step 与 slew:两种“改表”哲学的分水岭
1.1 时钟偏差到底从哪冒出来
先别急着聊模式,得先搞清楚偏差是怎么产生的。机器上的石英晶振不是理想器件,它的实际频率会随温度、电压、老化漂移,标称 24 MHz 的晶振实际可能是 24 MHz ± 50 ppm。50 ppm 听着小,一天下来就是 4.32 秒。再加上虚拟化环境下 CPU 被抢、时钟中断延迟、宿主机自身的频率漂移,一天偏个几十秒一点都不奇怪。
内核里有个叫时钟纪律(clock discipline)的子系统,它维护一个“频率偏差”的估计值,源源不断地告诉 clocksource“你走快了,每次换算乘 0.99997”或者“你走慢了,乘 1.00003”。时间服务要做两件事:测出偏差,然后把这个偏差消掉。
消掉的方式只有两种物理路径:要么改变钟面的读数(偏移量 offset),要么改变钟的走速(频率 freq)。前者就是 step,后者就是 slew。所有 NTP 实现,无论 ntpd、chronyd 还是 Windows 的 w32time,本质上都只在这两条路上做文章。
1.2 一次跳变 vs 慢慢拉近:从内核接口看本质
在 Linux 上,这两条路径对应两套完全不同的系统调用。
step 走的是clock_settime(CLOCK_REALTIME)或者clock_adjtime()带ADJ_SETOFFSET标志。效果是把内核里维护的“墙上时间与单调时间的偏移量”offs_real一次性改掉。改完之后,CLOCK_REALTIME立刻变了,但CLOCK_MONOTONIC纹丝不动——这一点极其重要,后面会反复用到。
slew 走的是adjtimex()的ADJ_OFFSET和ADJ_FREQUENCY。ADJ_FREQUENCY的参数单位是 ppm,通过 16.16 定点数表示;ADJ_OFFSET是微秒级的残余偏移,由内核在后续若干个 tick 里慢慢摊销掉。这里的关键是:频率调整作用于整个 timekeeping,所有基于 clocksource 推导出来的时钟都会受它影响,包括CLOCK_MONOTONIC。也就是说 slew 期间,你的System.nanoTime()、Go 的time.Since、任何用单调时钟测出来的耗时,都会跟着变快或变慢那么一点点。
CLOCK_MONOTONIC_RAW是例外,它直接读硬件计数器,完全不参与 NTP 调整。所以如果你要做严格的性能计时,用 RAW 更“干净”,代价是它会随温度漂移。
1.3 为什么内核不让你随便跳
有人会问:既然偏差就是错的,为什么不每次都直接跳过去,一次搞定?因为跳变会破坏“时间只能单向流动”这个隐含假设。大量软件逻辑建立在“后取的时间戳 ≥ 先取的时间戳”之上:数据库的 MVCC 版本号、分布式锁的租约判断、日志的排序、make的依赖比较、TLS 证书的有效期校验、JWT 的过期时间、限流器的滑动窗口。时间往回退 10 秒,这些环节全部可能出问题。
反过来,slew 的代价是“收敛慢”,而且期间时间是被拉长或压缩的。如果一台机器要补 60 秒的偏差,按 500 ppm 的速率,得补上一整天多。这个代价在业务连续性面前通常是可接受的,但在有些场景下不可接受——比如一个刚上电的嵌入式盒子,RTC 电池没电,系统时间回到了 1970 年。
注意:真正决定“选哪种”的从来不是技术偏好,而是“这台机器上的业务能不能容忍一次时间跳变”。先把这句话想清楚,再看后面的参数。
2. step 模式:一次性把表拨到位
2.1 传统 ntpd 的三个关键阈值
传统 ntpd 对 step 的控制全靠tinker指令,核心有三个数:
| 参数 | 默认值 | 含义 | 调大的后果 | 调小的后果 |
|---|---|---|---|---|
tinker step | 0.128 秒 | 偏移超过此值就 step | 更少跳变,但收敛慢 | 频繁小跳,业务易受惊 |
tinker panic | 1000 秒 | 偏移超过此值直接放弃并退出 | 更宽容,可能把明显错误的时间当真 | 机器时间严重错误时直接不同步 |
tinker stepout | 0.128 秒 | 步进后需要稳定多久才回到正常模式 | 延长观察期 | 更快恢复调整 |
tinker step 0是个容易记反的写法:阈值设为 0 意味着任何偏移都触发 step,也就是“总是跳”。想彻底关掉 step,得用-x启动参数,这个选项让 ntpd 永远只做 slew,同时 panic 阈值失效。反过来的-g是“首次修正时忽略 panic 阈值”,用在开机阶段 RTC 明显错误、但你又不想让 ntpd 直接退出的场景。
这两个参数我见过被搞混很多次:有人在配置文件里写tinker step 0,本意是禁用跳变,结果机器每次同步都跳,日志里时间戳一天错好几次。记住-x才是“禁跳”的开关。
2.2 chrony 的 makestep:次数窗口才是重点
chrony 把 step 的控制做成了两个维度的组合:makestep <阈值> <次数>。
第一个参数是阈值(秒),第二个参数是“在前几次时钟更新内生效”。makestep 1.0 3的意思是:开机后前 3 次时钟更新中,如果偏移超过 1 秒,就跳;第 4 次之后,即使偏移 10 秒也不跳,老老实实 slew。makestep 0.128 -1里的-1表示无限次,也就是任何时候偏移超阈值都跳。
多数发行版的chrony.conf模板里,这一行是注释掉的状态。也就是说默认行为是“从不主动 step”,只有极少数版本会在启动阶段对明显错误的时钟做一次修正。这个细节值得你去自己机器上确认一遍,别想当然。查看方式很简单:
grep -n "makestep" /etc/chrony.conf如果输出是带#的,说明你的机器当前不做主动 step。这时如果因为换主板、CMOS 电池没电导致时钟跑偏几天,chrony 会以极慢的速度去追,追到天荒地老。
makestep 1.0 3这个组合是我个人最推荐的默认配置:开机阶段允许快速纠偏,跑起来之后就老老实实 slew。它把“启动时时钟可能是错的”和“运行时时钟一定是接近对的”这两个假设区分开了。
2.3 手动强制 step 与开机一次性 step 的实践
有些场景你不想等下一次自动判断。chrony 提供了手动命令:
chronyc makestep执行后会立刻把当前估计的偏差一次性应用上去,tracking里的System time会瞬间归零。维护窗口里修完 RTC 之后,我一般就是这一条命令收尾。
如果是“只要同步一次就退出”的场景——比如容器启动脚本、CI 机器、临时虚拟机——用一次性模式比常驻服务更合适:
chronyd -q 'server 192.0.2.10 iburst'-q的意思是:读取配置、完成一次时钟修正、退出。它内部会先做一次 step(如果在启动阶段允许),然后退出。很多基础镜像里没有ntpdate(已经废弃),chronyd -q是现在推荐的替代写法。
还有一种情况是“这台机器根本不该联网同步”。比如某些合规要求高的内网机器,时间由带外管理口或者 GPS 授时卡提供。这时候你可以保留 chrony 做本地参考时钟的跟踪,但把makestep显式设为0 0,把行为固定下来,避免升级换版本后默认值变动带来的意外。
2.4 step 的代价清单
我见过 step 造成的真实故障,按出现频率排一排:
- 日志时间戳倒序。ELK 里同一批请求的时间戳跨度出现负数,聚合结果对不上。logrotate 的
dateext命名也可能撞名。 - 数据库事务时间异常。
created_at比updated_at晚,触发业务侧的脏数据校验;某些 ORM 的乐观锁用时间戳做版本号,直接冲突。 - 缓存 TTL 计算错误。写入时记
expire_at,读出时用now比较。时间后退导致已经过期的数据“复活”,时间前进导致本该有效的数据立刻失效。 - 定时任务重复或跳过。基于绝对时间轮询的调度器,在跳变窗口内可能漏掉一个周期或者连跑两次。
- TLS / JWT 校验失败。证书的
notBefore在“未来”,跳变后立刻变成“未生效”。 make和rsync行为异常。源文件 mtime 比目标旧,增量同步直接跳过,文件永远同步不过去。
这些问题的共同点是:它们都不会在跳变的那一刻报错,而是在之后几分钟到几小时内陆续冒出来,排查时很难第一时间联想到时间服务。所以我给一条硬性建议:任何对时间跳变敏感的服务,都必须用CLOCK_MONOTONIC做超时和耗时计算,绝对不要用CLOCK_REALTIME。Go 的time.Since内部用的是单调时钟,Java 的System.nanoTime()也是,这是它们比currentTimeMillis()更适合做超时的根本原因。
3. slew 模式:调的不是时间,是时钟频率
3.1 内核里到底发生了什么
slew 的执行路径比 step 精细得多。内核拿到目标频率调整量freq(单位 ppm)之后,会把它写进 timekeeping 的乘法因子mult里。每次读时钟时,delta = (cycles - base) * mult >> shift,mult变了,同样的硬件周期数换算出来的纳秒数就变了。
这解释了一个经常让人困惑的现象:slew 期间CLOCK_MONOTONIC和CLOCK_REALTIME是同比例变化的。因为它们共享同一个mult。你如果此时用单调时钟测一段 10 秒的耗时,测出来的可能真的是 9.99 秒或者 10.01 秒,误差在 ppm 量级。对绝大多数业务这是噪音级别的,但对高频交易、音视频同步、工业控制这类场景,就是必须考虑的因素。
另外内核还有一层限制:NTP_MAXFREQ定义为 500 ppm。这是内核层面允许的最大频率调整幅度。传统 ntpd 通过adjtimex做 slew 时,速率上限就是这 500 ppm,即每秒钟最多修正 0.5 毫秒。这个数字是 1990 年代定下来的,当时的网络延迟和晶振精度决定了它够用,放到今天就显得保守了。
3.2 补偿时间怎么算:几个必须记住的数字
收敛时间 = 偏差 ÷ 速率。500 ppm = 0.0005 秒/秒,也就是每 2000 秒修正 1 秒。换算成直觉:
| 初始偏差 | @500 ppm | @83333 ppm(chrony 默认上限量级) |
|---|---|---|
| 10 ms | 20 秒 | 0.12 秒 |
| 100 ms | 200 秒(3.3 分钟) | 1.2 秒 |
| 1 秒 | 2000 秒(33 分钟) | 12 秒 |
| 10 秒 | 20000 秒(5.6 小时) | 120 秒 |
| 60 秒 | 33 小时 | 12 分钟 |
这张表能直接回答一个高频问题:“为什么我的机器偏了 5 秒,过了半小时还没对上?”因为按传统 500 ppm 算,5 秒需要 2.8 小时。很多人以为是同步坏了,其实是正常工作中。
写个小脚本自己算更直观:
def converge(offset_s, ppm): return offset_s / (ppm * 1e-6) for off in (0.01, 0.1, 1.0, 10.0, 60.0): print(f"{off:>6.2f}s @500ppm={converge(off, 500):>10.0f}s " f"@83333ppm={converge(off, 83333):>8.1f}s")顺带说个反直觉的点:slew 的速率并不是恒定的。偏差小的时候,内核 PLL 会按比例缩小修正量,避免过冲振荡;偏差大的时候才会用满速率。所以实际收敛曲线是前快后慢的指数逼近,不是一条直线。你在chronyc tracking里看到的System time逐渐缩小但越来越慢,就是这个原因,别以为是卡住了。
3.3 chrony 的 maxslewrate 与传统 500ppm 的差异
chrony 在设计上绕开了内核 500 ppm 的限制。它的做法是:当偏差大到“按内核允许速率追不上”时,把修正量拆成一系列很小的时间设置操作,每次几十毫秒,对外表现为平滑前进,但实际是“用一串小 step 模拟 slew”。这就是为什么 chrony 收敛得比 ntpd 快得多。
maxslewrate就是控制这个上限的旋钮,单位是 ppm:
# 限制最快按 500 ppm 修正,行为向传统 ntpd 靠拢 maxslewrate 500 # 放开到 1000 ppm,适合对收敛速度有要求又要相对温和的场景 maxslewrate 1000不给这个参数时,chrony 会用自己认为合适的默认上限(数量级在几万 ppm,也就是每秒最多修正几十毫秒)。这个默认值在“开机快速纠偏”上很好用,但在“绝不允许时间出现陡峭变化”的场景下偏激进——毕竟每一次“小 step”虽然只有几十毫秒,累积起来仍然是一串不连续的跳变。
我的经验是:对时间连续性有强要求的机器(比如做时序数据采集、做音视频对齐、跑仿真),显式写上maxslewrate 500,把行为钉死。其他机器保持默认,享受快速收敛的好处。
3.4 slew 期间的“副作用”与观测方法
slew 期间有几件事值得留意。
第一,System time归零不代表同步完成。这个字段是“当前估计的残余偏移”,它到 0 说明偏移已经消掉了,但频率可能还在调整中。真正判断是否稳定,要看Frequency和Skew两个值:Frequency是当前估计的晶振频偏(单位 ppm),Skew是这个估计的误差范围。当Skew收敛到个位数 ppm 并且Frequency不再大幅摆动,才算真正进入稳态。
第二,chronyc tracking里的Last offset和System time不是一回事。前者是上一次时钟更新时测到的原始偏差,后者是当前时刻经过插值估计的偏差。排障时要看后者,前者只用于判断“上一次测量结果”。
第三,观测收敛过程别用watch chronyc tracking,因为每次调用都会新建连接,输出刷得太快反而看不清。用循环加时间戳更实在:
for i in $(seq 1 30); do printf "%s " "$(date +%H:%M:%S)" chronyc tracking | awk '/System time/{print $4, $5, $6}' sleep 10 done这段输出能直观看到System time从某个数值一路单调减小。如果它来回摆,说明上游时间源本身不稳,或者本机负载把时钟中断打乱了。
4. 场景选型:把两种模式放进真实业务里
4.1 场景对照表
选型这件事,我习惯按“能否容忍跳变”和“偏差有多大”两个维度来切:
| 场景 | 典型初始偏差 | 推荐模式 | 理由 |
|---|---|---|---|
| 新装物理机,RTC 电池正常 | < 1 秒 | 启动 step 一次,之后 slew | 一次性纠偏,后续平滑 |
| 长期关机后重新上电 | 分钟级到小时级 | 必须 step,且要放行 panic | slew 追不上,业务也等不起 |
| 数据库主库 / 事务型服务 | 通常 < 100 ms | 纯 slew,禁用 step | 时间倒退会破坏事务语义 |
| 分布式协调节点(选主、租约) | < 50 ms | 纯 slew,maxslewrate 500 | 租约判断对时间连续性敏感 |
| 日志采集 / 监控 Agent | 任意 | 可以 step | 时间戳短暂错乱可接受 |
| 时序数据库写入节点 | < 1 秒 | 纯 slew | 乱序写入会触发拒绝策略 |
| 边缘设备,无 RTC 电池 | 几十年 | 必须启动 step | 否则任何证书都校验失败 |
| 容器 / k8s 节点 | 取决于宿主 | 节点统一,容器内只读 | 容器内改时间影响宿主机,风险极高 |
表里最容易被忽视的是最后一行。容器默认没有CAP_SYS_TIME能力,clock_settime会直接返回EPERM。所以你在容器里跑 chrony 想 step,必然失败——日志里会写类似 “Could not step system clock” 的错误。正确做法是宿主机(或虚拟机)统一同步,容器共享宿主机的时钟命名空间,什么都不用做。
4.2 混合策略:启动 step + 运行 slew
绝大多数机器的最优解是这个组合,我在几十台机器上跑过,稳。chrony 的写法:
# /etc/chrony.conf server 192.0.2.10 iburst minpoll 4 maxpoll 6 server 192.0.2.11 iburst minpoll 4 maxpoll 6 driftfile /var/lib/chrony/drift # 开机后前 3 次更新,偏差超过 1 秒就跳 makestep 1.0 3 # 运行期限制最大修正速率,避免出现陡峭变化 maxslewrate 500 # 让硬件时钟跟随 rtcsync # 记录观测数据,排障时很有用 logdir /var/log/chrony log tracking statisticsiburst的作用是启动时快发几个包,把初始同步时间从几十秒压到几秒;minpoll 4 maxpoll 6意味着轮询间隔在 16 到 64 秒之间自适应,比默认的 64–1024 秒跟得紧。内网环境我一般会把maxpoll压到 6 甚至 5,代价是多一点点网络流量,换来的是偏差始终维持在毫秒级。
传统 ntpd 的等价写法:
# /etc/ntp.conf server 192.0.2.10 iburst server 192.0.2.11 iburst driftfile /var/lib/ntp/drift # 放宽步进阈值,让启动阶段能纠大偏差 tinker step 1.0 tinker panic 1000区别在于 ntpd 没有“前 N 次”的概念,tinker step 1.0是永久生效的——运行期偏差一旦超过 1 秒,它照样会跳。想只让启动阶段跳,ntpd 做不到,这也是我更倾向 chrony 的原因之一。
4.3 虚拟机、容器与双系统的特殊处理
虚拟机的时间漂移比物理机大得多。原因主要有两个:vCPU 被调度出去时,guest 的时钟中断得不到及时响应,累计误差;以及宿主机自己的时钟在调整时,guest 感知不到。开启宿主机的时钟同步透传(KVM 的kvm-clock、VMware 的 Tools 时间同步)能解决大部分问题,但要注意:guest 和宿主机同时跑时间服务会打架。要么让 guest 自己同步外部源,要么完全交给宿主机,二选一。我见过两边都配、结果 offset 反复振荡的机器。
容器里唯一要做的是确保它用宿主机的时钟命名空间(默认就是),并且不要在容器内安装运行 chrony/ntpd。如果需要给容器里的应用一个“时间是否同步”的信号,可以让宿主机把状态写到共享文件或者通过健康检查接口暴露出来。
双系统机器(同一块盘上既装 Linux 又装另一个系统)有个经典坑:另一个系统默认把硬件时钟当本地时间,Linux 默认当 UTC,一来一回差 8 小时。每次切换系统,NTP 都要 step 掉 8 小时。解法是把两边约定统一:Linux 这边执行timedatectl set-local-rtc 0,另一个系统那边改成用 UTC 存硬件时钟;或者反过来统一成本地时间。总之别让两个系统各说各话。
5. 一套可复现的实操与验证流程
5.1 动手前的现状盘点
改配置之前先看清楚现状,四条命令足够:
timedatectl chronyc tracking chronyc sources -v chronyc sourcestats -vtimedatectl看三件事:System clock synchronized是不是 yes、NTP service是不是 active、RTC in local TZ是不是 no。第三项如果是 yes,说明硬件时钟被当成本地时间了,这是一个需要单独处理的配置错误。
chronyc sources -v输出里,每一行前面的符号很关键:*是当前选中源,+是备选可用源,-是被排除的,?是不可达或有问题的。如果全是?,先查网络和 DNS,别急着改时间参数。
chronyc sourcestats -v里的Off是偏移估计,Freq是频率估计,Skew是误差界。多个源之间偏移互相打架,说明其中一个源不可信,需要人工排除。
5.2 配置落地
改完chrony.conf之后,重启服务和验证分两步:
systemctl restart chronyd chronyc waitsync 60 0.1 chronyc trackingwaitsync 60 0.1会最多轮询 60 次,直到偏移小于 0.1 秒,非常适合写进自动化脚本——比sleep 30 && 检查这种硬等待靠谱得多。
有个细节:systemctl restart chronyd会让 chrony 重新加载 drift 文件。如果之前的 drift 估计是准的,重启后频率会立刻收敛;如果 drift 文件被删了,就要重新学习,前几十分钟的频率精度会差一些。所以没事别乱删/var/lib/chrony/drift。
如果是容器镜像或者无人值守装机,可以把配置写进 cloud-init 或者 Ansible,但要保留“如果已有 chrony.conf 就不要覆盖”的判断——很多发行版的默认配置里带着发行版自己的源,直接覆盖会让机器失去同步能力。
5.3 造一个偏差来观察两种行为
想真正理解这两者的区别,最好的办法是现场造一次。只在测试机上做,生产环境千万别玩。
先造一个 10 秒的偏差:
date -s "$(date -d '+10 sec' '+%Y-%m-%d %H:%M:%S')"然后立刻观察。如果配置是makestep 1.0 3并且这是开机后第 1 次更新,chrony 会在下一次轮询时直接跳回去,chronyc tracking的System time从 10 秒变成 0,日志里会出现一条 “System clock was stepped by 10.000000 seconds”。
如果把makestep注释掉再重启 chronyd,同样造 10 秒偏差,你会看到完全不同的景象:
for i in $(seq 1 20); do chronyc tracking | awk -v t="$(date +%T)" '/System time/{print t, $4, $5, $6}' sleep 15 doneSystem time会从 10 秒开始一点点往下掉。按maxslewrate 500算,需要 20000 秒才能收敛,你这 5 分钟的观察窗口里只能看到它掉了几百毫秒。这时候把maxslewrate改成 20000 再重启,同样的观察,会看到它掉得快得多。
这个实验跑一次,比读十篇文档都管用。你会亲眼看到“step 是断崖,slew 是斜坡”这句话到底什么意思。
5.4 观测数据的正确读法
chronyc tracking的输出字段不少,容易看晕,我把实际排障时真正有用的几个列一下:
| 字段 | 含义 | 关注阈值 | 异常时的常见原因 |
|---|---|---|---|
System time | 当前估计偏移 | 稳态应 < 10 ms | 上游不稳、负载过高、slew 未完成 |
Last offset | 上次测量的原始偏移 | 与 System time 同量级 | 差值大说明两次测量之间漂移快 |
RMS offset | 偏移的均方根 | 长期看趋势 | 持续上升说明频率估计失效 |
Frequency | 晶振频偏估计(ppm) | ±100 ppm 内常见 | 超过 ±500 说明晶振严重老化 |
Residual freq | 残余频率误差 | 接近 0 | 不收敛说明源之间打架 |
Skew | 频率估计的误差界 | < 10 ppm 为佳 | 大说明样本不足或抖动大 |
Root delay/Root dispersion | 到参考源的往返延迟 / 累积误差 | 内网应 < 5 ms | 大说明网络链路有问题 |
Update interval | 当前轮询间隔 | 与 maxpoll 对应 | 一直不变说明没在自适应 |
Frequency这一项特别值得长期观察。它的绝对值反映本机晶振的质量:稳定的 ±20 ppm 是正常服务器水平,±200 ppm 说明晶振偏了不少,但 chrony 能补;如果某天这个值突然跳变几十 ppm,多半不是晶振变了,而是温度或者虚拟化环境变了,这时候要警惕。
6. 常见问题速查与排查路径
排障时最怕漫无目的地翻日志。我把这些年遇到过的问题整理成一张表,按“现象 → 先查什么 → 根因”的顺序走,能省掉大量时间。
| 现象 | 首先检查 | 根因 | 处置 |
|---|---|---|---|
| 时间偏了几十秒,几天都追不上 | grep makestep /etc/chrony.conf | 未启用 step,纯 slew 太慢 | 临时chronyc makestep,长期开makestep 1.0 3 |
手动chronyc makestep失败 | 是否在容器内 / 有无CAP_SYS_TIME | 权限不足 | 到宿主机层面处理 |
| 日志里反复出现 stepped by N seconds | journalctl -u chronyd | grep -i step | 每次开机都偏,RTC 电池失效 | 换电池,或开机后固定执行一次手动 step |
System time来回摆动 | chronyc sources -v看是否多源打架 | 上游源之间不一致 | 只保留可信源,其余加noselect |
Frequency超 ±500 ppm | 硬件、温度、虚拟化 | 晶振老化或平台问题 | 换硬件,或接受并依赖同步补偿 |
| 同步正常但业务仍报时间错误 | 应用是否用CLOCK_REALTIME做超时 | 时间跳变影响业务逻辑 | 改用单调时钟做耗时和超时 |
waitsync一直超时 | 网络到 123 端口是否通 | 防火墙拦截 UDP/123 | 放行,或换内网源 |
| 双系统切换后差 8 小时 | timedatectl的RTC in local TZ | 两边对硬件时钟的约定不一致 | 统一为 UTC 存硬件时钟 |
| 容器内时间比宿主慢 | 宿主机自身同步状态 | 容器继承宿主时钟 | 修宿主机,容器内别装服务 |
再补充几个表格放不下的排查要点。
关于日志的时间线交叉验证。怀疑时间出问题时,最快的确认方式是把journalctl -u chronyd和业务日志按时间轴对一遍。chrony 的日志里会明确写 “System clock wrong by X seconds” 和 “System clock was stepped by X seconds”,这两句话就是分水岭:前一句表示测量到偏差,后一句表示真的跳了。找到跳变的时间点,再去业务日志里搜那个时间窗口前后的异常,基本一抓一个准。
关于“同步了但偏差还在”的错觉。很多人看chronyc tracking里Leap status是Normal就以为万事大吉,其实那只说明没有闰秒待处理,跟偏移毫无关系。真正要看的是System time和RMS offset。我有一次排查一个“时钟不准”的投诉,最后发现是应用自己打了个错误的日志时间戳(用的还是进程启动时缓存的时间),跟系统时钟一点关系都没有。
关于闰秒。虽然闰秒事件本身不常发生,但处理不当会造成大面积时间跳变。现代做法是闰秒涂抹(leap smearing):在闰秒发生前后的一段时间内,把这一秒均匀分摊到多个小时内慢慢加进去,避免瞬时的 1 秒跳变。chrony 通过leapsmearinterval指令支持这个能力,需要上游源同步支持。如果你所在的环境对时间连续性极度敏感,这一项值得提前规划。
7. 我踩过的几个坑和一条经验准则
先说几个具体到能直接避开的坑。
第一个坑:以为注释掉 makestep 就等于不做 step。早期版本的某些发行版会在启动阶段对明显错误的时钟做一次隐式修正,配置文件里看不到,日志里能看到。所以判断行为要以日志为准,不要只读配置。查法是journalctl -u chronyd | grep -i "stepped\|step",把最近几十次开机的记录翻一遍,看有没有意外的跳变。
第二个坑:把maxslewrate设得过大。有一台机器为了快速纠偏,把maxslewrate设成了100000,结果每次同步都表现为一串密集的小跳变,采集程序的时间戳出现锯齿。后来改回500,问题消失。这件事让我明白:slew 的“平滑”是有前提的,前提是你限制了它的速度。不限制速度的 slew,本质上就是一堆小 step。
第三个坑:在多源配置里混入了不同层级的源。一个内网源加三个公网源,看起来冗余度高,实际上内网源的延迟远小于公网源,chrony 会偏向它,但偶尔公网源抖动又会把它踢掉,导致频率估计反复重算。稳妥做法是给不同层级的源设置不同的stratum权重,或者干脆只用同层级的源,用prefer明确主源。
第四个坑:在虚拟机里同时开宿主机时间同步和 guest 内的时间服务。两边都想改时钟,结果 offset 在正负之间反复横跳,Frequency一直不收敛。这类问题的特征是tracking里数值很大但业务侧感知不明显,容易被忽略,直到某天需要精确时间时才发现。
再讲一条我认为最重要的准则,是这些年反复验证出来的:把“时间”当成一个有状态的、会漂移的、可能被外部修改的依赖来设计,而不是当成一个永远正确的全局常量。
落到具体做法上就三句话。第一,所有耗时、超时、租约、重试间隔,一律用单调时钟,绝不用墙上时间。第二,所有需要持久化的时间戳,只在写入的那一刻取一次,之后不再重算。第三,跨机器比较时间时,先假设两边有几十毫秒的偏差,用逻辑时钟或版本号解决顺序问题,不要依赖物理时钟的绝对精度。
至于 step 和 slew 怎么选,我的判断标准很朴素:如果这台机器上的业务会在时间倒退一秒时出问题,那就只能 slew,并且把maxslewrate调低;如果不出问题,那就让启动阶段 step,运行阶段 slew,两全其美。真正难的不是配置怎么写,而是搞清楚你的业务到底属于哪一类——这件事没人能替你做,得自己去翻代码,找出那些now()调用,看看它们被用来干什么。