管理ツールの楽観的ロック:サイレント上書きを防ぐ
管理ツール向けの楽観的ロック(version カラムや `updated_at` チェック)と、編集競合を静かな上書きにせず扱うシンプルな UI パターンを学びます。

問題:多数の編集者によるサイレント上書き
「サイレント上書き」は、二人が同じレコードを開いてそれぞれ変更を加え、最後に保存した人の変更だけが残る現象です。最初に保存した人の編集は警告なく消え、復元も難しいことが多いです。
忙しい管理パネルではこれが頻繁に起きます。人は複数タブを開き、チケット間を行き来し、20分放置したフォームに戻って保存することがあります。そのとき最新のレコードを更新しているわけではなく、古い状態に上書きしてしまうのです。
この問題は一般の公開アプリよりもバックオフィス系ツールで目立ちます。内部チームは同じ顧客や注文、商品、要求を繰り返し編集するためです。公開アプリは多くが「ユーザーが自分のデータを編集する」一方で、管理ツールでは「多くのユーザーが共有データを編集する」状況になります。
瞬間的には大惨事にならないことが多いですが、積み重なると被害が大きくなります:
- プロモーション更新直後に、商品価格が古い値に戻される。
- サポート担当者の内部メモが消え、次の担当が同じトラブルシューティングを繰り返す。
- 注文ステータスが逆戻り(例:「Shipped」が「Packed」に戻る)して誤ったフォローが発生する。
- 顧客の電話番号や住所が古い情報に置き換わる。
サイレント上書きは厄介です。みんなシステムが正しく保存したと思い込むため、後になってレポートがおかしいと気づいたり、同僚が「誰がこれを変更したの?」と尋ねるまで原因がわかりません。
こうした競合は自然なもので、ツールが共有され有用であることの証拠でもあります。目的は二人が編集するのを止めることではなく、誰かが編集している間にレコードが変わったことを検出して、安全に対処することです。
AppMaster のようなノーコードプラットフォームで内部ツールを作るなら、早い段階でこれを計画する価値があります。管理ツールは急速に成長し、チームが依存するようになると「たまに」データが失われることが常態化して信頼を損ないます。
楽観的ロックを平易に説明すると
同じレコードを二人が開いて両方が保存を押すと、同時実行の問題が起きます。各ユーザーは古いスナップショットから作業を始めており、保存が起きると最後の保存だけが最新になります。
何の対策もないと最後に保存した人が勝ち、先に保存した人の変更が静かに消える——これがサイレント上書きです。
楽観的ロックはシンプルなルールです:「編集を保存するとき、そのレコードがあなたが編集を始めたときと同じ状態なら保存する。違っていたら保存を拒否して競合を表示する。」
これは悲観的ロックとは異なります。悲観的ロックは「私が編集している間は他は編集できない」という発想で、実際にはロックやタイムアウト、待ちが発生します。お金の移動のような稀なケースでは有用ですが、多くの小さな編集が頻発する管理ツールではユーザーをフラストレーションさせがちです。
楽観的ロックは通常デフォルトとして優れています。ユーザーは並行して編集でき、実際に衝突が起きたときだけシステムが介入します。
適している状況:
- 競合は起こりうるが常時ではない。
- 編集が短時間(少数フィールド、短いフォーム)で完了する。
- 他者をブロックすると作業が遅くなる。
- 「誰かが更新しました」という明確なメッセージを出せる。
- API が更新時にバージョン(またはタイムスタンプ)をチェックできる。
防げるのは「静かな上書き」です。データを失う代わりに、システムがきれいにストップして「このレコードはあなたが開いた後に更新されました」と通知してくれます。
できないことも重要です。楽観的ロックは、二人が古い情報に基づいて異なる正当な判断をするのを止められませんし、自動でマージしてくれるわけでもありません。また、サーバー側でチェックをしなければ解決したことにはなりません。
よくある制約:
- 競合を自動解決はしない(ユーザーに選択を求める必要がある)。
- オフラインで編集して後で同期するケースでは役に立たないことがある。
- 権限が不十分なら(編集してはいけない人が編集するなど)防げない。
- クライアント側だけでチェックすると捕捉できないケースがある。
実務では、楽観的ロックは編集と一緒に送られる追加の値(バージョンやタイムスタンプ)と、サーバー側の「一致する場合のみ更新する」というルールに集約されます。AppMaster で管理パネルを作るなら、このチェックは通常更新処理の直前にあるビジネスロジック内に置きます。
よく使われる二つのアプローチ:version カラム vs updated_at
レコードが編集中に変わったことを検出するには、通常 version 番号か updated_at タイムスタンプのどちらかを使います。
アプローチ1: version カラム(インクリメントする整数)
version フィールド(通常は整数)を追加します。編集フォームを読み込むときに現在の version を読み、保存時にその値を送り返します。
サーバーは保存時に格納されたバージョンが読み込んだ値と一致する場合だけ更新を許可し、一致すれば version を +1 します。一致しなければ上書きの代わりに競合を返します。
version 12 は「これが12回目の変更である」という直感的な意味を持ち、時間に依存する端境ケースを避けられます。
アプローチ2: updated_at(タイムスタンプ比較)
多くのテーブルには既に updated_at フィールドがあるので、同じ考え方で動かせます:フォームを開くときに updated_at を読み込み、保存時にそれを送る。サーバーは updated_at が変わっていなければ更新します。
ただしタイムスタンプには注意点があります。データベースによって精度が異なり、秒単位に丸められると短時間の連続した編集を見逃すことがあります。複数のシステムが同じ DB に書き込む場合は時計のズレやタイムゾーン処理が混乱を招くこともあります。
比較の簡単なまとめ:
- version カラム:挙動が明確でデータベースに依存せず、時計問題がない。
updated_at:既に存在することが多く便利だが、精度や時計処理で落とし穴がある。
ほとんどのチームでは version カラムを優先するのが良いでしょう。明示的で予測しやすく、ログやサポートチケットでも参照しやすいからです。
AppMaster で作るなら、Data Designer に整数の version フィールドを追加し、更新処理でそれをチェックするのが一般的です。updated_at は監査用に残しておいて、実際の同時実行判定は version に任せるという使い方が多いです。
どの値を保存し、どの値を編集時に送るか
楽観的ロックは、ユーザーがフォームを開いた瞬間の「最後に見た」マーカー(version か updated_at)をすべての編集に含めることで成立します。それがなければサーバーは編集中に変化があったかどうか判断できません。
レコードには通常のビジネスフィールドに加えて、サーバーが管理する同時実行用フィールドを一つ持ちます。最低限の構成は:
id(不変の識別子)- ビジネスフィールド(name, status, price, notes など)
version(更新成功ごとに増える整数)またはupdated_at(サーバーが書き込むタイムスタンプ)
編集画面が開かれたら、フォームはその時点の同時実行トークンを保持します。ユーザーが編集するものではないので、隠しフィールドやフォーム状態に入れておきます。例:API が version: 12 を返し、フォームは保存するまでそれを保持します。
保存時には変更内容と最後に見たマーカーの二つを送ります。シンプルなリクエスト形は id、変更フィールド、expected_version(または expected_updated_at)を含めることです。AppMaster で UI を作る場合は、これをバインドされた値として扱い、レコードと一緒に読み込み、変更せずに更新時に送信します。
サーバー側では更新は条件付きにします。マッチしなければマージして静かに上書きしないでください。
競合レスポンスは UI で扱いやすい形にします。実用的には次を含めるとよいです:
- HTTP ステータス
409 Conflict - 「このレコードは誰かに更新されました」のような短いメッセージ
- 現在のサーバー値(
current_versionやcurrent_updated_at) - 必要であれば現在のサーバーレコード(UI が何が変わったか見せられるように)
例:Sam が Customer を version: 12 で開き、Priya が先に保存して version: 13 になった後に Sam が expected_version: 12 で保存しようとすると、サーバーは 409 と version: 13 の現在レコードを返します。UI は Sam に最新値を確認させて上書きを防げます。
実装手順:楽観的ロックをエンドツーエンドで実装する
楽観的ロックは基本的に一つのルールに落ちます:すべての編集は「最新の保存バージョンに基づいている」ことを証明しなければならない。
1) 同時実行フィールドを追加する
書き込みごとに変わるフィールドを一つ選びます。
専用の整数 version が最も分かりやすいです。1から始めて更新ごとにインクリメントします。既に確実に毎回更新される updated_at があるならそれでも構いませんが、バックグラウンドジョブを含めて必ず更新されることを確認してください。
2) その値を読み取り時にクライアントに送る
編集画面を開くときに現在の version(または updated_at)をレスポンスに含めます。フォーム状態に隠し値として保持してください。
レシートのように「私はこれを最後に読んだ」という意味です。
3) 更新時にその値を必須にする
保存時、クライアントは編集フィールドと最後に見た同時実行値を送り返します。
サーバーは更新を条件付きで行います。SQL の例は次の通りです:
UPDATE tickets
SET status = $1,
version = version + 1
WHERE id = $2
AND version = $3;
更新が 1 行影響したら保存成功、0 行なら誰かが先に変更したことになります。
4) 成功後に新しい値を返す
保存成功後、更新されたレコードと新しい version(または新しい updated_at)を返します。クライアントはサーバーの返り値でフォーム状態を置き換え、古いバージョンでの二重保存を防ぎます。
5) 競合を通常の結果として扱う
条件付き更新が失敗したら、明確な競合レスポンス(多くは HTTP 409)を返し、次を含めると UI 側で扱いやすくなります:
- 現在のレコード(現状)
- クライアントが試みた変更(または復元に十分な情報)
- 差分が分かるならどのフィールドが違うか
AppMaster では、Data Designer の PostgreSQL モデルフィールド、読み取りエンドポイントでの version 返却、そして Business Process での条件付き更新と成功/競合の分岐がこの流れに対応します。
競合をユーザーに嫌がられずに処理する UI パターン
楽観的ロックは全体の半分に過ぎません。残りは「保存が拒否されたときにユーザーに何を見せるか」です。
良い競合 UI の目的は二つ:サイレント上書きを止めることと、ユーザーがタスクを速やかに完了できるよう助けることです。うまく作れば補助的なガードレールに感じられ、障害にはなりません。
パターン1:シンプルなブロッキングダイアログ(最速)
編集が短く、ユーザーが再適用で問題ない場合に使います。
メッセージは短く具体的に:「編集中にこのレコードが変更されました。最新のバージョンを再読み込みしてください。」行動は二つ:
- 再読み込みして続行(主アクション)
- 私の変更をコピー(オプション)
「私の変更をコピー」は未保存の値をクリップボードに入れるか、再読み込み後にフォームに残す機能です。単一フィールドや短いメモ、状態切替などに向きます。AppMaster のようなビルダーでも実装が容易です。
パターン2:「変更を確認する」(重要レコード向け)
価格や権限、支払など重要なレコードや長いフォームの場合はこちら。エラー画面で次を比較表示します:
- あなたの編集(保存しようとした内容)
- 現在の値(DB の最新)
- あなたが開いたあとに変わったフィールド
競合フィールドについては、各フィールドごとに選択肢を出します:
- 自分の内容を採用する
- 相手の内容を採用する
- マージ(タグやメモのように意味のあるときのみ)
長いリッチテキストや長文メモには差分表示を出すと判断がしやすくなります。
強制上書きを許すとき(誰ができるか)
まれに強制上書きが必要になることがありますが、限定的にすべきです。実装するなら理由を必須にしてログを残し、管理者やスーパーバイザーなどに限定してください。
通常ユーザーには「変更を確認」をデフォルトにし、強制上書きは所有者や低リスクな場合、または監督下でのデータ修正に限定するのが安全です。
例:同じレコードを二人が編集する場面
二人のサポート担当 Maya と Jordan が同じ顧客プロフィールを編集しています。両方ともステータスを更新し、通話後のメモを追加します。
タイムライン(version または updated_at を使った楽観的ロック有効の場合):
- 10:02 - Maya が Customer #4821 を開く。Status = "Needs follow-up", Notes = "Called yesterday", Version = 7。
- 10:03 - Jordan も同じ顧客を開き、同様に Version = 7 を見る。
- 10:05 - Maya が Status を "Resolved" に変更し、Notes に "Issue fixed, confirmed by customer." を追加して保存する。
- 10:05 - サーバーはレコードを更新し、Version を 8 にインクリメントし、誰が何をいつ変更したかの監査を記録する。
- 10:09 - Jordan が別のメモ "Customer asked for a receipt" を入力して保存をクリックする。
競合チェックがないと、Jordan の保存が Maya の変更を静かに上書きする可能性があります。楽観的ロックがあれば、Jordan の保存は拒否されます(彼は Version = 7 を送っていて、レコードは既に Version = 8 になっているため)。
Jordan は明確な競合メッセージを見て、次の選択肢を得ます:
- 最新レコードを再読み込みして破棄する
- 自分の変更を最新レコードに上書きで適用する(可能なら推奨)
- 差分をレビューしてどちらを残すか選ぶ
簡単な画面は次を示せます:
- "この顧客は 10:05 に Maya によって更新されました"
- 変わったフィールド(Status と Notes)
- Jordan の未保存メモのプレビュー
Jordan は「差分を確認」を選び、Maya の Status = "Resolved" を維持し、自分のメモを既存のメモに追記して保存します。今回は Version = 8 を使って保存が成功し、Version = 9 になります。
結果:データは失われず、誰が何を上書きしたか分からないということもなく、Maya のステータス変更と両方のメモが追跡可能な監査になりました。AppMaster で作る場合、更新時の一回のチェックと小さな競合解決ダイアログでこの流れが実現できます。
データ損失を招くよくあるミス
多くの「楽観的ロックのバグ」はアイデア自体の問題ではなく、UI、API、データベースの受け渡し部分で起きます。どれか一層でもルールを忘れるとサイレント上書きが発生します。
典型的なミスは、編集画面を開くときにバージョン(またはタイムスタンプ)を取得するが、保存時にそれを送らないことです。フォームがページ間で再利用され、隠しフィールドが落ちたり、API クライアントが変更されたフィールドのみを送ると起きます。
別の罠はブラウザ側だけで競合チェックをすることです。ユーザーは警告を見ても、サーバーが更新を受け入れてしまえば別のクライアントやリトライで上書きされます。最終的な判定はサーバーが行う必要があります。
データ損失を招くパターン:
- 保存リクエストに同時実行トークン(
version,updated_at, ETag など)が含まれていない。 idのみで更新していて、id + versionのような原子的条件更新にしていない。- 低精度な
updated_at(秒単位など)を使っている。短時間の連続編集を見逃す。 - 大きなフィールド(ノート、説明)や配列(タグ、行項目)を丸ごと置き換える操作は、誰かの編集を消す危険が高い。
- 競合を「ただリトライすればいい」と扱うと、古い値を新しい値の上に再適用してしまう。
具体例:二人のサポートリーダーが同じ顧客レコードを開く。片方が長い内部メモを追加し、もう片方がステータス変更をして保存すると、もし保存がペイロード全体を置き換える実装ならステータス変更で長いメモが消えることがあります。
競合が起きたときに API の応答が薄いとチームはデータを取り戻せません。単に "409 Conflict" を返すだけでなく、人が復旧できる情報を返しましょう:
- 現在のサーバーバージョン(または
updated_at) - 関係するフィールドの最新値
- 差分フィールドの名前一覧(可能なら)
- 誰がいつ変更したか(追跡しているなら)
AppMaster で実装するなら、UI 状態にバージョンを保持し、保存時に送信し、バックエンドロジックで PostgreSQL に書き込む前に比較するという規律を守ってください。
出荷前の簡単チェックリスト
リリース前に「保存は成功したのに他者の作業を静かに上書きしている」失敗モードを確認しましょう。
データと API のチェック
同時実行トークンがエンドツーエンドで扱われていることを確認します。トークンは version 整数か updated_at タイムスタンプでよく、レコードの一部として扱う必要があります。
- 読み取り時にトークンが含まれている(UI はフォーム状態に保存している)。
- 更新時に必ず最後に見たトークンを送り、サーバーは書き込み前に検証する。
- 成功時にサーバーは新しいトークンを返し、UI は同期を保つ。
- バルク編集やインライン編集も同じルールを守る。
- 同じ行を編集するバックグラウンドジョブもトークンを考慮する(でないとランダムな競合が発生する)。
AppMaster で作る場合は、Data Designer に version または updated_at フィールドが存在するか、Business Process の更新フローが比較を行っているかを再確認してください。
UI のチェック
サーバーが更新を拒否したときの次の一手が明確であることが重要です。
サーバーが更新を拒否したら「このレコードはあなたが開いた後に変更されました」といったメッセージを出し、まず安全なアクション(最新データの再読み込み)を提示します。できれば「再読み込みして未保存の入力を再適用する」パスを用意して、小さな修正で再入力を強いられないようにします。
必要なら、制御された「強制保存」オプションを導入します。役割で制限し、確認を求め、誰が強制したかをログに残すことで緊急時の対応は可能にしつつ、データ損失を常態化させないようにします。
次のステップ:まず一つのワークフローに導入して横展開する
まずは小さく始めましょう。人が頻繁に競合する管理画面を一つ選び、そこで楽観的ロックを導入します。競合が多いのは通常チケット、注文、価格、在庫などです。一つの忙しい画面で競合処理を安全にすれば、ほかにも繰り返し適用しやすくなります。
事前にデフォルトの競合挙動を決めておくと、バックエンドと UI の設計がぶれません:
- ブロックして再読み込み:保存を止めて最新レコードを読み込み、ユーザーに再適用させる。短い編集に向く。
- レビューしてマージ:あなたの変更と最新の差分を見せてどちらを採用するか選ばせる。長いフォームや重要なレコードに向く。
まず一つのフローを完全に実装してテストしてから拡張しましょう:
- 1 画面を選び、ユーザーが最も編集するフィールドをリストアップする。
- フォームペイロードに
version(またはupdated_at)を含め、保存時に必須にする。 - DB 書き込みを条件付きにする(version が一致する場合のみ更新)。
- 競合メッセージと次のアクション(再読み込み、テキストのコピー、比較表示)を設計する。
- 2 つのブラウザでテストする:タブA で保存し、タブB で古いデータを保存してみる。
軽量な競合ログを追加すると便利です。簡単な「競合発生」イベントにレコード種別、画面名、ユーザー役割を含めるだけでホットスポットを特定できます。
AppMaster (appmaster.io) で管理ツールを作るなら、Data Designer にバージョンフィールドをモデル化し、Business Processes で条件付き更新を強制し、UI ビルダーで小さな競合ダイアログを追加すれば主要部分はカバーできます。最初のワークフローが安定したら、画面ごとに同じパターンを繰り返し、競合 UI を一貫させておくとユーザーが一度学べばどこでも信頼して使えるようになります。
よくある質問
サイレント上書きは、別のタブやセッションで同じレコードを二人が編集し、最後に保存した人の変更が先に保存した人の変更を警告なしに置き換えてしまう現象です。両方のユーザーは「保存成功」を見ているため、消えた編集は後になって初めて発覚します。
楽観的ロックは、あなたが編集を保存するときに「そのレコードはあなたが開いたときと変わっていないか」をチェックする仕組みです。もし誰かが先に保存していれば、保存は拒否され(競合として扱われ)、上書きする代わりに最新データを確認できます。
悲観的ロックは誰かが編集している間に他をブロックするため、待ちやタイムアウト、「誰がロックしている?」といった問題が起きがちです。管理チームでは多くの小さな編集が同時に発生するため、楽観的ロックの方が流れを止めず、実際の競合が起きたときだけ介入するので適しています。
通常は version(整数のバージョン)を使うのが最も単純で予測しやすいです。updated_at でも動きますが、タイムスタンプの精度やシステム間の時計ずれで速い編集を見逃すことがあります。
楽観的ロックを機能させるには、サーバー側で管理された同時実行トークンが必要です。一般的には version(整数)か updated_at(タイムスタンプ)です。クライアントはフォームを開くときにそれを読み取り、編集中は変更せずに保存時に“期待値”として送り返します。
クライアント側のチェックだけでは不十分だからです。サーバーが最終的な門番になり、id と version のような条件付き更新(例:id が一致し、かつ version が一致する場合のみ更新)を強制しなければ、別のクライアントやリトライ、バックグラウンドジョブが静かに上書きしてしまいます。
デフォルトの安全策はブロッキングメッセージで「このレコードは編集中に更新されました」と表示し、まずは最新データを再読み込みすることを促すことです。長い入力がある場合は、未保存の入力を保持して再適用できるようにすると再入力の手間を減らせます。
競合時には明確な 409 応答(例:HTTP 409)とともに、回復に必要な情報を返してください:現在のサーバー側のバージョン(または updated_at)、最新のサーバー値、できれば誰がいつ変更したかといった情報です。UI はそれを使って差分を表示したり、再試行の判断を助けます。
よくあるミスは、保存時にトークンを送らない(フォームで隠しフィールドが漏れる、API が変更されたフィールドだけ送るなど)、id のみで更新して id + version 条件を使っていない、または低精度なタイムスタンプ(秒単位など)を使っていることです。さらにノートや配列をまるごと置き換えると、他人の編集を消してしまう危険があります。
AppMaster では、Data Designer に version フィールドを追加し、UI がフォーム状態にそれを読み込むようにします。Business Process で条件付き更新(期待されるバージョンが一致する場合のみ更新)を実装し、競合が起きたら UI で再読み込み/レビューのフローに分岐させれば、カスタムコードなしで実現できます。


