テクテク日記

テクテク=テクノロジー&一歩ずつ(テクテク)https://aka.ms/techtech2 より、カテゴリー別にフィルターできるようになります。

Power BIでスタースキーマが最適とは限らない事例

Power BIにおけるデータモデリングでは、スタースキーマがベストプラクティスとして広く推奨されています。ファクトテーブルとディメンションテーブルを明確に分離することで、データの効率的な処理やメンテナンスが容易になり、DAXによる分析もシンプルになるためです。しかし、特定のシナリオでは、スタースキーマを採用すると逆にパフォーマンスが低下したり、モデルの複雑さが増す場合があります。本記事では、スタースキーマをあえて使用しない方が良い特殊なケースを解説し、その背景や改善策を具体的に掘り下げます。特に、数百万行のデータを含む顧客テーブルがファクトとディメンションの両方の役割を果たす場合や、スナップショットデータを使用する場合に焦点を当て、クエリパフォーマンスの最適化について詳しく説明します。

概要紹介

Power BI において、スタースキーマ(Star Schema)はデータモデリングの最適解として広く受け入れられています。ファクトテーブルとディメンションテーブルを明確に分離することで、クエリパフォーマンスの向上、メモリ効率の最適化、モデルの再利用性・保守性の確保など、多くの利点が得られます。とりわけ  VertiPaq エンジンとの相性が非常に良く、大規模な分析環境においては欠かせない設計原則です。

しかし、すべてのユースケースがこの原則に従うことを前提としているわけではなく、実際の業務要件においては、スタースキーマを採用しない方が合理的とされる場面も存在します。

スタースキーマを適用しない方が現実的となるケース

  1. スナップショット形式やトランザクションの単純表示が目的の場合
    • ユースケース:大量の取引履歴や監査ログをそのまま表形式で表示したい場合
    • 理由:Fact テーブル単体で目的を達成でき、ディメンションの参照や集計処理が不要。スタースキーマにしてもパフォーマンス上のメリットがほとんどなく、逆に JOIN によるクエリ負荷が増加することもある
  2. シンプルな構造で完結するレポート用途の場合
    • ユースケース:単一ファイルで収まる軽量なレポート(例:アンケート結果、営業日報)
    • 理由:関係構築の必要がなく、フラットな構造のほうがモデルのメンテナンスが容易。将来的な拡張が見込まれていない場合には、スタースキーマ化に要する工数がコストに見合わない
  3. データ量が非常に小さく、可視化が主目的の場合
    • ユースケース:数百~数千件の軽量データで、分析よりもビジュアル表示を優先するケース
    • 理由:データ量が少ないため、VertiPaq による圧縮やクエリ最適化の恩恵が小さく、スタースキーマ化による設計・ETL負荷のほうが大きくなる可能性がある

上記2・3のようなケースでは、Power BIに習熟したユーザーがALL関数などを使わずに素早くアドホックな分析を行うことが可能です。特にスタースキーマではない構造でALL関数を多用すると、Power BI内部の「Autoexists」機能により意図しないフィルターの削除が行われるなど、DAXの評価ロジックに悪影響を及ぼす可能性がある点にも注意が必要です。

その意味でも、DAXにおける意図しない動作(例:AutoExistの影響)を防ぐという観点から、スタースキーマを採用すべきだという主張は極めて妥当であり、Power BIの経験が豊富なユーザーの間でも共通した見解として語られることが多いでしょう。

とはいえ今回は、そうした例外的ケースの中でも特に注目すべき、

1. ファクト兼ディメンションテーブルの使用により、スタースキーマよりもパフォーマンスが向上する

という実例を取り上げ、その背景となる状況や、適用可能なテクニック・改善策について詳しく解説します。

留意頂きたいのは、これはあくまで特殊な条件下における例外的なアプローチであり、すべてのシナリオに当てはまるものではありません。しかし、以下に示すような条件が揃っている場合には、「あえてスタースキーマを適用しないこと」こそが、パフォーマンス最適化の鍵となる可能性があることを、現場レベルで知っておく価値は十分にあります。

具体的なシナリオ

スタースキーマモデル

以下のようなシナリオを想定します:

  • カーディナリティの高い顧客データ(数百万件)が主データとなり、これが事実上のファクトテーブルである
  • しかし、実際にはこの顧客データ自体がディメンション(マスタ)でもあり、Fact=Dimension の構成になっている
  • データには「年間売上金額」や「直近6ヶ月の販売実績」などのスナップショット系数値が含まれているが、一般的な時系列データとしての年月や日付列は存在せず、時系列分析の取り扱いが難しい(※顧客企業の設立年月の情報はあるため、年月別の設立会社数といった時系列分析は可能)
  • このテーブルを用いて、顧客単位の明細表示を重視するダッシュボードを構築する

以下はそのサンプルレポートですが、まずはスタースキーマで作ったものをご紹介します。

🔸スタースキーマで作ったレポート

📐 レポートのビジュアルデザインの特徴

🔹 スナップショット型の一覧表示

  • 中央のテーブルビジュアルは、2百万件の顧客を1件ずつ表示する構成
  • ディメンションだけを使ったテーブルではなく、ファクトテーブルにある列とディメンション側にある列の両方を活用して可視化(理由は後述)
  • 指標についてはDAXメジャーを作成して活用

🔹 フィルターはスライサーで提供(上場区分、地域、郵便番号等)

  • スライサー数がそれなりに多い
  • ユーザーが自由に自分の視点で分析できるように様々な切り口を用意したい、というのがレポートデザインの思想

🧩 モデリングおよびDAX設計の特徴

🔹 複数のディメンションテーブルを持つスタースキーマ構成

  • 中央にFactテーブル(Data)、周囲に以下のディメンションが接続:
    • dCustomer(CustomerKey)
    • dPrefecture(PrefectureKey)
    • dListingClass(ListingClassCode)
    • dCalendar(FoundationDate)
  • すべてのリレーションシップは1対多(1:*)で正しく接続済み
  • Factテーブル側のKey列(e.g., CustomerKey)はisHiddenとなっており、レポートビルダーからはディメンション経由で操作される想定

🔹 DAXメジャーは極めてシンプル

  • 売上(1カ月~年間)、従業員数、資本金、顧客数など、すべてSUM/COUNTROWSで構成されており、パフォーマンス負荷は軽め

🔹 読み込み元はCSVファイル(Partition: import mode)
モデルのサイズに関する詳細ついてはこちら

📊 データ分布とVertiPaqのメモリ使用状況

🔹 Data テーブルは高カーディナリティ

  • CustomerKey: 1,999,999(全体と同数)→1レコード = 1顧客のスナップショット
  • 他にも、BusinessDetail(128万種類)など、高カーディナリティ列が多いため、
    • モデルのメモリ使用比率:Data テーブル単体で 68.49%
    • CustomerKey列だけで 21.31%
    • BusinessDetailだけで 32.48%

🔹 数値列はVALUEエンコーディングで効率的圧縮

🔹 多くの列はHASHエンコーディングで辞書(ディクショナリ)化

  • PostalCode, WardName, BusinessDetailなどはHASH圧縮 → ただし辞書サイズが大きい列も存在

🔹 リレーションシップのあるディメンションの影響度は小さい

  • dPrefecture(47件)、dCalendar(1,746件)など、低カーディナリティで軽量
  • メモリ使用量は全体の0.01~0.02%未満
✅まとめ
観点 特徴 備考
モデリング 正統なスタースキーマ構成 + Flat構成を併用 Flatテーブルの直参照で性能維持
DAX SUM/COUNTのみ 計算コストは非常に軽い
データ分布 高カーディナリティ列多く、Factに負荷集中 特に CustomerKey, BusinessDetail が肥大化要因
レポート設計 テーブルビジュアル主体、スライサー多用 Flat構造を活かし、パフォーマンスを最適化

大福帳モデル

ここからは比較用に大福帳ベースのモデルを作ってみます。具体的には、CustomerKeyとCustomerNameを保持するdCustomerを除外した下図のモデルとなります。

🧩 なぜスタースキーマから大福帳形式に?

🔹高カーディナリティなDim=Fact構造では、スタースキーマが逆効果になる場合がある

✴️ dCustomerCustomerKey = 2百万件 の高カーディナリティ

  • Fact(Data)と1対1対応するディメンションは、分割することで逆に圧縮効率が下がる
  • VertiPaqでは、JOINを挟んだ分散型よりも、1テーブルに統合した方が圧縮効率が良いケースがある
  • 特に今回のように、CustomerName が高カーディナリティで HASH圧縮されていると、dCustomerを分離するメリットよりも、JOINと辞書の重複によるコストの方が大きい

🔹モデルサイズ比較

モデル構成 テーブル数 カラム数 モデルサイズ(In-Memory)
HighCardinality_StarSchema.pbix 5 27 281.44 MB
HighCardinality_Flat.pbix 4 28 264.17 MB

🧠 カラムが1つ増えているにもかかわらず、非正規化構成(Flat)の方が約17 MB(≒6%)小さくなっている。これは、CustomerNameCustomerKey をFact側に吸収したことで、辞書が統一され、圧縮率が向上した結果と考えられます。

🔹他のディメンションを残した理由

  • dCustomer は高カーディナリティゆえにモデル全体のサイズ・パフォーマンスに最も悪影響を与える削除対象
  • 一方で、他のディメンション(dPrefecture, dListingClass, dCalendar)については以下の理由で保持
テーブル名 保持理由①:モデルサイズ影響 保持理由②:デモ・機能切り替え目的
dPrefecture 47件のみ → 微小 PrefectureKey と PrefectureName の関係を説明可能に
dListingClass 5件のみ スライサーとして便利
dCalendar 1,746件(≒日付分) 日付テーブルによるフィルタリングとTime Intelligenceとの連携

🔹HighCardinality_Flat.pbix では全て Data テーブルで対応可能

  • テーブルビジュアル:CustomerName, PostalCode, Sales, Capital など、すべて Data テーブル内に集約
  • スライサービジュアル:都道府県や上場区分も Data 内の列で完結
  • 結果として、テーブル間のJOIN不要/リレーションも限定的になることで、クエリパフォーマンスが飛躍的に向上(後述)

パフォーマンス比較

スタースキーマモデル vs 大福帳モデル

前置きが非常に長くなってしまいましたが、ここからパフォーマンスの比較を行っていきます。

こちらのレポートで最もパフォーマンスが悪いのは「顧客詳細_D+F」というテーブルビジュアルであり、こちらを以下の切り口でそれぞれ比較します。

🔴 スタースキーマモデル(切り口: Fact & Dim混在 + メジャー)
オーソドックスなスタースキーマをベースに、切り口となるテキスト項目は可能な限りディメンションテーブルから抽出し、DAXメジャーは基本的に SUM 関数を用いてシンプルに定義しています。

🟡 大福帳モデル(切り口: Factのみ + メジャー)
大福帳モデルには 2 つのパターンがあります。まず 1 つ目は、切り口としてすべて Data テーブルの列を使用し、定義済みの DAX メジャーを用いるパターンです。後述するもう一方の大福帳モデルとの違いは、このパターンではあらかじめ DAX メジャーが定義されており、それを可視化に使用している点にあります。

🟢 大福帳モデル(Factのみ、メジャーなし = 数値列をそのまま使用)

「大福帳モデル」として、すべての項目をファクトテーブル(ただし、ディメンションとしても扱える構造)である Data テーブルから抽出しています。数値項目については、1行ごとに顧客を表示する構成であるため、「要約処理」は “集計しない” に設定し、そのまま可視化しています。なお、本シナリオでは個別の顧客ごとの分析が目的であるため、合計値の有無は特に重要ではないと想定しています。

パフォーマンス測定の準備

1つずつ、パフォーマンスを測定していきます。Power BI DesktopにあるPerformance Analyzerを立ち上げ、以下の順番でテーブルから生成されるクエリをキャプチャする。

DAX Studio*2 がインストールされていることを確認したうえで、現在開いている Power BI Desktop(本例ではインポートモデル)から起動します。

手順④でコピーしたクエリをエディタに貼り付けたうえで、パフォーマンス測定の効果がより分かりやすくなるよう、DAX クエリをシンプルな形式に書き換えます。

DAX StudioからServer Timings *3、Query Plan *4、Clear on Run *5にチェックを入れ、クエリをRunする。うまく処理が出来れば、⑪の画面となり、結果を確認することができるようになります。

このように、🔴🟡🟢の3つのパフォーマンスを測定したところ、以下のような結果になりました。

✅ クエリパフォーマンス比較の結果(Server Timings)

項目 / クエリ名 🔴 MixedMeasures
(Dim + Fact)
🟡 FactMeasures
(Factのみ+メジャー)
🟢 FactNoMeasures
(Fact+列のみ)
Total Duration 3,166 ms 5,863 ms 1,771 ms
Total Duration 比較 - 🔴対比185% 🔴対比56%
FE(Formula Engine) 2,538 ms (80.2%) 4,576 ms (78.0%) 287 ms (16.2%)
SE(Storage Engine) 628 ms (19.8%) 1,287 ms (22.0%) 1,484 ms (83.8%)
SE CPU 625 ms 1,266 ms 1,453 ms
SE Queries 2(Data, dPrefecture) 1 1
Rows Scanned 約2,000,000 約2,000,000 約2,000,000
KB Processed 109,375 KB 109,375 KB 78,125 KB
列構成 dCustomer/dPrefecture参照+Factメジャー Factテーブルのみ+複数メジャー Factテーブルのみ+数値列をそのまま可視化
特徴 FE多い+JOIN発生 FE多い+JOINなし SE寄りで最も高速・軽量

結論として、🟢FactNoMeasures(Fact + 列のみ)の構成が最もクエリパフォーマンスに優れており、クエリ実行時間は🔴構成と比べて約44%(100% − 56%)高速であることが確認できました。なお、パフォーマンス測定にはPCのメモリ使用状況などによるばらつきがあるため、これらの数値はあくまで参考値としてご覧ください。

実測では、🔴および🟡のクエリはそれぞれおおよそ 3〜7秒 の範囲で推移していたのに対し、🟢のみが 2秒未満 に収まっていました。以下に、それぞれの DAX クエリを示します。

🔴 MixedMeasures(dテーブル+メジャー)

DEFINE
  VAR __DS0Core = 
    SUMMARIZECOLUMNS(
      'dCustomer'[CustomerName],
      'Data'[PostalCode],
      'dPrefecture'[PrefectureName],
      'dPrefecture'[PrefectureKey],
      'Data'[WardName],
      'dListingClass'[ListingClassName],
      'dListingClass'[SortBy],
      "Sales_1_month", 'Data'[Sales 1 month],
      "Sales_3_month", 'Data'[Sales 3 month],
      "Sales_6_month", 'Data'[Sales 6 month],
      "Annual_Sales", 'Data'[Annual Sales],
      "CapitalAmount", 'Data'[CapitalAmount]
    )
EVALUATE
  TOPN(502, __DS0Core)

🟡 FactMeasures(Factのみ+メジャー)

DEFINE
  VAR __DS0Core = 
    SUMMARIZECOLUMNS(
      'Data'[PostalCode],
      'Data'[WardName],
      'Data'[CustomerName],
      'Data'[PrefectureName],
      'Data'[ListingClassName],
      "Sales_1_month", 'Data'[Sales 1 month],
      "Sales_3_month", 'Data'[Sales 3 month],
      "Sales_6_month", 'Data'[Sales 6 month],
      "Annual_Sales", 'Data'[Annual Sales],
      "CapitalAmount", 'Data'[CapitalAmount]
    )
EVALUATE
  TOPN(502, __DS0Core)

🟢 FactNoMeasures(Factのみ+集計なし)

DEFINE
  VAR __DS0Core = 
    SUMMARIZE(
      'Data',
      'Data'[PostalCode],
      'Data'[WardName],
      'Data'[CustomerName],
      'Data'[PrefectureName],
      'Data'[ListingClassName],
      'Data'[1MonthSales],
      'Data'[3MonthSales],
      'Data'[6MonthSales],
      'Data'[AnnualSales],
      'Data'[Capital]
    )
EVALUATE
  TOPN(502, __DS0Core)
🧠 結果解説

🟢 FactNoMeasuresが最も早いですが、これには以下の理由が挙げられます。

  • 集計関数を使用しないため、ストレージエンジン(SE)の処理が最も軽い
  • クエリプラン上もシンプルで最小限

Power BI における処理パフォーマンスの大原則は、可能な限り処理を並列実行が可能な SE(Storage Engine) にプッシュし、FE(Formula Engine) にかかる負荷を最小限に抑えることです。今回のような特殊なケースでは、スタースキーマに基づくクエリを実行すると、エンジン側で JOIN 処理が発生します。そのため、リレーションシップを使用せず、すべての処理をファクトテーブル内で完結させる構成のほうが、結果としてパフォーマンスが良くなることが確認できました。

クエリプランは、DAX クエリの内部的な論理・物理処理の流れを視覚的に表示する機能ですが、🟢 FactNoMeasuresが最もシンプルであった。一方、🔴 MixedMeasuresdCustomer, dPrefecture, dListingClass と複数のディメンションテーブルから値を引いているため、内部的に複数のJOIN処理(セミジョイン)が発生しており、カーディナリティが低い(ユニークレコードが少ない)場合には良いが、数百万行のFact = Dimテーブルという状況においてはコストが非常に高くなってしまいます。

特に「Rows Scanned」の 200 万という数値は、たとえクエリ自体がシンプルであっても、実際に 200 万行のスキャンが発生していることを示しています。

やや専門的な内容になりますが、クエリプラン上では Spool_Iterator<SpoolIterator>(繰り返し計算に対する中間結果の再利用を目的としたキャッシュ的処理)が発生しており、DAX メジャーが存在する場合には、その数だけスプール評価が繰り返されます。これにより、行数や繰り返し実行回数が増えるほど、CPU 使用率やメモリ消費量が増加します(これは FE=Formula Engine 側の負荷上昇要因と対応します)。

下図は 🔴 MixedMeasures パターンの Physical Query Plan を示していますが、ご覧のとおり、Records に対して 200 万行規模の処理が複数回発生していることが確認できます。行数が多ければ多いほど、スキャンに要する時間やリソース消費が増加し、最終的にクエリの応答時間に大きな影響を及ぼします

通常であれば、こうした行数の多さが判明した際には、

  • 全体のテーブルスキャンを避ける
  • 必要な列だけを対象にしてスキャン量(行数×列数)を削減する

といった最適化手法を検討・実施します。

しかしながら、今回のケースでは「テーブルビジュアル上で最も細かい粒度(行レベル)での可視化」が要件となっていたため、これらの最適化は適用できません。

試しに、dPrefecture[Region] を軸に地域別の顧客数を集計する DAX クエリを取得し、パフォーマンス測定を行ったところ、実行時間は 17 ms (ミリ秒) という結果が得られました(※Rows =121行 は正しくないため、クエリプランで確認が必要)

結果およびクエリプランを確認したところ、処理対象の行数はわずか 8 行であり、処理が瞬時に完了していることが確認できました。

クエリパフォーマンスが向上するということは、CPU 使用量、すなわち Fabric の CU(Capacity Unit)消費が抑えられることを意味します。
これにより、他のワークロードへの影響を軽減し、容量の健全性を維持しやすくなるだけでなく、適切な Fabric SKU サイズの評価にもつながり、最終的にはコスト効率の向上にも寄与します。

最後に、DAX クエリを注意深くご覧いただくとお分かりになるかと思いますが、クエリの末尾にある EVALUATE ステートメントでは必ず TOPN 関数が使用されており、上位 500〜1,000 行程度のデータのみを抽出する構造になっていることにお気づきでしょうか。今回のように Power BI レポートで高いパフォーマンスが維持されている背景には、Power BI が実際には最初の数百件のデータしかフェッチしていないという挙動があります。このように、テーブルビジュアルから生成された DAX クエリでは、TOPN により上位 500 件前後のレコードだけが取得されるため、ユーザーが特に意識しなくてもパフォーマンスが自動的に最適化されている、非常にありがたい仕組みと言えるでしょう。

おまけ

🟢 大福帳モデル(Factのみ、メジャーなし = 数値列をそのまま使用)は以下の通りですが、この作り方における注意点が1つあります。

例えば、以下のように上場企業をカウントするメジャーを作ったとしtます。

DAX メジャー: 上場企業カウント

 上場企業カウント = 
    CALCULATE(
        COUNTROWS(Data),
        FILTER(
            VALUES(dListingClass[ListingClassName]),
            dListingClass[ListingClassName] = "プライム市場(旧・東証一部)"
        )
    )

これをテーブルに配置すると、DAX メジャーによってフィルターが適用され、不要な行が非表示にされてしまいます。そのため、このようなメジャーが含まれている場合は、初めから期待通りの結果が得られない可能性があることに留意する必要があります。

まとめ

Power BI においてはスタースキーマが原則的に推奨されます。モデルの圧縮効率、パフォーマンス、再利用性など、多くの利点があります。

しかしながら、今回紹介したように「Fact=Dim」「スナップショットデータ」「大量のスライサー+詳細表示」が組み合わさった極めて特殊な状況では、あえてスタースキーマを外すことでパフォーマンスが改善されることがあります。

スタースキーマは「原則」ではありますが、「絶対」ではありません。シナリオに応じて柔軟に設計判断を行うことが、より良いPower BIレポートの構築につながります。

次回は、もう一つのシナリオとレポートデザイン上の工夫を通じて、パフォーマンス改善のポイントについて解説していきたいと思います。

*1:VertiPaq は列内の値から辞書(ディクショナリ)を作成し、実際の値の代わりにその値が辞書内で何番目かというインデックスを格納します。つまり、列の元々のデータ型に関係なく、列の値は整数として格納されます。そのため、VertiPaq がある列に対してハッシュエンコーディングを選択した場合、技術的な観点では元のデータ型はほとんど関係がなく、VALUEエンコーディングが使用できない場合にはHASHエンコーディングが自動的に選択されます
Choosing between Date or Integer to represent dates in Power BI and Tabular - SQLBI

*2: DAX Studioの詳細はこちらより DAX_Studio | ドクセル

*3:DAX クエリが実行されたときの内部処理時間(FE / SE)を計測・可視化する機能。クエリのパフォーマンス分析や、どこに時間がかかっているか(SE: Storage Engine vs FE: Formula Engine)を確認したいとき

*4:DAX クエリの内部的な論理・物理処理の流れを視覚的に表示する機能。Server Timingsから得た情報を基に、どのテーブルがどんな順で処理されているかを把握し、最適化のヒントを得るとき見るもの

*5:クエリを実行するたびに、前回のログや出力を自動的にクリアする設定。キャッシュがクリアされるため、毎回クリーンな状態で結果を見たいときにONにする