评论系统是几乎所有内容型产品(文章、视频、商品)的标配功能。一个不起眼的评论区,背后涉及多级嵌套回复、分页加载、点赞统计等几个容易踩坑的设计点。如果从一开始就用对表结构和查询方式,后续扩展和维护会顺畅很多。
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_at和id:
-- 第一页
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条”的情况。可以用LIMIT加pageSize + 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 重启全量重建耗时长且易出错。
- 不要过度反范式:有些设计会把评论的“前几条子回复”冗余到根评论记录中,这会让查询变容易,但更新逻辑极其复杂,容易不一致。除非读压力远超写,否则不推荐。
一个稳健的评论系统,表结构干净,查询用对索引,分页用游标,热点数据走缓存异步刷新——这些原则组合在一起,就能以很低的运维成本扛住百万级评论的高并发读写。