「回声」的热门文章,取消点赞后数字还在涨
「回声」有一条讲城市骑行的文章,作者是站内粉丝最多的创作者之一。文章发布后两天,点赞数停在 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 内容。长尾内容的计数偏差同样会影响作者权益,抽样要覆盖不同量级。
- 重建时直接覆盖缓存,不做版本检查。重建期间的新写入可能被旧快照覆盖,需要用版本号或时间窗口保护。
- 把点赞、阅读、收藏的阈值设成同一个值。不同互动的量级和业务敏感度不同,阈值必须分别配置。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。