GCPUG Spanner DAY 2026 September

メルカリリアルSpannerバトル

@sinmetal いつも心に冪等性 / メルペイ/メルカリ
Mercari Architecture

メルカリのシステム構成の変遷

🏛️ 元はモノリス

この時代、DB は一つで超巨大な MySQL になっていた。

✂️ Microservice へ(8年ほど前)

モノリスでやるのは限界を感じ、Microservice にしよう!となった。そこから少しずつモノリスを切り崩していった。

🗄️ 巨大 MySQL は TiDB へ

超巨大 MySQL は TiDB へ移行し分散することで耐えている。負荷が高い処理は TiDB から切り出して Microservice にしていった。

Mercari Architecture

Microservice と DB の現在地

  • Microservice の数は 100 超 → DB も 100 個
  • Microservice の DB は MySQL(Cloud SQL)だったり、Spanner だったり
😇 しかし DBRE は 4 人しかいない(sinmetal は DBRE じゃない)
Mercari Architecture

オーナーシップの理想と現実

💡 思想

Microservice はそのチームがオーナーシップを持って、すべてを設計して決めよう!という思想でやっていた。

🔥 現実

チームの人が必ずしも DB に詳しいわけではないので、負荷に耐えられる設計になっていないことも多く、壊れると悲鳴が上がる状態。DBRE は 4 人なので、すべての DB に設計段階で入れない。

🗣️ DB 以外にも色んな要因があるが、「Microservice はやめていこう!」という話をしている
Links

もっと詳しく / 仲間募集

Background

移行対象: クーポン一覧取得 API

  • 機能概要: お客さまが自分が所持しているクーポンの一覧を取得する API
  • 特性: メルカリ内でも極めて呼び出し頻度(QPS)が高いエンドポイント
  • 課題: MySQL の実装をそのまま Spanner に載せ替えたところ、パフォーマンス問題が発生
💡 ポイント: MySQL と Spanner はどちらも SQL が使えますが、内部アーキテクチャが根本から異なるため、クエリとテーブルの設計思想を合わせる必要があります。
Architecture Difference

MySQL と Cloud Spanner のアーキテクチャ差

🐬 MySQL

  • Write: Primary Instance が担当(スケールアップで強化)
  • Read: Read Replica を増やすことでスケール可能
  • 特性: レプリカごとに独立しているため、重いクエリや特定処理を専用レプリカに隔離可能

⚡ Cloud Spanner

  • Split 分散: データを Split 単位に自動分割して分散配置
  • Read Replica なし: Read のみを特定インスタンスに隔離・増設は不可
  • 特性: 分散処理が強みだが、単一 Row や小さなテーブルへの高頻度アクセス(HotSpot)が不得意
Initial Implementation

移行当初のクエリ(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
⚠️ 問題点: MySQL では問題なくても、Spanner では Residual Condition(インメモリフィルタ)の多発、Sort の発生、そして HotRow も発生
Execution Plan

移行当初の 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))
Problem 1

課題①: 実行計画の悪化(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 処理が発生
Problem 2

課題②: 実行計画に現れない「HotRow」問題

🔥 超メルカリ市などの全体配布クーポン

全お客さまに配布される特定クーポンがある場合、プッシュ通知などで全ユーザーが一斉に同じ CouponID の Row を JOIN 参照しにいく。

⚡ 分割不可能な単一 Row への負荷集中

Spanner は小さなテーブル(Coupons マスタ)の単一 Row をこれ以上 Split 分割できないため、その Split を担当する特定ノードに負荷が集中し HotSpot になる!

Solution 1

施策①: Coupons Table の JOIN を廃止 & オンメモリキャッシュ

Step 1
  • 分析: アクティブなクーポン件数は数十件程度であり、変更頻度も極めて低い
  • アプローチ:
    • Spanner への JOIN を完全排除
    • Coupons マスタ情報はアプリケーションプロセスのメモリ上に一定期間キャッシュ
  • 効果:
    • Coupons テーブルへのアクセスがゼロになり HotRow を根本解決!
    • クエリを CouponOwners テーブル単体のシンプルなスキャンに変更可能に
Solution 2

施策②: ORDER BY の削除 & アプリ側ソート

Step 2
  • 分析: 1人のお客さまが所持するアクティブクーポン数は高々 10 件程度
  • アプローチ:
    • ORDER BY co.CreatedAt DESC を Spanner のクエリから削除
    • 取得した十数件のソート処理はアプリケーション側のメモリで実行
  • 効果:
    • Spanner 側の Sort オペレータを完全排除
    • Index Scan を Seek Condition だけで単一スキャン可能に
Solution 3

施策③: NULL 許容カラムの Filter 調整(Residual Condition 排除)

Step 3
  • 問題: 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があると気になるので。
Final Result

チューニング後のクエリと実行計画

▼ チューニング後の SQL
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つ目のクーポン

🏷️ アイテムクーポン

Item Coupon

ユーザクーポンとアイテムクーポン

👤 ユーザクーポン(ここまでの話)

ユーザに紐づくクーポン。
そのユーザが買い物をする時に使える。

🏷️ アイテムクーポン(ここからの話)

アイテムに紐づくクーポン。
そのアイテムを買おうとした時に使える。

Item Coupon

こんなクエリがありました

▼ SQL
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;
▼ DDL
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 はそんなに大きくない。クエリの結果はだいたい無し
Problem

課題: 小さい Table に大量の Read が集中

📦 ストレージサイズが小さい

クーポンがあるアイテムはごく一部なので、Table のサイズが小さく、Spanner の Split が増えない。

🔥 Split の CPU 利用率 100%

少ない Split に高頻度のクエリが集中し、Split の CPU 利用率が 100% になることもしばしばという状態に。

Idea

検討した策: Row をコピーして Table サイズを増やす

  • アプローチ: PK をずらして同じ Row をコピーし、Table サイズを増やして分散させる
PK: 1-couponXdyh
PK: 2-couponXdyh
PK: 3-couponXdyh
...
Idea

…あまり効かない気がした 🤔

  • Row が Hit しているなら、シャーディングされるので Split の負荷を分散できそう
  • しかし実際は 99.99% 以上、存在しない PK を探している
❓ 存在しない Key を探すクエリが、コピーした Row でうまく分散するのか・・・?
Solution

実際にやった策: 同じ Index を 30 個コピー

▼ _00 〜 _29 まで 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 で参照
Future Work

本当はやりたかった策: ネガティブキャッシュ

  • 狙い: 結果がだいたい無しなので、「クーポンが無い」ことをキャッシュしたい
  • やってみた: シンプルにアプリケーションサーバでネガティブキャッシュを入れてみたが、ItemID の種類が多いので OOM で死んだ 💀
🔁 再チャレンジしたい: Bloom Filter を使うなどして必要なメモリを削減し、キャッシュを持つ
Summary

まとめ: より良い Spanner ライフのために

📊 統計情報の監視

Statistics tables や Hot Split Statistics を定期監視し、HotRow や潜在負荷を早期発見する。

🔍 実行計画の確認

クエリ新規作成・変更時は必ず実行計画を確認。Seek なのか Residual / Sort なのかを見極める。

🧠 Spanner の気持ちになる

ノード増設だけでは HotSpot は解けない。アクセスパターンに対し Spanner がどう動くかを考えて設計する。(テーブルスキーマだけ見ても、負荷に耐えれる設計になっているかは分からない)

GCPUG Spanner

ご清聴ありがとうございました!

質問・感想はハッシュタグ #gcpug でお待ちしています!

元記事: メルカリエンジニアリングブログ