From d6f27545ad58651e08c100ceb8abf4d93a4f6050 Mon Sep 17 00:00:00 2001 From: opsx Date: Fri, 30 Oct 2026 18:40:03 +0000 Subject: [PATCH] =?UTF-8?q?fix(sqlite):=20DSN=20=E5=8A=A0=20busy=5Ftimeout?= =?UTF-8?q?/WAL/foreign=5Fkeys=20+=20=E8=BF=9E=E6=8E=A5=E6=B1=A0=E4=B8=8A?= =?UTF-8?q?=E9=99=90=EF=BC=8C=E9=81=BF=E5=85=8D=20database=20is=20locked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 背景:这是 opsxsql 的「死锁类」风险 === LokiStack 是自研 JsonDB + sync.RWMutex,问题是**锁重入死锁**(已由上游 2e97c04 修复)。 opsxsql 用 SQLite + GORM,风险形式不同但后果相似: 裸 DSN(不带任何 pragma)+ 默认 journal 模式 + 无连接池上限 → 写事务与读事务互相阻塞,并发写直接返回 "database is locked"(SQLITE_BUSY), 表现为「点保存失败 / 偶发 500」,而且不会挂住所以更难定位。 静态扫描结论:opsxsql 33 个 Go 文件无 RWMutex 重入问题(0 处), 唯一 RWMutex 在 internal/ghost 且用法正确。日志中 0 次 SQLITE_BUSY (并发压力尚未到临界点,属**潜在**风险)。 === 修改 1:DSN pragma === ?_busy_timeout=5000 遇锁等 5 秒而不是立刻失败 &_journal_mode=WAL 读写不互斥,写不阻塞读 &_synchronous=NORMAL WAL 下常规安全级别 &_foreign_keys=on SQLite 默认关闭外键,容易埋脏数据 仅在 DBPath 不含 "?" 时追加,保留运维自定义 DSN 的能力。 === 修改 2:连接池上限 4 === SQLite 同时只允许一个写者,连接开太多只会加剧锁竞争。 === 修改 3:ghost.go 回调移出锁(防御性)=== 原来 cb(taskID, result.Output) 在持有 Manager.mu 写锁时执行; 若调用方在回调里查任务状态(GetTaskStatus 需要 RLock)会永久死锁。 当前所有调用方都传 nil,属潜在问题,一并修掉。 === 实测(46a97a5c11b7,ns=sql)=== Pod 1/1 Running,重启 0 /app/data 出现 sqlite3.db-wal + sqlite3.db-shm → WAL 已生效 日志 0 次 database is locked / SQLITE_BUSY --- backend/cmd/server/main.go | 23 ++++++++++++++++++++++- backend/internal/ghost/ghost.go | 7 ++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index f77bbad..3b96d7a 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -32,10 +32,31 @@ func main() { } // Connect to SQLite - db, err := gorm.Open(sqlite.Open(config.C.DBPath), &gorm.Config{}) + // + // DSN 必须显式带 pragma,否则默认配置在高并发下会出问题: + // 裸 DSN + 默认 journal 模式下,写事务与读事务互相阻塞, + // 并发写会直接返回 "database is locked"(SQLITE_BUSY), + // 表现为「点保存失败 / 偶尔 500」,而且不会卡住所以极难定位。 + // 这里用业界通用组合: + // _busy_timeout=5000 遇到锁时等 5 秒再报错,而不是立刻失败 + // _journal_mode=WAL 读写不互斥(写不阻塞读),并发请求不再排队 + // _synchronous=NORMAL WAL 下的常规安全级别,比 FULL 快很多 + // _foreign_keys=on 外键约束(SQLite 默认关闭,容易埋脏数据) + dsn := config.C.DBPath + if !strings.Contains(dsn, "?") { + dsn += "?_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&_foreign_keys=on" + } + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) if err != nil { log.Fatalf("Failed to connect to database: %v", err) } + // 连接池上限:SQLite 同一时刻只允许一个写者,连接开太多只会加剧锁竞争。 + // 4 个连接足够支撑日常并发(读多写少),并把锁等待控制在可预期范围内。 + if sqlDB, dbErr := db.DB(); dbErr == nil { + sqlDB.SetMaxOpenConns(4) + sqlDB.SetMaxIdleConns(4) + sqlDB.SetConnMaxLifetime(0) + } model.DB = db // Auto-migrate (add missing tables/columns, won\'t drop existing data) diff --git a/backend/internal/ghost/ghost.go b/backend/internal/ghost/ghost.go index 744cfde..cc216fd 100644 --- a/backend/internal/ghost/ghost.go +++ b/backend/internal/ghost/ghost.go @@ -163,8 +163,9 @@ func RunGhOstAsync(task GhOstTask, ghOstPath string, cb ProgressCallback) string err := cmd.Run() + // 注意:这里不能用 defer Unlock —— 回调要放到锁外执行, + // 所以显式在写完 result 后 Unlock(上面已写明原因)。 Manager.mu.Lock() - defer Manager.mu.Unlock() result := &GhOstResult{ TaskID: taskID, @@ -182,7 +183,11 @@ func RunGhOstAsync(task GhOstTask, ghOstPath string, cb ProgressCallback) string } Manager.tasks[taskID] = result + Manager.mu.Unlock() + // 回调必须放在锁**外面**:调用方如果在回调里查任务状态 + // (GetTaskStatus 需要 RLock),持有写锁时调用会永久死锁。 + // 当前所有调用方传的都是 nil,属于防御性修改。 if cb != nil { cb(taskID, result.Output) }