PHP 语法进阶 17|日期与时间:时区、时间戳和那个 2038 年的问题

第一件事:设时区

<?php
date_default_timezone_set("Asia/Shanghai");

不设时区,PHP 会用 UTC 并抛一条警告(Warning: date_default_timezone_set() 没设过,或 date(): It is not safe to rely on the system's timezone settings)。新版本已经直接要求配置,php.ini 里就该写好:

date.timezone = Asia/Shanghai

不设时区的后果是"服务器跟客户端差 8 小时",而且这个 bug 常常在部署到另一台机器时才暴露。

时间戳是"绝对的",格式化才是"相对的"

<?php
$t = time();                   // 时间戳:自 1970-01-01 00:00:00 UTC 起的秒数
echo $t, PHP_EOL;              // 例如 1791436800

echo date("Y-m-d H:i:s", $t), PHP_EOL;          // 2026-11-08 12:00:00(本地时区)
echo gmdate("Y-m-d H:i:s", $t), PHP_EOL;        // 2026-11-08 04:00:00(UTC)

同一个时间戳,在不同时区格式化出不同字符串。所以:

  • 存储用时间戳或 UTC 字符串(绝对时间,无歧义)。
  • 展示时才转本地时区。

把 2026-11-08 12:00:00 这种字符串直接入库、并且不注明时区,是跨时区系统里最经典的坑。

date() 的格式化字符

常用的:

字符 含义 示例
Y 四位年 2026
y 两位年 26
m 两位月 11
n 月,无前导零 11
d 两位日 08
j 日,无前导零 8
H 24 小时制 14
h 12 小时制 02
i 分 30
s 秒 45
u 微秒(6 位) 123456
U 时间戳 1791436800
w 星期,0=周日 0
N 星期,1=周一 7
t 当月天数 30
L 是否闰年 0
z 一年中的第几天 311
<?php
echo date("Y-m-d H:i:s");
echo date("N");                      // 今天星期几(1~7)
echo date("Y-m-t");                  // 本月最后一天,如 2026-11-30

date("Y-m-t") 这个组合很实用:t 返回当月的天数,所以 Y-m-t 就是"本月最后一天",不管你当前是哪个月。

strtotime:好用,但坑不少

<?php
strtotime("2026-11-08 10:00:00");      // 绝对时间
strtotime("+1 day");
strtotime("next monday");
strtotime("last day of next month");
strtotime("-30 minutes");
var_dump(strtotime("not a date"));     // false

失败返回 false,不是 0。所以必须判断:

<?php
$ts = strtotime($input);
if ($ts === false) {
    throw new InvalidArgumentException("无法解析的时间:{$input}");
}

几个真实的坑:

一、带斜杠的日期按美式解析。

<?php
var_dump(date("Y-m-d", strtotime("11/08/2026")));   // 2026-11-08(月/日/年)
var_dump(date("Y-m-d", strtotime("08/11/2026")));   // 2026-08-11

11/08/2026 是"月/日/年",不是"日/月/年"。用户输入 08/11/2026 想表达 8 月 11 日,结果对上了;但输入 11/08 想表达 8 月 11 日,就错了。带分隔符的日期一律用 Y-m-d 格式,或者用 DateTime::createFromFormat() 明确指定:

<?php
$dt = DateTime::createFromFormat("d/m/Y", "11/08/2026");
var_dump($dt->format("Y-m-d"));     // 2026-08-11

二、strtotime("2026-02-30") 不报错。

<?php
echo date("Y-m-d", strtotime("2026-02-30"));    // 2026-03-02  自动"滚动"了

PHP 会把非法日期往后推。要严格校验:

<?php
function parseStrictDate(string $s, string $fmt = "Y-m-d"): DateTimeImmutable
{
    $dt = DateTimeImmutable::createFromFormat($fmt, $s);
    $err = DateTimeImmutable::getLastErrors();
    if ($dt === false || ($err && ($err['warning_count'] > 0 || $err['error_count'] > 0))) {
        throw new InvalidArgumentException("非法日期:{$s}");
    }
    return $dt;
}

parseStrictDate("2026-02-30");   // 抛异常

三、"一个月后"不总是加 30 天。

<?php
// 1 月 31 日 + 1 个月 = ?
$t = strtotime("2026-01-31 +1 month");
echo date("Y-m-d", $t);    // 2026-03-03  —— 2 月没有 31 日,滚到下个月了

这个行为让"会员到期日"算错。需要"月末对齐"就用:

<?php
$d = new DateTimeImmutable("2026-01-31");
echo $d->modify("last day of next month")->format("Y-m-d");   // 2026-02-28

DateTime vs DateTimeImmutable

<?php
$d = new DateTime("2026-11-08");
$d->modify("+1 day");          // 改了原对象
echo $d->format("Y-m-d");      // 2026-11-09

$di = new DateTimeImmutable("2026-11-08");
$next = $di->modify("+1 day"); // 返回新对象,原对象不变
echo $di->format("Y-m-d");     // 2026-11-08

一律用 DateTimeImmutable。原因很简单:DateTime 是可变对象,传参给别人时对方改了你的变量,你完全察觉不到(对象是引用语义)。这类 bug 极难排查。

<?php
function nextWeek(DateTimeImmutable $d): DateTimeImmutable
{
    return $d->modify("+7 days");        // 调用方的 $d 不受影响
}

时区转换

<?php
$utc = new DateTimeImmutable("2026-11-08 04:00:00", new DateTimeZone("UTC"));

echo $utc->format("Y-m-d H:i:s"), PHP_EOL;                     // 04:00:00
echo $utc
    ->setTimezone(new DateTimeZone("Asia/Shanghai"))
    ->format("Y-m-d H:i:s"), PHP_EOL;                          // 12:00:00

echo $utc
    ->setTimezone(new DateTimeZone("America/New_York"))
    ->format("Y-m-d H:i:s"), PHP_EOL;                          // 23:00:00(前一天)

构造时传时区非常重要。不传的话用的是默认时区,结果依赖 date_default_timezone_set(),很容易在另一台机器上出问题。

<?php
// ✗ 依赖全局时区
new DateTime("2026-11-08 10:00:00");

// ✓ 明确指定
new DateTimeImmutable("2026-11-08 10:00:00", new DateTimeZone("Asia/Shanghai"));

计算间隔

<?php
$start = new DateTimeImmutable("2026-01-01");
$end   = new DateTimeImmutable("2026-11-08");

$diff = $start->diff($end);
echo $diff->y, ' 年 ', $diff->m, ' 月 ', $diff->d, ' 天', PHP_EOL;
echo $diff->days, ' 天(总天数)', PHP_EOL;
echo $diff->format('%a 天'), PHP_EOL;

diff 返回 DateInterval,->days 是总天数。计算"天数差"用 ->days,不要用 ->d(->d 是"扣掉年月后剩下的天数")。

年龄计算就用这个:

<?php
function age(DateTimeImmutable $birth, ?DateTimeImmutable $now = null): int
{
    $now ??= new DateTimeImmutable("now", new DateTimeZone("Asia/Shanghai"));
    return $birth->diff($now)->y;
}

比"时间戳除以 31536000"正确得多——后者不考虑闰年,会算错一天。

日期区间遍历

<?php
$start = new DateTimeImmutable("2026-11-01");
$end   = new DateTimeImmutable("2026-11-10");
$period = new DatePeriod($start, new DateInterval("P1D"), $end);

foreach ($period as $day) {
    echo $day->format("Y-m-d"), PHP_EOL;
}

DateInterval 的字符串格式是 ISO 8601 duration:P1D = 1 天,PT1H = 1 小时,P1M = 1 个月,P1Y2M3DT4H5M6S 是年/月/日/时/分/秒。

注意 DatePeriod 的结束时间是"不含"的(左闭右开)。要包含最后一天,得把 $end 加一天。

数据库怎么存

三种选择:

类型 时区敏感 范围 建议
TIMESTAMP 是(随会话时区转换) 1970 ~ 2038 少用
DATETIME 否(存什么读什么) 1000 ~ 9999 推荐
BIGINT(时间戳) 否 极大 也可,但查询不便

推荐做法:库里存 UTC 的 DATETIME,取出时再转本地时区。

<?php
// 写入
$pdo->prepare("INSERT INTO logs (created_at) VALUES (?)")
    ->execute([(new DateTimeImmutable("now", new DateTimeZone("UTC")))
        ->format("Y-m-d H:i:s")]);

// 读取后转本地
$dt = new DateTimeImmutable($row['created_at'], new DateTimeZone("UTC"));
echo $dt->setTimezone(new DateTimeZone("Asia/Shanghai"))->format("Y-m-d H:i");

2038 问题

32 位系统上,时间戳是 int32,最大能表示到 2038-01-19 03:14:07 UTC。再往后会溢出成负数,日期变成 1901 年。

<?php
echo date("Y-m-d", 2147483647);       // 2038-01-19
echo date("Y-m-d", 2147483648);       // 1901-12-13(32 位环境)

64 位 PHP 没有这个问题(时间戳是 int64,够用到几十亿年后)。现在的服务器基本都是 64 位,但嵌入式设备、老式的 32 位 ARM 环境仍要注意。

微秒与高精度

<?php
$t = microtime(true);                  // 浮点秒数
printf("%.6f\n", $t);

$dt = DateTimeImmutable::createFromFormat("U.u", sprintf("%.6f", $t));
echo $dt->format("Y-m-d H:i:s.u");

microtime(true) 返回浮点数,用于测耗时。计算耗时用 hrtime() 更准(单调时钟,不受系统时间调整影响):

<?php
$t0 = hrtime(true);       // 纳秒
// ... 干活
printf("耗时 %.2f ms\n", (hrtime(true) - $t0) / 1e6);

夏令时:一年里有"不存在"和"重复"的小时

<?php
$tz = new DateTimeZone("Europe/London");

// 2026-03-29 01:00 跳到 02:00,01:30 不存在
// 2026-10-25 02:00 退回 01:00,01:30 出现两次

中国不用夏令时,所以国内业务基本碰不到。但做国际化或服务器在海外时,这个问题会导致"每天定时任务某天执行两次或一次都没执行"。

避免方法:定时任务的时间基准用 UTC,不要用本地时间。展示时才转本地。

坑清单

  1. 不设 date.timezone,时间和客户端差 8 小时。
  2. strtotime 失败返回 false,不判断就当成时间戳 0。
  3. 11/08/2026 被按"月/日/年"解析。
  4. 2026-02-30 不报错,自动滚到 3 月 2 日。
  5. +1 month 从 1 月 31 日滚到 3 月 3 日。
  6. 用 DateTime 而不是 DateTimeImmutable,对象被意外修改。
  7. 用 ->d 算总天数,得到的是扣掉年月后的天数。
  8. 用"时间戳 / 86400 / 365"算年龄,闰年会错。
  9. 存本地时间字符串且不注明时区。
  10. 跨时区的定时任务用本地时间,夏令时那天出问题。
  11. 32 位环境下计算 2038 年之后的时间戳。