=== 1. 告警阈值第二轮上调(500~5000 这一档仍然太敏感)===
实测业务高峰期这些量级就是常态,阈值贴着基线等于"每天准点报警":
应用错误激增 50000 → 100000(冷却 120m → 240m)
ingress 5xx 500 → 2000 (冷却 240m)
ingress 4xx 5000 → 20000 (冷却 240m)
数据库连接失败 200 → 1000 (冷却 240m)
迁移 v7→v8,只改"仍等于旧默认值"的规则,用户手动调过的不动。
=== 2. 告警风暴抑制:新增全局闸门 ===
单条规则的 cooldown 只能防"同一条重复",挡不住"多条规则同时炸"。
实际踩过:一次存储抖动让 8 条内置规则同时命中,10 秒内群里刷了 8 条,
之后大家开始无视这个群 —— 告警疲劳比漏报更难挽回。
新增 overStormLimit():最近 1 小时内全平台成功发送数 ≥ 12 条时,
后续命中不再发送,只写一条状态为 cooldown 的发送记录并注明
"告警风暴抑制:最近 1 小时已发送 N 条(上限 12)",可见可追溯。
=== 3. 分析页 subtitle 仍是裸 key ===
中文语言包缺顶层 subtitle(英文包有),i18next 找不到就把 key 原样输出。
补齐:「按应用与命名空间看日志量、错误与趋势,找出最吵的业务」。
=== 4. 分析页三条"没有应用维度的数据"的真正原因 ===
「活跃应用」「应用日志量 Top 5」「应用日志量流图」都依赖 by-app 重查询,
而这条查询被**底部排行卡片的可见性**门控(rankingEnabled: rankingView.inView)——
用户不滚到底就永远不发请求,于是顶部三块永远是空的。
改为进页面就发(两阶段渲染:索引类指标先出,app 维度到货后补)。
懒加载只保留在真正位于底部的「每日日志量」「Ingress 分析」。
=== 5. 饼图改 Top 5 + 文案 ===
用户要求:应用日志量 Top 5 用饼图。TOP_SLICES 8 → 5,
标题改「应用日志量 Top 5」,副标题写明"其余归入其他"。
=== 6. 卡片顺序 ===
「每日日志量」从中间移到「应用排行」之后(更靠底部,慢慢加载)。
=== 7. 仪表盘 ===
整个「平台自检」卡片(含所有指标行)按用户要求移除。
=== 实测(2e8c88817b6a)===
v8 迁移:4 条规则阈值上调,日志齐全
当前阈值:FATAL>10 / panic>10 / OOM>5 / StackOverflow>5 /
应用错误>100000 / ingress 5xx>2000 / 4xx>20000 / DB 连接>1000
冷却:低频规则 120m,高频规则 240m
874 lines
27 KiB
Go
874 lines
27 KiB
Go
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)
|
||
}
|
||
}
|
||
|
||
// 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 = 8
|
||
|
||
// 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
|
||
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{},
|
||
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{}),
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
for _, v := range d.DailyVolumeSnapshots {
|
||
if v.ID > d.sequences["dailyVolumeSnapshots"] {
|
||
d.sequences["dailyVolumeSnapshots"] = v.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()
|
||
}
|
||
}
|