GCPUG Spanner DAY 2026 September
メルカリリアルSpannerバトル
メルカリのシステム構成の変遷
🏛️ 元はモノリス
この時代、DB は一つで超巨大な MySQL になっていた。
✂️ Microservice へ(8年ほど前)
モノリスでやるのは限界を感じ、Microservice にしよう!となった。そこから少しずつモノリスを切り崩していった。
🗄️ 巨大 MySQL は TiDB へ
超巨大 MySQL は TiDB へ移行し分散することで耐えている。負荷が高い処理は TiDB から切り出して Microservice にしていった。
Microservice と DB の現在地
- Microservice の数は 100 超 → DB も 100 個
- Microservice の DB は MySQL(Cloud SQL)だったり、Spanner だったり
オーナーシップの理想と現実
💡 思想
Microservice はそのチームがオーナーシップを持って、すべてを設計して決めよう!という思想でやっていた。
🔥 現実
チームの人が必ずしも DB に詳しいわけではないので、負荷に耐えられる設計になっていないことも多く、壊れると悲鳴が上がる状態。DBRE は 4 人なので、すべての DB に設計段階で入れない。
もっと詳しく / 仲間募集
📺 アーキテクチャ遍歴の話
移行対象: クーポン一覧取得 API
- 機能概要: お客さまが自分が所持しているクーポンの一覧を取得する API
- 特性: メルカリ内でも極めて呼び出し頻度(QPS)が高いエンドポイント
- 課題: MySQL の実装をそのまま Spanner に載せ替えたところ、パフォーマンス問題が発生
MySQL と Cloud Spanner のアーキテクチャ差
🐬 MySQL
- Write: Primary Instance が担当(スケールアップで強化)
- Read: Read Replica を増やすことでスケール可能
- 特性: レプリカごとに独立しているため、重いクエリや特定処理を専用レプリカに隔離可能
⚡ Cloud Spanner
- Split 分散: データを Split 単位に自動分割して分散配置
- Read Replica なし: Read のみを特定インスタンスに隔離・増設は不可
- 特性: 分散処理が強みだが、単一 Row や小さなテーブルへの高頻度アクセス(HotSpot)が不得意
移行当初のクエリ(MySQL のコードをそのまま移行)
SELECT
co.CouponOwnerID, co.CouponID, co.Expire, co.CreatedAt
FROM
CouponOwners co
JOIN
Coupons c ON co.CouponID = c.CouponID
WHERE
co.UserID = @userID
AND c.IsActive = @isActive
AND co.Expire > @now
AND c.StartDate <= @now
AND co.Used = @isUsed
AND c.Type IN UNNEST(@couponType)
AND c.MarketPlace = @marketPlace
ORDER BY
co.CreatedAt DESC
移行当初の Spanner 実行計画(非常に重い状態)
+-----+----------------------------------------------------------------------------------------------------+
| ID | Query_Execution_Plan |
+-----+----------------------------------------------------------------------------------------------------+
| *0 | Distributed Union (distribution_table: CouponOwnersByUserIDUsedExpireCreatedAtDESC) |
| 1 | +- Serialize Result |
| 2 | +- Sort ⚠️ (Expire範囲検索とCreatedAtソートの両立不可) |
| *3 | +- Distributed Cross Apply |
| 4 | +- [Input] Create Batch |
| 6 | | +- Local Distributed Union |
| *7 | | +- Filter Scan (seekable_key_size: 3) ⚠️ (*7 Residual Condition 発生) |
| *8 | | +- Index Scan (Index: CouponOwnersByUserIDUsedExpireCreatedAtDESC) |
| 34 | +- [Map] Cross Apply |
| 37 | +- Batch Scan |
| 46 | +- Local Distributed Union |
| *47 | +- Filter Scan (seekable_key_size: 0) ⚠️ (*47 Residual Condition 発生) |
| *48 | +- Table Scan (Table: Coupons) ⚠️ (HotRow の原因) |
+-----+----------------------------------------------------------------------------------------------------+
Predicates:
7: Residual Condition: ($UserID = @userid)
47: Residual Condition: (($IsActive = @isactive) AND ($MarketPlace = @marketplace)
AND ($StartDate <= @now) AND ($Type IN @coupontype))
課題①: 実行計画の悪化(Residual Condition & Sort)
- Seek Condition vs Residual Condition:
- Seek: Index のスキャン開始・終了地点が確定しており高速
- Residual: 開始・終了が絞り込めず、メモリ上で全件読み込んで Filter するため CPU に負荷がかかり、Latencyが悪化
- Sort の発生:
Expire > @now(範囲検索)とORDER BY CreatedAt DESCが共存するため、Index Seek だけでソート順を保証できず Spanner 側で Sort 処理が発生
課題②: 実行計画に現れない「HotRow」問題
🔥 超メルカリ市などの全体配布クーポン
全お客さまに配布される特定クーポンがある場合、プッシュ通知などで全ユーザーが一斉に同じ CouponID の Row を JOIN 参照しにいく。
⚡ 分割不可能な単一 Row への負荷集中
Spanner は小さなテーブル(Coupons マスタ)の単一 Row をこれ以上 Split 分割できないため、その Split を担当する特定ノードに負荷が集中し HotSpot になる!
施策①: Coupons Table の JOIN を廃止 & オンメモリキャッシュ
- 分析: アクティブなクーポン件数は数十件程度であり、変更頻度も極めて低い
- アプローチ:
- Spanner への JOIN を完全排除
- Coupons マスタ情報はアプリケーションプロセスのメモリ上に一定期間キャッシュ
- 効果:
- Coupons テーブルへのアクセスがゼロになり HotRow を根本解決!
- クエリを
CouponOwnersテーブル単体のシンプルなスキャンに変更可能に
施策②: ORDER BY の削除 & アプリ側ソート
- 分析: 1人のお客さまが所持するアクティブクーポン数は高々 10 件程度
- アプローチ:
ORDER BY co.CreatedAt DESCを Spanner のクエリから削除- 取得した十数件のソート処理はアプリケーション側のメモリで実行
- 効果:
- Spanner 側の
Sortオペレータを完全排除 - Index Scan を Seek Condition だけで単一スキャン可能に
- Spanner 側の
施策③: NULL 許容カラムの Filter 調整(Residual Condition 排除)
- 問題:
CouponOwners.UserIDに NOT NULL 制約がないため、@userIDが NULL の可能性を考慮し Spanner が Residual Condition を生成していた - アプローチ:
-- 明示的に NULL 条件を追記することで Seek Condition 化 (co.UserID = @userID) OR (co.UserID IS NULL AND @userID IS NULL) - 効果:
Filter Scan (Residual Condition)になるのを防いだ。実際どの程度効果があるのかはあれだが、Residual Conditionがあると気になるので。
チューニング後のクエリと実行計画
SELECT
co.CouponOwnerID,
co.CouponID,
co.Expire,
co.CreatedAt
FROM
CouponOwners co
WHERE
(co.UserID = @userID)
OR (co.UserID IS NULL AND @userID IS NULL)
AND co.Expire > @now
AND co.Used = @isUsed
+----+----------------------------+
| ID | Query_Execution_Plan |
+----+----------------------------+
| *0 | Distributed Union |
| 1 | +- Local Distributed Union |
| 2 | +- Serialize Result |
| 3 | +- Filter Scan |
| *4 | +- Index Scan |
+----+----------------------------+
Predicates:
4: Seek Condition: 100% Seek!
(Sort なし / Residual なし / JOIN なし)
Second Coupon
2つ目のクーポン
🏷️ アイテムクーポン
ユーザクーポンとアイテムクーポン
👤 ユーザクーポン(ここまでの話)
ユーザに紐づくクーポン。
そのユーザが買い物をする時に使える。
🏷️ アイテムクーポン(ここからの話)
アイテムに紐づくクーポン。
そのアイテムを買おうとした時に使える。
こんなクエリがありました
SELECT
CouponAttachedItemsID,
Used,
ItemID,
UserID,
CouponID,
Expire,
CreatedAt,
UpdatedAt
FROM
CouponAttachedItems
WHERE
ItemID IN UNNEST(@itemIDs)
AND UserID IS NULL
AND Used = @used
AND Expire > @expire;
CREATE TABLE CouponAttachedItems (
CouponAttachedItemsID INT64 NOT NULL,
Used STRING(10) NOT NULL,
ItemID STRING(255) NOT NULL,
UserID INT64,
CouponID INT64 NOT NULL,
Expire TIMESTAMP NOT NULL,
CreatedAt TIMESTAMP NOT NULL OPTIONS (
allow_commit_timestamp = true
),
UpdatedAt TIMESTAMP NOT NULL OPTIONS (
allow_commit_timestamp = true
),
) PRIMARY KEY(CouponAttachedItemsID);
@itemIDs には 100 個ぐらいの ItemID が入る / クーポンがあるアイテムは 0.004% ぐらい→ ほとんどのアイテムにクーポンはないので Table はそんなに大きくない。クエリの結果はだいたい無し
課題: 小さい Table に大量の Read が集中
📦 ストレージサイズが小さい
クーポンがあるアイテムはごく一部なので、Table のサイズが小さく、Spanner の Split が増えない。
🔥 Split の CPU 利用率 100%
少ない Split に高頻度のクエリが集中し、Split の CPU 利用率が 100% になることもしばしばという状態に。
検討した策: Row をコピーして Table サイズを増やす
- アプローチ: PK をずらして同じ Row をコピーし、Table サイズを増やして分散させる
PK: 1-couponXdyh
PK: 2-couponXdyh
PK: 3-couponXdyh
...
…あまり効かない気がした 🤔
- Row が Hit しているなら、シャーディングされるので Split の負荷を分散できそう
- しかし実際は 99.99% 以上、存在しない PK を探している
実際にやった策: 同じ Index を 30 個コピー
CREATE INDEX
IDX_CouponAttachedItems_ItemID_UserID_Used_Expire_00
ON CouponAttachedItems(
ItemID,
UserID,
Used,
Expire)
STORING (
CouponID,
CreatedAt,
UpdatedAt);
- STORING: クエリに必要な Column を全部 Index に乗せ、Base Table との Back JOIN を不要に
- アプリケーション側: シンプルに 30 個の Index を順繰りに
FORCE_INDEXで参照
本当はやりたかった策: ネガティブキャッシュ
- 狙い: 結果がだいたい無しなので、「クーポンが無い」ことをキャッシュしたい
- やってみた: シンプルにアプリケーションサーバでネガティブキャッシュを入れてみたが、ItemID の種類が多いので OOM で死んだ 💀
まとめ: より良い Spanner ライフのために
📊 統計情報の監視
Statistics tables や Hot Split Statistics を定期監視し、HotRow や潜在負荷を早期発見する。
🔍 実行計画の確認
クエリ新規作成・変更時は必ず実行計画を確認。Seek なのか Residual / Sort なのかを見極める。
🧠 Spanner の気持ちになる
ノード増設だけでは HotSpot は解けない。アクセスパターンに対し Spanner がどう動くかを考えて設計する。(テーブルスキーマだけ見ても、負荷に耐えれる設計になっているかは分からない)
GCPUG Spanner
ご清聴ありがとうございました!
質問・感想はハッシュタグ #gcpug でお待ちしています!
元記事: メルカリエンジニアリングブログ