Rebounder Tech Blog

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

相乗りキーの設定行をロック無しで読んで→マージして→書くと、同時保存でキーが消える

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

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

結論

同じ行に複数の設定キーが相乗りしている場合、既存値を読んでスプレッドしてから書き戻すread-merge-write upsertは、その読み取りをSELECT ... FOR UPDATEで行ロックしない限り、並行更新との競合で相手が直前に書いたキーを上書きして消す。

結論

1つのJSONB行に複数の設定キーを相乗りさせ、「既存値を読む→スプレッドでマージする→丸ごと書き戻す」upsertを書くとき、その読み取りをSELECT ... FOR UPDATEで行ロックしていなければ、並行更新と競合したときに相手が直前に書いたキーごと上書きして消す。 行ロックを足すだけで直る。

症状になりうる状態

学校ごとの表示設定を持つschool_configsテーブルには、scope='school', kind='display_settings'の行が1行あり、そのvalue列(JSONB)に複数の設定キーが相乗りしていた。

  • assignmentDeadlineFormat(提出物の期日表示形式。“daysLeft” か “until”)
  • signageDesign(学校既定のサイネージデザイン)
  • editorDayCutover(エディタの既定対象日を切り替える時刻)

school_configsvalueはupsertで列ごと全置換になる。1つのキーだけを変えたい保存Actionでも、他の2キーを消さないためには、保存前に既存のvalueを読んでオブジェクトにスプレッドし、変更したいキーだけ差し替えてから書き戻す必要がある。

const prev = await lockDisplaySettingsValue(tx);
const base =
  prev && typeof prev === "object" && !Array.isArray(prev)
    ? (prev as Record<string, unknown>)
    : {};

const id = await upsertSchoolConfig(tx, {
  schoolId: actor.schoolId,
  kind: "display_settings",
  value: { ...base, assignmentDeadlineFormat: rawFormat },
  actorUserId,
});

この形自体は問題なく見える。だがlockDisplaySettingsValueが行ロック無しの素のSELECTだった場合、次の順序で並行更新が起こりうる。

  1. トランザクションA(assignmentDeadlineFormatを保存)が既存値{signageDesign: "pattern2", editorDayCutover: "15:30"}を読む
  2. ほぼ同時にトランザクションB(/opsの生JSON編集などでsignageDesignを保存)が同じ既存値を読む
  3. Aが{signageDesign: "pattern2", editorDayCutover: "15:30", assignmentDeadlineFormat: "until"}でUPSERTしてコミット
  4. Bが自分が読んだAのコミット前の値をもとに{signageDesign: "pattern3", editorDayCutover: "15:30"}でUPSERTしてコミット

Bの書き込みにはAが足したassignmentDeadlineFormat: "until"が含まれていない。BのUPSERTはvalue列を丸ごと置き換えるため、Aが直前に保存した設定はテーブル上から消える。どちらの保存も単体で見れば正しく成功しており、エラーは一切出ない。

原因

read-merge-write(読んでスプレッドしてから書き戻す)というパターンそのものは、1つの行に複数のキーを相乗りさせて部分更新を模倣する以上、避けにくい。問題は、この「読む」の時点で行をロックしていなかったことにある。

行ロックを伴わない読み取りは、2つのトランザクションに同じ既存値を見せてしまう。両者は互いの存在を知らないまま、それぞれ正しい差分を計算し、それぞれ正しくUPSERTを実行する。しかし後にコミットした側が、先にコミットした側の変更を含まない値で行全体を上書きする。これはSQLレベルでは制約違反でも何でもなく、静かに成立してしまうlast-writer-winsの事故で、アプリケーションのログにもDBのエラーログにも痕跡が残らない。

直し方

既存値の読み取りを、同一トランザクション内のSELECT ... FOR UPDATEに変えた。

async function lockDisplaySettingsValue(tx: TenantTx): Promise<unknown | null> {
  const [row] = await tx
    .select({ value: schoolConfigs.value })
    .from(schoolConfigs)
    .where(and(eq(schoolConfigs.scope, "school"), eq(schoolConfigs.kind, "display_settings")))
    .limit(1)
    .for("update");
  return row ? row.value : null;
}

行ロックを取ると、後から同じ行を読もうとするトランザクションは、先行トランザクションがコミット(またはロールバック)してロックを解放するまでブロックされる。PostgreSQLのデフォルト分離レベル(READ COMMITTED)では、ロック解放後の読み取りは最新のコミット済み値を返すため、後発のトランザクションは必ず先行トランザクションが書いたキーを含んだ状態からマージを始められる。これで2つの保存は事実上直列化され、どちらのキーも欠落しない。

行がまだ存在しない場合(初回保存でINSERTになる場合)はロック対象自体が無い。この経路の同時実行は行ロックでは防げないが、対象にはux_school_configs_targetのユニーク制約とonConflictDoUpdateを使ったupsertが別途あり、そちらが一意性を保証する。行ロックが効くのは「既に行があり、その値を読んでマージする」2回目以降の保存からになる。

再発防止

このパターンは、display_settingsと同じように「1行に複数の設定キーを相乗りさせ、read-merge-writeで部分更新する」他のテーブルにも当てはまる。読み取りをFOR UPDATEにする対応自体は既存のlockAndCountActiveSchoolAdminsと同じ作法で揃えており、新しい仕組みを持ち込んだわけではない。

同じ行に複数のキーを相乗りさせる設計を選ぶ以上、「読んでマージして書く」経路には必ずこの並行更新ロストのリスクが伴う。read-merge-write を書くたびに、その読み取りが行ロックを伴っているかを確認する価値がある。

よくある質問

Q1実際に本番でキーが消えたのですか?

いいえ。本番で発生した事故の報告ではなく、レビューで見つかった並行更新のホール(設計上のリスク)を、実際に消える前に塞いだ話です。既存機能の追加レビューで『この読み書きの並びだと並行更新でロストしうる』と指摘され、修正した。

Q2何と何が同じ行に相乗りしていたのですか?

学校ごとの display_settings という1行のJSONBに、提出物の期日表示形式(assignmentDeadlineFormat)・学校既定のサイネージデザイン(signageDesign)・エディタの日付切替時刻(editorDayCutover)の3つの設定キーが同居していた。upsertはvalue列を全置換するため、1キーだけ変えたくても他のキーを含めて書き戻す必要がある。

Q3なぜSELECT ... FOR UPDATEを足すと直るのですか?

行ロックを取らない読み取りは、2つのトランザクションが同じ行を同時にSELECTしてから別々にUPSERTできてしまう。後勝ちのUPSERTが先勝ちの変更を含まない値で行を上書きし、先勝ちのキーが消える。SELECT ... FOR UPDATEで既存値の読み取り自体をロックすれば、後から来たトランザクションはロック解放(先行txのコミット)まで読み取りがブロックされ、必ず最新の値をマージ基底にできる。

Q4行がまだ存在しない(初回保存)場合はロックできないのでは?

その通りで、行が無ければロック対象も無い。同時に初回INSERTが競合するケースは行ロックでは防げないため、対象のユニーク制約とonConflictDoUpdateによるupsertに一意性の保証を委ねている。行ロックが効くのは『既に行があり、その値を読んでマージする』2回目以降の保存から。

確認した環境

  • Next.js ^16.0.0 / drizzle-orm ^0.45.2(PostgreSQL)
  • 2026-07-12 レビュー指摘への対応としてマージ(#1264 follow-up・本番事故ではない)

この記事の根拠

  • TypeScriptファイル 24〜70行目コミット eb3780b
  • TypeScriptファイル 123〜153行目コミット eb3780b

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