gosuda.org/sqlite3: 공유 메모리 IPC 기반 CGO_ENABLED=0 database/sql 드라이버

gosuda.org/sqlite3는 CGO_ENABLED=0 환경에서 사용할 수 있는 Go용 database/sql 드라이버로, HQQ 공유 메모리 링을 통해 격리된 워커 프로세스 안에서 upstream C SQLite 3.54.1 amalgamation을 실행합니다. 표준 "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 amalgamation을 직접 감싸고 있습니다. 실제 C 쿼리 플래너, 완전한 WAL 동시성 의미론, 예측 가능한 C 메모리 관리를 얻을 수 있습니다. 그 대가로 CGO_ENABLED=1이 필요하며, 로컬 빌드와 CI 파이프라인이 느려지고, 최소 구성의 scratch 또는 distroless 컨테이너 이미지에는 대응하는 C 런타임 라이브러리가 필요하며, 다른 OS나 CPU 아키텍처로의 크로스 컴파일에는 완전한 C 크로스 툴체인이 필요하고, C 코드의 메모리 손상이 Go 프로세스 주소 공간을 공유하게 됩니다.
다른 한편에는 C amalgamation을 에뮬레이션된 C 런타임(libc) 위에서 Go 소스 코드로 기계적으로 번역하여 생성한 순수 Go SQLite 포트들이 있습니다. 이들은 CGO_ENABLED=0을 복원하여 빌드를 간단하게 만듭니다. 그러나 지속적인 워크로드에서는 번역된 C 힙과 행별 Go 할당이 가비지 컬렉션 부담을 증가시키며, 잠금, busy 타임아웃, 또는 문장 중단과 관련된 미묘한 경우들이 네이티브 C 빌드와 다르게 동작할 수 있습니다.
우리는 Go 애플리케이션이 CGO_ENABLED=0과 실제 C SQLite 엔진 사이에서 선택할 필요가 없도록 gosuda.org/sqlite3를 만들었습니다. 공식 SQLite 3.54.1 C amalgamation을 전용 공유 메모리 서버(hqq-sqlite-server)로 컴파일하고 gosuda.org/hqq를 통해 통신함으로써, 여러분의 Go 바이너리는 CGO_ENABLED=0으로 컴파일되면서도 프로세스 경계 너머에서 수정되지 않은 -O3 최적화 C SQLite를 실행합니다.
설정 없는 실행: go get에서 sql.Open까지
기존 Go 코드베이스에서 github.com/mattn/go-sqlite3를 교체하려면 blank import 경로만 변경하면 됩니다. 이 드라이버는 "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는 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) | 내장 바이너리 | 빌드 툴체인 및 출처 증명 |
|---|---|---|---|
| Linux | amd64, arm64 | hqq-sqlite-server_linux_<arch> | GCC -O3, SLSA Build L3 출처 증명 |
| macOS | arm64, amd64 | hqq-sqlite-server_darwin_<arch> | AppleClang -O3, SLSA Build L3 출처 증명 |
| Windows | amd64 | hqq-sqlite-server_windows_amd64.exe | MSVC -O2, SLSA Build L3 출처 증명 |
| Windows | arm64 | hqq-sqlite-server_windows_arm64.exe | zig 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 다이제스트를 검증한 후, --parent-stdin 옵션으로 워커를 기동하여 워커의 수명이 Go 프로세스에 엄격하게 묶이도록 합니다.
mattn/go-sqlite3 DSN 클린룸 호환성
진정한 드롭인 대체재로 기능하기 위해, DSN 파서(dsn.go)는 mattn/go-sqlite3 코드베이스 전반에서 사용되는 연결 및 pragma 설정 항목을 클린룸 방식으로 재구현합니다.
| DSN 쿼리 매개변수 | 허용 값 | SQLite 효과 |
|---|---|---|
_busy_timeout / _timeout | 정수 밀리초 (예: 5000) | 세션을 열 때 sqlite3_busy_timeout(db, ms)를 호출합니다. |
_journal_mode / _journal | DELETE, TRUNCATE, PERSIST, MEMORY, WAL, OFF | PRAGMA journal_mode = <mode>를 실행합니다. |
_txlock | immediate, exclusive, deferred | db.BeginTx에 대해 BEGIN IMMEDIATE, BEGIN EXCLUSIVE, 또는 BEGIN DEFERRED를 선택합니다. |
_foreign_keys / _fk | on, true, 1, off, false, 0 | PRAGMA foreign_keys = ON/OFF를 실행합니다. |
_synchronous / _sync | 0/OFF, 1/NORMAL, 2/FULL, 3/EXTRA | PRAGMA synchronous = <level>을 실행합니다. |
_cache_size | 정수 페이지 수 또는 -KiB (예: -20000) | PRAGMA cache_size = <val>을 실행합니다. |
_loc | auto 또는 IANA 시간대 (예: UTC, Asia/Seoul) | DATETIME/TIMESTAMP 텍스트 열을 대상 위치의 *time.Time으로 파싱합니다. |
mode | ro, rw, rwc, memory | SQLITE_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은 한 번에 한 행, 한 번에 한 열씩 순회를 진행합니다. 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의 두 가지 메커니즘을 중심으로 와이어 프로토콜을 재설계하여 행별 경계 횡단을 제거합니다.
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를 스텝하는 동안, Go 호출자는
11 배치 1의 행 1..128을 스캔합니다! <======= 배치 2가 공유 메모리에 준비됨
- 단일 플라이트 쿼리 파이프라이닝 (
stmt.go):QueryContext또는Stmt.QueryContext가 읽기 전용 문장을 실행할 때,s.query()는reset,bind_values,stmt_info(첫 실행 시),fetch_rows를 단일 공유 메모리 플라이트로 비동기 제출합니다. 네 가지 작업이 모두 C 워커에서 연달아 실행되며 한 번의 왕복으로 반환됩니다. - 촘촘한 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배 감소) |
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배 빠름 |
| Prepared Insert (자동 커밋) | 5.33 µs/op (0 B, 0 allocs) | 5.88 µs/op (120 B, 5 allocs) | 1.10배 빠름 (0 allocs) |
| 100행 트랜잭션 배치 | 189.99 µs/op (16 B, 1 alloc) | 191.50 µs/op (12.1 KB, 503 allocs) | 1.01배 (할당 500배 감소) |
취소, 경로 샌드박싱, 연결 풀 복구
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 파서 및 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
- GitHub 저장소: https://github.com/gosuda/hqq-sqlite3
- DSN 및 Pragma 참조: docs/DSN.md
- 아키텍처 및 와이어 프로토콜: docs/ARCHITECTURE.md