本文速览:本文是 Redis 系列第一篇,系统讲解 Redis 是什么、为什么快、五大核心数据结构的使用场景,并结合 Spring Boot + Lettuce 演示真实代码,最后给出 SaaS 场景下的 Key 设计规范。适合 Redis 入门及有一定 Java 基础的读者。
关键词:Redis教程、Redis数据结构、SpringBoot集成Redis、Redis入门、Redis String Hash List Set ZSet
一、为什么是 Redis?从一个真实的痛点说起
想象这样一个场景:你的 SaaS 系统上线后,用户量从 1000 增长到 10 万,数据库 CPU 飙升到 90%,接口 P99 延迟从 50ms 变成了 2000ms。老板盯着监控大屏,你盯着慢查询日志,一脸茫然。
这个时候,大多数工程师想到的第一个解法就是------引入缓存 。而缓存领域最炙手可热、生产环境使用率最高的方案,就是 Redis。
Redis 全称 Remote Dictionary Server,是一个基于内存的高性能键值存储系统。它之所以快,核心原因有以下几点:
Redis 为什么这么快?
- 纯内存操作:所有数据存储在内存中,读写不经过磁盘 I/O
- 单线程模型(命令执行层):避免了多线程的锁竞争开销(6.0 起引入多线程 I/O,但命令执行仍是单线程)
- 高效的 I/O 多路复用:基于 epoll/kqueue 实现,单进程可处理大量并发连接
- 精心设计的数据结构:如跳表、压缩列表、哈希表等,在内存布局上做了大量优化
根据官方基准测试,单节点 Redis 每秒可处理 10 万 ~ 100 万次请求,这个数字足以应对绝大多数业务场景。
二、Redis 安装与 Spring Boot 集成
2.1 本地快速安装
在开发阶段,推荐用 Docker 启动 Redis,避免污染本地环境:
bash
# 拉取并启动 Redis 7.x(推荐使用最新稳定版)
docker run -d \
--name redis-dev \
-p 6379:6379 \
-v $(pwd)/redis-data:/data \
redis:7.2 redis-server --appendonly yes
# 验证是否启动成功
docker exec -it redis-dev redis-cli ping
# 返回 PONG 则成功
这里有一个容易被忽略的坑 :不加 --appendonly yes 的话,Redis 默认只做 RDB 快照持久化,重启后可能丢失最近的写入数据。在开发环境无所谓,但如果你直接把这个命令抄到测试环境,就可能踩坑。关于持久化的完整讲解,我们放在第二篇详细展开。
2.2 Spring Boot 项目集成
在 pom.xml 中引入依赖。Spring Boot 默认使用 Lettuce 作为 Redis 客户端(基于 Netty,支持异步和响应式),如果你的项目有强烈的连接池偏好,也可以切换为 Jedis。
xml
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 如果需要对象序列化,引入 Jackson -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
接下来配置连接信息。很多教程到这里就只写 spring.redis.host=localhost,但在生产 SaaS 项目里,你还需要关注连接池参数:
yaml
# application.yml
spring:
data:
redis:
host: localhost
port: 6379
password: "" # 生产环境必须设置密码
database: 0
timeout: 3000ms # 连接超时,建议不超过 3s,防止阻塞业务线程
lettuce:
pool:
max-active: 16 # 最大连接数,根据业务并发量调整
max-idle: 8 # 最大空闲连接数
min-idle: 2 # 最小空闲连接数,保持少量热连接
max-wait: 1000ms # 获取连接的最大等待时间,超时抛异常而非无限等待
为什么要配置连接池? Lettuce 默认共享一个连接(单连接模式),在高并发场景下会成为瓶颈。配置连接池后,多个业务线程可并发使用不同连接,避免排队。
2.3 RedisTemplate 序列化配置
这是 Spring Boot 集成 Redis 最容易踩的坑之一 。默认的 RedisTemplate<Object, Object> 使用 JDK 序列化,存到 Redis 里的 Key 和 Value 是乱码,用 redis-cli 完全看不懂,给排查问题带来极大困扰。
强烈建议覆盖默认配置,改为 String Key + JSON Value:
java
// RedisConfig.java
@Configuration
public class RedisConfig {
/**
* 自定义 RedisTemplate,Key 用 String 序列化,Value 用 JSON 序列化。
* 这样在 redis-cli 里可以直接读懂数据,排查问题效率提升 10 倍。
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// Key 使用 String 序列化
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
// Value 使用 Jackson JSON 序列化
Jackson2JsonRedisSerializer<Object> jsonSerializer =
new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper mapper = new ObjectMapper();
// 序列化时保留类型信息,反序列化时才能还原为正确的对象类型
mapper.activateDefaultTyping(
mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL
);
jsonSerializer.setObjectMapper(mapper);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
注意 activateDefaultTyping 这行。如果不加,存入 User 对象,取出来会变成 LinkedHashMap,你还需要手动转型,非常麻烦。加上之后,JSON 里会带有类型标识,反序列化时自动还原。当然,这也意味着 JSON 里会多一个 @class 字段,存储空间略有增加,这是个权衡。
三、五大核心数据结构精讲
Redis 的核心竞争力之一,是它提供了丰富的语义化数据结构,而不仅仅是 key-value 的字符串存储。不同的数据结构对应不同的业务场景,选错了不仅浪费内存,性能也会大打折扣。
bash
Redis 数据结构全景图
┌─────────────────────────────────────────────────────┐
│ Redis 数据结构 │
├──────────┬──────────┬──────────┬──────────┬─────────┤
│ String │ Hash │ List │ Set │ ZSet │
│ 字符串 │ 哈希表 │ 双端队列│ 集合 │ 有序集合│
├──────────┼──────────┼──────────┼──────────┼─────────┤
│ 缓存对象 │ 用户信息 │ 消息队列 │ 标签系统 │排行榜 │
│ 计数器 │ 商品详情 │ 最近访问 │ 共同好友 │延迟队列│
│ 分布式锁 │ 购物车 │ 操作日志 │ 抽奖池 │ │
└──────────┴──────────┴──────────┴──────────┴─────────┘
3.1 String --- 万能的基础类型
String 是 Redis 最基础也最常用的数据类型,最大可存储 512MB 的内容。虽然叫"字符串",但它能存整数(用于计数)、浮点数、甚至二进制数据(如序列化的 Java 对象、图片)。
典型场景一:缓存用户信息
java
// UserCacheService.java
@Service
@RequiredArgsConstructor
public class UserCacheService {
private final RedisTemplate<String, Object> redisTemplate;
// 统一管理 Key 前缀,避免不同模块 Key 冲突(SaaS 多租户更重要)
private static final String USER_KEY_PREFIX = "saas:user:";
private static final Duration USER_CACHE_TTL = Duration.ofMinutes(30);
/**
* 获取用户信息:先查缓存,缓存未命中则查数据库并回填。
* 这是"Cache-Aside"模式,也叫旁路缓存,是最常用的缓存策略。
*/
public User getUserById(Long userId) {
String key = USER_KEY_PREFIX + userId;
// 1. 先查缓存
User cached = (User) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
// 2. 缓存未命中,查数据库
// 注意:这里有缓存击穿风险!高并发下多个请求同时到达数据库。
// 解决方案在第四篇《缓存击穿》中详细讲解,这里先实现基础版本。
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
// 3. 回填缓存,设置过期时间(必须设置,否则内存会无限增长)
redisTemplate.opsForValue().set(key, user, USER_CACHE_TTL);
return user;
}
/**
* 更新用户信息后,删除缓存(Cache-Aside 的删除策略)。
* 为什么是删除而不是更新缓存?因为更新缓存在并发场景下可能导致数据不一致。
*/
public void updateUser(User user) {
userRepository.save(user);
String key = USER_KEY_PREFIX + user.getId();
redisTemplate.delete(key);
}
}
典型场景二:原子计数器
Redis 的 INCR 命令是原子操作,天然适合做计数器,无需担心并发安全问题:
java
// 统计 API 调用次数(适用于 SaaS 按量计费场景)
public void incrementApiCallCount(String tenantId, String apiName) {
String key = String.format("saas:api:count:%s:%s:%s",
tenantId, apiName, LocalDate.now());
// INCR 是原子操作,即使高并发也不会出现计数丢失
Long count = redisTemplate.opsForValue().increment(key);
// 第一次写入时设置过期时间(当天结束后自动清理)
if (count == 1L) {
redisTemplate.expire(key, Duration.ofDays(2));
}
}
踩坑提醒 :
increment操作和expire操作是两条独立命令,不是原子的。极端情况下,increment成功但expire失败,这个 key 就永远不会过期,导致内存泄漏。更严谨的写法是用 Lua 脚本将两个操作打包,或使用SET key 1 EX 86400 NX初始化。我们在第五篇分布式锁中会深入讲 Lua 脚本。
3.2 Hash --- 结构化对象的最佳载体
如果说 String 适合缓存整个序列化对象,那 Hash 适合缓存有频繁局部更新需求的结构化对象。Hash 的每个 field 可以单独读写,避免了每次更新都序列化/反序列化整个对象的开销。
bash
Hash 内存结构示意
Key: "saas:user:profile:1001"
┌─────────────┬──────────────────────┐
│ Field │ Value │
├─────────────┼──────────────────────┤
│ name │ 张三 │
│ email │ zhang@example.com │
│ vipLevel │ 3 │
│ loginCount │ 128 │
│ lastLoginAt │ 2024-01-15 10:30:00 │
└─────────────┴──────────────────────┘
java
// 用 Hash 存储用户 Profile,支持局部字段更新
@Service
public class UserProfileCacheService {
private final RedisTemplate<String, Object> redisTemplate;
private String profileKey(Long userId) {
return "saas:user:profile:" + userId;
}
// 存储完整 profile
public void saveProfile(Long userId, UserProfile profile) {
Map<String, Object> fields = new HashMap<>();
fields.put("name", profile.getName());
fields.put("email", profile.getEmail());
fields.put("vipLevel", profile.getVipLevel());
fields.put("loginCount", profile.getLoginCount());
redisTemplate.opsForHash().putAll(profileKey(userId), fields);
redisTemplate.expire(profileKey(userId), Duration.ofHours(2));
}
// 只更新 VIP 等级,而无需重新序列化整个对象
// 这正是 Hash 相比 String 的核心优势:局部更新,节省带宽和 CPU
public void updateVipLevel(Long userId, int newLevel) {
redisTemplate.opsForHash().put(profileKey(userId), "vipLevel", newLevel);
}
// 读取单个字段
public Integer getVipLevel(Long userId) {
return (Integer) redisTemplate.opsForHash().get(profileKey(userId), "vipLevel");
}
// 读取完整 profile
public UserProfile getProfile(Long userId) {
Map<Object, Object> entries = redisTemplate.opsForHash()
.entries(profileKey(userId));
if (entries.isEmpty()) return null;
// 从 Map 还原对象
return mapToProfile(entries);
}
}
何时用 String,何时用 Hash? 经验法则:如果你的业务经常需要"只更新对象的某个字段",用 Hash;如果总是读写整个对象,用 String(序列化后存储反而更省内存,因为 Hash 每个 field 都有元数据开销)。
3.3 List --- 双端队列,消息与日志的家
Redis List 是双端链表(底层在小数据量时会优化为压缩列表),支持从头部或尾部插入/弹出元素,天然适合实现队列、栈、最近记录列表等场景。
bash
List 操作示意
LPUSH → [最新] ←─── [元素3] [元素2] [元素1] ───→ RPOP [最旧]
RPUSH → [最旧] [元素1] [元素2] [元素3] ←─── [最新] ← LPOP
java
// 记录用户最近浏览的商品(保留最近 20 条)
@Service
public class BrowseHistoryService {
private final RedisTemplate<String, Object> redisTemplate;
private static final int MAX_HISTORY = 20;
private String historyKey(Long userId) {
return "saas:browse:history:" + userId;
}
/**
* 记录用户浏览了某商品。
* 注意这里用 LPUSH + LTRIM 组合,而不是先 LLEN 判断再删除。
* LPUSH + LTRIM 是更 Redis-native 的写法,且 LTRIM 是 O(N),
* 但 N 很小(20条),实际性能完全可以接受。
*/
public void recordBrowse(Long userId, Long productId) {
String key = historyKey(userId);
ListOperations<String, Object> listOps = redisTemplate.opsForList();
// 从头部插入(最新的在最前面)
listOps.leftPush(key, productId.toString());
// 裁剪,只保留最近 MAX_HISTORY 条
// LTRIM key 0 19 表示只保留索引 0 到 19 的元素,其余删除
listOps.trim(key, 0, MAX_HISTORY - 1);
// 刷新过期时间(用户活跃时浏览记录持续有效)
redisTemplate.expire(key, Duration.ofDays(7));
}
// 获取全部浏览历史
public List<Long> getBrowseHistory(Long userId) {
List<Object> raw = redisTemplate.opsForList()
.range(historyKey(userId), 0, -1); // 0 到 -1 表示全部
if (raw == null) return Collections.emptyList();
return raw.stream()
.map(o -> Long.parseLong(o.toString()))
.collect(Collectors.toList());
}
}
List 作消息队列的局限 :用
LPUSH/BRPOP可以实现简单的消息队列,但它不支持消费者组、不能回溯历史消息、也没有 ACK 机制。如果你的业务对消息可靠性有要求,第六篇会介绍 Redis Stream,它解决了这些问题。
3.4 Set --- 去重集合与关系运算
Set 存储不重复的字符串元素,最大价值在于支持集合运算:交集(SINTER)、并集(SUNION)、差集(SDIFF),这让很多业务逻辑可以直接下推到 Redis 层完成,不必在 Java 代码里处理。
java
// 基于 Set 实现标签系统与共同关注功能
@Service
public class TagService {
private final RedisTemplate<String, Object> redisTemplate;
private String userTagKey(Long userId) {
return "saas:user:tags:" + userId;
}
// 给用户打标签
public void addTag(Long userId, String... tags) {
redisTemplate.opsForSet().add(userTagKey(userId), (Object[]) tags);
}
/**
* 计算两个用户的共同标签(交集)。
* 这个操作如果在数据库层面做,可能需要复杂的 JOIN + GROUP BY。
* 交给 Redis Set 的 SINTER 命令,一行搞定,性能极佳。
*/
public Set<Object> getCommonTags(Long userId1, Long userId2) {
return redisTemplate.opsForSet()
.intersect(userTagKey(userId1), userTagKey(userId2));
}
// 推荐给 userId1 但 userId2 没有的标签(差集)
public Set<Object> getRecommendedTags(Long userId1, Long userId2) {
return redisTemplate.opsForSet()
.difference(userTagKey(userId2), userTagKey(userId1));
}
}
Set 还有一个常见用法是去重统计 ,比如统计每天的 UV(独立访客数)。不过当数据量很大时(百万级),Set 的内存开销会比较高,此时应该用第六篇介绍的 HyperLogLog,用极少内存实现近似去重统计。
3.5 ZSet(Sorted Set)--- 带分值的有序集合
ZSet 是 Redis 最具特色的数据结构之一。每个成员都对应一个 score(分值),成员按 score 排序。底层由**跳表(Skip List)**实现,支持 O(log N) 的插入、删除和范围查询。
ZSet 的典型场景:排行榜、延迟队列、优先级队列。
bash
ZSet 内存结构示意(积分排行榜)
Key: "saas:leaderboard:game:2024-01"
┌────────────┬────────┐
│ Member │ Score │
├────────────┼────────┤
│ user:10086 │ 9850.0 │ ← 排名第一
│ user:10001 │ 8820.0 │ ← 排名第二
│ user:10099 │ 7730.0 │ ← 排名第三
│ ... │ ... │
└────────────┴────────┘
java
// 实时积分排行榜
@Service
public class LeaderboardService {
private final RedisTemplate<String, Object> redisTemplate;
private String leaderboardKey(String gameId, String period) {
return String.format("saas:leaderboard:%s:%s", gameId, period);
}
// 用户得分后更新排行榜
// ZINCRBY 是原子操作,高并发下不会丢分
public void addScore(String gameId, Long userId, double score) {
String key = leaderboardKey(gameId, getCurrentPeriod());
redisTemplate.opsForZSet().incrementScore(key, "user:" + userId, score);
}
/**
* 获取 Top N 排行榜。
* ZREVRANGEBYSCORE / ZREVRANGE 按 score 从高到低返回。
* withScores = true 同时返回分值,方便展示。
*/
public List<LeaderboardEntry> getTopN(String gameId, int n) {
String key = leaderboardKey(gameId, getCurrentPeriod());
// reverseRangeWithScores 返回 Set<ZSetOperations.TypedTuple>,按 score 降序
Set<ZSetOperations.TypedTuple<Object>> tuples =
redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, n - 1);
if (tuples == null) return Collections.emptyList();
List<LeaderboardEntry> result = new ArrayList<>();
int rank = 1;
for (ZSetOperations.TypedTuple<Object> tuple : tuples) {
result.add(new LeaderboardEntry(
tuple.getValue().toString(),
tuple.getScore(),
rank++
));
}
return result;
}
// 查询某用户的当前排名(ZREVRANK,从 0 开始,需要 +1)
public Long getUserRank(String gameId, Long userId) {
String key = leaderboardKey(gameId, getCurrentPeriod());
Long rank = redisTemplate.opsForZSet()
.reverseRank(key, "user:" + userId);
return rank == null ? -1L : rank + 1;
}
private String getCurrentPeriod() {
return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
}
}
ZSet 实现延迟队列 :把任务的"执行时间戳"作为 score,任务内容作为 member。用一个定时线程轮询
ZRANGEBYSCORE key 0 当前时间戳,取出所有到期任务执行。这是一个轻量级延迟队列的经典实现,无需引入 RabbitMQ 等重型 MQ,适合任务量不大的 SaaS 场景。
四、SaaS 场景下的 Key 设计规范
Key 的设计看似简单,实则是 Redis 使用中最容易被忽视、后期最难修改的部分。特别是 SaaS 多租户场景,Key 设计不合理会导致租户数据互相干扰、无法按租户统计内存用量、批量清理困难等一系列问题。
推荐的 Key 命名规范:
bash
{应用名}:{租户ID}:{业务模块}:{数据类型}:{唯一标识}
示例:
saas:tenant001:user:info:10086 # 租户001的用户10086信息
saas:tenant001:order:count:2024-01 # 租户001的1月订单计数
saas:tenant002:product:list:cat:3 # 租户002的分类3商品列表
关键原则:
- 必须包含租户标识:多租户系统中,不带租户 ID 的 Key 是定时炸弹
- 使用冒号分隔层级:redis-cli、Redis Insight 等工具会自动识别冒号并以树形结构展示,极大提升可维护性
- Key 长度适中:Key 本身也占内存,过长的 Key 在大规模场景下会有可观的内存开销,建议不超过 100 字符
- 必须设置 TTL:没有 TTL 的 Key 是内存泄漏的根源,永远为 Key 设置合理的过期时间
java
// 推荐:使用常量类统一管理 Key 模板,避免散落在业务代码各处
public final class RedisKeyConstants {
private RedisKeyConstants() {}
// Key 模板,使用 String.format 填充参数
public static final String USER_INFO = "saas:%s:user:info:%d";
public static final String USER_SESSION = "saas:%s:user:session:%s";
public static final String ORDER_COUNT = "saas:%s:order:count:%s";
public static final String LEADERBOARD = "saas:%s:leaderboard:%s:%s";
// TTL 常量
public static final Duration USER_INFO_TTL = Duration.ofMinutes(30);
public static final Duration SESSION_TTL = Duration.ofHours(2);
public static final Duration ORDER_COUNT_TTL = Duration.ofDays(32);
// 工具方法
public static String userInfo(String tenantId, long userId) {
return String.format(USER_INFO, tenantId, userId);
}
}
五、与 AI 大模型结合:Redis 在 LLM 应用中的入门用法
现在 AI 大模型应用越来越普遍,Redis 在这个领域也有天然的用武之地。最简单的场景是语义缓存(Semantic Cache):同一个问题(或语义相近的问题),不必每次都调用昂贵的 LLM API,可以先查 Redis 缓存。
java
// 简单的 LLM 响应缓存(基于精确 Key 匹配)
// 进阶的语义相似度匹配需要向量数据库,我们在第七篇详细讲解
@Service
public class LlmCacheService {
private final RedisTemplate<String, Object> redisTemplate;
private static final Duration LLM_CACHE_TTL = Duration.ofHours(24);
/**
* 缓存 LLM 响应。Key 是问题内容的 MD5,Value 是响应文本。
* 这种方式只能命中完全相同的问题,无法处理语义相近的变体。
* 真正的语义缓存需要将问题转为向量,存入 Redis Vector Store,
* 查询时通过相似度搜索而非精确匹配------第七篇见!
*/
public Optional<String> getFromCache(String question) {
String key = "saas:llm:cache:" + DigestUtils.md5DigestAsHex(
question.getBytes(StandardCharsets.UTF_8));
Object cached = redisTemplate.opsForValue().get(key);
return Optional.ofNullable(cached != null ? cached.toString() : null);
}
public void saveToCache(String question, String response) {
String key = "saas:llm:cache:" + DigestUtils.md5DigestAsHex(
question.getBytes(StandardCharsets.UTF_8));
redisTemplate.opsForValue().set(key, response, LLM_CACHE_TTL);
}
}
这只是 Redis 与 AI 结合的冰山一角。Redis 还支持存储向量嵌入(通过 RedisSearch 模块),可以作为 RAG(检索增强生成)架构中的向量数据库,实现基于语义相似度的知识检索。这部分内容我们在第七篇详细展开。
六、本篇总结与系列预告
本文覆盖了 Redis 的核心基础:
| 知识点 | 关键结论 |
|---|---|
| 为什么快 | 内存 + 单线程命令执行 + I/O 多路复用 |
| Spring Boot 集成 | 务必覆盖默认序列化配置,改为 String Key + JSON Value |
| String | 缓存对象、原子计数,配合 TTL 使用 |
| Hash | 结构化对象的局部更新场景 |
| List | 最近记录、轻量消息队列 |
| Set | 去重、集合运算(交差并) |
| ZSet | 排行榜、延迟队列 |
| Key 设计 | 分层命名 + 租户隔离 + 必须设 TTL |
下一篇预告:数据写到 Redis 里,进程重启后还在吗?磁盘上到底存了什么?RDB 快照和 AOF 日志各有什么优缺点?混合持久化是怎么工作的?------《Redis 持久化深度解析》,我们下篇见。
📌 觉得有帮助?点个赞支持一下,系列持续更新中!
💬 有问题欢迎评论区交流,踩过的坑越多,帮助越大。