服务器时区不对的五个层次:从 timedatectl 到 MySQL 与 cron 的逐层排查

时区问题为什么总在凌晨爆发

我的站上出过一次挺典型的故障:凌晨两点半,定时备份脚本把数据库导进了一个按日期命名的目录,第二天早上我发现最近三天的备份文件名全都错了位——昨天 23:40 的数据被写进了"后天"的目录。查下去才发现,PHP 进程用的是 UTC,MySQL 用的是 SYSTEM 时区,而 crontab 的执行环境又是另一套。三个地方三个时间,数据就这么错位了。

时区这类问题有个共同特征:不报错、不崩溃,只是数据悄悄错位。日志时间戳看着合理、文章发布时间看着合理,直到某天需要按时间对账,才发现差了八小时。这篇把我踩过的所有层次理一遍,从内核到应用,每一层给出检查命令和配法。

第一层:内核与系统时区

Linux 的时间其实是两个东西:硬件时钟(RTC,BIOS 上那个)和内核维护的系统时间。系统时区则完全由 /etc/localtime 这个符号链接决定,它指向 /usr/share/zoneinfo/ 下的某个时区文件。

# 当前设置
timedatectl

# 输出示例
#                Local time: Sat 2026-11-14 09:12:33 CST
#            Universal time: Sat 2026-11-14 01:12:33 UTC
#                  RTC time: Sat 2026-11-14 01:12:33
#                 Time zone: Asia/Shanghai (CST, +0800)
# System clock synchronized: yes
#               NTP service: active

# 设置时区
timedatectl set-timezone Asia/Shanghai

# 手工配置的方式(等价)
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
echo "Asia/Shanghai" > /etc/timezone

注意 RTC 那一行。如果 RTC time 显示的是本地时间而系统时间显示 UTC,说明硬件时钟存的是本地时间(timedatectl set-local-rtc 0 应该设成 0,让 RTC 存 UTC)。存本地时间的机器在夏令时切换时会跳一小时,虽然国内不用夏令时,但如果服务器在海外机房、又跑着跨时区的业务,这个坑迟早遇到。

容器里是另一回事。Docker 容器默认不继承宿主机的时区,/etc/localtime 是镜像里带的(通常是 UTC)。两种配法:挂载宿主机的时区文件 -v /etc/localtime:/etc/localtime:ro -v /etc/timezone:/etc/timezone:ro,或者在 Dockerfile 里装 tzdata 后 ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime。挂载的方式更省镜像体积,但要求宿主机本身配置正确——宿主机配错了,容器跟着一起错。

第二层:服务进程的环境变量

系统时区对了,不代表进程用的是它。glibc 在进程启动时会读 TZ 环境变量;如果没设置,就去读 /etc/localtime。也就是说,改完 /etc/localtime 之后,已经跑着的进程不会自动更新——它们在启动时已经把时区信息缓存进内存了。

# 看某个进程的时区环境
tr '\0' '\n' < /proc/$(pgrep -f php-fpm | head -1)/environ | grep -i '^TZ='

# 验证进程看到的当前时间(PHP)
php -r 'echo date("c"), PHP_EOL;'

# 验证 Python
python3 -c 'import time,datetime; print(time.tzname, datetime.datetime.now())'

改完系统时区,必须重启相关服务:systemctl reload-or-restart php-fpm nginx mysql。这一步经常被漏掉,症状是"明明改了时区,日志时间还是不对"。

systemd 服务单元里可以单独指定 Environment=TZ=Asia/Shanghai,但一般不需要——统一的系统时区更不容易出错。例外是那些必须用 UTC 记录的服务(日志采集、审计),这时候在 unit 里单独设 TZ=UTC 反而是好事,前提是你要记得它和别的日志时间不一致。

第三层:SSH 与终端

SSH 登录后的时间显示通常跟系统一致,因为 SSH 会话继承的是 sshd 的环境。但如果客户端传了 SendEnv TZ,服务端又开了 AcceptEnv TZ,你本机的时区就会被带进远程会话——同一个 shell 里 date 正常、跑脚本却是另一个时区。查法:

grep -i acceptenv /etc/ssh/sshd_config
echo $TZ

我一般把 AcceptEnv 里的 LANG 和 LC_* 保留、TZ 去掉。理由和后面讲 locale 的问题一样:服务器环境应该确定,不该被客户端影响。

第四层:cron 的环境

cron 的执行环境极其精简,PATH 只有 /usr/bin:/bin,TZ 通常没有设置。cron 启动时会按自己的方式解析时间——System V cron(Debian 上的 cron 包)和 systemd timer 的行为还不一样。

Debian/Ubuntu 的 cron 支持在 crontab 顶部写 CRON_TZ=Asia/Shanghai,让这个 crontab 里的时间按指定时区解释。但要注意,这只是解释调度时间的时区,不会改脚本内部看到的 TZ。CRON_TZ 用错了会出现"每天凌晨 2 点跑"变成"每天 UTC 2 点跑"的偏差。

# crontab -e 顶部
CRON_TZ=Asia/Shanghai
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin

# 每天凌晨 2:30(按上海时间)
30 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

更稳的做法是在脚本的第一行显式指定:

#!/bin/bash
export TZ=Asia/Shanghai
export LANG=zh_CN.UTF-8
export LC_ALL=zh_CN.UTF-8
cd /opt/scripts || exit 1
umask 022

脚本自己把环境钉死,不管谁调用、从哪调用,行为都一致。这是我所有定时脚本的固定开头。

systemd timer 则完全按系统时区解释 OnCalendar,且 Persistent=true 能在机器关机错过时间后补跑一次——这一点 cron 做不到。如果你有"必须每天跑一次、错过也要补"的任务(备份、证书检查),timer 比 cron 可靠。

第五层:应用的时区配置

PHP、MySQL、Redis、Nginx,各自都有独立的时间配置。这才是最容易出错的地方。

PHP:date.timezone 在 php.ini 里。不设置的话 PHP 8 会警告并按 UTC 处理。检查:

php -i | grep -i "date.timezone"
php -r 'echo date_default_timezone_get(), " ", date("Y-m-d H:i:s"), PHP_EOL;'

代码里再补一层 date_default_timezone_set('Asia/Shanghai'); 也不多余。我见过 php.ini 改了但 php-fpm 没重启,结果 CLI 正常、网站不对的情况。

MySQL:时区有三个层次——服务器全局 time_zone、会话级、以及列的存储类型。TIMESTAMP 类型在存储时转成 UTC,读取时按会话时区转回来;DATETIME 类型则原样存储,不做任何转换。这两者的差异是所有时间 bug 的根源。

SELECT @@global.time_zone, @@session.time_zone, NOW(), UTC_TIMESTAMP();

# my.cnf 里设置(需要加载时区表)
[mysqld]
default-time-zone = '+08:00'

用 +08:00 偏移量而不是 Asia/Shanghai,是因为后者需要先把时区表导入(mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql mysql)。如果没导入,设置成名字会报 Unknown or incorrect time zone,而那报错很可能只在某次重启后才暴露——因为偏移量写法不需要时区表。这个区别值得记住:偏移量永远能工作,名字要依赖数据。

更稳的策略是:数据库统一存 UTC,展示层再转成本地时间。这样换机房、改时区、跨时区访问都不会让历史数据错位。我的站早年用 DATETIME 存本地时间,后来改 UTC 时不得不写了一个迁移脚本逐行换算,那次教训之后,新表全部用 TIMESTAMP 或存 UTC 时间戳。

Redis:本身不存时区,但 TIME 命令返回的是服务器时间。EXPIRE 用的是相对时间,不受时区影响,这点没问题。

Nginx:access log 里的 $time_local 用服务器时区,$time_iso8601 带偏移量。两个都要注意——做日志分析时如果换了时区,新旧日志的格式就对不上了。建议日志里统一用 $time_iso8601,带明确的 +08:00 后缀,后续解析不用猜。

夏令时与偏移量:一个不太显眼的边界

国内不用夏令时,但如果服务器放在海外机房、或者站上有面向海外用户的板块,夏令时切换就是必须考虑的情况。用 +08:00 这种固定偏移量的写法,在需要夏令时的地区是错的——美国东部三月到十一月是 -04:00,其余是 -05:00。固定写死 -05:00,夏季就会差一小时。

所以我的原则是:能用时区名就用时区名(Asia/Shanghai),只在下面两种情况下用偏移量——一是 MySQL 没导入时区表、只能用偏移量的场景;二是日志格式里需要机器可读的显式偏移($time_iso8601 自动带出,不用手写)。

# 检验一台机器是否真的按名解析时区
TZ=America/New_York date -d '2026-07-01 12:00' '+%Y-%m-%d %H:%M %Z'
TZ=America/New_York date -d '2026-12-01 12:00' '+%Y-%m-%d %H:%M %Z'
# 两次输出的 %Z 应该不同(EDT / EST),相同说明时区数据有问题

时区数据库本身也会更新。比如某些国家修改过夏令时规则、或者新增了时区名,tzdata 包需要定期升级。这事儿平时不用管,但如果你跑着跨时区的定时任务,一次系统升级把 tzdata 升上去之后,某些任务的时间点可能发生偏移。我把它归到"每次系统大版本升级后顺手核对一遍"的清单里。

实际排查顺序

遇到时间不对,我固定按这个顺序走,一般五分钟内能定位:

一是 timedatectl 看系统时间对不对。二是 date -u 和 date 对比,确认时区设置生效。三是找具体出问题的那个进程,读它的 /proc/PID/environ 看 TZ。四是跑一行最小复现代码(php -r 'echo date("c");')确认应用层的时间。五是查应用的配置文件。六是对比数据库写入值和页面显示值,确定错位发生在哪一侧。

顺序背后的逻辑是:时间从内核往应用单向下传,逐层排查比跳着猜快。跳着猜最容易出现的结局是——你好不容易改对了某一层,结果因为另一层本来就错,现象完全没变化,于是误判"这个改动没用"。

几个反直觉的结论

第一,改了 /etc/localtime 必须重启服务,运行中的进程不会跟着变。第二,cron 的 TZ 和脚本的 TZ 是两回事,前者管调度、后者管内容。第三,MySQL 的 TIMESTAMP 和 DATETIME 不是可互换的,前者带时区语义、后者不带,混用迟早出问题。第四,容器不继承宿主机时区,除非显式挂载。第五,也是最容易被忽略的:有些 PHP 扩展和第三方库会自己调 date_default_timezone_set,把你的设置改掉,出问题时值得全局搜一遍代码。

小结

时区问题的本质不是"配置难",而是"层次多、状态隐式"。我的应对思路很简单:系统层用 timedatectl 一次性配好并确认 RTC 存 UTC;每个定时脚本开头显式导出 TZ、LANG、LC_ALL;数据库统一存 UTC、展示层转换;日志统一用带偏移量的 ISO8601 格式。四条规定下来,时区相关的故障基本就清零了。

Last modification:November 14th, 2026 at 09:25 pm

Leave a Comment