データベース

冪等なUpsertのための決定論的なULID

データベースが書き込みの自動リトライを禁止している場合、すべての書き込みはそれ自体で冪等でなければなりません。コンテンツから派生したIDは、それをコストなしで実現します。ここではその導出と、それを安全に保つ唯一の不変条件を示します。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

データベースが書き込みの自動リトライを禁止している場合、送信するすべての書き込みは、それ自体がべき等でなければなりません。なぜなら、他に何も重複排除を行ってくれないからです。この記事で紹介するテクニックは、決定論的ULIDです。これは、ランダムなソースからではなく、ドキュメントを識別するコンテンツから派生したドキュメントIDです。採用コストはほとんどかからず、構造上リトライを安全にし、そして決して破ってはならないたった一つのルールが付属しています。これは、その完全な導出、それが防ぐ障害、それを正しく保つ不変条件、そしてそれが適用されないケースです。

ID導出パイプラインの図

手動リトライにおいてランダムなIDが危険な理由

ULIDは128ビットの識別子です。上位ビットにタイムスタンプ、下位ビットにランダムな値が含まれます。タイムスタンプが接頭辞にあるため、ULIDはほぼ作成時間順にソートされます。これは主キーとして非常に有用です。ランダムな接尾辞がそれぞれのIDをユニークにしますが、同時に、手動で書き込みをリトライする瞬間に危険をもたらすものでもあります。

新しいランダムなIDを使用する挿入処理を想像してみてください。書き込みリクエストがサービスから送信され、データベースがそれを適用し、その後の確認応答が返ってくる途中で失われます。あなたのコードは、正しくも、書き込みが成功したかどうかを知ることができないため、リトライします。リトライ時には、新しいULIDを生成したため、新しいランダムなIDが使用されます。その結果、同じ論理的なものを表す2つのドキュメントが、2つの異なるIDで存在することになり、システム上ではそれらが重複していることを誰も知ることができません。あなたはすべてを正しく行ったにもかかわらず、データを破損させてしまったのです。

実際のMongoDBでは、リトライ可能な書き込み機能がこの問題をうまく処理します。ドライバーがトランザクション番号を付与し、サーバーが重複排除を行うため、リトライは安全です。しかし、一部のエンジンはそのメカニズムをサポートしておらず、retryWrites=false を要求します。その時点でセーフティネットはなくなり、責任はあなた自身にあります。サーバーが重複排除を行ってくれていたときには問題なかったランダムなIDが、あなたがリトライする側になった瞬間に、リスクとなるのです。

idを導出することで曖昧さを完全に取り除く

解決策は、ランダム性からidを生成するのをやめ、ドキュメントを識別するフィールドから導出することです。同じ論理ドキュメントが常に同じidを生成する場合、再試行された書き込みは同じidをターゲットにし、アップサートは2行目ではなく無害な上書きに変わります。

多言語ブログの場合、投稿はそのスラッグ、言語、および公開日時によって一意に識別されます。したがって、まさにそれらの情報から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])
}

ここでは2つの設計上の選択が重要です。ULIDのタイムスタンプ部分は、書き込み時の実時間ではなくpublishedAtから取得されるため、明日同じ投稿を再取り込みしてもidは変わりません。ランダム部分は、識別フィールドのSHA-256ハッシュの最初の10バイトに置き換えられるため、決定的でありながら、単一のインデックス範囲でのホットスポット化を避けるためにキースペース全体にidを分散させます。その結果、通常のULIDのように見え、ソートでき、意味のあるタイムスタンププレフィックスを持ちながら、コンテンツのみから再現可能なidができます。

そのidを手にすれば、書き込みはそれをキーとしたアップサートになります。

// 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),
)

これを一度実行すると挿入されます。これを100回実行しても、データベースにはドキュメントが1つだけ保持されます。なぜなら、最初の呼び出し以降はすべて、同じフィールドを同じidに書き込んでいるからです。この操作は、調整も、トランザクションも、重複排除テーブルもなしでべき等です。

冪等性は1回の呼び出しではなく、パイプライン全体の性質である

upsertの段階で処理を止めて勝利宣言をしたくなるものですが、冪等性はエンドツーエンドで維持されなければ、全く維持されていないのと同じです。もしidが安定していても、ドキュメントのボディに新しく生成されたタイムスタンプやランダムなフィールドが含まれている場合、2回の実行で同じidに対して2つの異なるボディが生成されてしまいます。そして、重複行は発生しないものの、不要な書き込みが発生し、パイプラインが実行されるたびにドキュメントが変更されることになります。その結果、変更フィードが乱れ、キャッシュが無効化され、実際の編集とno-op(何もしない操作)を区別することが不可能になります。

したがって、この規律はペイロードにも及びます。ドキュメント内で識別用のコンテンツから派生するものは、すべてそれ自体が決定論的であるべきです。ソースのコンテンツハッシュが良い例です。入力からそれを計算し、ドキュメントに保存し、保存されたハッシュが入力されたものと一致する場合には書き込みを完全にスキップします。こうすることで、変更されていない投稿の再実行は、単なる安全な上書きではなく、全く書き込みを行わない操作になります。

// 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が不適切な選択となる場合

このテクニックは普遍的なものではなく、適合しない場所で使うと独自の問題を引き起こします。ドキュメントが自然なアイデンティティ、つまりそれがどのドキュメントであるかを真に定義するフィールドのセットを持つ場合に、このテクニックは機能します。ブログ投稿にはそれがあります。スラッグ、言語、日付です。ユーザーアカウントにもそれがあります。メールアドレスや外部IDです。

ドキュメントに自然キーがなく、それぞれが個別のイベントである場合には、このテクニックは機能しません。独立した出来事の追記専用ログ、クリックのストリーム、見た目が同じ2つのエントリが正当に2つの異なるものであるジョブキュー、これらはすべて、2番目の同一イベントが最初のイベントを上書きすることを望まないというまさにその理由から、一意のランダムなIDを必要とします。これらのケースでは、通常のランダムなULIDを使用し、リトライの安全性が必要な場合は、プライマリIDに組み込むのではなく、リクエストに含まれる冪等キーからそれを取得してください。

判断基準は1つの質問です。もし2つの書き込みが同一のコンテンツを持つ場合、2番目は最初のものを置き換えるべきか、それともその隣に配置されるべきか?置き換える場合は、IDを導出します。隣に配置する場合は、ランダムなままにします。システム全体で一度にではなく、コレクションごとにその質問に答えてください。

その労力で得られるもの

労力はハッシュとヘルパー関数だけでほぼゼロに等しく、その見返りとして、分散システムにおけるある種のバグが一切発生し得なくなります。リトライは同じIDに収束するため安全です。ボディとハッシュが安定しているため、変更されていないコンテンツを再取り込みしても、コストの低いno-opになります。そして、あなたの書き込みは元からべき等であるため、データベースが自動書き込みリトライを禁止していることは、あなたが戦うべき制限ではなく、すでに満たしている制約となります。データベースの厳格さとあなたのIDスキームは、最終的に同じ方向を指し示すことになります。