每天自动生成一份报告,文件名显示是深夜生成的,内容却写着"当日数据"——而其中一部分数值其实是上一时点(隔夜)的。这篇文章讲的就是这种"时间戳全用本地现在"造成的错觉。
现象
一份每天生成的报告,文件名的时间戳显示它是深夜生成的,正文里也明确写着"当日数据"。但仔细核对会发现,其中一部分数值其实是上一时点(隔夜)的——数据源在那一刻还没更新,脚本却把它们当成"今天的最新数据"呈现了。
更隐蔽的一种表现:脚本在跨午夜时连续取两次时间,会得到两个不同的日期,于是报告里出现了"日期是今天、星期是昨天"这类自相矛盾的组合。
根因
根本原因是:所有时间戳一律用本地"当前时间",代码里没有任何时区、日历或时段状态的判断。
now = datetime.now() # 本地"现在"
report["date"] = now.date() # 直接拿来当数据的时间
这段代码蕴含了一个错误假设:"现在"就等于"数据的时间"。 但事实并非如此:
- 跨时区问题:数据源所在地和脚本运行地不在同一时区时,脚本在本地白天运行,拿到的是数据源那边"上一时点"的数据;
- 时段状态问题:很多数据源按"时段"更新(开盘 / 收盘 / 隔夜),不同时段拿到的其实是不同批次的数据,脚本却一视同仁地打上"现在";
- 跨午夜问题:连续两次调用
datetime.now(),如果第一次在 23:59:59、第二次在 00:00:01,日期就变了,而报告里其它地方还引用着旧日期,导致日期与星期不一致。
解决
核心原则:"现在"不是数据的时间,数据的时间点要单独建模。
第一,数据快照显式带上"时段状态"和"数据所属时间点":
from datetime import datetime, timezone, timedelta
# 明确区分三个时间
collected_at = datetime.now(timezone.utc) # 快照生成时刻(UTC)
source_tz = timezone(timedelta(hours=8)) # 数据源所在时区
data_asof = (collected_at.astimezone(source_tz)) # 数据"所属"的时间点
session = classify_session(data_asof) # "盘前/盘中/盘后/隔夜"
snapshot = {
"collected_at": collected_at.isoformat(),
"data_asof": data_asof.isoformat(),
"session": session,
}
第二,日期只取一次后复用。 不要在报告的不同地方分别调用 datetime.now()——那样会引发跨午夜的日期漂移:
run_date = datetime.now(source_tz).date() # 只取一次
# 后面全部复用 run_date,绝不再取第二次
report["date"] = run_date
report["weekday"] = run_date.strftime("%A")
第三,给入口加一个日期参数,便于指定和复现:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--date", help="报告日期,默认今天,格式 YYYY-MM-DD")
args = parser.parse_args()
run_date = (datetime.strptime(args.date, "%Y-%m-%d").date()
if args.date else datetime.now(source_tz).date())
有了这个参数,你就能随时重跑历史某一天的报告,而不必改代码。
延伸与预防
这个教训可以概括成一句话:时间戳是"三种时间",不是一个时间。 任何时候涉及时间,都要先分清:
- 采集时刻(我什么时候拿到的,用 UTC);
- 数据所属时刻(数据本身代表哪个时点,用数据源时区);
- 展示时刻(报告要显示成哪个时区/格式,用读者所在时区)。
把它们分开建模,是避免"把过期数据当实时数据"的根本办法。另外:
- 统一用 UTC 存储、按需转换,不要让本地时区渗进数据层;
- 在报告里显式写出数据的时点和时段,让读者自己判断新鲜度;
- 加一个"数据新鲜度"检查:如果
data_asof距collected_at超过预期间隔,就在报告里打上醒目的提示。
数据的时间语义搞错了,比数据算错了更危险——因为算错你看得出来,时间错你却以为那是最新的。