做后端开发的同学,几乎都会在某个阶段遇到Spring Boot整合Redis的场景。缓存热点数据、做分布式锁、存用户会话、实现消息队列,Redis在项目里承担的角色远比想象中多。这篇文章从环境准备开始,把核心配置、功能实现和几个真实场景下的实战案例串起来讲,尽量让你能照着做完一套完整的整合流程。如果你是刚接触Spring Boot的小白,可以把它当入门指南;如果你已经用过Redis但总在序列化、分布式锁、缓存击穿这些地方踩坑,也可以直接跳到对应章节看排查思路。我会把每一步的为什么讲清楚,不只是给你一段能跑的代码。
1. 环境准备:先把Redis和Spring Boot项目跑起来
1.1 Redis安装与启动,别在小事上翻车
很多同学在Spring Boot整合Redis时会直接跳过环境安装,后面连不上又开始怀疑代码,结果折腾半天发现Redis根本没起来。这种问题其实特别常见,所以环境准备阶段反而是最值得多花两分钟确认的。
本地开发我实测下来最省事的是用Docker,一条命令就能把Redis拉起来。如果你所在的环境不允许用Docker,也可以直接用系统自带包管理器安装,比如在Linux上用apt install redis-server或者yum install redis,macOS上用brew install redis。源码安装也不复杂,下载、编译、启动三步走,生产服务器上推荐这种方式,版本更可控。
启动之后别急着写Spring Boot,先用命令行确认Redis真的能通:
redis-cli -h 127.0.0.1 -p 6379 ping如果返回PONG,说明Redis服务已经正常。需要注意的是,Redis默认只监听127.0.0.1,一旦你改成对外提供服务,务必在配置里设置requirepass密码,并且用防火墙限制来源IP。我之前见过一个项目,Redis没设密码放在公网,半小时不到就被扫描器拉去了挖矿脚本,这种事故网上随便搜都是案例,不用抱有侥幸心理。
开发环境里持久化策略也值得顺手配好。Redis默认如果只靠RDB快照,宕机时会丢不少数据;如果开AOF,每次写命令都会追加日志,数据更安全但性能略降。本地开发开RDB就够了,生产环境建议AOF和RDB都开,AOF配置appendfsync everysec这个折中档位。
1.2 Spring Boot项目引入依赖,版本匹配是关键
创建一个Spring Boot项目后,在pom.xml里加依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>第一行是主依赖,第二行是连接池依赖。网上很多教程只写了第一行,后面用Lettuce连接池时才会发现少东西。Spring Boot 2.x及以上版本默认底层客户端是Lettuce,不是老项目里常见的Jedis。Lettuce本身基于Netty,一个连接就能多路复用,高并发下比Jedis更省资源。不过Lettuce的连接池需要commons-pool2支持,不引入这个包,你配置的max-active、max-idle这些参数不会生效。
版本匹配这方面翻过几次车。Spring Boot 3.x最低要求Java 17,如果你项目还在用Java 8,老老实实选Spring Boot 2.7.x,版本自带的Spring Data Redis也能满足绝大多数需求。Spring Boot 2.7里配置前缀是spring.redis,Spring Boot 3.x改成了spring.data.redis,这个差异特别隐蔽。我见过一个哥们从2.6升到3.2,配置文件没改,启动也不报错,但是Redis连接一直走默认localhost,排查了好几天才发现是前缀变了。
1.3 第一份配置文件:connection信息怎么填才不出错
基础的application.yml配置如下:
spring: data: redis: host: 127.0.0.1 port: 6379 password: yourpassword database: 0 connect-timeout: 5000 timeout: 3000 lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: -1ms解释几个参数。connect-timeout是建立连接的超时时间,单位毫秒,timeout是每次操作命令的响应超时。如果你设置得太短,比如100ms,一旦Redis所在机器负载偏高,正常请求也会抛超时异常,误以为Redis挂了。max-active是连接池最大活跃连接数,默认8,对于大多数Web后端来说8可能不太够,通常我会设置到16到50之间,具体看压测。max-idle是最大空闲连接数,min-idle是保持的最小空闲连接数,生产环境建议min-idle至少2,避免流量暴涨时临时创建连接产生抖动。max-wait是拿连接时的最大等待时间,默认-1表示无限等待,如果不想让请求卡住,可以设成2000ms,超时后直接报错。
还有一个容易踩的坑:Redis的database默认0,Redis集群模式并不支持多个database,只能用db0。如果你是在多项目共用一套Redis,可以按database隔离,但用集群时必须得换Redis key的前缀方案,比如某个项目统一加projectA:前缀。
2. 核心配置:把RedisTemplate调成一杆顺手的枪
2.1 RedisTemplate与StringRedisTemplate该选谁
Spring Data Redis里有两个最常用的Bean,一个是RedisTemplate<Object, Object>,另一个是StringRedisTemplate。这两个Bean在同一个Spring容器里都有,但行为差异很大,选错会导致数据读写对不上。
RedisTemplate默认的key和value序列化器是JdkSerializationRedisSerializer,它会把对象变成一串二进制字节,写入Redis里肉眼看起来是一堆\xAC\xED开头的乱码。而且被存储的Java对象必须实现Serializable接口,否则直接抛异常。StringRedisTemplate则用字符串序列化,读写出来的都是人类能看懂的普通字符串。
我个人的习惯是:如果只缓存普通字符串、JSON字符串、Set集合这些简单数据,直接用StringRedisTemplate;如果必须把Java对象直接丢进去,就自定义一个RedisTemplate,把value序列化器改成JSON,不要用默认的JDK序列化。下面这段配置算是一个通用模板,可以直接抄:
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; }这里有几个细节值得展开。key和hashKey都用了字符串序列化,这样在Redis客户端里能看到清晰的key,不会出现二进制乱码。value用GenericJackson2JsonRedisSerializer,它会在序列化时带上@class字段,反序列化时才能还原出具体类型。代价是每个JSON里多存了一段类信息,数据量会膨胀一些。如果项目里的对象类型明确,且不会跨系统,也可以用Jackson2JsonRedisSerializer<T>,但一定要指定具体类型,不然反序列化时拿不到类型信息就会报错。
2.2 序列化方案对比:JSON还是JDK原生
缓存方案的选型,本质上绕不开序列化。很多新手喜欢直接把对象往Redis里塞,图省事;但线上问题往往就出现在这里。下面这个表格是我实际对比后的结论:
| 对比项 | JDK原生序列化 | JSON序列化 |
|---|---|---|
| 可读性 | 差,全是二进制乱码 | 好,能直接看到字段 |
| 空间占用 | 大,类信息冗余多 | 相对较小 |
| 跨语言 | 不支持,只有Java能读 | 支持,其他语言可直接消费 |
| 反序列化风险 | 需要实现Serializable,类变更易出错 | 泛型丢类型,但可以用工具规避 |
| 性能 | 性能一般 | 性能较好 |
如果你只在Redis中存Java对象,且只有一个Java服务读写,JDK序列化也不是不能用。但缓存的意义往往在于减少重复处理,数据很可能要被后端、报表系统、数据服务共同读取,JSON这种通用格式明显更友好。
JSON序列化里还有一个容易忽略的问题:LocalDateTime字段。默认的Jackson序列化器处理不了JSR310时间类型,会在启动或运行时抛异常。解决办法是在ObjectMapper里注册JavaTimeModule,并关闭WRITE_DATES_AS_TIMESTAMPS。如果你自定义了RedisTemplate,一定记得把ObjectMapper配好,不然存带时间的DTO时又得回来补这一课。
2.3 连接池与线程池参数,到底怎么调
连接池参数网上说的很多,但很多人不知道怎么调出适合自己的值。我给出一个从实际压测里总结的思路:先看单台Redis能抗多少QPS,再看业务里最长链路的Redis访问次数。比如你一个请求要访问3次Redis,单台Redis能扛5万QPS,那你后端单机最多承受大约1.6万QPS。连接池的max-active只要大于请求和Redis之间的并发峰值就行。
如果设置的max-active太小,连接不够用会导致获取连接等待,请求排队,进而拖垮整个接口。但设得太大也有副作用:Redis的连接数增多会占用文件描述符,Lettuce底层是Netty,连接开销虽然比Jedis小,但也不是无上限的。所以建议从16开始,压测后逐步上调到50左右,同时观察Redis的connected_clients指标。
线程池方面,RedisTemplate本身是线程安全的,不需要你在业务代码里额外搞一个线程池来保护它。真正需要线程池的场景是异步缓存预热或批量操作,这时可以考虑用Spring的ThreadPoolTaskExecutor。一个常见误区是每来一个请求就手动new Thread去执行异步任务,线程创建销毁开销大,高并发下直接拖垮应用。宁可先让缓存查询走同步,也别用一张没连接的线程池。
2.4 cacheManager配置:Spring Cache默认行为解析
Spring Cache注解@Cacheable是整合Redis时最常用的抽象层。如果不配置,Spring Boot默认使用ConcurrentMapCacheManager,缓存只存在本地内存里,应用重启就没了,多实例部署时每个节点的缓存也不一致。所以要让@Cacheable优雅利用Redis,必须配置RedisCacheManager。
@Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .prefixCacheNameWith("cache:") .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); return RedisCacheManager.builder(factory) .cacheDefaults(config) .transactionAware() .build(); }这里要特别强调prefixCacheNameWith的作用。如果不给缓存key加前缀,多个不同的@Cacheable只要key相同就会互相覆盖。加上前缀之后,Redis里能清楚看到cache:user:123这样的结构,排查问题时一眼就能定位是哪个缓存。
entryTtl是默认过期时间,我这里设了30分钟。注意@Cacheable里的unless或condition可以控制哪些结果不缓存,实际使用中如果缓存了null,建议配置preventCacheNull或通过unless排除空值,不然Redis里会存下一堆没意义的空缓存。早期版本里cacheDefault没有针对Null值做处理,需要自己写一个空值包装类,新版Spring Data Redis则直接支持配置。
3. 功能实现:从字符串到Lua脚本都能写
3.1 基础类型操作:String、Hash、List、Set、ZSet
很多人以为Redis就是"缓存",实际上它五种基础数据类型每个都对应一类典型业务场景。我平时带项目时喜欢按下面的例子来讲解,新手也能很快理解。
String类型最常用的场景是缓存计数器。比如文章阅读数、验证码、登录token。用opsForValue()操作:
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue(); ops.set("article:read:1001", "0"); Long count = ops.increment("article:read:1001");Hash类型适合存对象字段。比如用户信息、商品信息,用opsForHash(),可以单独修改某个字段,不用把整个对象序列化进Redis:
HashOperations<String, Object, Object> hashOps = stringRedisTemplate.opsForHash(); hashOps.put("user:1001", "name", "张三"); hashOps.put("user:1001", "age", "25"); String name = (String) hashOps.get("user:1001", "name");List类型可以当轻量队列使用。leftPush加消息,rightPop消费消息,或者用BRPOP阻塞读取。我们常用它做简单的异步任务队列,虽然比不上专业消息队列,但数据量不大时足够用。
Set类型适合做去重和社区关系。比如用SADD记录用户关注的人,SINTER求两个用户共同关注的人:
SetOperations<String, String> setOps = stringRedisTemplate.opsForSet(); setOps.add("user:1:follows", "user:2", "user:3"); setOps.add("user:2:follows", "user:2", "user:3", "user:4"); Set<String> common = setOps.intersect("user:1:follows", "user:2:follows");ZSet类型则是排行榜和延迟队列的基础。它按照score排序,可以做TopN、分数累计这类需求。代码示例在后面的实战案例里会详细展开。
3.2 缓存过期策略与延时双删
设置过期时间是缓存设计里最容易出错的一环。直接给一个很长的过期时间,会让缓存数据长期跟数据库不一致;过期时间太短,又起不到缓存的缓存效果。我一般遵循一个原则:热点数据过期时间在10到30分钟之间,但会在每天的固定时间点前让缓存主动失效,保证用户能看到最新数据。
Redis里的过期策略分为主动过期和惰性过期。主动过期由一个后台线程每秒抽查一部分过期key进行删除;惰性过期是当某个key被访问时才检查是否过期。两者结合能保证过期key最终被清理,但客户端是无法感知后台清理过程的。所以对于需要精准失效的缓存,最好在写操作时主动删除对应key,而不是只依赖过期时间。
数据库更新和Redis缓存更新的顺序,网上一堆说法,真正靠谱的其实是延时双删。流程是:先删除Redis缓存,再更新数据库,然后过一小段时间再次删除缓存。这个“延时”通常50到100毫秒。它的目的是处理并发下先读到了旧数据、随后写库导致缓存不一致的问题。注意,双删不是银弹,如果主从同步延迟超过这个时间窗口,还是可能读到旧数据。所以重要业务一般还要配合版本号或消息通知机制做最终一致。
3.3 事务与管道:批量操作的正确姿势
Redis事务和关系型数据库事务完全是两回事。Redis的事务用MULTI、EXEC包裹一批命令,所有命令会按顺序执行,但在EXEC之前如果语法出错,整个事务会取消;如果命令执行时出现运行时错误,前面的成功命令并不会回滚。也就是说Redis事务保证的是“一条条顺序执行”和“不被其他客户端命令插入”,不是ACID。
Spring Data Redis里使用事务有两种思路。第一种是用SessionCallback把操作包起来,事务边界清晰:
List<Object> results = stringRedisTemplate.execute(new SessionCallback<List<Object>>() { @Override public List<Object> execute(@Nullable RedisOperations operations) throws DataAccessException { operations.multi(); operations.opsForValue().set("key1", "value1"); operations.opsForValue().set("key2", "value2"); return operations.exec(); } });第二种是直接启用setEnableTransactionSupport(true),然后配合Spring的@Transactional注解,把Redis事务挂到Spring事务里。注意这样做代价比较大,因为连接会被事务绑定,如果方法执行时间长,连接池里可用连接会减少。
管道则是另一类优化。executePipelined把多条命令打包一次网络发送给Redis,减少网络往返时间。我经常在批量读多个key时用管道,比如一次性查100个用户的名字,循环hget要发100次请求,用管道只需要一次。管道和事务不同,管道只是减少RTT,命令并不具备原子性,中间如果某条命令失败,其他命令照常执行。所以能用管道优化批量读,但别把业务原子的希望寄托在管道上。
3.4 Lua脚本:让复杂逻辑在Redis端原子执行
当你要保证多条Redis命令作为一个整体不被其他请求穿插,Lua脚本是天然直接的方式。Redis从2.6版本开始支持Lua脚本,整个脚本在Redis服务端执行,执行期间其他客户端操作会被阻塞,所以脚本本身要尽量短小。
一个非常经典的例子是分布式锁的释放。获取锁时为了防止死锁,会设置一个唯一value和过期时间;释放锁时不能简单地del,否则可能把别人刚获取的锁删掉。必须先用get判断value是否相同,再执行del。这两步不是原子的,就有了用Lua脚本的必要:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end在Spring Boot里执行Lua脚本:
DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"); script.setResultType(Long.class); Long result = stringRedisTemplate.execute(script, Collections.singletonList(lockKey), requestId);这里有个细节:脚本返回类型必须和真实返回一致。如果你把del返回的整数当成Long接收没问题,但如果脚本是返回字符串,结果类型设成Long就会反序列化报错。
Lua脚本还能实现秒杀库存扣减、限流等需求。一个简单的限流脚本可以统计窗口内的请求数,超过阈值直接拒绝,省得你为了计数逻辑多次调Redis。只要能想清楚业务条件,Lua几乎都能在Redis端搞定。但也要提醒一句,脚本里如果出现大循环或慢命令,会阻塞Redis实例,后果很严重,所以脚本里尽量避免KEYS *这种遍历操作。
4. 实战案例一:热门文章列表的缓存击穿与降级
4.1 场景拆解与整体设计
先来看一个最常见的实战场景:首页热门文章列表。文章表几十万条,用户一进首页就要查“近7天阅读量最高的50篇文章”,这个查询如果直接打到数据库,光是排序和聚合就够数据库喝一壶。最简单的方案是把热门文章ID列表缓存到Redis里,key为hot:article:list,value是JSON数组。
请求链路设计是:先查Redis,命中则直接返回;未命中则查数据库,把结果回填到Redis并设置过期时间。这个思路很简单,但高并发下藏着三个典型问题:缓存穿透、缓存击穿、缓存雪崩。
缓存穿透是指请求了一个缓存和数据库里都不存在的数据,比如一个不存在的文章ID,每次都会被直接打向数据库。缓存击穿是指某个热点key过期瞬间,大量请求同时穿透,导致数据库压力瞬间放大。缓存雪崩则是指大量缓存key在同一时间过期,或者Redis服务整体不可用,流量全部压到数据库。
针对这些问题的整体设计,我推荐的策略是:对查询结果为空的情况也缓存一个短时间空值;对热点key的过期时间加随机偏移;当缓存重建时用互斥锁,只让一个请求去回源数据库。下面代码会把这套方案完整落下来。
4.2 缓存构建流程与代码实现
假设我们有一个HotArticleService,专门提供热门文章列表查询。先定义RedisKey,建议把常量维护在一个地方:
public class RedisKey { public static final String HOT_ARTICLE_KEY = "hot:article:list"; public static final String HOT_ARTICLE_LOCK_KEY = "hot:article:lock"; }核心查询方法:
public List<ArticleVO> getHotArticles() { // 先读缓存 String cache = stringRedisTemplate.opsForValue().get(RedisKey.HOT_ARTICLE_KEY); if (StrUtil.isNotEmpty(cache)) { return JSONUtil.toList(cache, ArticleVO.class); } // 未命中,尝试获取互斥锁 String lockValue = UUID.randomUUID().toString(); Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(RedisKey.HOT_ARTICLE_LOCK_KEY, lockValue, Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { // 双重检查,避免拿到锁的线程再次查一次缓存 String secondCache = stringRedisTemplate.opsForValue().get(RedisKey.HOT_ARTICLE_KEY); if (StrUtil.isNotEmpty(secondCache)) { return JSONUtil.toList(secondCache, ArticleVO.class); } List<ArticleVO> list = articleMapper.selectHotList(50); // 缓存空结果,防止穿透 String cacheValue = CollUtil.isEmpty(list) ? "" : JSONUtil.toJsonStr(list); // 随机过期时间,避免雪崩 int expire = 600 + RandomUtil.randomInt(120); stringRedisTemplate.opsForValue().set(RedisKey.HOT_ARTICLE_KEY, cacheValue, Duration.ofSeconds(expire)); return list; } finally { // 释放锁 releaseLock(RedisKey.HOT_ARTICLE_LOCK_KEY, lockValue); } } else { // 获取锁失败,短暂等待后递归或重试 ThreadUtil.sleep(50); return getHotArticles(); } }这里面几个细节值得讲透。setIfAbsent可以同时做到“不存在时才设置”和“设置过期时间”,这替代了老代码里setnx加expire两步操作,两步操作之间一旦崩溃就会留下永不过期的锁,所以一定要用带过期时间的原子命令。
二次检查很关键。假设线程A先拿到锁,还没来得及回填缓存,此时线程B查缓存还是空的,获取锁失败进入等待。A回填完成后,B再醒来时如果直接查数据库,就白等了。所以拿到锁之后要先再查一次Redis,命中就直接返回。这会减少不必要的数据库查询。
锁的value用了UUID,这样释放锁时通过Lua脚本判断value是否匹配,防止把别人后来获取的锁误删。很多初版实现会直接del这个锁key,一旦锁过期了,线程A还没执行完,线程B拿到锁并写入新value,线程A结束后的del会把B的锁删除,造成并发安全问题。
4.3 缓存击穿、穿透、雪崩的应对细节
上面代码已经覆盖了三个问题的基本防御,我再把应对细节单独总结一下,方便以后按场景排查。
| 问题 | 出现场景 | 解决方案 |
|---|---|---|
| 缓存穿透 | 查询不存在的key | 缓存空值、布隆过滤器、参数校验 |
| 缓存击穿 | 热点key过期瞬间并发访问 | 互斥锁、热点key永不过期、分级缓存 |
| 缓存雪崩 | 大量key同时过期或Redis宕机 | 随机过期时间、多级缓存、熔断降级 |
互斥锁方案中,锁过期时间一定要比业务执行时间长。比如回填数据库需要500ms,锁过期时间至少设3秒,否则锁提前失效,别的线程又会进来。但锁过期时间也不宜太长,太长会让其他请求等待很久。如果业务确实执行过慢,建议加上“延长锁时间”的看门狗逻辑,或者干脆换Redisson这类自带看门狗的实现。
空值缓存虽然能挡住穿透,但会给Redis带来大量空key,被恶意攻击时Redis内存可能被打满。更优雅的方案是布隆过滤器。简单讲,它用少量内存判断一个key“肯定不存在”还是“可能存在”。Spring项目里可以用Redis的bitmap自己实现一个简易布隆过滤器,把现有文章ID都映射进bitmap,查询前先看bitmap,如果判断不存在就直接挡在Redis外,避免打数据库。
对于雪崩,除了随机过期时间,我还习惯在服务里加一层本地缓存作为第二级兜底。也就是先用本地缓存扛住部分流量,再访问Redis,最后走数据库。这样即使Redis实例整体不可用,本地缓存还能顶几秒,给熔断和恢复留出时间。但要注意本地缓存一致性更难保证,只适合“可以容忍短暂不一致”的热点数据。
5. 实战案例二:基于Redis的分布式锁与库存扣减
5.1 为什么单机锁到分布式环境就失灵
单机部署的Spring Boot应用里,我们经常用synchronized或者ReentrantLock去解决并发问题。但应用一旦部署成多实例,流量通过负载均衡分发到不同机器,每个JVM里的锁互相不可见。A机器的线程锁住了库存扣减方法,B机器的线程照样还能进来,这就是典型的多实例并发安全问题。
分布式锁要求的是“所有实例共享一个互斥机制”。Redis天然适合做这件事,因为所有实例连接的是同一套Redis。但它实现起来也需要考虑细节:锁必须是原子性的设置key和过期时间;锁必须设置过期时间,防止持有锁的实例宕机后锁永远不释放;锁的value要唯一,释放锁时要校验持有者。
如果直接用setnx expire两步执行,就有第二步失败导致锁永不释放的风险。Spring Boot新版本里的setIfAbsent(key, value, timeout)可以一步搞定。下面是一个最基本的加锁代码:
String requestId = UUID.randomUUID().toString(); Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent("product:1001:lock", requestId, Duration.ofSeconds(30)); if (Boolean.TRUE.equals(locked)) { try { // 执行业务 } finally { // 释放锁,使用Lua脚本 releaseLock("product:1001:lock", requestId); } }但手写方案有个麻烦:如果业务执行时间超过锁的30秒,锁已自动释放,第二个线程进来,此时第一个线程可能还没结束。要解决这个“锁续期”问题,要么自己起个定时任务延长锁,要么直接用现成框架。
5.2 Redisson方案还是手写Lua锁
手写Lua锁的好处是轻量、无额外依赖,适合简单场景。坏处是功能不完善,尤其锁自动续期和可重入都很难做好。我实际生产项目里更推荐Redisson,它提供了完整的分布式锁实现,并且内置看门狗自动续期,还支持可重入锁、公平锁、读写锁等。
引入Redisson很简单,在Spring Boot项目里用redisson-spring-boot-starter,然后配置连接信息。使用:
@Autowired private RedissonClient redissonClient; public void doSomething() { RLock lock = redissonClient.getLock("product:1001:lock"); try { // 尝试加锁,最多等待5秒,锁自动过期30秒,看门狗会持续续期 if (lock.tryLock(5, 30, TimeUnit.SECONDS)) { // 业务逻辑 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }tryLock(5, 30, TimeUnit.SECONDS)的含义是:最多等5秒拿锁,拿到后锁至少持有30秒,但Redisson的看门狗会在锁剩余时间快到时自动续期。这个机制帮我们解决了我之前说的“业务没跑完锁就过期”问题,非常省心。
当然Redisson也不是银弹。如果Redis发生主从切换,旧的锁记录可能丢失,导致另一个实例也能拿到锁。严格安全的方案要用红锁,但红锁要求搭建多个Redis主节点,性能损失不小,大部分互联网业务并不需要这么高的一致性。我们通常用“锁过期时间短一点 + 数据库兜底”的方式,把问题概率降到可接受范围。
5.3 库存扣减的完整实现与测试
库存扣减是分布式锁的经典场景。最简单粗暴的做法是用分布式锁包住整个“查库存、扣库存、写订单”流程,保证同一时刻只有一个请求扣减成功。但这样会把数据库查询放在锁里面,性能很差。更常见的方式是先用Redis做一个原子扣减,只有扣减成功时才允许执行后续下单流程。
我推荐用Lua脚本实现原子扣减,既避免了锁对象长期持有,也保证了判断库存和扣减两步的原子性。脚本大概这样:
local stock = tonumber(redis.call('get', KEYS[1]) or '0') if stock < tonumber(ARGV[1]) then return -1 else redis.call('decrby', KEYS[1], ARGV[1]) return 1 end调用时:
DefaultRedisScript<Long> stockScript = new DefaultRedisScript<>(scriptText, Long.class); Long result = stringRedisTemplate.execute(stockScript, Collections.singletonList("stock:product:1001"), "5"); if (result == 1L) { // 扣减成功,异步生成订单 }这里要注意,库存的初始值要在秒杀开始前提前放入Redis,并且要定期和数据库库存做对账。Redis扣减成功但后续订单创建失败时,要把库存回滚回去,否则会出现超卖或库存丢失。所以真实项目里一般还会加一个补偿任务,定时把Redis库存同步回数据库,或者用消息队列保存扣减流水,失败后重新恢复额度。
测试时可以用并发工具,比如Jmeter或代码里用CountDownLatch模拟100个线程同时抢购。重点观察三点:最终库存是否等于初始库存减去成功订单数;是否出现超卖;Redis慢日志里有没有执行时间很长的Lua脚本。我实测下来,纯Redis扣减在并发1000时吞吐量很高,瓶颈更多出现在后续数据库写入环节,所以下单环节一定要异步化。
6. 实战案例三:排行榜、附近的人与轻量消息队列
6.1 用ZSet实现排行榜的增量更新
排行榜是Redis ZSet的典型应用。假设我们要做一个“用户积分排行榜”,积分的实时变化通过ZINCRBY更新,这样不需要先读后写,天然原子:
stringRedisTemplate.opsForZSet().incrementScore("rank:total", "user:1001", 10);查询总榜TopN:
Set<ZSetOperations.TypedTuple<String>> result = stringRedisTemplate.opsForZSet().reverseRangeWithScores("rank:total", 0, 9);遍历TypedTuple可以拿到用户ID和对应score。但如果积分相同,ZSet的排序是按照member字典序排的,这会让某些用户长期排在前面,对活动榜单不公平。解决办法是构造一个组合score:把积分放大到一个足够大的数,再减去一个基于时间的时间戳偏移。比如score = 积分 * 1_000_000 + (1_000_000 - 当前时间戳秒数),这样积分相同的人,越早达到这个积分的人越靠前。这里有个细节,每个业务场景的分数范围不同,如果积分可能超过几万,乘数要相应放大,不然会出现溢出。
排行榜数据建议做两层:实时榜单直接查Redis,每隔几分钟落一份快照到关系型数据库,用于离线分析和故障恢复。如果Redis数据因异常丢失,至少能从快照恢复大部分数据。
6.2 GEO类型实现附近的人
Redis 3.2之后新增了GEO类型,底层用ZSet实现,但API更加语义化。比如我们要实现"附近的门店":
stringRedisTemplate.opsForGeo().add("store:geo", new Point(116.397128, 39.916527), "store:1001");查询某个坐标周围5公里内的门店:
Distance distance = new Distance(5, RedisGeoCommands.DistanceUnit.KILOMETERS); Circle circle = new Circle(new Point(116.397128, 39.916527), distance); RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs .newGeoRadiusArgs().includeCoordinates().includeDistance().sortAscending(); GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().radius("store:geo", circle, args);遍历结果时可以直接拿到门店ID、经纬度、离中心点的距离。GEO类型的性能在小几千个元素以内表现不错,但如果门店数量到了百万级,这种实时计算就可能成为瓶颈,届时要考虑用空间索引或分桶方案,而不是把所有门店都塞进一个GEO key。
另外,GEO数据在Redis里存储的是经纬度转成的一个52位整数,精度大概是厘米级,足够做附近的人或门店场景。不要用力过猛,非要把地址解析成精确到毫米的坐标,没有意义。
6.3 用List或Stream实现轻量消息队列
一些初学者会用Redis的List实现消息队列,LPUSH生产,BRPOP消费。这在低峰业务里够用,但存在两个问题:第一,消息没有消费确认机制,消费端如果中途崩溃,消息会丢失;第二,消息不支持重复消费语义,消费者组、消息回放这些能力都没有。
如果你的项目只是做简单异步解耦,List还是一次极其轻量的选择。示例:
// 生产者 stringRedisTemplate.opsForList().leftPush("task:queue", taskJson); // 消费者,阻塞读取 String taskJson = stringRedisTemplate.opsForList().rightPop("task:queue", 5, TimeUnit.SECONDS);使用BRPOP时,考虑多个消费者竞争同一个队列,Redis会保证每个元素只被一个消费者取到,算是负载均衡。但如果你需要不丢消息,必须在取到消息后先备份到另一个list或数据库,处理成功后再删除备份。
Redis 5.0引入的Stream类型比List更接近真实消息队列,支持消费者组、消息ACK、消费进度管理。核心操作:
// 生产 stringRedisTemplate.opsForStream().add( ObjectRecord.create("stream:order", orderJson), "stream:order"); // 消费 List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().read( Consumer.from("group1", "consumer1"), StreamReadOptions.empty().count(10).block(Duration.ofSeconds(5)), StreamOffset.create("stream:order", ReadOffset.lastConsumed()) );Stream的消费者组能保证组内每个消费者分到的消息不同,消费成功后需要ack。如果消息处理失败,可以让它进入pending列表,后续重新拉取。相比List,可靠性高了不少。但Redis Stream毕竟不是专业消息队列,消息堆积时内存压力大,也没有磁盘日志的存储机制优势。如果消息量大、要求强顺序性和超长堆积,还是要用独立的消息队列系统。
7. 常见问题与排查技巧实录
7.1 RedisTemplate注入后还连不上,先查这5个地方
我自己排查过无数次“连不上Redis”的问题,把最常见原因列成清单,按顺序查能省很多时间。
第一,配置前缀。Spring Boot 2.x用spring.redis,3.x用spring.data.redis,混用会导致配置完全没生效。第二,依赖冲突。项目里同时引入Jedis和Lettuce时,可能导致连接工厂变成不可预知的实现,检查dependency:tree后排除多余依赖。第三,网络不通。本地连不上先确认Redis是否绑定了外网地址,生产环境如果用云主机,还需要检查安全组是否放行了6379端口。第四,密码问题。密码两端有多余空格、Redis配置里密码大小写写错,都会报NOAUTH Authentication required。第五,Redis启动时报错。比如内存不足或者AOF文件损坏会导致启动失败,但日志被隐藏了,可以去Redis日志目录看具体内容。
如果代码里用了@Autowired注入RedisTemplate,但服务一直报“找不到RedisConnectionFactory”的异常,那大概率不是连接问题,而是容器里缺少Bean定义。检查你的启动类有没有@SpringBootApplication扫描到配置类,或者是不是你手动排除了RedisAutoConfiguration。
7.2 中文乱码与序列化异常,都是序列化器的锅
“Redis里存了一堆\xAC\xED开头的乱码”,看到这个现象就知道是JDK序列化在起作用。解决办法前面已经说过,自定义RedisTemplate,把key和value序列化器都换成String或JSON。需要注意的是,如果Redis里已经写入了JDK序列化的数据,你想通过改序列化器让它变成可读文本,旧数据是不会自动转换的,只能清掉或者迁移。
Jackson反序列化时最容易遇到两个异常。一个是“InvalidDefinitionException:cannot construct instance ofxxx”,通常是实体类缺少无参构造函数,或者是内部类没有加static修饰。另一个是“Java 8 date/time type not supported”,需要注册JavaTimeModule。还有一次我遇到反序列化时报ClassCastException,原因是同一个类加载器不同,虽然项目里看起来只有一个类,其实包里打包了两份,排查到最后发现是依赖冲突。所以序列化问题不只是格式问题,还可能是依赖冲突。
7.3 热key与大key排查:慢日志和scan实战
Redis变慢不一定连接问题,更常见是有热key或大key拖累了单线程模型。热key是指某个key被超高频率访问,导致Redis单线程一直在处理它,其他命令排队;大key是指某个key的value体积特别大,读写它本身就很慢。
排查工具优先用redis-cli --bigkeys,它会对所有大key做统计,但会遍历整个Redis,生产环境建议在低峰期执行,并加上--i 100限制扫描速度。改命令输出里会告诉你不同类型里最大的key分别是什么。
慢日志查询用:
redis-cli slowlog get 10每个慢日志能看到耗时、命令和参数。出现慢命令后,优先考虑能否拆分Redis命令,或者把value里的数据转移到哈希里。比如一个list里有几十万条消息,每次操作都可能阻塞Redis,应该拆分到多个key里去,或者换成Stream。
热key的发现可以在客户端侧监控,也可以用Redis的monitor命令临时观察,但monitor会降低性能,只能短时间开。应对热key的方式一般是加一层本地缓存,或者在客户端把热key复制成多个带后缀的key分散压力,因为Redis单线程处理,即使复制成多个key,底层仍然是同一个实例,实际上对CPU没有帮助,真正有用的还是多实例或本地缓存。
7.4 性能调优:你以为慢在Redis,其实慢在代码
很多性能问题的根因不在Redis本身,而在代码里怎么用它。最典型的是循环里访问Redis。我曾经见过一个接口循环查询商品列表,每个商品都调用一次opsForValue().get,50个商品就是50次网络往返,接口耗时直接500毫秒。改成管道或批量mget之后,耗时降到20毫秒以下。
另一个常见问题是缓存穿透导致数据库压力暴增。排查时看Redis监控的命中率,如果命中率低于80%,就要回头看看是不是空值缓存没做,或者缓存过期时间设置太短。还有一次,同事把一个大JSON对象塞进Redis,value有3MB,每次读都需要反序列化,接口越改越慢。后来把这个大JSON拆成多个Hash字段,只按需要读取对应字段,问题才解决。
连接池耗尽也会表现为“接口偶尔卡顿”,但Redis本身CPU不高。这时看应用的连接池监控,如果max-active经常被打满,要么调高连接数,要么优化代码减少连接占用。我遇到过连接泄漏的案例,某个地方用RedisTemplate后异常分支没有释放连接,导致连接池逐渐被耗尽,流量一大就报“Could not get a resource from the pool”。后来排查异常路径,把所有finally补充完整,连接数才恢复正常。
最后再分享一个小技巧。生产环境上线前,我会用一个简单的压测脚本先打一下Redis最常见的读操作,记录基准延迟。上线后如果出现延迟波动,就能对比基准数据,快速定位是Redis本身问题还是业务流量模型变化。这个基准数据相当重要,别等出问题才开始分析。
我自己的习惯是把Redis相关的代码尽量收口到一层独立的cache service里,业务层不直接碰RedisTemplate。这层CacheService可以统一处理序列化、缓存穿透、过期时间、降级策略。真遇到线上问题,排查范围会大大缩小。Spring Boot整合Redis并不难,真正考验人的是后面的数据一致性、性能调优和故障排查能力。希望这篇整理下来的套路,能让你少走一些弯路。