🐧

Spannerでインターリーブを用いてパフォーマンスを向上させる

2024/12/19に公開

この記事は、カウシェ 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
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の特徴を理解し、パフォーマンスの高いシステム設計を目指していきたいと思います!

カウシェ Tech Blog

Discussion