ブログのしゅーくりーむ

技術的なメモとかライフログとか。

Avro と BigQuery の load とうまく付き合いたい

Avro と BigQuery の読み込み

Apache Avro は BigQuery のデータ読み込みに対応したシリアライゼーションフォーマットであり、 Object Container Files フォーマットを採用することでスキーマが自己記述的になり読み込みに際して別途スキーマ情報を与えなくて済むメリットがあります。 また BigQuery としては Avro (を含めたいくつかの形式) では平行読み込みが可能とされ、それができない形式、たとえば gzip 圧縮された JSONL 形式などと比較して早く読み込めるようです。

cloud.google.com

加えて、実は並行読み込みが可能とされてかつ効率的な圧縮が期待できる Avro, Parquet, ORC の中にも読み込み処理において優劣があるようです。 Google BigQuery: The Definitive Guide によると "The most efficient expressive format is Avro" とあり、列志向で圧縮もかかる Avro が最も効率的であるとされています。 対して Parquet や ORC は行志向であり、これはこれで外部デーブル経由でファイルに直接クエリする分には効率的なものの、 BigQuery に読み込む際には全列読まなければならない分 Avro が有利なようです。

そんないい感じっぽい Avro の BigQuery へのデータ読み込み、本記事ではスキーマ周りについていくつか動作を確認してみようと思います。

BigQuery にさまざまなスキーマの Avro ファイルを読ませてみる

互換があるスキーマで追記する場合

まずは replace をせず単純に BigQuery のテーブルにレコードを追加して行こうと思います。 とりあえず適当に 1 カラムだけある空テーブルを作っておきます。

f:id:syu_cream:20200526231738p:plain

まずはこれにマッチする単純な Avro ファイルを作って load してみます。

$ cat user_v1.avsc
{
  "name": "User",
  "type": "record",
  "fields": [
    {
      "name": "id",
      "type": "long"
    }
  ]
}
$ java -jar ~/tools/avro-tools-1.8.2.jar random --schema-file user_v1.avsc --count 1 user_v1.avro 2>/dev/null
$ bq load --project_id syucream-dev --source_format AVRO syucream-dev:test_syucream.user user_v1.avro
Upload complete.
Waiting on bqjob_r750d0087bb28a293_00000172515bed9d_1 ... (1s) Current status: DONE
$ bq query --nouse_legacy_sql 'SELECT * FROM syucream-dev.test_syucream.user'
Waiting on bqjob_r7bab3c778b4c6a7d_00000172515c7520_1 ... (0s) Current status: DONE
+---------------------+
|         id          |
+---------------------+
| 7190660540979993749 |
+---------------------+

サクッとできました。追記もサクッとできます。

$ bq load --project_id syucream-dev --source_format AVRO syucream-dev:test_syucream.user user_v1.avro
Upload complete.
Waiting on bqjob_r26360ddf0526570_00000172515d711c_1 ... (1s) Current status: DONE
$ bq query --nouse_legacy_sql 'SELECT * FROM syucream-dev.test_syucream.user'
Waiting on bqjob_rba99937746b1712_00000172515d8f6f_1 ... (0s) Current status: DONE
+---------------------+
|         id          |
+---------------------+
| 7190660540979993749 |
| 7190660540979993749 |
+---------------------+

このスキーマと互換のあるスキーマを持つ Avro ファイルの load も問題なくできます。

$ cat user_v1_1.avsc
{
  "name": "User",
  "type": "record",
  "fields": [
    {
      "name": "id",
      "type": "long"
    },
    {
      "name": "name",
      "type": ["null", "string"],
      "default": null
    }
  ]
}
$ java -jar ~/tools/avro-tools-1.8.2.jar random --schema-file user_v1_1.avsc --count 1 user_v1_1.avro 2>/dev/null
$ bq load --project_id syucream-dev --source_format AVRO --schema_update_option ALLOW_FIELD_ADDITION syucream-dev:test_syucream.user user_v1_1.avro
Upload complete.
Waiting on bqjob_r616f9c494daf502a_00000172516056b9_1 ... (0s) Current status: DONE
$ bq query --nouse_legacy_sql 'SELECT * FROM syucream-dev.test_syucream.user'
Waiting on bqjob_r46005fd5ddeefbab_00000172516077d6_1 ... (0s) Current status: DONE
+----------------------+------+
|          id          | name |
+----------------------+------+
| -6779445778023123159 | NULL |
|  7190660540979993749 | NULL |
|  7190660540979993749 | NULL |
+----------------------+------+

BigQuery のスキーマ的には互換がある追記をする場合

今度は前述とは互換性がない、 name フィールドが nullable でなくなったスキーマを持つ Avro ファイルを load してみます。 これは成功しますが BigQuery のテーブルとしては name フィールドは nullable のままとなります。(まあ nullable から required の変更は許されていないですしね。。。)

$ cat user_v2.avsc
{
  "name": "User",
  "type": "record",
  "fields": [
    {
      "name": "id",
      "type": "long"
    },
    {
      "name": "name",
      "type": "string"
    }
  ]
}
$ java -jar ~/tools/avro-tools-1.8.2.jar random --schema-file user_v2.avsc --count 1 user_v2.avro 2>/dev/null
$ bq load --project_id syucream-dev --source_format AVRO --schema_update_option ALLOW_FIELD_ADDITION syucream-dev:test_syucream.user user_v2.avro
Upload complete.
Waiting on bqjob_r5a6eb8678879a075_0000017251620b01_1 ... (1s) Current status: DONE
$ bq query --nouse_legacy_sql 'SELECT * FROM syucream-dev.test_syucream.user'
Waiting on bqjob_r5105091904b8eed0_000001725162d619_1 ... (0s) Current status: DONE
+----------------------+-----------------+
|          id          |      name       |
+----------------------+-----------------+
|  3285309633976168209 | twbnsureievqwes |
|  7190660540979993749 | NULL            |
| -6779445778023123159 | NULL            |
|  7190660540979993749 | NULL            |
+----------------------+-----------------+

ここから逆行して最初に load した Avro ファイルを load しようとしても成功します。 これはやはり BigQuery のテーブル上では name フィールドは nullable であり、 name フィールドをそもそも持たないレコードの場合は null で埋めればいいからですね。

$ bq load --project_id syucream-dev --source_format AVRO syucream-dev:test_syucream.user user_v1.avro
Upload complete.
Waiting on bqjob_r3e8bbde894d35c3b_00000172516328ff_1 ... (0s) Current status: DONE
$ bq query --nouse_legacy_sql 'SELECT * FROM syucream-dev.test_syucream.user'
Waiting on bqjob_r7e87a10bad564007_00000172516369af_1 ... (0s) Current status: DONE
+----------------------+-----------------+
|          id          |      name       |
+----------------------+-----------------+
|  7190660540979993749 | NULL            |
|  3285309633976168209 | twbnsureievqwes |
|  7190660540979993749 | NULL            |
| -6779445778023123159 | NULL            |
|  7190660540979993749 | NULL            |
+----------------------+-----------------+

互換がない追記をする場合

今度はさらに BigQuery のテーブルとしても互換が取れないであろう変更をしてみます。 ここでは required となる age フィールドを追加してみます。 この場合、この Avro ファイル単体としては load できそうですが既存のレコードが age フィールドの値をもたないため load できません。

$ cat user_v3.avsc
{
  "name": "User",
  "type": "record",
  "fields": [
    {
      "name": "id",
      "type": "long"
    },
    {
      "name": "name",
      "type": "string"
    },
    {
      "name": "age",
      "type": "long"
    }
  ]
}
$ java -jar ~/tools/avro-tools-1.8.2.jar random --schema-file user_v3.avsc --count 1 user_v3.avro 2>/dev/null
$ bq load --project_id syucream-dev --source_format AVRO --schema_update_option ALLOW_FIELD_ADDITION syucream-dev:test_syucream.user user_v3.avro
Upload complete.
Waiting on bqjob_r6d0dccd4050d198_000001725165f2c9_1 ... (0s) Current status: DONE
BigQuery error in load operation: Error processing job 'syucream-dev:bqjob_r6d0dccd4050d198_000001725165f2c9_1': Provided Schema does not match Table
syucream-dev:test_syucream.user. Cannot add required fields to an existing schema. (field: age)

--replace する場合

BigQuery のデータ読み込みでは追記ではなくアトミックなテーブルの再生成も行えます。 この場合はテーブルとそのスキーマが作り直される都合、前述のスキーマの互換性を気にしなくてよくなります。

$ bq load --project_id syucream-dev --replace --source_format AVRO syucream-dev:test_syucream.user user_v1.avro
Upload complete.
Waiting on bqjob_r1441ed0e2b659303_000001725167e0af_1 ... (0s) Current status: DONE
$ bq query --nouse_legacy_sql 'SELECT * FROM syucream-dev.test_syucream.user'
Waiting on bqjob_r64a104d745d627a1_00000172516817e3_1 ... (0s) Current status: DONE
+---------------------+
|         id          |
+---------------------+
| 7190660540979993749 |
+---------------------+
$ bq load --project_id syucream-dev --replace --source_format AVRO syucream-dev:test_syucream.user user_v1_1.avro
Upload complete.
Waiting on bqjob_r1254a5673d1c4a55_0000017251684053_1 ... (0s) Current status: DONE
$ bq query --nouse_legacy_sql 'SELECT * FROM syucream-dev.test_syucream.user'
Waiting on bqjob_r542e92fd2eadaefe_0000017251685b41_1 ... (0s) Current status: DONE
+----------------------+------+
|          id          | name |
+----------------------+------+
| -6779445778023123159 | NULL |
+----------------------+------+
$ bq load --project_id syucream-dev --replace --source_format AVRO syucream-dev:test_syucream.user user_v2.avro
Upload complete.
Waiting on bqjob_r7f71a4c5acbb4a54_00000172516874f8_1 ... (0s) Current status: DONE
$ bq query --nouse_legacy_sql 'SELECT * FROM syucream-dev.test_syucream.user'
Waiting on bqjob_r26844a99b599657a_0000017251688ed0_1 ... (0s) Current status: DONE
+---------------------+-----------------+
|         id          |      name       |
+---------------------+-----------------+
| 3285309633976168209 | twbnsureievqwes |
+---------------------+-----------------+
$ bq load --project_id syucream-dev --replace --source_format AVRO syucream-dev:test_syucream.user user_v3.avro
Upload complete.
Waiting on bqjob_r3039d309e0eaecd0_000001725168a3bd_1 ... (1s) Current status: DONE
$ bq query --nouse_legacy_sql 'SELECT * FROM syucream-dev.test_syucream.user'
Waiting on bqjob_r3535c0ed97eae63f_000001725168c9b7_1 ... (0s) Current status: DONE
+---------------------+-----------+----------------------+
|         id          |   name    |         age          |
+---------------------+-----------+----------------------+
| 6571829868147110661 | gcypqmwby | -7543339857203188581 |
+---------------------+-----------+----------------------+

雑なまとめ

BigQuery による Avro ファイルのデータ読み込みは非常に協力で、 --replace によるテーブル更新によって楽で効果的な運用ができると思います。 BigQuery 上でスキーマがどうなるかを考えずに読み込みジョブを実行するだけで良くなるのはメリットが大きいでしょう。

ただし --replace ですべてのユースケースが叶えられるわけでもなく、巨大なデータソースから ETL を経て差分更新で同期したいだとかログをひたすら追記したい場合にはスキーマの更新問題がしばしばネックになると思われます。 ただその場合でも、BigQuery のテーブルのスキーマとして互換が取れる Avro ファイルであれば読み込み可能であることからスキーマ更新について考える負荷は減りそうです。 スキーマ更新が頻繁に発生しうるワークロードでは逆にこの特性を捉えた上でどうテーブル更新するかのワークフローを組むと良いのかもですね。