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"` // DailyVolumeSnapshots 每日日志量历史(Loki retention 只有 24h,历史必须自己留) DailyVolumeSnapshots []models.DailyVolumeSnapshot `json:"dailyVolumeSnapshots"` // 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}, // 第二轮上调(见 v8 迁移注释) {oldName: "应用错误激增(error > 5万)", newName: "应用错误激增(error > 10万)", oldThreshold: 50000, newThreshold: 100000, newCooldown: 240}, {oldName: "ingress-nginx 5xx 错误", newName: "ingress-nginx 5xx 错误", oldThreshold: 500, newThreshold: 2000, newCooldown: 240}, {oldName: "ingress-nginx 4xx 激增", newName: "ingress-nginx 4xx 激增", oldThreshold: 5000, newThreshold: 20000, newCooldown: 240}, {oldName: "数据库连接失败", newName: "数据库连接失败", oldThreshold: 200, newThreshold: 1000, newCooldown: 240}, } // 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) } } // expandDatabaseErrorPattern 把内置「数据库连接失败」规则升级为覆盖 // MySQL / PostgreSQL / Redis 的主流报错文案。 // // 只替换"仍是旧默认查询"的规则,不覆盖用户自己改过的。 func (d *JsonDB) expandDatabaseErrorPattern() { const oldQuery = `{namespace=~".+"} |~ "(?i)(connection refused|connection reset by peer|too many connections)"` // 见 seed/alert_rules.go 里的同名规则,两处必须保持一致 const newQuery = `{namespace=~".+"} |~ "(?i)(connection refused|connection reset by peer|broken pipe|no route to host|cannot assign requested address|connection timed out|i/o timeout|too many connections|too many clients already|max number of clients reached|connection pool|connection is not available|could not create connection|communications link failure|server closed the connection unexpectedly|unexpected EOF|driver: bad connection|FATAL:\\s*(password authentication failed|sorry, too many clients|terminating connection)|NOAUTH|WRONGPASS|READONLY you can.t write|CLUSTERDOWN)"` for i := range d.AlertRules { if d.AlertRules[i].Query == oldQuery { log.Printf("[schema] 告警规则 %q 扩展数据库报错匹配模式", d.AlertRules[i].Name) d.AlertRules[i].Query = newQuery } } } // normalizeDefaultChannelName 把默认通道的名字统一成「默认告警」。 // // 只改名,不动 Webhook 与 enabled:首次部署的占位通道必须保持"空地址 + 禁用", // 让管理员自己填(避免填了个空地址的启用通道,告警一命中就全部投递失败)。 func (d *JsonDB) normalizeDefaultChannelName() { const canonical = "默认告警" legacyNames := map[string]bool{ "lark 默认告警": true, "默认通知通道(待配置 Webhook)": true, "默认通知通道": true, } for i := range d.NotifyChannels { ch := &d.NotifyChannels[i] if legacyNames[ch.Name] { log.Printf("[schema] 通知通道 %q 改名为 %q", ch.Name, canonical) ch.Name = canonical ch.UpdatedAt = time.Now() } } } // removeOrphanManagedLogSources 清掉"部署已不存在、但数据源还在"的孤儿记录。 // // 判据:Managed=true(平台托管)且没有任何同名部署。 // 连带:删掉指向它的查询授权策略组,并禁用绑定它的告警规则。 func (d *JsonDB) removeOrphanManagedLogSources() { alive := map[string]bool{} for _, dep := range d.Deployments { alive[dep.Name] = true } orphans := map[uint]bool{} kept := make([]models.LogSource, 0, len(d.LogSources)) for _, src := range d.LogSources { if src.Managed && !alive[src.Name] { orphans[src.ID] = true log.Printf("[schema] 移除孤儿数据源 %q(id=%d):对应部署已删除", src.Name, src.ID) continue } kept = append(kept, src) } if len(orphans) == 0 { return } d.LogSources = kept groups := make([]models.LogPolicyGroup, 0, len(d.LogPolicyGroups)) for _, group := range d.LogPolicyGroups { if orphans[group.SourceID] { log.Printf("[schema] 移除指向孤儿数据源的策略组 %q", group.Name) continue } groups = append(groups, group) } d.LogPolicyGroups = groups for i := range d.AlertRules { if orphans[d.AlertRules[i].SourceID] { d.AlertRules[i].Enabled = false log.Printf("[schema] 告警规则 %q 的数据源已删除,规则已禁用", d.AlertRules[i].Name) } } } // linkRulesToDefaultChannel 给未关联通道的规则挂上默认通道。 // // 没有通道时不凭空造(那是 seed 首次部署的职责);这里只在已有通道时补齐关联。 func (d *JsonDB) linkRulesToDefaultChannel() { if len(d.NotifyChannels) == 0 { return } def := d.NotifyChannels[0].ID linked := 0 for i := range d.AlertRules { if len(d.AlertRules[i].ChannelIDs) == 0 { d.AlertRules[i].ChannelIDs = []uint{def} linked++ } } if linked > 0 { log.Printf("[schema] %d 条告警规则已关联默认通知通道 %q", linked, d.NotifyChannels[0].Name) } } // 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 = 10 // 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 case 5: // v5 → v6:把"没有关联任何通道"的告警规则挂到默认通道。 // // 之前内置规则 ChannelIDs 全是空的,规则命中后只能去回退旧的全局 // notify-config;那个早就不用了,结果就是"规则在响但哪里都没发出去"。 d.linkRulesToDefaultChannel() d.SchemaVersion = 6 case 6: // v6 → v7:清理"孤儿数据源"。 // 删除部署时曾漏掉对自动注册数据源的清理,导致查询页残留指向 // 已删实例的数据源(线上残留过 host-verify-loki)。 d.removeOrphanManagedLogSources() d.SchemaVersion = 7 case 7: // v7 → v8:第二轮上调告警阈值 + 拉长冷却,抑制告警风暴。 // 500~5000 这一档实测在业务高峰是常态量,贴着基线就等于天天报。 d.raiseLegacyAlertThresholds() d.SchemaVersion = 8 case 8: // v8 → v9:通知通道统一改名为「默认告警」。 // 首次部署创建的占位通道保持**禁用 + 空 Webhook**(由管理员自己填), // 这里只改名字,不动 Webhook、不动启用状态。 d.normalizeDefaultChannelName() d.SchemaVersion = 9 case 9: // v9 → v10:内置「数据库连接失败」规则扩展报错模式 // (原来只匹配 3 个词,漏掉 PG 的 FATAL: too many clients、 // Redis 的 NOAUTH / WRONGPASS / connection pool timeout 等常见报错) d.expandDatabaseErrorPattern() d.SchemaVersion = 10 default: // 防御:出现未知版本(比当前代码还新)时不乱动,避免降级把数据写坏 log.Printf("[schema] 数据库版本 %d 高于当前代码支持版本 %d,跳过迁移", d.SchemaVersion, CurrentSchemaVersion) return false } } log.Printf("[schema] 数据结构已从 v%d 迁移到 v%d", from, d.SchemaVersion) return true } // migrateLegacyDataFile 首次从历史命名(opsxloki.json / lokideploy.json)加载后, // 立即升级到 lokistack.json:数据原子写入新文件后删除旧文件。 // 不迁移的话回退链会让旧文件名(旧品牌残留)一直被沿用下去。 func (d *JsonDB) migrateLegacyDataFile() { const newName = "lokistack.json" if filepath.Base(d.dataFile) == newName { return } legacyPath := d.dataFile newPath := filepath.Join(filepath.Dir(legacyPath), newName) if _, err := os.Stat(newPath); err == nil { // 新旧并存属异常态:不覆盖任何一边,沿用旧路径并留日志交人工判断 log.Printf("[db] 检测到 %s 与 %s 并存,沿用 %s(请人工确认)", newPath, legacyPath, legacyPath) return } d.dataFile = newPath if err := d.doSave(); err != nil { d.dataFile = legacyPath // 写新文件失败:退回旧路径,下次启动再试 log.Printf("[ERROR] [db] 迁移数据文件到 %s 失败(%v),继续使用 %s", newPath, err, legacyPath) return } if err := os.Remove(legacyPath); err != nil { log.Printf("[db] 数据已写入 %s,旧文件 %s 删除失败(%v),可手工清理", newPath, legacyPath, err) return } log.Printf("[db] 数据文件已从 %s 升级为 %s", legacyPath, newPath) } // loadFromDisk 读取并解析数据文件,重建 ID 序列;文件损坏时备份留档绝不覆盖。 func (d *JsonDB) loadFromDisk() { if data, err := os.ReadFile(d.dataFile); err == nil { if err := json.Unmarshal(data, d); err != nil { // 损坏的数据文件可能已被部分填充,绝不能让随后的空库落盘覆盖原文件: // 先把原文件改名留档,再清回空库继续启动(seed 会重建 admin)。 // 改名失败(权限/只读)时宁可退出,也不销毁可能可恢复的数据。 backup := fmt.Sprintf("%s.corrupt-%s", d.dataFile, time.Now().Format("20060102-150405")) if renameErr := os.Rename(d.dataFile, backup); renameErr != nil { log.Fatalf("[db] 数据文件 %s 解析失败(%v)且无法备份(%v),为避免覆盖可恢复数据进程退出,请人工处理", d.dataFile, err, renameErr) } log.Printf("[ERROR] [db] 数据文件 %s 解析失败(%v),原文件已备份为 %s,以空库继续启动", d.dataFile, err, backup) d.Users, d.Clusters, d.Hosts, d.Storages = nil, nil, nil, nil d.Deployments, d.Collectors, d.Logs, d.Settings = nil, nil, nil, nil d.LogSources, d.AlertRules, d.AlertDeliveries = nil, nil, nil d.NotifyChannels, d.LogPolicyGroups, d.AuditLogs = nil, nil, nil d.DailyVolumeSnapshots = nil d.SchemaVersion = 0 } else { 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 } } for _, v := range d.DailyVolumeSnapshots { if v.ID > d.sequences["dailyVolumeSnapshots"] { d.sequences["dailyVolumeSnapshots"] = v.ID } } } } } 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{}, DailyVolumeSnapshots: []models.DailyVolumeSnapshot{}, 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, "dailyVolumeSnapshots": 0, }, saveChan: make(chan saveRequest, 10), stopChan: make(chan struct{}), } d.loadFromDisk() d.migrateLegacyDataFile() 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() } }