如果你的项目是 Java 技术栈,和 Redis 打交道十有八九绕不开 Spring Data Redis。我第一次用它是在一个用户缓存模块里,需求很简单:用户信息存 Redis,设置过期时间,查询的时候不要每次都打数据库。结果上手才发现,依赖引了、配置写了,代码跑起来,Redis 里存了一堆乱码,后来花了大半天才搞清楚是序列化的问题。这篇就把我实际用下来的经验整理一遍,从依赖导入、配置写法,到常用 API 和踩坑点,一次性说清楚,希望能帮你少走几个弯路。
1. 先想清楚:Spring Data Redis 到底帮你省了哪些事
1.1 从手动管连接说起
很多人第一次接触 Redis 是在命令行或者一些客户端工具里,Redis 本身就是一个基于 TCP 的 key-value 存储服务,你往它发指令,它给你返回结果。
在 Java 里直接操作 Redis,最常见的方式是连一个客户端库,比如 Jedis 或者 Lettuce,然后手动创建连接、发送命令、关闭连接。看起来不难,但一旦到了工程化场景,问题就来了:连接要不要复用?断线了要不要重连?并发高的时候连接怎么分配?命令异常怎么处理?对象的序列化和反序列化谁来管?
这些如果全部自己写,代码里会堆满样板逻辑。而 Spring Data Redis 做了一件非常实在的事情:把连接工厂、序列化器、模板方法这些基础设施都封装好了,你只需要注入一个 RedisTemplate 或者 StringRedisTemplate,然后像调用 Java 方法一样操作 Redis 就行。它底层连接的是 Lettuce 或 Jedis,但你在业务代码里基本感知不到。
1.2 它和直接用 Jedis 或 Lettuce 的区别
有人会问,那我直接用 Lettuce 不也一样吗?当然可以,但区别在于开发效率。
直接用 Lettuce,你要自己管 RedisConnection、自己创建连接池、自己处理异常、自己定义序列化规则。比如你要往 Redis 里放一个用户对象,直接用 Lettuce 的话,你得先把对象转成 JSON,再把 JSON 转成 byte[],读到数据之后再手动反序列化回来。这套流程每次写都差不多,特别容易出 bug。
Spring Data Redis 把这些事做了两层抽象:第一层是 RedisConnectionFactory,负责创建和管理底层连接;第二层是 RedisTemplate,它把 Redis 的不同数据结构映射成了 Java 方法,比如 opsForValue() 对应 String 类型、opsForHash() 对应 Hash 类型。同时它内置了序列化机制,你在 Java 里放一个对象进去,它帮你序列化存储,拿出来的时候自动反序列化,前提是序列化方案配置得当。
用一句话总结:如果你只是验证某个 Redis 特性,怎么随意都行;如果你在写一个要长期维护的 Spring Boot 应用,Spring Data Redis 几乎是标准选择。
1.3 什么项目适合上它
基本上,只要满足两个条件就适合用:第一,你的项目是 Spring 技术栈,尤其是 Spring Boot;第二,你用 Redis 的场景不止是简单 set/get,还包括缓存、分布式锁、消息队列、排行榜、计数器这类复杂操作。
它在 Spring Boot 项目里的生态整合很好,比如 Session 共享、Spring Cache 注解(@Cacheable、@CacheEvict)都可以和它无缝配合。换句话说,你不需要为了缓存再去单独集成一套缓存框架,把 Spring Data Redis 引入之后,注解缓存、模板操作、连接管理都是现成的。
2. 依赖导入与基础配置
2.1 三步把依赖加进项目
第一步,在 pom.xml 里加核心依赖。如果你用的是 Spring Boot,直接引入 starter:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>这个 starter 会帮你把 Spring Data Redis 的核心类、默认的 Lettuce 连接工厂都拉进来。注意,它默认用的是 Lettuce 客户端,而不是 Jedis。如果你确实想用 Jedis,需要排除掉 Lettuce,再单独加 Jedis 依赖。不过我现在做项目基本都是 Lettuce,性能和可维护性都更好,没必要换。
第二步,如果你要配连接池,还需要额外引入 Apache Commons Pool2:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>这一点很多人容易漏,导致配置了 spring.data.redis.lettuce.pool.max-active 但没生效,因为类路径上根本没有池化组件。
第三步,在启动类或者配置类上确认扫描。Spring Boot 的自动配置会帮你注册 RedisTemplate、StringRedisTemplate 和 RedisConnectionFactory,一般不需要额外写 @Enable 之类的注解。
2.2 配置文件里的关键参数
不同版本的 Spring Boot 配置前缀不一样,这是一个非常容易踩的坑。
Spring Boot 2.x 版本用:
spring: redis: host: 127.0.0.1 port: 6379 password: database: 0 timeout: 3sSpring Boot 3.x 版本改成了:
spring: data: redis: host: 127.0.0.1 port: 6379 password: database: 0 timeout: 3s如果你在 Spring Boot 3 项目里用了 spring.redis 前缀,配置不会生效,因为自动配置绑定不到属性上,只会连接默认的 localhost:6379,密码、数据库索引全部无效。这个问题我在升级版本的时候遇到过,查起来还比较隐蔽,因为不报错。
database 参数表示使用 Redis 哪个逻辑库。Redis 默认有 16 个库,索引从 0 到 15,默认走 0 号库。我是建议一个项目固定用同一个库,别在多个库之间来回切换,不然出问题的时候排查比较费劲。
2.3 连接池配置与参数解释
Lettuce 本身是 Netty 实现的,单个连接可以承载很多并发操作,所以很多人说 Lettuce 不需要连接池。但实际在高并发场景下,我建议还是配一个池子,主要目的是控制连接资源的占用上限,避免突发的连接风暴。
下面是一个生产环境相对合理的配置参考:
spring: data: redis: host: 127.0.0.1 port: 6379 password: yourpassword database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 3s这些参数的含义和注意事项,我用一个表格整理出来:
| 参数 | 默认值 | 生产建议 | 说明 |
|---|---|---|---|
| max-active | 8 | 16~64 | 连接池中最大连接数,建议根据应用并发量压测后调整 |
| max-idle | 8 | 和 max-active 接近 | 最大空闲连接数,空闲连接太多是资源浪费 |
| min-idle | 0 | 2~4 | 最小空闲连接数,保持基本的连接储备 |
| max-wait | -1 | 3000ms | 获取连接的最大等待时间,-1 表示无限等待,建议设置有限值 |
| timeout | 3s | 1~3s | 命令执行超时时间,超时会抛异常 |
max-wait 是很容易踩坑的地方。默认 -1 表示拿不到连接就无限等待,在高并发下,如果连接池被打满,所有请求都会阻塞在那里,接口直接卡死。建议设置一个明确的等待时间,比如 3 秒,宁可超时失败,至少能让问题快速暴露出来。
3. 核心 API 实操:把 Redis 能力用起来
3.1 先搞清楚两套模板:StringRedisTemplate 和 RedisTemplate
Spring Data Redis 提供了两个常用的模板类:StringRedisTemplate 和 RedisTemplate。
StringRedisTemplate 内部用的是 StringRedisSerializer,key 和 value 都以字符串方式存,适合存字符串、JSON 文本这类人类可读的内容。RedisTemplate 默认则是用 JdkSerializationRedisSerializer,它会将对象序列化成二进制,所以直接把一个对象放进去,在 Redis 客户端里看到的是一堆 \xAC\xED 开头的乱码,人不可读,其他语言也不容易解析。
我的做法是:项目中注入两个都保留,日常字符串类型操作和 JSON 字符串直接用 StringRedisTemplate;如果要操作对象结构,就对 RedisTemplate 重新配置序列化方案,而不是用它的默认序列化器。下面这个配置类是我目前用的模板配置:
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); // key、hash key 用 String 序列化,保证在 Redis 客户端里可读 template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); // value 用 JSON 序列化,可读且跨语言 GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }为什么 key 一定要用 StringRedisSerializer?因为运维和排查问题的时候,你打开 Redis 客户端看到的 key 如果是一长串二进制乱码,你根本不知道它在存什么。value 用 GenericJackson2JsonRedisSerializer,是因为它在存对象时会带一个 @class 类型信息字段,反序列化时能还原成原来的对象类型,这一点比 Jackson2JsonRedisSerializer 更省心,后者需要你手动指定目标类,泛型信息丢失时很容易反序列化失败。
3.2 String 类型:最常见的读写与过期时间控制
String 类型在 Redis 中应用极广,比如计数器、验证码、简单缓存。通过 RedisTemplate 的 opsForValue() 可以拿到 ValueOperations 进行操作。
最简单的写入和读取:
@Resource private RedisTemplate<String, Object> redisTemplate; // 写入 redisTemplate.opsForValue().set("user:1001", userInfo); // 读取 Object cached = redisTemplate.opsForValue().get("user:1001");带过期时间的写入是缓存场景的刚需:
redisTemplate.opsForValue().set("captcha:login:1001", "8848", 5, TimeUnit.MINUTES);这里要注意的是 key 的命名规范。我习惯用冒号分隔业务模块和对象 ID,比如 user:1001、captcha:login:1001,这样 Redis 客户端里可以按前缀浏览,后续做 key 清理也比价方便。
先判断 key 是否存在再执行逻辑也很常见:
Boolean hasKey = redisTemplate.hasKey("user:1001"); if (Boolean.TRUE.equals(hasKey)) { return; }这里有个细节:hasKey 返回的是 Boolean 封装类,判断时要使用 Boolean.TRUE.equals(...),直接使用 if (hasKey) 会有意外的空指针风险。
3.3 Hash 类型:对象与结构化数据的天然搭档
Hash 类型适合存对象、购物车、Session 这类需要单独修改字段的数据。通过 opsForHash() 拿到 HashOperations。
比如存用户信息,不用把整个对象序列化成一个 value,而可以把每个字段作为 hash field:
HashOperations<String, String, Object> hashOps = redisTemplate.opsForHash(); hashOps.put("session:user:1001", "name", "张三"); hashOps.put("session:user:1001", "level", 5); hashOps.put("session:user:1001", "status", 1);取出单个字段和全部字段都很方便:
Object name = hashOps.get("session:user:1001", "name"); Map<String, Object> entries = hashOps.entries("session:user:1001");Hash 还有一个好处是,你可以只更新某一个字段,不需要重新写入整个对象。比如用户等级变化,直接 hashOps.put 覆盖 level 字段即可。对高频更新的对象缓存来说,Hash 比 String 存整段 JSON 更节省流量和操作时间。
做购物车场景时也很典型,userId 作为 key,商品 ID 作为 field,数量作为 value:
hashOps.put("cart:1001", "sku_2001", 2); Long newCount = hashOps.increment("cart:1001", "sku_2001", 1);increment 方法可以实现原子自增,避免先读后写带来的并发问题。
3.4 List、Set、ZSet 的使用示例
List 类型适合做消息队列、最新消息列表这类场景。它的左右压入和弹出操作非常直观:
ListOperations<String, Object> listOps = redisTemplate.opsForList(); // 左进 listOps.leftPush("msg:queue", "message-1"); listOps.leftPush("msg:queue", "message-2"); // 右出,先进先出 Object message = listOps.rightPop("msg:queue");这里要注意 rightPop 是阻塞还是非阻塞。上面这种是非阻塞弹出,如果队列为空会返回 null。如果需要阻塞读取,可以用 rightPop(key, timeout, TimeUnit),它在没有数据时会等待一段时间,适合做简单的任务队列消费端。
Set 类型适合做去重集合,比如用户标签、已读记录。通过 opsForSet() 操作:
SetOperations<String, Object> setOps = redisTemplate.opsForSet(); setOps.add("article:1001:tags", "java", "redis", "spring"); Set<Object> tags = setOps.members("article:1001:tags"); // 判断是否存在 Boolean isMember = setOps.isMember("article:1001:tags", "java");ZSet 类型则适合做排行榜、签到积分这类需要排序的场景。通过 opsForZSet() 操作:
ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet(); zSetOps.add("rank:day", "user_a", 100); zSetOps.add("rank:day", "user_b", 88); // 获取前 N 名 Set<ZSetOperations.TypedTuple<Object>> topList = zSetOps.reverseRangeWithScores("rank:day", 0, 9);reverseRangeWithScores 会按分数从高到低返回排名前 N 的数据及分数,非常适合排行榜场景。
3.5 用 Spring 缓存注解把 Redis 接入业务
除了直接用模板操作,Spring Data Redis 还支持把 Redis 作为 Spring Cache 的实现,这样你在业务方法上直接加注解就能缓存结果,代码量会少很多。
先配置一个 RedisCacheManager:
@Configuration @EnableCaching public class CacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(config) .build(); } }然后在业务方法上使用缓存注解:
@Service public class UserService { @Cacheable(cacheNames = "user", key = "#userId") public UserInfo getUserInfo(String userId) { return userMapper.findByUserId(userId); } }调用 getUserInfo 方法时,如果 Redis 里没有对应 key,会执行方法从数据库查询,然后将结果缓存;下次调用直接走缓存。key 的默认生成规则是 cacheNames + "::" + key,这里是 user::1001。
更新和删除缓存也有对应的注解:
@CacheEvict(cacheNames = "user", key = "#userId") public void updateUser(String userId) { userMapper.updateByUserId(userId); }@CacheEvict 会在方法执行成功后删除对应缓存,避免用户改完数据后还能读到旧值。这里要注意,@Cacheable 默认只在方法被外部对象调用时生效,类内部方法之间的 this 调用不会走进代理逻辑,注解会失效。
3.6 封装一个顺手的使用工具类
项目里多个地方都要用 Redis 的时候,我建议不要直接散落一堆 redisTemplate 调用,而是封装一个轻量工具类,把序列化、过期时间、空值处理这些逻辑收敛到一起。但注意别为了封装而封装,保持简单就好。
@Component public class RedisCacheUtil { @Resource private RedisTemplate<String, Object> redisTemplate; public void set(String key, Object value, long timeout, TimeUnit unit) { redisTemplate.opsForValue().set(key, value, timeout, unit); } public Object get(String key) { return redisTemplate.opsForValue().get(key); } 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)); } }这个工具类只做最基础的包装,再往上的业务逻辑不要堆进来。哪种数据用什么结构、key 怎么命名,应该在业务代码里决定,不要把整个项目所有缓存逻辑都塞到一个工具类中,否则后面会变得很难维护。
4. 实战中绕不开的坑,以及排查思路
4.1 序列化导致的乱码问题
使用 Spring Data Redis 后,Redis 里存进去的值变成 \xAC\xED 00 05 74 00 0B 这样的二进制,是最常见的问题。原因就是默认使用的是 JdkSerializationRedisSerializer,它把 Java 对象转成了 JDK 原生的二进制格式。
数据写进去的时候是用 JDK 序列化,你还能通过 Java 反序列化读出来,但如果换了序列化器,或者想用 Redis 客户端直接看数据,看到的就是乱码。解决的办法前面已经提到,就是在 RedisTemplate 里把 key 和 value 的序列化器都改掉。改完配置后,原来已经写入的乱码数据是读不出来的,需要清理掉,或者设计成短过期时间让它自动过期。
4.2 RedisTemplate 和 StringRedisTemplate 混用导致数据读不出来
这个问题和序列化问题紧密相关。如果你用 StringRedisTemplate 写入数据,然后用 RedisTemplate 读取,很可能读到 null,或者反过来。原因是两者的 key 序列化方式不同,StringRedisTemplate 存 key 时直接用 UTF-8 编码的字符串,而 RedisTemplate 如果保持默认序列化器,key 会被 JDK 序列化成另一套字节,在 Redis 服务端看到的是两个完全不同的 key。
我踩过一次很深的坑:一个服务写缓存用了 StringRedisTemplate,另一个服务读缓存用了默认的 RedisTemplate,结果两边一整天都在互相追查为什么数据对不上。查到最后发现是 key 的字节序列不同导致的。解决方法是统一一个配置类里的序列化方案,不要让团队里不同人各自注入不同的模板。
4.3 连接池缺失导致的接口卡顿
如果项目里没有引入 commons-pool2,也没有任何连接池配置,Lettuce 默认走的是共享连接模式。并发极低的时候没问题,一旦并发上来,或者 Redis 有网络抖动,可能出现命令排队、连接重建过多的情况,最直观的表现就是接口突然变慢,过一段时间又恢复。
后来我把连接池参数加上,并显式引入 commons-pool2,并发时的表现又稳了下来。具体的参数参考 2.3 节里的表格,观察 Redis 服务端的连接数变化来调整 max-active 的大小。
另外,timeout 参数建议别设成无限。有一次 Redis 服务端假死,命令全部阻塞,因为 timeout 是 -1(默认),所有请求都卡在等待结果上。把 timeout 设为 2~3 秒,很快就能让故障暴露出来,而不是全部线程干等。
4.4 缓存穿透、击穿、雪崩的兜底手段
这三个问题是缓存体系里的经典问题。用 Spring Data Redis 实现缓存时一样会遇到。
缓存穿透:查询一个不存在的 key,缓存始终未命中,每个请求都打到数据库。最简单有效的办法是缓存空值,将 null 结果也缓存一段时间。注意 RedisTemplate 的 set 方法可以直接存 null,但读取的时候要区分是缓存了空值还是 key 不存在,可以配合 hasKey 做判断解决。
缓存击穿:某个热点 key 在过期的瞬间有大量请求涌进来,全部穿透到数据库。解决办法之一是加互斥锁,只让一个线程去查数据库并重建缓存,其他线程短暂等待。
缓存雪崩:大量 key 在同一时间过期,导致数据库压力瞬间上升。解决办法是给过期时间加一个随机值,避免集中失效,比如 30 分钟加 0~60 秒的随机秒数。
如果你的项目用的是 @Cacheable 注解缓存,穿透问题尤其要提前想好,因为注解方式默认不缓存空值,需要额外处理 null 时的缓存逻辑,或者用布隆过滤器提前拦截明显不存在的 key。
4.5 Redis 事务与 @Transactional 配合的注意点
Spring 的 @Transactional 默认管理的是数据库事务,而 Redis 的事务是另一个概念,两者混在一起用经常让人困惑。如果在一个 @Transactional 方法里操作 Redis,Redis 的命令不会因为数据库回滚而一起回滚。这一点要记住,别指望 Redis 操作能跟随数据库事务自动撤销。
Redis 自身的 multi/exec/discard 事务可以通过 RedisTemplate 的 execute(SessionCallback) 来实现。SessionCallback 会把连接上的多个操作绑定到一个事务里:
List<Object> results = redisTemplate.execute(new SessionCallback<List<Object>>() { @Override public List<Object> execute(RedisOperations operations) throws DataAccessException { operations.multi(); operations.opsForValue().set("key1", "value1"); operations.opsForValue().set("key2", "value2"); return operations.exec(); } });但即使这样,Redis 事务也不保证原子性之外的隔离性,主要作用是批量执行命令时中间不会插入其他命令。如果业务上真的需要强一致性,应该考虑别的原因,Lettuce 的事务执行在这种模板写法下有一些细节限制,项目里要慎用依赖 Redis 事务来做核心业务逻辑的方案。
4.6 常见问题速查表
为了让你排查的时候更快定位问题,我做了一张速查表:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| Redis 里 key 乱码 | key 用默认序列化器存了 | key 序列化改成 StringRedisSerializer |
| 值存进去是二进制 | 使用的模板没有配置 JSON 序列化 | 配置 GenericJackson2JsonRedisSerializer |
| 一个服务写、另一个服务读不到 | 两个服务序列化配置不一致 | 统一序列化方案和代码规范 |
| 接口偶发变慢 | 连接池缺失或参数不合理 | 引入 commons-pool2 并配置连接池 |
| 命令一直阻塞不报错 | timeout 设置成了 -1 | 设置有限 timeout,比如 3 秒 |
| @Cacheable 不生效 | this 方法内部调用或没开启缓存 | 检查代理调用和 @EnableCaching |
| 缓存击穿 | 热点 key 过期瞬间并发高 | 加互斥锁或逻辑过期 |
| 缓存穿透 | 请求大量不存在的 key | 缓存空值或布隆过滤器 |
5. 最后再分享一点实际体会
我个人在实际操作中的体会是:Spring Data Redis 上手不难,真正拉开差距的是序列化方案、连接池参数和 key 命名规范这几个容易被忽略的细节。一定要在项目初期就把这些定下来,不要等遇到乱码、接口变慢、数据读不到再来救火。
如果你是在团队里推广这套东西,最好在工程规范里写清楚一个约定:统一使用自定义配置的 RedisTemplate,key 统一这样的格式 业务:实体:ID,缓存时间统一在常量类里管理。这样项目越往后维护,体感差距越大。
另外一个小技巧:在测试环境调试 Redis 数据的时候,优先用 StringRedisTemplate + JSON 字符串,这样在 Redis 客户端里一眼能看到完整的键值内容,排查问题非常直观。等业务稳定了,再决定要不要切换到对象序列化方案,避免一开始就踩进二进制数据的坑。