【Google Cloud Spanner】LEFT JOINによる広範囲ロックを「APPLY_JOIN」で回避する

はじめに

この記事は、アソビュー Advent Calendar 2025の2日目(表面)です。
こんにちは。アソビューでバックエンドを担当している島田です。

Spannerを利用したRead-Writeトランザクションにおいて、ロック待機時間が発生しているがその原因がどこにあるのかが分からずに悩まされたことはないでしょうか?

私もそのうちの一人で、適切にインデックスが貼られたカラムを条件に検索を行っていたにもかかわらず、対象外のレコードに対するINSERTやUPDATEとロック競合が発生し、Lock Wait TimeoutやAbortedを引き起こす場面に遭遇しました。

調査の結果、SpannerのオプティマイザがLEFT JOINに対してHash Joinを選択し、結合先テーブルをフルスキャンしていたことで対象以外のレコードもロックしていることが判明しました。今回は、意図しないフルスキャンの原因と解決した事例を紹介します。

発生していた事象

Read-Write トランザクションにおいて、データの整合性を保つために、現在の状態取得と更新対象のレコードをロックする目的でSELECTを実行していました。

このクエリには以下の特徴がありました。

検索条件: UNIQUE INDEXを持つ一意な業務IDを指定
結合: 親テーブルと、それにぶら下がるインターリーブされた子テーブル群を結合
結合方式: 子データが存在しないケースを考慮し、LEFT JOINを使用

期待していた挙動としては、一意なIDで検索しているため、インデックスを使って「1レコード(およびその関連レコード)」だけがロックされると想定していました。しかし実際には、このクエリが 何十万件ものテーブル全体のレコードをロックしていました。この処理はアクセス頻度が高かったため、システム全体でロック待ちが多発する事態となりました。

実行クエリのイメージ
例として、特定のチケットIDを指定して現在のステータスを取得するクエリで解説していきます。

SELECT
    Tickets.TicketId,
    CASE
        WHEN CancelledTickets.CancelledTicketUid IS NOT NULL THEN "CANCELED"
        WHEN UsedTickets.UsedTicketUid IS NOT NULL THEN "USED"
        ELSE "VALID"
    END AS Status
FROM Tickets
    -- 親子関係(インターリーブ)にあるテーブルを LEFT JOIN
    LEFT JOIN UsedTickets USING (TicketUid)
    LEFT JOIN CancelledTickets USING (TicketUid)
WHERE
    Tickets.TicketId = 'TARGET-TICKET-001';

原因

問題のクエリに対してEXPLAIN ANALYZEを実行したところ想定とは異なる実行計画が生成されていました。

以下は実際の実行計画を抜粋して一部加工したものです。

| ID | Query_Execution_Plan                                                                                              | Rows_Returned | Total_Latency |
+----+-------------------------------------------------------------------------------------------------------------------+---------------+---------------+
|  0 | Serialize Result                                                                                                  | 1             | 1.7 secs      |
| *1 | +- Hash Join (execution_method: Row, join_type: BUILD_OUTER)                                                      | 1             | 1.21 secs      |
|  2 |    +- [Build] BloomFilterBuild                                                                                    |               |               |
| ...|       |                                                                                                           |               |               |
| *19|       +- Index Scan (Index: Tickets_By_TicketId, ...)                                                             | 1             | 0.16 msecs    |
| ...|                                                                                                                   |               |               |
| 140|    +- [Probe] Distributed Union (distribution_table: UsedTickets, ...)                                               | 0             | 274.88 msecs  |
| ...|          +- Filter Scan                                                                                           |               |               |
| 143|             +- Table Scan (Full scan: true, Table: UsedTickets, execution_method: Row, scan_method: Automatic)       | 0             | 385.52  msecs  |
| ...|                                                                                                                   |               |               |
1 rows in set (1.24 secs)
cpu time:             2.41 secs
rows scanned:         133543 rows

ID19をみると、Index Scan (Index: Tickets_By_TicketId) となっていてTicketsテーブル検索にはインデックスが使用されています。しかし、最終的に1行取得に対してrows scanned: 133543 rowsとなっていて意図していないレコードもスキャンされていることが分かります。

これらの元凶はID1にあるHash Joinです。Hash Join を成立させるために、Spannerは結合相手(Build側)であるUsedTicketsテーブルに対して、ID143にあるTable Scan(Full scan: true, Table: UsedTickets...) でフルスキャンをしています。たとえID 19 でチケットを1行に絞り込んでいても、結合方式がHash Joinである以上、Spannerはハッシュテーブルを作成するためにUsedTicketsテーブルの全データを読みに行きます。

本来は1行だけロックすればいいはずが、スキャンした全行に対して共有ロック(Reader Shared Lock)を取得した結果、全く無関係な他のチケットに対する更新処理と競合し、システム全体でロック待ちが多発していました。

なぜ1行なのにHash Joinが選ばれたのか?

公式ドキュメントにある通り、Hash Joinはセット単位の処理であり、大量のデータを一度に読み込んで、メモリ上で高速に突き合わせることに特化しています。

  • Hash Join: 全データをまとめて読み込むため、I/O効率が良く、大量データだと速い。
  • Apply Join: 一般的な RDBMS におけるNested Loop Joinに相当し、行ごとにディスクアクセス(Seek)が発生するため、大量データだと遅くなる。

オプティマイザは、統計情報などからこのクエリを一定以上の規模の結合と見積もり、Apply Joinで少しずつやるより、Hash Joinで一気にやった方がコストが低いと判断したようです。結果として、ハッシュテーブル作成のためのフルスキャンが選択されたと推察します。 実際今回問題となったクエリのJOIN数を減らして親テーブルとLEFT JOINしている子テーブルだけ結合してクエリを実行したところオプティマイザはApply Joinを選択しました。

@{JOIN_METHOD=APPLY_JOIN}のヒント句で解決する

少数レコード取得であれば、Hash JoinではなくApply Joinが圧倒的に有利です。オプティマイザにこれを強制させるため、@{JOIN_METHOD=APPLY_JOIN}ヒントを付与しました。

SELECT ...
FROM
    Tickets
    -- ヒント句を追加
    LEFT JOIN@{JOIN_METHOD=APPLY_JOIN} UsedTickets USING (...)
    LEFT JOIN@{JOIN_METHOD=APPLY_JOIN} CancelledTickets USING (...)
WHERE ...

修正後の実行計画(抜粋)

| ID | Query_Execution_Plan                                | Rows_Returned | Total_Latency |
+----+-----------------------------------------------------+---------------+---------------+
| *0 | Distributed Union ...                               | 1             | 11.82 msecs   |
| *1 | +- Distributed Cross Apply                          | 1             | 11.8 msecs    |
| ...|    +- Index Scan (Index: TicketsByTicketId)         | 1             | 0.13 msecs    |
| ...|    +- Table Scan (Table: UsedTickets)               | 0             | 1.38 msecs    |
1 rows in set (58.3 msecs)
cpu time:             50.86 msecs
rows scanned:         1 rows

rows scanned、Total_Latencyともに大幅に改善されました。

なぜこれで解決したのか?

@{JOIN_METHOD=APPLY_JOIN} ヒントを付与したことで、Spannerのオプティマイザに対してApply Joinを強制しました。

これにより、データアクセスの順序とロックの範囲が劇的に変化しました。解決のポイントは以下の2点です。

ロック範囲の極小化

Before (Hash Join): 結合相手のUsedTicketsテーブルをビルドするために、対象が1件であってもテーブル全体をフルスキャンする必要がありました。これにより、関係のない何十万件ものレコードまでロックしてしまっていました。

After (Apply Join): 親のTicketsテーブルから対象のID(1行)を取得し、そのキーを使ってUsedTicketsテーブルをピンポイントで参照(Seek)する挙動に変わりました。実行計画上のTable ScanからFull scan: trueが消えているのは、これが「全件走査」ではなく「特定のキーによるルックアップ」であることを示しています。必要な行しか触れないため、ロック競合が解消されました。

インターリーブとApply Joinの相乗効果

今回のテーブル設計では、親テーブルと子テーブルがインターリーブされていました。これは、親レコードの物理的な直下に子レコードが配置されることを意味します。

Apply Joinは、親の行ごとに子を探しに行く方式であるため、通常はランダムアクセスによるレイテンシが懸念されます。しかし、インターリーブされている場合、結合すべき子データが物理的に同じ場所(同じスプリット)に存在することが保証されます。

結果として、ネットワーク越しのデータ検索を最小限に抑えた高速な結合が可能となり、Hash Joinよりも遥かに低コストで処理を完了させることができました。

さいごに

日頃からロック待機が発生していないかを監視していることで早期発見ができました。今回のクエリ以外でもLEFT JOINをしている箇所については同様な事象が散見されるため、Spannerのオプティマイザに頼るのではなくヒント句を駆使して実行計画を見ながら意図通りになるか検証を行うことが重要だと感じています。

今回の記事が参考になると幸いです。


アソビューでは一緒に働くメンバーを大募集しています!カジュアル面談もありますので、少しでも興味があればお気軽にご応募いただければと思います!

www.asoview.co.jp