概要
Storage Write APIのオフセットで重複を「書かせない」
Pub/Subはat-least-once配信で再送が前提。BigQuery Storage Write APIにオフセットを明示指定すると、同一オフセットの再送はAPI側で重複として無視され、アプリ側の重複チェックSELECTが不要になる。
dbt incremental mergeを第2の防波堤にする
生ログ側で万一重複が残っても、incremental_strategy: mergeとunique_key: event_idでマート側は最終的に1行へ収束する。1つの防御に頼らない多層設計が要。
dbt Unit Testsは重複排除とは別軸の防御
今回の事故の直接原因は倍率マスタの結合ミスであり重複排除では防げない。固定入力・固定期待値のUnit Testでロジック回帰をCI時点で検知する。
パーティションはデータの実態に合わせる
取り込み日でパーティションを切るとバックフィル時に実際のevent_timestampとズレる。日次集計のWHERE句が意図通りプルーニングされなくなる。
問題
ECサイト MOps 販促システムチームでは、購入時にユーザーへポイントを付与するポイント付与基盤を運用している。決済システムが購入確定時にPub/Subトピックpoint-grant-events(at-least-once配信。再配信により同一イベントが複数回届く可能性がある)へイベントをpublishし、Cloud Run jobs上のPythonコンシューマがこれを購読してBigQueryの生ログテーブルraw_events.point_grant_eventsへ書き込む。このテーブルをdbt Core 1.8+のincremental modelでmart_point_ledger(ユーザーごとのポイント台帳。カスタマーサポート画面やポイント残高APIから参照される)に変換している。
先週起きたインシデント: キャンペーン倍率マスタの参照ミスとPub/Subの再配信が重なり、一部ユーザーへポイントが実際の10倍以上誤って付与される事故が発生し、数日間気づかれなかった。原因調査の結果、以下の実装には7つの設計・データ品質上の問題が潜んでいることが判明した。
問題点を全て洗い出し、BigQuery Storage Write APIのオフセット指定による重複書き込み防止 × dbt incrementalのmerge戦略(unique_key: event_id) × dbt Unit Tests(dbt Core 1.8+)によるポイント計算ロジックの回帰防止 × event_timestampによるパーティション設計とクラスタリング × Argo Workflowsバックフィルのチェックポイント管理による冪等な再実行を活用したBad→Goodリファクタリングを行ってください。
制約・前提条件
- BigQuery Standard SQL、BigQuery Storage Write API(GA機能。デフォルトストリームでもオフセットを明示指定すれば重複排除=exactly-once相当のセマンティクスが得られる)
- dbt Core 1.8+(
incremental_strategy: merge、dbt Unit Testsがいずれも利用可能) - Cloud Run jobs(Python 3.12、
google-cloud-bigquery-storageクライアント)、Argo Workflows 3.5+(バックフィル用DAG) - Pub/Subサブスクリプション
point-grant-events-subはat-least-once配信。ネットワーク瞬断やCloud Run jobsの再起動により、同一event_idを持つメッセージが複数回コンシューマに届くことがある - 現状のコンシューマ実装は、BigQuery Storage Write APIのデフォルトストリームにオフセットを指定せず
append_rowsを呼び出しており、Pub/Sub側のリトライでAPI呼び出し自体もリトライされると、同一イベントがBigQueryに複数回挿入される mart_point_ledgerを作るdbt incremental modelはincremental_strategy: append(追記のみ)になっており、生ログ側で重複行が発生するとそのままマート側にも伝播し、ユーザーのポイント残高(SUM(point_amount))が過大計算される- ポイント計算ロジック(
base_rate(1%) × campaign_multiplier)はSQLのCASE文でベタ書きされ、テストが一切無い。今回の事故はcampaign_multiplierの参照先テーブルの結合キーミスで倍率が10倍相当の値になっていたことが直接原因だが、これを検知するテストが存在しなかった raw_events.point_grant_eventsは取り込み日(コンシューマがBigQueryに書き込んだ日)でパーティション分割されている。バックフィルを行うと実際のイベント発生日(event_timestamp)とは異なるパーティションに書き込まれ、日次残高集計のパーティションプルーニングが効かずフルスキャンになる- テーブルにクラスタリングが設定されておらず、カスタマーサポート画面の「特定ユーザーのポイント残高照会」クエリ(
WHERE user_id = ?)が数十GBスキャンしている - Argo Workflowsのバックフィルは特定日の再取り込みが途中で失敗するとその日全体を最初から再実行する設計で、どのマイクロバッチまで成功済みかを記録する仕組みが無い
incremental_strategy: merge、dbt Unit Test)+ 改善後 BigQuery DDL(パーティション/クラスタリング)+ 改善後 Argo Workflowsバックフィル(チェックポイント管理)+ 実行例(input→output)+ 設計意図の説明
悪いコード (Before)
# 問題①: Storage Write APIにoffsetを指定せずappend_rows
# 問題②: 受信直後に無条件でackしており、Pub/Subの再配信を
# 前提とした冪等化の起点が存在しない
def handle_pubsub_message(message, write_client, stream_name):
proto_rows = build_proto_rows(message)
request = types.AppendRowsRequest(write_stream=stream_name)
request.proto_rows = types.AppendRowsRequest.ProtoData(rows=proto_rows)
write_client.append_rows(request) # offset未指定→再送のたび重複挿入
message.ack()
-- config(materialized='incremental') ※incremental_strategy未指定=append相当
-- 問題③: 生ログ側の重複がそのままマートへ伝播
-- 問題④: base_rate×campaign_multiplierの計算にdbt Unit Testが無い
SELECT
event_id, user_id,
purchase_amount * 0.01 * campaign_multiplier AS point_amount
FROM {{ ref('stg_point_grant_events') }}
{% if is_incremental() %}
WHERE event_timestamp > (SELECT MAX(event_timestamp) FROM {{ this }})
{% endif %}
ヒント(段階的開示)
ヒント1 — 方向性
ヒント2 — アプローチ
- 問題①②: Pub/Subの再配信でBigQuery Storage Write APIへの書き込みも重複しうる → デフォルトストリームにオフセットを明示指定して
append_rowsを呼び出し、同一オフセットの再送はBigQuery側で重複として無視されるようにする - 問題③: dbt incrementalが
appendのため生ログの重複がそのままマートへ伝播 →incremental_strategy: mergeとunique_key: event_idを設定し、生ログ側に重複が残っていてもマート側では1イベント1行に収束させる(多層防御の2段目) - 問題④: ポイント計算ロジックにテストが無く、倍率参照ミスが検知できなかった → dbt Unit Tests(
unit_tests:)で固定入力に対する期待出力を定義し、モデルのロジック変更やマスタ結合の破壊的変更をCIで検知する - 問題⑤: パーティションが取り込み日ベースでバックフィル時にズレる →
PARTITION BY DATE(event_timestamp)に変更し、データの実態に合わせてパーティションを切り直す - 問題⑥: クラスタリング未設定でユーザー単位の照会が高コスト →
CLUSTER BY user_idを追加する - 問題⑦: バックフィル失敗時に日全体を再実行 → Argo Workflowsでチェックポイントテーブルにマイクロバッチ単位の成功状態を記録し、リトライ時は未成功分のみを対象にする
ヒント3 — コードの骨格
# Cloud Run jobs コンシューマの骨格(オフセット指定によるexactly-once)
from google.cloud.bigquery_storage_v1 import types, writer
stream_name = write_client.default_stream_name(project, dataset, table)
def append_with_offset(rows: list[dict], offset: int) -> None:
request = types.AppendRowsRequest(write_stream=stream_name)
request.offset = offset # 同一offsetの再送は重複として無視される
...
# dbt schema.yml の骨格
models:
- name: mart_point_ledger
config:
incremental_strategy: merge
unique_key: event_id # 生ログ側の重複をここで最終的に収束させる
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | Storage Write APIにオフセット未指定 | 重複防止層 | append_rowsにoffsetを明示指定 |
| 2 | 再配信を前提とした冪等化の起点が無い | 重複防止層 | 書き込み成功確認後にack |
| 3 | dbt incrementalがappendで重複伝播 | 重複防止層 | incremental_strategy: merge + unique_key |
| 4 | ポイント計算にdbt Unit Testsが無い | ロジック検証層 | 固定入力・固定期待値のunit_tests |
| 5 | 取り込み日パーティションがバックフィルでズレる | パーティション設計層 | PARTITION BY DATE(event_timestamp) |
| 6 | クラスタリング未設定 | パーティション設計層 | CLUSTER BY user_id |
| 7 | バックフィルにチェックポイントが無い | オーケストレーション層 | マイクロバッチ単位の成功記録 |
多層防御パイプライン図 — Bad vs Good の比較
模範解答
"""Pub/Sub point-grant-events を購読し、BigQuery Storage Write API へ
オフセット指定付きで書き込むコンシューマ。
Pub/Subはat-least-once配信のため、同一event_idのメッセージが
複数回届くことを前提とする。BigQuery側でオフセットベースの
重複排除が効くよう、event単位で単調増加するoffsetを明示指定する。
"""
from google.cloud.bigquery_storage_v1 import types, BigQueryWriteClient
from google.cloud.bigquery_storage_v1.writer import AppendRowsStream
# 修正①: offsetをCloud Run jobsの永続ストレージ(Firestore等)で管理し、
# 再起動後も直近の書き込み位置から再開できるようにする
_OFFSET_STORE_COLLECTION = "point_grant_consumer_offsets"
def append_event_with_offset(
write_client: BigQueryWriteClient,
stream_name: str,
proto_rows: types.ProtoRows,
offset: int,
) -> types.AppendRowsResponse:
"""BigQuery Storage Write APIへオフセット指定で1バッチ書き込む。
Args:
write_client: BigQuery Storage Write APIクライアント。
stream_name: デフォルトストリーム名(プロジェクト/データセット/テーブル単位)。
proto_rows: 書き込む行データ(protobuf形式)。
offset: このバッチの書き込み開始オフセット。Pub/Subの再配信で
同じバッチを再送しても、同一offsetならBigQuery側で
重複として無視される(exactly-once相当のセマンティクス)。
Returns:
AppendRowsResponse: 書き込み結果。
Raises:
google.api_core.exceptions.GoogleAPICallError: 書き込み失敗時。
"""
request = types.AppendRowsRequest(write_stream=stream_name)
request.offset = offset # 修正①: 明示的なoffset指定が重複防止の要
proto_data = types.AppendRowsRequest.ProtoData(rows=proto_rows)
request.proto_rows = proto_data
append_stream = AppendRowsStream(write_client, request)
response = append_stream.send(request).result()
return response
def handle_pubsub_message(message, write_client, stream_name, offset_store) -> None:
"""Pub/Subメッセージ1件を処理し、ackを返す。
修正②: 重複配信を前提に、event_idごとの処理済みoffsetを
先に確認してから書き込みを行う(受信直後の無条件ackを避ける)。
"""
event_id = message.attributes["event_id"]
next_offset = offset_store.get_next_offset() # 単調増加するoffset
proto_rows = build_proto_rows(message)
append_event_with_offset(write_client, stream_name, proto_rows, next_offset)
offset_store.advance(next_offset, event_id)
message.ack() # 書き込み成功を確認してからack
version: 2
models:
- name: mart_point_ledger
description: >
ユーザーごとのポイント付与台帳。生ログ(raw_events.point_grant_events)から
incremental mergeで構築し、event_id単位で重複を最終的に収束させる。
config:
materialized: incremental
incremental_strategy: merge # 修正③: append→mergeへ変更
unique_key: event_id # 修正③: 生ログ側の重複をここで吸収する最終防波堤
columns:
- name: event_id
data_type: string
constraints: [{ type: not_null }, { type: primary_key }]
- name: user_id
data_type: string
constraints: [{ type: not_null }]
- name: point_amount
data_type: numeric
unit_tests:
- name: test_point_amount_normal_campaign
description: >
通常キャンペーン(倍率2倍)時、購入金額1000円に対して
1%基本レート×2倍 = 20ポイントが計算されることを検証する。
今回の事故のような「倍率マスタの結合ミスで倍率が異常値になる」
回帰をCIで検知するための固定入力テスト。
model: mart_point_ledger
given:
- input: ref('stg_point_grant_events')
rows:
- { event_id: "evt_1", purchase_amount: 1000, campaign_multiplier: 2 }
- input: ref('stg_campaign_master')
rows:
- { campaign_id: "camp_default", multiplier: 2 }
expect:
rows:
- { event_id: "evt_1", point_amount: 20 } # 1000 * 0.01 * 2
- name: test_point_amount_multiplier_join_miss_is_capped
description: >
倍率マスタの結合キーが一致せずNULLになった場合、
異常な倍率でポイントが計算されない(基本レートのみが
適用される)ことを検証する。
model: mart_point_ledger
given:
- input: ref('stg_point_grant_events')
rows:
- { event_id: "evt_2", purchase_amount: 1000, campaign_multiplier: null }
expect:
rows:
- { event_id: "evt_2", point_amount: 10 } # 1000 * 0.01 * 1(デフォルト倍率)
CREATE TABLE `mops-project.raw_events.point_grant_events`
(
event_id STRING NOT NULL,
user_id STRING NOT NULL,
purchase_amount NUMERIC,
campaign_multiplier NUMERIC,
event_timestamp TIMESTAMP NOT NULL,
ingested_at TIMESTAMP NOT NULL
)
PARTITION BY DATE(ingested_at); -- 問題⑤: 取り込み日でパーティション分割
-- 問題⑥: CLUSTER BYが無い
-- 修正⑤: 取り込み日ではなくevent_timestamp(データの実態)でパーティション分割
-- 修正⑥: ユーザー単位の照会が多いためuser_idでクラスタリング
CREATE TABLE IF NOT EXISTS `mops-project.raw_events.point_grant_events`
(
event_id STRING NOT NULL,
user_id STRING NOT NULL,
purchase_amount NUMERIC,
campaign_multiplier NUMERIC,
event_timestamp TIMESTAMP NOT NULL,
ingested_at TIMESTAMP NOT NULL -- 取り込み日時は監査用に残すが分割キーには使わない
)
PARTITION BY DATE(event_timestamp) -- 修正⑤
CLUSTER BY user_id -- 修正⑥
OPTIONS (
partition_expiration_days = 730
);
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: backfill-point-events-
spec:
entrypoint: backfill
arguments:
parameters:
- name: target-date # 例: 2026-08-20
templates:
- name: backfill
steps:
# 修正⑦-1: 対象日をマイクロバッチ(1時間単位)に分割し、
# チェックポイントテーブルで成功済みバッチを事前に除外する
- - name: list-pending-batches
template: list-pending-batches
arguments:
parameters: [{ name: target-date, value: "{{workflow.parameters.target-date}}" }]
- - name: process-batch
template: process-hourly-batch
arguments:
parameters: [{ name: hour, value: "{{item}}" }]
withParam: "{{steps.list-pending-batches.outputs.result}}"
- name: list-pending-batches
inputs:
parameters: [{ name: target-date }]
script:
image: gcr.io/mops-project/bq-tools:1.8
command: [python]
source: |
# 修正⑦-2: ops_backfill_checkpoints に status='succeeded' が
# 記録されていない時間帯(hour)のみを再処理対象として返す
from google.cloud import bigquery
client = bigquery.Client()
query = """
SELECT hour
FROM UNNEST(GENERATE_ARRAY(0, 23)) AS hour
WHERE hour NOT IN (
SELECT hour FROM `mops-project.ops.backfill_checkpoints`
WHERE target_date = @target_date AND status = 'succeeded'
)
"""
job = client.query(query, job_config=bigquery.QueryJobConfig(
query_parameters=[
bigquery.ScalarQueryParameter("target_date", "DATE", "{{inputs.parameters.target-date}}")
]
))
print([row.hour for row in job.result()])
- name: process-hourly-batch
inputs:
parameters: [{ name: hour }]
steps:
- - name: reload-batch
template: reload-hourly-batch
- - name: mark-succeeded
# 修正⑦-3: マイクロバッチ単位で成功をチェックポイントに記録
# (dbt側のmergeと合わせた二重防御)
template: mark-checkpoint-succeeded
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ①② オフセット未指定・ack設計 | append_rowsにoffsetを明示指定 | 生ログ時点で重複がほぼ発生しない(第1防御) |
| ③ dbt incrementalがappend | incremental_strategy: merge + unique_key | 万一の重複をマート側で最終収束(第2防御) |
| ④ ロジックにテストが無い | dbt Unit Testsで固定入出力を検証 | 倍率参照ミスのような論理バグをCIで検知 |
| ⑤ 取り込み日パーティション | PARTITION BY DATE(event_timestamp) | バックフィルでもプルーニングが機能 |
| ⑥ クラスタリング未設定 | CLUSTER BY user_id | ユーザー単位照会のスキャン量削減 |
| ⑦ バックフィルにチェックポイント無し | マイクロバッチ単位の成功記録 | 無駄な再処理コストと二重防御の欠如を解消 |
実行例(input → output)
input(改善前のシナリオ):
Pub/Subのネットワーク瞬断により、event_id=evt_9001(購入金額5,000円、キャンペーン倍率2倍、正しい期待ポイント: 100pt)のメッセージが3回コンシューマに再配信された。
output(Before・問題のある実装):
Storage Write APIにオフセット指定が無いため3回とも書き込みが成功し、生ログにevt_9001が3行重複して記録される。dbt incrementalがappendのため、mart_point_ledgerにも3行が伝播し、SUM(point_amount)によるユーザー残高計算で100ptのはずが300pt付与されてしまう。さらにキャンペーン倍率マスタの結合ミス(倍率が誤って20倍相当になっていた場合)が起きても、これを検知するテストが無いため、campaign_multiplierが異常値のまま本番稼働し続ける。
output(After・改善後):
2回目・3回目の再配信は、Storage Write APIが同一offsetの書き込みを重複として無視するため生ログには1行のみ記録される。仮に何らかの理由で生ログに重複が残ったとしても、dbt incrementalがmergeかつunique_key: event_idのため、mart_point_ledger側では最終的に1行に収束し、残高は正しく100ptとなる。またキャンペーン倍率マスタの結合ミスによる異常値は、test_point_amount_multiplier_join_miss_is_cappedのdbt Unit Testがdbt build時点で失敗することで検知され、本番稼働前にCIで止まる。
ポイント解説
user_idでクラスタリングすることでスキャン量を大幅に削減できる。実務への応用
MOpsチームが扱うイベント駆動の基盤(ポイント付与に限らず、クーポン発行イベントや在庫引当イベントなど)は、いずれもPub/Subのat-least-once配信を前提とする限り同種の重複リスクを抱えている。今回のStorage Write APIオフセット指定×dbt incremental merge×dbt Unit Testsの組み合わせは、金額やポイントのような「重複すると実害が金銭的に発生する」イベント処理のテンプレートとして横展開できる。特にdbt Unit Testsは、これまで「テストが書きにくい」とされてきたビジネスロジック混じりのSQLモデルに対しても固定入力・固定期待値で回帰テストを効かせられるため、今回のような参照ミス系の事故の再発防止に直接効く投資になる。
今日のまとめ
次のステップ
- 発展問題:
mart_point_ledgerのユーザー残高が実際の決済システム側のポイント残高マスタと日次で突合できない場合(システム間の非同期性により一時的な差異が生じ得る)、その差異を自動検知して許容範囲内かどうかを判定するリコンサイル(reconciliation)バッチを設計せよ。差異が閾値を超えた場合のアラート設計・当日の付与処理の一時停止判断も含めること。 - 参考: BigQuery Storage Write API(exactly-once semantics, offset) / dbt Unit Tests(dbt Core 1.8+) / dbt incremental merge strategy / BigQueryパーティション・クラスタリング設計 / Pub/Sub at-least-once delivery