ゲームサービス事業本部の勝倉です。
Cloud Spanner には、大量のデータを操作するための仕組みとして Partitioned DML (以下 PDML) が提供されています。
しかし、ドキュメントの記述からだけでは読み取りづらい「アンチパターン」が存在しており、実装によってはパフォーマンスが出せない場合があります。
この記事では、PDML を利用して大量のデータを操作する際の注意点を紹介します。
要旨
最初に、この記事の要点をまとめます:
- 「キー空間を絞った PDML を並列に実行する」ようなアプローチは機能しない。
- (理由)
- PDML は WHERE 条件によらず、テーブルのキー空間全体に対してパーティションを作成する。つまり、WHERE 条件でキー範囲を絞り込んでも発行される「小さなトランザクション」はフルスキャンの場合と変わらない。
- DB 全体にわたる PDML の実行管理をするコンポーネントの存在が示唆されるため、トランザクション数が多くなるとオーバーヘッドが無視できない量になる。
Partitioned DML について
サービスを運用していると、不要になったデータのクリーンアップや新たに追加したカラムのバックフィルが必要になる場面が発生します。
最もナイーブにアプローチすると、Read Write Transaction で DML を発行する形になるのですが、テーブル全体に対する操作の場合、テーブル全体をスキャンすることによるロックの懸念や、そもそも ミューテーション上限 によって実行できないケースも考えられます。
こうした煩雑さを Spanner 側で「よしなに」対応してくれる仕組みが PDML になります。 具体的には、
- 発行したステートメントを、Spanner 側でキー空間を分割してパーティションを作成し、パーティションごとに個別の小さなトランザクションとして実行してくれる。
- WHERE 条件と一致した行のみがロックされるようになる。
- ステートメントが失敗した場合、パーティションごとにリトライを試行してくれる。
といったメリットを享受することができます。
一方で、
- ステートメントは fully partitionable であることが必要。
- ステートメントは冪等であることが必要。
- ステートメント全体についてのアトミック性は保証されない (パーティション単位では保証される)。
など、実行に関して一定の制約がかかりますが、これらの制約を受け入れられる場合は大規模なオペレーションにおける有力な選択肢となります。
Partitioned DML で実現したかったこと
ゲームサーバーのデータベースとして、次のようなテーブルを考えます:
CREATE TABLE Players (
PlayerId STRING (MAX) NOT NULL,
Nickname STRING (MAX) NOT NULL,
Status INT64 NOT NULL,
) PRIMARY KEY (PlayerId);
CREATE TABLE Inventories (
PlayerId STRING (MAX) NOT NULL,
InventoryId INT64 NOT NULL,
ItemId STRING (MAX) NOT NULL,
Quantity INT64 NOT NULL,
-- some other 10 columns
) PRIMARY KEY (PlayerId, InventoryId),
INTERLEAVE IN PARENT Players ON DELETE CASCADE;
Players
テーブルは、ユーザーのアイデンティティを表します。
Players.Status
は、ユーザーの状態を表し、値としては
- 1: 通常ユーザー
- 2: サスペンドされたユーザー
- 3: 退会済みのユーザー
が格納されているものとします。
Inventories
テーブルは、ユーザーの所有アイテムを表します。
1 Player あたり、平均 1000±500 レコードを含み、かつ STORING INDEX が 3つ 作成されている状況だとします。
今回は「Player.Status=3
のユーザーの Inventories
レコードを削除したい」というケースを考えます (Parent レコードを消すことでの CASCADE DELETE は諸般の事情により不可とします)。
通常の DML であれば、たとえば以下のようなクエリになると思います:
DELETE FROM
Inventories i
WHERE
EXISTS(
SELECT
*
FROM
Players p
WHERE
i.PlayerId = p.PlayerId
AND
p.Status = 3
)
;
このクエリは、ミューテーション上限の制約によって単一の読み書きトランザクションでは実行できません。(Inventories
テーブルのフルスキャンになるため、広域ロックを長い時間保持する懸念もあります)
また、Inventories
と Players
の複数テーブルにアクセスするため、fully partitionable の要件を満たせず、PDML も実行できません。
実現アイディアの 1 つとして、以下のような対応が考えられます。
- 読み取りトランザクションで
Status=3
である PlayerID を SELECT する。 - PDML で WHERE 句に PlayerID を指定する。
-- 対象を抽出
SELECT
PlayerId
FROM
Players
WHERE
Status = 3
;
-- 削除は PDML で実行
DELETE FROM
Inventories
WHERE
PlayerId IN (...)
Spanner は クエリのパラメーターの上限が 950 に制約されているため 、複数回に分けて DELETE 文を発行することになりますが、アプリケーションコードでの並列化は容易です。
一見すると、このアプローチはロックレンジも限定的で、ミューテーション上限を気にする必要もなく、削除のペースをアプリケーション側でコントロールすることもできるので、悪くないように思えます。
なにが起ころるのか
DELETE 文の実行並列度が小さいうちは、想定通り並列度に比例する形で削除スピードもリソース使用量も変化します。
ところが、一定の並列度まで上げると1つの DELETE 文にかかる時間が長くなり、削除スピードが頭打ちとなります。
再現環境では、200並列程度まで PDML の DELETE 文の同時実行数を上げると、DELETE 文の実行時間が2倍以上に伸びました。
CPU 使用量は増えていないことが確認されており、リソース不足の可能性は低いと見ています。
ロック待ち時間の指標は大きく上昇しましたが、Spanner では、ロック待ち時間の指標が原因ではなく結果として現れる場合も少なくないため、PDML の処理スピードが上がらない事象と直接関係があるのか、まではわかりませんでした。
ちなみに、PDML 側の処理速度が低下している状況においても、API サーバーなどから発行される別の DML にはパフォーマンス影響はありませんでした。
なにがまずいのか
最初は、PDML をパーティションに区切る際には検索条件を加味した上で必要十分なリモートサーバーにディスパッチされるような動きをイメージしていました (下図)。

Google Cloud 様と公式ドキュメントを基に、この解釈に関してご相談させていただいたところ、どうもメンタルモデルに誤りがありそうなことがわかりました。 実際には、「WHERE 条件によらず、全てのスプリットを網羅するようにパーティションが切られリモートサーバーにディスパッチされる」というイメージの方が、モデルとしては妥当なようです。

Spanner 側では 小トランザクションの同時実行数が 20,000 までに収まるように制御されます 。このことから、DB 全体における PDML の実行を管理をしている機構の存在が示唆されます。
最初に想定していた実行機序であれば DELETE 文ごとに数パーティション程度になる見込みだったため 20,000 というオーダーに対して並列実行した PDML のトランザクション数が問題にならないだろうことを期待していましたが、
上記の「正しいメンタルモデル」を前提にすると、実際には「何も処理しないパーティション」も多く発生することにより DB 全体の PDML の実行を制御する部分でのオーバーヘッドがボトルネックになってしまうと考えられます。
この想定が正しいとすると、「ある一定の並列度を超えると問題が顕在化する」という観測事実にも説明がつき、矛盾しません。。
考えられる代替案
1つは、DELETE 文を PDML ではなく通常の DML として実行する方法があります。
Spanner にはクエリ優先度の概念があるため、API サーバーから発行されるクエリよりも優先度を下げておけば、クリーンアップ処理がユーザー体験に影響を与えるリスクも抑えることが可能です。
ミューテーション上限の制約に関しては実装側で考慮する必要がありますが、ナイーブに書き込みトランザクションを分割することでも現実的に対応可能な範疇と思われます。
もう1つは batch write API を利用する方法です。Mutation の塊を一括で Spanner に送りつけて処理させることができます。(2025年08月04日時点では、プレビュー機能)
(おまけ) “partitionable” について
Spanner にはテーブル全体にわたる処理に対するインターフェースとして Partitioned DML
および Partition Query
が提供されています。
Partitioned DML
はデータの更新操作のためのインターフェース。Partition Query
はデータの読み取りのためのインターフェース。
「Spanner 側で処理を並列化してくれる」という点においては類似していますが、実行のための要件は、
- Partitioned DML の実行条件は DML ステートメントが
fully partitionable
であること。 - Partition Query の実行条件は クエリが
root partitionable
であること。
と、若干異なっています。
fully partitionable
https://cloud.google.com/spanner/docs/dml-partitioned#partitionable-idempotent
the partitioned DML statement must be expressible as the union of a set of statements, where each statement accesses a single row of the table and each statement accesses no other tables
「単一テーブルの単一行だけにアクセスするステートメントに分割可能でなければならない」ということになります。
たとえば
DELETE FROM
Inventories i
WHERE
EXISTS (
SELECT * FROM Players p WHERE i.PlayerId=p.PlayerId AND Status=3
)
は fully partitionable ではありません (Inventories と Players の両方にアクセスしているため)。
root partitionable
https://cloud.google.com/spanner/docs/reads#read_data_in_parallel
For a query to be root-partitionable, the query plan must satisfy one of the following conditions:
- The first operator in the query execution plan is a distributed union and the query execution plan only contains one distributed union (excluding Local Distribution Unions). Your query plan can’t contain any other distributed operators, such as distributed cross apply .
- There are no distributed operators in the query plan.
「クエリに含まれる分散演算子が分散ユニオンただ1つで、かつそれが最初の演算子である」か「分散演算子が1つも含まれない」かのどちらか、ということになります。
たとえば、先ほど fully partitionable ではなかった DELETE 文と同じ検索条件になるようにした以下のクエリは root partitionable です。
SELECT
*
FROM
Inventories i
WHERE
EXISTS (
SELECT * FROM Players p WHERE i.PlayerId=p.PlayerId AND Status=3
)
ただし、Inventories を Players に interleave していない場合は root partitionable ではありません。
実行計画を比べてみると、
interleave してないとき
+-----+--------------------------------------------------------------------------------------------------------------------+
| ID | Query_Execution_Plan |
+-----+--------------------------------------------------------------------------------------------------------------------+
| 0 | Distributed Union (distribution_table: Inventories, execution_method: Row, split_ranges_aligned: false) |
| 1 | +- Serialize Result (execution_method: Row) |
| *2 | +- Distributed Semi Apply (execution_method: Row) |
| 3 | +- [Input] Create Batch (execution_method: Row) |
| 4 | | +- Local Distributed Union (execution_method: Row) |
| 5 | | +- Compute Struct (execution_method: Row) |
| 6 | | +- Table Scan (Full scan: true, Table: Inventories, execution_method: Row, scan_method: Automatic) |
| 17 | +- [Map] Semi Apply (execution_method: Row) |
| 18 | +- [Input] KeyRangeAccumulator (execution_method: Row) |
| 19 | | +- Batch Scan (Batch: $v2, execution_method: Row, scan_method: Row) |
| 25 | +- [Map] Local Distributed Union (execution_method: Row) |
| *26 | +- Filter Scan (execution_method: Row, seekable_key_size: 0) |
| *27 | +- Table Scan (Table: Players, execution_method: Row, scan_method: Row) |
+-----+--------------------------------------------------------------------------------------------------------------------+
Interleave しているとき
+-----+---------------------------------------------------------------------------------------------------------------------+
| ID | Query_Execution_Plan |
+-----+---------------------------------------------------------------------------------------------------------------------+
| 0 | Distributed Union (distribution_table: Players, execution_method: Row, split_ranges_aligned: true) |
| 1 | +- Local Distributed Union (execution_method: Row) |
| 2 | +- Serialize Result (execution_method: Row) |
| 3 | +- Semi Apply (execution_method: Row) |
| 4 | +- [Input] Table Scan (Full scan: true, Table: Inventories, execution_method: Row, scan_method: Automatic) |
| 9 | +- [Map] Local Distributed Union (execution_method: Row) |
| *10 | +- Filter Scan (execution_method: Row, seekable_key_size: 0) |
| *11 | +- Table Scan (Table: Players, execution_method: Row, scan_method: Row) |
+-----+---------------------------------------------------------------------------------------------------------------------+
Interleave していない場合、Semi Apply
だったところが Distributed Semi Apply
になっているため、
クエリに含まれる分散演算子が分散ユニオンただ1つ
に反することがわかります。

開発事業部
第二技術部
サーバー第三グループ
グループリーダー
最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。