先交代一下背景:我工作这几年,前前后后用 Redis 做了缓存、分布式锁、排行榜、接口限流、消息队列,踩过的坑比看过的资料还多。说实话,网上的教程不少,但大多东讲一点西讲一点,要么只讲 Jedis 原生 API,要么把 Spring Data Redis 封装成一坨“黑盒”,出了问题完全不知道从哪里排查。这篇博文我会用最直接的方式,把 Java 环境里操作 Redis 的完整链路拆开讲透,从基础客户端连接到 Spring 环境下的 RedisTemplate 使用,再到序列化方案、五种数据类型的实战场景、分布式锁的写法与演进,最后把工作中真正遇到过的坑和排查思路一并交代清楚。适合刚上手 Redis 的 Java 开发者,也适合用了 Redis 一阵子但对“为什么这么配置”还不太清楚的兄弟。
先把结论放这:在 Java 和 Spring 环境下操作 Redis 并没有那么玄乎,核心就三件事——搞懂数据结构、选对客户端、把序列化和连接配置做对。这三件事搞明白,项目里百分之八十的 Redis 问题都能提前消灭掉。
1. 环境准备与两种基础连接方式
1.1 先把 Redis 跑起来:安装与最常用的命令
你想操作 Redis,第一步肯定是先有一个 Redis 服务。开发环境推荐直接用 Docker 拉镜像,省去编译安装的麻烦。如果是 Mac,一条命令的事:
docker run -d --name redis-dev -p 6379:6379 redis:7.0Windows 环境下,官方原版 Redis 不提供 Windows 版本,建议同样用 Docker,或者用 Memurai、tporadowski/redis 这类社区维护的发行版。这里多说一句:开发环境用 Docker 跑 Redis 是最省心的方案,别在这上面浪费时间编译源码。
服务启动后,先用命令行工具确认一下:
redis-cli 127.0.0.1:6379> PING PONG 127.0.0.1:6379> SET hello world OK 127.0.0.1:6379> GET hello "world"Redis 的核心操作说白了就是围绕“键值对”展开的一组命令。你先把这些命令背熟,后面再看 Java 代码就非常轻松:
| 数据类型 | 常用命令 | 典型场景 |
|---|---|---|
| String | SET / GET / INCR / EXPIRE | 缓存、计数器、分布式ID |
| Hash | HSET / HGET / HGETALL | 对象缓存、购物车 |
| List | LPUSH / RPOP / LRANGE | 消息队列、最新消息列表 |
| Set | SADD / SISMEMBER / SPOP | 去重、抽奖、标签 |
| ZSet | ZADD / ZRANGEBYSCORE / ZREVRANGE | 排行榜、延迟队列 |
| 通用 | KEYS / DEL / EXPIRE / TTL / EXISTS | 键管理、过期策略 |
这里必须提醒一个新手常见误区:生产环境严禁直接使用 KEYS 命令。它会对全库做遍历,数据量大的时候直接把 Redis 卡死。后面我会单独说这个坑。
1.2 Jedis 与 Lettuce 的选型对比
Java 里老牌的 Redis 客户端有 Jedis,Spring Boot 2.x 之后默认用的是 Lettuce。你肯定会问:到底选哪个?我直接说结论,不要纠结。
Jedis是直连模式,操作直观,但线程不安全。什么意思?就是说多个线程共享一个 Jedis 实例时,需要自己搞连接池去管理,一个线程拿一个连接,用完了还回去。所以用 Jedis 的时候,最佳实践就是配合 JedisPool 使用。
Lettuce基于 Netty 实现,连接实例是线程安全的,多个线程可以共享同一个连接,底层通过异步事件循环处理并发请求。Spring Boot 默认集成 Lettuce,所以只要你是 Spring Boot 项目,基本不用动脑子,直接用 spring-boot-starter-data-redis 里自带的 Lettuce 就行。
我的实际经验是:没有特殊强制要求,就用 Lettuce + Spring Data Redis。原生 Jedis 我只在某些老项目的改造里见过,新项目基本碰不到。
1.3 从 Jedis 原生 API 到“手写一个 Redis 工具类”
既然标题是“在 Java 中操作 Redis”,我就先用原生 Jedis 写一个最简示例,帮你把底层逻辑弄清楚。因为后面哪怕你用了 Spring Data Redis,它的底层依然离不开这些基本操作。
先引入依赖:
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>4.4.3</version> </dependency>然后写一个简单的测试:
public class JedisDemo { public static void main(String[] args) { // 创建连接池配置 JedisPoolConfig poolConfig = new JedisPoolConfig(); poolConfig.setMaxTotal(50); poolConfig.setMaxIdle(20); poolConfig.setMinIdle(5); // 创建连接池 try (JedisPool jedisPool = new JedisPool(poolConfig, "127.0.0.1", 6379)) { try (Jedis jedis = jedisPool.getResource()) { // 验证密码(没有密码可以省略) jedis.auth("yourpassword"); // 基本的键值操作 jedis.set("name", "geektime"); String name = jedis.get("name"); System.out.println("name = " + name); // 带过期时间的操作 jedis.setex("token:1001", 3600, "session-token-value"); // 计数器操作 long count = jedis.incr("page:view:article:1001"); System.out.println("第" + count + "次访问"); // 哈希操作 jedis.hset("user:1001", "nickname", "老王"); jedis.hset("user:1001", "level", "VIP5"); System.out.println(jedis.hgetAll("user:1001")); } } } }这段代码里有几个点要重点说:
第一,用连接池是必须的。Jedis 实例不是线程安全的,不池化的话高并发场景下你会看到各种连接异常。
第二,try-with-resources 是个好习惯。JedisPool和Jedis都实现了Closeable,这样能保证连接正确归还。还有个细节:JedisPool也实现了Closeable,正常要把池的关闭放在最外层,上面代码里两个 try 嵌套,内层的Jedis归还,外层的JedisPool最后随主流程结束,这个写法是对的。
第三,setex和auth这种命令实际工作中很常用,一个是设置值同时给过期时间,一个是鉴权,网上很多旧代码里是分开写的,注意别踩旧教程的坑。
不过实话实说,真正工作里你不会用原生 Jedis 写业务代码,太啰嗦了,还容易漏掉连接释放。但理解这一段非常重要,因为后面你遇到 Redis 连接池耗尽、连接泄漏这类问题时,能顺藤摸瓜找到根因。
2. 走进 Spring 环境:Spring Data Redis 集成
2.1 依赖引入与配置参数
到了 Spring Boot 项目里,事情就简单很多了。先引入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>注意:Spring Boot 3.x 里面这个依赖会帮你带上 Lettuce 和 Spring Data Redis。如果你想用 Jedis,要单独排除掉 Lettuce 再加 Jedis 依赖,但日常开发没必要这么折腾。
配置文件application.yml:
spring: data: redis: host: 127.0.0.1 port: 6379 password: 123456 database: 0 timeout: 3s lettuce: pool: max-active: 32 max-idle: 16 min-idle: 8 max-wait: 3s time-between-eviction-runs: 60s这几个配置参数建议每个都弄明白:
max-active:连接池最大连接数。设太小,高并发下会导致获取连接等待甚至报错;设太大,会占用 Redis 服务端太多连接资源。32 是比较保守的起步值,高并发项目可以按压测调大。max-idle和min-idle:控制空闲连接数量,主要影响请求的响应速度和资源占用。min-idle 保持几个空闲连接,能避免冷启动时频繁创建新连接。max-wait:获取连接的最大等待时间,超过就抛异常。time-between-eviction-runs:后台线程驱逐空闲连接的频率。
关于 timeout 和 max-wait 这两个时间参数,很多人容易混淆。timeout是读写 Redis 操作的最大等待时间,max-wait是从连接池拿连接时的最大等待时间。这两个可以相等,但含义完全不同,面试的时候经常被问到。
2.2 RedisTemplate 与 StringRedisTemplate 的差别
Spring Data Redis 给你封装好了RedisTemplate。默认情况下,容器里会有两个现成的 Bean:RedisTemplate<Object, Object>和StringRedisTemplate。
这里要专门讲清楚它们的序列化差异,因为这是新手踩坑最集中的地方。
默认的RedisTemplate使用的键和值的序列化器是JdkSerializationRedisSerializer。什么意思?就是它会把你的 Java 对象通过 JDK 原生的ObjectOutputStream序列化成二进制字节数组,存的时候前面还会带一串\xAC\xED\x00\x05之类的乱码头。
所以你会遇到这种情况:用RedisTemplate存进去的键,在 redis-cli 里看是一堆\xac\xed\x00\x05t\x00\x04name这样的鬼东西。这就是传说中的“看到乱码”,其实数据本身没错,只是序列化方式导致键带了前缀。
而StringRedisTemplate的键和值默认都是StringRedisSerializer,存进去是什么就是什么,在可视化工具里看非常清爽。
核心结论:如果你的键值都是字符串(比如业务 key 和 JSON 字符串),直接用StringRedisTemplate;如果你非要让 Redis 帮你存整个 Java 对象,那就得自定义RedisTemplate的序列化器,下面的部分给你具体方案。
2.3 序列化方案选型:别让乱码和冗余数据折腾你
实际项目里,我最推荐的一套组合是:
- key 用
StringRedisSerializer,保证 key 的可读性,方便排查问题和运维管理。 - value 用
GenericJackson2JsonRedisSerializer,直接把对象序列化成 JSON 字符串,可读性好,适合业务对象。 - hash 的 key 用
StringRedisSerializer,hash 的 value 用GenericJackson2JsonRedisSerializer。
配置类如下:
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // key 序列化 StringRedisSerializer keySerializer = new StringRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); // value 序列化 GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }有人会问,为啥不用 Fastjson?我以前也用过,后来项目升级出过几回反序列化兼容性问题,再加上那个@JSONType各种注解带来的线上事故,现在基本不碰了。老老实实用 Spring 官方提供的 GenericJackson2JsonRedisSerializer 最稳,虽然序列化出来的 JSON 里会多一个@class字段(记录类类型,方便反序列化还原对象),但换来的是安全性和兼容性,值得。
再补充一个坑:如果 value 用了 GenericJackson2JsonRedisSerializer,存储的字符串值在反序列化时会被转成LinkedHashMap而不是你原来的 POJO。那是因为它在 JSON 里存了类型信息才认得出来。如果你只是存普通字符串,建议直接用StringRedisTemplate,别让 Object 类型到处横跳,省得后面ClassCastException找上门。
2.4 封装一个开箱即用的 Redis 工具类
配置好序列化器后,强烈建议你按自己项目的需求封装一个工具类。不要每次在业务代码里直接注入RedisTemplate然后用原始 API,那样代码会很散。
我这里给你一个精简版但足够实用的封装思路:
@Component public class RedisCache { private final RedisTemplate<String, Object> redisTemplate; public RedisCache(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } public void set(String key, Object value, long timeout, TimeUnit unit) { redisTemplate.opsForValue().set(key, value, timeout, unit); } public <T> T get(String key, Class<T> type) { Object value = redisTemplate.opsForValue().get(key); if (value == null) { return null; } return type.cast(value); } public void delete(String key) { redisTemplate.delete(key); } public boolean expire(String key, long timeout, TimeUnit unit) { return Boolean.TRUE.equals(redisTemplate.expire(key, timeout, unit)); } public boolean hasKey(String key) { return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } }注意我上面代码里用了Boolean.TRUE.equals(...)而不是直接返回 Boolean,是因为 RedisTemplate 很多方法返回的是 Boolean 对象,直接自动拆箱有可能出现 NPE。这个写法虽然多打几个字,但能规避空指针风险,老手都懂。
真正要跨模块复用、带有复杂操作(比如 Lua 脚本、分布式锁)的工具类,还可以进一步拆成多个方法,但你得记住“工具类不要干太多事”,该交给业务层逻辑去编排的别塞进工具类。
3. 五大数据结构在业务里的经典实战
3.1 String:缓存、计数、分布式 ID、限流
String 是最基础也是用得最多的类型。缓存不用多解释,就是 set/get 一把梭,但要注意缓存穿透和缓存击穿的防护,第 5 部分会细说。
计数器很经典。比如文章浏览量:
Long pageView = redisCache.incr("article:pageview:" + articleId);INCR命令在 Redis 内部是原子操作,天然适合计数器。多实例部署也不会因为并发覆盖导致计数丢失。
分布式 ID:业务需要一个不重复的自增 ID,可以用INCR配合daily前缀实现:
String key = "order:id:" + LocalDate.now(); Long orderId = redisCache.incr(key); // 如果要当天凌晨重置,设置过期时间 redisCache.expire(key, 1, TimeUnit.DAYS);这里有个细节:如果之前 key 不存在,INCR 会从 0 开始,第一次执行结果就是 1。如果你希望从某个数起跳,可以用INCRBY,或者在设置初始值时先SET key 1000再INCR。
接口限流经常用 String + 过期时间,比如“一个用户一分钟最多访问 5 次”:
String limitKey = "rate:limit:" + userId; long times = redisCache.incr(limitKey); if (times == 1) { redisCache.expire(limitKey, 60, TimeUnit.SECONDS); } if (times > 5) { throw new BizException("操作过于频繁,请稍后再试"); }千万别忽略判times == 1后设置过期时间这个步骤,不然这个 key 就永远不过期了。
3.2 Hash:对象缓存与购物车
Hash 类型非常契合“一个对象整体存进去,频繁修改其中某个字段”的场景。
比如用户信息:
// 存用户对象 redisCache.hset("user:info:1001", "nickname", "老王"); redisCache.hset("user:info:1001", "level", "VIP5"); redisCache.hset("user:info:1001", "balance", "1000"); // 修改某个字段 redisCache.hset("user:info:1001", "balance", "990");你用 String 类型存整个用户 JSON,改余额时就得“取出 -> 反序列化 -> 改字段 -> 再序列化 -> 放回”,在并发下还容易丢更新。Hash 天然支持字段级操作,省心很多。
购物车是最常被写进面试题的案例:
// 商品 ID 为 field,数量为 value redisTemplate.opsForHash().put("cart:user:1001", "sku:123456", "2"); // 增加商品数量 redisTemplate.opsForHash().increment("cart:user:1001", "sku:123456", 1); // 获取购物车所有商品及数量 Map<Object, Object> cartItems = redisTemplate.opsForHash().entries("cart:user:1001");Hash 存购物车的优势是:一个 key 对应整个购物车,一个 field 对应一个商品,查询、修改单个商品都不需要动整个结构。不过要注意,Hash 的 field 数量如果特别多(比如上万),HGETALL可能会阻塞 Redis,届时要考虑分 key 拆桶。
3.3 List:消息队列与“最新列表”
严格来说 Redis 的 List 可以作为轻量级消息队列使用,通过LPUSH+BRPOP实现生产者和消费者解耦。
生产者:
redisTemplate.opsForList().leftPush("queue:order", orderJson);消费者(阻塞式拿取):
Object msg = redisTemplate.opsForList().rightPop("queue:order", 5, TimeUnit.SECONDS);BRPOP的好处是:队列没数据时,客户端会阻塞等待而不是空轮询浪费 CPU。这个方案适合轻量级、不需要消息确认、不需要复杂路由的场景。
最新消息列表也很好用:比如用户的消息盒子,每次有新消息就LPUSH,然后展示时LRANGE取前 N 条:
redisTemplate.opsForList().leftPush("msg:inbox:" + userId, messageJson); List<Object> latest = redisTemplate.opsForList().range("msg:inbox:" + userId, 0, 19);注意 List 做“最新列表”要带一个长度上限控制,不然这个 key 会无限增长。一般可以用LTRIM截断,或者定时清理。
3.4 Set:去重、抽奖、共同好友
Set 去重的特性在业务里很好用。
比如“用户已点赞的帖子”:
redisTemplate.opsForSet().add("post:liked:" + postId, String.valueOf(userId)); boolean liked = Boolean.TRUE.equals(redisTemplate.opsForSet().isMember("post:liked:" + postId, String.valueOf(userId)));再来个简单抽奖:
// 把所有参与用户放入集合 redisTemplate.opsForSet().add("lottery:activity:1001", "u1", "u2", "u3"); // 随机抽一个用户并移除 Object winner = redisTemplate.opsForSet().pop("lottery:activity:1001");共同好友用集合的求交:
Set<Object> commonFriends = redisTemplate.opsForSet().intersect("user:friends:1001", "user:friends:1002");这种场景换个关系型数据库来写,SQL 可能得 JOIN 半天,Redis 一行就出来了。
3.5 ZSet:排行榜延迟队列等进阶玩法
ZSet(有序集合)每个元素都带一个分数,Redis 按分数排序。最经典的场景就是排行榜:
// 用户积分加 10 redisTemplate.opsForZSet().incrementScore("rank:global", "user:1001", 10); // 取出总分前 10 Set<Object> topTen = redisTemplate.opsForZSet().reverseRange("rank:global", 0, 9);取到的只是用户 ID,要展示昵称头像还得回源数据库查。常见做法是先把用户基本信息缓存一份,再组装返回,不然打榜接口能把数据库查死。
ZSet 还有一个进阶用途:延迟队列。把任务执行时间戳作为分数,任务内容作为值,然后起一个定时任务不停查询ZRANGEBYSCORE小于当前时间戳的任务:
// 延迟 5 分钟执行 long executeAt = System.currentTimeMillis() + 5 * 60 * 1000; redisTemplate.opsForZSet().add("delay:queue:order", orderId, executeAt); // 消费端轮询 Set<Object> dueOrders = redisTemplate.opsForZSet().rangeByScore("delay:queue:order", 0, System.currentTimeMillis());实现简单、对 Redis 压力小,适合“订单超过 30 分钟未支付自动关闭”这类业务。不过要注意消费端的持久化和可靠性,进程挂了任务就丢了,生产环境一般还得配合数据库状态兜底。
4. 分布式锁:从手写 SETNX 到 Redisson
4.1 为什么需要分布式锁:从超卖说起
先想一个电商场景:库存只有 10 件商品,用户同时下了 100 个订单,每个订单都要减库存。如果是单机应用,你可以在 JVM 层面加锁,比如synchronized或者ReentrantLock。但现在的服务基本都是多实例部署,负载均衡之后,同一段代码可能在 N 台机器上同时执行,Java 的锁管不着其他机器上的线程。
这时候就需要一个“所有机器都能看见的锁”——Redis 分布式锁。
4.2 基础版:SET NX EX 的正确姿势
网上有些老教程,还在教SETNX然后EXPIRE分开写,这是有缺陷的。如果SETNX成功之后,还没来得及EXPIRE,进程就挂了,这个锁就永远释放不掉。
正确写法是 Redis 官方提供的原子指令:
// 加锁:key 不存在才能设置成功,同时带上过期时间 Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock:order:pay", "thread-1", 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(lock)) { try { // 业务逻辑 } finally { releaseLock("lock:order:pay", "thread-1"); } }释放锁的时候要注意一个经典问题:只能删除自己持有的锁。如果 A 线程加的锁,因为业务执行超过 30 秒,锁自动过期了,此时 B 线程成功加锁。结果 A 线程业务跑完,执行del lock,就把 B 的锁删了。所以删除前要先比较 value 是不是自己的。
public void releaseLock(String key, String owner) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class); redisTemplate.execute(redisScript, Collections.singletonList(key), owner); }注意:这里用了 Lua 脚本。为什么不用 Java 代码“先 get 再 del”?因为“比较 value 和删除”这两个步骤不是一个原子操作,在判断到删除之间,锁可能已经过期,别的线程抢到了锁,你照样会误删。Lua 脚本在 Redis 中是原子执行的,这是分布式锁正确性的关键。
4.3 进阶版:Redisson 与看门狗机制
上面手写版本有个很烦的问题:锁的过期时间设短了,业务没跑完,锁提前过期,其他线程就进来了;设长了,万一持有锁的线程崩溃,其他线程要等非常久。怎么办?
用 Redisson 的RLock:
RLock lock = redissonClient.getLock("lock:order:pay"); boolean locked = lock.tryLock(1, 30, TimeUnit.SECONDS); if (locked) { try { // 业务 } finally { lock.unlock(); } }Redisson 有“看门狗”机制:如果没指定 leaseTime,默认加锁成功后会有后台线程每 10 秒给锁续期一次,把过期时间一直保持在 30 秒。这样既不会因为业务时间长而提前释放,也不用担心线程崩溃后锁永远不释放——因为看门狗线程是跟着持有锁的 JVM 走的,一旦持有者挂了,看门狗也不跑了,锁会在 30 秒后自动过期。
生产建议:不是很复杂的场景,直接用 Redisson 最省心,别自己写。但要明白它的原理,面试时能讲清楚看门狗和 Lua 脚本还不够,最好能说道说道“为什么 Redisson 的锁只能保证 Redis 单节点下的安全”。
4.4 分布式锁的坑位提醒
- 锁key的设计要带业务前缀,比如
lock:order:pay,别写太短的通用 key,容易跟其他模块冲突。 - 锁的粒度要小。尽量锁“资源 ID”而不是锁全表。比如按订单号、用户 ID 分段锁,能大幅提升并发能力。
- 别在 finally 里无条件 unlock,先判断是否持锁成功再释放,否则没拿到锁的线程也会去 unlock,报异常还好,更怕的是误删别人锁。
- 主从切换导致的锁丢失:Redisson 的普通锁在 Redis 主节点宕机、从节点顶上来的极端场景下可能失效,严格场景要用 RedLock,但业界对 RedLock 本身也有争论。实际项目里多出于成本考虑直接接受极小概率的极端风险。
5. 缓存三大问题的治理方案
5.1 缓存穿透:查一个不存在的数据
什么叫穿透?就是请求的数据在 Redis 和数据库里都不存在,每次请求都直接打到数据库。攻击者可以构造一批不存在的 ID(比如负数 ID 或随机 UUID)疯狂请求,数据库直接被压垮。
常见解决方案:
- 缓存空结果:查不到的数据也缓存起来,过期时间设短一点(比如 60 秒)。
Object value = redisCache.get(key); if (value == null) { value = queryFromDb(key); if (value == null) { redisCache.set(key, "", 60, TimeUnit.SECONDS); } else { redisCache.set(key, value, 10, TimeUnit.MINUTES); } }布隆过滤器:请求进来先过布隆过滤器,判断这个 key 是否“一定不存在”,如果大概率不存在则直接返回,不打库。注意布隆过滤器有误判率(判断存在不一定存在),但判断“不存在”则一定不存在。
参数校验:很多穿透其实是因为非法参数,接口层做好基础校验能挡掉一部分。
5.2 缓存击穿:热点 key 过期瞬间
缓存击穿和穿透不一样。击穿是某个热点 key 过期的瞬间,大量请求同时打到数据库。比如秒杀商品的库存 key,在过期的那一刹那,所有线程都发现缓存没有,全部涌入数据库。
解决方案:
- 互斥锁:缓存没命中时,先尝试加分布式锁,只有一个线程去查数据库回填缓存,其他线程等待后重新查缓存。这个思路简单有效,但要注意别把整个系统锁死。
Object value = redisCache.get(key); if (value == null) { String lockKey = "lock:cache:" + key; boolean lock = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(lock)) { try { // 双重检查,防止并发 value = redisCache.get(key); if (value == null) { value = queryFromDb(key); redisCache.set(key, value, 10, TimeUnit.MINUTES); } } finally { releaseLock(lockKey, "1"); } } else { // 没有抢到锁,短暂等待后重试 Thread.sleep(50); return getWithLock(key); } }- 逻辑过期:不给 key 设真实过期时间,而是把过期时间放在 value 里。读取时发现逻辑上已过期,则启动一个线程去更新缓存,当前请求还是返回旧数据。这种做法用户体验更好,但实现复杂度高一点。
5.3 缓存雪崩:大面积 key 同时失效
雪崩是指大量 key 在同一时间段集体过期,或者 Redis 节点宕机,导致所有请求落到数据库。
应对手段:
- 过期时间加随机值:避免同一批 key 在同一秒过期,给每个 key 的过期时间加一个随机秒数。
long timeout = 10 * 60 + ThreadLocalRandom.current().nextInt(300);- 热点 key 不设置过期时间,改为后台任务定时更新。这种方式能从根本上避免热点 key 过期,但更新逻辑要写好,别出现缓存跟 DB 不一致。
- Redis 高可用:搭建主从复制 + 哨兵或 Cluster,防止单点故障。
- 多级缓存:Redis 之上再加一层本地缓存(比如 Caffeine),Redis 挂了本地还能扛一阵。
5.4 缓存与数据库的双写一致性
关于“先更新数据库还是先删缓存”,业界是有过很多争论的。这里我给一套偏稳妥的方案。
最常用的策略是Cache Aside(旁路缓存):
- 读:先读缓存,未命中读数据库,回填缓存。
- 写:先更新数据库,再删除缓存(不是更新缓存)。
为什么写的时候要“删除缓存”而不是“更新缓存”?因为更新缓存需要把整个对象的最新值算好再写进去,成本高,而且并发场景下更新顺序容易错乱,删掉让下次读的时候再回填,反而更稳。
删缓存这个操作有个经典并发问题:A 读缓存未命中,去 DB 查旧值,B 此时更新 DB 并删缓存,然后 A 把旧值回填进缓存。最终缓存里还是旧数据。解决方案是延迟双删:更新 DB 后,先删一次缓存,然后过几百毫秒再删一次。也可以依赖消息队列在发出更新操作后异步重试删除,保证最终一致性。
记住,没有绝对一致,业务上要允许短暂的旧数据存在,能做到最终一致已经能覆盖绝大多数场景。追求强一致性的数据(比如余额、支付状态)不应该走缓存。
6. 生产环境的真实坑位:排查记录与高频问题速查
6.1 序列化乱码:一个 key 引发的现象级事故
之前有个同事在新项目里直接用默认RedisTemplate存数据,Redis 里全是\xac\xed\x00\x05开头的 key。后来要排查线上数据,用可视化工具搜 key,怎么都搜不到,最后发现 key 的存储和查询不是同一套序列化器,导致查出来一堆乱码。
排查思路并不复杂:先用 redis-cli 看实际存的 key 长什么样,然后检查代码里RedisTemplate的 keySerializer 是什么。如果业务代码用了默认RedisTemplate,把配置类里的序列化器换掉,然后把原来乱码的数据删除,重新写入。注意旧的乱码 key 不会自动变好,你还得写个清理脚本。
6.2 连接池耗尽与连接超时
线上遇到过RedisCommandTimeoutException,控制台一直刷 “Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException”。常见的诱因有三个:
- 连接池
max-active设太小,高并发时大量线程阻塞在获取连接上。 - 某个业务逻辑耗时长,一个线程长期占着连接不释放,导致池中连接被占满。
- Lettuce 默认 timeout 是 60 秒,配合超时线程的调度,很容易把请求拖住。
解决步骤:
- 先把
spring.data.redis.timeout调小一点(比如 3s),让超时快速暴露而不是拖死线程。 - 调大连接池参数,或者检查是不是哪段代码忘了释放连接。
- 排查有没有大 key 操作,因为大 key 的序列化和传输非常耗时。
6.3 大 key 与热 key 治理
大 key:单个 key 的值特别大(比如几 MB 的 JSON 字符串,或者一个 Set 里有几十万成员)。大 key 会导致 Redis 阻塞、网络带宽打满、集群数据倾斜,删除的时候还容易阻塞主线程。
排查方法:用redis-cli --bigkeys扫描,它会统计每种类型里最大的 key 是哪些。
治理思路:
- 拆分:纯大字符串拆成多个小 key;大 Hash/Set 按业务的某个维度拆桶。
- 压缩:把序列化前的对象做字段裁剪或压缩,比如压缩成 GZIP。
- 不直接 DEL:删除大 key 用
UNLINK命令(异步释放),或者在低峰期分批删除。
热 key:某个 key 被超高频率访问,比如明星微博的点赞数。单台 Redis 可能被它打爆。治理思路有:热 key 数据做副本分散到多个 key(比如 key#1、key#2),读请求随机取一个;或者本地缓存兜一层。
6.4 慢查询排查
Redis 里耗时超过设定阈值的命令会被记录在慢查询日志里。慢查询默认阈值是 10 毫秒(slowlog-log-slower-than),可以通过SLOWLOG GET查看。
慢查询常见的元凶:
- KEYS 命令全库扫描。
- HGETALL 取超大 Hash。
- ZRANGEBYSCORE 取超大范围的 ZSet。
- 批量 MGET 传入大量 key。
- 使用 O(N) 复杂度的命令还没控制 N 的量级。
排查到慢查询之后,重点看命令类型、key 和耗时,再针对性地优化数据结构或拆分命令。
6.5 高频面试题速查卡
| 问题 | 回答要点 |
|---|---|
| Redis 为什么快 | 内存操作 + 单线程模型 + I/O 多路复用 |
| 缓存穿透 / 击穿 / 雪崩区别 | 穿透是查不存在,击穿是热点 key 过期瞬间,雪崩是大面积过期/宕机 |
| RedisTemplate 的序列化乱码 | 默认 JDK 序列化,建议 key 用 StringRedisSerializer、value 用 GenericJackson2JsonRedisSerializer |
| 怎么设计分布式锁 | SET NX EX + Lua 删除,或直接 Redisson |
| Redis 持久化机制 | RDB 快照 + AOF 日志,各自优缺点要背熟 |
| 为什么 ZSet 能做排行榜 | 跳表结构 + 分数排序,增删改查都是 O(logN) 量级 |
| Redis 内存淘汰策略 | 默认 noeviction,常用 allkeys-lru / volatile-lru,面试前过一遍 |
7. 写在最后:我的几点真实建议
这篇内容拖了很久,正好趁整理的机会把我这两年用 Redis 踩过的坑都说了一遍。最后基于个人经验分享三个小建议,不一定适合所有团队,但值得你参考。
第一,把 Redis 当缓存用的时候,一定要想过期时间。我见过太多 key 只 set 不设过期时间的代码,最后 Redis 内存被打爆,服务直接雪崩。就算你确实想“永久保存”,也得想清楚数据增长的速度,而不是一味放任。
第二,线上排查问题,先把 redis-cli 上的数据读一遍。很多诡异的问题,看一眼实际存储的数据形态,心里就大概有底了。可视化工具当然方便,但命令行能给你最原始、最准确的信息。
第三,序列化和连接池是关键中的关键。十个 Redis 相关的事故,至少有八个能从这两个地方找到原因。这篇文章里反复强调的东西,实际工作中真的会一遍又一遍地遇到。
最后分享一个小技巧:如果你的项目正好是 Spring Boot 3.x,配置的写法有些变化,但底层原理没有变。真遇到配置不生效、连接失败之类的问题,先把spring.data.redis下的配置全部打出来核对一遍,再去看异常堆栈。很多时候,问题不是代码写得不对,而是配置压根没加载进去。