《后端架构设计·演进与治理》6.3 从信号到诊断:一次跨服务排障复盘

从 arch-lab 订单系统一次 40 分钟才定位的支付超时切入,说明指标、日志、追踪与变更记录各自能回答什么,给出按 trace exemplar 关联时间线、用半衰期和独立信号加成排序候选原因的 Go 实现,并复盘示例事故的排序结果与歧义出口。

一次 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 定义:指标、窗口与用户视角 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查