夏令时不是「差一小时」这么简单
做中文站点的站长往往觉得夏令时与自己无关——国内不实行夏令时,服务器时区也统一设成 Asia/Shanghai,不就行了?但只要你的站点有海外流量、用了海外 CDN、在海外机器上跑定时任务,或者接入了面向欧美的第三方服务,夏令时就会在某两个凌晨给你制造一批难查的故障。
典型的现场是这样的:某天凌晨定时任务没有按预期执行,或者一条订单的时间戳比预期早了一小时,或者日志里出现了两个相同的 02:30 记录。这些现象分散在不同系统里,很容易被当成「偶发」,直到下一次切换再次出现才引起注意。
这篇把夏令时相关的问题拆成两类:一类是时间点重复和缺失,一类是时区标识用错。两类的修法完全不同。
第一类问题:那一小时里发生的事
在北半球,春天切换时时钟从 02:00 直接跳到 03:00,那一小时在本地时钟上不存在;秋天切换时从 03:00 退回 02:00,那一小时在本地时钟上出现两次。
这两件事对系统有不同的破坏方式。
春天的不存在时段:如果你在 crontab 里写了 02:30 执行备份,那么切换当天的 02:30 根本不会到来,任务直接跳过一天。更隐蔽的是应用层的「按本地时间判断」逻辑:比如「每天 02:30 生成日报」,代码里用 date('H:i') 和 '02:30' 比较,那天就永远不会命中。
秋天的重复时段:02:30 会来两次,crontab 里的 02:30 任务会在两个小时内各跑一次。如果这个任务是发邮件、扣费、同步数据,结果就是重复执行。而且两次执行的时间间隔看起来只有一小时,很多去重逻辑(比如「上次执行时间距今不足 12 小时则跳过」)正好失效。
最要命的是,这两个问题都不是每年都会暴露——只在切换那天的凌晨出现,如果那个任务本来是每天跑的,日志里就是「少了一条」或者「多了一条」,很容易被当成偶发抖动。
治本:所有存储和比较都用 UTC
解决这一整类问题的根本方法只有一条:在存储、计算、比较、传输这四个环节里,全部使用 UTC;只在展示给用户的那一刻才转成本地时间。
很多项目栽在「存本地时间」上。数据库里一个 DATETIME 字段写着 2026-10-28 02:30:00,你无法仅凭这个值判断它对应哪个绝对时刻——它可能是切换前的第一次,也可能是切换后的第二次。一旦出现重名,同一天的数据就分不清先后,排序、去重、增量同步全部失去依据。
正确的做法是:
-- 时间戳统一用 UTC 存储
INSERT INTO orders (created_at) VALUES (UTC_TIMESTAMP());
-- 查询时按 UTC 划范围,展示时在前端转本地时区
SELECT id FROM orders WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY);如果用时间戳类型(int/bigint 存 Unix 时间),那它本身就是 UTC 的秒数,天然没有这个问题,这是最省心的选择。需要注意的是 MySQL 的 TIMESTAMP 类型会受会话 time_zone 影响——它会自动在 UTC 和会话时区之间转换,看起来智能,实际上很容易在跨时区的主从或备份恢复时错一小时。要么统一把会话时区设为 +00:00,要么干脆用 BIGINT 存时间戳。
定时任务同理:crontab 的时间是服务器本地时区。如果服务器时区会随夏令时变化,那么「每天 02:30」这个语义本身是含糊的。解决办法是把服务器时区固定为 UTC,然后按业务需要换算:想在北京时间 07:00 跑,就在 UTC 的 23:00 跑。这样一年 365 天没有例外。
timedatectl set-timezone UTC
timedatectl status | grep -E 'Time zone|synchronized'服务器时区改成 UTC 之后,系统日志的时间戳会变成 UTC,看日志时要在脑子里减 8 小时,这确实麻烦,但换来的是「永远不会有那一个小时的问题」。很多海外 VPS 默认就是 UTC,不要急着改成 Asia/Shanghai,先想清楚改动会牵动什么。
第二类问题:时区标识写错,结果差了一整个小时
比夏令时本身更常见的,是时区名写错导致的固定偏移错误。这里有几个高频错误,每一个都值得单独说。
错误一:把 CST 当成「中国标准时间」。CST 是个三重歧义缩写,它同时可以是 China Standard Time(UTC+8)、Central Standard Time(美国中部,UTC-6/-5)、Cuba Standard Time(UTC-5)。在 Unix 环境和很多库的默认表里,CST 往往解析成美国中部时间。见到 CST 就要改,写 Asia/Shanghai 或者 +08:00,不要留模糊空间。
错误二:用 GMT+8 这种固定偏移代替时区名。固定偏移不会跟随夏令时规则变化。对一个本身有夏令时的地区(比如 America/New_York),写 GMT-5 在冬天是对的,在夏天就差一小时。这类错误只在夏天暴露,排查时又很容易被忽略。
错误三:PHP 的时区缩写解析。PHP 的 strtotime('2026-10-28 02:30 EST') 里,EST 会被当成固定的 -05:00,而不是 America/New_York 的时区规则,导致同一个字符串在夏天解析出来差一小时。要处理美东时间,一律写 America/New_York。
错误四:毫秒和秒搞混。这不是夏令时问题但经常同时出现。十位是秒,十三位是毫秒。JS 的 Date.now() 返回毫秒,PHP 的 time() 返回秒,跨语言传参时如果不统一,时间会差出几十年,表现出来是「数据全部过期」或者「日期是 1970」。
# 检查系统里所有出现过的时区写法,揪出可疑的缩写
grep -rnE '(CST|EST|PST|GMT[+-][0-9])' /etc/php/*/fpm/pool.d/ /etc/nginx/ /var/www/*/config/ 2>/dev/null | head -20这类 grep 往往能一次翻出好几个含糊的时区写法,改成 IANA 时区名(Asia/Shanghai、America/New_York、Europe/London)之后再核对一遍。
数据库时区:三层设置要一致
MySQL 这边的时区问题特别容易踩,因为涉及三层:系统时区、MySQL 服务端的 time_zone、以及连接会话的 time_zone。
MySQL 默认的 time_zone 是 SYSTEM,也就是跟随操作系统。如果服务器时区在某天被改过,MySQL 里所有 TIMESTAMP 字段的读写在那一刻前后就不一致了。建议显式设为 +00:00:
[mysqld]
default-time-zone = '+00:00'改完要重启 MySQL 才生效,而且要注意:已有的 TIMESTAMP 数据在切换时区后,读出来的值会变(因为存储是 UTC,展示按会话时区转换)。如果业务代码里假设「读出来的就是北京时间」,这个改动会让所有历史数据显示偏 8 小时。所以这个决定要在项目早期做,中期改要连带修代码。
排查当前状态:
SELECT @@global.time_zone, @@session.time_zone, NOW(), UTC_TIMESTAMP();
SHOW VARIABLES LIKE 'time_zone';NOW() 和 UTC_TIMESTAMP() 差 8 小时说明会话时区是 +08:00,差 0 说明是 UTC。两者的差值就是你要在心里做换算的那个数。
还有一个小坑:MySQL 的 CONVERT_TZ 函数依赖 mysql.time_zone_name 等几张表,如果这几张表是空的(很多精简安装默认不动它们),CONVERT_TZ 会静默返回 NULL,而不是报错。你的数据一条都不会丢,但排序和筛选结果全是空的,非常难查。检查方法:
SELECT COUNT(*) FROM mysql.time_zone_name;
SELECT CONVERT_TZ('2026-10-28 02:30:00','Asia/Shanghai','UTC');第一句返回 0 就说明时区表没导入,需要用 mysql_tzinfo_to_sql 从系统的 zoneinfo 导进去。
定时任务的四种写法,选最不容易错的那个
回到最开始的场景。同一件事——「每天北京时间 07:00 生成日报」——有四种实现,出错概率从高到低排列:
写法一:服务器本地时区 + crontab 写本地时间。最容易理解,但只要服务器时区不是 Asia/Shanghai 就错了,而且一旦有人改了系统时区,任务静默漂移。不推荐在会变的服务器上用。
写法二:服务器固定 UTC + crontab 写 UTC 时间。推荐。UTC 没有夏令时,crontab 的每一行都是确定的。0 23 * * * 就是北京时间的次日 07:00。唯一的不便是要换算,但换算只做一次,之后永远正确。
写法三:在脚本内部做时区判断。脚本里把当前时间转到目标时区,检查是否是目标小时,是才执行。这种写法适合「不想改系统时区」的存量环境,代价是要处理「任务本应在某个时刻跑,但那台机器当时是关机的」这种补跑逻辑。
写法四:用带时区的调度器。如果业务复杂到需要按用户所在时区定时(比如给不同时区的用户分别推送),crontab 就不够了,需要调度器支持时区参数。这种场景下,判断条件必须是「目标时区的本地时间」,而不是 UTC 时间加偏移。
三种写法里,最容易出事故的是第二种的一个变体:crontab 里写 UTC,但脚本内部又用 date() 取了本地时间做判断。调度是 UTC 的,逻辑是本地时区的,两者拼起来就是一个定时炸弹。判断标准:脚本里凡是出现 date()、strftime()、NOW() 的地方,都要确认它取的是哪个时区。
三个月的验证方式:把切换日当演练
夏令时的问题一年只有两次机会验证,所以更要提前准备。一个成本很低的做法是把切换日当成一次演练:在切换前一周,把系统的切换规则查出来,手工模拟一次。
# 查看某个时区接下来一年的切换点
zdump -v America/New_York | grep -E '2026|2027'zdump 会把每一次切换的 UTC 时刻和本地时刻都列出来,一眼就能看出 3 月和 11 月的那两个点。拿到切换点之后,可以临时把测试环境的系统时间调到切换前后(用 libfaketime 或者 test 环境改时区),把关键任务跑一遍,看输出是否符合预期。
另外两条低成本措施值得一直挂着:
日志里同时打两种时间。应用日志的时间戳用 ISO 8601 带偏移的格式,比如 2026-10-28T02:30:00+08:00,比裸的 2026-10-28 02:30:00 好查一个量级。Nginx 的 log_format 里用 $time_iso8601,它自带偏移,任何跨时区的事件都能一眼定位。
log_format main '$remote_addr - $time_iso8601 "$request" $status $body_bytes_sent';关键任务加「重复执行」保护。不管是秋天那一小时跑两次,还是运维手滑跑了两遍,幂等性都是最后的防线。最简单的方式是任务开始前写一个带日期标记的锁文件,或者在数据库里插一条唯一键记录,插不进去就说明今天已经跑过了。这个成本极低,但能挡住大部分重复执行事故。
给中文站点的实操结论
如果你的站只有国内用户、服务器在国内、所有任务都是给国内看的,最省事的配置是:服务器时区固定 Asia/Shanghai,数据库会话时区设为 +08:00,所有时间字段存 Unix 时间戳或者「转为 UTC 后存 DATETIME」,crontab 全部写北京时间。这套配置下不会踩夏令时,只要你不用 CST 这种歧义缩写、不在代码里写死 GMT+8 的偏移,就基本安全。
一旦有海外流量、海外 CDN 回源、或者与海外服务对接,就该切换到「全链路 UTC」:系统 UTC、数据库 UTC、日志带偏移、只在渲染时转本地。这个切换在项目小的时候做几乎不花成本,等项目有了几十张表几百万行数据,就变成一件需要排期两周的工程。
最后一条经验:时间问题的排查成本极高、修复成本极低,所以正确的策略是把时间格式统一这件事在项目第一天做掉,而不是等出事再回补。一个只写 time() 不写 date() 的项目,永远不会有夏令时问题。