45395 - シコウサクゴ -

集計の二重計上が回路遮断器を連日誤発動させた話:根本原因は『冪等でない日次ジョブ』

データ集計ジョブで「日次の処理件数が異常に多い」と判定されてサーキットブレーカーが連日発動する事故がありました。

調査した結果、入力件数自体は増えていないにも関わらず、集計時に同じレコードが複数回カウントされて閾値を超えていました。冪等でない日次集計の罠と、その修正を書きます。

何が起きたか

サーキットブレーカーの誤発動

業務ロジックで「日次の処理件数が直近 7 日平均の 2 倍を超えたら異常」とみなしてサーキットブレーカーをトリップさせ、後続処理を止める仕組みを入れていました。

1
def check_daily_count(today_count: int, last_7d_avg: float) -> bool:
2
if today_count > last_7d_avg * 2:
3
circuit_breaker.trip("daily_count_anomaly")
4
return False
5
return True

ある日から 連続でトリップ するようになりました。実データを見ると:

1
今日: 12,400 件
2
直近 7 日平均: 3,100 件
3
判定: 12,400 > 6,200 → トリップ

しかし入力データの量は変わっていません。何かがおかしい。

原因:日次集計が冪等でなかった

コードの構造

日次集計ジョブの実装は次のようになっていました。

1
def aggregate_daily(target_date: date):
2
new_records = fetch_records_for_date(target_date)
3
for r in new_records:
4
# 「過去にカウントされていたら追加しない」と思っていた
5
daily_summary[target_date]["count"] += 1
6
save_summary(daily_summary)

問題: daily_summary は永続化されていて、ジョブが再実行されると 既存の値に加算 してしまいます。

1
1 回目の実行: count = 0 → 3100
2
2 回目の実行: count = 3100 → 6200
3
3 回目の実行: count = 6200 → 9300
4
4 回目の実行: count = 9300 → 12400

サーキットブレーカーが「閾値超過」を検知したのは 正しい振る舞い で、本当の異常は「同じジョブが 4 回走っていた」ことでした。

なぜ複数回走っていたか

launchd の KeepAlive 設定で、スクリプトが exit 1 で終了したら再起動 するようにしていました。

1
<key>KeepAlive</key>
2
<dict>
3
<key>SuccessfulExit</key>
4
<false/>
5
</dict>

集計の途中で DB の一時的な接続エラー が起き、exit 1。launchd が再起動 → 既存の集計に追加加算 → exit 1 → ループ。

ジョブのログを見ると 1 日に 4〜5 回起動していました。リトライ自体は想定内ですが、冪等性が欠けていたことが連鎖障害を引き起こしました。

修正:冪等な集計に書き直す

Before: 加算ベース

1
daily_summary[target_date]["count"] += 1 # 加算

After: 上書きベース(冪等)

1
def aggregate_daily(target_date: date):
2
records = fetch_records_for_date(target_date)
3
summary = {
4
"count": len(records),
5
"total_value": sum(r["value"] for r in records),
6
"categories": Counter(r["category"] for r in records),
7
}
8
upsert_summary(target_date, summary) # date をキーに上書き

upsert_summary は date をキーにして、既存があれば置き換え、無ければ作成します。何度実行しても結果は同じ(冪等)。

upsert の実装例(PostgreSQL)

1
def upsert_summary(target_date: date, summary: dict):
2
sql = """
3
INSERT INTO daily_summary (target_date, count, total_value, categories)
4
VALUES (%s, %s, %s, %s)
5
ON CONFLICT (target_date) DO UPDATE SET
6
count = EXCLUDED.count,
7
total_value = EXCLUDED.total_value,
8
categories = EXCLUDED.categories,
9
updated_at = NOW()
10
"""
11
cursor.execute(sql, (target_date, summary["count"], ...))

PostgreSQL の ON CONFLICT DO UPDATE を使えば、target_date の重複時に上書きできます。

検知の仕組みも作る

修正したものの、「冪等じゃないコード」が将来も書かれる可能性は残ります。検知の仕組みを足しました。

Idempotency テスト

ジョブを 2 回連続で実行して結果が同じかを確かめるテストを書きます。

1
def test_aggregate_is_idempotent(setup_test_data):
2
target_date = date(2026, 5, 1)
3
4
# 1 回目
5
aggregate_daily(target_date)
6
summary_1 = load_summary(target_date)
7
8
# 2 回目
9
aggregate_daily(target_date)
10
summary_2 = load_summary(target_date)
11
12
assert summary_1 == summary_2, "ジョブは冪等であるべき"

このテストを CI に入れることで、「2 回実行で結果が変わる」コードを早期に潰せます。

実行回数の可視化

launchd のジョブが何回起動したかをログから集計できるようにしました。

Terminal window
1
# 当日の起動回数
2
grep "$(date +%Y-%m-%d)" /var/log/aggregation.log \
3
| grep "starting aggregation" \
4
| wc -l

「1 日 1 回しか起動しないはず」のジョブが 4 回起動していたら、サーキットブレーカーがトリップする前に気づけます。

サーキットブレーカー側の改善

サーキットブレーカーが「異常を検知」したとき、何が異常なのかをログに残すようにしました。

1
def check_daily_count(today_count: int, last_7d_avg: float, target_date: date) -> bool:
2
threshold = last_7d_avg * 2
3
if today_count > threshold:
4
circuit_breaker.trip(
5
reason="daily_count_anomaly",
6
details={
7
"target_date": str(target_date),
8
"today_count": today_count,
9
"last_7d_avg": last_7d_avg,
10
"threshold": threshold,
11
"ratio": today_count / max(last_7d_avg, 1),
12
},
13
)
14
return False
15
return True

ratio まで残しておくと、「どの程度の異常で発動したか」が後で追えます。今回のケースは ratio=4.0 で、4 倍に達していたので「ジョブが 4 回走った」と推測できました。

連鎖障害の発生図

1
1. DB 一時エラー → スクリプト exit 1
2
2. launchd が再起動 → 集計が再実行
3
3. 加算ベースの集計が二重計上
4
4. サーキットブレーカーが閾値超過を検知 → トリップ
5
5. 後続ジョブが全部止まる
6
6. 翌日も DB 一時エラー → 同じ連鎖

観点: サーキットブレーカーは「異常を正しく検知」していました。問題は「異常じゃないものを異常に見せたコード」(冪等性の欠如)でした。

監視側のチューニングだけで対応していたら、根本原因が見つからずに閾値を緩める方向に行っていたはずです。

学び

1. 「リトライされたとき」を必ず考える

外部要因で再起動されるジョブは多いです。1 回目と N 回目の結果が同じになる書き方を最初から意識します。

1
# Bad: 加算
2
counter += 1
3
4
# Good: 入力から決定的に計算
5
counter = len(records)

2. 冪等性は「テストで担保」する

レビューで「これ冪等ですか?」と聞いて確認するのは見落としやすいです。CI で 2 回実行して結果が同じか確かめるテストを書くと、機械的に検知できます。

3. サーキットブレーカーは「正しく動いた」ときも分析する

ブレーカーが発動したとき「閾値が厳しすぎた?」とまず疑いがちですが、正しく異常を検知していたケースが現実にあります。発動したら必ず details を読んで根本原因を追います。

4. launchd の KeepAlive はリトライ条件を吟味する

KeepAlive で再起動するなら、冪等なジョブだけにします。集計や送信のような副作用がある処理に無条件で KeepAlive を付けると、二重実行が起きます。

まとめ

観点BeforeAfter
集計ロジック加算(+= 1上書き(upsert
再実行時の挙動二重計上同じ結果(冪等)
CI テストなし2 回実行 → 同一結果を assert
ログ起動回数が見えない起動回数を可視化
ブレーカーログreason のみreason + details で根本原因が追える

サーキットブレーカーの「誤発動」と思った事象が、実は 正しい異常検知 で、本当のバグは集計ジョブの冪等性欠如でした。

監視側のチューニングではなく 集計側の書き直し で根治できる場合があります。「ブレーカーが鳴った」ときは、まず鳴った原因を疑わず、鳴らされた事象を信じて掘るのが大事だと感じました。

Article title:集計の二重計上が回路遮断器を連日誤発動させた話:根本原因は『冪等でない日次ジョブ』
Article author:45395
Release time:2026-05-03

記事へのご質問・ご感想をお聞かせください

フィードバックを送る