【Access VBA極限開発】排他制御を完全掌握する——`DAO.Recordset.LockEdits`による悲観的・楽観的ロック最適化戦略
Accessによる業務アプリケーション開発において、開発者を最も悩ませるのが「複数ユーザー同時アクセス時の競合・ロック障害」です。「書き込みが衝突しました」「他のユーザーがデータを変更しました」という致命的なエラーは、単なるコードミスではなく排他制御の設計思想そのものの欠落から発生します。
Accessのデフォルト挙動に頼った実装は、数人の同時利用で容易に崩壊します。本稿では、`DAO.Recordset`オブジェクトの核となるプロパティ`LockEdits`にスポットを当て、悲観的(Pessimistic)ロックと楽観的(Optimistic)ロックを理論的・実践的に解説します。ACE(JET)データベースエンジンの挙動を完全に制御し、堅牢なプロダクションコードを構築する技術を学びましょう。
—
1. Accessにおける排他制御の物理的挙動と`LockEdits`の本質
多くのVBAエンジニアは、`Recordset.Edit`を呼び出した瞬間に裏で何が起きているかを意識していません。`DAO.Recordset`における排他制御を支配するのが`LockEdits`プロパティです。
Dim rs As DAO.Recordset
Set rs = db.OpenRecordset(“t_Order”, dbOpenDynaset)
rs.LockEdits = True ‘ 悲観的ロック (デフォルト)
‘ rs.LockEdits = False ‘ 楽観的ロック
`LockEdits` のブール値が意味する絶対的ルール
| プロパティ値 | ロックの名称 | ロックが成立するタイミング | 解放されるタイミング |
| :— | :— | :— | :— |
| `True` (初期値) | 悲観的ロック (Pessimistic) | `.Edit` メソッドを実行した瞬間 | `.Update` または `.CancelUpdate` 完了時 |
| `False` | 楽観的ロック (Optimistic) | `.Update` メソッドを実行した瞬間(一瞬) | `.Update` 処理完了の直後 |
なぜデフォルト(`LockEdits = True`)のままではダメなのか?
Accessのデフォルトである悲観的ロック(`LockEdits = True`)では、あるユーザーがVBAコード内で`.Edit`を発行した瞬間から`.Update`を完了するまで、該当レコード(および同一ページ内の他レコード)に対して他のユーザーは編集も削除もできなくなります。
特に問題なのは、画面(UI)のフォーム入力の裏で悲観的ロックを保持し続ける設計です。ユーザーが離席したり、入力に時間を取ったりすると、その間データベースの物理ページ(2KB単位)がロックされ、システム全体が不全に陥ります。
マルチユーザー環境において安定したパフォーマンスを叩き出すためには、処理の性質に応じて`LockEdits`を意図的に切り替えるロジックが不可欠です。
—
2. 悲観的ロック VS 楽観的ロック:アーキテクチャ選定基準
どちらのロック機構を採用すべきかは、感性ではなくデータの粒度とトランザクションの長さによって論理的に決定されます。
【悲観的ロック (LockEdits = True)】
メリット: 衝突は事前にブロックされる。後勝ちによるデータ上書きリスクがゼロ。
デメリット: ロック保持時間が長くなりやすく、他ユーザーのレスポンス低下・エラー誘発。
適用領域: バッチ更新処理、一貫性が何より優先される在庫・会計の数値加減算。
【楽観的ロック (LockEdits = False)】
メリット: ロック時間がミリ秒単位。マルチユーザー環境で最もスループットが高い。
デメリット: `.Update` 実行時に衝突エラー(エラー 3197)が発生するリスクがあり、再試行ロジックが必要。
適用領域: マスター保守画面、同時更新頻度が低いトランザクションデータの修正。
—
3. 実務でそのまま使える堅牢なプロダクションコード
ここからは、実務でそのまま運用に投入できる保守性の高いVBAコード例を示します。オブジェクトのライフサイクル管理(`CurrentDb`の参照保持、明示的なクローズ、メモリ解放)を徹底しています。
【パターン1】楽観的ロック(`LockEdits = False`)による衝突検知・安全更新
大多数の業務UIやAPI連携処理に採用すべき標準パターンです。他のユーザーにブロックされる時間を最小化し、更新競合(エラー 3197)が発生した場合は補償処理を行います。
Option Explicit
‘ ==============================================================================
‘ 処理名 : UpdateCustomerAddressOptimistic
‘ 概要 : 楽観的ロック(LockEdits = False)を用いて顧客住所を安全に更新する
‘ 引数 : lngCustomerID (顧客ID), strNewAddress (新住所)
‘ 戻り値 : Boolean (成功: True, 失敗: False)
‘ ==============================================================================
Public Function UpdateCustomerAddressOptimistic( _
ByVal lngCustomerID As Long, _
ByVal strNewAddress As String _
) As Boolean
Dim db As DAO.Database
Dim rs As DAO.Recordset
Dim strSQL As String
Dim isSuccess As Boolean
On Error GoTo ErrorHandler
isSuccess = False
‘ 必要なレコードのみを限定して取得(全件取得は厳禁)
strSQL = “SELECT CustomerID, Address, UpdatedAt FROM t_Customer WHERE CustomerID = ” & lngCustomerID
‘ CurrentDbのインスタンスを保持
Set db = CurrentDb()
Set rs = db.OpenRecordset(strSQL, dbOpenDynaset, dbSeeChanges)
If rs.EOF And rs.BOF Then
MsgBox “対象の顧客レコードが存在しません。ID: ” & lngCustomerID, vbExclamation, “処理中断”
GoTo CleanUp
End If
‘ ————————————————————————–
‘ 明示的に楽観的ロックを設定
‘ ————————————————————————–
rs.LockEdits = False
‘ 編集モードを開始(この時点ではまだロックはかからない)
rs.Edit
‘ 値の設定
rs.Fields(“Address”).Value = strNewAddress
rs.Fields(“UpdatedAt”).Value = Now()
‘ 更新の実行(このミリ秒単位の瞬間のみ排他ロックが発生する)
rs.Update
isSuccess = True
CleanUp:
‘ オブジェクトの明示的破棄(リーク防止)
If Not rs Is Nothing Then
rs.Close
Set rs = Nothing
End If
If Not db Is Nothing Then
Set db = Nothing
End If
UpdateCustomerAddressOptimistic = isSuccess
Exit Function
ErrorHandler:
Select Case Err.Number
Case 3197 ‘ Write Conflict (データの衝突検知)
‘ 読み込み後、.Updateを発行するまでの間に他ユーザーがレコードを書き換えた
MsgBox “他のユーザーが同時にこのデータを更新しました。” & vbCrLf & _
“最新のデータを確認のうえ、再度やり直してください。”, vbCritical, “排他エラー (3197)”
‘ レコードセットの編集状態をキャンセル
On Error Resume Next
rs.CancelUpdate
On Error GoTo ErrorHandler
Case 3188, 3260 ‘ ページのロック中エラー
MsgBox “該当データまたは隣接データが別処理でロックされています。時間を置いて再試行してください。”, vbExclamation, “ロック競合”
Case Else
MsgBox “予期せぬエラーが発生しました。” & vbCrLf & _
“Err [” & Err.Number & “] : ” & Err.Description, vbCritical, “システムエラー”
End Select
isSuccess = False
Resume CleanUp
End Function
—
【パターン2】悲観的ロック(`LockEdits = True`)+指数バックオフ再試行ロジック
バッチ処理でデータの一貫性を何よりも重視する場合、あるいは一連の計算ロジック中に他者の割り込みを一切許容したくない場合のパターンです。ロック獲得に失敗した際の「再試行(リトライ)ルーチン」を実装します。
Option Explicit
If VBA7 Then
Private Declare PtrSafe Sub Sleep Lib “kernel32” (ByVal dwMilliseconds As Long)
Else
Private Declare Sub Sleep Lib “kernel32” (ByVal dwMilliseconds As Long)
End If
‘ ==============================================================================
‘ 処理名 : ExecuteBatchInventoryDeduction
‘ 概要 : 悲観的ロックによる厳格な在庫引落とし処理(リトライ機構付き)
‘ 引数 : lngProductID (商品ID), intQty (引落数)
‘ 戻り値 : Boolean
‘ ==============================================================================
Public Function ExecuteBatchInventoryDeduction( _
ByVal lngProductID As Long, _
ByVal intQty As Integer _
) As Boolean
Dim db As DAO.Database
Dim rs As DAO.Recordset
Dim strSQL As String
Dim retryCount As Integer
Const MAX_RETRIES As Integer = 5
Const BASE_WAIT_MS As Long = 200 ‘ 基礎待ち時間(ミリ秒)
On Error GoTo ErrorHandler
strSQL = “SELECT ProductID, StockQuantity FROM t_Inventory WHERE ProductID = ” & lngProductID
Set db = CurrentDb()
Set rs = db.OpenRecordset(strSQL, dbOpenDynaset, dbSeeChanges)
If rs.EOF Then GoTo CleanUp
‘ ————————————————————————–
‘ 悲観的ロックを明示宣言
‘ ————————————————————————–
rs.LockEdits = True
AttemptLock:
‘ .Editを呼んだ瞬間、他者はこのレコード(およびページ)を編集不可になる
rs.Edit
‘ ビジネスロジックチェック
If rs.Fields(“StockQuantity”).Value < intQty Then
Err.Raise vbObjectError + 1001, , "在庫不足のため引き落としできません。"
End If
' 計算と更新
rs.Fields("StockQuantity").Value = rs.Fields("StockQuantity").Value - intQty
rs.Update
ExecuteBatchInventoryDeduction = True
CleanUp:
If Not rs Is Nothing Then
rs.Close
Set rs = Nothing
End If
If Not db Is Nothing Then
Set db = Nothing
End If
Exit Function
ErrorHandler:
' 他ユーザーによるロック競合エラー(3188, 3197, 3218, 3260等)
If Err.Number = 3188 Or Err.Number = 3260 Or Err.Number = 3218 Then
If retryCount < MAX_RETRIES Then
retryCount = retryCount + 1
' 指数バックオフ:試行回数に応じて待ち時間を伸ばし、ネットワーク混雑を回避
Sleep (BASE_WAIT_MS (2 ^ (retryCount - 1)))
DoEvents
Resume AttemptLock
Else
MsgBox "他ユーザーの処理完了を待ちましたが、タイムアウトしました。" & vbCrLf & _
"時間を置いてやり直してください。", vbExclamation, "ロック獲得失敗"
End If
ElseIf Err.Number = vbObjectError + 1001 Then
MsgBox Err.Description, vbExclamation, "業務エラー"
Else
MsgBox "エラーが発生しました: " & Err.Description, vbCritical, "エラー"
End If
On Error Resume Next
If Not rs Is Nothing Then rs.CancelUpdate
ExecuteBatchInventoryDeduction = False
Resume CleanUp
End Function
---
4. アーキテクチャ視点で徹底すべき3つの排他制御原則
コードレベルでの`LockEdits`制御に加え、Accessというインフラ特有の課題を克服するために、以下の3原則をプロジェクト全体で厳守してください。
① スプリットデータベース(フロントエンド/バックエンド分離)の徹底
テーブル(.accdb)をファイルサーバーに置き、ロジックと画面が入ったフロントエンド(.accdb / .accde)を各ユーザーのローカルPCに配布してください。全ユーザーが同一のMDB/ACCDBファイルを直接開いて実行する環境では、排他制御以前にファイルレベルの破壊が発生します。
② クライアントオプション「レコードレベルのロック」の有効化
ACEエンジンは歴史的に2KB単位の「ページロック」を行いますが、Accessのオプションで「レコードレベルのロックを開く」を有効に設定することで、ロックの衝突範囲を最小化できます。
- 設定箇所: `ファイル` > `オプション` > `クライアント設定` > `詳細設定` > `既定の開くモード` および `レコードレベルのロックを使用する` にチェック
③ `CurrentDb` の解放漏れと参照破壊の防止
VBA内で `CurrentDb.OpenRecordset(…)` と直接記述すると、暗黙的に生成されたDatabaseオブジェクトの破棄タイミングがVBAのガベージコレクションに依存し、一時的なLDB/RACCDBファイルロックが残存するケースがあります。必ず `Dim db As DAO.Database: Set db = CurrentDb()` として参照を固定し、処理終了時に `Set db = Nothing` を実行してください。
—
5. まとめ
Access VBAにおける排他制御は、Access任せにするのではなく、開発者が意図を持って`LockEdits`を制御することで初めて完成します。
- 基本は楽観的ロック(`LockEdits = False`): UI操作や通常の更新処理に採用し、エラー3197をハンドリングする。
- 限定的な悲観的ロック(`LockEdits = True`): バッチ処理や厳密な整合性が要求される加減算処理に採用し、リトライロジックを組む。
- ライフサイクル管理の徹底: `.Close`と `Set = Nothing` を徹底し、不要な物理ロックを残さない。
この原則をコード規約に組み込み、堅牢で止まらないAccessシステムを構築してください。
