服务器时区与时间同步的六种故障排查实录

凌晨三点的「未来文章」

前阵子一个站长的博客出了件怪事:后台文章列表里,有几篇文章的发布时间显示在明天,而且前台刷不出来。他以为是 Typecho 的 bug,翻了两小时源码没找到原因,最后发现是 VPS 的时区设成了 UTC,而他人在东八区——他在本地时间 23:30 点「发布」,服务器认为是 15:30,于是他以为「晚了一天」,实际是「差了八小时」的另一堆问题叠在一起。

时间这件事在服务器上极度容易出错,而且错得悄无声息:日志时间戳差 8 小时、定时任务在错误的时间跑、SSL 证书「明明没过期」却报错、数据库主从因为几毫秒偏差积累出延迟。这篇把我在自己几台机器上踩过的六类时区/时间同步故障整理出来,每类都给出定位命令和修法。

先把三个概念分开:时区、时钟、NTP

很多混淆都源于这三个东西被混为一谈:

  • 时区(timezone):是一个显示规则,决定同一个 Unix 时间戳在你的日志里长什么样。它不改变时间本身。Linux 的时区由 /etc/localtime 决定(通常是指向 /usr/share/zoneinfo/Asia/Shanghai 的软链接)。
  • 系统时钟(system clock):内核维护的时间,是真正意义上的「现在几点」。它可能是错的,比如差了几秒甚至几分钟。
  • NTP/时间同步:负责把系统时钟校准到真实时间的服务(chrony / systemd-timesyncd / ntpd)。

把这三点分清,排查就有方向了:日志时间格式对但数值偏 → 时区问题;所有服务的时间都偏同样多 → 时钟问题;时间偶尔跳变、每天慢慢漂移 → 同步问题。

故障一:systemd 服务用了错误的时区

这是最隐蔽的一类。你把 /etc/localtime 改成了 Asia/Shanghai,date 也显示对了,但某些服务的日志还是 UTC。原因:服务启动时读了一次时区,之后不会重新读;更常见的是 /etc/systemd/system/xxx.service 里显式设了 Environment=TZ=UTC,或者 Docker 容器里 /etc/localtime 是镜像自带的 UTC。

定位:

timedatectl                          # 看系统时区与 NTP 状态
timedatectl list-timezones | grep -i shanghai
ls -l /etc/localtime                 # 确认软链接指向
systemctl show myapp -p Environment  # 看服务有没有覆盖 TZ
docker exec myapp date               # 容器内时间
docker exec myapp ls -l /etc/localtime

修法:sudo timedatectl set-timezone Asia/Shanghai(这一步会同时更新 /etc/localtime 和 /etc/timezone,比手动 ln -sf 可靠),然后 systemctl restart 相关服务。Docker 容器必须单独处理:

docker run -d \
  -e TZ=Asia/Shanghai \
  -v /etc/localtime:/etc/localtime:ro \
  --name myapp myapp:latest

我推荐用 -e TZ 而不是挂载 /etc/localtime,因为挂载在宿主机换时区后容器内不会自动跟随,而且有些精简镜像(alpine)根本没有 tzdata,TZ 变量会被忽略——那就要在 Dockerfile 里 apk add --no-cache tzdata。

故障二:MySQL 的 time_zone 与 PHP 的时区不一致

这个坑我在一个订单系统上踩得很实:PHP 用 date_default_timezone_set('Asia/Shanghai') 生成 created_at,MySQL 的 time_zone 是 SYSTEM(跟随系统 UTC),于是 NOW() 和 PHP 时间相差 8 小时。表现出来就是「刚下的单,列表里显示 8 小时前」。

三层都要对齐:

-- MySQL 侧
SELECT @@global.time_zone, @@session.time_zone, @@system_time_zone;
-- 期望:+08:00 / +08:00 / CST

如果 system_time_zone 是 UTC,先在 my.cnf 的 [mysqld] 段加 default-time-zone = '+08:00',不要写 Asia/Shanghai——那需要先导入 mysql 的时区表(mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql mysql),很多云数据库不给你这个权限,写名字会直接启动失败,报 Unknown or incorrect time zone。写偏移量最稳,代价是没有夏令时(中国不用,无所谓)。

PHP 侧在 php.ini 里设 date.timezone = Asia/Shanghai,别只靠代码里 date_default_timezone_set,因为 CLI 脚本(比如 cron 跑的采集任务)不会走框架的初始化。检查方式:php -i | grep 'date.timezone'。

最稳的做法是存储层统一用 UTC:数据库存 UTC 时间戳,展示时按用户/站点时区转换。这样换服务器、换服务器所在国家都不会出问题。老项目已经写成北京时间了,那就别再混着来,全都用偏移量固定住。

故障三:cron / systemd timer 在错误的时间执行

cron 的时区来源是 cron 守护进程启动时读的 /etc/localtime。改了系统时区之后必须重启 cron,否则它还按老时区算。sudo systemctl restart cron(Debian/Ubuntu)或 crond(CentOS)。

验证方法很土但有效:加一条 * * * * * date >> /tmp/cron_tz_test.log,等两分钟看日志里的时间对不对,看完记得删掉。

systemd timer 的情况更复杂,因为 OnCalendar= 默认按系统时区解释,但 timer 支持在单元文件里写 Timezone=:

[Timer]
OnCalendar=daily
OnCalendar=*-*-* 03:00:00
Persistent=true
Timezone=Asia/Shanghai

改完 systemctl daemon-reload + systemctl restart mytimer.timer,然后用 systemctl list-timers --all 看 NEXT 列的时间是否符合预期。这一步一定要看,因为 Persistent=true 在时区改动后可能触发一次「补跑」——如果你的备份脚本撞上补跑,又在同一时刻被 cron 触发一次,就会出现两个备份进程同时写同一个目录。用 flock 包一层最省心:flock -n /var/lock/backup.lock /usr/local/bin/backup.sh。

故障四:证书「没过期」却报 expired

如果服务器时钟比真实时间快(或者证书刚签发时服务器时间是过去的),openssl 会报 certificate has expired 或 certificate is not yet valid,而 certbot certificates 显示的到期日还很远。先查时间再查证书,能省半小时:

date -u
curl -sI https://www.example.com --resolve www.example.com:443:127.0.0.1 | head -1
openssl s_client -connect 127.0.0.1:443 -servername www.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -dates

顺手提一句时间偏差对 ACME 的影响:Let's Encrypt 的校验要求客户端时间误差在几分钟内,时钟漂移过大时 certbot renew 会报 JWS has an invalid anti-replay nonce 之类的错,看着像网络问题,其实是时钟问题。用 chronyc tracking 看 System time 那一行,超过 1 秒就该处理了。

故障五:时钟同步服务没跑,或者跑了但没生效

Debian 12 / Ubuntu 22.04 之后默认是 systemd-timesyncd,它够用但功能弱:只做客户端、不做服务端、没有精细的漂移统计。我一般换成 chrony:

sudo apt install -y chrony
sudo systemctl disable --now systemd-timesyncd
sudo systemctl enable --now chrony
chronyc tracking
chronyc sources -v

判读 chronyc tracking 的几个关键字段:

  • Reference ID 是当前选中的上游服务器;如果是 127.127.1.0(本地时钟),说明它谁都没同步上,等于没在用;
  • Stratum 应在 2~4 之间,显示 0 或 16 表示无效;
  • System time 是当前偏差,长期在 0.0001 秒级算正常;
  • Frequency 是本地晶振漂移率,几十 ppm 是正常的,几百 ppm 说明虚拟机的时间源不稳。

虚拟机/云主机特有的坑:KVM 上如果同时跑 chrony 和宿主机的 kvm-clock 同步,两者会打架,表现为时间每秒跳一下。判断方法:dmesg | grep -i 'clocksource' 看当前用的时钟源,cat /sys/devices/system/clocksource/clocksource0/current_clocksource。如果是 kvm-clock 且时间不稳,可以在 chrony.conf 里加 makestep 1.0 3 允许启动后三次内大步校正。

故障六:日志时间戳跨时区对不上,排查全靠猜

最难受的不是时间错,而是不同来源的日志时间格式不一致:Nginx 默认日志是 21/Oct/2026:14:03:12 +0800,PHP 错误日志是 [21-Oct-2026 14:03:12 Asia/Shanghai],syslog 是 Oct 21 14:03:12(没有年份、没有时区),MySQL 慢日志是 Unix 时间戳。事故复盘时对不上,光对齐时间就能耗掉一小时。

我的做法是全部日志统一成 ISO 8601 + 显式时区:

# nginx.conf
log_format main '$remote_addr - $remote_user [$time_iso8601] '
                '"$request" $status $body_bytes_sent '
                '"$http_referer" "$http_user_agent" $request_time';
access_log /var/log/nginx/access.log main;

$time_iso8601 输出 2026-10-21T14:03:12+08:00,任何工具都能解析,排序也天然正确(字符串序 = 时间序)。同时把 rsyslog 的模板也改一下,在 /etc/rsyslog.conf 里保留 RSYSLOG_TraditionalFileFormat 之外自定义一个带时区的模板。这一步做完,以后跨机对比日志只需要 sort。

再补一个细节:syslog 默认不写年份,跨年排查会懵。用 RSYSLOG_FileFormat(templated 里自带)就是带年份和时区的 ISO 格式,改一行配置的事。

补充:时区与「站点显示时间」的最后一公里

服务器时间都对了,用户看到的仍然是错的,这是最后也是最容易漏的一层——时区信息可能被缓存在浏览器或 CDN 上。典型场景:页面顶部有「最后更新:xx 分钟前」这类相对时间,如果这段 HTML 被 CDN 缓存了 10 分钟,用户看到的时间就会永远慢一截。更隐蔽的是带时区偏移的格式化日期被缓存,比如首页侧栏的「今日热榜(2026-10-20)」,跨零点后仍然显示昨天。

处理原则很明确:凡是随时钟变化的片段,都不能整页缓存。三条具体做法:

  • 相对时间不要服务端渲染成固定字符串,改成输出一个 <time datetime="2026-10-21T14:03:12+08:00"> 标签,用几行前端脚本在浏览器里算「几分钟前」——这样缓存安全,用户本地时区也天然正确;
  • 日期敏感的页面片段(今日热榜、倒计时、每日一句)放进 ESI 或用 Cache-Control: no-cache 单独输出,别和整页混在一起;
  • 服务端渲染的日期统一用 ISO 8601 带偏移量输出,别输出 2026-10-21 14:03:12 这种没有时区的裸字符串,跨时区用户会全部误读。

还有一个数据库层面的细节值得单独讲:别用 timestamp 存「未来要按用户时区解释的时间」。MySQL 的 TIMESTAMP 类型存储时按会话 time_zone 转成 UTC,读取时再转回来——一旦会话时区变了(比如应用连池里两个连接用了不同的 time_zone),同一列读出来能差 8 小时。用 DATETIME 存业务时间(不做时区转换,所见即所得),配合应用层统一约定「这个字段是 UTC」或「这个字段是北京时间」,比依赖数据库的类型转换可靠得多。我自己所有站点的表结构都统一成 DATETIME + 应用层处理时区,五年来没再出过时间错乱。

顺带提一个排查工具:zdump -v Asia/Shanghai | grep 2026 可以确认时区数据库里这一年的规则有没有异常(历史上确有 tzdata 更新导致某些地区偏移变化的案例)。tzdata 这个包值得放在 unattended-upgrades 的白名单里,让它自动更新——时区规则变更不更新,就会出现「某些国家用户看到的时间差一小时」这种极难复现的问题。

一份五分钟自检清单

我把上面这些东西压缩成开机后必跑的一段,贴在新机器上很方便:

echo "== timezone =="; timedatectl | grep -E 'Time zone|NTP|synchronized'
echo "== clock drift =="; chronyc tracking | grep -E 'System time|Stratum|Reference'
echo "== php =="; php -r 'echo date_default_timezone_get(), PHP_EOL, date("c"), PHP_EOL;'
echo "== mysql =="; mysql -N -e "SELECT @@global.time_zone, @@session.time_zone, NOW();"
echo "== cron =="; head -3 /var/log/syslog | grep -i cron || journalctl -u cron -n 3 --no-pager
echo "== tls =="; openssl s_client -connect 127.0.0.1:443 -servername localhost </dev/null 2>/dev/null \
  | openssl x509 -noout -dates 2>/dev/null || echo "no local tls"

六行输出看完,基本能确定时间这块有没有隐患。

小结

时区问题之所以难查,是因为它不报错——所有组件都「正常工作」,只是工作在错误的时间上。我的经验是三条:

  1. 存储统一 UTC,展示统一本地时区,这条定下来能消掉八成麻烦;
  2. 改时区后必须重启依赖它的服务(cron、PHP-FPM、应用进程、容器),它们不会自动跟随;
  3. 用偏移量而不是时区名配置 MySQL,避免时区表未导入导致的启动失败。

最后一句个人看法:时间同步这种「基础设施中的基础设施」,值得花半小时配好 chrony 并接到监控里。等到证书续期失败、订单时间错乱、备份没跑才开始查,代价就不止半小时了。

Last modification:October 21st, 2026 at 01:27 pm

Leave a Comment