gosuda.org/sqlite3: 共有メモリIPCを介したCGO_ENABLED=0対応のdatabase/sqlドライバ

gosuda.org/sqlite3 は、CGO_ENABLED=0 で動作する Go 向けの database/sql ドライバであり、アップストリームの C 言語版 SQLite 3.54.1 アマルガメーションを、HQQ の共有メモリリングを介して分離されたワーカープロセス内で実行します。標準の "sqlite3" ドライバ名で登録され、github.com/mattn/go-sqlite3 の DSN クエリパラメータをサポートし、6 つの OS およびアーキテクチャ向けに SLSA 証明付きのビルド済みサーバーバイナリを埋め込み、リクエストのパイプライン化と 64 KiB のバッチプリフェッチを用いることで、複数行クエリにおいて in-process の cgo を上回る性能を発揮します。
1go get gosuda.org/sqlite3@v0.3.3540100
なぜ別の Go SQLite ドライバなのか?
SQLite は世界で最も広く展開されているソフトウェアの一つですが、Go からこれを利用する場合、従来はビルドの簡便さとエンジンの忠実性のどちらかを選ばざるを得ないという、扱いにくい選択を技術者に強いてきました。
一方には、長年のコミュニティ標準である github.com/mattn/go-sqlite3 があります。これは公式の C 言語版 SQLite アマルガメーションを cgo を介して直接ラップしています。利用者は実際の C クエリプランナ、完全な WAL の並行性セマンティクス、そして予測可能な C のメモリ管理を得られます。その代償は CGO_ENABLED=1 であり、ローカルビルドおよび CI パイプラインが遅くなり、最小構成の scratch イメージや distroless コンテナイメージでは対応する C ランタイムライブラリが必要となり、別の OS や CPU アーキテクチャへのクロスコンパイルには完全な C のクロスツールチェーンが必要となり、さらに C 側のメモリ破壊はGo プロセスのアドレス空間を共有してしまう点です。
もう一方には、C 言語版アマルガメーションを、エミュレートされた C ランタイム (libc) 上で Go のソースコードへ機械的に変換して生成された純 Go 実装の SQLite ポートがあります。これらは CGO_ENABLED=0 を復活させ、ビルドを極めて容易にします。しかしながら、持続的なワークロードの下では、変換された C ヒープおよび行ごとの Go のメモリ割り当てがガベージコレクションの負荷を増大させ、ロック、busy タイムアウト、あるいはステートメントの中断に関する微妙なエッジケースが、ネイティブ C ビルドとは異なる振る舞いを示すことがあります。
私たちは gosuda.org/sqlite3 を、Go アプリケーションが CGO_ENABLED=0 と本物の C 言語版 SQLite エンジンのいずれかを選ばなくてもよいように構築しました。公式 SQLite 3.54.1 の C アマルガメーションを専用の共有メモリサーバー (hqq-sqlite-server) にコンパイルし、gosuda.org/hqq を介して通信させることで、お使いの Go バイナリは CGO_ENABLED=0 でコンパイルされながら、プロセス境界を越えて、改変されていない -O3 最適化済みの C 言語版 SQLite を実行します。
ゼロコンフィグ実行: go get から sql.Open まで
既存の Go コードベースにおいて github.com/mattn/go-sqlite3 を置き換えるには、ブランクインポートのパスを変更するだけで十分です。このドライバは "sqlite3" (および明示的な選択のための "hqq-sqlite") として登録され、同じ DSN クエリ文字列を受け付けます。
1package main
2
3import (
4 "context"
5 "database/sql"
6 "fmt"
7 "log"
8
9 _ "gosuda.org/sqlite3"
10)
11
12func main() {
13 dsn := "file:app.db?_journal_mode=WAL&_busy_timeout=5000&_txlock=immediate&_foreign_keys=on"
14 db, err := sql.Open("sqlite3", dsn)
15 if err != nil {
16 log.Fatal(err)
17 }
18 defer db.Close()
19
20 ctx := context.Background()
21 if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`); err != nil {
22 log.Fatal(err)
23 }
24 if _, err := db.ExecContext(ctx, `INSERT INTO users (name) VALUES (?)`, "Ada Lovelace"); err != nil {
25 log.Fatal(err)
26 }
27
28 var id int64
29 var name string
30 if err := db.QueryRowContext(ctx, `SELECT id, name FROM users WHERE name = ?`, "Ada Lovelace").Scan(&id, &name); err != nil {
31 log.Fatal(err)
32 }
33 fmt.Printf("user #%d: %s\n", id, name)
34}
SLSA 証明付きの埋め込みワーカーバイナリ
ライブラリが C のワーカープロセスを使用すると聞いたときに自然に浮かぶ疑問は、_「C のバイナリを Go プログラムと一緒に別途インストールまたは配布する必要があるのか?」_というものでしょう。
答えは否です。gosuda.org/sqlite3 は、6 つの主要ターゲットすべてに対するビルド済みの hqq-sqlite-server バイナリを、プラットフォームタグ付きの Go ファイル (internal/embedded/embed_<goos>_<goarch>.go) の内部に直接埋め込んでいます。Go のビルド制約により、対象の GOOS と GOARCH に一致するファイルのみがコンパイルされるため、GOOS=linux GOARCH=arm64 go build を実行すると、約 1.4 MiB の linux/arm64 サーバーバイナリのみが自動的に埋め込まれます。
| 対象 OS | 対象アーキテクチャ (GOARCH) | 埋め込みバイナリ | ビルドツールチェーンと来歴 |
|---|---|---|---|
| Linux | amd64, arm64 | hqq-sqlite-server_linux_<arch> | GCC -O3, SLSA Build L3 来歴 |
| macOS | arm64, amd64 | hqq-sqlite-server_darwin_<arch> | AppleClang -O3, SLSA Build L3 来歴 |
| Windows | amd64 | hqq-sqlite-server_windows_amd64.exe | MSVC -O2, SLSA Build L3 来歴 |
| Windows | arm64 | hqq-sqlite-server_windows_arm64.exe | zig cc -target aarch64-windows-gnu -O3, SLSA L3 |
sql.Open が最初の接続を確立すると、autolaunch.go が埋め込みバイナリをユーザーのキャッシュディレクトリ ($XDG_CACHE_HOME/hqq/bin/<sha256>/) に展開し、gosuda.org/hqq/secureexec を介して、その暗号学的 SHA-256 ダイジェストを hashes.go 内に固定された ReleaseHashes マップと照合して検証したうえで、--parent-stdin を指定してワーカーを起動します。これにより、ワーカーの存続期間は Go プロセスに厳密に束縛されます。
mattn/go-sqlite3 DSN 互換性のためのクリーンルーム実装
真のドロップイン代替として機能させるため、DSN パーサ (dsn.go) は、mattn/go-sqlite3 を利用するコードベース全体で使用される接続およびプラグマの設定項目について、クリーンルームによる再実装を提供しています。
| DSN クエリパラメータ | 受け付ける値 | SQLite への影響 |
|---|---|---|
_busy_timeout / _timeout | ミリ秒単位の整数 (例: 5000) | セッション open 時に sqlite3_busy_timeout(db, ms) を呼び出す。 |
_journal_mode / _journal | DELETE, TRUNCATE, PERSIST, MEMORY, WAL, OFF | PRAGMA journal_mode = <mode> を実行する。 |
_txlock | immediate, exclusive, deferred | db.BeginTx に対して BEGIN IMMEDIATE, BEGIN EXCLUSIVE, または BEGIN DEFERRED を選択する。 |
_foreign_keys / _fk | on, true, 1, off, false, 0 | PRAGMA foreign_keys = ON/OFF を実行する。 |
_synchronous / _sync | 0/OFF, 1/NORMAL, 2/FULL, 3/EXTRA | PRAGMA synchronous = <level> を実行する。 |
_cache_size | ページ数の整数または -KiB (例: -20000) | PRAGMA cache_size = <val> を実行する。 |
_loc | auto または IANA タイムゾーン (例: UTC, Asia/Seoul) | DATETIME/TIMESTAMP のテキスト列を、対象のロケーションにおける *time.Time へ解析する。 |
mode | ro, rw, rwc, memory | SQLITE_OPEN_READONLY, SQLITE_OPEN_READWRITE, SQLITE_OPEN_CREATE, または SQLITE_OPEN_MEMORY を設定する。 |
_case_sensitive_like, _query_only, _recursive_triggers, _secure_delete, _AutoVacuum | 真偽値またはモードの整数 | 接続初期化時に対応するセッション PRAGMA を適用する。 |
プロセス分離をスループットの優位性へ転換する
一見すると、SQLite を別プロセスで実行することは、cgo を介して in-process で sqlite3_step を呼び出すよりも遅くなるはずに思えます。しかし実際には、クエリが単一行を超える行を返した時点で、逆の現象が起こります。
行ごとの cgo 呼び出しに隠れたコスト
従来の cgo ベースの SQLite ドライバでは、database/sql が反復処理を行と列の単位で一つずつ進めます。4 列を持つ 1,000 行を読み込むと、次のような処理が発生します。
sqlite3_stepに対する 1,000 回のcgo呼び出しsqlite3_column_typeおよびsqlite3_column_int64/sqlite3_column_textに対する 4,000 回のcgo呼び出し- Go スケジューラと C スタックの間における、数千回に及ぶ個別の境界横断
リクエストのパイプライン化と 64 KiB バッチプリフェッチ
gosuda.org/sqlite3 は、stmt.go、rows.go、および sqlite3/server/sqlite3_hooks.c に含まれる 2 つの仕組みを中心にワイヤプロトコルを再設計することで、行ごとの境界横断を排除します。
1Go クライアント (stmt.go / rows.go) C11 ワーカー (sqlite3_hooks.c)
2----------------------------- ----------------------------
31. 1 フライトでパイプライン化:
4 ResetAsync + BindValuesAsync
5 + StmtInfoAsync + FetchRowsAsync(128) =======> reset -> bind -> stmt_info ->
6 最大 128 行まで sqlite3_step() をループ
7 <======= バッチ 1 (行 1..128 を 64 KiB スロットに格納)
82. 直ちにプリフェッチを発行:
9 FetchRowsAsync(128) (バッチ 2) =======> 行 129..256 に対して sqlite3_step() をループ
10 C がバッチ 2 をステップ実行している間、
11 Go の呼び出し元はバッチ 1 の行 1..128 を走査! <======= バッチ 2 が共有メモリに準備完了
- シングルフライトのクエリパイプライン化 (
stmt.go):QueryContextまたはStmt.QueryContextが読み取り専用ステートメントを実行する際、s.query()はreset、bind_values、stmt_info(初回実行時)、およびfetch_rowsを、単一の共有メモリフライトにおいて非同期に送信します。これら 4 つの操作はすべて C ワーカー上で連続して実行され、1 回のラウンドトリップで返されます。 - 緊密な C バッチループと非同期プリフェッチ (
rows.go):fetch_rows(sqlite3_hooks.c) の内部では、C ワーカーがネイティブ C のループ内でsqlite3_step(stmt)をステップ実行し、バッチ上限、スロットの満杯、またはSQLITE_DONEに達するまで、最大FetchSize行 (デフォルトは 128) を 64 KiB の共有メモリ応答スロットに直接エンコードします。rows.ingest()がバッチ 1 をデコードするとすぐに、最初の行を呼び出し元へ返す前に、バッチ 2 のためにFetchRowsAsyncを発行します。Go コードがバッチ 1 を走査している間、C ワーカーは別の CPU コア上で既にバッチ 2 をステップ実行しています。
Apple M4 (darwin/arm64) において、同一の SQLite データベースに対して gosuda.org/sqlite3 (CGO_ENABLED=0) と github.com/mattn/go-sqlite3 (CGO_ENABLED=1) を比較すると、バッチ処理とパイプライン化の効果が次のように示されます。
| ワークロード | gosuda.org/sqlite3 (CGO_ENABLED=0) | mattn/go-sqlite3 (CGO_ENABLED=1) | 相対速度 |
|---|---|---|---|
ポイントセレクト (PK による QueryRow) | 3.52 µs/op (9 B, 1 alloc) | 3.56 µs/op (232 B, 11 allocs) | 1.01 倍 (同等、割り当て 11 分の 1) |
100 行スキャン (rows.Next) | 20.41 µs/op (30.5 KB, 211 allocs) | 36.34 µs/op (21.8 KB, 309 allocs) | 1.78 倍高速 |
1,000 行スキャン (rows.Next) | 125.92 µs/op (296 KB, 2,030 allocs) | 349.10 µs/op (216 KB, 3,009 allocs) | 2.77 倍高速 |
| 準備済み INSERT (オートコミット) | 5.33 µs/op (0 B, 0 allocs) | 5.88 µs/op (120 B, 5 allocs) | 1.10 倍高速 (割り当て 0) |
| 100 行トランザクションバッチ | 189.99 µs/op (16 B, 1 alloc) | 191.50 µs/op (12.1 KB, 503 allocs) | 1.01 倍 (割り当て 500 分の 1) |
キャンセル、パスのサンドボックス化、および接続プールの回復
SQLite を分離されたワーカーで実行することは、タイムアウト、信頼されていない SQL、およびファイルシステムの境界の扱いも強化します。
- 決定論的なコンテキストキャンセル (
conn.goおよびerrors.go): ドライバのすべての呼び出しは、軽量なcontext.AfterFuncガード (cn.guard) を設定します。呼び出し元のcontext.Contextがキャンセルされるか、その期限が切れた場合、サーバー上で遅いリカーシブクエリまたはロック待機が実行中であれば、ガードは接続を不良とマークし、ローカルのセッションリンクを終了させ、帯域外のCancel()フレームを送信します。C ワーカーは直ちにsqlite3_interrupt(db)を呼び出してsqlite3_stepのブロックを解除し、一方 Go ドライバは、結果として生じたSQLITE_INTERRUPTステータスをcontext.Canceledまたはcontext.DeadlineExceededに対応付け、database/sqlにプール内の接続を置き換えるよう指示します。 - ディレクトリのサンドボックス化 (
sqlsrv_pathrules.c): C ワーカーは、すべてのopen_v2呼び出しに対して--allow-dirのパスルールを適用し、シンボリックリンクを解決して、許可されたディレクトリ外への相対的な..によるトラバーサルを拒否します。sqlite3_load_extensionおよびSQLITE_LIMIT_ATTACHEDはデフォルトで無効化されており、SQL インジェクションによってデータベースディレクトリの外へ脱出したり、任意の共有オブジェクトを読み込んだりすることを防ぎます。
リポジトリの構成
リポジトリは、トップレベルのパッケージがドロップイン型の database/sql ドライバを提供し、一方で launcher/ と sqlite3/ が高度なカスタムサーバー構成のために引き続き利用できるように構成されています。
1gosuda.org/sqlite3
2├── driver.go / api.go # database/sql ドライバの登録 ("sqlite3") と Connector API
3├── dsn.go # クリーンルーム実装の mattn/go-sqlite3 DSN パーサおよびプラグマ設定
4├── conn.go / tx.go # セッションのライフサイクル、コンテキストガード、およびトランザクション状態機械
5├── stmt.go / rows.go # シングルフライトのリクエストパイプライン化と 64 KiB バッチプリフェッチ
6├── errors.go # 型付けされた SQLite の基本および拡張エラーコードとセンチネルマッチング
7├── autolaunch.go / hashes.go # ゼロコンフィグの埋め込みサーバー展開と SHA-256 固定
8├── internal/embedded/ # プラットフォームタグ付きの //go:embed バイナリ (linux, darwin, windows)
9├── launcher/ # カスタムサーバー配備のための構成可能なプロセス監視機構
10└── sqlite3/ # 生成された hqq-bindgen Go クライアントおよび C11 SQLite サーバーのソース
11 └── server/ # C11 サーバーの実装 (sqlite3_hooks.c, sqlsrv_policy.c)
はじめに
go get でモジュールをインストールし、sql.Open("sqlite3", dsn) でデータベースを開いてください。
1go get gosuda.org/sqlite3@v0.3.3540100
- GitHub リポジトリ: https://github.com/gosuda/hqq-sqlite3
- DSN およびプラグマのリファレンス: docs/DSN.md
- アーキテクチャおよびワイヤプロトコル: docs/ARCHITECTURE.md