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