Skip to content

一个业务事实需要几个时间:计划、发生与记录 ​

财务对账发现,有一批订单的支付日期比实际晚了一天。追查下来,报表里的「支付时间」取的是订单表的 updated_at:这些订单在支付之后改过收货地址。

很多表里只有 created_at 和 updated_at 两个时间。它们是记录的时间:这一行什么时候被写入、什么时候被修改。业务关心的往往是另外两种时间:一件事计划在什么时候发生,以及它实际在什么时候发生。三者在大多数时候很接近,于是被混用;一旦出现修改、迟到、时区或并发,它们就会分开,而且分开的方向各不相同。

本文用五个场景,看把这三种时间混为一谈各会出什么问题。所有时间都是源码里的固定值,实验见文末。

一次取消的时间线(2026-10-16,UTC)用户提出 01:59:58计划时刻 02:00:00任务执行 02:00:03服务端收到 02:00:05按收到时间:取消晚于执行,拒绝取消按提出时间:早于计划时刻,应视为已取消并补偿计划时刻应该在什么时候发生存本地时间 + 时区 + 规则发生时刻实际在什么时候发生paidAt、occurredAt(Instant)记录时刻系统什么时候知道receivedAt、updated_at存 Instant 每天 +24h:夏令时后 9:00 变 8:00按发生日期:10-15 = 30按收到日期:10-15 = 10updatedAt 当支付时间:晚了一天计划与实际都存,才能算出「迟到了多久」:3 个任务中 2 个晚于计划 5 分钟以上
图 1 · 实测(JDK 25.0.4.1):同一次取消有两个时间。用户提出时,任务还没到计划时刻;服务端收到时,任务已经执行。按哪个时间判断,结论相反,这需要业务写成规则,两个时间都要记下来。下面三类时间也各自回答不同的问题,混用时报表、提醒和汇总会在修改、迟到与时区切换时出错。

一、三种时间回答三个问题 ​

时间回答的问题例子由谁写入
计划时刻这件事应该在什么时候发生预约时间、提醒时间、账单日业务规则或用户的选择
发生时刻这件事实际在什么时候发生支付成功、设备采集、任务执行发生的那一方
记录时刻系统什么时候知道并写下了它收到消息、写入数据库、updated_at系统自己的时钟

建模时,遇到一个叫「时间」的字段,先问它是这三种中的哪一种。一个业务事实常常需要不止一个:一笔支付有发生时刻(支付渠道确认的时间)和记录时刻(我们收到回调的时间);一个计划任务有计划时刻和实际执行时刻。

二、记录时刻不是业务时间 ​

text
支付后第二天改了收货地址:用 updatedAt 当支付时间,报表里这笔订单的支付时间是 2026-10-16T08:00:00Z;单独记录 paidAt 的是 2026-10-15T01:05:00Z

updated_at 是「这一行最后被改的时间」。任何字段的修改都会刷新它:改地址、加备注、补一条迁移数据。拿它当支付时间,相当于说「这笔订单最后一次被碰的时间就是它支付的时间」。

修法是把业务事实的时间作为领域字段:paidAt 在支付这个动作里由聚合写入,之后不再改变。审计字段继续留着,它回答的是「这行数据什么时候变的」,对排查问题有用,但不参与业务计算。更进一步,「支付」本身可以记成一个领域事件 OrderPaid(orderId, paidAt),时间是事件的一部分,见事件风暴。

三、计划时刻:存规则,不存算好的时间点 ​

「每天 9:00 提醒」是一个计划。它的含义是用户所在地的 9:00,而不是某个固定的时间点每 24 小时一次:

text
纽约用户约定每天 9:00 提醒:把第一次的 Instant 每天加 24 小时,夏令时结束后在当地 [2026-10-30 09:00, 2026-11-02 08:00]
存「本地时间 09:00 + 时区」、每次按当天计算:[2026-10-30 09:00, 2026-11-02 09:00]

第一种写法在创建计划时就把它换算成了一个 Instant,之后每次加 24 小时。夏令时结束那天有 25 小时,从那以后提醒就提前了一小时。第二种写法存的是用户真正说出的东西:本地时间 9:00、时区 America/New_York,每次需要时再按当天的时区规则算出时间点。

所以计划时刻的模型是规则(本地时间、时区、重复方式),时间点是从规则派生出来的。这也适用于未来的单次预约:用户约在明年某天的 14:00,存「本地日期时间 + 时区」,而不是现在就换算成 UTC。时区规则会改变,提前换算的时间点会跟着错。已经发生的事实则相反,存 Instant:它已经发生了,不会因为以后的规则变化而移动。plusDays 与 plusHours(24) 在夏令时前后的区别,见加一天,为什么只过了 23 小时。

四、发生时刻与记录时刻:迟到的事实按哪个日期算 ​

设备离线一段时间后,把缓存的读数一起补传上来。读数 d2 在 10-15 采集,10-16 才到:

text
按系统收到的日期汇总:{2026-10-15=10, 2026-10-16=25}
按实际发生的日期汇总:{2026-10-15=30, 2026-10-16=5}

两种汇总都「正确」,回答的是不同的问题。按收到日期汇总,回答的是「那天系统处理了多少数据」,适合容量和运维统计;按发生日期汇总,回答的是「那天实际发生了多少」,这才是业务报表要的。用错了,10-15 的业务量少了 20。

更难的是迟到的事实到达时,那一天的报表已经发出去了:

text
10-15 的日报在 2026-10-16T00:10:00Z 已经发出:d2 的读数 在日报发出之后才到,需要一条更正记录(10-15 由 10 改为 30),而不是悄悄改掉已发出的数字

这时有三种选择,它们是业务决定,不是技术细节:

  • 重新计算并覆盖:最简单,但已经被下游使用过的数字变了,没有人知道。
  • 生成更正记录:原来的数字保留,另有一条「10-15 由 10 更正为 30,原因是迟到的读数」。财务、计费类数据通常要求这样做。
  • 设截止时间:超过截止时间才到的数据计入到达日,并单独标记。

两种汇总口径,以及日报发出之后的更正:

按系统收到的日期按实际发生的日期102530510-1510-1610-1510-16回答:那天系统处理了多少回答:那天实际发生了多少日报已发出10-16 00:10 发出 10-15 日报更正记录10-15 由 10 改为 30 · 迟到读数两个时间都存,才能判断是否迟到
图 2 · 实测(JDK 25.0.4.1,固定时间):读数 d2 在 10-15 采集、10-16 才到。按收到日期汇总,10-15 只有 10;按发生日期汇总是 30。10-15 的日报已经发出时,正确的做法是生成一条更正记录,而不是悄悄改掉已发出的数字

无论选哪种,模型里都要同时有发生时刻和记录时刻,否则根本无法判断一条数据是不是迟到的。同时记录「事实何时为真」和「系统何时知道」,在数据建模里称为双时态。

五、并发的先后:用哪个时间判断 ​

用户在 01:59:58 点了取消,网络延迟让请求 02:00:05 才到;计划在 02:00:00 的任务已经在 02:00:03 执行了:

text
按「收到时间」判断:取消晚于执行,拒绝取消
按「用户提出的时间」判断:取消早于计划时刻 2026-10-16T02:00:00Z,应当视为已取消,需要补偿已执行的动作;规则选哪一种是业务决定,两个时间都要记下来

两个时间给出了相反的结论。按收到时间判断简单、可信,因为它来自服务端自己的时钟;按用户提出的时间判断对用户更公平,但这个时间来自客户端,可以被改,也可能因为设备时钟不准而偏差很大。

常见的折中是:以服务端的收到时间为准,同时允许一个有限的宽限窗口,并且把客户端声称的时间作为一个单独的字段记下来,用于客服和争议处理。这里的要点不在于选哪条规则,而在于规则必须写明用的是哪个时间。只存一个「取消时间」,就只能做一种判断,而且以后没人知道它到底是哪一种。

六、计划与实际之差:只记一个就算不出来 ​

text
3 个计划任务,按「实际执行时刻 − 计划时刻 ≥ 5 分钟」算迟到:2 个;只记执行时刻(没有计划时刻)就算不出来

「迟到了多少」「有没有按时完成」这类问题,需要计划时刻和发生时刻同时存在。调度系统常常只记录执行时间,任务排队、重试、补跑之后,计划的时刻就丢了,延迟只能靠猜。服务等级目标、超时告警、补偿规则都依赖这个差值。

七、落到模型里 ​

场景字段类型
已经发生的业务事实paidAt、occurredAtInstant,由发生方或聚合在动作中写入
系统何时知道receivedAt、recordedAtInstant,服务端时钟,经注入的 Clock 取得
未来的计划localTime / localDateTime + zone + 重复规则派生出时间点,不存算好的 Instant
计划任务scheduledFor + executedAt两个都存
审计created_at、updated_at只用于排查,不参与业务计算

字段名直接说出它是哪种时间,比一个笼统的 time 或 date 好得多。测试时把 Clock 固定在夏令时切换日、日报截止前后、计划时刻前后几秒,这些边界在真实时钟下几乎不会被碰到。

八、检查清单 ​

  • 每个时间字段都能说出它是计划、发生还是记录。
  • 业务报表不使用 updated_at。
  • 未来的计划存本地时间、时区与规则;已经发生的事实存 Instant。
  • 可能迟到的事实同时存发生时刻和记录时刻;报表发出后的变化有明确的更正策略。
  • 判断先后的规则写明用哪个时间;客户端时间不作为唯一依据。
  • 计划任务同时存计划时刻与实际执行时刻。
  • 测试用固定的 Clock 覆盖时区切换与截止时间附近的边界。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权