ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PHP处理日期时间怎么避免时区问题

PHP处理日期时间怎么避免时区问题 前言时区问题最讨厌的地方是它不一定报错。它可能只是让今天变成了昨天、让订单时间差了 8 小时、让统计报表在跨月那天的数字对不上。典型症状有这么几类数据库里存的时间和服务器上date命令的输出差 8 小时或别的整数小时用户在北京看到的时间正常用户在其他时区看到的时间整体偏移本地开发环境一切正常上线后全线偏移——因为开发机与服务器上php.ini的date.timezone设置不同每年夏令时切换的那两天某个定时任务的执行时间莫名其妙早了一小时加一个月算出来的日期跳到了下下个月。这些问题的共同根源是时间的三种表示被混用了。时间戳timestamp是绝对时刻本地时间是某时区的墙上时钟读数时区标识决定了这两者之间怎么换算。任何时候把墙上时钟读数当成绝对时刻来运算或者反过来就会出问题。本文按概念 → 默认时区 → 不可变对象 → 时区运算 → 数据库边界的顺序给出一套能直接用的写法。示例最低要求 PHP 8.1因为用到了初始化器中的 new这一 8.1 语法糖即把new DateTimeZone(UTC)直接写成参数默认值文中涉及的日期时间 API 在 PHP 7.x 上就已存在。需要提醒的是PHP 8.1 起strftime()与gmstrftime()已被标记废弃做本地化格式化不要再依赖它们。一、三种表示别混用表示例子是否含时区信息用途Unix 时间戳1704067200是隐含 UTC绝对时刻传输、比较、存储的最终形态本地时间字符串2024-01-01 08:00:00否展示、日志带时区的时刻对象DateTimeImmutableDateTimeZone是运算、换算核心规则只有一句在系统内部流转和存储的应该是绝对时刻只在最终展示给用户的边界上才换算成本地时间字符串。PHP 侧对应三个函数行为差别很大date_default_timezone_set(Asia/Shanghai); echo date(Y-m-d H:i:s), PHP_EOL; // 2024-01-01 08:00:00按默认时区 echo gmdate(Y-m-d H:i:s), PHP_EOL; // 2024-01-01 00:00:00恒按 UTC echo time(), PHP_EOL; // 绝对时刻与时区无关date()用默认时区gmdate()恒用 UTC。这两个函数长得几乎一样混用就是偏移量 Bug 的源头。二、默认时区作用域与未设置的默认值date.timezone这个 ini 项没配置时PHP不会报错、不会告警而是直接按 UTC 处理date_default_timezone_get()会返回UTC。这在 PHP 5.4 之后就一直是这个行为所以很多人根本没意识到自己的站点跑在 UTC 上。带来两个连锁反应CLI 与 FPM 的 php.ini 往往不是同一个文件两边date.timezone不一致于是命令行跑脚本没问题、走 HTTP 就偏移。MySQL 的会话时区又与 PHP 无关。PDO 连接建立时MySQL 用的是服务器系统时区或配置的时区于是 PHP 写进去的本地时间和 MySQL 自己生成的NOW()可能差好几个小时。规范做法有三条在 php.ini 里显式设置date.timezone在应用入口再调一次date_default_timezone_set()兜底因为有些环境是date.timezone 以及数据库连接后立刻把会话时区固定为 UTC。三条都做才能保证不论部署在哪台机器上行为一致。; php.ini显式设置不要留空 date.timezone Asia/Shanghai?php declare(strict_types1); // 应用入口兜底php.ini 没配或配错时不至于走成 UTC if (ini_get(date.timezone) || ini_get(date.timezone) false) { date_default_timezone_set(Asia/Shanghai); }注意date_default_timezone_set()是全局副作用它改的是进程级默认值之后所有date()、strtotime()、new DateTime(now)都受影响而且在同一个请求里多次调用会让不同代码段看到不同时区。所以它只应该在入口调用一次业务代码里一律用显式带时区的对象。三、DateTimeImmutable别让共享实例被改坏DateTime的modify()、setTimezone()、add()都是原地修改而且返回$this。这会造成一种很隐蔽的 Bug把同一个DateTime对象注入到多个地方某处调了一次modify其他地方看到的对象也变了。?php declare(strict_types1); $tz new DateTimeZone(Asia/Shanghai); $start new DateTime(2024-01-01 00:00:00, $tz); function showTitle(DateTime $d): string { return $d-modify(1 day)-format(Y-m-d); // 原地改了 $d } echo showTitle($start), PHP_EOL; // 2024-01-02 echo $start-format(Y-m-d), PHP_EOL; // 也是 2024-01-02原始值被污染了把DateTime全换成DateTimeImmutable同一段代码的输出会变成2024-01-02和2024-01-01——原始对象不被改动。新项目一律用DateTimeImmutable类型声明上优先写DateTimeInterface这样两种实现都能接收。还有一处差异要记住new DateTime(1704067200)这种用加时间戳的构造方式会忽略默认时区得到的对象时区固定为 UTC。想显示用户本地时间必须再setTimezone()$t new DateTimeImmutable(1704067200); echo $t-format(Y-m-d H:i:s), PHP_EOL; // 2024-01-01 00:00:00UTC echo $t-setTimezone(new DateTimeZone(Asia/Shanghai)) -format(Y-m-d H:i:s), PHP_EOL; // 2024-01-01 08:00:00四、时区运算日历运算与绝对时长的区别这是最容易出错的一类加一天和加 86400 秒不是一回事。modify(1 day)或add(new DateInterval(P1D))是日历运算把日期字段加 1墙上时钟读数保持不变。modify(86400 seconds)是绝对时长把时间戳加 86400物理上过了 24 小时。在夏令时切换的那一天两者会相差一小时。以America/New_York为例2024 年 3 月 10 日发生春季前跳02:00 直接跳到 03:00这一天只有 23 小时?php declare(strict_types1); // 本片段只用到 PHP 5.5 起就存在的 DateTime API $tz new DateTimeZone(America/New_York); $day new DateTimeImmutable(2024-03-10 00:00:00, $tz); $calendar $day-modify(1 day); // 日历运算3 月 11 日 00:00 $absolute $day-modify(86400 seconds); // 绝对时长3 月 11 日 01:00 echo 日历运算: , $calendar-format(Y-m-d H:i:s P), PHP_EOL; echo 绝对时长: , $absolute-format(Y-m-d H:i:s P), PHP_EOL; echo 这一天实际秒数: , $calendar-getTimestamp() - $day-getTimestamp(), PHP_EOL;最后一行输出8280023 × 3600而不是 86400。这就是跨夏令时算天数不准的原因你想表达明天同一时刻却用了加固定秒数。另一类陷阱是月末溢出。加一个月在遇到不存在的日期时不会报错而是继续往后溢出$jan31 new DateTimeImmutable(2024-01-31, new DateTimeZone(UTC)); echo $jan31-modify(1 month)-format(Y-m-d), PHP_EOL; // 2024-03-022024 年是闰年二月有 29 天1 月 31 日加一个月得到2 月 31 日PHP 把它规范化成 3 月 2 日。做按月扣费按月生成账单这类逻辑时必须自己定规则比如一律取当月最后一天不能依赖1 month。五、数据库边界统一存 UTC在边缘转换数据库里的时间字段有两种常见类型行为完全不同字段类型存储方式是否随时区变化建议DATETIME原样存墙上时钟读数不带时区否你写什么就是什么存 UTC 值TIMESTAMP写入时按会话时区转成 UTC 存读出时再转回会话时区是随会话时区变明确会话时区后再用否则容易双重转换TIMESTAMP的双重转换是经典事故PHP 已经算好 UTC 写进去MySQL 又按会话时区比如08:00再转一次读出时又转回来……一旦会话时区在中途变过同一条记录读出来的值就可能不同。规范做法是列类型用DATETIME值统一为 UTC并在连接建立后立刻固定会话时区。?php declare(strict_types1); // 最低要求PHP 8.0构造函数属性提升是 PHP 8.0 引入的 final class UtcClock { public function __construct( private DateTimeZone $zone new DateTimeZone(UTC), ) {} public function now(): DateTimeImmutable { return new DateTimeImmutable(now, $this-zone); } /** 存库格式不带时区后缀的 UTC 墙上时钟 */ public function forStorage(DateTimeInterface $t): string { return $t-setTimezone($this-zone)-format(Y-m-d H:i:s); } /** 从库里读出来必须显式指定 UTC否则会按默认时区解析 */ public function fromStorage(string $value): DateTimeImmutable { $t DateTimeImmutable::createFromFormat(Y-m-d H:i:s, $value, $this-zone); if ($t false) { $errors DateTimeImmutable::getLastErrors(); throw new InvalidArgumentException(无法解析时间 . json_encode($errors)); } return $t; } /** 展示给用户转成目标时区 */ public function forDisplay(DateTimeInterface $t, string $tzId): string { return $t-setTimezone(new DateTimeZone($tzId))-format(Y-m-d H:i:s P); } /** 对外输出ISO 8601带偏移量任何客户端都能正确解析 */ public function toIso8601(DateTimeInterface $t): string { return $t-setTimezone($this-zone)-format(DateTimeInterface::ATOM); } } $pdo new PDO(mysql:host127.0.0.1;dbnameapp;charsetutf8mb4, $user, $pass, [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, ]); // 关键把 MySQL 会话时区固定为 UTC避免隐式转换 $pdo-exec(SET time_zone 00:00); $clock new UtcClock(); $now $clock-now(); echo $clock-forStorage($now), PHP_EOL; // 2024-01-01 00:00:00 echo $clock-forDisplay($now, Asia/Shanghai), PHP_EOL; // 2024-01-01 08:00:00 08:00 echo $clock-forDisplay($now, America/New_York), PHP_EOL; // 2023-12-31 19:00:00 -05:00 echo $clock-toIso8601($now), PHP_EOL; // 2024-01-01T00:00:0000:00这套内部 UTC、边界转换的模型有三个好处数据库里的时间可以做纯数学比较不用考虑时区跨时区用户的排序天然正确将来要支持用户自定义时区时只需要改forDisplay()的目标时区数据不用迁移。常见坑点1. 直接比较date()和gmdate()的结果// ❌ 默认时区若不是 UTC这里判断的今天是错的 if (date(Y-m-d) gmdate(Y-m-d)) { /* 以为在同一天 */ }// ✅ 明确你要比较的是什么绝对时刻用时间戳业务日界线要指定时区 $todayInShanghai (new DateTimeImmutable(now, new DateTimeZone(Asia/Shanghai)))-format(Y-m-d);2. 用时间戳构造后直接格式化// ❌ 构造会忽略默认时区得到的永远是 UTC 读数 $t new DateTimeImmutable( . $row[created_ts]); echo $t-format(Y-m-d H:i:s); // 比用户预期少 8 小时// ✅ 先声明存储时区再转成展示时区 $t (new DateTimeImmutable( . $row[created_ts]))-setTimezone(new DateTimeZone(Asia/Shanghai)); echo $t-format(Y-m-d H:i:s);3. 把DateTime对象到处传被某处modify()改坏// ❌ DateTime::modify() 原地修改并返回 $this共享实例会被污染 function nextDay(DateTime $d): DateTime { return $d-modify(1 day); }// ✅ 用 DateTimeImmutable每次运算返回新对象 function nextDay(DateTimeImmutable $d): DateTimeImmutable { return $d-modify(1 day); }4. 用1 month处理月末// ❌ 2024-01-31 加一个月得到 2024-03-02账单周期直接错位 $next $date-modify(1 month);// ✅ 显式定义月末规则比如一律取目标月份的最后一天 // first day of next month 落到下月 1 号再用 last day of this month 回到该月最后一天 $next $date-modify(first day of next month)-modify(last day of this month);5. 用86400 seconds表示加一天// ❌ 跨夏令时的日子只有 23 小时加固定秒数会得到 01:00 而不是 00:00 $tomorrow $day-modify(86400 seconds);// ✅ 日历语义用日历运算 $tomorrow $day-modify(1 day);6. MySQL 会话时区没固定// ❌ PDO 连上就用MySQL 按自己的时区生成 NOW()与 PHP 的 UTC 差几个小时 $pdo-exec(INSERT INTO orders (created_at) VALUES (NOW()));// ✅ 连接后立刻固定会话时区并统一由应用写入时间 $pdo-exec(SET time_zone 00:00); $stmt $pdo-prepare(INSERT INTO orders (created_at) VALUES (?)); $stmt-execute([$clock-forStorage($clock-now())]);7. 用strftime()做本地化格式化// ❌ PHP 8.1 起 strftime() 已废弃会产生 Deprecated 提示 echo strftime(%Y年%m月%d日, $ts);// ✅ 用 date()/format() 拼装或用 intl 扩展的 IntlDateFormatter需 intl 扩展 echo (new DateTimeImmutable( . $ts))-format(Y年m月d日);8. 用microtime()测耗时// ❌ microtime 是墙上时钟NTP 校时会回拨可能算出负数耗时 $start microtime(true); doWork(); printf(耗时 %.3f 秒\n, microtime(true) - $start);// ✅ 单调时钟不受系统时间调整影响 $start hrtime(true); doWork(); printf(耗时 %.3f 秒\n, (hrtime(true) - $start) / 1e9);9. 把带偏移的 ISO 字符串当命名时区// ❌ 解析后对象内部是固定偏移 08:00不是 Asia/Shanghai不处理夏令时 $t new DateTimeImmutable(2024-03-10T12:00:0008:00); echo $t-format(e), PHP_EOL; // 08:00// ✅ 需要按地区规则含夏令时运算时显式设置命名时区 $t (new DateTimeImmutable(2024-03-10T12:00:0008:00)) -setTimezone(new DateTimeZone(Asia/Shanghai)); echo $t-format(e), PHP_EOL; // Asia/Shanghai总结关注点规范做法反例默认时区php.ini 显式设置 入口兜底一次留空等于 UTC却以为用的是本地时区对象类型一律DateTimeImmutable声明用DateTimeInterface到处传DateTime被modify()改坏时间戳用getTimestamp()比较和传输用格式化后的字符串比较日历运算modify(1 day)、DateIntervalmodify(86400 seconds)代替一天月末逻辑显式定义规则取当月最后一天依赖1 month的溢出行为数据库列类型DATETIME存 UTC连接后SET time_zone 00:00混用TIMESTAMP与会话时区发生双重转换边界转换只在展示层setTimezone()在业务逻辑中间层来回转换耗时测量hrtime(true)单调时钟microtime(true)受系统校时影响一句话结论避免时区问题不需要记住多少函数只需要守住一条架构纪律——系统内部只流转绝对时刻时间戳或带时区的对象存储统一为 UTC本地时间字符串只在给用户看的最后一厘米出现。再配合入口固定默认时区、连接固定数据库会话时区、运算分清朝历语义与绝对时长这三条跨时区、跨夏令时、跨月末的绝大多数问题都会在设计阶段被消掉。
返回列表