二级评论区

在现代 Web 应用中,评论系统是用户互动的核心功能之一。一个设计良好的评论系统不仅要能处理大量的读写请求,还需要支持诸如“回复评论”这样的嵌套结构(通常称为二级评论或评论回复)。Redis,作为一个高性能的内存数据库,凭借其丰富的数据结构,非常适合用来构建这样的系统。

本文将探讨如何利用 Redis 的 Hashes(哈希) 和 Sorted Sets(有序集合) 来设计和实现一个高效、可扩展的二级评论系统。

核心设计理念

我们设计的核心思想可以概括为两点:

  1. 实体存储: 使用 Redis Hash 来存储每条评论(包括一级评论和二级回复)的具体内容-不存博客的具体内容。
  2. 关系与排序: 使用 Redis Sorted Set 来维护评论之间的父子关系,并利用其天然的排序能力(基于 Score)来管理评论和回复的顺序(例如按时间倒序)。

数据结构详解

存储评论/回复内容 (Hash)

我们将每条评论或回复的实际数据存储在一个 Hash 中。

  • Key: comment:{unique_comment_id} (例如 comment:1001, comment:1002)

    • unique_comment_id是一个全局唯一的标识符,可以通过INCR命令生成计数器或使用 UUID 等方式获得。
  • Fields and Values(字段和值):

    • content: 评论的文本内容。
    • user_id: 发表评论的用户 ID。
    • post_id: 评论所属的文章或帖子 ID(外键 id1)。
    • parent_id: 父评论的 ID(外键 id2)。
      • 对于一级评论,parent_id可以设为 “0” 或留空。
      • 对于二级评论(回复),parent_id设为其所回复的那条评论的unique_comment_id
    • timestamp: 发表时间戳 (Unix timestamp),用于排序。
    • status: 评论状态 (如 ‘active’, ‘deleted’),方便实现逻辑删除。

优点: 通过唯一的comment:{id} Key,可以 O(1) 时间复杂度地获取单条评论的所有信息。

存储文章下的一级评论列表 (Sorted Set)

对于每篇文章,我们使用一个 Sorted Set 来存储其下所有一级评论的 ID,并按时间排序。

  • Key: post:{post_id}:comments (例如 post:123:comments)
  • Member(成员): 一级评论的unique_comment_id(例如 1001)
  • Score(分数): 该评论的timestamp

优点: Sorted Set 保证了评论按时间排序,且可以方便地进行分页查询(如ZREVRANGE ... LIMIT ...)。

存储评论下的回复列表 (Sorted Set)

类似地,对于每一条有回复的评论(通常是一级评论),我们使用一个 Sorted Set 来存储其直接回复的 ID,并按时间排序。

  • Key:comment:{parent_comment_id}:replies(例如comment:1001:replies)
    Member(成员): 回复(二级评论)的unique_comment_id(例如 1002)
    Score(分数): 该回复的timestamp

优点: 同样提供了对回复列表的快速排序和分页能力。

核心操作流程

发表一级评论

  1. 生成唯一评论 ID (new_comment_id)。
  2. 使用HSET comment:{new_comment_id}存储评论内容,设置parent_id为 “0”。
  3. 使用ZADD post:{post_id}:comments {timestamp} {new_comment_id}将评论 ID 添加到文章的评论列表中。

发表二级评论 (回复)

  1. 生成唯一回复 ID (new_reply_id)。
  2. 使用HSET comment:{new_reply_id}存储回复内容,设置parent_id为被回复评论的 ID (parent_comment_id)。这一步和上一环节第二步一样,但是多了一个parent_comment_id
  3. 使用ZADD comment:{parent_comment_id}:replies {timestamp} {new_reply_id}将回复 ID 添加到父评论的回复列表中。

这里引入了一个设计,主表拥有关联表的主键控制权,而从表通过自身的paren_id来寻找父节点。不同元素之间不使用同一个parent_id而使用另一个类型的id作为外键。而且回复需要用一张单独的三级表

读取文章评论 (带分页和回复)

这些步骤告诉我们,对多个数据结构就应该多步操作。

  1. 获取一级评论列表: 使用ZREVRANGE post:{post_id}:comments {start} {end} 获取指定范围(如最新的 N 条)一级评论的 ID 列表。
  2. 获取评论详情: 遍历上一步获取的 ID 列表,对每个 ID 使用HGETALL comment:{comment_id}获取其完整内容。
  3. (可选)获取回复列表: 对于每条一级评论,如果需要加载其回复:
    • 使用ZREVRANGE comment:{parent_comment_id}:replies 0 {limit}获取该评论下的前limit条回复 ID。
    • 再次遍历回复 ID 列表,使用HGETALL comment:{reply_id}获取每条回复的完整内容。
  4. 删除评论
    • 逻辑删除: 推荐做法是将 comment:{id} Hash 中的 status 字段更新为 ‘deleted’。在读取时过滤掉状态为 ‘deleted’ 的评论。
    • 物理删除: 更复杂,需要同时从父级列表 (ZREM)、删除其回复列表 (DEL)、删除其内容 Hash (DEL),并处理计数器等。

示例 (Redis 命令)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 发表一级评论
INCR global:comment_id_counter # 假设得到 1001
HSET comment:1001 content "Great article!" user_id "user_a" post_id "post_123" parent_id "0" timestamp 1700000000 status "active"
# 这里两张表可以用同一个 timestamp
ZADD post:post_123:comments 1700000000 1001

# 回复一级评论
INCR global:comment_id_counter # 假设得到 1002
HSET comment:1002 content "Thanks!" user_id "user_b" post_id "post_123" parent_id "1001" timestamp 1700000050 status "active"

# 这里只插入 reply 表,没有插入 comment 表
ZADD comment:1001:replies 1700000050 1002

# 读取文章评论 (前1条) 及其回复 (前1条)

# 第一个 0 (start): 表示范围的起始索引。索引从 0 开始计数。所以 0 就是排序后的第一个元素。
# 第二个 0 (stop): 表示范围的结束索引。同样,索引从 0 开始计数。所以 0 也表示排序后的第一个元素。
# 组合起来 0 0: 意思就是“从排序后的第 1 个元素开始,到第 1 个元素结束”,也就是只获取排序后的第一个元素。
ZREVRANGE post:post_123:comments 0 0 WITHSCORES
# 假设返回: 1) "1001" 2) "1700000000" 注意这里只有一条返回值,1700000000 是时间戳

HGETALL comment:1001
# 返回评论 1001 的内容

ZREVRANGE comment:1001:replies 0 0
# 假设返回: 1) "1002"

HGETALL comment:1002
# 返回回复 1002 的内容

range 的提示:

在 Redis 中,像 0 -1 这样的起止点(通常用于 ZRANGE, ZREVRANGE, LRANGE, SUBSTR, GETRANGE 等命令)有其特定的设计含义,主要用于指定范围的开始和结束位置。这种设计在很多编程语言和数据结构库中都很常见。

以下是关键点:

  1. 索引从 0 开始: 和大多数编程语言一样,Redis 中范围的索引通常是从 0 开始计数的。第一个元素的索引是 0,第二个是 1,依此类推。
  2. 负数索引: Redis 支持使用负数作为索引,这为从序列末尾开始计数提供了便利。
    • -1 表示最后一个元素。
    • -2 表示倒数第二个元素。
    • -3 表示倒数第三个元素。
    • 以此类推。
  3. start 和 stop 参数:
    • start: 范围的起始索引(包含该位置的元素)。
    • stop: 范围的结束索引(也包含该位置的元素)。这与某些编程语言(如 Python 的切片,list[0:5] 不包含索引 5)有所不同,Redis 的范围是包含两端的。
  4. 0 -1 的含义:
    • 0: 从第一个元素开始。
    • -1: 到最后一个元素结束。
    • 因此,0 -1 组合在一起,通常表示“从头到尾的所有元素”。
  5. 其他常用组合:
    • 0 0: 获取第一个元素。
    • 0 4: 获取前 5 个元素 (索引 0, 1, 2, 3, 4)。
    • -1 -1: 获取最后一个元素。
    • -5 -1: 获取最后 5 个元素。
    • 2 -1: 从第三个元素开始,到末尾的所有元素。

延时队列

延时队列是一种在指定时间后才处理消息的队列。在许多场景中非常有用,例如订单超时取消、邮件延迟发送、定时任务等。Redis 本身没有直接提供延时队列的功能,但我们可以利用其 Sorted Set (ZSet) 数据结构来实现一个高效且可靠的延时队列。

核心设计思想

我们将使用 Redis 的 Sorted Set 来存储延时消息。Sorted Set 中的每个元素(消息)都有一个与之关联的 Score。我们可以将消息的预期处理时间戳(Unix Timestamp)作为 Score。这样,Sorted Set 会根据时间戳自动对消息进行排序。

一个后台的 Worker 进程会周期性地检查这个 Sorted Set,将所有 Score(到期时间)小于或等于当前时间戳的消息取出来进行处理。

数据结构

  • Key: delayed_queue:{queue_name} (例如 delayed_queue:order_timeout, delayed_queue:email_reminders)
    • 这是一个 Sorted Set
  • Member(成员): 消息体。这可以是一个字符串,也可以是序列化后的 JSON 字符串,包含任务所需的所有信息。
  • Score(分数): 消息的到期时间戳 (Unix Timestamp)。这是实现延时功能的关键。

核心操作流程

添加延时消息 (Enqueue with Delay)

要添加一个延时消息,你需要:

  1. 确定消息需要被处理的时间点,并计算出对应的 Unix 时间戳 (execution_timestamp)。
    • 例如:当前时间戳是 1678886400,你想让消息在 5 分钟(300 秒)后处理,那么 execution_timestamp = 1678886400 + 300 = 1678886700
  2. 准备好消息的内容 (message)。
  3. 使用 ZADD 命令将消息添加到 Sorted Set 中。

Redis 命令示例:

1
2
# 假设我们要在 1678886700 时间戳处理一条消息
ZADD delayed_queue:order_timeout 1678886700 "{\"order_id\": \"12345\", \"action\": \"cancel\"}"
  1. 队列的value就是消息本身,而不是 hash。
  2. 队列名最好是性质加类型。

处理到期消息 (Dequeue / Process Ready Messages)

这部分由一个后台 Worker 进程负责执行。

Worker 的核心逻辑:

  1. 获取当前时间戳 (current_timestamp)。
  2. 查询到期消息: 使用ZRANGEBYSCORE命令查询Sorted Set中所有Score小于或等于 current_timestamp的消息。通常会限制一次取出的消息数量 (LIMIT) 以防止瞬间负载过高。
  3. 处理消息: 遍历查询到的到期消息,执行相应的业务逻辑(如取消订单、发送邮件)。
  4. 移除已处理消息: 非常重要:处理完成后,必须将这些消息从Sorted Set 中移除,避免被重复处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# --- Worker 查询到期消息 ---
# 获取当前时间戳 (由 Worker 程序生成)
# current_timestamp = 1678886800 # (示例值)

# 查询所有已到期的消息 (最多取 10 条)。如果要继续取值,在应用层写个 for 循环,获取本批最大的 score,最为新的 start 值,继续取。

# 这个设计比较好的地方是,如果存的是时间戳,就拿最新的时间戳作为 stop 值
# 不 revrange 就可以升序查询
ZRANGEBYSCORE delayed_queue:order_timeout 0 1678886800 LIMIT 0 10 WITHSCORES
# 假设返回:
# 1) "{\"order_id\": \"12345\", \"action\": \"cancel\"}"
# 2) "1678886700"

# --- Worker 处理逻辑 (伪代码) ---
# 1. 获取当前 Unix 时间戳
# current_timestamp = get_current_unix_timestamp()

# 2. 查询到期消息
# expired_messages = redis_client.zrangebyscore('delayed_queue:order_timeout', 0, current_timestamp, start=0, num=10, withscores=True)

# 3. 遍历并处理
# for message, score in expired_messages:
# a. 解析消息内容 (例如 JSON)
# task_data = json.loads(message)
# b. 执行业务逻辑 (关键步骤!)
# if task_data['action'] == 'cancel':
# cancel_order(task_data['order_id'])
# c. 从延时队列中移除已处理的消息 (关键步骤!)
# redis_client.zrem('delayed_queue:order_timeout', message)

记住,zrange本身并不移除元素,而元素是必须通过 zrem 移除的

保证原子性 (推荐)

在高并发场景下,可能存在多个 Worker 同时取出同一条消息的风险。为了避免重复处理,最好将“取出消息”和“移除消息”这两个操作变成一个原子操作。

可以使用 Redis 的 Lua 脚本来实现这一点:

1
2
3
4
5
6
7
8
-- Lua script: 原子性地获取并移除一条到期消息
local expired = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 1)
if #expired > 0 then
redis.call('ZREM', KEYS[1], expired[1])
return expired[1]
else
return nil
end

使用 Lua 脚本的 Worker 逻辑(伪代码)

1
2
3
4
5
6
7
8
9
10
# lua_script = redis_client.register_script(lua_code_above)

# while True: # Worker 持续运行
# current_timestamp = get_current_unix_timestamp()
# expired_message = lua_script(keys=['delayed_queue:order_timeout'], args=[current_timestamp])
# if expired_message:
# parsed_message = json.loads(expired_message)
# process_message(parsed_message) # 执行业务逻辑
# else:
# time.sleep(1) # 没有消息时短暂休眠,避免空轮询

优点

  • 简单高效: 利用 Redis 原生的Sorted Set实现,无需引入额外的组件。
  • 精确延时: 基于 Unix 时间戳,可以实现秒级甚至毫秒级的精确延时。
  • 可扩展: 可以轻松创建多个不同的延时队列(通过不同的queue_name)。

注意事项

  • 时钟同步: 确保生成到期时间戳和 Worker 获取当前时间戳的服务器时钟是同步的。
  • Worker 可靠性: Worker 进程需要稳定运行,最好有监控和自动重启机制。
  • 消息处理幂等性: Worker 处理消息的逻辑最好是幂等的,以防万一消息因故障被重复处理。
  • 原子性: 强烈推荐使用 Lua 脚本或其他方式保证“获取并移除”操作的原子性。
  • 持久化: 确保 Redis 配置了适当的持久化策略(RDB/AOF),以防宕机丢失延时消息。
  • 性能: 如果消息量非常巨大,ZRANGEBYSCORE 的性能可能会成为瓶颈,需要考虑分片或其他优化策略。

排行榜

排行榜是游戏中非常常见的功能,需要根据分数对用户进行实时排名。Redis 的 Sorted Set(ZSet) 是实现排行榜的理想选择,因为它天然支持按 Score 排序,并提供了丰富的命令来查询排名、分数和范围。

数据结构设计

  1. 排行榜存储(Sorted Set)
    • Key: leaderboard:{board_name} (例如leaderboard:global, leaderboard:weekly_scores, leaderboard:level_1)
    • Member(成员): 用户的唯一标识符 (User ID)。例如user:123,player_abc
    • Score(分数): 用户的得分。可以是整数或浮点数。

核心操作流程

  1. 更新用户分数(Update Score)
    • 当用户的分数发生变化时(例如,得分增加或减少),使用ZADD命令更新其在 Sorted Set 中的分数。
    • Redis 的ZADD命令非常智能,如果 Member (用户ID) 已经存在,它会更新其 Score;如果不存在,则会添加一个新的 Member-Score 对。
    • 命令:ZADD leaderboard:{board_name} {new_score} {user_id}
    • (可选): 如果只想在新分数更高时才更新,可以先用 ZSCORE 获取旧分数比较,或者使用ZADDGT(Greater Than) 选项 (需要 Redis 6.2+)。
    • ZADD leaderboard:{board_name} GT {new_score} {user_id}
  2. 获取用户排名(Get User Rank)。我们经常使用遍历读操作来使用 redis,其实是可以用 rank 操作的,但是最好用ZREVRANK
    • 获取特定用户在排行榜中的排名(从高分到低分)。
    • 命令: ZREVRANK leaderboard:{board_name} {user_id}
    • 注意: Redis 的排名是从 0 开始的。返回 0 表示第一名,1 表示第二名,以此类推。如果用户不在排行榜中,返回 nil。
  3. 获取用户分数(Get User Score)
    • 获取特定用户的当前分数。
    • 命令: ZSCORE leaderboard:{board_name} {user_id}
    • 如果用户不在排行榜中,返回 nil。
  4. 获取排行榜(Get Top N Players)
    • 获取排行榜上前 N 名的用户及其分数。
    • 命令: ZREVRANGE leaderboard:{board_name} 0 {N-1} WITHSCORES
    • ZREVRANGE按 Score 从高到低返回成员。0 {N-1}表示获取前 N 个成员。WITHSCORES会同时返回成员和其分数。
  5. 获取排行榜的一部分(Get Players Around a User / Pagination)
    • 场景 1: 获取用户周围的排名 (例如,显示用户自己及前后各几位玩家)。
      • 先获取用户的排名: user_rank = ZREVRANK leaderboard:{board_name} {user_id}
      • 计算起始和结束索引: start = user_rank - k, end = user_rank + k (k 是前后要显示的玩家数)。
      • 获取范围: ZREVRANGE leaderboard:{board_name} {start} {end} WITHSCORES
    • 场景 2: 分页显示排行榜。
      • 决定每页显示多少名玩家 (page_size) 和当前页码 (page_number, 从 0 或 1 开始)。
      • 计算起始和结束索引: start = page_number * page_size, end = start + page_size - 1
      • 获取范围: ZREVRANGE leaderboard:{board_name} {start} {end} WITHSCORES
  6. 增加用户分数(Increment Score)
    • 如果只是给用户增加分数(例如,获得奖励分数),可以使用ZINCRBY命令,这比先 ZSCOREZADD更高效且是原子操作。
    • 命令: ZINCRBY leaderboard:{board_name} {increment_value} {user_id}
    • 例如,给用户user:123增加 10 分: ZINCRBY leaderboard:global 10 user:123

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# --- 场景:更新或添加用户分数 ---
# 玩家 player_1 获得了 1500 分
ZADD leaderboard:global 1500 player_1

# 玩家 player_2 获得了 2100 分
ZADD leaderboard:global 2100 player_2

# 玩家 player_1 再次游戏,分数更新为 1800 分
ZADD leaderboard:global 1800 player_1

# 玩家 player_3 获得 950 分
ZADD leaderboard:global 950 player_3

# --- 场景:获取玩家排名和分数 ---
# 获取 player_2 的排名 (0-based)
ZREVRANK leaderboard:global player_2
# 返回: (integer) 0 -> player_2 是第一名

# 获取 player_1 的分数
ZSCORE leaderboard:global player_1
# 返回: "1800"

# --- 场景:获取排行榜 Top 3 ---
ZREVRANGE leaderboard:global 0 2 WITHSCORES
# 假设返回:
# 1) "player_2"
# 2) "2100"
# 3) "player_1"
# 4) "1800"
# 5) "player_3"
# 6) "950"

# --- 场景:玩家 player_3 获得额外奖励 50 分 ---
ZINCRBY leaderboard:global 50 player_3
# 返回 player_3 的新分数: "1000"

# --- 场景:获取 player_3 周围的排名 ---
# 获取 player_3 的排名
ZREVRANK leaderboard:global player_3
# 假设返回: (integer) 2 -> player_3 现在是第三名 (索引 2)

# 显示 player_3 及其前后各一名玩家 (k=1)
# start_index = 2 - 1 = 1
# end_index = 2 + 1 = 3
ZREVRANGE leaderboard:global 1 3 WITHSCORES
# 假设返回 player_1, player_3, player_4 的信息

zsort 的几个关键命令:

  1. zrange-zrevrange,从高到低。重要选项:WITHSCORES。升序要想清楚是不是从0开始,逆序一般是用来取排行榜最高排名的元素。
  2. zscore:获取分数
  3. zrank:查询一个 member 的分数
  4. zadd:原子全量添加
  5. zincr:原子加分
  6. ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]:按分数区间找 member,这比 zscore要不方便一点。查找单个 member 的 score (ZSCORE) 比查找一个 score 范围内的所有 members (ZRANGEBYSCORE/ZREVRANGEBYSCORE) 要快得多,因为前者是直接哈希查找 O(1),而后者涉及范围查询和遍历 O(log(N)+M)。只有在需要根据 score 范围获取 member 时才会使用 ZRANGEBYSCORE/ZREVRANGEBYSCORE。
  7. 我们容易忘记的是,区间查找即使在 redis 里也是需要加上 limit 的。