GoSuda

gosuda.org/sqlite3: 共有メモリ上でネイティブC版SQLiteを実行する、置換可能な database/sql ドライバ

By Lemon Mint
views ...

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)埋め込みバイナリビルドツールチェーンおよびプロベナンス
Linuxamd64, arm64hqq-sqlite-server_linux_<arch>GCC -O3, SLSA Build L3 Provenance
macOSarm64, amd64hqq-sqlite-server_darwin_<arch>AppleClang -O3, SLSA Build L3 Provenance
Windowsamd64hqq-sqlite-server_windows_amd64.exeMSVC -O2, SLSA Build L3 Provenance
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 を介して 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 / _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 を経由してプロセス内で 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 の準備完了
  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 アロケーション)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