Spannerでインターリーブを用いてパフォーマンスを向上させる
この記事は、カウシェ Advent Calendar 2024の19日目の記事です。
はじめに
分散型データベースであるSpannerは、データをスプリットと呼ばれる単位に分割して保存します。各スプリットは独立しているため、データが物理的に異なる場所に配置されることがあります。そのため、2つのテーブルをJOINするクエリでは、複数のサーバにアクセスする必要があり、クエリのパフォーマンスに影響を与える可能性があります。
こうした課題に対処するために、Spannerではインターリーブという機能を提供しています。この機能を使うと、親子関係にあるデータを物理的に同じ場所に配置でき、クエリ性能を向上させることができます。
インターリーブ未使用時の実行計画
まずは、インターリーブを使用しない場合の実行計画を確認します。以下の例では、Singers(親テーブル)とAlbums(子テーブル)を用意しています。
CREATE TABLE Singers (
SingerId INT64 NOT NULL,
FirstName STRING(1024),
LastName STRING(1024),
SingerInfo BYTES(MAX),
) PRIMARY KEY (SingerId);
CREATE TABLE Albums (
SingerId INT64 NOT NULL,
AlbumId INT64 NOT NULL,
AlbumTitle STRING(MAX),
) PRIMARY KEY (SingerId, AlbumId);
次に、以下のクエリを実行します。このクエリはSingersとAlbumsをJOINして特定の歌手に関連する情報を取得するクエリを実行します。
SELECT
*
FROM
Singers AS s
INNER JOIN
Albums AS a ON s.SingerId = a.SingerId
WHERE
s.SingerId = 1;
このクエリの実行計画は以下です。Albums
を取得する際にDistributed Unionが発生します。Distributed Union
はサブプランを他のサーバに送信し実行するため、追加のレイテンシが生じます。
+-----+-----------------------------------------------------------------------------------------------+---------------+------------+---------------+
| ID | Query_Execution_Plan | Rows_Returned | Executions | Total_Latency |
+-----+-----------------------------------------------------------------------------------------------+---------------+------------+---------------+
| *0 | Distributed Union (distribution_table: Singers, split_ranges_aligned: false) | 11 | 1 | 18.14 msecs |
| 1 | +- Local Distributed Union | 11 | 1 | 18.13 msecs |
| 2 | +- Serialize Result | 11 | 1 | 18.13 msecs |
| 3 | +- Cross Apply | 11 | 1 | 18.12 msecs |
| 4 | +- [Input] Filter Scan (seekable_key_size: 0) | | | |
| *5 | | +- Table Scan (Table: Singers, scan_method: Scalar) | 1 | 1 | 1.55 msecs |
| *14 | +- [Map] Distributed Union (distribution_table: Albums, split_ranges_aligned: false) | 11 | 1 | 16.56 msecs |
| 15 | +- Local Distributed Union | 11 | 1 | 1.82 msecs |
| 16 | +- Filter Scan (seekable_key_size: 0) | | | |
| *17 | +- Table Scan (Table: Albums, scan_method: Scalar) | 11 | 1 | 1.81 msecs |
+-----+-----------------------------------------------------------------------------------------------+---------------+------------+---------------+
Predicates(identified by ID):
0: Split Range: ($SingerId = 1)
5: Seek Condition: ($SingerId = 1)
14: Split Range: ($SingerId_1 = 1)
17: Seek Condition: ($SingerId_1 = 1)
インターリーブを適用したクエリの実行計画
次に、AlbumsテーブルをSingersテーブルの子としてインターリーブします。
CREATE TABLE Singers (
SingerId INT64 NOT NULL,
FirstName STRING(1024),
LastName STRING(1024),
SingerInfo BYTES(MAX),
) PRIMARY KEY (SingerId);
CREATE TABLE Albums (
SingerId INT64 NOT NULL,
AlbumId INT64 NOT NULL,
AlbumTitle STRING(MAX),
) PRIMARY KEY (SingerId, AlbumId),
INTERLEAVE IN PARENT Singers ON DELETE CASCADE;
インターリーブを設定した状態でデータをインサートすると、親データの次に子のデータが保存されます。親と子のデータが同じスプリットに保存されるため、クエリ効率が向上します。
https://cloud.google.com/spanner/docs/schema-and-data-model
SingersとAlbumsをJOINするクエリの実行計画が以下になります。データが同じサーバーに格納されるため、Albums
を取得する際にDistributed Union
が発生しないことが確認できます。
+-----+--------------------------------------------------------------------------------------------------------+---------------+------------+---------------+
| ID | Query_Execution_Plan | Rows_Returned | Executions | Total_Latency |
+-----+--------------------------------------------------------------------------------------------------------+---------------+------------+---------------+
| *0 | Distributed Union (distribution_table: Singers, execution_method: Scalar, split_ranges_aligned: true) | 10 | 1 | 2.62 msecs |
| 1 | +- Local Distributed Union (execution_method: Scalar) | 10 | 1 | 2.6 msecs |
| 2 | +- Serialize Result (execution_method: Scalar) | 10 | 1 | 2.58 msecs |
| 3 | +- Cross Apply (execution_method: Scalar) | 10 | 1 | 2.57 msecs |
| 4 | +- [Input] Filter Scan (execution_method: Scalar, seekable_key_size: 0) | | | |
| *5 | | +- Table Scan (Table: Singers, execution_method: Scalar, scan_method: Scalar) | 1 | 1 | 2.45 msecs |
| 14 | +- [Map] Local Distributed Union (execution_method: Scalar) | 10 | 1 | 0.12 msecs |
| 15 | +- Filter Scan (execution_method: Scalar, seekable_key_size: 0) | | | |
| *16 | +- Table Scan (Table: Albums, execution_method: Scalar, scan_method: Scalar) | 10 | 1 | 0.11 msecs |
+-----+--------------------------------------------------------------------------------------------------------+---------------+------------+---------------+
Predicates(identified by ID):
0: Split Range: ($SingerId = 1)
5: Seek Condition: ($SingerId = 1)
16: Seek Condition: ($SingerId_1 = 1)
STRUCTオブジェクトを活用したデータ取得の最適化
JOINには取得するデータ量が子テーブルの件数になる、という問題があります。例えば、親テーブルに1件のデータがあり、子テーブルにその親に関連するデータが100件ある場合、JOINクエリでは100件分のデータが返されます。
ARRAY(SELECT AS STRUCT...)
という構文を使用することで、子テーブルのデータを配列として取得可能です。以下のクエリで、親と子のデータを一括して取得できます。
SELECT
*,
ARRAY(SELECT AS STRUCT * FROM Albums WHERE SingerId = s.SingerId) as Albums,
FROM
Singers AS s
WHERE
s.SingerId = 1;
クエリ結果例:
SingerId | FirstName | LastName | Albums |
---|---|---|---|
1 | Alice | Brown | [[1, 1, First Album], [1, 2, Second Album]] |
パフォーマンスについてもJOINを使うよりも、STRUCTのサブクエリを使った方がパフォーマンスが良いことが、こちらの記事で検証されています。
Goの構造体へのマッピング
GoではARRAY(SELECT AS STRUCT...)
で取得したデータは、ToStructメソッドを使用してGoの構造体に簡単にマッピングできます。
// 構造体定義
type Album struct {
SingerID int64
AlbumID int64
AlbumTitle string
}
type Singer struct {
SingerID int64
FirstName string
LastName string
SingerInfo []byte
Albums []*Album
}
stmt := spanner.Statement{
SQL: `
SELECT
*,
ARRAY(
SELECT AS STRUCT * FROM Albums WHERE SingerId = s.SingerId
) AS Albums
FROM Singers AS s
WHERE s.SingerId = @SingerId`,
Params: map[string]interface{}{
"SingerId": 1,
},
}
iter := client.Single().Query(ctx, stmt)
defer iter.Stop()
var singer Singer
for {
row, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
return err
}
// Goのstructにマッピング
if err := row.ToStruct(&singer); err != nil {
return err
}
}
fmt.Printf("Singer: %d, Name: %s %s\n", singer.SingerID, singer.FirstName, singer.LastName)
for _, album := range singer.Albums {
fmt.Printf(" Album: %d, Title: %s\n", album.AlbumID, album.AlbumTitle)
}
上記のコードを実行すると、以下の出力が得られます。
Singer: 1, Name: Alice Brown
Album: 1, Title: First Album
Album: 2, Title: Second Album
まとめ
SpannerのインターリーブとSTRUCTオブジェクトを活用することで、クエリのパフォーマンスとデータ効率を向上させることが可能です。データ設計時にこれらの機能を適切に取り入れることで、より効率的かつ高性能なデータベース運用が実現できます。
Spannerの特徴を理解し、パフォーマンスの高いシステム設計を目指していきたいと思います!
株式会社カウシェのProductチームによる技術ブログです。私たちと一緒に世界一楽しいショッピング体験をつくりませんか? enjoy-working.kauche.com/product
Discussion