FDE Times 数据在招 FDE 职位 798▼30 近 7 天在招职位数的变化(同口径比较):不计入本周新开始追踪的公司的职位近 7 天新增 67在招公司 139支持远程 25%美国薪资中位数 $202k招聘最多 Databricks 108
ENVI

前线部署工程师(FDE)的专业媒体

技能指南 · 指南

报表恰好差一天:排查客户数据里的时区、夏令时和日期格式

仪表盘上的收入恰好差了一天,原因很少出在公式上。多半是某个时间戳从头到尾没人标过时区。

报表恰好差一天:排查客户数据里的时区、夏令时和日期格式
图片:Luis Cortes / Unsplash: 图片:Luis Cortes / Unsplash
分享微博

要点速览

  • 没有时区的时间戳只能靠猜:Python 管它叫 naive datetime;JavaScript 把纯日期字符串当作 UTC,带时间的字符串却当作本地时间。
  • PostgreSQL 的 timestamptz 以 UTC 存储,并丢掉原始时区,所以 IANA 时区名要单独存一列,不能存固定偏移量,也不能存 CST 这类缩写。
  • 报表按客户的本地日分组,夏令时结束时重复的那一小时要单独测试。
两个面板对比。修改前:报表按 date(order_ts) 即 UTC 日分组,芝加哥周五晚上 23:30 的订单被挤进周六那一列。修改后:按 date(order_ts AT TIME ZONE source_tz) 即门店日分组,同一笔订单留在正确的周五那一列。
按 UTC 日分组时,芝加哥的深夜订单会被挤到第二天。先换算成本地时间再切日期,收入就落在正确的那一天。来源:文中芝加哥连锁门店的例子。

周一早上,客户发来一张截图。你刚部署的仪表盘上,周五的收入比他们收银系统里的数字低,周六的又偏高。一周总数仍然对得上,只是一部分深夜订单被挤到了第二天。

遇到这类 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 个信源
路线图上的下一篇 · 阶段 2: 全栈工程能力Nebius 招聘 FDE,把 Tavily 推向生产环境让智能体调用搜索 API,几行代码就够了。难的是在企业内部把它从原型做到生产环境,这也是 Nebius 为 Tavily 招聘 FDE 的原因。