《互联网产品的服务端架构·内容、社交与实时互动》13.3 质量指标与进入卷三

收口卷二:用 Feed 新鲜度、消息到达率与未读准确率三个指标判断内容社交系统是否变好,解释为什么平均延迟不能作为唯一指标,给出 Aggregate 与 Meets 的最近秩 P95 实现、阈值测试、真实输出,并用一段话把读者交接给卷三的交易、增长与平台化。

排序改完之后,仪表盘更漂亮了,用户却说内容变旧了

「回声」的 Feed 团队做了一次排序改版:把实时互动权重调高,降低旧内容的回填比例。上线后,服务端仪表盘上的平均响应时间从 42ms 降到 38ms,平均 Feed 组装耗时下降了 8%,周报上写着“性能提升”。

一周后,客服收到一类投诉:“首页怎么总是昨天的内容?”产品同学自己刷了十次,有三次看到超过 24 小时的内容。再查分位数,Feed 新鲜度的 P95 从改版前的 12 秒涨到了 40 秒,最慢的那批用户看到的内容明显变旧。平均耗时下降,是因为大量请求命中了缓存;但那批没命中的长尾请求,被新的排序逻辑拖得更慢。

团队想把改版回滚,却拿不出证据。改版前只记录了平均响应时间,没有记录 Feed 新鲜度的分布;没有基线,就无法证明是改版导致变旧,还是当天流量异常。讨论变成了“我觉得”“我印象里”,最后只能全量回滚,连改版里真正有效的部分也一起丢掉。

这次事故的教训不是“不要看平均延迟”,而是“平均延迟回答不了内容质量的问题”。内容社交系统至少需要三个指标:Feed 新鲜度、消息到达率、未读准确率。它们分别回答“内容多久能被看到”“消息有没有送到”“状态有没有对齐”。

三个指标,各回答一个不同的问题

指标不是越多越好。每个指标都要能对应一个用户能感知的失败,否则就只是仪表盘上的装饰。

指标定义案例阈值能抓到什么抓不到什么
Feed 新鲜度 P95内容发布到首次出现在关注者 Feed 的时间,取 P95≤ 30 秒长尾用户看到旧内容、回填过多内容是否相关、排序是否公平
消息到达率均值成功送达设备的消息数 / 发送消息数≥ 0.98网关故障、长连接断连、区域路由错误用户是否已读、消息顺序是否正确
未读准确率误差均值客户端与服务端未读数不一致的会话占比≤ 0.02多端状态分裂、已读回执丢失消息内容是否正确、推送是否及时

Feed 新鲜度用 P95,而不是平均值。平均 8 秒可能意味着 95% 的用户 2 秒看到、5% 的用户 3 分钟看到;对那 5% 来说,产品就是“坏了”。P95 把长尾暴露出来,代价是需要更多的样本和更明确的窗口定义。

消息到达率用均值是合理的,因为它的失败通常是成片的:某个网关节点故障,会同时影响一批消息;均值下降能直接反映故障规模。但如果只看均值,仍可能漏掉某个小区域的持续失败,所以生产环境通常还会按区域、运营商和客户端版本拆分。

未读准确率误差是一个比例,不是延迟。它衡量的是“状态有没有对齐”,而不是“状态更新得快不快”。多端场景里,未读错 1 条和错 100 条对用户的影响不同,所以这个指标适合用“不一致会话占比”,再配合绝对差值做告警。

为什么平均延迟不能作为唯一指标

平均延迟至少有三个盲点。第一,它把长尾平均掉了,而用户体验往往由长尾决定。第二,它测的是服务端处理时间,不是用户看到内容的时间;缓存命中、排队、客户端渲染都可能让两者相差一个数量级。第三,它无法覆盖到达率和未读准确率,这两个问题根本不表现为“慢”。

案例假设值:改版前 Feed 新鲜度 P95 为 12 秒,改版后为 40 秒,平均组装耗时从 42ms 降到 38ms;这些数字用于说明均值与长尾可以反向变化,不是本机实测。

失败场景:没有基线,改完 Feed 排序没人能证明变好还是变坏

把事故还原成一次可复盘的发布。

发布前,团队只有一个平均响应时间面板,采样窗口是最近一小时。没有人记录 Feed 新鲜度的 P95,也没有人记录未读误差。评审时,大家默认“平均耗时下降就是变好”。

发布后,平均耗时确实下降了。但新的排序逻辑对冷缓存更不友好:第一次请求要回源计算更多候选,耗时集中在少数请求上。平均值被大量缓存命中拉低,P95 却被这些冷请求抬高。客服的投诉正好来自这批冷请求。

回滚讨论卡在归因上。没有改版前的 P95 基线,就无法排除“当天内容生产量突然变大”或“某个机房网络抖动”。团队只能凭感觉回滚,回滚后也没有指标证明恢复到了什么水平。

正确的做法是在发布前固定三件事:同一批样本、同一个时间窗口、同一套阈值。发布时用同一套指标对比,而不是换一个更好看的面板。发布后如果 P95 超过阈值,自动触发回滚或降级,而不是等到客服投诉。

基线不是一次性的。内容量、用户结构、客户端版本都会变化,基线要按周或按版本滚动更新;但更新基线时不能悄悄放宽阈值,否则指标会变成“永远达标”。

用 Aggregate 和 Meets 把指标变成发布门禁

指标要能进入发布流程,必须变成确定的函数:输入样本,输出聚合值,再和阈值比较。下面的代码来自 webapp-lab,Aggregate 负责 P95 和两个均值,Meets 负责阈值判断。

// file: internal/socialbase/metrics.go
// tier: L1
// verify: go test ./internal/socialbase/
package socialbase

import (
	"errors"
	"math"
	"sort"
)

// Sample 是一次观测到的三个内容质量指标。
type Sample struct {
	FeedAge      float64
	DeliveryRate float64
	UnreadError  float64
}

// Summary 是样本聚合后的结果。
type Summary struct {
	FeedAgeP95      float64
	DeliveryRateAvg float64
	UnreadErrorAvg  float64
}

// Aggregate 用最近秩法计算 Feed 新鲜度 P95,并计算到达率与未读误差的均值。
// 函数会复制输入,不修改调用方的切片。
func Aggregate(samples []Sample) (Summary, error) {
	n := len(samples)
	if n == 0 {
		return Summary{}, errors.New("no samples")
	}

	ages := make([]float64, n)
	var delivery, unread float64
	for i, s := range samples {
		ages[i] = s.FeedAge
		delivery += s.DeliveryRate
		unread += s.UnreadError
	}

	sort.Float64s(ages)
	rank := int(math.Ceil(0.95 * float64(n)))
	if rank < 1 {
		rank = 1
	}
	if rank > n {
		rank = n
	}

	return Summary{
		FeedAgeP95:      ages[rank-1],
		DeliveryRateAvg: delivery / float64(n),
		UnreadErrorAvg:  unread / float64(n),
	}, nil
}

// Meets 判断聚合结果是否同时满足三个阈值。指标越界或阈值非法时返回 false。
func Meets(s Summary, maxAge, minDelivery, maxUnreadErr float64) bool {
	if math.IsNaN(s.FeedAgeP95) || math.IsNaN(s.DeliveryRateAvg) || math.IsNaN(s.UnreadErrorAvg) {
		return false
	}
	if s.FeedAgeP95 < 0 || s.DeliveryRateAvg < 0 || s.DeliveryRateAvg > 1 {
		return false
	}
	if s.UnreadErrorAvg < 0 || s.UnreadErrorAvg > 1 {
		return false
	}
	if math.IsNaN(maxAge) || math.IsNaN(minDelivery) || math.IsNaN(maxUnreadErr) {
		return false
	}
	if maxAge < 0 || minDelivery < 0 || minDelivery > 1 || maxUnreadErr < 0 || maxUnreadErr > 1 {
		return false
	}
	return s.FeedAgeP95 <= maxAge && s.DeliveryRateAvg >= minDelivery && s.UnreadErrorAvg <= maxUnreadErr
}

P95 用最近秩法:把样本升序排列,取第 ceil(0.95 * n) 个,再减一到下标。20 个样本时,秩是 19,取第二大的值;100 个样本时,秩是 95,取第 95 个。最近秩法不插值,结果一定是样本里真实出现过的值,便于解释和复现。

Aggregate 先复制 FeedAge 到新切片再排序,不修改调用方的样本顺序。这个约束很重要:调用方可能还要用原始样本做其他统计,如果排序发生在入参上,后续结果会悄悄变化。

Meets 同时检查聚合值和阈值。FeedAge 不能为负,DeliveryRate 和 UnreadError 必须在 [0, 1];阈值本身也要合法,负的 maxAge、超过 1 的 minDelivery 都返回 false。NaN 也返回 false,避免比较链被 NaN 带偏。

阈值判断用“小于等于”和“大于等于”,正好等于阈值算达标。这个边界要在测试里固定,否则不同实现会在临界值上给出不同结论。

测试:最近秩、均值、空样本、输入不变和阈值边界

指标代码的测试重点不是复杂算法,而是边界和契约:P95 的秩怎么取、空样本是否报错、入参是否被修改、越界和 NaN 是否被拒绝。

// file: internal/socialbase/metrics_test.go
// tier: L1
// verify: go test ./internal/socialbase/
package socialbase

import (
	"math"
	"reflect"
	"testing"
)

func TestAggregateUsesNearestRankP95(t *testing.T) {
	cases := []struct {
		name    string
		samples []Sample
		want    float64
	}{
		{"single sample", []Sample{{FeedAge: 7}}, 7},
		{"ten samples", fanoutSamples(10), 10},
		{"twenty samples", fanoutSamples(20), 19},
		{"hundred samples", fanoutSamples(100), 95},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, err := Aggregate(tc.samples)
			if err != nil {
				t.Fatalf("Aggregate() error = %v", err)
			}
			if got.FeedAgeP95 != tc.want {
				t.Fatalf("FeedAgeP95 = %v, want %v", got.FeedAgeP95, tc.want)
			}
		})
	}
}

func TestAggregateComputesAverages(t *testing.T) {
	got, err := Aggregate([]Sample{
		{FeedAge: 1, DeliveryRate: 0.9, UnreadError: 0.02},
		{FeedAge: 2, DeliveryRate: 0.8, UnreadError: 0.04},
	})
	if err != nil {
		t.Fatalf("Aggregate() error = %v", err)
	}
	if got.FeedAgeP95 != 2 {
		t.Fatalf("FeedAgeP95 = %v, want 2", got.FeedAgeP95)
	}
	if math.Abs(got.DeliveryRateAvg-0.85) > 1e-9 {
		t.Fatalf("DeliveryRateAvg = %v, want 0.85", got.DeliveryRateAvg)
	}
	if math.Abs(got.UnreadErrorAvg-0.03) > 1e-9 {
		t.Fatalf("UnreadErrorAvg = %v, want 0.03", got.UnreadErrorAvg)
	}
}

func TestAggregateRejectsEmptySamples(t *testing.T) {
	if _, err := Aggregate(nil); err == nil {
		t.Fatal("Aggregate(nil) error = nil, want error")
	}
}

func TestAggregateDoesNotMutateInput(t *testing.T) {
	in := []Sample{
		{FeedAge: 30, DeliveryRate: 0.5, UnreadError: 0.1},
		{FeedAge: 10, DeliveryRate: 0.9, UnreadError: 0.01},
		{FeedAge: 20, DeliveryRate: 0.7, UnreadError: 0.05},
	}
	before := make([]Sample, len(in))
	copy(before, in)

	if _, err := Aggregate(in); err != nil {
		t.Fatalf("Aggregate() error = %v", err)
	}
	if !reflect.DeepEqual(in, before) {
		t.Fatalf("Aggregate() mutated input: got %+v, want %+v", in, before)
	}
}

func TestMeetsThresholds(t *testing.T) {
	good := Summary{FeedAgeP95: 30, DeliveryRateAvg: 0.98, UnreadErrorAvg: 0.02}
	cases := []struct {
		name        string
		summary     Summary
		maxAge      float64
		minDelivery float64
		maxUnread   float64
		want        bool
	}{
		{"all pass", good, 30, 0.98, 0.02, true},
		{"feed too old", good, 29, 0.98, 0.02, false},
		{"delivery too low", good, 30, 0.99, 0.02, false},
		{"unread error too high", good, 30, 0.98, 0.01, false},
		{"negative feed age", Summary{FeedAgeP95: -1, DeliveryRateAvg: 1, UnreadErrorAvg: 0}, 30, 0.9, 0.1, false},
		{"delivery above one", Summary{FeedAgeP95: 1, DeliveryRateAvg: 1.1, UnreadErrorAvg: 0}, 30, 0.9, 0.1, false},
		{"unread below zero", Summary{FeedAgeP95: 1, DeliveryRateAvg: 1, UnreadErrorAvg: -0.1}, 30, 0.9, 0.1, false},
		{"invalid max age", good, -1, 0.9, 0.1, false},
		{"invalid min delivery", good, 30, 1.1, 0.1, false},
		{"invalid max unread", good, 30, 0.9, 1.1, false},
		{"nan summary", Summary{FeedAgeP95: math.NaN(), DeliveryRateAvg: 1, UnreadErrorAvg: 0}, 30, 0.9, 0.1, false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := Meets(tc.summary, tc.maxAge, tc.minDelivery, tc.maxUnread); got != tc.want {
				t.Fatalf("Meets() = %v, want %v", got, tc.want)
			}
		})
	}
}

func fanoutSamples(n int) []Sample {
	out := make([]Sample, n)
	for i := range out {
		out[i] = Sample{FeedAge: float64(i + 1), DeliveryRate: 1, UnreadError: 0}
	}
	return out
}

TestAggregateUsesNearestRankP95 用 1、10、20、100 个样本固定最近秩的结果:10 个取最大,20 个取第二大的 19,100 个取 95。TestAggregateComputesAverages 验证两个均值,并用浮点误差而不是直接相等比较,避免二进制表示带来的假失败。

TestAggregateRejectsEmptySamples 确认空样本返回错误,而不是返回一个全零的 Summary。TestAggregateDoesNotMutateInput 先复制入参,调用后再用 reflect.DeepEqual 比较,确保排序没有发生在原切片上。

TestMeetsThresholds 覆盖十一组情况:全部达标、Feed 太旧、到达率太低、未读误差太高、三个指标各自越界、三个阈值非法,以及 NaN。每个用例都同时验证“该拒绝的拒绝”和“该通过的通过”。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/socialbase/ -run 'TestAggregate|TestMeets' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestAggregateUsesNearestRankP95
=== RUN   TestAggregateUsesNearestRankP95/single_sample
=== RUN   TestAggregateUsesNearestRankP95/ten_samples
=== RUN   TestAggregateUsesNearestRankP95/twenty_samples
=== RUN   TestAggregateUsesNearestRankP95/hundred_samples
--- PASS: TestAggregateUsesNearestRankP95 (0.00s)
    --- PASS: TestAggregateUsesNearestRankP95/single_sample (0.00s)
    --- PASS: TestAggregateUsesNearestRankP95/ten_samples (0.00s)
    --- PASS: TestAggregateUsesNearestRankP95/twenty_samples (0.00s)
    --- PASS: TestAggregateUsesNearestRankP95/hundred_samples (0.00s)
=== RUN   TestAggregateComputesAverages
--- PASS: TestAggregateComputesAverages (0.00s)
=== RUN   TestAggregateRejectsEmptySamples
--- PASS: TestAggregateRejectsEmptySamples (0.00s)
=== RUN   TestAggregateDoesNotMutateInput
--- PASS: TestAggregateDoesNotMutateInput (0.00s)
=== RUN   TestMeetsThresholds
=== RUN   TestMeetsThresholds/all_pass
=== RUN   TestMeetsThresholds/feed_too_old
=== RUN   TestMeetsThresholds/delivery_too_low
=== RUN   TestMeetsThresholds/unread_error_too_high
=== RUN   TestMeetsThresholds/negative_feed_age
=== RUN   TestMeetsThresholds/delivery_above_one
=== RUN   TestMeetsThresholds/unread_below_zero
=== RUN   TestMeetsThresholds/invalid_max_age
=== RUN   TestMeetsThresholds/invalid_min_delivery
=== RUN   TestMeetsThresholds/invalid_max_unread
=== RUN   TestMeetsThresholds/nan_summary
--- PASS: TestMeetsThresholds (0.00s)
    --- PASS: TestMeetsThresholds/all_pass (0.00s)
    --- PASS: TestMeetsThresholds/feed_too_old (0.00s)
    --- PASS: TestMeetsThresholds/delivery_too_low (0.00s)
    --- PASS: TestMeetsThresholds/unread_error_too_high (0.00s)
    --- PASS: TestMeetsThresholds/negative_feed_age (0.00s)
    --- PASS: TestMeetsThresholds/delivery_above_one (0.00s)
    --- PASS: TestMeetsThresholds/unread_below_zero (0.00s)
    --- PASS: TestMeetsThresholds/invalid_max_age (0.00s)
    --- PASS: TestMeetsThresholds/invalid_min_delivery (0.00s)
    --- PASS: TestMeetsThresholds/invalid_max_unread (0.00s)
    --- PASS: TestMeetsThresholds/nan_summary (0.00s)
PASS
ok  	webapp/internal/socialbase	0.413s

四个顶层用例和十六个子用例全部通过。TestAggregateUsesNearestRankP95 的四个子用例分别对应 1、10、20、100 个样本,TestMeetsThresholds 的十一个子用例覆盖了阈值边界。包级耗时包含测试二进制启动,单个用例显示 0.00s,因为聚合本身只是排序和几次浮点运算。

小结

Feed 新鲜度、消息到达率、未读准确率,分别回答“内容多久能被看到”“消息有没有送到”“状态有没有对齐”。平均延迟只能回答“服务端处理得快不快”,而且会把长尾平均掉,不能作为内容质量的唯一指标。

把指标写成 Aggregate 和 Meets,是为了让发布门禁可执行:同一批样本、同一个窗口、同一套阈值,改版前后才能比较。P95 用最近秩法,结果可解释;空样本报错;越界和 NaN 返回 false;聚合不修改入参。这些约束看起来琐碎,但正是指标能否被信任的基础。

到这里,卷二关于内容、社交与实时互动的讨论告一段落:从内容元数据、发布审核、Feed 分发、关系与互动,到 IM、未读和端到端验证,再到本章的质量指标,一条内容从产生到被看见、被互动的链路已经闭合。卷三《互联网产品的服务端架构·交易、增长与平台化》会把视角转向交易、增长与平台化,讨论订单、支付、营销、增长实验和平台治理如何在同一个服务端体系里共存。

阅读导航:上一节:13.2 IM 与未读的端到端验证 · 下一节:目录与学习路径 。

常见错误与边界

  • 用平均延迟代替 Feed 新鲜度。平均组装耗时下降,可能同时意味着长尾用户看到的内容变旧。
  • 只在服务端测耗时,不测用户可见时间。缓存、排队和客户端渲染都会让两者产生数量级差异。
  • 发布前不记录基线。没有改版前的同一指标,回滚讨论只能靠感觉,无法归因。
  • 更新基线时悄悄放宽阈值。指标会变成“永远达标”,失去门禁意义。
  • P95 用插值法却不说明口径。不同实现给出不同数值,跨团队比较时无法对齐。
  • 排序直接发生在入参切片上。调用方后续统计会被悄悄改变,问题很难定位。
  • 空样本返回全零 Summary。零值看起来像“完美”,实际是没有数据。
  • 阈值非法时静默通过。负的 maxAge 或超过 1 的 minDelivery 会让判断失去意义,应该直接返回 false。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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