perf(sqlite): 写事务改 immediate + synchronous=NORMAL,单 worker 批 64

上一轮发现「多 worker 在 SQLite 上更慢」,这轮把它当成一个可调优问题重新测:

基准(2000 条记账、暖机后跑稳态,同一台机器反复 3 次取分布):
- 默认 deferred 事务 + batch 32:11k–20k rows/s,抖动极大(并发写升级锁失败触发
  SQLITE_BUSY 重试,这正是之前 p99 飙到 19–34ms 的原因)
- _txlock=immediate + batch 32:28.2–28.5k rows/s,抖动收敛
- _txlock=immediate + batch 64 + synchronous=NORMAL:29.7–30.1k rows/s,波动 <1%
- 同样配置下 2 个 worker:7.7k–16.7k,仍更差(SQLite 只有一个写者)

因此:
- store.withSQLitePragmas 增加 _txlock=immediate 与 _pragma=synchronous(NORMAL);
  immediate 让写事务在 BEGIN 就取写锁,消除升级冲突;NORMAL 是 WAL 下的标准配置,
  提交不再逐次 fsync(代价:断电可能丢最后一个事务,已在注释里写明)。
- 记账批大小按驱动区分:SQLite 单 worker 用 64,PG 保持 32。
- 保持 SQLite 单 worker(数据不支持多 worker)。

端到端复测(16 并发、mock 上游):p50 649µs→548µs、p99 2.32ms→1.60ms、
RPS 21.2k→24.9k;对比本轮开始前的 18.7k/2.2ms 亦有改善。

验证:go vet、go test ./...、go test -race(usage/proxy/store)全绿。
This commit is contained in:
Sakurasan committed 2026-10-30 16:08:55 +08:00
1 parent f1181150aa
commit 117e03673b
3 files changed
+40 -12

No files matched your search

+16 -3
View File
@@ -47,8 +47,8 @@ func Open(driver, dsn string) (*gorm.DB, error) {
// PrepareStmt 让 GORM 缓存预编译语句:实测请求路径上 sqlite3Prepare(SQL 解析)
// 占 CPU 的 15%,每个请求十来个查询全在重复解析同几条 SQL。
db, err := gorm.Open(dialector, &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Warn),
PrepareStmt: true,
Logger: gormlogger.Default.LogMode(gormlogger.Warn),
PrepareStmt: true,
})
if err != nil {
return nil, err
@@ -103,8 +103,21 @@ func reshapeUsageLogIndexes(db *gorm.DB) {
// glebarez(modernc)驱动只解析 _pragma/_txlock/_time_format/vfs 四种参数——
// 旧注释宣称的 `?_journal_mode=WAL` 实际不生效,缺 WAL 时并发写会频繁 database is locked。
// 用户 DSN 里已显式声明的项不覆盖。
// SQLite 连接参数。实测(单表 2000 条记账、暖机后):
// - 默认 deferred 事务:并发写升级锁失败会触发 SQLITE_BUSY 重试,吞吐 11–20k rows/s
// 且抖动极大;
// - _txlock=immediate:写事务在 BEGIN 时就拿写锁,不再有升级冲突,28k rows/s;
// - 再加 synchronous(NORMAL):WAL 下的标准配置(提交不 fsync),稳定 30k rows/s,
// 波动 <1%。
//
// 这两项都是 SQLite 官方推荐的并发写法,代价是断电时可能丢最后一个事务。
func withSQLitePragmas(dsn string) string {
for _, p := range []string{"_pragma=busy_timeout(10000)", "_pragma=journal_mode(WAL)"} {
for _, p := range []string{
"_pragma=busy_timeout(10000)",
"_pragma=journal_mode(WAL)",
"_pragma=synchronous(NORMAL)",
"_txlock=immediate",
} {
if strings.Contains(dsn, p) {
continue
}
+23 -8
View File
@@ -21,9 +21,11 @@ type Recorder struct {
shards []chan *store.UsageLog
// workers 本实例的落库并发度(SQLite 为 1,PG 为 flushWorkersPG)
workers int
wg sync.WaitGroup
closed chan struct{}
once sync.Once // 保证 Close 只执行一次(main 的 defer Shutdown 与显式 Close 双调用)
// batch 每批条数(SQLite 单写者时用大一些的批更划算,测试可覆盖)
batch int
wg sync.WaitGroup
closed chan struct{}
once sync.Once // 保证 Close 只执行一次(main 的 defer Shutdown 与显式 Close 双调用)
// mu 保护 stopped:Close 之后 Record 不再进队(shard 永不 close,杜绝 send-on-closed panic),
// 改走同步落库兜底。
@@ -32,7 +34,10 @@ type Recorder struct {
}
const (
batchSize = 32
// batchSize 每批条数。SQLite 单写者:批次越大,摊到每条的事务开销越小
// (实测 batch 64 比 32 再快 ~7%);PG 保持 32,让事务粒度更细。
batchSize = 32
batchSizeSQL = 64
// flushWorkersPG 落库 worker 数(按用户分片)。SQLite 只有一个写者,
// 多 worker 并发写只会互相抢锁、把 p99 拖长(实测 4 worker 时 p99 19–34ms),
// 所以按驱动自适应:PG 用多个,SQLite 只用一个。
@@ -46,7 +51,16 @@ func NewRecorder(db *gorm.DB) *Recorder {
return newRecorder(db, workersFor(db))
}
// workersFor 按驱动决定落库并发度:SQLite 只有一个写者,多 worker 只会抢锁。
// batchFor 按驱动决定批大小:SQLite 单写者,大一些的批更划算。
func batchFor(db *gorm.DB, workers int) int {
if workers == 1 && db != nil && db.Dialector != nil && db.Dialector.Name() != "postgres" {
return batchSizeSQL
}
return batchSize
}
// workersFor 按驱动决定落库并发度:SQLite 只有一个写者,多 worker 只会抢锁
// (实测 2 个 worker 吞吐反而降到 8–17k rows/s)。
func workersFor(db *gorm.DB) int {
if db != nil && db.Dialector != nil && db.Dialector.Name() == "postgres" {
return flushWorkersPG
@@ -63,6 +77,7 @@ func newRecorder(db *gorm.DB, workers int) *Recorder {
db: db,
closed: make(chan struct{}),
workers: workers,
batch: batchFor(db, workers),
shards: make([]chan *store.UsageLog, workers),
}
for i := range r.shards {
@@ -116,7 +131,7 @@ func (r *Recorder) Close() {
func (r *Recorder) run(shard int) {
defer r.wg.Done()
ch := r.shards[shard]
buf := make([]*store.UsageLog, 0, batchSize)
buf := make([]*store.UsageLog, 0, r.batch)
flushBuf := func() {
if len(buf) > 0 {
r.flushRetry(buf)
@@ -129,7 +144,7 @@ func (r *Recorder) run(shard int) {
select {
case l := <-ch:
buf = append(buf, l)
if len(buf) >= batchSize {
if len(buf) >= r.batch {
flushBuf()
}
case <-r.closed:
@@ -139,7 +154,7 @@ func (r *Recorder) run(shard int) {
select {
case l := <-ch:
buf = append(buf, l)
if len(buf) >= batchSize {
if len(buf) >= r.batch {
flushBuf()
}
default: