1. 盖楼系统的核心难点到底在哪
大概半年前我接手了一个类抖音的短视频App后端改造,其中一个硬骨头就是评论区。产品一句话:“评论要有抖音那样的盖楼效果”,听起来简单,做起来全是细节。所谓“盖楼”,本质上就是多级嵌套评论,用户可以在任意一条评论下继续回复,回帖套回帖,形成一棵对话树。抖音、微博、B站的热门评论区都有这个能力,但能把几千条评论叠成一栋高楼,同时扛住几十万人同时刷评论、发评论,这就是另一回事了。
先说结论:这个系统的难点不在于“评论”本身,而在于“高并发”和“多级关系”这两个词叠加在一起之后产生的连锁反应。
如果只是普通评论,一个评论表、一个视频id索引,写一条读一页,MySQL单库就能扛。但盖楼系统要求你在任意层级插入回复,还要按热度或时间展示整棵楼,这直接导致两个问题:第一,每次查询都可能需要递归地组装父子评论;第二,热门视频的评论区往往集中爆发流量,某个几百万播放的视频上线几分钟内可能涌入几万条评论和回复,写入压力远高于普通帖子的评论区。
我在设计前反复想过几个方向:用关系型数据库递归查询行不行?用MongoDB存JSON树行不行?用Elasticsearch当主存储行不行?最后都否掉了。核心原因很简单:读多写少的热点场景,适合做缓存和异步;而写多读也多、层级还深的场景,必须把“读”和“写”拆开设计,各用各的架构。
这个项目的整体思路,就是把它拆成三条链路:写入链路、读取链路、一致性保障链路。写入链路走异步化,接口只接收请求、返回结果,实际落库交给消息队列异步执行;读取链路走两级缓存,首页和楼层数据优先走Redis,缓存未命中再回源数据库;一致性链路负责在缓存和数据库之间做最终一致。下面我按这几个部分分别展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 存储选型与数据模型设计
2.1 为什么不用纯关系型数据库
很多第一次做盖楼系统的同学,第一反应就是设计一张 comment 表,加一个 parent_id 自关联,然后查询时递归查子节点。这个方案在小规模场景下确实能用,但到了高并发场景就撑不住了。原因有两个层面。
第一,递归查询的性能衰减非常快。在MySQL里,无论是 WITH RECURSIVE 还是应用层多次查询,每深入一层就是一次额外的SQL交互。如果一栋楼有上千层,一次查询要几百上千次数据库往返,这个延迟和数据库压力都是灾难级的。第二,MySQL的乐观锁和索引机制对高并发写入的支撑是有限的。同一栋楼下如果同时有几百个人盖楼,行锁竞争会让写入吞吐断崖式下降,更别说还有分库分表带来的跨节点查询问题。
我的最终选型是 MySQL存基础数据 + Redis存热数据 + Elasticsearch做冷数据检索。MySQL负责最终落库和持久化,Redis扛住热门的楼层与评论流,ES负责那些老视频的评论区搜索和后台管理查询。有人会问:这个架构是不是太重了?我的回答是,盖楼系统本身就把读和写分成两条完全不同的访问路径,冷热数据的访问模式差异巨大,这种东西天生适合分层存储。
当然,小项目别学我。如果你预估QPS不到几百,直接用MySQL一张表加 parent_id 索引完全够用。这篇文章讲的是类抖音的高并发场景,默认峰值QPS在万级以上。
2.2 表结构怎么设计才不返工
我见过不少团队在设计评论表时踩坑,最常见的问题就是只考虑了“存”没想到“查”。一张到处都是索引但查询仍然慢的评论表,往往是因为没有为查询路径设计冗余字段。
我最终的表结构大致如下:
sql复制CREATE TABLE `comment` (
`id` bigint(20) NOT NULL COMMENT '评论ID',
`video_id` bigint(20) NOT NULL COMMENT '视频ID',
`root_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '根评论ID,0表示自身就是根评论',
`parent_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '父评论ID',
`reply_user_id` bigint(20) DEFAULT NULL COMMENT '被回复人用户ID',
`user_id` bigint(20) NOT NULL COMMENT '评论人用户ID',
`content` text NOT NULL COMMENT '评论内容',
`like_count` int(11) NOT NULL DEFAULT '0' COMMENT '点赞数',
`reply_count` int(11) NOT NULL DEFAULT '0' COMMENT '子回复数',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1正常,0删除,-1审核中',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_video_root` (`video_id`, `root_id`, `create_time`),
KEY `idx_parent` (`parent_id`),
KEY `idx_root` (`root_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论表';
注意 root_id 这个字段。它是整栋楼的根评论ID,每个子评论都冗余记录自己的根是谁。这样设计的好处是,查询“某一栋楼的全部楼层”时,只需要一条 WHERE root_id = ? ORDER BY create_time 就能拿到所有数据,根本不需要递归。这是盖楼系统能扛住并发读的关键之一。
reply_count 也是一个常用的冗余字段。每次插入子评论时对根评论做 reply_count + 1,展示楼的时候直接显示,不用每次 COUNT(*) 去数。这种以空间换时间的思路,在高并发读场景下非常顺手。
2.3 评论ID、楼层号、路径——这三个字段是命根子
很多人设计评论表时容易忽略一个细节:楼层的展示顺序。抖音的盖楼效果里,楼层号是连续增长的,而且子评论插入在父评论下方,形成一种“树状的论坛感”。要实现这个效果,单纯靠 create_time 排序会遇到一个问题:同一秒内发出两条评论,时间一样怎么办?
我用的方案是 评论ID与楼层号统一由发号器生成,并且设计成雪花ID。雪花ID本身携带时间戳信息,同一毫秒内可以生成数千个不同ID,天然保证顺序。展示楼层时直接按 id ASC 排序,无论是根评论还是楼中楼,都按ID排序即可。这样不但排序简单,而且也能避免时钟回拨问题。
还有一个字段容易忽略:path,即从根评论到当前评论的完整路径,存成类似 1_18_145 的字符串。这个字段的存在是为了处理那种“回复的回复的回复”的场景。虽然我们已经用 root_id 把整栋楼拉出来了,但前端展示时需要知道每条评论的父是谁、层级是多少。如果每次都靠 parent_id 递归查,查询次数太多了;直接用 path 字符串,前端拿到评论列表后按 path 的层级关系在内存里组装树就行。
不过要提醒一点:path 字段不要用文本模糊匹配来查询,否则索引完全失效。它唯一的用途就是应用层组装树时快速判断层级,不参与SQL筛选条件。
3. 写入链路:从API到Kafka再到落库
3.1 同步接口只做“接单”不“干活”
高并发写入的第一原则:别在请求线程里直接操作数据库。类抖音的场景里,一次热门视频的发布可能导致评论区瞬间涌入上万条评论,如果每条评论都同步写数据库,数据库连接池会立刻被打满,后续请求全部排队,接口响应变慢,最后整个服务雪崩。
我采用的方案是“接单-派单-处理”三段式。用户发起评论请求后,服务端在Controller层只做参数校验、风控校验、用户鉴权,然后生成评论ID和楼层号,把评论内容连同ID、时间戳等信息封装成一条消息,发送到Kafka。接口直接返回“评论成功”。用户看到的是评论已经发出去了,但此时数据还没进数据库,而是躺在消息队列里。
这个方案的好处显而易见:接口耗时从几十毫秒变成几毫秒,服务端能扛住的并发上限提升好几个量级。代价是引入了最终一致性——如果消费者失败了或者消息丢了,用户的评论可能会消失。所以我们在消息里带了完整的评论ID和幂等键,消费端通过幂等机制保证“至少一次投递”不会造成重复写入。
3.2 Kafka在高并发写评论里的具体用法
关于Kafka的选型,很多人问过我:为什么用Kafka而不是RabbitMQ或者RocketMQ?我的判断是,评论这个场景消息量大、峰值明显、允许一定的消息延迟,而且对吞吐量的要求远高于对单条消息实时性的要求。Kafka的吞吐能力是所有MQ里最顶级的,加上分区机制的天然并行度,非常适合这种大规模写入削峰场景。
Kafka的核心参数需要认真调。我用的是三个分区,主题名 comment-write,副本数3。分区数不是越大越好,分区太多会增加集群的元数据管理开销;但分区数太少又会限制消费并行度。三个分区的设定,是结合我们集群的节点数和消费端实例数定出来的:消费端三个实例,一个实例消费一个分区,吞吐能力刚好匹配。
生产者端的配置,有四个参数很关键:
java复制props.put("acks", "1");
props.put("retries", 3);
props.put("linger.ms", 10);
props.put("batch.size", 16384);
acks=1 表示只要分区首领写入成功就返回确认,兼顾了吞吐和数据安全;retries=3 处理瞬时网络抖动;linger.ms=10 和 batch.size=16384 是让小消息攒一批再发,减少网络往返次数,吞吐能提升好几倍。
消费端的核心是 关闭自动提交,手动提交偏移量。原因很简单:自动提交的机制是“拉取消息后定时提交”,如果消费逻辑中途挂了,消息会丢。手动提交则是在消息完全处理完之后才提交偏移量,配合消费端的幂等设计,能实现“消息不丢、重复最多一次”。
消费者端落库的逻辑,大致是这样:
java复制public void onMessage(CommentMessage msg) {
// 1. 幂等检查
if (idempotentService.isExists(msg.getCommentId())) {
return;
}
// 2. 写comment表
commentMapper.insert(msg);
// 3. 更新根评论的reply_count
commentMapper.updateReplyCount(msg.getRootId());
// 4. 删除相关缓存
cacheService.deleteCommentCache(msg.getVideoId(), msg.getRootId());
// 5. 标记幂等
idempotentService.markProcessed(msg.getCommentId());
}
这里第一步的幂等检查,我用的是Redis的 SETNX 命令配合过期时间。同一个评论ID第一次处理时写入一个短期的key,后续重复消息直接跳过。注意,SETNX 的过期时间不能太长,否则Redis里会堆积大量无用key;也不能太短,否则消息延迟高时起不到去重作用。我通常设置为2小时,覆盖Kafka可能的最大重试窗口。
3.3 消费端落库的幂等与顺序
提到顺序,有一个坑我必须说:同一个视频下的评论写入必须保证大致有序。如果评论A比评论B先发出,最终在数据库里的ID顺序却反过来了,前端按ID排序时楼层就乱了。Kafka 单个分区内的消息是有序的,所以我发送消息时直接用了 video_id % partition_count 作为分区键,确保同一个视频的评论都进同一个分区,消费端单线程消费该分区时,消息顺序就是写入顺序。
这种设计的代价是,如果一个视频的评论量太大,单个分区的消费会成为瓶颈。但在实际业务里,单个视频的评论量再大,也远达不到单分区消费能力的上限。一个分区单线程每秒处理几千条消息很轻松,而一个爆款视频的评论峰值通常也就每分钟几千条,完全够用。
落库本身也有讲究。大量插入操作如果逐条发起SQL,数据库的压力很大。我改成了批量插入,攒够100条或者每隔200毫秒刷一次,一条批处理SQL写多行。实测下来,批量插入的吞吐比逐条插入高了一个数量级。当然,批量插入会引入更大的故障爆炸半径——如果一次批处理失败,100条消息需要重新处理。但因为有幂等机制兜底,重复处理也无所谓,最坏情况就是浪费一点CPU。
4. 读链路:两级缓存与分页切片的实战方案
4.1 热评、楼中楼、全部评论三种查询模式
读链路是用户能直接感知的部分,也是性能优化的主战场。我根据产品的浏览行为把查询分成三类,分别设计不同的缓存策略。
第一类是最常见的“全部评论按热度排序”,对应评论区上方的主列表。用户刷新视频时,第一眼看到的是按点赞数排序的热门评论,这个列表必须毫秒级返回。第二类是“楼中楼”,即某条热门评论下的所有回复,也是按热度或时间排序。第三类是“全部评论按时间排序”,也就是楼层正序展示,用户点开“查看全部回复”时使用。
这三种查询模式对应三种不同的索引和缓存结构。如果混在一起设计,会让缓存key变得极其复杂,命中率也上不去。我的做法是三种列表各自有独立的缓存key,互不干扰。
4.2 缓存结构怎么设计
Redis缓存我的核心思路是:缓存列表只缓存评论ID列表,不缓存完整评论内容,评论详情单独缓存。为什么?评论内容是可变数据(点赞数会变,内容可能被删除),而列表ID是相对稳定的。如果缓存里直接存整个评论实体,点赞数变化时必须更新整个列表,缓存失效成本很高;如果只缓存ID列表,点赞数变化只需要更新对应评论的独立缓存,列表缓存本身就是最新的。
具体来说,主评论列表的key设计成 comment:hot:{videoId}:{page},value是一个JSON数组,存的是评论ID和楼层号。过期时间设置成5分钟。为什么只设5分钟?因为评论列表的实时性要求高,过期时间太长老用户看到的新评论就滞后了;太短又会频繁回源数据库。5分钟是我在实时性和数据库压力之间反复测试后的折中值。
评论详情的key设计成 comment:detail:{commentId},value是评论的完整信息。这个key的过期时间可以设长一些,比如1小时。评论内容本身变化频率低,只有点赞数和回复数会变,这两个字段单独维护即可。
楼中楼的缓存key设计成 comment:floor:{rootId}:{page},过期时间同样5分钟。由于楼中楼的访问量通常集中在热门评论上,冷门评论的缓存命中率很低,所以我在代码里加了判断:只有根评论的 reply_count > 50 时才写楼中楼缓存,冷门评论直接读数据库,避免Redis里塞满只被访问一次的数据。
4.3 分页与高楼层返回问题
分页这里有一个很容易被忽略的性能陷阱:OFFSET 深分页。当一栋楼有一千层,用户第六十七页时,SQL 是 LIMIT 20 OFFSET 1340,MySQL 需要扫描前1340条记录然后扔掉,这个代价会随着楼层增高越来越大。
我采用的方案是 基于游标的分页。每个分页接口的入参除了 pageSize 之外,还带一个 cursor(通常是上一页最后一条评论的ID或楼层号)。查询时直接 WHERE id > ? ORDER BY id ASC LIMIT ?,完全避免OFFSET扫描。楼中楼也是同理,用 WHERE root_id = ? AND id > ? ORDER BY id ASC LIMIT ? 来实现。
不过这里有个细节:按热度排序的列表不能用 id > cursor,因为热度排序的列表顺序会随点赞数变化而变化,游标分页会导致同一评论在不同页重复出现。所以热评列表我用的是页码分页,但控制在100页以内,超过就提示“已加载全部”;按时间排序的楼层列表用游标分页,彻底解决深分页问题。
还要注意缓存穿透的问题。如果某个视频的评论列表压根不存在,缓存里存的是空值,每次请求都直接穿透到数据库,这就是缓存穿透。我的处理是对空列表也做缓存,存一个空数组,过期时间比正常列表短一些,比如1分钟。加上布隆过滤器作为第一层拦截:布隆过滤器判断某个评论ID或视频ID不存在时,直接返回空,不再访问缓存和数据库。
java复制@GetMapping("/videos/{videoId}/comments")
public Result<List<CommentVO>> listComments(
@PathVariable Long videoId,
@RequestParam(defaultValue = "hot") String order,
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "20") Integer pageSize) {
String cacheKey = buildKey(order, videoId, cursor, pageSize);
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return Result.success(JSON.parseArray(cached, CommentVO.class));
}
List<CommentVO> list = queryFromDB(order, videoId, cursor, pageSize);
// 缓存空列表防止穿透
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(list),
list.isEmpty() ? 60 : 300, TimeUnit.SECONDS);
return Result.success(list);
}
这段代码里,我把“缓存空列表”这个操作从 if 里提出来了,无论是空列表还是正常列表都走统一缓存逻辑。这个细节帮我挡住了很多次压测时的缓存穿透问题。
5. 稳定性保障与故障排查实录
5.1 我压测时踩过的三个坑
第一坑:缓存雪崩。上线前压测,我把所有评论缓存key的过期时间都设成5分钟,结果压测到第5分钟时,大量缓存同时过期,所有请求同时回源数据库,数据库瞬间被查询请求打爆。这个教训是惨痛的。解决办法是给过期时间加一个随机偏移量,比如 5分钟 + Random.nextInt(120) 秒,让缓存过期时间呈正态分布,避免同一时刻集体失效。
第二坑:级联事务导致的死锁。消费端落库时,我最初使用了事务注解,事务里先 insert 评论,再 update 根评论的 reply_count。压测时发现,并发的评论请求在更新同一个根评论时频繁产生死锁。原因是两个事务都持有了记录锁,再等待对方释放,MySQL检测到死锁后回滚其中一个。解决办法是调整更新顺序,先更新根评论的 reply_count 再插入评论数据。由于 reply_count 的更新是同一行,经过 UPDATE 加锁后,事务串行化,死锁自然消失。
第三坑:Kafka消费位移提交太快。上线初期,我手动提交偏移量的代码写的位置不对——在消费消息之后立即提交,但此时批量插入SQL还没执行完。结果消费者挂了之后,消息虽然标记为已消费,实际数据并没写进数据库,造成评论丢失。排查了很久才发现,正确姿势是批量消息处理完、数据库事务提交之后,才调用 commitSync()。从那以后我定了一条铁律:偏移量提交必须跟业务数据落库绑定,绝不提前提交。
5.2 热key问题的终极方案
评论区的热key问题比一般业务严重得多。一个爆款视频的评论区,可能同一时刻有几十万人高频访问同一个 comment:hot:{videoId}:0 这个缓存key。Redis单实例每秒能处理10万级别的请求,但如果几十万请求同时打到同一个key上,Redis实例的CPU会瞬间飙高,这个key所在的Redis分片会成为集群瓶颈。
我的处理方案是 缓存key多副本。对每个热点视频的列表缓存,不只存一份,而是拆成多个副本:comment:hot:{videoId}:0:copy1、copy2、copy3。请求进来时随机选择一个副本读取,数据库回源时更新所有副本。这个方案把热点key的压力分散到多个key上,虽然牺牲了一点缓存一致性,但在评论区这种数据实时性要求不算极端的场景下,完全可接受。
有人问:为什么不直接加本地缓存?本地缓存确实能扛热点,但需要处理缓存一致性和内存上限问题。我在这个项目里没有引入本地缓存,原因是多实例部署时,本地缓存的数据一致性很难保证——用户可能看到同一栋楼在不同实例下数据不一致。所以我的结论是:宁可多浪费一点Redis内存做多副本,也绝不做本地缓存。
5.3 最终一致性窗口期怎么兜底
异步写入链路必然存在一个窗口期:用户评论返回成功后,到评论查询可见之间,有几秒到十几秒的延迟。这期间如果用户刷新评论区,看不到自己刚发的评论,体验会打折。
这个问题没法完全消除,只能尽量缩短并做好兜底。我做了两件事:第一,评论接口返回时直接把当前用户刚发的评论内容放进响应体,前端本地插入评论列表最前面,用户立刻能看到自己的评论;第二,缩短Kafka到Redis更新的链路延迟,把消费端的 linger.ms 调低到5毫秒,把批量插入的阈值从100条降到50条,让评论更快进入缓存。
真要说清楚,这套设计没法做到强一致,但对评论区业务来说,“刚刚发的评论过几秒才能在全列表里看到”是完全可以接受的。这里的关键不是追求零延迟,而是让用户感知不到延迟,前端体验和技术代价之间找到一个平衡点。
5.4 高并发下的监控指标和告警阈值
最后说说线上监控。评论系统是典型的高频写、高频读系统,没有监控等于裸奔。我重点盯四个指标:Kafka消费者的堆积量、Redis的命中率、MySQL的慢查询数、接口的P99延迟。
Kafka堆积量是最直观的问题预警。消费端处理不过来时,消息会堆积在分区里,堆积量持续增长说明消费者挂了或者瓶颈了。我设的告警阈值是堆积超过5万条持续1分钟就报警。
Redis命中率掉到80%以下,就要检查是不是缓存过期时间设置不合理,或者有大量新视频ID在刷缓存。MySQL慢查询超过200毫秒的,直接看是不是深分页或者 OFFSET 导致的,这个问题排查起来比较费劲,建议直接开启慢查询日志,配合 EXPLAIN 分析索引。
接口P99延迟高于500毫秒时,通常意味着读链路出现缓存击穿或者数据库连接池耗尽。我习惯把监控面板分成“红线指标”和“黄线指标”,红线指标(如堆积量、P99)一超就报警,黄线指标(如缓存命中率)先记录趋势,容错空间大一些,不频繁打扰值班人。
这套评论盖楼系统从架构设计到上线稳定运行,用了大约三周时间。从压测数据看,单机写QPS稳定在2000以上,读QPS配合缓存可以到5万以上,集群水平扩容后还有更大的余量。我个人最大的体会是,盖楼系统跟其他评论系统最大的区别在于:层级关系必须一次性建模正确,否则后续所有的缓存、分页、排序设计都会跟着变形。先把数据模型想透,再谈高并发改造,这条顺序不能反。
