阅读主题
时间的表示、存储与往返验证
会员截止已经算出:北京时间 2027 年 4 月 1 日零点。写入数据库后,另一个客户端查询到的却是 3 月 31 日下午四点。数据错了吗?仅凭显示还不能判断。如果后者采用 UTC,两者表示同一瞬间;如果它仍声称采用北京时间,截止就真的提前了八小时。
存储时间时,我们既要允许表示方式变化,又要防止原来的含义随之改变。上一节的预约还提出了另一层要求:除了执行瞬间,用户选择的当地时间、地区时区和日程规则也可能需要保留。
1.7 时间的表示与存储
1.7.1 为不同对象定义不变量
先看会员截止的两种表示:
text
2027-04-01T00:00:00+08:00
2027-03-31T16:00:00Z文本不同,日期也不同,但解析后应得到同一个瞬间。我们要求存取前后保持的,就是这个时间点。出生日期的要求则不同:1995-06-12 写入再读出,仍应是这个日期,无需先补上零点、换成 UTC,再设法还原。
存取前后必须保持的性质,称为不变量。 对时间点,比较是否仍为同一瞬间;对日期,比较是否仍为同一日期;对保持当地日程的预约,既要保留原始当地输入、地区时区和解析选择,也要保留当前计算出的执行时间。不能用一种字符串比较方式验证所有对象。
因此,“统一存 UTC”适合规范时间点的表示,却不能代替完整的数据设计。从一个 UTC 时间点,无法反推出用户最初填写的是出生日期还是预约时间,也无法知道预约原先采用哪个地区的规则。“每个工作日九点”还需要日期选择规则和业务日历依据,单个执行瞬间更不可能表达这些信息。
这里要区分计算依据与派生结果。保持当地日程的预约以用户输入和规则为依据,执行瞬间由它们算出;即使将执行瞬间写入数据库,它也仍是计算结果。用户改期或相关规则更新后,需要按前一节的变更政策重新检查它。若产品承诺固定已确认的瞬间,则应以该瞬间为准,原始输入用于说明它如何形成。
派生值是否保存,取决于怎样使用。计算便宜且不需要独立查询的值,可以读取时计算;调度器需要查找即将到期的提醒,通常就需要保存提醒时刻。保存以后,要维持它与预约及版本的关联,否则预约已经改到明天,旧提醒仍可能在今天触发。计划时间和实际发生时间则记录不同事实,不能靠覆盖同一字段同时保留两者。
展示时也应遵守同一个约定。用户从北京去纽约,精确截止可以按纽约时间显示,但会员原来的“按北京时间至 3 月 31 日全天有效”并没有改变。页面若仍用最后有效日期说明权益,应保留“按北京时间”的限定;若显示当地截止时刻,就应从同一个截止瞬间换算,不能把原日期重新按纽约零点解释。
因此,展示函数可以显式接收时区,例如 formatInstant(instant, displayZone)。语言与日期格式另行选择:中文界面可以显示纽约时间,英文界面也可以显示北京时间。展示偏好决定用户看到什么样的读数,不能反过来修改业务截止。
1.7.2 接口怎样表达字段含义
本例会员接口返回已经确定的截止:
json
{
"expires_at": "2027-03-31T16:00:00Z"
}接口文档还应说明:这是从该瞬间起失效的排他截止,采用什么格式、允许多少小数位、接受什么范围,以及缺失时是什么意思。本例使用 RFC 3339 的 UTC 表示。格式能让接收方定位瞬间,但不会告诉它这个瞬间是开始、截止还是实际完成时间。RFC 3339
如果只输出 2027-04-01 00:00:00,又让接收方按自己的默认时区解释,同一串字符就会产生不同结果。给这个北京时间读数直接追加 Z 也没有完成转换:2027-04-01T00:00:00Z 表示 UTC 零点,比原截止晚了八小时。转换必须先确定原值所指的瞬间,再生成目标表示;增加或删除时区后缀会改变解释,不能当作普通文本修饰。
数字时间戳是另一种协议选择。若约定使用从 1970-01-01T00:00:00Z 起计数的 epoch 毫秒,可以将字段命名为 expires_at_epoch_ms。同一个瞬间按秒和毫秒计数,数值相差一千倍;字段写成笼统的 timestamp,接收方仍然需要猜。协议还需固定所用的计时约定,并验证接收端的数值类型能否保留所需范围和精度。
零值不是天然的“未知”,它在 epoch 表示中对应纪元起点。若业务存在尚未确定、永久有效或没有该字段等情况,应明确分别如何表达,不能把它们都交给调用方自行解释为 0 或 null。
解析应检查字段契约,而不只是字符串能否转成某种时间类型。 本例时间点字段要求明确偏移量,缺失时就应拒绝,不能默默借用服务器配置。出生日期字段则只接收约定的日期格式;当地预约输入按照上一节查询候选并校验。即使底层解析器接受更多格式,接口也应约束自己承诺支持的范围。
1.7.3 列类型与驱动共同决定存取结果
协议确定以后,再为数据选择列类型。以 PostgreSQL 18 为例,出生日期可以使用 date;不带时区的预约输入可以使用 timestamp without time zone,地区标识另存;已确定的截止则可以使用 timestamptz。后者按 UTC 保存时间点,文本输出随会话时区变化,不保留原始地区名称。因此,一个 timestamptz 列不能同时替代预约的当地输入和 time_zone。PostgreSQL 日期时间类型
无时区列也可以按应用约定保存 UTC 读数,但列本身不会强制这种解释。所有写入、读取和导出程序都要遵守约定。维护已有系统时可以保留这样的策略,前提是把解释落实到转换代码和验证中;仅仅把列命名为 created_at_utc,不会阻止脚本写入其他时区的读数。
反过来,选了 timestamptz 也不能省略输入契约。PostgreSQL 将不含时区的输入解释为该类型时,会使用会话的 TimeZone。同样写入 2027-04-01 00:00:00,会话采用北京时间时得到 UTC 3 月 31 日 16 点,采用 UTC 时则得到 UTC 4 月 1 日零点。差异在写入时已经产生,之后再统一显示为 UTC 也修复不了。PostgreSQL 时间戳输入规则
驱动还决定应用类型怎样映射到数据库类型。业务代码使用 Instant,不等于驱动就支持直接绑定 Instant。例如 pgJDBC 文档将 timestamp with time zone 映射到 OffsetDateTime,并说明不直接支持 Instant 和 ZonedDateTime。采用这套映射时,可以在持久化边界将 Instant 转成 UTC 偏移的 OffsetDateTime,读取后再调用 toInstant();实际项目仍需按使用的驱动版本和框架验证。pgJDBC 日期时间映射
参数绑定、数据库存储、驱动读取和接口序列化共同决定最终结果。 应验证完整路径,而不只是打开数据库客户端看一眼。
表 1-4:本例按明确的时间点映射读写时,预期保持的关系。数据库和驱动部分需要实际集成验证,以下不作为实测结果。
| 位置 | 值或预期观察 | 要保持的关系 |
|---|---|---|
| 应用准备写入 | 2027-03-31T16:00:00Z | 定位原截止瞬间 |
| 参数绑定 | 转成驱动支持的时间点表示 | 不丢掉或猜测偏移量 |
数据库 timestamptz | 保存该瞬间 | 不保留原地区名称 |
Asia/Shanghai 会话查询 | 2027-04-01 00:00:00+08 | 按北京时间显示同一瞬间 |
| UTC 会话查询 | 2027-03-31 16:00:00+00 | 显示变化,瞬间不变 |
| 驱动读回应用 | 转为与原值相等的时间点 | 保留约定精度 |
| 接口输出并重新解析 | 与原时间点相等 | 完成整个往返 |
图 1-5:写入、存储、读取与接口解析共同组成待验证的转换链。比较对象是时间点及约定精度,不能只看数据库客户端中的显示。
读回后,应先按语义比较,再检查业务判断。接口返回 2027-04-01T00:00:00+08:00 与返回 2027-03-31T16:00:00Z 都可以保持本例截止;把前者的后缀直接改成 Z 则不行。给同一个检查瞬间,存取前后的截止还应产生相同的有效期判断。完整 SQL 和其他存储策略见存取实验。
1.7.4 精度、连接环境与历史修复
即使时区解释完全正确,精度损失也可能移动边界。假设某个排他截止是 2027-03-31T16:00:00.123456Z,而一层序列化把小数截断到毫秒:
text
原截止: 16:00:00.123456Z
读回截止:16:00:00.123000Z
检查瞬间:16:00:00.123200Z检查瞬间早于原截止,却晚于读回截止。同一次访问,存取前满足时间条件,存取后就不满足。把两个截止都格式化到秒,会让它们看起来完全一样,恰好掩盖这个差异。这里的截断是示例假设,实际层可能拒绝、截断或舍入,需要分别核对。
精度契约要说明接受多细的输入,以及无法保留时怎么办。 如果业务只支持毫秒,可以拒绝更细的值,或按已公开的规则先规范化,再用于判断和保存。往返应与规范化后的业务值比较,不能先用微秒值作决定、保存时才悄悄降为毫秒。精度变更还应检查端点附近的输入,因为那里最容易改变结果。
运行环境也需要进入验证。固定同一个输入,改变服务默认时区和数据库会话时区,再通过连接池中的不同物理连接读写,检查结果是否仍满足原约定。若依赖会话配置,应确认新连接与复用连接都得到正确设置;一个连接上的成功结果不能代表整条存取路径都正确。日期用例要检查日期未变,预约用例还要检查原始输入、地区标识和选择依据仍在。
接口往返可以在内存中验证格式与解析;数据库往返则必须经过实际列、驱动和连接配置。前者通过不能代替后者。对于要求在编辑中保留精度的字段,还应验证打开表单后原样保存:若表单只显示到秒,却把显示文本当作原值提交,也可能丢掉未显示的小数部分。
历史数据修复需要另外的证据。发现一批时间相差八小时,应先分清只是客户端显示不同,还是记录的瞬间真的改变。若确实写错,再按写入来源、程序版本和当时配置核对独立流水;同一列可能混有正确值与不同原因造成的错误值,统一加减八小时会把正确记录也改坏。
缺少来源时,一串不带偏移量的日期时间可能有多个合理解释,单凭值本身无法确定原意。此时应保留原值与待核查状态,不把猜测当作确定修复。具体查证与迁移步骤见历史数据排查。
这些检查保证截止在传输和保存后仍保持约定含义。接下来还要确定,系统在什么时候检查这个截止,以及检查通过后允许操作继续多久。