Redis List集合存取实战:序列化、反序列化与长度控制全攻略
2026/9/11 11:32:24 网站建设 项目流程

做了几年 Redis 项目落地,我发现自己被问得最多的问题不是“Redis 怎么安装”,也不是“Redis 怎么搭集群”,而是非常朴素的一句:我要把一个 List 集合存进 Redis,应该怎么存、怎么取?这个问题看起来简单,真踩进去就会发现里面全是细节——同一个 key 存进去再读出来,怎么就成了 LinkedHashMap?为什么用 Redis Desktop Manager 一看,value 里全是乱码?为什么列表越存越长,最后把内存吃爆?所以这篇不打算讲八股,就从“存取 list 集合”这个具体动作出发,把序列化、写入、读取、长度控制这些环节全部拆开讲一遍,方便你直接照着做。

1. 先分清你要存的是“Redis 的 List”还是“Redis 里的一条 List 字符串”

1.1 Redis 的 List 在底层到底是什么

很多 Java 开发想当然地把 Redis List 理解成 Java 里的 ArrayList,这是第一个误区。Redis 的 List 本质是一个链表结构,早期版本是 linkedlist 和 ziplist 组合,从 3.2 开始合并成 quicklist,Redis 7.0 之后又把部分节点编码换成了 listpack。不管是哪种编码,它的核心特征都是:从两端插入、弹出是 O(1),但按 index 随机访问是 O(N),因为它要从链表头或链表尾一步一步走到目标位置。

这个特性决定了它跟 Java 集合的使用方式完全不同。Java 里你拿List<Integer>通常是拿来随机访问,list.get(10)这种操作很常见;但 Redis List 更适合的场景是“消息管道”“最近 N 条记录”“待消费任务队列”这类数据流。你把一个 Java List 直接塞进去,或者把每个元素当作 Redis List 节点推入,实际效果和适用场景差很远。

1.2 “存取 List 集合”这个需求,可以拆成三种不同的做法

我复盘过很多项目里的用法,发现“存取 list 集合”这句需求背后,其实有三种完全不同的技术方案:

  • 整个集合作为一个字符串 value 存:比如SET user:list "[\"张三\",\"李四\"]",或者直接用 RedisTemplate 把 Java 对象序列化后塞进一个 string 类型的 key。优点是简单,缺点是它压根不是 Redis List 数据结构,用不了 lpush、rpush、lrange 这些操作。
  • 将集合里的每个元素序列化成字符串,分别作为 Redis List 的一个节点:这才是真正在操作 Redis 的 List 类型,可以左进右出、可以做队列、可以用 lrange 分段读取。
  • 用多个 string key 存元素,再用 Set 或 ZSet 维护顺序索引:这种写法很少见,一般是为了绕过 List 的某些限制,但对大多数人来说没有必要。

这篇文章主线是第二种。我在实际项目中对比过,如果只是临时缓存一个接口列表、一次性取出来用,第一种方案也够用;但如果你想做消息队列、最近记录、分页读取这些事,第二种才是正经做法。

2. 序列化选型是存取 List 集合的重灾区,动手前先把方案定下来

2.1 为什么默认的 RedisTemplate 会把数据变成乱码

如果你是 Spring Boot 项目,直接用RedisTemplate而不做任何配置,value 的序列化器默认是JdkSerializationRedisSerializer。当你执行:

redisTemplate.opsForValue().set("user:list", userList);

Redis 里存的其实是 Java 原生序列化后的二进制流。用 Redis Desktop Manager 看 key,value 是一段\xAC\xED\x00\x05t...的开头,完全没有可读性。如果对象没有实现Serializable,写入直接抛NotSerializableException

Jdk 序列化最大的问题不只是乱码,还包括:

  • 序列化后的体积大,尤其对象内部还有嵌套字段时,膨胀得厉害;
  • 跨语言调用时基本没法用,Java 序列化格式只有 Java 自己认识;
  • 一旦对象的类结构变化,老缓存反序列化很容易失败。

所以我的习惯是:不管项目大小,只要会上 Redis,第一件事就是统一配置序列化器。

2.2 不同序列化方案的对比

我把项目里常见的 4 种方案放在一起对比:

方案可读性是否有原生 List 操作反序列化难度适用场景
默认 Jdk 序列化高,需要对象 Serializable内部临时数据,不推荐
GenericJackson2JsonRedisSerializer低,但 JSON 里有 @class 类型信息对象结构稳定时的通用方案
StringRedisTemplate + 整体 JSON 字符串一次性读取的接口缓存
StringRedisTemplate + 每条元素 JSON 串推荐,最可控

这几种方案里,GenericJackson2JsonRedisSerializer的优点是很省事,它会在序列化时把 Java 类的信息写到 JSON 里,反序列化时能自动还原成正确的对象类型。但坑也在这里:一旦你把实体类改名、移动包路径、改动字段结构,旧的缓存数据因为里面记着老的类路径,可能直接解析失败。

所以我个人最推荐最后一种:用StringRedisTemplate,key 用普通字符串,value 由我们自己决定用什么格式。存取对象列表时,每个元素单独序列化成 JSON 字符串,再作为 Redis List 的一个节点 push。这样排查数据时一眼能看懂,出现问题时可以直接在 redis-cli 里lrange看原始数据,完全不需要依赖 IDE 反编译。

2.3 如果你还是想用 RedisTemplate,配置也不难

如果你项目里其他地方已经大量使用了RedisTemplate<Object, Object>,或者团队统一要求走 RedisTemplate,那至少要把序列化器配置对。下面这段配置是我项目里的基础版:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }

这里 key 用StringRedisSerializer,保证不会出现乱码 key;value 用GenericJackson2JsonRedisSerializer,保证对象能转成 JSON 而不是二进制。这个配置只能解决一部分问题,真正的 List 对象存入和取出仍然要注意泛型擦除,我在第 4 节会重点说。

3. 写入环节:逐个 push 才能真正发挥 Redis List 的本事

3.1 rightPush 和 leftPush 到底选哪个

当你想把数据真正存成 Redis List 时,核心 API 就是opsForList()下的几个方法。其中最容易混淆的就是rightPushleftPush

记住一句口诀:rightPush 就是往尾巴上追加,leftPush 就是往头上插。

  • 列表是顺序展示的数据,比如用户浏览记录、操作日志,用 rightPush,因为后产生的数据在右边。
  • 如果要做的是任务队列,生产者用 leftPush 往左塞,消费者用 rightPop 从右边取,这就是先来先服务的 FIFO 队列。
  • 如果要用 List 模拟栈,那就 leftPush + leftPop,右进右出也行,反正取和存放在同一端。

实际代码长这样:

// 往列表尾部追加一个元素 stringRedisTemplate.opsForList().rightPush("user:login:list", "10001"); stringRedisTemplate.opsForList().rightPush("user:login:list", "10002"); // 从头部插入一个元素 stringRedisTemplate.opsForList().leftPush("task:queue", "{...}");

3.2 把整个集合一次性写入,而不是循环 push

很多人第一次写批量数据时,会写一个 for 循环,一条一条 rightPush。数据量少感觉不出来,一旦有一千条、一万条,每一条都是独立网络 RTT,性能会差一个数量级。正确的姿势是用rightPushAll

List<User> userList = userMapper.selectList(...); List<String> jsonList = userList.stream() .map(JSON::toJSONString) .collect(Collectors.toList()); // 先删旧key,再写入新数据 stringRedisTemplate.delete("biz:user:list"); stringRedisTemplate.opsForList().rightPushAll("biz:user:list", jsonList); stringRedisTemplate.expire("biz:user:list", Duration.ofMinutes(30));

这里有几个细节值得解释:

第一,rightPushAll第二个参数接收的是String...可变参数,也可以传一个Collection<String>,所以集合可以直接传进去。第二,如果列表里可能有个别元素是 null,需要提前过滤,否则序列化时会出问题。第三,deletepush不是原子的,如果两个线程同时执行这一段逻辑,可能出现先删除的线程还没 push 完,另一个线程就已经开始读取,读到一半空数据,形成并发脏读。这个问题我在第 6 节还会提。

如果数据量特别大,比如一次要 push 几万条,rightPushAll仍然可能因为阻塞 Redis 而出现超时,更好的做法是用 pipeline 批处理:

stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> { byte[] rawKey = "biz:user:list".getBytes(StandardCharsets.UTF_8); for (User user : userList) { byte[] value = JSON.toJSONString(user).getBytes(StandardCharsets.UTF_8); connection.rPush(rawKey, value); } return null; });

pipeline 的核心原理是客户端先把一批命令发到 Redis,而不是每条命令都等一次往返。我在生产中实测,1 万条元素入库,普通循环大概花费几百毫秒到一秒多,pipeline 基本在百毫秒以内。

3.3 写入时最容易忽略的两个问题

第一个是超时时间。List 和 String 一样,需要单独设置expire。千万不要觉得“列表数据反正还会更新,不设过期时间也行”。线上我见过一个报表模块,每天往同一个 List key 里追加数据,忘了设置 TTL,一个月之后这个 key 占了几个 GB 内存,最后只能手动清理。如果业务允许,建议无论读写都带上过期时间,或者至少做一次定期清理。

第二个是对象里的时间字段格式。如果直接把LocalDateTime这类对象交给JSON.toJSONString,不同的 JSON 库序列化出来的格式不同。你写入时用的是LocalDateTime,读取时老数据里的字符串可能已经变成"2024-01-01 10:00:00",新数据是带毫秒和时区的格式,一旦同一个 key 混入新旧两种格式,反序列化会非常难受。项目里最好统一用一个全局的 JSON 配置,比如JSON.toJSONStringWithDateFormat(obj, "yyyy-MM-dd HH:mm:ss")

4. 读取环节:还原成 List<T> 的通用姿势和三类典型报错

4.1 用 StringRedisTemplate 读取的基础代码

如果你按照第 3 节的方式,每个元素都是独立 JSON 字符串,那么读取就非常简单:

List<String> jsonList = stringRedisTemplate.opsForList().range("biz:user:list", 0, -1); List<User> userList = jsonList.stream() .map(json -> JSON.parseObject(json, User.class)) .collect(Collectors.toList());

这里的range(key, 0, -1)表示从头取到尾,也就是读取整个列表。如果列表很长,建议只读取需要的区间,比如range(key, 0, 19)就是取前 20 条。

除了 range,下面这几个读取类 API 也很常用:

// 获取列表长度 Long size = stringRedisTemplate.opsForList().size("biz:user:list"); // 获取某个下标位置的元素 String element = stringRedisTemplate.opsForList().index("biz:user:list", 0); // 从左边弹出一个元素(取出后会从列表删除) String task = stringRedisTemplate.opsForList().leftPop("task:queue"); // 阻塞式弹出,队列没数据时最多等10秒 String task = stringRedisTemplate.opsForList().leftPop("task:queue", 10, TimeUnit.SECONDS);

如果你确实要拿整个列表做缓存查询,range(0, -1)没问题;但如果只是想知道列表里有没有数据,用size()判断就够了,别把全量数据先拉一遍再数数,白送一次网络大包传输。

4.2 为什么反序列化时会报 LinkedHashMap cannot be cast

这是 Redis 存取 List 集合时最典型、最常见的一个报错:

java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class com.example.User

问题根源其实是 Java 泛型擦除。当你写出下面这种代码时,Jackson 在反序列化阶段只能看到List.class,并不知道 List 里装的到底是什么元素:

// 反例,看起来没问题,一运行就报错 List<Object> list = objectMapper.readValue(json, List.class); User user = (User) list.get(0);

Jackson 碰到没有指定元素类型的 List,默认会把每个 JSON 对象转成LinkedHashMap,所以强制转换成User必然失败。

用 Fastjson 或者 Jackson 的正确姿势,都必须显式带上元素类型:

// Fastjson 方案 List<User> userList = JSON.parseArray(json, User.class); // Jackson 方案 List<User> userList = objectMapper.readValue(json, new TypeReference<List<User>>() {});

泛型嵌套的情况更是如此,假如你是List<Map<String, User>>这种复杂结构,必须用TypeReference<List<Map<String, User>>>() {}才能保证内层类型不丢。

还有一个技巧,适用于已经有 RedisTemplate 存进去、读出来变成 List<Object> 的项目:如果你拿到的List<Object>里每个元素实际是 LinkedHashMap,可以用“先序列化成 JSON,再反序列化成目标类型”的办法绕过去:

List<Object> rawList = redisTemplate.opsForList().range("biz:user:list", 0, -1); String json = JSON.toJSONString(rawList); List<User> userList = JSON.parseArray(json, User.class);

这个方法不算最优,但在处理历史脏数据时极好用。

4.3 读取阶段的典型报错排查表

现象根因解决方案
读到一半报 ClassCastException,元素变成 LinkedHashMap反序列化时未指定泛型使用 TypeReference 或 JSON.parseArray
Redis 管理器里见到的 key 是乱码RedisTemplate key 序列化配置不对keySerializer 改成 StringRedisSerializer
读出来是完整 JSON 字符串,但对象字段被丢弃反序列化时没有匹配到对应字段检查 JSON 序列化时是否忽略了 null,以及字段名是否一致
项目把实体类包路径从 A 移到 B,旧缓存全部失败GenericJackson 序列化时保留了旧类路径清理旧缓存,或者改用 StringRedisTemplate 手写 JSON
用默认 RedisTemplate 存了对象,没有实现 Serializable默认序列化器无法处理换 GenericJackson 或 StringRedisTemplate

5. 列表无限增长是定时炸弹:长度控制、分页读取与过期策略

5.1 用 trim 把列表限制在合理大小内

Redis 的 List 不会像 MySQL 那样自动清理旧数据,它会一直增长,直到你把 Redis 内存吃满。一个常见业务场景是“用户最近浏览记录”,这种列表通常只需要保留最近 100 条。当你用 rightPush 写入新记录后,紧跟着调一次trim,就可以把列表裁剪到指定范围:

String key = "user:recent:" + userId; stringRedisTemplate.opsForList().rightPush(key, productId.toString()); stringRedisTemplate.opsForList().trim(key, 0, 99);

lltrim key 0 99的意思是只保留下标 0 到 99 的元素,其他全部删掉。这样列表永远最多 100 条,内存占用固定在一个很小的范围内。这个思路特别适合“最近 N 条”“Top N 条”这类需求。

5.2 分页读取的正确姿势

List 本身支持lrange key start stop,所以很多人直接用 Redis 做分页:

// 第 1 页,每页 20 条 List<String> page = stringRedisTemplate.opsForList().range("biz:user:list", 0, 19); // 第 2 页 List<String> page2 = stringRedisTemplate.opsForList().range("biz:user:list", 20, 39);

在小数据量场景下这样没问题,但你要知道:底层是链表,lrange要找到第 20 个位置,得从头步进过去,越到后面的页,耗时越长。如果列表只有几百条,肉眼无感知;如果到了几万条,翻到后面的页会明显变慢。

所以如果确实要维护一个超大列表,我一般会评估一下业务场景,考虑是否改用 ZSet。ZSet 通过 score 来排序,按 score 范围做分页,底层是跳表,性能稳定得多。List 更适合短列表,比如几百几千条这种,别拿它硬扛几十万的量。

5.3 一次性全量读取的代价

很多人图省事,不管列表多长都range(key, 0, -1),取完再在内存里做筛选。这个习惯很危险。一次返回 5000 条 JSON 字符串,转成对象就要花时间,网络传输也要时间,响应时间自然上不去。更严重的是一旦列表膨胀到几万、几十万,这个接口可能直接超时。

比较好的做法是:

  • size看一下列表长度;
  • 根据接口需要,只range出需要的区间;
  • 如果列表确实特别大,考虑在写入时做拆分,比如按用户维度拆 key,而不是一个 key 塞下所有用户数据。

5.4 过期时间与主动淘汰

给 List key 设置 TTL 是推荐做法,但有一个细节容易忽略:rightPush后设置expire,如果列表本身已经有 TTL,每次写入不会自动刷新 TTL。也就是说,如果一个 key 的 TTL 是 10 分钟,写入时已经过了 8 分钟,那么还剩 2 分钟就会过期,而不是重新从 10 分钟开始计时。所以每次 push 后都主动重新设置一次过期时间,行为才可控。

stringRedisTemplate.expire(key, Duration.ofHours(2));

如果你的项目里有“热点列表”和“冷数据列表”之分,也可以考虑用懒删除策略:读取时发现列表为空或已过期,再回源数据库查询并重建缓存。这个逻辑通常放在一个封装方法里,避免每处都重复写。

6. 一整套可直接落地的 List 缓存封装与项目复盘教训

6.1 一个通用的 ListCacheHelper

下面这段代码是我在业务项目里经常使用的封装,基于StringRedisTemplate,适合大多数“List 集合存取”的业务场景:

@Component public class ListCacheHelper { private final StringRedisTemplate stringRedisTemplate; public ListCacheHelper(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } /** * 往列表尾部追加一个对象,并刷新过期时间 */ public void rightPush(String key, Object obj, long timeout, TimeUnit unit) { if (obj == null) { return; } stringRedisTemplate.opsForList().rightPush(key, JSON.toJSONString(obj)); stringRedisTemplate.expire(key, timeout, unit); } /** * 读取列表并转换成目标对象集合 */ public <T> List<T> range(String key, long start, long end, Class<T> clazz) { List<String> rawList = stringRedisTemplate.opsForList().range(key, start, end); if (rawList == null || rawList.isEmpty()) { return Collections.emptyList(); } return rawList.stream() .map(item -> JSON.parseObject(item, clazz)) .collect(Collectors.toList()); } /** * 只保留最近 N 条记录 */ public void trim(String key, long maxSize) { if (maxSize <= 0) { stringRedisTemplate.delete(key); return; } stringRedisTemplate.opsForList().trim(key, 0, maxSize - 1); } }

这套封装的思路是:外部永远只和对象打交道,内部负责序列化和反序列化。代价是每个元素都存成 JSON 字符串,会多出一些存储空间,但只要列表规模控制在几千、几万级别,这一点内存完全可以接受。

如果你的元素对象里有很多字段,或者单个 JSON 字符串比较大,还可以考虑在写入前先压缩成 GZIP 字节数组,再转成 Base64 字符串。这样读的时候多一步解压,但内存占用会大幅下降。压缩方案一般用在对存储成本敏感的列表场景。

6.2 生产环境里复盘出来的 5 个教训

第一,序列化方案必须全团队统一。我见过一个项目,A 服务用 RedisTemplate 写入 key,B 服务为了省事直接用 StringRedisTemplate 读。两个服务使用的是同一个 Redis 实例,但 value 格式完全不同,B 服务读出来直接报反序列化错误。后来花了半天时间排查才发现是两边序列化器不一样。所以 RedisConfig 应该是基础设施配置,谁都不能随便改。

第二,更新缓存时不要采取“先删后写”。两个线程同时触发更新,一个删完 key 正要写入,另一个开始读数据,读出来就是空列表,甚至读到半截数据。更稳妥的方式是使用版本号 key 或者临时 key 写入完成后,再用 rename 原子切换:

String tempKey = "biz:user:list:tmp"; String finalKey = "biz:user:list"; stringRedisTemplate.delete(tempKey); stringRedisTemplate.opsForList().rightPushAll(tempKey, jsonList); stringRedisTemplate.expire(tempKey, Duration.ofMinutes(30)); stringRedisTemplate.rename(tempKey, finalKey);

rename是原子操作,切换瞬间对读端不可见,能有效避免脏读。

第三,不要迷信“整体序列化 List 然后存入 string key”。很多新手图省事:

redisTemplate.opsForValue().set("biz:user:list", userList);

这种写法在代码里确实能存能取,但线上一旦出问题,你没法用 redis-cli 去分析数据,也没法直接操作单个元素。真正的 Redis List 存取方式,虽然代码量多一点点,但可维护性高一个等级。

第四,反序列化失败的代码,不要通过加 try-catch 掩盖。try-catch 只是把异常吞掉,数据仍然不可用。正确做法是在缓存读取失败后做降级:记录日志、删除坏 key、回源数据库重建缓存。

第五,别在 for 循环里造 key。有些人存某个业务下所有对象列表,习惯在循环里对每一个对象执行一次 Redis 操作,比如:

for (User user : userList) { stringRedisTemplate.opsForList().rightPush("user:cache:" + user.getId(), JSON.toJSONString(user)); }

如果列表不大还好,如果循环几十次上百次,网络开销非常明显。能批量就用批量,能 pipeline 就用 pipeline。

6.3 “存取 List 集合”最容易忽略的边界条件

边界条件主要集中在这几点:

  • 空列表需不需要缓存?如果数据库里查出来就是空,建议不要写 Redis,否则就是一个永远取不到数据的空 key,白白占内存。可以约定状态码,比如返回空时只在本地线程里提示“查不到”,而不是写一个空 List。
  • 列表元素是 null 时,rightPush 会直接抛异常,写入前过滤掉。
  • 对象里的集合字段嵌套多层时,序列化方案一定要提前测试。比如List<Order>而且Order里又有List<OrderItem>,泛型嵌套复杂时,Fastjson 和 Jackson 的处理结果并不一致。写个小测试用例跑一遍,比上线后排查省心得多。
  • 写入的 JSON 字符串不要携带和业务无关的运行时字段,比如由 ORM 框架生成的代理对象字段,序列化出来像$refhandler这类内容,既占空间,反序列化时也可能踩坑。可以在序列化前先转成干净的 DTO。

我自己在实际项目里的习惯是,凡是 List 类的缓存,第一件事永远是先想清楚它需要原生 List 操作还是只要整存整取;第二件事就是统一用 StringRedisTemplate 加 JSON 字符串的方式处理元素。虽然会显得不那么“高级”,但出问题的时候,你能用 redis-cli 一条命令看到所有原始数据,能在十分钟内定位到问题,这比任何花哨的序列化方案都值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询