AIニュース最前線
最新ニュースAI日報Hacker日報週報動画AIツールトレンド企業

AIニュース最前線

世界中のAI最新情報を日本語で毎時更新

最新ニュース日報トレンド企業プレミアムRSS
© 2026 ainew.jp特定商取引法に基づく表記
ニュース一覧元記事を開く
InfoQ·2026年4月3日 18:00·約37分で読める

100以上のサービスを停止させずにデータベースシーケンスを大規模に置き換える

#データベース#システムアーキテクチャ#バックエンド開発#スケーラビリティ#マイクロサービス
TL;DR

InfoQは、100以上のサービスを壊さずにデータベースシーケンスを大規模に置き換える方法についての記事を公開したが、記事の本文内容は「TBD」(未定)としており、詳細な技術情報は提供されていない。

AI深層分析2026年4月3日 19:41
2
参考/ 5段階
深度40%
1
関連度30%
1
実用性20%
3
革新性10%
2

キーポイント

1

記事の公開と主題

InfoQがデータベースシーケンスの大規模置換に関する記事を公開したが、本文は「TBD」で内容が未定である。

2

技術トピックの概要

記事のタイトルから、100以上のサービスに影響を与えずにデータベースシーケンスを置き換える大規模な技術課題が主題と推測される。

3

情報の不足

記事本文が「TBD」のため、具体的な手法、実装詳細、結果などの技術的な分析や知見は一切提供されていない。

影響分析・編集コメントを表示

影響分析

この記事は現時点では内容が未定(TBD)であり、技術的な影響や意味を分析するための十分な情報が提供されていません。データベースシーケンスの大規模置換は重要な技術課題ですが、詳細が明らかになるまで実質的な影響評価は不可能です。

編集コメント

記事の本文が「TBD」であり、技術内容が完全に欠如しているため、現時点では参考情報としての価値が極めて限定的です。実際の技術分析には、内容が公開されるのを待つ必要があります。

主要なポイント

常に要件を検証してください。当初、チームにはギャップのないIDと厳格なグローバル順序が必要だと考えていましたが、いくつかの不快な議論を経て、その両方を欠いても問題ないことに気づきました。この単一の認識の変化により、困難な分散調整の問題が、ほとんど情けないほど単純なものに収束しました。

最も優れたネットワーク呼び出しは、行わないものです。シーケンス生成をアプリケーション内にライブラリとして直接組み込んだため、リクエストの99%において、シーケンスIDの取得はローカルメモリ内の数値をインクリメントするだけで済み、ネットワークホップやサービス呼び出し、データベースへのアクセスは不要でした。

パフォーマンスだけでなく障害に対処して設計してください。クライアント側とサーバー側の2層のキャッシュがあるため、DynamoDBの停止やサービスの不具合はアプリケーションから見えなくなりました。私たちは、キャッシュがスローダウンを防ぐよりも、障害から守ってくれる方がはるかに頻繁であることを発見しました。

後方互換性が、マイグレーションを一行の変更に変えます。レガシーなデータベースシーケンスがサポートしていたすべてのパラメータを一致させたため、チームはアプリケーションロジックに触れることなく古いシステムを置き換えることができました。これにより、Ordersチームは3週間で12のサービスを移行できました。

ホワイトボードで賞賛できる設計よりも、午前3時にデバッグ可能な設計を優先してください。コンセンサスプロトコルやベクトルクロックを利用できましたが、ホワイトボードに収まり、障害発生時に予測可能な挙動をするアーキテクチャを選びました。大規模なスケールでは、運用上の明確さが何よりの目的だからです。

大規模プラットフォームを運用している場合、何百ものサービスが存在すると、データベースのマイグレーションは孤立して行われるものではありません。特にシーケンス(Sequence)に関連する部分では、チーム間、コードベース全体、そして長年組み込まれた前提条件にまで影響が波及します。

シーケンスは、それが消滅するまでほとんど意識されないデータベース機能の一つです。本質的にはカウンターであり、要求に応じて一意で単調増加する数値を割り当てるデータベース管理オブジェクトです。

行を挿入して主キー(Primary Key)が必要なたびに、データベースはカウンターを増分し、次の値を返します。衝突の心配も、アプリケーション側での調整コードも不要で、特別な考慮も必要ありません。カウンターは非常に信頼性が高く目立たないため、多くのエンジニアが何らかの要因でその置き換えを余儀なくされた時点で、それらがどれほど深く埋め込まれているかを知ることになります。

Coupangにおいて、リレーショナルデータベースからNoSQLへの移行は、この置き換えの段階で予期せぬ壁にぶつかりました。

100 以上のチームが、主キーとしてデータベースネイティブのシーケンスに依存していました。一部のチームは順序保証のためにこれを使用し、他のチームは下流システムとの後方互換性(単調増加する識別子を期待するシステム)のためにこれに依存していました。シーケンス自体は複雑ではありませんでしたが、組織全体にほぼ一万の異なるカウンターが散在していました。

DynamoDB などの NoSQL ストアは、ネイティブなシーケンスサポートを提供していません。UUID が選択肢の一つでしたが、順序の前提条件を破り、複数のサービスにわたる変更が必要でした。Snowflake 方式の ID は、私たちが引き受けたくない運用上の複雑さを導入しました。私たちはよりシンプルなものを必要としていました。

目標は明確でした:アプリケーションを書き換えることなく、チームがリレーショナルデータベースから移行できるようにする、差し替え可能な代替システムを構築することです。

社全体でのレガシーデータベースベンダーの非推奨化と、クラウドネイティブインフラへの移行の一環として、私たちはソースデータベースのシーケンスが提供するすべての機能(開始点、カスタム増分、昇順および降順)をサポートし、既存のシステムを壊すことなくチームが自身のペースで移行できるよう完全な後方互換性を提供する必要がありました。

移行の一環として、私たちの Orders チームは 3 週間で 12 のサービスをゼロダウンタイムで移行し、変更したコードは 50 行未満でした。その実現を支えたシステムの構築方法をご紹介します。

シーケンスにおいて、賢明さよりもシンプルさが勝る理由

分散されたシーケンス生成は、洗練された調整を必要とする問題のように聞こえます。コンセンサスプロトコル、ベクトルクロック、分散ロック——文献にはホワイトボード上で美しく見えるエレガントな解決策が溢れています。

私たちはその道を選ばなかったのです。

複雑なシステムは、複雑な方法で失敗します。調整の層が一つ増えるごとに、レイテンシ(遅延)、障害モード、運用上の負担が増加します。これらは、アラートが鳴る深夜3時にあなたが実際に感じることです。シーケンスに関する実際の要件は、それほど高度なものではありませんでした。

一意性:どの呼び出し元も同じ値を二度と受け取らないこと

単調増加性(Monotonicity):シーケンス内で値が時間とともに増減すること

可用性:システムが障害を優雅に許容すること

低レイテンシ:シーケンス生成がボトルネックにならないこと

ホットパスでのネットワーク呼び出しゼロ:ネットワーク往復なしでローカルにシーケンスを生成すること

リストから除外されているものにも注目してください:すべての消費者間で厳格なグローバル順序、隙のないシーケンス、リアルタイム一貫性。ほとんどのチームはこれらの性質を必要としませんでした。それらが必要だと考えていたチームでさえ、数回の不快な議論の後、それらなしでも生きられることに気づきました。

ネットワーク呼び出しの制約は重要でした。従来のデータベースシーケンスでは、値ごとに往復が必要でした。高スループットの場合、その往復がレイテンシを支配し、中央のボトルネックを生み出しました。私たちはシーケンス生成がローカル変数のインクリメントのように感じられることを望みました。なぜなら、ほとんどのリクエストにおいて、まさにそれだからです。

この認識が、私たちの設計原則を形作しました:

  • 可能な限り分散ロックやコンセンサスを避けるため、調整(コーディネーション)を最小限に抑える
  • 未使用のシーケンス番号の欠番を許容し、ギャップ(隙間)を受け入れる
  • サーバーとクライアントの両方で積極的なキャッシングを行い、ラウンドトリップを減らすため、キャッシングをエッジ側に押し付ける
  • 誰がオンコール対応しても深夜3時に動作原理を理解できるほど、アーキテクチャを明確で理解しやすい状態に保つ
  • 既存のスキーマ、API、消費者に変更が必要ないため、後方互換性を維持する

既存のアプローチとその限界

何かを構築する前に、既存のソリューションを評価しました。それぞれに merits(利点)がありましたが、私たちの制約条件に適合するものはありませんでした。

最も明白な選択肢は UUID を使用することでしたが、数十のサービスが BIGINT 型の主キーを持っていました。カラムタイプを変更すると、スキーマ、API、レポートシステム全体に波及し、すでに実施している移行作業の上にさらに大きな移行作業が発生してしまいます。また、UUID は B ツリーインデックス全体に挿入を散在させるため、高スループットのテーブルにおける書き込みパフォーマンスが低下します。加えて、いくつかのチームは ID の順序付けをページネーションに依存しており、UUID ではそれを提供できません。

私たちは Snowflake ID に注目しました。これらは順序付けの問題を解決し、BIGINT に収まるという点で魅力的です。しかし、ワーカー ID の管理が必要であり、これはオートスケーリング環境において独自の調整問題となります。また、同期されたクロックに依存しています。クロックのずれは順序付けの不整合や完全な衝突を引き起こします。さらに悪いことに、これはドロップイン(差し替え)の代替手段ではありません:シーケンスが 1001, 1002 を生成していた場合、それが突然 1578323451234567890 に変わってしまうのです。

単一のコーディネーターを持つデータベースシーケンスは、理論上もっともシンプルな選択肢でしたが、私たちが回避しようとしていたボトルネックをそのまま生み出すことになりました。つまり、単一の障害点、すべての値に対するレイテンシの発生、そしてスケーリングするほど悪化するロック競合です。

タイムスタンプベースのアプローチにも独自の課題がありました。同じミリ秒内の複数のリクエストが衝突を引き起こし、分散されたホスト間でのクロックのずれにより順序付けが信頼できなくなり、カスタムな開始値や増分をサポートする方法がありません。

これらのオプションのいずれも、私たちのすべての制約を満たすものではありませんでした。それは、ソースデータベースとの完全な互換性、スキーマ変更の不要さ、サブミリ秒単位のレイテンシ、そして運用上のシンプルさです。そのため、偶然ではなく設計によってシンプルになるよう、目的に特化したソリューションを構築しました。

シーケンスサービスのコアアーキテクチャ

このシーケンスサービスは「ティア0」サービス(注文や支払いといったコアビジネス機能の失敗が業務を停止させる、重要なパス上のインフラストラクチャを指す社内用語)となりました。複数の「ティア1」サービスが、このサービスを主キーの生成源として依存していました。それは3つのレイヤーで構成されています:真実の情報源であるDynamoDB、サーバー側のキャッシュ層、そしてローカルにキャッシュを持つ厚いクライアントです。

図1:リクエストは2つのキャッシュ層を通過してDynamoDBに到達し、シーケンス需要の0.1%未満を処理します。

以下の図は、クライアントがシーケンスを要求した際に各レイヤーで何が起こるかを追跡しています。リクエストがどこで処理されるかによって、3つのシナリオが発生します。1つは、アプリケーションプロセスを離れることのないクライアントキャッシュでのキャッシュヒットです。2つ目は、クライアントキャッシュの残量が少なくなった際にシーケンスサービスから行われるバックグラウンドでのリフィルです。3つ目は、両方のキャッシュ層が枯渚した場合にのみトリガーされるDynamoDBからのフェッチです。

図2:シーケンス順の3つのシナリオ。ほとんどのリクエストはアプリケーションプロセスを離れません。DynamoDBにアクセスされるのは、両方のキャッシュ層の残量が少なくなった場合のみです。

[上記画像をクリックして全サイズで表示]

DynamoDBを唯一の信頼源として

各シーケンスは単一のDynamoDBアイテムであり、カウンター名がキー、現在の位置が数値(アプリケーション内ではLongにマッピングされるDynamoDBのNumber型として保存)です。

キー(文字列)

値(数値/Long)

orders_seq

50000000

users_seq

12000000

transactions_seq

890000

サービスがより多くのシーケンスを必要とする場合、DynamoDBの条件付き更新を使用してアトミックなインクリメントを実行します。

UpdateExpression: SET #val = #val + :blockSize

ConditionExpression: #val = :expectedValue

条件が失敗した場合、別のインスタンスがそのブロックを先に取得します。サービスは新しい値で再試行します。これにより、分散ロックなしで一意性を保証できます。

一括フェッチが重要な理由

一度に一つのシーケンスを取得すると、DynamoDB に対してリクエストが集中してしまいます。代わりに、1 回の呼び出しあたり 500〜1,000 個のシーケンスをブロック単位で取得しています。1 つの DynamoDB への書き込みオペレーションにより、その後の数百回のリクエストをキャッシュから提供できる十分な数のシーケンスがプロビジョニングされます。

このバルク(一括)アプローチにより、DynamoDB のコストが削減され(書き込みオペレーションの数が減少し)、レイテンシが改善され(ほとんどのリクエストがキャッシュにヒットし)、可用性が向上します(DynamoDB の一時的な障害が発生してもサービスは継続します)。

その代償として、ギャップ(欠番)が生じます。サーバーがクラッシュし、キャッシュ内に未使用のシーケンス 400 個が残っている場合、それらの値は永久に失われます。私たちのユースケースにおいては、これは納得のいく代償でした。

サーバーサイドキャッシュレイヤー

シーケンスサービスは、各カウンターに対して事前にフェッチされたシーケンスのインメモリキャッシュを保持しています。複数のサービスインスタンスが同時に実行可能であり、各インスタンスは DynamoDB から割り当てられた重複しない異なるシーケンスブロックを保持します。その結果、インスタンス間で渡される値はグローバルに厳密な単調増加にはなりませんが、一意性は保証されます。単一のインスタンス内では、値は常に増大します。すべてのコンシューマー間で厳密なグローバル順序付けを必要とするユースケースの場合、この設計は適切ではありません。

私たちは、RedisやValkeyのような共有外部キャッシュではなく、インメモリキャッシングを意図的に選択しました。外部キャッシュはネットワークホップと新たな障害依存関係を再導入することになり、私たちが回避しようとしていたまさにその問題を引き起こすことになります。各サービスインスタンスはDynamoDBからブロックをアトミックに割り当てるため、インスタンス間でキャッシュ状態を共有する必要はありません。その代償として、インスタンスが再起動した場合、キャッシュ内の未使用シーケンスはギャップとして失われます。しかし、私たちのユースケースではこれは許容範囲でした。

クライアントと同様に、サーバーにも設定可能なリフィル制限があり、これは各カウンターでキャッシュに保持するシーケンスの最大数を示します。クライアントがシーケンスを要求すると、サービスは以下の処理を行います。

キャッシュ内にそのカウンターのシーケンスが存在するか確認する

存在する場合、アトミックにインクリメントして次の値を返す

キャッシュが不足しているか空の場合、DynamoDBからバックグラウンドでリフィルを実行する(設定された制限まで)

ブロック割り当て戦略

各カウンターには、予想されるトラフィックに基づいた設定可能なキャッシュパラメータがありました。

Counter

Max Cache Size

Block Size (per DDB fetch)

Typical Traffic

orders

2000

1000

High volume

users

1000

500

Medium volume

audit-logs

5000

2000

Burst traffic

最大キャッシュサイズは、サーバーがそのカウンターのためにメモリ内に保持するシーケンスの数を制御しました。ブロックサイズは、DynamoDB 呼び出しごとにフェッチするシーケンスの数を制御しました。スライディングウィンドウはリフィルをトリガーする時期を決定し、リフィルはキャッシュを最大サイズまで戻すのに十分な数をフェッチしました。

より大きなキャッシュサイズは、DynamoDB 呼び出し回数は減少しますが、トラフィックの見積もりが過大であった場合やサーバーが再起動した場合に、より多くのシーケンスが無駄になります。これらの値は観測されたパターンとコストの許容範囲に基づいて調整しました。ギャップ(欠番)への許容度が低いクライアントには、サーバーメモリと DynamoDB の間にオプションの Berkeley DB (BDB) 層を追加し、ネットワーク往復を伴わずにローカルな耐久性を提供しました。

スライディングウィンドールによるレート計算

システムにおいて運用上最も重要な部分は、シーケンス生成そのものではなく、キャッシュをいつリフィルするかを知ることです。これを誤ると、他のすべてが崩壊します。早すぎるリフィルは不要な DynamoDB 呼び出しや容量の無駄といった悪い結果をもたらします。遅すぎるリフィルはキャッシュミス、レイテンシの増加、および潜在的な失敗を引き起こします。

私たちはスライディングウィンドウアルゴリズムを使用して消費率を継続的に推定し、適切な時期にリフィルをトリガーしました。重要なのは、リフィルが非同期に行われたことです。キャッシュは空になるまで待機しませんでした。スライディングウィンドウは、既存のキャッシュが枯渇する前に新しいシーケンスが届くように、バックグラウンドリフィルを開始する時期を予測しました。

スライディングウィンドウは、アプリケーションプロセスに直接組み込まれたライブラリとして動作する「シッククライアント」内で実行されます。これは、別のプロセスやサービス呼び出しを伴うものではありません。クライアントは自身のシーケンス消費率を追跡し、ローカルキャッシュが枯渇する前にシークエンスサービスからのリフィルをいつ要求すべきかを判断するためにそれを使用します。

図 3: 枯渇前にリフィルを予測するスライディングウィンドウ – ユーザーリクエストをクリティカルパスから外す。

[ここをクリックして上記の画像を全サイズに拡大]

この非同期アプローチにより、ユーザーリクエストがネットワーク呼び出しまたは DynamoDB への書き込みのいずれにおいてもブロックされることはほぼありませんでした。リフィルはバックグラウンドで完了し、キャッシュは残りのバッファからリクエストを引き続き提供し続けました。

各カウンターについて、サービスはローリング60秒のウィンドウ内でシーケンス割り当てを追跡し、現在の消費率を計算します。リフィル閾値は動的です:

refill_threshold = current_rate × buffer_seconds

キャッシュされたシーケンスがこの閾値を下回ると、サービスはバックグラウンドでのリフィルを開始します。

適応的動作

スライディングウィンドウは、トラフィックの急増や減少、さらにはバースト処理といったトラフィックパターンに自然に適応します。消費率が上昇し、リフィル閾値が上がるにつれてトラフィックの急増が発生します。これに対応し、サービスはバッファを維持するために新しいブロックを早期にフェッチします。一方、消費が緩やかになり閾値が下がると逆の現象が起こります。サービスはプリフェッチを積極的に停止することでこれに対応し、不要な DynamoDB 呼び出しを削減します。バースト処理に関しては、短時間の急増が一時的に消費率を押し上げると閾値が上昇し、リフィルトリガーが発動します。バーストが収束すれば、閾値は徐々に通常値に戻ります。

パラメータの調整

パラメータ

目的

デフォルト値

window_size

考慮する履歴の範囲

60 秒

sample_interval

消費記録の頻度

1 秒

buffer_seconds

バッファを維持する先行時間

10 秒

min_threshold

低トラフィックカウンター下限値

50 シーケンス

min_threshold は、低トラフィックのカウンターがリフィル閾値を過度に低く設定するのを防ぎます。1 分に 1 リクエストしか処理しないカウンターが、シーケンスが完全に枯渇するまで待機する必要はありません。

擬似コードによる実装例を以下に示します。

// 毎秒:ウィンドウをシフトし、合計を更新

// 残量が (rate × buffer_seconds) より少ない場合、リフィルを実行

public synchronized int calculateRefillThreshold() {

double ratePerSecond = (double) totalInWindow / windowSizeSeconds;

int dynamicThreshold = (int) (ratePerSecond * bufferSeconds);

return Math.max(dynamicThreshold, minThreshold);

}

Thick Client Design(太字クライアント設計)

サーバー側でのキャッシングは DynamoDB の負荷を軽減します。クライアント側でのキャッシングは、绝大多数のリクエストに対してネットワーク呼び出しを完全に排除します。

このキャッシングソリューションが私たちの主要な設計目標でした:シーケンス生成の呼び出しは、ほぼ例外なくアプリケーションプロセス内に留まるべきです。ネットワーク呼び出しは、レイテンシだけでなく、障害モード、接続プール管理、運用上の複雑さという点でも高コストです。データベースネイティブなシーケンスは、値一つひとつに対して往復通信を強制しました。私たちはその逆を目指しました。

太字クライアントは、アプリケーションが直接埋め込む SDK です。これはシーケンスのローカルキャッシュを独自に維持し、そのキャッシュが補充を必要とした場合にのみシーケンスサービスと通信します。適切なスライディングウィンドウの調整が行われていれば、この補充は頻繁には発生しません。

なぜクライアントにキャッシングを押し付けるのか

高スループットなサービスにおいて、高速なネットワーク呼び出しでさえ累積して大きな負担になります。1 秒あたり一万件の注文を処理するサービスは、各シーケンスに対してネットワークの往復通信を行う余裕はありません。太字クライアントはこのボトルネックを完全に排除します。

明確に言っておくと、既存のデータベースのシーケンスに対してもすでにキャッシュの実装は行われていました。各チームがすべての値に対してデータベース呼び出しを行うことはありませんでした:そのようなアプローチでは実用性がありませんでした。しかし、既存のキャッシュには限界がありました。チーム間で一貫性がなく、多くの場合独自開発であり、接続プールの動作に依存していました。異なるサービスがそれぞれ異なった方法で実装しており、ブロックサイズ、リフィル戦略、障害時の処理がばらついていました。

新しいシステムは、このキャッシュを標準化し最適化し、目的に特化した二段階アーキテクチャへと統合しました。

クライアントSDKの責任範囲

厚いクライアント(Thick Client)は複数の課題を処理します。ローカルキャッシュ管理は、シーケンスのブロックをメモリ内に保持します。バックグラウンドでのリフィルは、キャッシュが枯渇する前に新しいブロックを取得します。レート計算は、独自のスライディングウィンドウを実行してリフィルのタイミングを予測します。障害処理は、サービス利用不可時に機能低下(Degradation)を提供します。最後に、プロセス内の単調性保証によりローカルな順序性が確保されます。

クライアントが処理しないものを注意してください:それは設定です。クライアントは増分値、開始点、方向について何も知りません。シーケンス名によってブロックを要求し、受信した順序で値を返すだけです。すべての複雑さはサーバー側にあります。

サーバーはクライアントに対して初期の充填率を提案しますが、クライアントのスライディングウィンドウは実際の消費量を継続的に観察し、自動的に調整します。デプロイから数分以内に、設定が大幅に誤っていても、クライアントは実際のワークロードに対して最適な充填率に収束します。

コアクライアントロジック

public synchronized long next() {

rateCalculator.recordAllocations(1);

int remaining = cachedValues.length - cachePosition;

int threshold = rateCalculator.calculateRefillThreshold();

// Trigger async refill before cache exhaustion

if (remaining <= threshold && !refillInProgress) {

triggerBackgroundRefill();

}

if (cachePosition >= cachedValues.length) {

// Cache exhausted, must block on refill

blockingRefill();

}

// Values already have an increment applied by the server

return cachedValues[cachePosition++];

}

クライアントとサーバーのパラメータ

この非対称性は意図的なものでした。サーバーは1回のDynamoDBフェッチで500〜1000個のシーケンスをリクエストに対してブロックしますが、クライアント側のキャッシュ上限はアプリケーションに応じて50〜500個のシーケンスです。クライアント側のメモリ制約はサーバー側よりも厳しいため、クライアントクラッシュによるシーケンスの無駄遣い(つまり、アプリケーションの再起動が頻繁に行われるケース)はより一般的です。サーバーは複数のクライアントからの需要を吸収するため、そこでは大きなブロックサイズが理にかなっています。一方、小さなクライアントキャッシュは、アプリケーションのスケーリングダウンや再デプロイが行われた際の無駄を最小限に抑えます。

耐障害性と故障モード

この二段階のキャッシュアーキテクチャは、単なるパフォーマンス向上のためだけでなく、障害に対する層状の保護を提供するためでもありました。

クライアントキャッシュはサービス停止から保護する:シーケンスサービスが利用不可になった場合、クライアントはローカルキャッシュからシーケンスを提供し続けた。500件のシーケンスをキャッシュし、1秒間に10件を使用するクライアントは、アプリケーションへの影響なしで50秒間のサービス停止に耐えることができた。

サーバーキャッシュはDynamoDBの障害から保護する:DynamoDBにパーティション障害やスロットリングが発生した場合、シーケンスサービスはインメモリキャッシュから提供を続けた。カウンターごとに数千件のシーケンスをキャッシュすることで、サーバーは数分間トラフィックを支えることができた。

クライアントとサーバーの両方のキャッシングを組み合わせて保護を強化する:2つの階層が耐性を乗算する。DynamoDBの障害は、サーバーキャッシュが影響を吸収したため、直ちにクライアントに影響を与えなかった。シーケンスサービスの障害は、クライアントキャッシュが影響を吸収したため、直ちにアプリケーションに影響を与えなかった。

このキャッシングの組み合わせにより、システムの有効な可用性は、個々のコンポーネントのみで構成されていた場合よりも高くなった。どのレイヤーでも短時間の障害は、エンドユーザーには見えないものであった。

障害影響のまとめ

障害

影響

クライアントのクラッシュ

シーケンスのギャップ(許容範囲内)

サーバーの再起動

ギャップ(許容範囲内)

DynamoDBの障害

二段階のキャッシュにより、一定期間DynamoDBを回避可能

単調性と一意性の保証

システムが提供する保証について正確に確認しよう。

一意性:強力な保証

呼び出し元が同じシーケンス値を受け取ることは決してありません。この保証は、すべてのクライアントとサーバーにわたってグローバルに適用されます。

動作原理:

DynamoDB の条件付き書き込みにより、各ブロックは正確に1回だけ割り当てられます。

サーバーのキャッシュは重複しないブロックから割り当てを行います。

クライアントのキャッシュは、サーバーから重複しない範囲を受け取ります。

障害シナリオ(クラッシュ、リトライ、ネットワークパーティションなど)が発生した場合でも、一意性は維持されます。最悪の場合でも生じるのは欠番であり、重複ではありません。

単調増加性:クライアントごとの保証

単一のクライアントインスタンス内では、値は厳密に増加します(減少シーケンスの場合は減少)。クライアントの観点からは、値 N+1 は常に値 N より大きくなります。

異なるクライアント間では、値は一般的に時間とともに増加しますが、順序が入れ替わって見えることがあります。クライアント A の値 1050 が、クライアント B の値 1100 の後に使用される場合があります。

欠番:予期される動作

欠番は設計上の必然です。サーバーやクライアントがキャッシュ内の未使用シーケンスを残したままクラッシュした場合、またはブロックが割り当てられたもののトラフィックが発生しなかった場合に発生します。

ほとんどのユースケースにおいて、欠番は問題になりません。主キーが連続している必要はありません。監査ログに連番の ID が必要になることもありません。真に欠番のないシーケンスを必要とするシステムは、連続性に対して厳格な外部依存関係を持つものに限られますが、私たちの経験では、エンジニアが当初想定するよりもはるかに稀です。

キャッシュ効率

2段階のキャッシュとスライディングウィンドウ方式のレート計算を組み合わせることで、顕著な効率を実現しました:

メトリクス

値

クライアントキャッシュから提供されるシーケンス

~99%

サーバーキャッシュから提供されるシーケンス

~0.9%

DynamoDB呼び出しが必要なシーケンス

~0.1%

ピーク時に毎秒5万件のシーケンスを処理するシステムにおいて、このアプローチにより、約49,500件がクライアントメモリから即座に提供され、約450件がサーバー呼び出しを必要とし(これも高速で、サーバーキャッシュから提供され)、約50件がDynamoDBへの書き込みトリガーとなりました。

スライディングウィンドウの非同期リフィルがここで重要でした。キャッシュ枯渇前にリフィルがトリガーされるため、ユーザーリクエストがネットワーク呼び出しを待機することはほぼありませんでした。ユーザーは、裏側で何が起こっていようとも、一貫したサブミリ秒のレイテンシーを体験しました。

スループット

スループットメトリクスには、すべてのカウンターで毎秒5万件を超えるピーク値が含まれ、典型的なスループットは毎秒1万〜2万件のシーケンスでした。最もトラフィックの高いカウンターにおける1カウンターあたりの最大値は、毎秒約5,000件のシーケンスでした。このスループットの大部分は、シーケンサーサービスに到達しませんでした。実際のサービストラフィックは、シーケンス消費量の約1%でした。

DynamoDBのパターン

メトリクス 値

書き込みTPS(持続) 10-20

書き込みTPS(ピーク) 50-100

読み取り容量 ほぼゼロ(メタデータのみ)

アイテム数 シーケンスごとに1件(100以上のチームで約10,000件のシーケンス)

テーブルサイズ 数MB

はい、本物です。毎秒5万件のシーケンスを処理するシステムは、通常の負荷下でDynamoDBへの書き込みを毎秒10〜20回しか生成していません。この1000対1という比率は、私が言及した際にも依然として人々を驚かせます。この比率は、一括フェッチと予測リフィルが連携して動作した結果です。

レイテンシ

Path

p50

p99

Client cache hit

<0.01ms

<0.05ms

Server cache hit

1-2ms

5ms

DynamoDB fetch

5-10ms

20ms

通常の運用では、99%以上のリクエストがクライアントキャッシュにヒットします。サーバー呼び出しは稀であり、DynamoDBへの呼び出しはさらに稀でした。

コスト

コストは妥当なものでした。最小限の書き込み容量と微小なストレージを持つDynamoDBは、月額約50ドルでした。低CPU使用率の小さなフリートに対するシーケンスサービスの計算コストは約500ドルでした。ネットワークコストは無視できるほど小さく、厚いクライアント(thick clients)はサービス間のトラフィックを削減します。総コストは月額1000ドル未満で、毎秒数万件のシーケンスを処理しました。

移行:チームの採用支援

システムの構築は作業の半分でした。100以上のチームに実際にこのシステムを採用してもらうこと――それが作業のもう半分であり、正直なところ、より困難な部分でした。Ordersチームは3週間で12件のサービスを移行し、毎秒8万件というピーク負荷とゼロダウンタイムを実現しました。

API互換性

クライアントAPIは、レガシーデータベースのAPIよりもシンプルでした:

// レガシーデータベース(移行前)

long id = connection.getNextSequenceValue("orders_seq");

// 新システム(移行後)

long id = sequenceClient.next("orders_seq");

ほとんどのチームにとって、移行は依存関係の更新に加えて1行の変更で完了しました。設定やセットアップ、パラメータは不要で、必要なのはシーケンス名のみでした。インクリメント値、方向、ブロックサイズといった複雑な処理はすべて、オンボーディング時にサーバー側で処理されました。

カウンター登録と設定

シーケンスの設定は完全にサーバー側で行われ、動的な設定ストアに保存されました。オンボーディングの際、各チームはソースデータベースと互換性のあるすべてのパラメータ(シーケンス名、開始値、インクリメントサイズ、最小値、最大値、方向:昇順または降順)を指定してシーケンスを登録しました。

動的な設定ストアを使用することで、サービスの再デプロイやクライアントコードの変更なしに、ブロックサイズ、インクリメント値、キャッシュ制限などのパラメータを調整できました。ピークシーズン中に高トラフィックのシーケンスに対してより大きなブロックが必要になった場合、設定を更新するだけで変更は数秒以内に反映されました。この運用上の柔軟性は、より多くのチームをオンボーディングし、実際のトラフィックパターンを理解する過程で非常に価値のあるものとなりました。

この分離は意図的なものでした。設定は個々のアプリケーションではなくプラットフォームチームの管轄としました。シーケンスのパラメータ調整(例:高トラフィック用により大きなブロック、または新しいユースケース用の異なるインクリメント値)が必要な場合、クライアントコードに触れることなくサーバー側の変更を行いました。

教訓

成功した点

二段階キャッシング

クライアントキャッシュによりサーバーの負荷は99%削減されました。サーバーキャッシュによりDynamoDBの負荷はさらに90%以上軽減されました。これらを組み合わせることで、シーケンスの提供数とデータベースへの書き込み数の比は1000:1という結果を得ました。

障害の分離

キャッシュ階層は多層的な耐障害性を提供しました。クライアントキャッシュはサービス停止を吸収し、サーバーキャッシュはDynamoDBの障害を吸収しました。どの階層での一時的な失敗もアプリケーションからは見えず、システムの有効可用性は個々のコンポーネントのいずれよりも高くなりました。

非同期リフィル

キャッシュ枯渇前にリフィル(再充填)トリガーを発行することで、通常運用時にユーザーリクエストがネットワーク呼び出しを待たない状態を実現しました。これは「多くの場合は高速」であることと「一貫して高速」であることの差であり、SREチームが言うように、これらは非常に異なる概念です。

自己修正されるリフィルレート

システムは数分で自動調整され、トラフィック予測は不要でした。誤設定されたクライアントも自らを修正しました。

シンプルさ

この設計はホワイトボード1枚に収まります。新入りのエンジニアも1回のミーティングで理解できました。オンコール担当者がデバッグに苦戦することもありませんでした。

ギャップの受容

チームに対して「ギャップ(欠落)が許容可能である」と説得することで、単純な設計全体が実現しました。これらの対話はエンジニアリングよりも困難でした。エンジニアは「正しさ」に対して強い直感を持っていますが、時にはそれを優しく揺さぶる必要があります。

再検討すべき事項

ブロックサイズ調整

当初、ブロックサイズが大きすぎたため、トラフィックの少ないカウンターに対してシーケンスが浪費されていました。観測されたトラフィックに基づいて動的にブロックサイズを調整していれば、より良い結果になったでしょう。

モニタリングの粒度

集計メトリクスについては適切に監視していましたが、カウンターごとの可視性は後から導入されました。当初から詳細なダッシュボードを構築すべきでした。最終的に、各カウンターのキャッシュ深さ、リフィルレート、ギャップ発生頻度を示すリアルタイムダッシュボードを追加し、オンコール時のトリアージに不可欠な情報源としました。

分散システムについてこれが教えてくれること

このシステムは、私が頻繁に直面するより広範なパターンを示しています。分散システムにおいて、キャッシングは単なるパフォーマンスのハックではなく、レジリエンス(耐障害性)とシンプルさを実現するための基本要素です。ここで得られた重要な洞察は技術的なものではなく、ほとんどのチームが実際に必要としていたと信じていた保証を本当に必要としていなかったことを認識した点にあります。一旦これらの制約を取り除く手助けをすると、ソリューションはほとんど恥ずかしさを感じるほどシンプルになりました。

結論

データベースネイティブのシーケンスは利便性をもたらしますが、スケールが大きくなると制約となります。ほとんどのチームが何らかの形で実装していたキャッシングがあっても、標準化の欠如と予測可能なリフィル供給がないため、一貫性のないパフォーマンスと運用上の負担が生じます。

私たちのアーキテクチャは、DynamoDBを唯一の信頼できる情報源(source of truth)とし、二段階のキャッシュとスライディングウィンドウによるリフィル(refills)を採用しました。これにより、従来のデータベースのシーケンス機能(開始点、カスタムインクリメント、昇順/降順など)と完全な互換性を達成しつつ、100以上のチームが書き換えなしで移行できるようになりました。通常の負荷下では、シーケンス生成の99%がアプリケーションメモリ内だけで完結します。ネットワークもリモートサービスもデータベースも不要で、単に数値をインクリメントするだけです。

結局のところ、最も優れた分散システムとは、分散そのものを目に見えなくするものでした。

同様の移行を計画している場合は、何が「正しい」と感じるかではなく、実際に必要な保証が何かから問い始めることをお勧めします。その答えは、予想よりもシンプルな設計を実現する鍵になるかもしれません。

著者について

サウマ・タイアギ

サウマ・タイアギはAugerのシニアソフトウェアエンジニアであり、IEEEフェローです。Google、Amazon、Coupangで分散システムの構築に10年以上の経験を持っています。

詳細表示非表示

原文を表示

Key Takeaways

Always validate your requirements. We initially assumed teams needed gap-free IDs and strict global ordering, but after some uncomfortable conversations realized they could live without both. That single shift collapsed a hard distributed coordination problem into something almost embarrassingly simple.

The best network call is the one you never make. We embedded sequence generation directly into the application as a library, so for ninety-nine percent of requests, getting a sequence ID is just incrementing a number in local memory, without requiring a network hop, service call, or database.

Design for failures, not just performance. With two tiers of cache, one in the client and one in the server, a DynamoDB outage or service hiccup became invisible to applications; we found that caching saved us from outages far more often than from slowness.

Backward compatibility is what turns a migration into a one-line change. We matched every parameter the legacy database sequences supported, so teams could swap out the old system without touching application logic and the Orders team migrated twelve services in three weeks because of it.

Prefer the design you can debug at 3 AM over the one you can admire on a whiteboard. We had consensus protocols and vector clocks available to us, but chose an architecture that fits on a whiteboard and behaves predictably under failure, because at scale, operational clarity is the whole point.

Introduction: The Sequence Problem Nobody Plans For

When you operate a large-scale platform with hundreds of services, database migrations don’t happen in isolation. They ripple across teams, codebases, and assumptions baked in for years, especially around sequences.

Sequences are one of those database features you rarely think about until they're gone. At their core, they are counters, database-managed objects that hand out unique, monotonically increasing numbers on demand.

Every time you insert a row and need a primary key, the database increments the counter and gives you the next value. No collisions and no coordination code exists in your application and no thinking is required. Counters are so reliable and invisible that most engineers only discover how deeply embedded they are when something forces their replacement.

Related Sponsors

Inside MCP: A Protocol for AI IntegrationWhy APIs Can’t Trust Clients—and How to Bridge the GapData as a Competitive Moat: Architecting for Durability, Portability, and ControlCutting Java Costs in 2026 Without Slowing Delivery

Scalable Enterprise Java for the Cloud - Download the eBook

At Coupang, our migration from a relational database to NoSQL hit an unexpected wall right at this point of replacement.

Over one hundred teams depended on database-native sequences for primary keys. Some used them for ordering guarantees. Others relied on them for backward compatibility with downstream systems that expected monotonically increasing identifiers. The sequences themselves weren’t complex, but they were everywhere: Nearly ten thousand distinct counters were spread across the organization.

NoSQL stores like DynamoDB don't offer native sequence support. UUIDs were an option, but they broke ordering assumptions and required changes across multiple services. Snowflake-style IDs introduced operational complexity we didn't want to take on. We needed something simpler.

The goal was clear: Build a drop-in replacement that let teams migrate off the relational database without rewriting their applications.

As part of a company-wide push to deprecate a legacy database vendor in favor of cloud-native infrastructure, we needed to support everything the source database’s sequences offered, starting points, custom increments, and ascending and descending orders, while providing full backward compatibility so teams could migrate at their own pace without breaking existing systems.

As part of the migration, our Orders Team migrated twelve services in three weeks with zero downtime, changing fewer than fifty lines of code. Here's how we built the system that made that possible.

Why Simple Beats Clever for Sequences

Distributed sequence generation sounds like a problem that demands sophisticated coordination. Consensus protocols, vector clocks, and distributed locks – the literature is full of elegant solutions that look great on a whiteboard.

We resisted that path.

Complex systems fail in complex ways. Every layer of coordination adds latency, failure modes, and operational burden, things you really feel at 3 AM when the pager goes off. For sequences, the actual requirements were modest:

Uniqueness, so that no two callers would ever receive the same value

Monotonicity so values would increase (or decrease) over time within a sequence

Availability so the system would tolerate failures gracefully

Low latency so sequence generation would not be a bottleneck

Zero network calls in the hot path so that would generate sequences locally without a network round-trip

Notice what’s not on the list: strict global ordering across all consumers, gap-free sequences, or real-time consistency. Most teams didn’t need these properties. The ones who thought they did usually discovered, after a few uncomfortable conversations, that they could live without them.

The network call constraint was critical. Traditional database sequences required a round-trip for every value. At high throughput, that round-trip dominated latency and created a central bottleneck. We wanted sequence generation to feel like incrementing a local variable, because for most requests, that’s exactly what it would be.

This realization shaped our design principles:

Minimize coordination to avoid distributed locks and consensus where possible

Tolerate gaps so unused sequences are acceptable

Push caching to the edges to reduce round-trips with aggressive caching on the server and client

Keep the architecture legible so that anyone on-call would understand how it works at 3 AM

Preserve backward compatibility, because existing schemas, APIs, and consumers should not need changes

Existing Approaches and Their Limitations

Before building anything, we evaluated existing solutions. Each had merit, but none fit our constraints.

The most obvious option was to use UUIDs, but dozens of services had BIGINT primary keys. Changing column types would cascade through schemas, APIs, and reporting systems, which would be a migration on top of the migration we were already doing. UUIDs also scatter inserts across B-tree indexes, degrading write performance in high-throughput tables. In addition, several teams relied on ID ordering for pagination, which UUIDs simply can’t provide.

We looked hard at Snowflake IDs. They solve ordering and fit in a BIGINT, which is appealing. But they require managing worker IDs, which is its own coordination problem in auto-scaling environments, and they depend on synchronized clocks. Clock skew causes ordering anomalies or outright collisions. Worse, they are not a drop-in replacement: For a sequence producing 1001, 1002 would suddenly become 1578323451234567890.

A single-coordinator database sequence was the simplest option on paper, but it would create exactly the bottleneck we were trying to escape: one point of failure, latency on every value, and lock contention that gets worse the more you scale.

Timestamp-based approaches had their own issues. Multiple requests in the same millisecond cause collisions, clock skew across distributed hosts makes ordering unreliable, and there is no way to support custom starting points or increments.

None of these options met all our constraints: full source-database parity, no schema changes, sub-millisecond latency, and operational simplicity. So we built a purpose-specific solution that was simple by design, not by accident.

Core Architecture of the Sequence Service

The sequence service became a tier-0 service (our internal term for critical-path infrastructure whose failure halts core business functions, such as orders or payments). Multiple tier-1 services depended on it for their primary keys. It has three layers: DynamoDB as the source of truth, a server-side caching layer, and thick clients that cache locally.

Figure 1: Requests flow down through two cache layers before reaching DynamoDB, which handles less than 0.1% of sequence demand.

The diagram below traces what happens at each layer when a client requests a sequence. Three scenarios play out depending on where the request is served: a cache hit in the client that never leaves the application process, a background refill from the sequence service when the client cache runs low, and a DynamoDB fetch that only triggers when both cache tiers are depleted.

Figure 2: Three scenarios in sequence order. Most requests never leave the application process. DynamoDB is only accessed when both cache tiers are running low.

[Click here to expand image above to full-size]

DynamoDB as Source of Truth

Each sequence is a single DynamoDB item with the counter name as the key and the current position as a numeric value (stored as DynamoDB's Number type, which maps to Long in our application):

Key (String)

Value (Number/Long)

orders_seq

50000000

users_seq

12000000

transactions_seq

890000

When the service needs more sequences, it performs an atomic increment using DynamoDB's conditional update:

UpdateExpression: SET #val = #val + :blockSize

ConditionExpression: #val = :expectedValue

If the condition fails, another instance grabs that block first. The service retries with the new value. This gives us coordination-free uniqueness without distributed locks.

Why Bulk Fetch Matters

Fetching one sequence at a time would hammer DynamoDB with requests. Instead, we fetch blocks of five hundred to one thousand sequences per call. A single DynamoDB write provisions enough sequences to serve hundreds of subsequent requests from cache.

This bulk approach reduces DynamoDB costs (resulting in fewer write operations), improves latency (most requests hit the cache), and increases availability (the service survives brief DynamoDB blips).

The tradeoff is gaps. If a server crashes with four hundred unused sequences sitting in its cache, those values are gone forever. For our use cases, that was a price we were happy to pay.

Server-Side Caching Layer

The sequence service maintains an in-memory cache of pre-fetched sequences for each counter. Multiple instances of the service can run simultaneously; each instance holds a different non-overlapping block of sequences allocated from DynamoDB. As a result, values handed out across instances won't be strictly monotonically increasing globally, but they will be unique. Within a single instance, values are always increasing. If your use case requires strict global ordering across all consumers, this design is not the right fit.

We chose in-memory caching deliberately over a shared external cache like Redis or Valkey. An external cache would reintroduce a network hop and a new failure dependency, which is exactly what we were trying to avoid. Since each service instance allocates its own block from DynamoDB atomically, there's no need for instances to share cache state. The trade-off is that if an instance restarts, the unused sequences in its cache are lost as gaps. For our use cases, that was acceptable.

Like the client, the server had a configurable refill limit, the maximum number of sequences to hold in cache for each counter. When a client requests a sequence, the service:

Checks if sequences are available in cache for that counter

Atomically increments and returns the next value, if available

Triggers a background refill from DynamoDB (up to the configured limit), if the cache is low or empty

Block Allocation Strategy

Each counter had configurable cache parameters based on expected traffic:

Counter

Max Cache Size

Block Size (per DDB fetch)

Typical Traffic

orders

2000

1000

High volume

users

1000

500

Medium volume

audit-logs

5000

2000

Burst traffic

The max cache size controlled how many sequences the server would hold in memory for that counter. The block size controlled how many sequences to fetch per DynamoDB call. The sliding window determined when to trigger a refill, and the refill would fetch enough to bring the cache back up to the max size.

Larger cache sizes resulted in fewer DynamoDB calls but more wasted sequences if either traffic was overestimated or the server restarted. We tuned these values based on observed patterns and cost tolerance. For clients with lower gap tolerance, we added an optional Berkeley DB (BDB) layer between server memory and DynamoDB, providing local durability without network round-trips.

Sliding Window Rate Calculation

The most operationally important piece of the system isn’t the sequence generation itself; it is knowing when to refill the cache. Get this wrong, and everything else falls apart. Refill too early has poor results such as unnecessary DynamoDB calls and wasted capacity. Refilling too late results in cache misses, increased latency, and potential failures.

We used a sliding window algorithm to continuously estimate the consumption rate and trigger refills at the right time. Critically, refills happened asynchronously; the cache didn't wait until it was empty. The sliding window predicted when to start a background refill so that new sequences arrived before the existing cache ran out.

The sliding window runs inside the thick client, a library embedded directly in the application process. No separate process or service call is involved. The client tracks its own sequence consumption rate and uses that to decide when to request a refill from the sequence service, before the local cache runs dry.

Figure 3: Sliding window predicting refill before exhaustion – keeping user requests out of the critical path.

[Click here to expand image above to full-size]

This async approach resulted in user requests almost never being blocked on either network calls or DynamoDB writes. The refill completed in the background while the cache continued serving requests from its remaining buffer.

For each counter, the service tracks sequence allocations over a rolling sixty second window, calculating the current consumption rate. The refill threshold is dynamic:

refill_threshold = current_rate × buffer_seconds

When cached sequences drop below this threshold, the service initiates a background refill.

Adaptive Behavior

The sliding window naturally adapts to traffic patterns, with respect to traffic ramp-up and decline, as well as burst handling. Traffic ramp-up occurs as the consumption rate increases and the refill threshold rises. In reaction, the service fetches new blocks earlier, maintaining the buffer. The reciprocal traffic declines as consumption slows and the threshold drops. The service reacts by aggressively stopping pre-fetching, which reduces unnecessary DynamoDB calls. In terms of burst handling, when short bursts temporarily spike the rate, the threshold increases, triggering a refill. If the burst subsides, the threshold gradually returns to normal.

Tuning Parameters

Parameter

Purpose

Our Default

window_size

How much history to consider

60 seconds

sample_interval

How often to record consumption

1 second

buffer_seconds

How far ahead to maintain a buffer

10 seconds

min_threshold

Floor for low-traffic counters

50 sequences

The min_threshold prevents low-traffic counters from setting refill thresholds too low. A counter serving one request per minute shouldn't wait until no sequences remain.

A pseudocode implementation is shown below.

// Every second: shift window, update total

// Refill when remaining < (rate × buffer_seconds)

public synchronized int calculateRefillThreshold() {

double ratePerSecond = (double) totalInWindow / windowSizeSeconds;

int dynamicThreshold = (int) (ratePerSecond * bufferSeconds);

return Math.max(dynamicThreshold, minThreshold);

}

Thick Client Design

Caching on the server reduces DynamoDB load. Caching on the client eliminates network calls entirely for the vast majority of requests.

This caching solution was our primary design goal: A sequence generation call should almost never leave the application process. Network calls are expensive, not just in latency, but also in failure modes, connection pool management, and operational complexity. Database-native sequences forced a round trip for every single value. We wanted the opposite.

The thick client is an SDK that applications embed directly. It maintains its own local cache of sequences and communicates with the sequence service only when that cache needs refilling, which, with proper sliding window tuning, happens infrequently.

Why Push Caching to the Client

For high-throughput services, even fast network calls add up. A service processing ten thousand orders per second can't afford a network round-trip for each sequence. The thick client entirely eliminates this bottleneck.

To be clear, we had already implemented caching for the legacy database’s sequences, too. Teams weren’t making a database call for every single value: That approach would have been unusable. But the existing caching had limitations. It was inconsistent across teams, often homegrown, and tied to connection pool behavior. Different services implemented it differently, with varying block sizes, refill strategies, and failure handling.

The new system standardized and optimized this caching into a purpose-built, two-tier architecture.

Client SDK Responsibilities

The thick client handles several issues. Local cache management stores a block of sequences in memory. Background refills fetch new blocks before cache exhaustion. Rate calculation runs its own sliding window to predict refill timing. Failure handling provides degradation when service is unavailable. Finally, monotonicity within the process guarantees local ordering.

Note what the client doesn't handle: configuration. The client knows nothing about increments, starting points, or direction. It simply requests blocks by sequence name and hands out values in the order received. All the complexity lives server-side.

The server suggests an initial fill rate to clients, but the client's sliding window continuously observes actual consumption and adjusts automatically. Within minutes of deployment, even a badly misconfigured client converges to an optimal fill rate for its actual workload.

Core Client Logic

public synchronized long next() {

rateCalculator.recordAllocations(1);

int remaining = cachedValues.length - cachePosition;

int threshold = rateCalculator.calculateRefillThreshold();

// Trigger async refill before cache exhaustion

if (remaining <= threshold && !refillInProgress) {

triggerBackgroundRefill();

}

if (cachePosition >= cachedValues.length) {

// Cache exhausted, must block on refill

blockingRefill();

}

// Values already have an increment applied by the server

return cachedValues[cachePosition++];

}

Client vs. Server Parameters

The asymmetry was intentional; Server blocks request five hundred to one thousand sequences per DynamoDB fetch, while the client cache limit is fifty to five hundred sequences, depending on the application. Client-side memory is more constrained than server memory. Wasted sequences from client crashes are more common (i.e., applications restart frequently). The server absorbs demand from multiple clients, so larger blocks make sense there. Smaller client caches create less waste when applications scale down or redeploy.

Fault Tolerance and Failure Modes

The two-tier caching architecture wasn't just about performance; it also provided layered protection against outages.

Client cache protects against service outages: If the sequence service became unavailable, clients continued serving sequences from their local cache. A client with five hundred sequences cached and consuming ten per second could survive a fifty second service outage without any impact to the application.

Server cache protects against DynamoDB outages: If DynamoDB experienced a partition outage or throttling, the sequence service continued serving from its in-memory cache. With thousands of sequences cached per counter, the server could sustain traffic for minutes.

Using both client and server caching combines protection: The two tiers multiply resilience. A DynamoDB outage didn't immediately affect clients, because the server cache absorbed the impact. A sequence service outage didn't immediately affect applications, because the client cache absorbed the impact.

This combination of caching increased the system's effective availability to be higher than it would have been with any individual component. Brief outages at any layer were invisible to end users.

Failure Impact Summary

Failure

Impact

Client crash

Gaps in sequence (acceptable)

Server restart

Gaps (acceptable)

DynamoDB outage

Two-tier cache avoids DynamoDB for a certain period

Guaranteeing Monotonicity and Uniqueness

Let's be precise about the guarantees the system provides.

Uniqueness: Strong Guarantee

No two callers will ever receive the same sequence value. This guarantee holds globally, across all clients and servers.

How it works:

DynamoDB's conditional writes ensure each block is allocated exactly once.

Server caches allocate from non-overlapping blocks.

Client caches receive non-overlapping ranges from servers.

Even under failure scenarios (e.g., crashes, retries, and network partitions), uniqueness is preserved. The worst case is gaps, not duplicates.

Monotonicity: Per Client Guarantee

Within a single client instance, values are strictly increasing (or decreasing, for decreasing sequences). Value N+1 is always greater than value N from that client's perspective.

Across clients, values generally increase over time but may appear out of order. Client A's value 1050 might be used after Client B's value 1100.

Gaps: Expected Behavior

Gaps are inherent in the design. They occur when a server or client crashes with unused sequences in cache, or when blocks are allocated but traffic doesn't materialize.

For most use cases, gaps don’t matter. Primary keys don’t need to be contiguous. Audit logs don’t need sequential IDs. The only systems that truly need gap-free sequences are those with hard external dependencies on contiguity, which in our experience is far rarer than engineers initially assume.

Cache Efficiency

The combination of two-tier caching and sliding window rate calculation produced remarkable efficiency:

Metric

Value

Sequences served from client cache

~99%

Sequences served from the server cache

~0.9%

Sequences requiring DynamoDB call

~0.1%

For a system serving fifty thousand sequences per second at peak, this approach resulted in approximately 49,500 served instantly from client memory, approximately four hundred fifty that required a server call (still fast, from server cache), and approximately fifty that triggered a DynamoDB write.

The sliding window's async refill was critical here. Because refills triggered before cache exhaustion, nearly no user requests waited on network calls. Users experienced consistent sub-millisecond latency regardless of what was happening behind the scenes.

Throughput

Throughput metrics included a peak of over fifty thousand sequences per second across all counters with a typical throughput of ten thousand to twenty thousand sequences per second. The per-counter maximum was approximately five thousand sequences per second for counters with the highest traffic. Most of this throughput never hit the sequence service. Actual service traffic was roughly one percent of sequence consumption.

DynamoDB Patterns

Metric

Value

Write TPS (sustained)

10-20

Write TPS (peak)

50-100

Read capacity

Near zero (metadata only)

Items

One per sequence (~10,000 sequences across 100+ teams)

Table size

A few MB

Yes, really. A system serving fifty thousand sequences per second generated only ten to twenty DynamoDB writes per second under normal load. That thousand-to-one ratio still surprises people when I mention it; the ratio came from bulk fetching plus predictive refills working in concert.

Latency

Path

p50

p99

Client cache hit

<0.01ms

<0.05ms

Server cache hit

1-2ms

5ms

DynamoDB fetch

5-10ms

20ms

Under normal operation, over ninety-nine percent of requests hit the client cache. Server calls were rare; DynamoDB calls were rarer.

Costs

The costs were reasonable. DynamoDB with minimal write capacity and tiny storage was about fifty dollars per month. Sequence service compute for a small fleet with low CPU use was around five hundred dollars. Network costs were negligible; thick clients reduce cross-service traffic. The total cost was less than one thousand dollars a month and served tens of thousands of sequences per second.

Migration: Helping Teams Adopt

Building the system was half the work. Getting over a hundred teams to actually adopt it – that was the other half of the effort, and honestly, the harder part. The Orders team migrated twelve services in three weeks, with a peak load of eight thousand sequences per second and zero downtime.

API Compatibility

The client API was simpler than the legacy database’s API:

// Legacy database (before)

long id = connection.getNextSequenceValue("orders_seq");

// New system (after)

long id = sequenceClient.next("orders_seq");

For most teams, migration was a one-line change plus a dependency update. No configuration, no setup, and no parameters were required, just the sequence name. All the complexity (increment, direction, and block sizes) was handled on the server-side during onboarding.

Counter Registration and Configuration

Sequence configuration was entirely server-side, stored in a dynamic config store. During onboarding, teams registered their sequences with all the source database-compatible parameters: sequence name, starting value, increment size, minimum value, maximum value, and direction (ascending or descending).

Using the dynamic config store allowed us to adjust parameters, such as block sizes, increments, and cache limits, without redeploying the service or touching client code. When a high-traffic sequence needed larger blocks during peak season, we updated the config, and the change took effect within seconds. This operational flexibility proved invaluable as we onboarded more teams and learned their actual traffic patterns.

This separation was deliberate. Configuration belonged to the platform team, not individual applications. When a sequence needed tuning (e.g., larger blocks for higher traffic or different increments for a new use case) we made server-side changes without touching client code.

Lessons Learned

What Worked Well

Two-Tier Caching

The client cache reduced server load by ninety-nine percent. The server cache reduced DynamoDB load by another ninety percent or more. Combined, we achieved a thousand-to-one ratio between sequences served and database writes.

Outage Isolation

The caching tiers provided layered fault tolerance. Client caches absorbed service outages. Server caches absorbed DynamoDB outages. Brief failures at any layer were invisible to applications; the system's effective availability exceeded any individual component.

Async Refills

Triggering refills before cache exhaustion resulted in user requests never waiting on network calls under normal operation. This was the difference between "fast most of the time" and "consistently fast", which, as our SRE team will tell you, are very different things.

Self-Correcting Fill Rates

The system auto-tuned within minutes; no traffic forecasting was needed. Misconfigured clients fixed themselves.

Simplicity

The design fits on a whiteboard. New engineers understood it in one meeting. On-call never struggled to debug it.

Gaps Acceptance

Convincing teams that gaps were acceptable unlocked the entire simple design. Those conversations were harder than the engineering; engineers have strong intuitions about "correctness" that sometimes require gentle challenging.

What We Would Reconsider

Block Size Tuning

Initially, we made block sizes too large, wasting sequences on low-traffic counters. Dynamic block sizing based on observed traffic would have been better.

Monitoring Granularity

We monitored aggregate metrics well, but per-counter visibility came later. Should have built detailed dashboards from the beginning. We eventually added real-time dashboards showing cache depth, refill rate, and gap frequency per counter, which was critical for on-call triage.

What This Teaches Us About Distributed Systems

This system illustrates a broader pattern I keep running into: In distributed systems, caching isn’t just a performance hack, it’s a resilience and simplicity primitive. The key insight here wasn’t technical. Rather, it was recognizing that most teams didn’t actually need the guarantees they thought they needed. Once we helped them drop those constraints, the solution became almost embarrassingly simple.

Conclusion

Database-native sequences are a convenience that becomes a constraint at scale. Even with caching, which most teams had implemented in some form, the lack of standardization and predictive refills supplies inconsistent performance and operational burden.

Our architecture, DynamoDB as source of truth, two-tier caching, and sliding window refills, achieved full parity with the legacy database’s sequence capabilities (e.g., starting points, custom increments, and ascending/descending order) while enabling more than one hundred teams to migrate without rewrites. Under normal load, ninety-nine percent of sequence generation happens entirely in application memory: no network, no remote service, and no database, just incrementing a number.

In the end, the best distributed system was the one that made distribution invisible.

If you’re planning a similar migration, start by asking what guarantees you actually need rather than what guarantees feel right. The answer might unlock a simpler design than you expected.

About the Author

Saumya Tyagi

Saumya Tyagi is a Sr. Software Engineer at Auger and an IEEE Senior Member with over a decade of experience building distributed systems at Google, Amazon, and Coupang.

Show moreShow less

この記事をシェア

関連記事

Simon Willison Blog★32026年4月12日 04:56

SQLite 3.53.0 のリリース

SQLite がバージョン3.53.0を公開。ALTER TABLEでNOT NULLやCHECK制約の追加・削除が可能になり、ユーザー向けおよび内部の改善が多数含まれる。

InfoQ★32026年4月11日 16:13

Etsy、1000シャード・425TBのMySQLシャーディングアーキテクチャをVitessに移行

Etsyのエンジニアリングチームは、長年運用してきたMySQLシャーディング基盤をVitessに移行した。内部システムからVitessのvindexesを使用してシャードルーティングを移行し、データの再シャーディングや未シャーディングテーブルのシャーディングを可能にした。

Simon Willison Blog2026年5月25日 06:38

Simon Willison Blog の「datasette-fixtures 0.1a0」リリース

Simon Willison が、Datasette 1.0a30 に含まれる新機能として、データベースにフィクスチャデータを挿入するためのヘルパー関数を実装したライブラリ「datasette-fixtures 0.1a0」を公開しました。

ニュース一覧に戻る元記事を読む