Redis--场景篇1--如何实现排行榜分数相同时,根据实现先后顺序排序?(Zset结合时间戳的分数处理(转整数处理,转小数处理))

发布于:2025-07-21 ⋅ 阅读:(14) ⋅ 点赞:(0)

一、认识Redis的Zset数据结构

Redis的有序集合(Sorted Set,简称zset)是一种特殊的数据结构,它并不是一个key对应两个value的形式。相反,zset中的每个元素都是由成员(member)和分数(score)组成的键值对。
具体来说:

  • 成员(Member):这是zset中的元素,可以是任何字符串。
  • 分数(Score):这是一个浮点数,用于排序zset中的成员。分数可以重复,但通常用来确定成员在zset中的顺序。

简单来说:
Zset结构是一种哈希表的结构。在横向上看是一个数组,而数组的每一个节点都可以存储了一个对象(每个对象都包含成员和分数两部分)。

数据结构:
在zset中,每个成员都关联着一个分数,这个分数决定了成员在zset中的位置。zset根据分数对成员进行排序,分数相同的情况下,成员按照插入顺序排列(注意:字典顺序仅在使用整数分数且没有时间戳等附加信息时适用)。因此,可以说zset是一个特殊的集合,其中的元素不仅有唯一性(成员),还有顺序性(通过分数实现)。

存储方式:
从存储角度来看,zset实际上是由两部分组成:

  1. 哈希表:用于保证成员的唯一性。这意味着zset中的每个成员都是唯一的,不能重复添加相同的成员。
  2. 跳跃表(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中分数在50150之间的元素
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!!!