GoSuda

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

By Lemon Mint views ...

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 docelowyArchitektura docelowa (GOARCH)Osadzone binariumZestaw narzędzi kompilacji i pochodzenie
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

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 DSNAkceptowane wartościEfekt w SQLite
_busy_timeout / _timeoutLiczba całkowita w milisekundach (np. 5000)Wywołuje sqlite3_busy_timeout(db, ms) przy otwarciu sesji.
_journal_mode / _journalDELETE, TRUNCATE, PERSIST, MEMORY, WAL, OFFWykonuje PRAGMA journal_mode = <mode>.
_txlockimmediate, exclusive, deferredWybiera BEGIN IMMEDIATE, BEGIN EXCLUSIVE lub BEGIN DEFERRED dla db.BeginTx.
_foreign_keys / _fkon, true, 1, off, false, 0Wykonuje PRAGMA foreign_keys = ON/OFF.
_synchronous / _sync0/OFF, 1/NORMAL, 2/FULL, 3/EXTRAWykonuje PRAGMA synchronous = <level>.
_cache_sizeLiczba całkowita stron lub -KiB (np. -20000)Wykonuje PRAGMA cache_size = <val>.
_locauto lub strefa czasowa IANA (np. UTC, Asia/Seoul)Parsuje kolumny tekstowe DATETIME/TIMESTAMP do *time.Time w docelowej lokalizacji.
modero, rw, rwc, memoryUstawia SQLITE_OPEN_READONLY, SQLITE_OPEN_READWRITE, SQLITE_OPEN_CREATE lub SQLITE_OPEN_MEMORY.
_case_sensitive_like, _query_only, _recursive_triggers, _secure_delete, _AutoVacuumWartości logiczne lub liczby trybówStosuje 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ń cgo do sqlite3_step
  • 4000 wywołań cgo do sqlite3_column_type oraz sqlite3_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
  1. Potokowanie zapytań w jednym locie (stmt.go): Gdy QueryContext lub Stmt.QueryContext uruchamia instrukcję tylko do odczytu, s.query() wysyła asynchronicznie reset, bind_values, stmt_info (przy pierwszym wykonaniu) oraz fetch_rows w jednym locie przez pamięć współdzieloną. Wszystkie cztery operacje wykonują się bez przerwy na workerze C i zwracają wynik w jednej rundzie komunikacyjnej.
  2. Ciasne pętle wsadowe w C oraz asynchroniczne wstępne pobieranie (rows.go): Wewnątrz fetch_rows (sqlite3_hooks.c) worker C wykonuje sqlite3_step(stmt) w natywnej pętli C, kodując bezpośrednio do slotu odpowiedzi w pamięci współdzielonej o rozmiarze 64 KiB do FetchSize wierszy (domyślnie 128), aż do osiągnięcia limitu wsadu, zapełnienia slotu lub wystąpienia SQLITE_DONE. Gdy tylko rows.ingest() zdekoduje Wsad 1, wysyła FetchRowsAsync dla 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ążeniegosuda.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 wierszy189.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.go i errors.go): Każde wywołanie sterownika uzbraja lekki strażnik context.AfterFunc (cn.guard). Jeśli context.Context wywoł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łuje sqlite3_interrupt(db), odblokowując sqlite3_step, a sterownik Go mapuje powstały status SQLITE_INTERRUPT na context.Canceled lub context.DeadlineExceeded i nakazuje database/sql zastąpić połączenie w puli.
  • Piaskownica katalogów (sqlsrv_pathrules.c): Worker C egzekwuje reguły ścieżek --allow-dir przy każdym wywołaniu open_v2, rozwiązując dowiązania symboliczne i odrzucając względne przejścia .. poza dozwolony katalog. Funkcja sqlite3_load_extension oraz SQLITE_LIMIT_ATTACHED są 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