报表恰好差一天:排查客户数据里的时区、夏令时和日期格式
仪表盘上的收入恰好差了一天,原因很少出在公式上。多半是某个时间戳从头到尾没人标过时区。
要点速览
- 没有时区的时间戳只能靠猜:Python 管它叫 naive datetime;JavaScript 把纯日期字符串当作 UTC,带时间的字符串却当作本地时间。
- PostgreSQL 的 timestamptz 以 UTC 存储,并丢掉原始时区,所以 IANA 时区名要单独存一列,不能存固定偏移量,也不能存 CST 这类缩写。
- 报表按客户的本地日分组,夏令时结束时重复的那一小时要单独测试。
周一早上,客户发来一张截图。你刚部署的仪表盘上,周五的收入比他们收银系统里的数字低,周六的又偏高。一周总数仍然对得上,只是一部分深夜订单被挤到了第二天。
遇到这类 bug,很多人的第一反应是打开 SQL 查公式。公式通常没问题。问题在于,像 03/04/2024 23:30 这样的字符串先后经过了三个系统,每个系统都猜了一次它属于哪个时区。
对前线部署工程师(FDE)来说,这种情况并不少见。客户的真实数据,你往往是第一个接触的人:企业资源计划(ERP)系统的导出文件、另一个大洲服务器上的日志、有人手工录入的表格。
一开始就把时间处理对,客户会信任你的数字。处理错了,之后每次开会都会绕着一个问题转:“这个数为什么不对?”
时间戳为什么会有歧义?
以字符串 01/11/06 为例。按日-月-年读,是 2006 年 11 月 1 日;按美国的月-日-年顺序读,是 2006 年 1 月 11 日。
按年-月-日读,则是 2001 年 11 月 6 日。没有哪个库能替你猜对,因为这是导出文件那个人的习惯,而不是数据本身的属性。
格式弄清之后,下一个问题是时区。Python 文档把不带时区的 datetime 称为“naive”:它携带的信息不足以让它相对其他时间戳明确定位。
它表示的是 UTC 还是本地时间,由程序自己决定。Python 还建议用带时区的“aware”datetime 表示 UTC,因为很多方法会把 naive datetime 当作本地时间处理。这也是 utcnow() 在 3.12 版本中被弃用的原因。
JavaScript 更难预料。据 MDN 介绍,字符串不带偏移量时,纯日期形式按 UTC 解析,日期加时间的形式却按本地时间解析。MDN 承认这是一个由来已久的规范错误,与 ISO 8601 不一致,但一改就会破坏 Web 兼容性,所以没法修。
new Date("2024-03-01") // UTC 午夜
new Date("2024-03-01T00:00") // 运行机器本地时间的午夜
在负偏移时区(比如美国任何地方)的浏览器里,第一行会显示为 2 月 29 日晚上。仪表盘上的日期选择器往前错一天,原因就这么简单。
时间戳应该带上哪些信息?
数据要没有歧义,每个时间戳需要三样东西:绝对时刻、事件发生地的时区名,以及报表对“一天”的约定。缺任何一样,bug 迟早会出现。
绝对时刻应以 UTC 存储。以字符串交换时,用带 Z 后缀的 ISO 8601 格式,例如 2024-03-05T05:30:00Z,谁都不用猜。
关于 PostgreSQL,有一点要记住:timestamp with time zone 类型在内部以 UTC 存值,并不保留原始时区。如果以后需要知道一笔订单是在哪家门店、按谁的时钟下的,就得把时区另存一列。
这一列应存 Area/Location 形式的 IANA 时区名,例如 America/Chicago 或 Asia/Ho_Chi_Minh,而不是缩写。IANA tz 文档就举过 CST 的例子:它在中国是一个意思,在北美是另一个意思。
-06:00 这样的固定偏移量也不安全,因为各国政府将来可能修改时间规则。存时区名,并保持 tzdata 包为最新,规则变化时系统才能算对。
套用到芝加哥的一家连锁门店
假设客户是芝加哥的一家连锁门店。他们的收银系统导出一份 CSV,其中 order_time 列的格式是 03/04/2024 23:30,不带时区。
第一件事不是写代码,而是问客户两个问题:文件里的顺序是月/日还是日/月?文件里的时间是门店时间还是服务器时间?
假设客户确认是月/日,且是门店时间。
from datetime import datetime
from zoneinfo import ZoneInfo
STORE_TZ = "America/Chicago" # 客户确认的 IANA 名称,不是 "CST"
def parse_order_time(raw: str) -> datetime:
naive = datetime.strptime(raw, "%m/%d/%Y %H:%M") # 月日年,已与客户确认
local = naive.replace(tzinfo=ZoneInfo(STORE_TZ)) # naive -> aware
return local.astimezone(ZoneInfo("UTC")) # 以 UTC 存储
写入表时,order_ts 为 timestamptz 类型,source_tz 为文本列,存 America/Chicago。日报先换算成本地时间,再切出日期:
SELECT date(order_ts AT TIME ZONE source_tz) AS business_day,
sum(amount)
FROM orders
GROUP BY 1;
开头截图里的 bug,通常来自按 date(order_ts) 分组,也就是按 UTC 日分组。芝加哥周五晚上 23:30 下的订单,在 UTC 里已经是周六。改一行 GROUP BY,收入就回到了正确的那一天。
夏令时结束时重复的那一小时
有一种情况要单独处理。PostgreSQL 文档指出,由于夏令时(DST),一个时区的偏移量在一年中会变化,所以不带日期的时间几乎没有意义。夏令时结束的那个晚上,有一段本地时间会出现两次。两笔都记为 01:30 的订单,实际可能相隔一小时。
Python 用 fold 属性处理这种情况:fold=0 使用切换前的偏移量,fold=1 使用切换后的偏移量。上面的 strptime 函数得到的始终是 fold=0。
如果客户的文件里有交易流水号或递增的日志记录,可以据此判断哪些行属于第二次出现的那一小时,并设置 naive.replace(fold=1, ...)。
如果没有,至少要把落在这段时间里的行标出来,并告诉客户。
夏令时也会影响工作安排。越南全年使用 UTC+7,不实行夏令时。
团队按越南时间工作、客户在美国或欧洲,时差就会随季节变化。所以“在客户时间早上 8 点运行”的 cron 任务,必须按客户的时区名配置,而不是算一次偏移量就不管了。
第一次需求调研会上要问的五个问题
芝加哥例子里的两个问题只是开始。上面所有的歧义,归结起来就是五个问题。尽早问清楚,比客户截完图再去修仪表盘便宜得多。
| 问客户的问题 | 为什么重要 |
|---|---|
| 文件里的日期顺序是日月年、月日年还是年月日? | 01/11/06 有三种读法,没有哪个库能替你猜 |
| 文件里的时间是门店时间、服务器时间还是 UTC? | naive datetime 不会告诉你它属于哪个时区 |
| 每个地点使用哪个 IANA 时区? | CST 这类缩写有歧义;固定偏移量在规则变化时会出错 |
| 报表里的“一天”在什么时候结束,按谁的时钟? | 对深夜订单来说,UTC 日和门店日不是同一天 |
| 夏令时结束的那晚,数据里有没有流水号来区分重复的那一小时? | 同一个本地时间可能对应两个真实时刻 |
把答案记在交接文档里,写在对应的列名旁边。之后接手项目的人就不用再问一遍。
常见错误,以及怎样把它变成一个可讲的故事
有几种错误反复出现:让库去猜日/月顺序;调用 datetime.now() 或 utcnow(),以为拿到的就是 UTC;在时区列里存 CST 或 -06:00;在前端把 YYYY-MM-DD 字符串直接传给 new Date()。
还有:报表按 UTC 日分组,客户却按门店日看数字。带着这些错误的代码在你的机器上跑得好好的,只有碰到另一个时区的真实数据才会出问题。
学得最快的办法,是亲手复现开头那张截图。建一张 orders 表,放几十笔 America/Chicago 的假订单,其中一些在周五晚上 23:00 之后,一些在夏令时结束的那晚。先用 date(order_ts) 跑报表,记下周五少了多少,再换成 AT TIME ZONE source_tz 对比一次。
这个练习能给你一个完整的故事:现象、假设、问客户的问题、修掉的那一行代码。
在真实数据上做过一遍之后,在简历上写一句具体的话,比如“将来自多个时区的时间戳列统一为 UTC 并附 IANA 时区名,修复了报表差一天的问题”,面试时也按这个顺序讲。
下次客户问某个数字为什么恰好差一天,先别急着打开公式。先问:这个时间戳是在哪里生成的,按的是谁的时钟?
这篇文章对你有帮助吗?
感谢你的反馈!
8 个信源
- Date - JavaScript | MDN
- datetime — Basic date and time types (Python 3.15.1 documentation)
- PostgreSQL: Documentation: 18: 8.5. Date/Time Types
- zoneinfo — IANA time zone support (Python 3.15.1 documentation)
- Theory and pragmatics of the tz code and data
- List of date formats by country - Wikipedia
- Time in Vietnam
- ISO 8601 - Wikipedia