「查看更多回复」点开之后,评论重复了
「回声」的一篇长文下面有三千多条评论。产品要求每条根评论下面先展示两条回复,点「查看更多回复」再展开剩余部分。开发用 offset 做回复分页:第一次取 offset=0, limit=2,点开以后取 offset=2, limit=20。
上线后问题出现在热门文章上。用户点开「查看更多回复」时,前面两条回复突然又出现了一次;再点一次,又混进几条已经看过的内容。用户以为服务端重复写入了评论,客诉单里出现大量「评论重复」的反馈。
排查发现,评论没有重复入库。真正的原因是两次请求之间有人回复了同一条根评论:新回复插到了列表前面,第二次请求的 offset=2 已经指向了不同的位置。案例假设值:一条热门根评论每分钟新增 5 条回复,用户从点开到再次点击间隔十几秒,就足以让偏移量错位。
评论分页不是「把数组切一段」这么简单。它同时依赖三个决定:评论树有多深、每一层按什么排序、翻页用偏移量还是游标。三个决定没有定清楚,接口在测试数据上永远正常,一到热门内容就漂移。
评论树先定形状,再谈分页
评论区常见三种形状。形状不同,存储、查询和分页的代价完全不同,不能先写接口再补结构。
| 形状 | 存储 | 最大深度 | 分页方式 | 适合的产品 |
|---|---|---|---|---|
| 平铺 | 一张评论表,无父子关系 | 0 | 按时间或热度偏移分页 | 弹幕、短评、轻量留言 |
| 两层 | 根评论 + 回复,回复记录根 ID | 1 | 根评论分页,回复按根聚合 | 资讯、社区、大多数内容站 |
| 多级树 | ParentID 任意层级 | 产品上限,通常 3 到 5 | 根评论分页,子树按需展开 | 论坛、问答、深度讨论 |
两层结构是成本和体验的折中点。它保留了「回复某条评论」的语义,又不需要为每一层单独设计分页。多级树表达力更强,但渲染、审核、通知和删除都要递归处理,深度每增加一层,边界情况就多一批。
根评论的排序也要先定。按时间倒序符合「看最新讨论」的预期,按热度排序能把优质内容顶上来,但热度和时间的组合会让分页结果更不稳定:一条评论的点赞数在两次请求之间变化,就可能从第二页跳到第一页。对大多数产品,根评论按时间倒序、回复按时间正序是最稳的默认值。
根评论和回复要用不同的分页语义
根评论通常按时间倒序,用户关心「又有什么新讨论」。回复按时间正序,用户关心「这条评论下面发生了什么」。两种顺序都可以用偏移量,但都怕插入:根评论插入会让后续页重复,回复插入会让「查看更多」重复。
真正稳定的做法是游标分页:记录上一页最后一条的 (At, ID),下一页查询严格小于或大于这个组合。游标不依赖「前面有多少条」,新内容插入不会改变已翻过的位置。本节的 Page 只实现偏移分页,用来把边界语义固定下来;生产接口应该在 Page 之上换成游标。
失败场景:offset 分页遇到新评论
回到那篇三千条评论的文章。第一次请求根评论 offset=0, limit=20,返回 20 条,客户端记住 offset=20。用户读了五分钟,期间新增 12 条根评论。第二次请求 offset=20,服务端按最新顺序重新切数组,原来的第 8 到第 20 条被挤到了第二页开头,用户看到 12 条重复内容,同时有 12 条更早的评论被跳过。
回复的「查看更多」更容易复现。第一次展示两条回复,用户点开时又来了 5 条新回复,offset=2 指向的已经是原来的第 7 条,中间 5 条被跳过。如果客户端做了按 ID 去重,重复内容会消失,但被跳过的内容不会回来,用户看到的是一个无声的缺口。
后果不只是体验。客诉会先怀疑「重复写入」,排查方向被带偏;运营为了止血,把回复改成一次性全量返回,热门评论的响应体从几十 KB 涨到几 MB,又带来新的超时。最后团队才发现,问题不在数据库,而在分页契约没有包含「排序键 + 唯一键」。
修正方案有三步。第一,根评论和回复都改游标分页,游标由排序字段和评论 ID 组成。第二,客户端不再用页码推算偏移,只透传服务端给的游标。第三,保留 Page 作为内部工具,但明确它只适用于快照数据,不适用于持续插入的线上列表。
用 Build 固定树形,用 Page 隔离边界
建树逻辑散落在接口里,每个接口对孤儿评论、超深回复和同级排序的处理都会不一样。把规则收口到 Build,至少能保证同一份评论数据在不同入口得到同一棵树。
下面的代码来自本书配套的 webapp-lab。Build 先校验 ID、重复和自环,再按 At、ID 排序;父评论不存在时返回 ErrUnknownParent,父子成环时返回 ErrCycle;超过 maxDepth 的回复挂到第 maxDepth 层的祖先下面。Page 只做同层切片,并明确拒绝负偏移、非正 limit 和越界偏移。
// file: internal/comment/tree.go
// tier: L1
// verify: go test ./internal/comment/
package comment
import (
"errors"
"fmt"
"sort"
)
// Comment 是评论的持久化字段。AuthorID 用于审核和拉黑判定;
// RootID 是写入时冗余的根评论 ID,Build 只依赖 ParentID 建树。
type Comment struct {
ID string
ParentID string
RootID string
AuthorID string
At int64
Likes int
}
// Node 是建树后的评论节点。Folded 只由 Fold 标记,Build 保留原值。
type Node struct {
Comment
Children []Node
Folded bool
}
var (
// ErrInvalidDepth 表示 maxDepth 为负数。
ErrInvalidDepth = errors.New("comment: maxDepth must not be negative")
// ErrInvalidComment 表示评论缺少 ID、ID 重复或父子关系自环。
ErrInvalidComment = errors.New("comment: invalid comment")
// ErrUnknownParent 表示 ParentID 指向了不存在的评论。
ErrUnknownParent = errors.New("comment: unknown parent")
// ErrCycle 表示父子关系形成环。
ErrCycle = errors.New("comment: parent cycle")
// ErrPageRange 表示分页参数越界。
ErrPageRange = errors.New("comment: page range out of bounds")
)
// Build 把扁平评论列表组装成树。根评论是 ParentID 为空的那一层,
// 超过 maxDepth 的回复会挂到第 maxDepth 层的祖先下面,而不是继续加深。
// 同级节点按 At 升序排列,时间相同按 ID 升序排列。
func Build(list []Comment, maxDepth int) ([]Node, error) {
if maxDepth < 0 {
return nil, ErrInvalidDepth
}
byID := make(map[string]Comment, len(list))
for _, c := range list {
if c.ID == "" {
return nil, fmt.Errorf("%w: empty id", ErrInvalidComment)
}
if _, ok := byID[c.ID]; ok {
return nil, fmt.Errorf("%w: duplicate id %q", ErrInvalidComment, c.ID)
}
if c.ParentID == c.ID {
return nil, fmt.Errorf("%w: self parent %q", ErrInvalidComment, c.ID)
}
byID[c.ID] = c
}
sorted := make([]Comment, len(list))
copy(sorted, list)
sort.SliceStable(sorted, func(i, j int) bool {
if sorted[i].At != sorted[j].At {
return sorted[i].At < sorted[j].At
}
return sorted[i].ID < sorted[j].ID
})
type buildNode struct {
comment Comment
children []*buildNode
}
nodes := make(map[string]*buildNode, len(sorted))
for _, c := range sorted {
nodes[c.ID] = &buildNode{comment: c}
}
var roots []*buildNode
for _, c := range sorted {
path, err := parentChain(byID, c.ID)
if err != nil {
return nil, err
}
depth := len(path) - 1
if depth == 0 {
roots = append(roots, nodes[c.ID])
continue
}
anchor := c.ParentID
if depth > maxDepth {
anchor = path[depth-maxDepth]
}
nodes[anchor].children = append(nodes[anchor].children, nodes[c.ID])
}
var convert func([]*buildNode) []Node
convert = func(bs []*buildNode) []Node {
out := make([]Node, len(bs))
for i, b := range bs {
out[i] = Node{Comment: b.comment, Children: convert(b.children)}
}
return out
}
return convert(roots), nil
}
// parentChain 返回从 id 到根评论的 ID 路径,path[0] 是 id 自己。
// 路径长度减一就是该评论的深度,根评论的深度为 0。
func parentChain(byID map[string]Comment, id string) ([]string, error) {
seen := make(map[string]struct{}, 8)
path := make([]string, 0, 8)
cur := id
for {
if _, ok := seen[cur]; ok {
return nil, fmt.Errorf("%w at %q", ErrCycle, cur)
}
seen[cur] = struct{}{}
c, ok := byID[cur]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownParent, cur)
}
path = append(path, cur)
if c.ParentID == "" {
return path, nil
}
cur = c.ParentID
}
}
// Page 对同一层节点做偏移分页。offset 可以等于长度,表示空页;
// offset 大于长度、limit 非正数都会返回 ErrPageRange。
func Page(nodes []Node, offset, limit int) ([]Node, error) {
if offset < 0 || limit <= 0 || offset > len(nodes) {
return nil, fmt.Errorf("%w: offset=%d limit=%d len=%d", ErrPageRange, offset, limit, len(nodes))
}
end := offset + limit
if end > len(nodes) {
end = len(nodes)
}
out := make([]Node, end-offset)
copy(out, nodes[offset:end])
return out, nil
}
代码里有几个刻意的选择。
- 孤儿评论选择报错,而不是静默挂到根。静默挂根会把「父评论被删除」和「数据写坏」混在一起,排查时看不出差异;调用方需要展示孤儿时,可以捕获
ErrUnknownParent后自行降级。 - 超过
maxDepth的回复不是丢弃,而是挂到第maxDepth层的祖先下面。产品只展示三层,不代表更深的数据可以消失;把它们压平到可见层,至少内容还在。 - 同级排序用
At升序、ID升序兜底。只按At排序时,同一秒内的多条评论顺序不稳定,分页和快照 diff 都会随机抖动。 RootID不参与建树。它是写入时冗余的根评论 ID,用于按根查询回复;建树只认ParentID,避免两个字段冲突时出现两套树。Page返回新切片,不返回入参的子切片。调用方修改返回值的元素不会影响原树,分页结果可以安全地缓存或二次排序。maxDepth=0时所有回复都被压到根评论下面,等于退化成两层结构。这个行为是显式的,不需要调用方特判。
Build 是内存模型,不是生产查询计划。真实系统不会把三千条评论全部加载进内存再建树,而是按根评论分页、按根 ID 批量查回复;函数的价值在于固定孤儿、深度、排序和分页边界的语义,让数据库查询和内存模型可以互相校验。
测试:排序、截断、坏输入、分页边界
建树和分页的测试要覆盖四类边界:同级顺序、超深截断、坏输入、分页参数。任何一类缺失,线上都可能出现「顺序随机」「回复消失」或「翻页 500」。
下面的测试构造了两棵小树,分别验证同级按时间排序、超过 maxDepth 的回复被压平、五类坏输入返回预期错误,以及 Page 在空页和越界时的行为。
// file: internal/comment/tree_test.go
// tier: L1
// verify: go test ./internal/comment/
package comment
import (
"errors"
"reflect"
"testing"
)
func ids(nodes []Node) []string {
out := make([]string, len(nodes))
for i, n := range nodes {
out[i] = n.ID
}
return out
}
func TestBuildOrdersSiblingsAndNestsReplies(t *testing.T) {
list := []Comment{
{ID: "r1", At: 20, AuthorID: "u1"},
{ID: "r2", At: 10, AuthorID: "u2"},
{ID: "c2", ParentID: "r1", RootID: "r1", At: 30, AuthorID: "u3"},
{ID: "c1", ParentID: "r1", RootID: "r1", At: 15, AuthorID: "u4"},
}
roots, err := Build(list, 3)
if err != nil {
t.Fatalf("Build: %v", err)
}
if got, want := ids(roots), []string{"r2", "r1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("roots = %v, want %v", got, want)
}
if got, want := ids(roots[1].Children), []string{"c1", "c2"}; !reflect.DeepEqual(got, want) {
t.Fatalf("r1 children = %v, want %v", got, want)
}
}
func TestBuildClampsDepthToMaxDepth(t *testing.T) {
list := []Comment{
{ID: "r1", At: 1},
{ID: "c1", ParentID: "r1", At: 2},
{ID: "g1", ParentID: "c1", At: 3},
{ID: "gg1", ParentID: "g1", At: 4},
}
roots, err := Build(list, 1)
if err != nil {
t.Fatalf("Build: %v", err)
}
c1 := roots[0].Children[0]
if got, want := ids(c1.Children), []string{"g1", "gg1"}; !reflect.DeepEqual(got, want) {
t.Fatalf("c1 children = %v, want %v", got, want)
}
if len(c1.Children[0].Children) != 0 {
t.Fatalf("g1 should be clamped, got children %v", ids(c1.Children[0].Children))
}
}
func TestBuildRejectsBrokenInput(t *testing.T) {
cases := []struct {
name string
list []Comment
want error
}{
{"empty id", []Comment{{At: 1}}, ErrInvalidComment},
{"duplicate id", []Comment{{ID: "a"}, {ID: "a"}}, ErrInvalidComment},
{"self parent", []Comment{{ID: "a", ParentID: "a"}}, ErrInvalidComment},
{"unknown parent", []Comment{{ID: "a", ParentID: "missing"}}, ErrUnknownParent},
{"cycle", []Comment{{ID: "a", ParentID: "b"}, {ID: "b", ParentID: "a"}}, ErrCycle},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := Build(tc.list, 2); !errors.Is(err, tc.want) {
t.Fatalf("err = %v, want %v", err, tc.want)
}
})
}
}
func TestBuildRejectsNegativeDepth(t *testing.T) {
if _, err := Build(nil, -1); !errors.Is(err, ErrInvalidDepth) {
t.Fatalf("err = %v, want ErrInvalidDepth", err)
}
}
func TestPageBoundaries(t *testing.T) {
nodes := []Node{{Comment: Comment{ID: "a"}}, {Comment: Comment{ID: "b"}}, {Comment: Comment{ID: "c"}}}
got, err := Page(nodes, 1, 5)
if err != nil {
t.Fatalf("Page: %v", err)
}
if ids(got)[0] != "b" || len(got) != 2 {
t.Fatalf("Page = %v, want [b c]", ids(got))
}
empty, err := Page(nodes, 3, 2)
if err != nil || empty == nil || len(empty) != 0 {
t.Fatalf("empty page = %v, %v; want non-nil empty", empty, err)
}
for _, tc := range []struct{ offset, limit int }{{-1, 2}, {0, 0}, {4, 1}} {
if _, err := Page(nodes, tc.offset, tc.limit); !errors.Is(err, ErrPageRange) {
t.Fatalf("Page(%d,%d) err = %v, want ErrPageRange", tc.offset, tc.limit, err)
}
}
}
TestBuildClampsDepthToMaxDepth 是最值得看的一条。它构造了 r1 -> c1 -> g1 -> gg1 四层链,maxDepth=1 时要求 g1 和 gg1 都成为 c1 的子节点,而不是让 gg1 继续挂在 g1 下面。只检查「没有报错」不够,还要检查压平后的实际层级,否则一个只改深度字段、不改父子关系的实现也能通过。
TestBuildRejectsBrokenInput 用表驱动覆盖空 ID、重复 ID、自环、未知父评论和环。环检测不能只靠「父评论存在」:a -> b -> a 的每条边都能找到对方,但永远走不到根。parentChain 用 seen 集合在遍历路径上检测重复,才能在建树前失败。
TestPageBoundaries 同时验证两件事:offset=len 返回非 nil 的空页,越界偏移和非正 limit 返回 ErrPageRange。空页返回 nil 还是空切片会影响客户端判断,统一返回非 nil 能让契约更稳定。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/comment/ -run 'TestBuild|TestPage' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestBuildOrdersSiblingsAndNestsReplies
--- PASS: TestBuildOrdersSiblingsAndNestsReplies (0.00s)
=== RUN TestBuildClampsDepthToMaxDepth
--- PASS: TestBuildClampsDepthToMaxDepth (0.00s)
=== RUN TestBuildRejectsBrokenInput
=== RUN TestBuildRejectsBrokenInput/empty_id
=== RUN TestBuildRejectsBrokenInput/duplicate_id
=== RUN TestBuildRejectsBrokenInput/self_parent
=== RUN TestBuildRejectsBrokenInput/unknown_parent
=== RUN TestBuildRejectsBrokenInput/cycle
--- PASS: TestBuildRejectsBrokenInput (0.00s)
--- PASS: TestBuildRejectsBrokenInput/empty_id (0.00s)
--- PASS: TestBuildRejectsBrokenInput/duplicate_id (0.00s)
--- PASS: TestBuildRejectsBrokenInput/self_parent (0.00s)
--- PASS: TestBuildRejectsBrokenInput/unknown_parent (0.00s)
--- PASS: TestBuildRejectsBrokenInput/cycle (0.00s)
=== RUN TestBuildRejectsNegativeDepth
--- PASS: TestBuildRejectsNegativeDepth (0.00s)
=== RUN TestPageBoundaries
--- PASS: TestPageBoundaries (0.00s)
PASS
ok webapp/internal/comment 0.265s
PASS 表示建树、深度截断、坏输入和分页边界的用例全部通过。输出里的耗时是纯内存操作的耗时,不代表数据库查询延迟;线上评论列表的瓶颈通常在索引、按根批量查询和游标稳定性,而不是这段循环。
小结
评论先定形状,再定分页。两层结构覆盖大多数内容产品,多级树只在论坛、问答这类深度讨论场景才值得付出递归成本。根评论按时间倒序、回复按时间正序是稳妥默认值;无论哪种顺序,线上列表都应该用游标,而不是偏移量。
Build 固定了孤儿、深度、排序和坏输入的处理,Page 固定了分页边界。生产实现可以换存储、换查询、换游标,但这些语义不能变。下一节讨论评论审核与折叠,重点是如何在发布前后处理违规内容,以及折叠为什么不能等于删除。
阅读导航:上一节:目录与学习路径 · 下一节:7.2 评论审核与折叠 。
常见错误与边界
- 用
offset给持续插入的列表分页。新内容会把旧内容往后挤,用户看到重复或缺口;应该用(排序字段, ID)游标。 - 把孤儿评论静默挂到根。父评论被删除和数据写坏看起来一样,排查时无法区分;至少要在日志和指标里留下计数。
- 超过展示深度就丢弃回复。产品只展示三层,不代表数据可以消失;应该压平到可见层或按需展开。
- 同级只按时间排序。同一秒内的多条评论顺序不稳定,分页、缓存和快照 diff 都会随机抖动;需要唯一键兜底。
- 把
RootID和ParentID同时用于建树。两个字段冲突时会出现两套树;建树只认ParentID,RootID只用于查询。 - 分页参数不做校验。负偏移、零 limit 和越界偏移直接切片,轻则空结果,重则 panic;边界要显式返回错误。
- 在请求链路里加载整篇文章的评论再建树。热门内容一次几千条,内存和序列化都不可控;应该按根分页、按根批量取回复。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。