Skip to content

排行榜功能怎么实现

初始方案:ZSET

在设计商品销量排行榜这类需求时,首选的技术方案是使用 Redis 的有序集合 (ZSET)。

  • 核心优势:ZSET 是一个天然的、高效的排序数据结构。它内部通过跳表 (Skip List) 维持元素的有序性,使得在插入和更新时就能保证排序,查询时无需进行耗时的全量排序操作。

  • 实现方式:

    • Key:leaderboard:product_sales

    • Member:商品ID (ProductID)

    • Score:商品下单量 (OrderCount)

  • 常用命令:

    • 增加销量:ZINCRBY leaderboard:product_sales 1 "product_id_123"。此操作为原子性,时间复杂度为 $O(\log N)$。

    • 查询Top10榜单:ZREVRANGE leaderboard:product_sales 0 9 WITHSCORES。此操作用于按分值从高到低查询,时间复杂度为 $O(\log N + M)$,其中 M 为返回的元素数量。

在高并发场景下,单个 ZSET 会面临“热点键写入瓶颈”。

  • 问题根源:Redis 的命令执行是单线程的。当某个商品成为爆款,瞬间产生大量订单时,无数的 ZINCRBY 请求会集中操作同一个 ZSET key,甚至是更新同一个 member。

  • 具体表现:所有对这个热点 key 的写操作必须在 Redis 的单线程中排队串行执行。当并发量过高时,会导致命令处理延迟,CPU 负载集中,进而影响整个 Redis 实例的性能,甚至阻塞其他业务的请求。

当商品数量达到上亿级别时,单个 ZSET 会遇到两个核心瓶颈,

  • 瓶颈1:内存瓶颈。上亿的 member 和 score 会占用巨大的内存空间(几十GB甚至上百GB),这通常会超出单个 Redis 节点的物理内存上限。

  • 瓶颈2:单Key扩展性瓶颈。在 Redis Cluster 集群模式下,一个 key 只能被哈希到某一个 slot 中,由一个节点负责处理。这意味着这个巨大的 ZSET 无法被水平扩展到多个节点,所有的读写压力都压在单个节点上,无法利用集群的分布式优势。

解决方案:分片 (Sharding) + 定时聚合

为了解决上述的写入瓶颈和存储瓶颈,我们需要采用分治的思想,将一个大榜单拆解为多个小榜单。

  1. 数据写入:分片处理

    • 原理:将原来一个大的 ZSET key,拆分为多个小的 ZSET key,例如 leaderboard_shard_0, leaderboard_shard_1, ..., leaderboard_shard_99

    • 路由规则:当商品销量增加时,通过对商品ID进行哈希取模,来决定将数据写入哪一个分片。

    • 示例:shard_index = hash(product_id) % 100,然后执行 ZINCRBY leaderboard_shard_{shard_index} 1 "product_id"

    • 效果:将写入压力和内存占用均匀地分散到不同的 key 上,在集群环境下这些 key 也能分布到不同节点,解决了热点和存储瓶颈。

  2. 数据读取:后台定时聚合

    • 挑战:数据被分散在各个分片中,需要一种机制来合并计算出全局的总榜单。

    • 实现:启动一个独立的后台任务(Worker),按固定频率(如每分钟)执行以下操作:

      1. 遍历所有分片 (shard_0 到 shard_99)。

      2. 从每个分片中使用 ZREVRANGE 取出 Top N (例如 Top 200) 的商品数据。这个 N 需要取得比最终榜单大一些,以保证结果的准确性。

      3. Worker 在自身内存中收集所有分片的 Top N 数据。

      4. 对收集到的数据进行合并、排序,计算出最终的全局 Top K (例如 Top 10)。

      5. 将这个全局榜单结果写入一个独立的、专供前端查询的 key 中(例如一个 JSON 字符串或一个新的 ZSET)。

    • 用户查询:用户查询榜单时,直接读取这个预先计算好的全局结果,响应速度极快。

  3. 方案总结

    • 优点:高可扩展性,通过增加分片数量可以应对更大的数据量和并发;解决了写入和存储瓶颈;前端查询性能高。

    • 缺点:非实时性,榜单数据存在一定的延迟(延迟时间等于聚合任务的执行周期);架构复杂性增加,需要额外维护聚合逻辑。这种最终一致性的方案在绝大多数排行榜场景中都是可以接受的。