一个业务事实需要几个时间:计划、发生与记录
财务对账发现,有一批订单的支付日期比实际晚了一天。追查下来,报表里的「支付时间」取的是订单表的
updated_at:这些订单在支付之后改过收货地址。
很多表里只有 created_at 和 updated_at 两个时间。它们是记录的时间:这一行什么时候被写入、什么时候被修改。业务关心的往往是另外两种时间:一件事计划在什么时候发生,以及它实际在什么时候发生。三者在大多数时候很接近,于是被混用;一旦出现修改、迟到、时区或并发,它们就会分开,而且分开的方向各不相同。
本文用五个场景,看把这三种时间混为一谈各会出什么问题。所有时间都是源码里的固定值,实验见文末。
一、三种时间回答三个问题
| 时间 | 回答的问题 | 例子 | 由谁写入 |
|---|---|---|---|
| 计划时刻 | 这件事应该在什么时候发生 | 预约时间、提醒时间、账单日 | 业务规则或用户的选择 |
| 发生时刻 | 这件事实际在什么时候发生 | 支付成功、设备采集、任务执行 | 发生的那一方 |
| 记录时刻 | 系统什么时候知道并写下了它 | 收到消息、写入数据库、updated_at | 系统自己的时钟 |
建模时,遇到一个叫「时间」的字段,先问它是这三种中的哪一种。一个业务事实常常需要不止一个:一笔支付有发生时刻(支付渠道确认的时间)和记录时刻(我们收到回调的时间);一个计划任务有计划时刻和实际执行时刻。
二、记录时刻不是业务时间
支付后第二天改了收货地址:用 updatedAt 当支付时间,报表里这笔订单的支付时间是 2026-10-16T08:00:00Z;单独记录 paidAt 的是 2026-10-15T01:05:00Zupdated_at 是「这一行最后被改的时间」。任何字段的修改都会刷新它:改地址、加备注、补一条迁移数据。拿它当支付时间,相当于说「这笔订单最后一次被碰的时间就是它支付的时间」。
修法是把业务事实的时间作为领域字段:paidAt 在支付这个动作里由聚合写入,之后不再改变。审计字段继续留着,它回答的是「这行数据什么时候变的」,对排查问题有用,但不参与业务计算。更进一步,「支付」本身可以记成一个领域事件 OrderPaid(orderId, paidAt),时间是事件的一部分,见事件风暴。
三、计划时刻:存规则,不存算好的时间点
「每天 9:00 提醒」是一个计划。它的含义是用户所在地的 9:00,而不是某个固定的时间点每 24 小时一次:
纽约用户约定每天 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 才到:
按系统收到的日期汇总:{2026-10-15=10, 2026-10-16=25}
按实际发生的日期汇总:{2026-10-15=30, 2026-10-16=5}两种汇总都「正确」,回答的是不同的问题。按收到日期汇总,回答的是「那天系统处理了多少数据」,适合容量和运维统计;按发生日期汇总,回答的是「那天实际发生了多少」,这才是业务报表要的。用错了,10-15 的业务量少了 20。
更难的是迟到的事实到达时,那一天的报表已经发出去了:
10-15 的日报在 2026-10-16T00:10:00Z 已经发出:d2 的读数 在日报发出之后才到,需要一条更正记录(10-15 由 10 改为 30),而不是悄悄改掉已发出的数字这时有三种选择,它们是业务决定,不是技术细节:
- 重新计算并覆盖:最简单,但已经被下游使用过的数字变了,没有人知道。
- 生成更正记录:原来的数字保留,另有一条「10-15 由 10 更正为 30,原因是迟到的读数」。财务、计费类数据通常要求这样做。
- 设截止时间:超过截止时间才到的数据计入到达日,并单独标记。
两种汇总口径,以及日报发出之后的更正:
无论选哪种,模型里都要同时有发生时刻和记录时刻,否则根本无法判断一条数据是不是迟到的。同时记录「事实何时为真」和「系统何时知道」,在数据建模里称为双时态。
五、并发的先后:用哪个时间判断
用户在 01:59:58 点了取消,网络延迟让请求 02:00:05 才到;计划在 02:00:00 的任务已经在 02:00:03 执行了:
按「收到时间」判断:取消晚于执行,拒绝取消
按「用户提出的时间」判断:取消早于计划时刻 2026-10-16T02:00:00Z,应当视为已取消,需要补偿已执行的动作;规则选哪一种是业务决定,两个时间都要记下来两个时间给出了相反的结论。按收到时间判断简单、可信,因为它来自服务端自己的时钟;按用户提出的时间判断对用户更公平,但这个时间来自客户端,可以被改,也可能因为设备时钟不准而偏差很大。
常见的折中是:以服务端的收到时间为准,同时允许一个有限的宽限窗口,并且把客户端声称的时间作为一个单独的字段记下来,用于客服和争议处理。这里的要点不在于选哪条规则,而在于规则必须写明用的是哪个时间。只存一个「取消时间」,就只能做一种判断,而且以后没人知道它到底是哪一种。
六、计划与实际之差:只记一个就算不出来
3 个计划任务,按「实际执行时刻 − 计划时刻 ≥ 5 分钟」算迟到:2 个;只记执行时刻(没有计划时刻)就算不出来「迟到了多少」「有没有按时完成」这类问题,需要计划时刻和发生时刻同时存在。调度系统常常只记录执行时间,任务排队、重试、补跑之后,计划的时刻就丢了,延迟只能靠猜。服务等级目标、超时告警、补偿规则都依赖这个差值。
七、落到模型里
| 场景 | 字段 | 类型 |
|---|---|---|
| 已经发生的业务事实 | paidAt、occurredAt | Instant,由发生方或聚合在动作中写入 |
| 系统何时知道 | receivedAt、recordedAt | Instant,服务端时钟,经注入的 Clock 取得 |
| 未来的计划 | localTime / localDateTime + zone + 重复规则 | 派生出时间点,不存算好的 Instant |
| 计划任务 | scheduledFor + executedAt | 两个都存 |
| 审计 | created_at、updated_at | 只用于排查,不参与业务计算 |
字段名直接说出它是哪种时间,比一个笼统的 time 或 date 好得多。测试时把 Clock 固定在夏令时切换日、日报截止前后、计划时刻前后几秒,这些边界在真实时钟下几乎不会被碰到。
八、检查清单
- 每个时间字段都能说出它是计划、发生还是记录。
- 业务报表不使用
updated_at。 - 未来的计划存本地时间、时区与规则;已经发生的事实存
Instant。 - 可能迟到的事实同时存发生时刻和记录时刻;报表发出后的变化有明确的更正策略。
- 判断先后的规则写明用哪个时间;客户端时间不作为唯一依据。
- 计划任务同时存计划时刻与实际执行时刻。
- 测试用固定的
Clock覆盖时区切换与截止时间附近的边界。
配套实验
- codesphere-labs/ddd/planned-occurred-recorded:JDK 25.0.4.1:用
updatedAt当支付时间、计划存成Instant与存本地时间加时区、迟到的读数按收到与发生日期汇总、取消按收到与提出时间判断、计划与实际之差(验证记录)
参考资料
- Martin Fowler,Bitemporal History
- Martin Fowler,Temporal Patterns
- Java SE 25 API:ZonedDateTime
- Java SE 25 API:Clock