一个人连点二十个赞,用户关掉了通知权限
「回声」的通知服务最初只有一个判断:互动事件的接收者不是操作者本人,就发推送。点赞、评论、回复、@ 都走同一条路径,事件写入消息队列后立即投递。
一位创作者在半小时内收到二十条推送,全部来自同一个人连续点赞他的二十条动态。每一条都写着「某某赞了你的动态」。他先关掉了点赞通知,随后把整个应用的通知权限关掉。案例假设值:这位创作者是平台头部作者,后续一周没有打开过 App,运营后台的推送到达率没有异常,但内容发布频率下降了四成。
团队一开始想限制「每人每天最多二十条推送」,但这样会误伤真实互动:二十个不同的人点赞,和同一个人点赞二十次,价值完全不同。问题不是推送总量,而是触发边界没有定义:谁的操作应该通知谁、什么关系下不通知、多短的时间窗口内合并。
通知是产品里少数「做错一次就会被永久关闭」的能力。用户关掉权限后,后续再好的内容也触达不了。触发规则必须在服务端收口,不能靠客户端去重或运营事后补发。
通知触发要先过四道边界
一条互动事件从产生到推送,至少要经过四道判断。任何一道缺失,都会在特定场景下放大成骚扰。
| 边界 | 判定 | 不通过时的处理 | 缺失的后果 |
|---|---|---|---|
| 自操作 | ActorID == OwnerID | 不通知 | 自己点赞自己的内容也推送 |
| 屏蔽关系 | 操作者在接收者的 mute 列表 | 不通知 | 被拉黑的人仍能反复触发通知 |
| 事件有效性 | Action 合法、时间戳不晚于当前时间 | 丢弃并记录 | 脏数据或时钟漂移造成误发 |
| 聚合窗口 | 同一 (Owner, Target, Action) 窗口内只发一次 | 合并为「和另外 N 人赞了」 | 连点、刷屏、脚本攻击 |
自操作是最好判断的一条,但很多系统仍然漏掉。评论作者回复自己的文章、用户给自己的动态点赞、运营账号给自己发 mention,都会产生 ActorID == OwnerID 的事件。通知服务应该在入口直接丢弃,而不是等推送网关再过滤。
屏蔽关系要区分「接收者屏蔽操作者」和「操作者屏蔽接收者」。前者不应该收到通知,后者通常也不应该,因为被屏蔽者继续互动往往带有骚扰意图。拉黑、静音、隐藏动态应该共用一份关系判定,避免三套名单各写一遍。
聚合窗口解决的是短时间重复。同一个用户连续点赞二十条动态,如果 TargetID 不同,按 (Owner, Target, Action) 聚合不会合并成一条;这需要产品层决定是把点赞通知按作者聚合,还是接受二十条。更常见的刷屏是同一个目标被反复操作:取消赞再点赞、反复编辑评论、脚本连续 @。窗口按目标聚合能挡住这一类。
逐条通知还是聚合通知
逐条通知保留细节,适合评论、回复和 @;聚合通知降低打扰,适合点赞、收藏和关注。两者不是互斥的:评论逐条发,点赞在窗口内合并成「某某和另外 3 人赞了你的动态」。
聚合的代价是丢失即时性。用户看到「3 人赞了」时,可能已经过了十分钟;如果产品强依赖即时反馈,窗口应该短一些,或者只对高频动作聚合。另一个代价是状态管理:聚合需要记录上一次通知的时间,这份状态放在内存里会随实例重启丢失,放在 Redis 里又要处理并发写入。
本节的 ShouldNotify 用调用方传入的 last map 记录窗口,保持函数无全局状态。生产实现可以把 last 换成 Redis 的 SET NX EX,但判定顺序和 key 维度应该保持一致。
失败场景:点赞风暴与沉默的拉黑
回到那位创作者。二十条点赞来自同一个人,TargetID 是二十条不同的动态。按 (Owner, Target, Action) 聚合,二十条不会被合并;按 (Owner, Action) 聚合,又会把不同人的点赞混在一起,失去「谁赞了哪条」的细节。
更隐蔽的问题是拉黑没有进入通知链路。创作者把骚扰者加入黑名单后,对方仍然能通过点赞、评论和 @ 触发通知,因为通知服务只查了「是否关注」,没有查「是否被屏蔽」。用户以为拉黑等于静音,实际只是看不到对方的内容,通知照常到达。
第三个问题是自操作。创作者用小号给自己的动态点赞做测试,结果主号收到推送;运营账号在评论区回复自己的文章,也收到「有人回复了你」。这些事件量不大,但会让用户对通知的准确性产生怀疑。
修正方案分三层。第一层在事件入口做硬过滤:自操作、屏蔽关系、非法 Action、未来时间戳直接丢弃,并记录原因指标。第二层做窗口聚合:同一 (Owner, Target, Action) 在窗口内只发一次,后续事件累加计数。第三层做产品聚合:点赞按作者和时间窗合并成一条摘要,评论和 @ 保持逐条。三层都做完,案例中的二十条推送才会变成一条「某某赞了你的 20 条动态」。
用 ShouldNotify 收口触发规则
通知规则散落在各个业务服务里,每个服务对「自己」「拉黑」「重复」的理解都会不一样。把判定收口到一个纯函数,至少能保证点赞、评论和 @ 使用同一套边界。
下面的代码来自本书配套的 webapp-lab。ShouldNotify 按固定顺序检查事件有效性、自操作、屏蔽关系、未来时间戳和聚合窗口;通过时返回可读原因并更新 last,不通过时返回具体原因,方便打点和排查。
// file: internal/comment/notify.go
// tier: L1
// verify: go test ./internal/comment/
package comment
import "fmt"
// Action 是互动类型。不同 Action 分开聚合,点赞不会吃掉评论通知。
type Action string
const (
ActionLike Action = "like"
ActionComment Action = "comment"
ActionReply Action = "reply"
ActionMention Action = "mention"
)
// Event 是一条待判定的互动事件。OwnerID 是被互动内容的作者,
// TargetID 是被互动的对象(动态、评论或会话)。
type Event struct {
ID string
ActorID string
OwnerID string
TargetID string
Action Action
At int64
}
// ShouldNotify 判断一条互动是否应该产生通知,并返回可读原因。
// 自己被自己操作、作者被屏蔽、时间戳晚于 now、以及同一
// (Owner, Target, Action) 在 aggregateWindow 秒内重复触发都不通知。
// 通知成立时会更新 last,调用方传入 nil 则跳过聚合记录。
func ShouldNotify(e Event, muted map[string]bool, aggregateWindow, now int64, last map[string]int64) (bool, string) {
if e.ActorID == "" || e.OwnerID == "" || e.TargetID == "" {
return false, "invalid event: actor, owner and target are required"
}
if !validAction(e.Action) {
return false, fmt.Sprintf("invalid action %q", e.Action)
}
if e.ActorID == e.OwnerID {
return false, "self action: actor owns the target"
}
if muted[e.ActorID] {
return false, "actor is muted by owner"
}
if e.At > now {
return false, "event timestamp is in the future"
}
if aggregateWindow > 0 && last != nil {
key := aggregateKey(e)
if prev, ok := last[key]; ok && now-prev < aggregateWindow {
return false, fmt.Sprintf("aggregated: %s within %d seconds", e.Action, aggregateWindow)
}
last[key] = now
}
return true, fmt.Sprintf("notify: %s from %s", e.Action, e.ActorID)
}
func aggregateKey(e Event) string {
return e.OwnerID + "\x00" + e.TargetID + "\x00" + string(e.Action)
}
func validAction(a Action) bool {
switch a {
case ActionLike, ActionComment, ActionReply, ActionMention:
return true
default:
return false
}
}
代码里有几个刻意的选择。
- 先校验字段和 Action,再判断关系。缺少
ActorID或TargetID的事件说明上游写坏了,应该在入口丢弃并报警,而不是当成正常互动继续往下走。 - 自操作判断放在屏蔽之前。
ActorID == OwnerID是最确定的无效事件,先短路可以减少后续 map 查询;顺序不影响结果,但影响指标归因。 - 屏蔽判断用
muted[ActorID],key 是操作者。接收者屏蔽的是「谁」,不是「哪条内容」;按内容屏蔽应该在列表查询层做,不应该复用通知的 mute 表。 - 未来时间戳直接拒绝。事件来自客户端或跨机房消息时,时钟漂移很常见;允许未来事件会占住聚合窗口,把后续正常事件挤掉。
- 聚合 key 包含
Owner、Target、Action三个维度。只按Owner聚合会把不同动态的点赞混在一起,只按Target聚合会让不同接收者互相影响。 - 窗口用
now - prev < aggregateWindow,而不是At - prev。now是服务端处理时间,不受客户端时间影响;用事件时间做窗口,客户端改时间就能绕过聚合。 last为 nil 时跳过聚合,但仍然返回通知结果。调用方可以选择不聚合,但不能因为没传状态就把事件丢掉。
ShouldNotify 只回答「是否通知」和「为什么」,不负责推送渠道、模板和重试。生产系统通常还会加一层「用户通知偏好」:有人关闭点赞通知但保留评论通知,有人只接收 @。偏好判断应该在 ShouldNotify 之后、推送之前,避免把偏好和触发边界混成一个巨型函数。
测试:自己、拉黑、窗口、维度
通知测试要覆盖四类边界:自己操作自己、被屏蔽者、窗口内重复、不同维度不互相聚合。任何一类缺失,线上都可能出现「连点刷屏」或「正常互动被吞」。
下面的测试用固定的 now 和 last map 验证四类场景,并检查拒绝原因是否包含可读关键词,避免实现只返回一个布尔值、排查时无法定位。
// file: internal/comment/notify_test.go
// tier: L1
// verify: go test ./internal/comment/
package comment
import (
"strings"
"testing"
)
func TestShouldNotifySelfAndMuted(t *testing.T) {
now := int64(1000)
if ok, reason := ShouldNotify(Event{ActorID: "u1", OwnerID: "u1", TargetID: "p1", Action: ActionLike, At: now}, nil, 60, now, nil); ok || !strings.Contains(reason, "self") {
t.Fatalf("self action = %v %q", ok, reason)
}
muted := map[string]bool{"spammer": true}
if ok, reason := ShouldNotify(Event{ActorID: "spammer", OwnerID: "u1", TargetID: "p1", Action: ActionComment, At: now}, muted, 60, now, nil); ok || !strings.Contains(reason, "muted") {
t.Fatalf("muted actor = %v %q", ok, reason)
}
}
func TestShouldNotifyAggregatesWindow(t *testing.T) {
now := int64(1000)
last := make(map[string]int64)
first := Event{ActorID: "u2", OwnerID: "u1", TargetID: "p1", Action: ActionLike, At: now}
if ok, _ := ShouldNotify(first, nil, 60, now, last); !ok {
t.Fatal("first like should notify")
}
repeat := first
repeat.At = now + 10
if ok, reason := ShouldNotify(repeat, nil, 60, now+10, last); ok || !strings.Contains(reason, "aggregated") {
t.Fatalf("repeat like = %v %q", ok, reason)
}
repeat.At = now + 61
if ok, _ := ShouldNotify(repeat, nil, 60, now+61, last); !ok {
t.Fatal("like after window should notify again")
}
}
func TestShouldNotifySeparatesDimensions(t *testing.T) {
now := int64(1000)
last := make(map[string]int64)
base := Event{ActorID: "u2", OwnerID: "u1", TargetID: "p1", Action: ActionLike, At: now}
if ok, _ := ShouldNotify(base, nil, 60, now, last); !ok {
t.Fatal("first event should notify")
}
otherTarget := base
otherTarget.TargetID = "p2"
if ok, _ := ShouldNotify(otherTarget, nil, 60, now+1, last); !ok {
t.Fatal("different target must notify")
}
otherAction := base
otherAction.Action = ActionReply
if ok, _ := ShouldNotify(otherAction, nil, 60, now+2, last); !ok {
t.Fatal("different action must notify")
}
}
func TestShouldNotifyRejectsBadEvents(t *testing.T) {
now := int64(1000)
cases := []struct {
name string
e Event
want string
}{
{"missing actor", Event{OwnerID: "u1", TargetID: "p1", Action: ActionLike, At: now}, "invalid event"},
{"unknown action", Event{ActorID: "u2", OwnerID: "u1", TargetID: "p1", Action: "share", At: now}, "invalid action"},
{"future", Event{ActorID: "u2", OwnerID: "u1", TargetID: "p1", Action: ActionLike, At: now + 1}, "future"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if ok, reason := ShouldNotify(tc.e, nil, 60, now, nil); ok || !strings.Contains(reason, tc.want) {
t.Fatalf("got %v %q, want reason containing %q", ok, reason, tc.want)
}
})
}
}
TestShouldNotifyAggregatesWindow 是最值得看的一条。它验证第一次点赞通知、十秒内重复不通知、六十一秒后再次通知。窗口边界用 < 而不是 <=,意味着恰好等于窗口长度时允许再次通知;这个选择要写进测试,否则不同实现会在边界上产生分歧。
TestShouldNotifySeparatesDimensions 验证不同目标、不同 Action 不会被同一个 key 吞掉。只按作者聚合的实现会在这里失败:第二个目标的点赞被误判为重复,用户永远收不到另一条动态的互动。
TestShouldNotifyRejectsBadEvents 覆盖缺字段、非法 Action 和未来时间戳。这些事件在测试环境不常见,但在跨机房消息、客户端补发和脚本攻击里很常见;入口不拒绝,脏数据就会进入聚合状态。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/comment/ -run 'TestShouldNotify' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestShouldNotifySelfAndMuted
--- PASS: TestShouldNotifySelfAndMuted (0.00s)
=== RUN TestShouldNotifyAggregatesWindow
--- PASS: TestShouldNotifyAggregatesWindow (0.00s)
=== RUN TestShouldNotifySeparatesDimensions
--- PASS: TestShouldNotifySeparatesDimensions (0.00s)
=== RUN TestShouldNotifyRejectsBadEvents
=== RUN TestShouldNotifyRejectsBadEvents/missing_actor
=== RUN TestShouldNotifyRejectsBadEvents/unknown_action
=== RUN TestShouldNotifyRejectsBadEvents/future
--- PASS: TestShouldNotifyRejectsBadEvents (0.00s)
--- PASS: TestShouldNotifyRejectsBadEvents/missing_actor (0.00s)
--- PASS: TestShouldNotifyRejectsBadEvents/unknown_action (0.00s)
--- PASS: TestShouldNotifyRejectsBadEvents/future (0.00s)
PASS
ok webapp/internal/comment 0.231s
PASS 表示自操作、屏蔽、聚合窗口、维度隔离和坏事件五类用例全部通过。输出里的耗时是纯内存判断的耗时,不代表推送网关或 Redis 的延迟;线上通知的难点在状态一致性、并发窗口和用户偏好,而不是这段 if 判断。
小结
通知触发要有边界,而不是「不是自己就发」。自操作、屏蔽关系、事件有效性和聚合窗口是四道基本闸门;逐条通知适合评论、回复和 @,聚合通知适合点赞、收藏和关注。聚合 key 的维度决定了哪些事件会被合并,选错维度会让正常互动被吞掉。
ShouldNotify 把判定顺序、原因文本和窗口语义固定下来,调用方可以换存储、换推送渠道,但边界不能变。评论区的建树、折叠和通知三件事在这里收口:树决定展示结构,折叠决定默认可见性,通知决定哪些互动值得打扰用户。
阅读导航:上一节:7.2 评论审核与折叠 · 下一节:8.1 会话、消息与序号 。
常见错误与边界
- 只判断「不是自己」就发通知。缺少屏蔽、窗口和有效性判断,连点、脚本和拉黑后的骚扰都会直接变成推送。
- 把拉黑只做在内容列表。通知链路不查屏蔽关系,用户仍会被拉黑者反复打扰;关系判定要在所有出口共用。
- 用事件时间做聚合窗口。客户端时间可以篡改,跨机房时钟也会漂移;窗口应该基于服务端处理时间。
- 聚合 key 只包含作者。不同动态的点赞被合并,用户看不到具体是哪条内容;key 至少要包含接收者、目标和动作。
- 聚合 key 包含操作者。同一个人连续点赞会被拆成多条,窗口失去意义;操作者应该作为计数维度,而不是 key 维度。
- 允许未来时间戳进入窗口。未来事件会占住 key,把后续正常通知挤掉;入口应该直接拒绝并记录。
- 把用户通知偏好塞进触发函数。偏好是「想不想收」,触发边界是「该不该发」,两者混在一起后规则无法独立测试。
- 窗口状态只放进程内存。多实例部署时每个实例各记一份,聚合失效;生产实现要用共享存储或按用户分片。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。