一次 40 分钟才定位的支付超时
2026 年 10 月 29 日 10:00,arch-lab 的订单错误率告警响了。值班同学先看指标:order.error.rate 从 0.4% 升到 4.5%,payment.latency.p99 从 120 ms 升到 1800 ms,inventory 的延迟正常。再看日志:order-service 大量 order.checkout.slow,payment-service 有 payment.timeout。最后看追踪:一条 tr-1001 的支付 span 耗时 1.7 秒,库存 span 只有 20 毫秒。
三块屏幕都指向 payment,但没人能立刻排除“两分钟前刚发布的 order-service v42”和“五分钟前改过的 checkout 超时配置”。真正把范围收窄的是把它们放进同一条时间线:09:58 发布开始,09:58:40 发布完成,10:00:10 payment p99 抬升,10:00:12 支付 span 变慢,10:00:13 日志打出超时,10:00:14 库存 span 仍然正常。发布是时间上的先行者,支付是依赖上的直接瓶颈,库存被排除。这个过程人工做了 40 分钟;本节把它拆成可复现的关联与排序。这些数字是本节复盘用的示例值,不是线上采集数据;目的是让输入、判据和输出都可核对。告警到行动的闭环 讨论过告警质量,本节只处理“告警已经响了,信号怎么收敛”。
四类信号各自回答什么
| 信号 | 回答的问题 | 看不到什么 | 关联键 |
|---|---|---|---|
| 指标 | 影响面多大、从何时开始 | 单个请求的因果链 | 服务、时间窗、exemplar trace ID |
| 日志 | 具体哪一步报错、错误文本 | 跨服务时序、全局比例 | trace ID、span ID |
| 追踪 | 哪个 span 慢、调用顺序 | 长期趋势、未采样请求 | trace ID、parent span ID |
| 变更 | 最近改了什么、何时生效 | 变更是否真的相关 | 服务、版本、时间 |
指标适合定界,日志适合定性,追踪适合定位,变更记录适合提供候选。四者没有一个能单独给出“根因”,但放在同一条时间线上,能排除掉大部分错误方向。关键不是接更多数据源,而是确保它们共享 trace ID、服务名和时间基准;没有关联键,四块屏幕仍然是四个故事。
先连时间线,再谈排序
关联的第一步是稳定时间线。同一毫秒内可能同时有 span 和日志,排序必须保留原始插入顺序,否则每次重放得到不同结果,复盘无法复现。第二步是用 exemplar 把指标接到 trace:指标样本带一个 trace_id 属性,指向产生该样本的慢请求;没有 exemplar 时,只能按服务与时间窗做模糊匹配,精度会明显下降。第三步是算“告警到首个相关 span”的时间,它既是排障速度指标,也能暴露采集延迟。排序则要把“候选原因”显式列出来。每个候选有一组关键词和一个先验权重,事件按与 onset 的时间距离做半衰期衰减,再按命中的独立信号种类加成。至少两条证据才进入候选,避免一条孤立日志把某个假设顶到第一。这个模型不是自动根因分析,它只是把值班同学脑子里的排除过程写成可审计的分数;最终确认仍然要回到 trace 和代码。
排序必须有歧义出口
如果前两名分数相差不到 10%,系统应该明确输出“歧义”,而不是硬选一个。歧义意味着现有证据不足以区分两个假设,正确的下一步是补数据:查同一时间窗的另一条 trace、看变更的灰度范围、对比上一个版本的基线。把歧义藏起来,排序就会变成一种有说服力但不可靠的猜测。
可运行实现
下面的 obs 包维护一个事件切片,提供稳定时间线、按 trace 连接、exemplar 提取、告警到 span 的时延,以及带证据门槛和歧义判定的候选排序。输入约定是“异常事件与变更记录”,不是全量遥测;正常事件应在进入诊断器之前被阈值过滤掉。
// file: internal/platform/obs/correlate.go
// tier: L2
// verify: go test ./internal/platform/obs/ -count=1
package obs
import (
"fmt"
"math"
"sort"
"strings"
"time"
)
// EventKind is one observability signal or change record.
type EventKind string
const (
EventLog EventKind = "log"
EventMetric EventKind = "metric"
EventSpan EventKind = "span"
EventChange EventKind = "change"
)
// Event is a normalized record. Only anomalies and changes are fed to the
// diagnoser; normal telemetry stays in the warehouse.
type Event struct {
Time time.Time
Kind EventKind
Service string
Name string
TraceID string
Value float64
Attrs map[string]string
}
// Correlator stores a small incident slice and answers join questions.
type Correlator struct{ events []Event }
func NewCorrelator() *Correlator { return &Correlator{} }
func (c *Correlator) Add(e Event) { c.events = append(c.events, e) }
// Events returns a stable copy sorted by time; equal timestamps keep insertion
// order, so replaying the same input produces the same timeline.
func (c *Correlator) Events() []Event {
out := append([]Event(nil), c.events...)
sort.SliceStable(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) })
return out
}
// ByTrace joins direct trace IDs and metric exemplars.
func (c *Correlator) ByTrace(traceID string) []Event {
if traceID == "" {
return nil
}
var out []Event
for _, e := range c.Events() {
if e.TraceID == traceID || e.Attrs["trace_id"] == traceID {
out = append(out, e)
}
}
return out
}
// ExemplarTraces joins a metric to traces through its trace_id attribute.
func (c *Correlator) ExemplarTraces(metric string, since time.Time) []string {
seen := make(map[string]bool)
var out []string
for _, e := range c.Events() {
if e.Kind != EventMetric || e.Name != metric || e.Time.Before(since) {
continue
}
id := e.Attrs["trace_id"]
if id != "" && !seen[id] {
seen[id] = true
out = append(out, id)
}
}
sort.Strings(out)
return out
}
// TimeToCorrelate measures alert-to-first-span for one trace.
func (c *Correlator) TimeToCorrelate(alert time.Time, traceID string) (time.Duration, bool) {
for _, e := range c.Events() {
if e.TraceID == traceID && e.Kind == EventSpan && !e.Time.Before(alert) {
return e.Time.Sub(alert), true
}
}
return 0, false
}
// Hypothesis is one candidate cause; Weight is the prior, not the final score.
type Hypothesis struct {
Name string
Keywords []string
Weight float64
}
type Candidate struct {
Name string
Score float64
Events int
Signals map[EventKind]int
Evidence []string
}
type Diagnosis struct {
Candidates []Candidate
Ambiguous bool
Reason string
}
// Diagnoser ranks causes. HalfLife decays evidence away from the onset;
// minEvidence suppresses one-off coincidences.
type Diagnoser struct {
hypotheses []Hypothesis
halfLife time.Duration
lookback time.Duration
minEvidence int
}
func NewDiagnoser(halfLife time.Duration) *Diagnoser {
if halfLife <= 0 {
halfLife = 10 * time.Minute
}
return &Diagnoser{halfLife: halfLife, lookback: 30 * time.Minute, minEvidence: 2}
}
func (d *Diagnoser) Add(h Hypothesis) {
if h.Weight <= 0 {
h.Weight = 1
}
d.hypotheses = append(d.hypotheses, h)
}
// Diagnose scores every hypothesis against the incident slice.
func (d *Diagnoser) Diagnose(onset time.Time, events []Event) Diagnosis {
var out []Candidate
for _, h := range d.hypotheses {
signals := make(map[EventKind]int)
var score float64
var evidence []string
count := 0
for _, e := range events {
if e.Time.Before(onset.Add(-d.lookback)) || e.Time.After(onset.Add(d.lookback)) || !matches(h.Keywords, e) {
continue
}
dt := e.Time.Sub(onset)
if dt < 0 {
dt = -dt
}
score += h.Weight * math.Exp(-float64(dt)/float64(d.halfLife))
signals[e.Kind]++
count++
if len(evidence) < 5 {
evidence = append(evidence, fmt.Sprintf("%s %s %s (%+.0fs)", e.Kind, e.Service, e.Name, e.Time.Sub(onset).Seconds()))
}
}
if count < d.minEvidence {
continue
}
score *= 1 + 0.15*float64(len(signals)-1) // independent signals are stronger
out = append(out, Candidate{
Name: h.Name, Score: math.Round(score*1000) / 1000,
Events: count, Signals: signals, Evidence: evidence,
})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
return out[i].Name < out[j].Name
})
diag := Diagnosis{Candidates: out}
if len(out) >= 2 && out[0].Score > 0 && out[1].Score/out[0].Score >= 0.9 {
diag.Ambiguous = true
diag.Reason = fmt.Sprintf("top two within 10%%: %s=%.3f %s=%.3f", out[0].Name, out[0].Score, out[1].Name, out[1].Score)
}
return diag
}
func matches(keywords []string, e Event) bool {
for _, kw := range keywords {
if strings.Contains(e.Name, kw) {
return true
}
for k, v := range e.Attrs {
if k == kw || strings.Contains(v, kw) {
return true
}
}
}
return false
}
// ManualClock is a fixed, advanceable clock for replay tests.
type ManualClock struct{ now time.Time }
func NewManualClock(t time.Time) *ManualClock { return &ManualClock{now: t} }
func (c *ManualClock) Now() time.Time { return c.now }
func (c *ManualClock) Advance(d time.Duration) { c.now = c.now.Add(d) }
测试用固定时钟重放 11 条事件:两次发布、两次配置变更、一次告警、一条支付指标 exemplar、三个同 trace 的 span/日志和一条订单错误率指标。另一个测试构造分数相同的两个候选,验证歧义出口。
// file: internal/platform/obs/correlate_test.go
// tier: L2
// verify: go test ./internal/platform/obs/ -count=1
package obs
import (
"fmt"
"strings"
"testing"
"time"
)
func incidentCorrelator() (*Correlator, time.Time) {
onset := time.Date(2026, 10, 29, 10, 0, 0, 0, time.UTC)
clock := NewManualClock(onset)
c := NewCorrelator()
add := func(offset time.Duration, e Event) {
e.Time = clock.Now().Add(offset)
c.Add(e)
}
add(-2*time.Minute, Event{Kind: EventChange, Service: "order", Name: "deploy.started", Attrs: map[string]string{"version": "v42"}})
add(-80*time.Second, Event{Kind: EventChange, Service: "order", Name: "deploy.completed"})
add(-5*time.Minute, Event{Kind: EventChange, Service: "order", Name: "config.apply"})
add(-290*time.Second, Event{Kind: EventChange, Service: "order", Name: "feature.flag.checkout"})
add(5*time.Second, Event{Kind: EventLog, Service: "alertmanager", Name: "alert.fired"})
add(10*time.Second, Event{Kind: EventMetric, Service: "payment", Name: "payment.latency.p99", Attrs: map[string]string{"trace_id": "tr-1001"}})
add(12*time.Second, Event{Kind: EventSpan, Service: "payment", Name: "payment.authorize", TraceID: "tr-1001"})
add(12*time.Second, Event{Kind: EventLog, Service: "order", Name: "order.checkout.slow", TraceID: "tr-1001"})
add(13*time.Second, Event{Kind: EventLog, Service: "payment", Name: "payment.timeout", TraceID: "tr-1001"})
add(14*time.Second, Event{Kind: EventSpan, Service: "inventory", Name: "inventory.hold", TraceID: "tr-1001"})
add(60*time.Second, Event{Kind: EventMetric, Service: "order", Name: "order.error.rate", Attrs: map[string]string{"trace_id": "tr-1001"}})
return c, onset
}
func incidentDiagnoser() *Diagnoser {
d := NewDiagnoser(10 * time.Minute)
d.Add(Hypothesis{Name: "payment_dependency", Keywords: []string{"payment.authorize", "payment.timeout", "payment.latency"}, Weight: 1})
d.Add(Hypothesis{Name: "deploy_regression", Keywords: []string{"deploy", "release"}, Weight: 0.8})
d.Add(Hypothesis{Name: "config_change", Keywords: []string{"config.apply", "feature.flag"}, Weight: 0.6})
d.Add(Hypothesis{Name: "db_saturation", Keywords: []string{"db.pool", "db.query"}, Weight: 0.9})
return d
}
func TestDiagnosePaymentDependency(t *testing.T) {
c, onset := incidentCorrelator()
diag := incidentDiagnoser().Diagnose(onset, c.Events())
if diag.Ambiguous || len(diag.Candidates) != 3 || diag.Candidates[0].Name != "payment_dependency" {
t.Fatalf("diagnosis = %+v", diag)
}
for _, cand := range diag.Candidates {
t.Logf("candidate=%s score=%.3f events=%d signals=%v", cand.Name, cand.Score, cand.Events, cand.Signals)
}
if got := diag.Candidates[0].Signals; got[EventMetric] != 1 || got[EventSpan] != 1 || got[EventLog] != 1 {
t.Fatalf("payment signals = %v", got)
}
}
func TestExemplarAndTimeline(t *testing.T) {
c, onset := incidentCorrelator()
traces := c.ExemplarTraces("payment.latency.p99", onset.Add(-time.Minute))
if len(traces) != 1 || traces[0] != "tr-1001" || len(c.ByTrace("tr-1001")) != 6 {
t.Fatalf("exemplars=%v joined=%d", traces, len(c.ByTrace("tr-1001")))
}
if d, ok := c.TimeToCorrelate(onset.Add(5*time.Second), "tr-1001"); !ok || d != 7*time.Second {
t.Fatalf("alert to trace = %s", d)
}
events := c.Events()
var at12 []string
for _, e := range events {
if e.Time.Equal(onset.Add(12 * time.Second)) {
at12 = append(at12, e.Name)
}
}
if len(events) != 11 || len(at12) != 2 || at12[0] != "payment.authorize" || at12[1] != "order.checkout.slow" {
t.Fatalf("timeline: events=%d at12=%v", len(events), at12)
}
t.Logf("exemplars=%v joined=6 alert_to_span=7s events=%d stable=%v", traces, len(events), at12)
}
func TestDeterministicAndAmbiguous(t *testing.T) {
c, onset := incidentCorrelator()
d := incidentDiagnoser()
first := fmt.Sprint(d.Diagnose(onset, c.Events()))
if first != fmt.Sprint(d.Diagnose(onset, c.Events())) {
t.Fatalf("diagnosis changed between runs")
}
base := time.Date(2026, 10, 29, 10, 0, 0, 0, time.UTC)
events := []Event{
{Time: base, Kind: EventLog, Service: "a", Name: "symptom.one"},
{Time: base.Add(time.Second), Kind: EventLog, Service: "a", Name: "symptom.two"},
}
amb := NewDiagnoser(10 * time.Minute)
amb.Add(Hypothesis{Name: "cause_a", Keywords: []string{"symptom"}, Weight: 1})
amb.Add(Hypothesis{Name: "cause_b", Keywords: []string{"symptom"}, Weight: 1})
diag := amb.Diagnose(base, events)
if !diag.Ambiguous || !strings.Contains(diag.Reason, "within 10%") {
t.Fatalf("ambiguity = %+v", diag)
}
t.Logf("stable_bytes=%d ambiguous=%v reason=%s", len(first), diag.Ambiguous, diag.Reason)
}
本机运行输出
环境同上:macOS darwin/arm64、Go 1.26.0。完整输出保存在 .local/reports/be-arch-gov/ch06-be-arch-gov-06-3-signal-to-diagnosis-incident.txt。
$ go version
go version go1.26.0 darwin/arm64
$ gofmt -l .
$ go vet ./...
$ go test ./internal/platform/obs/ -count=1 -v -run 'TestDiagnosePaymentDependency|TestExemplarAndTimeline|TestDeterministicAndAmbiguous'
=== RUN TestDiagnosePaymentDependency
correlate_test.go:51: candidate=payment_dependency score=3.825 events=3 signals=map[log:1 metric:1 span:1]
correlate_test.go:51: candidate=deploy_regression score=1.355 events=2 signals=map[change:2]
correlate_test.go:51: candidate=config_change score=0.734 events=2 signals=map[change:2]
--- PASS: TestDiagnosePaymentDependency (0.00s)
=== RUN TestExemplarAndTimeline
correlate_test.go:77: exemplars=[tr-1001] joined=6 alert_to_span=7s events=11 stable=[payment.authorize order.checkout.slow]
--- PASS: TestExemplarAndTimeline (0.00s)
=== RUN TestDeterministicAndAmbiguous
correlate_test.go:99: stable_bytes=417 ambiguous=true reason=top two within 10%: cause_a=1.998 cause_b=1.998
--- PASS: TestDeterministicAndAmbiguous (0.00s)
PASS
ok archlab/internal/platform/obs 0.237s
读数有三层。排序层,payment_dependency 3.825 分,deploy_regression 1.355 分,config_change 0.734 分;支付候选命中 metric、span、log 三种独立信号,按 1.3 倍加成,发布与配置各只有 change 一种信号,没有加成。排除层,db_saturation 虽然先验权重 0.9,但时间窗内没有 db.pool 或 db.query 事件,证据数为 0,低于门槛,不进入候选。关联层,指标 exemplar 指向 tr-1001,按 trace 连出 6 条事件;告警在 10:00:05,首个 span 在 10:00:12,告警到 trace 的时延是 7 秒。歧义测试给出两个同关键词、同权重、同时间分布的候选,分数都是 1.998,差值 0,系统输出 ambiguous=true,而不是按名字随便选一个。这是本节最重要的边界:排序只在证据足够区分时才有意义;分数接近时必须补数据,不能把排序结果当成结论。
常见错误与边界
- 把全量遥测灌进诊断器。正常事件也会命中关键词,候选分数被噪声抬高;输入应该先经过阈值、错误标记和变更事件过滤。
- 把排序第一当成根因。它只是“当前证据下最值得先查的假设”,确认仍要回到 trace、代码和变更记录;错误的关键词表会稳定地给出错误排名。
- 忽略同一时间戳的稳定排序。span 和日志经常同毫秒到达,不稳定排序会让复盘每次得到不同时间线,无法对比两次重放。
- 不设歧义出口。前两名分数接近时硬选一个,会把“证据不足”伪装成“系统判断”,延误补数据的时机。
小结
跨服务排障的难点不是缺信号,而是信号之间没有共同的时间线和关联键。指标定界、日志定性、追踪定位、变更提供候选;exemplar 把指标接到 trace,稳定排序保证复盘可复现,半衰期和独立信号加成把“离事故多近、证据多独立”写进分数。本节用 11 条示例事件重放了一次支付超时:payment_dependency 以 3.825 分排在首位,发布与配置分列其后,库存与数据库因证据不足被排除;告警到首个 span 的时延是 7 秒。分数不是结论,歧义出口和人工确认才是。事故复盘与改进 会把这次排序结果转成后续的检测项和演练场景。
阅读导航:上一节:6.2 分布式追踪:采样、上下文传播与成本 · 下一节:7.1 SLI 定义:指标、窗口与用户视角 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。