一、特性说明
- 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 数据流程
-
用户点击:
- 当用户查看一条资讯时,后端接口
viewNews被调用。 - 系统获取当前日期,生成当天的 Key(如
20250825hotnews)。 - 使用
ZINCRBY命令(对应 Java 中的incrementScore),将对应newsId的分数加 1。如果newsId不存在,Redis 会自动将其加入集合,并设置初始分数为 1。
- 当用户查看一条资讯时,后端接口
-
查看当日排行榜:
- 前端请求
getHotNewsList接口。 - 系统获取当前日期,生成当天的 Key。
- 使用
ZREVRANGE命令(对应 Java 中的reverseRange),获取分数从高到低排序的前 N 个成员(这里是前 10 个)。 - 得到
newsId列表后,再根据这些 ID 去数据库或缓存中查询完整的资讯信息返回给前端。
- 前端请求
-
查看七日排行榜:
- 挑战: 七日排行榜需要聚合过去七天的数据。如果每次请求都去实时计算七天的并集,当数据量大时,性能会很差。
- 解决方案: 采用“空间换时间”的策略,使用一个定时任务来预先计算好七日排行榜的结果,并存储在一个专门的 Key 中。
- 定时任务流程 (
countLastSevenHotNews):- 确定需要聚合的七个每日 Key(今天和过去六天)。
- 确定一个用于存储结果的“七日汇总 Key”。
- 使用
ZUNIONSTORE命令(对应 Java 中的unionAndStore),将这七个每日集合的分数相加,并将结果存入汇总 Key。
- 用户请求:
- 前端请求
getLastSevenHotNewsList接口。 - 系统直接从预先计算好的“七日汇总 Key”中,使用
ZREVRANGE命令获取前 10 名。 - 这个过程非常快,因为它只是一个简单的读取操作。
- 前端请求
用户点击的接口在哪调用呢 当天排行榜 和 七天排行榜 列表点进去 查看详情的时候调用吗?
是的,查看详情的接口调用,正是在用户点击“当天排行榜”或“七天排行榜”列表中的某一条资讯时发生的。
下面我们来详细拆解这个流程,并结合您的设计代码进行说明。
一、接口调用时机与流程解析
整个流程可以分为两个主要阶段:排行榜展示 和 资讯详情查看。
(一)排行榜展示阶段
这个阶段的目标是获取并展示一个资讯列表,列表中的每一项都代表一条热点资讯,通常包含标题、摘要、排名等信息。
- 用户操作:用户在前端页面上点击“当日热点”或“七日热点”按钮。
- 前端调用:前端会向后端请求对应的排行榜数据。
- 如果是“当日热点”,前端会调用
getHotNewsList接口。 - 如果是“七日热点”,前端会调用
getLastSevenHotNewsList接口。
- 如果是“当日热点”,前端会调用
- 后端处理:
getHotNewsList接口会从 Redis 中取出当天的有序集合(例如20250825hotnews),按分数从高到低排序,然后返回排名前 N 的newsId列表29。getLastSevenHotNewsList接口会先将过去七天的七个有序集合进行并集计算,存入一个新的临时 Key 中,然后从这个临时 Key 中取出分数最高的 N 个newsId列表返回29。
- 前端渲染:前端接收到后端返回的
newsId列表后,将其渲染成一个可点击的列表,展示给用户。
关键点:在这个阶段,后端返回的仅仅是资讯的ID列表,并不包含资讯的完整内容(如正文、作者、发布时间等)。这样做是为了提高排行榜页面的加载速度和降低服务器压力。
(二)资讯详情查看阶段
这个阶段是您问题的核心,即“用户点击的接口”被调用的时机。
- 用户操作:用户在已经渲染好的排行榜列表中,点击了其中一条感兴趣的资讯(例如,点击了排名第三的资讯)。
- 前端调用:前端会携带这条资讯的唯一标识符
newsId,跳转到资讯详情页,并在详情页加载时调用viewNews接口。 - 后端处理 (
viewNews接口):这个接口会执行两个关键操作:- 业务逻辑一:记录点击量。这是您代码中实现的核心功能。接口会获取当前日期,生成对应的 Redis Key(如
20250825hotnews),然后使用incrementScore命令,将传入的newsId的分数加 1。这代表该资讯的点击数又增加了一次29。 - 业务逻辑二:返回资讯详情。在记录完点击量后,接口需要根据
newsId从数据库(如 MySQL、MongoDB)中查询该资讯的完整内容(标题、正文、图片等),然后将这些详情数据返回给前端。
- 业务逻辑一:记录点击量。这是您代码中实现的核心功能。接口会获取当前日期,生成对应的 Redis Key(如
- 前端渲染:前端接收到完整的资讯详情数据后,将其渲染在详情页面上,供用户阅读。
三、总结与补充
- 调用时机确认:您的理解是完全正确的。
viewNews接口就是那个“用户点击的接口”,它在用户从排行榜列表点击进入详情页时被调用。这个调用既是数据获取行为(获取详情),也是用户行为记录(记录一次有效点击)29。 - 设计优点:这种将“排行榜展示”和“详情查看”分离的设计非常经典且高效。它避免了在加载排行榜时一次性查询大量详情数据,极大地提升了首页的性能和用户体验。
- 潜在考虑:在实际应用中,为了防止恶意刷点击,可能会在
viewNews接口中增加一些防刷逻辑,例如:- 对同一用户在同一短时间内的重复点击进行去重(可以使用用户ID和newsId组合作为Key进行短时间缓存)。
- 使用 IP 限制等方式防止机器人恶意请求7。
综上所述,您的系统设计清晰地划分了数据展示和用户行为追踪的边界,viewNews 接口正是连接这两者的关键枢纽,在用户点击排行榜列表项时被调用,以完成详情展示和点击量统计的双重任务
- 性能高: 核心操作(计数、排序)都利用了 Redis ZSET 的高效特性,读写性能极佳。
- 实时性好: 当日排行榜是实时更新的,用户点击后能立即反映在排行榜上。
- 架构合理: 对于七日排行榜这种计算成本较高的查询,采用定时任务预计算的“读写分离”思想,有效提升了用户体验和系统稳定性。
- 可扩展性: 如果需要扩展为30日排行榜,只需修改定时任务的逻辑即可,架构上很容易支持。
潜在问题与风险
- 代码质量问题: 如上所述,存在多处硬伤,包括 Key 生成错误、API 参数和返回值设计不当、定时任务逻辑有误等。这些代码无法直接在生产环境运行。
- 数据持久化与清理:
- Redis 通常是内存数据库,每日产生的
hotnewsKey 会越来越多,占用内存。 - 缺少过期策略: 没有为每日的 Key 设置 TTL (Time To Live)。例如,可以设置
20250825hotnews这个 Key 在 10 天后自动过期删除。 - 缺少清理脚本: 对于七日汇总 Key,如果命名为
hotnews:weekly:rank,它会被定时任务不断覆盖,所以问题不大。但每日 Key 必须有清理机制。
- Redis 通常是内存数据库,每日产生的
- 定时任务的可靠性:
- 如果应用服务器重启或宕机,定时任务可能会错过执行周期,导致七日排行榜数据不是最新的。
- 改进: 可以考虑使用分布式任务调度框架(如 XXL-Job, Quartz Cluster)来保证任务的高可用。
- 资讯信息查询:
- 代码中只获取了
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) 在集合中是唯一的,天然符合需求。
- 自动排序:ZSET 内部会根据
-
Key 设计:
- 每日排行榜 Key:
hotnews:daily:yyyyMMdd(例如hotnews:daily:20250825)。这种设计可以清晰地按天隔离数据,便于管理和查询。 - 七日排行榜 Key:
hotnews:weekly:aggregate。这是一个固定的 Key,用于存储定时任务计算出的七日聚合结果。使用固定 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 分钟执行一次。 - 核心逻辑:
- 构建源 Key 列表:循环生成过去 7 天(含今天)的每日排行榜 Key。
- 执行并集计算:
redisTemplate.opsForZSet().unionAndStore(sourceKeys, HotNewsConstants.WEEKLY_HOT_NEWS_KEY)是整个设计的精髓。它会将sourceKeys列表中所有 ZSET 的相同成员(newsId)的分数相加,然后将结果存入目标 Keyhotnews:weekly:aggregate中。 - 预计算:这个定时任务的核心思想是“预计算”。它将耗时的七日数据聚合操作在后台异步完成,使得用户在查询七日排行榜时,只需要一次简单的
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),性能非常高。”
- “首先,Sorted Set 的每个成员(在这里是
-
Key 是如何设计的?
- “为了方便管理,我们采用了层级化的 Key 命名。每日的排行榜 Key 是
hotnews:daily:yyyyMMdd,比如hotnews:daily:20250825。这样做的好处是数据按天隔离,查询清晰,也方便设置过期策略。” - “对于七日排行榜,我们没有在用户查询时再去聚合数据,因为那样太慢了。而是采用了一个预计算的思路。我们设置了一个固定的 Key,比如
hotnews:weekly:aggregate,然后通过一个定时任务,定期将过去七天的数据合并计算好,存入这个 Key。这样用户查询时,直接从这个 Key 里取数据,速度就和查当日排行榜一样快了。”
- “为了方便管理,我们采用了层级化的 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 来存储每个资讯的点击数。这个方案的问题更明显:- 数据不一致:Set 和 计数器是两个独立的数据结构,无法保证原子性。可能一个资讯被加入了 Set,但计数器还没来得及初始化。
- 排序更复杂:要获取排行榜,需要先从 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 层面优化:
- 内存优化:Sorted Set 的内存消耗是主要瓶颈。我们可以考虑只对热点数据进行统计。比如,设置一个规则,只有点击量超过 100 次的资讯才被加入到当日的 Sorted Set 中。对于长尾资讯,可以先用其他方式(如本地缓存或消息队列)收集点击,达到阈值后再写入 Redis。这能极大地控制 Sorted Set 的规模。
- 读写分离/集群:单台 Redis 的内存和性能总有上限。我们可以搭建 Redis Cluster 集群,将数据分片存储,突破单机瓶颈。对于读多写少的场景(查询排行榜远多于点击),也可以搭建 Redis 主从复制,让读请求分流到从节点,减轻主节点的压力。
- Key 的 TTL 策略:严格执行每日 Key 的 TTL 策略,比如 7 天后自动过期,防止无效数据堆积,浪费内存。
-
应用架构层面优化:
- 引入消息队列削峰:
viewNews接口是直接写 Redis,在高并发瞬时流量(如突发新闻)下,可能会压垮 Redis。我们可以引入 Kafka 或 RocketMQ 这样的消息队列。用户点击时,接口不直接写 Redis,而是将newsId和点击事件发送到 MQ。后台再启动一个或多个消费者服务,从 MQ 中批量消费消息,再异步地、批量地更新 Redis 的分数。这样就能起到削峰填谷的作用,保护后端系统。 - 缓存热点数据:对于排行榜的查询结果,可以在应用层使用 Caffeine 或 Guava Cache 做一个短时间的本地缓存(比如缓存 10 秒)。这样,对于短时间内的大量重复请求,可以直接从应用内存返回,进一步减轻 Redis 的压力。
- 引入消息队列削峰:
-
业务层面优化:
- 防刷策略:上亿的点击量中可能包含大量恶意刷量。我们需要在
viewNews接口或 MQ 消费者中加入防刷逻辑。例如,对同一用户/IP 在短时间内的重复点击进行去重(可以使用布隆过滤器或 Redis 的 Set 记录),或者对异常高频的点击进行识别和拦截。
- 防刷策略:上亿的点击量中可能包含大量恶意刷量。我们需要在
“通过以上‘Redis优化 + 架构升级 + 业务治理’的组合拳,我们就能让系统从容应对上亿级别的访问量。”
一、核心含义解析
简单来说,这行代码的作用是:将多个有序集合合并成一个新的有序集合,并将结果永久存储在 Redis 中。
我们可以把它拆解成三个部分来理解:
-
redisTemplate.opsForZSet()- 含义:这是 Spring Data Redis 提供的模板方法。
opsForZSet()返回一个专门用于操作 Redis 有序集合 的操作类 (ZSetOperations)。所有对 ZSET 的操作,如添加元素、获取排名、增加分数等,都需要通过这个对象来调用。
- 含义:这是 Spring Data Redis 提供的模板方法。
-
.unionAndStore(...)- 含义:这是
ZSetOperations接口中的一个方法,它对应 Redis 的原生命令ZUNIONSTORE。 Union(并集):在数学中,并集是指将多个集合中的所有元素合并在一起,去除重复的元素。对于有序集合而言,合并的规则是:- 如果一个元素(在我们的例子中是
newsId)只存在于一个源集合中,那么它在结果集中的分数就是它原来的分数。 - 如果一个元素存在于多个源集合中,那么它在结果集中的分数是这些源集合中该元素分数的之和。这一点至关重要,它意味着我们通过这个操作,天然地实现了“七日总点击量”的计算。
- 如果一个元素(在我们的例子中是
AndStore(并存储):这个后缀表明,计算出的并集结果不会直接返回给调用者,而是会被存储到 Redis 中的一个指定 Key 里。这是一个异步的、在 Redis 服务器端完成的计算和存储过程,非常高效。
- 含义:这是
-
参数解析
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"`。
二、结合您的业务场景
让我们把这行代码放回您的“七日热点资讯排行榜”业务中,看看它到底做了什么:
- 输入:
sourceKeys包含了过去七天(比如 8月19日 到 8月25日)的每日热点排行榜的 Key。 - 计算过程: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。- …以此类推,计算出所有在过去七天内出现过的资讯的总点击量。
- 读取
- 输出与存储:Redis 将计算出的所有
newsId和它们的总点击量,作为一个新的有序集合,存入HotNewsConstants.WEEKLY_HOT_NEWS_KEY(例如hotnews:weekly:20250825)这个 Key 中。这个新的 ZSET 已经按照总点击量从高到低自动排好了序。
最终效果:当您需要查询七日排行榜时,只需要对 hotnews:weekly:20250825 这个 Key 执行 ZREVRANGE 命令,就能立刻得到按总点击量排序的资讯列表。整个复杂的聚合计算过程被 unionAndStore 优雅地完成了。
三、面试讲解思路
在面试中,当被问到这行代码时,你可以按照以下思路来回答,这会显得你对技术理解深刻,并且能将技术与业务紧密结合。
讲解思路:
-
一句话概括:“这行代码的核心作用是,利用 Redis 的
ZUNIONSTORE命令,将过去七天的每日热点排行榜数据,高效地聚合计算成一个总的七日排行榜,并将结果持久化存储在 Redis 中,以供后续快速查询。” -
拆解技术点:
- 数据结构:“我们使用 Redis 的 Sorted Set (ZSET) 来存储每日的排行榜。ZSET 的特点是每个成员(这里是资讯ID)都有一个分数(这里是点击量),并且会根据分数自动排序,这天然适合排行榜场景。”
- 操作命令:“
unionAndStore方法对应的是 Redis 的ZUNIONSTORE命令。它分为两部分理解:‘Union’ 是求并集,它会将多个 ZSET 中的成员合并,如果成员重复,则其分数会相加;‘AndStore’ 是指计算结果不会返回,而是直接存入一个新的 ZSET Key 中。” - 参数说明:“第一个参数
sourceKeys是一个集合,里面包含了我们想要合并的七个每日排行榜的 Key。第二个参数WEEKLY_HOT_NEWS_KEY是我们指定的目标 Key,用来存放最终计算出的七日排行榜结果。”
-
阐述业务价值:
- 实现需求:“这个操作完美地解决了‘计算七日总点击量’的业务需求。通过分数相加的特性,我们轻松地将分散在七天数据中的同一资讯的点击量汇总了起来。”
- 性能优势:“这个计算过程完全在 Redis 服务器端内存中完成,速度极快。相比于把七天的数据都拉到应用服务器内存中用 Java 代码来计算,这种方式极大地减少了网络传输和 CPU 开销,性能非常高。”
- 解耦与预计算:“它将‘计算’和‘查询’两个动作解耦了。我们通过一个定时任务(比如每天凌晨)来执行一次
unionAndStore,提前把结果算好。这样,当用户在前端请求七日排行榜时,我们只需要对预计算好的 Key 进行一次简单的ZREVRANGE查询即可,响应速度是毫秒级的,用户体验非常好。”
-
总结升华:“所以,这行代码虽然简短,但它充分利用了 Redis 的数据结构和高级命令,以一种非常高效、优雅的方式,实现了复杂的业务逻辑,是‘让专业的人做专业的事’这一思想在技术选型上的体现。”
四、面试官可能问的问题及回答
问题 1:如果一条资讯在过去七天中,某一天没有点击量(即不在那天的 ZSET 中),unionAndStore 会怎么处理?
回答:
“这取决于 unionAndStore 的另一个我们这里没有显式设置的参数 weights(权重)。在我们当前的代码中,由于没有指定权重,Redis 默认所有源集合的权重都是 1。因此,如果一个 newsId 在某一天的 ZSET 中不存在,Redis 会认为它在那一天的分数是 0。在求和时,0 加上其他天的分数,结果就是其他天的分数之和。所以,这条资讯依然会出现在最终的七日排行榜中,它的分数就是它有点击量的那些天的分数总和。这完全符合我们的业务预期。”
问题 2:unionAndStore 是同步操作还是异步操作?如果源集合数据量非常大,会不会阻塞 Redis?
回答:
“ZUNIONSTORE 是一个同步的、原子性的 Redis 命令。当 Redis 执行这个命令时,它会阻塞其他客户端的请求,直到这个命令执行完毕。
如果源集合的数据量非常大(比如每个 ZSET 有几百万条数据),这个合并操作确实会消耗较多的 CPU 和内存资源,并可能导致 Redis 短暂阻塞,影响其他服务的响应。
解决方案:
- 控制数据规模:在我们的业务场景中,每日排行榜通常只保留 Top N(比如 Top 1000)的资讯,而不是所有资讯。这样,
sourceKeys中的每个 ZSET 数据量都很小(最多 1000 条),合并七个这样的集合,对 Redis 来说是非常轻量的操作,几乎不会造成性能问题。这是我们设计时一个很重要的考量点。 - 在业务低峰期执行:我们通过
@Scheduled定时任务,将这个操作安排在凌晨等访问量最低的时段执行,即使有轻微的性能影响,也不会影响到核心业务。 - 主从架构/集群:如果数据量真的到了必须处理的地步,可以考虑在 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实现排行榜,每次有新的点赞,我们需要:
- 找到这个
newsId在List中的位置(O(N)复杂度)。 - 删除它(O(N)复杂度)。
- 根据新的分数,找到它应该插入的新位置(O(N)复杂度)。
- 插入它(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,如点赞数)。 - 核心优势:
- 自动排序:ZSet会根据
score对所有member进行自动排序。我们无需关心排序过程,Redis在底层已经为我们做好了。 - 高效的更新:当某个资讯获得新的点赞时,我们使用
ZINCRBY key increment member命令。这个命令会先找到该member,然后给它的score加上increment。由于底层使用了跳跃表,这个操作的时间复杂度是 O(log N),非常高效。 - 高效的查询:
- 获取排行榜Top N:使用
ZREVRANGE key 0 N-1,按分数从高到低获取成员。这个操作也是 O(log N + M)(M是返回的元素数量),性能极高。 - 获取某个成员的排名:使用
ZREVRANK key member,可以快速知道某个资讯排在第几名,复杂度同样是 O(log N)。
- 获取排行榜Top N:使用
- 自动排序:ZSet会根据
核心点总结
所以,我们选择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. 核心操作:查找、插入与随机层数
-
查找过程
- 从头节点的最高层开始。
- 比较当前节点的下一个节点的
score与目标score。 - 如果下一个节点的
score小于目标值,就沿着当前层的前进指针向右移动。 - 如果下一个节点的
score大于等于目标值,或者到达了当前层的末尾,就下降到下一层,继续步骤2。 - 重复此过程,直到下降到最底层(第0层),此时,当前位置的下一个节点就是要找的节点(如果存在的话)。
-
插入过程与随机层数
- 查找插入位置:首先,像查找过程一样,从高层到底层找到新节点应该插入的位置(在第0层)。
- 随机生成节点层数:这是跳跃表的一个精髓。新节点的层数不是固定的,而是通过一个随机算法决定的。Redis使用的是
p=0.25的幂次定律算法。简单说,一个节点有1/4的概率有2层,1/16的概率有3层,以此类推。这种随机化策略能保证跳跃表在宏观上保持平衡,避免极端情况(如所有节点都堆在最高层),从而保证了O(log N)的性能。 - 调整指针:确定新节点的层数
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的元素:
- 从最高层开始:我们从最顶层的头节点开始,向右查找。第一个节点是
6,17 > 6,继续向右。 - 遇到“天花板”:下一个节点是
25,17 < 25,无法继续向右。此时,我们就像走到了楼梯的尽头,必须向下走一层。 - 在下一层继续:我们下到Level 1,从
6的下一个节点开始,也就是9。17 > 9,继续向右。 - 再次遇到“天花板”:下一个节点是
21,17 < 21,无法继续向右。再次向下走一层。 - 到达最底层:我们下到Level 0,从
9的下一个节点开始,也就是12。17 > 12,继续向右。 - 找到目标:下一个节点就是
17,查找成功。
你看,在整个过程中,我们跳过了1, 7, 19等大量节点,大大减少了比较次数,这就是“跳跃”的由来。其查找效率与层数有关,而平均层数是log N,所以平均时间复杂度是O(log N)。
3. 插入与删除:随机性与平衡性
跳跃表最巧妙的地方在于它的插入和删除过程,以及如何维持这种多层的结构。
-
插入过程:
- 首先,像查找一样,从高层到低层,找到新元素应该插入的位置。
- 然后,将新元素插入到最底层的链表中。
- 关键一步:通过一个随机算法(比如抛硬币)来决定这个节点要“晋升”到多少层。例如,可以设定一个概率
p(通常是0.5或0.25),不断进行“抛硬币”,只要结果是正面,就让节点上升一层,直到抛出反面或达到最大层数限制。
-
为什么是随机的?
- 这是为了避免复杂的平衡操作。像AVL树或红黑树,在插入或删除后需要进行旋转来保持平衡,逻辑非常复杂。
- 跳跃表通过这种概率性的方式,从宏观上保证了整个结构的平衡性。虽然可能某个时刻某个节点的层数很高,但从整体来看,层数的分布是均匀的,从而保证了整体的O(log N)性能。这是一种概率平衡,而非严格平衡。
-
删除过程:
- 查找到要删除的节点。
- 由于该节点可能存在于所有层中,所以需要从它所在的最高层开始,逐层向下,在每一层中都将其从链表中移除。
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的底层实现,主要有以下几个原因:
- 性能均衡:在插入、删除、查找和范围操作上都表现优异,平均时间复杂度为O(log N),没有明显的性能短板。
- 实现简单:相比于红黑树等自平衡二叉搜索树,跳跃表的实现逻辑更简单直观,代码更容易编写和维护,也更不容易出错。
- 范围查询友好:跳跃表本身就是基于链表的,进行范围查询(如ZRANGE)时,只需定位到起始点,然后顺序遍历即可,非常高效。而二叉搜索树的范围查询通常需要中序遍历,实现上稍显复杂。
- 内存占用可控:通过调整晋升概率
p,可以灵活地在时间效率和空间效率之间做权衡。当p=0.5时,空间复杂度平均为O(N),非常节省内存。
因此,跳跃表是Redis在ZSet这个场景下,一个经过深思熟虑、权衡了性能、复杂度和内存使用后的最优解。它完美地支撑了ZSet作为排行榜、范围索引等高级功能的需求。
改进后的流程
- 写入/更新缓存:当有新的资讯发布或资讯内容更新时,除了写入数据库,我们同时将资讯详情写入Redis Hash。
- 查询排行榜:
- 先从ZSet中获取到排序后的
newsId列表。 - 遍历这个
newsId列表。 - 对于每一个
newsId,先去Redis Hash中查询完整的资讯详情。 - 缓存命中:如果Redis中存在,直接使用。
- 缓存未命中:如果Redis中不存在(即缓存穿透),则去数据库查询。查询到后,将结果写回Redis Hash(这个过程叫缓存回填),然后再返回给前端。这样下次同样的请求就不会再打到数据库了。
- 先从ZSet中获取到排序后的
具体代码实现
我们将创建一个新的服务类 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;
}
}
总结与优势
通过以上改进,我们实现了一个高性能的排行榜系统:
-
职责分离:
- ZSet 专注于
newsId的排序,是轻量级的索引”。 - Hash 专注于
newsId对应的详情存储,是高效的缓存”。 - 数据库 作为最终的数据源,只会在缓存未命中时被访问。
- ZSet 专注于
-
性能提升:
- 数据库压力骤降:绝大多数情况下,用户请求的资讯详情都直接从内存中的Redis Hash返回,响应速度在毫秒级,数据库只承担了首次访问或缓存失效后的少量查询。
- 系统扩展性增强:由于数据库不再是瓶颈,整个系统能够承载更高的并发请求。
-
代码结构清晰:
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的Key:就是
详细解释:这个对象/Map是如何存的?
这个过程分为两步:
- Java对象如何被转换:
NewsPOJO ->Map<String, String>。 - 转换后的Map如何存入Redis:
redisTemplate.opsForHash().put()的执行过程。
第1步:Java对象到Redis Hash的序列化
当您调用 redisTemplate.opsForHash().put(key, newsId, newsPOJO) 时,RedisTemplate 并不会直接把 News 这个Java对象塞进Redis。因为Redis只理解字符串、字节等基本类型。所以,它必须进行序列化。
这个过程的核心是 HashMapper。RedisTemplate 内部会使用一个 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 会做以下事情:
- 反射分析:通过反射,获取
News类的所有属性(字段)。 - 属性名作为Field:将每个属性的名称(如
title,summary,author)作为Redis Hash中的Field。 - 属性值作为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.title, 1002.author |
| Hash Value | 对象属性序列化后的字符串值。 | "今日热点:科技新突破" |
| 存储方式 | 一个大Hash里,通过不同的Field存储了成千上万个新闻对象的所有属性。 | HSET news:detail:hash 1001.title "..." |
| 过期策略 | 对整个Hash Key设置TTL,实现批量过期和缓存更新。 | EXPIRE news:detail:hash 86400 |
这种设计模式非常高效,因为它:
- 聚合管理:所有同类数据(新闻详情)都在一个Key下,便于统一管理(如设置统一的过期时间)。
- 访问高效:获取单个新闻详情 (
HGETALL news:detail:hash 1001.*) 或单个属性 (HGET news:detail:hash 1001.title) 都非常快。 - 内存友好:相比将每个新闻对象序列化为一个大的JSON字符串存为独立的String Key,Hash结构在存储多个小对象时通常更节省内存。
-1
1万+

被折叠的 条评论
为什么被折叠?



