Redis数据结构之Zset实现热点资讯排行榜

-1

一、特性说明

  • ZSet采用了一种跳跃表的实现。
  • ZSet数据是有序的

二、应用场景

1、需求

实现每分钟当天的热点资讯排行榜
实现查看七天热点资讯排行榜

2.代码实现

public class HotNewsConstants {
    public static final String DAILY_HOT_NEWS_KEY_PREFIX = "hotnews:daily:";
    public static final String WEEKLY_HOT_NEWS_KEY = "hotnews:weekly:rank";
    public static final int TOP_N = 10;
}
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.bind.annotation.*;
import io.swagger.annotations.ApiOperation;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/api/news")
public class HotNewsController {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");

    /**
     * 用户查看资讯,增加点击量
     * @param newsId 资讯ID
     */
    @ApiOperation("查看资讯")
    @PostMapping("/view/{newsId}")
    public void viewNews(@PathVariable Long newsId) {
        String todayKey = HotNewsConstants.DAILY_HOT_NEWS_KEY_PREFIX + LocalDate.now().format(DATE_FORMATTER);
        // 使用 ZINCRBY 原子性地增加分数
        redisTemplate.opsForZSet().incrementScore(todayKey, newsId, 1);
        // 可选:为每日Key设置TTL,例如7天后过期
        // redisTemplate.expire(todayKey, 7, TimeUnit.DAYS);
    }

    /**
     * 获取当日热点排行榜
     * @return 热点资讯ID列表
     */
    @ApiOperation("获取当日热点排行榜")
    @GetMapping("/hot/daily")
    public List<Long> getDailyHotNewsList() {
        String todayKey = HotNewsConstants.DAILY_HOT_NEWS_KEY_PREFIX + LocalDate.now().format(DATE_FORMATTER);
        // 获取分数最高的前N个newsId
        Set<Object> newsIds = redisTemplate.opsForZSet().reverseRange(todayKey, 0, HotNewsConstants.TOP_N - 1);
        
        if (newsIds == null || newsIds.isEmpty()) {
            return Collections.emptyList();
        }
        
        // 将Set<Object>转换为List<Long>
        return newsIds.stream()
                .map(id -> Long.valueOf(id.toString()))
                .collect(Collectors.toList());
        // 注意:实际项目中,这里应该调用一个service方法,根据newsIds列表批量查询资讯详情(标题、摘要等)
        // return newsService.getNewsDetailsByIds(newsIdList);
    }

    /**
     * 定时任务:每隔5分钟计算一次最近七天的热点资讯排行榜
     * 使用 cron 表达式定义执行周期
     */
    @Scheduled(cron = "0 0/5 * * * ?") // 每小时的第0,5,10,...55分钟执行
    public void countLastSevenHotNews() {
        LocalDate today = LocalDate.now();
        List<String> sourceKeys = new ArrayList<>();
        
        // 收集过去7天(包括今天)的Key
        for (int i = 0; i < 7; i++) {
            String dateKey = today.minusDays(i).format(DATE_FORMATTER);
            sourceKeys.add(HotNewsConstants.DAILY_HOT_NEWS_KEY_PREFIX + dateKey);
        }
        
        // 将7个Key的并集(分数相加)存储到目标Key中
        // 如果某个Key不存在,unionAndStore会忽略它
        redisTemplate.opsForZSet().unionAndStore(
                sourceKeys, 
                HotNewsConstants.WEEKLY_HOT_NEWS_KEY
        );
        
        System.out.println("七日热点排行榜更新完成: " + new Date());
    }

    /**
     * 获取最近七天热点排行榜
     * @return 热点资讯ID列表
     */
    @ApiOperation("获取最近七天热点排行榜")
    @GetMapping("/hot/weekly")
    public List<Long> getWeeklyHotNewsList() {
        // 从预计算的Key中获取排行榜
        Set<Object> newsIds = redisTemplate.opsForZSet().reverseRange(
                HotNewsConstants.WEEKLY_HOT_NEWS_KEY, 
                0, 
                HotNewsConstants.TOP_N - 1
        );

        if (newsIds == null || newsIds.isEmpty()) {
            return Collections.emptyList();
        }

        return newsIds.stream()
                .map(id -> Long.valueOf(id.toString()))
                .collect(Collectors.toList());
        // 注意:实际项目中,这里应该调用一个service方法,根据newsIds列表批量查询资讯详情
        // return newsService.getNewsDetailsByIds(newsIdList);
    }
}

1. 设计思路解析

这个方案的核心思想是利用 Redis 的 Sorted Set (ZSET) 数据结构。Sorted Set 非常适合实现排行榜,因为它具有以下特性:

  • 唯一成员: 每个 newsId 在一个集合中是唯一的。
  • 关联分值: 每个 newsId 都关联一个 double 类型的分数。
  • 自动排序: 集合会根据分数自动对所有成员进行排序。
  • 高效操作: 插入、更新、获取排名等操作的时间复杂度都是 O(log(N)),性能非常高。

1.1 键的设计

  • 每日热点键{date}:hotnews
    • {date} 是格式化后的日期,如 20250825
    • 这个键存储了当天的所有资讯及其点击次数。例如,20250825hotnews
    • 优点: 按天分片,数据隔离性好,便于管理和清理。查询当日排行时,只需操作一个 Key,非常高效。

1.2 数据流程

  1. 用户点击:

    • 当用户查看一条资讯时,后端接口 viewNews 被调用。
    • 系统获取当前日期,生成当天的 Key(如 20250825hotnews)。
    • 使用 ZINCRBY 命令(对应 Java 中的 incrementScore),将对应 newsId 的分数加 1。如果 newsId 不存在,Redis 会自动将其加入集合,并设置初始分数为 1。
  2. 查看当日排行榜:

    • 前端请求 getHotNewsList 接口。
    • 系统获取当前日期,生成当天的 Key。
    • 使用 ZREVRANGE 命令(对应 Java 中的 reverseRange),获取分数从高到低排序的前 N 个成员(这里是前 10 个)。
    • 得到 newsId 列表后,再根据这些 ID 去数据库或缓存中查询完整的资讯信息返回给前端。
  3. 查看七日排行榜:

    • 挑战: 七日排行榜需要聚合过去七天的数据。如果每次请求都去实时计算七天的并集,当数据量大时,性能会很差。
    • 解决方案: 采用“空间换时间”的策略,使用一个定时任务来预先计算好七日排行榜的结果,并存储在一个专门的 Key 中。
    • 定时任务流程 (countLastSevenHotNews):
      • 确定需要聚合的七个每日 Key(今天和过去六天)。
      • 确定一个用于存储结果的“七日汇总 Key”。
      • 使用 ZUNIONSTORE 命令(对应 Java 中的 unionAndStore),将这七个每日集合的分数相加,并将结果存入汇总 Key。
    • 用户请求:
      • 前端请求 getLastSevenHotNewsList 接口。
      • 系统直接从预先计算好的“七日汇总 Key”中,使用 ZREVRANGE 命令获取前 10 名。
      • 这个过程非常快,因为它只是一个简单的读取操作。

 

用户点击的接口在哪调用呢 当天排行榜 和 七天排行榜 列表点进去 查看详情的时候调用吗?

是的,查看详情的接口调用,正是在用户点击“当天排行榜”或“七天排行榜”列表中的某一条资讯时发生的。

下面我们来详细拆解这个流程,并结合您的设计代码进行说明。

一、接口调用时机与流程解析

整个流程可以分为两个主要阶段:排行榜展示 和 资讯详情查看

(一)排行榜展示阶段

这个阶段的目标是获取并展示一个资讯列表,列表中的每一项都代表一条热点资讯,通常包含标题、摘要、排名等信息。

  1. 用户操作:用户在前端页面上点击“当日热点”或“七日热点”按钮。
  2. 前端调用:前端会向后端请求对应的排行榜数据。
    • 如果是“当日热点”,前端会调用 getHotNewsList 接口。
    • 如果是“七日热点”,前端会调用 getLastSevenHotNewsList 接口。
  3. 后端处理
    • getHotNewsList 接口会从 Redis 中取出当天的有序集合(例如 20250825hotnews),按分数从高到低排序,然后返回排名前 N 的 newsId 列表29。
    • getLastSevenHotNewsList 接口会先将过去七天的七个有序集合进行并集计算,存入一个新的临时 Key 中,然后从这个临时 Key 中取出分数最高的 N 个 newsId 列表返回29。
  4. 前端渲染:前端接收到后端返回的 newsId 列表后,将其渲染成一个可点击的列表,展示给用户。

关键点:在这个阶段,后端返回的仅仅是资讯的ID列表,并不包含资讯的完整内容(如正文、作者、发布时间等)。这样做是为了提高排行榜页面的加载速度和降低服务器压力。

(二)资讯详情查看阶段

这个阶段是您问题的核心,即“用户点击的接口”被调用的时机。

  1. 用户操作:用户在已经渲染好的排行榜列表中,点击了其中一条感兴趣的资讯(例如,点击了排名第三的资讯)。
  2. 前端调用:前端会携带这条资讯的唯一标识符 newsId,跳转到资讯详情页,并在详情页加载时调用 viewNews 接口。
  3. 后端处理 (viewNews 接口):这个接口会执行两个关键操作:
    • 业务逻辑一:记录点击量。这是您代码中实现的核心功能。接口会获取当前日期,生成对应的 Redis Key(如 20250825hotnews),然后使用 incrementScore 命令,将传入的 newsId 的分数加 1。这代表该资讯的点击数又增加了一次29。
    • 业务逻辑二:返回资讯详情。在记录完点击量后,接口需要根据 newsId 从数据库(如 MySQL、MongoDB)中查询该资讯的完整内容(标题、正文、图片等),然后将这些详情数据返回给前端。
  4. 前端渲染:前端接收到完整的资讯详情数据后,将其渲染在详情页面上,供用户阅读。

三、总结与补充

  • 调用时机确认:您的理解是完全正确的。viewNews 接口就是那个“用户点击的接口”,它在用户从排行榜列表点击进入详情页时被调用。这个调用既是数据获取行为(获取详情),也是用户行为记录(记录一次有效点击)29。
  • 设计优点:这种将“排行榜展示”和“详情查看”分离的设计非常经典且高效。它避免了在加载排行榜时一次性查询大量详情数据,极大地提升了首页的性能和用户体验。
  • 潜在考虑:在实际应用中,为了防止恶意刷点击,可能会在 viewNews 接口中增加一些防刷逻辑,例如:
    • 对同一用户在同一短时间内的重复点击进行去重(可以使用用户ID和newsId组合作为Key进行短时间缓存)。
    • 使用 IP 限制等方式防止机器人恶意请求7。

综上所述,您的系统设计清晰地划分了数据展示和用户行为追踪的边界,viewNews 接口正是连接这两者的关键枢纽,在用户点击排行榜列表项时被调用,以完成详情展示和点击量统计的双重任务

 

  1. 性能高: 核心操作(计数、排序)都利用了 Redis ZSET 的高效特性,读写性能极佳。
  2. 实时性好: 当日排行榜是实时更新的,用户点击后能立即反映在排行榜上。
  3. 架构合理: 对于七日排行榜这种计算成本较高的查询,采用定时任务预计算的“读写分离”思想,有效提升了用户体验和系统稳定性。
  4. 可扩展性: 如果需要扩展为30日排行榜,只需修改定时任务的逻辑即可,架构上很容易支持。

潜在问题与风险

  1. 代码质量问题: 如上所述,存在多处硬伤,包括 Key 生成错误、API 参数和返回值设计不当、定时任务逻辑有误等。这些代码无法直接在生产环境运行。
  2. 数据持久化与清理:
    • Redis 通常是内存数据库,每日产生的 hotnews Key 会越来越多,占用内存。
    • 缺少过期策略: 没有为每日的 Key 设置 TTL (Time To Live)。例如,可以设置 20250825hotnews 这个 Key 在 10 天后自动过期删除。
    • 缺少清理脚本: 对于七日汇总 Key,如果命名为 hotnews:weekly:rank,它会被定时任务不断覆盖,所以问题不大。但每日 Key 必须有清理机制。
  3. 定时任务的可靠性:
    • 如果应用服务器重启或宕机,定时任务可能会错过执行周期,导致七日排行榜数据不是最新的。
    • 改进: 可以考虑使用分布式任务调度框架(如 XXL-Job, Quartz Cluster)来保证任务的高可用。
  4. 资讯信息查询:
    • 代码中只获取了 newsId,但最终需要展示资讯详情。如果每次都从数据库查询,当排行榜资讯数量多时,会给数据库带来压力。
    • 改进: 可以使用 Redis 的 Hash 或 String 来缓存资讯的详细信息,实现“缓存先行”策略,进一步降低数据库负载。

 

好的,我们来对这段代码进行一次全面的解析,并为您准备一套完整的面试讲解思路,以及预测面试官可能会提出的问题和相应的回答策略。


一、代码解析

这段代码是一个使用 Spring Boot 和 Redis 实现热点资讯排行榜功能的完整控制器。它设计清晰,功能明确,是 Redis 在实际应用中的一个经典案例。

1. 整体架构与职责

HotNewsController 是一个 RESTful 风格的控制器,负责处理所有与热点资讯相关的 HTTP 请求。它通过 @Autowired 注入 RedisTemplate 来与 Redis 进行交互,实现了核心的业务逻辑。

2. 核心组件与设计

  • Redis 数据结构选择:核心是 Sorted Set (ZSET)。选择它的原因在于:

    • 自动排序:ZSET 内部会根据 score 自动排序,非常适合排行榜场景。
    • 高效操作ZINCRBY (增加分数)、ZREVRANGE (按分数从高到低获取成员)、ZUNIONSTORE (求并集并存储) 等命令的时间复杂度都非常优秀,能保证高性能。
    • 成员唯一性:每条资讯 (newsId) 在集合中是唯一的,天然符合需求。
  • Key 设计

    • 每日排行榜 Keyhotnews:daily:yyyyMMdd (例如 hotnews:daily:20250825)。这种设计可以清晰地按天隔离数据,便于管理和查询。
    • 七日排行榜 Keyhotnews:weekly:aggregate。这是一个固定的 Key,用于存储定时任务计算出的七日聚合结果。使用固定 Key 可以避免每次查询七日排行时都进行昂贵的并集计算,实现“空间换时间”,提升查询性能。
  • 常量管理:代码中使用了 HotNewsConstants 类(虽然未展示,但从命名可以推断)来管理 Key 前缀和排行榜数量等常量。这是一个非常好的编程习惯,提高了代码的可维护性和可读性。

3. 方法功能详解

viewNews(@PathVariable Long newsId)

  • 功能:用户点击查看资讯的入口。
  • 核心逻辑:通过 redisTemplate.opsForZSet().incrementScore(todayKey, newsId, 1) 原子性地为指定 newsId 的分数加 1。这是整个计分系统的基石。
  • 原子性ZINCRBY 是 Redis 的原子操作,保证了在高并发场景下,点击计数的准确性,不会出现数据错乱。
  • TTL (Time-To-Live):注释中提到了 redisTemplate.expire(todayKey, 7, TimeUnit.DAYS)。这是一个非常重要的优化,可以自动清理过期的每日数据,防止 Redis 内存无限增长。

getDailyHotNewsList()

  • 功能:获取当天的热点资讯排行榜。
  • 核心逻辑:使用 redisTemplate.opsForZSet().reverseRange(todayKey, 0, HotNewsConstants.TOP_N - 1) 从当天的 ZSET 中获取分数最高的前 N 个 newsId
  • 数据处理:将 Redis 返回的 Set<Object> 转换为 List<Long>,方便前端使用。
  • 潜在优化:注释中提到 newsService.getNewsDetailsByIds(newsIdList)。这指出了一个关键点:当前接口只返回了 ID 列表。在实际项目中,前端需要的是资讯的标题、摘要等详细信息。因此,正确的做法是,在获取到 newsIdList 后,调用一个 Service 方法,批量查询数据库,获取完整的资讯对象列表。这比在循环中逐条查询(N+1 问题)效率高得多。

countLastSevenHotNews() (定时任务)

  • 功能:定期计算并更新七日热点排行榜。
  • 触发机制@Scheduled(cron = "0 0/5 * * * ?") 注解使其成为一个定时任务,每 5 分钟执行一次。
  • 核心逻辑
    1. 构建源 Key 列表:循环生成过去 7 天(含今天)的每日排行榜 Key。
    2. 执行并集计算redisTemplate.opsForZSet().unionAndStore(sourceKeys, HotNewsConstants.WEEKLY_HOT_NEWS_KEY) 是整个设计的精髓。它会将 sourceKeys 列表中所有 ZSET 的相同成员(newsId)的分数相加,然后将结果存入目标 Key hotnews:weekly:aggregate 中。
    3. 预计算:这个定时任务的核心思想是“预计算”。它将耗时的七日数据聚合操作在后台异步完成,使得用户在查询七日排行榜时,只需要一次简单的 ZREVRANGE 操作,从而实现了极快的查询响应速度。

getWeeklyHotNewsList()

  • 功能:获取最近七天的热点资讯排行榜。
  • 核心逻辑:与 getDailyHotNewsList 类似,但它操作的 Key 是由定时任务预计算好的 HotNewsConstants.WEEKLY_HOT_NEWS_KEY。这体现了预计算带来的巨大性能优势。

 

当面试官让你讲解这段代码时,你可以按照以下思路,层层递进,展现你的技术深度和广度。

讲解思路:总 -> 分 -> 总

1. 总体概述 (30秒)

“面试官您好,这段代码实现了一个基于 Redis 的热点资讯排行榜系统。它主要包含三个核心功能:一是记录用户对资讯的点击,二是提供当日热点排行榜的查询,三是提供最近七日热点排行榜的查询。整个系统充分利用了 Redis 的 Sorted Set 数据结构来实现高性能的计数和排序,并通过定时任务进行预计算,以优化七日排行榜的查询效率。”

  • 目的:快速让面试官明白代码是做什么的,核心技术栈是什么,解决了什么问题。

2. 核心设计与原理 (2分钟)

“接下来我讲一下它的核心设计原理。这个系统的基石是 Redis 的 Sorted Set。”

  • 为什么用 Sorted Set?

    • “首先,Sorted Set 的每个成员(在这里是 newsId)都有一个唯一的分数(score)。我们用 ZINCRBY 命令来原子性地增加分数,完美契合了‘点击一次,分数加一’的需求,并且在高并发下也能保证数据准确。”
    • “其次,Sorted Set 是按分数自动排序的。我们用 ZREVRANGE 命令就能非常高效地取出分数最高的前 N 条资讯,实现排行榜功能,这个操作的时间复杂度是 O(log(N)+M),性能非常高。”
  • Key 是如何设计的?

    • “为了方便管理,我们采用了层级化的 Key 命名。每日的排行榜 Key 是 hotnews:daily:yyyyMMdd,比如 hotnews:daily:20250825。这样做的好处是数据按天隔离,查询清晰,也方便设置过期策略。”
    • “对于七日排行榜,我们没有在用户查询时再去聚合数据,因为那样太慢了。而是采用了一个预计算的思路。我们设置了一个固定的 Key,比如 hotnews:weekly:aggregate,然后通过一个定时任务,定期将过去七天的数据合并计算好,存入这个 Key。这样用户查询时,直接从这个 Key 里取数据,速度就和查当日排行榜一样快了。”
  • 目的:深入解释技术选型和设计模式的“为什么”,展现你对底层原理的理解,而不仅仅是“会用”。

3. 关键流程拆解 (2分钟)

“具体到代码实现上,有四个关键的方法,对应了系统的核心流程。”

  • **viewNews:这是用户行为的入口。当用户点击一条资讯,前端会带着 newsId 调用这个接口。接口内部就做一件事:用 ZINCRBY 给当天的 Key 里对应的 newsId 加一分。非常简单直接。”

  • getDailyHotNewsList:这是获取当日排行榜的接口。它用 ZREVRANGE 从当天的 Key 里直接取出 Top N 的 newsId 列表返回。(这里可以主动提及优化点)** 值得注意的是,这个接口目前只返回了 ID 列表。在实际项目中,我们拿到 ID 列表后,应该去数据库进行一次批量查询,把资讯的标题、摘要等详细信息查出来再返回给前端,以避免 N+1 查询问题。”

  • **countLastSevenHotNews:这是系统的后台引擎。它被 @Scheduled 注解标记成一个定时任务,每 5 分钟跑一次。它的任务就是执行 ZUNIONSTORE 命令,把过去 7 天的每日 Key 里的数据,按 newsId 把分数加总,然后存到那个固定的七日汇总 Key 里。这个过程对用户是无感知的。”

  • **getWeeklyHotNewsList:这是获取七日排行榜的接口。因为它依赖于定时任务的预计算结果,所以它的逻辑和查当日排行榜几乎一样,只是查询的 Key 换成了那个预计算好的汇总 Key,所以查询速度非常快。”

  • 目的:将代码和之前讲的设计原理对应起来,展示你对代码细节的掌握,并能主动思考实际应用中的优化点。

4. 总结与扩展 (1分钟)

“总的来说,这个设计通过 Redis Sorted Set 实现了高效的计数和排序,通过 预计算 策略优化了复杂聚合查询的性能,通过 定时任务 实现了后台数据的自动化更新,是一个比较经典和成熟的排行榜解决方案。”

  • (可以主动提及) “当然,这个方案还有一些可以扩展和思考的地方,比如如何防止恶意刷点击、如何处理海量资讯下的内存问题、是否可以引入更复杂的权重算法(比如不同来源的点击权重不同)等等。”

  • 目的:进行总结,拔高立意,并展示你具备举一反三、思考更深层次问题的能力。


三、面试官可能问的问题及回答

Q1: 为什么用 Redis 的 Sorted Set,而不用 Hash 或普通 Set + 单独的计数器?

回答思路:对比不同方案的优劣,突出 Sorted Set 的不可替代性。

回答
“这是一个很好的问题。我们确实可以考虑其他方案,但 Sorted Set 是最合适的。

  • 如果用 Hash:Hash 可以存储 field (newsId) 和 value (score)。但它不能自动排序。每次要获取排行榜,我们都需要 HGETALL 获取所有数据,然后在应用内存中进行排序。如果数据量很大(比如几十万条资讯),这个排序操作会非常慢,而且会消耗大量应用服务器的 CPU 和内存,性能很差。

  • 如果用普通 Set + 单独的 String 计数器:我们可以用一个 Set 存所有 newsId,然后用 newsId:count 这样的 String Key 来存储每个资讯的点击数。这个方案的问题更明显:

    1. 数据不一致:Set 和 计数器是两个独立的数据结构,无法保证原子性。可能一个资讯被加入了 Set,但计数器还没来得及初始化。
    2. 排序更复杂:要获取排行榜,需要先从 Set 中拿到所有 newsId,然后循环用 MGET 去获取每个 newsId 的计数,最后再在内存中排序。这比 Hash 方案还要慢,网络开销也更大。
  • Sorted Set 的优势:它完美地解决了以上所有问题。它将成员分数封装在同一个数据结构中,保证了数据的一致性。并且,它内部由 Redis 负责维护排序,我们只需要用 ZREVRANGE 就能直接拿到有序结果,无论数据量多大,这个操作都非常高效。所以,Sorted Set 是实现排行榜的‘天选之子’。”

Q2: 你提到了预计算,那为什么不用用户查询的时候再实时计算七日数据?这样岂不是更实时?

回答思路:从性能和用户体验的角度,解释“空间换时间”的权衡。

回答
“这是一个关于实时性和性能的经典权衡问题。实时计算确实能保证数据的绝对最新,但它的代价是性能很差,用户体验会非常糟糕。

  • 实时计算的代价:假设我们每天都有 1 万条热点资讯。七天的数据就是 7 万条。当用户请求七日排行榜时,我们需要在 Redis 中执行一个 ZUNIONSTORE 操作,合并 7 个大的 Sorted Set。这个操作虽然是在 Redis 内部完成,但依然需要消耗一定的 CPU 和时间。如果同时有 100 个用户请求,就会触发 100 次这样的聚合计算,很容易导致 Redis 的 CPU 使用率飙升,响应变慢,甚至影响其他业务。用户等待时间也会从几毫秒变成几百毫秒甚至几秒。

  • 预计算的优势:我们的方案采用了预计算,本质上是用后台的定时任务去承担了计算压力。定时任务每 5 分钟才执行一次聚合计算,无论有多少用户查询,这个聚合操作都只执行一次。用户查询时,只是从一个已经计算好的、固定的 Sorted Set 中读取数据,这个操作是 O(log(N)+M) 级别的,非常快。用户感受到的延迟极低。

  • 权衡的结论:对于排行榜这种场景,数据的‘准实时’(比如延迟 5 分钟)是完全可接受的,用户不会因为排名更新慢了几分钟而感到不适。但查询的低延迟和高并发能力却是至关重要的。因此,我们牺牲了绝对的实时性,换取了卓越的性能和用户体验,这是一个非常值得的权衡。”

Q3: 如果这个系统的访问量非常大,每天有上亿次的点击,你会如何优化?

回答思路:从 Redis、应用架构、业务层面提出多层次、可落地的优化方案。

回答
“面对上亿级别的日点击量,我们需要从多个层面进行优化,以保证系统的稳定性和性能。”

  • Redis 层面优化

    1. 内存优化:Sorted Set 的内存消耗是主要瓶颈。我们可以考虑只对热点数据进行统计。比如,设置一个规则,只有点击量超过 100 次的资讯才被加入到当日的 Sorted Set 中。对于长尾资讯,可以先用其他方式(如本地缓存或消息队列)收集点击,达到阈值后再写入 Redis。这能极大地控制 Sorted Set 的规模。
    2. 读写分离/集群:单台 Redis 的内存和性能总有上限。我们可以搭建 Redis Cluster 集群,将数据分片存储,突破单机瓶颈。对于读多写少的场景(查询排行榜远多于点击),也可以搭建 Redis 主从复制,让读请求分流到从节点,减轻主节点的压力。
    3. Key 的 TTL 策略:严格执行每日 Key 的 TTL 策略,比如 7 天后自动过期,防止无效数据堆积,浪费内存。
  • 应用架构层面优化

    1. 引入消息队列削峰viewNews 接口是直接写 Redis,在高并发瞬时流量(如突发新闻)下,可能会压垮 Redis。我们可以引入 Kafka 或 RocketMQ 这样的消息队列。用户点击时,接口不直接写 Redis,而是将 newsId 和点击事件发送到 MQ。后台再启动一个或多个消费者服务,从 MQ 中批量消费消息,再异步地、批量地更新 Redis 的分数。这样就能起到削峰填谷的作用,保护后端系统。
    2. 缓存热点数据:对于排行榜的查询结果,可以在应用层使用 Caffeine 或 Guava Cache 做一个短时间的本地缓存(比如缓存 10 秒)。这样,对于短时间内的大量重复请求,可以直接从应用内存返回,进一步减轻 Redis 的压力。
  • 业务层面优化

    1. 防刷策略:上亿的点击量中可能包含大量恶意刷量。我们需要在 viewNews 接口或 MQ 消费者中加入防刷逻辑。例如,对同一用户/IP 在短时间内的重复点击进行去重(可以使用布隆过滤器或 Redis 的 Set 记录),或者对异常高频的点击进行识别和拦截。

“通过以上‘Redis优化 + 架构升级 + 业务治理’的组合拳,我们就能让系统从容应对上亿级别的访问量。”

 

一、核心含义解析

简单来说,这行代码的作用是:将多个有序集合合并成一个新的有序集合,并将结果永久存储在 Redis 中。

我们可以把它拆解成三个部分来理解:

  1. redisTemplate.opsForZSet()

    • 含义:这是 Spring Data Redis 提供的模板方法。opsForZSet() 返回一个专门用于操作 Redis 有序集合 的操作类 (ZSetOperations)。所有对 ZSET 的操作,如添加元素、获取排名、增加分数等,都需要通过这个对象来调用。
  2. .unionAndStore(...)

    • 含义:这是 ZSetOperations 接口中的一个方法,它对应 Redis 的原生命令 ZUNIONSTORE
    • Union (并集):在数学中,并集是指将多个集合中的所有元素合并在一起,去除重复的元素。对于有序集合而言,合并的规则是:
      • 如果一个元素(在我们的例子中是 newsId)只存在于一个源集合中,那么它在结果集中的分数就是它原来的分数。
      • 如果一个元素存在于多个源集合中,那么它在结果集中的分数是这些源集合中该元素分数的之和这一点至关重要,它意味着我们通过这个操作,天然地实现了“七日总点击量”的计算。
    • AndStore (并存储):这个后缀表明,计算出的并集结果不会直接返回给调用者,而是会被存储到 Redis 中的一个指定 Key 里。这是一个异步的、在 Redis 服务器端完成的计算和存储过程,非常高效。
  3. 参数解析

    • sourceKeys: 这是一个 Collection<String> 类型的参数,代表源有序集合的 Key 集合。在您的场景中,这个集合里存放的就是过去七天每日排行榜的 Key,例如:

java

复制

        // sourceKeys 可能包含以下内容
        ["hotnews:20250819", "hotnews:20250820", "hotnews:20250821", "hotnews:20250822", "hotnews:20250823", "hotnews:20250824", "hotnews:20250825"]

引用

*   **`HotNewsConstants.WEEKLY_HOT_NEWS_KEY`**: 这是一个 `String` 类型的参数,代表**目标有序集合的 Key**。它指定了将计算结果存放到 Redis 的哪个位置。例如,它的值可能是 `"hotnews:weekly:20250825"`。

二、结合您的业务场景

让我们把这行代码放回您的“七日热点资讯排行榜”业务中,看看它到底做了什么:

  1. 输入sourceKeys 包含了过去七天(比如 8月19日 到 8月25日)的每日热点排行榜的 Key。
  2. 计算过程:Redis 服务器接收到这个命令后,会执行以下操作:
    • 读取 hotnews:20250819 这个 ZSET,里面有 {newsId_1: 100, newsId_2: 50, newsId_5: 200}
    • 读取 hotnews:20250820 这个 ZSET,里面有 {newsId_1: 80, newsId_3: 150, newsId_5: 10}
    • …读取所有七天的 ZSET。
    • 开始计算并集:
      • newsId_1:在多个集合中出现,总分数 = 100 (19日) + 80 (20日) + … = 总点击量A
      • newsId_2:只在19日出现,总分数 = 50。
      • newsId_3:只在20日出现,总分数 = 150。
      • newsId_5:在多个集合中出现,总分数 = 200 (19日) + 10 (20日) + … = 总点击量B
      • …以此类推,计算出所有在过去七天内出现过的资讯的总点击量。
  3. 输出与存储:Redis 将计算出的所有 newsId 和它们的总点击量,作为一个新的有序集合,存入 HotNewsConstants.WEEKLY_HOT_NEWS_KEY(例如 hotnews:weekly:20250825)这个 Key 中。这个新的 ZSET 已经按照总点击量从高到低自动排好了序。

最终效果:当您需要查询七日排行榜时,只需要对 hotnews:weekly:20250825 这个 Key 执行 ZREVRANGE 命令,就能立刻得到按总点击量排序的资讯列表。整个复杂的聚合计算过程被 unionAndStore 优雅地完成了。


三、面试讲解思路

在面试中,当被问到这行代码时,你可以按照以下思路来回答,这会显得你对技术理解深刻,并且能将技术与业务紧密结合。

讲解思路:

  1. 一句话概括:“这行代码的核心作用是,利用 Redis 的 ZUNIONSTORE 命令,将过去七天的每日热点排行榜数据,高效地聚合计算成一个总的七日排行榜,并将结果持久化存储在 Redis 中,以供后续快速查询。”

  2. 拆解技术点

    • 数据结构:“我们使用 Redis 的 Sorted Set (ZSET) 来存储每日的排行榜。ZSET 的特点是每个成员(这里是资讯ID)都有一个分数(这里是点击量),并且会根据分数自动排序,这天然适合排行榜场景。”
    • 操作命令:“unionAndStore 方法对应的是 Redis 的 ZUNIONSTORE 命令。它分为两部分理解:‘Union’ 是求并集,它会将多个 ZSET 中的成员合并,如果成员重复,则其分数会相加;‘AndStore’ 是指计算结果不会返回,而是直接存入一个新的 ZSET Key 中。”
    • 参数说明:“第一个参数 sourceKeys 是一个集合,里面包含了我们想要合并的七个每日排行榜的 Key。第二个参数 WEEKLY_HOT_NEWS_KEY 是我们指定的目标 Key,用来存放最终计算出的七日排行榜结果。”
  3. 阐述业务价值

    • 实现需求:“这个操作完美地解决了‘计算七日总点击量’的业务需求。通过分数相加的特性,我们轻松地将分散在七天数据中的同一资讯的点击量汇总了起来。”
    • 性能优势:“这个计算过程完全在 Redis 服务器端内存中完成,速度极快。相比于把七天的数据都拉到应用服务器内存中用 Java 代码来计算,这种方式极大地减少了网络传输和 CPU 开销,性能非常高。”
    • 解耦与预计算:“它将‘计算’和‘查询’两个动作解耦了。我们通过一个定时任务(比如每天凌晨)来执行一次 unionAndStore,提前把结果算好。这样,当用户在前端请求七日排行榜时,我们只需要对预计算好的 Key 进行一次简单的 ZREVRANGE 查询即可,响应速度是毫秒级的,用户体验非常好。”
  4. 总结升华:“所以,这行代码虽然简短,但它充分利用了 Redis 的数据结构和高级命令,以一种非常高效、优雅的方式,实现了复杂的业务逻辑,是‘让专业的人做专业的事’这一思想在技术选型上的体现。”


四、面试官可能问的问题及回答

问题 1:如果一条资讯在过去七天中,某一天没有点击量(即不在那天的 ZSET 中),unionAndStore 会怎么处理?

回答
“这取决于 unionAndStore 的另一个我们这里没有显式设置的参数 weights(权重)。在我们当前的代码中,由于没有指定权重,Redis 默认所有源集合的权重都是 1。因此,如果一个 newsId 在某一天的 ZSET 中不存在,Redis 会认为它在那一天的分数是 0。在求和时,0 加上其他天的分数,结果就是其他天的分数之和。所以,这条资讯依然会出现在最终的七日排行榜中,它的分数就是它有点击量的那些天的分数总和。这完全符合我们的业务预期。”

问题 2:unionAndStore 是同步操作还是异步操作?如果源集合数据量非常大,会不会阻塞 Redis?

回答
ZUNIONSTORE 是一个同步的、原子性的 Redis 命令。当 Redis 执行这个命令时,它会阻塞其他客户端的请求,直到这个命令执行完毕。

如果源集合的数据量非常大(比如每个 ZSET 有几百万条数据),这个合并操作确实会消耗较多的 CPU 和内存资源,并可能导致 Redis 短暂阻塞,影响其他服务的响应。

解决方案

  1. 控制数据规模:在我们的业务场景中,每日排行榜通常只保留 Top N(比如 Top 1000)的资讯,而不是所有资讯。这样,sourceKeys 中的每个 ZSET 数据量都很小(最多 1000 条),合并七个这样的集合,对 Redis 来说是非常轻量的操作,几乎不会造成性能问题。这是我们设计时一个很重要的考量点。
  2. 在业务低峰期执行:我们通过 @Scheduled 定时任务,将这个操作安排在凌晨等访问量最低的时段执行,即使有轻微的性能影响,也不会影响到核心业务。
  3. 主从架构/集群:如果数据量真的到了必须处理的地步,可以考虑在 Redis 从节点上执行这类耗时的读/写操作,或者使用 Redis 集群来分散压力。”

问题 3:除了 unionAndStore,Redis 还有类似的命令吗?它们有什么区别?

回答
“是的,Redis 还有一个非常相似的命令叫做 ZINTERSTORE,它对应 Spring Data Redis 中的 intersectAndStore 方法。

核心区别在于聚合逻辑

  • ZUNIONSTORE (并集):取所有源集合中所有的成员,分数相加。它的逻辑是“或”(OR)。只要一个成员在任何一个源集合中出现,它就会出现在结果中。这适用于我们的“七日总榜”场景,我们关心的是所有在七天内有过点击的资讯。
  • ZINTERSTORE (交集):只取在所有源集合中都存在的成员,分数可以相加或使用其他聚合函数。它的逻辑是“与”(AND)。只有当一个成员在每一个源集合中都存在时,它才会出现在结果中。

应用场景举例
假设我们要做一个“七日持续热点榜”,只统计那些连续七天每天都进入排行榜的资讯。这时,ZINTERSTORE 就派上用场了。我们可以用过去七天的 Key 作为源,执行 intersectAndStore,得到的结果就是那些每天都上榜的资讯,它们的分数可以是七天的分数总和,代表其持续的热度

 

 

为什么使用 ZSet 来实现点赞排行榜?zset底层原理是什么,跳跃表底层如何实现的?面试中被问到这个问题,有逻辑的输出答案,并且输出详细的回答和核心点

第一部分:为什么使用 ZSet 来实现点赞排行榜?

要回答为什么用ZSet,我们最好的方式是先分析排行榜这个场景的核心需求,然后看看Redis提供的数据结构中,哪一个能最完美地满足这些需求。

1. 排行榜的核心需求分析

一个点赞排行榜,本质上需要同时满足两个核心操作:

  • 按分数排序:我们需要根据每个资讯的点赞数(或点击数,即分数)进行从高到低的排序,以确定其排名。
  • 按成员查找:我们需要能够快速地找到、更新或删除某个特定的资讯(比如,用户给某条新闻点赞,我们需要给这条新闻的分数+1)。这里的成员”就是资讯的唯一标识,比如 newsId

2. 对比Redis其他数据结构

现在我们来看看Redis的其他常用数据结构,为什么它们不合适,或者说不如ZSet合适。

  • List (列表)

    • 原理:底层是双向链表或压缩列表。它是有序的,但它的有序是基于插入顺序,而不是基于某个自定义的分数。
    • 为什么不合适:如果用List实现排行榜,每次有新的点赞,我们需要:
      1. 找到这个 newsId 在List中的位置(O(N)复杂度)。
      2. 删除它(O(N)复杂度)。
      3. 根据新的分数,找到它应该插入的新位置(O(N)复杂度)。
      4. 插入它(O(1)复杂度)。
    • 结论:每次更新分数都需要O(N)的时间复杂度,对于频繁更新的排行榜来说,性能是灾难性的。
  • Hash (哈希表)

    • 原理:底层是数组+链表/红黑树实现的哈希表。它存储的是 field-value 键值对。
    • 为什么不合适:Hash的查找和更新单个 newsId 的分数非常快,是O(1)。但是,Hash本身是无序的。如果我们想得到一个排序后的排行榜,我们需要把Hash里所有的 field-value 都取出来,然后在应用程序的内存中进行排序(例如,使用Java的 stream.sort())。这个过程的时间复杂度是 O(N log N),而且每次展示排行榜都要重新计算,效率低下,也浪费了应用服务器的CPU资源。
  • Set (集合)

    • 原理:底层是哈希表或整数集合。它保证了成员的唯一性,但同样是无序的,并且只能存储成员,不能存储与成员关联的分数。所以它完全不满足需求。

3. ZSet 的完美契合

现在我们来看 ZSet (Sorted Set),它就像是为排行榜场景量身定做的。

  • 结构:ZSet是一个有序集合,它存储的是 member-score 对。每个成员(member,如 newsId)都是唯一的,并且关联一个分数(score,如点赞数)。
  • 核心优势
    1. 自动排序:ZSet会根据 score 对所有 member 进行自动排序。我们无需关心排序过程,Redis在底层已经为我们做好了。
    2. 高效的更新:当某个资讯获得新的点赞时,我们使用 ZINCRBY key increment member 命令。这个命令会先找到该 member,然后给它的 score 加上 increment。由于底层使用了跳跃表,这个操作的时间复杂度是 O(log N),非常高效。
    3. 高效的查询
      • 获取排行榜Top N:使用 ZREVRANGE key 0 N-1,按分数从高到低获取成员。这个操作也是 O(log N + M)(M是返回的元素数量),性能极高。
      • 获取某个成员的排名:使用 ZREVRANK key member,可以快速知道某个资讯排在第几名,复杂度同样是 O(log N)

核心点总结

所以,我们选择ZSet,是因为它同时兼顾了高效的排序和高效的成员查找/更新。它将排序的压力从应用服务器转移到了性能更高的Redis服务器上,并且通过其精妙的底层实现,保证了所有核心操作都在对数时间复杂度内完成,是构建排行榜类功能的不二之选。


第二部分:ZSet 的底层实现原理是什么?

了解了为什么用ZSet之后,我们再深入它的内部。ZSet的强大性能,源于它巧妙地结合了两种数据结构。

1. 双重编码:Hash + SkipList

在Redis中,ZSet的底层实现同时使用了 哈希表 和 跳跃表 这两种数据结构。一个ZSet对象,内部就包含一个指向哈希表的指针和一个指向跳跃表的指针。

  • 哈希表

    • 作用:存储 member 到 score 的映射。
    • 优势:提供了 O(1) 复杂度的单点查询。比如,我们只想知道某个 newsId 当前的分数是多少,直接查哈希表就能瞬间得到,不需要遍历跳跃表。这保证了 ZSCORE 命令的高效。
  • 跳跃表

    • 作用:存储所有 member 并根据 score 进行排序。
    • 优势:提供了 O(log N) 复杂度的范围操作和排序操作。比如,获取排行榜(ZREVRANGE)、根据分数获取成员(ZRANGEBYSCORE)、给成员更新分数(ZINCRBY)等,这些操作都依赖于跳跃表的高效性。

2. 数据一致性与内存开销

  • 数据一致性:这两种结构中的数据是同步维护的。当我们向ZSet中添加一个元素时,Redis会同时把它添加到哈希表和跳跃表中。当我们删除或更新一个元素时,也会在两个结构中同时进行。这保证了无论通过哪种方式查询,结果都是一致的。

  • 内存开销:这种设计的代价是额外的内存消耗。因为同一个 member 和 score 在哈希表和跳跃表中各存了一份。这是一种典型的用空间换时间的策略,通过牺牲一部分内存,来换取极致的查询和更新性能。对于追求速度的Redis来说,这个 trade-off 是完全值得的。

核心点总结

ZSet的底层原理是 “哈希表 + 跳跃表” 的双剑合璧。

  • 哈希表 负责 快速的单点查找,解决了“某个成员分数是多少”的问题。
  • 跳跃表 负责 高效的排序和范围操作,解决了“谁排第一”、“给我前10名”的问题。
    两者协同工作,使得ZSet既能像普通Hash一样快速查询单个元素,又能高效地处理复杂的排序和范围查询,从而完美支撑了排行榜等复杂场景。

第三部分:跳跃表底层是如何实现的?

这是整个问题的技术制高点,能清晰地展示你对算法和数据结构的掌握程度。

1. 跳跃表的设计思想:为链表加速

我们都知道,有序链表的插入和删除效率很高(O(1),如果已经找到位置),但查找效率很低(O(N)),因为它只能从头到尾遍历。

跳跃表的核心思想,就是在有序链表的基础上,建立多级索引,来加速查找过程。这非常像我们生活中的地铁线路图或书本的目录。

  • 没有索引的链表:要从1找到88,必须走1->2->3->…->88,很慢。
  • 建立索引:我们抽取一部分节点(比如每隔一个),建立一个上层索引链表。现在查找88,可以先在上层索引链表上走(1->21->41->61->81),发现81后面没有,再回到81的下一层,继续走81->82->…->88。这样走的节点数就大大减少了。
  • 多层索引:我们还可以在第二层索引上,再建立第三层索引,以此类推,形成一个金字塔”结构。层数越高,节点越稀疏,跳跃”的跨度越大。

2. 跳跃表的结构组成

一个完整的跳跃表主要由以下几个部分构成:

  • 节点:每个节点都包含:

    • member:成员。
    • score:分数。
    • backward 指针:指向当前节点在同一层的前一个节点,用于反向遍历(例如 ZRANGE)。
    • level 数组:这是最关键的部分。它是一个柔性数组,包含了指向不同层级的前进指针level[i] 指向了第 i 层的下一个节点。每个 level 元素通常还包含一个 span(跨度),用于记录当前指针指向的下一个节点之间,有多少个节点被“跳过”了,这是实现 ZRANK 命令的关键。
  • 头节点:一个特殊的空节点,它的 level 数组大小决定了整个跳跃表的最大层数。它是每一层索引的入口。

  • 表尾节点:所有层级的最后一个节点都指向它,表示链表的末尾。

  • 层级:整个跳跃表的高度,即头节点 level 数组的大小。

3. 核心操作:查找、插入与随机层数

  • 查找过程

    1. 从头节点的最高层开始。
    2. 比较当前节点的下一个节点的 score 与目标 score
    3. 如果下一个节点的 score 小于目标值,就沿着当前层的前进指针向右移动。
    4. 如果下一个节点的 score 大于等于目标值,或者到达了当前层的末尾,就下降到下一层,继续步骤2。
    5. 重复此过程,直到下降到最底层(第0层),此时,当前位置的下一个节点就是要找的节点(如果存在的话)。
  • 插入过程与随机层数

    1. 查找插入位置:首先,像查找过程一样,从高层到底层找到新节点应该插入的位置(在第0层)。
    2. 随机生成节点层数:这是跳跃表的一个精髓。新节点的层数不是固定的,而是通过一个随机算法决定的。Redis使用的是 p=0.25 的幂次定律算法。简单说,一个节点有1/4的概率有2层,1/16的概率有3层,以此类推。这种随机化策略能保证跳跃表在宏观上保持平衡,避免极端情况(如所有节点都堆在最高层),从而保证了O(log N)的性能。
    3. 调整指针:确定新节点的层数 L 后,需要从第0层到第 L-1 层,依次更新每一层的指针,将新节点插入到对应的位置,并更新相关节点的 span

核心点总结

跳跃表是一种概率平衡的数据结构,它通过在有序链表上构建多级索引来加速查找。

  • 结构上,每个节点包含一个 level 数组,用于指向不同层级的后继节点,实现了“跳跃式”前进。
  • 性能上,其查找、插入、删除操作的平均时间复杂度均为 O(log N),最坏情况下是O(N),但概率极低。
  • 实现上,通过随机化决定新节点的层数,巧妙地避免了像平衡树那样复杂的旋转操作来维持平衡,使得实现相对简单,同时保证了高效的性能。

正是因为跳跃表这种兼具了高效性实现相对简单的特点,才使得它成为Redis ZSet实现排序功能的首选数据结构。

 

面试话术:Redis ZSet的跳跃表详解

第一部分:开篇立论——为什么需要跳跃表?(总)

面试官您好,当谈到Redis的ZSet(有序集合)时,它最核心的功能就是能够高效地进行范围查询(如ZRANGE)和按分值排序。如果只使用一个简单的数据结构,比如一个普通的数组或链表,我们无法同时满足“高效插入”和“高效查找”这两个需求。

  • 如果用普通链表:插入和删除操作是O(1)的,非常快。但是要查找一个元素或者进行范围查询,就必须从头到尾遍历,时间复杂度是O(N),当数据量很大时,性能会急剧下降。
  • 如果用有序数组:通过二分查找,查询效率是O(log N),非常快。但是插入或删除一个元素,为了保证有序性,可能需要移动大量元素,时间复杂度是O(N),同样无法接受。

为了解决这个矛盾,Redis引入了一种叫做跳跃表的概率平衡数据结构。它本质上是一种**“多层的有序链表”,通过增加“索引”的方式,实现了在接近O(log N)的时间复杂度**内完成插入、删除和查找操作,完美地平衡了增删改查的性能。

第二部分:层层递进——跳跃表是如何工作的?(分)

1. 核心思想:空间换时间,建立“索引”

跳跃表的核心思想非常直观,就像我们查字典或者坐地铁一样。

  • 查字典的例子:我们不会从第一页一页一页翻到我们想查的词。我们会先看字典最上方的“索引标签”(比如A, B, C…),快速定位到目标词所在的大致章节,然后再在那个章节里进行精确定位。这个“索引标签”就是跳跃表中的“上层链表”。

  • 跳跃表的结构

    • 它由多个组成,最底层(Level 0)是一个包含所有元素的有序链表
    • 在这个底层链表之上,会随机地生成一些“索引”节点,形成上层链表(Level 1, Level 2, …)。
    • 上层链表中的节点,会指向下层链表中对应的节点,形成一个“楼梯”一样的结构。

2. 查找过程:从上到下,快速“跳跃”

假设我们要在图中查找值为17的元素:

  1. 从最高层开始:我们从最顶层的头节点开始,向右查找。第一个节点是617 > 6,继续向右。
  2. 遇到“天花板”:下一个节点是2517 < 25,无法继续向右。此时,我们就像走到了楼梯的尽头,必须向下走一层
  3. 在下一层继续:我们下到Level 1,从6的下一个节点开始,也就是917 > 9,继续向右。
  4. 再次遇到“天花板”:下一个节点是2117 < 21,无法继续向右。再次向下走一层
  5. 到达最底层:我们下到Level 0,从9的下一个节点开始,也就是1217 > 12,继续向右。
  6. 找到目标:下一个节点就是17,查找成功。

你看,在整个过程中,我们跳过了1719等大量节点,大大减少了比较次数,这就是“跳跃”的由来。其查找效率与层数有关,而平均层数是log N,所以平均时间复杂度是O(log N)

3. 插入与删除:随机性与平衡性

跳跃表最巧妙的地方在于它的插入和删除过程,以及如何维持这种多层的结构。

  • 插入过程

    1. 首先,像查找一样,从高层到低层,找到新元素应该插入的位置。
    2. 然后,将新元素插入到最底层的链表中。
    3. 关键一步:通过一个随机算法(比如抛硬币)来决定这个节点要“晋升”到多少层。例如,可以设定一个概率p(通常是0.5或0.25),不断进行“抛硬币”,只要结果是正面,就让节点上升一层,直到抛出反面或达到最大层数限制。
  • 为什么是随机的?

    • 这是为了避免复杂的平衡操作。像AVL树或红黑树,在插入或删除后需要进行旋转来保持平衡,逻辑非常复杂。
    • 跳跃表通过这种概率性的方式,从宏观上保证了整个结构的平衡性。虽然可能某个时刻某个节点的层数很高,但从整体来看,层数的分布是均匀的,从而保证了整体的O(log N)性能。这是一种概率平衡,而非严格平衡
  • 删除过程

    1. 查找到要删除的节点。
    2. 由于该节点可能存在于所有层中,所以需要从它所在的最高层开始,逐层向下,在每一层中都将其从链表中移除。

4. Redis ZSet中的实际实现:跳表 + 哈希表

最后,需要强调一点,Redis的ZSet并非只使用跳跃表。它实际上是一个复合数据结构,由跳跃表哈希表共同组成。

  • 哈希表:存储了 member(成员)到 score(分值)的映射。这使得通过成员查找分值的操作(如ZSCORE)时间复杂度为O(1)
  • 跳跃表:存储了 score 和 member 的映射,并按 score 排序。这使得范围查询(如ZRANGE)、按排名查找(如ZRANK)等操作非常高效,时间复杂度为O(log N)

这种设计,结合了哈希表的O(1)查找特性和跳跃表的O(log N)有序范围查询特性,使得ZSet成为一个功能强大且性能卓越的数据结构。

第三部分:总结升华——跳跃表的优势与选择(总)

总结来说,Redis选择跳跃表作为ZSet的底层实现,主要有以下几个原因:

  1. 性能均衡:在插入、删除、查找和范围操作上都表现优异,平均时间复杂度为O(log N),没有明显的性能短板。
  2. 实现简单:相比于红黑树等自平衡二叉搜索树,跳跃表的实现逻辑更简单直观,代码更容易编写和维护,也更不容易出错。
  3. 范围查询友好:跳跃表本身就是基于链表的,进行范围查询(如ZRANGE)时,只需定位到起始点,然后顺序遍历即可,非常高效。而二叉搜索树的范围查询通常需要中序遍历,实现上稍显复杂。
  4. 内存占用可控:通过调整晋升概率p,可以灵活地在时间效率和空间效率之间做权衡。当p=0.5时,空间复杂度平均为O(N),非常节省内存。

因此,跳跃表是Redis在ZSet这个场景下,一个经过深思熟虑、权衡了性能、复杂度和内存使用后的最优解。它完美地支撑了ZSet作为排行榜、范围索引等高级功能的需求。

 

改进后的流程

  1. 写入/更新缓存:当有新的资讯发布或资讯内容更新时,除了写入数据库,我们同时将资讯详情写入Redis Hash。
  2. 查询排行榜
    • 先从ZSet中获取到排序后的 newsId 列表。
    • 遍历这个 newsId 列表。
    • 对于每一个 newsId,先去Redis Hash中查询完整的资讯详情。
    • 缓存命中:如果Redis中存在,直接使用。
    • 缓存未命中:如果Redis中不存在(即缓存穿透),则去数据库查询。查询到后,将结果写回Redis Hash(这个过程叫缓存回填),然后再返回给前端。这样下次同样的请求就不会再打到数据库了。

具体代码实现

我们将创建一个新的服务类 NewsCacheService 来封装所有缓存相关的操作,保持代码的整洁性。

1. 定义常量

首先,在 HotNewsConstants 类中增加新的Key常量。

public class HotNewsConstants {
    // ... 之前的常量
    public static final String DAILYHOTNEWSKEY = "daily:hotnews";
    public static final String WEEKLYHOTNEWSKEY = "weekly:hotnews";

    // 新增:资讯详情的Hash Key
    public static final String NEWS_DETAIL_HASH_KEY = "news:detail:hash";
}

2. 创建 NewsCacheService

这个服务将负责所有与资讯详情缓存相关的逻辑

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@Service
public class NewsCacheService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private NewsMapper newsMapper; // 假设你有一个MyBatis或JPA的数据库访问层

    /**
     * 缓存资讯详情到Redis Hash
     * @param newsId 资讯ID
     * @param newsDetail 资讯详情对象 (可以是一个Map或一个POJO)
     */
    public void cacheNewsDetail(String newsId, Map<String, Object> newsDetail) {
        String key = HotNewsConstants.NEWS_DETAIL_HASH_KEY;
        // 使用opsForHash().putAll()一次性存入整个Map
        redisTemplate.opsForHash().putAll(key, newsDetail);
        // 设置一个合理的过期时间,例如1天,防止缓存雪崩和无限增长
        redisTemplate.expire(key, 1, TimeUnit.DAYS);
    }
    
    // 如果你的newsDetail是一个POJO,可以这样写
    public void cacheNewsDetail(String newsId, News newsPOJO) {
        String key = HotNewsConstants.NEWS_DETAIL_HASH_KEY;
        // RedisTemplate会自动将POJO转换为Hash结构(前提是配置了合适的Serializer)
        redisTemplate.opsForHash().put(key, newsId, newsPOJO);
        redisTemplate.expire(key, 1, TimeUnit.DAYS);
    }


    /**
     * 获取资讯详情 (缓存优先)
     * @param newsId 资讯ID
     * @return 资讯详情Map
     */
    public Map<Object, Object> getNewsDetail(String newsId) {
        String key = HotNewsConstants.NEWS_DETAIL_HASH_KEY;
        // 1. 先从Redis Hash中查询
        Map<Object, Object> newsDetail = redisTemplate.opsForHash().entries(key);

        if (newsDetail != null && !newsDetail.isEmpty()) {
            // 2. 缓存命中,直接返回
            return newsDetail;
        }

        // 3. 缓存未命中,查询数据库
        // News newsFromDb = newsMapper.selectById(newsId); // 假设的数据库查询方法
        // if (newsFromDb == null) {
        //     return null; // 数据库也没有,返回null
        // }
        
        // 模拟数据库查询结果
        Map<String, Object> newsFromDb = newsMapper.selectNewsDetailById(newsId);
        if (newsFromDb == null) {
            return null;
        }

        // 4. 将数据库结果回填到Redis缓存 (缓存回填)
        cacheNewsDetail(newsId, newsFromDb);

        return newsFromDb;
    }
}

意:这里的 newsMapper.selectNewsDetailById 是一个模拟方法,实际项目中它会连接数据库执行查询。

 

3. 修改排行榜查询接口

现在,我们修改之前的 HotNewsService,让它利用 NewsCacheService 来获取完整的资讯详情。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;

@Service
public class HotNewsService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private NewsCacheService newsCacheService; // 注入缓存服务

    /**
     * 获取当日热点资讯排行榜(带详情)
     * @return 包含完整资讯详情的列表
     */
    public List<Map<Object, Object>> getDailyHotNewsWithDetails() {
        String key = HotNewsConstants.DAILYHOTNEWSKEY;
        ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet();

        // 1. 从ZSet获取排序后的newsId集合 (例如取前10名)
        Set<Object> newsIds = zSetOps.reverseRange(key, 0, 9);
        if (newsIds == null || newsIds.isEmpty()) {
            return Collections.emptyList();
        }

        // 2. 遍历newsId,从缓存中获取详情
        List<Map<Object, Object>> hotNewsList = new ArrayList<>();
        for (Object newsId : newsIds) {
            Map<Object, Object> newsDetail = newsCacheService.getNewsDetail(newsId.toString());
            if (newsDetail != null) {
                // 可以选择性地把排名和分数也加进去
                Long rank = zSetOps.reverseRank(key, newsId);
                Double score = zSetOps.score(key, newsId);
                newsDetail.put("rank", rank != null ? rank + 1 : null); // 排名从1开始
                newsDetail.put("score", score); // 热度分数

                hotNewsList.add(newsDetail);
            }
        }

        return hotNewsList;
    }

    /**
     * 获取七日热点资讯排行榜(带详情)
     * @return 包含完整资讯详情的列表
     */
    public List<Map<Object, Object>> getWeeklyHotNewsWithDetails() {
        String key = HotNewsConstants.WEEKLYHOTNEWSKEY;
        ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet();

        // 1. 从ZSet获取排序后的newsId集合
        Set<Object> newsIds = zSetOps.reverseRange(key, 0, 9);
        if (newsIds == null || newsIds.isEmpty()) {
            return Collections.emptyList();
        }

        // 2. 遍历newsId,从缓存中获取详情
        List<Map<Object, Object>> hotNewsList = new ArrayList<>();
        for (Object newsId : newsIds) {
            Map<Object, Object> newsDetail = newsCacheService.getNewsDetail(newsId.toString());
            if (newsDetail != null) {
                Long rank = zSetOps.reverseRank(key, newsId);
                Double score = zSetOps.score(key, newsId);
                newsDetail.put("rank", rank != null ? rank + 1 : null);
                newsDetail.put("score", score);
                hotNewsList.add(newsDetail);
            }
        }
        return hotNewsList;
    }
}

 

总结与优势

通过以上改进,我们实现了一个高性能的排行榜系统:

  1. 职责分离

    • ZSet 专注于 newsId 的排序,是轻量级的索引”。
    • Hash 专注于 newsId 对应的详情存储,是高效的缓存”。
    • 数据库 作为最终的数据源,只会在缓存未命中时被访问。
  2. 性能提升

    • 数据库压力骤降:绝大多数情况下,用户请求的资讯详情都直接从内存中的Redis Hash返回,响应速度在毫秒级,数据库只承担了首次访问或缓存失效后的少量查询。
    • 系统扩展性增强:由于数据库不再是瓶颈,整个系统能够承载更高的并发请求。
  3. 代码结构清晰

    • NewsCacheService 封装了所有缓存逻辑,使得 HotNewsService 的代码更加纯粹,只关注业务流程,易于维护和测试。

这个方案是构建高性能Web应用的经典模式,即 **多级缓存”策略,在面试中清晰地阐述出这套思路和代码实现,会极大地加分。

 

非常好的问题!这触及到了Redis中Hash数据结构的核心工作原理,以及Spring Data Redis是如何将Java对象与Redis数据结构进行映射的。

我们来详细拆解这行代码,彻底搞懂它背后发生了什么。

结论先行:这是一个对象一个Key吗?

不是的。这是一个大Key(Hash Key)下,存储了多个对象,每个对象有一个唯一的子Key(Field)。

具体来说:

  • 只有一个Redis Key:就是 HotNewsConstants.NEWS_DETAIL_HASH_KEY 这个字符串,比如它的值是 news:detail:hash
  • 在这个Key对应的Hash结构内部,存储了成千上万个资讯对象。每个资讯对象都通过它自己的 newsId 作为 Field 来标识。

这可以类比为一个Java的 Map<String, News>

  • 外层Map的Key:就是 HotNewsConstants.NEWS_DETAIL_HASH_KEY
  • 外层Map的Value:是一个内层的Map,其结构是 Map<String, News>
    • 内层Map的Key:就是 newsId (如 “1001”, “1002”)。
    • 内层Map的Value:就是 News 对象本身。

详细解释:这个对象/Map是如何存的?

这个过程分为两步:

  1. Java对象如何被转换News POJO -> Map<String, String>
  2. 转换后的Map如何存入RedisredisTemplate.opsForHash().put() 的执行过程。

第1步:Java对象到Redis Hash的序列化

当您调用 redisTemplate.opsForHash().put(key, newsId, newsPOJO) 时,RedisTemplate 并不会直接把 News 这个Java对象塞进Redis。因为Redis只理解字符串、字节等基本类型。所以,它必须进行序列化。

这个过程的核心是 HashMapperRedisTemplate 内部会使用一个 HashMapper 来将你的 News 对象转换成一个 Map<String, String>

默认情况下,如果你没有特殊配置,RedisTemplate 会使用 ObjectHashMapper。但更常见和推荐的做法是配置 Jackson2HashMapper,因为它功能强大且灵活。

我们以 Jackson2HashMapper 为例,看看转换过程:

假设你的 News POJO是这样的:

java

复制

public class News {
    private String id;      // 注意:这个id和newsId通常是同一个值
    private String title;
    private String summary;
    private String author;
    private Date publishTime;
    // getters and setters...
}

当你传入一个 News 对象时,Jackson2HashMapper 会做以下事情:

  1. 反射分析:通过反射,获取 News 类的所有属性(字段)。
  2. 属性名作为Field:将每个属性的名称(如 titlesummaryauthor)作为Redis Hash中的 Field
  3. 属性值作为Value:将每个属性的值通过 Jackson 库序列化成字符串,作为对应 Field 的 Value
    • 对于 title (String) -> "今日热点:科技新突破"
    • 对于 summary (String) -> "人工智能领域取得重大进展..."
    • 对于 publishTime (Date) -> "2025-08-25T12:00:00.000+00:00" (ISO8601格式)
    • 对于一个嵌套对象,它会被扁平化,例如 user.id -> "123"

转换结果示例:

传入 newsPOJO 对象后,它会被转换成如下的 Map<String, String>

java

复制

Map<String, String> flattenedNewsMap = new HashMap<>();
flattenedNewsMap.put("id", "1001");
flattenedNewsMap.put("title", "今日热点:科技新突破");
flattenedNewsMap.put("summary", "人工智能领域取得重大进展...");
flattenedNewsMap.put("author", "张三");
flattenedNewsMap.put("publishTime", "2025-08-25T12:00:00.000+00:00");

第2步:数据存入Redis

现在,redisTemplate 拿到了转换后的 flattenedNewsMap。它会执行 HSET 命令将这个Map存入Redis。

redisTemplate.opsForHash().put(key, newsId, newsPOJO) 实际上在Redis层面执行的是一系列 HSET 命令。

假设:

  • HotNewsConstants.NEWS_DETAIL_HASH_KEY 的值是 "news:detail:hash"
  • newsId 的值是 "1001"

那么,Redis中实际发生的操作是:

redis

复制

# HSET命令的格式: HSET key field value

HSET "news:detail:hash" "1001.id" "1001"
HSET "news:detail:hash" "1001.title" "今日热点:科技新突破"
HSET "news:detail:hash" "1001.summary" "人工智能领域取得重大进展..."
HSET "news:detail:hash" "1001.author" "张三"
HSET "news:detail:hash" "1001.publishTime" "2025-08-25T12:00:00.000+00:00"

注意Jackson2HashMapper 默认会在每个Field前加上对象在Hash中的Key作为前缀(即 1001.),以避免不同对象的属性名冲突。例如,如果还有一个 User 对象也存进这个Hash,它的 id 字段会变成 user123.id,从而和 1001.id 区分开。

最终在Redis中的数据结构可视化:

 

复制

// Redis Key: news:detail:hash (这是一个Hash类型)
// 这个Hash内部存储了多个Field-Value对,每个Field代表一个新闻对象的一个属性

{
  "1001.id": "1001",
  "1001.title": "今日热点:科技新突破",
  "1001.summary": "人工智能领域取得重大进展...",
  "1001.author": "张三",
  "1001.publishTime": "2025-08-25T12:00:00.000+00:00",
  
  "1002.id": "1002",
  "1002.title": "财经快讯:市场新动态",
  "1002.summary": "全球股市今日表现...",
  "1002.author": "李四",
  "1002.publishTime": "2025-08-25T11:30:00.000+00:00",
  
  // ... 更多新闻
}

引用

关于 expire(key, 1, TimeUnit.DAYS)

这行代码 redisTemplate.expire(key, 1, TimeUnit.DAYS); 为整个Hash设置了过期时间。

  • 作用:它为 整个 "news:detail:hash" 这个Key 设置了1天的生存时间。
  • 效果:1天后,这个Key以及它内部包含的所有新闻详情数据(1001, 1002…)都会被Redis自动删除。
  • 目的:这是一种缓存淘汰策略。防止缓存数据无限增长,保证缓存中的数据最终会和数据库保持一致(即使不是强一致)。1天后,如果再有用户请求这些新闻,系统会从数据库重新加载并缓存。

总结

概念解释示例
Redis Key唯一标识一个Hash结构,所有新闻详情都存在这一个Hash里。news:detail:hash
Hash Field由 newsId 和 POJO属性名 组合而成,用于唯一标识一个对象的某个属性。1001.title1002.author
Hash Value对象属性序列化后的字符串值。"今日热点:科技新突破"
存储方式一个大Hash里,通过不同的Field存储了成千上万个新闻对象的所有属性。HSET news:detail:hash 1001.title "..."
过期策略对整个Hash Key设置TTL,实现批量过期和缓存更新。EXPIRE news:detail:hash 86400

这种设计模式非常高效,因为它:

  1. 聚合管理:所有同类数据(新闻详情)都在一个Key下,便于统一管理(如设置统一的过期时间)。
  2. 访问高效:获取单个新闻详情 (HGETALL news:detail:hash 1001.*) 或单个属性 (HGET news:detail:hash 1001.title) 都非常快。
  3. 内存友好:相比将每个新闻对象序列化为一个大的JSON字符串存为独立的String Key,Hash结构在存储多个小对象时通常更节省内存。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值