- Pure Nim wire protocol client (async + sync, no C FFI) - Query Builder: fluent API, SQL generator, execution, transactions - Schema Builder: create/alter/drop tables, column operations - Connection pool with aging and timeout handling - ORM integration via compile-time DB_BARADB switch - Tests: test_open, test_query - Documentation: README.md, PLAN_BARADB.md
12 KiB
PostgreSQL driver 効率改善レポート
MariaDB driver に対して実施した効率改善と同様の観点で、PostgreSQL driver(src/allographer/query_builder/libs/postgres/ および models/postgres/)を調査した結果をまとめる。
P0: 重大(即時修正推奨)
1. exec / insertId が DML のたびに information_schema.columns を問い合わせている
ファイル: models/postgres/postgres_exec.nim 196-211行, 214-229行
proc exec(self:PostgresQuery, queryString:string) {.async.} =
...
let table = self.query["table"].getStr
let columnGetQuery = &"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = '{table}'"
let (columns, _) = postgres_impl.query(..., columnGetQuery, ...).await
postgres_impl.exec(..., queryString, ..., columns, ...).await
問題:
- INSERT / UPDATE / DELETE のたびに追加で1回 SELECT が発生し、DML 性能が実質2倍遅くなる
'{table}'の文字列補間は SQL インジェクションリスクがある(insertIdにも同様の問題)
改善案:
MariaDB で実施済みの columnTypeCache: Table[string, seq[seq[string]]] を Connections に追加し、テーブルごとに初回のみ取得・キャッシュする。SQL はパラメータ化クエリに変更する。
P1: 中程度
2. waiters が seq[Future[void]] で先頭削除が O(n)
ファイル: models/postgres/postgres_types.nim 20行, models/postgres/postgres_exec.nim 28-35行
# postgres_types.nim
waiters*: seq[Future[void]]
# postgres_exec.nim
proc wakeOnePoolWaiter(pools: Connections) =
while pools.waiters.len > 0:
let w = pools.waiters[0]
pools.waiters.delete(0) # ← O(n) のシフト操作
...
問題: seq の先頭削除は全要素シフトを伴う O(n) 操作。waiter が多い高負荷時に性能劣化する。removePoolWaiter も同じくリニアスキャン+シフト。
改善案: std/deques の Deque[Future[void]] に変更。popFirst() が O(1)。MariaDB で実施済み。
3. PgWaitState が ref object(ヒープアロケーション)
ファイル: libs/postgres/postgres_impl.nim 14行
type
PgWaitState = ref object
cancelled: bool
問題: waitPgReadable / waitPgWritable が呼ばれるたびに PgWaitState のヒープアロケーションが発生する。
改善案: object(値型)に変更。async proc のクロージャ環境に格納されるため、値型でも正しく動作する。MariaDB で実施済み。
4. waitPgReadable と waitPgWritable がほぼ重複している
ファイル: libs/postgres/postgres_impl.nim 37-81行
proc waitPgReadable(db: PPGconn, timeoutMs: int): Future[bool] {.async.} =
...
addRead(fd, readCb) # ← 違いはここだけ
...
proc waitPgWritable(db: PPGconn, timeoutMs: int): Future[bool] {.async.} =
...
addWrite(fd, writeCb) # ← 違いはここだけ
...
問題: 2つの proc の差は addRead vs addWrite だけ。20行以上の完全な重複。
改善案: waitPgIo(db, timeoutMs, forRead: bool) のような統一 proc にまとめる。
5. waitPgReadable / waitPgWritable が pqsocket を二重に呼んでいる
ファイル: libs/postgres/postgres_impl.nim 37-58行
proc waitPgReadable(db: PPGconn, timeoutMs: int): Future[bool] {.async.} =
...
ensurePgSocketRegistered(db) # 内部で pqsocket(db) を呼ぶ
let sock = pqsocket(db) # ← 再度 pqsocket を呼んでいる
if sock < 0:
dbError(db)
let fd = AsyncFD(cint(sock))
...
問題: ensurePgSocketRegistered 内で pqsocket を呼んで fd を得ているのに、その戻り値を使わず直後にもう一度 pqsocket を呼んでいる。FFI 呼び出しの無駄。
改善案: ensurePgSocketRegistered を AsyncFD を返す proc にする(MariaDB 版 ensureMariadbSocketRegistered と同じ設計)。
P2: 軽微
6. getTime().toUnix() によるデッドライン計算(秒精度)
ファイル: libs/postgres/postgres_impl.nim 83-89行, 149-150行 他多数
proc pgRemainingMs(deadline: int64): int =
let leftSec = deadline - getTime().toUnix()
...
result = int(leftSec * 1000)
# deadline の生成(秒精度)
let calledAt = getTime().toUnix()
let deadline = calledAt + timeout.int64
問題:
getTime().toUnix()は秒精度のため、ミリ秒単位のタイムアウト制御ができない- NTP 補正によるクロック巻き戻りの影響を受ける
timeout = 1のケースでは、0.9秒経過後に残り 0ms と判定される可能性がある
改善案: std/monotimes の MonoTime + Duration に統一する。MariaDB で実施済み。
7. setColumnInfo が行ごとに全カラム分の FFI 呼び出しを行っている
ファイル: libs/postgres/postgres_lib.nim 159-167行, libs/postgres/postgres_impl.nim 160-163行 他
# postgres_lib.nim
proc setColumnInfo*(res: PPGresult; dbRows: var DbRows; line, cols: int32) =
var columns: DbColumns
setLen(columns, cols)
for col in 0'i32..cols-1:
columns[col].name = $pqfname(res, col) # 行に依存しない
columns[col].typ = getColumnType(res, line, col) # NULL判定のみ行依存
columns[col].tableName = $(pqftable(res, col)) # 行に依存しない
dbRows.add(columns)
# postgres_impl.nim(query* 内)
for i in 0'i32 .. pqNtuples(pqresult) - 1:
setRow(pqresult, row, i, cols)
setColumnInfo(pqresult, dbRows, i, cols) # ← 毎行で全カラム FFI 呼び出し
rows.add(row)
問題: pqfname, pqftable, pqftype はカラムの OID・名前・テーブル名を返す関数で、行によって結果が変わらない。行に依存するのは pqgetisnull(res, line, col) による NULL 判定のみ。1000行×10カラムでは、カラム名・テーブル名だけで2万回の不要な FFI 呼び出し+文字列アロケーションが発生する。
改善案: カラムの基本情報(name, tableName, OID)はループ外で1回だけ取得し、行ごとには NULL 判定のみ行う。
# 基本情報を1回だけ取得
var baseColumns: DbColumns
setLen(baseColumns, cols)
for col in 0'i32..cols-1:
baseColumns[col].name = $pqfname(res, col)
baseColumns[col].typ = getBaseColumnType(res, col) # NULL判定なし
baseColumns[col].tableName = $(pqftable(res, col))
# 行ごとにNULLだけ上書き
for i in 0'i32 .. pqNtuples(pqresult) - 1:
setRow(pqresult, row, i, cols)
var rowColumns = baseColumns
for col in 0'i32..cols-1:
if pqgetisnull(res, i, col) == 1:
rowColumns[col].typ = DbType(kind: dbNull, name: "null")
dbRows.add(rowColumns)
rows.add(row)
8. dbFormat / questionToDaller の char-by-char 連結
ファイル: libs/postgres/postgres_lib.nim 203-216行, 219-229行
proc dbFormat*(formatstr: string, args: varargs[string]): string =
result = ""
...
for c in items(formatstr):
if c == '?':
add(result, dbQuote(args[a]))
inc(a)
else:
add(result, c) # ← 1文字ずつ add
proc questionToDaller*(s:string):string =
var i = 1
for c in s:
if c == '?':
result.add(&"${i}")
i += 1
else:
result.add(c) # ← 1文字ずつ add
問題: ? が出現するまでの連続部分を1文字ずつ add しており、長いクエリ文字列で非効率。
改善案: ? 位置間のサブ文字列を一括 add する。MariaDB の dbFormat で実施済みの手法。
9. query* / rawQuery* / execGetValue* のパラメータ送信パターンが重複
ファイル: libs/postgres/postgres_impl.nim 134-166行, 193-225行, 228-260行
# query*, execGetValue*, rawQuery* の冒頭がほぼ同一:
let status =
if pgParams.nParams > 0:
pqsendQueryParams(db, query.cstring, pgParams.nParams, nil, pgParams.values, ...)
else:
pqsendQueryParams(db, query.cstring, pgParams.nParams, nil, nil, nil, nil, 0)
defer:
if pgParams.nParams > 0: pgParams.values.deallocCStringArray()
if status != 1: dbError(db)
問題: 4箇所にわたるクエリ送信ボイラープレート。保守コストが高い。
改善案: pgSendQuery(db, pgParams) のような共通 proc を抽出する。
10. fromObjArray の2つのオーバーロードが大部分重複
ファイル: libs/postgres/postgres_lib.nim 239-291行, 293-337行
問題: columns パラメータの有無のみが異なり、JSON→string 変換ロジックは同一。
改善案: 共通の内部 proc を抽出し、columns が空の場合はバイナリ判定をスキップする。
P3: 微小
11. dbQuote の容量見積もりなし
ファイル: libs/postgres/postgres_lib.nim 182-201行
proc dbQuote(s: string): string =
if s == "null":
return "NULL"
result = "'"
...
問題: newStringOfCap による事前容量確保がない。特殊文字が多い文字列で再アロケーションが発生する。
改善案: result = newStringOfCap(s.len + 2) を先頭に追加。
改善優先度まとめ
| 優先度 | # | 問題 | 影響 | 修正難度 |
|---|---|---|---|---|
| P0 | 1 | exec/insertId が毎回 information_schema を問い合わせ + SQL インジェクション |
DML ごとに追加1クエリ | 中(キャッシュ + パラメータ化) |
| P1 | 2 | waiters の seq.delete(0) が O(n) |
高負荷時の性能劣化 | 低(Deque に変更) |
| P1 | 3 | PgWaitState が ref object |
毎回のヒープアロケーション | 低(object に変更) |
| P1 | 4 | waitPgReadable/waitPgWritable が重複 |
保守コスト | 低(統一) |
| P1 | 5 | pqsocket 二重呼び出し |
不要な FFI 呼び出し | 低 |
| P2 | 6 | getTime().toUnix() の秒精度デッドライン |
タイムアウト精度 | 低(MonoTime 化) |
| P2 | 7 | setColumnInfo が行ごとに全カラム FFI 呼び出し |
行数×カラム数の不要な FFI + アロケーション | ���(構造変更) |
| P2 | 8 | dbFormat/questionToDaller の char-by-char |
長いクエリで微小な差 | 低 |
| P2 | 9 | クエリ送信ボイラープレート重複 | 保守コスト | 低 |
| P2 | 10 | fromObjArray の重複 |
保守コスト | 低 |
| P3 | 11 | dbQuote の容量見積もりなし |
微小 | 低 |
MariaDB との比較
| 項目 | MariaDB(修正済み) | PostgreSQL(現状) |
|---|---|---|
| カラム型キャッシュ | columnTypeCache 導入済み |
毎回 information_schema を問い合わせ |
| ブロッキング ping | 除去済み | assert db.status == CONNECTION_OK(FFI なし、問題小) |
setColumnInfo 位置 |
ループ外に移動済み | ループ内で毎行呼び出し |
| waiter データ構造 | Deque に変更済み |
seq(先頭削除 O(n)) |
| 待機状態オブジェクト | 値型(object)に変更済み |
ref object(ヒープ割当) |
| デッドライン | MonoTime に変更済み |
getTime().toUnix()(秒精度) |
dbFormat |
セグメント一括 add | char-by-char |
| SQL インジェクション | プレースホルダ化済み | 文字列補間のまま |
注意: PostgreSQL の assert db.status == CONNECTION_OK は pqStatus() がローカルのフィールド読み取りであり、MariaDB の mysql_ping() のようなネットワークラウンドトリップは発生しない。そのため、PostgreSQL 側はこの assert の除去は不要。