尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

RedisTemplate操作List全解析:方法、场景与序列化避坑指南

发布时间:2026/9/29 17:58:06

资讯中心
01
ARTICLE

RedisTemplate操作List全解析:方法、场景与序列化避坑指南

RedisTemplate操作List全解析:方法、场景与序列化避坑指南
做Java后端的人只要项目里装了Redis基本都绕不过RedisTemplate这个入口。而在Redis的数据类型里List又是日常用得最多的之一任务队列、操作日志、站内信、最新动态全是它的典型场景。如果你刚下载安装完Redis正盯着数据类型不知道从哪里入手String和List大概率是你最先接触的两种。很多同学刚看到redisTemplate.opsForList()的时候面对一长串方法名直接发懵——leftPush、rightPush、leftPop、range、trim……到底哪个对应哪条命令什么场景该用哪个参数怎么填这篇文章就专门拆这件事。我会把ListOperations接口里的常用方法逐个讲清楚对照Redis原生命令说明原理再配合消息队列、最新列表、分页缓存三个实战场景最后把序列化和常见坑一起处理掉。适合刚上手Spring Data Redis的同学也适合写了不少但没深究过细节的人。先给个结论opsForList()返回的是ListOperations接口它封装了Redis List相关的全部操作方法名基本就是原生命令的直译。搞清楚这一点你就不需要死记API理解命令本身就会用了。1. 先搞清楚opsForList()到底是什么1.1 从Redis的List数据结构说起Redis的List本质上是一个双向链表结构。从概念上说每个key下面可以保存一个有序的字符串列表可以在头部Left插入删除也可以在尾部Right插入删除这两个方向的操作时间复杂度都是O(1)。所以它天生就适合做队列、栈和最近N条这类场景。底层实现在不同版本有一些演进。早期版本使用ziplist压缩链表来节省内存当列表元素增多或者单个元素变大时会转换为双向链表。后来Redis引入了quicklist本质上是把压缩链表和双向链表做了混合设计每个节点内部保存一段压缩的数据节点之间用指针连接。Redis 7.0之后quicklist的每个节点又从ziplist换成了listpack。这些底层细节我们日常使用不必死磕但理解列表是有序的、两端操作很快、中间操作相对更费这一点后面很多选择就顺理成章了。与String类型相比List能保存多个元素并且维持顺序与Set、ZSet相比List允许重复元素并且天然保留插入顺序。所以在我需要一个有序队列我需要取最新N条我需要按页读取一段数据这些场景下List往往是最直接的选择。我建议刚接触Redis的人先把这个数据类型和命令放一起理解而不是死记Spring的方法名。因为Spring Data Redis里的方法名基本就是Redis命令的直译比如leftPush对应LPUSHrightPop对应RPOP理解原生命令后写Java代码时几乎不用查API。1.2 opsForList()返回的是ListOperationsRedisTemplate是Spring Data Redis对外提供的统一操作入口它本身并不直接暴露List命令而是通过一种操作视图的方式把命令按数据类型分组。这些分组我们都很熟opsForValue()对应StringopsForHash()对应Hash而opsForList()对应List。这里有个值得理解的细节为什么不直接在一个类里把所有方法都写完而要分成这么多个ops原因很简单Redis的数据类型差异很大String就是单值读写Hash是字段级的操作List是双向队列操作ZSet是分数排序操作。如果全部塞进一个类接口会变得无比臃肿使用者也很难快速找到自己要的方法。按数据类型分组之后opsForList()拿到的就是一组专门操作List的接口语义非常清晰。opsForList()方法的返回值类型是ListOperationsK, V这个接口把所有与List相关的操作都封装成了Java方法。也就是说你平时写的redisTemplate.opsForList().rightPush(queue:task, task-1);实际上就等效于在Redis上执行了一条RPUSH queue:task task-1命令。理解这层包装关系很重要。因为当你怀疑某个方法执行效果不对时你随时可以用redis-cli直接敲原生命令对照着验证排查效率会高很多。ListOperations接口里大致可以按功能分为四类写入、弹出、查询、修改删除。下面我按这个顺序逐个拆。2. List操作方法逐个拆解从写入到删除2.1 写入类方法leftPush、rightPush与批量写入写入类最常用的是leftPush和rightPush分别对应Redis的LPUSH和RPUSH。简单说leftPush是把元素放到列表头部最左边rightPush是把元素放到列表尾部最右边。// 等效命令LPUSH queue:task task-1 redisTemplate.opsForList().leftPush(queue:task, task-1); // 等效命令RPUSH queue:task task-2 redisTemplate.opsForList().rightPush(queue:task, task-2);这两个方法的返回值是Long类型表示执行完这次写入后列表当前的长度。如果你需要判断列表长度比如写入后超过阈值就做清理这个返回值可以直接拿来用。例如Long len redisTemplate.opsForList().rightPush(user:feed:1001, article-1); if (len ! null len 100) { // 超过100条执行裁剪 redisTemplate.opsForList().trim(user:feed:1001, 0, 99); }再提醒一个细节leftPush和rightPush各有两个重载一个传单个元素另一个传可变参数或者Collection。批量版本的命名是leftPushAll和rightPushAll// 批量从头部插入 redisTemplate.opsForList().leftPushAll(queue:task, task-1, task-2, task-3); // 批量从尾部插入 ListString tasks Arrays.asList(task-4, task-5); redisTemplate.opsForList().rightPushAll(queue:task, tasks);使用leftPushAll时要注意插入顺序。比如依次传task-1、task-2、task-3最终列表从左到右是task-3、task-2、task-1因为每插一个都会成为新的头部。如果你希望最终顺序保持原样老老实实从尾部批量插入也就是rightPushAll。还有一个不那么常用但可能救命的方法rightPushIfPresent和leftPushIfPresent。它只在key已经存在时才执行写入如果key不存在什么也不做。这个方法适合列表只能追加一次之类的唯一初始化逻辑或者防止因为误操作给不存在的key创建出一个空列表。比如你想确认一个公告列表已经初始化过了如果没有初始化就不写入用这个就很安全。2.2 弹出类方法leftPop、rightPop与阻塞版本有写入自然有读出。leftPop从头部取出一个元素rightPop从尾部取出一个元素对应Redis命令是LPOP和RPOP。要点是弹出不只是读取而是取出后从列表中删除这是一个真正的消费操作。// 取头部元素并删除 String task redisTemplate.opsForList().leftPop(queue:task); // 取尾部元素并删除 String task2 redisTemplate.opsForList().rightPop(queue:task);如果key不存在或者列表为空返回null。注意不要和range那种只读不删的操作混淆。更实用的场景是阻塞弹出。Spring Data Redis提供了带超时时间的方法重载// 等效命令BLPOP queue:task 5 String task redisTemplate.opsForList().leftPop(queue:task, 5, TimeUnit.SECONDS);第一个参数仍然是key第二个参数是超时时间第三个是时间单位。它的行为是如果列表里没有元素线程会在这个key上阻塞等待直到有新的元素写入或者等待时间到。如果超时仍未等到返回null。Spring Data Redis 3.x还提供了Duration版本的重载写法更干净String task redisTemplate.opsForList().leftPop(queue:task, Duration.ofSeconds(5));阻塞弹出的价值在于它能避免每次轮询都打一次Redis的低效做法。举个例子如果用普通的leftPop去消费队列但队列恰好是空的这一秒内可能要发几十次请求完全是浪费。改成阻塞弹出后一个线程挂在那里等来一个消息处理一个Redis压力小很多。ListOperations里还有一个特别值得关注的方法rightPopAndLeftPush对应RPOPLPUSH以及它的阻塞版本对应BRPOPLPUSH。它的作用是原子地从一个列表尾部弹出元素再把它写入另一个列表的头部。这一招在可靠消息队列场景特别有用后面实战部分我会单独演示。2.3 查询类方法range、size、index查询是开发中最常见的操作。range方法对应LRANGE可以获取列表中指定范围内的元素返回ListV。// 取所有元素 ListString all redisTemplate.opsForList().range(queue:task, 0, -1); // 取前10条 ListString firstPage redisTemplate.opsForList().range(queue:task, 0, 9);这个方法有两个关键点。第一索引从0开始start和end都是闭区间也就是说range(key, 0, 9)会返回下标0到9的10个元素。第二索引支持负数-1代表最后一个元素-2代表倒数第二个。所以range(key, 0, -1)是取整个列表range(key, -10, -1)是取最后10个。这一点和Java的List.subList的语义有细微区别subList是左闭右开而Redis是双闭区间写代码时很容易踩坑。size方法对应LLEN返回列表长度返回类型是LongLong size redisTemplate.opsForList().size(queue:task);index方法对应LINDEX返回指定下标位置的元素。下标超出范围时返回null下标为负时从尾部开始找String first redisTemplate.opsForList().index(queue:task, 0); String last redisTemplate.opsForList().index(queue:task, -1);另外在新版Spring Data Redis中你可以用indexOf方法来查找元素第一次出现的下标对应Redis 6.0引入的LPOS命令。不过这个方法在项目里用得不是很频繁知道有它就行真到用的时候查一下接口签名即可。2.4 修改和删除trim、remove、settrim方法对应LTRIM它只保留指定范围内的元素范围之外的全部删除。这个方法是做只保留最近N条这类功能的利器。// 只保留下标0到99的元素其余删除 redisTemplate.opsForList().trim(user:feed, 0, 99);注意trim也是双闭区间索引同样支持负数。比如trim(key, -5, -1)可以只保留最后5条元素。remove方法对应LREM它按照value来删除元素。参数是count和valueLong removed redisTemplate.opsForList().remove(queue:task, 1, task-1);count在这里控制的不是删除的总数而是删除的方向和数量。总结一个口诀count大于0从列表头部开始往尾部数最多删除count个count小于0从列表尾部开始往头部数最多删除count的绝对值个count等于0删除列表中所有等于value的元素。返回值是实际删除的数量。set方法对应LSET它的作用是把指定下标位置的元素替换成新值redisTemplate.opsForList().set(queue:task, 0, new-task);这个方法和普通的写入不一样它不能用来新增元素。如果下标超出当前列表范围Redis会报错Spring会抛出一个运行时异常。所以在调用set之前最好先用size确认一下列表长度或者用index先判断当前位置能不能取到值。这一组方法里remove的value匹配是精准匹配不是模糊匹配。如果想按条件批量删除比如删掉所有包含failed的操作记录那就只能先range出来再在业务层过滤然后用remove按具体值删没法靠一条命令完成。3. 三个高频业务场景的落地写法3.1 任务队列rightPush加leftPop实现FIFO最简单的队列模型是生产者往列表尾部写入任务消费者从列表头部取任务。只要统一方向就能保证先进入的先被消费也就是FIFO。生产者侧redisTemplate.opsForList().rightPush(order:pay:queue, orderId);消费者侧String orderId redisTemplate.opsForList().leftPop(order:pay:queue, 5, TimeUnit.SECONDS); if (orderId null) { // 没有任务等待下一轮 return; } process(orderId);这里有两个细节值得展开。第一写入和弹出的方向必须相反。如果生产者用rightPush消费者必须用leftPop反过来生产者用leftPush消费者用rightPop。如果两边都从同一个方向取那这个结构就变成栈了后进先出任务执行顺序会完全颠倒。有些团队写队列代码时没有刻意统一方向结果线上任务处理顺序和预期不一致查了半天才发现是push和pop的方向配错了。第二尽量使用阻塞弹出而不是普通的leftPop。普通leftPop在队列为空时立刻返回null如果你的消费者是一个while循环你会看到它在一秒内疯狂地发请求到RedisCPU和网络开销都不小。阻塞版本相当于把等待逻辑下沉到了Redis里没有消息时连接挂起有消息或者超时时再返回。虽然阻塞期间占着一个连接但相对空轮询来说综合成本低得多。当然用List做队列也存在天然的限制没有消费确认机制。消息被leftPop弹出后如果业务处理失败或者进程崩溃这条消息就丢了。真要追求可靠投递可以用刚才提到的rightPopAndLeftPush方案先弹出到backup列表处理成功后再删除失败就从backup列表重新投递。// 从task列表取出原子地写入backup列表头部 String task redisTemplate.opsForList().rightPopAndLeftPush(task:queue, task:backup); if (task ! null) { try { process(task); // 处理成功后从backup列表移除 redisTemplate.opsForList().remove(task:backup, 1, task); } catch (Exception e) { // 失败的任务留在backup里后续重新入队 log.error(task process failed: {}, task, e); } }这个模式比直接pop多了一个中间态代码稍微复杂但能把消息丢失变成一个消息可能重复但不会丢的问题。对于订单通知、支付回调这类敏感场景多写几行代码非常值得。3.2 最新N条动态leftPush加trim如果业务里需要展示最新10条公告最近30条操作日志这类数据与其从数据库反复查不如用List维护一个滚动窗口。每次产生新数据时从头部插入一条然后用trim把超出窗口范围的老数据削掉String key notice:latest; redisTemplate.opsForList().leftPush(key, noticeJson); redisTemplate.opsForList().trim(key, 0, 29);两步操作之后这个key里始终只有最新的30条记录旧数据被自动淘汰。读取的时候直接range全部ListString latestNotices redisTemplate.opsForList().range(key, 0, -1);用leftPush而不是rightPush是因为最新数据要排在最前面读取时不用再翻转。trim的索引是按当前列表算的0是头部也就是刚插入的数据所以trim(key, 0, 29)保留的是最新30条。如果这里误用了rightPush你还得把trim改成trim(key, -30, -1)否则会保留旧数据丢掉新数据逻辑完全反掉。这个模式最适合插入频率高、读取频率也高、但数据总量不需要很大的场景。比如站内信摘要、服务器告警列表、支付回调日志都很合用。唯一的注意点是leftPush和trim不是原子操作极端并发情况下可能出现一瞬间列表长度超过30但下一次操作马上又会被trim掉。对最终一致性要求不严格的场景这完全不是问题如果要求严格一致可以考虑用Lua脚本把两步包在一起。3.3 分页列表缓存range的边界计算把数据库表的数据缓存成一个List再按页查询是很多团队会做的事。比如公告列表、商品列表查询条件固定但数据量大一点用List缓存后可以做到页级读取String key notice:page; if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) { ListNotice list noticeMapper.selectAll(); redisTemplate.delete(key); redisTemplate.opsForList().rightPushAll(key, list); } int start (currentPage - 1) * pageSize; int end start pageSize - 1; ListNotice pageData redisTemplate.opsForList().range(key, start, end);这里最需要小心的就是end的计算。很多人写成currentPage * pageSize在Redis的双闭区间语义里这样会多取一条数据。比如第一页10条range(key, 0, 10)实际上会返回下标0到10一共11条。牢记end start pageSize - 1。另一个常见问题list里存的对象反序列化问题。如果list里存的是Notice对象你要确认RedisTemplate的value序列化方式是JSON否则取出来看到的可能是一串Java序列化对象类型转换直接报错。这个我下一章专门讲。再一个限制是这种缓存方式适合数据总量可控的场景。如果list里有一百万条range(key, 0, -1)就会把整个列表一次性拉回内存这操作很危险。即便只是按页rangeRedis在生成子列表时也需要从节点里遍历页数越深成本越高。真到了这个量级就不适合用List做全量分页了更合理的做法是只缓存热门前几页或者换用ZSet按时间/分数分页。4. 序列化配置不搞定这个opsForList就是一团乱码4.1 默认JDK序列化的坑如果你新建一个Spring Boot项目什么都不配置直接用RedisTemplate存List大概率会在redis-cli里看到一串类似\xac\xed\x00\x05t\x00\x03foo的乱码。这是默认JdkSerializationRedisSerializer干的。默认行为带来的问题有三层。第一key和value都用Java序列化存进Redis后肉眼不可读生产环境查询数据非常痛苦第二Java序列化后的体积远大于原始数据明明只存一个短字符串却可能占用几十倍的存储空间第三跨语言不通用如果后续有其他语言的服务要读同一个key解析Java序列化内容是件非常麻烦的事。所以凡是用RedisTemplate存业务数据第一件事就是把序列化器换掉。这一点在官方文档里其实没有特别强调但几乎每个生产事故排查到最后都跟它有关。4.2 推荐配置方案我的常规做法是key用StringRedisSerializervalue用GenericJackson2JsonRedisSerializer。配置如下Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, 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用一个人类可读的字符串比如queue:task、user:feed:1001方便在redis-cli里直接查value用JSON体积可控、跨语言友好而且存储的数据自带类型信息。这里的hashKeySerializer和hashValueSerializer也要一起设置否则使用opsForHash时又会踩同样的坑。如果你整个应用里的value都是简单字符串更省事的做法是直接用StringRedisTemplate。它的key和value默认都是字符串序列化不需要额外配置。缺点是不支持直接存对象需要手动做JSON转换。所以我一般在纯字符串场景用StringRedisTemplate在对象场景用自定义的RedisTemplate。4.3 新版Spring Data Redis的几个变化使用较新的Spring Data Redis版本时要注意两个变化。第一GenericJackson2JsonRedisSerializer的默认构造器在新版本中不推荐使用官方更建议传入自定义的ObjectMapper。原因是默认构造器会开启DefaultTyping反序列化时依赖JSON中的class字段还原类型。这在大多数场景够用但如果你的项目对安全要求高希望严格约束允许反序列化的类型就应该自己构建一个ObjectMapper并设置activateDefaultTyping的访问范围。第二很多方法的参数从long timeout, TimeUnit unit增加了Duration重载比如leftPop(key, Duration.ofSeconds(5))。老代码不用改新代码建议直接写Duration语义更清晰也少一层时间单位换算。另外提醒一句RedisTemplate的泛型声明最好和实际数据一致。比如你声明成RedisTemplateString, Notice那opsForList().range返回的就是ListNotice代码写起来干净如果你声明成RedisTemplateString, Objectrange返回的是ListObject取出后往往需要手动强转或做instanceof判断。具体业务里自己权衡我倾向于对象与List泛型保持一致避免一堆类型转换代码。5. 常见问题与排查速查表5.1 key乱码、取不到数据如果发现redis-cli里看到key是一长串乱码几乎可以肯定是序列化配置问题。排查三步走先确认用的是不是自定义的RedisTemplate再看keySerializer有没有设置成StringRedisSerializer最后检查调用方有没有往RedisTemplate里塞了非String的key对象。这里有一个特别容易踩的坑如果项目里同时存在多个RedisTemplate Bean比如一个由Spring Boot自动装配一个是自定义的注入时一定要用Qualifier指定名字否则很可能注入到默认的那个所有配置白做。我之前就遇到过类似的线上问题代码里配了一个很完美的RedisTemplate结果因为注入的时候没指定Bean名字实际用的还是自动配置的默认模板list里的数据在redis-cli里怎么看都是乱码。5.2 List里的对象变成LinkedHashMap用GenericJackson2JsonRedisSerializer存对象时反序列化偶尔会得到LinkedHashMap而不是原来的类型。常见原因是同一个key里混存了不同类型或者泛型里用的是Object。比如你一个list里先放了Notice又放了User反序列化时JSON里的class信息可能匹配不上预期的类型Spring只能退回Map结构。解决方向有两个一是让一个key只存一种类型保持数据纯净二是自定义ObjectMapper把允许的类型明确注册进来。存取时做好类型判断能少踩很多莫名其妙的坑。5.3 阻塞弹出的连接池占用用leftPop(key, timeout, unit)这类阻塞方法时需要清醒认识到阻塞期间这个线程占用着一条Redis连接。如果消费者很多而连接池上限又很小比如Jedis连接池maxTotal只有8你开了10个消费者线程做阻塞弹出剩下2个会一直排队等连接其他非阻塞操作也会被拖慢。解决办法是给阻塞消费单独预留连接池或者合理评估消费者线程数和连接池大小。使用Lettuce时也可以考虑为阻塞操作单独配置线程池资源和连接工厂避免互相影响。这个坑在开发环境不明显因为流量小、连接不紧张一旦上生产并发一上来问题立刻暴露。5.4 消息丢失、重复消费怎么防List做队列最大的隐患是消息一旦pop出来就没了。进程正常返回还好进程崩溃、业务抛异常没来得及处理这条消息就消失了。对策是两种一是业务处理器自己保证幂等比如处理前先查订单状态已经处理过就直接返回二是用rightPopAndLeftPush加backup列表把消息的取出和删除分成两步只有确认成功后才会真的删掉。我的经验是能幂等就幂等幂等不了的场景再加backup队列两者配合最稳。比如电商场景的支付回调处理天然适合幂等因为订单状态会先落库重复消费时检查到已支付就直接返回成功根本不会重复发奖。而一些没有状态标记的第三方通知任务就必须用backup队列兜底了。5.5 remove、range、trim这类方法最容易记错的点把最容易记混的语义集中成一张表方法Redis命令最容易记错的地方range(key, start, end)LRANGEstart和end是闭区间会包含end下标trim(key, start, end)LTRIM同样是闭区间保留的是这段范围内的元素remove(key, count, value)LREMcount大于0从头删小于0从尾删等于0全删set(key, index, value)LSET只能更新已有元素越界直接报错index(key, index)LINDEX负索引从尾部算越界返回null这张表我建议保留在手边。平时看代码不觉得真到写的时候闭区间和count符号这两处我见过太多次线上小事故都是从这两个细节来的。还有一次同事用range做分页把end算成了page * size页面一直多一条数据排查了很久才发现是双闭区间的问题。最后再分享两个我自己的习惯。第一不管用List里的哪个方法先在redis-cli里用原生命令把数据结构和预期核对一遍十个怪问题里有九个都是序列化和索引没对上。第二List虽好别把所有队列都塞进去真正需要消息不丢、消费确认、重复投递的业务还是建议上Redis Stream或专业消息队列。List最适合的场景永远是要一个简洁有序的列表用对了它特别顺手用超范围了反而会给自己找麻烦。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。