《互联网产品的服务端架构·内容、社交与实时互动》6.2 计数一致性:精确、近似与对账

从「回声」大 V 取消点赞后计数仍上涨的事故写起:对比精确计数、近似去重计数与对账重建三档方案,说明缓存计数何时可以滞后、何时必须回源,给出 Drift、Evaluate 与 NeedsRebuild 的 Go 实现、表驱动测试和真实运行输出,并整理对账的常见错误与边界。

「回声」的热门文章,取消点赞后数字还在涨

「回声」有一条讲城市骑行的文章,作者是站内粉丝最多的创作者之一。文章发布后两天,点赞数停在 18.6 万,评论区里有人截图说“我取消点赞了,数字怎么还多了 200”。运营同学第一反应是用户看错了,直到自己取关再刷新,数字确实从 186,412 变成了 186,613。

排查发现计数链路分成了两段。第一段是关系服务,取消点赞会删除 user -> post 的边,这部分是强一致的,删除后立刻能查到“未点赞”。第二段是计数服务,它只订阅“点赞事件”做加一,取消点赞走的是另一条“取消事件”通道,而这条通道的消费者在一次发布中漏配了 topic,消息全部堆在死信队列里。

于是关系表已经在正确减少,计数缓存却只增不减。更麻烦的是,计数缓存每十分钟会从关系表“预热”一次,但预热逻辑写的是“取当前计数与关系表条数的较大值”,本意是防止缓存回退,结果把错误值永久保留了下来。用户每次取消点赞,只是让真实值变小,缓存值反而因为“取较大值”继续站在高位。

这个事故把计数一致性问题拆成了三层:计数到底要多精确、缓存允许偏多久、偏了之后怎么发现并修回来。三层混在一起讨论,就会出现“既然可以最终一致,那就不用对账”的误解。

精确、近似与对账是三档不同方案

计数不是一个非黑即白的属性。产品可以接受“点赞数晚十秒”,但不能接受“取消点赞后永远不降”;可以接受“阅读数是一个近似值”,但不能接受“收藏列表里少了一条”。把需求拆成三档,才能选对实现。

档位典型实现误差来源回源时机适用计数
精确计数关系表事务内更新,或单行原子加减无,除非并发写丢失每次写后读、对账时评论数、收藏列表、订单数
近似计数Redis 计数器、HyperLogLog、分片累加重复消息、乱序、采样定时校准、异常阈值触发点赞数、阅读数、曝光数
对账重建定期全量或增量重算,与缓存比较重算窗口内的新写入漂移超阈值、发布后所有物化计数

精确计数并不等于“每次都查关系表”。评论数可以在评论写入的同一个数据库事务里更新计数行,因为评论和计数通常能按内容 ID 放在同一分片;收藏列表本身就是关系表,计数只是它的派生值,读列表时不需要计数。真正难的是点赞这种高频、跨分片、允许短暂不一致的场景。

近似计数的价值在于吞吐。一条爆款内容每秒可能收到上万次点赞,如果每次都去更新同一行计数,热点行会成为瓶颈;用 Redis 的 INCR 或本地分片累加再合并,可以把写压力摊开。代价是重复消息、消费失败和乱序都会留下误差,所以必须有对账,而不是假设“最终会一致”。

对账重建是兜底,不是主路径。它解决的是“已经偏了怎么办”,不能替代写入时的幂等和顺序保证。对账的频率取决于业务对偏差的容忍度:点赞数可以每小时一次,收藏数可能需要每分钟一次,订单金额则应该实时校验。

大 V 的点赞数为什么常常来自缓存

大 V 的内容是典型的热点。一条内容被几十万人点赞,计数行会被频繁更新,数据库的行锁会把并发写串行化;即使改用原子加减,单行写入也有上限。把计数放进缓存,读请求直接命中缓存,写请求在缓存里累加,再异步落库,能显著降低数据库压力。

但缓存计数有两个前提。第一,缓存必须能重建,重建的源数据是关系表,不是另一个缓存。第二,缓存值不能作为唯一事实来源,取消点赞、删除内容、封禁账号都必须让缓存失效或触发重算。案例假设值:一条内容 18 万点赞,读请求是写请求的 50 倍,缓存命中率 99%;这些数字用于说明热点形状,不是实测结果。

什么时候必须回源?至少三种情况:用户刚完成点赞或取消,页面需要确认自己的操作;计数与关系表对账发现漂移超过阈值;内容进入结算、榜单或风控流程,数字会直接影响权益。前两种是体验和正确性,第三种是合规和资金,不能用近似值糊弄。

缓存只增不减是最危险的优化

很多团队在缓存预热时写“取缓存与数据库的较大值”,理由是防止缓存回退导致数字忽高忽低。这个策略在只增不减的计数器上看似合理,在支持取消的互动计数上却是灾难:真实值下降时,缓存永远不降,错误被固化。

正确做法是让缓存值可以被覆盖,而不是只能取较大值。预热时以关系表重算结果为准;如果担心读请求看到回退,可以用版本号或时间戳判断新旧,而不是用数值大小判断。数值大小不是版本,18 万比 17 万大,不代表 18 万更新。

失败场景:取消事件漏消费,预热又把错误固化

把事故链路完整还原一遍。第一天,计数消费者正常处理点赞事件,缓存从 0 涨到 186,412。第二天发布时,取消事件的 topic 配置被改错,消费者启动后订阅的是一个空 topic,取消消息全部进入死信队列,但监控只告警了“死信数量增长”,没有人把它和计数下降联系起来。

同一时间,预热任务每十分钟跑一次。它从关系表统计出真实点赞数是 186,100,然后读缓存得到 186,412,按照“取较大值”写回 186,412。真实值继续下降,缓存继续站在高位,差值越来越大。用户看到的不是“数字晚一点更新”,而是“取消点赞完全无效”。

第三个问题是缺少对账指标。团队有缓存命中率、消费延迟和死信数量,但没有“缓存计数与关系表条数的漂移率”。如果有这个指标,取消事件漏消费的当天就会触发告警,而不是等到用户截图。

修复分成三步。第一,取消事件重新接入消费者,死信队列回放,保证增量不丢。第二,预热逻辑改成“以关系表重算值为准”,并记录覆盖前后的差值。第三,增加漂移率监控:定时抽样若干内容,比较缓存值与重算值,超过阈值就触发重建。三步里最容易被忽略的是第三步,因为它不会立刻改善体验,但决定了下一次故障能否被提前发现。

用 Drift 和 Evaluate 把漂移变成可判断的数字

对账不能靠“感觉差不多”。需要两个量:漂移的绝对值和漂移率。绝对值告诉运维“差了多少条”,漂移率告诉系统“相对规模是否已经不可接受”。同一条内容差 10 条,在 100 条赞和 10 万条赞下是完全不同的严重程度。

下面的代码来自本书配套的 webapp-lab。Drift 计算 cached - stored 和 |drift| / max(stored, 1);Evaluate 校验 tolerance 并判断是否需要重建;NeedsRebuild 是保守版本,tolerance 非法时直接返回 true。

// file: internal/counter/reconcile.go
// tier: L1
// verify: go test ./internal/counter/

package counter

import (
	"fmt"
	"math"
)

// Snapshot 是一次对账输入:同一 key 在缓存中的值与存储中的值。
type Snapshot struct {
	Key    Key
	Cached int64
	Stored int64
}

// Drift 返回缓存相对存储的漂移(cached - stored)和漂移率。
// 漂移率 = |drift| / max(stored, 1):stored 为 0 时分母取 1,
// 即退化为漂移绝对值,避免除零,也让“从 0 涨到 100”有一个明确的比率。
func Drift(cached, stored int64) (int64, float64) {
	drift := cached - stored
	denom := stored
	if denom < 1 {
		denom = 1
	}
	return drift, math.Abs(float64(drift)) / float64(denom)
}

// Evaluate 判断快照是否需要重建,并校验 tolerance。
// tolerance 是允许的漂移率上限,必须在 [0, 1] 内;NaN 和无穷大视为越界。
// 漂移率严格大于 tolerance 时返回 true。
func Evaluate(s Snapshot, tolerance float64) (bool, error) {
	if math.IsNaN(tolerance) || math.IsInf(tolerance, 0) || tolerance < 0 || tolerance > 1 {
		return false, fmt.Errorf("counter: tolerance %v out of range [0,1]", tolerance)
	}
	_, ratio := Drift(s.Cached, s.Stored)
	return ratio > tolerance, nil
}

// NeedsRebuild 是 Evaluate 的便捷版本。为了不在调用侧吞掉错误,
// tolerance 非法时它选择保守重建(返回 true);需要区分错误时请用 Evaluate。
func NeedsRebuild(s Snapshot, tolerance float64) bool {
	need, err := Evaluate(s, tolerance)
	if err != nil {
		return true
	}
	return need
}

stored 为 0 时分母取 1,这是一个明确的约定:漂移率退化为漂移绝对值。比如缓存显示 7、存储为 0,比率就是 7,而不是无穷大。这样阈值配置仍然有意义,也不会在除零时产生 NaN 把比较逻辑带偏。

tolerance 限定在 [0, 1],因为它是比例而不是百分比。传 5 想表达 5% 是常见错误,代码会直接报错而不是把 5 当成 500%。Evaluate 用严格大于判断:漂移率正好等于 tolerance 时不重建,只有超过才重建。这个边界要在测试里钉死,否则不同实现会在临界值上产生不同行为。

NeedsRebuild 在 tolerance 非法时返回 true,是一个有意的保守选择。调用方如果只想拿一个布尔值,至少不会因为配置错误而跳过重建;需要区分“配置错”和“确实需要重建”时,必须调用 Evaluate。

测试:比率、边界和非法配置

对账逻辑的测试重点不是复杂算法,而是边界:缓存高于存储、低于存储、存储为 0、比率正好等于阈值、阈值非法。

// file: internal/counter/reconcile_test.go
// tier: L1
// verify: go test ./internal/counter/

package counter

import (
	"math"
	"testing"
)

func TestDriftUsesStoredAsDenominator(t *testing.T) {
	cases := []struct {
		name      string
		cached    int64
		stored    int64
		wantDrift int64
		wantRatio float64
	}{
		{"cached-ahead", 105, 100, 5, 0.05},
		{"cached-behind", 95, 100, -5, 0.05},
		{"stored-zero", 7, 0, 7, 7},
		{"both-zero", 0, 0, 0, 0},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			drift, ratio := Drift(c.cached, c.stored)
			if drift != c.wantDrift || ratio != c.wantRatio {
				t.Fatalf("Drift(%d, %d) = %d, %v; want %d, %v",
					c.cached, c.stored, drift, ratio, c.wantDrift, c.wantRatio)
			}
		})
	}
}

func TestEvaluateToleranceIsStrictUpperBound(t *testing.T) {
	s := Snapshot{Key: Key{Counter: Like, TargetID: "post-1"}, Cached: 105, Stored: 100}

	need, err := Evaluate(s, 0.05)
	if err != nil {
		t.Fatalf("Evaluate = %v, want nil", err)
	}
	if need {
		t.Fatal("ratio equal to tolerance must not rebuild")
	}

	need, err = Evaluate(s, 0.049)
	if err != nil {
		t.Fatalf("Evaluate = %v, want nil", err)
	}
	if !need {
		t.Fatal("ratio above tolerance must rebuild")
	}
}

func TestEvaluateRejectsInvalidTolerance(t *testing.T) {
	s := Snapshot{Cached: 1, Stored: 1}
	for _, tolerance := range []float64{-0.1, 1.1, math.NaN(), math.Inf(1), math.Inf(-1)} {
		if _, err := Evaluate(s, tolerance); err == nil {
			t.Fatalf("Evaluate tolerance %v = nil, want error", tolerance)
		}
		if !NeedsRebuild(s, tolerance) {
			t.Fatalf("NeedsRebuild tolerance %v = false, want conservative true", tolerance)
		}
	}
}

func TestNeedsRebuildZeroStored(t *testing.T) {
	if !NeedsRebuild(Snapshot{Cached: 3, Stored: 0}, 0) {
		t.Fatal("any drift from zero stored must rebuild at tolerance 0")
	}
	if NeedsRebuild(Snapshot{Cached: 0, Stored: 0}, 0) {
		t.Fatal("zero drift must not rebuild")
	}
}

TestDriftUsesStoredAsDenominator 用四组数据覆盖正漂移、负漂移、存储为 0 和双方为 0。负漂移的比率取绝对值,因为“缓存少了 5 条”和“缓存多了 5 条”对重建来说同样严重,不能因为方向不同就放过。

TestEvaluateToleranceIsStrictUpperBound 固定了临界行为:5% 的漂移在 tolerance 为 0.05 时不重建,在 0.049 时重建。TestEvaluateRejectsInvalidTolerance 覆盖负数、超过 1、NaN 和正负无穷,并同时验证 NeedsRebuild 的保守返回值。TestNeedsRebuildZeroStored 则确认存储为 0 时任何非零缓存都会触发重建。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/counter/ -run 'TestDrift|TestEvaluate|TestNeedsRebuild' -count=1 -v

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

=== RUN   TestDriftUsesStoredAsDenominator
=== RUN   TestDriftUsesStoredAsDenominator/cached-ahead
=== RUN   TestDriftUsesStoredAsDenominator/cached-behind
=== RUN   TestDriftUsesStoredAsDenominator/stored-zero
=== RUN   TestDriftUsesStoredAsDenominator/both-zero
--- PASS: TestDriftUsesStoredAsDenominator (0.00s)
    --- PASS: TestDriftUsesStoredAsDenominator/cached-ahead (0.00s)
    --- PASS: TestDriftUsesStoredAsDenominator/cached-behind (0.00s)
    --- PASS: TestDriftUsesStoredAsDenominator/stored-zero (0.00s)
    --- PASS: TestDriftUsesStoredAsDenominator/both-zero (0.00s)
=== RUN   TestEvaluateToleranceIsStrictUpperBound
--- PASS: TestEvaluateToleranceIsStrictUpperBound (0.00s)
=== RUN   TestEvaluateRejectsInvalidTolerance
--- PASS: TestEvaluateRejectsInvalidTolerance (0.00s)
=== RUN   TestNeedsRebuildZeroStored
--- PASS: TestNeedsRebuildZeroStored (0.00s)
PASS
ok  	webapp/internal/counter	0.766s

PASS 表示四个顶层用例和四个子用例通过。输出里的 0.766s 是包级耗时,包含测试二进制启动和所有用例;单个用例显示 0.00s,说明它们本身只是几次浮点比较,真实对账的成本在扫描关系表和读写缓存,不在这个函数里。

小结

计数一致性要分三档看:精确计数保证每一次增减都不丢,近似计数用误差换吞吐,对账重建负责发现并修正已经发生的偏差。三者不是互相替代,而是同一条链路上的不同层。只做近似不做对账,错误会永久保留;只做对账不做幂等,重建会反复被重复消息打偏。

大 V 的点赞数适合放在缓存里,但缓存必须是可重建的派生值,不能变成唯一事实来源。“取较大值”的预热策略在支持取消的计数上是反模式,它把短时回退的担忧换成了永久偏高的错误。

下一节讨论互动防刷与频控:同一个点赞接口,攻击者可以用多少种方式绕过只按 IP 的限流,频控键又该怎么组合。

阅读导航:上一节:6.1 点赞、评论与收藏 · 下一节:6.3 互动防刷与频控 。

常见错误与边界

  • 把缓存计数的“取较大值”当成防回退。数值大小不是版本,真实值下降时错误会被永久固化。
  • 用缓存作为重建源。重建必须回到关系表或事件日志,不能从一个可能已经偏掉的缓存再算一遍。
  • 只监控缓存命中率和消费延迟,不监控漂移率。取消事件漏消费时,前两个指标可能完全正常。
  • 把 tolerance 当成百分比传 5。接口约定是 [0, 1] 的比例,越界应该报错而不是静默放大。
  • 用 == 判断漂移是否超阈值。浮点比较要用严格大于,并固定临界值的行为。
  • 对账只抽样大 V 内容。长尾内容的计数偏差同样会影响作者权益,抽样要覆盖不同量级。
  • 重建时直接覆盖缓存,不做版本检查。重建期间的新写入可能被旧快照覆盖,需要用版本号或时间窗口保护。
  • 把点赞、阅读、收藏的阈值设成同一个值。不同互动的量级和业务敏感度不同,阈值必须分别配置。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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