人人都会AI编程

26.3 评论系统:多级评论、分页、点赞计数设计

更新时间:2026-07-11

评论系统是几乎所有内容型产品(文章、视频、商品)的标配功能。一个不起眼的评论区,背后涉及多级嵌套回复、分页加载、点赞统计等几个容易踩坑的设计点。如果从一开始就用对表结构和查询方式,后续扩展和维护会顺畅很多。

26.3.1 多级评论的表结构设计

多级评论本质是一棵树:根评论是一级,回复根评论的是二级,回复二级的是三级,以此类推。在关系型数据库中,最常见的做法是一张评论表,通过 parent_id 自关联

基础表结构:

CREATE TABLE comments (
    id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    target_id   BIGINT UNSIGNED NOT NULL COMMENT '评论对象ID,如文章ID',
    user_id     BIGINT UNSIGNED NOT NULL COMMENT '发表用户ID',
    parent_id   BIGINT UNSIGNED DEFAULT 0 COMMENT '父评论ID,0表示根评论',
    root_id     BIGINT UNSIGNED DEFAULT 0 COMMENT '根评论ID,方便查询某条根评论下的所有回复',
    content     TEXT            NOT NULL COMMENT '评论内容',
    like_count  INT UNSIGNED   DEFAULT 0 COMMENT '点赞数(冗余字段)',
    is_deleted  TINYINT        DEFAULT 0 COMMENT '软删除标记',
    created_at  DATETIME       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_target_root (target_id, root_id, id),
    INDEX idx_target_created (target_id, created_at),
    INDEX idx_parent (parent_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

设计要点:

  • parent_id = 0 表示根评论,这样单表就能同时存放一级评论和所有回复,查询时不需要额外的表结构。
  • root_id 非常关键。它记录该条回复所属的根评论 ID。当需要展示“某条评论下的所有回复”时,直接 WHERE root_id = xxx 即可,而不用在应用层递归拼接。这个字段冗余了树根信息,却极大简化了查询。
  • target_id 表示评论对象(如文章 ID),配合 root_id 可以精准过滤,避免跨文章查询。
  • 索引 idx_target_root (target_id, root_id, id):用于按根评论拉取所有回复,并按回复 ID 排序。
  • 索引 idx_target_created (target_id, created_at):用于常规的“按时间排序”加载根评论列表。
  • like_count 冗余点赞数:避免每次展示评论时都去点赞表做 COUNT,代价是写入点赞时需要同步更新这个字段(后面会详细讨论)。

26.3.2 多级评论的查询与展示

最常见的展示形式是:先分页加载根评论,每条根评论下面再展示前几条子回复(如“展开 3 条回复”),用户点击后加载更多子回复。

加载根评论(分页):

SELECT id, user_id, content, like_count, created_at
FROM comments
WHERE target_id = ? AND parent_id = 0 AND is_deleted = 0
ORDER BY created_at DESC
LIMIT ?, ?;

加载某条根评论下的子回复(一次拉取所有,或带分页):

SELECT id, user_id, content, parent_id, like_count, created_at
FROM comments
WHERE target_id = ? AND root_id = ? AND is_deleted = 0
ORDER BY id ASC;  -- 按回复时间正序展示更符合阅读习惯

如果子回复数量很大(比如上万条),也可以对子回复做分页:

SELECT id, user_id, content, parent_id, like_count, created_at
FROM comments
WHERE target_id = ? AND root_id = ? AND id > ? AND is_deleted = 0
ORDER BY id ASC
LIMIT ?;

使用游标分页(基于 id)可以避免深分页性能问题,并保证数据有序不重复。

如果需要展示“楼中楼”的嵌套结构(即三级以上),常见有两种处理方式:

  • 扁平化展示:所有回复平铺在根评论下方,通过 parent_id 区分“回复谁”,前端根据 parent_id@用户名 来渲染层级感。这是微博、B站等产品的做法,实现简单,性能好。
  • 递归树形展示:应用层拉取所有子回复后,在内存里构建树结构再渲染。适用于嵌套层次较浅(如2~3层)的场景。

MySQL 8.0 虽然支持 CTE 递归查询,可以一条 SQL 拉出整棵子树,但在高并发评论场景下,递归查询的性能和复杂度往往不划算,不如应用层组装。

26.3.3 评论分页的优化实战

评论列表最怕两件事:深分页数据空洞

  • 深分页问题LIMIT 10000, 10 会让数据库扫描 10010 行再丢弃前 10000 行,越往后越慢。解决方案是使用游标分页代替页码分页。对于按时间排序的根评论列表,可以传入上一页最后一条的 created_atid
-- 第一页
SELECT id, user_id, content, like_count, created_at
FROM comments
WHERE target_id = ? AND parent_id = 0 AND is_deleted = 0
ORDER BY created_at DESC, id DESC
LIMIT ?;

-- 后续页
SELECT id, user_id, content, like_count, created_at
FROM comments
WHERE target_id = ?
  AND parent_id = 0
  AND is_deleted = 0
  AND (created_at, id) < (?, ?)   -- 传入上一页最后一条的时间戳和ID
ORDER BY created_at DESC, id DESC
LIMIT ?;

(created_at, id) 的联合比较能避免时间重复时的数据遗漏,索引 idx_target_created 也能直接覆盖这个查询。

  • 软删除带来的空洞is_deleted = 1 的记录会占用索引空间,且分页时可能出现“这一页本该有10条,结果软删掉了3条,只返回7条”的情况。可以用 LIMITpageSize + 1 的冗余取法,由应用层过滤后再判断是否有下一页,但更根本的方案是定期将已删除评论归档到历史表,保证主表数据紧凑。

26.3.4 点赞计数的设计方案

点赞功能虽然简单,但更新 like_count 字段在高并发下会引发热点行锁竞争(比如热门评论的点赞数被频繁更新)。设计上需要根据业务规模选择合适的方案。

方案一:直接更新(适合小规模)

点赞时:

UPDATE comments SET like_count = like_count + 1 WHERE id = ?;

简单直接,但热门的评论行可能被频繁锁定,导致大量更新请求排队,RT 上升。如果日活不高,这完全够用。

方案二:异步写回 + Redis 计数(推荐)

用户点赞/取消点赞先操作一张点赞明细表(记录谁点了什么),同时用 Redis 的 Hash 或 Bitmap 缓存点赞状态和计数,定时(如每5分钟)将增量数据刷新回 comments.like_count

明细表:

CREATE TABLE comment_likes (
    comment_id BIGINT UNSIGNED NOT NULL,
    user_id    BIGINT UNSIGNED NOT NULL,
    status     TINYINT NOT NULL DEFAULT 1 COMMENT '1点赞,0取消',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (comment_id, user_id),
    INDEX idx_comment_time (comment_id, created_at)
) ENGINE=InnoDB;

前端展示点赞数时,先从 Redis 取实时数值,兜底读 MySQL 的 like_count。Redis 数据结构可以用 INCR / DECR 原子操作,读性能极高。定时任务将 Redis 中的脏计数——比如更改了多次的评论——批量更新到数据库,大幅减少对单一数据库行的写压力。

方案三:仅 Redis 计数(极致性能)

完全不在 MySQL 中维护 like_count,所有点赞数存 Redis,后台异步持久化到明细表用于历史查询,评论展示时纯读 Redis。缺点是一旦 Redis 故障,点赞数丢失(可以从明细表修复,但耗时)。适用于极高并发且能接受短期数据弱一致的场景。

一条真实的点赞逻辑伪代码:

def toggle_like(user_id, comment_id):
    # 1. 先在明细表查当前状态(或 Redis 缓存状态)
    status = get_like_status(user_id, comment_id)
    if status == LIKED:
        # 取消点赞
        update_like_record(user_id, comment_id, CANCELED)
        redis.decr(f"like:{comment_id}")
    else:
        # 点赞
        update_like_record(user_id, comment_id, LIKED)
        redis.incr(f"like:{comment_id}")

    # 2. 异步或定时将 Redis 中的点赞数增量刷新到 comments 表
    schedule_increment_flush(comment_id)

26.3.5 其它真实场景的经验

  • “已删除”评论的处理:不要物理删除,保留 is_deleted 标记,内容置空或替换为“该评论已删除”。如果该评论下有回复,保留树结构,否则子回复会变为孤儿,阅读体验很差。
  • 敏感词与审核:评论表增加 status 字段(待审核、通过、驳回),用户发评后先进入待审核,审核通过后再对其他用户可见。索引 idx_target_status (target_id, status, created_at) 可以高效加载已审评论。
  • 无单一故障点:如果使用 Redis 计数,务必结合持久化到 MySQL,否则 Redis 重启全量重建耗时长且易出错。
  • 不要过度反范式:有些设计会把评论的“前几条子回复”冗余到根评论记录中,这会让查询变容易,但更新逻辑极其复杂,容易不一致。除非读压力远超写,否则不推荐。

一个稳健的评论系统,表结构干净,查询用对索引,分页用游标,热点数据走缓存异步刷新——这些原则组合在一起,就能以很低的运维成本扛住百万级评论的高并发读写。