fix(sqlite): DSN 加 busy_timeout/WAL/foreign_keys + 连接池上限,避免 database is locked

=== 背景:这是 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
This commit is contained in:
opsx committed 2026-10-30 18:40:03 +00:00
1 parent 9dcb39e971
commit d6f27545ad
2 files changed
+28 -2

No files matched your search

+22 -1
View File
@@ -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)
+6 -1
View File
@@ -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)
}