一、认识Redis的Zset数据结构
Redis的有序集合(Sorted Set,简称zset)是一种特殊的数据结构,它并不是一个key对应两个value的形式。相反,zset中的每个元素都是由成员(member)和分数(score)组成的键值对。
具体来说:
- 成员(Member):这是zset中的元素,可以是任何字符串。
- 分数(Score):这是一个浮点数,用于排序zset中的成员。分数可以重复,但通常用来确定成员在zset中的顺序。
简单来说:
Zset结构是一种哈希表的结构。在横向上看是一个数组,而数组的每一个节点都可以存储了一个对象(每个对象都包含成员和分数两部分)。
数据结构:
在zset中,每个成员都关联着一个分数,这个分数决定了成员在zset中的位置。zset根据分数对成员进行排序,分数相同的情况下,成员按照插入顺序排列(注意:字典顺序仅在使用整数分数且没有时间戳等附加信息时适用)。因此,可以说zset是一个特殊的集合,其中的元素不仅有唯一性(成员),还有顺序性(通过分数实现)。
存储方式:
从存储角度来看,zset实际上是由两部分组成:
- 哈希表:用于保证成员的唯一性。这意味着zset中的每个成员都是唯一的,不能重复添加相同的成员。
- 跳跃表(Skip List):用于维护成员按分数排序后的顺序。跳跃表提供了一种快速查找、添加和删除元素的方法,平均时间复杂度为O(log N)。
示例:
假设你有一个zset,用来存储用户的积分排名:
命令示例:
# 插入元素
ZADD leaderboard 60 "user:123"
ZADD leaderboard 200 "user:456"
ZADD leaderboard 150 "user:789"
ZADD leaderboard 250 "user:250"
# 获取有序集合leaderboard中排名前3的元素
ZRANGE leaderboard 0 2 WITHSCORES
# 获取有序集合leaderboard中分数在50到150之间的元素
ZRANGEBYSCORE leaderboard 50 150 WITHSCORES
解释下:
在这个例子中,
- leaderboard是zset的键名,“user:123” 是成员,其分数为 100。
- ZRANGE 基于排名进行查询。
- ZRANGEBYSCORE 基于分数进行查询。
二、如何实现相同分数基于时间先后排名
在实现排行榜功能时,若希望分数相同时按照实现(插入)的先后顺序排序,可以采用以下方案,核心思想是 将时间信息编码进分数中,以确保相同分数下,先插入的记录排在前面。
1、核心思路
- 使用 Redis 的 ZSET(有序集合):这是实现排行榜的首选数据结构。
- 分数设计:将原始分数与时间戳结合,确保相同分数下,时间戳更早的记录排在前面。
- 时间戳编码方式:
- 时间戳倒序:将时间戳取反(如MAX_TIMESTAMP - timestamp),使得时间越早的记录分数越大。
- 时间戳作为小数:将时间戳作为分数的小数部分,确保相同分数下,时间更早的记录分数更大。
2、实现方案
方案一:组合分数(推荐)
将原始分数与时间戳结合,生成一个复合分数,确保相同分数下,时间戳更早的记录排在前面。
实现步骤:
(1)、分数计算公式:
整数放大法:将原始分数*10^13放大,在加上原始时间戳的值。
这样可以确保原始分数(raw_score)在排序中占主导地位,而时间戳只作为次要排序条件,用于在分数相同时区分先后顺序。
实际上最终score得到是一个整数。
score = raw_score * 1e13 + (MAX_TIMESTAMP - timestamp)
解释:
- raw_score:原始分数。
- timestamp:当前时间戳(毫秒或秒)。
- MAX_TIMESTAMP:一个足够大的时间戳上限(如 2^31 - 1)。
- 说明:通过MAX_TIMESTAMP - timestamp,时间越早的记录,该值越大,从而保证相同分数下,先插入的记录排在前面。
- 因为时间戳(毫秒级)最大值大约是 4102444800000(即 2100 年),也就是最多 13 位数字。所以这里我们可以使用1e13的方式扩大原始数据。这样score的原始分数就都在13位之前,时间戳的值就在后13位上。
- 如果原始数据区分浮点精度到较深的位置,如小数点后20位。那么对应1e13也要改成更大以减少误差的影响,如1e20。
2、添加成员到 ZSET
import redis
import time
r = redis.Redis(host='localhost', port=6379, db=0)
MAX_TIMESTAMP = 231 - 1 # 时间戳上限
def add_member(key, member, raw_score):
timestamp = int(time.time()) # 当前时间戳(秒)
combined_score = raw_score * 1e13 + (MAX_TIMESTAMP - timestamp) # 按照公式计算分数
r.zadd(key, {member: combined_score})
3、获取排行榜
def get_ranking(key):
return r.zrevrangebyscore(key, '+inf', '-inf', withscores=True) # 使用分数排序
4、解析原始分数
def parse_score(combined_score):
return int(combined_score / 1e13) # 提取原始分数
优点:
- 简单高效,仅需一次 ZSET 操作。
- 时间戳编码清晰,便于解析。
缺点:
- 分数精度可能受限(如浮点数精度问题)。
- 需要合理设置MAX_TIMESTAMP,避免溢出。
方案二:时间戳作为小数部分
将时间戳作为分数的小数部分,确保相同分数下,时间更早的记录分数更大。
实现步骤:
1、分数计算公式
score = raw_score + (1 - timestamp / MAX_TIMESTAMP)
解释:
- timestamp:当前时间戳(秒)。
- MAX_TIMESTAMP:时间戳上限。
- 说明:时间戳越早,1 - timestamp / MAX_TIMESTAMP 的值越大,当然结果肯定是小数。从而保证相同分数下,先插入的记录排在前面。
2、添加成员到 ZSET
def add_member(key, member, raw_score):
timestamp = time.time() # 当前时间戳(秒)
combined_score = raw_score + (1 - timestamp / MAX_TIMESTAMP) # 原始分值 + 计算的小数部分
r.zadd(key, {member: combined_score})
3、获取排行榜:
def get_ranking(key):
return r.zrevrangebyscore(key, '+inf', '-inf', withscores=True) # 按照分数排序
4、解析原始分数:
def parse_score(combined_score):
return int(combined_score) # 提取原始分数
优点:
- 时间戳作为小数,直观易懂。
- 适用于分数精度要求不高的场景。
缺点:
- 浮点数精度问题可能导致排序异常。
- 需要合理设置 MAX_TIMESTAMP,避免数值过大。
3、注意事项
- 时间戳单位:建议使用 秒级时间戳,避免毫秒级时间戳导致数值过大。
- 分数精度:若使用浮点数,需注意精度问题,可考虑使用 整数存储(如 score * 1e13)。
- 并发处理:在并发场景下,需确保分数生成的原子性(如使用 Lua 脚本或事务)。
向阳出发,Dare To Be!!!