BigQueryのMaterialized Viewを使う

2021年2月からBigQueryのMaterialized ViewがGA(一般提供)になりました。Materialized Viewを活用すると、大規模なテーブルのデータを効率的に集計できますが、実際に業務上で使うにあたって理解しておくべき特性や制約があったので、ここにまとめていきたいと思います。

# Materialized Viewとは
https://cloud.google.com/bigquery/docs/materialized-views-intro

Materialized Viewは**事前に計算されたView**です。

通常のViewはテーブルと同じ使い勝手でクエリーから参照できますが、テーブルと違い実体(実際に蓄積されたデータ)を持ちません。そのため、参照されるたびに定義されたクエリーが実行され、その分の時間とコストが発生してしまいます。

Materialized Viewも、Viewと同じようにクエリーを書いて定義できます。ただし、Materialized Viewは通常のViewと違って実体を持ちます。Materialized Viewの中身(クエリーの実行結果)は通常のテーブルと同じように保存されており、参照されるたびにそのクエリーが実行され再計算されるということはないため、通常のViewよりも低コスト・高速にデータを参照できるというメリットがあります。

さらに、Materialized Viewでは計算対象のデータに変化があった場合にも、[「新しく追加された分のデータだけ」が追加で処理される](https://cloud.google.com/bigquery/docs/materialized-views#incremental_updates)ため、パフォーマンス・コストの点でさらに有利であるという特徴があります。

多少イメージが掴みにくいかもしれないので、例で説明します。

# 簡単な例

| transaction_datetime | user_id |
|:-:|:-:|:-:|
| 2021-12-01 19:00:00 | 1 |
| 2021-12-01 20:30:00 | 2 |
| 2021-12-01 20:45:00 | 1 |

上のような広告の接触ログが蓄積されていくテーブルがあったとします。日々の広告のImpression数を集計するために、下記のようなクエリーを書くことにします。

```sql
SELECT 
DATE(transaction_datetime, "Asia/Tokyo") AS transaction_date, 
COUNT(1) AS imp 
FROM `ad_log` 
GROUP BY transaction_date 
ORDER BY transaction_date
```

| transaction_date | imp |
|:-:|:-:|
| 2021-12-01  | 100  | 
| 2021-12-02  | 80  | 
| 2021-12-03  | 120  | 

このクエリーは、実行されるたびにテーブル全体のデータが処理され、集計が行われます。後から遡って古い日付のログが追加されない限り、過去の日付の集計結果は毎回同じ結果になりますが、全ての日付に対して集計が行われてしまいます。テーブルの行数は日々広告が視聴される度に増えていくため、処理されるデータ量、処理に必要な時間・費用も日を追う毎に増加してしまいます。

毎日、前日の分のログのみを集計して、集計結果を保存していけば無駄な集計をなくすことができますが、バッチ処理を走らせるためのインフラを用意するのは何かと面倒です。

Materialized Viewはこういった場面で効果を発揮します。

```sql
CREATE MATERIALIZED VIEW project-id.my_dataset.daily_ad_imp
AS SELECT 
DATE(transaction_datetime, "Asia/Tokyo") AS transaction_date, 
COUNT(1) AS imp 
FROM `ad_log` 
GROUP BY transaction_date 
```

このクエリーからMaterialized Viewを作ってみます。Materialized Viewの作成は通常のViewの作成とほとんど変わりません。`CREATE VIEW`の代わりに`CREATE MATERIALIZED VIEW`を呼ぶだけで、Materialized Viewを作ることができます。

```sql
SELECT * FROM project-id.my_dataset.daily_ad_imp ORDER BY transaction_date
```

このMaterialized Viewを参照した結果は、上で書いた集計用クエリーの結果と同様になります。

| transaction_date | imp |
|:-:|:-:|
| 2021-12-01  | 100  | 
| 2021-12-02  | 80  | 
| 2021-12-03  | 120  | 

ただし、通常のViewと異なり、クエリー実行の度に元データのテーブル `ad_log` の中身が参照されるということはありません。集計結果はあらかじめMaterialized View `daily_ad_imp` の中にストアされているため、どれだけクエリーを叩いても集計の処理が走ることはなく、高速に結果を得ることができます。

ありがたいことに、**参照しているテーブル(ベーステーブル)の内容が更新されると、それを参照しているMaterialized Viewの内容もバックグラウンドで自動的に集計され直し更新されます。**しかも、パーティション分割されているMaterialized Viewでは新しく追加/変更されたパーティションの分の差分だけが集計され直す(増分更新)ため、非常に少ないコストで効率的に集計結果が更新される仕組みになっています。

このようにMaterialized Viewを使うと、バッチ更新などの仕組みを用意することなく、簡単に低コストで最新の集計結果を参照することができるようになります。BigQueryでは[スケジュールされたクエリ](https://cloud.google.com/bigquery/docs/scheduling-queries)を使って、定期的にテーブルの集計を行うことができますが、更新タイミングを気にしなくて良いことや増分更新による効率の良さ(コストの低さ)という点でMaterialized Viewを使える場面では積極的にMaterialized Viewを使用していきたいところです。

参考: [他の BigQuery 技術との比較](https://cloud.google.com/bigquery/docs/materialized-views-intro#comparison)

# Materialized Viewでできないこと
ただし、この便利なMaterialized Viewには色々と制限があり、実際に業務で利用しようとすると一筋縄ではいかないことが多い印象です。

## COUNT(DISTINCT)ができない
例えば、Materialized Viewでは`COUNT(1)`を使って日毎のインプレッション数の合計を足し上げていくことはできても、`COUNT(DISTINCT user_id)`を使って一日あたり何人のユーザに接触したかをカウントすることはできません。`APPROX_COUNT_DISTINCT`はMaterialized Viewでもサポートされていますが、誤差が発生してしまいます。

ベーステーブルに新しくデータが追加された時の更新処理を考えると、`SUM`や`COUNT(1)`は追加の差分に対して集計を行えば値の更新が可能ですが、`COUNT(DISTINCT)`の場合は全体を集計し直す必要が出てきてしまうため、Materialized Viewの更新の仕組みを考えると仕方がないように思えます。

このような場合では、ユーザ単位でグルーピングしたものをMaterialized Viewとして作成し、そのMaterialized Viewに対してさらに集計のクエリーをかけるというようなワークアラウンドが考えられます。

```sql
CREATE MATERIALIZED VIEW project-id.my_dataset.daily_user_ad_imp
AS SELECT 
DATE(transaction_datetime, "Asia/Tokyo") AS transaction_date, 
user_id,
COUNT(1) AS imp 
FROM `ad_log` 
GROUP BY transaction_date, user_id

SELECT transaction_date, count(distinct user_id) as uu, sum(imp) as imp 
FROM `project-id.my_dataset.daily_user_ad_imp`
ORDER BY transaction_date
```

このやり方では、ユーザの一日あたりの平均接触回数分しかテーブルのサイズを圧縮することができないため、Materialized Viewを使うことによる効果はかなり限定的になってしまいます。

```sql
SELECT transaction_date, count(distinct user_id) as uu, sum(imp) as imp 
FROM `project-id.my_dataset.daily_user_ad_imp`
ORDER BY transaction_date
```

このクエリーからさらに別のMaterialized Viewを作ることができれば良いのですが、残念ながら[Materialized ViewのベーステーブルにMaterialized Viewを指定することはできません](https://cloud.google.com/bigquery/docs/materialized-views-intro#limitations)。

## 複数のテーブルを参照したり、同じテーブルを複数回参照することはできない

Materialized Viewは参照しているテーブル(ベーステーブル)の更新に応じて、その集計結果が自動で更新されるものですが、ベーステーブルは一つしか指定をすることができません。

[Preview版ではINNER JOINがサポートされています](https://cloud.google.com/bigquery/docs/materialized-views#inner_joins)が、2021年12月現在のGAでは[A materialized view can reference only a single table, and cannot use joins.](https://cloud.google.com/bigquery/docs/materialized-views-intro#limitations)とされています。

運良く分析に必要な情報が一つのテーブルに記録されている場合は、Materialized Viewを使って効率的に集計することができますが、そうでない場合Materialized Viewで一部の情報のみを集約し、それをさらにViewやクエリーから参照して集約する、などの多段構成が必要になったり、場合によってはそもそもMaterialized Viewを使えないケースもあります。(JOINした情報に基づいた軸で集計したい場合など)

これも差分更新の仕組みを考えるとある程度仕方がない制約だとは理解できますが、Preview版では既にINNER JOINがサポートされるなど今後JOINがサポートされていきそうな兆候はあるため、期待したいところです。

## その他の制約

他にも、使用できる関数が限定されていたり作成できる個数が限られているなど、現在のMaterialized Viewには色々制約があります。

参考: [制限事項](https://cloud.google.com/bigquery/docs/materialized-views-intro#limitations)

# まとめ
Materialized Viewは繰り返し参照されるような集計データを生成したい際に非常に効率的で強力な仕組みです。今回は省略しましたが、[ソーステーブルに対するクエリまたはクエリの一部がマテリアライズド ビューへのクエリによって解決できる場合は、BigQuery によってマテリアライズド ビューを使用するようにクエリが書き換えられる](https://cloud.google.com/bigquery/docs/materialized-views-intro#overview)というすごい機能もあります。

しかし、少なくとも現時点ではまだ使用にあたって色々な制約があるため、実際に業務で利用しようとするとうまくいかない場面も多いです。ただ、JOIN対応をはじめ現在も様々な機能が活発に開発されアップデートされており、今後は少しずつ使える場面が増えていくと予想されます。Materialized Viewを本格的に活用できるようになると、集計に関するフローの設計が大きく変わり、データ更新のための処理/設定も不要になり管理コストの削減も期待できます。今後のアップデートに引き続き注目していきたいと思います。