返回博客

mysql2 读 DATETIME 日期差一天:别让它经过 new Date()

后台报表上线了一个「最后付款日期」字段,测试通过,产品验收通过,上线第二天用户反馈:报表里的日期比实际付款日早了一天。本地复现不了,线上稳定复现。看起来是个偶发 bug,但仔细一查,每一条跨午夜的记录都差一天,规律性极强。

现象

  • UI 显示的日期比数据库里存的值早一天(或晚一天,取决于时间方向)
  • 本地 Mac 开发环境完全正常,Docker 部署到服务器后必现
  • 前端加了 toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai' }) 能"修好",但实际上治标
  • 不同记录的偏移量不一样:存的是 00:30:00 跨午夜的差一天,存的是 12:00:00 的正常

根因

mysql2 在反序列化 DATETIME 列时,会把它构造成一个 JavaScript Date 对象,使用的是 Node 进程的本地时区

关键链路:

  1. DB 存的是 2026-04-02 00:30:00,语义是东八区时间
  2. 生产容器没有设置 TZ 环境变量,Node 进程默认跑 UTC
  3. mysql2 内部做了等价于 new Date('2026-04-02 00:30:00') 的操作,这行代码在 UTC 环境下被解析为 UTC 时间 2026-04-02T00:30:00Z
  4. 随后取日期用 .getDate().toISOString().slice(0, 10),UTC 下这个时刻对应的日历日是 2026-04-02,但东八区下应该是 2026-04-02——等等,差在哪?
  5. 反过来:如果 DB 存的是 2026-04-01 23:30:00(东八区),容器以 UTC 解析后对应 UTC 的 2026-04-01T23:30:00Z.getDate() 还是 1,没问题;但如果 DB 存的是 2026-04-02 00:30:00(东八区),UTC 解析得到 2026-04-02T00:30:00Z,换算回东八区是 2026-04-02 08:30:00——这时 .toISOString() 给出的是 2026-04-02,看起来对?

实际上问题的方向是反的:DB 里东八区存的 2026-04-01 23:30:00,UTC Node 读出来当 UTC 解析,用 .toISOString() 显示为 2026-04-01T23:30:00Z,没错;但如果业务代码把这个 Date 转成「北京时间日期字符串」(+8h),就变成了 2026-04-02——比实际早了一天。

总结成一句话:mysql2 把 DATETIME 当成无时区字符串喂给 new Date(),而 new Date('YYYY-MM-DD HH:mm:ss') 的解析行为依赖进程时区,本地和容器时区不一致就出现漂移。

坑:mysql2 用进程时区解析 DATETIME,本地 Mac 和 UTC 容器行为不同

new Date('2026-04-01 23:30:00')Asia/Shanghai 环境解析为北京时间,在 UTC 环境解析为 UTC——两者相差 8 小时,日期字段一跨午夜就差一天。

这个坑在本地永远看不出来,必须在 UTC 容器里才复现,所以很容易被误判为「偶发」。

正解

不要让 DATETIME 经过 new Date()——在 SQL 层直接用 DATE_FORMAT 格式化成字符串返回,mysql2 拿到字符串类型不做任何时区处理,原样透传。

修复前(有问题):

// 查询返回 DATETIME 列,mysql2 自动反序列化为 JS Date
const [rows] = await pool.query<RowDataPacket[]>(
  `SELECT order_id, MAX(payment_date) AS last_payment_date
   FROM payment_records
   WHERE order_id IN (${ids.map(() => '?').join(',')})
   GROUP BY order_id`,
  ids
)
 
// 手动格式化——进程时区影响 getDate() 结果
for (const row of data) {
  const d = new Date(row.last_payment_date)
  row.paymentDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}

修复后(正确):

// 在 SQL 层格式化,返回的是 string 而非 Date
const [rows] = await pool.query<RowDataPacket[]>(
  `SELECT order_id,
          DATE_FORMAT(MAX(payment_date), '%Y-%m-%d') AS last_payment_date
   FROM payment_records
   WHERE order_id IN (${ids.map(() => '?').join(',')})
   GROUP BY order_id`,
  ids
)
 
// 字符串透传,无时区解析
for (const row of data) {
  row.paymentDate = row.last_payment_date  // 直接用,不经过 new Date()
}

需要带时分秒就用 '%Y-%m-%d %H:%i:%s'

其他备选方案(不推荐作为主方案):

  • mysql2 连接配置加 dateStrings: true:全局生效,把所有 date 列都返回字符串,会影响依赖 Date 对象的代码
  • 连接配置加 timezone: '+08:00':把 mysql2 的解析时区硬编码为东八区,脆,依赖 DB 写入时区与配置一致
正解:DATE_FORMAT 在 SQL 层格式化,字符串零时区风险

把日期列在查询时格式化为字符串 DATE_FORMAT(col, '%Y-%m-%d'),mysql2 不会对字符串类型做任何时区转换,Node 端直接使用,彻底切断时区漂移链路。

一句话外卖

只要 DATETIME 经过了 new Date(),时区就参与了——生产容器 UTC、开发 Mac 东八区,任何一端不一致都会偏移;根治方法是在 SQL 层格式化成字符串,让时区从未介入。