データベース

アトミックカウンターがドリフトする理由と、リコンシリエーションによるその修正方法

アトミックインクリメントで維持されるカウンターは、実際の行数から徐々に乖離する可能性があります。定期的なリコンシリエーションジョブが真の値を再計算し、それを収束させて元に戻します。

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

アトミックなインクリメントで維持される派生カウンターは、信頼できる情報源 (source of truth) から乖離していきます。インクリメントが間違っているからではなく、アトミック性が保証するのは1つの操作であって、別のレコードとの一致ではないからです。クラッシュ、リトライ、部分的な失敗はそれぞれ数値を少しずつずらし、その誤差は蓄積されていきます。解決策は、より大きなロックをかけることではありません。カウンターを高速なキャッシュとして扱い、情報源から真の値を再計算して書き戻す定期的なジョブを実行することです。そうすれば、数値は一時的に間違っていても、最終的には正しい値になります。

ドリフトとは何か、そしてアトミック性ではなぜ防げないのか

コメントを行として保存しており、ページが読み込まれるたびにコメントのコレクションをスキャンすることなく、各投稿にコメント数を表示したいとします。当然考えられる最適化は、派生カウンターです。投稿に comment_count フィールドを保持し、コメントが作成されるたびにそれをインクリメントします。

// Fast path: bump the derived counter whenever a comment is created.
// This single update is atomic for this one document. Nothing here ties
// it to the actual number of comment rows that exist.
_, err := counters.UpdateOne(ctx,
    bson.M{"_id": postID},
    bson.M{"$inc": bson.M{"comment_count": 1}},
    options.Update().SetUpsert(true),
)

$inc はアトミックです。2つの同時インクリメントは、読み取り-変更-書き込みのように更新を失うことはありません。そのため、カウンターは正しいと結論付けたくなります。しかし、そうではありません。その理由は、アトミック性がもたらすものについてのカテゴリーエラーにあります。

アトミック性とは、単一の操作のプロパティです。それは、この1つのインクリメントが、値を破損させるインターリービングなしに、完全に発生するか、まったく発生しないかのどちらかであることを意味します。それは、インクリメントのシーケンスが別のコレクションの行数と一致するかどうかについては何も言及しません。カウンターとコメント行は、2つの別々の操作によって更新される、2つの別々の状態です。それらの間の一貫性は、どちらの操作も持つプロパティではありません。これら2つの書き込みが1つのアトミックな単位の一部でない場合 (そして、ドキュメントストアと別々のコレクションをまたぐ場合、通常はそうではありません)、それらが一致しなくなる可能性が生じます。その不一致がドリフトです。

ドリフトはどこから来るのか

ドリフトは単一のバグではありません。それは、行の書き込みとカウンターの書き込みの間に存在する小さなギャップの集合体であり、それぞれが予測可能な方向にカウントを押しやります。その原因を知ることで、リコンシリエーションジョブが何を修正すべきか、そして実際のバグが現れたときにメトリクスがどのように見えるべきかがわかります。

原因 メカニズム 方向
2つの書き込みの間のクラッシュ 行が挿入された後、$incが実行される前にプロセスが停止する アンダーカウント
逆の順序でのクラッシュ カウンターがインクリメントされた後、行の挿入が失敗またはロールバックする オーバーカウント
At-least-once (少なくとも1回) のリトライ メッセージが2回配信され、同じイベントがカウンターを2回インクリメントする オーバーカウント
デクリメントの欠落 行が削除またはソフトデリートされたが、対応するデクリメントが実行されない オーバーカウント
バックフィルまたは手動修正 インクリメントパスをバイパスして、データベースに直接行がインポートまたは修復される アンダーカウント
順序が入れ替わったソフトデリートとリストア デリートでデクリメントされ、後のリストアでインクリメントを忘れる、またはその逆 どちらか

2つのことが際立っています。第一に、エラーは相殺されません。一部のインクリメントをドロップし、他のインクリメントを二重に適用するシステムは、平均化されて修正されることはありません。それはどこか間違った値に着地します。第二に、エラーの方向は診断に役立ちます。常に高い値を示すカウンターは、重複した処理やデクリメントの欠落を指し示します。常に低い値を示すカウンターは、行がコミットされた後に失敗する書き込みパスを指し示します。数値を修正するリコンシリエーションジョブは、ギャップを閉じる前に記録しておけば、これらのうちどちらが発生しているかを教えてくれます。

カウンターを信頼できる情報源ではなく、キャッシュとして扱う

これらすべてを管理可能にするためのメンタルモデルは、カウンターを事実と考えるのをやめることです。それは事実のキャッシュなのです。事実とはコメント行のセットです。カウンターは、ページが読み込まれるたびに実行したくない問い合わせに対する、事前に計算された答えです。

カウンターがキャッシュであると見なすと、2つのルールが導かれます。キャッシュは古くなることが許容されるため、一時的にカウントが間違っていても、それは危機ではなく許容されます。そして、キャッシュはキャッシュ元のデータから再構築する方法を持たなければなりません。なぜなら、再生成できないキャッシュは、信頼性の低い一次データにすぎないからです。信頼できる情報源は、その権威を保ちます。それは、カウンターを完全に無視して直接クエリされた行のコレクションです。意思決定の根拠となる数値が必要なときはいつでも、行をカウントします。カウンターは、わずかな一時的なエラーが何のコストももたらさない、安価な読み取りのためのものです。

この再定義こそが、アプローチ全体を誠実なものにします。常に正しいカウンターを約束し、それを達成できないまま放置するのではないのです。あなたが約束するのは、高速な近似カウンターと、いつでもフォールバックできる信頼できる情報源、そしてその2つを近い状態に保つジョブです。

再計算と上書きを行うリコンシリエーションジョブ

アトミックカウンターのドリフトと定期的なリコンシリエーション 太いストローク、大きな角丸、パステル調の塗りつぶし。色分け:信頼できる情報源と収束はティール、ドリフトとエラーはコーラル、中立的な枠はグレー。番号付きステップ、エッジラベル、末尾の // キャプション。 リコンシリエーション (ホットパス外) 1 ドリフトが蓄積する 2 行数を再カウント 3 ベースの上書き 4 カウンターが収束する アトミックカウンター comment_count += 1 リコンシリエーションジョブ 定期スケジュール 真の情報源(行) CountDocuments 収束カウンター base_count // the counter is only a cache; the reconciliation Job recounts truth from rows and erases drift

リコンシリエーションは、ホットパスから外れたスケジュールされたジョブであり、行から真のカウントを再計算し、それをカウンターに書き込みます。リクエストごとではなくタイマーで実行されるため、ホットパスが避けていたコストの高い処理、すなわち実際のカウント処理を行うことが許されています。

ナイーブなバージョンは3行です。行をカウントし、数値を書き込み、完了です。

// Naive reconciliation: recompute from the source of truth and overwrite.
// Correct in isolation, but see the race in the next section.
trueCount, err := comments.CountDocuments(ctx, bson.M{
    "post_id":    postID,
    "deleted_at": bson.M{"$exists": false},
})
if err != nil {
    return err
}
_, err = counters.UpdateOne(ctx,
    bson.M{"_id": postID},
    bson.M{"$set": bson.M{
        "comment_count": trueCount,
        "reconciled_at": time.Now(),
    }},
)

これは、古い値をまったく信頼しないため、過剰であれ過少であれ、あらゆる種類のドリフトを一度に修正します。行から数値を再導出します。古いカウンターが示していた値は、正しいか間違っているかに関わらず、破棄されます。

1つの危険があり、それが単純なバージョンが最終的なバージョンではない理由です。CountDocumentsが返ってきてから$setが適用されるまでの間に、新しいコメントが到着する可能性があります。ホットパスは、それらのためにカウンターをインクリメントします。その後、$setは、それらのインクリメントが存在する前に計算された数値でカウンターを上書きし、それらのインクリメントは失われます。ドリフトを修正するための調整が、新たなドリフトを生み出してしまいました。

進行中の書き込みが失われないように、ウォーターマークを基準にリコンサイルする

同時書き込みを上書きしてしまうのを避けるためのクリーンな方法は、データの確定したプレフィックスのみをリコンサイルし、最近の書き込みはそのままにしておくことです。これには、カウンターの保存方法に小さな変更が必要です。それを、リコンサイルが所有する base_count と、ホットパスが所有する live_delta の2つのフィールドに分割します。表示される数値は、それらの合計です。

ホットパスは、リコンサイルされた値に触れなくなります。ライブデルタのみをインクリメントします。

// Hot path now only touches live_delta. Reconciliation never overwrites
// this field, so a concurrent increment can never be clobbered.
_, err := counters.UpdateOne(ctx,
    bson.M{"_id": postID},
    bson.M{"$inc": bson.M{"live_delta": 1}},
    options.Update().SetUpsert(true),
)

読み取りで2つのフィールドが追加されます。

displayed := doc.BaseCount + doc.LiveDelta

リコンシリエーションはウォーターマーク、つまり、それより古い作成時刻を持つ新しい行が決して到着しないほど過去のタイムスタンプを選択します。実際には、それは書き込み可視性のラグよりも古く、最も古いオープンなトランザクションよりも古いことを意味するため、通常は数秒で十分です。それは、そのウォーターマークまでの正の状態をカウントし、それを新しいベースとして設定し、ベースが現在考慮している増分を正確にライブデルタから削除します。その書き込みのすべては1つのアトミックな更新で行われるため、フィールドが不一致になることはありません。

// Settled boundary: no new row can appear with a timestamp older than this.
watermark := time.Now().Add(-30 * time.Second)

baseTrue, err := comments.CountDocuments(ctx, bson.M{
    "post_id":    postID,
    "deleted_at": bson.M{"$exists": false},
    "created_at": bson.M{"$lte": watermark},
})
if err != nil {
    return err
}

// Atomic pipeline update. Install the recomputed base, and subtract from
// live_delta the number of rows the base has just absorbed (baseTrue minus
// the old base_count). Increments for rows after the watermark stay in
// live_delta untouched.
_, err = counters.UpdateOne(ctx,
    bson.M{"_id": postID},
    bson.A{
        bson.M{"$set": bson.M{
            "live_delta": bson.M{"$subtract": bson.A{
                "$live_delta",
                bson.M{"$subtract": bson.A{baseTrue, "$base_count"}},
            }},
            "base_count":    baseTrue,
            "reconciled_at": "$$NOW",
        }},
    },
)

これが安全である理由は、調整とホットパスが排他的なフィールドに書き込むようになったためです。ベースは行から再導出されるため、古いベースに蓄積されたドリフトは一掃されます。ライブデルタはごく最近の増分のみを保持し、それらもウォーターマークを過ぎると、次のサイクルでベースに折りたたまれます。調整中の同時書き込みはライブデルタに格納され、上書きのパスに入ることは決してありません。カウンターは、ロックや書き込みの一時停止なしで収束します。

可能な限り最も単純なバージョンが必要で、わずかに大きい一時的なエラーを許容できる場合は、単一フィールドの上書きを維持し、調整と競合する書き込みが一時的に失われ、次の実行で修正される可能性があることを受け入れます。これは、書き込みが非常に少ないカウンターにとっては正当な選択です。ウォーターマークと分割されたフィールドは、上書きによる破壊が問題になるほど書き込みレートが高い場合に採用するものです。

ドリフトを測定し、実際のバグを表面化させる

リコンシリエーションが破棄する数値は、それが書き込む数値よりも価値があります。新しいベースをインストールする前は、古い値と真の値がわかっています。その差がドリフトであり、それは書き込みパスの健全性に関する直接的な証拠です。

drift := baseTrue - oldBaseCount
metrics.Observe("counter_drift", float64(drift),
    "collection", "comments")

それをゲージまたはヒストグラムとして、カウンターの種類でタグ付けして発行します。これでシグナルが得られ、それはストーリーを物語っています。ゼロ付近を推移し、そこにとどまるドリフトは、ホットパスが基本的に正しく、リコンシリエーションは稀なクラッシュをクリーンアップしているだけであることを意味します。実行の合間に増大するドリフトは、インクリメントが思ったよりも速く失われたり、二重に適用されたりしていることを意味し、その符号がどちらであるかを示します。突然のスパイクは、デプロイやインシデントと一致し、書き込みパスを壊した変更を直接示します。

これがなければ、リコンシリエーションはバグを隠してしまいます。それは毎晩、壊れたインクリメントパスを静かに取り繕い、表示される数値は朝にはいつも正常に見えるため、パスが壊れていることに気づくことはありません。このメトリクスは、静かな修正をアラートに変えます。ジョブは依然として症状を修正しますが、今や病気そのものも報告するようになります。ドリフトが注意すべきしきい値を超えた場合は、誰かをページングしてください。なぜならその時点では、夜間ジョブが安全にマスクできるよりも速くカウンターがドリフトしているからです。

近似カウンターでは不十分なとき

このアプローチ全体は、瞬間的な正確さを、低コストな読み取りと自己修復機能と引き換えにしています。そのトレードオフは、多くの種類のカウンターにとっては正しく、特定の種類のカウンターにとっては間違っています。そして、その境界線は金銭です。

統計、ランキング、表示回数、閲覧総数、コメント数、「いいね」の集計、フォロワー数などにおいて、数分間、数個のずれがあるカウントはユーザーには見えず、何のコストもかかりません。分散システムにおいて、それらを正確かつリアルタイムにすることを強制すると、最もビジーな書き込みのホットパス上でトランザクションやロックが必要になることを意味し、そのコストはトラフィックと共に増大します。定期的な調整を伴う高速な近似カウンターは実用的な答えであり、結果的に正しくなることは、十分に強力な保証です。

カウントが現実的な結果を伴う決定を左右するようなものであれば、その計算は一変します。ウォレットの残高、強制する有料クォータ、過剰販売の可能性がある在庫、マイナスになってはならないクレジット台帳など、これらは遅延して調整されるキャッシュであってはなりません。それらについては、信頼できる情報源は読み取りパス上に置かれるべきです。決定時にカウントするか、信頼できる残高を読み取るか、あるいは制約に対してデクリメント自体をトランザクショナルにします。一時的に間違っているカウンターは「いいね」の集計には問題ありませんが、金額に対しては許容できません。

考慮すべき運用コストもあります。行のカウントは無料ではなく、巨大なコレクションに対してすべてのカウンターを再計算する調整ジョブは、それ自体が負荷問題になり得ます。一般的な解決策は、最後の実行以降に変更されたドキュメントのみを調整すること、スキャンを時間でウィンドウ化すること、一度にすべてをカウントしないようにずらして実行すること、そして乖離を小さく保つために十分に頻繁に、しかしスキャンを安価に保つためには十分に稀に実行することです。そのチューニングこそが、調整されるカウンターを運用する上での実際の作業です。設計はシンプルです。データが増加するにつれてジョブを手頃なコストに保つこと、そこにエンジニアリングの力が注がれます。