排序改完之后,仪表盘更漂亮了,用户却说内容变旧了
「回声」的 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。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。