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

AIニュース最前線

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

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

MySQL から Spanner への移行時に実施したパフォーマンスチューニング

#データベース移行#分散システム#Spanner#MySQL#パフォーマンスチューニング
TL;DR

メルカリエンジニアリングは、MySQL から Spanner への移行に伴うクエリパフォーマンス問題の具体例と解決策を公開し、分散データベース特有のアーキテクチャ差異への対応重要性を示した。

AI深層分析2026年6月4日 22:29
3
注目/ 5段階
深度40%
4
関連度30%
2
実用性20%
5
革新性10%
3

キーポイント

1

MySQL と Spanner のアーキテクチャ根本的差異

MySQL は Read/Write を別インスタンスでスケール可能だが、Spanner はデータ分割(Split)による自動分散が基本であり、Read Replica による特定クエリ最適化や手動スケーリングが不可である点が決定的な違いとなる。

2

移行直後のパフォーマンスボトルネック

MySQL での実装をそのまま Spanner に移植した結果、Residual Condition の発生やソート処理のオーバーヘッドにより、実行計画が非効率になる深刻な問題が発生した。

3

アーキテクチャ適合型クエリ設計の必要性

単なる SQL 文法の変更ではなく、各 DB の分散・整合性保証メカニズムを踏まえたテーブル設計やインデックス戦略の見直しが必要であることが示された。

4

複合インデックスの設計

WHERE句やORDER BY句に頻出するカラム(IsActive, StartDateなど)を順序立てて配置した複合インデックスを作成し、検索性能を最適化しています。

5

自動更新タイムスタンプの活用

CreatedAtとUpdatedAtフィールドにallow_commit_timestampオプションを設定することで、アプリケーション側で時刻を管理せず、Spannerが自動的にコミット時のタイムスタンプを設定できるようにしています。

6

HotRow問題の解決策

高頻度アクセスによるホットスポットを防ぐため、JOINを排除してマスタテーブルをアプリ側メモリキャッシュし、クエリを単一テーブル処理へ簡素化した。

7

実行計画の最適化

ORDER BY を削除してアプリケーション側でソートを行うことで Seek Condition の利用を可能にし、NULL 値フィルタ条件を調整して不要な Residual Condition の発生を防いだ。

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

影響分析

この記事は、大規模システムにおけるデータベース移行プロジェクトの実践的な知見を提供しており、特に分散データベースへの移行を検討するエンジニアにとって、アーキテクチャの違いによる予期せぬボトルネックを回避するための重要な指針となる。単なる技術紹介ではなく、具体的なクエリ例と実行計画の分析を通じて、実務レベルでの設計思想の転換を促す内容となっている。

編集コメント

データベース移行プロジェクトにおいて、単なる「書き換え」ではなく「再設計」の重要性を具体的に示した貴重なケーススタディです。特に Spanner のような分散 DB では、既存の MySQL 発想からの脱却が性能維持の鍵となります。

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

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

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

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

MySQL のアーキテクチャ

image
image

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

Read は Read Replica(読み取り用レプリカ)を増やすことでもスケールさせることができます。

Read Replica は独立しているので、OLAP(オンライン分析処理)のような特定のクエリを特定の Instance で実行することもできます。

Spanner のアーキテクチャ

image
image

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

+-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

| 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

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 になります。

image
image

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を利用できるデータベースは増えていますが、インターフェースとして SQL が利用できても、内部のアーキテクチャはそれぞれ異なります。

Spanner は予算さえあれば気軽にノードを増やせるため、レイテンシや CPU 使用率に不満があれば、ノードを追加することで解決することも可能です。

しかし、コスト効率は良くないですし、ホットスポットのようにノードを増やしても解決できない問題もあります。

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

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

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

※1 FilterScan オペレータの Seek Condition と Residual Condition

Seek Condition はテーブルやインデックスのスキャン範囲の開始点と終了点が特定できている状態の時に使用されます。開始点から終了点まで読み込むだけなので、高速に動作します。

Residual Condition はテーブルやインデックスを読み込む際に開始点と終了点が特定できず、データを実際に読んでフィルタリングする必要がある場合に利用されます。Seek Condition と比べると CPU を多く消費し、スキャンするデータ量に応じてレイテンシも増加します。

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 で動作していました。まだドキュメントには反映されていないので、記事の中では使用していませんが、近々リリースノートが出るのでしょう。

原文を表示

こんにちは、いつも心に冪等性 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が必要など非常に厳しい状態です。

code
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
code
+-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| 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

code
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)

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

code
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
code
+----+-----------------------------------------------------------------------------------------------------------------------------------------+
| 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で動作していました。まだ、ドキュメントには反映されてないので、記事の中では使っていませんが、近々リリースノートが出るのでしょう。

この記事をシェア

関連記事

Mercari Engineering★32026年3月23日 18:00

TiDBのDM使用中に安全にDDLを実行する方法

メルカリがMySQLからTiDBへの移行中に、DMツールを使用して差分同期を行いながら、安全にDDLを実行する方法を紹介している。

CyberAgent Developers Blog★32026年2月25日 08:42

AmebaブログにおけるDynamoDBからMySQLへのストレージ移行の取り組み

AmebaブログがDynamoDBからMySQLへのデータベース移行を実施し、その過程と技術的課題について解説しています。

Amazon Science★42026年5月28日 19:30

AWS データセンターネットワークにおける「フラット構造」が「ファットツリー」を代替する理由

Amazon Science は、従来の階層型データ構造である「ファットツリー」に代わり、より効率的なルーティングを実現する「フラット構造」の導入について解説している。この技術は、AWS のデータセンターネットワークのパフォーマンス向上に寄与する可能性がある。

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