Files
opsx d6f27545ad 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
2026-10-30 18:40:03 +00:00

276 lines
7.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ghost
import (
"bytes"
"context"
"fmt"
"os/exec"
"regexp"
"strings"
"sync"
"time"
"sql-platform/internal/safe"
)
// GhOstTask defines a gh-ost execution task
type GhOstTask struct {
Host string `json:"host"`
Port int `json:"port,omitempty"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
Table string `json:"table"`
Alter string `json:"alter"` // e.g. "ADD COLUMN status INT NOT NULL DEFAULT 0"
ChunkSize int `json:"chunk_size,omitempty"`
MaxLoad string `json:"max_load,omitempty"`
CriticalLoad string `json:"critical_load,omitempty"`
MaxLagMillis int `json:"max_lag_millis,omitempty"`
AllowOnMaster bool `json:"allow_on_master"`
DryRun bool `json:"dry_run"`
Execute bool `json:"execute"`
ExactRowCount bool `json:"exact_row_count"`
PostponeCutOver bool `json:"postpone_cut_over"`
AliyunRDS bool `json:"aliyun_rds,omitempty"` // --aliyun-rds
AWSAurora bool `json:"aws_aurora,omitempty"` // --aws
}
// GhOstResult holds the execution result
type GhOstResult struct {
Success bool `json:"success"`
Output string `json:"output"`
Error string `json:"error"`
Command string `json:"command"`
Duration string `json:"duration"`
TaskID string `json:"task_id"`
}
// ProgressCallback is called with stdout lines for real-time progress
type ProgressCallback func(taskID string, line string)
// TaskManager manages async gh-ost tasks
type TaskManager struct {
mu sync.RWMutex
tasks map[string]*GhOstResult
status map[string]string // running, completed, failed
}
var Manager = &TaskManager{
tasks: make(map[string]*GhOstResult),
status: make(map[string]string),
}
// RunGhOst executes gh-ost synchronously
func RunGhOst(ctx context.Context, task GhOstTask, ghOstPath string) (*GhOstResult, error) {
start := time.Now()
args := []string{
"--host=" + task.Host,
fmt.Sprintf("--port=%d", task.Port),
"--user=" + task.User,
"--password=" + task.Password,
"--database=" + task.Database,
"--table=" + task.Table,
"--alter=" + task.Alter,
"--verbose",
"--skip-port-validation",
"--allow-on-master",
"--initially-drop-ghost-table",
"--initially-drop-old-table",
"--initially-drop-socket-file",
}
if task.ChunkSize > 0 {
args = append(args, fmt.Sprintf("--chunk-size=%d", task.ChunkSize))
}
if task.MaxLoad != "" {
args = append(args, "--max-load="+task.MaxLoad)
}
if task.CriticalLoad != "" {
args = append(args, "--critical-load="+task.CriticalLoad)
}
if task.MaxLagMillis > 0 {
args = append(args, fmt.Sprintf("--max-lag-millis=%d", task.MaxLagMillis))
}
if task.AllowOnMaster {
args = append(args, "--allow-on-master")
}
if task.ExactRowCount {
args = append(args, "--exact-rowcount")
}
if task.PostponeCutOver {
args = append(args, "--postpone-cut-over-flag-file=/tmp/gh-ost-cutover.flag")
}
// Cloud-specific flags
if task.AliyunRDS {
args = append(args, "--aliyun-rds")
}
if task.AWSAurora {
args = append(args, "--aws")
}
// Dry-Run vs Execute
// gh-ost defaults to noop (dry-run), only --execute actually applies changes
if task.Execute {
args = append(args, "--execute")
}
// always verbose for output
cmd := exec.CommandContext(ctx, ghOstPath, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
result := &GhOstResult{
Command: "gh-ost " + maskSecrets(strings.Join(args, " ")),
Duration: time.Since(start).String(),
Output: stdout.String(),
}
if err != nil {
result.Success = false
result.Error = stderr.String() + "\n" + err.Error()
return result, err
}
result.Success = true
return result, nil
}
// RunGhOstAsync runs gh-ost in background and tracks status
func RunGhOstAsync(task GhOstTask, ghOstPath string, cb ProgressCallback) string {
taskID := fmt.Sprintf("ghost-%d", time.Now().UnixNano())
Manager.mu.Lock()
Manager.status[taskID] = "running"
Manager.tasks[taskID] = &GhOstResult{TaskID: taskID}
Manager.mu.Unlock()
run := func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
start := time.Now()
args := buildArgs(task, ghOstPath)
cmd := exec.CommandContext(ctx, ghOstPath, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
// 注意:这里不能用 defer Unlock —— 回调要放到锁外执行,
// 所以显式在写完 result 后 Unlock(上面已写明原因)。
Manager.mu.Lock()
result := &GhOstResult{
TaskID: taskID,
Command: "gh-ost " + maskSecrets(strings.Join(args, " ")),
Duration: time.Since(start).String(),
Output: stdout.String(),
Success: err == nil,
}
if err != nil {
result.Error = stderr.String() + "\n" + err.Error()
Manager.status[taskID] = "failed"
} else {
Manager.status[taskID] = "completed"
}
Manager.tasks[taskID] = result
Manager.mu.Unlock()
// 回调必须放在锁**外面**:调用方如果在回调里查任务状态
// (GetTaskStatus 需要 RLock),持有写锁时调用会永久死锁。
// 当前所有调用方传的都是 nil,属于防御性修改。
if cb != nil {
cb(taskID, result.Output)
}
}
safe.Go("ghost:"+taskID, run)
return taskID
}
// maskSecrets 将命令行中的密码参数值替换为 ******,防止经任务状态接口回传前端
func maskSecrets(cmdline string) string {
return regexp.MustCompile(`(--password=)([^ ]+)`).ReplaceAllString(cmdline, `$1******`)
}
// GetTaskStatus returns the status of an async task
func (m *TaskManager) GetTaskStatus(taskID string) (string, *GhOstResult) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.status[taskID], m.tasks[taskID]
}
// ListTasks returns all tracked tasks
func (m *TaskManager) ListTasks() map[string]string {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]string)
for k, v := range m.status {
result[k] = v
}
return result
}
func buildArgs(task GhOstTask, ghOstPath string) []string {
args := []string{
"--host=" + task.Host,
fmt.Sprintf("--port=%d", task.Port),
"--user=" + task.User,
"--password=" + task.Password,
"--database=" + task.Database,
"--table=" + task.Table,
"--alter=" + task.Alter,
"--verbose",
"--skip-port-validation",
"--allow-on-master",
"--initially-drop-ghost-table",
"--initially-drop-old-table",
"--initially-drop-socket-file",
}
if task.ChunkSize > 0 {
args = append(args, fmt.Sprintf("--chunk-size=%d", task.ChunkSize))
}
if task.MaxLoad != "" {
args = append(args, "--max-load="+task.MaxLoad)
}
if task.CriticalLoad != "" {
args = append(args, "--critical-load="+task.CriticalLoad)
}
if task.MaxLagMillis > 0 {
args = append(args, fmt.Sprintf("--max-lag-millis=%d", task.MaxLagMillis))
}
if task.AllowOnMaster {
args = append(args, "--allow-on-master")
}
if task.ExactRowCount {
args = append(args, "--exact-rowcount")
}
if task.PostponeCutOver {
args = append(args, "--postpone-cut-over-flag-file=/tmp/gh-ost-cutover.flag")
}
// Cloud-specific flags
if task.AliyunRDS {
args = append(args, "--aliyun-rds")
}
if task.AWSAurora {
args = append(args, "--aws")
}
// gh-ost defaults to noop, only --execute actually applies changes
if task.Execute {
args = append(args, "--execute")
}
return args
}