Redis用了这么多年,List算是五大基础类型里我用得最早也最频繁的一个。刚开始那会儿我直接把它当消息队列用,后来项目里又拿它做Feed流、最近浏览记录、异步任务池,总之就是各种“排队存取”的活儿它都干过。可别看它简单,真正想把“Redis存取List集合”这套玩明白,命令层面、Java客户端API层面、序列化层面都有不少容易栽跟头的地方。这篇就以List为线索,把存、取、消费、集群下的注意点,以及我踩过的坑一次说清。
1. 为什么是List:Redis五大基础类型里的“有序队列”担当
1.1 List的底层结构演进
很多人刚接触Redis时,会拿它和数据库表做对比。存一个List集合,放MySQL里大概是建一张子表,再按时间或某个ID排序查询;放到Redis里,其实就是一个key对应一条“线性的、可重复、按住入顺序排列”的元素列表。
Redis的List底层结构在3.2版本前后有一个明显的变化。3.2之前,当列表元素少、单个元素体积小的时候,Redis用压缩列表(ziplist)来节省内存;当元素变多或者元素变大时,自动切换成双向链表(linkedlist)。这种双轨制的设计,本质上是“省内存”和“快操作”之间的权衡。但双向链表本身有个问题:每个节点都要维护prev和next指针,节点一多,额外内存开销很可观,而且缓存局部性差。
3.2之后引入的quicklist,就是“用多个压缩列表串成一个双向链表”。每个节点内部是一段ziplist,节点之间再用指针相连。这样既保留了双向链表快速头尾操作的特性,又降低了节点跳转带来的内存碎片。再后来推出的listpack,继续优化了ziplist在级联更新上的性能问题。理解这个演进过程,对后面优化List存取性能有帮助:你不需要总是担心里面是链表还是数组,但要知道它是一个“双端均可操作”的结构。
1.2 List适合做什么,不适合做什么
在业务里,List最适合的场景有这几类:
- 轻量级队列:生产者往队尾写,消费者从队头读,天然FIFO。
- 时间线/Feed流:用户发帖后LPUSH进List,展示时LRANGE取前N条。
- 最新N条记录:比如“最近浏览的商品”“最近登录的设备”,插入新元素后配合LTRIM控制长度。
- 异步任务池:把待处理任务临时缓存到List,由多个worker并发消费。
不适合用List的场景也很明确:
- 需要去重的集合,应该用Set;
- 需要按分数排序的集合,应该用ZSet;
- 需要持久化、回溯、消费者组管理的高级消息场景,应该用Stream而不是List。
很多团队一上来就把List当万能队列用,等到需要“消费确认”“消息回溯”“消费者分组”时才发现List根本给不了这些能力,最后只能重构。List的优势在于简单、轻量、低延迟,这既是优点也是边界。
2. 从底层命令到Java API:List存取的核心全览
2.1 基础命令与RedisTemplate方法对应关系
不管用什么语言操作Redis,命令层面都是同一个标准。我用Java的RedisTemplate最多,先列一张常用命令和API的对照表,方便你随手查。
| Redis命令 | 作用 | RedisTemplate方法 |
|---|---|---|
| LPUSH | 从左侧(头部)插入一个或多个元素 | leftPush / leftPushAll |
| RPUSH | 从右侧(尾部)插入一个或多个元素 | rightPush / rightPushAll |
| LPOP | 从左侧弹出并移除一个元素 | leftPop |
| RPOP | 从右侧弹出并移除一个元素 | rightPop |
| LRANGE | 获取指定区间内的所有元素 | range |
| LINDEX | 按下标获取单个元素 | index |
| LLEN | 获取List长度 | size |
| LREM | 从List中移除指定个数的匹配元素 | remove |
| LTRIM | 只保留区间内的元素 | trim |
| BLPOP / BRPOP | 带阻塞地弹出头部/尾部元素 | leftPop / rightPop 带超时参数 |
这里有个特别容易忽略的点:leftPush和rightPush的语义代表“从哪个方向插入”。很多新同事会混淆,总觉得“push就是追加”。其实在Redis里,LPUSH是往头部塞,RPUSH才是往尾部塞。如果你用LPUSH往里写数据,再用LRANGE 0 -1查询,看到的结果顺序和插入顺序是反的,这经常造成“数据错乱”的假象。
2.2 阻塞命令的使用细节
消费者场景下,我强烈建议用BLPOP/BRPOP代替LPOP/RPOP。普通弹出命令在List为空时立刻返回null,如果你用循环不停调用,就会造成CPU空转。阻塞版本则会在没有元素时挂起连接,直到超时或有新元素进入。
BLPOP key timeout里的timeout单位是秒,设置为0表示无限等待。Java侧对应写法:
// 最多阻塞3秒 Object task = redisTemplate.opsForList().leftPop("task:queue", 3, TimeUnit.SECONDS); if (task == null) { // 超时没拿到任务 } else { // 处理任务 }如果你把阻塞时间设得太大,Redis连接会被长时间占用,在高并发下容易把连接池打满。这个坑我在4.2节会展开讲。
3. 实操演示:Redis List存取完整流程(Spring Boot + RedisTemplate)
3.1 环境准备与依赖引入
我这边最常用的组合是Spring Boot 2.x + spring-boot-starter-data-redis。无论你是用Docker启动Redis还是本机直接装,先把服务跑起来,然后用Redis Desktop Manager或者redis-cli查一下能不能连通。依赖就一个:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>配置文件application.yml里把连接信息写清楚:
spring: redis: host: 127.0.0.1 port: 6379 password: 123456 timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2密码没有就不填。值得提醒的是,生产环境千万别用默认端口和空密码,这个下面会专门讲。
3.2 配置序列化器:避免“每天一个乱码”
这是我见过新手踩得最多、也最坑的一个问题。Spring Boot默认的RedisTemplate,key和value都用的JdkSerializationRedisSerializer,你往Redis里存一个对象,肉眼在redis-cli里看到的就是一串乱码:
"\xac\xed\x00\x05t\x00\x04user"这种数据不仅不好排查,而且在跨语言、跨系统对接时很容易出问题。我自己的习惯是:key统一用StringRedisSerializer,value用Jackson2JsonRedisSerializer(或者GenericJackson2JsonRedisSerializer),这样存进去的是可读的JSON字符串。
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); Jackson2JsonRedisSerializer<Object> jsonSerializer = new Jackson2JsonRedisSerializer<>(Object.class); ObjectMapper om = new ObjectMapper(); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); jsonSerializer.setObjectMapper(om); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }这里的核心是“区分key序列化器和value序列化器”。很多人只改value,忘记改key,结果就是key还是带着一堆前缀字节。改完serializer后,清理掉之前写入的脏数据,否则旧key依然用Jdk反序列化,一取就报错。
3.3 写入List:实现“最近浏览记录”的存取
以一个非常常见的需求为例:记录用户最近浏览过的商品ID,每个用户保留最新50条。用List来做是最直接的选择。
@Resource private RedisTemplate<String, Object> redisTemplate; // 用户每次浏览商品时调用 public void recordView(Long userId, Long productId) { String key = "user:viewed:" + userId; // 从左侧写入,头部是最新的 redisTemplate.opsForList().leftPush(key, productId); // 只保留前50条,旧数据自动挤出 redisTemplate.opsForList().trim(key, 0, 49); }这里有个细节:leftPush之后立刻调用trim,可以把List的长度控制在50以内。如果你只 push 不 trim,时间长了List会无限膨胀,最终变成一个大Key,读取、删除都会变慢。
读取的时候按区间取:
// 取最近10条浏览记录 List<Object> recentList = redisTemplate.opsForList().range(key, 0, 9);注意range(0, 9)是“包含两端”,也就是最多取10条。Redis的List下标是从0开始的,range(key, 0, -1)表示取全部,这个操作要慎用。
3.4 读取并消费:从任务队列里取数据
另一个高频场景是把它当任务队列。生产者有Job进来就往List里塞,消费者用阻塞方式取。
// 生产者:任务进来,从尾部塞入 redisTemplate.opsForList().rightPush("job:queue", jobJson); // 消费者:从头部阻塞取出 String jobJson = (String) redisTemplate.opsForList() .leftPop("job:queue", 5, TimeUnit.SECONDS); if (jobJson == null) { // 队列空了,跳过 return; } // 反序列化并处理任务 handleJob(jobJson);很多团队在这个环节会纠结:到底用LPUSH还是RPUSH?我的习惯是“生产者RPUSH,消费者LPOP”,这样数据方向是FIFO的,第一个进队列的任务最先被处理。如果你想要LIFO栈式处理,就用“生产者LPUSH,消费者LPOP”。两种方向都能跑,关键是全队统一语义,别一会儿LPUSH一会儿RPUSH,最后数据顺序一团乱。
3.5 批量写入List的两种姿势
有时要一次性把数据库里查出来的1000条列表数据丢进Redis。两种常见姿势:
- 循环
rightPush,每条数据一次网络往返,写1000条就要1000个RTT,慢。 - 批量接口
rightPushAll,一次调用写入整个集合,性能好得多。
List<Product> productList = getFromDb(); List<Object> productJsonList = productList.stream() .map(this::toJson) .collect(Collectors.toList()); redisTemplate.opsForList().rightPushAll("product:list", productJsonList);如果一次性数据量太大,比如几万条甚至更多,即便用rightPushAll,也可能因为单条命令过大撑爆Redis的协议缓冲区。稳妥做法是分批写,每批1000到3000条左右:
List<List<Object>> partitions = Lists.partition(productJsonList, 1000); for (List<Object> partition : partitions) { redisTemplate.opsForList().rightPushAll("product:list", partition); }这个“分批”思路在处理大List初始化时特别实用。
4. 实战进阶:消息队列、分布式锁与性能优化
4.1 用List实现轻量级任务队列
用List做任务队列,最简单也最经典。我在一个后台项目里,就靠一个List承担了“报表异步生成”的任务分发:用户点击生成报表,接口立刻返回“生成中”,后台worker从List里消费任务,生成了再通知用户。
核心流程是:
生产端:生成任务 -> 序列化成JSON -> RPUSH到队列 消费端:BLPOP从队列取任务 -> 反序列化 -> 执行业务逻辑 -> 记录结果这里有几个注意点:
- 任务JSON的字段要固定,尽量是普通对象,不要塞一堆嵌套大对象,否则序列化和反序列化容易出兼容性问题。
- 消费任务时如果异常,不要直接吞掉,可以让任务入“重试队列”或者记录日志后手动补偿。
- 如果多个服务实例同时消费同一个队列,Redis的原子弹出命令可以避免重复消费。
再深入一点,为了保证“消息不丢”,比较朴素的方案是引入“待确认队列”:消费者LPOP拿到任务后,先放到另一个List里,业务处理完再删除,超时的任务重新回到主队列。但这套方案复杂度不低,如果消息可靠性要求很高,直接用Redis Stream或者引入消息中间件更合适。
4.2 List里的分布式小设计
热词里提到“Redis分布式锁”,日常中分布式锁最常见的做法是基于String的SETNX加过期时间,这个和List关系不大。但List可以在分布式场景中承担另一类职责:多实例任务竞争。
比如你有三个微服务实例,都去消费同一个List队列,由于LPOP是原子性的,同一个任务只会被一个实例拿走。天然形成了“多消费者竞争消费”的效果,省去了你手动设计分布式锁的麻烦。
不过要注意,如果某个消费者拿到任务后进程崩溃了,任务就会丢失。这种“至少一次”还是“最多一次”的语义,用List原生能力很难保证。我自己的原则是:List适合延迟不敏感、允许小概率丢失的异步任务;如果任务涉及资金、订单状态变更等强一致场景,一定要有补偿机制或者直接上专业的消息队列。
4.3 内存控制与性能优化
List用久了,最怕的就是“没人管”。我接手过一个模块,一个List里塞了几十万条消息,因为消费者挂了一个月,队列堆积到Redis内存几乎被打满。恢复消费后,光那一个大Key就拖慢了整个Redis实例。
控制方法无非这么几招:
- 写入时LTRIM:只保留最近N条,适用于Feed流、浏览记录。
- 消费端限流:如果生产速度远超消费能力,要么加消费者实例,要么限制生产速率。
- 设置过期时间:List支持TTL,如果整个列表在一段时间后没有意义,就
expire(key, 86400)让它自动清理。 - 用redis-cli定期体检:
redis-cli --bigkeys这条命令能扫描出哪些key是“大Key”,List的bigkey提示会明确告诉你有多少条元素。我在巡检习惯里每月跑一次,提前发现问题,比等线上报警后再处理省心得多。
5. 常见问题与排查技巧实录(踩坑合集)
5.1 序列化乱码和反序列化失败
现象很典型:用redisTemplate.opsForList().rightPush("key", userObj)存完之后,用range取出来,强转成User对象直接抛异常。
原因基本就是序列化器不统一。我在3.2节已经给出了配置,这里再强调一个细节:改完配置后,旧数据不会自动“变干净”。如果是测试环境,直接flushall;如果是生产环境,需要换一个新的key前缀,或者写个迁移脚本把旧数据读出来重新存一遍。
另一个大坑是JDK序列化后的数据跨系统读。比如你用Java写进去一个对象,想用Python读出来,默认序列化协议完全读不了。解决思路就一个:存JSON字符串,不存“Java对象”。这也意味着,value序列化器要固定为Jackson或Fastjson,再用的时候手动反序列化。
在代码里常见这样一段:
List<Object> list = redisTemplate.opsForList().range(key, 0, -1); String json = objectMapper.writeValueAsString(list); List<User> users = objectMapper.readValue(json, new TypeReference<List<User>>() {});这里反复出现的objectMapper.readValue(json, new TypeReference<List<User>>(){}),就是热词里jsonserializer.deserialize<list<object>>想表达的意思。Jackson的TypeReference能保留泛型信息,可以安全地把JSON数组转成具体的List类型。
5.2 increment() 报错 not an integer or out of range
热词里有“Java中redis使用redistemplate的increment()报错不是integer or out of range”。这个我排查过好几次,大多数情况是value里存的不是整数。
罪魁祸首通常是序列化配置不一致。假设你用StringRedisSerializer存了一个字符串"100",再调increment,理论上没问题。但如果value实际是JSON序列化后的对象字面量,比如{"count":100},那increment肯定报错。
排查思路很简单:
redis-cli get counter先看看这个key底下存的是什么。如果是被JSON包裹的字符串,要么换一个key重存一个纯数字,要么调整存储方式,确保递增操作的值是整数类型。
还有一种情况:存的是字符串“100”但带了不可见字符,比如前面有一个\u0000。这种肉眼极难发现,可以先把结果取出来打印字节长度,逐个定位。
5.3 List
这个也是热门问题。你从Redis里取出来的往往是一个JSON字符串,想转成List<Map<String, Object>>时,直接强转ClassCastException。
正确做法是用Jackson的TypeReference:
String json = redisTemplate.opsForValue().get("list:data"); List<Map<String, Object>> list = objectMapper.readValue(json, new TypeReference<List<Map<String, Object>>>() {});注意,不要用objectMapper.readValue(json, List.class),这样得到的List里每个元素是LinkedHashMap,Map里面的value类型也可能被推断成Integer或String,后续取数时如果期望的是别的类型就得手动转换。
5.4 消费端空轮询的问题
用非阻塞的LPOP循环取数,队列为空时Redis连接的QPS会飙升,抬高实例负载。以前有个同事写了个消费线程:
while (true) { Object task = redisTemplate.opsForList().leftPop("task:queue"); if (task != null) { process(task); } // 没有sleep,直接下一轮 }结果Redis CPU直接被打满。改成阻塞弹出或者加一个合理的sleep间隔后,负载立刻降了下来。阻塞弹出依然会占用连接,但不会产生高频命令。
while (true) { Object task = redisTemplate.opsForList() .leftPop("task:queue", 2, TimeUnit.SECONDS); if (task != null) { process(task); } // 拿到null说明队列空了,继续阻塞等待,不空转 }这里设置的2秒不是“轮询间隔”,而是最长阻塞时间。线程会挂起,等有新数据进来或者2秒超时后再试一次。
5.5 大List查询变慢
range(key, 0, -1)取所有数据,如果List有几万条甚至更多,单次查询会非常慢,还可能一次性占满客户端内存。更好的做法是分页查:
int pageSize = 20; long start = (page - 1) * pageSize; long end = start + pageSize - 1; List<Object> pageList = redisTemplate.opsForList().range(key, start, end);或者根本不要用List存储大量数据,只保留固定长度(配合LTRIM),需要全量时走数据库或其他存储。Redis不适合做海量数据全量查询的“主存储”,它更擅长做小数据量的高性能缓冲。
5.6 缓存过期与穿透
List和普通key一样可以设置过期时间。可是有些场景下,你只给key设置了过期时间,却没有缓存空结果,短时间并发量一大,key过期的一瞬间大量请求同时打到数据库,就是典型的缓存穿透。
缓解办法:
- 对“空结果”也做缓存,比如List为空时缓存一个空数组。
- 过期时间加随机值,防止同一时间集体失效。
- 热点key用逻辑过期或者互斥锁控制回源。
这些都是老生常谈,但真的在List这种结构上容易忽略,因为大家总想着“List是空的,缓存什么意义”,实际上它恰恰是穿透高发点。
Redis的List看着简单,真正要在项目里稳定地存取、消费,还得把序列化、方向、长度控制、过期策略这几个细节都处理好。我在实际项目中踩过不少次坑,尤其是序列化那部分,刚开始走了弯路,后来统一了Key和Value的序列化方式,线上问题少了一大半。如果你现在正准备在项目里用List存集合,建议先按文章里的配置把基础打好,再根据业务场景灵活选型。最后提醒一句:生产环境一定要给Redis设置强密码和必要的网络白名单,别等被扫描到再来补救。