受注の備考を直して保存したら、別の担当者が入力した配送区分まで元に戻っていた。 業務システムでは、同じデータを複数人が開くことがあります。保存ボタンを押した順に上書きするだけでは、先に保存した人の変更が静かに消えます。
この問題は、画面の保存処理に少し条件を足すだけで見つけられます。 画面を開いた時点のバージョン番号と、保存時点のバージョン番号が同じときだけ更新する形です。
なお、この記事のSQLはSQLiteで実行確認しています。? はアプリケーションから渡す値を表すプレースホルダーです。Oracle、PostgreSQL、MySQLなどでも、バージョン番号を条件に含める考え方は同じです。
同じ受注を2人が更新したときの上書き
利用者Aと利用者Bが同じ画面を開く
受注番号501に、通常便、時間指定なしという内容が入っているとします。 利用者Aは配送区分を冷蔵便へ変え、利用者Bは備考を午後指定へ変えようとしています。2人とも画面を開いた時点では、同じ内容を見ています。
| 項目 | 画面を開いた時点の値 | 利用者Aの変更 | 利用者Bの変更 |
|---|---|---|---|
| 受注番号 | 501 | 変更なし | 変更なし |
| 配送区分 | standard | chilled | standardのまま |
| 備考 | no time request | 変更なし | afternoon request |
| バージョン番号 | 5 | 5を保持 | 5を保持 |
業務画面は、利用者が開いてから保存するまでに数分かかることがあります。 その間に別の人が保存しても、画面を開いている側には通常、変更が自動では反映されません。
tomo利用者から見ると、どちらも普通に編集して保存しているだけです。だから保存時に競合を検出しないと、変更が消えた理由を画面上で説明できません。
更新条件が受注番号だけの場合
排他制御がない更新は、次のようになります。
UPDATE orders
SET
shipping_type = ?,
note = ?
WHERE
order_id = ?;利用者Aが先に保存して配送区分をchilledへ変えても、利用者Bの画面には古いstandardが残っています。 その状態でBが画面全体の値を保存すると、配送区分までstandardへ戻る可能性があります。
更新対象を受注番号だけで特定すると、古い画面の値でも更新できてしまいます 備考だけを変えたつもりでも、画面から送られた配送区分や金額が一緒に上書きされることがあります。
楽観ロックで使うバージョン番号
テーブルに持たせる値
楽観ロックでは、レコードごとに更新回数を表すversion_noを持たせます。 更新のたびに1増やし、画面表示時に取得した値と一致する場合だけ保存を許可します。
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
shipping_type TEXT NOT NULL,
note TEXT,
version_no INTEGER NOT NULL
);
INSERT INTO orders (
order_id,
shipping_type,
note,
version_no
) VALUES (
501,
'standard',
'no time request',
5
);画面のhidden項目や更新用DTOには、order_idだけでなく、取得時のversion_noも持たせます。 楽観ロックで比較するのは、画面を開いたあとにその行が変わったかどうかです。
更新条件へバージョン番号を加える
更新SQLでは、画面が持っているバージョン番号をWHERE句へ加えます。 更新に成功したら、DB側のバージョン番号を1増やします。
UPDATE orders
SET
shipping_type = ?,
note = ?,
version_no = version_no + 1
WHERE
order_id = ?
AND version_no = ?;このSQLでは、受注番号が一致しても、バージョン番号が古ければ更新されません。 アプリケーション側は、更新件数が1なら保存成功、0なら競合または対象状態の変化として扱います。
バージョン番号を使う方式は、利用者が入力している間にDBロックを持ち続けません。競合は保存時に検出し、利用者へ再読込を案内します。入力待ちの長い業務画面に向いています。
受注更新をSQLで再現する
利用者Aの更新が成功する例
利用者Aは、画面を開いたときのversion_no = 5を持っています。 この値を条件にして更新すると、対象行は1件更新され、バージョン番号は6になります。
UPDATE orders
SET
shipping_type = 'chilled',
note = 'no time request',
version_no = version_no + 1
WHERE
order_id = 501
AND version_no = 5;実行結果は次の通りです。
changes
1
order_id | shipping_type | note | version_no
501 | chilled | no time request | 6更新件数が1件なので、Aの保存は成功です。 この時点で、DBにある受注番号501の最新バージョンは6になります。
利用者Bの更新が0件になる例
利用者Bの画面には、まだversion_no = 5が残っています。 同じ条件で備考を保存しようとすると、DB側はすでにバージョン6なので一致しません。
UPDATE orders
SET
shipping_type = 'standard',
note = 'afternoon request',
version_no = version_no + 1
WHERE
order_id = 501
AND version_no = 5;実行結果は次の通りです。
changes
0
order_id | shipping_type | note | version_no
501 | chilled | no time request | 6利用者Bの更新は0件です。配送区分をstandardへ戻す更新も、備考をafternoon requestへ変える更新も行われません。 更新0件を保存成功として扱わないことが、上書きを防ぐ分かれ目です。
更新件数が0件だったのに、画面で保存完了を表示すると、利用者は変更済みだと思い込みます。更新件数の判定と画面の成功表示は、同じ処理の中で必ず結び付けます。



更新件数0件は、SQLの失敗ではありません。古い画面のまま保存しようとしたことを、アプリケーションが受け取るための結果です。
更新0件を競合だけと決めつけない
更新0件は、別の利用者が更新したときに起きます。 一方で、対象受注が取消済みになった、論理削除された、更新権限がなくなった、といった場合にも起きます。
画面では一律に「他の利用者による変更がありました」と出すより、最新データを再取得して状態を確認します。 取消済みなら編集不可画面へ戻す。権限が変わったなら権限エラーとして扱う。実際にバージョンだけ変わっていたなら、再読込を案内します。
更新件数0件だけで原因を断定すると、取消済みデータや権限変更の調査を見落とします
競合した利用者へ返す画面と再操作
保存失敗だけでは次の操作が分からない
競合したときに「更新できませんでした」だけを出すと、利用者は再度保存するか、画面を閉じるか迷います。 何が起きたかと、次に何をするかを分けて伝えます。
この受注は、画面を開いたあとに別の利用者が更新しました。
最新の内容を確認してから、もう一度変更してください。再読込ボタンを置き、最新の受注を取得します。 再読込で画面の入力が消える場合は、備考など利用者が入力した未保存値を一時表示するか、少なくとも消えることを先に知らせます。
再読込後にどこまで復元するか
備考だけを編集していた場合は、最新データを読み直したうえで、備考を再入力してもらう形でも運用できます。 一方で、金額、明細、配送先を複数変更していた場合に、画面を丸ごと初期化すると再入力ミスが起きやすくなります。
この場合は、DBの最新値と画面の未保存値を並べ、どの項目が変わっていたかを確認できる方がよいです。 全部の画面に差分表示を作る必要はありません。金額や承認状態のように、誤った再入力が業務へ影響する項目から検討します。



競合時の画面は、エラー画面ではなく再操作の入口です。利用者が最新データを見て、何をもう一度入力すればよいか分かる形にします。
操作ログへ残す値
競合が起きたときは、利用者から問い合わせが来ることがあります。 ログには、少なくとも次の値を残します。
| ログ項目 | 記録例 | 確認できること |
|---|---|---|
| 受注番号 | 501 | どのデータで起きたか |
| 画面表示時のversion_no | 5 | 利用者が見ていた状態 |
| 更新時のDB version_no | 6 | 競合があったか |
| 操作者 | user_b | 問い合わせ時の確認先 |
| 処理日時 | 2026-07-31 14:32:08 | 前後の更新との照合 |
ログにSQL全文や個人情報をそのまま出す必要はありません。 受注番号、バージョン番号、操作者、処理結果があれば、更新競合なのか別要因なのかを追いやすくなります。
テストで確認する同時更新のケース
同じ受注を2画面で開いて順番に保存する
テストでは、ブラウザを2つ開くか、別ユーザーで同じ受注を開きます。 どちらの画面もversion_no = 5の状態から始め、Aを先に保存します。その後でBを保存し、更新0件が画面上の競合メッセージへ変換されることを確認します。
1. 利用者Aと利用者Bで受注番号501を開く
2. Aが配送区分を変更して保存する
3. Bが備考を変更して保存する
4. Bに再読込を案内するメッセージが出ることを確認する
5. DBにAの変更が残り、Bの古い値で上書きされていないことを確認するSQLの更新件数だけで終わらせず、画面表示、ログ、DBの最終値まで確認します。 この3つが揃っていないと、利用者にはエラーが見えない、または保守担当が原因を追えない状態になります。
再読込後にもう一度保存する
競合エラーが出たあと、Bが最新の受注を読み直します。 最新の配送区分がchilledであることを確認し、備考だけをafternoon requestへ直して保存します。
このときは、再読込後のversion_no = 6を条件に更新するため、更新件数は1件になります。 競合を検出するだけでなく、利用者が業務を完了できることまでテスト対象に入れます。
バッチとCSV取込も同じルールで更新する
受注を更新する経路が画面だけとは限りません。 CSV取込、外部API、夜間バッチが同じテーブルを更新するなら、どの経路がversion_noを確認し、どの経路が競合時に停止または再試行するのかを決めます。
画面だけで楽観ロックを実装しても、バッチが受注番号だけを条件に更新すれば、同じ上書きが別経路で起きます。 更新経路ごとに例外を作らず、バージョン番号の扱いをそろえることが重要です。
悲観ロックを検討する処理
楽観ロックだけで済ませにくい場面
楽観ロックは、同時更新が起きたら保存時に検出し、再操作してもらう方式です。 受注の備考、配送先、社内メモの更新のように、競合がまれで再入力できる処理には扱いやすい方法です。
一方で、在庫を1点だけ引き当てる、座席を確保する、決済を確定する処理では、競合したあとに再入力してもらうだけでは足りないことがあります。 同じ資源を同時に確保させないため、更新直前に行をロックする悲観ロックを検討します。
入力中にロックを持ち続けない
悲観ロックを使う場合でも、利用者が画面を開いて入力している間ずっとロックを持つ設計は避けます。 他の処理が待たされ、タイムアウトやデッドロックの原因になるからです。
ロックが必要な処理は、入力内容を確認したあと、確定処理の直前に短いトランザクションで実行します。 方式の名前だけで決めず、競合時に再操作できるか、同じ資源を二重に確保してよいかで選びます。
在庫引当であれば、在庫数を読み、引当可能かを確認し、在庫を減らすところまでを短くまとめます。 その途中でメール送信や外部API呼び出しを待たないようにします。ロックを解放したあとに通知する方が、他の利用者を待たせにくくなります。
実装前に決める項目
競合時に誰の変更を残すか
後から保存した値で上書きしてよい項目と、必ず競合エラーにする項目を分けます。 受注金額、承認状態、出荷指示のように、後続処理へ影響する値は、後勝ちにしない方がよいです。備考や社内メモでも、誰かの内容を消してよいとは限りません。
競合時に、再読込だけでよいのか、差分を見せるのか、管理者が確認するのかを仕様へ残します。 「競合したらエラー」の一文だけでは、画面と運用を作れません。
バージョン番号を増やす更新経路
画面、API、CSV取込、バッチのどれが受注を更新するかを洗い出します。 どこか1つでもversion_noを条件に含めない更新があると、そこから古い値による上書きが起きます。
更新経路ごとに、競合時の動作も決めます。画面は再読込を案内する。CSV取込は対象行をエラー一覧へ出す。バッチは対象をログへ残して止めるか、次回へ繰り越す。この差を仕様書とテスト表に書きます。
テスト表とログに残す情報
テスト表には、操作した利用者、操作順、画面表示時のversion_no、更新結果、画面メッセージ、DBの最終値、ログの有無を残します。 競合テストは、更新0件になったことだけでは不十分です。古い値で上書きされなかったことと、利用者が再操作できたことまで確認します。
| 確認対象 | 確認する状態 | 残す証跡 |
|---|---|---|
| 更新SQL | version_noがWHERE条件に含まれる | SQLレビュー記録 |
| 競合時の画面 | 保存完了ではなく再読込を案内する | 画面キャプチャとテスト結果 |
| DBの最終値 | 先に保存した値が残り、古い値で戻らない | 受注番号とversion_noの取得結果 |
| 操作ログ | 操作者と新旧version_noを追える | ログIDまたはログ出力 |
| 再操作 | 最新値を読み直して保存できる | 再実行したテスト結果 |
この表をレビュー時に使うと、SQLだけを見て「排他制御を入れた」と判断するのを防げます。 画面が競合を正しく受け取り、ログに残り、利用者が作業を続けられるところまで確認対象になります。
楽観ロックの実装は、WHERE句にバージョン番号を足すところから始まります。競合時の画面、ログ、再操作まで決めて初めて、業務システムで使える排他制御になります。





