はじめに
LINE から「ランチ 900」と送ると支出を1行記録する、自分用の家計簿ボットを運用しています。Claude Code にコードレビューを頼んだところ「支出登録が冪等になっていない」と言われました。同じ Webhook が2回届くと、同じ支出が2行入ってしまう作りだったのです。
この記事では、そのとき Claude Code と一緒に入れた重複対策を、判断の理由も含めて整理します。環境は PHP + MySQL(PDO)です。
結論
- 重複の判定は本文ではなく LINE が振る ID(
webhookEventIdかmessage.id)で行う - 処理済み ID を主キー付きの専用テーブルに記録し、先に INSERT して 1062 なら重複とみなす
- 判定は許可チェックの後、処理の入口1か所に置く
- 処理が途中で例外になったら(登録確定前なら)控えを消して再送を受け入れる。
catchはThrowable - ついでに署名検証は最初に、
hash_equals()で
なぜ Webhook は重複するのか
- 再送機能:ボットが 2xx を返さないと LINE が送り直す(コンソールで有効化したときのみ)
- ネットワーク経路の都合による重複:再送設定に関係なく起こりうる
LINE Developers の「メッセージ(Webhook)を受信する」でも、重複が問題になるなら webhookEventId で検出するよう書かれています。再送をオフにしていても安心はできません。
見分けるキー
同じ「ランチ 900」が2通来ても、それが本当に2回の支出である可能性があります。内容の一致では判定できません。
| キー | 付与単位 | メモ |
|---|---|---|
webhookEventId |
イベントごと | 公式推奨。postback 等にも付く |
message.id |
メッセージごと | メッセージイベントのみ |
再送時は deliveryContext.isRedelivery 以外が同一なので、どちらも変わりません。今回のボットはテキストだけなので message.id を使いましたが、汎用にするなら webhookEventId がおすすめです。
また、「財布 3000」(実残高との差額を記録)のように、2回目は差額0で何も起きない=もともと冪等な処理もありました。全処理を守るのではなく、2回走ると困る処理を先に特定すると、変更範囲が小さくなります。
実装
テーブル
既存の支出テーブルにも「メッセージID」列がありましたが、メール取り込みの重複防止や別機能の削除キーとして使われていたため流用しませんでした。
CREATE TABLE processed_events (
event_id VARCHAR(64) PRIMARY KEY,
received_at DATETIME NOT NULL
);
重複判定
function isDuplicateEvent(PDO $db, ?string $id): bool
{
if ($id === null || $id === '') {
return false;
}
try {
$st = $db->prepare('INSERT INTO processed_events (event_id, received_at) VALUES (?, NOW())');
$st->execute([$id]);
return false;
} catch (PDOException $e) {
if ((int)$e->errorInfo[1] === 1062) { // Duplicate entry
return true;
}
error_log('processed_events: ' . $e->getMessage());
return false; // 控えテーブルの障害時は登録を止めない
}
}
PDO::ATTR_ERRMODE が PDO::ERRMODE_EXCEPTION になっていないと、重複しても例外が飛ばず catch に入りません。
SELECT してから INSERT しない理由
A: SELECT → なし
B: SELECT → なし ← A の INSERT 前
A: INSERT → 支出登録
B: INSERT → 支出登録(二重)
チェックと記録の間にすき間があると、ほぼ同時の2件がすり抜けます。INSERT を先にして主キー制約に任せれば、DB が片方だけを通します。
例外時に控えを戻す
foreach ($events as $event) {
$id = $event['message']['id'] ?? null;
try {
handleEvent($db, $event, $id);
} catch (Throwable $e) {
releaseEvent($db, $id); // 再送で処理し直せるように控えを削除
error_log($e->getMessage());
}
}
http_response_code(200);
-
catch (Exception $e)だとTypeErrorなどのError系を取りこぼし、同じリクエスト内の残りイベントと最後の 200 応答が失われる - 控えを戻してよいのは支出登録が確定する前に失敗した場合だけ。登録後(返信送信など)の失敗で戻すと、再送で二重登録になる
控えの記録と支出登録を同一トランザクションにするのが理想ですが、このボットは途中で処理時間の読めない外部プログラムを呼ぶため見送りました。長い処理なら「処理中/完了」の状態列を持たせる設計も考えられます。
控えテーブルが壊れたときの方針
家計簿は二重登録なら後で1行消せますが、登録できない状態は取り返せないので「通す」側にしました。決済やポイント付与など取り消しが重い処理なら「止める」側が妥当です。選んだ方針はコメントに理由ごと残しておくと安全です。
署名検証も入口の先頭へ
元のコードはリクエスト本文をログに書いた後で署名を比較し、比較も !== でした。
$raw = file_get_contents('php://input');
$expected = base64_encode(hash_hmac('sha256', $raw, $channelSecret, true));
$given = $_SERVER['HTTP_X_LINE_SIGNATURE'] ?? '';
if (!hash_equals($expected, $given)) {
http_response_code(400);
exit;
}
- 検証を最初に行い、通ったリクエストだけをログに残す
- 署名計算には受け取った生の本文を使う(JSON デコード・整形しない)
- 比較は
hash_equals()(処理時間が一致度に依存しない)
動作確認
- 同じ ID で2回 → 2回目は「処理済み」と返る
- 支出登録を同じ ID で2回 → 行は1行のみ
- 別 ID → 通常処理
- ID なし → 2回とも処理、空 ID は記録されない
- 許可されていない送信者 → 拒否され、ID も記録されない
- 控え削除後 → 同じ ID で再処理できる
テストで作った行と控えは削除し、件数が元に戻ったことを確認しました。
おわりに
レビューで刺さったのは、「正常な順番で動くか」ではなく「この行の直後で落ちたら何が残るか」という問いでした。1回だけ実行したい処理を書くときは、行ごとにこの問いを当ててみると穴が見つかりやすいです。
元記事(AI 側の視点での記録):
https://kujiragames.com/2026/09/line-webhook-duplicate-guard/
参考
- LINE Developers「メッセージ(Webhook)を受信する」
- LINE Developers「Webhookの署名を検証する」