1. 为什么 Java 后端离不开 Redis
1.1 从一次接口超时说起
前阵子帮一个刚转 Java 的同事排查线上问题,场景特别典型:一个订单查询接口,数据库每秒扛到几千次查询,高峰期直接打满连接池,接口从几十毫秒一路涨到几秒。我上去一看代码,里面没有任何缓存,所有请求全部怼到 MySQL。当时我做的第一件事,就是给热点订单数据加了一层 Redis 缓存。加完之后接口耗时直接掉到个位数毫秒,数据库压力瞬间降了一大截。
这个场景你应该不陌生。只要你做的系统要撑住并发,迟早会碰到“数据库扛不住”的瓶颈。这时候 Redis 几乎就是 Java 后端技术栈里的默认答案。这些年我见过不少项目,小到单机服务,大到一二十个节点的集群,都在用 Redis 做缓存、做分布式锁、做计数器、做排行榜。它上手快、生态成熟,Java 里用 Spring Boot 接入也就几行配置的事。但越是容易上手的东西,越容易被用糙。很多人把 Redis 当成了“一个能存数据的 Map”,用完 GET/SET 就觉得自己会了,结果一上生产就各种翻车。
这篇文章不打算讲那些人人都能搜到的命令手册,而是从一个 Java 开发者的实际使用视角,拆一拆 Redis 作为分布式缓存的核心技术点:该掌握到什么程度、怎么用才稳、缓存三大坑怎么踩平、分布式锁怎么做才算合理,以及面试官真正想考你的是什么。
1.2 本地缓存与分布式缓存的区别
先理清楚一个概念:为什么说 Redis 是“分布式”缓存,它和你在代码里写的 HashMap、ConcurrentHashMap 到底差在哪。
本地缓存指的是 JVM 进程内的缓存。你用 Caffeine、Guava Cache,或者干脆用一个 ConcurrentHashMap 存数据,查询的时候先查本地内存,没命中再查库。优点是快,因为不经过网络,直接读内存;缺点是每个 JVM 实例各存一份,数据不一致。一旦服务部署了多个节点,A 节点更新了缓存,B 节点还是旧数据。如果缓存的是用户会话、商品库存这种强一致数据,本地缓存就应付不了。
分布式缓存是独立部署的缓存服务,所有应用节点通过网络共享同一份缓存数据。Redis 就是最典型的代表。节点 A 写入 key,节点 B 立刻能读到;数据一致性由缓存服务统一保证,应用只负责调用客户端。它的代价是每次读写都多一次网络 IO,性能比本地缓存慢几个量级,但换来的是多实例共享、集中管理、持久化、过期策略这些分布式场景必须的能力。
那两者能互相替代吗?不能。实际项目里更常见的做法是“多级缓存”:一级用 Caffeine 做本地缓存挡掉 90% 的热点流量,二级用 Redis 做分布式缓存扛住集群共享的那部分数据,最后才打到底层数据库。这种方案我后面还会细讲,先说结论:本地缓存解决性能上限,Redis 解决数据一致性和共享问题,两者不是替代关系,是协作关系。
1.3 Redis 到底赢在哪里
Redis 的核心优势归纳起来就八个字:快、简单、丰富、可靠。
快,是因为它是纯内存操作,单线程 IO 多路复用模型下,QPS 能轻松破十万,极限可以到十几万。简单,是因为数据模型就是 key-value,命令语义清晰,几乎没有学习曲线。丰富,是指它提供了 String、Hash、List、Set、ZSet、Stream 等多种数据结构,同一个缓存中间件还能兼职做分布式锁、延迟队列、排行榜、布隆过滤器。可靠,是指它支持 RDB 和 AOF 两种持久化方式,支持主从复制、哨兵、集群,能构建高可用架构。
但 Redis 也不是万能的。它没有原生事务(准确说是弱事务),不支持复杂的 SQL 查询和联合索引,内存成本远高于磁盘存储。所以你不能什么数据都往 Redis 里塞,得想清楚哪些数据值得进缓存、缓存多久、淘汰策略是什么。把 Redis 用明白的人,本质上是在做数据分层:什么数据放内存、什么数据放磁盘、什么数据可以丢、什么数据必须持久化。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Redis 安装与环境准备
2.1 Windows 快速安装(含可视化客户端)
很多用 Windows 开发机的朋友,一上来就卡在环境安装上。Redis 官方其实没有正式支持 Windows,微软过去维护过一个老版本分支,后面也不再更新了。现在的主流做法有两种。
第一种:直接用 Redis 官方提供的 Windows 移植版,去 Redis 官网或者 GitHub releases 页面找带 .zip 或 .msi 后缀的 Windows 包。下载后解压到你想要的目录,比如 D:\redis,打开命令行进入该目录,执行:
bash复制redis-server.exe
看到 Ready to accept connections 的日志就说明启动成功了。默认端口 6379,默认无密码,本地开发够用。想换端口或设置密码时,编辑同目录下的 redis.windows-service.conf,改完启动命令后带配置文件路径:
bash复制redis-server.exe redis.windows-service.conf
第二种:用 Docker 在 Windows 里跑 Linux 容器。这种方式更接近生产环境,后面我会单独讲。Windows 下用 Docker Desktop 装一个 Redis 镜像,比下载移植版更干净,升级也方便。
开发环境下我建议再装一个可视化客户端。热词里很多人搜 Redis Desktop Manager,这个工具现在改名叫 Another Redis Desktop Manager,界面更现代,功能也更全,支持连接多个 Redis 实例、查看 key、执行命令、分析大 key。连接的时候填服务器地址、端口,如果有密码就在对应位置填上,几乎不需要额外配置。
注意:Windows 下 redis-server.exe 启动后会占用当前命令行窗口,关掉窗口 Redis 就停了。想挂后台可以注册成 Windows 服务,用
redis-server --service-install redis.windows-service.conf来注册,之后用net start redis启动。
2.2 Linux 与 Docker 方式部署
生产环境里,Redis 几乎都跑在 Linux 上。如果用宿主机直接部署,流程是下载源码、编译、安装。
bash复制wget https://download.redis.io/releases/redis-7.2.4.tar.gz
tar xzf redis-7.2.4.tar.gz
cd redis-7.2.4
make
make install
安装完成后,redis-server 命令就进系统 PATH 了,编辑一个自定义配置文件,比如 /etc/redis/redis.conf,然后启动:
bash复制redis-server /etc/redis/redis.conf
几处生产必改的配置项我要单独提一下:
daemonize yes:后台运行,否则 SSH 一断开 Redis 就停了。requirepass:设置访问密码,生产环境必须开。bind:默认只监听 127.0.0.1,外部访问要改成实际内网 IP,千万不要直接绑 0.0.0.0 并暴露到公网,否则会被恶意扫描攻击。protected-mode:没设密码时最好保持 yes,避免被局域网内其他机器随意连接。
如果用了 Docker,部署就更简洁。先把数据和配置目录挂载出来,方便升级和维护:
bash复制docker run -d --name redis \
-p 6379:6379 \
-v /data/redis/data:/data \
-v /data/redis/redis.conf:/etc/redis/redis.conf \
redis:7.2 redis-server /etc/redis/redis.conf
Docker 部署的另一个隐藏价值是方便搭主从和集群。比如主从复制,一条命令就能起一个从节点:
bash复制docker run -d --name redis-slave -p 6380:6379 \
-v /data/redis-slave/data:/data \
redis:7.2 redis-server --slaveof 192.168.1.10 6379
生产环境我见过很多团队用 Docker Compose 把 Redis 主从、Sentinel、集群一次性编排起来,比在裸机上一个个起进程省事太多。不过主从复制只解决数据备份和读扩展,不解决自动故障转移,想做到高可用,还得上哨兵或集群方案。这部分内容在面试里考得很多,后面统一讲。
2.3 开发环境的前置检查:JDK 与 Maven
这一步很多人容易忽略。Redis 本身是 C 语言写的,不依赖 Java,但你作为 Java 开发者要用它,本机肯定得有可用的 JDK 和 Maven 环境。我看到热词里大量的人在搜 java 环境变量配置和 java 安装,这里顺嘴提一下关键点。
JDK 装完之后,必须手动配置三个环境变量才能让 IDE 和 Maven 正确识别:
JAVA_HOME:指向 JDK 安装目录,比如C:\Program Files\Java\jdk-17。PATH:追加%JAVA_HOME%\bin。CLASSPATH:旧版本项目要求配置,新版 JDK 一般不用配了。
配完之后,命令行执行 java -version,能输出版本号就说明环境变量生效了。
然后 Maven 需要注意的其实就一个:仓库镜像。国内直接拉依赖会出现下载失败或龟速的情况,需要在 settings.xml 里配置阿里云镜像仓库。很多初学者不做这步,以为是自己网络问题,折腾半天。
回到 Redis 开发本身,Java 项目里引入 Redis 客户端的方式很简单。如果你用 Spring Boot,加一个 spring-boot-starter-data-redis 依赖,再在 application.yml 里填上连接信息,剩下的就交给 Spring 自动装配了。传统 SSM 项目可以直接引 Jedis 或 Lettuce,不过现在主流就是 Spring Data Redis,所以下面的代码示例我都基于这个来写。
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
yaml复制spring:
redis:
host: 127.0.0.1
port: 6379
password:
database: 0
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 0
这里的连接池参数要结合并发量来做:并发高的服务,max-active 可以调到 50 以上;内存受限或并发低,16 就够了。连接池开太大并不会让 Redis 变快,反而会占用资源、增加建连开销。
3. 五种核心数据类型与 Java 实操
3.1 String:缓存与计数器的基石
String 是你用得最多、也是 Redis 底子最基础的一种结构。别以为它只能存字符串,它底层是动态字符串(SDS),可以存储文本、二进制数据、JSON 序列化后的对象,还能做数值自增自减操作。既然是二进制安全的,存图片内容、序列化对象都没问题。
实际开发里 String 最常见的三个场景:
第一,对象缓存。把 Java 对象序列化成 JSON 字符串存进去,取出来再反序列化。比如用户信息、商品详情、配置项。代码写起来很简单:
java复制@Autowired
private StringRedisTemplate redisTemplate;
// 写入缓存,设置 30 分钟过期
User user = userService.getById(1001);
redisTemplate.opsForValue().set("user:1001", JSON.toJSONString(user), 30, TimeUnit.MINUTES);
// 读取缓存
String cached = redisTemplate.opsForValue().get("user:1001");
User cachedUser = JSON.parseObject(cached, User.class);
第二,计数器。视频播放量、商品浏览量、验证码发送次数,这些高频计数场景非常契合 Redis 的单线程自增特性。INCR 是原子操作,天然不怕并发覆盖:
java复制Long count = redisTemplate.opsForValue().increment("video:play:1001");
第三,分布式会话。传统单机系统用 Session 存登录态,部署多个节点后就失效了,因为用户请求打到 B 节点时,A 节点里的 Session 它拿不到。把会话信息存到 Redis,以 sessionId 为 key,所有节点共享一份,就解决了这个问题。这是分布式应用最基础的会话一致方案。
String 相关的坑主要是两个:一是对过期时间没规划,导致部分 key 永久占用内存;二是 value 过大,序列化成大 JSON 后一个 key 几百 KB,读写效率断崖式下跌。这种情况要看是否该换 Hash 结构,具体在下一节讲。
3.2 Hash:对象存储最优解
Hash 是一个 key 下挂多个 field 和 value 的结构,相当于一个浅层的对象模型,用来存 JavaBean 这种“一个对象多个属性”的数据特别合适。
比如缓存用户信息:
bash复制HSET user:1001 username "zhangshan" age 25 city "shanghai"
用 Java API 操作:
java复制Map<String, String> userMap = new HashMap<>();
userMap.put("username", "zhangsan");
userMap.put("age", "25");
userMap.put("city", "shanghai");
redisTemplate.opsForHash().putAll("user:1001", userMap);
Object age = redisTemplate.opsForHash().get("user:1001", "age");
比起把对象整个序列化成 JSON 塞进 String,Hash 的好处是粒度更细:你可以单独读写对象的某一个字段,不用一改就把整个对象都反序列化再写回去。这对“只更新用户最后登录时间”“只读用户手机号”这类场景特别友好。在内存开销上,如果字段少、字段名短,Hash 比 String 更省内存。
HashMap 的底层实现值得一提。Java 的 HashMap 本身也是“数组+链表/红黑树”,Redis 的 Hash 底层一样是字典结构,冲突多了会渐进式 rehash,不会像 Java 那样一次性把所有元素搬移,而是分摊到每次读写操作里,避免卡顿。所以大量写入 Hash 时性能依然稳定,这也是它能扛住高并发的原因之一。
3.3 List:消息队列与时间线
List 是双向链表结构,支持从头部和尾部推入、弹出元素。最经典的两个用途是“时间线”和“简易消息队列”。
做用户最新动态列表时,可以一边发布动态一边往 List 头部插:
bash复制LPUSH user:timeline:1001 "动态1"
LPUSH user:timeline:1001 "动态2"
拿的时候用 LRANGE 0 -1 取出全部。但是要控制长度,比如只保留最近 100 条,可以用 LTRIM 截断,避免 List 无限增长。
Java 里对应操作:
java复制redisTemplate.opsForList().leftPush("user:timeline:1001", "动态内容");
List<String> timeline = redisTemplate.opsForList().range("user:timeline:1001", 0, 99);
做简易消息队列的思路也很直接:生产者 LPUSH 进队列,消费者异步 BRPOP 阻塞弹出。BRPOP 是阻塞版本,队列没数据时会一直等待,减少空轮询带来的资源浪费。
bash复制# 生产者
LPUSH msg:queue "task-1"
# 消费者
BRPOP msg:queue 0
这种模式能应付轻量级的异步任务,但别指望它替代 Kafka。List 消息队列没有消费确认机制、没有消息回溯、没有分区和持久化保障,消息一旦被 RPOP 拿走就没了。真想做好消息队列,用 Redis 5.0 之后引入的 Stream 结构更合适,它提供了消费者组、消息确认、断点续读这些能力,算是给轻量 MQ 场景补上了短板。
3.4 Set 与 ZSet:去重、抽奖与排行榜
Set 是无序、不可重复的集合。它最天然的应用场景是去重:比如判断用户是否签到过,每个用户 ID 只会被添加一次。
bash复制SADD sign:20250601 user:1001
SISMEMBER sign:20250601 user:1001
上班的人午休时间做个抽奖小程序,用 Set 也特别简单:把参与用户全部 SADD 进去,然后用 SPOP 随机弹出一个或几个,自动去重、自动删除。
java复制Long size = redisTemplate.opsForSet().size("lottery:1001");
String winner = (String) redisTemplate.opsForSet().pop("lottery:1001");
ZSet 是升级版,每个元素带一个 score 分数,按 score 从小到大排序。排行榜功能几乎是为它量身定制的:写入分数、更新分数、取 TopN,三个操作对应三条命令。
bash复制ZADD rank:score 100 user:1001
ZADD rank:score 89 user:1002
ZADD rank:score 95 user:1003
# 获取前 10 名
ZREVRANGE rank:score 0 9 WITHSCORES
Java 里操作:
java复制redisTemplate.opsForZSet().add("rank:score", "user:1001", 100);
Set<String> top10 = redisTemplate.opsForZSet().reverseRange("rank:score", 0, 9);
ZSet 好玩的地方不止排行榜,还能做延时队列。score 存”到期时间戳”,轮询时用 ZRANGEBYSCORE key -inf now 取所有已经到期的任务,批量处理。我这个方案在低流量任务调度里跑过,稳定、简单、不依赖额外的消息队列组件。
3.5 底层结构与选型口诀
很多 Java 面试题会问:Redis 的 String、Hash、List、Set、ZSet 底层分别是什么结构。这里给你一个速记版:
- String:SDS(简单动态字符串),修改字符串不会频繁分配内存。
- Hash:压缩列表或哈希表,元素少且小时用压缩列表节省内存,超过阈值后转为哈希表。
- List:压缩列表或双向链表,同样有个阈值切换,高版本里叫 quicklist 综合两者。
- Set:整数集合或哈希表,元素都是整数且量小时用整数集合,否则用哈希表。
- ZSet:压缩列表或跳表,跳表支持高效的按 score 范围查询和排序。
选型上,我给自己定了个口诀:简单值用 String,对象属性多用 Hash,顺序数据用 List,去重交集用 Set,排序榜单用 ZSet。 绝大多数业务场景都能被这五种结构覆盖,没有覆盖到的基本是设计上有问题,而不是 Redis 缺功能。
4. 缓存穿透、击穿、雪崩的治理方案
4.1 三种故障到底是啥
缓存治理是 Redis 的重头戏,面试和技术排查里绕不开的三大难题:穿透、击穿、雪崩。名字容易记混,我用大白话拆开讲。
- 缓存穿透:请求了一个缓存和数据库里都不存在的数据。比如用户查一个不存在的订单 ID,缓存没命中,数据库也查不到,于是缓存永远没机会写入。恶意攻击者只要不断伪造不存在的 ID,就能让所有请求绕过缓存直接打到数据库。
- 缓存击穿:某个热点 key 过期的一瞬间,大量并发请求同时发现缓存没命中,一起涌向数据库。注意,是单个 key 过期引起的“单点压力”。
- 缓存雪崩:大量 key 在同一时期集中过期,或者 Redis 节点直接挂掉,导致请求全部打到数据库,数据库扛不住,服务雪崩。
一张表总结三者的区别:
| 问题 | 本质 | 影响范围 | 核心原因 |
|---|---|---|---|
| 穿透 | 数据本身不存在 | 所有无效请求直连 DB | 缓存和 DB 都没有 |
| 击穿 | 热点 key 过期 | 单个 key 高并发打到 DB | 缓存过期瞬间 |
| 雪崩 | 大量 key 过期/宕机 | 大面积请求打到 DB | 缓存集中失效 |
4.2 缓存穿透:布隆过滤器与空值缓存
穿透的治本方案有两个层次。第一层是从源头拦截,把合法的查询范围提前过滤掉恶意请求,比如参数校验、ID 格式校验、权限校验。第二层才是缓存层的手段。
最专业的做法是布隆过滤器。布隆过滤器是一种概率型数据结构,能非常快速地判断“这个 key 一定不存在”或“这个 key 可能存在”。它基于多个哈希函数映射到一个位数组上,查询只要算几个哈希、查几个位,一个都不存在就说明 key 一定不在。缺点是误判存在,不能精准判断“在”,所以它是把自己判断为“一定不存在”的请求直接拦住。
Java 里集成布隆过滤器常用 Redisson 的 RBloomFilter:
java复制RBloomFilter<String> bloomFilter = redisson.getBloomFilter("order:bloom");
bloomFilter.tryInit(1000000L, 0.01); // 预期数据量 100 万,误判率 1%
// 启动时把合法订单 ID 全量初始化
bloomFilter.add("1001");
// 查询前先判断
if (!bloomFilter.contains(orderId)) {
throw new BusinessException("订单不存在");
}
不用 Redisson 也可以用你自己实现的布隆过滤器,或者用 Sentinel、网关层做限流,思路都一样:让无效请求进不到数据库那一层。
第二层是空值缓存:查询不到数据时,把”空结果“也缓存起来,并设置一个较短的过期时间,比如 3~5 分钟。这样一来,同一个无效 key 的重复请求会直接命中空缓存,不会反复打到数据库。代价是 redis 里会多出一部分空 key,但配合过期时间,内存压力可控。
我个人的建议是:布隆过滤器适合数据量明确、能预热的场景;空值缓存适合任意场景,实现成本最低。两者可以同时上,先用布隆过滤器拒绝明显不存在的 key,再对漏网的 key 缓存空值。
4.3 缓存击穿:互斥锁与逻辑过期
击穿的核心矛盾是“一个 key 过期瞬间的高并发”。解决思路有两个方向:要么在回源时做互斥控制,让只有一个请求去查数据库、重建缓存,其他人等待;要么在缓存里存一个逻辑过期时间,拿不到”新数据“时先返回旧数据,后台异步刷新。
互斥锁方案是面试最常考的。伪代码如下:
java复制public String queryProduct(String productId) {
String cacheKey = "product:" + productId;
String value = redisTemplate.opsForValue().get(cacheKey);
if (value != null) {
return value;
}
// 缓存未命中,尝试获取锁
String lockKey = "lock:" + cacheKey;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (!locked) {
// 没拿到锁,说明其他线程正在重建缓存,这里可以短暂自旋等待后重读
Thread.sleep(50);
return redisTemplate.opsForValue().get(cacheKey);
}
try {
// 双重检查,防止拿到锁后缓存已被其他线程重建
value = redisTemplate.opsForValue().get(cacheKey);
if (value != null) {
return value;
}
// 查询数据库
value = productDao.query(productId);
redisTemplate.opsForValue().set(cacheKey, value, 30, TimeUnit.MINUTES);
return value;
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}
注意这个代码里有个隐蔽的坑:释放锁时必须校验当前线程的标识,否则可能出现 A 线程的锁已经过期,B 线程拿到锁写入缓存后,A 线程才执行 finally 里的 delete,把 B 的锁误删了。所以锁的 value 应该存一个唯一标识,释放时先比对再删除。更省心的办法是直接用 Redisson 的 RLock,它内置了锁标识和看门狗续期,我后面会专门讲。
逻辑过期方案更偏向高并发读多写少的场景。缓存里不设物理过期时间,而是存一个过期时间戳。查询时如果发现时间戳已过期,就先返回”过期的旧数据“,同时提交一个异步线程去刷新缓存。用户感知不到延迟,但数据会短暂滞后。对商品详情、榜单这类强读场景很合适,对一致性要求高的场景要谨慎。
4.4 缓存雪崩:过期时间打散与多级缓存
雪崩的典型成因是大量 key 设了相同的过期时间,比如所有商品详情都设 30 分钟,同一秒集体过期,压力瞬间全打到数据库。
治标手段是给过期时间加随机扰动:
java复制int baseExpire = 3600;
int randomExpire = baseExpire + new Random().nextInt(600);
redisTemplate.opsForValue().set(key, value, randomExpire, TimeUnit.SECONDS);
把过期时间打散到 1 小时内,不同 key 的过期时刻错开,数据库就不会在同一瞬间承压。这个方案实现成本几乎为零,我建议所有人从第一天就养成加随机值的习惯。
治本手段是架构层面做多级缓存。本地 Caffeine 一级缓存挡掉大部分流量,Redis 二级缓存承载共享数据。即使 Redis 挂了,本地缓存还能扛一会儿,给数据库留出缓冲时间。Redis 自身挂了怎么办?这就要靠主从加哨兵,或者 Redis Cluster 保证高可用。别把单点 Redis 当成理所当然,生产环境 Redis 实例挂了,影响面是整个服务,不只是缓存闪断那么简单。
还有一个容易被忽略的点:冷启动场景。服务刚上线时,大量缓存是空的,数据库会被集中的缓存重建请求压垮。我的习惯是上线前做一个预热任务,把热门 key 提前写进 Redis,避免冷启动雪崩。Spring Boot 里可以写一个 ApplicationRunner 实现类,启动时加载热点数据。
5. Redis 分布式锁从入门到落地
5.1 为什么不直接用 synchronized
Java 单体应用里要保证并发安全,第一反应是 synchronized 或 ReentrantLock。但在分布式环境下,锁只对当前 JVM 进程内的线程有效。同一个订单号请求打到两台服务器,A 机器和 B 机器各持有一把 JVM 锁,谁也管不着谁,重复扣减库存、重复提交订单这类问题就会出现。
分布式锁的核心诉求就一句话:多个进程之间,互斥地访问同一个共享资源。Redis 因为单线程、性能极高,成了实现分布式锁最常见的中间件。此外也有 ZooKeeper 和 etcd 实现的分布式锁,各有优劣。但 Redis 靠生态和上手简单,在 Java 技术栈里普及率最高。
5.2 SETNX 方案与典型坑
分布式锁最基础的实现,是利用 Redis 的 SET NX EX 命令。NX 表示只有当 key 不存在时才设置成功,相当于”占位“;EX 设置过期时间,避免占位后进程挂了导致死锁。
bash复制SET lock:order:1001 uuid-123 NX EX 30
Java 里的原始写法:
java复制Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
if (locked) {
try {
// 执行业务逻辑
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}
这段代码能跑,但至少有四个坑,我全部踩过:
- 第一个坑:释放锁时把它
delete掉,但如果业务执行时间超过了过期时间,锁自己过期了,另一个线程拿到了同一把锁,这时第一个线程执行完 finally 里的delete,把别人的锁删掉了。解决办法是释放前先比较 value 是否是自己写入的唯一标识,Lua脚本里比较并删除,保证原子性。 - 第二个坑:锁过期时间设置得过短,业务慢一点就锁失效了。解决办法是用看门狗机制自动续期,Redisson 内置了这个能力。
- 第三个坑:锁的粒度太粗。一把锁锁住整个用户的所有操作,导致其他无关操作也被串行化。分布式锁的 key 要尽量细到具体资源,比如
lock:order:1001而不是lock:user:1001。 - 第四个坑:重入问题。同一个线程多次调用加锁方法会把自己挡在外面,所以锁要考虑可重入性。Redisson 的
RLock本身就是可重入的,用的时候要选对。
5.3 Redisson 生产级用法
自己手写 SETNX 锁又累又容易出 bug,生产环境我强烈建议用 Redisson。它是一个 Redis Java 客户端,自带封装好的分布式锁,把上面说的续期、可重入、原子释放都处理好了。
引入依赖:
xml复制<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.27.2</version>
</dependency>
核心用法:
java复制@Autowired
private RedissonClient redissonClient;
public void deductStock(Long orderId) {
RLock lock = redissonClient.getLock("lock:order:" + orderId);
try {
// 尝试加锁,最多等待 3 秒,自动释放时间默认 30 秒
boolean locked = lock.tryLock(3, 30, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("系统繁忙,请稍后重试");
}
// 业务逻辑
orderService.deduct(orderId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException("加锁失败");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
看到 tryLock(waitTime, leaseTime, unit) 里的 leaseTime 了吗?如果传了 30,锁 30 秒后一定释放,不会触发看门狗续期。如果不传 leaseTime,Redisson 会启动一个后台任务,每 10 秒检查一次,只要当前线程持有锁,就把过期时间续到 30 秒,任务结束后自动删除。这个机制就是看门狗。
不过我要提醒一个常见的误用:如果你的业务逻辑本身超过 30 秒,就不要依赖看门狗,而是把 leaseTime 设得足够长,或者提前评估业务能不能拆分。看门狗解决的是不确定耗时的场景,确定耗时的场景最好显式设置合理超时。
5.4 RedLock 值与不值
关于分布式锁,还有一个绕不开的话题:RedLock。它的思路是同时向多个独立的 Redis 节点申请锁,超过半数成功才算加锁成功,以此避免单个 Redis 节点故障时锁失效。
面试问到“Redis 分布式锁的缺点”,八成就是在等你提 RedLock 和它的争议。RedLock 的问题在于:它依赖多个 Redis 节点各自独立,但在极端场景下,比如节点间时钟漂移、网络分区、GC 导致锁超时,仍然可能出现两个客户端同时持锁的情况。Redis 的作者在博客里都明确表达过这个方案在分布式系统严格意义上是存在问题的,分布式系统领域的专家也持保留态度。
我的观点很务实:在绝大多数业务场景里,用 Redisson 的单节点锁已经足够。如果你的系统达到需要强一致分布式锁的程度,说明业务对一致性要求极高,应该考虑 ZooKeeper 或 etcd 实现,它们比 RedLock 更成熟可靠。面试时能清楚说出 RedLock 的原理和争议,比死记硬背“五个节点”要有说服力得多。
6. 缓存治理与生产监控
6.1 key 设计规范与内存淘汰
Redis 用得多了,最头疼的就是 key 变得乱七八糟,没人知道某个 key 是干什么的、谁写入的、该不该删。我见过一个项目,一段逻辑直接拿用户手机号当 key,还有用 UUID 当 key 的,排查问题的时候只能抓瞎。
我建议从第一天就建立 key 命名规范。通用的格式是“业务域:对象:标识”,比如 user:info:1001、order:detail:2001、product:stock:3001。用冒号分层,层级清晰,在 Redis Desktop Manager 这类可视化工具里也方便按前缀折叠浏览。
内存淘汰机制也要提前规划。Redis 默认的 maxmemory-policy 是 noeviction,内存满了之后写入直接报错。生产环境必须设 maxmemory,并按业务特性选淘汰策略:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| volatile-lru | 对设置了过期时间的 key 淘汰最久未使用 | 绝大多数缓存场景首选 |
| allkeys-lru | 对所有 key 淘汰最久未使用 | 缓存和持久数据混存,且不在乎里被淘汰 |
| volatile-ttl | 淘汰剩余过期时间最短的 key | 近似优先淘汰即将过期的数据 |
| volatile-random | 从设置过期时间的 key 中随机淘汰 | 对数据冷热不敏感 |
| noeviction | 不淘汰,内存满则报错 | 绝不希望缓存被淘汰的场景,但要有容量预案 |
缓存场景我首推 volatile-lru。注意,如果某些 key 永不过期,volatile-lru 不会淘汰它们,可能导致这部分数据把内存占满,所以要设计好过期时间。还有一种办法:真正常驻的热点数据单独放一个 Redis 实例,和其他缓存隔离,互不影响。
6.2 BigKey 和热 Key 处理
BigKey 指单个 key 的 value 过大。多大的 key 算大?经验值是:String 类型 value 超过 10 KB,集合类型元素总数超过 5000 个,或元素总大小超过 1 MB。BigKey 的危害很隐蔽,平时正常读写,一旦执行 KEYS 扫描或 DEL 删除时,会因为单线程处理阻塞 Redis 主线程几秒钟,造成整个实例卡顿。
排查 BigKey 的方法,我推荐用 redis-cli 自带的扫描命令:
bash复制redis-cli --bigkeys
它会扫描所有 key,筛选出不同类型里最大的几个。生产环境这个命令要在低峰期执行,避免扫描本身拖慢主线程。
处理 BigKey 的思路分几种。如果是 List 或 Set 过大,要拆分成多个小 key,比如按时间分片,一个分片一个 key;如果是 Hash 过大,可以按业务字段拆,或者把大 Hash 改成多个 String key。如果是 String 存大 JSON,考虑换 Hash 存储,或者把 JSON 压缩后再存。删除 BigKey 时用 UNLINK 代替 DEL,UNLINK 是异步删除,不会阻塞主线程。
热 Key 是指短时间被大量请求访问的 key。比如双11秒杀时,某个商品库存 key 的访问量暴增,单个 Redis 实例可能扛不住。解决思路有几种:本地缓存加一层,热点 key 不进 Redis,直接用 Caffeine 挡掉;读写分离,热 key 复制到多个只读节点;或者对这个 key 做”多副本“,比如在 key 后面加随机后缀拆成多个 key,分散到不同实例。
6.3 慢日志、监控与容量规划
Redis 给你提供了慢查询日志功能,通过设置 slowlog-log-slower-than 阈值(单位微秒),可以记录执行时间超过阈值的命令。
bash复制# 设置超过 100 毫秒的命令记录慢日志
CONFIG SET slowlog-log-slower-than 100000
# 查看最近 10 条慢日志
SLOWLOG GET 10
看到慢日志,别急着优化命令,先分析为什么慢。常见原因:执行了 KEYS * 导致全量扫描;BigKey 的删除和读取;使用了复杂度很高的命令,比如对一个超大 ZSet 执行 ZRANGE;还有一些是网络往返频繁导致的。
监控方面,我建议至少盯四个指标:
used_memory:内存使用率,接近maxmemory时要告警。connected_clients:连接数,超过配置上限说明连接池或服务有问题。hit_rate:缓存命中率,长期低于 80% 要检查缓存设计和预热机制。instantaneous_ops_per_sec:QPS,看流量波动和瓶颈。
容量规划也不能拍脑袋。估算公式很简单:单 key 平均大小乘以 key 总数,再加上 20%~30% 的 buffer 和 Redis 自身元数据开销。假设一亿个 key,平均每个 100 字节,那内存至少需要 10 GB左右,实际建议按 15 GB 以上规划。别把 Redis 内存塞得太满,内存越接近上限,淘汰策略越频繁,性能波动越大。
7. Redis 面试高频问题速查
7.1 面试官最爱问的 10 个点
热词里出现了一堆 java 面试八股文、redis 面试题,说明这确实是 Java 开发面试的重灾区。我梳理了面试官最爱问的 10 个 Redis 问题,你可以当自查清单:
- Redis 为什么快?答案要点:纯内存操作、单线程避免上下文切换和锁竞争、IO 多路复用、高效的数据结构。
- Redis 是单线程为什么还能高并发?答案要点:单线程处理命令,IO 多路复用让网络读写不阻塞,瓶颈在网络 IO 和内存,不在 CPU。
- 缓存穿透、击穿、雪崩的区别和解决方案?答案要点:见本文第四章。
- Redis 持久化 RDB 和 AOF 的区别?答案要点:RDB 是快照、体积小、恢复快、可能丢数据;AOF 是日志、可靠性高、文件大、恢复慢。生产一般两者结合。
- Redis 过期删除策略是什么?答案要点:惰性删除加定期删除结合,内存满还有内存淘汰策略兜底。
- Redis 主从复制、哨兵、集群的区别?答案要点:主从解决读扩展,哨兵解决主节点故障自动切换,集群解决数据分片和高可用。
- Redis 分布式锁怎么实现,有什么坑?答案要点:SETNX 加 Lua 释放,注意锁续期、可重入、误删问题,生产用 Redisson。
- 为什么要用 Redis 而不是直接存内存?答案要点:多实例共享、持久化、分布式一致性、统一管理。
- Redis 的 key 过期了为什么内存没降?答案要点:内存统计包含已经过期但未删除的 key,惰性删除和定期删除都有延迟。
- 缓存数据一致性问题怎么解决?答案要点:先更新数据库再删缓存,配合延迟双删、消息队列补偿、设置较短过期时间。
第 10 个问题值得多说一句。缓存一致性没有银弹,我的做法是:核心数据采用“先更新数据库,再删除缓存”,删除失败就发消息队列重试;非核心数据接受短暂不一致,靠过期时间兜底。不要轻易尝试“先更新缓存再更新数据库”的顺序,出事概率太高。
7.2 实战排查问题实录
最后分享一下我在实际排查 Redis 相关问题时的经验。这些都是运维文档里不写,但生产环境一定会碰到的事。
第一个常见问题:应用启动后报 Unable to connect to Redis。第一步看 Redis 进程在不在,第二步看防火墙和 bind 配置,第三步用 redis-cli -h 目标IP -p 6379 ping 从应用服务器测试连通性。大概率是 bind 限制或密码不对。
第二个常见问题:缓存命中率突然下降。先看是否发了新版本、key 前缀有没有变;再看 Redis 是不是发生过重启,持久化数据没恢复;最后用 INFO stats 看 keyspace_hits 和 keyspace_misses 的比例。很多时间命中率下降是代码里 key 拼接变了,比如新增了环境变量前缀。
第三个常见问题:Redis 实例 CPU 飙升。优先看慢日志,再看热 key,最后看是不是有大 key 在做读写。CPU 高但 QPS 不高时,往往是大 key 或 KEYS 这类阻塞命令在捣乱。
还有一个很隐蔽的坑:Spring Data Redis 低版本和高版本的默认序列化器不一样。JDK 序列化会把对象序列化成二进制流,存入 Redis 后肉眼看不出来内容,还会让 key 带一串诡异的前缀。排查时看见 \xAC\xED\x00\x05t... 这种 key,不要慌,改用 StringRedisSerializer 和 GenericJackson2JsonRedisSerializer 就能解决。开发调试阶段尽量用 StringRedisTemplate,看到的数据直观。
最后分享一个生产小技巧
写到这里,关于 Redis 的核心知识点基本都过了一遍。最后说一个小习惯,是我跑了几年生产之后总结出来的:给所有缓存 key 都设计唯一前缀,并在服务启动时打印一份 key 清单。
听起来很简单,但价值很大。有了规范的前缀,排查问题的时候 redis-cli --scan --pattern "user:*" 一秒能定位所有相关 key;清理数据的时候知道哪些可以批量删,哪些绝对不能碰;做容量规划的时候能按前缀统计各类数据的增长趋势。反观那些没有命名规范的项目,一旦系统中途换人维护,Redis 里的数据就成了一锅浆糊,谁也不敢动,只能让它们腐烂在内存里。
Redis 这东西,入门容易精通难。难的不是命令怎么敲,而是你要理解它为什么快、适合解决什么问题、不适合解决什么问题。把这几个问题想透了,你写的代码自然会变得“有 Redis 味”。
如果你正打算深入学习,建议按这个顺序推进:先把五种数据结构和适用场景吃透,再亲手在 Spring Boot 项目里接一遍缓存和分布式锁,然后去了解主从、哨兵、集群的部署方式。每一层都亲手敲过代码,面试和实战才不会心虚。
