Redis
イベント
マガジン
技術ブログ
はじめに こんにちは、バックエンドエンジニアの小笠原( @yukineko_819 )です。 Checkout Reliabilityチームに所属し、負荷試験環境の構築や購入ロジックの最適化など、購入体験の信頼性向上に向けた取り組みをしています。 今回は、「どのショップでも、いつでも安定して購入できる」という購入体験を守るために、購入時のクレジットカード決済へ流量制御(以降、レートリミッタ)を導入した話をご紹介したいと思います。とくに、 特定のショップに購入が集中しても、ほかのショップでは決済ができる状態にする ために、アクセス集中の影響をできる限り抑え、全体の購入可用性を平準化する設計をどう考えたか、というところが中心になります。 あるショップにアクセスが集中している時、ほかのショップで購入ができない まずは、私たちが解きたかった問題からお話しさせてください。 一般的に、決済処理には安定稼働のために単位時間あたりに捌ける流量の上限が設定されており、これを決済流量制限(以降、決済レートリミット)と言います。平常時であればこの決済レートリミットを意識することはほとんどありません。しかし、大型の販売イベントや限定商品の販売といったケースで、特定のショップに対してごく短期間に購入が一気に集中している時、この決済レートリミットが問題になります。 このとき、購入が集中しているショップの決済で決済レートリミットに達すると、まったく無関係なほかのショップにも影響が及んで制限がかかり、購入できなくなってしまう可能性があります。 これは購入者から見れば、自分にもショップにも何の落ち度もないにもかかわらず購入できない、というマイナスの体験となってしまいます。ネットショップの買い物体験として、これほど避けたいものはないでしょう。 いつでも買えるカートを目指して そこで私たちが目指したのは、特定のショップに購入リクエストが集中している状況でも、ほかのショップではいつもどおり購入できる状態を保つことでした。これを実現するために、次の2つを満たすレートリミッタの設計を考えました。 1. 流量を自分たちで把握して制御する ひとつめは、流量を自分たちでカウントして能動的に制限をかけることです。決済レートリミットに到達しているか否かわからないまま決済を実行するのではなく、安定して捌ける範囲の上限を自社側で定義し、あえて能動的に流量を絞る方針を取りました。 2. その他のショップの取り分を「引き算」で残す ふたつめは、アクセス集中ショップとその他のショップを分けて管理することです。これまではアクセス集中ショップが流量を独り占めしてしまい、その他のショップの決済が通らなくなってしまう危険がありました。そこで、あえてアクセス集中ショップに本来の上限値よりも少しだけ低い上限を設定し、残った流量をその他のショップが使えるようにする、という方針を取りました。 この設計のポイントは、その他のショップ用の流量を予め確保しておくのではなく、アクセス集中ショップの上限値にキャップをかける形にしたことです。アクセス集中ショップといっても常に上限に張り付いているわけではありませんし、その他のショップでの購入がたまたま同時刻に重なってリクエスト数がはね上がる可能性もあります。この方針は、その他のショップ側の流量に不要な制限をかけてしまわないようにするという意図があります。 図1: アクセス集中の影響を抑え、その他のショップの取り分を「引き算」で残す どう作ったか ここからは、より具体的な処理フローの話に移りたいと思います。 決済を実行する直前に、1トークンを要求する レートリミッタをどこに挟むべきか検討した時、私たちは決済処理を実行する直前に挟むことにしました。 決済処理は、その実行前にレートリミッタへ「1トークンください」と要求します。このレートリミッタの実体は、専用のRedis上でアトミックに実行される Luaスクリプトで、古典的なトークンバケットとして振る舞います。 なぜLuaなのかというと、「トークンの回復 → トークン残量を確認する → トークンを消費する」という一連の判定をひとつのアトミックな操作にまとめたかったからです。こうしておけば、同時に大量のリクエストが来ても、競合することなく正しくカウントできます。 このレートリミッタは、トークンが残っていればトークンを消費してOKを返し、制限と判定されればNGを返します。レートリミッタを呼び出したアプリケーション側は、判定がOKであればそのまま外部APIを実行して決済に進みます。NGであれば外部APIを実行することなく、決済を諦めて処理をエラーで終了させます。 グローバルバケットとアクセス集中ショップ用バケットの2段構成にする ただ、総量を制御するだけでは足りません。先着順でトークンを奪い合うと、アクセス集中ショップが全てのトークンを独占してしまい、その他のショップが締め出されてしまうからです。これでは今までと何も変わりません。 そこで中核になるのがアクセス集中ショップ用に別途専用のトークンバケットを用意するという方法です。 全ショップが共通のグローバルバケットからトークンを消費する アクセス集中ショップだけアクセス集中ショップ用バケットからも追加で消費する アクセス集中ショップ用バケットの上限をグローバルバケットより小さく設定する ポイントは、アクセス集中ショップではないショップ向けのバケットを明示的に確保しているわけではない、というところです。あえてアクセス集中ショップ側を絞ることで、その差分がその他のショップの取り分として引き算で自動的に残るようにしています。 この設計には、 work-conserving (余力を遊ばせない)という嬉しい性質があります。アクセス集中ショップがなければグローバルバケットは誰でも使えるので、トークンが余ることはありません。アクセス集中ショップが現れて初めて、その分だけアクセス集中ショップ用バケットの制約が効き始める、というわけです。 判定のルールを整理すると、次のようになります。 アクセス集中ショップ : グローバルバケットとアクセス集中ショップ用バケットの両方に空きがあってはじめて許可。 その他のショップ :グローバルバケットに空きがあれば許可。 図2: その他のショップはグローバルバケットだけ、アクセス集中ショップは両方の空きが必要 なお、レートリミッタが制限と判定したときはトークンを消費しないようにしています。これは、弾いたリクエストでトークンを消費してしまうと本来なら通せたはずの後続のリクエストにまで影響が及んでしまうからです。 アクセス集中ショップをどう見分けるか アクセス集中ショップをどう判定するか、という点にも少し工夫が要りました。 素朴に閾値だけで判定すると、ショップの状態が閾値の前後で行ったり来たりしてしまい、挙動が安定しません。 そこで、ショップごとに単位時間あたりの決済リクエスト回数をカウントし、一定以上に達したらアクセス集中ショップと判定するようにしました。さらに、一度アクセス集中ショップと判定されたらしばらくはそのフラグを引き継ぐクールダウン期間を設けています。このクールダウン時間は、実際の過去のリクエストの状況を分析して決定しました。 Off → Observe → Enforce、3つのモードで段階的に出す 決済という事故が許されない経路に新しい制御、それも決済を能動的に遮断する機能を入れるわけですから、リリースの手順は慎重に検討しました。いきなり遮断するようなことはせず、フィーチャーフラグで3つのモードを切り替えられるようにして段階的にリリースできるように設計しました。具体的には、以下の3つのモードを管理画面から瞬時に切り替えられるような作りにしました。 Off :完全な機能OFF状態。Redisにも通信しない、完全に無害な状態で本番に導入する。 Observe :制限の判定はするが、制限はせずに全て許可する状態。「もし遮断していたら、いつ・どれだけ弾いていたか」を記録するだけにとどめる。このモードで誤って制限してしまうケースがないかを本番のデータで実測する。 Enforce :制限の判定を行い、遮断も実行する状態。この状態で初めて本格的なレートリミッタの挙動がリリースされる。 万が一 Enforce で誤った遮断が起きても管理画面からフラグを即座にObserveへ戻すだけで、デプロイなしに誤遮断を解除することができます。さらに、この状態の計測自体は継続することで、何が起こったか後から分析できるように情報を残すこともできます。この「デプロイなしで止められる」「後から分析できるデータも残せる」という安心感は、本番に投入していくうえで大きく効いたと感じています。 レートリミッタで決済を止めないようにする 当然ですが、流量制御のために導入したレートリミッタ自身が新たな障害点になってしまっては本末転倒です。レートリミッタの不調で決済全体が止まってしまうのは、避けたい事態の中でも最悪の部類だといえます。 そこでRedisに異常があったときは判定をスキップして決済を通すfail-open方針にしました。「厳密に上限を守ること」よりも「決済を止めないこと」を優先する、という価値判断です。あわせて、判定にかけるタイムアウトはごく短期間に設定し、Redisが重くなったときも素早く安全側に倒れて、決済の本流の足を引っ張らないようにしています。 すべての判定を観測する 最後は観測の話です。すべての判定経路で、レートリミッタの呼び出し毎に1つのイベントを記録するようにしました。 記録しているのは、どのモードで動いていたか(Off / Observe / Enforce)、許可したのか制限したのか、制限判定の理由(グローバルバケットで弾いたのか、アクセス集中ショップ用バケットで弾いたのか)、処理にかかった所要時間、そしてfail-openが発生したかどうか、といった情報です。 特に Observe モードで記録した「もし遮断していたら弾いていたはずの量」は、Enforce へ昇格してよいかを判断するための一次データになります。また、レートリミッタが事前に遮断したものと、決済処理そのものが失敗したものとでは意味がまったく異なるので、ログには専用のタグを付けて、両者をはっきり区別できるようにしています。 おわりに 今回は、購入時のクレジットカード決済に流量制御を導入した話をご紹介しました。 やったことを一言でまとめると、 特定のアクセス集中ショップに購入が集中しても、その他のショップに影響が及ばないように、限られた決済の流量を能動的に制御することで受け止められるようにした 、ということになります。決済の流量を1つのグローバルカウンタで測って制御しつつ、アクセス集中ショップ用バケットによってアクセス集中ショップの流量に少しだけきつめの制限を設け、その他のショップの決済分を引き算で残す。そして3つのモードで安全に段階リリースし、万が一レートリミッタ自身が不調になってもfail-openで決済を止めない。 ひとつ補足しておきたいのは、これは「決済の処理能力がこれ以上伸ばせないから絞っている」という話ではない、ということです。実は、既存の実装を整理することで、レートリミッタを導入してもなお、単位時間あたりに捌ける決済の流量そのものはむしろ増えています。だからこそ今回、思い切ってアクセス集中ショップに制限を設けるという判断ができました。レートリミッタ導入前と比べても、アクセス集中ショップが単位時間あたりに購入できる流量はむしろ増えているのです。 そのうえで、限られたリソースのバランスを短期的に取るための手段として、レートリミッタによる制御を併用している、という位置づけです。一部のショップへのアクセス集中でシステム全体が不安定になれば、影響を受けるのはほかのショップだけでなく、アクセスが集中しているショップ自身も同じです。全体を安定して動かし続けることこそが、結果的にすべてのショップの購入機会を守ることにつながると考えています。 もちろん、ここで満足しているわけではありません。処理能力そのものの引き上げや、ほかの選択肢の検討も含めて、「どのショップでも、いつでも買いやすいカート」を目指して改善を続けていきます。 派手な機能ではありませんが、「どのショップでも、いつでも購入できる」という当たり前を裏側で支える仕組みとして、地味に効いてくれるものになったのではないかと思っています。今回のような改善はユーザーの目に直接見えるものではありませんが、快適な購入体験のために、Checkout Reliabilityチームではこういった細やかな改善を引き続き積み重ねていきます。 BASE では、「どのショップでも、いつでも購入できる」という当たり前を、こうした地道な設計と運用で支えていく仲間を募集しています。カートや決済まわりの信頼性に興味がある方は、ぜひお気軽に採用情報をご確認ください。 binc.jp
このBlog postは Open Governance for MySQL: A Step Forward for the Community の日本語訳です。 MySQL — 世界中の数百万のアプリケーションを支えるオープンソースデータベース — が新たな章を開きます。本日、Oracleは、より広範なコミュニティがプロジェクトの開発と方向性に参加するための道筋を作る、 MySQLのコミュニティガバナンスモデルを発表 しました。 このポストでは、AWSがこの動きを支持する理由と、MySQLコミュニティにとっての意味を説明します。 オープンガバナンスがオープンソースを機能させる 多様なコントリビューターと透明性のあるガバナンスを持つオープンソースプロジェクトは、より良いソフトウェアを生み出します。オープンガバナンスは、ユーザーからコントリビューター、そしてリーダーへの明確な道筋を示し、組織がプロジェクトの将来にエンジニアリングリソースなどを投資するを自信を与えます。 MySQLは約30年にわたり、インターネットインフラストラクチャの基盤となってきました。スタートアップから世界最大の企業まで、数十万の企業が最も重要なワークロードをMySQL上で実行しています。コミュニティの参加方法を明確化することで、その基盤が強化され、MySQLの利用者が将来を見据え、ビジネスを構築するための判断材料に役立ちます。 新しいガバナンスモデルの仕組み OracleがMySQLを買収して以来初めて、Oracle以外の組織がエンジンの構築方法と方向性において定義された役割を持つことになります。このモデルは、役割の段階を作ります:コントリビューターがコードと修正を提出し、コミッターが変更をレビューして承認し、プロジェクトリードがオプティマイザーやInnoDBなどの主要サブシステムを所有します。 これらの役割の上に、MySQLの長期的な方向性とリリースポリシーを設定するステアリングコミッティがあります。コミッティには、Oracle以外から4つの席があり、クラウドプロバイダー、MySQLの顧客、オープンソースコミュニティが占め、Oracleが過半数を持ちます。Oracleが最初のメンバーを2年の任期で指名し、その後、Oracle以外の席はコミュニティによる投票により決定されます。 これらすべてを支えるため、これまで存在しなかった外部コラボレーションとコントリビューションのためのチャネルとして、OracleはMySQLコミュニティのためのパブリックGitHubを立ち上げました。 AWSがこれを支持する理由 AWSは15年以上にわたり、ユーザーとして、コントリビューターとして、そしてMySQLに依存するサービスの構築者として、MySQLに深く投資してきました。今日、数万のお客様がAWS上でMySQLワークロードを実行しています。MySQLは私たちのエコシステムで最も重要なデータベースの一つであり、お客様はその長期的な健全性に直接的な利害関係を持っています。 AWSでは、オープンソースはすべての人にとって良いものであると信じており、オープンソースの価値をお客様に、そしてAWSの運用上のオペレーショナルエクセレンスをオープンソースコミュニティにもたらすことにコミットしています。そのコミットメントはシンプルな形で現れます:お客様がAWS上でオープンソースデータベースを実行して問題に遭遇した場合、私たちはアップストリームに対してMySQLを利用するすべてのユーザのために修正に取り組みます。 私たちにはまさにこれらを行ってきた実績があります。PostgreSQLでは、VACUUMを6倍高速化し、アップグレード時にレプリケーションスロットを維持し、autovacuum設定変更の再起動要件を削除しました。LinuxFoundationによるRedisのフォークであるValkeyでは、全文検索とハイブリッドクエリサポートを追加しました。そして、大量のテーブルを持つデータベースのアップグレード時のメモリ不足エラーの修正やヒストグラムエラーの修正など、MySQL自体にもすでにアップストリームに対し修正を貢献しています。 健全なアップストリームプロジェクトは、MySQLに依存するすべての人に利益をもたらします — 自ら運用する人、マネージドサービスを活用する人、またはそれらのシステムにツールや統合を構築する人。より多くのエンジニアがコードをレビューすれば、より多くのバグが発見されます。設計上の決定がオープンに行われれば、リリースされる機能はより幅広い実世界のユースケースを反映します。ガバナンスが透明であれば、組織はコントリビューションが評価され、声が聞かれるという自信を持ってプロジェクトに投資できます。 これは理論ではありません — OpenJDK、Valkey、その他数十のプロジェクトで、幅広い参加がソフトウェアをより良く、コミュニティをより強くした経験です。 私たちはMySQLにもそれを望んでいます。 MySQLコミュニティにとっての意味 このガバナンスモデルは、ユーザー、コントリビューター、エコシステム全体にとって、プロジェクトの長期的な健全性のシグナルです: 品質とセキュリティへのより多くの目 — コミッター、プロジェクトリード、コンポーネント横断的な監視による構造化されたレビュープロセスにより、コードがリリースされる前に、より多くのエンジニアが正確性、パフォーマンス、セキュリティを検証します。 より速いイノベーショ ン — 明確なコントリビューションパスとパブリックなコラボレーションにより、より広範なエコシステムが改善を提案し提供するための障壁が低くなります。 プロジェクトの将来への自信 — Oracle、エンドユーザー、オープンソースコミュニティからの代表を含むステアリングコミッティにより、MySQLの方向性は単一のベンダーだけでなく、それに依存する利用者の利益を反映します。 継続性と互換性 — ガバナンスモデルは、安定性、後方互換性、リリース品質を明示的に優先します。ユーザーとオペレーターは、破壊的な変更を心配することなく改善を採用できます。 より強力なアップストリームプロジェクトは、MySQL上に構築されたすべてのもの — マネージドサービス、セルフホストデプロイメント、ツール、そしてより広範なエコシステム — のより強力な基盤を意味します。 今後の展望 AWSはMySQLステアリングコミッティに席を持ち、プロジェクトのロードマップとリリース決定に直接的な発言権を持っています。私たちは、MySQLを利用しているお客様のためにその発言権を使うつもりです。 AWSは長期にわたってオープンソースコミュニティに貢献しており、お客様のワークロードに最も直接的な影響を与える分野でMySQLプロジェクトに積極的に関与しています: パフォーマンス — 実際のワークロードの実行速度を決定するエンジンの部分に焦点を当てています:クエリオプティマイザー、クエリ実行、インデックス作成、InnoDBストレージエンジン、およびその下のキャッシュレイヤー。 ベクトル検索とインデックス作成 — オープンソースデータベースのベクトル機能を強化してきたAWSの経験が、コミュニティ全体の共同作業に基づいて、MySQLの新しいベクトルサポートに貢献しています。 拡張フレームワーク — MySQLのコンポーネントインフラストラクチャにより、新しい機能はコアサーバーコードに組み込まれるのではなく、定義されたサービスインターフェースを通じて接続するロード可能なコンポーネントとしてリリースできます。これはコミュニティコントリビューションに最もオープンな分野の一つであり、ここに投資する予定です。 これらは、私たちがすでに行っているアップストリームへの貢献の上に構築されています。数十万のお客様のミッションクリティカルなワークロードを実行することで、MySQLの多くのユーザーに影響する実際の問題 — 正確性、安定性、信頼性の問題 — が表面化し、GitHubを通じてコミュニティ全体のための修正に取り組んでいます。 要点はシンプルです:MySQLの開発がオープンになり、AWSはその方向性を形作る席を持ち、すでにアップストリームで修正と改善の貢献をしています。お客様はMySQLをどこで実行してもこれらの恩恵を受けることができます。 Get involved MySQLエコシステム全体の開発者、ユーザー、組織の皆様に、ガバナンスモデルを読み、どのように参加したいかを検討することをお勧めします。オープンソースは人々が参加することで成長します — そしてこのモデルにより、コントリビューションがこれまで以上に簡単になります。 Read Oracle’s announcement Read the Governance model Pravin Mittal Pravin Mittal is Director of Engineering for Amazon Aurora at AWS, where he leads teams building managed MySQL and PostgreSQL services for hundreds of thousands of customers. He represents AWS on the MySQL Community Steering Committee.
こんにちは。メルコインのフロントエンド(FE)エンジニアとしてインターンをしている@nanacomです。この記事は「 Merpay & Mercoin Tech Openness Month 2026 」の7日目の記事です。 はじめに インターンではFEに限らず、要件定義からバックエンド(BE)開発まで、1つのプロジェクトに幅広く取り組みました。その中で、メルコインの社内ツールを開発する際に、2つのAPIの結果を日時降順にマージして返すエンドポイントを実装するケースに直面しました。 結果を結合して並べ替えるだけならシンプルですが、 マージした一覧にもページネーションを提供しようとすると、各ソースのカーソルをどこまで進めるべきか が複雑になります。本記事では、「マージ結果として採用された件数」と「各データソース側で進めるべきカーソル」のズレにどう対処したかを紹介します。具体的には、データ取得とカーソル確定を分離する「2フェーズ取得パターン」と、各ソースのカーソルを1つのトークンに束ねる「複合ページネーショントークン」の2つの設計を取り上げます。 前提:対象とするユースケース マイクロサービスアーキテクチャでは、BFF(Backend For Frontend)で複数のサービスからデータを集約して一覧表示することがよくあります。今回対象としたのは、2つの独立したデータソース(A, B)のデータをマージするケースです。いずれも日時降順にソートされたデータを返し、それぞれがカーソルベースのページネーションAPIを提供しています。カーソルベースのページネーションとは、前回の取得結果の末尾を示すトークン(カーソル)を次のリクエストに渡すことで、続きのデータを取得する方式です。 この2つのソースの結果を日時降順にマージした一覧をクライアントに返しつつ、その一覧自体にもページネーションを提供する必要がありました。つまり、各ソースが独立して管理するカーソルを、BFF側でどう扱うかが設計上の焦点でした。 売買と入出金をマージした一覧表示(※表示データはすべてダミーです) 素朴なアプローチとその限界 この設計上の焦点に対して、私たちはまず2つの素朴なアプローチを検討しました。いずれも限界があり、最終的な設計への動機となりました。 アプローチ1:全件取得してソート 最も単純な方法は、両ソースから全件を取得し、アプリケーション側でソートしてからページごとに切り出す方法です。しかし、データ数が増えるとメモリ使用量とレイテンシーが線形に増加するため、スケールしません。 アプローチ2:各ソースからpageSize件取得してマージ 各ソースからそれぞれ pageSize 件を取得し、マージして上位 pageSize 件を選択する方法です。データ取得量を抑えられるため現実的ですが、ここで1つの問題が発生します。 例として pageSize=5 のとき、Aから [A1..A5] 、Bから [B1..B5] が返ってきたとします(いずれも日時降順)。これらをマージして上位5件を作ると、マージ結果に含まれるのがAから3件(A1,A2,A3)、Bから2件(B1,B2)になるとします。 次のページでは本来、AはA4から、BはB3から取得を再開する必要があります。しかし各ソースAPIが返すカーソルは「返却リスト末尾の次」を指すため、手元のカーソルはA6(= Aを5件進めた次)やB6(= Bを5件進めた次)を指してしまいます。 マージ結果に必要な再開位置(A4/B3)と、手元のカーソル(A6/B6)が一致しません。 (図1)各ソースから取得 (pageSize=5) Source A: [A1][A2][A3][A4][A5] -> cursorA = A6 Source B: [B1][B2][B3][B4][B5] -> cursorB = B6 マージして上位5件を採用すると、実際に消費したのは Aが3件 / Bが2件 になります(採用: A1 A2 A3 / B1 B2)。 このとき次ページで「本当に再開したい位置」と「手元のカーソル」がズレます。 ソース 次ページで本当は 手元のカーソル A A4 から再開 A6 を指す B B3 から再開 B6 を指す これが、本記事で解決する核心的な課題です。次のセクションでは、この課題に対して理想的にはどう解決すべきかを考え、そのうえで私たちが採った設計方針を説明します。 理想の解決策と現実の制約 カーソルベースAPIでは、返却件数とカーソルの進行量が常に一致します。 pageSize=5 でリクエストすれば5件返り、カーソルも5件分進みます。しかし今回のように複数ソースのデータをマージするケースでは、5件取得しても実際に採用するのは一部だけです。この「取得件数」と「消費件数」のズレが根本原因です。 仮に各ソースのAPIがカーソルではなくタイムスタンプによる範囲指定をサポートしており、かつソース内のタイムスタンプが一意であれば、この問題は発生しません。例えば、以下のように、マージ結果で最後に消費したアイテムの日時を基準に次ページを取得できます。 GET /orders?before=2025-01-01T10:00:00Z&limit=5 GET /transfers?before=2025-01-01T10:00:00Z&limit=5 この方式であれば、各ソースの消費済み最終タイムスタンプを1つのトークンに含めるだけで、BFF側に状態を持たずに1回のリクエストでページネーションを実現できます。また before で過去方向に切るため、新しいデータが追加されてもページ跨ぎの重複が起きません。 しかし、各マイクロサービスのAPI仕様を変更するのは現実的ではないため、既存仕様のままBFF層で解決する方法を検討しました。 設計方針の決定 BFF層での解決策として、トークンへの情報埋め込み、サーバー側キャッシュ、データ取得とカーソル確定の分離という3つの方法を検討しました。設計のシンプルさとステートレス性を重視した結果、3つ目の「2フェーズ取得」方式を採用しました。 方法1:トークンに情報を詰め込む(拡張複合トークン) 各ソースのカーソルを1つのトークンに束ねて返す際に、 カーソルだけでなく、次ページを再開するために必要な情報をまるごとトークン内に埋め込む 設計です。例えば「Aから何件/Bから何件消費したか」のようなメタ情報も含め、JSONにまとめてBase64エンコードして返します。 { "cursorA": "abc123", "cursorB": "def456", "consumedA": 3, "consumedB": 2 } この方式だと、クライアントが次のリクエストでトークンをそのまま返すことで、サーバーはトークンをデコードするだけで「次ページの再開位置(A4/B3など)」を復元できます。 しかし、既存のソースAPIがカーソルベースの仕組みを提供している中で独自にオフセット等も管理すると、「カーソルの意味」が二重になり設計が複雑化するため、採用しませんでした。 方法2:Redisなどで「使わなかったデータ」を保持する(サーバー側キャッシュ) 各ソースから pageSize 件ずつ取得してマージした結果、 採用されなかった"余り"のデータ(例:A4, A5 / B3, B4, B5)をサーバー側で保持しておく 設計です。例えばユーザー(またはリクエスト)単位のセッションキーでRedisに格納します。 session:user123 → { unusedA: [A4, A5], unusedB: [B3, B4, B5] } 次のページのリクエストが来たら、 まずRedisに残っているデータを先に使ってマージし 足りない分だけ各ソースAPIから追加取得する という流れにすれば、カーソルのズレ問題を回避できます。 しかし、サーバー側に状態を持つことになり、社内ツールの規模に対してインフラの運用コストが見合わないため、採用しませんでした。 方法3(採用):データ取得とカーソル確定を分離する(2フェーズ取得) 上記2つの方法では、1回のAPI呼び出しでデータ取得とカーソル確定を同時に済ませようとしています。発想を変え、 データを取得してマージするフェーズと、消費件数に基づいてカーソルを確定するフェーズを分ける ことで、この問題を解決します。サーバーはステートレスのまま、既存APIの仕組みをそのまま活かせます。API呼び出し回数は増えますが、最もシンプルな設計です。 許容するトレードオフ ただし、この方式では2回のAPI呼び出しの間に多少の時間差が生じます。そのわずかな間に対象データが追加された場合、次ページに重複したデータが現れる可能性があります。 私たちはこの問題を、以下の理由から許容可能なトレードオフと判断しました。 影響は「ページを跨ぐ際の重複表示」に限定される 対象がリアルタイムに頻繁に更新されるデータではないため、発生頻度は低い 完全な整合性を保証するには、各ソースのAPI仕様変更が必要になり、コストに見合わない この判断のもと、以降のセクションで方法3の具体的な実装を説明します。 2フェーズ取得パターン 前のセクションで述べた方法3を、具体的にどう実装したかを説明します。データを取得してマージするフェーズと、消費件数に対応するカーソルを確定するフェーズに分けて設計しました。 フェーズ1:取得とマージ ソースA、ソースBからそれぞれ pageSize 件を並行して取得する 日時降順でマージし、合計 pageSize 件を取り出す ソースAとソースBそれぞれで、実際に消費した件数を記録する この処理は、Go の container/heap を使ったストリーミングマージとして実装できます。各ソースの先頭要素をヒープに入れ、日時が最も新しいものを1つずつ取り出しながら pageSize 件を集めます。以下のコードのとおり、各ソースのインデックス( indexA , indexB )がそのまま消費件数を表します。 func Merge(pageSize int32, itemsA, itemsB []*Item) ([]*Item, int32, int32) { indexA, indexB := 0, 0 result := []*Item{} h := &timeHeap{} heap.Init(h) if len(itemsA) > 0 { heap.Push(h, &record{source: SourceA, time: itemsA[0].Timestamp}) } if len(itemsB) > 0 { heap.Push(h, &record{source: SourceB, time: itemsB[0].Timestamp}) } for h.Len() > 0 && len(result) < int(pageSize) { r := heap.Pop(h).(*record) switch r.source { case SourceA: result = append(result, itemsA[indexA]) indexA++ if indexA < len(itemsA) { heap.Push(h, &record{source: SourceA, time: itemsA[indexA].Timestamp}) } case SourceB: result = append(result, itemsB[indexB]) indexB++ if indexB < len(itemsB) { heap.Push(h, &record{source: SourceB, time: itemsB[indexB].Timestamp}) } } } return result, int32(indexA), int32(indexB) } 戻り値の indexA と indexB が、フェーズ2でカーソルを正確に進めるための入力になります。 フェーズ2:カーソルの確定 ソースA、ソースBそれぞれにおいて、フェーズ1と同じ開始位置から消費件数分だけ再取得し、進んだ位置のページネーショントークンを取得する( cursorA , cursorB ) pageToken を cursorA:cursorB (参照:次のセクション)とすることで、次ページの取得時に正しい位置からデータを取得できる なお、一方のソースのデータがもう一方より古い場合など、フェーズ1でデータが返ってきたにもかかわらずマージで1件も採用されないケースがあります。この場合は、そのソースのカーソルを前回の位置のまま保持し、次ページのリクエストで再び同じデータを取得してマージの対象にします。また、フェーズ1でデータが0件だった場合は、そのソースを枯渇と判定し、ターミナルトークン _ を設定します。 (図2)フェーズ1:取得とマージ(消費件数を記録) Source A ──(pageSize件)──┐ ├→ Merge → Top N Source B ──(pageSize件)──┘ │ 消費件数を記録 (A=3件, B=2件) (図3)フェーズ2:カーソルの確定(消費件数分だけ進める) Source A ──(消費3件)──→ cursorA Source B ──(消費2件)──→ cursorB → 複合トークン: "cursorA:cursorB" 複合ページネーショントークン設計 2フェーズ取得パターンにより、各ソースで消費件数分だけ進んだカーソルを取得できるようになりました。次に、これらのカーソルをクライアントにどのように渡すかを設計します。今回の一覧取得APIでは、 pageToken を各ソースのカーソルを結合した複合トークンとして設計します。 "cursorA:cursorB" 片方のソースが完全に尽きた場合は、ターミナルトークン _ で表現します。トークンがターミナルトークン _ だった場合、API呼び出しをスキップできます。これにより、初回リクエストから片方のソースが枯渇した状態まで、以下のようにページネーショントークンで表現することができます。 トークン 意味 "" (空文字) 初回リクエスト "cursorA:cursorB" 両ソースとも継続あり "_:cursorB" ソースAは枯渇、Bのみ継続 "cursorA:_" ソースBは枯渇、Aのみ継続 "_:_" → "" に変換 全データ取得済み(次ページなし) この複合トークンと2フェーズ取得パターンを組み合わせることで、サーバー側に状態を持たずに、マージした一覧のページネーションを実現できます。 まとめ 本記事では、カーソルベースAPIを持つ複数データソースから一覧を構築する際に直面した「マージで実際に消費した件数」と「APIが返すカーソル位置」のズレという課題と、その解決策を紹介しました。 最初は1回のAPI呼び出しで全てを済ませようとしていましたが行き詰まり、「データを取得するフェーズ」と「カーソルを確定するフェーズ」に分離することで解決できました。1つの処理が複数の責務を担って複雑になったとき、フェーズを分けて各ステップの役割を単純化するアプローチは、ページネーションに限らず設計全般で有効な考え方だと感じています。 このような設計上のトレードオフを実際に手を動かしながら考えられたのは、インターン期間中の貴重な経験でした。FEに限らず幅広く関わらせていただいたことに感謝しています。本当にありがとうございました! 次の記事は@mikupoさんです。引き続きお楽しみください。












