02A|时区与时间字段:避免悄悄偏移 8 小时
时区错误最麻烦的地方,是程序通常不会立即报错。数据库仍能插入,TypeScript 仍能通过,接口也能返回字符串,但显示时间、锁定截止时间或统计日期可能已经偏移了 8 小时。
本章结合当前项目回答:
- MySQL、Sequelize、JavaScript
Date和 API 各自怎样处理时间; DATETIME、TIMESTAMP和DATE应该怎样选择;timezone: "+00:00"和toISOString()分别负责什么;- 为什么 MySQL CLI、DBeaver 和 Node.js 可能得到不同的
NOW(); - UTC 存储与北京时间存储应该怎样选择;
- 前端、HTTP API、Service、Sequelize 和 MySQL 如何共同处理时间;
- 国内单时区业务与国际多时区业务分别应该采用什么策略;
- 已有数据可能混用时区时,怎样安全迁移;
- 如何测试时间经过数据库往返后没有发生偏移。
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 CLI;
- DBeaver;
- Sequelize 连接池中的连接;
- Migration 执行器;
- 定时脚本;
- 其他语言编写的数据程序。
检查一次 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
特点:
- 保存无时区的年月日时分秒;
- 不在列值中保存 offset 或地区时区;
- 支持毫秒;
- 范围较大;
- 必须由系统约定这些字面值代表 UTC 还是统一业务时区。
适合当前项目的事件时间,只要所有入口严格遵守同一策略:
created_at;updated_at;last_login_at;locked_until;password_changed_at。
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()
优点:
- 不依赖服务器物理位置;
- 跨地区服务和第三方系统更容易对接;
- 日志、JWT 时间和数据库事件容易统一比较;
- API 字符串自带
Z,语义明确; - 展示层可以自由转换成北京时间或用户时区。
配套要求:
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()
真正要补齐的是:
- Migration 执行连接使用 UTC;
- DBeaver/CLI 手工写时间数据前设置 UTC Session;
- 前端明确使用
Asia/Shanghai展示; - 测试覆盖时间往返;
- 文档声明数据库
DATETIME字面值统一表示 UTC。
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
原因:
- UTC 让数据库、JWT、日志、缓存和第三方接口更容易比较;
- 以后迁移到海外服务器或拓展海外用户时不需要重写历史事件时间;
- API 中的
Z消除了“这个 20:00 是哪里的时间”的歧义; Asia/Shanghai明确承担展示和自然日规则,用户仍然看到北京时间。
对于当前项目,这是最合适的方案,因此应继续保留 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 主要影响:
- 建立连接后的 MySQL Session 时区;
- JavaScript
Date写入DATE/DATETIME时的格式化; - 从数据库读取无时区日期字符串时的解释方式;
NOW()、CURRENT_TIMESTAMP()等 Session 时间函数的结果。
它不负责:
- 改变用户电脑的显示时区;
- 让
toISOString()输出+08:00; - 自动识别一条历史数据原来按什么时区写入;
- 自动修复已经混用 UTC 和北京时间的列;
- 替代业务日期边界计算。
不要只设置操作系统的 TZ 就认为 Sequelize 和 MySQL 已经统一。数据库 Session 和应用格式化都应显式配置和验证。
13. TypeScript 类型也不能证明时区正确
模型中:
declare last_login_at: Date | null;
TypeScript 只能证明代码把它当作 Date 或 null 使用,不能证明:
- 数据库存的是 UTC 还是北京时间;
- mysql2 是否按正确时区解析;
- 历史数据是否来自不同 Session;
- API 是否带有正确 offset。
下面两个值类型都是 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);
如果表中一部分数据本来就是北京时间,这条语句会再次把它们改错。
推荐迁移流程
- 备份数据库并确认可恢复;
- 列出所有时间列及其业务语义;
- 确认各批数据由 Sequelize、DBeaver、CLI 还是旧程序写入;
- 查询当时各写入入口的 Session 时区和版本配置;
- 用业务日志、审计记录或已知事件核对真实时刻;
- 把来源不同的数据划分为可证明的批次;
- 在测试库演练转换 SQL;
- 编写版本化数据 Migration,而不是只在 DBeaver 临时执行;
- 同时发布新的连接时区配置;
- 完成后做固定样本的往返验证。
无法证明历史值语义时,不要假装能够精确自动修复。应先记录不确定范围,再决定人工校正或业务容忍策略。
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 主线时:
- [ ]
db.ts显式配置timezone: "+00:00"; - [ ]
DATETIME(3)字段文档声明保存 UTC 字面值; - [ ] Migration 和数据脚本连接使用 UTC Session;
- [ ] DBeaver/CLI 手工 DML 前执行
SET time_zone = '+00:00'; - [ ] API 时间点通过
toISOString()返回带Z的 ISO 8601; - [ ] 前端按
Asia/Shanghai展示中国用户时间; - [ ] API 输入要求携带
Z或 offset; - [ ] 前端不直接截取 ISO 字符串,而是按
Asia/Shanghai格式化; - [ ]
datetime-local输入在转换前有明确的业务时区; - [ ] 中国自然日查询先把边界转换成 UTC;
- [ ] 日志记录带时区的时间;
- [ ] 使用真实 MySQL 测试固定时间点往返;
- [ ] 不把密码、Token 或敏感请求体写进时间调试日志;
- [ ] 改变策略前调查并备份历史数据。
如果明确采用北京时间存储,则把所有 +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. 复盘问题
2026-09-08T20:00:00+08:00和2026-09-08T12:00:00Z是否是同一时间?- 为什么
DATETIME无法自行说明存的是 UTC 还是北京时间? - Sequelize
timezone会影响哪些行为,不会影响哪些行为? - 为什么
toISOString()永远返回Z,却不代表时间被改错? - 为什么 DBeaver 与 Sequelize 的
NOW()可能不同? - 数据库存 UTC 时,怎样查询北京时间某一个自然日?
DATE为什么不应该一律转换成 UTC 时间点?- 为什么修改连接时区前必须调查历史
DATETIME数据? - 如何证明数据库
CURRENT_TIMESTAMP(3)默认值按预期执行? - 如果项目决定使用北京时间存储,需要同时修改和验证哪些入口?
- 为什么国内业务仍推荐事件时间使用 UTC?
- 为什么
datetime-local的结果不能直接拼接Z? - 国际业务为什么通常需要额外保存 IANA 时区?
- 前端展示时间时,应该采用浏览器时区、查看者时区还是事件地点时区?由谁决定?
22. 本章结论
时间处理可以归纳为三条规则:
- 先明确字段表示时间点、业务日期还是地区日程,再选择数据库类型。
- 对时间点统一存储语义、连接 Session、解析方式和 API 格式,不能只改一层。
- 通过固定时间的真实数据库往返测试证明策略,而不是依赖开发电脑显示结果。
- 后端返回明确的时间点,前端按业务指定的 IANA 时区展示,不在两端重复加减小时。
对当前项目,推荐继续使用:
DATETIME(3) 保存 UTC 字面值
Sequelize timezone = +00:00
API toISOString() 返回 Z
前端使用 Asia/Shanghai 展示
如果业务最终坚持北京时间存储,也可以使用 +08:00,但必须让 Sequelize、MySQL Session、Migration、DBeaver、CLI、测试和 API 契约共同遵守同一决定。