GoSuda

gosuda.org/sqlite3: 공유 메모리 상에서 네이티브 C SQLite를 구동하는 드롭인 database/sql 드라이버

By Lemon Mint
views ...

gosuda.org/sqlite3는 HQQ 공유 메모리 링을 기반으로 고립된 워커 프로세스 내부에서 업스트림 C SQLite 3.54.1 아마르고메이션을 실행하는 Go용 CGO_ENABLED=0 database/sql 드라이버입니다. 표준 "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 파이프라인이 느려지고, 최소한의 스크래치 또는 distroless 컨테이너 이미지에는 일치하는 C 런타임 라이브러리가 필요하며, 다른 OS나 CPU 아키텍처로의 크로스 컴파일에는 전체 C 크로스 툴체인이 필요하고, C의 메모리 손상은 Go 프로세스 주소 공간을 공유합니다.

다른 한쪽에는 에뮬레이트된 C 런타임(libc) 위에서 C 아마르고메이션을 Go 소스 코드로 기계적으로 번역하여 생성한 순수 Go SQLite 포트가 있습니다. 이들은 CGO_ENABLED=1을 제거하여 빌드를 단순하게 만듭니다. 그러나 지속적인 워크로드 하에서는 번역된 C 힙과 행별 Go 할당이 가비지 컬렉션 압력을 증가시키며, 락킹, 바쁜 타임아웃, 또는문 중단에서의 미묘한 예외 케이스가 네이티브 C 빌드와 다를 수 있습니다.

우리는 Go 애플리케이션이 CGO_ENABLED=0과 실제 C SQLite 엔진 사이에서 선택할 필요가 없도록 gosuda.org/sqlite3를 구축했습니다. 공식 SQLite 3.54.1 C 아마르고메이션을 전용 공유 메모리 서버(hqq-sqlite-server)로 컴파일하고 gosuda.org/hqq를 통해 통신함으로써, Go 바이너리는 프로세스 경계를 넘어 수정되지 않은 -O3 최적화된 C SQLite를 실행하는 동시에 CGO_ENABLED=0으로 컴파일됩니다.

제로 설정 실행: 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 코드베이스 전체에서 사용되는 연결 및 프라그마 노브의 클린룸 복제품을 구현합니다.

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은 한 번에 한 행과 한 컬럼씩 반복을 구동합니다. 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. 단일 왕복(flight)에서 파이프라인 처리:
 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를 스텝하는 동안, Go 호출자는
11   배치 1에서 1..128행을 스캔합니다!       <=======  공유 메모리에서 배치 2 준비 완료
  1. 단일 왕복 쿼리 파이프라이닝 (stmt.go): QueryContext 또는 Stmt.QueryContext가 읽기 전용 문을 실행할 때, s.query()는 단일 공유 메모리 왕복 내에서 비동기적으로 reset, bind_values, stmt_info(첫 실행 시), 및 fetch_rows를 제출합니다. 네 가지 작업 모두 C 워커에서 연속으로 실행되고 하나의 왕복으로 반환됩니다.
  2. 타이트한 C 배치 루프 및 비동기 프리페치 (rows.go): fetch_rows(sqlite3_hooks.c) 내부에서 C 워커는 배치 제한에 도달하거나, 슬롯이 가득 차거나, SQLITE_DONE이 발생할 때까지 최대 FetchSize 행(기본값 128)을 64 KiB 공유 메모리 응답 슬롯에 직접 인코딩하면서 네이티브 C 루프에서 sqlite3_step(stmt)을 진행합니다. 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배 빠름
준비된 인서트 (자동 커밋)5.33 µs/op (0 B, 0 allocs)5.88 µs/op (120 B, 5 allocs)1.10배 빠름 (0회 할당)
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 경로 규칙을 강제하며, 심볼릭 링크를 해결하고 허용된 디렉토리 외부로의 상대적인 .. 순회를 거부합니다. SQL 인젝션이 데이터베이스 디렉토리를 탈출하거나 임의의 공유 객체를 로드할 수 없도록 sqlite3_load_extension 및 SQLITE_LIMIT_ATTACHED는 기본적으로 비활성화되어 있습니다.

리포지토리 레이아웃

리포지토리는 최상위 패키지가 드롭인 database/sql 드라이버를 제공하는 동시에 launcher/와 sqlite3/는 고급 커스텀 서버 배포를 위해 접근 가능하도록 구조화되어 있습니다.

 1gosuda.org/sqlite3
 2├── driver.go / api.go         # database/sql 드라이버 등록 ("sqlite3") 및 Connector API
 3├── dsn.go                     # 클린룸 mattn/go-sqlite3 DSN 파서 및 프라그마 설정
 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