mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-28 17:44:56 +08:00
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:
@@ -12,7 +12,6 @@ func main() {
|
||||
}
|
||||
|
||||
debug.SetGCPercent(10)
|
||||
debug.SetMemoryLimit(256 << 20)
|
||||
|
||||
ace, err := initAce()
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 懒加载防火墙实例
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user