Go 的 time.Time 可以携带纳秒,PostgreSQL 的 timestamptz 只保留微秒。第一次写入证据时,数据库会保存被压缩后的时间;同一个幂等请求再次到达时,程序拿内存中的纳秒时间与数据库读回的微秒时间做精确比较,重复请求就会被错误判定为“同一个键对应了不同内容”。
修复方法是在进入 PostgreSQL 之前统一执行 UTC 转换和微秒截断,并让规范化后的值同时参与序列化、比较和重放。这样重复请求仍然可以命中原记录,真正改变证据内容的请求仍然会被拒绝。
问题详情
故障出现在一个带证据内容的幂等写入流程中。流程通常是:
- 计算一次用量或账单证据;
- 使用证据键和事件编号写入 PostgreSQL;
- 网络超时后,调用方携带相同幂等键重试;
- 数据库唯一约束发现记录已经存在,程序读取旧记录并比较完整内容。
第一次写入成功。重试请求携带的业务字段完全相同,只有一个时间字段带有纳秒尾数:
第一次请求产生: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 还应补充并发幂等写入测试,确认唯一约束与冲突读取路径在竞争下保持同一结果。
参考资料
喜欢这篇文章?
如果这篇文章帮到了你,可以请我喝杯咖啡,支持我继续写下去。
评论