MySQLで実装されたクエリをSpannerに移行した時に行ったパフォーマンスチューニング

こんにちは、いつも心に冪等性 sinmetalです。「Merpay & Mercoin Tech Openness Month 2026」の3日目の記事です。

本記事では、MySQLで動いていた「お客さまが所持するクーポン一覧取得」クエリをSpannerへ移行した際に直面したパフォーマンス問題と、その解決までの過程を紹介します。

DBごとのアーキテクチャの差

MySQLとSpannerはどちらもSQLを利用できますが、アーキテクチャが大きく異なるので、テーブルやクエリの設計は異なります。移行する時は差異があるSQLを修正するだけでなく、各DBのアーキテクチャまで意識して、実装を見直す必要があります。

MySQLのアーキテクチャ

MySQLはPrimary InstanceがWriteを担当するのでWriteをスケールさせたいなら、Primary Instanceのマシンを強化します。

ReadはRead Replicaを増やすことでもスケールさせることができます。

Read Replicaは独立しているので、OLAPのような特定のクエリを特定のInstanceで実行することもできます。

Spannerのアーキテクチャ

Spannerはデータを分割してSplitに保存します。Splitはデータサイズや負荷で自動的に増減し、データの分散範囲も調整されます。Writeに対してもスケールできますが、Read Replicaが無いので特定のクエリを特定のInstanceで処理するようなことはできません。Read Replicaだけを増やすということもできないので、Read性能だけを増やすこともできません。WriteもReadもどちらもAuto Scaleして分散して処理するのが強みです。

メルカリのクーポン機能の移行

クーポン機能の中に自分が所持しているクーポンの一覧を取得するAPIがあります。その中で実行されていたクエリのチューニングを行いました。

まずはMySQLの実装をそのまま移行したので、以下のようなクエリになっていました。

MySQLなら、それほど問題になるようなクエリではないかもしれませんが、Spannerの実行計画を見るとResidual Condition(Residual Conditionはインメモリで処理する必要がある状態です。後述※1)が必要、Sortが必要など非常に厳しい状態です。

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
+-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| ID  | Query_Execution_Plan                                                                                                                                                   |
+-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|  *0 | Distributed Union (distribution_table: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, preserve_subquery_order: true, split_ranges_aligned: false) |
|   1 | +- Serialize Result (execution_method: Row)                                                                                                                            |
|   2 |    +- Sort (execution_method: Row)                                                                                                                                     |
|  *3 |       +- Distributed Cross Apply (execution_method: Row)                                                                                                               |
|   4 |          +- [Input] Create Batch (execution_method: Batch)                                                                                                             |
|   5 |          |  +- RowToDataBlock                                                                                                                                          |
|   6 |          |     +- Local Distributed Union (execution_method: Row)                                                                                                      |
|  *7 |          |        +- Filter Scan (execution_method: Row, seekable_key_size: 3)                                                                                         |
|  *8 |          |           +- Index Scan (Index: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, scan_method: Row)                                       |
|  34 |          +- [Map] Cross Apply (execution_method: Row)                                                                                                                  |
|  35 |             +- [Input] KeyRangeAccumulator (execution_method: Row)                                                                                                     |
|  36 |             |  +- DataBlockToRow                                                                                                                                       |
|  37 |             |     +- Batch Scan (Batch: $v2, execution_method: Batch, scan_method: Batch)                                                                              |
|  46 |             +- [Map] Local Distributed Union (execution_method: Row)                                                                                                   |
| *47 |                +- Filter Scan (execution_method: Row, seekable_key_size: 0)                                                                                            |
| *48 |                   +- Table Scan (Table: Coupons, execution_method: Row, scan_method: Row)                                                                              |
+-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
Predicates(identified by ID):
  0: Split Range: (($UserID = @userid) AND ($Used = @isused) AND ($Expire > @now))
  3: Split Range: ($CouponID_1 = $CouponID)
  7: Residual Condition: ($UserID = @userid)
  8: Seek Condition: (IS_NOT_DISTINCT_FROM($UserID, @userid) AND ($Used = @isused)) AND ($Expire > @now)
 47: Residual Condition: (($IsActive = @isactive) AND ($MarketPlace = @marketplace) AND ($StartDate <= @now) AND ($Type IN @coupontype(array)))
 48: Seek Condition: ($CouponID_1 = $batched_CouponID')

DB Schema

CREATE TABLE Coupons (
  CouponID STRING(36) NOT NULL,
  Type STRING(36) NOT NULL,
  IsActive BOOL NOT NULL,
  StartDate TIMESTAMP NOT NULL,
  MarketPlace STRING(255) NOT NULL,
) PRIMARY KEY(CouponID);

CREATE INDEX CouponsByIsActiveStartDateTypeMarketPlaceID
  ON Coupons(IsActive, StartDate, Type, MarketPlace);

CREATE TABLE CouponOwners (
  CouponOwnerID STRING(36) NOT NULL,
  Used STRING(10) NOT NULL,
  UserID INT64,
  CouponID STRING(36) 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(CouponOwnerID);

CREATE INDEX CouponOwnersByUserIDUsedExpireCreatedAtDESC
  ON CouponOwners(UserID, Used, Expire, CreatedAt DESC)
  STORING (CouponID);

実行計画には現れない問題もありました。

HotSpotです。

Coupons Tableはクーポンの情報が書かれているマスタテーブルなのですが、件数はそれほど多くありません。

SpannerはRead Replicaが無いため、小さなTableや特定のRowへの高頻度の読み取りがMySQLと比べると不得意です。対象のRowが存在するSplitに負荷が集中してしまうからです。

特に負荷が集中するものとしてメルカリのすべてのお客さまに配信する 超メルカリ市土日限定クーポン がありました。

自分が所持しているクーポンの一覧を取得するとCoupons Tableの該当のRowがJOINのために参照されます。

すべてのお客さまが持っていて、しかも、超メルカリ市という大きなキャンペーンの中で、特定の土日にだけ使えて、クーポン付与のタイミングでプッシュ通知も行われることもあり、高い確率でHotRowになります。

Note: HotRowとは?

アクセスが集中していてそれ以上分割不能なRowのことを指します。

HotRowが発生しているかはHot Spot Statisticsを見ると分かります。

開発環境でチェックする場合は、負荷テストを行い、Hot Split Statisticsをチェックします。

https://docs.cloud.google.com/spanner/docs/introspection/hot-split-statistics?hl=en#hot_row

パフォーマンスチューニング

これらの問題を解決するためにアーキテクチャを見直しました。

やったことは大きく分けて3つです。

  • Coupons TableのJOINをやめて、アプリ側に処理を寄せた
  • ORDER BYを削除した
  • NULLを許可しているColumnのFilter条件を調整してResidual Conditionが発生しないようにした

Coupons TableのJOINをやめて、Coupons Tableはアプリケーション側で参照するようにし、更に一定期間メモリ上にキャッシュするようにしました。

ValkeyやRedisに保存することも検討しましたが、アクティブな状態のクーポンの数は数十程度であること、変更はほぼないことで、メモリ上に持ってしまって良いだろうと判断しました。

今後、他にもキャッシュしたいものが増えれば、専用のインフラを用意して、実装し直すかもしれません。

キャッシュを入れたことで、HotRowが発生しづらくなり、クエリもCouponOwners Table単体で処理できるようになり、JOINも不要になりました。

次に ORDER BY co.CreatedAt DESC を無くしました。

Filter条件として Expire > @now があるので、CreatedAtのSortがあると単一のIndexをSeek Conditionで読むだけという処理にはできません。

ExpireをResidual ConditionでFilter Scanするか、CreatedAtをSortするかを選択することになります。

1人のお客さまが持っているクーポンの数は多くても10程度なので、Sortしてしまっても良いかなとは思いましたが、呼び出し回数が非常に多いAPIなので、アプリケーション側に処理を寄せています。代わりにアプリケーション側の負担が増えているので、どちらがよいかは悩ましいところです。今後の状況によってはSortをSpanner側に戻すこともあるかもしれません。

もう一工夫している点として、UserIDのFilter条件 (co.UserID IS NULL AND @userID IS NULL) を追加しています。

これはCouponOwners.UserIDはNOT NULL制約がないため、@userIDにNULLが入る可能性をSpannerが考慮して、Filter ScanとしてResidual Condition: ($UserID = @userid)を追加してしまうのを抑制するためです。(元の実行計画の*7)(後述※2)

結果としてクエリの実行計画は非常にシンプルなものにできました。

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 (distribution_table: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, split_ranges_aligned: false) |
|  1 | +- Local Distributed Union (execution_method: Row)                                                                                      |
|  2 |    +- Serialize Result (execution_method: Row)                                                                                          |
|  3 |       +- Filter Scan (execution_method: Row, seekable_key_size: 3)                                                                      |
| *4 |          +- Index Scan (Index: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, scan_method: Row)                    |
+----+-----------------------------------------------------------------------------------------------------------------------------------------+
Predicates(identified by ID):
 0: Split Range: (($UserID = @userid) OR (ISNULL($UserID) AND ($Used = @isused) AND ($Expire > @now) AND ISNULL(@userid)))
 4: Seek Condition: (($UserID = @userid) OR (ISNULL($UserID) AND ($Used = @isused) AND ($Expire > @now) AND ISNULL(@userid)))

まとめ

元のクエリと比べると非常にシンプルになり、高速で負荷の小さなものになりました。SQLが利用できるDBは増えていますが、インターフェースとしてSQLが使えても中のアーキテクチャがそれぞれ異なります。

Spannerは予算さえあれば気軽にNodeを増やせるため、LatencyやCPU使用率に不満があれば、Nodeを増やすことで解決もできます。

しかし、コスト効率はよくないですし、HotSpotのようにNodeを増やしても解決できない問題もあります。

  • Statistics tablesを定期的に確認し、パフォーマンスの状態をモニタリングすること。
  • 新しいクエリの作成や既存のクエリの修正を行う場合は実行計画を確認すること。
  • アクセスパターンに対してSpannerがどんな挙動をする必要があるかSpannerの気持ちになって考えること。

これらを日常的に行うことで、よりよいSpannerライフが送れます。

次の記事は komatuさんの「決済プラットフォームと経理を繋ぐ MoneyFlow」です。引き続きお楽しみください。

※1 FilterScan operator の Seek ConditionとResidual Condition

Seek ConditionはTableやIndexのスキャン範囲の開始と終了地点が特定できている状態の時に使われます。開始地点から終了地点まで読み込むだけなので、高速に動作します。

Residual ConditionはTableやIndexを読み込む時に開始地点と終了地点が特定できず、データを実際に読んでFilterする必要がある時に利用されます。Seek Conditionと比べるとCPUを多く消費しますし、スキャンするデータ量に応じてLatencyも増加します。

Seek ConditionとResidual ConditionについてはCloud Spanner Unofficial Hacksを読むとよいでしょう。

※2 IS NOT DISTINCT FROM

SpannerにはIS NOT DISTINCT FROMがないので、co.UserID IS NULL AND @userID IS NULLを入れているわけですが、2026年5月25日に再度試したところIS NOT DISTINCT FROMで動作していました。まだ、ドキュメントには反映されてないので、記事の中では使っていませんが、近々リリースノートが出るのでしょう。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加