MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Redis Sorted Set在排行榜功能中的实现

2021-07-041.7k 阅读

Redis Sorted Set 基础概述

Redis 作为一款高性能的键值对数据库,提供了丰富的数据结构来满足不同的应用场景。Sorted Set(有序集合)是其中一种非常特殊且强大的数据结构,它在排行榜功能的实现中扮演着关键角色。

Sorted Set 与 Set 类似,也是不包含重复元素的集合,但它的每个成员都会关联一个分数(score),Redis 正是通过这个分数来对集合中的成员进行从小到大的排序。这一特性使得 Sorted Set 非常适合用来实现各种排行榜需求,比如游戏中的玩家得分排行榜、网站的热门文章排行榜等。

Sorted Set 的底层实现主要有两种方式:压缩列表(ziplist)和跳跃表(skiplist)。当 Sorted Set 中元素数量较少,并且成员的长度和分数范围都比较小时,Redis 会使用压缩列表来存储,这样可以节省内存空间。而当元素数量较多,或者成员的长度和分数范围较大时,Redis 会切换到跳跃表,以提高查询和排序的效率。

Sorted Set 常用命令

  1. ZADD:用于向 Sorted Set 中添加一个或多个成员,以及设置每个成员的分数。语法为 ZADD key [NX|XX] [CH] [INCR] score member [score member ...]。例如,向名为 leaderboard 的 Sorted Set 中添加一个玩家 player1,其分数为 100,可以执行 ZADD leaderboard 100 player1
  2. ZRANGE:用于返回指定范围内的成员,按照分数从小到大排序。语法为 ZRANGE key start stop [WITHSCORES]。比如,要获取 leaderboard 排行榜上前 10 名的玩家,可以执行 ZRANGE leaderboard 0 9 WITHSCORESWITHSCORES 选项会同时返回成员和对应的分数。
  3. ZREVRANGE:与 ZRANGE 类似,但返回的成员是按照分数从大到小排序。语法为 ZREVRANGE key start stop [WITHSCORES]。常用于获取排行榜中排名靠前的成员,如 ZREVRANGE leaderboard 0 9 WITHSCORES 可以获取前 10 名高分玩家。
  4. ZSCORE:用于获取指定成员的分数。语法为 ZSCORE key member。例如,要查询 player1 的分数,可以执行 ZSCORE leaderboard player1
  5. ZINCRBY:用于对指定成员的分数增加指定的增量。语法为 ZINCRBY key increment member。比如,player1 获得了额外的 10 分,可以执行 ZINCRBY leaderboard 10 player1
  6. ZREM:用于从 Sorted Set 中移除一个或多个成员。语法为 ZREM key member [member ...]。若 player1 退出游戏,可执行 ZREM leaderboard player1 将其从排行榜中移除。

简单排行榜实现

场景设定

以一个简单的游戏玩家得分排行榜为例,每个玩家都有一个唯一的 ID,并且随着游戏的进行,玩家的得分会不断变化。我们的目标是使用 Redis Sorted Set 来实时更新和展示玩家的排行榜。

代码实现(Python 示例)

import redis


class Leaderboard:
    def __init__(self, host='localhost', port=6379, db=0):
        self.r = redis.StrictRedis(host=host, port=port, db=db)
        self.leaderboard_key = 'game_leaderboard'

    def add_player(self, player_id, score):
        self.r.zadd(self.leaderboard_key, {player_id: score})

    def update_score(self, player_id, increment):
        self.r.zincrby(self.leaderboard_key, increment, player_id)

    def get_top_players(self, num):
        return self.r.zrevrange(self.leaderboard_key, 0, num - 1, withscores=True)


# 示例使用
if __name__ == '__main__':
    leaderboard = Leaderboard()
    leaderboard.add_player('player1', 100)
    leaderboard.add_player('player2', 150)
    leaderboard.add_player('player3', 75)

    print("排行榜前 3 名玩家:")
    top_players = leaderboard.get_top_players(3)
    for rank, (player_id, score) in enumerate(top_players, start=1):
        print(f"排名 {rank}: 玩家 {player_id}, 分数 {score}")

    leaderboard.update_score('player3', 50)
    print("\n更新分数后排行榜前 3 名玩家:")
    top_players = leaderboard.get_top_players(3)
    for rank, (player_id, score) in enumerate(top_players, start=1):
        print(f"排名 {rank}: 玩家 {player_id}, 分数 {score}")

在上述代码中,我们定义了一个 Leaderboard 类来管理排行榜。__init__ 方法初始化 Redis 连接和排行榜的键。add_player 方法用于添加新玩家及其初始分数,update_score 方法用于更新玩家的分数,get_top_players 方法用于获取排行榜上前 num 名的玩家。

复杂排行榜功能拓展

分页查询

在实际应用中,排行榜可能包含大量的成员,一次性获取所有成员并不现实。因此,我们需要实现分页查询功能,只获取指定页面的成员。

在 Redis 中,可以通过 ZRANGEZREVRANGE 命令的 startstop 参数来实现分页。start 表示起始索引,stop 表示结束索引,索引从 0 开始。例如,要获取第 2 页,每页显示 10 个成员的排行榜,可以这样计算索引:

page = 2
page_size = 10
start = (page - 1) * page_size
stop = start + page_size - 1
players = leaderboard.r.zrevrange(leaderboard.leaderboard_key, start, stop, withscores=True)

时效性排行榜

有时候,我们需要根据时间范围来生成排行榜,比如每日排行榜、每周排行榜等。为了实现这个功能,可以在 Sorted Set 的分数中融入时间因素。

一种常见的做法是,将分数设置为时间戳加上玩家的实际得分。例如,对于每日排行榜,可以将每天的零点作为基准时间戳,玩家当天的得分加上这个基准时间戳作为 Sorted Set 中的分数。这样,通过调整时间戳,就可以轻松获取不同时间段的排行榜。

假设我们要实现一个每日排行榜,当前时间为 current_time,玩家 player_id 的当天得分是 daily_score,可以这样添加到 Sorted Set 中:

import time


# 获取当天零点的时间戳
midnight_timestamp = int(time.mktime(time.strptime(time.strftime('%Y-%m-%d 00:00:00'), '%Y-%m-%d %H:%M:%S')))
score = midnight_timestamp + daily_score
leaderboard.add_player(player_id, score)

分类排行榜

在一些应用中,可能需要根据不同的分类生成排行榜。比如游戏中,可能有不同的游戏模式,每个模式都有自己的排行榜。

可以通过在 Redis 键名中加入分类信息来实现分类排行榜。例如,对于游戏的 pvp 模式排行榜,可以将键名设置为 game_pvp_leaderboard,对于 pve 模式排行榜,键名设置为 game_pve_leaderboard

class CategorizedLeaderboard:
    def __init__(self, host='localhost', port=6379, db=0):
        self.r = redis.StrictRedis(host=host, port=port, db=db)

    def add_player(self, category, player_id, score):
        key = f'game_{category}_leaderboard'
        self.r.zadd(key, {player_id: score})

    def update_score(self, category, player_id, increment):
        key = f'game_{category}_leaderboard'
        self.r.zincrby(key, increment, player_id)

    def get_top_players(self, category, num):
        key = f'game_{category}_leaderboard'
        return self.r.zrevrange(key, 0, num - 1, withscores=True)


# 示例使用
if __name__ == '__main__':
    categorized_leaderboard = CategorizedLeaderboard()
    categorized_leaderboard.add_player('pvp', 'player1', 100)
    categorized_leaderboard.add_player('pve', 'player1', 150)

    print("PVP 模式排行榜前 3 名玩家:")
    top_players = categorized_leaderboard.get_top_players('pvp', 3)
    for rank, (player_id, score) in enumerate(top_players, start=1):
        print(f"排名 {rank}: 玩家 {player_id}, 分数 {score}")

    print("PVE 模式排行榜前 3 名玩家:")
    top_players = categorized_leaderboard.get_top_players('pve', 3)
    for rank, (player_id, score) in enumerate(top_players, start=1):
        print(f"排名 {rank}: 玩家 {player_id}, 分数 {score}")

高并发场景下的排行榜优化

减少写操作竞争

在高并发场景下,大量的玩家分数更新操作可能会导致 Redis 的写操作竞争,从而影响性能。为了减少这种竞争,可以采用以下几种方法:

  1. 批量操作:尽量将多个玩家的分数更新操作合并为一个批量操作。例如,在 Python 中可以使用 pipeline 来实现批量操作。
pipe = leaderboard.r.pipeline()
pipe.zincrby(leaderboard.leaderboard_key, 10, 'player1')
pipe.zincrby(leaderboard.leaderboard_key, 5, 'player2')
pipe.execute()
  1. 分布式更新:如果应用是分布式架构,可以将更新操作分散到多个 Redis 实例上,根据玩家 ID 的哈希值分配到不同的实例,从而减少单个实例的写压力。

缓存预热与异步更新

为了提高排行榜的查询性能,可以在系统启动时进行缓存预热,将部分常用的排行榜数据加载到内存中。同时,对于分数更新操作,可以采用异步更新的方式,将更新操作放入消息队列(如 RabbitMQ、Kafka 等)中,由专门的消费者来处理更新,这样可以避免更新操作对查询性能的影响。

假设使用 RabbitMQ 实现异步更新,代码示例如下:

import pika


def send_update_message(player_id, increment):
    connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
    channel = connection.channel()
    channel.queue_declare(queue='leaderboard_updates')
    message = f"{player_id}:{increment}"
    channel.basic_publish(exchange='', routing_key='leaderboard_updates', body=message)
    connection.close()


# 消费者端代码
import pika
import redis


def update_leaderboard_callback(ch, method, properties, body):
    parts = body.decode('utf - 8').split(':')
    player_id = parts[0]
    increment = float(parts[1])
    r = redis.StrictRedis(host='localhost', port=6379, db=0)
    r.zincrby('game_leaderboard', increment, player_id)


connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
channel.queue_declare(queue='leaderboard_updates')
channel.basic_consume(queue='leaderboard_updates', on_message_callback=update_leaderboard_callback, auto_ack=True)
channel.start_consuming()

数据一致性与持久化

数据一致性

在使用 Redis 进行排行榜开发时,数据一致性是一个重要的问题。由于 Redis 是基于内存的数据库,在发生故障或重启时,可能会导致数据丢失。为了保证数据一致性,可以采用以下措施:

  1. 持久化策略:Redis 提供了两种持久化方式,RDB(Redis Database)和 AOF(Append - Only File)。RDB 会在指定的时间间隔内将内存中的数据快照写入磁盘,适合大规模数据恢复,但可能会丢失最近一次快照之后的数据。AOF 则是将每次写操作追加到文件末尾,数据更完整,但文件可能会较大。可以根据实际需求选择合适的持久化策略,或者同时启用两种策略。

  2. 主从复制:通过设置主从复制,可以将主节点的数据复制到从节点。当主节点发生故障时,从节点可以晋升为主节点,继续提供服务,从而提高系统的可用性和数据一致性。

持久化策略配置

  1. RDB 配置:在 Redis 配置文件(redis.conf)中,可以通过以下参数配置 RDB:

    • save:指定快照的触发条件,例如 save 900 1 表示 900 秒内如果至少有 1 个键被修改,则进行快照。
    • dbfilename:指定 RDB 文件的名称,默认为 dump.rdb
    • dir:指定 RDB 文件的存储目录。
  2. AOF 配置:同样在 redis.conf 中,可以通过以下参数配置 AOF:

    • appendonly:开启 AOF 持久化,设置为 yes
    • appendfsync:指定 AOF 文件的同步策略,有 always(每次写操作都同步到磁盘)、everysec(每秒同步一次)、no(由操作系统决定何时同步)三种选项。everysec 是一个比较平衡的选择,既能保证数据的完整性,又不会对性能造成太大影响。
    • aoffilename:指定 AOF 文件的名称,默认为 appendonly.aof

常见问题及解决方案

分数相同的情况处理

在排行榜中,可能会出现多个玩家分数相同的情况。Redis 的 Sorted Set 在分数相同时,会按照成员的字典序进行排序。如果希望在分数相同的情况下有更合理的排序方式,可以在分数后面附加一个唯一的标识,比如时间戳或者玩家 ID 的哈希值等。

例如,当添加玩家时,如果分数为 score,可以将实际存储的分数设置为 score * 1000000 + player_id_hash,这样在分数相同的情况下,也能保证按照唯一标识进行排序。

内存占用问题

随着排行榜数据量的不断增加,Redis 的内存占用也会逐渐增大。为了控制内存占用,可以采取以下措施:

  1. 定期清理:对于一些过期的数据,如历史排行榜数据,可以定期进行清理。可以通过设置 Sorted Set 的过期时间,或者定期执行删除操作来实现。
  2. 数据压缩:如果成员的名称较长,可以考虑使用哈希表将成员名称映射为较短的 ID,从而减少内存占用。同时,对于分数范围较小的情况,可以使用 Redis 的压缩列表存储方式,进一步节省内存。

冷数据处理

在排行榜中,可能存在一些很少被查询到的冷数据。对于这些冷数据,可以将其迁移到其他存储介质,如磁盘文件或者关系型数据库中,以释放 Redis 的内存空间。当需要查询冷数据时,可以先从其他存储介质中读取,再根据需要重新加载到 Redis 中。

与其他技术结合

与关系型数据库结合

虽然 Redis 提供了高性能的排行榜功能,但它并不适合存储复杂的业务数据。在实际应用中,可以将玩家的详细信息(如用户名、等级、装备等)存储在关系型数据库(如 MySQL、PostgreSQL 等)中,而 Redis 只负责维护排行榜的分数和成员关系。

例如,当查询排行榜时,可以先从 Redis 中获取玩家的 ID 和分数,然后根据 ID 从关系型数据库中查询玩家的详细信息,最后将两者结合展示给用户。

与缓存框架结合

为了进一步提高性能,可以将 Redis 与其他缓存框架(如 Ehcache、Guava Cache 等)结合使用。在应用服务器端,可以使用本地缓存(如 Ehcache、Guava Cache)来缓存经常查询的排行榜数据,减少对 Redis 的查询压力。当排行榜数据发生变化时,及时更新本地缓存和 Redis 中的数据,保证数据的一致性。

性能测试与优化

性能测试工具

为了评估排行榜功能的性能,可以使用一些性能测试工具,如 JMeter、Gatling 等。这些工具可以模拟大量的并发请求,对排行榜的查询和更新操作进行性能测试。

以 JMeter 为例,要测试排行榜的查询性能,可以创建一个线程组,设置线程数(模拟并发用户数)、循环次数等参数。然后添加一个 Redis 请求 sampler,配置 Redis 服务器的地址、端口,选择 ZRANGEZREVRANGE 命令,并设置相关参数(如键名、起始索引、结束索引等)。运行测试后,JMeter 会生成详细的性能报告,包括平均响应时间、吞吐量、错误率等指标。

性能优化策略

  1. 索引优化:在 Redis 中,Sorted Set 本身已经基于分数建立了索引,查询效率较高。但如果有复杂的查询需求,如根据多个条件查询成员,可以考虑在应用层建立额外的索引,或者使用 Redis 的二级索引方案。
  2. 数据结构优化:根据实际数据量和访问模式,选择合适的数据结构。如果排行榜数据量较小且成员变动不频繁,可以考虑使用 Redis 的哈希表存储排行榜数据,以减少内存占用。如果数据量较大且需要频繁进行排序和范围查询,则使用 Sorted Set 更为合适。
  3. 网络优化:确保 Redis 服务器与应用服务器之间的网络带宽充足,减少网络延迟。可以通过优化网络拓扑、使用高速网络设备等方式来提高网络性能。同时,合理设置 Redis 的 tcp - keepalive 参数,保持网络连接的稳定性。

在实际应用中,还需要根据具体的业务场景和性能需求,不断调整和优化排行榜的设计和实现,以提供高效、稳定的排行榜服务。通过深入理解 Redis Sorted Set 的特性和应用技巧,结合其他相关技术,可以构建出满足各种复杂需求的排行榜系统。无论是简单的游戏排行榜,还是大型网站的热门内容排行榜,都能通过合理的设计和优化,为用户提供良好的体验。