gosuda.org/sqlite3: driver database/sql con CGO_ENABLED=0 basato su IPC a memoria condivisa

gosuda.org/sqlite3 è un driver database/sql per Go con CGO_ENABLED=0 che esegue l'amalgama C di SQLite 3.54.1 upstream all'interno di un processo worker isolato, comunicando tramite gli anelli di memoria condivisa di HQQ. Si registra con il nome standard del driver "sqlite3", supporta i parametri di query del DSN di github.com/mattn/go-sqlite3, incorpora binari server precompilati con attestazione SLSA per sei target di sistema operativo e architettura, e utilizza il pipelining delle richieste con prefetch di batch da 64 KiB per superare cgo in-process nelle query a più righe.
1go get gosuda.org/sqlite3@v0.3.3540100
Perché un Altro Driver SQLite per Go?
SQLite è una delle componenti software più diffuse al mondo, eppure il suo utilizzo da Go ha storicamente costretto gli ingegneri a una scelta scomoda tra semplicità di build e fedeltà del motore.
Da un lato si colloca github.com/mattn/go-sqlite3, il tradizionale standard della comunità. Esso integra direttamente l'amalgama C ufficiale di SQLite tramite cgo. Si ottengono il vero query planner C, la piena semantica di concorrenza WAL e una gestione della memoria C prevedibile. Il costo è CGO_ENABLED=1: le build locali e le pipeline CI rallentano, le immagini container minimali (scratch o distroless) richiedono librerie runtime C compatibili, la cross-compilazione verso un altro sistema operativo o un'altra architettura CPU richiede una toolchain C cross-compilante completa, e qualsiasi corruzione della memoria in C condivide lo spazio degli indirizzi del processo Go.
Dall'altro lato vi sono i port SQLite puri in Go, generati traducendo meccanicamente l'amalgama C in codice sorgente Go su un runtime C emulato (libc). Essi ripristinano CGO_ENABLED=0, rendendo le build banali. Sotto carichi di lavoro prolungati, tuttavia, l'heap C tradotto e le allocazioni Go per riga aumentano la pressione sul garbage collector, e sottili casi limite nel locking, nei busy timeout o nell'interruzione delle istruzioni possono divergere dalle build C native.
Abbiamo realizzato gosuda.org/sqlite3 affinché le applicazioni Go non debbano scegliere tra CGO_ENABLED=0 e il vero motore C di SQLite. Compilando l'amalgama C ufficiale di SQLite 3.54.1 in un server dedicato a memoria condivisa (hqq-sqlite-server) e comunicando tramite gosuda.org/hqq, il binario Go viene compilato con CGO_ENABLED=0 pur eseguendo SQLite C non modificato e ottimizzato con -O3 attraverso un confine di processo.
Esecuzione Senza Configurazione: da go get a sql.Open
Sostituire github.com/mattn/go-sqlite3 in una base di codice Go esistente richiede di modificare solo il percorso dell'import anonimo. Il driver si registra come "sqlite3" (e come "hqq-sqlite" per una selezione esplicita) e accetta le stesse stringhe di query 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}
Binari Worker Incorporati con Attestazione SLSA
Una domanda naturale, quando si apprende che una libreria utilizza un processo worker C, è: "Devo installare o distribuire un binario C separato insieme al mio programma Go?"
No. gosuda.org/sqlite3 incorpora binari hqq-sqlite-server precompilati per tutti e sei i target principali, direttamente all'interno di file Go con tag di piattaforma (internal/embedded/embed_<goos>_<goarch>.go). Poiché i vincoli di build di Go compilano solo il file corrispondente al target GOOS e GOARCH, la cross-compilazione con GOOS=linux GOARCH=arm64 go build incorpora automaticamente solo il binario server linux/arm64 di circa 1,4 MiB.
| Sistema Operativo Target | Architettura Target (GOARCH) | Binario Incorporato | Toolchain di Build e Provenienza |
|---|---|---|---|
| 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 |
Quando sql.Open stabilisce la sua prima connessione, autolaunch.go materializza il binario incorporato nella directory della cache utente ($XDG_CACHE_HOME/hqq/bin/<sha256>/), verifica il suo digest crittografico SHA-256 rispetto alla mappa ReleaseHashes fissata in hashes.go tramite gosuda.org/hqq/secureexec, e avvia il worker con --parent-stdin, così che la sua durata sia strettamente vincolata al processo Go.
Compatibilità DSN Clean-Room con mattn/go-sqlite3
Per funzionare come un vero sostituto diretto, il parser DSN (dsn.go) implementa una replica clean-room delle opzioni di connessione e dei pragma utilizzati nelle basi di codice mattn/go-sqlite3:
| Parametro di Query DSN | Valori Accettati | Effetto in SQLite |
|---|---|---|
_busy_timeout / _timeout | Intero in millisecondi (es. 5000) | Chiama sqlite3_busy_timeout(db, ms) all'apertura della sessione. |
_journal_mode / _journal | DELETE, TRUNCATE, PERSIST, MEMORY, WAL, OFF | Esegue PRAGMA journal_mode = <mode>. |
_txlock | immediate, exclusive, deferred | Seleziona BEGIN IMMEDIATE, BEGIN EXCLUSIVE o BEGIN DEFERRED per db.BeginTx. |
_foreign_keys / _fk | on, true, 1, off, false, 0 | Esegue PRAGMA foreign_keys = ON/OFF. |
_synchronous / _sync | 0/OFF, 1/NORMAL, 2/FULL, 3/EXTRA | Esegue PRAGMA synchronous = <level>. |
_cache_size | Intero di pagine o -KiB (es. -20000) | Esegue PRAGMA cache_size = <val>. |
_loc | auto o fuso orario IANA (es. UTC, Asia/Seoul) | Analizza le colonne di testo DATETIME/TIMESTAMP in *time.Time nella località di destinazione. |
mode | ro, rw, rwc, memory | Imposta SQLITE_OPEN_READONLY, SQLITE_OPEN_READWRITE, SQLITE_OPEN_CREATE o SQLITE_OPEN_MEMORY. |
_case_sensitive_like, _query_only, _recursive_triggers, _secure_delete, _AutoVacuum | Valori booleani o interi di modalità | Applica il corrispondente PRAGMA di sessione all'inizializzazione della connessione. |
Trasformare l'Isolamento di Processo in un Vantaggio di Throughput
A prima vista, eseguire SQLite in un processo separato sembra dover essere più lento che invocare sqlite3_step in-process tramite cgo. In pratica accade l'opposto non appena una query restituisce più di una singola riga.
Il Costo Nascosto delle Chiamate cgo Riga per Riga
In un tipico driver SQLite basato su cgo, database/sql guida l'iterazione una riga e una colonna alla volta. La lettura di 1.000 righe con 4 colonne comporta:
- 1.000 chiamate
cgoasqlite3_step - 4.000 chiamate
cgoasqlite3_column_typeesqlite3_column_int64/sqlite3_column_text - migliaia di singoli attraversamenti di confine tra lo scheduler Go e lo stack C
Pipelining delle Richieste e Prefetch dei Batch da 64 KiB
gosuda.org/sqlite3 elimina gli attraversamenti di confine per ogni riga riprogettando il protocollo di comunicazione attorno a due meccanismi in stmt.go, rows.go e sqlite3/server/sqlite3_hooks.c:
1Go Client (stmt.go / rows.go) C11 Worker (sqlite3_hooks.c)
2----------------------------- ----------------------------
31. Pipeline in 1 flight:
4 ResetAsync + BindValuesAsync
5 + StmtInfoAsync + FetchRowsAsync(128) =======> reset -> bind -> stmt_info ->
6 loop sqlite3_step() up to 128 rows
7 <======= Batch 1 (rows 1..128 packed in 64 KiB slot)
82. Immediately fire prefetch:
9 FetchRowsAsync(128) (Batch 2) =======> loop sqlite3_step() for rows 129..256
10 While C steps Batch 2, Go caller
11 scans rows 1..128 from Batch 1! <======= Batch 2 ready in shared memory
- Pipelining delle Query a Singolo Volo (
stmt.go): QuandoQueryContextoStmt.QueryContextesegue un'istruzione di sola lettura,s.query()invia in modo asincronoreset,bind_values,stmt_info(alla prima esecuzione) efetch_rowsin un unico volo nella memoria condivisa. Tutte e quattro le operazioni vengono eseguite in sequenza sul worker C e restituiscono il risultato in un solo viaggio di andata e ritorno. - Cicli Batch C Stretti e Prefetch Asincrono (
rows.go): All'interno difetch_rows(sqlite3_hooks.c), il worker C eseguesqlite3_step(stmt)in un ciclo C nativo, codificando fino aFetchSizerighe (predefinito 128) direttamente nello slot di risposta in memoria condivisa da 64 KiB, fino al raggiungimento del limite del batch, al riempimento dello slot o al verificarsi diSQLITE_DONE. Non appenarows.ingest()decodifica il Batch 1, inviaFetchRowsAsyncper il Batch 2 prima di restituire la prima riga al chiamante. Mentre il codice Go scandisce il Batch 1, il worker C sta già eseguendo i passi per il Batch 2 su un altro core della CPU.
Su Apple M4 (darwin/arm64), confrontando gosuda.org/sqlite3 (CGO_ENABLED=0) con github.com/mattn/go-sqlite3 (CGO_ENABLED=1) sullo stesso database SQLite, emerge l'effetto del batching e del pipelining:
| Carico di Lavoro | gosuda.org/sqlite3 (CGO_ENABLED=0) | mattn/go-sqlite3 (CGO_ENABLED=1) | Velocità Relativa |
|---|---|---|---|
Selezione Puntuale (QueryRow per PK) | 3.52 µs/op (9 B, 1 alloc) | 3.56 µs/op (232 B, 11 allocs) | 1,01x (parità, 11x meno allocazioni) |
Scansione di 100 Righe (rows.Next) | 20.41 µs/op (30.5 KB, 211 allocs) | 36.34 µs/op (21.8 KB, 309 allocs) | 1,78x più veloce |
Scansione di 1.000 Righe (rows.Next) | 125.92 µs/op (296 KB, 2.030 allocs) | 349.10 µs/op (216 KB, 3.009 allocs) | 2,77x più veloce |
| Inserimento Preparato (Autocommit) | 5.33 µs/op (0 B, 0 allocs) | 5.88 µs/op (120 B, 5 allocs) | 1,10x più veloce (0 allocazioni) |
| Batch di Transazione da 100 Righe | 189.99 µs/op (16 B, 1 alloc) | 191.50 µs/op (12.1 KB, 503 allocs) | 1,01x (500x meno allocazioni) |
Cancellazione, Sandboxing dei Percorsi e Ripristino del Pool di Connessioni
Eseguire SQLite in un worker isolato rafforza anche il modo in cui il driver gestisce timeout, SQL non attendibile e confini del filesystem:
- Cancellazione Deterministica del Contesto (
conn.goeerrors.go): Ogni chiamata del driver attiva una leggera guardiacontext.AfterFunc(cn.guard). Se ilcontext.Contextdel chiamante viene annullato o la sua scadenza si esaurisce mentre una query ricorsiva lenta o un'attesa di lock è in esecuzione sul server, la guardia contrassegna la connessione come non valida, interrompe il collegamento di sessione locale e invia un frameCancel()fuori banda. Il worker C invoca immediatamentesqlite3_interrupt(db), sbloccandosqlite3_step, mentre il driver Go associa lo statoSQLITE_INTERRUPTrisultante acontext.Canceledocontext.DeadlineExceedede istruiscedatabase/sqla sostituire la connessione nel pool. - Sandboxing delle Directory (
sqlsrv_pathrules.c): Il worker C applica le regole di percorso--allow-dira ogni chiamataopen_v2, risolvendo i link simbolici e rifiutando l'attraversamento relativo..al di fuori della directory consentita.sqlite3_load_extensioneSQLITE_LIMIT_ATTACHEDsono disabilitati per impostazione predefinita, così che un'iniezione SQL non possa uscire dalla directory del database né caricare oggetti condivisi arbitrari.
Struttura del Repository
Il repository è strutturato in modo che il pacchetto di primo livello fornisca il driver database/sql pronto all'uso, mentre launcher/ e sqlite3/ restano accessibili per distribuzioni avanzate con server personalizzati:
1gosuda.org/sqlite3
2├── driver.go / api.go # Registrazione del driver database/sql ("sqlite3") e API Connector
3├── dsn.go # Parser DSN clean-room di mattn/go-sqlite3 e configurazione dei pragma
4├── conn.go / tx.go # Ciclo di vita della sessione, guardia del contesto e macchina a stati delle transazioni
5├── stmt.go / rows.go # Pipelining delle richieste a singolo volo e prefetch dei batch da 64 KiB
6├── errors.go # Codici di errore primari/estesi tipizzati di SQLite e corrispondenza sentinella
7├── autolaunch.go / hashes.go # Materializzazione del server incorporato senza configurazione e fissaggio SHA-256
8├── internal/embedded/ # Binari //go:embed con tag di piattaforma (linux, darwin, windows)
9├── launcher/ # Supervisore di processo configurabile per distribuzioni con server personalizzati
10└── sqlite3/ # Client Go hqq-bindgen generato e sorgente del server SQLite C11
11 └── server/ # Implementazione del server C11 (sqlite3_hooks.c, sqlsrv_policy.c)
Per Iniziare
Installa il modulo con go get e apri il database con sql.Open("sqlite3", dsn):
1go get gosuda.org/sqlite3@v0.3.3540100
- Repository GitHub: https://github.com/gosuda/hqq-sqlite3
- Riferimento DSN e Pragma: docs/DSN.md
- Architettura e Protocollo di Comunicazione: docs/ARCHITECTURE.md