gosuda.org/sqlite3: 共有メモリ上でネイティブC版SQLiteを実行する、置換可能な database/sql ドライバ
gosuda.org/sqlite3 は、Go 言語向けの CGO_ENABLED=0 である database/sql ドライバであり、HQQ 共有メモリリングを介して、独立したワーカープロセス内でアップストリームの C SQLite 3.54.1 アマルガメーションを実行します。これは標準の "sqlite3" ドライバ名で登録され、github.com/mattn/go-sqlite3 の DSN クエリパラメータをサポートし、6つの OS およびアーキテクチャターゲット向けに SLSA 準拠の事前ビルド済みサーバーバイナリを埋め込んでおり、64 KiB のバッチプリフェッチを伴うリクエストパイプライン処理を使用して、複数行のクエリにおいてプロセス内の cgo を凌駕するパフォーマンスを発揮します。
1go get gosuda.org/sqlite3@v0.3.3540100
なぜ別の Go SQLite ドライバなのか?
SQLite は世界で最も展開されているソフトウェアの一つですが、Go からこれを使用することは、これまでエンジニアにとってビルドのシンプルさとエンジンの忠実度の間で不快な選択を迫るものでした。
一方の端には、長年のコミュニティ標準である github.com/mattn/go-sqlite3 があります。これは cgo を経由して公式の C SQLite アマルガメーションを直接ラップします。本物の C クエリプランナー、完全な WAL の並行性セマンティクス、および予測可能な C メモリ管理が得られます。その代償は CGO_ENABLED=1 です。ローカルビルドや CI パイプラインが低速化し、最小限のスクラッチまたはディストロレスコンテナイメージには一致する C ランタイムライブラリが必要となり、別の OS や CPU アーキテクチャへのクロスコンパイルには完全な C クロスツールチェーンが必要となり、C におけるあらゆるメモリ破損が Go プロセスのアドレス空間を共有することになります。
もう一方には、エミュレートされた C ランタイム (libc) 上で C アマルガメーションを Go ソースコードに機械的に翻訳することによって生成された、純粋な Go による SQLite ポートがあります。これらは CGO_ENABLED=0 を復元するため、ビルドが容易になります。しかし、持続的なワークロードの下では、翻訳された C ヒープおよび行ごとの Go アロケーションがガベージコレクションの負荷を高め、ロッキング、ビジータイムアウト、またはステートメント中断における微妙なエッジケースがネイティブな C ビルドから乖離する可能性があります。
我々は、Go アプリケーションが CGO_ENABLED=0 と実際の C SQLite エンジンの間で選択を迫られないようにするために、gosuda.org/sqlite3 を構築しました。公式の SQLite 3.54.1 の C アマルガメーションを専用の共有メモリサーバー (hqq-sqlite-server) にコンパイルし、gosuda.org/sqlite3/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 のワーカープロセスを使用すると聞いたときに生じる自然な疑問は、「Go プログラムと一緒に個別の C バイナリをインストールまたは同梱しなければならないのか?」というものです。
いいえ、その必要はありません。gosuda.org/sqlite3 は、プラットフォームタグが付与された Go ファイル (internal/embedded/embed_<goos>_<goarch>.go) の内部に直接、6つの主要ターゲットすべてに向けた事前ビルド済みの hqq-sqlite-server バイナリを埋め込んでいます。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 Provenance |
| macOS | arm64, amd64 | hqq-sqlite-server_darwin_<arch> | AppleClang -O3, SLSA Build L3 Provenance |
| Windows | amd64 | hqq-sqlite-server_windows_amd64.exe | MSVC -O2, SLSA Build L3 Provenance |
| 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 を介して hashes.go 内のピン留めされた ReleaseHashes マップと暗号学的 SHA-256 ダイジェストを照合して検証し、そのライフサイクルが Go プロセスに厳密に結び付けられるように --parent-stdin を指定してワーカーを起動します。
クリーンルーム型 mattn/go-sqlite3 DSN 互換性
真のドロップイン代替として機能するために、DSN パーサ (dsn.go) は、mattn/go-sqlite3 のコードベース全体で使用されている接続および pragma つまみのクリーンルーム型レプリカを実装しています:
| DSN クエリパラメータ | 受け入れられる値 | SQLite の効果 |
|---|---|---|
_busy_timeout / _timeout | 整数ミリ秒(例: 5000) | セッションオープン時に 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 を経由してプロセス内で sqlite3_step を呼び出すよりも低速でなければならないように思われます。実際には、クエリが単一の行よりも多くを返すようになるとすぐに、逆の現象が発生します。
行ごとの cgo 呼び出しの隠れたコスト
従来の cgo SQLite ドライバでは、database/sql が一度に 1 行および 1 列ずつイテレーションを駆動します。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 (64 KiB スロットにパックされた 1..128 行)
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 アロケーション) | 3.56 µs/op (232 B, 11 アロケーション) | 1.01倍 (同等、11分の1のアロケーション数) |
100 行スキャン (rows.Next) | 20.41 µs/op (30.5 KB, 211 アロケーション) | 36.34 µs/op (21.8 KB, 309 アロケーション) | 1.78倍高速 |
1,000 行スキャン (rows.Next) | 125.92 µs/op (296 KB, 2,030 アロケーション) | 349.10 µs/op (216 KB, 3,009 アロケーション) | 2.77倍高速 |
| プリペアードインサート (オートコミット) | 5.33 µs/op (0 B, 0 アロケーション) | 5.88 µs/op (120 B, 5 アロケーション) | 1.10倍高速 (0 アロケーション) |
| 100 行トランザクションバッチ | 189.99 µs/op (16 B, 1 アロケーション) | 191.50 µs/op (12.1 KB, 503 アロケーション) | 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パス規則を強制し、シンボリックリンクを解決し、許可されたディレクトリ外への相対的な..走査を拒否します。SQL インジェクションがデータベースディレクトリから脱出したり、任意の共有オブジェクトをロードしたりしないように、sqlite3_load_extensionおよびSQLITE_LIMIT_ATTACHEDはデフォルトで無効になっています。
リポジトリレイアウト
リポジトリは、高度なカスタムサーバー展開のために launcher/ および sqlite3/ がアクセス可能な状態を維持しながら、トップレベルパッケージがドロップインの database/sql ドライバを提供するように構造化されています:
1gosuda.org/sqlite3
2├── driver.go / api.go # database/sql ドライバ登録 ("sqlite3") および Connector API
3├── dsn.go # クリーンルーム型 mattn/go-sqlite3 DSN パーサおよび pragma 設定
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 & Pragma リファレンス: docs/DSN.md
- アーキテクチャ & ワイヤプロトコル: docs/ARCHITECTURE.md