GoSuda

gosuda.org/sqlite3: Драйвер database/sql для прямой замен, выполняющий нативный C SQLite через общую память

By Lemon Mint
views ...

gosuda.org/sqlite3 представляет собой драйвер database/sql с CGO_ENABLED=0 для языка Go, который запускает вышестоящую C-реализацию SQLite amalgamation версии 3.54.1 внутри изолированного рабочего процесса поверх кольцевых буферов общей памяти HQQ. Он регистрируется под стандартным именем драйвера "sqlite3", поддерживает параметры запросов DSN от github.com/mattn/go-sqlite3, внедряет предварительно собранные двоичные файлы сервера с подтверждением SLSA для шести целевых ОС и архитектур, а также использует конвейеризацию запросов с пакетной предварительной выборкой по 64 КиБ для превосходства над внутрипроцессорным cgo при выполнении запросов с большим количеством строк.

1go get gosuda.org/sqlite3@v0.3.3540100

Почему еще один драйвер SQLite для Go?

SQLite является одним из наиболее часто развертываемых программных продуктов в мире, однако его использование из Go исторически вынуждало инженеров делать сложный выбор между простотой сборки и точностью движка.

С одной стороны находится github.com/mattn/go-sqlite3, давний стандарт сообщества. Он напрямую оборачивает официальную C-реализацию SQLite amalgamation с помощью cgo. Вы получаете настоящий C-планировщик запросов, полную семантику параллелизма WAL и предсказуемое управление памятью в C. Цена за это — CGO_ENABLED=1: локальные сборки и конвейеры CI замедляются, минимальные образы контейнеров или образы без дистрибутива требуют наличия соответствующих библиотек среды выполнения C, кросс-компиляция под другую ОС или архитектуру ЦП требует полноценной цепочки инструментов кросс-компиляции C, а любое повреждение памяти в C затрагивает адресное пространство процесса Go.

С другой стороны располагаются чисто Go-порты SQLite, созданные путем механического перевода C-реализации amalgamation в исходный код Go поверх эмулируемой среды выполнения C (libc). Они восстанавливают CGO_ENABLED=0, что делает сборку тривиальной. Однако при постоянных рабочих нагрузках переведенная C-куча и выделения памяти в Go для каждой строки увеличивают нагрузку на сборщик мусора, а тонкие граничные случаи блокировок, тайм-аутов занятости или прерывания инструкций могут отличаться от нативных C-сборок.

Мы создали gosuda.org/sqlite3, чтобы приложениям на Go не приходилось выбирать между CGO_ENABLED=0 и настоящим C-движком SQLite. Путем компиляции официальной C-реализации SQLite 3.54.1 amalgamation в выделенный сервер совместной памяти (hqq-sqlite-server) и взаимодействия через gosuda.org/sqlite3, ваш двоичный файл Go компилируется с CGO_ENABLED=0, выполняя при этом неизмененный C-код SQLite, оптимизированный с флагом -O3, через границу процессов.

Выполнение без настройки: от go get до sql.Open

Замена github.com/mattn/go-sqlite3 в существующей кодовой базе Go требует изменения только пути импорта вхолостую. Драйвер регистрируется как "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-процесс, звучит так: «Должен ли я устанавливать или распространять отдельный двоичный файл C вместе с моей программой на Go?»

Нет. gosuda.org/sqlite3 внедряет предварительно собранные двоичные файлы hqq-sqlite-server для всех шести основных целевых платформ непосредственно в файлы Go с тегами платформ (internal/embedded/embed_<goos>_<goarch>.go). Поскольку ограничения сборки Go компилируют только файл, соответствующий вашим целевым значениям GOOS и GOARCH, кросс-компиляция с помощью GOOS=linux GOARCH=arm64 go build автоматически встраивает только двоичный файл сервера linux/arm64 размером около 1,4 МБ.

Целевая ОСЦелевая архитектура (GOARCH)Внедренный двоичный файлЦепочка сборки и происхождение
Linuxamd64, arm64hqq-sqlite-server_linux_<arch>GCC -O3, происхождение SLSA Build L3
macOSarm64, amd64hqq-sqlite-server_darwin_<arch>AppleClang -O3, происхождение SLSA Build L3
Windowsamd64hqq-sqlite-server_windows_amd64.exeMSVC -O2, происхождение SLSA Build L3
Windowsarm64hqq-sqlite-server_windows_arm64.exezig cc -target aarch64-windows-gnu -O3, SLSA L3

Когда sql.Open устанавливает свое первое соединение, autolaunch.go распаковывает внедренный двоичный файл в каталог кэша пользователя ($XDG_CACHE_HOME/hqq/bin/<sha256>/), проверяет его криптографическую контрольную сумму SHA-256 по закрепленной карте ReleaseHashes в файле hashes.go через gosuda.org/hqq/secureexec и порождает рабочий процесс с параметром --parent-stdin, гарантируя, что его время жизни строго привязано к вашему процессу Go.

Совместимость с DSN в чистой комнате для mattn/go-sqlite3

Для работы в качестве полноценной замены синтаксический анализатор DSN (dsn.go) реализует реплику параметров подключения и прагм, используемых в кодовых базах mattn/go-sqlite3, созданную в условиях чистой комнаты:

Параметр запроса DSNДопустимые значенияЭффект в SQLite
_busy_timeout / _timeoutЦелые миллисекунды (например, 5000)Вызывает sqlite3_busy_timeout(db, ms) при открытии сеанса.
_journal_mode / _journalDELETE, TRUNCATE, PERSIST, MEMORY, WAL, OFFВыполняет PRAGMA journal_mode = <mode>.
_txlockimmediate, exclusive, deferredВыбирает BEGIN IMMEDIATE, BEGIN EXCLUSIVE или BEGIN DEFERRED для db.BeginTx.
_foreign_keys / _fkon, true, 1, off, false, 0Выполняет PRAGMA foreign_keys = ON/OFF.
_synchronous / _sync0/OFF, 1/NORMAL, 2/FULL, 3/EXTRAВыполняет PRAGMA synchronous = <level>.
_cache_sizeЦелое число страниц или -KiB (например, -20000)Выполняет PRAGMA cache_size = <val>.
_locauto или часовой пояс IANA (например, UTC, Asia/Seoul)Преобразует текстовые столбцы DATETIME/TIMESTAMP в *time.Time в целевом часовом поясе.
modero, 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 в отдельном процессе должен работать медленнее, чем вызов sqlite3_step внутри процесса через cgo. На практике происходит противоположное, как только запрос возвращает более одной строки.

Скрытая стоимость построчных вызовов cgo

В обычном драйвере cgo для SQLite компонент database/sql управляет итерацией по одной строке и одному столбцу за раз. Чтение 1000 строк с 4 столбцами инициирует:

  • 1000 вызовов cgo к sqlite3_step
  • 4000 вызовов cgo к sqlite3_column_type и sqlite3_column_int64 / sqlite3_column_text
  • Тысячи индивидуальных переходов через границу между планировщиком Go и стеком C

Конвейеризация запросов и пакетная предварительная выборка по 64 КиБ

gosuda.org/sqlite3 устраняет межграничные переходы для каждой строки за счет переработки сетевого протокола вокруг двух механизмов в stmt.go, rows.go и 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
  1. Конвейеризация однорейсовых запросов (stmt.go): Когда QueryContext или Stmt.QueryContext выполняет оператор только для чтения, s.query() отправляет reset, bind_values, stmt_info (при первом выполнении) и fetch_rows асинхронно в рамках одного общего рейса через память. Все четыре операции выполняются последовательно на C-воркере и возвращаются за один круговой обход (round-trip).
  2. Плотные пакетные циклы C и асинхронная предвыборка (rows.go): Внутри fetch_rows (sqlite3_hooks.c) рабочий C-процесс выполняет шаги sqlite3_step(stmt) в нативном цикле C, кодируя до FetchSize строк (по умолчанию 128) непосредственно в слот ответа разделяемой памяти размером 64 КиБ до достижения лимита пакета, заполнения слота или возникновения SQLITE_DONE. Как только rows.ingest() декодирует пакет 1, он инициирует FetchRowsAsync для пакета 2 перед возвратом первой строки вызывающему коду. Пока ваш код на Go сканирует пакет 1, рабочий C-процесс уже выполняет шаги пакета 2 на другом ядре ЦП.

На чипе Apple M4 (darwin/arm64) сравнение gosuda.org/sqlite3 (CGO_ENABLED=0) и github.com/mattn/go-sqlite3 (CGO_ENABLED=1) на одной и той же базе данных SQLite демонстрирует эффект пакетирования и конвейеризации:

Рабочая нагрузкаgosuda.org/sqlite3 (CGO_ENABLED=0)mattn/go-sqlite3 (CGO_ENABLED=1)Относительная скорость
Точечная выборка (QueryRow по первичному ключу)3.52 µs/op (9 байт, 1 выделение)3.56 µs/op (232 байта, 11 выделений)1.01x (паритет, в 11 раз меньше выделений)
Сканирование 100 строк (rows.Next)20.41 µs/op (30.5 КБ, 211 выделений)36.34 µs/op (21.8 КБ, 309 выделений)в 1.78 раза быстрее
Сканирование 1000 строк (rows.Next)125.92 µs/op (296 КБ, 2030 выделений)349.10 µs/op (216 КБ, 3009 выделений)в 2.77 раза быстрее
Подготовленная вставка (Автокоммит)5.33 µs/op (0 байт, 0 выделений)5.88 µs/op (120 байт, 5 выделений)в 1.10 раза быстрее (0 выделений)
Пакет транзакции из 100 строк189.99 µs/op (16 байт, 1 выделение)191.50 µs/op (12.1 КБ, 503 выделения)1.01x (в 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-процесс применяет правила путей --allow-dir при каждом вызове open_v2, разрешая символические ссылки и отклоняя относительный обход .. за пределами разрешенного каталога. Функции sqlite3_load_extension и SQLITE_LIMIT_ATTACHED отключены по умолчанию, поэтому SQL-инъекции не могут выйти за пределы каталога базы данных или загрузить произвольные разделяемые объекты.

Структура репозитория

Репозиторий структурирован таким образом, что пакет верхнего уровня предоставляет драйвер database/sql типа «drop-in», в то время как папки launcher/ и sqlite3/ остаются доступными для расширенных развертываний пользовательских серверов:

 1gosuda.org/sqlite3
 2├── driver.go / api.go         # регистрация драйвера database/sql ("sqlite3") и API соединителя
 3├── dsn.go                     # парсер DSN и конфигурация прагм в чистой комнате для mattn/go-sqlite3
 4├── conn.go / tx.go            # жизненный цикл сеанса, защита контекста и конечный автомат транзакций
 5├── stmt.go / rows.go          # однорейсовая конвейеризация запросов и пакетная предвыборка по 64 КиБ
 6├── errors.go                  # типизированные первичные/расширенные коды ошибок SQLite и сопоставление меток
 7├── autolaunch.go / hashes.go  # автоматическая инициализация встроенного сервера без настройки и закрепление SHA-256
 8├── internal/embedded/         # бинарные файлы с тегами платформ //go:embed (linux, darwin, windows)
 9├── launcher/                  # настраиваемый супервизор процессов для развертывания пользовательских серверов
10└── sqlite3/                   # сгенерированный клиент Go hqq-bindgen и исходный код 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