C データエンジニアリング — ポイント付与基盤 Pub/Sub at-least-once重複配信(BigQuery Storage Write APIにオフセット未指定で重複書き込み × dbt incrementalがappendで生ログ重複がマートへ伝播 × ポイント計算ロジックにdbt Unit Testsが無く倍率参照ミスを検知できず × パーティションが取り込み日ベースでバックフィル時にズレる × クラスタリング未設定 × バックフィルDAGにチェックポイントが無く失敗のたび全再実行)(MOps 販促システムチーム ポイント10倍誤付与事故 Bad→Good)

2026-08-26 (Day 142) 水曜 C: データエンジニアリング ★★★★☆ BigQuery Storage Write API / dbt Core 1.8+ Pub/Sub at-least-once / Argo Workflows

概要

🎯

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のバックフィルは特定日の再取り込みが途中で失敗するとその日全体を最初から再実行する設計で、どのマイクロバッチまで成功済みかを記録する仕組みが無い
期待する回答形式: 問題点の列挙(番号付き)+ 改善後 Python(Storage Write APIオフセット指定コード)+ 改善後 dbt設定(incremental_strategy: merge、dbt Unit Test)+ 改善後 BigQuery DDL(パーティション/クラスタリング)+ 改善後 Argo Workflowsバックフィル(チェックポイント管理)+ 実行例(input→output)+ 設計意図の説明

悪いコード (Before)

この実装には 7つの設計・データ品質上の問題 が隠れています。見つけてみてください。
consumer/main.py — オフセット未指定
# 問題①: 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()
models/marts/mart_point_ledger.sql — append戦略・テスト無し
-- 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 %}
問題点サマリー(7点)
5取り込み日パーティション — バックフィル時にevent_timestampとズレる
6クラスタリング未設定 — user_id照会が数十GBスキャン
7バックフィルにチェックポイント無し — 失敗のたび日全体を再実行

ヒント(段階的開示)

ヒント1 — 方向性
問題は3層に分類できる。(1) 重複そのものを防ぐ層——Pub/Subがat-least-once配信である以上、「メッセージが複数回届くこと」自体は前提として受け入れるしかない。重要なのは、届いた重複を「どこで」「どうやって」吸収するかであり、BigQuery Storage Write APIのオフセット指定・dbtのmerge戦略という2段構えの防御がそれぞれ独立して効くように設計されているかを見る。(2) 計算ロジックの正しさを検証可能にする層——今回の事故の直接原因はキャンペーン倍率の参照ミスだが、これは「重複排除」とは全く別の問題であり、dbt Unit Testsのようにビジネスロジックそのものを固定入力・固定期待値で検証する仕組みが無ければ、参照ミスのような論理バグは何度でも再発し得る。(3) スキャン効率とバックフィルの整合性——パーティションを「取り込み日」で切ると、バックフィル時にデータの実態(イベント発生日)とパーティション境界がズレる。これはコストの問題であると同時に、「WHERE句で絞り込んだつもりが実は絞り込めていない」というデータ品質の落とし穴でもある。
ヒント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点)

#問題点分類改善方法
1Storage Write APIにオフセット未指定重複防止層append_rowsにoffsetを明示指定
2再配信を前提とした冪等化の起点が無い重複防止層書き込み成功確認後にack
3dbt 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 の比較

Bad(変更前)— 防御が1つも効かない構成 Pub/Sub point-grant-events(at-least-once) ネットワーク瞬断・Cloud Run再起動で同一event_idが複数回配信 再配信は仕様であり止められない Storage Write API append_rows(offset未指定) 問題①②: 再送のたび生ログに重複行が挿入される 第1の防御が最初から存在しない raw_events.point_grant_events(取り込み日パーティション) 問題⑤⑥: バックフィルでevent_timestampとズレ/クラスタ無し 同一event_idが複数行で存在 dbt incremental(append戦略) 問題③: 生ログの重複がそのままマートへ伝播 第2の防御も機能していない mart_point_ledger(SUM(point_amount)が過大) 問題④: 倍率参照ミスもdbt Unit Testsが無く未検知 実績: 一部ユーザーへ10倍以上のポイント誤付与が数日間放置 問題⑦: バックフィル失敗時は日全体を再実行しコストも二重発生 × 重複防止の防御線が0段(オフセットもmergeも無い) × 重複排除とロジック検証を混同し倍率ミスを見逃す × パーティション境界とデータの実態が一致しない Good(変更後)— 独立した2段の重複防御 + 別軸のロジック検証 Pub/Sub point-grant-events(at-least-once) 再配信は引き続き発生する(仕様は変えない) 前提を受け入れた上で下流に防御を積む方針 Storage Write API append_rows(offset明示指定)— 第1防御 修正①②: 同一offsetの再送はAPI側で重複として無視 生ログの時点でほぼ重複が発生しない raw_events.point_grant_events(event_timestampパーティション+user_idクラスタ) 修正⑤⑥: バックフィルでもデータの実態と境界が一致 ユーザー単位照会のスキャン量も削減 dbt incremental(merge + unique_key: event_id)— 第2防御 修正③: 万一残った重複もマート側で1行に収束 第1防御が破られても実害を出さない mart_point_ledger(残高が正しく1回分に収束) 修正④: dbt Unit Testsが倍率参照ミスをdbt build時点で検知 重複排除とは独立した別軸のロジック検証が効く 修正⑦: バックフィルはチェックポイントで未成功分のみ再処理 ✓ オフセット指定とmergeが独立した2段の防御になる ✓ dbt Unit Testsで倍率ミスのような論理バグをCIで検知 ✓ パーティション境界がデータの実態と一致する 修正

模範解答

consumer/main.py(修正①②: オフセット指定によるexactly-once)
"""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
models/marts/_marts.yml(修正③: incremental merge)
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
修正④: ポイント計算ロジックのdbt Unit Test
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(デフォルト倍率)
Before — 取り込み日パーティション・クラスタ無し
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が無い
After — event_timestampパーティション + user_idクラスタ
-- 修正⑤: 取り込み日ではなく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
);
workflows/backfill-point-events.yaml(修正⑦: チェックポイント管理)
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がappendincremental_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で止まる。

ポイント解説

1at-least-once配信を「起きないこと」にせず、多層防御で吸収する — Pub/Subの再配信は仕様であり無くならない。Storage Write APIのオフセット指定(第1層)とdbt incrementalのmerge(第2層)という独立した2つの防御線を用意することで、片方が何らかの理由(実装ミス・将来の設定変更)で機能しなくなっても、もう片方が残存重複を吸収できる。
2オフセット指定は「重複を検知する」のではなく「重複を書き込ませない」仕組みである — アプリケーション側で重複チェックのSELECTを行う方式と異なり、BigQuery側がAPIレベルで重複を弾くため、レイテンシやレースコンディションの影響を受けにくい。
3重複排除と計算ロジックの正しさは別の問題である — 今回の事故の直接原因はキャンペーン倍率の参照ミスであり、これは重複排除の仕組みをいくら強化しても検知できない。dbt Unit Testsのように「入力→期待出力」を固定して検証する仕組みが、ロジックバグの再発防止には不可欠。
4パーティションキーは「データが書き込まれたタイミング」ではなく「データが表す時点」に合わせる — 取り込み日でパーティションを切ると、バックフィルのたびにデータの実態とパーティション境界がズレ、集計クエリのWHERE句が意図通りに機能しなくなる。
5クラスタリングはアクセスパターンに合わせて設計する — カスタマーサポート画面のようにユーザー単位のアクセスが頻発する場合、user_idでクラスタリングすることでスキャン量を大幅に削減できる。
6バックフィルのチェックポイントは「コスト最適化」であると同時に「多層防御の一部」でもある — マイクロバッチ単位で成功を記録しておけば、無駄な再処理コストを避けられるだけでなく、将来incremental_strategyが誤って後退した場合でも、同じ範囲を何度も再送しないことが重複防止の一助になる。

実務への応用

MOpsチームが扱うイベント駆動の基盤(ポイント付与に限らず、クーポン発行イベントや在庫引当イベントなど)は、いずれもPub/Subのat-least-once配信を前提とする限り同種の重複リスクを抱えている。今回のStorage Write APIオフセット指定×dbt incremental merge×dbt Unit Testsの組み合わせは、金額やポイントのような「重複すると実害が金銭的に発生する」イベント処理のテンプレートとして横展開できる。特にdbt Unit Testsは、これまで「テストが書きにくい」とされてきたビジネスロジック混じりのSQLモデルに対しても固定入力・固定期待値で回帰テストを効かせられるため、今回のような参照ミス系の事故の再発防止に直接効く投資になる。

オフセット管理の永続化を忘れない: Cloud Run jobsは再起動やスケールイン・アウトが起こり得るため、offsetをプロセス内メモリだけで管理すると再起動時にoffsetがリセットされ、結局重複が発生する。Firestoreや専用のBigQueryテーブルなど、コンシューマのライフサイクルとは独立した場所にoffsetを永続化しておくことが実務上の勘所になる。

今日のまとめ

Pub/Subのat-least-once配信を前提とする以上、重複は「起きないようにする」ものではなく「起きても実害を出さないように多層で吸収する」ものとして設計する。 BigQuery Storage Write APIのオフセット指定とdbt incremental mergeは重複防止の2つの独立した防波堤であり、これとは別に、ポイント計算のようなビジネスロジックの正しさはdbt Unit Testsという別軸の仕組みで固定入力・固定期待値による回帰検知を効かせる必要がある。パーティション・クラスタリングはデータの実態(イベント発生日・アクセスパターン)に合わせて設計しないと、バックフィルのたびにコストと正確性の両方が損なわれる。

次のステップ

  • 発展問題: 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

自己評価(あとで記入)