1. 先弄清楚opsForList到底在操作什么数据结构
很多人第一次见到redisTemplate.opsForList()时,第一反应是翻IDE里的方法提示,然后被一长串leftPush、rightPop、range看得头皮发麻。其实这玩意儿背后的数据结构特别朴素——Redis的List就是一个双向链表,你可以把它想象成一根两头开口的管子,左边能塞能掏,右边也能塞能掏,中间的元素只能挨个看、不能直接跳过去。
理解这个基础模型以后,opsForList()里所有方法都能归到四个动作上:往两端写入、从两端取出、按范围读取、按条件修剪或删除。所有的业务场景,什么用户最近浏览记录、消息队列、时间线分页、简单限流,本质上都是这四个动作在组合。
1.1 双向链表决定了方法的对称与不对称
我最早踩的坑就是以为leftPush和rightPush只是方向不同、其他完全一样。实际用下来你会发现,这两个方法在绝大部分场景下是对称的,但在阻塞弹出 + 转移到另一个List这类组合操作里,左右方向的差别直接决定了你的队列是“先进先出”还是“先进后出”。
比如你要做一个简单的任务队列:生产者往右边塞任务(rightPush),消费者从左边弹任务(leftPop),这就是标准的FIFO。如果生产者rightPush,消费者也从rightPop,那就变成了栈,后进的反而先被消费,这在业务里往往是个隐藏Bug。
所以拿到opsForList()的第一步,不是背方法名,而是先在纸上写清楚:你的数据流是从哪端进、哪端出。
1.2 和String、Set、Hash这些操作的边界感
RedisTemplate里还有opsForValue()、opsForSet()、opsForHash(),它们和opsForList()很容易混。我见过有同事把opsForValue().set()当列表追加用,key一覆盖,之前的数据全丢了;也有人用opsForSet()存入重复数据,结果取出来发现少了。
opsForList()最适合的场景只有一个:有序且允许重复的集合。顺序是List的核心资产,重复元素是List的合法状态。你如果只关心“存不存在”,不需要顺序,那就该用Set;如果你只有一个值,不需要结构,那就用String;如果你要按字段存对象,用Hash。用错结构,后面写出的代码要么性能差,要么语义绕。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 写入类方法逐个拆讲:不仅仅是leftPush和rightPush
这一节我们把所有“往List里放数据”的方法过一遍。我按使用频率排了个序,结合代码讲清楚每个方法的入参、返回值和隐藏行为。
2.1 rightPush和leftPush:最基础的追加
java复制// 从右边推入一个元素,等价于把元素追加到列表尾部
Long len = redisTemplate.opsForList().rightPush("user:history:1001", "sku_8899");
// 从左边推入一个元素,等价于把元素插入到列表头部
Long len2 = redisTemplate.opsForList().leftPush("user:history:1001", "sku_9900");
两个方法返回的都是操作完成后List的长度,这个返回值很多人忽略,但它在做“限制列表长度”时特别有用——你先rightPush,再检查返回长度是否超过N,超过就trim,一秒钟实现固定长度的“最近N条记录”。
还有一个变体叫rightPushAll和leftPushAll,支持批量推入:
java复制List<String> skus = Arrays.asList("sku_1", "sku_2", "sku_3");
redisTemplate.opsForList().rightPushAll("batch:list", skus);
注意批量方法在Spring Data Redis的实现中会走循环调用或者命令组合,如果你推入几百上千个元素,建议自己控制批次大小,避免一次命令过大导致Redis阻塞。
2.2 rightPushIfPresent:只在Key存在时写入
这个方法的语义很冷门,但极其实用:如果key不存在,什么都不干。我用它做过一个分布式锁里的续期名单维护——锁的持有者List存在才往里面追加自己的标记,锁释放后key删了,再有线程来追加时自动失败,不会产生僵尸数据:
java复制Boolean pushed = redisTemplate.opsForList()
.rightPushIfPresent("lock:active:holders", workerId);
if (Boolean.TRUE.equals(pushed)) {
// 说明锁还存在,可以继续干活
}
2.3 set:把List当数组用,覆盖指定位置的元素
java复制redisTemplate.opsForList().set("user:history:1001", 0, "sku_updated");
这个方法解决的是“我需要更新列表中间某个元素”的问题。注意如果索引越界,会抛IndexOutOfBoundsException,所以调用前最好先size()确认一下长度。
2.4 写入时的null和类型问题
opsForList()里传入的value如果为null,不同版本的行为不完全一致。老版本里leftPush返回null,新版本里有的会直接抛IllegalArgumentException。我现在的习惯是:所有写入List的值,在推入前统一做一次null判断,不为null才推。宁可多写一行,也不要等到线上半夜收到报警再爬起来看日志。
写入的另一个大坑是序列化。Spring Boot默认用的RedisTemplate如果没指定RedisSerializer,会用JdkSerializationRedisSerializer,你推入一个对象进去,存到Redis里的是一长串二进制。你能往里push,也能往外pop,但如果有人用RedisDesktopManager直接看数据,会发现根本读不懂。除非明确要跨语言访问,否则我会建议在配置RedisTemplate的时候统一用GenericJackson2JsonRedisSerializer或者StringRedisSerializer。
3. 读取类方法的重头戏:range和index的边界条件
读取是opsForList()里最容易被“差不多先生”搞砸的部分。range(0, -1)很多人会背,但真的问你“我要取第3页,每页20条,怎么做”,还是会有人在start和end上算错。
3.1 range:闭区间范围内的所有元素
java复制// 取全部元素
List<Object> all = redisTemplate.opsForList().range("user:history:1001", 0, -1);
// 取前20个元素
List<Object> firstPage = redisTemplate.opsForList().range("user:history:1001", 0, 19);
range(K key, long start, long end)的start和end都是闭区间,也就是说range(0, 19)会返回索引从0到19的20个元素。end还可以是负数,-1表示最后一个元素,-2表示倒数第二个。这个设计在解决“我要最后5条”时非常方便:
java复制List<Object> lastFive = redisTemplate.opsForList().range("user:history:1001", -5, -1);
这里有个很容易踩的坑:如果列表长度只有3,range(0, 5)并不会报错,而是只返回那3个元素。Redis的List range天然容忍越界,这段逻辑是安全的。但如果你反过来用range(-5, -1)去取倒数5条,列表长度不足5条时,同样只会返回能取到的部分,不会补null。不要假设返回的list长度一定等于你要求的区间长度,这是很多分页代码出Bug的根源。
3.2 index:按索引取单个元素
java复制Object firstElement = redisTemplate.opsForList().index("user:history:1001", 0);
Object lastElement = redisTemplate.opsForList().index("user:history:1001", -1);
index返回的类型是V,也就是你在RedisTemplate里配置的泛型类型。如果你用的是默认的RedisTemplate<String, Object>,取出来的自然就是Object,需要自己强转。这里提醒一下:取出来强转前,确认序列化方式能还原出原始类型。用GenericJackson2JsonRedisSerializer序列化的,转回Java对象时比较顺利;用了Jdk序列化而你的实体类做过结构调整(比如字段改名、类名变了),反序列化就可能砸穿,一定要做兼容处理。
3.3 size:列表长度,写限流逻辑的基石
java复制Long size = redisTemplate.opsForList().size("user:history:1001");
size返回null的情况有两种:key不存在,或者key对应的不是List类型。如果你拿一个String类型的key去调opsForList().size(),Redis会返回一个类型错误,Spring Data Redis通常把它包装成RedisSystemException抛出来。所以调用size之前,要么通过hasKey判断,要么干脆在逻辑里容忍null并把它当成0处理。
我写过一个简单的容量控制逻辑,就靠rightPush的返回值和trim配合:
java复制Long len = redisTemplate.opsForList().rightPush("visit:record", userId);
if (len != null && len > 1000) {
// 只保留最近1000条
redisTemplate.opsForList().trim("visit:record", -1000, -1);
}
这个组合比先查size再push要安全,因为在并发环境下,size和push之间可能存在时间差,多个线程同时操作会导致判断失真。rightPush返回新的length后,由这个length触发判断,逻辑上是严格的。
4. 弹出和阻塞:队列、栈、可靠传递的实现基础
leftPop、rightPop和它们对应的阻塞版本,是把List从“数据存储”升级成“消息通道”的关键。这一节的代码,值得你留在自己工程里当模板用。
4.1 leftPop和rightPop:取出并删除
java复制Object task = redisTemplate.opsForList().leftPop("task:queue");
if (task != null) {
// 执行任务
}
弹出操作是原子的——要么取出并删除,要么返回null。这也是为什么Redis List能当简单消息队列用:多个消费者同时弹同一个队列,不会出现两个消费者拿到同一个任务。有个隐藏的细节是:弹出后如果列表空了,Redis会自动删除这个key,所以你不会看到大量“空列表”残留。
4.2 带超时参数的版本:让消费者优雅等待
java复制// 阻塞5秒,如果5秒内没有元素可弹,返回null
Object task = redisTemplate.opsForList().leftPop("task:queue", 5, TimeUnit.SECONDS);
阻塞弹出是轮询的优雅替代方案。自己写while(true){ pop(); sleep(100); }是性能杀手,如果用带timeout的阻塞版本,Redis会在内部等待,等到数据来了或者超时了才返回,整个循环体的CPU占用几乎为零。
注意timeout参数的单位是TimeUnit,传0表示永不超时。我确实见过同事把0当“立刻返回”,结果生产上出现一堆线程无限阻塞。你要不想要阻塞,就直接用无参版本的leftPop,不要传0。这是容易送命的一个细节:阻塞永不超时会长期占用一个连接,如果消费者线程不够,可能把连接池打满。
4.3 rightPopAndLeftPush:可靠消息传递的平民方案
java复制Object rawMessage = redisTemplate.opsForList()
.rightPopAndLeftPush("task:queue", "task:processing");
rightPopAndLeftPush(sourceKey, destinationKey)做的事情是:从source右边弹出一个元素,同时把这个元素推入destination左边,整个过程是原子的。用这个操作,可以构建一个最简单的“确认机制”:
- 生产者往
task:queue右边推任务。 - 消费者执行
rightPopAndLeftPush("task:queue", "task:processing"),把任务从待处理队列挪到“处理中”队列。 - 处理成功后,从
task:processing里移除这个任务。 - 如果处理失败,可以从
task:processing里再读出来重新入队。
这样即使消费者进程崩溃,任务也不会丢——它还在task:processing队列里躺着,等下一次恢复后可以捞回来。
这个方法唯一的缺点是要维护两个key,且“处理中”的队列是多条线程公用的,你需要人工设计任务的唯一标识才能在失败后准确识别。我的做法是把消息体设计成JSON字符串,里面带一个UUID,这样无论它流转到哪个队列,都能trace到完整生命周期。
4.4 rotate系列:冷门但分页时很香
Spring Data Redis的ListOperations里还提供了rotate(Key, V value),它会执行一次RPOPLPUSH把最后一个元素挪到最前面,同时插入一个新元素。用来做“滚动展示”的场景很合适,比如榜单占位轮播。不过这个API的语义相对小众,普通业务里用到的概率不高,我提它是想让你知道:如果突然看见源码里有这么个方法,别慌,它就是把两端操作拼在了一起。
5. 三个高频业务场景的完整代码模板
API讲完了,其实还不足以直接干活。这里我给出三个实战场景的组合写法,都是可以直接抄进项目的骨架级代码。
5.1 用户最近浏览记录(带去重与容量限制)
java复制public void recordView(String userId, String skuId) {
String key = "user:history:" + userId;
Long size = redisTemplate.opsForList().size(key);
// 如果列表已经存在且该商品已在最近记录里,先移除旧的,再放到最前面
if (size != null && size > 0) {
redisTemplate.opsForList().remove(key, 0, skuId);
}
// 左推入最新浏览
redisTemplate.opsForList().leftPush(key, skuId);
// 仅保留最近50条
redisTemplate.opsForList().trim(key, 0, 49);
}
remove(key, count, value)是另一个顺手方法,count为0表示移除所有匹配的元素。这里的思路是“先删旧值再左推”,保证List里不出现重复skuId。trim(key, 0, 49)相当于只保留索引0到49的元素,超过50条的旧记录被直接丢弃,完美契合“最近浏览只留50条”的产品需求。
5.2 简易延迟队列(基于阻塞弹出)
这个场景要配合计划任务或异步线程使用:
java复制// 生产者
redisTemplate.opsForList().rightPush("delay:order:task", new OrderTask(orderId, executeAt));
// 消费者线程
while (running) {
OrderTask task = redisTemplate.opsForList()
.leftPop("delay:order:task", 2, TimeUnit.SECONDS);
if (task != null && task.executeAt <= System.currentTimeMillis()) {
// 执行任务
} else if (task != null) {
// 还没到时间,塞回去
redisTemplate.opsForList().rightPush("delay:order:task", task);
}
}
这里用rightPush入队、leftPop出队,天然保证先到期的任务先被取到。如果取到但没到期,再塞回队尾。这是一个“穷人版延迟队列”,架构上肯定没有专业延迟队列那么强,但我用它扛过每天几万条订单自动确认收货的定时扫描,减少了数据库轮询压力。
5.3 时间线分页读取
java复制public PageResult<Object> pageFeed(String userId, int page, int size) {
String key = "feed:timeline:" + userId;
int start = (page - 1) * size;
int end = start + size - 1;
List<Object> items = redisTemplate.opsForList().range(key, start, end);
long total = redisTemplate.opsForList().size(key) == null ? 0 : redisTemplate.opsForList().size(key);
return new PageResult<>(items, total, page, size);
}
分页的start和end计算必须用(page-1)*size到page*size-1,这是闭区间,和MySQL的LIMIT offset, count思维不一样。如果你记不住,干脆写个公共方法,专门做“page转range区间”,以后所有List分页都走它,杜绝手算出错。
6. 排错经验:opsForList用了一段时间后,我踩过的几个坑
这一节的内容可能比前面所有方法都值钱。因为这些坑,不在生产环境里摸爬滚打一遍,靠看文档是不可能发现的。
6.1 序列化器不一致导致“看不到数据”或“强转失败”
这是最让我头疼的一类问题。项目里有人单独配置了一个StringRedisTemplate用于部分缓存写入,结果这个List是StringRedisTemplate写的,读的时候却用默认RedisTemplate去读,取出来的值带着乱码,强转会直接炸。
解决方案其实很简单:一个工程里,List的读写必须固定同一个Template实例族。如果一定要混用,至少保证RedisTemplate的keySerializer和valueSerializer完全一致。你也可以观察Redis里存放的key前缀,StringRedisTemplate存的key是明文,默认RedisTemplate加上Jdk序列化后key会带\xAC\xED\x00\x05t\x00前缀,一眼就能定位是谁写的。
6.2 remove操作误删数据
java复制Long removed = redisTemplate.opsForList().remove("list:sku", 0, "sku_123");
remove的count参数:count大于0从表头开始移除count个匹配元素,count小于0从表尾开始移除count个匹配元素,count为0移除所有匹配元素。这个参数语义不仔细看文档,非常容易用错。我想只删一个重复值时,传了0,结果所有重复的都删了,数据瞬间少了一大截。
现在我的习惯是:除非明确要清空重复项,否则remove永远传1,从头部开始删。要删多条,就用循环。
6.3 range取出大批量数据时的内存风险
range(key, 0, -1)如果列表有几万条甚至几十万条,会一次性全部加载到JVM内存里,Redis不是问题,JVM先撑不住了。我的解决方案是:即使要遍历全量,也要分页range,比如每次取500条,处理完再取下一批。别图省事一把梭,线上内存告警就是这样来的。
6.4 阻塞弹出连接泄漏
前面提过timeout传0的坑,这里再补充一个场景:你用阻塞弹出做消费者,但是消费者的线程池被@Async默认策略限制得特别小,结果阻塞弹出一多,所有线程都卡在leftPop上,新任务排不进去,系统表现为“没有消费者在处理”。
我的对策是给阻塞弹出的消费者单独设置ThreadPoolTaskExecutor,保证核心线程数大于等于预期的消费者并发数,并设置keepAliveSeconds让空闲线程及时回收。同时,达到Timeout后返回null的分支一定要有日志,方便判断队列是不是持续为空,还是真的卡死了。
最后补一句:opsForList的定位,决定了你的架构复杂度
用redisTemplate.opsForList()做了三四个项目之后,我的感受是:它是一个真正好用但又容易用“脏”的API。它能把你的代码写得非常简洁,一个Redis操作完成入队出队加转移;但如果你没有搞清楚左右两端的方向、阻塞超时的语义、序列化的一致性,它也能让你的线上问题变得非常难排查。
我个人现在有一个习惯:在工程里为List相关操作封装一个统一的RedisListService,把push、pop、range、trim组合成语义明确的方法,比如offerToTail、pollFromHead、fetchLatestN。业务代码只管调用这些方法,底层再逐个去调整Redis操作的细节。这样即使哪天Redis结构要换成Kafka或者消息中间件,业务层的改动也小得多。
如果你正准备在自己的项目里用opsForList(),我的建议是:先别急着写业务代码,花半个小时把List作为一个双向链表的模型在脑子里过一遍,把这篇文里提到的边界条件都代码块跑一遍,再考虑怎么和你现有的业务结合。工具本身不难,难的是你用它的方式是不是匹配这个工具最擅长的形态。
