dbtプロジェクト構築に関する ベストプラクティス #4「マート層(Marts Layer)に関する検討事項」 #dbt

2024.02.29

アライアンス事業部 エンジニアグループ モダンデータスタック(MDS)チームのしんやです。

dbtはクラウド型データウェアハウス(DWH)におけるデータ変換に特化したツールです。非常に使い勝手が良く便利なツールである一方、様々な機能が提供されているのでいざ使ってみよう!となると『何をどうやって作り上げていけば良いんだろう?』『この場合のルールや制限はどういうものがあるの?どういう取り決めをもって扱えば良いんだろう?』という風に思うこともあるかと思います。(実際私自身そう感じました)

そんなユーザーの疑問や悩みを解決する、いわゆるdbtユーザー向けのガードレール的な存在となりうるコンテンツがdbt社から展開されています。それが『dbtベストプラクティスガイド(Best practice guides)』です。構造、スタイル、セットアップなど、dbt Labsの現在の視点を通した「ベストプラクティス」がまとめられています。

そこで当エントリでは、幾つか展開されている「dbtベストプラクティスガイド」で紹介されているコンテンツの中から『dbtプロジェクト構築』に関するもの、データウェアハウスにおける『マート層(Mart Layer)』をどうdbtで構築していくかについて読み解いていきたいと思います。

目次

 

マート層におけるファイルとフォルダの検討事項

まず初めに断りとして、dbtのドキュメントではこの「マート層」のベストプラクティスについて「dbt Semantic Layerを使う場合」と「dbt Semantic Layerを使わない場合」の2つの情報が存在しています。当エントリでは「dbt Semantic Layerを使わない場合」の内容に関して紹介していきます。 ※機会をみて「dbt Semantic Layerを使う場合」の情報も紹介出来ればと思います。

ちなみに大方針としては、以下のように整備の仕方が異なります。

  • dbt Semantic Layerを使用しないプロジェクトでは、以下のベストプラクティスに従って、大幅に非正規化することをお勧め
  • dbt Semantic Layerを使用する場合は、MetricFlowが最も柔軟に対応できるように、できるだけ正規化することをお勧め

当エントリでの内容を説明、解説するために、jaffle_shopプロジェクトのmodels/marts配下の構造を使います。

jaffle_shop
├── README.md
├── analyses
├── seeds
│   └── employees.csv
├── dbt_project.yml
├── macros
│   └── cents_to_dollars.sql
├── models
│   ├── intermediate
│   │   └── finance
│   │       ├── _int_finance__models.yml
│   │       └── int_payments_pivoted_to_orders.sql
│   ├── marts
│   │   ├── finance
│   │   │   ├── _finance__models.yml
│   │   │   ├── orders.sql
│   │   │   └── payments.sql
│   │   └── marketing
│   │       ├── _marketing__models.yml
│   │       └── customers.sql
│   ├── staging
│   │   ├── jaffle_shop
│   │   │   ├── _jaffle_shop__docs.md
│   │   │   ├── _jaffle_shop__models.yml
│   │   │   ├── _jaffle_shop__sources.yml
│   │   │   ├── base
│   │   │   │   ├── base_jaffle_shop__customers.sql
│   │   │   │   └── base_jaffle_shop__deleted_customers.sql
│   │   │   ├── stg_jaffle_shop__customers.sql
│   │   │   └── stg_jaffle_shop__orders.sql
│   │   └── stripe
│   │       ├── _stripe__models.yml
│   │       ├── _stripe__sources.yml
│   │       └── stg_stripe__payments.sql
│   └── utilities
│       └── all_dates.sql
├── packages.yml
├── snapshots
└── tests
    └── assert_positive_value_for_total_amount.sql


✅ すべき(dos) ❌ べからず(dont’s)
部門または関心のある分野ごとにグループ化
・マートの数が10程度未満の場合は、サブフォルダーはあまり必要ない可能性があるため、中間層の場合と同様に、早期に最適化しすぎないように。
・ただし、更に構造とグループ化を挿入する必要がある場合は、ここで役立つビジネス概念を使用してください。 マート層では、ソースに準拠したデータを気にする必要がなくなったので、部門(マーケティング、財務など)ごとにグループ化することが、この段階では最も一般的な構造です。
同じコンセプトをチームごとに異なる方法で構築
finance_ordersmarketing_ordersといった命名は通常、アンチパターンとみなされます。
・例外はあります。私たちが目にする一般的なパターンは、財務部門には特定のニーズがある場合があるということです。
・例えば、企業全体が日々の収益を測定する方法とは異なる方法で政府に収益を報告する...などです。これらが明確に設計されており、同じ概念に対する部門別の見解ではなく、別個の概念として理解できるようにしてください。
・つまり、finance_revenuemarketing_revenueでは無く、tax_revenuerevenueです。
エンティティ毎の名前
・マートの顧客(customers)や注文(orders)の要素を形成する概念に基づいて、わかりやすい英語を使用してファイルに名前を付ける。
・純粋なマートの場合、ここに時間ディメンション(orders_per_day)を含めるべきではないことに注意。これは通常、メトリクスを介して取得するのが最適。
----

 

中間データ層におけるモデルの検討事項

ここでは2つのmartsモデルを挙げて、marts ディレクトリ内のモデルのベストプラクティスを見ていきます。これらは、ビジネスに適合した、つまり私たちのビジョンとニーズに合わせて作成されたエンティティであり、これらの変換されたコンポーネントを組み合わせて作成しています。

orders.sql

-- orders.sql

with

orders as  (

<pre><code>select * from {{ ref('stg_jaffle_shop__orders' )}}
</code></pre>

),

order_payments as (

<pre><code>select * from {{ ref('int_payments_pivoted_to_orders') }}
</code></pre>

),

orders_and_order_payments_joined as (

<pre><code>select
    orders.order_id,
    orders.customer_id,
    orders.order_date,
    coalesce(order_payments.total_amount, 0) as amount,
    coalesce(order_payments.gift_card_amount, 0) as gift_card_amount

from orders

left join order_payments on orders.order_id = order_payments.order_id
</code></pre>

)

select * from orders_and_payments_joined

customers.sql

-- customers.sql

with

customers as (

<pre><code>select * from {{ ref('stg_jaffle_shop__customers')}}
</code></pre>

),

orders as (

<pre><code>select * from {{ ref('orders')}}
</code></pre>

),

customer_orders as (

<pre><code>select
    customer_id,
    min(order_date) as first_order_date,
    max(order_date) as most_recent_order_date,
    count(order_id) as number_of_orders,
    sum(amount) as lifetime_value

from orders

group by 1
</code></pre>

),

customers_and_customer_orders_joined as (

<pre><code>select
    customers.customer_id,
    customers.first_name,
    customers.last_name,
    customer_orders.first_order_date,
    customer_orders.most_recent_order_date,
    coalesce(customer_orders.number_of_orders, 0) as number_of_orders,
    customer_orders.lifetime_value

from customers

left join customer_orders on customers.customer_id = customer_orders.customer_id
</code></pre>

)

select * from customers_and_customer_orders_joined


✅ すべき(dos) ❌ べからず(dont’s)
テーブルまたはインクリメンタルモデルとして実体化
・マート層に到達したら、ウェアハウスへのロジックだけでなくデータ自体の構築をはじめましょう。この作業を行うことで、実際にエンドユーザーの用途に合わせて設計されたこれらの新しいモデルのパフォーマンスが大幅に向上し、誰かがダッシュボードを更新したり Python で回帰を実行したりするたびにモデルのチェーン全体を再計算するコストが節約されます。

・マテリアライゼーションに関する一般的な経験則としては、常にビューから開始し (基本的にストレージを消費せず、常に最新の結果が得られるため)、ビューの実際のクエリに時間がかかりすぎたら、ビューをテーブルに構築します。
・最後に、テーブルの構築に時間がかかりすぎて実行が遅くなったら、それを増分モデルとして構成します。
・いつものように、単純なものから始めて、必要に応じてのみ複雑さを追加してください。

・最も多くのデータと計算集約型の変換を行うモデルは、dbtの優れたインクリメンタルモデルの設定オプションを確実に活用する必要がありますが、デフォルトですべてのマートモデルをこの「インクリメンタルモデル」とすることを急いでしまうと余計な困難が生じるので注意してください。
  ・参考:Incremental models | dbt Developer Hub
・インクリメンタルモデリングの詳細については、下記Tristanの古典的な投稿を読むことをお勧めします。
  ・参考:On the limits of incrementality - In-Depth Discussions - dbt Community Forum

1つのマートに結合が多すぎる
・dbt 変換を構築するときの経験則の1つは、「1つのマートにあまりにも多くの概念をまとめないようにすること」です。
・何が「多すぎる」のかは状況に依って様々ですが、単純な結合のみを使用して8つのステージングモデルをまとめる必要がある場合は、それで問題ないかもしれません。
・逆に、4つの概念を複雑で計算量の多いウィンドウ関数と組み合わせている場合、それは多すぎる可能性があります。
・参加するモデルの数とマート内のロジックの複雑さを比較検討する必要があります。すべてを読んで明確なメンタル モデルを構築するのが多すぎる場合は、モジュール化を検討してください。
・これは厳密なルールではありませんが、マートを作成するために4つまたは5つを超えるコンセプトをまとめている場合は、より明確にするためにいくつかの中間モデルを追加すると効果があるかもしれません。
・それぞれ3つの概念をまとめた2つの中間モデルと、それら2つの中間モデルをまとめたマートを使用すると、通常、6つの結合を持つ単一のマートよりもはるかに読みやすいロジックチェーンが生成されます。
広範かつ非正規化
・昔ながらのウェアハウス管理とは異なり、現代のデータスタックでは、ストレージは安価ですが、コンピューティングは高価であるため、優先順位を付ける必要があります。
・これらを非常に広範な非正規化コンセプトに詰め込み、目標としてのコンセプトについて必要なものすべてを提供できます。
----
慎重に別々のマートを構築
・マート層までのDAGの縮小を維持するよう努めてきていますが、実際に作業を進めていくうちに、状況は少し厳しくなり始める可能性があります。
・一般的な例としては、上で見たように、異なる粒度でマート間で情報を渡すことであり、注文マートを顧客マートに持ち込み、重要な注文データを顧客粒度に集約します。
・実際に出力でデータを構築することでコンピューティングとストレージを実際に「費やしている」ため、同じビューと CTEを最初から再計算するのではなく、同様のデータを必要とする出力を高速化してコストを節約するために、以前に構築したリソースを活用することが賢明です。
・ここでの適切なアプローチは、独自の DAG、モデル、目標に大きく依存します。別のマートを構築する際にマートを使用することは問題ありませんが、リソースの無駄や循環依存を避けるために慎重に検討する必要があることに注意することが重要です。
----
マートをエンティティ毎に細分化
・マートの最も重要な側面は、特定のエンティティに関する有用なデータがすべて粒度レベルで含まれていることです。
・それは、注文マートに大量のユーザー データなど、他の多くのエンティティや概念を取り込まないという意味ではありません。 個々の注文が私たちのテーブルの中核であり続けることを意味します。
・ユーザーと注文をdate spineに沿って user_orders_per_day のようなグループに分類し始めると、マートを超えて指標に移行することになります。
  ・参考:date spine_dbt-labs/dbt-utils: Utility functions for dbt projects.
----

 

マート層におけるその他検討事項

  • テーブルを使用してトラブルシューティングを行う
    • マートまでビューと一時的なモデルを積み重ねる(エンドユーザーに本当に使用してもらいたいモデルがある場合に、チェーンの最後にあるウェアハウスにデータを構築するだけ)ことは、運用環境では理想的ですが、開発ではいくつかの困難が生じる可能性があります。
    • 特に、特定のエラーが後のモデルで表面化しているように見える場合がありますが、実際にはモデルチェーンのかなり初期の依存関係(モデルがエラーをスローする前に構築されたDAG内の祖先モデル)に起因しています。
    • データベースエラーがどこで何を示しているのかを特定するのが難しい場合は、ウェアハウスが実際に発生しているエラーをスローするように、モデルの特定のチェーンをテーブルとして一時的に構築すると役立つことがあります。

 

まとめ

という訳で、dbtプロジェクト構築に関するベストプラクティス第4弾としてデータウェアハウスにおける「マート層」に関する取り組み方の紹介でした。

分析ユーザーやBIツールに見せることになるレイヤー(層)のデータとなることもあり、データや情報の在り方についてもこれまで紹介したものとは異なる内容が多かったですね。幾つかの指摘事項では「急ぎ過ぎるな、時が来たらその時に動け」的なものもあり、そのあたりの整理整頓の見極めも状況によってしていかなければならないということも分かり勉強になりました。