GoSuda

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

By Lemon Mint views ...

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)埋め込みバイナリビルドツールチェーンと来歴
Linuxamd64, arm64hqq-sqlite-server_linux_<arch>GCC -O3, SLSA Build L3 来歴
macOSarm64, amd64hqq-sqlite-server_darwin_<arch>AppleClang -O3, SLSA Build L3 来歴
Windowsamd64hqq-sqlite-server_windows_amd64.exeMSVC -O2, SLSA Build L3 来歴
Windowsarm64hqq-sqlite-server_windows_arm64.exezig 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 / _journalDELETE, TRUNCATE, PERSIST, MEMORY, WAL, OFFPRAGMA journal_mode = <mode> を実行する。
_txlockimmediate, exclusive, deferreddb.BeginTx に対して BEGIN IMMEDIATE, BEGIN EXCLUSIVE, または BEGIN DEFERRED を選択する。
_foreign_keys / _fkon, true, 1, off, false, 0PRAGMA foreign_keys = ON/OFF を実行する。
_synchronous / _sync0/OFF, 1/NORMAL, 2/FULL, 3/EXTRAPRAGMA synchronous = <level> を実行する。
_cache_sizeページ数の整数または -KiB (例: -20000)PRAGMA cache_size = <val> を実行する。
_locauto または IANA タイムゾーン (例: UTC, Asia/Seoul)DATETIME/TIMESTAMP のテキスト列を、対象のロケーションにおける *time.Time へ解析する。
modero, rw, rwc, memorySQLITE_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 が共有メモリに準備完了
  1. シングルフライトのクエリパイプライン化 (stmt.go): QueryContext または Stmt.QueryContext が読み取り専用ステートメントを実行する際、s.query() は reset、bind_values、stmt_info (初回実行時)、および fetch_rows を、単一の共有メモリフライトにおいて非同期に送信します。これら 4 つの操作はすべて C ワーカー上で連続して実行され、1 回のラウンドトリップで返されます。
  2. 緊密な 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