Redis 的神奇用例
二级评论区
在现代 Web 应用中,评论系统是用户互动的核心功能之一。一个设计良好的评论系统不仅要能处理大量的读写请求,还需要支持诸如“回复评论”这样的嵌套结构(通常称为二级评论或评论回复)。Redis,作为一个高性能的内存数据库,凭借其丰富的数据结构,非常适合用来构建这样的系统。
本文将探讨如何利用 Redis 的 Hashes(哈希) 和 Sorted Sets(有序集合) 来设计和实现一个高效、可扩展的二级评论系统。
核心设计理念
我们设计的核心思想可以概括为两点:
- 实体存储: 使用 Redis Hash 来存储每条评论(包括一级评论和二级回复)的具体内容-不存博客的具体内容。
- 关系与排序: 使用 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
。
优点: 同样提供了对回复列表的快速排序和分页能力。
核心操作流程
发表一级评论
- 生成唯一评论 ID (
new_comment_id
)。 - 使用
HSET comment:{new_comment_id}
存储评论内容,设置parent_id
为 “0”。 - 使用
ZADD post:{post_id}:comments {timestamp} {new_comment_id}
将评论 ID 添加到文章的评论列表中。
发表二级评论 (回复)
- 生成唯一回复 ID (
new_reply_id
)。 - 使用
HSET comment:{new_reply_id}
存储回复内容,设置parent_id
为被回复评论的 ID (parent_comment_id
)。这一步和上一环节第二步一样,但是多了一个parent_comment_id
。 - 使用
ZADD comment:{parent_comment_id}:replies {timestamp} {new_reply_id}
将回复 ID 添加到父评论的回复列表中。
这里引入了一个设计,主表拥有关联表的主键控制权,而从表通过自身的paren_id
来寻找父节点。不同元素之间不使用同一个parent_id
而使用另一个类型的id作为外键。而且回复需要用一张单独的三级表。
读取文章评论 (带分页和回复)
这些步骤告诉我们,对多个数据结构就应该多步操作。
- 获取一级评论列表: 使用
ZREVRANGE post:{post_id}:comments {start} {end}
获取指定范围(如最新的 N 条)一级评论的 ID 列表。 - 获取评论详情: 遍历上一步获取的 ID 列表,对每个 ID 使用
HGETALL comment:{comment_id}
获取其完整内容。 - (可选)获取回复列表: 对于每条一级评论,如果需要加载其回复:
- 使用
ZREVRANGE comment:{parent_comment_id}:replies 0 {limit}
获取该评论下的前limit
条回复 ID。 - 再次遍历回复 ID 列表,使用
HGETALL comment:{reply_id}
获取每条回复的完整内容。
- 使用
- 删除评论
- 逻辑删除: 推荐做法是将 comment:{id} Hash 中的 status 字段更新为 ‘deleted’。在读取时过滤掉状态为 ‘deleted’ 的评论。
- 物理删除: 更复杂,需要同时从父级列表 (ZREM)、删除其回复列表 (DEL)、删除其内容 Hash (DEL),并处理计数器等。
示例 (Redis 命令)
1 |
|
range 的提示:
在 Redis 中,像 0 -1 这样的起止点(通常用于 ZRANGE, ZREVRANGE, LRANGE, SUBSTR, GETRANGE 等命令)有其特定的设计含义,主要用于指定范围的开始和结束位置。这种设计在很多编程语言和数据结构库中都很常见。
以下是关键点:
- 索引从 0 开始: 和大多数编程语言一样,Redis 中范围的索引通常是从 0 开始计数的。第一个元素的索引是 0,第二个是 1,依此类推。
- 负数索引: Redis 支持使用负数作为索引,这为从序列末尾开始计数提供了便利。
- -1 表示最后一个元素。
- -2 表示倒数第二个元素。
- -3 表示倒数第三个元素。
- 以此类推。
- start 和 stop 参数:
- start: 范围的起始索引(包含该位置的元素)。
- stop: 范围的结束索引(也包含该位置的元素)。这与某些编程语言(如 Python 的切片,list[0:5] 不包含索引 5)有所不同,Redis 的范围是包含两端的。
- 0 -1 的含义:
- 0: 从第一个元素开始。
- -1: 到最后一个元素结束。
- 因此,0 -1 组合在一起,通常表示“从头到尾的所有元素”。
- 其他常用组合:
- 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)
要添加一个延时消息,你需要:
- 确定消息需要被处理的时间点,并计算出对应的 Unix 时间戳 (
execution_timestamp
)。- 例如:当前时间戳是
1678886400
,你想让消息在 5 分钟(300 秒)后处理,那么execution_timestamp = 1678886400 + 300 = 1678886700
。
- 例如:当前时间戳是
- 准备好消息的内容 (
message
)。 - 使用
ZADD
命令将消息添加到Sorted Set
中。
Redis 命令示例:
1 |
|
- 队列的value就是消息本身,而不是 hash。
- 队列名最好是性质加类型。
处理到期消息 (Dequeue / Process Ready Messages)
这部分由一个后台 Worker 进程负责执行。
Worker 的核心逻辑:
- 获取当前时间戳 (
current_timestamp
)。 - 查询到期消息: 使用
ZRANGEBYSCORE
命令查询Sorted Set
中所有Score
小于或等于current_timestamp
的消息。通常会限制一次取出的消息数量 (LIMIT
) 以防止瞬间负载过高。 - 处理消息: 遍历查询到的到期消息,执行相应的业务逻辑(如取消订单、发送邮件)。
- 移除已处理消息: 非常重要:处理完成后,必须将这些消息从
Sorted Set
中移除,避免被重复处理。
1 |
|
记住,zrange
本身并不移除元素,而元素是必须通过 zrem 移除的!
保证原子性 (推荐)
在高并发场景下,可能存在多个 Worker 同时取出同一条消息的风险。为了避免重复处理,最好将“取出消息”和“移除消息”这两个操作变成一个原子操作。
可以使用 Redis 的 Lua 脚本来实现这一点:
1 |
|
使用 Lua 脚本的 Worker 逻辑(伪代码)
1 |
|
优点
- 简单高效: 利用 Redis 原生的
Sorted Set
实现,无需引入额外的组件。 - 精确延时: 基于 Unix 时间戳,可以实现秒级甚至毫秒级的精确延时。
- 可扩展: 可以轻松创建多个不同的延时队列(通过不同的
queue_name
)。
注意事项
- 时钟同步: 确保生成到期时间戳和 Worker 获取当前时间戳的服务器时钟是同步的。
- Worker 可靠性: Worker 进程需要稳定运行,最好有监控和自动重启机制。
- 消息处理幂等性: Worker 处理消息的逻辑最好是幂等的,以防万一消息因故障被重复处理。
- 原子性: 强烈推荐使用 Lua 脚本或其他方式保证“获取并移除”操作的原子性。
- 持久化: 确保 Redis 配置了适当的持久化策略(RDB/AOF),以防宕机丢失延时消息。
- 性能: 如果消息量非常巨大,
ZRANGEBYSCORE
的性能可能会成为瓶颈,需要考虑分片或其他优化策略。
排行榜
排行榜是游戏中非常常见的功能,需要根据分数对用户进行实时排名。Redis 的 Sorted Set(ZSet) 是实现排行榜的理想选择,因为它天然支持按 Score 排序,并提供了丰富的命令来查询排名、分数和范围。
数据结构设计
- 排行榜存储(Sorted Set)
- Key:
leaderboard:{board_name}
(例如leaderboard:global
,leaderboard:weekly_scores
,leaderboard:level_1
) - Member(成员): 用户的唯一标识符 (User ID)。例如
user:123
,player_abc
。 - Score(分数): 用户的得分。可以是整数或浮点数。
- Key:
核心操作流程
- 更新用户分数(Update Score)
- 当用户的分数发生变化时(例如,得分增加或减少),使用
ZADD
命令更新其在 Sorted Set 中的分数。 - Redis 的
ZADD
命令非常智能,如果 Member (用户ID) 已经存在,它会更新其 Score;如果不存在,则会添加一个新的 Member-Score 对。 - 命令:
ZADD leaderboard:{board_name} {new_score} {user_id}
- (可选): 如果只想在新分数更高时才更新,可以先用 ZSCORE 获取旧分数比较,或者使用
ZADD
的GT
(Greater Than) 选项 (需要 Redis 6.2+)。 ZADD leaderboard:{board_name} GT {new_score} {user_id}
- 当用户的分数发生变化时(例如,得分增加或减少),使用
- 获取用户排名(Get User Rank)。我们经常使用遍历读操作来使用 redis,其实是可以用 rank 操作的,但是最好用
ZREVRANK
。- 获取特定用户在排行榜中的排名(从高分到低分)。
- 命令:
ZREVRANK leaderboard:{board_name} {user_id}
。 - 注意: Redis 的排名是从 0 开始的。返回 0 表示第一名,1 表示第二名,以此类推。如果用户不在排行榜中,返回 nil。
- 获取用户分数(Get User Score)
- 获取特定用户的当前分数。
- 命令:
ZSCORE leaderboard:{board_name} {user_id}
- 如果用户不在排行榜中,返回 nil。
- 获取排行榜(Get Top N Players)
- 获取排行榜上前 N 名的用户及其分数。
- 命令:
ZREVRANGE leaderboard:{board_name} 0 {N-1} WITHSCORES
ZREVRANGE
按 Score 从高到低返回成员。0 {N-1}
表示获取前 N 个成员。WITHSCORES
会同时返回成员和其分数。
- 获取排行榜的一部分(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
- 决定每页显示多少名玩家 (
- 场景 1: 获取用户周围的排名 (例如,显示用户自己及前后各几位玩家)。
- 增加用户分数(Increment Score)
- 如果只是给用户增加分数(例如,获得奖励分数),可以使用
ZINCRBY
命令,这比先ZSCORE
再ZADD
更高效且是原子操作。 - 命令:
ZINCRBY leaderboard:{board_name} {increment_value} {user_id}
- 例如,给用户
user:123
增加 10 分:ZINCRBY leaderboard:global 10 user:123
- 如果只是给用户增加分数(例如,获得奖励分数),可以使用
示例
1 |
|
zsort 的几个关键命令:
- zrange-zrevrange,从高到低。重要选项:WITHSCORES。升序要想清楚是不是从0开始,逆序一般是用来取排行榜最高排名的元素。
- zscore:获取分数
- zrank:查询一个 member 的分数
- zadd:原子全量添加
- zincr:原子加分
- 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。
- 我们容易忘记的是,区间查找即使在 redis 里也是需要加上 limit 的。