Redis List集合存取实战:序列化、反序列化与长度控制全攻略
做了几年 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 开始合并成 quicklistRedis 7.0 之后又把部分节点编码换成了 listpack。不管是哪种编码它的核心特征都是从两端插入、弹出是 O(1)但按 index 随机访问是 O(N)因为它要从链表头或链表尾一步一步走到目标位置。这个特性决定了它跟 Java 集合的使用方式完全不同。Java 里你拿ListInteger通常是拿来随机访问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 看 keyvalue 是一段\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 里反序列化时能自动还原成正确的对象类型。但坑也在这里一旦你把实体类改名、移动包路径、改动字段结构旧的缓存数据因为里面记着老的类路径可能直接解析失败。所以我个人最推荐最后一种用StringRedisTemplatekey 用普通字符串value 由我们自己决定用什么格式。存取对象列表时每个元素单独序列化成 JSON 字符串再作为 Redis List 的一个节点 push。这样排查数据时一眼能看懂出现问题时可以直接在 redis-cli 里lrange看原始数据完全不需要依赖 IDE 反编译。2.3 如果你还是想用 RedisTemplate配置也不难如果你项目里其他地方已经大量使用了RedisTemplateObject, Object或者团队统一要求走 RedisTemplate那至少要把序列化器配置对。下面这段配置是我项目里的基础版Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, 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保证不会出现乱码 keyvalue 用GenericJackson2JsonRedisSerializer保证对象能转成 JSON 而不是二进制。这个配置只能解决一部分问题真正的 List 对象存入和取出仍然要注意泛型擦除我在第 4 节会重点说。3. 写入环节逐个 push 才能真正发挥 Redis List 的本事3.1 rightPush 和 leftPush 到底选哪个当你想把数据真正存成 Redis List 时核心 API 就是opsForList()下的几个方法。其中最容易混淆的就是rightPush和leftPush。记住一句口诀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性能会差一个数量级。正确的姿势是用rightPushAllListUser userList userMapper.selectList(...); ListString 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...可变参数也可以传一个CollectionString所以集合可以直接传进去。第二如果列表里可能有个别元素是 null需要提前过滤否则序列化时会出问题。第三delete和push不是原子的如果两个线程同时执行这一段逻辑可能出现先删除的线程还没 push 完另一个线程就已经开始读取读到一半空数据形成并发脏读。这个问题我在第 6 节还会提。如果数据量特别大比如一次要 push 几万条rightPushAll仍然可能因为阻塞 Redis 而出现超时更好的做法是用 pipeline 批处理stringRedisTemplate.executePipelined((RedisCallbackObject) 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. 读取环节还原成 ListT 的通用姿势和三类典型报错4.1 用 StringRedisTemplate 读取的基础代码如果你按照第 3 节的方式每个元素都是独立 JSON 字符串那么读取就非常简单ListString jsonList stringRedisTemplate.opsForList().range(biz:user:list, 0, -1); ListUser 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 里装的到底是什么元素// 反例看起来没问题一运行就报错 ListObject list objectMapper.readValue(json, List.class); User user (User) list.get(0);Jackson 碰到没有指定元素类型的 List默认会把每个 JSON 对象转成LinkedHashMap所以强制转换成User必然失败。用 Fastjson 或者 Jackson 的正确姿势都必须显式带上元素类型// Fastjson 方案 ListUser userList JSON.parseArray(json, User.class); // Jackson 方案 ListUser userList objectMapper.readValue(json, new TypeReferenceListUser() {});泛型嵌套的情况更是如此假如你是ListMapString, User这种复杂结构必须用TypeReferenceListMapString, User() {}才能保证内层类型不丢。还有一个技巧适用于已经有 RedisTemplate 存进去、读出来变成 ListObject 的项目如果你拿到的ListObject里每个元素实际是 LinkedHashMap可以用“先序列化成 JSON再反序列化成目标类型”的办法绕过去ListObject rawList redisTemplate.opsForList().range(biz:user:list, 0, -1); String json JSON.toJSONString(rawList); ListUser userList JSON.parseArray(json, User.class);这个方法不算最优但在处理历史脏数据时极好用。4.3 读取阶段的典型报错排查表现象根因解决方案读到一半报 ClassCastException元素变成 LinkedHashMap反序列化时未指定泛型使用 TypeReference 或 JSON.parseArrayRedis 管理器里见到的 key 是乱码RedisTemplate key 序列化配置不对keySerializer 改成 StringRedisSerializer读出来是完整 JSON 字符串但对象字段被丢弃反序列化时没有匹配到对应字段检查 JSON 序列化时是否忽略了 null以及字段名是否一致项目把实体类包路径从 A 移到 B旧缓存全部失败GenericJackson 序列化时保留了旧类路径清理旧缓存或者改用 StringRedisTemplate 手写 JSON用默认 RedisTemplate 存了对象没有实现 Serializable默认序列化器无法处理换 GenericJackson 或 StringRedisTemplate5. 列表无限增长是定时炸弹长度控制、分页读取与过期策略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 条 ListString page stringRedisTemplate.opsForList().range(biz:user:list, 0, 19); // 第 2 页 ListString 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 ListT range(String key, long start, long end, ClassT clazz) { ListString 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 写入 keyB 服务为了省事直接用 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 会直接抛异常写入前过滤掉。对象里的集合字段嵌套多层时序列化方案一定要提前测试。比如ListOrder而且Order里又有ListOrderItem泛型嵌套复杂时Fastjson 和 Jackson 的处理结果并不一致。写个小测试用例跑一遍比上线后排查省心得多。写入的 JSON 字符串不要携带和业务无关的运行时字段比如由 ORM 框架生成的代理对象字段序列化出来像$ref、handler这类内容既占空间反序列化时也可能踩坑。可以在序列化前先转成干净的 DTO。我自己在实际项目里的习惯是凡是 List 类的缓存第一件事永远是先想清楚它需要原生 List 操作还是只要整存整取第二件事就是统一用 StringRedisTemplate 加 JSON 字符串的方式处理元素。虽然会显得不那么“高级”但出问题的时候你能用 redis-cli 一条命令看到所有原始数据能在十分钟内定位到问题这比任何花哨的序列化方案都值钱。