「回声」用户点了赞,数字却没有变
「回声」的内容卡片右下角有三个互动按钮:点赞、评论、收藏。点赞和收藏是即时动作,评论会打开输入框。产品经理给这一版定的验收标准很朴素:点一下赞,按钮变红,旁边的数字加一;再点一下取消,按钮变灰,数字减一。
上线当天晚上,值班同学收到一条用户反馈:点赞按钮已经变红,刷新页面也还是红的,但旁边的数字一直是 0。用户又点了一次取消,按钮变灰,数字仍然是 0。再点一次,按钮变红,数字还是 0。用户开始怀疑自己的操作没有生效,连续点了十几次。
查日志后发现两条链路都成功了:点赞关系表里确实写入了 user-42 -> post-7 的记录,接口也返回了 200;但计数服务那边,更新请求在消息队列里积压了十几秒,前端拿到的仍是缓存里的旧值。更麻烦的是,取消点赞时计数服务按“减一”处理,而最初那次加一还没消费到,于是缓存计数被减成了负数,展示层又把它截断成 0。
这个事故不是某一个服务的 bug,而是三个概念被混在了一起:用户和内容之间的互动关系、用来展示的计数、以及两者之间的同步方式。把它们拆开之后,问题才会变得可讨论。
点赞是关系,计数是物化结果
点赞的本质是一条关系:某个用户在某个时间对某条内容表达过态度。它可以被反查、被取消、被用来做推荐特征。计数则是这条关系的物化结果,是为了让列表页不用对关系表做 COUNT(*) 而提前算好的数字。
评论和收藏看起来也是“加一”,但要求并不相同。评论本身是一条有正文、有作者、有审核状态的记录,评论数只是它的附属统计;收藏既要能反查“我收藏了什么”,也要能回答“这条内容被多少人收藏”。三种互动可以共用一套增量模型,但不能共用同一条一致性策略。
| 互动 | 主要数据 | 计数的用途 | 一致性要求 | 能否接受短时偏差 |
|---|---|---|---|---|
| 点赞 | 用户与内容的边 | 列表页展示热度 | 最终一致即可 | 可以,通常容忍数秒 |
| 评论 | 带正文的记录 | 展示讨论规模 | 不能少一条,也不能凭空多一条 | 基本不能,至少不能少 |
| 收藏 | 用户与内容的边 | 展示收藏量、支持反查 | 写后可读,取消后要能对账 | 短暂偏差可接受,反查必须准 |
这张表决定了很多实现细节。点赞可以走异步计数,因为用户刷新后看到数字晚一秒并不影响他对“我赞过”的判断;评论数不能异步丢消息,因为一条评论已经出现在评论区、计数却没涨,用户会认为系统吞了内容。收藏的难点在反查:计数可以近似,但“我收藏的列表”必须精确,否则用户找不到自己刚收藏的文章。
关系写入和计数更新为什么要分开
如果把点赞关系和计数放在同一张表、同一个事务里,写入会变成“关系表插入 + 计数行更新”两件事。关系表通常按用户或内容分片,计数行则按内容聚合,两者的分片键不同,跨分片事务的代价很高。更现实的做法是:关系写入是同步的、强一致的;计数更新是异步的、可重放的。
分开之后,计数服务必须能回答两个问题:一条增量是否已经应用过,以及应用失败后如何重试。前者靠幂等键,后者靠消息队列的至少一次投递加消费端去重。只做重试不做去重,取消点赞的减一可能被执行两次,数字就会比真实值少。
前端看到的“已赞”和数字来自不同数据源
按钮状态来自关系表或关系缓存,数字来自计数缓存。两个数据源更新有先后,就会出现“按钮已经变红、数字还没变”的中间态。这个中间态本身可以接受,但不能长期存在,也不能让数字变成负数或回退到明显错误的值。
产品上常见的处理是:按钮状态以关系为准,数字允许短暂滞后;当计数服务不可用时,数字展示上一次的稳定值,而不是展示 0。技术上则要给计数加下界保护:任何一次扣减都不能让结果小于 0,否则一个乱序的减一就能把展示值打穿。
失败场景:关系写成功,计数更新失败
回到那次事故,完整的链路是这样:客户端调用点赞接口,接口在关系表插入 (user-42, post-7, like),然后向消息队列发送一条 delta=+1 的消息,最后返回成功。计数消费者从队列取消息,先读缓存里的旧值 0,再加一写回。正常情况下这没问题。
故障发生在消费者重启的那个窗口。消息已经投递,但消费者在处理到一半时崩溃,偏移量没有提交;重启后同一条消息被再次投递,而此时缓存里已经是 1,再加一就变成 2。另一条取消点赞的消息因为队列积压,晚于重复消息到达,减一后变成 1。真实关系已经不存在,展示数字却还是 1。
更糟的是负数问题。如果取消点赞先于点赞被消费,计数器从 0 减到 -1,展示层直接把负数截断成 0;等点赞消息到达,又从 -1 加到 0。用户看到的现象就是“点了赞数字不动,取消后数字也不动”,和最初那条反馈完全一致。
这类故障有三个后果。第一,用户对按钮状态和数字的信任被破坏,反复点击会放大流量。第二,计数偏差会进入热度排序,影响内容分发,而且偏差会随着时间累积。第三,事后对账缺少幂等键,很难判断哪条增量重复、哪条丢失,只能整体重算。
修正要同时做三件事:增量消息带唯一 ID,消费端按 ID 去重;计数应用保证结果非负;定期用关系表重算计数并与缓存对账。前两件是止血,第三件才是长期兜底。下一节会展开对账,这一节先把增量的形状固定下来。
用 Counter 和 Apply 固定增量形状
计数逻辑最容易出的问题不是算法复杂,而是入口太松:counter 传了未知字符串、target 为空、一次批量里混进负数,代码却默默接受。把 key 和增量写成结构体,把校验放进 Apply,错误就会在写入边界暴露。
下面的代码来自本书配套的 webapp-lab。Apply 先把同一批增量求和,再一次性加到 base 上;它不修改入参,不做原地排序,也不依赖调用顺序。未知 counter、空 target、int64 溢出和负数结果都会返回 error。
// file: internal/counter/count.go
// tier: L1
// verify: go test ./internal/counter/
package counter
import (
"fmt"
"math"
)
// Counter 是互动计数的种类。点赞、评论、收藏的写入路径不同,
// 但都需要在同一套计数模型上做增减,不能用 bool 或裸字符串区分。
type Counter string
const (
Like Counter = "like"
Comment Counter = "comment"
Favorite Counter = "favorite"
)
// Key 定位一个目标的某一类计数。TargetID 是内容 ID,不能为空;
// Counter 必须是 Like、Comment、Favorite 之一。
type Key struct {
Counter Counter
TargetID string
}
func (k Key) validate() error {
if k.TargetID == "" {
return fmt.Errorf("counter: empty target id")
}
switch k.Counter {
case Like, Comment, Favorite:
return nil
default:
return fmt.Errorf("counter: unknown counter %q", k.Counter)
}
}
// Delta 是一条待应用的计数增量。Value 可以为负(取消点赞、删评论),
// 但是否允许扣减由具体业务决定,Apply 只保证结果不为负。
type Delta struct {
Key Key
Value int64
}
// Apply 把一批增量累加到 base 上,返回新值。它不修改调用方传入的切片,
// 也不依赖 Delta 的顺序:同一 key 的多条增量会先求和再一次性应用。
// 未知 counter、空 target、int64 溢出和负数结果都会返回 error。
func Apply(base int64, deltas []Delta) (int64, error) {
var sum int64
for i, d := range deltas {
if err := d.Key.validate(); err != nil {
return 0, fmt.Errorf("counter: delta %d: %w", i, err)
}
if d.Value > 0 && sum > math.MaxInt64-d.Value {
return 0, fmt.Errorf("counter: delta %d overflows int64: %d + %d", i, sum, d.Value)
}
if d.Value < 0 && sum < math.MinInt64-d.Value {
return 0, fmt.Errorf("counter: delta %d underflows int64: %d + %d", i, sum, d.Value)
}
sum += d.Value
}
if sum > 0 && base > math.MaxInt64-sum {
return 0, fmt.Errorf("counter: base %d plus sum %d overflows int64", base, sum)
}
if sum < 0 && base < math.MinInt64-sum {
return 0, fmt.Errorf("counter: base %d plus sum %d underflows int64", base, sum)
}
result := base + sum
if result < 0 {
return 0, fmt.Errorf("counter: result would be negative: %d + %d = %d", base, sum, result)
}
return result, nil
}
这里有一个刻意的取舍:Apply 先求和再判断负数,而不是每加一条就检查。批量里的 -3 和 +5 如果按顺序应用,中间会出现负数,但最终结果是合法的;按业务语义,这一批增量代表同一个事务窗口内的净变化,应该看最终值。如果业务要求“任何一步都不能为负”,调用方应该把批量拆成单条,而不是让 Apply 猜。
溢出检查看起来啰嗦,但计数是长期累积的字段,一旦溢出回绕,展示层会出现一个巨大的负数或正数。Go 的 int64 加法不会 panic,只会在运行时悄悄回绕,所以必须在应用前用 MaxInt64 - value 和 MinInt64 - value 判断。
测试:同一 key 的多条增量必须累加
计数模型最重要的性质有三个:同 key 的多条增量累加而不是覆盖;非法 key 被拒绝;入参不被修改。测试直接围绕这三点写,而不是只测一个 1+1=2。
// file: internal/counter/count_test.go
// tier: L1
// verify: go test ./internal/counter/
package counter
import (
"math"
"reflect"
"testing"
)
func TestApplyAccumulatesSameKey(t *testing.T) {
deltas := []Delta{
{Key: Key{Counter: Like, TargetID: "post-1"}, Value: 2},
{Key: Key{Counter: Like, TargetID: "post-1"}, Value: 3},
{Key: Key{Counter: Like, TargetID: "post-1"}, Value: -1},
}
before := append([]Delta(nil), deltas...)
got, err := Apply(10, deltas)
if err != nil {
t.Fatalf("Apply = %v, want nil", err)
}
if got != 14 {
t.Fatalf("Apply = %d, want 14", got)
}
if !reflect.DeepEqual(deltas, before) {
t.Fatalf("Apply mutated input: got %+v, want %+v", deltas, before)
}
}
func TestApplyAllowsZeroAndNegativeBaseErrors(t *testing.T) {
got, err := Apply(0, nil)
if err != nil || got != 0 {
t.Fatalf("Apply(0, nil) = %d, %v; want 0, nil", got, err)
}
got, err = Apply(1, []Delta{{Key: Key{Counter: Favorite, TargetID: "post-1"}, Value: 0}})
if err != nil || got != 1 {
t.Fatalf("Apply zero delta = %d, %v; want 1, nil", got, err)
}
if _, err := Apply(1, []Delta{{Key: Key{Counter: Like, TargetID: "post-1"}, Value: -2}}); err == nil {
t.Fatal("Apply to negative = nil, want error")
}
}
func TestApplyRejectsBadKeys(t *testing.T) {
cases := []struct {
name string
key Key
}{
{"empty-target", Key{Counter: Like, TargetID: ""}},
{"unknown-counter", Key{Counter: Counter("share"), TargetID: "post-1"}},
{"empty-counter", Key{TargetID: "post-1"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := Apply(0, []Delta{{Key: c.key, Value: 1}})
if err == nil {
t.Fatalf("Apply(%+v) = nil, want error", c.key)
}
})
}
}
func TestApplyDetectsOverflow(t *testing.T) {
big := []Delta{
{Key: Key{Counter: Like, TargetID: "post-1"}, Value: math.MaxInt64},
{Key: Key{Counter: Like, TargetID: "post-1"}, Value: 1},
}
if _, err := Apply(0, big); err == nil {
t.Fatal("Apply overflow = nil, want error")
}
if _, err := Apply(math.MaxInt64, []Delta{{Key: Key{Counter: Like, TargetID: "post-1"}, Value: 1}}); err == nil {
t.Fatal("Apply base overflow = nil, want error")
}
}
TestApplyAccumulatesSameKey 传入三条同一 key 的增量 +2、+3、-1,期望从 10 变成 14。它在调用前复制了一份入参,调用后用 reflect.DeepEqual 比较,确保 Apply 没有偷偷排序或改写切片。这个检查很重要:很多实现为了合并同 key 增量会原地修改 deltas,调用方如果还要复用这批数据,就会拿到被改过的值。
TestApplyRejectsBadKeys 用子测试覆盖空 target、未知 counter 和空 counter。错误信息里带上 delta 的下标,线上排查时能直接定位是批次里的第几条。TestApplyDetectsOverflow 则分别验证“增量求和溢出”和“base 加 sum 溢出”,这两条路径的边界不同,不能只测一个。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/counter/ -run 'TestApply' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestApplyAccumulatesSameKey
--- PASS: TestApplyAccumulatesSameKey (0.00s)
=== RUN TestApplyAllowsZeroAndNegativeBaseErrors
--- PASS: TestApplyAllowsZeroAndNegativeBaseErrors (0.00s)
=== RUN TestApplyRejectsBadKeys
=== RUN TestApplyRejectsBadKeys/empty-target
=== RUN TestApplyRejectsBadKeys/unknown-counter
=== RUN TestApplyRejectsBadKeys/empty-counter
--- PASS: TestApplyRejectsBadKeys (0.00s)
--- PASS: TestApplyRejectsBadKeys/empty-target (0.00s)
--- PASS: TestApplyRejectsBadKeys/unknown-counter (0.00s)
--- PASS: TestApplyRejectsBadKeys/empty-counter (0.00s)
=== RUN TestApplyDetectsOverflow
--- PASS: TestApplyDetectsOverflow (0.00s)
PASS
ok webapp/internal/counter 0.349s
PASS 表示四个顶层用例和三个子用例全部通过,ok 行末尾是包路径和本次耗时。0.00s 是单个用例的耗时,不代表真实系统的计数写入延迟;真实链路还包含关系表事务、消息投递、缓存读写和消费端去重,不能拿单元测试耗时估算端到端延迟。
小结
点赞、评论、收藏都可以抽象成“对某个目标的某类计数加一或减一”,但抽象只应该停在增量模型这一层。点赞关系可以最终一致,评论数不能丢,收藏要支持精确反查;把三者的策略混成一套,必然会在某一条路径上出问题。
Counter、Key、Delta 和 Apply 解决的是最底层的问题:增量必须是合法的、可累加的、不会把计数打成负数的。它不解决消息重复、乱序和对账,那些属于下一节。先把这一层写对,后面的重试和重建才有意义。
下一节讨论计数一致性:精确计数、近似计数和对账重建分别在什么场景下使用,以及大 V 的点赞数为什么常常来自缓存。
阅读导航:上一节:目录与学习路径 · 下一节:6.2 计数一致性:精确、近似与对账 。
常见错误与边界
- 把点赞关系塞进用户表的一个数组字段。关系需要反查、取消和分页,数组会随着用户量增长变成不可维护的大字段。
- 关系写入和计数更新放在同一个跨分片事务里。两者分片键不同,事务代价高,还会把计数服务的故障放大到点赞接口。
- 用
UPDATE counter SET value = value + 1却不做幂等。消息至少一次投递时,重复消息会让计数多涨一次。 - 允许计数减成负数。乱序的取消点赞会先把计数打到 0 以下,展示层截断后用户看到的就是“数字不动”。
- 用评论数代替评论列表的真实条数。审核删除、作者删除和反垃圾拦截都会改变可见评论数,两者需要明确的同步策略。
- 把收藏计数和收藏列表当成同一个数据源。计数可以缓存或近似,列表必须精确,否则用户找不到刚收藏的内容。
- 只在入口校验 counter,不校验 target。空 target 会把不同内容聚到同一个 key 上,计数会串。
- 用浮点数存计数。计数是整数语义,浮点累加会产生舍入误差,排序和展示都会出现奇怪的小数。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。