多级评论结构设计

社区类应用的场景离不开多级评论。本文介绍一种利用闭包表实现的多级评论结构,充分利用闭包表的优点实现高效CRUD。

多级评论

以即刻APP为例,可以看到在一个帖子(Post)的下面有很多一级评论,需要显示它们的发布时间,作者头像,昵称,点赞数。

1

每个一级评论下面有可能有二级评论,默认显示最先的两条,如果大于两条还会显示总的评论数目。

2

当点击“共12条回复时”查看所有关于这个一级评论的二级评论和多级评论。

3

如果有二级评论的回复,形成了更深层次的评论嵌套时,即刻这里的做法是只显示到二级评论,也就是更深级的评论嵌套只显示回复xxx,而不是继续显示嵌套评论。如果你使用微博的话,可以看到微博是显示三级评论嵌套的,比即刻多一级。

4

闭包表

闭包表(Closure Table),也称为路径枚举表,是一种数据库设计模式,用于有效地存储和查询树形或图形结构中的节点之间的关系。这种设计模式在处理具有层级关系的数据时特别有用,如评论系统中的多级评论、组织结构图、类别树等。

在闭包表模式中,除了主要的数据表(Comment表)之外,还会创建一个额外的表来存储节点之间的所有关系(即每个节点与其所有祖先节点之间的关系)。这个额外的表通常包含如下字段:

  • 祖先(Ancestor): 表示层级关系中的上级节点。
  • 后代(Descendant): 表示层级关系中的下级节点。
  • 深度(Depth): 表示两个节点之间的层级深度。

闭包表的优点:

  1. 灵活性:能够轻松地查询任意两个节点之间的关系,包括父子关系、所有后代、所有祖先等。
  2. 性能:通过单次查询就能够获取到完整的层级结构,提高了查询效率,尤其是在处理深层次的层级结构时。
  3. 简化操作:添加、移除或修改节点关系相对简单,特别是在节点移动或删除时,只需要更新关系表而不是整个树。
  4. 保持数据一致性:通过在关系表中存储层级关系,可以避免数据冗余和不一致性的问题。

使用闭包表的缺点是需要额外的存储空间来维护关系表,以及在修改层级结构时可能需要更新大量的记录。

例如,在评论系统中,可以创建一个名为CommentClosure的闭包表,用于存储每个评论与其所有祖先评论之间的关系。这样,无论评论层级有多深都可以通过一次查询来获取整个评论线。

我们使用闭包表主要就利用它能够一次查询获取整个评论线的优点,如果没有闭包表,在获取多级评论时就要递归获取评论的回复才能拿到全部的评论,这样会造成如果只设计了二级评论结构(比如即刻),需要递归查询 ReplyTo 字段才能找到全部的评论(因为只有二级评论,大于二级的评论结构要全部展平),因此多次查询数据库。

数据库设计

我们设计评论结构时不仅仅要使用CommentClosure,还要在Comment 表中使用 ReplyTo 字段记录评论是回复谁的。可能有的同学会问“闭包表的祖先字段不就可以找到是回复谁的了吗”,理论上确实是,但你不记录ReplyTo 字段的话会发现如果你想要一次查询数据库拿到全部数据并对应好谁是回复谁的评论十分麻烦,所以要记录ReplyTo 字段。

MongoDB和MySQL都一样(反正MySQL不能用外键和JOIN),字段设计如下:

字段

  1. Comment 表
    • ID: 评论的唯一标识符。
    • Content: 评论内容。
    • ReplyTo: 父级评论的ID(如果是根评论,则为NULL)。
  2. CommentClosure 表
    • AncestorID: 祖先节点ID。
    • DescendantID: 后代节点ID。
    • Depth: 两个节点之间的层级深度。

示例数据

假设Comment表中的数据如下:

ID Content ReplyTo
1 根评论1 NULL
2 根评论2 NULL
3 回复评论1 1
4 回复评论3 3

对应的CommentClosure表数据将是:

AncestorID DescendantID Depth
1 1 0
1 3 2
1 4 2
2 2 0
3 3 0
3 4 1
4 4 0

CRUD逻辑

插入根评论

当我们插入一个根评论时,首先在Comment表中添加一条记录。然后在CommentClosure表中添加一条记录,表示这个评论既是自己的祖先也是后代,深度为0。

示例:

假设插入一个新的根评论,内容为"根评论3"。

  1. Comment表插入前:

    ID Content ReplyTo
    1 根评论1 NULL
    2 根评论2 NULL
    3 回复评论1 1
    4 回复评论3 3
  2. 插入操作:

    INSERT INTO Comment (Content, ReplyTo) VALUES ('根评论3', NULL);
  3. CommentClosure表插入对应记录:

    假设新评论的ID为5。

    INSERT INTO CommentClosure (AncestorID, DescendantID, Depth) VALUES (5, 5, 0);
  4. Comment表和CommentClosure表插入后:

    • Comment:

      ID Content ReplyTo
      1 根评论1 NULL
      2 根评论2 NULL
      3 回复评论1 1
      4 回复评论3 3
      5 根评论3 NULL
    • CommentClosure:

      AncestorID DescendantID Depth
      1 1 0
      1 3 1
      1 4 2
      2 2 0
      3 3 0
      3 4 1
      4 4 0
      5 5 0

插入回复评论

当回复一个已存在的评论时,需要在Comment表中添加一条新记录,然后在CommentClosure表中根据被回复的评论添加新的关系记录。

示例:

假设回复评论ID为3的评论,回复内容为"回复评论3的回复"。

  1. 插入操作:

    INSERT INTO Comment (Content, ReplyTo) VALUES ('回复评论3的回复', 3);
  2. 更新CommentClosure:

    假设新评论的ID为6。

    • 首先,为新评论添加自引用记录。
    INSERT INTO CommentClosure (AncestorID, DescendantID, Depth) VALUES (6, 6, 0);
    • 接着,为每个祖先添加新的记录。由于是回复ID为3的评论,我们需要添加新评论与3的所有祖先(包括3本身)之间的关系。

      -- 假设手动执行,实际应用中通过查询自动完成
      INSERT INTO CommentClosure (AncestorID, DescendantID, Depth) VALUES (1, 6, 3);
      INSERT INTO CommentClosure (AncestorID, DescendantID, Depth) VALUES (3, 6, 1);
  3. CommentClosure表插入后:

AncestorID DescendantID Depth
1 1 0
1 3 1
1 4 2
1 6 3
2 2 0
3 3 0
3 4 1
3 6 1
4 4 0
5 5 0
6 6 0

在这个表中,新添加的行用于展示如何在插入新评论(例如,评论ID为6)后更新闭包表,以反映新评论与其祖先评论之间的关系。例如,如果评论6是回复评论3,那么我们会添加两条新记录:一条记录显示评论6直接回复评论3(深度为1),另一条记录显示评论6通过评论3回复评论1(深度为3),同时也会添加一条评论6自关联的记录(深度为0),表示它是自己的祖先和后代。

通过这样的方式,我们可以有效地管理和查询多级评论的层级结构。在实际应用中,插入和查询操作通常会通过编程语言中的数据库操作库来完成(ORM,上述SQL操作和手动过程仅供理解和演示之用。

查询Post下的第一级评论及其相关信息

假设每个评论都有PostId字段指向所属的Post,我们可以利用闭包表来实现对评论的高效查询。

查询所有第一级评论

SELECT Comment.*
FROM Comment
WHERE Comment.PostId = ? AND Comment.ReplyTo IS NULL
ORDER BY Comment.CreatedAt;

这个查询获取了指定Post的所有第一级评论,这是直接查询而不涉及闭包表的部分。

利用闭包表查询第一级评论的所有子评论数量

SELECT AncestorId, COUNT(*) AS TotalComments
FROM CommentClosure
WHERE AncestorId IN (
    SELECT ID
    FROM Comment
    WHERE PostId = ? AND ReplyTo IS NULL
)
AND Depth > 0
GROUP BY AncestorId;

这个查询利用了闭包表的优点,直接计算了每个第一级评论下的子评论总数(包括所有层级),无需递归查询。

查询某个评论下的所有子评论

利用闭包表,我们可以一次性获取到某个评论下的所有子评论,包括多级嵌套评论。

SELECT Child.*
FROM Comment AS Child
JOIN CommentClosure AS Closure ON Child.ID = Closure.DescendantId
WHERE Closure.AncestorId = ? -- 目标评论ID
ORDER BY Closure.Depth, Child.CreatedAt;

这个查询通过闭包表一次性获取了指定评论下的所有子评论,Closure.AncestorId = ?确保了我们只查询目标评论的后代。这种方法相比于逐个查询ReplyTo字段,不仅简化了查询逻辑,也大大提高了查询效率,尤其是在处理深层次评论结构时。

闭包表的优点体现

  1. 子评论计数:上述计数查询通过闭包表直接统计了每个第一级评论下的所有子评论数量,避免了复杂的递归查询,大大提高了性能。
  2. 获取所有层级的评论:通过闭包表的查询,我们可以一次性获取某个评论下的所有子评论,包括多级嵌套评论。这种方法避免了对每个ReplyTo字段的逐个递归查询,使得数据的提取更加高效和直观。

通过这种方式,我们可以充分利用闭包表的优势,实现对评论数据的高效管理和查询,尤其是在构建具有复杂层级关系的社区类应用场景中。

分页查询(使用游标)

分页查询通常涉及到LIMITOFFSET语句,但使用游标进行分页可以提高效率,尤其是在数据量大的情况下。游标分页依赖于一个唯一标识(通常是ID),客户端存储上一次加载的最后一个ID,下次查询从这个ID开始。

-- 假设上次加载的最后一个评论ID为10
SELECT * FROM Comment WHERE ID > 10 ORDER BY ID LIMIT 10;

这样,每次加载新的评论页时,只需要传递上一页最后一个评论的ID作为游标。这种方法比传统的OFFSET方法更高效,因为它避免了跳过大量行的性能开销。(这里就不详细介绍了)

删除评论

这里说一下,不建议真删除,在闭包表里如果这个评论还有后代,建议只更新评论正文为“该评论已被删除),如果该评论没有后代,则可以删除。

Golang例子(推荐看一下,注释很详细)

这里使用MySQL和Gorm来举例,其中 Team 就是动态,这里是我的业务场景,你可以当成 Post 。实现了和即刻APP一样的操作逻辑。

模型定义

// Comment 评论表
type Comment struct {
    gorm.Model
    TeamId        uint      `gorm:"column:team_id;index"` // 关联队伍ID,建立索引
    UserId        uint      `gorm:"column:user_id;index"` // 关联用户ID,建立索引
    User          User      `gorm:"foreignKey:UserId"`
    Content       string    `gorm:"column:content;type:text"` // 评论内容
    ReplyTo       uint      `gorm:"column:reply_to;index"`    // 回复的评论ID,建立索引
    Children      []Comment `gorm:"-"`
    ChildrenCount int       `gorm:"-"`
}

// CommentClosure 评论闭包表
type CommentClosure struct {
    gorm.Model
    AncestorId   uint `gorm:"column:ancestor_id;index"`   // 祖先评论ID
    DescendantId uint `gorm:"column:descendant_id;index"` // 后代评论ID
    Depth        int  `gorm:"column:depth;type:int"`      // 两者之间的深度
}

DAO层

package dao
import (
"context"
"errors"
"github.com/CZT0/ustc-uu/internal/config"
"github.com/CZT0/ustc-uu/internal/model"
"github.com/CZT0/ustc-uu/internal/utils"
)
// CreateComment 创建一个新的评论,可以是根评论或回复现有评论
// ctx: 上下文对象,用于控制函数执行时的行为(如超时、取消等)
// teamId: 评论所属的团队ID
// userId: 发布评论的用户ID
// content: 评论内容
// replyTo: 可选,被回复的评论ID,如果是根评论则为nil
func CreateComment(ctx context.Context, teamId, userId uint, content string, replyTo *uint) (*model.Comment, error) {
// 初始化Comment对象
comment := &model.Comment{
TeamId:  teamId,
UserId:  userId,
Content: content,
}
// 如果是回复评论,设置ReplyTo字段
if replyTo != nil {
comment.ReplyTo = *replyTo
}
// 向数据库中插入新评论
result := config.DB.WithContext(ctx).Create(comment)
if result.Error != nil {
return nil, result.Error // 插入失败,返回错误
}
// 插入新评论与自己的关系(depth=0),表示评论本身
selfRelation := model.CommentClosure{
AncestorId:   comment.ID,
DescendantId: comment.ID,
Depth:        0,
}
if err := config.DB.WithContext(ctx).Create(&selfRelation).Error; err != nil {
return nil, err // 插入失败,返回错误
}
// 如果是回复评论,需要更新闭包表以反映新的层级关系
if replyTo != nil {
var ancestorRelations []model.CommentClosure
// 获取回复目标评论的所有祖先(包括目标评论本身),以及它们到目标评论的深度
config.DB.WithContext(ctx).
Model(&model.CommentClosure{}).
Where("descendant_id = ?", *replyTo).
Find(&ancestorRelations)
var newRelations []model.CommentClosure
// 为每个祖先关系添加一条新记录,深度加1
for _, relation := range ancestorRelations {
newRelation := model.CommentClosure{
AncestorId:   relation.AncestorId,
DescendantId: comment.ID,
Depth:        relation.Depth + 1,
}
newRelations = append(newRelations, newRelation)
}
// 批量插入新的闭包表记录
batchSize := 100
if err := config.DB.WithContext(ctx).CreateInBatches(newRelations, batchSize).Error; err != nil {
return nil, err // 插入失败,返回错误
}
}
// 加载关联的用户信息并返回新创建的评论对象
return GetCommentById(ctx, comment.ID)
}
// GetCommentById 根据评论ID获取评论对象,包括关联的用户信息
// ctx: 上下文对象
// id: 评论ID
func GetCommentById(ctx context.Context, id uint) (*model.Comment, error) {
comment := &model.Comment{}
result := config.DB.WithContext(ctx).Preload("User").First(comment, id)
if result.Error != nil {
return nil, result.Error // 查询失败,返回错误
}
return comment, nil
}
// GetCommentsByTeam 根据团队ID分页查询第一级评论,并获取每个评论的部分二级评论和二级评论总数
// ctx: 上下文对象,用于控制函数执行时的行为(如超时、取消等)
// teamId: 团队ID,指定查询评论所属的团队
// first: 查询的评论数量限制,用于分页控制
// cursor: 可选,上一页最后一个评论的ID,用于实现游标分页
func GetCommentsByTeam(ctx context.Context, teamId uint, first int, cursor *uint) ([]*model.Comment, error) {
var comments []*model.Comment
// 构建查询,只查询第一级评论
query := config.DB.WithContext(ctx).
Preload("User").
Where("comments.team_id = ? AND comments.reply_to = 0", teamId)
// 如果提供了游标,只查询ID大于游标值的评论
if cursor != nil {
query = query.Where("comments.id > ?", *cursor)
}
// 应用Limit和Order来支持分页和排序
err := query.Order("comments.id").Limit(first).Find(&comments).Error
if err != nil {
return nil, err // 查询失败,返回错误
}
// 检查是否查询到评论
if len(comments) == 0 {
return comments, nil // 如果没有一级评论,直接返回
}
// 准备批量查询二级评论数量和前两个二级评论
// 获取所有一级评论的ID
var commentIDs []uint
for _, comment := range comments {
commentIDs = append(commentIDs, comment.ID)
}
// 查询每个一级评论的二级评论总数
countsMap := make(map[uint]int) // 存储每个评论的二级评论数量
config.DB.Model(&model.Comment{}).
Select("reply_to, COUNT(*) as count").
Where("reply_to IN (?)", commentIDs).
Group("reply_to").
Scan(&countsMap)
// 查询每个一级评论的前两个二级评论
var childComments []model.Comment
config.DB.Where("reply_to IN (?)", commentIDs).
Order("id").
Limit(2).
Find(&childComments)
// 组织二级评论数据,映射到相应的一级评论上
childCommentsMap := make(map[uint][]model.Comment) // 存储每个一级评论的前两个二级评论
for _, child := range childComments {
childCommentsMap[child.ReplyTo] = append(childCommentsMap[child.ReplyTo], child)
}
// 遍历一级评论,附加二级评论数量和前两个二级评论
for i, comment := range comments {
comments[i].ChildrenCount = countsMap[comment.ID] // 设置二级评论数量
comments[i].Children = childCommentsMap[comment.ID] // 设置前两个二级评论
}
return comments, nil
}
// GetChildrenCommentsByID 根据评论ID分页查询其所有子评论
// ctx: 上下文对象
// commentId: 被查询的评论ID
// first: 查询的评论数量限制
// cursor: 可选,上一页最后一个子评论的ID,用于分页查询
func GetChildrenCommentsByID(ctx context.Context, commentId uint, first int, cursor *string) ([]*model.Comment, error) {
var descendantIds []uint
// 构建基础查询,目标是获取所有子评论的ID
query := config.DB.WithContext(ctx).
Model(&model.CommentClosure{}).
Where("ancestor_id = ?", commentId).
Order("depth")
// 如果提供了游标,调整查询以包括游标条件
if cursor != nil {
cursorId, err := utils.ParseUintPtr(cursor)
if err != nil {
return nil, err // 游标值无效,返回错误
}
query = query.Where("descendant_id > ?", cursorId)
}
// 查询所有后代评论ID,考虑游标和数量限制
err := query.Pluck("descendant_id", &descendantIds).Error
if err != nil {
return nil, err // 查询失败,返回错误
}
// 如果没有找到后代评论,直接返回空切片
if len(descendantIds) == 0 {
return []*model.Comment{}, nil
}
// 限制结果数量,如果指定了first参数
if first > 0 && len(descendantIds) > first {
descendantIds = descendantIds[:first]
}
// 根据后代评论ID查询评论详细信息
var comments []*model.Comment
err = config.DB.WithContext(ctx).
Preload("User"). // 预加载User信息
Where("id IN (?)", descendantIds).
Order("id").
Find(&comments).Error
if err != nil {
return nil, err // 查询失败,返回错误
}
return comments, nil
}
// DeleteComment 删除指定ID的评论
// ctx: 上下文对象
// id: 要删除的评论ID
// userId: 尝试删除评论的用户ID,用于权限验证
func DeleteComment(ctx context.Context, id uint, userId uint) error {
// 首先获取评论对象,验证是否存在以及用户权限
result, err := GetCommentById(ctx, id)
if err != nil {
return err // 查询失败或评论不存在,返回错误
}
// 验证尝试删除评论的用户是否为评论的发布者
if result.UserId != userId {
return errors.New("no permission") // 没有权限,返回错误
}
// 检查该评论是否有后代
var count int64
err = config.DB.WithContext(ctx).
Model(&model.CommentClosure{}).
Where("ancestor_id = ? AND depth > 0", id).
Count(&count).Error
if err != nil {
return err // 查询失败,返回错误
}
if count > 0 {
// 有后代评论时,更新评论内容为"该评论已删除",而不是真正删除评论
// 这是为了保持评论结构的完整性
result := config.DB.WithContext(ctx).
Model(&model.Comment{}).
Where("id = ?", id).
Update("content", "该评论已删除")
if result.Error != nil {
return result.Error // 更新失败,返回错误
}
} else {
// 没有后代评论,可以安全删除评论本身
result := config.DB.WithContext(ctx).Delete(&model.Comment{}, id)
if result.Error != nil {
return result.Error // 删除失败,返回错误
}
// 同时删除闭包表中与该评论相关的所有记录
result = config.DB.WithContext(ctx).
Where("ancestor_id = ? OR descendant_id = ?", id, id).
Delete(&model.CommentClosure{})
if result.Error != nil {
return result.Error // 删除失败,返回错误
}
}
return nil // 删除成功
}

发表评论