Files
LokiStack/backend/repository/db.go
T
opsx fa7ddd67c1 perf(analysis): 分析页按索引/内容分层,告警默认阈值抬高
=== 1. 分析页"一直转圈/请求超时(40000ms)"的真实原因 ===
三个问题叠加,逐个量化:

a) 首屏用空 namespace 发查询
   页面初始 namespace='',等策略返回前就发出 `{namespace=~".+"}` 全集群查询,
   策略到位后又查一次 → 一次访问跑两遍。
   → 加 namespaceReady 门控:解析完再发(effectiveSourceId)。

b) histogram 索引查询和内容查询抢同一个信号量
   /logs/histogram 首选 index/volume_range(毫秒级),但仍要先抢重型令牌,
   而令牌被 4 条 count_over_time(各 14~29s)占满 → 排队/429/超时。
   → 索引路径**不再占用重型令牌**(15s 独立超时),只有回退到
     count_over_time 时才抢令牌。实测各跨度(15m/1h/6h/24h)均 0s。

c) 重查询条数过多
   原来 4 条全局 count_over_time(total/error/warn)+ 2 条 by-app,
   串行 1~2 分钟。现在只留 2 条 by-app:
     - 全局总量已由 histogram(索引)提供
     - 全局 error 由 errByApp 序列求和得出
     - 全局 warn 放弃(优先级最低却要 20s)
   实测 by-app 单条 26s,两条串行 ~52s;主图 0s 先出。

d) 日报 7 天 count_over_time(2.6TB 扫描)必然失败
   → 改走 /logs/histogram(索引)按自然日聚合,只出"每日总量",
     应用维度交给下方 Top 应用表格(有自己的时间范围)。
   实测 7 天跨度毫秒级。

=== 2. 告警默认阈值抬高(告警疲劳治理)===
验证时用 `{namespace="astralx"}` + 阈值 0 触发了一次:
15 分钟命中 3051 万条 —— 阈值 0 等于把告警变成日志流复读机。

新基线(15 分钟窗口,冷却统一 120 分钟):
    致命日志 FATAL           0 → 10
    Go panic                 0 → 10
    JVM OutOfMemoryError     0 → 5
    StackOverflowError       0 → 5
    应用错误激增             1000 → 50000(改名,原"error > 1000"名不副实)
    ingress-nginx 5xx         100 → 500
    ingress-nginx 4xx        1000 → 5000
    数据库连接失败             50 → 200

迁移只改"仍等于旧默认值"的规则,用户手动调过的阈值不动。
设计原则写进 seed 注释:绝不用 threshold 0;宁可少报也不要让人对告警脱敏;
绝对阈值不跨环境通用,大集群必须按 app/namespace 收窄。

=== 实测(v6-1028-2218 / 0bf66199de66)===
  规则 8 条,阈值=0 的规则数 0,冷却全部 120m
  histogram astralx(15m/1h/6h/24h): 0s
  by-app metrics astralx 15m: 26s,正常返回数据
2026-10-28 22:20:19 +00:00

784 lines
24 KiB
Go
Raw 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 repository
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"lokistack/config"
"lokistack/models"
"lokistack/pkg/metrics"
)
// saveRequest 表示一个保存请求,通过channel序列化
type saveRequest struct {
done chan error // 用于同步等待保存完成
}
// JsonDB 使用基于channel的序列化保存机制,防止并发数据丢失。
// 该包只负责持久化(落盘 + 并发安全),领域实体定义在 models 包。
type JsonDB struct {
mu sync.RWMutex
dataFile string
Users []models.User `json:"users"`
Clusters []models.Cluster `json:"clusters"`
Hosts []models.Host `json:"hosts"`
Storages []models.StorageConfig `json:"storages"`
Deployments []models.LokiDeployment `json:"deployments"`
Collectors []models.Collector `json:"collectors"`
Logs []models.DeploymentLog `json:"logs"`
Settings []models.SystemSetting `json:"settings"`
LogSources []models.LogSource `json:"logSources"`
AlertRules []models.AlertRule `json:"alertRules"`
AlertDeliveries []models.AlertDelivery `json:"alertDeliveries"`
NotifyChannels []models.NotifyChannel `json:"notifyChannels"`
LogPolicyGroups []models.LogPolicyGroup `json:"logPolicyGroups"`
// AuditLogs 操作审计日志(保留期 90 天 / 上限 2 万条,见 services.auditRetention)
AuditLogs []models.AuditLog `json:"auditLogs"`
// SchemaVersion 数据结构版本号。
// 之前加字段全靠 JSON 反序列化的零值兜底,字段一多必然出事
// (例如新增 ScrapeJobs 时老数据要手写兼容)。有了版本号,
// 以后的结构变更可以在 InitDB 里按版本号顺序迁移,可测、可回滚。
SchemaVersion int `json:"schemaVersion"`
sequences map[string]uint
// 防抖保存机制
saveTimer *time.Timer
saveMu sync.Mutex
saveChan chan saveRequest
stopChan chan struct{}
wg sync.WaitGroup
}
var db *JsonDB
func GetDB() *JsonDB {
return db
}
// alertThresholdUpgrade 记录"旧种子阈值 → 新基线"的映射。
//
// 只在规则阈值**仍等于旧默认值**时才升级,避免覆盖用户自己调过的值。
type alertThresholdUpgrade struct {
oldName string
newName string
oldThreshold int
newThreshold int
newCooldown int
}
var alertThresholdUpgrades = []alertThresholdUpgrade{
{oldName: "致命日志 FATAL", newName: "致命日志 FATAL", oldThreshold: 0, newThreshold: 10, newCooldown: 120},
{oldName: "Go panic", newName: "Go panic", oldThreshold: 0, newThreshold: 10, newCooldown: 120},
{oldName: "JVM OutOfMemoryError", newName: "JVM OutOfMemoryError", oldThreshold: 0, newThreshold: 5, newCooldown: 120},
{oldName: "StackOverflowError", newName: "StackOverflowError", oldThreshold: 0, newThreshold: 5, newCooldown: 120},
{oldName: "应用错误过多(error > 1000)", newName: "应用错误激增(error > 5万)", oldThreshold: 1000, newThreshold: 50000, newCooldown: 120},
{oldName: "ingress-nginx 5xx 错误", newName: "ingress-nginx 5xx 错误", oldThreshold: 100, newThreshold: 500, newCooldown: 120},
{oldName: "ingress-nginx 4xx 激增", newName: "ingress-nginx 4xx 激增", oldThreshold: 1000, newThreshold: 5000, newCooldown: 120},
{oldName: "数据库连接失败", newName: "数据库连接失败", oldThreshold: 50, newThreshold: 200, newCooldown: 120},
}
// raiseLegacyAlertThresholds 抬高仍是旧默认值的告警阈值与冷却时间。
func (d *JsonDB) raiseLegacyAlertThresholds() {
byName := map[string]alertThresholdUpgrade{}
for _, u := range alertThresholdUpgrades {
byName[u.oldName] = u
}
for i := range d.AlertRules {
rule := &d.AlertRules[i]
u, ok := byName[rule.Name]
if !ok {
continue
}
if rule.Threshold != u.oldThreshold {
// 用户已经手动调过,尊重用户设置
continue
}
old := rule.Threshold
rule.Threshold = u.newThreshold
rule.Name = u.newName
if rule.CooldownMin < u.newCooldown {
rule.CooldownMin = u.newCooldown
}
log.Printf("[schema] 告警规则 %q 阈值 %d → %d(冷却 %dm),降低告警频率",
u.newName, old, u.newThreshold, rule.CooldownMin)
}
}
// fixUnreachableLogSourceURLs 把数据源里 0.0.0.0 / [::] 的监听地址换成主机真实 IP。
//
// 平台进程不在被采集主机上,0.0.0.0 只在本机有意义,用它必然连不上。
func (d *JsonDB) fixUnreachableLogSourceURLs() {
hostIPByID := map[uint]string{}
for _, h := range d.Hosts {
hostIPByID[h.ID] = strings.TrimSpace(h.HostIP)
}
// 数据源按名字对应部署名,用于推断该源属于哪台主机
hostByName := map[string]string{}
for _, dep := range d.Deployments {
if dep.HostID == nil {
continue
}
if ip, ok := hostIPByID[*dep.HostID]; ok && ip != "" {
hostByName[dep.Name] = ip
}
}
for i := range d.LogSources {
src := &d.LogSources[i]
if !strings.Contains(src.URL, "0.0.0.0") && !strings.Contains(src.URL, "[::]") {
continue
}
ip := hostByName[src.Name]
if ip == "" && len(d.Hosts) == 1 {
// 只有一台主机时可以直接推断(常见于单机试用)
ip = strings.TrimSpace(d.Hosts[0].HostIP)
}
if ip == "" {
log.Printf("[schema] 数据源 %q 的 URL 是不可达的监听地址 %s,但无法推断主机 IP,请手动修正", src.Name, src.URL)
continue
}
old := src.URL
src.URL = strings.Replace(src.URL, "0.0.0.0", ip, 1)
src.URL = strings.Replace(src.URL, "[::]", ip, 1)
log.Printf("[schema] 数据源 %q URL 修正:%s → %s", src.Name, old, src.URL)
}
}
// dedupeCollectionIDs 对所有带 ID 的集合做重复 ID 修复。
//
// 注意:实际迁移里按版本分步调用(见 migrateSchema),
// 不要直接用它替代分步逻辑 —— 那会让"已迁移库"漏掉后加的清理步骤。
//
// 背景:这些集合曾经都漏在 InitDB 的 sequences 重建之外,导致 NextID 每次
// 进程重启都从 1 开始,于是不同时期创建的对象会撞号。撞号后的表现各不相同:
//
// LogSource : 查询页数据源下拉选不动(两个 option 同 value)
// AlertRule : 规则列表出现两条同 id,编辑/删除操作命中错的那条
// NotifyChannel: 通知渠道覆盖错对象
func (d *JsonDB) dedupeCollectionIDs() {
d.dedupeLogSourceIDs()
d.dedupeAlertRuleIDs()
d.dedupeNotifyChannelIDs()
}
// dedupeAlertRuleIDs 重新编号重复的告警规则 ID。
// 同名规则(seed 重复执行导致)只保留第一条,其余删除。
func (d *JsonDB) dedupeAlertRuleIDs() {
if len(d.AlertRules) == 0 {
return
}
seenID := map[uint]bool{}
seenName := map[string]bool{}
next := uint(0)
for _, r := range d.AlertRules {
if r.ID > next {
next = r.ID
}
}
fixed := make([]models.AlertRule, 0, len(d.AlertRules))
for i := range d.AlertRules {
rule := d.AlertRules[i]
if rule.Name != "" && seenName[rule.Name] {
log.Printf("[schema] 删除重复的告警规则 %q(同名)", rule.Name)
continue
}
seenName[rule.Name] = true
if rule.ID == 0 || seenID[rule.ID] {
next++
log.Printf("[schema] 告警规则 %q 重复 ID %d → 重新分配为 %d", rule.Name, rule.ID, next)
rule.ID = next
}
seenID[rule.ID] = true
fixed = append(fixed, rule)
}
d.AlertRules = fixed
for _, r := range d.AlertRules {
if r.ID > d.sequences["alertRules"] {
d.sequences["alertRules"] = r.ID
}
}
}
// dedupeNotifyChannelIDs 重新编号重复的通知渠道 ID。
func (d *JsonDB) dedupeNotifyChannelIDs() {
if len(d.NotifyChannels) == 0 {
return
}
seen := map[uint]bool{}
next := uint(0)
for _, c := range d.NotifyChannels {
if c.ID > next {
next = c.ID
}
}
for i := range d.NotifyChannels {
ch := &d.NotifyChannels[i]
if ch.ID == 0 || seen[ch.ID] {
next++
log.Printf("[schema] 通知渠道 %q 重复 ID %d → 重新分配为 %d", ch.Name, ch.ID, next)
ch.ID = next
}
seen[ch.ID] = true
if ch.ID > d.sequences["notifyChannels"] {
d.sequences["notifyChannels"] = ch.ID
}
}
}
// removeInfraAlertRules 删除误配置的「基础设施类」告警规则。
//
// 判据:查询指向 kube-state-metrics / event-exporter,或在日志里匹配
// 节点 NotReady / 容器 OOMKilled。这些是 Prometheus 指标监控的职责:
// - 日志里有没有这行取决于采集器是否采到那些命名空间,不可靠
// - 指标侧(kube_node_status_condition / kube_pod_container_status_terminated_reason)
// 才是权威数据源
func (d *JsonDB) removeInfraAlertRules() {
infraMarkers := []string{"kube-state-metrics", "event-exporter"}
infraNames := []string{"Kubernetes 节点 NotReady", "K8s 节点 NotReady", "容器 OOMKilled"}
kept := make([]models.AlertRule, 0, len(d.AlertRules))
for _, r := range d.AlertRules {
isInfra := false
for _, marker := range infraMarkers {
if strings.Contains(r.Query, marker) {
isInfra = true
}
}
for _, name := range infraNames {
if strings.TrimSpace(r.Name) == name {
isInfra = true
}
}
if isInfra {
log.Printf("[schema] 移除基础设施类告警规则 %q(应交给 Prometheus)", r.Name)
continue
}
kept = append(kept, r)
}
d.AlertRules = kept
}
// dedupeLogSourceIDs 修复历史遗留的重复 ID,并同步更新引用它的策略组。
//
// 策略组通过 SourceID 关联数据源。重复 ID 时无法判断分组到底指向哪一个,
// 这里按"保留第一个、其余顺延"处理,并让原本指向该 ID 的策略组继续指向
// 编号最小的那个(即重建前第一次创建的数据源)。
func (d *JsonDB) dedupeLogSourceIDs() {
if len(d.LogSources) < 2 {
return
}
seen := map[uint]bool{}
remap := map[uint]uint{} // 旧ID → 新ID(仅记录需要改的)
next := uint(0)
for _, s := range d.LogSources {
if s.ID > next {
next = s.ID
}
}
fixed := make([]models.LogSource, 0, len(d.LogSources))
kept := map[uint]bool{} // 已被占用的 ID(首次出现时占位)
for i := range d.LogSources {
src := d.LogSources[i]
if src.ID == 0 || kept[src.ID] {
// 重复或非法 ID:分配新号
next++
old := src.ID
src.ID = next
if _, ok := remap[old]; !ok && old != 0 {
// 同一个旧 ID 只记录一次映射(指向保留的那个)
remap[old] = 0 // 0 表示"不需要改引用"
}
log.Printf("[schema] 数据源 %q 重复 ID %d → 重新分配为 %d", src.Name, old, src.ID)
} else {
kept[src.ID] = true
}
seen[src.ID] = true
fixed = append(fixed, src)
}
d.LogSources = fixed
// 同步策略组引用(只在旧 ID 已不存在时改写)
for i := range d.LogPolicyGroups {
sid := d.LogPolicyGroups[i].SourceID
if sid == 0 || seen[sid] {
continue
}
if nv, ok := remap[sid]; ok && nv != 0 {
d.LogPolicyGroups[i].SourceID = nv
}
}
// 迁移发生在 sequences 重建**之后**,重新分配可能抬高最大 ID,
// 必须同步提升计数器,否则紧接着创建的数据源会撞号。
for _, s := range d.LogSources {
if s.ID > d.sequences["logSources"] {
d.sequences["logSources"] = s.ID
}
}
// 保证只有一个默认数据源
defaultSeen := false
for i := range d.LogSources {
if d.LogSources[i].IsDefault {
if defaultSeen {
d.LogSources[i].IsDefault = false
}
defaultSeen = true
}
}
if !defaultSeen && len(d.LogSources) > 0 {
d.LogSources[0].IsDefault = true
}
}
// CurrentSchemaVersion 当前数据结构版本。
// 每次做**不兼容**的结构变更时 +1,并在 migrateSchema 里补一段迁移逻辑。
//
// v0/v1(无字段)→ 1:新增 auditLogs / schemaVersion(老库走零值兜底即可,无需处理)
// v1 → 2:预留示例(如把 ScrapeJobs 从 logPath 拆分时,需按旧结构生成默认 job)
const CurrentSchemaVersion = 5
// migrateSchema 按版本号顺序迁移老库。返回是否发生了迁移(需要回写)。
func (d *JsonDB) migrateSchema() bool {
if d.SchemaVersion >= CurrentSchemaVersion {
return false
}
from := d.SchemaVersion
// 逐版本递进,每段只处理"上一版 → 下一版"的差异,
// 不要写成 if version < X 的叠加判断,否则分支会指数增长。
for d.SchemaVersion < CurrentSchemaVersion {
switch d.SchemaVersion {
case 0:
// v0 → v1:补审计表;老库没有 auditLogs 字段,反序列化后为 nil,
// 这里显式初始化,避免后续 append 到 nil 之外还留着 nil 判断。
if d.AuditLogs == nil {
d.AuditLogs = []models.AuditLog{}
}
d.SchemaVersion = 1
case 1:
// v1 → v2:修复 LogSource 重复 ID(曾漏在 sequences 重建之外,
// NextID 每次重启从 1 开始,线上出现两个数据源都是 id=1)。
d.dedupeLogSourceIDs()
d.SchemaVersion = 2
case 2:
// v2 → v3:修复 AlertRule / NotifyChannel 重复 ID + 清掉基础设施类规则。
//
// ⚠️ 教训:v2 那次我把"告警去重 + 清基础设施规则"塞进了 case 1,
// 但当时线上库已迁移到 v2,于是这段代码永远不执行(DB 里 schemaVersion
// 已经等于当前版本)。结构变更只要改了逻辑、且目标库已到该版本,
// 就必须**再抬一版**,不能改历史迁移分支。
d.dedupeAlertRuleIDs()
d.dedupeNotifyChannelIDs()
d.removeInfraAlertRules()
d.SchemaVersion = 3
case 3:
// v3 → v4:修复主机模式数据源的不可达 URL。
//
// 主机模式部署的 endpoint 存的是容器监听地址 0.0.0.0:3100,
// 平台把它原样当数据源 URL → 查询页对该数据源的所有请求
// 都报 dial tcp 0.0.0.0:3100: connection refused,
// 前端表现为「命名空间/App 下拉永远加载中」。
d.fixUnreachableLogSourceURLs()
d.SchemaVersion = 4
case 4:
// v4 → v5:抬高内置告警规则的默认阈值。
//
// 旧种子里有 4 条 threshold=0(命中一条就报),另外几条绝对值也偏低:
// 实测某环境 15 分钟 3051 万条日志 → 告警永远在响,运维直接脱敏。
// 只改"仍等于旧默认值"的规则,用户手动调过的阈值不动。
d.raiseLegacyAlertThresholds()
d.SchemaVersion = 5
default:
// 防御:出现未知版本(比当前代码还新)时不乱动,避免降级把数据写坏
log.Printf("[schema] 数据库版本 %d 高于当前代码支持版本 %d,跳过迁移", d.SchemaVersion, CurrentSchemaVersion)
return false
}
}
log.Printf("[schema] 数据结构已从 v%d 迁移到 v%d", from, d.SchemaVersion)
return true
}
func InitDB() error {
cfg := config.GetConfig()
_ = os.MkdirAll(cfg.DataDir, 0700)
d := &JsonDB{
dataFile: cfg.DatabasePath,
Users: []models.User{},
Clusters: []models.Cluster{},
Hosts: []models.Host{},
Storages: []models.StorageConfig{},
Deployments: []models.LokiDeployment{},
Collectors: []models.Collector{},
Logs: []models.DeploymentLog{},
Settings: []models.SystemSetting{},
LogSources: []models.LogSource{},
AlertRules: []models.AlertRule{},
AlertDeliveries: []models.AlertDelivery{},
NotifyChannels: []models.NotifyChannel{},
LogPolicyGroups: []models.LogPolicyGroup{},
AuditLogs: []models.AuditLog{},
SchemaVersion: CurrentSchemaVersion,
sequences: map[string]uint{
"users": 0,
"clusters": 0,
"hosts": 0,
"storages": 0,
"deployments": 0,
"collectors": 0,
"logs": 0,
"logPolicyGroups": 0,
"logSources": 0,
"notifyChannels": 0,
"alertRules": 0,
"alertDeliveries": 0,
"auditLogs": 0,
},
saveChan: make(chan saveRequest, 10),
stopChan: make(chan struct{}),
}
if data, err := os.ReadFile(d.dataFile); err == nil {
if err := json.Unmarshal(data, d); err == nil {
d.sequences = map[string]uint{}
for _, u := range d.Users {
if u.ID > d.sequences["users"] {
d.sequences["users"] = u.ID
}
}
for _, c := range d.Clusters {
if c.ID > d.sequences["clusters"] {
d.sequences["clusters"] = c.ID
}
}
for _, h := range d.Hosts {
if h.ID > d.sequences["hosts"] {
d.sequences["hosts"] = h.ID
}
}
for _, s := range d.Storages {
if s.ID > d.sequences["storages"] {
d.sequences["storages"] = s.ID
}
}
for _, de := range d.Deployments {
if de.ID > d.sequences["deployments"] {
d.sequences["deployments"] = de.ID
}
}
for _, col := range d.Collectors {
if col.ID > d.sequences["collectors"] {
d.sequences["collectors"] = col.ID
}
}
for _, l := range d.Logs {
if l.ID > d.sequences["logs"] {
d.sequences["logs"] = l.ID
}
}
for _, group := range d.LogPolicyGroups {
if group.ID > d.sequences["logPolicyGroups"] {
d.sequences["logPolicyGroups"] = group.ID
}
}
// ⚠️ 以下集合曾经漏在重建之外,导致 NextID 每次重启都从 1 开始,
// 于是不同进程生命周期里创建的对象 ID 重复(线上实际出现过
// 两个 LogSource 都是 id=1 → 前端数据源下拉无法切换)。
// 以后新增任何带 ID 的集合,都必须在这里补一段。
for _, s := range d.LogSources {
if s.ID > d.sequences["logSources"] {
d.sequences["logSources"] = s.ID
}
}
for _, ch := range d.NotifyChannels {
if ch.ID > d.sequences["notifyChannels"] {
d.sequences["notifyChannels"] = ch.ID
}
}
for _, rule := range d.AlertRules {
if rule.ID > d.sequences["alertRules"] {
d.sequences["alertRules"] = rule.ID
}
}
for _, del := range d.AlertDeliveries {
if del.ID > d.sequences["alertDeliveries"] {
d.sequences["alertDeliveries"] = del.ID
}
}
for _, a := range d.AuditLogs {
if a.ID > d.sequences["auditLogs"] {
d.sequences["auditLogs"] = a.ID
}
}
}
}
migratedLogAccess := migrateLogAccessToGroups(d)
// 结构版本迁移(审计表、采集项等新字段的兼容处理)
migratedSchema := d.migrateSchema()
// 启动序列化保存goroutine
d.wg.Add(1)
go d.saveWorker()
db = d
if migratedLogAccess || migratedSchema {
d.RequestSave()
}
return nil
}
// Legacy per-user grants are grouped by identical source and label scope.
func migrateLogAccessToGroups(d *JsonDB) bool {
changed := false
for i := range d.LogSources {
source := &d.LogSources[i]
if len(source.Access) == 0 {
continue
}
byLabels := map[string]*models.LogPolicyGroup{}
for _, rule := range source.Access {
validUser := false
for _, user := range d.Users {
if user.ID == rule.UserID && user.Role != "admin" {
validUser = true
break
}
}
if !validUser {
continue
}
alreadyGrouped := false
for _, group := range d.LogPolicyGroups {
if group.SourceID != source.ID {
continue
}
for _, id := range group.UserIDs {
if id == rule.UserID {
alreadyGrouped = true
break
}
}
}
if alreadyGrouped {
continue
}
keyBytes, _ := json.Marshal(rule.Labels)
key := string(keyBytes)
group := byLabels[key]
if group == nil {
d.sequences["logPolicyGroups"]++
id := d.sequences["logPolicyGroups"]
group = &models.LogPolicyGroup{ID: id, Name: fmt.Sprintf("旧授权 · %s · %d", source.Name, id), SourceID: source.ID, Labels: rule.Labels, CreatedAt: time.Now(), UpdatedAt: time.Now()}
byLabels[key] = group
}
group.UserIDs = append(group.UserIDs, rule.UserID)
}
for _, group := range byLabels {
d.LogPolicyGroups = append(d.LogPolicyGroups, *group)
}
source.Access = nil
changed = true
}
return changed
}
// Shutdown 优雅关闭数据库,确保所有数据已落盘。
// 顺序很关键:先停防抖定时器(不再产生新请求)→ 关闭 worker 并等其退出
// (worker 可能正在处理已入队的请求)→ 此时已无并发,做最后一次 doSave 把
// 最新内存状态原子写入。注意绝不能在 worker 仍在运行时直接 Save(),否则
// 两个 doSave 并发改写同一文件会发生"后写覆盖先写"的丢更新。
func Shutdown() error {
if db == nil {
return nil
}
// 1. 取消防抖定时器,阻止新的保存请求进入 channel
db.saveMu.Lock()
if db.saveTimer != nil {
db.saveTimer.Stop()
db.saveTimer = nil
}
db.saveMu.Unlock()
// 2. 通知 worker 停止并等待其排空 saveChan、完成进行中的 doSave 后退出
close(db.stopChan)
db.wg.Wait()
// 3. worker 已退出,无并发,落盘最终状态
if err := db.doSave(); err != nil {
return fmt.Errorf("final save failed: %w", err)
}
return nil
}
// saveWorker 通过channel序列化所有保存操作,防止并发写入冲突
func (d *JsonDB) saveWorker() {
defer d.wg.Done()
for {
select {
case req, ok := <-d.saveChan:
if !ok {
return
}
err := d.doSave()
if req.done != nil {
req.done <- err
}
case <-d.stopChan:
return
}
}
}
// doSave 执行实际的原子文件写入
func (d *JsonDB) doSave() error {
start := time.Now()
err := d.doSaveInner()
// JSON DB 是全量写盘,落盘耗时是平台最主要的延迟来源之一,必须可观测
metrics.ObserveDBSave(time.Since(start).Seconds(), err == nil)
return err
}
func (d *JsonDB) doSaveInner() error {
d.mu.RLock()
data, err := json.MarshalIndent(d, "", " ")
d.mu.RUnlock()
if err != nil {
return err
}
// 原子写入:先写入临时文件,再重命名
dir := filepath.Dir(d.dataFile)
tmpFile := filepath.Join(dir, fmt.Sprintf(".lokistack-%d.tmp", time.Now().UnixNano()))
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
return err
}
// 确保数据落盘
f, err := os.Open(tmpFile)
if err != nil {
os.Remove(tmpFile)
return err
}
_ = f.Sync()
f.Close()
if err := os.Rename(tmpFile, d.dataFile); err != nil {
os.Remove(tmpFile)
return err
}
metrics.DBSizeBytes.Set(float64(len(data)))
return nil
}
// Save 同步保存数据库到文件(原子写入)
func (d *JsonDB) Save() error {
return d.doSave()
}
// RequestSave 请求异步保存(防抖机制+序列化channel)
// 多次快速调用会在500ms内合并为一次保存
func (d *JsonDB) RequestSave() {
d.saveMu.Lock()
defer d.saveMu.Unlock()
// 如果已有待触发的定时器,重置它(防抖)
if d.saveTimer != nil {
d.saveTimer.Stop()
}
d.saveTimer = time.AfterFunc(500*time.Millisecond, func() {
d.saveMu.Lock()
d.saveTimer = nil
d.saveMu.Unlock()
// 通过channel发送到保存worker,确保序列化
select {
case d.saveChan <- saveRequest{}:
case <-d.stopChan:
}
})
}
// RequestSaveSync 请求同步保存,等待保存完成(用于shutdown场景)
func (d *JsonDB) RequestSaveSync() error {
req := saveRequest{done: make(chan error, 1)}
select {
case d.saveChan <- req:
case <-d.stopChan:
return d.Save() // worker已停止,直接同步保存
}
return <-req.done
}
func (d *JsonDB) NextID(table string) uint {
d.mu.Lock()
defer d.mu.Unlock()
d.sequences[table]++
return d.sequences[table]
}
func (d *JsonDB) Lock() {
d.mu.Lock()
}
func (d *JsonDB) Unlock() {
d.mu.Unlock()
}
func (d *JsonDB) RLock() {
d.mu.RLock()
}
func (d *JsonDB) RUnlock() {
d.mu.RUnlock()
}
// CreateUser 创建用户并触发防抖保存
func (d *JsonDB) CreateUser(user *models.User) error {
d.mu.Lock()
defer d.mu.Unlock()
if user.ID == 0 {
user.ID = d.sequences["users"] + 1
d.sequences["users"] = user.ID
}
if user.CreatedAt.IsZero() {
now := time.Now()
user.CreatedAt = now
user.UpdatedAt = now
}
d.Users = append(d.Users, *user)
d.RequestSave()
return nil
}
// RecoverOrphanDeployments 启动时把残留的 deploying 状态部署标记为失败:
// 部署 goroutine 随进程消亡,不恢复会永久卡在「部署中」(UI 无停止入口)。
func (d *JsonDB) RecoverOrphanDeployments() {
d.Lock()
defer d.Unlock()
recovered := 0
for i := range d.Deployments {
if d.Deployments[i].Status == "deploying" {
d.Deployments[i].Status = "failed"
d.Deployments[i].StatusMessage = "服务重启导致部署中断,请重新部署"
d.Deployments[i].UpdatedAt = time.Now()
recovered++
}
}
if recovered > 0 {
d.RequestSave()
}
}