用于幂等性更新插入的确定性 ULID
当数据库禁止自动写入重试时,每次写入操作本身都必须是幂等的。一个由内容派生的 ID 可免费实现这一点。以下是其推导过程,以及保证其安全的那个唯一不变量。
当数据库禁止自动写入重试时,你发送的每一次写入都必须自身是幂等的,因为没有其他东西会为你进行去重。本文要介绍的技术是一种确定性的 ULID:一个从标识文档的内容中派生出的文档 ID,而非来自随机源。采用它的成本几乎为零,它通过其构造使重试变得安全,并且它附带一条你绝不能违反的规则。本文将完整介绍其派生过程、它能防止的故障、保持其正确性的不变量,以及它不适用的情况。
为什么在手动重试下,随机 ID 是危险的
ULID 是一个 128 位的标识符:高位是时间戳,低位是随机数。时间戳前缀意味着 ULID 大致按创建时间排序,这对于主键来说确实很有用。随机后缀是使其独一无二的原因,也正是在你手动重试写入操作的那一刻,它让随机 ID 变得危险。
设想一个使用全新随机 ID 的插入操作。写入请求离开你的服务,数据库应用了该请求,然后确认信息在返回途中丢失了。你的代码(这是正确的)不知道写入是否成功,所以它会重试。重试请求携带了一个新的随机 ID,因为你生成了一个新的 ULID。现在有了两个描述同一个逻辑事物的文档,它们有两个不同的 ID,而系统中没有任何东西能分辨出它们是重复的。你做的每一步都是对的,但仍然损坏了你的数据。
在实际的 MongoDB 中,可重试写入 (retryable writes) 掩盖了这个问题。驱动程序会附加一个事务编号,服务器会进行去重,因此你的重试是安全的。但有些引擎不支持该机制,并要求设置 retryWrites=false,此时安全网便消失了,责任就落在了你的身上。当服务器为你去重时,随机 ID 本没有问题;但当你成为重试方的那一刻,它就变成了一个隐患。
通过派生 id 完全消除歧义
解决方法是停止从随机性生成 id,而是从标识文档的字段中派生它。如果同一个逻辑文档总是产生相同的 id,那么重试的写入操作会指向同一个 id,而一次 upsert 操作会将其变成一次无害的覆盖,而不是产生第二行数据。
对于一个多语言博客,一篇文章由其 slug、语言和发布时刻唯一标识。因此,我们精确地从这些字段构建 ULID:
// PostID derives a stable ULID from the fields that identify a post.
// The same (slug, lang, publishedAt) always yields the same id, so an
// upsert is a no-op on every retry instead of a duplicate insert.
func PostID(slug, lang string, publishedAt int64) string {
sum := sha256.Sum256([]byte(slug + ":" + lang))
return buildULID(uint64(publishedAt), sum[:10])
}
这里的两个设计选择是关键所在。ULID 的时间戳部分来自 publishedAt,而不是写入时的挂钟时间,因此即使你明天重新提取同一篇文章,id 也不会改变。随机部分被替换为标识字段的 SHA-256 哈希值的前十个字节,因此它是确定性的,但仍然能将 id 分散到整个键空间中,以避免在单个索引范围上出现热点。最终得到的 id 看起来和排序方式都像一个普通的 ULID,仍然带有一个有意义的时间戳前缀,但仅从内容本身即可复现。
有了这个 id,写入操作就变成了一个以它为键的 upsert 操作:
// First call inserts, every retry overwrites identical data.
// Row count never grows, no server-side dedup required.
_, err := coll.UpdateByID(ctx, PostID(slug, lang, publishedAt),
bson.M{"$set": doc},
options.Update().SetUpsert(true),
)
运行一次,它会插入数据。运行一百次,数据库中仍然只有一个文档,因为第一次调用之后的所有调用都是将相同的字段写入相同的 id。该操作是幂等的,无需协调、无需事务、也无需去重表。
幂等性是整个管道的属性,而非单次调用的属性
人们很容易在执行更新插入(upsert)操作后就此打住并宣布大功告成,但幂等性必须端到端地保持,否则就根本无法保持。如果 ID 是稳定的,但文档正文包含新生成的时间戳或随机字段,那么两次运行会为同一个 ID 生成两个不同的正文。尽管你不会得到重复的行,但你会得到不必要的写入,以及一个每次管道运行时都会改变的文档。这会扰动你的变更流 (change feed),使缓存失效,并且让你无法区分真正的编辑和无操作(no-op)。
因此,这种规范也延伸到了有效负载(payload)。文档中的任何内容,如果它是从识别性内容派生出来的,那么它本身也应该是确定性的。源内容的内容哈希就是一个很好的例子:从输入计算它,将其存储在文档中,并在存储的哈希与传入的哈希匹配时完全跳过写入。现在,对未更改的帖子重新运行,将不再仅仅是一次安全的覆盖写入,而是根本不写入。
// Skip the write when nothing changed. Idempotent and cheap.
if existing != nil && existing.SourceHash == incoming.SourceHash {
return nil // unchanged, nothing to do
}
保持这种正确性的心智模型是:相同的输入,相同的 ID,相同的正文,并且在输入未改变时,理想情况下不进行写入。其中的每一层都会加强其上一层。
你必须遵守的唯一不变准则
id 由 publishedAt 派生而来,这意味着 publishedAt 在文档的整个生命周期内必须是不可变的。这是整个方案所依赖的唯一规则,并且破坏它所带来的问题非常微妙,值得专门提出警告。
假设一篇文章已发布,其 id 是根据其发布日期计算出来的,并且它正存放在数据库中。之后,有人在源文件中编辑了发布日期。下一次提取数据时会计算出一个新的 id,因为用于派生的输入发生了变化。使用新 id 的更新插入(upsert)操作会插入一个全新的文档,而旧的文档(在旧 id 下)现在成了一个孤儿,没有任何代码会再更新或删除它。你没有覆盖这篇文章。你创建了它的一个分支。
要明确地防范这种情况,而不是相信作者们会记住。在提取数据时,通过其标识字段查找现有文档,重新计算 id,如果存储的 id 和新计算出的 id 不一致,就停止操作并报一个明确的错误,而不是写入数据。
// If publishedAt changed, the derived id changed, and a blind upsert would
// orphan the old document. Refuse to proceed.
if existing != nil && existing.ID != PostID(slug, lang, publishedAt) {
return fmt.Errorf("publishedAt is immutable for %s/%s: changing it orphans the old document", slug, lang)
}
编辑正文总是可以的,因为正文不是 id 的一部分。只有那些用于派生的字段是冻结的。在你的创作文档中明确指出这个边界,这样这条规则就成了一个已知的约束,而不是人们偶然发现的陷阱。
确定性 ID 何时是错误的选择
这项技术并非普遍适用,在不适用的地方使用它会产生其自身的问题。当一个文档具有自然标识,即一组能真正定义该文档身份的字段时,这项技术才能奏效。一篇博客文章就有这样的标识:slug、language、date。一个用户帐户也有:电子邮件或外部 ID。
当文档没有自然键且每个文档都是一个独立事件时,它就不起作用了。一个只追加的独立事件日志、一个点击流、一个作业队列(其中两个看起来相同的条目确实是两个不同的事物),所有这些都需要唯一的随机 ID,这正是因为你不希望第二个相同的事件覆盖第一个。对于这些情况,应使用普通的随机 ULID,并且,如果需要重试安全性,应从请求中携带的幂等键获取,而不是将其内置于主 ID 中。
测试方法只有一个问题:如果两次写入携带了相同的内容,第二次写入应该替换第一次,还是与第一次并存?如果替换,就派生出 ID。如果并存,就保持 ID 随机。应按集合(collection)来回答这个问题,而不是为整个系统只回答一次。
这样做的好处
付出的努力微乎其微,只需要一个哈希和一个辅助函数,而回报就是一整类分布式系统错误根本不会发生。重试是安全的,因为它们会收敛到同一个 id。重新摄入未更改的内容是一种开销很低的无操作 (no-op),因为内容主体和哈希是稳定的。而且,数据库禁止自动写入重试,这就不再是你需要与之抗争的限制,反而成了一个你已经满足的约束,因为你的写入操作从一开始就是幂等的。数据库的严格性与你的 id 方案最终会指向同一个方向。