Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

ロックは効いているのに件数だけ二重になる。数えていたのは書き込みではなく「書くつもり」だった

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

FOR UPDATEで親行を直列化していても、サマリの件数を『分類した意図』の長さで数えていると、同時発火した実行のうちINSERTを見送った敗者側まで計上され、DBの行は1行のままサマリの合計だけが2になることがある。

結論

FOR UPDATEで直列化していても、サマリの件数を「分類した意図」の長さで数えていると、行は正しく1行のままサマリの合計だけが2になることがある。 TVの死活監視チェッカ(runTvLivenessCheck)は、2つの実行が同時に発火するとnewlyDownの合計が1のはずが2になるflakyなテストを抱えていた。DB側の未解決行は親行のFOR UPDATEロックで常に1行に保たれており、行数のアサート自体は安定してpassしていた。壊れていたのはサマリの数え方で、実際にINSERTした件数ではなく「down対象として分類した件数」を返していたため、ロックの結果INSERTを見送った側の分類まで1件として計上されていた。

発端

このチェッカは、TVごとの生死を判定する純関数classifyTvLivenessと、その判定結果をDBへ反映するapplyTransitionsに分かれている。呼び出し元のrunTvLivenessCheckは、判定結果の長さをそのままサマリとして返していた。

// 修正前相当
const classification = classifyTvLiveness(states, now, thresholds);
await applyTransitions(tx, classification);

return {
  scanned: states.length,
  newlyDown: classification.newlyDown.length,
  recovered: classification.recovered.length,
};

classification.newlyDownは「down状態に遷移すべきと判定されたTVの一覧」であり、実際にDBへ書き込まれた件数ではない。2接続が同時に走るテストで、r1.newlyDown + r2.newlyDown === 1というアサートが不安定にfailしていた。

原因

classifyTvLivenessによる分類は、loadDeviceStatesが事前に読み取ったスナップショットに基づく。2本のチェッカ実行が同時に発火すると、どちらも「このTVはまだ未解決のdowntime行を持っていない」という同じ状態を読み、両方が同一TVをnewlyDownと分類してしまう。

実際のDB書き込みはapplyTransitions側で直列化されている。down遷移のINSERT前に、親のtv_devices行をFOR UPDATEでロックする。

// FOR UPDATE による直列化点(applyTransitions内)
await tx
  .select({ deviceId: tvDevices.deviceId })
  .from(tvDevices)
  .where(eq(tvDevices.deviceId, down.deviceId))
  .for("update");

const open = await tx
  .select({ id: tvDeviceDowntime.id })
  .from(tvDeviceDowntime)
  .where(
    and(eq(tvDeviceDowntime.deviceId, down.deviceId), isNull(tvDeviceDowntime.recoveredAt)),
  );
if (open.length > 0) {
  // 別チェッカ実行が先に INSERT 済み → 状態フラグだけ揃えて二重計上しない。
  await tx
    .update(tvDevices)
    .set({ alertState: "down", updatedAt: new Date() })
    .where(eq(tvDevices.deviceId, down.deviceId));
  continue;
}

未解決のdowntime行を持つ親TVの行が常にFK経由で1行存在するため、初回down(未解決行がまだ0件の状態)でもロック対象が空にならず、2本目の実行はロック解放を待って再走査し、未解決行が既に存在することを見てINSERTをスキップする。ここまではDBの整合性としては正しい。

問題は、このスキップされた側(敗者)のdownもまだclassification.newlyDownという配列には含まれたままだったことだ。サマリをclassification.newlyDown.lengthで数えると、勝者・敗者の両方が1件ずつ、合計2件として計上されてしまう。DBに書き込まれた行は1行だけなのに、チェッカが返すサマリの数字だけが実態と食い違っていた。

直し方

サマリの件数を、分類の長さではなくapplyTransitions実際に書き込んだ件数に変えた。

// 修正後(applyTransitions)
async function applyTransitions(
  tx: TenantTx,
  classification: TvLivenessClassification,
): Promise<{ newlyDown: number; recovered: number }> {
  let newlyDown = 0;
  let recovered = 0;
  for (const down of classification.newlyDown) {
    // ...FOR UPDATE ロック、未解決行の再確認...
    if (open.length > 0) {
      // ...状態フラグだけ揃えて continue(非計上)
      continue;
    }
    await tx.insert(tvDeviceDowntime).values({ /* ... */ });
    // ...
    newlyDown += 1; // 実際に INSERT した時のみ計上(スキップ経路は continue 済みで非計上)
  }

  for (const rec of classification.recovered) {
    const closed = await tx
      .update(tvDeviceDowntime)
      .set({ /* recoveredAt, durationSec など */ })
      .where(and(eq(tvDeviceDowntime.deviceId, rec.deviceId), isNull(tvDeviceDowntime.recoveredAt)))
      .returning({ id: tvDeviceDowntime.id });
    // ...
    if (closed.length > 0) {
      recovered += 1; // 実際に未解決行を締めた時のみ計上
    }
  }

  return { newlyDown, recovered };
}

down側はINSERTを実際に実行できた時だけnewlyDownをインクリメントし、ロックの結果スキップした経路(continue)ではインクリメントしない。recover側も対称に直した。UPDATEの条件が「未解決行のみ」なので、別のチェッカ実行が先に同じ行を締めていれば実際の更新行数は0になる。ここで.returning()を使い、戻ってきた行が1件以上あった場合だけrecoveredを増やすようにした。呼び出し元のrunTvLivenessCheckは、この実書込件数をそのままサマリとして返すだけになった。

// 修正後(runTvLivenessCheck)
return {
  scanned: states.length,
  ...(await applyTransitions(tx, classification)),
};

これで勝者側が1・敗者側が0となり、タイミングに依存せず合計は常に1に決定論化された。スキーマやマイグレーションの変更は不要で、既存のFOR UPDATEによる直列化はそのままに、戻り値の数え方だけを直している。

まとめ

ロックが効いているかどうかと、サマリの数字が正しいかどうかは、別の層の問題だった。今回のロックはDBの行を1行に保つという仕事を最初から正しく果たしており、崩れていたのは「何件処理したことにするか」を判定結果の長さで数えるという、ロックとは別の集計コードだった。同時実行を伴う処理でサマリや戻り値の件数を返すときは、「何をしようとしたか(分類・意図)」の数ではなく、「実際に何が起きたか(書き込み・更新の実績)」の数を数えるという区別を、常にどちらの数を返しているのか意識して書く必要がある。

よくある質問

Q1なぜFOR UPDATEで直列化しているのに件数だけ2になったのですか?

分類(classification)はDB反映より前の読み取り結果に基づいて行われるため、2つの実行が同時に発火すると、両方とも同じTVをnewlyDownと分類できてしまいます。直列化点は親行のFOR UPDATEロックで、これはあくまでDBへのINSERT/UPDATEの重複を防ぐものです。サマリの件数を『分類した対象の数』で数えてしまうと、ロックの結果INSERTを見送った側の分類まで1件として数えられ、実際に書き込まれた行は1行なのにサマリの合計が2になっていました。

Q2recover側も同じ理由で直したのですか?

対称な理由です。復帰(recover)のUPDATEも、未解決行を条件にした更新なので、別の実行が先に同じ行を締めていれば実際には0行しか更新されません。recover側は`.returning()`で実際に更新できた行があった場合のみ計上するようにし、down側の『INSERTを実行できた時だけ計上する』という直し方と対称に揃えました。

Q3スキーマやマイグレーションは変更したのですか?

変更していません。未解決行の重複を防ぐ一意制約を新設する方向ではなく、親のtv_devices行に対するFOR UPDATEが既に二重INSERTを防止できていたため、既存のロック設計はそのままに、戻り値の数え方だけを直しました。

確認した環境

  • drizzle-orm ^0.45.2 / postgres(pg) ^3.4.5
  • 2026-06-03 に修正コミットで解消(元issueはCloses #517)

この記事の根拠

  • TypeScriptファイル 58〜206行目コミット 9bd1e49

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。