序論:なぜあなたのAccessフォームは遅延するのか
Microsoft Accessが四半世紀以上にわたり基幹業務を支え続けている理由は、そのラピッドプロトタイピング性能にある。しかし、業務規模の拡大とデータ量の爆発に伴い、多くの社内システムは例外なく「画面遷移の引っ掛かり」「フォーム描画時のプチフリーズ」という壁に突き当たる。
その主因は、Access初学者が無批判に採用する「テーブルや重厚なビューを直接バインドした連結フォーム」と「文字列連結による`Form.RecordSource`への動的SQL代入」にある。
`Me.RecordSource = “SELECT FROM …”` という代入文を実行した瞬間、ACE(Access Connectivity Engine / 旧Jet)の内部では以下の重篤な処理が同期的に走る。
1. 文字列SQLの構文解析(パーシング)
2. 実行計画(クエリプラン)のゼロベース再生成
3. コントロール群と全フィールドの動的再バインディング
4. 不要な列情報を含む全メタデータの走査とクライアントメモリへの展開
このオーバーヘッドを排除し、マイクロ秒単位で応答するエンタープライズグレードのフォームを構築するには、「非連結フォーム」を基盤とし、インメモリの一時QueryDef(Query Definition)でコンパイル済みの実行プランを生成したうえで、`DAO.Recordset`を`Form.Recordset`へ直結バインドする設計が不可欠となる。
本稿では、COMオブジェクトの厳密なライフサイクル管理、高精度Windows APIによる性能プロファイリング、そしてACEの内部挙動を考慮した「極限の最適化手法」を提示する。
—
1. 文字列SQL直接代入 vs 一時QueryDef直接バインドのアーキテクチャ差
フォームのソース更新において、`RecordSource`へのSQL代入と`QueryDef`経由の`Recordset`バインドでは、下位レイヤーで生じている処理が根本から異なる。
| 評価軸 | `Me.RecordSource = strSQL` | 一時QueryDef + `Set Me.Recordset = rs` |
| :— | :— | :— |
| 実行プランの生成 | 実行ごとに毎回インタープリタ的に完全解析 | コンパイルされたネイティブプランを即時実行 |
| システムカタログへの影響 | `MSysObjects`/`MSysQueries`の内部スワップを誘発 | メモリ上(インメモリ)のみで完結しカタログ非破壊 |
| 型解決(Type Resolution) | 文字列からの暗黙的キャストに依存 | `DAO.DataTypeEnum`による明示的かつ即時の解決 |
| SQLインジェクション対策 | 自前エスケープ必須(脆弱性の温床) | パラメーターオブジェクトによる完全なデータ分離 |
| 描画開始タイミング | 全メタデータ同期完了までUIブロック | カソルオープン後、即座にウィンドウハンドル描画 |
最も重要な点は、名前を持たない「一時QueryDef(Name引数に空文字 `””` を指定)」を生成する手法だ。データベースファイル(.accdb/.mdb)のシステムテーブルを一切汚染することなく、純粋なインメモリQueryDefオブジェクトとしてACE内部の最適化エンジンを駆動できる。
—
2. 実装:QueryPerformanceCounterによるマイクロ秒プロファイリングと動的バインド
以下に、実戦投入可能な最適化モジュールを示す。計測にはミリ秒精度の`Timer`関数ではなく、ハードウェアクロックに直結するWindows API `QueryPerformanceCounter`を使用し、ナノ秒単位のジッターまで可視化する。
2.1. API宣言と最適化バインドコアロジック
標準モジュール(例:`mod_FormOptimizer`)またはフォームの宣言セクションに記述する。
Option Compare Database
Option Explicit
‘ ==============================================================================
‘ 高精度システムタイマー (Windows API)
‘ ==============================================================================
If VBA7 Then
Private Declare PtrSafe Function QueryPerformanceCounter Lib “kernel32” ( _
ByRef lpPerformanceCount As Currency) As Long
Private Declare PtrSafe Function QueryPerformanceFrequency Lib “kernel32” ( _
ByRef lpFrequency As Currency) As Long
Else
Private Declare Function QueryPerformanceCounter Lib “kernel32” ( _
ByRef lpPerformanceCount As Currency) As Long
Private Declare Function QueryPerformanceFrequency Lib “kernel32″ ( _
ByRef lpFrequency As Currency) As Long
End If
‘ ==============================================================================
‘ 構造体/定数定義
‘ ==============================================================================
Private Const DB_FAIL_ON_ERROR As Long = &H80
”’
”’
”’ 対象フォームインスタンス(Meを渡す)
”’ 抽出パラメーター:部門ID
”’ 抽出パラメーター:基準日
Public Sub BindOptimizedRecordset( _
ByRef targetForm As Access.Form, _
ByVal deptId As Long, _
ByVal targetDate As Date)
Dim db As DAO.Database
Dim qdf As DAO.QueryDef
Dim rs As DAO.Recordset
Dim strSQL As String
‘ 計測用変数
Dim curFreq As Currency
Dim curStart As Currency
Dim curEnd As Currency
Dim dblElapsedMs As Double
QueryPerformanceFrequency curFreq
QueryPerformanceCounter curStart
On Error GoTo Err_Handler
‘ 1. カレントデータベースのポインタ取得
‘ ※ DBEngine(0)(0) は CurrentDb よりも高速だが、
‘ スコープ管理を安全にするためここでは CurrentDb を明示取得
Set db = CurrentDb()
‘ 2. 射影(PROJECTION)と選択(SELECTION)の極限化
‘ SELECT は厳禁。フォームの表示・計算に必要な最小限の列のみを列挙する。
‘ PARAMETER句を厳格に定義し、ACEのクエリパーサーに型を直接教え込む。
strSQL = “PARAMETERS prmDeptId LONG, prmTargetDate DATETIME; ” & vbCrLf & _
“SELECT ” & _
” E.EmpID, ” & _
” E.EmpName, ” & _
” E.Salary, ” & _
” E.JoinedDate ” & _
“FROM tbl_Employees AS E ” & _
“WHERE E.DepartmentID = [prmDeptId] ” & _
” AND E.JoinedDate <= [prmTargetDate] " & _
"ORDER BY E.EmpID ASC;"
' 3. 一時QueryDefの生成(第1引数に空文字列を指定することでインメモリ生成)
Set qdf = db.CreateQueryDef("", strSQL)
' 4. パラメーターのバインド(型キャストオーバーヘッドの完全排除)
qdf.Parameters("prmDeptId").Value = deptId
qdf.Parameters("prmTargetDate").Value = targetDate
' 5. 最適なカーソルタイプによるRecordsetオープン
' 更新可能なサブフォーム/単票の場合は dbOpenDynaset
' 参照専用であれば dbOpenSnapshot + dbForwardOnly が最高速だが、
' フォームのRecordsetプロパティには双方向スクロール可能なカーソルが必須。
Set rs = qdf.OpenRecordset(dbOpenDynaset, dbSeeChanges)
' 6. フォームへのRecordset直接バインド
' RecordSource文字列ではなく、ACEメモリ空間上のRecordsetインターフェースを直接渡す
Set targetForm.Recordset = rs
QueryPerformanceCounter curEnd
dblElapsedMs = ((curEnd - curStart) / curFreq) 1000#
Debug.Print "[PERF] Recordset Binding Completed: " & _
Format$(dblElapsedMs, "0.000") & " ms"
Exit_Handler:
' --------------------------------------------------------------------------
' オブジェクトの完全なカスケード解放
' --------------------------------------------------------------------------
' targetForm.Recordset にアサインされたRecordsetはフォーム側で参照カウントが
' 保持されるため、ローカルポインタは安全にデリファレンス(Nothing)可能。
If Not rs Is Nothing Then
rs.Close
Set rs = Nothing
End If
If Not qdf Is Nothing Then
qdf.Close
Set qdf = Nothing
End If
If Not db Is Nothing Then
Set db = Nothing
End If
Exit Sub
Err_Handler:
Debug.Print "[CRITICAL] Error " & Err.Number & ": " & Err.Description
' エラー発生時は即座にリソースを解放して上位にエラーを投げる
Resume Exit_Handler
End Sub
---
3. 非連結フォームのコントロール最適化と遅延バインド
フォーム側の初期設計も徹底する必要がある。フォームのデザインビューにおいて、以下のプロパティを事前に固定しておくこと。
フォーム側の設定
- レコードソース(RecordSource): 空白(完全な非連結状態)
- レコードロック(RecordLocks): 編集済みレコード(No Locks)
- 読み込み時取得(FetchDefaults): いいえ(False)
コントロール側の設定(テキストボックス等)
- 各コントロールの `コントロールソース(ControlSource)` には、SQLで射影した列名(例: `EmpID`, `EmpName`)をあらかじめ静的に設定しておく。
[UIレイヤー: コントロール]
txtEmpID.ControlSource –> “EmpID”
txtEmpName.ControlSource –> “EmpName”
▲
│ (Set Me.Recordset = rs が発行された瞬間に自動接続)
│
[ACEレイヤー: DAO.Recordset]
Fields(“EmpID”)
Fields(“EmpName”)
非連結フォームにコントロールソースだけを静的に埋め込んでおくと、デザイン時およびロード直後は一時的に `#Name?` や空白となるが、`Set Me.Recordset = rs` がコールされた瞬間に、コントロールは再描画イベントを最小限に抑えつつ、一瞬で値に吸着(バインド)する。
このアプローチにより、Accessがフォームオープン時に全テーブルを走査してメタデータをフェッチする「暗黙の初期通信」を100%遮断できる。
—
4. エンタープライズ運用の深淵:メモリリークの遮断とODBC環境の最適化
大規模運用および基盤保守において、シニアエンジニアが担保すべき2つの重要項目を解説する。
4.1. フォーム破棄時の循環参照(Circular Reference)問題
フォームの `Recordset` プロパティに外部生成した `DAO.Recordset` を代入した場合、フォームがアンロードされた後も内部COM参照がリークし、Accessプロセスのコミットチャージが単調増加し続けるケースが存在する。
これを防ぐため、フォームの `Form_Close` イベントで参照を明示的に切断するコードを必ず組み込む。
Private Sub Form_Close()
On Error Resume Next
‘ フォームのRecordset参照を切断し、内部COM参照カウンタを正常にデクリメントさせる
Set Me.Recordset = Nothing
On Error GoTo 0
End Sub
4.2. SQL Serverバックエンド(ODBCリンク)時のパススルーQueryDef
バックエンドがSQL ServerやAzure SQL Databaseの場合、ACEのクエリエンジンを介在させること自体がネットワークトラフィックの無駄となる。
ODBC接続環境下では、一時QueryDefをパススルーモード(`Connect`プロパティを設定)へ切り替える。
‘ パススルー用の一時QueryDef構築例
Set qdf = db.CreateQueryDef(“”)
qdf.Connect = “ODBC;DRIVER={ODBC Driver 18 for SQL Server};SERVER=srv-sql01;DATABASE=ProductionDB;Trusted_Connection=Yes;Encrypt=Yes;”
qdf.SQL = “EXEC dbo.usp_GetDepartmentEmployees @DeptID = ” & deptId & “;”
qdf.ReturnsRecords = True
‘ パススルー結果をスナップショットとして受け取りフォームへ流し込む
Set rs = qdf.OpenRecordset(dbOpenSnapshot, dbSQLPassThrough)
Set Me.Recordset = rs
これにより、T-SQLのコンパイルおよび実行はSQL Server側の強力なリソースで完結し、Accessクライアントは返却されたストリームをそのままコントロールに描画する「純粋なGUIクライアント」へと昇華する。
—
結論:レガシーの皮を被った超高速エンジンの運用
Accessが遅いのではない。Accessに過剰な仕事を同期的に強要するコードが遅いのだ。
1. 連結フォームの「暗黙のフルスキャン」を捨て、非連結フォームをベースとする。
2. 文字列SQLの再代入を廃止し、インメモリの一時QueryDef(`CreateQueryDef(“”, …)`) で型安全な実行計画を固定化する。
3. `Form.RecordSource` ではなく、`Form.Recordset` への直接参照注入を行う。
4. コントロールの `ControlSource` を静的マッピングし、描画パイプラインのフリッカー(ちらつき)と再評価を完全に抑え込む。
これらの原則を徹底することで、何十万行ものレコードを抱える基幹サブシステムであっても、Webネイティブの最新SPAフレームワークに劣らないミリ秒単位のレスポンスを実現できる。アーキテクチャの根底を理解し、ランタイムの挙動を支配することこそが、レガシーを真の資産へと変える技術者の責務である。
