gosuda.org/sqlite3: sterownik database/sql z CGO_ENABLED=0 oparty na Shared-Memory IPC

gosuda.org/sqlite3 to sterownik database/sql dla Go z ustawieniem CGO_ENABLED=0, który uruchamia upstreamowe amalgamation C SQLite 3.54.1 w odizolowanym procesie roboczym za pośrednictwem pierścieni pamięci współdzielonej HQQ. Rejestruje się pod standardową nazwą sterownika "sqlite3", obsługuje parametry zapytań DSN znane z github.com/mattn/go-sqlite3, osadza dla sześciu systemów operacyjnych i architektur docelowych prekompilowane binaria serwera z atestacją SLSA oraz wykorzystuje potokowanie żądań i wstępne pobieranie wsadów po 64 KiB, dzięki czemu przewyższa wydajnością cgo działające w tym samym procesie przy zapytaniach zwracających wiele wierszy.
1go get gosuda.org/sqlite3@v0.3.3540100
Dlaczego kolejny sterownik SQLite dla Go?
SQLite jest jednym z najczęściej wdrażanych programów na świecie, jednak korzystanie z niego w Go historycznie zmuszało inżynierów do niewygodnego wyboru między prostotą budowania a wiernością silnika.
Z jednej strony stoi github.com/mattn/go-sqlite3, od dawna standard społeczności. Bezpośrednio opakowuje oficjalne amalgamation C SQLite za pośrednictwem cgo. Otrzymujemy prawdziwy planista zapytań C, pełną semantykę współbieżności WAL oraz przewidywalne zarządzanie pamięcią C. Kosztem jest CGO_ENABLED=1: lokalne buildy i potoki CI ulegają spowolnieniu, minimalne obrazy kontenerów typu scratch lub distroless wymagają odpowiednich bibliotek uruchomieniowych C, kompilacja krzyżowa na inny system operacyjny lub architekturę procesora wymaga pełnego kompletu narzędzi krzyżowych C, a każde uszkodzenie pamięci w kodzie C dzieli przestrzeń adresową procesu Go.
Z drugiej strony znajdują się czyste w Go porty SQLite, generowane przez mechaniczne tłumaczenie amalgamation C na kod źródłowy Go, działające na emulowanym środowisku uruchomieniowym C (libc). Przywracają one CGO_ENABLED=0, co czyni budowanie trywialnym. Przy długotrwałym obciążeniu przetłumaczony heap C oraz alokacje Go na każdy wiersz zwiększają jednak presję na garbage collector, a subtelne przypadki brzegowe w blokowaniu, limitach czasu oczekiwania (busy timeouts) czy przerywaniu instrukcji mogą odbiegać od natywnych buildów C.
Stworzyliśmy gosuda.org/sqlite3, aby aplikacje Go nie musiały wybierać między CGO_ENABLED=0 a prawdziwym silnikiem C SQLite. Dzięki kompilowaniu oficjalnego amalgamation C SQLite 3.54.1 do dedykowanego serwera z pamięcią współdzieloną (hqq-sqlite-server) oraz komunikacji przez gosuda.org/hqq, Twoja binarka Go kompiluje się z CGO_ENABLED=0, jednocześnie wykonując niezmodyfikowany, zoptymalizowany flagą -O3 kod C SQLite poza granicą procesu.
Wykonanie bez konfiguracji: od go get do sql.Open
Zastąpienie github.com/mattn/go-sqlite3 w istniejącej bazie kodu Go wymaga zmiany wyłącznie ścieżki importu z pustym identyfikatorem. Sterownik rejestruje się jako "sqlite3" (oraz "hqq-sqlite" dla jawnego wyboru) i akceptuje te same ciągi zapytań 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}
Osadzone binaria roboczego z atestacją SLSA
Naturalnym pytaniem, gdy słyszy się, że biblioteka korzysta z procesu roboczego C, jest: „Czy muszę zainstalować lub dostarczyć osobne binarium C obok mojego programu Go?”
Nie. gosuda.org/sqlite3 osadza prekompilowane binaria hqq-sqlite-server dla wszystkich sześciu głównych celów bezpośrednio w plikach Go oznaczonych platformą (internal/embedded/embed_<goos>_<goarch>.go). Ponieważ ograniczenia budowania (build constraints) Go kompilują wyłącznie plik odpowiadający Twojemu docelowemu GOOS i GOARCH, kompilacja krzyżowa GOOS=linux GOARCH=arm64 go build automatycznie osadza jedynie binarium serwera linux/arm64 o rozmiarze około 1,4 MiB.
| System docelowy | Architektura docelowa (GOARCH) | Osadzone binarium | Zestaw narzędzi kompilacji i pochodzenie |
|---|---|---|---|
| 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 |
Gdy sql.Open nawiązuje swoje pierwsze połączenie, autolaunch.go zapisuje osadzone binarium do katalogu cache użytkownika ($XDG_CACHE_HOME/hqq/bin/<sha256>/), weryfikuje jego kryptograficzny skrót SHA-256 względem przypiętej mapy ReleaseHashes w hashes.go za pośrednictwem gosuda.org/hqq/secureexec i uruchamia proces roboczy z opcją --parent-stdin, dzięki czemu jego czas życia jest ściśle powiązany z Twoim procesem Go.
Kompatybilność DSN z mattn/go-sqlite3 w modelu clean-room
Aby działać jako prawdziwy zamiennik typu drop-in, parser DSN (dsn.go) implementuje replikę clean-room parametrów połączenia i pragm stosowanych w bazach kodu mattn/go-sqlite3:
| Parametr zapytania DSN | Akceptowane wartości | Efekt w SQLite |
|---|---|---|
_busy_timeout / _timeout | Liczba całkowita w milisekundach (np. 5000) | Wywołuje sqlite3_busy_timeout(db, ms) przy otwarciu sesji. |
_journal_mode / _journal | DELETE, TRUNCATE, PERSIST, MEMORY, WAL, OFF | Wykonuje PRAGMA journal_mode = <mode>. |
_txlock | immediate, exclusive, deferred | Wybiera BEGIN IMMEDIATE, BEGIN EXCLUSIVE lub BEGIN DEFERRED dla db.BeginTx. |
_foreign_keys / _fk | on, true, 1, off, false, 0 | Wykonuje PRAGMA foreign_keys = ON/OFF. |
_synchronous / _sync | 0/OFF, 1/NORMAL, 2/FULL, 3/EXTRA | Wykonuje PRAGMA synchronous = <level>. |
_cache_size | Liczba całkowita stron lub -KiB (np. -20000) | Wykonuje PRAGMA cache_size = <val>. |
_loc | auto lub strefa czasowa IANA (np. UTC, Asia/Seoul) | Parsuje kolumny tekstowe DATETIME/TIMESTAMP do *time.Time w docelowej lokalizacji. |
mode | ro, rw, rwc, memory | Ustawia SQLITE_OPEN_READONLY, SQLITE_OPEN_READWRITE, SQLITE_OPEN_CREATE lub SQLITE_OPEN_MEMORY. |
_case_sensitive_like, _query_only, _recursive_triggers, _secure_delete, _AutoVacuum | Wartości logiczne lub liczby trybów | Stosuje odpowiedni PRAGMA sesji podczas inicjalizacji połączenia. |
Przekształcenie izolacji procesu w przewagę przepustowości
Na pierwszy rzut oka uruchamianie SQLite w osobnym procesie wydaje się musieć być wolniejsze niż wywoływanie sqlite3_step w tym samym procesie przez cgo. W praktyce dzieje się odwrotnie, gdy zapytanie zwraca więcej niż jeden wiersz.
Ukryty koszt wywołań cgo wiersz po wierszu
W konwencjonalnym sterowniku SQLite opartym na cgo, database/sql prowadzi iterację po jednym wierszu i jednej kolumnie naraz. Odczyt 1000 wierszy z 4 kolumnami powoduje:
- 1000 wywołań
cgodosqlite3_step - 4000 wywołań
cgodosqlite3_column_typeorazsqlite3_column_int64/sqlite3_column_text - tysiące pojedynczych przekroczeń granic między harmonogramem Go a stosem C
Potokowanie żądań i wstępne pobieranie wsadów po 64 KiB
gosuda.org/sqlite3 eliminuje przekraczanie granic dla każdego wiersza, przeprojektowując protokół komunikacyjny wokół dwóch mechanizmów w stmt.go, rows.go oraz sqlite3/server/sqlite3_hooks.c:
1Klient Go (stmt.go / rows.go) Worker C11 (sqlite3_hooks.c)
2----------------------------- ----------------------------
31. Potok w 1 locie:
4 ResetAsync + BindValuesAsync
5 + StmtInfoAsync + FetchRowsAsync(128) =======> reset -> bind -> stmt_info ->
6 pętla sqlite3_step() do 128 wierszy
7 <======= Wsad 1 (wiersze 1..128 spakowane w slocie 64 KiB)
82. Natychmiast uruchom prefetch:
9 FetchRowsAsync(128) (Wsad 2) =======> pętla sqlite3_step() dla wierszy 129..256
10 Podczas gdy C wykonuje kroki dla
11 Wsadu 2, wywołujący w Go odczytuje
12 wiersze 1..128 z Wsadu 1! <======= Wsad 2 gotowy we współdzielonej pamięci
- Potokowanie zapytań w jednym locie (
stmt.go): GdyQueryContextlubStmt.QueryContexturuchamia instrukcję tylko do odczytu,s.query()wysyła asynchroniczniereset,bind_values,stmt_info(przy pierwszym wykonaniu) orazfetch_rowsw jednym locie przez pamięć współdzieloną. Wszystkie cztery operacje wykonują się bez przerwy na workerze C i zwracają wynik w jednej rundzie komunikacyjnej. - Ciasne pętle wsadowe w C oraz asynchroniczne wstępne pobieranie (
rows.go): Wewnątrzfetch_rows(sqlite3_hooks.c) worker C wykonujesqlite3_step(stmt)w natywnej pętli C, kodując bezpośrednio do slotu odpowiedzi w pamięci współdzielonej o rozmiarze 64 KiB doFetchSizewierszy (domyślnie 128), aż do osiągnięcia limitu wsadu, zapełnienia slotu lub wystąpieniaSQLITE_DONE. Gdy tylkorows.ingest()zdekoduje Wsad 1, wysyłaFetchRowsAsyncdla Wsadu 2, zanim zwróci pierwszy wiersz wywołującemu. Podczas gdy Twój kod Go skanuje Wsad 1, worker C wykonuje już kroki dla Wsadu 2 na innym rdzeniu procesora.
Na Apple M4 (darwin/arm64), porównanie gosuda.org/sqlite3 (CGO_ENABLED=0) z github.com/mattn/go-sqlite3 (CGO_ENABLED=1) na tej samej bazie SQLite pokazuje efekt wsadowania i potokowania:
| Obciążenie | gosuda.org/sqlite3 (CGO_ENABLED=0) | mattn/go-sqlite3 (CGO_ENABLED=1) | Względna szybkość |
|---|---|---|---|
Wybór punktowy (QueryRow po PK) | 3.52 µs/op (9 B, 1 alokacja) | 3.56 µs/op (232 B, 11 alokacji) | 1,01x (parytet, 11x mniej alokacji) |
Skan 100 wierszy (rows.Next) | 20.41 µs/op (30.5 KB, 211 alokacji) | 36.34 µs/op (21.8 KB, 309 alokacji) | 1,78x szybciej |
Skan 1000 wierszy (rows.Next) | 125.92 µs/op (296 KB, 2030 alokacji) | 349.10 µs/op (216 KB, 3009 alokacji) | 2,77x szybciej |
| Wstawianie przygotowane (autocommit) | 5.33 µs/op (0 B, 0 alokacji) | 5.88 µs/op (120 B, 5 alokacji) | 1,10x szybciej (0 alokacji) |
| Wsadowa transakcja 100 wierszy | 189.99 µs/op (16 B, 1 alokacja) | 191.50 µs/op (12.1 KB, 503 alokacje) | 1,01x (500x mniej alokacji) |
Anulowanie, piaskownica ścieżek i odzyskiwanie puli połączeń
Uruchamianie SQLite w odizolowanym procesie roboczym wzmacnia również sposób, w jaki sterownik obsługuje limity czasu, niezaufany SQL oraz granice systemu plików:
- Deterministyczne anulowanie kontekstu (
conn.goierrors.go): Każde wywołanie sterownika uzbraja lekki strażnikcontext.AfterFunc(cn.guard). Jeślicontext.Contextwywołującego zostanie anulowany lub jego termin wygaśnie, podczas gdy po stronie serwera trwa wolne rekurencyjne zapytanie lub oczekiwanie na blokadę, strażnik oznacza połączenie jako uszkodzone, zamyka lokalne łącze sesji i wysyła pozapasmową ramkęCancel(). Worker C natychmiast wywołujesqlite3_interrupt(db), odblokowującsqlite3_step, a sterownik Go mapuje powstały statusSQLITE_INTERRUPTnacontext.Canceledlubcontext.DeadlineExceededi nakazujedatabase/sqlzastąpić połączenie w puli. - Piaskownica katalogów (
sqlsrv_pathrules.c): Worker C egzekwuje reguły ścieżek--allow-dirprzy każdym wywołaniuopen_v2, rozwiązując dowiązania symboliczne i odrzucając względne przejścia..poza dozwolony katalog. Funkcjasqlite3_load_extensionorazSQLITE_LIMIT_ATTACHEDsą domyślnie wyłączone, dzięki czemu wstrzyknięcie SQL nie może wyjść poza katalog bazy danych ani załadować dowolnych obiektów współdzielonych.
Układ repozytorium
Repozytorium jest zorganizowane tak, że pakiet najwyższego poziomu udostępnia sterownik database/sql typu drop-in, podczas gdy launcher/ i sqlite3/ pozostają dostępne dla zaawansowanych wdrożeń z własnym serwerem:
1gosuda.org/sqlite3
2├── driver.go / api.go # Rejestracja sterownika database/sql ("sqlite3") i API Connector
3├── dsn.go # Parser DSN mattn/go-sqlite3 w modelu clean-room i konfiguracja pragm
4├── conn.go / tx.go # Cykl życia sesji, strażnik kontekstu i maszyna stanów transakcji
5├── stmt.go / rows.go # Potokowanie żądań w jednym locie i wstępne pobieranie wsadów po 64 KiB
6├── errors.go # Typowane podstawowe i rozszerzone kody błędów SQLite oraz dopasowanie wartości wartowniczych
7├── autolaunch.go / hashes.go # Materializacja osadzonego serwera bez konfiguracji i przypinanie SHA-256
8├── internal/embedded/ # Binaria //go:embed oznaczone platformą (linux, darwin, windows)
9├── launcher/ # Konfigurowalny nadzorca procesów dla niestandardowych wdrożeń serwera
10└── sqlite3/ # Wygenerowany klient Go hqq-bindgen oraz kod źródłowy serwera C11 SQLite
11 └── server/ # Implementacja serwera C11 (sqlite3_hooks.c, sqlsrv_policy.c)
Pierwsze kroki
Zainstaluj moduł poleceniem go get i otwórz bazę danych za pomocą sql.Open("sqlite3", dsn):
1go get gosuda.org/sqlite3@v0.3.3540100
- Repozytorium GitHub: https://github.com/gosuda/hqq-sqlite3
- Dokumentacja DSN i pragm: docs/DSN.md
- Architektura i protokół komunikacyjny: docs/ARCHITECTURE.md