feat(panel): 数据库故障降级、健康告警与检测

- 网站统计聚合器 flush 失败也 commit(丢弃已 drain 增量),防止 DB
  长期不可写时内存无界累积导致 OOM/CPU 100%
- 势态感知同样在 upsert 失败时上报健康问题
- 新增 internal/app.Health 全局健康状态注册表,通过 /home/health 暴露
- 前端 layout 顶部新增 HealthBanner,DB 故障时红色横幅提醒并给出修复引导
- acepanel fix 检测数据库损坏改用 PRAGMA quick_check,能识别 malformed
  等仅在查询时才暴露的静默损坏
- 主库与辅助库 DSN 一律设为 synchronous(FULL),减少宿主 write-back
  缓存丢失 fsync 导致的损坏概率
- 移除 debug.SetMemoryLimit(256MB):软上限在活对象超限时会驱动 GC 反复
  追赶不可回收对象,反而放大 CPU 打满

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
耗子
2026-07-03 04:21:10 +08:00
parent 3098d9fb64
commit f024381660
12 changed files with 206 additions and 17 deletions
-1
View File
@@ -12,7 +12,6 @@ func main() {
}
debug.SetGCPercent(10)
debug.SetMemoryLimit(256 << 20)
ace, err := initAce()
if err != nil {
+73
View File
@@ -0,0 +1,73 @@
package app
import (
"sort"
"sync"
"time"
)
// HealthLevelError 需要用户立即处理
const HealthLevelError = "error"
// HealthLevelWarning 提示性问题,通常已被系统自动降级处理
const HealthLevelWarning = "warning"
// HealthIssue 单条健康问题
// Key 为稳定标识符(如 database:stat),前端据此选择翻译文案
// Message 为原始错误详情,供诊断参考
type HealthIssue struct {
Key string `json:"key"`
Level string `json:"level"`
Message string `json:"message"`
Since time.Time `json:"since"`
}
type healthRegistry struct {
mu sync.RWMutex
issues map[string]HealthIssue
}
// Health 全局健康状态注册表,供各后台任务上报/清除故障
var Health = &healthRegistry{issues: make(map[string]HealthIssue)}
// Report 上报或更新一条健康问题
// 同 key 重复上报时保留首次上报时间,仅更新 message 和 level
func (h *healthRegistry) Report(key, level, message string) {
h.mu.Lock()
defer h.mu.Unlock()
if existing, ok := h.issues[key]; ok {
existing.Level = level
existing.Message = message
h.issues[key] = existing
return
}
h.issues[key] = HealthIssue{
Key: key,
Level: level,
Message: message,
Since: time.Now(),
}
}
// Clear 清除指定 key 的健康问题
func (h *healthRegistry) Clear(key string) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.issues, key)
}
// Snapshot 返回当前所有健康问题,按 Since 升序(越早发生越靠前)
func (h *healthRegistry) Snapshot() []HealthIssue {
h.mu.RLock()
defer h.mu.RUnlock()
result := make([]HealthIssue, 0, len(h.issues))
for _, issue := range h.issues {
result = append(result, issue)
}
sort.Slice(result, func(i, j int) bool {
return result[i].Since.Before(result[j].Since)
})
return result
}
+1 -1
View File
@@ -32,7 +32,7 @@ func NewDB(conf *config.Config) (*gorm.DB, error) {
options = append(options, sloggorm.WithTraceAll())
}
db, err := gorm.Open(sqlite.Open("file:"+filepath.Join(app.Root, "panel/storage/panel.db")+"?_txlock=immediate&_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)"),
db, err := gorm.Open(sqlite.Open("file:"+filepath.Join(app.Root, "panel/storage/panel.db")+"?_txlock=immediate&_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(FULL)"),
&gorm.Config{
Logger: sloggorm.New(options...),
SkipDefaultTransaction: true,
+11 -7
View File
@@ -1211,8 +1211,8 @@ func (r *backupRepo) FixPanel() error {
panelBroken := !io.Exists(filepath.Join(app.Root, "panel", "ace")) ||
!io.Exists(filepath.Join(app.Root, "panel", "storage", "config.yml")) ||
!io.Exists(filepath.Join(app.Root, "panel", "storage", "panel.db"))
// 检查主数据库连接
if err := r.db.Exec("VACUUM").Error; err != nil {
// 检查主数据库完整性
if !quickCheck(r.db) {
panelBroken = true
}
if err := r.db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error; err != nil {
@@ -1223,13 +1223,17 @@ func (r *backupRepo) FixPanel() error {
var brokenAuxDBs []string
for _, name := range []string{"stat", "scan"} {
auxDB, err := openDB(name)
if err == nil {
if sqlDB, dbErr := auxDB.DB(); dbErr == nil {
_ = sqlDB.Close()
}
if err != nil {
brokenAuxDBs = append(brokenAuxDBs, name)
continue
}
brokenAuxDBs = append(brokenAuxDBs, name)
ok := quickCheck(auxDB)
if sqlDB, dbErr := auxDB.DB(); dbErr == nil {
_ = sqlDB.Close()
}
if !ok {
brokenAuxDBs = append(brokenAuxDBs, name)
}
}
// 一切正常,无需修复
+10 -1
View File
@@ -55,7 +55,7 @@ func getOperatorID(ctx context.Context) uint64 {
// openDB 打开数据库
func openDB(name string) (*gorm.DB, error) {
dsn := "file:" + filepath.Join(app.Root, fmt.Sprintf("panel/storage/%s.db", name)) +
"?_txlock=immediate&_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
"?_txlock=immediate&_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(FULL)"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
SkipDefaultTransaction: true,
DisableForeignKeyConstraintWhenMigrating: true,
@@ -69,6 +69,15 @@ func openDB(name string) (*gorm.DB, error) {
return db, nil
}
// quickCheck 用 PRAGMA quick_check 检测 SQLite 数据库是否损坏
func quickCheck(db *gorm.DB) bool {
var result string
if err := db.Raw("PRAGMA quick_check").Row().Scan(&result); err != nil {
return false
}
return result == "ok"
}
// upsert 分批大小,避免超出 SQLite 变量数限制
const upsertBatchSize = 100
+6
View File
@@ -17,6 +17,9 @@ import (
"github.com/acepanel/panel/v3/pkg/geoip"
)
// healthKeyScanDB 扫描事件辅助数据库健康问题上报 key
const healthKeyScanDB = "database:scan"
// ipCounter 单个 IP 的扫描计数器
type ipCounter struct {
count uint
@@ -187,7 +190,10 @@ func (r *FirewallScan) flush() {
if err := r.scanRepo.Upsert(events); err != nil {
r.log.Warn("failed to upsert scan events", slog.Any("err", err))
app.Health.Report(healthKeyScanDB, app.HealthLevelError, err.Error())
return
}
app.Health.Clear(healthKeyScanDB)
}
// ensureFirewall 懒加载防火墙实例
+22 -7
View File
@@ -15,6 +15,9 @@ import (
"github.com/acepanel/panel/v3/pkg/websitestat"
)
// healthKeyStatDB 网站统计辅助数据库健康问题上报 key
const healthKeyStatDB = "database:stat"
// WebsiteStat 网站统计后台任务
type WebsiteStat struct {
log *slog.Logger
@@ -177,9 +180,13 @@ func (r *WebsiteStat) flush() {
if err := r.statRepo.Upsert(stats); err != nil {
r.log.Warn("failed to upsert website stats", slog.Any("err", err))
app.Health.Report(healthKeyStatDB, app.HealthLevelError, err.Error())
// 直接丢弃已 drain 的增量,避免 DB 长期不可写时内存无界累积
commit()
return
}
commit()
app.Health.Clear(healthKeyStatDB)
}
// flushErrors 将错误日志缓冲写入数据库
@@ -205,9 +212,13 @@ func (r *WebsiteStat) flushErrors() {
if err := r.statRepo.InsertErrors(errors); err != nil {
r.log.Warn("failed to insert website error logs", slog.Any("err", err))
app.Health.Report(healthKeyStatDB, app.HealthLevelError, err.Error())
// 直接丢弃已 snapshot 的错误缓冲,避免内存无界累积
commit()
return
}
commit()
app.Health.Clear(healthKeyStatDB)
}
// flushDetails 将详细统计增量写入数据库(蜘蛛/客户端/IP/URI)
@@ -286,25 +297,29 @@ func (r *WebsiteStat) flushDetails() {
}
}
failed := false
var lastErr error
if err := r.statRepo.UpsertSpiders(spiders); err != nil {
r.log.Warn("failed to upsert spider stats", slog.Any("err", err))
failed = true
lastErr = err
}
if err := r.statRepo.UpsertClients(clients); err != nil {
r.log.Warn("failed to upsert client stats", slog.Any("err", err))
failed = true
lastErr = err
}
if err := r.statRepo.UpsertIPs(ips); err != nil {
r.log.Warn("failed to upsert ip stats", slog.Any("err", err))
failed = true
lastErr = err
}
if err := r.statRepo.UpsertURIs(uris); err != nil {
r.log.Warn("failed to upsert uri stats", slog.Any("err", err))
failed = true
lastErr = err
}
if !failed {
commit()
// 无论成败都 commit:成功→减去已入库增量;失败→直接丢弃,避免内存无界累积
commit()
if lastErr != nil {
app.Health.Report(healthKeyStatDB, app.HealthLevelError, lastErr.Error())
} else {
app.Health.Clear(healthKeyStatDB)
}
}
+1
View File
@@ -245,6 +245,7 @@ func (route *Http) Register(r *chi.Mux) {
r.Post("/restart_server", route.home.RestartServer)
r.Get("/runtime_info", route.home.RuntimeInfo)
r.Get("/goroutines", route.home.Goroutines)
r.Get("/health", route.home.Health)
})
r.Route("/task", func(r chi.Router) {
+5
View File
@@ -522,3 +522,8 @@ func (s *HomeService) Goroutines(w http.ResponseWriter, r *http.Request) {
Success(w, goroutines)
}
// Health 返回当前所有健康问题,供前端全局横幅展示
func (s *HomeService) Health(w http.ResponseWriter, r *http.Request) {
Success(w, app.Health.Snapshot())
}
+2
View File
@@ -30,4 +30,6 @@ export default {
runtimeInfo: (): any => http.Get('/home/runtime_info'),
// Goroutine 列表
goroutines: (): any => http.Get('/home/goroutines'),
// 面板健康问题列表(全局横幅使用)
health: (): any => http.Get('/home/health', { meta: { noAlert: true } }),
}
@@ -0,0 +1,72 @@
<script lang="ts" setup>
import { useGettext } from 'vue3-gettext'
import home from '@/api/panel/home'
interface HealthIssue {
key: string
level: 'error' | 'warning'
message: string
since: string
}
const { $gettext } = useGettext()
const { data } = useAutoRequest(() => home.health(), { initialData: [] })
// 前端根据后端稳定 key 选择本地化文案,避免依赖后端错误详情
const titleFor = (issue: HealthIssue): string => {
switch (issue.key) {
case 'database:panel':
return $gettext('Main database write failed, panel may not work properly')
case 'database:stat':
return $gettext('Website statistics database write failed, statistics may be inaccurate')
case 'database:scan':
return $gettext('Scan events database write failed, situation awareness may be inaccurate')
default:
return $gettext('Panel encountered a fault: %{key}', { key: issue.key })
}
}
const hintFor = (issue: HealthIssue): string => {
switch (issue.key) {
case 'database:panel':
case 'database:stat':
case 'database:scan':
return $gettext(
'Try running "acepanel fix" on the server to detect and rebuild the broken database file.'
)
default:
return ''
}
}
// 一次性显示所有问题,最严重的靠前
const sorted = computed<HealthIssue[]>(() => {
const order: Record<string, number> = { error: 0, warning: 1 }
const list = (data.value ?? []) as HealthIssue[]
return [...list].sort((a, b) => (order[a.level] ?? 9) - (order[b.level] ?? 9))
})
</script>
<template>
<div v-if="sorted.length" class="flex flex-col gap-1 border-b border-border-default">
<n-alert
v-for="issue in sorted"
:key="issue.key"
:type="issue.level"
:show-icon="true"
:bordered="false"
class="rounded-none"
>
<div class="flex flex-col">
<span class="font-medium">{{ titleFor(issue) }}</span>
<span v-if="hintFor(issue)" class="text-xs opacity-80">{{ hintFor(issue) }}</span>
<span v-if="issue.message" class="text-xs opacity-60 font-mono">
{{ issue.message }}
</span>
</div>
</n-alert>
</div>
</template>
+3
View File
@@ -1,6 +1,8 @@
<script lang="ts" setup>
import { useThemeStore } from '@/stores'
import HealthBanner from '@/components/system/HealthBanner.vue'
import AppMain from './AppMain.vue'
import AppHeader from './header/IndexView.vue'
import SideBar from './sidebar/IndexView.vue'
@@ -55,6 +57,7 @@ onBeforeUnmount(() => window.removeEventListener('resize', handleResize))
>
<app-header />
</header>
<health-banner />
<section class="bg-bg-base flex flex-col flex-1 overflow-hidden">
<app-main />
</section>