SQLite 时区陷阱:一字之差,38 条会话归错日期

一、症状:三个页面,两个时间线

venxine.vip 的 /token/ 看板有三个子页面:

页面 数据来源 日期基准
summary(用量总览) Python datetime Asia/Shanghai
usage(用量明细) Python datetime Asia/Shanghai
database(数据明细) SQLite GROUP BY date() UTC

summary 和 usage 用的是 Python 代码里的 datetime.now(),天然按服务器本地时区(Asia/Shanghai)。但 database 页面用的是 SQL 的 GROUP BY date(started_at)

SELECT date(datetime(started_at, 'unixepoch')) as date,
       COUNT(*) as sessions,
       SUM(input_tokens + output_tokens) as tokens
FROM sessions
GROUP BY date
ORDER BY date DESC

早上 06:00~08:00 的 cron 会话(华尔街日报、Crypto 日报、AI 新闻),在北京时间已经是"今天",但在 UTC 时间还在"昨天"。

结果:同样一笔 token 消耗,在 summary 归入 7月3日,在 database 归入 7月2日。

二、根因:unixepoch 的默认时区

SQLite 的 datetime() 函数签名:

datetime(timestring, modifier, modifier, ...)

unixepoch 修饰符的含义是"把前面的数字当作 Unix 时间戳"。但它默认输出 UTC 时间,不会自动转换时区。

-- unixepoch = 1751500800(2026-07-03 00:00:00 UTC = 2026-07-03 08:00:00 BJT)

-- 默认 UTC
SELECT datetime(1751500800, 'unixepoch');
-- → '2026-07-03 00:00:00'

-- 加 localtime 转北京时区
SELECT datetime(1751500800, 'unixepoch', 'localtime');
-- → '2026-07-03 08:00:00'

不加 localtime,SQLite 当 UTC 处理。加了 localtime,SQLite 读取操作系统的时区设置(/etc/timezoneTZ 环境变量)做转换。

三、修复:一行代码

# app.py 第 2013 行

# 改前(UTC)
date(datetime(started_at, 'unixepoch')) as date

# 改后(北京时区)
date(datetime(started_at, 'unixepoch', 'localtime')) as date

只加了一个参数 'localtime'

四、影响范围

修复前统计:

SELECT COUNT(*) FROM sessions
WHERE date(datetime(started_at, 'unixepoch'))
   != date(datetime(started_at, 'unixepoch', 'localtime'));
--  38 

38 条会话(占总数的 9.5%)之前归错了日期。全部是 06:00~08:00 之间启动的早间 cron 任务。

修复后三个页面完全统一:

页面 时区 修复前 修复后
summary Asia/Shanghai ✅ 正确 ✅ 正确
usage Asia/Shanghai ✅ 正确 ✅ 正确
database UTCAsia/Shanghai ❌ 错位 ✅ 正确

五、SQLite 时区速查表

需求 写法 说明
存时间戳(推荐) INTEGER 存 Unix 秒 无时区歧义,前端转换
读时间戳为 UTC datetime(ts, 'unixepoch') 默认 UTC
读时间戳为本地时区 datetime(ts, 'unixepoch', 'localtime') 跟随 OS 时区
当前 UTC 时间 datetime('now') 等价于 UTC now
当前本地时间 datetime('now', 'localtime') 跟随 OS 时区
存文本时间(不推荐) TEXT'2026-07-03 08:00:00' 有歧义,不知道是哪个时区

最佳实践:永远用 INTEGER 存 Unix 时间戳,查询时显式指定 'unixepoch' + 'localtime'

六、教训

  1. SQLite 没有原生时区类型:datetime() 只是格式化函数,不做时区推断
  2. unixepoch 默认 UTC:不加 localtime 就是 UTC 输出
  3. Python 和 SQLite 的时区不一致是常见陷阱:Python datetime.now() 拿本地时间,SQLite datetime(ts, 'unixepoch') 输出 UTC,同一个"今天"可能差 8 小时
  4. 三页面统一验证法:如果一个数据源有多处展示,抽查同一天的数值是否能对上,这是发现时区 bug 的最快方法