--- description: 333-prepared-statementブランチでの開発時に読み込む alwaysApply: false --- 333-prepared-statement ブランチでの高速化内容 === このブランチで実装することは以下の通りです。 - prepared statement を、高頻度な同一 SQL 実行で通常経路より速くなるように改善する - とくに PostgreSQL で、prepare/deallocate の無駄と接続拡散を減らす - benchmark が prepared statement の warm 状態を正しく測れるようにする ## 進捗 - [x] `example/benchmark.nim` の PostgreSQL 計測で prepared update が遅く見える理由を調査した - [x] prepared statement 高速化の設計方針を整理した - [ ] PostgreSQL にプール単位の prepared statement cache を追加する - [ ] `close()` を論理 close 化し、物理破棄は別 API に分離する - [ ] 接続固定で prepared statement を連続実行できる API を追加する - [ ] `example/benchmark.nim` を cold/warm 計測に分離し、warm 経路で同一 statement を再利用する - [ ] prepared cache と接続固定 API のテストを追加する ## 参考資料 - PostgreSQL libpq prepared statements: https://www.postgresql.jp/document/12/html/libpq-exec.html - `PQsendPrepare` - `PQsendQueryPrepared` - `DEALLOCATE` - PostgreSQL 非同期 libpq: https://www.postgresql.jp/document/12/html/libpq-async.html - 対象実装 - `src/allographer/query_builder/models/postgres/postgres_exec.nim` - `src/allographer/query_builder/models/postgres/postgres_types.nim` - `src/allographer/query_builder/libs/postgres/postgres_impl.nim` - `example/benchmark.nim` ## 調査結果・設計まとめ ### 現状の問題 PostgreSQL で prepared statement が遅く見える主因は、prepared statement 自体の性質ではなく、現在の実装と benchmark の計測条件にある。 - `benchmark.nim` は計測のたびに `prepare` と `close` を行っており、steady-state の再利用性能ではなくライフサイクル全体を測っている - PostgreSQL 実装は prepared statement をコネクションごとに lazy prepare するため、プールが大きいと `PREPARE` が広く拡散する - `first()` と `exec()` が毎回 `getFreeConn()` するため、1 つの業務操作でも接続が変わり、prepared state の局所性が弱い - 通常経路も `PQsendQueryParams` による bind 実行なので、prepared の利得は parse/plan 再利用ぶんに限られる ### 高速化の基本方針 prepared statement を速くするには、単発 API を追加するだけでは足りない。以下をまとめて入れる必要がある。 - prepare/deallocate の回数を減らす - 同じ logical operation を同じ接続に寄せる - warm 済み prepared state をプール内で継続利用する ### 設計方針 1: プール単位の prepared cache `PreparedStatement` ハンドルごとに物理 state を持つのではなく、`Connections` が SQL ごとの prepared state を保持する。 概念設計: ```nim type PostgresPreparedEntry = ref object sql*: string nArgs*: int stmtBaseName*: string stmtNames*: seq[string] ## connI ごとの物理 prepared 名 refCount*: int lastUsedAt*: int64 type Connections* = ref object conns*: seq[Connection] timeout*: int waiters*: Deque[Future[void]] columnTypeCache*: Table[string, seq[Row]] preparedCache*: Table[string, PostgresPreparedEntry] ``` 期待効果: - 同じ SQL に対する `prepare()` の重複を避けられる - 同一プール内では physical prepare を接続ごとに 1 回へ寄せられる - 短命な prepared handle を繰り返し作っても warm state を失いにくくなる ### 設計方針 2: `close()` の論理 close 化 現在の `close()` は、その handle が使った接続すべてに `DEALLOCATE` を送る。高速化の観点ではこれは不利なので、意味論を分ける。 - `close()`: handle の参照を閉じるだけ - `clearPreparedCache()` または `flushPrepared(sql)`: 物理 `DEALLOCATE` - 接続 close 時: その接続に紐づく prepared state を一括破棄 期待効果: - 利用者が毎回 `prepare` / `close` しても、プール側は hot SQL を保持できる - benchmark の warm 計測でも、毎回の物理破棄コストを避けられる ### 設計方針 3: 接続固定 API の追加 prepared statement の locality を高めるため、同じ接続上で複数回の prepared 実行を束ねる API を追加する。 候補 API: ```nim proc withPreparedConnection*( self: PostgresConnections, body: proc (ctx: PostgresPreparedContext): Future[void] ): Future[void] ``` ```nim type PostgresPreparedContext* = ref object owner*: PostgresConnections connI*: int ``` prepared statement 側には `ctx` 付き overload を足す。 ```nim proc first*(self: PostgresPreparedStatement, ctx: PostgresPreparedContext, args: seq[string]): Future[Option[JsonNode]] proc exec*(self: PostgresPreparedStatement, ctx: PostgresPreparedContext, args: seq[string]): Future[void] ``` 期待効果: - `SELECT` と `UPDATE` が別接続に飛ぶのを防げる - lazy prepare の発生先が必要な接続に限定される - transaction 中の `transactionConn` と整合しやすい ### benchmark の変更方針 `example/benchmark.nim` は prepared statement の高速化効果を正しく測れるように変更する。 - `selectStmt` / `updateStmt` を `timeProcess` の外で 1 回だけ作る - 繰り返し計測では同じ prepared statement を再利用する - `close()` は全計測の最後に 1 回だけ呼ぶ - benchmark 名を `cold` と `warm` に分ける - warm 計測では `withPreparedConnection` を使って接続固定経路を測る ### 実装順序 #### Phase 1 - `Connections.preparedCache` を追加する - `prepare()` を cache lookup ベースに変更する - `close()` を論理 close 化する - `clearPreparedCache()` を追加する #### Phase 2 - `PostgresPreparedContext` を追加する - `withPreparedConnection` を追加する - prepared `get/first/exec` に `ctx` overload を追加する - `benchmark.nim` を warm/cold 計測へ更新する #### Phase 3 - `preparedCacheMaxEntries` を追加する - `refCount == 0` の entry を対象に LRU eviction を入れる - eviction 時に `DEALLOCATE` を発行する ### テスト方針 - 同じ SQL を複数回 `prepare()` しても、同一接続で physical prepare が 1 回に収まること - `close()` 後に再 `prepare()` しても、cache が残る設計なら physical prepare が増えないこと - `withPreparedConnection` の block 内で同じ `connI` が使われること - transaction 中は `transactionConn` が優先されること - cache eviction 時に `DEALLOCATE` が正しく走ること