在当今的互联网世界中,多级评论系统已成为众多网站和应用不可或缺的一部分。然而,其设计与实现并非易事,尤其是在面对高并发和大规模数据的挑战时。最近看了下B 站的多级评论系统建设方案,发现其泰山架构对于一些中等规模的网站或应用而言,还是比较复杂的,成本相对较高,这里提供一种解决方案,该方案能够实现每秒 50 万行以上的读取性能以及 10 万行以上的写入性能的方案.
一、一个多级评论系统是如何设计的
在多级评论系统,评论可以有多级嵌套(即评论可以回复其他评论),并且每个评论可以有点赞数和时间戳等元数据。系统还需要支持以下功能:
- 获取某个Topic下的所有评论,按时间或点赞数排序(以及进阶的推荐排序)。
- 支持评论的多级嵌套(父评论与子评论关系)。
- 支持对评论的点赞。
- 支持对评论的回复(评论可以是根评论,也可以是对其他评论的回复)
1.2 数据模型设计
1.2.1评论表
这是最核心的表,用于存储topic的评论。通过设置reply_comment_id字段,能够精准地确定每条评论是否为对其他评论的回复,从而构建起评论之间的关联网络。将评论按照时间顺序进行排序,不仅有助于快速获取最新的评论内容,还能为用户呈现出一个动态、有序的交流场景。以下是使用 PostgreSQL 创建的评论表结构示例:
CREATE TABLE comments (
comment_id SERIAL PRIMARY KEY, -- 评论ID,主键
reply_comment_id INT NOT NULL, -- 回复的评论ID
parent_comment_id INT REFERENCES comments(id) ON DELETE CASCADE, -- 父评论ID,NULL 表示顶级评论
content TEXT NOT NULL, -- 评论内容(评论可以加类型,或者默认为markdownm形式)
user_id INT NOT NULL, -- 用户ID,表示评论者
created_at TIMESTAMP DEFAULT NOW(), -- 评论创建时间
updated_at TIMESTAMP DEFAULT NOW(), -- 评论更新时间
is_deleted BOOLEAN DEFAULT FALSE, -- 软删除标志
topic INT NOT NULL -- 关联的TopicID
likes_count INT NOT NULL , -- 点赞数
);
在最基础的多级评论系统中,我们只需要这一张表,就能完成所有的需求
包括创建评论/回复某人的评论/查询最近10条父评论及其子评论,根据点赞数查询,最多的10条评论等一系列常见需求
1.2.2递归查询
为了处理多级评论的展示,我们需要使用递归查询。PostgreSQL 提供了 WITH RECURSIVE 语句,可以用来实现这一功能。以下是一个递归查询的例子,用于获取某个Topic的所有评论,包括子评论:
WITH RECURSIVE comment_tree AS (
SELECT id, parent_id, content, user_id, created_at, 0 AS level
FROM comments
WHERE parent_id IS NULL AND topic_id = $1
UNION ALL
SELECT c.id, c.parent_id, c.content, c.user_id, c.created_at, ct.level + 1
FROM comments c
INNER JOIN comment_tree ct ON c.parent_id = ct.id
)
SELECT * FROM comment_tree ORDER BY level, created_at;
1.2.3 注意事项
(1) 评论ID最好是有序的,很多时候,我们直接用评论ID 排序能解决很多问题
(2) 多级评论可能会导致查询性能下降,尤其是递归查询会在有大量嵌套时比较复杂。需要给查询的条件加上索引
(3) 确保父评论与子评论之间的关系不会形成无限循环
(4) 并发读写事务处理
二、性能的瓶颈
单表的读写性能是有上限的,根据机器性能不同基本一般在千到万级别左右,这在面对增长的用户的高并发读写请求时会显得力不从心。此外,单节点架构还存在单点故障风险,一旦该节点出现问题,可能会导致整个评论系统的瘫痪,严重影响用户体验和业务的正常运转。我们探索了以下几种切实可行的解决方案:
2.1 PG 数据库水平扩展(Sharding)
解决方案:通过 水平分片(Sharding) 来将数据分散到多个数据库实例中。
-
分库分表:我们可以根据评论的不同维度(如
topic_id、user_id)将数据分割成多个表或数据库。对于每个维度,使用不同的数据库实例进行存储。例如,基于topic_id 切分,每个topic_id的评论存储在不同的数据库中,这样可以避免一个单一数据库的压力。 -
实现方式:
- PostgreSQL 的分片:可以使用 Citus 扩展(Citus 是一个 PostgreSQL 的分布式数据库扩展)来将数据自动分布到多个节点上。
- 应用层管理分片:在应用层进行分片,手动管理不同数据库实例的访问,确保查询和写入请求路由到正确的分片。
-
优点:
- 增加了水平扩展性,可以随着数据量增长而扩展。
- 可以减轻单个数据库节点的负载。
-
缺点:
- 需要额外的管理和复杂的分片策略。
- 查询跨多个分片时需要聚合,可能带来一定的性能开销。
2.2 引入 NoSQL 数据库(ScyllaDB)
-
解决方案:
- ScyllaDB:适合高写入负载的场景,尤其是对于实时数据处理。ScyllaDB 是分布式的,可以水平扩展,适用于海量数据的存储和快速写入。
-
优点:
- 高性能写入:ScyllaDB 能提供非常高的写入吞吐量,适合处理大量的评论数据。
- 易于扩展:NoSQL 数据库天然支持水平扩展,可以根据负载自动增加节点。
-
缺点:
-
一致性问题:NoSQL 数据库的最终一致性模型有时可能不适用于某些严格一致性要求的场景。
显而易见,在数据规模上来之后,PGSQL其实是不太适合多级评论系统的,而且多级评论系统的需求是非常明确的,高吞吐量、高并发写入且对查询的复杂性要求不高,通过使用NoSQL,我们可以通过提高写入的复杂度,换取更好的读取的延迟和吞吐量.至于写入复杂度,在业务层统一处理即可
-
三、 ScyllaDB在多级评论系统中的落地
还是先建表吧
3.1 Topic 评论表
CREATE TABLE topic_comments (
topic_id UUID, -- topic ID
comment_id UUID, -- 评论 ID
parent_comment_id UUID, -- 父评论 ID(如果是根评论则为 null)
reply_comment_id UUID, -- 回复的评论 ID
user_id UUID, -- 评论的用户 ID
content TEXT, -- 评论内容
timestamp TIMESTAMP, -- 评论时间
likes_count INT, -- 点赞数
PRIMARY KEY (topic_id, comment_id) -- 分区键是 topic_id,聚簇键是 comment_id
) WITH CLUSTERING ORDER BY (comment_id DESC);
-
topic_id 是分区键,确保所有评论都与topic关联,并且查询时能够快速按topic ID 获取评论。 -
comment_id 是聚簇键,用于排序评论。这里的排序是根据评论的时间戳来定义的,可以支持按时间倒序返回评论。 -
parent_comment_id 用来表示评论的层级关系。如果评论没有父评论,它的值为null,表示这是一个根评论;否则,存储它的父评论 ID。
3.2 评论点赞表
为了支持评论点赞排序功能,可以设计一个表来存储用户对评论的点赞记录。
CREATE TABLE comment_likes (
comment_id UUID, -- 评论 ID
user_id UUID, -- 点赞的用户 ID
PRIMARY KEY (comment_id, user_id) -- 分区键是 comment_id,聚簇键是 user_id
);
-
comment_id 作为分区键,确保每个评论的数据集中存储,方便高效查询。 -
user_id 是聚簇键,用于查询某个评论的所有点赞用户,或者检查某个用户是否已经点赞了评论。
3.3 评论回复表
这张表记录了评论之间的回复关系。例如,某条评论有一条或多条回复。
CREATE TABLE comment_replies (
comment_id UUID, -- 父评论 ID
reply_comment_id UUID, -- 回复的评论 ID
user_id UUID, -- 回复的用户 ID
timestamp TIMESTAMP, -- 回复时间
PRIMARY KEY (comment_id, reply_comment_id) -- 分区键是父评论,聚簇键是回复评论
) WITH CLUSTERING ORDER BY (reply_comment_id ASC);
-
comment_id 是父评论的 ID,reply_comment_id 是该评论下的回复评论 ID。 - 通过这种方式,可以高效查询某条评论的所有回复。
3.4 性能优化与扩展
-
分区键设计:
topic_id 作为分区键确保了所有评论都与Topic关联,并且可以高效地获取某个topic_id的评论数据。ScyllaDB 会根据topic_id 将评论数据分布到多个节点中,从而实现横向扩展。 -
聚簇键设计:聚簇键的选择影响着查询的效率。
comment_id 和timestamp 聚簇键确保评论按时间顺序存储,可以高效支持基于时间的查询。 -
反向索引:为了支持按点赞数、按用户查询点赞评论等操作,可以在数据表中设计冗余字段,或者使用二级索引来加速查询。
-
写入优化:评论系统中的评论创建和点赞操作是高频写入,因此 ScyllaDB 的高写入性能将有助于确保系统在高并发情况下的稳定运行。
ScyllaDB 对于复杂的 SQL 查询(如多表连接、聚合查询)相对较弱,因此在设计时需要特别注意查询模式,尽量避免需要复杂查询的场景。如果需要支持复杂的查询操作,可以使用缓存机制来优化查询。
3.4 一些设计的解答
在 ScyllaDB 中,我没在 `topic_comments` 表上直接创建二级索引(Secondary Indexes),而是开了几个新表.这是因为ScyllaDB 的二级索引与传统关系型数据库(如 PostgreSQL)不同,其实现方式不太适合高并发场景。ScyllaDB 是为高吞吐量的写入操作优化的,二级索引会引入额外的负担,因为每当对主表进行写操作时,二级索引也需要被更新,这会导致性能下降。通过创建按时间排序、点赞排序等专用的表,能够更灵活地优化查询性能,减少对主表写入的影响。在追求高吞吐,低延迟的场景,我选择开新表是更实用的方案(有点数据dup,但是影响还好)
3.5 ScyllaDB没有跨表事务如何解决
正如我们之前提到的,我们采用了多张表来提高查询的效率,通过提高写入的复杂读,来提高整个系统的吞吐量.因此,如果在将数据插入到 `topic_comments` 表时失败,如何处理失败的插入操作需要在业务层做出决策。
3.5.1 回滚插入到 `topic_comments` 表
这种策略的核心思想是,在插入 `topic_comments` 表失败时,回滚之前成功插入的 `comments` 表中的评论数据。
- 步骤 1:首先尝试插入`topic_comments` 表,并将评论数据保存到数据库。
- 步骤 2:然后尝试将评论数据插入`comment_likes` 表。
- 步骤 3:如果第二步(插入`comment_likes`)失败,则回滚第一步`topic_comments`表中插入的评论数据。以此类推,这些逻辑需要在业务代码中去实现
优点:
- 数据一致性:通过回滚机制,可以确保
comments表和comments_by_time表的数据一致性,即如果某个表插入失败,整个操作将回到初始状态。 - 简单易懂:逻辑比较直观,容易实现,符合传统数据库事务的思路。
缺点:
- 性能开销:回滚操作可能会带来额外的延迟,尤其是在分布式环境下,删除数据可能比单纯的插入数据更慢。
- 复杂性增加:需要额外的逻辑来处理回滚,这可能会导致代码复杂度增加。
- 数据丢失风险:如果回滚过程中发生其他错误,可能会导致部分数据丢失或不一致。
3.5.2 丢到补偿队列(消息队列)中
另一种处理方式是在`topic_comments`相关表插入失败时,不立即回滚之前表的数据,而是将失败的操作记录到一个消息队列或任务队列中。然后,系统可以在后续的补偿任务中重试这些失败的操作。
实现方式:
- 步骤 1:首先插入 `topic_comments`表。
- 步骤 2:然后尝试插入`comment_likes`表。
- 步骤 3:如果`comment_likes`插入失败,则将失败的插入任务(包括评论数据和失败原因)推送到一个 消息队列(如 Kafka、RabbitMQ 或 AWS SQS)中。
- 步骤 4:系统后台会有一个消费者从队列中读取任务并重试失败的操作。重试逻辑可以设置一定的重试次数,或者在某个时间点如果失败,则发送警报通知。
优点:
- 高可用性:这种方式将失败的任务保存在队列中,即使有部分失败的操作,也不会直接影响整个应用的业务流程。
- 后续重试机制:可以设置重试策略,最大限度地减少丢失数据的风险。
- 业务不中断:即使某些操作失败,应用依然可以继续执行其他操作,避免了因为单个操作失败导致整个流程回滚的情况。
缺点:
- 复杂性增加:需要管理消息队列和后台消费者的逻辑,系统的复杂性增加。
- 延迟问题:任务需要通过队列来补偿,这可能会导致延迟,尤其是在失败重试的过程中。
- 可能的丢失:如果队列中的任务处理失败,可能会导致某些操作无法成功补偿。
3.5.3 哪种方式更合适?
- 数据一致性要求高的场景:如果你希望保证comments多个表的数据完全一致,并且你希望避免不一致的状态,那么选择 回滚插入到comments多个表是一种简单且直接的解决方案。尽管它带来了性能上的一些开销,但可以确保数据的一致性。
- 高可用性和容错性要求高的场景:如果你的系统需要保持高可用性,并且能容忍某些操作的延迟和暂时不一致,那么 丢到补偿队列中 是一个更合适的方案。它能确保系统不中断,并且能够通过后台任务处理失败的操作。
3.5.3. 补充建议
无论采用哪种方式,都可以通过以下几种方法进一步增强系统的健壮性:
- 幂等性:无论是回滚操作还是重试机制,都需要确保操作是幂等的,即同一操作执行多次不会对系统产生不良影响。比如对于评论的插入,确保每条评论有唯一的 ID(如使用
UUID),避免重复插入。 - 错误监控和警报:在失败的情况下,务必有监控机制来捕捉错误并及时报警,以便尽早发现问题并进行修复。
- 合理的重试机制:如果使用消息队列进行补偿,设计合理的重试机制,并考虑失败后的报警和人工干预,以避免积压和数据丢失。