Skip to content

Commit 9a0a036

Browse files
author
nodedata
committed
v3.1.1: 首屏关键指标对比(当前 vs 1h/6h/12h/1d/3d/7d);留证按类别限流;faultlab 核对类别
- /api/compare + 首屏对比表:人话名称、历史值精确取、无历史显示—不当 0、多盘多网卡逐设备 - 留证:全局 1 分钟限流改为按类别(实测 IO 现场被先到的 CPU 证据挡掉);空字段 [] 不是 null - faultlab 证据核对同时比对类别与 PID;gofmt
1 parent e7d74cb commit 9a0a036

10 files changed

Lines changed: 365 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# nodedata Changelog
22

3+
## [v3.1.1] — 2026-09-11 · 首屏"关键指标对比";留证按类别限流
4+
5+
### 首屏:关键指标对比
6+
人话名称 + 当前值 + 1h / 6h / 12h / 1d / 3d / 7d 前同一时刻的值 + 变化百分比,按 CPU / 内存 / 磁盘 / 网络分组;
7+
多块盘、多张网卡时逐设备各一行。历史值服务端精确取(24h 内原始层,更早 5 分钟长期层),
8+
没有历史返回 null、页面显示"—"(不当作 0);本机没有的指标整行不显示;
9+
红色只标"往坏方向变化 ≥20%"(可用内存往下为坏)。`GET /api/compare`
10+
z 热力图与诊断链移到下方,回答"变化是否异常、谁干的"。
11+
12+
### 事故留证
13+
- 限流由"全局 1 分钟"改为"同一类别 1 分钟"。实机注入发现:IO 故障先触发一份 CPU 证据(写入本身吃 CPU),
14+
全局限流挡掉了紧随其后的 IO 结论,真正的 IO 现场没留下;内存证据被推迟到检查窗口之外;
15+
- 证据里空字段输出 `[]` 而不是 `null`
16+
- faultlab 核对证据时同时比对类别与 PID(此前只比 PID,把 CPU 类证据误算成 IO 场景通过)。
17+
318
## [v3.1.0] — 2026-09-11 · 归因、留证、语料库(物理机 / 虚机)
419

520
### L1:采集维度与数值修正

cmd/faultlab/main.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,12 @@ func main() {
105105
r.evidence = "缺失"
106106
var list []struct {
107107
ID string `json:"id"`
108+
Class string `json:"class"`
108109
Culprit *culprit `json:"culprit"`
109110
}
110111
if getJSON(*url+"/api/incidents", &list) == nil {
111-
for _, m := range list {
112-
if m.Culprit != nil && m.Culprit.PID == pid {
112+
for _, m := range list { // 类别与 PID 都要对上
113+
if m.Class == class && m.Culprit != nil && m.Culprit.PID == pid {
113114
r.evidence = m.ID
114115
break
115116
}

cmd/nodedata/compare.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// compare.go — 关键指标对比:人话名称,当前值与 1h/6h/12h/1d/3d/7d 前的值并排,附变化百分比。
2+
//
3+
// 首屏不再要求读者先懂 σ:先回答"现在和平时比变了什么",z 值热力图与诊断链在下面给出"变化是否异常、谁干的"。
4+
// 历史值用 Series.Lookup 精确取:24 小时内来自原始层,更早来自 5 分钟长期层。
5+
// 没有历史时返回 null(页面显示"—"),绝不用 0 冒充。
6+
package main
7+
8+
import (
9+
"math"
10+
"runtime"
11+
"sort"
12+
"strings"
13+
"time"
14+
)
15+
16+
var compareCols = []struct {
17+
Name string
18+
Ago time.Duration
19+
}{
20+
{"1h", time.Hour}, {"6h", 6 * time.Hour}, {"12h", 12 * time.Hour},
21+
{"1d", 24 * time.Hour}, {"3d", 72 * time.Hour}, {"7d", 7 * 24 * time.Hour},
22+
}
23+
24+
type cmpRow struct {
25+
Label string `json:"label"`
26+
Unit string `json:"unit"`
27+
Now *float64 `json:"now"`
28+
Past []*float64 `json:"past"`
29+
Bad int `json:"bad"` // +1 往上是坏,-1 往下是坏,0 无所谓好坏
30+
ID string `json:"id"`
31+
}
32+
33+
type cmpGroup struct {
34+
Name string `json:"name"`
35+
Rows []cmpRow `json:"rows"`
36+
}
37+
38+
type CompareJSON struct {
39+
At int64 `json:"at"`
40+
Cols []string `json:"cols"`
41+
Groups []cmpGroup `json:"groups"`
42+
}
43+
44+
// cmpDef:一行的定义。ids 多于一个时按 f 合成(如 CPU 忙碌 = user+sys+softirq,按核数归一)。
45+
type cmpDef struct {
46+
label, unit string
47+
bad int
48+
ids []string
49+
f func(v []float64) float64
50+
}
51+
52+
func one(label, unit string, bad int, id string) cmpDef {
53+
return cmpDef{label: label, unit: unit, bad: bad, ids: []string{id}}
54+
}
55+
56+
func perCPU(label string, ids ...string) cmpDef {
57+
n := float64(runtime.NumCPU())
58+
return cmpDef{label: label, unit: "percent", bad: 1, ids: ids, f: func(v []float64) float64 {
59+
s := 0.0
60+
for _, x := range v {
61+
s += x
62+
}
63+
return s / n // cpu.* 是"占一个核的百分比"之和,除以核数得整机百分比
64+
}}
65+
}
66+
67+
var compareDefs = []struct {
68+
name string
69+
rows []cmpDef
70+
}{
71+
{"CPU", []cmpDef{
72+
perCPU("CPU 忙碌 %", "cpu.user", "cpu.sys", "cpu.softirq"),
73+
perCPU("iowait %", "cpu.iowait"),
74+
perCPU("steal %(虚机被宿主机抢占)", "cpu.steal"),
75+
one("1 分钟负载", "load", 1, "loadavg.1m"),
76+
one("运行队列", "count", 1, "procs_running"),
77+
one("CPU 压力 PSI %", "percent", 1, "psi.cpu.some10"),
78+
one("上下文切换/秒", "/s", 0, "ctxt"),
79+
one("中断/秒", "/s", 0, "intr"),
80+
}},
81+
{"内存", []cmpDef{
82+
one("可用内存", "bytes", -1, "mem.available"),
83+
one("页缓存", "bytes", 0, "mem.cached"),
84+
one("脏页", "bytes", 1, "mem.dirty"),
85+
one("内核 slab", "bytes", 1, "slab"),
86+
one("swap 使用", "bytes", 1, "swap.used"),
87+
one("内存压力 PSI %", "percent", 1, "psi.mem.some10"),
88+
one("主缺页/秒", "/s", 1, "pgmajfault"),
89+
}},
90+
{"磁盘", []cmpDef{
91+
one("最忙盘 util %", "percent", 1, "disk.util"),
92+
one("读延迟", "ms", 1, "disk.await_r"),
93+
one("写延迟", "ms", 1, "disk.await_w"),
94+
one("读 IOPS", "/s", 0, "disk.riops"),
95+
one("写 IOPS", "/s", 0, "disk.wiops"),
96+
one("读吞吐", "bytes/s", 0, "disk.rbytes"),
97+
one("写吞吐", "bytes/s", 0, "disk.wbytes"),
98+
one("IO 压力 PSI %", "percent", 1, "psi.io.some10"),
99+
one("D 状态进程", "count", 1, "procs_blocked"),
100+
}},
101+
{"网络", []cmpDef{
102+
one("网卡入向", "bytes/s", 0, "net.rx"),
103+
one("网卡出向", "bytes/s", 0, "net.tx"),
104+
one("收包/秒", "/s", 0, "net.rx_pps"),
105+
one("发包/秒", "/s", 0, "net.tx_pps"),
106+
one("收丢包/秒", "/s", 1, "net.rx_drop"),
107+
one("发丢包/秒", "/s", 1, "net.tx_drop"),
108+
one("TCP 重传/秒", "/s", 1, "tcp.retrans"),
109+
one("conntrack 条目", "count", 1, "conntrack"),
110+
}},
111+
}
112+
113+
// lookupTol:历史值的时间容差,约为回看时长的 1%,夹在 30 秒到 10 分钟之间。
114+
func lookupTol(ago time.Duration) time.Duration {
115+
t := ago / 100
116+
if t < 30*time.Second {
117+
t = 30 * time.Second
118+
}
119+
if t > 10*time.Minute {
120+
t = 10 * time.Minute
121+
}
122+
return t
123+
}
124+
125+
func (b *HeatmapBuilder) valueAt(d cmpDef, at time.Time, tol time.Duration, current bool) *float64 {
126+
vals := make([]float64, 0, len(d.ids))
127+
for _, id := range d.ids {
128+
var v float64
129+
var ok bool
130+
if current {
131+
var p point
132+
if p, ok = b.series.Last(id); ok && at.Sub(p.TS) > 2*time.Minute {
133+
ok = false // 最后一个点太旧:该指标已停止上报
134+
}
135+
v = p.V
136+
} else {
137+
v, ok = b.series.Lookup(id, at, tol)
138+
}
139+
if !ok || math.IsNaN(v) || math.IsInf(v, 0) {
140+
return nil
141+
}
142+
vals = append(vals, v)
143+
}
144+
x := vals[0]
145+
if d.f != nil {
146+
x = d.f(vals)
147+
}
148+
return &x
149+
}
150+
151+
// Compare 生成对比表。多块盘 / 多张网卡时追加逐设备的行。
152+
func (b *HeatmapBuilder) Compare(now time.Time) *CompareJSON {
153+
out := &CompareJSON{At: now.Unix()}
154+
for _, c := range compareCols {
155+
out.Cols = append(out.Cols, c.Name)
156+
}
157+
ids := b.series.MetricIDs()
158+
perDev := func(prefix, label, unit string, bad int) []cmpDef {
159+
var devs []string
160+
for _, id := range ids {
161+
if strings.HasPrefix(id, prefix) {
162+
devs = append(devs, strings.TrimPrefix(id, prefix))
163+
}
164+
}
165+
if len(devs) < 2 { // 只有一块盘/一张网卡时整机行已经说明一切
166+
return nil
167+
}
168+
sort.Strings(devs)
169+
var rows []cmpDef
170+
for _, d := range devs {
171+
rows = append(rows, one(label+" · "+d, unit, bad, prefix+d))
172+
}
173+
return rows
174+
}
175+
for _, g := range compareDefs {
176+
defs := g.rows
177+
switch g.name {
178+
case "磁盘":
179+
defs = append(append([]cmpDef(nil), defs...), perDev("disk.util@", "util %", "percent", 1)...)
180+
defs = append(defs, perDev("disk.await_w@", "写延迟", "ms", 1)...)
181+
case "网络":
182+
defs = append(append([]cmpDef(nil), defs...), perDev("net.rx@", "入向", "bytes/s", 0)...)
183+
defs = append(defs, perDev("net.tx@", "出向", "bytes/s", 0)...)
184+
defs = append(defs, perDev("net.rx_drop@", "收丢包/秒", "/s", 1)...)
185+
}
186+
grp := cmpGroup{Name: g.name}
187+
for _, d := range defs {
188+
row := cmpRow{Label: d.label, Unit: d.unit, Bad: d.bad, ID: strings.Join(d.ids, "+")}
189+
if row.Now = b.valueAt(d, now, 0, true); row.Now == nil {
190+
continue // 这台机器没有这项(比如没有 PSI、没有 conntrack):整行不显示
191+
}
192+
for _, c := range compareCols {
193+
row.Past = append(row.Past, b.valueAt(d, now.Add(-c.Ago), lookupTol(c.Ago), false))
194+
}
195+
grp.Rows = append(grp.Rows, row)
196+
}
197+
if len(grp.Rows) > 0 {
198+
out.Groups = append(out.Groups, grp)
199+
}
200+
}
201+
return out
202+
}

cmd/nodedata/compare_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package main
2+
3+
import (
4+
"runtime"
5+
"testing"
6+
"time"
7+
8+
"github.com/githubflyideas/nodedata/internal/collector"
9+
)
10+
11+
func TestCompare(t *testing.T) {
12+
s := NewSeries()
13+
now := time.Date(2026, 9, 11, 14, 0, 0, 0, time.UTC)
14+
// 长期层:8 天前到 25 小时前,mem.available 恒为 8GiB;原始层:最近 24 小时为 4GiB
15+
for ts := now.Add(-8 * 24 * time.Hour); ts.Before(now.Add(-25 * time.Hour)); ts = ts.Add(coarseStep) {
16+
s.AddCoarse([]collector.Sample{{MetricID: "mem.available", TS: ts, Value: 8 << 30}})
17+
}
18+
for ts := now.Add(-24 * time.Hour); !ts.After(now); ts = ts.Add(5 * time.Second) {
19+
s.Add([]collector.Sample{
20+
{MetricID: "mem.available", TS: ts, Value: 4 << 30},
21+
{MetricID: "cpu.user", TS: ts, Value: 100}, {MetricID: "cpu.sys", TS: ts, Value: 50}, {MetricID: "cpu.softirq", TS: ts, Value: 10},
22+
{MetricID: "disk.util", TS: ts, Value: 30}, {MetricID: "disk.util@sda", TS: ts, Value: 30},
23+
{MetricID: "net.rx", TS: ts, Value: 1000}, {MetricID: "net.rx@eth0", TS: ts, Value: 1000},
24+
})
25+
}
26+
// sdb 只在最近 2 小时出现
27+
for ts := now.Add(-2 * time.Hour); !ts.After(now); ts = ts.Add(5 * time.Second) {
28+
s.Add([]collector.Sample{{MetricID: "disk.util@sdb", TS: ts, Value: 90}})
29+
}
30+
c := NewHeatmapBuilder(s).Compare(now)
31+
rows := map[string]cmpRow{}
32+
for _, g := range c.Groups {
33+
for _, r := range g.Rows {
34+
rows[r.Label] = r
35+
}
36+
}
37+
mem := rows["可用内存"]
38+
if *mem.Now != 4<<30 || *mem.Past[0] != 4<<30 || mem.Past[3] == nil || *mem.Past[4] != 8<<30 || *mem.Past[5] != 8<<30 {
39+
t.Fatalf("mem.available past values wrong: %+v", mem.Past)
40+
}
41+
busy := rows["CPU 忙碌 %"]
42+
if want := 160 / float64(runtime.NumCPU()); *busy.Now != want {
43+
t.Fatalf("CPU 忙碌 = %v, want (user+sys+softirq)/NumCPU = %v", *busy.Now, want)
44+
}
45+
if _, ok := rows["swap 使用"]; ok {
46+
t.Fatalf("metric absent on this host must be omitted, not shown as 0")
47+
}
48+
sdb, ok := rows["util % · sdb"]
49+
if !ok || *sdb.Now != 90 || sdb.Past[1] != nil {
50+
t.Fatalf("per-disk row: ok=%v %+v(6h 前 sdb 不存在,必须是 null 而不是 0)", ok, sdb.Past)
51+
}
52+
if _, ok := rows["入向 · eth0"]; ok {
53+
t.Fatalf("single NIC must not get per-NIC rows")
54+
}
55+
if len(c.Cols) != 6 || c.Cols[0] != "1h" || c.Cols[5] != "7d" {
56+
t.Fatalf("cols = %v", c.Cols)
57+
}
58+
}

cmd/nodedata/incident.go

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import (
2626
const (
2727
incidentKeep = 100
2828
incidentSigCool = 30 * time.Minute // 同一类 + 同一责任方
29-
incidentMinGap = time.Minute // 任意两次之间
29+
incidentMinGap = time.Minute // 同一类别任意两次之间(不同类别互不阻挡)
3030
incidentThreadTop = 10
3131
incidentStackDepth = 24
3232
incidentKmsgLines = 100
@@ -42,9 +42,9 @@ type Recorder struct {
4242
procs func() []diagnosis.Proc
4343
devs func() []diagnosis.Deviation
4444

45-
mu sync.Mutex
46-
lastSig map[string]time.Time
47-
lastAny time.Time
45+
mu sync.Mutex
46+
lastSig map[string]time.Time
47+
lastClass map[string]time.Time
4848
}
4949

5050
func NewRecorder(dir, procRoot string, run func() *diagnosis.Chain, procs func() []diagnosis.Proc, devs func() []diagnosis.Deviation) (*Recorder, error) {
@@ -53,7 +53,7 @@ func NewRecorder(dir, procRoot string, run func() *diagnosis.Chain, procs func()
5353
}
5454
host, _ := os.Hostname()
5555
return &Recorder{dir: dir, procRoot: procRoot, kmsgPath: "/dev/kmsg", host: host,
56-
run: run, procs: procs, devs: devs, lastSig: map[string]time.Time{}}, nil
56+
run: run, procs: procs, devs: devs, lastSig: map[string]time.Time{}, lastClass: map[string]time.Time{}}, nil
5757
}
5858

5959
// Loop 周期性执行 L4;与页面是否打开无关。
@@ -92,9 +92,11 @@ func (r *Recorder) Tick(now time.Time) []string {
9292
}
9393
sig := signature(it)
9494
r.mu.Lock()
95-
cool := now.Sub(r.lastSig[sig]) < incidentSigCool || now.Sub(r.lastAny) < incidentMinGap
95+
// 按类别限流:实测里 IO 故障先触发一份 CPU 证据(写入本身吃 CPU),
96+
// 若用全局间隔,紧随其后的 IO 结论会被挡掉,真正的 IO 现场就丢了。
97+
cool := now.Sub(r.lastSig[sig]) < incidentSigCool || now.Sub(r.lastClass[it.Class]) < incidentMinGap
9698
if !cool {
97-
r.lastSig[sig], r.lastAny = now, now
99+
r.lastSig[sig], r.lastClass[it.Class] = now, now
98100
}
99101
r.mu.Unlock()
100102
if cool {
@@ -165,9 +167,12 @@ func (r *Recorder) Capture(trigger diagnosis.Item, chain *diagnosis.Chain, now t
165167
}
166168
id := now.UTC().Format("20060102T150405Z") + "-" + classSlug(cls)
167169
ev := &Evidence{ID: id, Host: r.host, Time: now, Version: version, Trigger: trigger, Chain: chain,
168-
Threads: map[string][]threadRow{}, ProcDetail: map[string]procDetail{}, System: map[string]string{}}
170+
Threads: map[string][]threadRow{}, ProcDetail: map[string]procDetail{}, System: map[string]string{},
171+
Deviations: []devRow{}, Procs: []diagnosis.Proc{}, DState: []dRow{}, Kernel: []string{}}
169172
if r.procs != nil {
170-
ev.Procs = r.procs()
173+
if ps := r.procs(); ps != nil {
174+
ev.Procs = ps
175+
}
171176
}
172177
if r.devs != nil {
173178
for _, d := range r.devs() {
@@ -205,10 +210,13 @@ func (r *Recorder) Capture(trigger diagnosis.Item, chain *diagnosis.Chain, now t
205210
ev.ProcDetail[strconv.Itoa(pid)] = r.detail(pid)
206211
}
207212
}
208-
ev.DState = r.dstate()
209-
var err error
210-
if ev.Kernel, err = r.kernelLog(); err != nil {
213+
if d := r.dstate(); d != nil {
214+
ev.DState = d
215+
}
216+
if kl, err := r.kernelLog(); err != nil {
211217
ev.Errors = append(ev.Errors, "kernel_log: "+err.Error())
218+
} else if kl != nil {
219+
ev.Kernel = kl
212220
}
213221
for _, f := range []string{"loadavg", "uptime", "meminfo", "vmstat", "pressure/cpu", "pressure/io",
214222
"pressure/memory", "diskstats", "net/dev", "net/snmp"} {

0 commit comments

Comments
 (0)