C データエンジニアリング — パーソナライズ導線A/BテストCVR集計基盤(1ユーザー1行のab_assignmentsと1ユーザーN行のconversion_eventsを事前集約せず直接JOINしfan-out重複でCVRが水増し × ダッシュボードクエリのCURRENT_TIMESTAMP()で結果キャッシュが一切効かない × 割当比率50:50の閾値がSQLベタ書きでdbt Unit Testsが無くデプロイ時の書き間違いを検知できず × user_idクラスタリング未設定 × dbt incrementalがappendでリトライ時の重複が伝播 × Argo Workflowsのバッチ実行がダッシュボード閲覧ピークと重なりスロット輻輳 × 統計的有意性検定が無く目視判断)(MOps 販促システムチーム「新ロジックが有意に優れている」誤判断寸前 Bad→Good)

2026-09-02 (Day 149) 水曜 C: データエンジニアリング ★★★★☆ BigQuery / dbt Core 1.9+ Argo Workflows / Reservation API

概要

🔀

JOIN前にカーディナリティを揃える

1ユーザー1行のassignmentsと1ユーザーN行のconversion_eventsを直接JOINするとfan-outする。JOIN前に多側をuser_id単位へ事前集約し、常に1:1のJOINにするのが原則。

💾

非決定的関数はキャッシュを殺す

CURRENT_TIMESTAMP()を含むクエリはBigQueryの結果キャッシュ(24時間)の対象外。CURRENT_DATE()ベースの決定的な条件に変えるだけで同日内はキャッシュがヒットする。

🧪

「変えてはいけない定数」はUnit Testsで固定

割当比率50:50の閾値をSQLにベタ書きすると書き間違いに気づけない。dbt Unit Testsで固定入力・固定期待値による回帰検知を効かせる。

📊

見かけ上の差と統計的な差は別物

サンプルサイズ・信頼区間を経ない割合比較は、集計バグを増幅したまま意思決定に使われるリスクがある。二項比率のz検定で有意性を判定する。

問題

ECサイト MOps 販促システムチームでは、トップページのレコメンド導線を2パターン(control: 従来ロジック / variant: 新ロジック)でA/Bテストしている。ユーザーには初回訪問時にMOD(FARM_FINGERPRINT(user_id), 100) < 50というハッシュベースの割当ロジックで50:50の比率でcontrol/variantを割り当て、raw_events.ab_assignments(1ユーザー1行)に記録する。ユーザーが商品を購入するたびにraw_events.conversion_events(1ユーザーにつき複数回発生しうる)にイベントが追記される。この2つの生ログをdbt Core 1.9+でmart_ab_test_conversion(variant別CVR集計マート。Looker Studioの「A/Bテスト結果ダッシュボード」から5分間隔で参照される)に変換し、Argo Workflows CronWorkflowで毎時実行している。

先週起きた出来事: ダッシュボード上でvariantのCVRがcontrolを大きく上回り、担当者が「新ロジックの方が統計的に有意に優れている」と判断しかけたが、社内レビューで疑義が入り原因調査したところ、以下の実装には7つの設計・データ品質・コスト上の問題が潜んでいることが判明した。

問題点を全て洗い出し、ウィンドウ関数/事前集約によるJOIN fan-out重複の解消 × クエリ結果キャッシュを効かせる決定的なクエリ設計 × dbt Unit Tests(dbt Core 1.9+)による割当ロジックの回帰防止 × user_idクラスタリング × dbt incremental merge戦略 × BigQuery Reservation API(baseline + autoscale) × 二項比率検定(z検定)による統計的有意性判定を活用したBad→Goodリファクタリングを行ってください。

制約・前提条件

  • BigQuery Standard SQL、dbt Core 1.9+(incremental_strategy: merge、Unit Testsがいずれも利用可能)、Argo Workflows 3.5+ CronWorkflow
  • raw_events.ab_assignments: user_id(1ユーザー1行), variant, assigned_at
  • raw_events.conversion_events: user_id, conversion_id, order_amount, converted_at(1ユーザーにつき0回以上、複数回発生しうる。セール期間中は同一ユーザーが複数回購入することも珍しくない)
  • 現状のmart_ab_test_conversionは両テーブルをuser_idで直接JOINしており、複数回コンバージョンしたユーザーの割当行がコンバージョン件数分だけ複製される。GROUP BY variantでユーザー数をカウントすると、複数回購入したユーザーが多い方のvariantで「コンバージョンしたユーザー数」自体が水増しされる
  • Looker Studioが発行するダッシュボードクエリのWHERE句にWHERE assigned_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)という非決定的関数CURRENT_TIMESTAMP()が含まれ、BigQueryの結果キャッシュ(同一クエリ文字列・対象テーブル未更新なら24時間有効)が一切効かず、5分間隔の自動リフレッシュのたびに対象データをフルスキャンしている
  • 割当比率50:50を決めるMOD(FARM_FINGERPRINT(user_id), 100) < 50はdbtモデル内にCASE文でベタ書きされ、閾値50という数値にdbt Unit Testsによる検証が無い。今回のインシデントの真因のひとつは、直近のデプロイで担当者が誤って閾値を< 57と書き間違えたことであり、variant側に本来より多くのユーザーが割り当てられていた
  • ab_assignments・conversion_eventsいずれもuser_idでのJOINが主要アクセスパターンだが、クラスタリングが設定されておらず、数千万行規模のテーブル同士のJOINで不要なシャッフルコストが発生している
  • mart_ab_test_conversionを作るdbt incremental modelはincremental_strategy: append(追記のみ)になっており、Argo Workflowsのリトライ(ジョブがタイムアウトして再実行されるケースがある)で同一ユーザーの割当行が複数回insertされると、そのまま重複がマートに伝播する
  • Argo Workflows CronWorkflowはdbt実行を毎時0分に固定でスケジュールしており、これがLooker Studio利用者(経営陣・現場責任者)が集中する朝会・夕会の時間帯(9時・18時台)のダッシュボード閲覧ピークと重なる。BigQueryはオンデマンドスロットのみで運用しており、バッチクエリとダッシュボードの対話的クエリがスロットを奪い合い、表示が数十秒単位で遅延する
  • CVRの差をvariant_cvr - control_cvrの単純な割合比較でしか可視化しておらず、サンプルサイズや信頼区間、二項比率検定(z検定)などの統計的有意性の判定ロジックが組み込まれていない
期待する回答形式: 問題点の列挙(番号付き)+ 改善後dbtモデル・schema.yml(merge戦略・Unit Tests)+ 改善後BigQuery DDL(クラスタリング)+ 改善後ダッシュボードクエリ(キャッシュが効く設計)+ 改善後Terraform(Reservation API)+ 改善後Argo Workflowsスケジュール + 二項比率検定SQL + 実行例(input→output)+ 設計意図の説明

悪いコード (Before)

この実装には 7つの設計・データ品質・コスト上の問題 が隠れています。見つけてみてください。
models/marts/mart_ab_test_conversion.sql — 直接JOIN・append・ベタ書き閾値
-- config(materialized='incremental')  ※incremental_strategy未指定=append相当
-- 問題①: conversion_eventsを事前集約せずuser_idで直接JOIN → fan-out
-- 問題③: 割当閾値50がCASE文にベタ書き、Unit Testsが無い
-- 問題④: クラスタリング未設定
-- 問題⑤: incremental_strategyがappendでリトライ時の重複が伝播
SELECT
  a.user_id,
  CASE WHEN MOD(FARM_FINGERPRINT(a.user_id), 100) < 50
       THEN 'control' ELSE 'variant' END AS variant,
  a.assigned_at,
  c.conversion_id IS NOT NULL AS is_converted  -- 複数回購入で行が複製される
FROM {{ ref('stg_ab_assignments') }} a
LEFT JOIN {{ ref('stg_conversion_events') }} c USING (user_id)
dashboard_query.sql — 非決定的関数でキャッシュ無効化
-- 問題②: CURRENT_TIMESTAMP()を含むため結果キャッシュ(24h)が一切効かない
-- 5分間隔の自動リフレッシュのたびにフルスキャンが発生
SELECT variant, COUNT(*) AS n, COUNTIF(is_converted) AS conversions
FROM `mops-project.marts.mart_ab_test_conversion`
WHERE assigned_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY variant
-- 問題⑦: この後、割合比較のみで「有意」と判断されており
--        サンプルサイズ・信頼区間・有意性検定のロジックが無い
問題点サマリー(7点)
6Argo Workflowsが毎時0分固定 — ダッシュボード閲覧ピーク(9時/18時台)とバッチが衝突しオンデマンドスロット輻輳

ヒント(段階的開示)

ヒント1 — 方向性
問題は4層に分類できる。(1) 集計の正しさの層——「1ユーザー1行」と「1ユーザーN行」というカーディナリティが異なる2つのテーブルを直接JOINしてはいけない。JOINする前に、どちらの粒度に合わせて集計したいのか(今回は「ユーザー単位のCVR」)を先に決め、多側のテーブルを先に1行/ユーザーへ事前集約してからJOINする。(2) コストの層——クエリキャッシュ・クラスタリング・Reservationはいずれも「同じ処理を何度も高コストで繰り返している」ことへの対処であり、正しさの問題とは独立している。(3) ロジックの回帰防止の層——割当比率のような「一度決めたら簡単に変えてはいけない定数」は、コードレビューだけに頼らずUnit Testsで機械的に検証する。(4) 意思決定の層——CVRの差を見るとき、その差が「本当に統計的な差」なのか「集計バグやサンプルサイズ不足による見かけ上の差」なのかを区別する仕組みが無いと、(1)〜(3)の欠陥がそのまま経営判断を誤らせるリスクになる。
ヒント2 — アプローチ
  • 問題①: conversion_eventsを直接JOINするとfan-outする → JOIN前にuser_id単位でGROUP BYし、「コンバージョンしたか(0/1)」に事前集約してから1:1でJOINする
  • 問題②: CURRENT_TIMESTAMP()が結果キャッシュを無効化する → DATE(assigned_at) >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)のような決定的な条件に変更し、同日中は24時間キャッシュがヒットするようにする
  • 問題③: 割当閾値50がベタ書きでテストが無い → dbt変数と Unit Tests で、閾値が意図した値であることをCIで固定入力・固定期待値で検証する
  • 問題④: クラスタリング未設定でJOINが重い → 両テーブルにCLUSTER BY user_idを設定する
  • 問題⑤: dbt incrementalがappendでリトライ時の重複が伝播する → incremental_strategy: mergeとunique_key: user_idに変更する
  • 問題⑥: Argo Workflowsの実行時刻がダッシュボード閲覧ピークと重なる → 実行時刻をピーク帯を避けてずらし、BigQuery Reservation APIでbaseline+autoscaleのスロットプールをバッチ用に分離する
  • 問題⑦: 有意性検定が無く目視判断に頼っている → 二項比率のz検定(正規近似)をSQLで実装し、p値または信頼区間をダッシュボードに表示する
ヒント3 — コードの骨格
-- dbtモデルの骨格(事前集約してからJOIN)
WITH conversion_agg AS (
  SELECT user_id, COUNT(*) > 0 AS is_converted
  FROM {{ ref('stg_conversion_events') }}
  GROUP BY user_id                      -- fan-outを防ぐ要
)
SELECT a.user_id, a.variant, COALESCE(c.is_converted, FALSE) AS is_converted
FROM {{ ref('stg_ab_assignments') }} a
LEFT JOIN conversion_agg c USING (user_id)  -- 1:1のJOINになる
-- 二項比率検定(z検定)の骨格
WITH stats AS (
  SELECT variant, COUNT(*) AS n, SUM(CAST(is_converted AS INT64)) AS conversions
  FROM {{ ref('mart_ab_test_conversion') }}
  GROUP BY variant
)
-- pooled proportion → 標準誤差 → z値 の順で計算する

問題点分析(7点)

#問題点分類改善方法
1JOIN前に事前集約せず直接JOINしfan-out正しさ層user_id単位に事前集約してから1:1でJOIN
2CURRENT_TIMESTAMP()で結果キャッシュ無効化コスト層DATE()ベースの決定的な条件に変更
3割当閾値がベタ書きでUnit Testsが無い回帰防止層dbt変数化+Unit Tests
4user_idクラスタリング未設定コスト層CLUSTER BY user_id
5dbt incrementalがappendで重複伝播正しさ層incremental_strategy: merge + unique_key
6バッチ実行がダッシュボード閲覧ピークと衝突コスト層実行時刻分散 + Reservation API
7統計的有意性の判定ロジックが無い意思決定層二項比率のz検定SQL

パイプライン比較図 — Bad vs Good の比較

Bad(変更前)— fan-outと未検証の判断が積み重なる構成 ab_assignments(1行/user) + conversion_events(N行/user) 問題③: 割当閾値50がベタ書き、書き間違い(→57)が未検知 問題④: user_idクラスタリング無しでJOINが重い user_idで直接JOIN(事前集約なし) 問題①: 複数回購入ユーザーの割当行が複製(fan-out) variant側でコンバージョン数が水増しされる dbt incremental(append戦略) 問題⑤: Argo Workflowsリトライで重複行が再度insert 重複が是正されないまま蓄積 Argo Workflows 毎時0分固定 + オンデマンドスロットのみ 問題⑥: 朝会/夕会のダッシュボード閲覧ピークと衝突 スロット奪い合いで表示が数十秒遅延 Looker Studio(CURRENT_TIMESTAMP()でキャッシュ無効化) 問題②: 5分毎にフルスキャン、月間コストも増大 問題⑦: 単純割合比較のみで「有意」と誤判断寸前 実績: variant CVR 11.4% vs control 8.1%と見えた × fan-outで見かけ上の差が拡大されている × 母集団比率自体が閾値ミスで歪んでいる × 有意性検定を経ずに経営判断へ直結しかけた Good(変更後)— 正しさ・コスト・回帰防止・判定を分離して積む ab_assignments + conversion_events(CLUSTER BY user_id) 修正③④: 閾値はdbt変数+Unit Testsで固定、クラスタリング付与 母集団比率とJOINコストの両方を守る conversion_eventsをuser_id単位に事前集約 → 1:1でJOIN 修正①: GROUP BYで1行/ユーザーに畳んでからJOIN fan-outが構造的に発生しない dbt incremental(merge + unique_key: user_id) 修正⑤: リトライで再insertされても最終的に1行へ収束 オーケストレーションのリトライ前提に対応 Argo Workflowsをピーク帯回避 + Reservation API(baseline+autoscale) 修正⑥: バッチとダッシュボードのスロットプールを分離 閲覧ピークでも表示遅延が起きない Looker Studio(DATE()ベースの決定的クエリでキャッシュヒット) 修正②: 同日内は結果キャッシュが効きスキャンコスト削減 修正⑦: 二項比率z検定で|z|=1.42 → 有意水準5%未満と判定 実際には統計的有意差が無かったことが判明 ✓ 事前集約でfan-outが構造的に発生しない ✓ 母集団比率・コスト・回帰防止が独立して守られる ✓ z検定を経てから経営判断に使われる 修正

模範解答

models/marts/mart_ab_test_conversion.sql(修正①⑤: 事前集約 + merge)
-- config(materialized='incremental', incremental_strategy='merge', unique_key='user_id')
--
-- 修正①: conversion_eventsをuser_id単位に事前集約してからJOINし、fan-outを防ぐ
-- 修正⑤: incremental_strategy=mergeにより、Argo Workflowsのリトライで
--        割当行が複数回insertされても、user_id単位で最終的に1行へ収束する

WITH assignments AS (
  SELECT user_id, variant, assigned_at
  FROM {{ ref('stg_ab_assignments') }}
  {% if is_incremental() %}
  WHERE assigned_at > (SELECT MAX(assigned_at) FROM {{ this }})
  {% endif %}
),

-- 修正①: 1ユーザーにつき複数回のコンバージョンを、ここで1行に集約してから
--        assignmentsとJOINする。JOIN後もuser_id単位の粒度が保たれる。
conversion_agg AS (
  SELECT
    user_id,
    COUNT(*) > 0 AS is_converted,
    SUM(order_amount) AS total_conversion_amount,
    MIN(converted_at) AS first_converted_at
  FROM {{ ref('stg_conversion_events') }}
  GROUP BY user_id
)

SELECT
  a.user_id,
  a.variant,
  a.assigned_at,
  COALESCE(c.is_converted, FALSE) AS is_converted,
  COALESCE(c.total_conversion_amount, 0) AS total_conversion_amount,
  c.first_converted_at
FROM assignments a
LEFT JOIN conversion_agg c USING (user_id)  -- 修正①: 1:1のJOINになりfan-outしない
_marts.yml(修正③: dbt変数 + Unit Tests)
version: 2

models:
  - name: mart_ab_test_conversion
    config:
      materialized: incremental
      incremental_strategy: merge   # 修正⑤
      unique_key: user_id           # 修正⑤: リトライ時の重複をここで収束させる
    columns:
      - name: user_id
        data_type: string
        constraints: [{ type: not_null }, { type: primary_key }]
      - name: variant
        data_type: string
        constraints: [{ type: not_null }, { type: check, expression: "variant IN ('control', 'variant')" }]

vars:
  ab_test_split_threshold: 50   # 修正③: 割当比率をdbt変数として一元管理

# 修正③: 割当ロジックの閾値が意図通りであることを固定入力で検証する
unit_tests:
  - name: test_assignment_split_is_50_50
    description: >
      MOD(FARM_FINGERPRINT(user_id), 100) < {{ var('ab_test_split_threshold') }} という
      割当ロジックが、閾値50のとき固定ハッシュ値の集合に対して意図通り約50%を
      variantに割り当てることを検証する。閾値の書き間違い(50→57)をCIで検知するための回帰テスト。
    model: stg_ab_assignments
    given:
      - input: ref('raw_ab_hash_fixture')
        rows:
          - { user_id: "u1", hash_mod_100: 10 }   # < 50 → control
          - { user_id: "u2", hash_mod_100: 49 }   # < 50 → control
          - { user_id: "u3", hash_mod_100: 50 }   # >= 50 → variant
          - { user_id: "u4", hash_mod_100: 99 }   # >= 50 → variant
    expect:
      rows:
        - { user_id: "u1", variant: "control" }
        - { user_id: "u2", variant: "control" }
        - { user_id: "u3", variant: "variant" }
        - { user_id: "u4", variant: "variant" }
Before — クラスタリング無し
CREATE TABLE `mops-project.raw_events.ab_assignments`
(
  user_id STRING NOT NULL,
  variant STRING NOT NULL,
  assigned_at TIMESTAMP NOT NULL
)
PARTITION BY DATE(assigned_at);
-- 問題④: CLUSTER BYが無くuser_idでのJOINが重い
After — user_idクラスタリング付き
-- 修正④: user_idでのJOINが主要アクセスパターンのためクラスタリングを設定
CREATE TABLE IF NOT EXISTS `mops-project.raw_events.ab_assignments`
(
  user_id STRING NOT NULL,
  variant STRING NOT NULL,
  assigned_at TIMESTAMP NOT NULL
)
PARTITION BY DATE(assigned_at)
CLUSTER BY user_id;   -- 修正④

CREATE TABLE IF NOT EXISTS `mops-project.raw_events.conversion_events`
(
  conversion_id STRING NOT NULL,
  user_id STRING NOT NULL,
  order_amount NUMERIC,
  converted_at TIMESTAMP NOT NULL
)
PARTITION BY DATE(converted_at)
CLUSTER BY user_id;   -- 修正④
Before — CURRENT_TIMESTAMP()でキャッシュ無効化
SELECT variant, COUNT(*) AS n, COUNTIF(is_converted) AS conversions
FROM `mops-project.marts.mart_ab_test_conversion`
WHERE assigned_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY variant
-- 問題②: 秒単位で値が変わる関数のため結果キャッシュが一切効かない
After — 決定的な条件でキャッシュがヒット
-- 修正②: CURRENT_DATE()は1日1回しか値が変わらないため
--        同日中に発行される同一クエリは結果キャッシュ(24時間)がヒットする
SELECT
  variant,
  COUNT(*) AS n,
  COUNTIF(is_converted) AS conversions,
  SAFE_DIVIDE(COUNTIF(is_converted), COUNT(*)) AS cvr
FROM `mops-project.marts.mart_ab_test_conversion`
WHERE DATE(assigned_at) >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
GROUP BY variant
reservation.tf(修正⑥: Reservation API)
# 修正⑥: ダッシュボード用とバッチ用でスロットプールを分離し、
#        オンデマンドスロットの奪い合いを構造的に避ける
resource "google_bigquery_reservation" "ab_test_batch" {
  name     = "ab-test-batch-reservation"
  project  = "mops-project"
  location = "asia-northeast1"

  slot_capacity = 100          # baseline: 常時確保するスロット
  edition       = "ENTERPRISE"

  autoscale {
    max_slots = 300            # ピーク時(dbt実行×ダッシュボード集中)は自動拡張
  }
}

resource "google_bigquery_reservation_assignment" "batch_assignment" {
  reservation = google_bigquery_reservation.ab_test_batch.id
  job_type    = "PIPELINE"     # dbt実行(バッチ)をこのReservationに割り当て
  project     = "mops-project"
}
cronworkflows/ab-test-mart.yaml(修正⑥: スケジュール分散)
apiVersion: argoproj.io/v1alpha1
kind: CronWorkflow
metadata:
  name: ab-test-mart-hourly
spec:
  # 修正⑥: 朝会(9時台)・夕会(18時台)のダッシュボード閲覧ピークを避け、
  #        毎時25分・55分(ピーク直前を避けた分)に実行をずらす
  schedules:
    - "25,55 0-8,10-17,19-23 * * *"   # 9時台・18時台はスキップ
  concurrencyPolicy: Forbid
  workflowSpec:
    entrypoint: dbt-run
    templates:
      - name: dbt-run
        container:
          image: gcr.io/mops-project/dbt:1.9
          command: ["dbt", "build", "--select", "mart_ab_test_conversion"]
z_test_ab.sql(修正⑦: 二項比率のz検定)
-- 二項比率のz検定(正規近似)。有意水準5%(|z| >= 1.96)を基準に判定する。
WITH stats AS (
  SELECT
    variant,
    COUNT(*) AS n,
    COUNTIF(is_converted) AS conversions,
    SAFE_DIVIDE(COUNTIF(is_converted), COUNT(*)) AS p
  FROM `mops-project.marts.mart_ab_test_conversion`
  GROUP BY variant
),
pivoted AS (
  SELECT
    MAX(IF(variant = 'control', n, NULL)) AS n_control,
    MAX(IF(variant = 'control', p, NULL)) AS p_control,
    MAX(IF(variant = 'variant', n, NULL)) AS n_variant,
    MAX(IF(variant = 'variant', p, NULL)) AS p_variant
  FROM stats
),
pooled AS (
  SELECT
    *,
    SAFE_DIVIDE(p_control * n_control + p_variant * n_variant, n_control + n_variant) AS p_pool
  FROM pivoted
)
SELECT
  n_control, p_control, n_variant, p_variant,
  p_variant - p_control AS diff,
  SAFE_DIVIDE(
    p_variant - p_control,
    SQRT(p_pool * (1 - p_pool) * (SAFE_DIVIDE(1, n_control) + SAFE_DIVIDE(1, n_variant)))
  ) AS z_score,
  ABS(SAFE_DIVIDE(
    p_variant - p_control,
    SQRT(p_pool * (1 - p_pool) * (SAFE_DIVIDE(1, n_control) + SAFE_DIVIDE(1, n_variant)))
  )) >= 1.96 AS is_significant_at_5pct
FROM pooled;
問題修正内容効果
① 直接JOINでfan-outuser_id単位に事前集約してから1:1でJOINCVRの水増しが構造的に発生しない
② CURRENT_TIMESTAMP()でキャッシュ無効化DATE()ベースの決定的な条件に変更同日内は結果キャッシュがヒットしコスト削減
③ 割当閾値がベタ書きdbt変数化 + Unit Testsで固定入出力を検証閾値の書き間違いをCIで検知
④ クラスタリング未設定CLUSTER BY user_idJOINのシャッフルコスト削減
⑤ incrementalがappendincremental_strategy: merge + unique_keyリトライ時の重複を最終的に収束
⑥ バッチとダッシュボードのスロット衝突実行時刻分散 + Reservation API(baseline+autoscale)閲覧ピークでも表示遅延が起きない
⑦ 有意性検定が無い二項比率のz検定SQL見かけ上の差と統計的な差を区別できる

実行例(input → output)

input(改善前のシナリオ):

7日間でcontrolに49,800ユーザー、variant(閾値の書き間違いにより実際は本来より多い)に52,300ユーザーが割り当てられた。うちvariant側でセール期間中に3回購入したユーザーが2,000人おり、conversion_eventsとのJOINでこの2,000人の割当行が3行ずつに複製された。

output(Before・問題のある実装):

JOIN後の行数がユーザー数と一致せず、variant側は複製された分だけ「コンバージョンしたユーザー数」が多く見え、control CVR=8.1% に対し variant CVR=11.4% と表示される。ダッシュボードはCURRENT_TIMESTAMP()のせいで5分ごとにフルスキャンし直され、月間BigQueryコストも増大している。単純な割合比較のみで「variantが有意に優れている」と判断されかけた。

output(After・改善後):

conversion_aggでユーザー単位に事前集約してからJOINするため、複数回購入したユーザーも1行のまま扱われ、CVRが正しい母数で計算される。割当閾値のUnit Testsがdbt build時点で書き間違いを検知するため、正しい50:50の母集団で比較できる。二項比率のz検定を実施した結果|z| = 1.42(有意水準5%の基準1.96未満)となり、実際には統計的有意差は無かったことが判明する。ダッシュボードクエリは決定的な日付条件に変わり結果キャッシュがヒットするため、5分間隔のリフレッシュでも同日内はスキャンコストが発生しない。

ポイント解説

1カーディナリティの異なるテーブルは、JOIN前に目的の粒度へ事前集約する — 「1ユーザー1行」と「1ユーザーN行」を直接JOINすると、多側の行数だけ少側が複製される(fan-out)。集計したい粒度を先に決め、多側をGROUP BYで1行/粒度に畳んでからJOINするのが原則。
2非決定的関数はWHERE句からできる限り排除する — CURRENT_TIMESTAMP()やRAND()を含むクエリはBigQueryの結果キャッシュの対象外になる。日次以上の粒度で十分ならCURRENT_DATE()ベースの決定的な条件に置き換えるだけで、同日内の再実行はキャッシュヒットしコストと表示速度の両方が改善する。
3「変えてはいけない定数」はUnit Testsで固定する — 割当比率のような一度決めたら安易に変えるべきでない値は、コードレビューの見落としに備えてdbt Unit Testsのような機械的検証を用意する。ベタ書きの数値ほど書き間違いに気づきにくい。
4クラスタリングはJOINキーにも効く — WHERE句の絞り込みだけでなく、頻出するJOINキーにクラスタリングを設定することで、大規模テーブル同士のJOINのシャッフルコストを削減できる。
5incremental戦略はオーケストレーション層の再実行前提で選ぶ — Argo Workflowsのようなオーケストレーションはリトライを前提とする。appendはリトライで重複が生じることを想定していない戦略であり、merge+unique_keyの方が安全。
6バッチとダッシュボードのスロットは分離する — オンデマンドスロットは単一プールを全クエリが奪い合う。Reservation APIでbaseline+autoscaleのプールを用意し、用途別に割り当てることで、片方の負荷がもう片方の性能を引きずり下ろす事態を避けられる。
7見かけ上の差と統計的な差は別物である — サンプルサイズ・信頼区間・有意性検定を経ない割合比較は、データ品質のバグを増幅したまま意思決定に使われるリスクがある。集計基盤の正しさと統計的判定はセットで設計する。

実務への応用

MOpsチームが運用するA/Bテスト集計基盤は、レコメンド導線に限らずクーポン施策・バナー配置・価格表示のテストなど横展開が多い場面であり、「割当テーブル(1:1) × イベントテーブル(1:N)」という同型の構造を持つケースが繰り返し登場する。今回のJOIN fan-out対策(事前集約してからJOIN)は、A/Bテスト集計に限らず「ユーザー単位の指標をイベントログから作る」あらゆるマート設計の基本パターンとして再利用できる。また、クエリキャッシュを意識したダッシュボードクエリ設計は、A/Bテストに限らず全社ダッシュボードのコスト最適化にそのまま応用できる実務的なチェック項目になる。

統計的判定はデータエンジニアの仕事の一部: 集計基盤にバグがあっても、有意性検定という「もう一段の検証」があれば、その差が本当に意味のある差なのかを機械的に問い直せる。数値の正しさだけでなく、その数値をどう解釈すべきかまで設計に含めることが、意思決定を誤らせないための最後の砦になる。

今日のまとめ

カーディナリティの異なるテーブルを事前集約せずにJOINするとfan-outで集計結果が水増しされ、これは重複防止(incremental merge)とは別軸のバグである。 加えて非決定的関数によるキャッシュ無効化・クラスタリング未設定・スロット輻輳という3つのコスト問題、割当比率のようなベタ書き定数をUnit Testsで固定する回帰防止、そして最後に統計的有意性検定を欠いた目視判断——これらは互いに独立した問題でありながら、今回のように複合すると「バグで水増しされた見かけ上の差を、有意な差だと誤判断する」という意思決定リスクに直結する。データ基盤の正しさとコスト効率、そして統計的な判定ロジックは、いずれか一つでも欠けると意思決定を誤らせ得るという点をセットで押さえる必要がある。

次のステップ

  • 発展問題: 今回はvariantが2値(control/variant)だが、3パターン以上の多変量テスト(A/B/n テスト)に拡張する場合、二項比率検定ではなくカイ二乗検定(またはBonferroni補正付きの多重比較)が必要になる。mart_ab_test_conversionをn変量対応に拡張し、多重比較補正込みの有意性判定SQLを設計せよ。
  • 参考: BigQueryクエリ結果キャッシュの条件(決定的クエリ) / dbt Unit Tests(dbt Core 1.9+) / dbt incremental merge strategy / BigQuery Reservation API(baseline + autoscale) / 二項比率のz検定・多重比較補正

自己評価(あとで記入)