返回所有文章
2239 字8 分钟阅读访问统计加载中

Go 纳秒时间写入 PostgreSQL 后产生重放冲突:统一截断到微秒

postmortem

Go 的 time.Time 可以携带纳秒,PostgreSQL 的 timestamptz 只保留微秒。第一次写入证据时,数据库会保存被压缩后的时间;同一个幂等请求再次到达时,程序拿内存中的纳秒时间与数据库读回的微秒时间做精确比较,重复请求就会被错误判定为“同一个键对应了不同内容”。

修复方法是在进入 PostgreSQL 之前统一执行 UTC 转换和微秒截断,并让规范化后的值同时参与序列化、比较和重放。这样重复请求仍然可以命中原记录,真正改变证据内容的请求仍然会被拒绝。

问题详情

故障出现在一个带证据内容的幂等写入流程中。流程通常是:

  1. 计算一次用量或账单证据;
  2. 使用证据键和事件编号写入 PostgreSQL;
  3. 网络超时后,调用方携带相同幂等键重试;
  4. 数据库唯一约束发现记录已经存在,程序读取旧记录并比较完整内容。

第一次写入成功。重试请求携带的业务字段完全相同,只有一个时间字段带有纳秒尾数:

第一次请求产生:2026-09-19 10:20:30.123456789+00
数据库保存为:  2026-09-19 10:20:30.123456+00
重放请求携带:  2026-09-19 10:20:30.123456789+00

如果重放比较直接调用 time.Time.Equal,数据库记录和当前请求的时间点相差 789 纳秒,比较结果为不相等。程序随后返回幂等冲突,调用方会误以为自己修改了同一个请求的内容。

复盘中实际返回的冲突说明是:

Evidence Key is already associated with different cost data

这个错误只在数据库路径出现。内存存储保留完整的纳秒值,重复写入可以成功;只使用整秒或微秒时间的数据库测试也无法触发差异。

排查与原因

PostgreSQL 的时间精度低于 Go

PostgreSQL 的日期时间类型以微秒精度保存小数秒。timestamptz 解决的是时区表示和时间点存储,无法保留 Go 时间值中的第七到第九位小数。

Go 的时间值可以同时包含:

  • UTC 或其他时区位置;
  • 纳秒级墙上时间;
  • 同一进程内用于计算经过时长的单调时钟读数。

进入数据库后,时区会被转换为统一的时间点,纳秒尾数则无法继续保留。数据库读回的值因此已经发生了表示层变化。

幂等比较把表示差异当成业务差异

典型的数据库幂等流程如下:

affected, err := insert(ctx, event)
if err != nil {
    return err
}
if affected == 1 {
    return nil
}

stored := loadByEvidenceKey(ctx, event.EvidenceKey)
if stored.Equivalent(event) {
    return nil // 重放成功
}
return ErrConflict

Equivalent 往往会逐字段比较金额、数量、供应商、引用和观测时间。金额等字段没有变化,时间却经历了 Go → PostgreSQL → Go 的精度转换,于是同一证据被判定为不同证据。

问题出在比较时机。程序先把高精度值写入低精度存储,再拿未规范化的原始值参与幂等判断,数据在系统边界两侧使用了两套精度标准。

只放宽比较误差会破坏冲突检测

一个看似简单的修复是:时间相差不超过 1 微秒就当作相同。这个判断有两个风险:

  • 它把“存储精度损失”和“调用方确实修改了时间”混在一起;
  • 时间比较规则可能只修复某一处,JSON 证据、哈希、审计记录仍然保留纳秒,最终不同模块得出不同结论。

幂等比较需要稳定的规范化表示,不应依赖散落在各个比较函数里的容差。

只规范数据库列仍然不够

如果表列被截断到微秒,但事件证据同时以 JSON 保存,JSON 中的原始纳秒时间仍然存在。第一次写入和重放比较 JSON 时,差异会再次出现。

同理,只规范 ObservedAt 而遗漏 RecordedAt、创建时间或嵌套结构里的时间,也会让冲突只在某一类事件上出现。时间精度规则必须覆盖所有进入 PostgreSQL 的时间字段。

解决办法

在持久化边界生成唯一规范格式

将所有写入 PostgreSQL 的时间先统一为 UTC,再截断到微秒:

const storagePrecision = time.Microsecond

func normalizePersistedTime(value time.Time) time.Time {
    return value.UTC().Truncate(storagePrecision)
}

编码数据库列、编码 JSON 证据、生成比较副本时都调用同一套规范化函数。规范化应发生在存储适配层,领域逻辑可以继续保留更高精度的时间,便于本地测量和诊断。

如果业务只允许有限的时间字段落库,可以在对应构造器中显式标注;如果一个结构会被多个 PostgreSQL 表或 JSON 文档复用,应在统一编码入口处理,避免调用方漏掉某个字段。

只在 SQL 编码器里截断仍然不够:幂等插入冲突后,比较函数手里可能还是最初那份带纳秒的事件,数据库读回值已经只有微秒,两边依旧不相等。更稳妥的做法是在领域对象创建时就生成规范时间;如果领域对象必须保留原始时间,则冲突比较的两边都要经过同一个规范化函数。SQL 参数、JSON 文档和比较副本必须共用这条规则。

规范化后再做幂等比较

幂等写入的顺序应调整为:

接收请求
  -> 规范化所有持久化时间
  -> 生成数据库参数和证据 JSON
  -> 尝试插入
  -> 冲突时读取数据库记录
  -> 使用同样的规范化结构比较

第一次请求和重放请求都会得到相同的规范化时间:

输入: 2026-09-19 10:20:30.123456789+00
规范化:2026-09-19 10:20:30.123456+00

数据库读回的值与当前请求使用同一精度,精确比较可以安全识别重复写入。若金额、数量、引用或其他证据字段发生变化,比较仍然返回冲突。

给规范化函数配套单测和数据库往返测试

至少覆盖下面四类情况:

场景预期
纳秒时间截断到微秒截断结果稳定,时区统一为 UTC
同一规范化证据重复写入返回成功,不新增记录
同一证据键修改金额或数量返回冲突
规范化证据经过 PostgreSQL 写入再读回读回结果与规范化值相等

数据库往返测试必须故意使用 123456789 这样的纳秒尾数。测试只使用整秒时间,会把精度丢失隐藏起来;只测内存存储,也无法覆盖数据库编码和解码过程。

不要把所有时间都粗暴改成微秒

持久化精度和本地耗时精度是两个边界:

  • PostgreSQL 事件字段统一到微秒,保证读写和重放稳定;
  • 进程内耗时可以使用 Go 的单调时钟,避免系统时间回拨影响持续时间;
  • 日志和诊断数据可以保留纳秒,但与数据库证据比较时必须先转换到持久化规范;
  • 对外 API 的时间格式需要单独规定时区和小数位,不能让客户端自行猜测。

这样既保留本地诊断所需的信息,也不会让数据库精度差异破坏幂等语义。

验证结果

问题复核确认了两条对照结果:内存账本保留完整纳秒值,重复证据能够命中;PostgreSQL 持久化边界只保存微秒,带纳秒尾数的合法重复证据在精确比较时会被判定为冲突。

最小对照应固定同一个证据键和全部业务字段,只改变存储往返:

原始 ObservedAt:2026-09-19T10:20:30.123456789Z
规范化后:       2026-09-19T10:20:30.123456Z

修复后的验收条件是:第一次写入成功;同一规范化证据重放返回幂等成功;修改任意业务证据字段仍返回冲突;数据库读回的时间与规范化值精确相等。

现有复盘还暴露了一个测试盲点:如果数据库往返夹具只使用整秒或已经对齐到微秒的时间,回归测试会继续通过。纳秒尾数必须成为固定测试输入,真实 PostgreSQL 还应补充并发幂等写入测试,确认唯一约束与冲突读取路径在竞争下保持同一结果。

参考资料

喜欢这篇文章?

如果这篇文章帮到了你,可以请我喝杯咖啡,支持我继续写下去。

评论