接手一个Java老项目,翻代码的时候最烦的不是业务逻辑复杂,而是满屏的redisTemplate.opsForValue().set(...)、redisTemplate.opsForHash().put(...),翻着翻着就分不清哪个方法是操作哪种数据类型的。说实话,RedisTemplate是Spring生态里用得非常高频的一个工具类,但很多人用了两三年,翻来覆去就只会set和get,一旦遇到批量操作、过期时间、序列化乱码、管道命令就抓瞎。这篇东西就是把RedisTemplate的核心方法做一个系统性的梳理,从opsForValue到execute,从序列化到管道,把每个方法背后的使用场景、注意事项、踩坑经验一次讲透。适合刚接触Spring Data Redis的初学者,也适合用了很久但一直停留在"会用不深"阶段的老开发。
1. RedisTemplate的定位:它是怎么把Jedis和Lettuce藏起来的
1.1 连接工厂与模板类的关系
先花点时间把RedisTemplate的定位说清楚。RedisTemplate本身不直接连接Redis服务器,它依赖一个RedisConnectionFactory。这个工厂在Spring Boot的自动配置里默认是Lettuce(Spring Boot 2.x之后),以前老项目里常见的是Jedis。你可以把RedisConnectionFactory理解为"创建数据库连接"的入口,而RedisTemplate是基于这个连接做二次封装的门面。
为什么要多包一层?因为直接用Jedis写代码,你要自己管理连接、自己处理序列化、自己拼接命令参数,代码非常啰嗦。比如用Jedis原生写法:
Jedis jedis = new Jedis("localhost", 6379); jedis.set("name", "张三"); byte[] bytes = jedis.get("name".getBytes(StandardCharsets.UTF_8)); String name = new String(bytes, StandardCharsets.UTF_8); jedis.close();这还只是最简单的字符串存取,如果是Hash,你要自己处理Map的序列化,要自己把Java对象转成字节数组,这一套下来代码量直接翻倍。而RedisTemplate帮你把"连接管理""序列化""命令封装"这三件事全干了,你只需要关心业务数据本身:
redisTemplate.opsForValue().set("name", "张三"); String name = redisTemplate.opsForValue().get("name");1.2 一个核心模板对应五类操作入口
RedisTemplate最核心的设计就是opsForXxx()这一组方法,它把Redis的五种基本数据类型分别封装成了五个操作接口:
| 方法 | 返回类型 | 对应Redis类型 | 典型场景 |
|---|---|---|---|
opsForValue() | ValueOperations | String | 缓存、计数器、分布式锁 |
opsForHash() | HashOperations | Hash | 对象/字典存储 |
opsForList() | ListOperations | List | 队列、消息列表 |
opsForSet() | SetOperations | Set | 去重、标签、好友关系 |
opsForZSet() | ZSetOperations | ZSet | 排行榜、按分数排序的数据 |
每个Operations接口里都是一堆方法,但命名规律很统一,基本上就是你脑子里的命令名。比如set、get、delete、increment。
除了这五个入口,还有opsForStream()、opsForHyperLogLog()等扩展类型,但日常业务里前五种占了九成以上。下面我按类型逐个拆,每个都加实际场景和注意事项。
2. 字符串与值操作:opsForValue()使用率最高的操作
2.1 从set到setIfAbsent:不同方法解决不同问题
opsForValue()是RedisTemplate里最常被调用的入口。基础方法不多,但每个都有讲究。
set(K key, V value)是最基础的写法,存一个键值对。set(K key, V value, long timeout, TimeUnit unit)是带过期时间的写法。这个我非常推荐在实际项目里用,因为大多数缓存场景都需要过期时间,与其先set再expire分两步(两步之间如果程序崩溃就永远没有过期时间了),不如一步到位。
setIfAbsent(K key, V value)很多人不知道怎么用,它在业务里太有用了。这个方法对应Redis的SETNX命令,只有key不存在时才写入。这就是实现分布式锁的核心。下面是一个简单的分布式锁实现:
Boolean locked = redisTemplate.opsForValue() .setIfAbsent("lock:order:123", "1", 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 执行业务逻辑 } finally { redisTemplate.delete("lock:order:123"); } }注意这里setIfAbsent返回的是Boolean对象,不是基本类型boolean,所以判空很重要。实际项目中曾经踩过坑:Redis连接超时时这个方法返回null,直接if (locked)会把null拆箱成NPE,正确写法是Boolean.TRUE.equals(locked)。
2.2 get、increment、append:别小看这几个方法
get(Object key)没啥好说的,取数据。getAndDelete是取完顺手删掉,做"一次性读取"很方便。increment(K key, long delta)是原子自增,对应Redis的INCRBY命令,做计数器、库存扣减都用它。
这里要特别说明:increment是原子操作,不需要担心并发问题。很多新手用get拿到数字,在Java里加1再set回去,这在并发下必出问题。正确姿势永远是让Redis帮你算:
Long count = redisTemplate.opsForValue().increment("order:count:2024", 1);append(K key, String value)是追加字符串,对应APPEND命令。这个用的不多,但在记录日志、拼接长字符串时有奇效,比get回来拼接再set回去省一次网络往返。
get(K key, long start, long end)是截取字符串子串的方法,对应GETRANGE,偶尔用得上。
放一张表方便收藏:
| 方法 | 等效命令 | 用途 | 注意点 |
|---|---|---|---|
set(key, value) | SET | 普通赋值 | 默认不设过期时间 |
set(key, value, timeout, unit) | SET EX | 赋值+过期 | 推荐优先使用 |
setIfAbsent(key, value) | SETNX | 不存在才赋值 | 返回Boolean注意判空 |
get(key) | GET | 取值 | 无值返回null |
getAndDelete(key) | GETDEL | 取值并删除 | 一次性操作 |
increment(key, delta) | INCR/INCRBY | 原子自增 | 并发安全 |
append(key, value) | APPEND | 字符串追加 | 注意编码格式 |
get(key, start, end) | GETRANGE | 截取子串 | 与substring语义不同 |
2.3 批量操作:multiGet的用法与限制
multiGet(Collection<K> keys)一次传入多个key,返回一个List<V>。这个方法的坑是:返回的List顺序和传入key的顺序不一定完全对应,而且在Spring Data Redis的某些版本里,如果某个key不存在,List里对应位置是null,位置关系基本可以放心,但还是要养成"拿到结果后先判null"的习惯。
批量get最大的价值是减少网络往返次数。比如查用户信息列表,遍历调用get要N次网络请求,multiGet一次搞定。但注意它没法保证原子性,只适合通用查询场景,不适合需要强一致的逻辑。
3. Hash操作:opsForHash()处理对象与字段变更
3.1 put、get、entries:最简单也最常用的三角组合
Hash在Redis里是"字段-值"的映射结构,最适合存对象。put(H key, HK hashKey, HV value)往Hash里塞一个字段,get(H key, Object hashKey)取一个字段,entries(H key)取整个Hash。
比如存用户信息,我们一般有两种方式。第一种是把整个对象序列化成JSON字符串塞给opsForValue().set();第二种是拆成Hash,每个字段单独存。两种各有优劣:
| 方案 | 优点 | 缺点 |
|---|---|---|
| JSON字符串 | 实现简单,整体读写 | 改一个字段要读整个对象、改完整再写回 |
| Hash字段 | 字段级读写,灵活 | 代码繁琐,数据乱 |
实际项目中我的经验是:读多写少的"快照型"数据用JSON字符串,字段频繁更新的数据用Hash。比如用户资料页,头像、昵称、签名都可能被单独修改,用Hash就很舒服:
redisTemplate.opsForHash().put("user:1001", "name", "张三"); redisTemplate.opsForHash().put("user:1001", "age", "28"); String name = (String) redisTemplate.opsForHash().get("user:1001", "name"); Map<Object, Object> entries = redisTemplate.opsForHash().entries("user:1001");3.2 putIfAbsent与increment:Hash里的并发控制
putIfAbsent(H key, HK hashKey, HV value)对应Redis的HSETNX,字段不存在才写入。这个在防重、初始化场景很实用。举个例子,做电商活动时,每个用户的领取记录放在Hash里,用putIfAbsent保证一个人只能领一次:
Boolean first = redisTemplate.opsForHash() .putIfAbsent("campaign:spring", "user:1001", "claimed");如果返回false说明之前已经领过了。
increment(H key, HK hashKey, long delta)对应HINCRBY,对Hash里的数字字段做原子自增。比如统计商品分类下的访问量,用Hash的increment比用String的increment更有结构性,因为多个商品的计数器都挂在同一个Hash key下面管理起来很方便。
3.3 delete、hasKey与批量取值:别忘的收尾操作
delete(H key, Object... hashKeys)可以删一个或多个字段,对应HDEL。注意第一个参数是大key,后面可变参数是要删除的字段,千万别搞反了。hasKey(H key, Object hashKey)判断字段是否存在,对应HEXISTS,做幂等判断非常常用。
multiGet(H key, Collection<HK> hashKeys)批量取字段值,返回List。这里有个隐藏问题:它和opsForValue().multiGet()一样,返回结果里不存在的字段对应null,所以拿到结果后最好做一次空值过滤。
values(H key)是取所有字段的值,keys(H key)是取所有字段名,size(H key)是取字段数量。这几个方法在大Hash上要慎用,Redis是单线程模型,取超大Hash的keys或values会阻塞其他命令执行,生产环境大key必须拆分。
4. List、Set和ZSet:队列、去重与排行榜的三兄弟
4.1 List操作:leftPush和rightPush别搞混方向
opsForList()对应的是一系列List命令。最容易搞混的就是leftPush和rightPush。以leftPush(K key, V value)来说,它对应当前Redis的LPUSH,意思是从头部插入;rightPush对应RPUSH,从尾部插入。取的时候leftPop从头部弹出,rightPop从尾部弹出。
画个图理解一下:List就像一根管子,左边是头,右边是尾。leftPush是往管子左边塞东西,rightPop是从管子右边掏东西——组合起来就是一个先进先出的队列(FIFO)。反过来leftPush配合leftPop就变成了先进后出的栈(LIFO)。
实际项目里用得最多的是用它做简单的消息队列。比如异步处理任务:
// 生产者 redisTemplate.opsForList().rightPush("task:queue", taskJson); // 消费者 String taskJson = redisTemplate.opsForList().leftPop("task:queue");rightPush入队,leftPop出队,天然的FIFO。leftPop(K key, long timeout, TimeUnit unit)是带阻塞的版本,对应BLPOP,队列为空时最多等timeout,超时返回null。这个在消息队列里非常实用,避免空轮询消耗CPU。
range(K key, long start, long end)对应LRANGE,取一段范围内的元素。注意这里的范围是闭区间,range(key, 0, -1)是取全部。size(K key)是取List长度。trim(K key, long start, long end)是裁剪List,只保留指定区间,做"只保留最近N条记录"很方便。
4.2 Set操作:add、remove、isMember与多个集合间运算
opsForSet()处理的是无序去重集合。add(K key, V... values)添加一个或多个元素,remove(K key, Object... values)移除,members(K key)取全部成员,isMember(K key, Object o)判断是否存在,size(K key)取数量。
Set独特的价值在于集合运算。intersect是交集,union是并集,difference是差集。比如做社交功能,计算共同好友:
Set<String> commonFriends = redisTemplate.opsForSet() .intersect("user:1001:friends", "user:1002:friends");这个方法别看是Set类型,实际应用特别广:用户标签去重、UV统计、抽奖去重、推荐系统里的物品相似度计算,都用得上。
还有两个方法容易被忽略:randomMember(K key)随机返回一个成员,适合做抽奖;pop(K key)弹出并移除一个随机成员,适合做限量抢购的虚拟库存扣减。
4.3 ZSet操作:sorted set的分数概念与常用方法
ZSet(有序集合)是带有分数的Set,每个成员关联一个double类型的分数,按分数从小到大排序。add(K key, V value, double score)添加成员并设置分数,score(K key, Object o)查分数,rank(K key, Object o)查排名(从0开始,分数最低的排名为0),rangeWithScores(K key, long start, long end)按排名取一段带分数的成员。
排行榜是ZSet最经典的场景。比如游戏积分排行:
// 玩家得分 redisTemplate.opsForZSet().add("rank:game:2024", "player_1001", 9800.0); // 取前10名 Set<ZSetOperations.TypedTuple<String>> top10 = redisTemplate.opsForZSet().reverseRangeWithScores("rank:game:2024", 0, 9);注意这里用了reverseRangeWithScores,分数从大到小排列,因为排行榜通常取分数最高的。如果直接rangeWithScores,得到的是分数最低的排名。
incrementScore(K key, V value, double delta)是给分数加法,对应ZINCRBY,做实时积分更新不用先取分数再写回,原子且高效。count(K key, double min, double max)是按分数区间统计数量,remove(K key, Object... values)是移除成员。
5. execute与序列化:这两个点决定了你的上线质量
5.1 execute方法家族:绕过模板直达底层
execute方法族是RedisTemplate最底层的接口,它能让你拿到RedisConnection直接操作命令。它有几个重载:
execute(RedisCallback<T>):最简单的形式,回调里给你一个RedisConnectionexecute(SessionCallback<T>):在同一个Session(连接)里执行多条命令,保证连接复用executePipelined(RedisCallback<T>):管道模式executePipelined(RedisCallback<T>, RedisSerializer<?> resultSerializer):管道并指定结果反序列化器
日常开发中,executePipelined是性能优化的利器。它对应Redis的Pipeline机制,把多条命令一次性发给服务器,减少RTT(往返时延)。如果一个请求里要写20个key,一条条写要20次网络往返,pipeline一次搞定。看一个例子:
redisTemplate.executePipelined((RedisCallback<Object>) connection -> { for (int i = 0; i < 1000; i++) { connection.stringCommands().set( ("batch:key:" + i).getBytes(StandardCharsets.UTF_8), ("value:" + i).getBytes(StandardCharsets.UTF_8)); } return null; });用管道之后测试,1000条写入从原来的几十毫秒降到几毫秒,提升非常明显。
SessionCallback的价值在于保证多条命令在同一个连接执行。Redis的事务MULTI/EXEC就得通过它配合connection.multi()和connection.exec()来实现。RedisTemplate本身没有提供transaction()这样的高亮方法,要执行事务就得走Session:
List<Object> results = redisTemplate.execute(new SessionCallback<List<Object>>() { @Override public List<Object> execute(RedisOperations operations) { operations.multi(); operations.opsForValue().set("tx:key1", "v1"); operations.opsForValue().set("tx:key2", "v2"); return operations.exec(); } });5.2 序列化:乱码问题的根源在哪
RedisTemplate的序列化是绕不开的话题。默认情况下,Spring Data Redis用JdkSerializationRedisSerializer,这个序列化器有两个问题:一是存储的key和value会带一串二进制头,直接进Redis客户端看全是\xAC\xED\x00\x05t\x00...这样的乱码;二是序列化后的体积大,占内存。
解决方法是显式配置序列化器。最常见的组合是:key用StringRedisSerializer,value用GenericJackson2JsonRedisSerializer或者Jackson2JsonRedisSerializer。在配置类里写:
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; }这里有个容易忽视的细节:key和hashKey通常用String序列化,value和hashValue用JSON序列化。key用JSON序列化会存成带引号的字符串,看着别扭还容易出错。
另外还有一个特殊的模板类StringRedisTemplate,它继承自RedisTemplate,但key和value都强制用String序列化。如果你只存字符串,直接用StringRedisTemplate省事;如果你要存对象,用RedisTemplate加Jackson配置。
5.3 序列化不一致导致的坑
实际项目里最常见的报错是这样的:java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.xxx.User。原因是value用了GenericJackson2JsonRedisSerializer存了一个User对象,取出来反序列化时Jackson不认识这个具体类型,只能先反序列化成LinkedHashMap。
这个问题的根源是:GenericJackson2JsonRedisSerializer会把类型信息存在JSON的@class字段里,但如果你在代码里用redisTemplate.opsForValue().get("user:1")取,模板只知道返回Object,不知道要转成User,所以拿到LinkedHashMap。
解法有两种:一种是用Jackson2JsonRedisSerializer<User>明确指定泛型类型;另一种是取出来之后再手动转换:
Object obj = redisTemplate.opsForValue().get("user:1"); User user = objectMapper.convertValue(obj, User.class);我的建议是:搞一个CacheService封装层,在封装层里统一做类型转换,别让业务代码直接跟RedisTemplate裸奔。
6. 过期时间、删除与批量:业务里天天踩的小细节
6.1 expire、delete、hasKey的正确姿势
expire(K key, long timeout, TimeUnit unit)设置过期时间。很多人图省事在set之后单独调expire,其实set(key, value, timeout, unit)带过期时间就一步到位了。但有一个例外:有些场景是先创建key,等业务处理完再设置过期时间,比如"等待支付30分钟",这时候才需要单独调expire。
delete(K key)删除单个key,delete(Collection<K> keys)批量删除。注意hasKey(K key)返回的也是Boolean对象,同样要用Boolean.TRUE.equals()判断。
6.2 批量写入的正确姿势:别再循环set
批量写入最差的方案是for循环里一条条set,最好的是Pipeline。Pipeline上面已经介绍了。另一种是opsForValue().multiSet(Map<K, V> map),对应MSET命令,一次性写入多个key。但multiSet没法带过期时间,所以如果你需要批量写入并且要过期时间,还得用Pipeline逐条set(key, value, timeout, unit)。
6.3 缓存穿透、雪崩与空值缓存方案
实际项目中用Redis做缓存,绕不开三个问题:穿透、击穿、雪崩。穿透是指请求了一个不存在的key,每次都要打数据库。最常用的方案是"空值缓存":
Object value = redisTemplate.opsForValue().get(key); if (value == null) { value = queryFromDb(key); if (value == null) { // 缓存空值,过期时间设短一点 redisTemplate.opsForValue().set(key, "", 60, TimeUnit.SECONDS); } else { redisTemplate.opsForValue().set(key, value, 600, TimeUnit.SECONDS); } }击穿是指一个热点key过期瞬间,大量请求同时打到数据库。解决思路是互斥锁,用前面提到的setIfAbsent来实现。
雪崩是大量key同时过期,解决思路是给过期时间加一个随机抖动,避免同一时刻集体失效:
long timeout = 600 + ThreadLocalRandom.current().nextInt(300); redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);7. 我的实操体会:把这些方法串成一个可用的工具类
最后分享一点自己的使用心得。
我在项目里习惯封装一个RedisCacheService,把RedisTemplate的常用方法再包一层。好处有三个:一是业务代码不用直接依赖RedisTemplate的泛型地狱;二是序列化、过期时间这些策略在封装层统一处理;三是以后要换缓存中间件,改一个类就行。
封装的时候有几点经验:
第一,模板的key统一用String类型,不要用对象当key。对象当key意味着每次都要算hashCode,而且序列化方式不同算出来的hashCode不一样,同一个业务key换个对象就丢了。
第二,value的类型统一用JSON字符串或者泛型T。不要一会儿存String一会儿存List一会儿存对象,时间长了你会忘了这个key里到底是什么。
第三,所有获取操作都要判空。Redis没有数据的时候所有get都返回null,不判空就NPE。
第四,设置过期时间必须做。不留过期时间的key就是定时炸弹,等Redis内存被打满,重启、淘汰策略都是灾难。
第五,能批量就批量。单个key操作多了,RTT是很大的开销,Pipeline能大幅提升吞吐量,尤其在高频写入场景下收益非常明显。
这个方法汇总,说白了就是把RedisTemplate的常用API捋了一遍,但真正的价值不在API本身,而在你什么时候选哪个方法、怎么避坑。像setIfAbsent做分布式锁、increment做原子计数、opsForList做队列、opsForZSet做排行榜、executePipelined做批量写、GenericJackson2JsonRedisSerializer解决乱码——这些组合拳才是项目里真正值钱的东西。业务代码好写,但把Redis用对、用稳、用快,是要靠一次次踩坑换来的。希望这份汇总能让你少踩几个我踩过的坑。