02A|时区与时间字段:避免悄悄偏移 8 小时

时区错误最麻烦的地方,是程序通常不会立即报错。数据库仍能插入,TypeScript 仍能通过,接口也能返回字符串,但显示时间、锁定截止时间或统计日期可能已经偏移了 8 小时。

本章结合当前项目回答:


1. 先区分四个概念

1.1 时间点 instant

“管理员在某一刻完成登录”是一个确定的时间点。不论人在北京、伦敦还是纽约,这个事件只发生了一次。

下面两个 ISO 8601 字符串表示同一时刻:

2026-09-08T20:00:00.000+08:00
2026-09-08T12:00:00.000Z

Z 表示 UTC,也就是 +00:00。北京时间是 UTC+8,所以北京时间 20:00 等于 UTC 12:00。

1.2 墙上时间 wall-clock time

下面只是一个日历和时钟读数:

2026-09-08 20:00:00

它没有说明时区,无法单独确定一个时间点。它可能表示北京时间 20:00,也可能表示伦敦时间 20:00。

MySQL DATETIME 保存的就是这种无时区的年月日时分秒。

1.3 UTC offset

偏移量说明当地时间与 UTC 的差值:

+08:00

它可以把墙上时间转换成时间点,但它只表示一个数值差,不完整表达地区历史规则。

1.4 IANA 时区

IANA 时区名称表达一个地区的时区规则:

Asia/Shanghai
America/New_York
Europe/London

有夏令时或历史时区变化时,地区名比固定 offset 更准确。中国当前统一使用 UTC+8,没有现行夏令时,但业务规则和用户展示仍推荐使用 Asia/Shanghai 表达“北京时间”,而不是依赖服务器所在地。


2. 当前项目的时间链路

当前 db.ts 明确配置:

const db = new Sequelize({
  // 其他配置
  dialect: "mysql",

  // 数据库读写和 MySQL 会话统一使用 UTC。
  timezone: "+00:00",
});

当前 services/admin-user.mapper.ts 使用:

last_login_at: admin.last_login_at?.toISOString() ?? null,
created_at: admin.created_at.toISOString(),

这套组合表达的项目策略是:

MySQL DATETIME 字面值按 UTC 解释
  ↓
Sequelize 使用 +00:00 读写并设置连接 Session
  ↓
JavaScript Date 保存确定时间点
  ↓
API 用 toISOString() 返回带 Z 的 UTC
  ↓
客户端按 Asia/Shanghai 或用户时区展示

这是常见且推荐的事件时间处理方式。关键不是 UTC 天生“更正确”,而是写入、读取和输出规则清晰一致。


3. JavaScript Date 没有“北京时间属性”

JavaScript Date 内部保存的是从 Unix Epoch 开始的毫秒数,也就是一个时间点。它不会永久记住输入字符串原来写的是 Z 还是 +08:00。

const utc = new Date("2026-09-08T12:00:00.000Z");
const beijing = new Date("2026-09-08T20:00:00.000+08:00");

console.log(utc.getTime() === beijing.getTime());
// true

同一个 Date 可以按不同地区展示:

const value = new Date("2026-09-08T12:00:00.000Z");

console.log(value.toISOString());
// 2026-09-08T12:00:00.000Z

console.log(
  new Intl.DateTimeFormat("zh-CN", {
    timeZone: "Asia/Shanghai",
    dateStyle: "medium",
    timeStyle: "medium",
  }).format(value),
);
// 2026年9月8日 20:00:00

toISOString() 做了什么

toISOString() 永远输出 UTC,并使用 Z:

2026-09-08T12:00:00.000Z

它不会把正确时间“减去 8 小时”。它只是用 UTC 表示同一个时间点。

JSON 也经常自动变成 UTC

如果直接序列化 Date:

JSON.stringify({ created_at: new Date() });

Date.prototype.toJSON() 通常会调用 toISOString(),因此结果也带 Z。不要因为开发电脑位于中国,就假定 res.json() 会自动输出北京时间。


4. MySQL 时区是连接级状态

MySQL 有全局默认时区,也有每条连接自己的 Session 时区:

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

可能看到:

@@global.time_zone  = SYSTEM
@@session.time_zone = SYSTEM
NOW(3)              = 2026-09-08 20:00:00.000
UTC_TIMESTAMP(3)    = 2026-09-08 12:00:00.000

这表示当前连接按系统的 UTC+8 运行。

另一条连接可以执行:

SET time_zone = '+00:00';

然后同一时刻得到:

NOW(3)           = 2026-09-08 12:00:00.000
UTC_TIMESTAMP(3) = 2026-09-08 12:00:00.000

因此下面这些连接可能同时拥有不同 Session 时区:

检查一次 MySQL 全局时区,不能证明所有应用连接都按同一时区读写。


5. CURRENT_TIMESTAMP 也依赖 Session 时区

当前表中有类似定义:

created_at DATETIME(3)
  NOT NULL
  DEFAULT CURRENT_TIMESTAMP(3)

当 INSERT 省略 created_at 时,MySQL 使用当前连接的 Session 时区计算 CURRENT_TIMESTAMP(3)。

假设真实时刻是北京时间 20:00:

UTC Session 写入 DATETIME:   2026-09-08 12:00:00.000
+08:00 Session 写入 DATETIME:2026-09-08 20:00:00.000

DATETIME 不保存“这条是 UTC、那条是北京时间”的标签。如果两个入口采用不同 Session 时区,同一列就会混入两套含义。

这也是为什么“默认值由数据库生成”仍然不等于“时区一定一致”。


6. DATETIME、TIMESTAMP 和 DATE 怎样选择

6.1 DATETIME(3)

created_at DATETIME(3) NOT NULL

特点:

适合当前项目的事件时间,只要所有入口严格遵守同一策略:

6.2 TIMESTAMP(3)

created_at TIMESTAMP(3) NOT NULL

MySQL 会根据 Session 时区在输入/输出时做转换,内部按 UTC 处理。但它仍然要求连接时区正确,而且常见范围限制比 DATETIME 更窄。

把字段改成 TIMESTAMP 并不能让团队从此不用理解时区,也不能自动修复已经混乱的历史 DATETIME。

当前项目没有必要只为时区问题立即改成 TIMESTAMP。

6.3 DATE

settlement_date DATE NOT NULL

生日、账期、营业日等业务日期不是一个精确瞬间,通常应使用 DATE:

birthday
settlement_date
business_date

不要为了“统一”把纯日期先转换成 UTC 时间点,否则跨时区展示时可能变成前一天或后一天。

6.4 地区日程

“每天北京时间 09:00 执行”不是单独一个 UTC 时间点。它更接近:

local_time = 09:00
time_zone  = Asia/Shanghai

如果未来支持多个地区,应保存业务所需的当地时间和 IANA 时区,运行时再计算下一次 UTC 执行时间。


7. 两套可行策略,只能选择一套

7.1 策略 A:数据库和 API 使用 UTC(推荐)

数据库 DATETIME:UTC 字面值
Sequelize:+00:00
API:带 Z 的 ISO 8601
展示:Asia/Shanghai

当前 db.ts 和 DTO Mapper 已经采用这个方向:

timezone: "+00:00"
created_at: admin.created_at.toISOString()

优点:

配套要求:

SET time_zone = '+00:00';

Migration、DBeaver 和 CLI 如果执行包含 NOW()、CURRENT_TIMESTAMP() 的 DML,也应使用 UTC Session。

7.2 策略 B:数据库 DATETIME 保存北京时间

中国单地区业务也可以明确选择:

数据库 DATETIME:北京时间字面值
Sequelize:+08:00
MySQL/DBeaver/CLI:+08:00
API:仍返回 UTC Z,或明确返回 +08:00

Sequelize 配置必须显式修改:

timezone: "+08:00"

其他写入连接也必须执行:

SET time_zone = '+08:00';

如果 API 继续调用 toISOString(),它仍会输出 UTC。这不是错误:

数据库:2026-09-08 20:00:00(按 +08:00 解释)
API:   2026-09-08T12:00:00.000Z

两者是同一时刻。

如果产品明确要求 API 也返回北京时间,必须带 offset:

2026-09-08T20:00:00.000+08:00

不要返回缺少时区的:

2026-09-08 20:00:00

由于当前项目没有日期库,建议仍让 API 返回 UTC ISO,把北京时间格式化留给前端。若未来必须生成带地区规则的 API 时间,应选择明确支持 IANA 时区的日期库,并解释引入依赖的作用。


8. 为什么当前项目更推荐 UTC

当前业务在中国,并不意味着数据库一定要保存北京时间。数据库保存的是事件时间,展示才面向用户。

管理员北京时间 20:00 登录时:

数据库保存:2026-09-08 12:00:00.000(约定为 UTC)
API 返回:  2026-09-08T12:00:00.000Z
前端显示:  2026-09-08 20:00:00(Asia/Shanghai)

用户看到的仍然是北京时间。

推荐继续保留当前:

timezone: "+00:00"

和:

toISOString()

真正要补齐的是:


9. 前后端完整处理链路

时区不能只由数据库或前端单独负责。一个时间点从用户输入到再次显示,通常会经过:

前端输入
  → HTTP 请求(带 Z 或 offset)
  → Controller 校验格式
  → Service 使用 Date 表示时间点
  → Sequelize 按统一时区写入
  → MySQL 保存统一语义的 DATETIME
  → Mapper 输出带 Z 的 ISO 8601
  → 前端按指定 IANA 时区展示

9.1 后端接收时间点

客户端提交登录过期时间、预约发生时刻等“时间点”时,必须携带 Z 或 offset:

{
  "starts_at": "2026-09-08T20:00:00.000+08:00"
}

Controller 或请求校验层负责拒绝没有时区信息、格式不合法的字符串;Service 再把合法输入转换为 Date:

interface CreateEventInput {
  starts_at: string;
}

function parseInstant(value: string): Date {
  // 简化示例:生产代码还应使用项目统一的校验器限制完整格式。
  const has_time_zone = /(Z|[+-]\d{2}:\d{2})$/i.test(value);
  const timestamp = Date.parse(value);

  if (!has_time_zone || !Number.isFinite(timestamp)) {
    throw new AppError(
      "VALIDATION_ERROR",
      "starts_at 必须是带 Z 或时区偏移的 ISO 8601 时间",
    );
  }

  return new Date(timestamp);
}

不要让 Repository 或 Model 猜测一个无时区字符串属于哪个地区。时区语义应在 HTTP 边界就明确。

9.2 Service 内部处理时间点

Service 中的事件时间应使用 Date、Unix 时间戳或其他明确表示时间点的类型进行比较:

const locked =
  admin.locked_until !== null &&
  admin.locked_until.getTime() > Date.now();

不要先格式化成北京时间字符串再比较。字符串是传输或展示形式,不适合作为核心时间运算模型。

对于“中国自然日订单数量”这类需求,Service 需要先根据业务时区计算查询区间,再把 UTC 起止时间交给数据访问层。不要让 Controller 临时加减 8 小时,也不要把规则散落在多条 SQL 中。

9.3 后端返回时间点

DTO 统一返回带时区信息的 ISO 8601。当前项目使用 UTC Z 最简单:

interface AdminUserDto {
  last_login_at: string | null;
}

function toAdminUserDto(admin: AdminUser): AdminUserDto {
  return {
    last_login_at: admin.last_login_at?.toISOString() ?? null,
  };
}

API 返回的是“真实发生时刻”,不是已经排版好的界面文案。除非 API 契约有明确需要,不建议后端返回 2026年9月8日 20:00 之类的展示字符串。

9.4 前端解析与展示

浏览器收到:

2026-09-08T12:00:00.000Z

可以明确按北京时间展示:

const formatter = new Intl.DateTimeFormat("zh-CN", {
  timeZone: "Asia/Shanghai",
  year: "numeric",
  month: "2-digit",
  day: "2-digit",
  hour: "2-digit",
  minute: "2-digit",
  second: "2-digit",
  hour12: false,
});

const text = formatter.format(new Date(admin.last_login_at));

国际业务则把 timeZone 换成用户或门店保存的 IANA 时区。不要默认依赖浏览器所在时区,因为客服代查订单、用户旅行和设备时区配置错误都会使结果不符合业务语义。

界面可同时展示时区,减少审计歧义:

2026-09-08 20:00:00(北京时间)
2026-09-08 08:00:00(America/New_York)

9.5 前端日期时间输入框的陷阱

HTML datetime-local 返回的值没有时区:

2026-09-08T20:00

它只能表示墙上时间。提交前必须先确定它属于哪个 IANA 时区,再转换成带 offset 或 Z 的时间点。国内固定北京时间的表单可以明确规定输入按 Asia/Shanghai 解释;国际预约则应让用户选择地区,或使用账户/门店已保存的时区。

不要简单拼接 Z:

// 错误:把用户输入的北京时间 20:00 冒充成 UTC 20:00,偏移了 8 小时。
const wrong = `${datetime_local_value}:00.000Z`;

原生 Date 不擅长把“某地区墙上时间”可靠转换成时间点,尤其是存在夏令时的地区。国际业务应使用项目统一、支持 IANA 时区和夏令时规则的日期方案,并用测试覆盖夏令时切换日。

9.6 纯日期不要经过 Date 往返

生日、结算日等 DATE 字段可在 API 中直接使用:

{
  "birthday": "1995-06-18"
}

前后端把它当作 YYYY-MM-DD 的业务日期,而不是先构造成午夜 Date 再调用 toISOString()。否则用户所在地改变时,日期可能前后移动一天。


10. 国内业务与国际业务的最佳实践

10.1 国内单时区业务

适用前提:用户、门店、结算和运营规则均明确以中国大陆时间为准。

推荐方案:

事件时间:DATETIME(3) 保存 UTC 字面值
Sequelize / MySQL Session:+00:00
API:UTC ISO 8601,例如 2026-09-08T12:00:00.000Z
前端默认展示:Asia/Shanghai
中国自然日统计:按 Asia/Shanghai 计算边界,再转换为 UTC 查询
纯日期:DATE + YYYY-MM-DD

原因:

对于当前项目,这是最合适的方案,因此应继续保留 timezone: "+00:00" 和 DTO 中的 toISOString()。

如果已有大量报表、旧系统或人工 SQL 已经约定 DATETIME 保存北京时间,也可以继续使用 +08:00,但它是兼容性选择。必须把这一约定写入文档,并统一 Sequelize、连接 Session、脚本、Migration、DBeaver 和测试。新项目不应仅因为业务在中国就默认选择该方案。

10.2 国际或多时区业务

国际业务必须区分“事件发生时刻”和“当地业务规则”。推荐方案:

事件时间:统一 UTC 存储和传输
用户/门店时区:单独保存 IANA 名称,例如 Europe/Paris
API:输入输出使用 Z 或明确 offset
前端:按查看者时区或业务地点时区展示,并标注时区
自然日统计:按目标业务时区计算 UTC 查询边界
地区日程:保存当地规则 + IANA 时区,必要时另存下一次 UTC 执行时刻

例如跨国门店预约至少要明确:

starts_at = 2026-11-01T13:00:00.000Z
time_zone = America/New_York

starts_at 用于排序、提醒和比较;time_zone 用于解释“门店当地时间”、计算后续重复日程和正确处理夏令时。只保存 -05:00 不够,因为纽约在不同季节可能使用不同 offset。

国际业务不能把所有用户都按服务器时区或 Asia/Shanghai 展示,也不能用固定的“加减小时数”处理夏令时。业务必须明确每个页面采用:

10.3 两类业务的核心差异

决策 国内单时区业务 国际多时区业务
事件时间存储 UTC UTC
API 时间点 带 Z 的 ISO 8601 带 Z 或 offset 的 ISO 8601,推荐统一 Z
默认展示 Asia/Shanghai 用户或事件地点的 IANA 时区
自然日边界 固定按 Asia/Shanghai 根据目标用户、门店或报表时区计算
是否保存地区时区 通常可由系统配置固定 通常需要为用户、门店或日程保存
夏令时 当前中国大陆通常无需处理 必须使用 IANA 规则并覆盖切换日测试

共同原则是:时间点统一,展示本地化,业务日期不强行时间点化,地区规则单独保存。


11. 时区错误会造成哪些业务问题

11.1 登录锁定提前或延后 8 小时

当前项目使用:

admin.locked_until.getTime() > now.getTime()

如果数据库中的 locked_until 本来是北京时间字面值,却被 Sequelize 按 UTC 解析,锁定时间就可能多出 8 小时。反方向混淆则可能让账号提前解锁。

11.2 审计日志和应用日志对不上

Node.js 日志通常使用 UTC ISO,DBeaver 查询出来却像北京时间。如果数据库值的语义没有记录,排查人员可能把同一个事件误认为发生了两次或相差 8 小时。

11.3 按日统计落入错误日期

UTC 16:30 是北京时间第二天 00:30。如果直接按 UTC 的日期部分统计“中国自然日”,记录会落入前一天。

存储使用 UTC 时,按中国自然日统计必须把业务边界转换为 UTC:

北京时间 2026-09-08 00:00
到
北京时间 2026-09-09 00:00

对应 UTC:

2026-09-07 16:00Z
到
2026-09-08 16:00Z

查询仍建议使用左闭右开:

WHERE created_at >= :start_at
  AND created_at < :end_at

不要对索引列随意包 DATE() 再过滤,否则可能影响索引使用。

11.4 定时任务在错误时刻执行

“每天 00:00”如果没有说明时区,部署到 UTC 服务器后可能在北京时间 08:00 执行。

定时规则必须声明:

Asia/Shanghai 00:00

11.5 Token 时间与数据库时间难以比较

JWT 的 iat、exp 是 Unix 时间戳,本身代表 UTC 时间点,不依赖显示时区。如果数据库 locked_until 被错误解析,可能出现 JWT 已经过期但数据库状态看起来仍在未来等错觉。

11.6 缓存和消息系统顺序异常

Redis TTL、消息时间戳和多数云日志通常使用时间点或 UTC。数据库如果混入本地无时区值,跨系统排序和延迟计算会出现错误。


12. Sequelize timezone 到底影响什么

MySQL 方言下,Sequelize 的 timezone 主要影响:

它不负责:

不要只设置操作系统的 TZ 就认为 Sequelize 和 MySQL 已经统一。数据库 Session 和应用格式化都应显式配置和验证。


13. TypeScript 类型也不能证明时区正确

模型中:

declare last_login_at: Date | null;

TypeScript 只能证明代码把它当作 Date 或 null 使用,不能证明:

下面两个值类型都是 Date,但其中一个可能因为错误解析而偏移 8 小时。

因此时区策略属于运行时数据契约,必须通过配置、文档和集成测试保证。

不要轻易打开 mysql2 的全局 dateStrings 后仍把 Model 属性声明成 Date。如果驱动改为返回字符串,TypeScript Model、Mapper 和全部日期逻辑都必须同步调整。


14. 输入时间必须带 Z 或 offset

推荐 API 输入:

2026-09-08T12:00:00.000Z
2026-09-08T20:00:00.000+08:00

不推荐:

2026-09-08 20:00:00

后者没有时区,不同运行环境可能按不同方式解释。

也不要依赖实现相关或容易误解的解析:

new Date("2026-09-08 20:00:00");

请求边界应验证 ISO 格式,并要求时间点必须带 Z 或明确 offset。解析后还要检查:

const timestamp = Date.parse(input);

if (!Number.isFinite(timestamp)) {
  throw new AppError("VALIDATION_ERROR", "时间格式不正确");
}

仅仅 Date.parse() 成功还不能证明输入满足产品规定,格式限制应在 validator 中明确完成。


15. DBeaver、CLI 和 Migration 的注意事项

只执行下面的 DDL 时,Session 时区通常不会改变列定义:

CREATE TABLE ...
ALTER TABLE ...
CREATE INDEX ...

但 Migration 如果包含下面内容,就会受 Session 时区影响:

UPDATE admin_user
SET created_at = CURRENT_TIMESTAMP(3);

如果项目采用 UTC,可以在执行前确认:

SET time_zone = '+00:00';

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

如果项目采用北京时间,则统一设置:

SET time_zone = '+08:00';

不要把 DBeaver 界面显示的本地时间直接当作数据库值的时区证据。应查询 Session 配置,并理解客户端驱动是否又做了一次格式化。

Migration 工具也必须使用和应用相同的连接时区设置,不能只配置 Web 服务。


16. 已有数据怎样迁移

改变 Sequelize timezone 不只是显示调整。对于 DATETIME,同一个数据库字面值会被按新的时区重新解释。

例如旧数据:

2026-09-08 12:00:00

旧约定是 UTC,它代表北京时间 20:00。若直接把读取配置改成 +08:00,它会被解释为北京时间 12:00,真实时间提前 8 小时。

不要直接全表加减 8 小时

危险操作:

UPDATE admin_user
SET created_at = DATE_ADD(created_at, INTERVAL 8 HOUR);

如果表中一部分数据本来就是北京时间,这条语句会再次把它们改错。

推荐迁移流程

  1. 备份数据库并确认可恢复;
  2. 列出所有时间列及其业务语义;
  3. 确认各批数据由 Sequelize、DBeaver、CLI 还是旧程序写入;
  4. 查询当时各写入入口的 Session 时区和版本配置;
  5. 用业务日志、审计记录或已知事件核对真实时刻;
  6. 把来源不同的数据划分为可证明的批次;
  7. 在测试库演练转换 SQL;
  8. 编写版本化数据 Migration,而不是只在 DBeaver 临时执行;
  9. 同时发布新的连接时区配置;
  10. 完成后做固定样本的往返验证。

无法证明历史值语义时,不要假装能够精确自动修复。应先记录不确定范围,再决定人工校正或业务容忍策略。


17. 时间往返集成测试

时区必须用真实 MySQL 测试,不能只 Mock Sequelize。

17.1 固定时间点写入再读出

UTC 策略下:

const instant = new Date("2026-09-08T12:00:00.123Z");

await admin.update({
  last_login_at: instant,
});

await admin.reload();

expect(admin.last_login_at?.getTime()).toBe(instant.getTime());
expect(admin.last_login_at?.toISOString()).toBe(
  "2026-09-08T12:00:00.123Z",
);

不要使用测试执行时的 new Date() 再模糊比较,因为测试耗时和数据库精度会增加噪声。

17.2 验证数据库默认时间

通过 Raw SQL 省略 created_at,让 CURRENT_TIMESTAMP(3) 真正执行,再读取并检查它接近数据库当前时间。

Model 中如果配置了 defaultValue: DataTypes.NOW,Sequelize 可能在应用侧生成并写入时间,这样的测试不能独立证明数据库默认值生效。

17.3 验证 Sequelize Session

import { QueryTypes } from "sequelize";

interface TimeZoneRow {
  session_time_zone: string;
}

const [row] = await db.query<TimeZoneRow>(
  `
    SELECT @@session.time_zone AS session_time_zone
  `,
  { type: QueryTypes.SELECT },
);

expect(row?.session_time_zone).toBe("+00:00");

如果项目明确改为北京时间,断言应改成 +08:00。

17.4 验证业务日边界

至少准备这几条数据:

北京时间 2026-09-08 00:00:00
北京时间 2026-09-08 23:59:59.999
北京时间 2026-09-09 00:00:00

确认“中国 9 月 8 日”的查询只返回前两条。

17.5 分别验证不同入口

如果团队会使用 Migration、DBeaver 和后台脚本写入时间,每个入口至少做一次固定样本验证。只验证 Web 服务不能证明其他连接遵守时区策略。


18. 新人常见错误

错误一:服务器在中国,所以不需要配置

应用可能迁移到 UTC 容器或云服务器;Sequelize 也可能主动修改数据库 Session。物理位置不是可靠配置。

错误二:看到 Z 就手工加 8 小时再保存

Z 表示 UTC。前端展示时可以转换,但不要把显示转换后的值再次当成新时间点写回数据库,否则会发生二次偏移。

错误三:去掉 Z 让字符串“看起来正确”

2026-09-08T12:00:00.000Z

去掉 Z 变成:

2026-09-08T12:00:00.000

不是转换成北京时间,而是删除了时区信息,使字符串变得含糊。

错误四:数据库统一 UTC,但按 UTC 日期统计中国自然日

存储策略和业务日边界是两个问题。中国自然日必须按 Asia/Shanghai 计算边界后再查询 UTC 范围。

错误五:只修改 Sequelize,不处理旧数据

改变 timezone 会改变 DATETIME 的解释。必须先调查历史数据,再发布配置和数据 Migration。

错误六:只修改 MySQL 全局时区

现有连接不会一定立即继承新值;应用还可能在连接后主动覆盖 Session。必须验证实际应用连接。

错误七:用本地字符串构造 Date

new Date("2026-09-08 20:00:00")

缺少 offset,跨系统行为不够明确。API 时间点应携带 Z 或 +08:00。

错误八:所有日期都转 UTC

生日、账期等纯日期不是瞬间,应使用 DATE。不要让生日因为时区转换变成前一天。

错误九:认为 TIMESTAMP 会自动解决所有问题

TIMESTAMP 仍依赖 Session 时区进行输入输出转换,也有范围和迁移成本。先建立策略,再选类型。

错误十:日志没有时区标记

2026-09-08 20:00:00 login failed

无法判断来自哪个时区。机器日志推荐使用 UTC ISO:

2026-09-08T12:00:00.000Z

错误十一:锁定时间使用两套来源

应用计算 new Date(),SQL 又使用 NOW(),如果两端时区策略不同,locked_until 判断会混乱。应统一时间点语义,并在测试中覆盖锁定边界。

错误十二:直接记录驱动返回的日期字符串

如果更改 mysql2 日期解析选项,返回值可能不再是 Date。TypeScript Model 和日志格式必须同步调整,不能继续调用不存在的 toISOString()。

错误十三:前端直接展示后端字符串

API 返回 Z 是正确的数据契约,但直接截取字符串可能让国内用户看到 UTC。前端应把它解析为时间点,再按明确的 IANA 时区格式化。

错误十四:国际业务只保存固定 offset

+08:00 适合表达某一时刻的偏移,却不能完整表达有夏令时的地区规则。重复日程和当地自然日必须保存 IANA 时区。

错误十五:把 datetime-local 直接拼接 Z

datetime-local 没有时区。直接拼 Z 是改变含义,不是补全格式;应先根据业务指定的地区时区转换。


19. 当前项目推荐检查清单

当前项目采用 UTC 主线时:

如果明确采用北京时间存储,则把所有 +00:00 一致替换为 +08:00,并重新审查历史数据与 API 输出约定。不能只改其中一个入口。


20. 动手练习

练习一:比较两个 Session

分别在 DBeaver 和 Sequelize 中执行:

SELECT
  @@session.time_zone,
  CAST(NOW(3) AS CHAR) AS session_now,
  CAST(UTC_TIMESTAMP(3) AS CHAR) AS utc_now;

记录为什么结果相同或不同。

练习二:固定时间往返

把 2026-09-08T12:00:00.123Z 写入测试管理员的 last_login_at,再从 MySQL CLI 和 Sequelize 分别读取。解释数据库字面值、Date.getTime() 和 toISOString() 的关系。

练习三:中国自然日统计

写出查询“北京时间 2026-09-08”的 UTC 起止边界,并使用左闭右开范围查询登录审计。

练习四:制造错误配置

只在测试库中暂时让 CLI 使用 +08:00、Sequelize 使用 +00:00,分别通过 CURRENT_TIMESTAMP(3) 插入两条记录,观察同一真实时刻的字面值差异,然后清理测试数据。

练习五:评审历史数据迁移

不要执行更新。只写一份调查清单,说明如何判断现有 created_at 是 UTC 还是北京时间,以及证据不足时为什么不能直接加 8 小时。

练习六:完成一次前后端往返

前端以北京时间输入 2026-09-08 20:00,转换后提交带 offset 的 ISO 字符串。后端写入并返回 UTC Z,前端再按 Asia/Shanghai 展示。验证最终仍为 20:00,并记录每一层的数据形式。

练习七:设计国际门店预约

为纽约门店设计 starts_at 和 time_zone 字段,说明为什么只保存 America/New_York、只保存 UTC 或只保存 -05:00 都不能独立满足所有预约与重复日程需求。


21. 复盘问题

  1. 2026-09-08T20:00:00+08:00 和 2026-09-08T12:00:00Z 是否是同一时间?
  2. 为什么 DATETIME 无法自行说明存的是 UTC 还是北京时间?
  3. Sequelize timezone 会影响哪些行为,不会影响哪些行为?
  4. 为什么 toISOString() 永远返回 Z,却不代表时间被改错?
  5. 为什么 DBeaver 与 Sequelize 的 NOW() 可能不同?
  6. 数据库存 UTC 时,怎样查询北京时间某一个自然日?
  7. DATE 为什么不应该一律转换成 UTC 时间点?
  8. 为什么修改连接时区前必须调查历史 DATETIME 数据?
  9. 如何证明数据库 CURRENT_TIMESTAMP(3) 默认值按预期执行?
  10. 如果项目决定使用北京时间存储,需要同时修改和验证哪些入口?
  11. 为什么国内业务仍推荐事件时间使用 UTC?
  12. 为什么 datetime-local 的结果不能直接拼接 Z?
  13. 国际业务为什么通常需要额外保存 IANA 时区?
  14. 前端展示时间时,应该采用浏览器时区、查看者时区还是事件地点时区?由谁决定?

22. 本章结论

时间处理可以归纳为三条规则:

  1. 先明确字段表示时间点、业务日期还是地区日程,再选择数据库类型。
  2. 对时间点统一存储语义、连接 Session、解析方式和 API 格式,不能只改一层。
  3. 通过固定时间的真实数据库往返测试证明策略,而不是依赖开发电脑显示结果。
  4. 后端返回明确的时间点,前端按业务指定的 IANA 时区展示,不在两端重复加减小时。

对当前项目,推荐继续使用:

DATETIME(3) 保存 UTC 字面值
Sequelize timezone = +00:00
API toISOString() 返回 Z
前端使用 Asia/Shanghai 展示

如果业务最终坚持北京时间存储,也可以使用 +08:00,但必须让 Sequelize、MySQL Session、Migration、DBeaver、CLI、测试和 API 契约共同遵守同一决定。