
こんにちは、ノバセルでデータエンジニアをしている森田です。
普段 dbt でデータ変換を実装していると、データ量の増加やモデルの複雑化に伴い dbt build の実行時間が長くなり、開発効率や運用コストに影響が出てきます。特に、開発サイクルを高速に回すためには、ビルド時間の短縮が重要な課題となります。
今回は、dbt build を高速化する二つの効果的なテクニックを紹介します。一つは materialized = table を活用した dbt run の高速化、もう一つは Custom Generic Test による dbt test の高速化です。どちらも実際のプロジェクトで大きな効果を発揮した方法です。
materialized = table で dbt run のパフォーマンスを改善する
重い処理によるパフォーマンス問題
dbt でモデルを構築していると、特に正規表現のような CPU 負荷の高い処理が含まれるモデルでは実行時間が大幅に増加することがあります。
具体的には以下のような URL からクエリパラメータを抽出する処理が重いケースがありました:
with raw_data as ( select id, url from {{ ref('source_table') }} ) select id, url, -- パラメータ foo の値を抽出するための正規表現 regexp_extract(url, '.*[?&]foo=([^&]+)', 1) as foo_value, -- パラメータ bar の値を抽出するための正規表現 regexp_extract(url, '.*[?&]bar=([^&]+)', 1) as bar_value from raw_data
このモデルが後続の様々なモデルから参照されていたため、dbt run の実行時間が 30 分程度かかるようになっていました。正規表現処理は CPU リソースを多く消費するため、パフォーマンスのボトルネックとなりやすいのです。
materialized = table による解決策
問題の根本原因は、dbt のデフォルトの materialization である view のモデルで重い処理をしていることでした。view として定義されているモデルは、参照されるたびに SQL が実行されます。つまり、複数のモデルから参照される場合、同じ正規表現処理が何度も実行されることになります。
この問題を解決するために、モデルの materialization を table に変更します:
{# ここの Config で設定を追加 #}
{{ config(
materialized='table'
) }}
with raw_data as (
select
id,
url
from {{ ref('source_table') }}
)
select
id,
url,
-- パラメータ foo の値を抽出するための正規表現
regexp_extract(url, '.*[?&]foo=([^&]+)', 1) as foo_value,
-- パラメータ bar の値を抽出するための正規表現
regexp_extract(url, '.*[?&]bar=([^&]+)', 1) as bar_value
from raw_data
この設定変更により、モデルの実行結果が物理テーブルとして保存されるようになります。その結果、正規表現処理は一度だけ実行され、後続のモデルはこの物理テーブルを参照するため、実行時間が大幅に短縮されます。
実際に適用した結果、30 分かかっていた処理が 3 分程度まで短縮され、約 90% の時間削減に成功しました。
適用の注意点
materialized = table は強力な最適化手法ですが、以下の点に注意が必要です:
- リアルタイム性の考慮: テーブル化したモデルは dbt run が実行されたタイミングでのみ更新されるため、常に最新データが必要なケースには適していません。
- ストレージコストの増加: 物理テーブルとして保存されるため、ストレージ使用量が増加します。
- 選択的な適用: すべてのモデルをテーブル化するのではなく、以下のような条件に当てはまるモデルに選択的に適用することが重要です:
- 複雑な処理(正規表現、高コストの関数など)を含むモデル
- 複数のモデルから参照される中間テーブル
- 更新頻度と鮮度のバランスが取れているモデル
基本的な方針としては、デフォルトは view として定義し、パフォーマンス改善が必要なモデルのみ table として具現化するアプローチが効果的です。
Custom Generic Test で dbt test を高速化する
標準テストのパフォーマンス課題
dbt の Generic Test は非常に便利ですが、テスト数が増えるにつれて実行時間も増加します。特に、以下のような場合に実行時間が長くなりがちです:
- 多数のカラムに対して同じテストを適用する場合
- 大規模なテーブルに対するテスト
- materialization が view のモデルに対するテスト(テスト実行のたびに view の SQL が実行される)
例えば、以下のように 7 つのカラムに対して not_null テストを適用すると:
version: 2 models: - name: my_model description: "サンプルデータセット。7 つのカラムに対して not_null テストを実施します。" columns: - name: id description: "各レコードのユニークな識別子。常に値が存在する必要があります。" data_tests: - not_null - name: order_id description: "注文を識別するためのID。必ず値が存在する必要があります。" data_tests: - not_null - name: customer_id description: "顧客ID。各注文に紐づく顧客情報として必須です。" data_tests: - not_null - name: product_id description: "商品ID。注文に含まれる各商品の識別子です。" data_tests: - not_null - name: created_at description: "レコードが作成された日時。時間軸の整合性のために必須です。" data_tests: - not_null - name: updated_at description: "レコードが最後に更新された日時。必ず正しい値が必要です。" data_tests: - not_null - name: status description: "レコードの状態を示すカラム。NULL 値が含まれないことが求められます。" data_tests: - not_null
これは内部的に 7 回の SQL クエリを実行することになります。各テストは以下のような SQL を生成します:
{% macro default__test_not_null(model, column_name) %}
{% set column_list = '*' if should_store_failures() else column_name %}
select {{ column_list }}
from {{ model }}
where {{ column_name }} is null
{% endmacro %}
この場合、同じテーブルに対して 7 回のクエリが実行されるため、特に大規模なテーブルや materialization が view のモデルでは非効率です。
Custom Generic Test による解決策
この問題を解決するために、複数カラムを一度にチェックする Custom Generic Test を実装しました。
{% test not_null_multiple_columns(model, columns) %}
SELECT *
FROM {{ model }}
WHERE
{% for column in columns %}
{{ column }} IS NULL {% if not loop.last %}OR {% endif %}
{% endfor %}
{% endtest %}
このカスタムテストを使用すると、以下のように一度のテストで複数カラムをチェックできます:
version: 2 models: - name: my_model description: "サンプルデータセット。7 つのカラムに対して not_null テストを実施します。" data_tests: - not_null_multiple_columns: columns: - id - order_id - customer_id - product_id - created_at - updated_at - status columns: - name: id description: "各レコードのユニークな識別子。常に値が存在する必要があります。" - name: order_id description: "注文を識別するためのID。必ず値が存在する必要があります。" - name: customer_id description: "顧客ID。各注文に紐づく顧客情報として必須です。" - name: product_id description: "商品ID。注文に含まれる各商品の識別子です。" - name: created_at description: "レコードが作成された日時。時間軸の整合性のために必須です。" - name: updated_at description: "レコードが最後に更新された日時。必ず正しい値が必要です。" - name: status description: "レコードの状態を示すカラム。NULL 値が含まれないことが求められます。"
このアプローチにより、テストの数が 7 つから 1 つに減り、実行時間も大幅に削減されます。特に materialization が view のモデルに対するテストでは、view の SQL が一度だけ実行されるため、効果が顕著です。
実際のプロジェクトでこの方法を適用した結果、テスト実行時間が約 50% 削減されました。
今回は、自作の Custom Generic Test を実装しましたが、dbt-utils にもパフォーマンスを向上させるためのマクロが含まれているためこちらも参照すると良いでしょう。
結果
これら二つのテクニックを組み合わせることで、dbt build の実行時間を大幅に短縮できました。実際のプロジェクトでは、以下のような改善を達成しました:
- materialized = table による dbt run の高速化: 30 分 → 3 分(90% 削減)
- Custom Generic Test による dbt test の高速化: テスト時間を約 50% 削減
これらの改善により、開発サイクルの高速化、CI/CDパイプラインの効率化、そして最終的にはデータ提供の迅速化につながると考えています。
その他の高速化テクニック
本記事では主に「materialized = table」と「Custom Generic Test」による高速化を紹介しましたが、dbt build のパフォーマンスを向上させるための手法はほかにもあります。以下に簡単に紹介します。
incremental マテリアライゼーションの活用
大規模データセットでは、全データを再構築する table よりも、新しいデータのみを処理する incremental マテリアライゼーションが効果的な場合があります。特に日次更新など定期的なパイプラインで威力を発揮します。
パーティショニングとクラスタリングの活用
BigQuery や Snowflake などのデータウェアハウスでは、テーブルのパーティショニングやクラスタリングを設定することで、クエリパフォーマンスを向上させることができます。
部分的なビルド戦略
開発中は --select フラグを使用して、変更したモデルとその関連モデルのみをビルドすることで時間を節約できます。
並列実行の最適化
--threads パラメータを調整することで、並列実行の度合いを最適化し、ビルド時間を短縮できます。
これらのテクニックを状況に応じて組み合わせることで、dbt パイプラインの実行時間をさらに短縮し、開発効率を高めることができます。プロジェクトの特性に合わせて最適な戦略を選択していきましょう。
まとめ
dbt build の高速化は、データエンジニアリングの効率化において重要な要素です。本記事で紹介した二つの手法を適切に組み合わせることで、実行時間を大幅に短縮し、開発サイクルを加速させることができます。
特に重要なのは、プロジェクトの特性に合わせて最適化戦略を選択することです。すべてのモデルをテーブル化したり、すべてのテストをカスタム化するのではなく、ボトルネックとなっている部分を特定し、選択的に最適化することが効果的です。
dbt プロジェクトの成長に伴い、パフォーマンス最適化はますます重要になります。継続的な改善を通じて、スケーラブルで効率的なデータパイプラインを構築していきましょう。