DAO.RecordsetにおけるLockEditsプロパティの深淵:悲観的・楽観的排他制御のアーキテクチャと極限運用
Microsoft Accessのデータベースエンジン(JET/ACE)におけるマルチユーザー環境の構築において、最もエンジニアの腕が試されるのが「書き込み競合(Write Conflict)」の制御である。画面上のバインドフォームに任せきりにした排他制御は、ネットワーク遅延や同時アクセス数の増加によってあっけなく破綻し、エラー3197や3260を吐き出してエンドユーザーを困惑させる。
ミッションクリティカルな業務システムにおいて、データを破壊から守り、スループットを最大化するには、`DAO.Recordset`の`LockEdits`プロパティと、JET/ACEエンジンの内部ロックメカニズムを完全に掌握しなければならない。
本稿では、数々のレガシー現場を極限状態で支えてきたチーフアーキテクトの視点から、楽観的排他制御(Optimistic Locking)と悲観的排他制御(Pessimistic Locking)の真の挙動の違い、2KBページロックの物理的制約、そしてエラーリカバリを組み込んだ完璧なVBA実装パターンを徹底解説する。
—
1. JET/ACEエンジンの物理ロック構造と LockEdits プロパティ
Accessのバックエンド(.mdb / .accdb)において、ロック情報はすべて対になるロックファイル(.ldb / .laccdb)内で管理される。ここで理解すべきは、DAOにおける排他制御の挙動を決定づけるのが `DAO.Recordset.LockEdits` プロパティであるという事実だ。
LockEdits = True —> 悲観的排他制御 (Pessimistic Locking)
LockEdits = False —> 楽観的排他制御 (Optimistic Locking) ※デフォルト
多くの開発者が誤解しているが、この設定は単なる「フラグ」ではない。エンジンの物理的なデータアクセスルーチンを根底から変革させる。
ページレベルロックとレコードレベルロックの罠
デフォルトのAccess環境において、JET/ACEエンジンは2KB(2048バイト)単位の物理ページロックを行う。つまり、目的の1レコードをロックしたつもりでも、その2KBのページ内に同居する前後の無関係なレコードまで巻き添えでロックされる(ページロッキング)。
Access 2000以降、「レコードレベルロック」がオプションとして導入されたが、DAOコードから明示的に制御を行わない場合、エンジンは必要に応じてページロックへ昇格(Lock Escalation)させる。悲観的ロックを選択する場合は、この物理構造を常に意識しなければならない。
—
2. 悲観的排他制御 (LockEdits = True) の極致
メカニズムと発生タイミング
悲観的排他制御では、`.Edit` メソッドが呼び出された「瞬間」に対象レコード(あるいはページ)に排他ロック(Exclusive Lock)が設定される。
- メリット: `.Edit` に成功した時点で、他のセッションによる変更は100%遮断される。`.Update` 実行時の衝突が原理的に発生しない。
- デメリット: ユーザーが入力中に離席するなど、長時間ロックが保持されるリスクがある。ロック取得失敗(エラー3260など)に対する即座のハンドリングが必須。
Windows APIを併用した指数バックオフ再試行実装
悲観的排他制御では、ロック衝突時の「リトライ戦略」が必須となる。単なる `DoEvents` や単純ループはCPUリソースを食いつぶすため、Windows APIの `Sleep` を利用してスレッドを休止させつつ、指数バックオフ(Exponential Backoff)で再試行を行うのがプロフェッショナルの鉄則だ。
Option Explicit
‘ — Windows API 宣言(64bit / 32bit 両対応) —
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
‘ 悲観的ロックによる安全な更新処理
Public Function UpdateRecordPessimistic( _
ByRef db As DAO.Database, _
ByVal strSQL As String, _
ByVal strNewValue As String _
) As Boolean
On Error GoTo ErrorHandler
Dim rs As DAO.Recordset
Dim retryCount As Long
Dim maxRetries As Long
Dim backoffMs As Long
maxRetries = 5
backoffMs = 50 ‘ 初期ウェイト (ミリ秒)
‘ dbOpenDynaset または dbOpenOpenRecordset で開く
Set rs = db.OpenRecordset(strSQL, dbOpenDynaset, dbSeeChanges)
‘ 悲観的ロックを設定
rs.LockEdits = True
If Not (rs.BOF And rs.EOF) Then
AttemptLock:
On Error GoTo LockErrorHandler
‘ 【ロック発生地点】 LockEdits = True の場合、.Edit 呼出時にロックを試みる
rs.Edit
‘ ロック取得成功後、本線エラーハンドラに戻す
On Error GoTo ErrorHandler
‘ 値の更新
rs.Fields(“TargetColumn”).Value = strNewValue
‘ 書き込み (dbFailOnError 相当の厳格処理)
rs.Update
UpdateRecordPessimistic = True
End If
CleanUp:
On Error Resume Next
If Not rs Is Nothing Then
rs.Close
Set rs = Nothing
End If
Exit Function
LockErrorHandler:
‘ 3260: 他のユーザーによってロックされています
‘ 3186: 読み込みロック競合
If Err.Number = 3260 Or Err.Number = 3186 Then
retryCount = retryCount + 1
If retryCount <= maxRetries Then
' 指数バックオフ+ジッター(ミリ秒)で競合を回避
Sleep backoffMs + (Int(Rnd() 20))
backoffMs = backoffMs 2
DoEvents
Resume AttemptLock
Else
MsgBox "レコードが他のユーザーによって長時間ロックされています。時間をおいて再試行してください。", vbExclamation, "排他エラー"
UpdateRecordPessimistic = False
Resume CleanUp
End If
Else
GoTo ErrorHandler
End If
ErrorHandler:
Dim errNum As Long
Dim errDesc As String
errNum = Err.Number
errDesc = Err.Description
UpdateRecordPessimistic = False
MsgBox "予期せぬエラーが発生しました: (" & errNum & ") " & errDesc, vbCritical, "システムエラー"
Resume CleanUp
End Function
---
3. 楽観的排他制御 (LockEdits = False) の極致
メカニズムと発生タイミング
楽観的排他制御(デフォルト)では、`.Edit` 時にはロックをかけず、`.Update` メソッドが呼び出された「瞬間」に、取得時と現在のメモリ上のイメージ(またはタイムスタンプ/バイナリ値)を比較し、変更の衝突を検知する。
- メリット: ロック保持時間がミリ秒単位となり、高並列環境でスループットが劇的に向上する。
- デメリット: `.Update` 実行時にエラー3197(書き込み競合)が発生する可能性があり、他セッションが更新した値の上書き(ロストアップデート)をどう防止・解決するか設計が必要。
エラー3197のハンドリングと競合検知戦略
楽観的ロックで競合が発生した場合、DAOはエラー `3197` (“他のユーザーが同じデータを変更したため、データは変更されませんでした”) を発生させる。この時、`rs.CancelUpdate` を呼んで状態をリセットするか、ユーザーに上書きの選択権を与える設計が必要だ。
‘ 楽観的ロックによる競合検知と安全な更新処理
Public Function UpdateRecordOptimistic( _
ByRef db As DAO.Database, _
ByVal strSQL As String, _
ByVal strNewValue As String _
) As Boolean
On Error GoTo ErrorHandler
Dim rs As DAO.Recordset
Set rs = db.OpenRecordset(strSQL, dbOpenDynaset, dbSeeChanges)
‘ 明示的に楽観的ロックを指定
rs.LockEdits = False
If Not (rs.BOF And rs.EOF) Then
rs.Edit
rs.Fields(“TargetColumn”).Value = strNewValue
On Error GoTo UpdateErrorHandler
‘ 【衝突検知地点】 .Update 実行時に他者の変更をチェック
rs.Update
On Error GoTo ErrorHandler
UpdateRecordOptimistic = True
End If
CleanUp:
On Error Resume Next
If Not rs Is Nothing Then
rs.Close
Set rs = Nothing
End If
Exit Function
UpdateErrorHandler:
‘ 3197: 別のユーザーによってデータが変更されている
If Err.Number = 3197 Then
rs.CancelUpdate ‘ 編集状態を取り消す
Dim res As VbMsgBoxResult
res = MsgBox(“更新中に他のユーザーによってデータが書き換えられました。” & vbCrLf & _
“最新のデータを取得して処理を中止しますか?”, vbQuestion + vbYesNo, “競合検知”)
If res = vbYes Then
‘ データを再読み込みするなどのリカバリロジック
rs.Requery
Else
‘ 強制上書きを行う場合は、再度EditしてUpdateを試みる(非推奨だが現場要件による)
End If
UpdateRecordOptimistic = False
Resume CleanUp
Else
GoTo ErrorHandler
End If
ErrorHandler:
UpdateRecordOptimistic = False
MsgBox “エラーが発生しました: ” & Err.Description, vbCritical
Resume CleanUp
End Function
—
4. どちらを選択すべきか:設計決定マトリクス
業務要件に応じた選定基準を以下に示す。安易に「すべて楽観的」あるいは「すべて悲観的」とするのではなく、画面・バッチの処理特性に応じて切り替えるのがアーキテクトの正道である。
| 評価軸 | 悲観的排他制御 (`LockEdits = True`) | 楽観的排他制御 (`LockEdits = False`) |
| :— | :— | :— |
| ロック確保のタイミング | `.Edit` 実行時 | `.Update` 実行時 |
| ロック保持期間 | `.Edit` から `.Update`(または `.CancelUpdate`)まで | `.Update` 処理中の数ミリ秒間 |
| 同時実行性(スループット) | 低(他者の読み書きをブロックするリスクあり) | 高(リソースの競合を最小化) |
| 発生する主要エラー | `3260` (Locked by another user) | `3197` (Data has changed) |
| 最適ユースケース | – 発行連番の採番テーブル
– 在庫数の引き当て処理
– 短時間で完結するバッチトランザクション | – マスタメンテナンス画面
– 参照が中心の業務ロジック
– ユーザーの入力時間が長い画面 |
—
5. オブジェクトライフサイクルとメモリリークの完全排除
Access VBAにおける排他制御の失敗は、コード上のロジックエラーだけでなく、メモリ上のオブジェクト参照リークに起因することが非常に多い。
特に `CurrentDb` の扱いは極めて危険である。`CurrentDb` は呼び出されるたびに新しい `DAO.Database` インスタンスを内部生成する。変数に保持せず `CurrentDb.OpenRecordset(…)` のように記述すると、オブジェクトが明示的に解放されず、.laccdb 内にゴーストセッションが残存し、永久にロックが解除されない不具合を引き起こす。
メモリとリソースを完全に掌握するルール
1. `CurrentDb` の参照は必ず変数に保持し、処理の終わりに `Set db = Nothing` を実行する。
2. `Recordset` は例外なく `Close` 呼び出し後に `Set rs = Nothing` で解放する。
3. トランザクション(`Workspace.BeginTrans`)を使用する場合、DAOの排他制御とトランザクション分離レベルの相乗効果を意識する。
‘ プロダクション環境に耐えうる完全なリソース解放テンプレート
Public Sub ExecSafeTransaction()
Dim ws As DAO.Workspace
Dim db As DAO.Database
Dim rs As DAO.Recordset
Set ws = DBEngine(0)
Set db = CurrentDb ‘ 変数に保持
On Error GoTo ErrorHandler
ws.BeginTrans
Set rs = db.OpenRecordset(“SELECT FROM T_Inventory WHERE ItemID = 101”, dbOpenDynaset, dbSeeChanges)
rs.LockEdits = True
rs.Edit
rs.Fields(“Quantity”).Value = rs.Fields(“Quantity”).Value – 1
rs.Update
ws.CommitTrans
CleanUp:
‘ 逆順かつ確実にオブジェクトを解放
On Error Resume Next
If Not rs Is Nothing Then
rs.Close
Set rs = Nothing
End If
If Not db Is Nothing Then
‘ CurrentDbの場合はClose不要(参照を切るのみ)
Set db = Nothing
End If
Set ws = Nothing
Exit Sub
ErrorHandler:
If Not ws Is Nothing Then
ws.Rollback
End If
MsgBox “トランザクションがロールバックされました: ” & Err.Description, vbCritical
Resume CleanUp
End Sub
—
6. まとめ:レガシーの皮をかぶった堅牢なシステムの構築
`DAO.Recordset` の `LockEdits` プロパティは、単なるプロパティの設定値にあらず、JET/ACEエンジンの物理的なディスクI/O、ロックファイルの制御、メモリの参照構造と直結した高レベルなアーキテクチャ・スイッチである。
- 高頻度のデータ更新や採番処理には、`LockEdits = True` と指数バックオフ再試行ロジックを組み合わせた悲観的制御を適用する。
- 通常の画面入力や大量アクセスには、`LockEdits = False` と競合検知エラーハンドリングを組み合わせた楽観的制御を適用する。
- `CurrentDb` や `Recordset` のライフサイクルを厳格に管理し、メモリリークによる「不可解なロック残存」を根絶する。
これらを徹底することで、どれほどマルチユーザーアクセスが集中しようとも、データ整合性を完璧に保ち、エラーで倒れない堅牢なAccessシステムを作り上げることができる。これこそが、アーキテクトが目指すべきAccess開発の深遠である。
