BigQueryを始めるときに知っておきたいこと

# はじめに
この度BigQueryを使う機会があり、その際調べたことをまとめてみました。
主に公式のドキュメントと、BigQuery Admin reference guideからの抜粋です。
RDSはある程度触っていて、これからBigQueryを使おうという方向けです。

# 【知識編】BigQueryの仕組みについて知っておきたいこと
筆者は多少のRDBの利用経験があったので「ふむふむ標準SQL使えるのね、RDBと似たような感じかな」くらいに思っていたのですが実際は全然違いました、すいません。

## BigQueryはカラムストレージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/220881/82cfba7d-9427-e74e-6e3b-5278e11765f8.png)
参考：[BigQuery Admin reference guide: Storage internals](https://cloud.google.com/blog/topics/developers-practitioners/bigquery-admin-reference-guide-storage?utm_source=youtube&utm_medium=unpaidsoc&utm_campaign=CDR_ali_databases_0hd23gnz1be_BigQuerySpotlight_072221&utm_content=description)

BigQueryを扱う上でまず押さえたいのはデータの保存方法です。上図の通り、

- 個々のカラムのデータは別々のファイルブロックに保存される
- クエリが参照するカラムの数が実行時に参照されるファイルの数に比例する

という特徴があります。これはクエリのパフォーマンス、コストを考えるうえでとても重要なことです。

## Queryは分割され、並列処理される
JobとSlotという概念があります。SlotとはCPU、メモリ、ネットワークなどの演算に必要なリソースの単位です。

- BigQueryに対する操作（Load, Query, Copy, Export）が行われると、Jobが作成される
- Jobは非同期、並列に実行される
- 各Jobにはslotと呼ばれる演算単位が割り当てられる
- オンデマンド課金ではプロジェクトごとに 2,000 slot が割り当てられている

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/220881/4503fdfc-1bfa-91de-a930-1eb23a60a81c.png)
参考：[BigQuery Admin reference guide: Jobs & reservation model](https://cloud.google.com/blog/topics/developers-practitioners/bigquery-admin-reference-guide-jobs-reservation-model)

Jobはさらに小さいステージに分割されて処理されます。

- QueryはBigQueryのQuery Engin `Dremel` によって処理される
- `Dremel` はワーカーのクラスター
- Queryは小さなステージに分解されてワーカーによって並列に処理される
- ワーカーが作成する中間データは `memory shuffle` に保持される
- ステージ数に対してワーカー（Slot）数が不足するとクエリが遅くなる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/220881/cb8c7346-f655-ae9c-83bc-1167d73e9c28.png)
参考：[BigQuery Admin reference guide: Query processing](https://cloud.google.com/blog/topics/developers-practitioners/bigquery-admin-reference-guide-query-processing)


`date` カラムを持つ `sample_table` テーブルから特定の期間のレコードの件数を取得する以下のクエリの実行結果を見てみます。

```SQL
SELECT
  count(date)
FROM 
  `test_dataset.sample_table`
WHERE
  date BETWEEN '2021-01-01' AND '2021-08-30'
;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/220881/a60bab19-f9a4-067c-87e0-b26c00a0eb9a.png)

赤枠でマークしたところを確認してください。
最初のステージ （`S00`）では入力が約300万レコード、出力が「`9`」となっています。次のステージ （`S01`）では入力が「`9`」、出力が「`1`」となっていますね。先ほどの説明と照らし合わせると以下のことが起こっています。

1. WHERE句の絞り込み条件に該当する約300万レコードのファイルが、9個のワーカーによって並行にreadされる（おそらく9つの別々のファイルブロックに格納されているのでしょう）。
1. それぞれのワーカーが自分がreadしたレコードの数をcountして、memory shufullにwtire（`S00`の出力`9`）
1. すべてのワーカーの書き込みが終わったら次のステージ（`S01`）が開始して、（`S00`）でwriteされた結果をmemory shufullからread（`S01`の入力`9`）
1. 前のステージで独自に計算された9つのcountをすべて合計して、最終結果としてwrite（`S01`の出力`1`）


## 実行の詳細と統計情報を見よう
前項でも触れた実行の詳細では各ステージが何にどのくらい時間を使ったかを確認でき、スロークエリを分析するときにヒントになりそうです。

- Wait
    - ワーカーが空くのを待っている
    - 依存関係がある他のステージが終わるのを待っている
        - 　→　Slot数を増やす
- Read
    - 参照先のテーブル、もしくは前のステージで書き込まれたmemory shufullからデータを読み込んでいる
        - 　→　limit や 絞り込みで読み込むファイルの数を減らす（参照するデータを減らす）
- Compute Phase
    - 演算している
        - →　近似関数を利用する、コストが高い文字列操作などを調整する
- Write
    - memory shufull化永続ストレージ（ユーザに結果を返すために書き込むストレージ）に書き込んでいる
        - →　クエリで取得するデータを減らす

また、`INFORMATION SCHEMA` を参照することでより詳細な実行の結果を確認できます。
たとえば `JOBS_BY_PROJECT` から特定のアカウントが実行したクエリの概要を取得する場合は以下のようになります。
（`region` は東京なら `region-asia-northeast1` です。）

```SQL
SELECT 
  job_id
  ,cache_hit
  ,creation_time
  ,start_time
  ,query
  ,referenced_tables
  ,total_bytes_processed
  ,total_slot_ms
  ,timeline
  ,job_stages
FROM
  `your_project`.`project_region`.INFORMATION_SCHEMA.JOBS_BY_PROJECT
WHERE
  user_email = 'your_account_mail'
ORDER BY 
  start_time DESC
;
```

# 【実践編】テーブルの設計やクエリ実行時に知っておきたいこと 
BigQueryに対してQueryを実行した場合、読み込まれたデータの量に対して課金されます。また、読み込まれるデータが多くなると演算や途中の書き込みにかかる時間も多くなり結果的にクエリが遅くなります。UX的にもお財布的に **もとにかく早い段階で取り扱うデータの量を絞ることが大事** と思われます。

## 参照するカラムを絞る
基本中の基本ですが効果が大きいです。最初にふれたとおりBigQueryはカラムごとに別々のファイルにデータを保存しているので、参照するカラムが増えると読み込むファイルが増えます。逆に、必要なカラムだけを読み込みことでデータ量を抑えることができます。

以下の2つのクエリは公開されているデータセット `bigquery-public-data.covid19_open_data.covid19_open_data` を参照しています。こちらのテーブルは記事執筆時点では700以上のカラムがありますので、実際にBiqQueryのコンソールにクエリを添付すると参照されるデータ量が大きく変わることが確認できると思います。
（※実際にクエリを実行する際はデータ使用量にご注意ください）

- 悪い例

```SQL
SELECT * FROM `bigquery-public-data.covid19_open_data.covid19_open_data` 
WHERE location_key = 'JP_13'
  AND date BETWEEN '2021-01-01' AND '2021-08-30'
ORDER BY date DESC;
```

- 良い例

```SQL
SELECT
  location_key
  ,date
  ,country_name
  ,subregion1_name
  ,new_confirmed
  ,new_deceased
  ,cumulative_confirmed
  ,cumulative_deceased
  ,population
FROM `bigquery-public-data.covid19_open_data.covid19_open_data` 
WHERE location_key = 'JP_13'
  AND date BETWEEN '2021-01-01' AND '2021-08-30'
ORDER BY date DESC;
```

## テーブル分割（Pertitioning）で絞る
BigQueryでもテーブル分割機能がサポートされています。
分割キーとして指定できるカラムのデータ型は以下の通りです。

-  時間単位の列: TIMESTAMP、DATE、または DATETIME 列による分割
    -  日別、時間別、月別、年別
-  取り込み時間: BigQuery がデータを取り込む際のタイムスタンプに基づいた分割
-  整数範囲: 整数列


下図の右のテーブルでは`Creation_date`カラムによる分割が行われています。左右のテーブルにそれぞれ
`WHERE Creation_date = '20180302'`
の絞り込み条件を含む `SELECT` 文を実行した場合、左のテーブルでは全件がreadされますが右のテーブルでは薄緑の範囲のレコードしかreadされません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/220881/fd745555-d373-fc41-7239-e7650d675f73.png)
参考：[BigQuery Admin reference guide: Storage internals](https://cloud.google.com/blog/topics/developers-practitioners/bigquery-admin-reference-guide-storage?utm_source=youtube&utm_medium=unpaidsoc&utm_campaign=CDR_ali_databases_0hd23gnz1be_BigQuerySpotlight_072221&utm_content=description)

その他の以下のような特徴があります。

- Pertitionの有効期限を設定することで自動でデータを削除できる（オプション,  整数範囲以外）
- ユーザに対して、クエリでの分割キーの指定を強制できる（オプション）
- 分割テーブルは 4,000 個まで作成できる
- 分割によってできるテーブルのサイズがおおよそ1GBを下回ると、分割テーブルの管理コストがメリットを上回る

公開されているデータセットから分割テーブルを作成し、動きを確認するためのSQLを載せておきます。
（※実際にクエリを実行する際はデータ使用量にご注意ください）

テーブルを作成するとき、 `AS SELECT` を利用することで選択された結果を作成されたテーブルに `INSERT` できます。
もちろん、空のテーブルを作るだけの `CREATE TABLE` 文で分割を指定することもできます。

```SQL
-- dateカラムを月ごとに分割するテーブルを作成する
CREATE TABLE
  my_dataset_001.covid19_open_data_pertitioned_by_month (
    location_key STRING
    ,date DATE
    ,country_name STRING
    ,subregion1_name STRING
    ,new_confirmed INTEGER
    ,new_deceased INTEGER
    ,cumulative_confirmed INTEGER
    ,cumulative_deceased INTEGER
    ,population INTEGER
  )
PARTITION BY
  DATE_TRUNC(date, MONTH)
AS SELECT
  location_key
  ,date
  ,country_name
  ,subregion1_name
  ,new_confirmed
  ,new_deceased
  ,cumulative_confirmed
  ,cumulative_deceased
  ,population
FROM
  `bigquery-public-data.covid19_open_data.covid19_open_data`
;
```

```SQL
-- 分割テーブルから取得する
SELECT
  location_key
  ,date
  ,country_name
  ,subregion1_name
  ,new_confirmed
  ,new_deceased
  ,cumulative_confirmed
  ,cumulative_deceased
  ,population
FROM `my_dataset_001.covid19_open_data_pertitioned_by_month`
WHERE location_key = 'JP_13'
  AND date BETWEEN '2021-01-01' AND '2021-08-30'
ORDER BY date DESC;
```

## クラスタリング（Clustering）で行を絞る
指定したカラムでデータをあらかじめソートしておく機能です。
BigQueryにはRDSのindexに相当する概念がありませし、前項の `Partitioning` では利用できるカラムのデータ型に制限がありました。
RDSでいうところの「主キーを使って高速にデータにアクセスしたい」というときにはクラスタリングを利用できそうです。

下図の右のテーブルでは`Creation_date`カラムによる分割に加えて、 `Tags` カラムによるクラスタリングが行われています。左右のテーブルにそれぞれ
`WHERE Creation_date = '20180302' AND Tags = 'Android'`
の絞り込み条件を含む `SELECT` 文を実行した場合、左のテーブルでは全件がreadされますが右のテーブルでは薄緑の範囲の最初の3レコードしかreadされません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/220881/0c516c8e-b9c3-5db8-ed90-8decb8903b75.png)
参考：[BigQuery Admin reference guide: Storage internals](https://cloud.google.com/blog/topics/developers-practitioners/bigquery-admin-reference-guide-storage?utm_source=youtube&utm_medium=unpaidsoc&utm_campaign=CDR_ali_databases_0hd23gnz1be_BigQuerySpotlight_072221&utm_content=description)

その他の以下のような特徴があります。

- フィルター条件にクラスター化されたカラムを指定することで捜査対象を絞ることができる
- カーディナリティが大きいカラム（群）に特に有効
- Sortは自動で行われる
- カラムの指定の順序がソートの順番を決める
- クラスタ化されたテーブルに対するクエリはプレビューが効かなくなる
- パーティションと併用可能
- 既存のテーブルのカラムに後からクラスター化を設定できるが、それまであったデータはSortされない

公開されているデータセットからクラスター化したテーブルを作成し、動きを確認するためのSQLを載せておきます。
（※実際にクエリを実行する際はデータ使用量にご注意ください）

```SQL
-- クラスター化テーブルを作成する
CREATE TABLE
  my_dataset_001.covid19_open_data_cluster_by_key_date (
    location_key STRING
    ,date DATE
    ,country_name STRING
    ,subregion1_name STRING
    ,new_confirmed INTEGER
    ,new_deceased INTEGER
    ,cumulative_confirmed INTEGER
    ,cumulative_deceased INTEGER
    ,population INTEGER
  )
CLUSTER BY
  location_key
  ,date
AS SELECT
  location_key
  ,date
  ,country_name
  ,subregion1_name
  ,new_confirmed
  ,new_deceased
  ,cumulative_confirmed
  ,cumulative_deceased
  ,population
FROM
  `bigquery-public-data.covid19_open_data.covid19_open_data`
;
```

複数のカラムを指定してクラスター化する場合、絞り込み条件を指定するときには注意が必要です。
上記のクエリでは `CLUSTER BY` 句の中で `location_key` -> `date` の順番でカラムを指定しています。
この場合、2つ目のカラム（ `date` ）だけを指定するとクラスター化が効きません。

```SQL
-- クラスター化が効く（読み込まれるデータ：25.5MB）
SELECT * FROM `my-project-1562073507650.my_dataset_001.covid19_open_data_cluster_by_key_date`
WHERE location_key = 'JP_13'
; 

-- クラスター化が効かない（読み込まれるデータ：880.1 MB）
SELECT * FROM `my-project-1562073507650.my_dataset_001.covid19_open_data_cluster_by_key_date`
WHERE date = '2021-04-01'
;
```

## ワイルドカードテーブルで絞る
BigQueryのドキュメントによると、テーブル分割は[シャーディング](https://cloud.google.com/bigquery/docs/partitioned-tables?hl=ja#dt_partition_shard)よりも効果的で、シャーディングは控えるべきとあります。
しかし、テーブル分割で指定できるカラムの型には制限があり、たとえばPostgreSQLではできた文字列カラムによる分割ができません。
そのような場合には `sample_table_hoge`, `sample_table_fuga` といったシャーディングも選択肢に入りますが、この2つのテーブルのどちらか、あるいは両方を参照したいときに少し困ります。シャーディングテーブルの数が決まっていて名前が既知であればすべて列挙して`UNION`する方法がありますが、この手のテーブルは動的に増えていく場合が多いと思います。

そういう場合にはワイルドカードテーブルが利用できます。
以下のクエリは`sample_table_hoge`, `sample_table_fuga`の全件を返します。
（`sample_table_hoge`, `sample_table_fuga`は同じdataset内にある必要があります）。

```SQL
SELECT
  *
FROM
  sample_table_*
;
```

また、以下のクエリは`sample_table_hoge`の全件を返します。

```SQL
SELECT
  *
FROM
  sample_table_*
WHERE
  _TABLE_SUFFIX = 'hoge'
;
```
`_TABLE_SUFFIX` を利用して各テーブル名の `*` に該当する部分を利用してフィルターをかけることが可能です。
`_TABLE_SUFFIX` はワイルドカードテーブルを利用したクエリの中で利用可能で、あらかじめテーブルにこのようなカラムを作る必要はありません。
1点注意なのはワイルドカードテーブルでヒットする各テーブルは **スキーマが共通である** 必要があり、これを満たしていないとクエリがエラーになります。`user_hoge`, `user_fuga`といったシャーディングとワイルドカードテーブルクエリを利用ているときに `user_detail` みたいな別スキーマのテーブルを同じデータセットに追加してしまうとクエリが通らなくなります。