刚接手的项目中,缓存这一环怎么都不顺。开发环境跑得好好的Spring Boot项目,一到生产环境就冒出各种乱象:Redis里的key全是\xAC\xED\x00开头、压测时缓存穿透把数据库拖垮、分布式锁偶尔失效导致超卖……最后逐层排查才发现,问题不在Redis本身,而是项目里用Redis的方式从一开始就埋了雷。这篇就把Spring Boot使用Redis过程中真正值得关注的细节整理出来,从连接到序列化,从分布式锁到缓存三大经典问题,再到数据类型建模和线上运维排查,一次说透。适合刚把Redis引入项目的初学者,也适合已经在用但总感觉不踏实的开发者。
1. 先用一次线上事故说清:Redis不是只会"存个缓存"
1.1 一个缓存半小时失效引发的连环问题
有次活动上线,运营反馈用户领券一直报"稍后再试"。排查时发现,优惠券模板的列表接口在Redis里缓存了半个小时,活动开始后运营改了部分券的库存和状态,缓存没及时失效,用户端拿到的还是旧数据。当时业务代码里用的是"先更新数据库,再删除缓存",但因为某些缓存key没有统一管理,删除操作漏了几个,就造成了脏读。
这个案例其实暴露了三个层面的问题:缓存key的管理没有一个统一收口的地方;缓存过期时间设置随意,不同业务混用同一个Redis;更新数据库和缓存之间的一致性没有形成固定规范。很多人以为Redis用得不好是性能问题,实际上大多数事故都是"用错了方式"导致的逻辑问题。
1.2 Redis在Spring Boot项目里的正确定位
先把定位理清楚。在典型的Spring Boot应用里,Redis通常承担四类职责:
- 缓存:热点数据、配置信息、会话共享,利用内存读写速度扛住高并发;
- 分布式锁:多实例部署时,保证某个操作同一时刻只有一个实例在执行;
- 数据结构存储:排行榜、关注关系、去重、消息队列轻量替代等;
- 计数器/限流:借助INCR/DECR的原子性做访问计数或滑动窗口限流。
这四类职责对Redis的要求不一样。缓存需要关注序列化、过期策略和一致性;分布式锁需要关注原子性、续期和释放安全;数据结构存储需要关注类型选择和API设计。所以,在Spring Boot里引入Redis之前,先搞清楚这个项目到底要用它的哪一面,比直接用默认配置重要得多。
2. Spring Boot连接Redis:版本兼容和配置是第一道门槛
2.1 依赖选择与Spring Boot版本对应关系
Spring Boot整合Redis主要依赖spring-boot-starter-data-redis,底层默认用的是Lettuce连接池框架。这里最常见的坑是版本不匹配。Spring Boot 2.4以前和2.4以后,针对Redis的配置项命名有变化;到了Spring Boot 3.0,整个模块切换到了Jakarta EE规范,如果还在用旧版的Jedis或者自定义的Redis配置,很容易出现类找不到或者配置不生效。
我的建议是,Spring Boot 2.x项目中直接使用spring-boot-starter-data-redis,版本跟随Spring Boot父工程管理即可,不要单独指定redis客户端的版本。Spring Boot 3.x也是同理。如果你需要自定义连接池参数,注意看当前Spring Boot版本的配置前缀:
spring: data: redis: host: localhost port: 6379 password: yourpassword database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 2s这里有个很多人会踩的坑:Spring Boot 2.4以后,redis配置前缀从spring.redis.*改成了spring.data.redis.*。如果你从老版本升级上来的项目还在用旧前缀,配置会静默失效,连接的是默认localhost:6379,到时候线上连不上Redis还找不到原因。
2.2 Lettuce和Jedis怎么选
现在Spring Boot默认用Lettuce是有原因的。Jedis是阻塞式IO,多线程环境下需要靠连接池保证线程安全;Lettuce基于Netty,连接实例是线程安全的,多个线程可以共享同一个连接,省掉了大量连接创建和池化管理的开销。对大多数业务系统来说,Lettuce的性能表现都更好。
不过Lettuce也有小毛病。在Spring Boot 2.1之前的某些版本里,Lettuce的ShareNativeConnection默认开启,如果某个操作长时间阻塞,会影响其他请求。遇到这种情况,可以显式配置连接池参数,或者升级Spring Boot版本解决。另外,如果业务里有大批量的pipeline操作,Lettuce的默认配置不一定是最优的,需要自己压测调整。
2.3 连接不上Redis的常见排查路径
开发时最常用的排查路径,按顺序来基本能覆盖大部分问题:
- 先确认Redis进程有没有起来。命令行执行
redis-cli ping,返回PONG说明服务在运行; - 没有PONG,看下Redis配置文件里的
daemonize yes是否设置、日志文件里有没有启动报错。Windows下不少人用解压版Redis,双击redis-server.exe没反应的时候,看一眼端口是否被占用; - 进程正常但应用连不上,用
redis-cli -h 127.0.0.1 -p 6379 -a 密码手动连接。这是最直接区分"网络不通"还是"认证失败"的方式; - 网络通、认证通过还是连不上,检查Spring Boot配置的database索引。Redis默认有16个库,很多项目会混用db0到db15,如果把不同环境的配置指向了不存在的库,日志里会报错但不够直观。
黑马点评这个项目里经常有人问连接不上Redis的报错,多数都是同一个问题:Linux服务器上Redis绑定了127.0.0.1,而Spring Boot通过服务器公网IP去连,自然连不上。需要把配置文件里的bind改为0.0.0.0并设置protected-mode no,或者使用SSH隧道连接,但不建议在生产环境开放无保护模式。
3. 序列化方案不能默认:RedisTemplate配置一次到位
3.1 默认序列化器的"乱码"问题
Spring Data Redis默认使用JdkSerializationRedisSerializer,这个方案的坑用过的都知道。往Redis里存一个User对象,再通过可视化客户端看一眼,key和value全是\\xAC\\xED\\x00\\x05t\\x00开头的乱码,根本看不出业务含义。跨语言、跨平台读取数据更麻烦,还因为序列化后体积膨胀,白白占用内存。
更隐蔽的问题是,默认的序列化方式在处理某些类型时会直接报序列化异常。比如不小心把一个Optional空值对象放进了缓存,JDK序列化直接抛SerializationException。所以很多项目实际使用中都会自定义RedisTemplate的序列化器。
3.2 一套稳妥的序列化配置方案
我推荐一套组合,经过多个项目验证,稳定且可读性好:
- key:使用
StringRedisSerializer,保证key在命令行、可视化工具里清晰可读; - value:使用
GenericJackson2JsonRedisSerializer,会把Java对象的类型信息作为@class字段保存到JSON头里,读取时能自动反序列化成原始类型; - hash的key和value:同样分别使用
StringRedisSerializer和GenericJackson2JsonRedisSerializer。
配置代码如下:
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashKeySerializer(stringSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }用GenericJackson2JsonRedisSerializer有个小注意点:它序列化出的结果带了类型信息,所以反序列化时能还原成对象。但如果你的value只是普通字符串,直接用StringRedisSerializer会更轻量,省掉JSON解析开销。
3.3 可视化客户端里看到的内容,才是真实内容
配置好序列化器后,用可视化客户端确认一下数据形态是个好习惯。常见的客户端工具,属于个人比较常用的:RedisInsight,跨平台且官方维护;Another Redis Desktop Manager也不错,轻量。Windows开发机上随便选一个都行,重点是看存储的key和value是不是可读的。
一个值得记住的排查技巧:如果某个key始终删不掉或者总显示二进制乱码,打开可视化客户端的redis-cli控制台,执行keys *命令看原始输出。如果显示\xac\xed开头,说明这个key是旧的Jdk序列化器写入的,重新配置序列化器并不会自动清理旧数据,需要在业务代码里做一次迁移或过期清理。线上踩过这种坑的人应该不少——配置改了,老数据还留在Redis里占空间,直到内存告警才发现。
4. 分布式锁:SETNX只是开始,坑在后面
4.1 为什么单机锁在多实例下一定出问题
单体应用里用synchronized或者ReentrantLock没问题,但Spring Boot服务在线上通常不止一实例,Nginx前面挂了四个应用节点,一个秒杀请求被负载均衡分发到四个不同的JVM,每个JVM里各有一把JVM锁,等于四把锁管同一批库存,超卖就在所难免。
分布式锁的思路,简单说就是用一台大家都能访问到的独立组件(比如Redis)来"占位"。谁在Redis里成功写入标记,谁就获得执行权限,执行完删除标记释放。
4.2 一个原子性问题引发的线上事故
最常见的错误写法:
if (redisTemplate.opsForValue().setIfAbsent(key, value)) { // 执行业务逻辑 redisTemplate.delete(key); }这段代码的问题在于setIfAbsent没有设置过期时间。如果拿到锁之后,应用在执行业务过程中突然宕机,锁永远不释放,其他线程全部阻塞等待。有人会用两步来解决:先setIfAbsent,再expire单独设置过期时间。但这两个操作不是原子的,万一第一步成功、第二步失败,锁依然得不到释放。
正确做法是使用set命令的原子扩展参数:
Boolean locked = redisTemplate.opsForValue() .setIfAbsent(key, value, Duration.ofSeconds(30));setnx、expire、set扩展参数这三者的区别值得记住:setnx原本不支持设置过期,老代码里很多人搭配expire用,出问题的概率极大。Spring Data Redis里setIfAbsent(K key, V value, Duration timeout)本身就对应Redis的SET key value NX EX timeout,原子完成加锁和设置过期时间。
4.3 解锁要用Lua脚本,别用"先查询再删除"
解锁阶段的坑同样隐蔽。解锁逻辑不能是"先get判断是不是自己的值,再delete",因为get和delete之间是有间隙的,可能出现:线程A的锁过期自动释放了,线程B获得新锁,然后线程A的delete把线程B的锁删掉。这就是经典的"删了别人的锁"问题。
解决方法是结合Lua脚本,在Redis服务端原子地执行"如果值匹配才删除":
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end这段Lua脚本可以放在类路径下,用DefaultRedisScript读取,也可以直接用字符串构造。核心在于判断和删除必须是原子操作。用RedisTemplate执行脚本时,需要注意返回类型的声明:
DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText("...lua..."); script.setResultType(Long.class); Long result = redisTemplate.execute(script, Arrays.asList(key), value);4.4 锁续期和锁粒度:Redisson与两个实战建议
如果业务执行时间可能超过锁的过期时间,最简单的方案是使用Redisson。Redisson的lock自带看门狗机制,默认30秒过期,每隔10秒自动续期,只要进程活着锁就不会提前过期。Redisson还封装了公平锁、读写锁,接入成本很低:
@Autowired private RedissonClient redissonClient; RLock lock = redissonClient.getLock("order:" + orderId); if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.unlock(); } }锁粒度也是一个容易被忽视的点。锁的key设得太粗,比如所有订单都锁一把lock:order,同一时刻只有一个订单能处理,QPS直接被砍到1;锁的key设得太细,比如每个订单都有自己的锁,那和没加锁差不多。合理的做法是按业务操作的最小冲突范围来设计key,比如"同一个用户的领券操作"用lock:coupon:userId,"同一商品的下单操作"用lock:stock:productId。
5. 缓存穿透、击穿、雪崩:三个容易混的问题逐个拆
5.1 穿透:查询不存在的数据,布隆过滤器和空值缓存
缓存穿透指的是大量请求直接打到数据库,而数据库中压根不存在这条记录。比如用户查询一个不存在的商品ID,缓存没有,数据库也没有,每次请求都穿透到MySQL。攻击者可以伪造不存在的ID持续刷新,数据库压力暴涨。
两种解决办法:
- 空值缓存:对查询结果为空的数据,也往Redis里写一个空值或特殊标记,设置一个较短的过期时间(比如5分钟)。后续同样的请求直接命中空值,不再打数据库;
- 布隆过滤器:建立一个布隆过滤器,把所有可能存在的数据ID放进去。请求进来先经过过滤器判断,过滤器认为不存在的直接拦截。布隆过滤器的缺点是存在误判率,而且数据集合变化时需要同步更新。
我的项目里常用组合拳:对确定性不存在的非法请求用布隆过滤器挡,对正常业务中偶发的不存在记录用空值缓存兜底。布隆过滤器的实现可以直接用Redisson的RBloomFilter,不必自己实现位图逻辑。
5.2 击穿:热点key过期,互斥锁与逻辑过期怎么选
缓存击穿指一个非常热点的key在过期瞬间,大量请求同时涌入,缓存重建还没完成,所有请求直接打穿到数据库。和高并发下数据库连接池被打满的问题常在一起出现。
两个经典方案:
- 互斥锁:当缓存不存在或过期时,让部分线程去重建缓存,其他线程等待。实现上可以用
RedisTemplate的setIfAbsent加锁,也可以直接用Redisson的锁。问题在于加锁会影响吞吐,热点key过期那一刻会有少量线程阻塞等待; - 逻辑过期:缓存里value存的是业务数据+过期时间戳,例如
{"data": "{...}", "expire": 1700000000000}。每次读取时判断逻辑时间是否过期,如果过期,获取锁后由单线程异步重建缓存,其他线程返回旧数据。这个方案牺牲了短暂的一致性,但避免了热点key过期时的阻塞,适合读多写少的场景。
两种方案没有绝对优劣。数据一致性要求高就选互斥锁,对读取延迟敏感就选逻辑过期。我在项目中遇到过热点活动的商品详情页,用的就是逻辑过期方案。注意一点,不管用哪种方案,重建缓存时一定要做一步"回源数据库并确保数据完整写入Redis",避免重建过程中数据缺失。
5.3 雪崩:大量key同时过期,或Redis整体宕机
缓存雪崩和击穿的区别在于范围。击穿是一个key,雪崩是大量key同时过期,或者Redis服务整体不可用,导致所有请求同时落到数据库。排查时先区分是哪种情况,处理方式完全不同。
预防大量key同时过期,最实用的做法是给缓存过期时间加一个随机偏移:
long baseExpire = 3600; long randomExpire = baseExpire + ThreadLocalRandom.current().nextLong(600);把原本统一1小时过期的key,变成1小时~1小时10分钟之间过期,避免同一时段集体失效。
真正要防的还有Redis宕机这种极端情况。常用的兜底手段包括:Redis主从+哨兵,保证故障自动切换;应用层的本地缓存,比如Caffeine,作为第二级缓存,Redis不可用时还能扛一部分读流量。本地缓存的更新策略需要自己处理,一般是在写入Redis后同时清掉本地缓存,或者设置较短的本地过期时间。
5.4 双写一致性:先更新库还是先删缓存
缓存和数据库的一致性是老生常谈。常见套路有三种:
- Cache Aside:读时先读缓存,没有则读库再回填;写时先更新数据库,再删除缓存;
- Read/Write Through:把缓存作为唯一数据源,数据库操作由缓存组件代理,这个对业务透明但实现复杂;
- Write Behind:先写缓存,异步批量更新数据库,性能好但数据一致性风险大。
实际项目中90%的场景用Cache Aside就够了。写入顺序选择上,推荐先更新数据库,再删除缓存,而不是先删缓存再更新库。因为先删缓存后更新库,在更新数据库期间有其他线程读到缓存空值,会把旧数据回填到缓存,反而导致长时间不一致。先更新数据库再删缓存,只要删除动作成功,下次读取就是新数据。
有人会问,删除缓存失败了怎么办?方案是消息队列重试,把删除操作发到MQ里异步重试几次。如果不想引入MQ,也可以用延迟双删的思路:先删缓存→更新数据库→再删一次缓存(间隔几百毫秒)。延迟双删不完美,在极端并发下仍可能短暂读到旧数据,但配合短的缓存过期时间,实际影响可控。
6. 数据类型用地好,Redis才有性价比
6.1 五种常用数据类型的操作入口
Spring Data Redis的RedisTemplate提供了opsForValue()、opsForHash()、opsForList()、opsForSet()、opsForZSet()五个主要入口,分别对应String、Hash、List、Set、ZSet五种类型。很多人集成Redis后只会用RedisTemplate存JSON字符串,其他类型完全没用上,其实是把Redis当成一个纯KV存储,没有发挥它真正的优势。
各类型的典型场景:
- String:缓存JSON、计数器、token存储、分布式ID;
- Hash:对象属性存储,比把整个对象序列化成一个大字符串更灵活,可以单独更新某个字段;
- List:消息队列(LPOP/RPUSH)、最新公告列表、操作日志列表;
- Set:去重、共同关注、随机抽奖;
- ZSet:排行榜、积分排名、延时任务。
6.2 一个排行榜需求用ZSet的完整做法
拿积分排行来说。需求是实时展示用户积分榜,按积分倒序,积分相同时按更新时间排序。如果用数据库ORDER BY在并发写多的情况下压力很大,ZSet天然适合这种场景。
写入积分:
redisTemplate.opsForZSet().add("rank:score", userId.toString(), score + System.currentTimeMillis() / 1e12);这里的小技巧是把积分和更新时间拼进了score的数值里。ZSet的score只有一位double精度,如果业务积分上限在千万级以内,可以用积分整数部分 + 时间戳小数部分的方式近似实现"积分相同则先来先排"。读取前50名:
Set<ZSetOperations.TypedTuple<String>> top = redisTemplate.opsForZSet() .reverseRangeWithScores("rank:score", 0, 49);ZSet还支持范围查询、按分数段统计、删除指定元素等操作。排名变更时,不用改动整条数据,只调用add即可更新分数。相比用数据库做实时排名,这套方案在高并发写入场景下的友好度好很多。
6.3 数据模型设计时容易犯的错
用Redis存业务数据,常见的设计问题有三个:
- key设计没有前缀规范。建议统一按
业务:对象:标识的约定,比如cache:user:1001、lock:stock:sku299,一个清晰的key命名习惯在排查线上问题时能节约大量时间; - Hash和String的选择不做区分。用户对象的某些属性频发更新,却把整个用户序列化成一个JSON放进String,每次改一个字段都要整存整取。换成Hash,按字段更新,成本更低;
- 过期时间一刀切。所有key都是永不过期,Redis内存总有一天被打满。生产环境建议给每个缓存key都设置过期时间,并为不同业务设定不同的过期策略。
7. 主从、监控和线上问题排查的实用套路
7.1 用Docker搭Redis主从
单机Redis在数据可靠性上天然有风险,机器宕机或磁盘故障可能造成数据丢失。主从复制是最基础的保障。Docker部署主从比较快,做法是搭两个Redis容器,一个作为master,一个作为replica。在replica的配置里加一行:
replicaof <master-ip> <master-port>容器化部署时两个Redis需要在同一个Docker网络里,可以用docker network create创建自定义网络。启动后执行redis-cli info replication,看到role:slave和connected_slaves:1就说明主从关系正常。生产环境一般在此基础上加哨兵或直接上集群,但不管哪种架构,主从复制逻辑是基础。
7.2 Redis持久化选RDB还是AOF
Spring Boot项目把Redis当缓存用,持久化优先级低;当存储用,持久化就非常重要。RDB是周期性快照,恢复快但丢失窗口大;AOF是记录每次写操作,数据更完整但文件大、恢复慢。实际项目中常用AOF加appendfsync everysec的组合,每秒刷盘一次,性能和数据安全之间相对平衡。所谓的"redis aop与rdb",如果是在说持久化,指的就是AOF和RDB两种机制,配置项在Redis的redis.conf中:save指令管RDB触发条件,appendonly yes开启AOF。
7.3 日志定位、版本兼容和缓存治理
线上Redis出问题时,最直观的抓手有这几个:
- 应用日志。Spring Boot里开启Redis相关的日志级别(比如
logging.level.org.springframework.data.redis=DEBUG),能直接看到读写时执行的命令。报错里的RedisConnectionFailureException多半是网络不可达,RedisCommandTimeoutException多半是操作过慢或配置超时太短; - Redis日志。在
redis.conf里配置loglevel notice和logfile路径,排查运维问题比看应用日志更直接。比如主从同步中断、客户端连接数突然暴增,在redis日志里都会有记录; - 慢查询日志。使用
SLOWLOG GET命令查看执行时间超过阈值的命令,排除掉慢命令对Redis整体吞吐的影响; - 版本兼容。这里的坑主要集中在:Spring Boot 3.x要求Java 17及以上,对应的Spring Data Redis内部实现有调整;Redis 7.x之后的默认保护模式、模块加载方式与旧版有差异。Spring Boot项目里遇到连接异常但配置没问题时,先检查本地Redis版本和线上版本是否一致,再检查Spring Boot版本对应的Lettuce版本是否过旧。
缓存治理的"土办法"我一直在用:给Spring Boot项目加一个简单的监控端点,定时采样Redis的内存占用、连接数、key数量、命令QPS。数据不一定要接监控平台,落到日志文件里,线上出问题时能快速回看这几天Redis的变化曲线。很多公司没有专门的Redis运维平台,这套土办法能在关键时刻救急。
Redis在Spring Boot项目里看似简单,实际坑不少。选对序列化器、把分布式锁的原子性做对、区分缓存穿透击穿雪崩的应对方式、按类型用Redis而不是把所有东西塞进字符串,这几件事做到了,Redis这块基本就稳了。最后分享一个私人心得:每次在项目里引入一个新的Redis用法之前,先在测试环境用redis-cli monitor观察一下真实的命令序列,看到命令那一刻,很多理解不到位的坑都能自己冒出来。