资讯动态

Redis List实战:存取、序列化与队列场景全解析

发布时间:2026/9/8 19:43:09 来源:尧图企业网站定制
Redis用了这么多年List算是五大基础类型里我用得最早也最频繁的一个。刚开始那会儿我直接把它当消息队列用后来项目里又拿它做Feed流、最近浏览记录、异步任务池总之就是各种“排队存取”的活儿它都干过。可别看它简单真正想把“Redis存取List集合”这套玩明白命令层面、Java客户端API层面、序列化层面都有不少容易栽跟头的地方。这篇就以List为线索把存、取、消费、集群下的注意点以及我踩过的坑一次说清。1. 为什么是ListRedis五大基础类型里的“有序队列”担当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 APIList存取的核心全览2.1 基础命令与RedisTemplate方法对应关系不管用什么语言操作Redis命令层面都是同一个标准。我用Java的RedisTemplate最多先列一张常用命令和API的对照表方便你随手查。Redis命令作用RedisTemplate方法LPUSH从左侧头部插入一个或多个元素leftPush / leftPushAllRPUSH从右侧尾部插入一个或多个元素rightPush / rightPushAllLPOP从左侧弹出并移除一个元素leftPopRPOP从右侧弹出并移除一个元素rightPopLRANGE获取指定区间内的所有元素rangeLINDEX按下标获取单个元素indexLLEN获取List长度sizeLREM从List中移除指定个数的匹配元素removeLTRIM只保留区间内的元素trimBLPOP / 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 RedisTemplate3.1 环境准备与依赖引入我这边最常用的组合是Spring Boot 2.x spring-boot-starter-data-redis。无论你是用Docker启动Redis还是本机直接装先把服务跑起来然后用Redis Desktop Manager或者redis-cli查一下能不能连通。依赖就一个dependency groupIdorg.springframework.boot/groupId artifactIdspring-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默认的RedisTemplatekey和value都用的JdkSerializationRedisSerializer你往Redis里存一个对象肉眼在redis-cli里看到的就是一串乱码\xac\xed\x00\x05t\x00\x04user这种数据不仅不好排查而且在跨语言、跨系统对接时很容易出问题。我自己的习惯是key统一用StringRedisSerializervalue用Jackson2JsonRedisSerializer或者GenericJackson2JsonRedisSerializer这样存进去的是可读的JSON字符串。Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer new StringRedisSerializer(); Jackson2JsonRedisSerializerObject 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 RedisTemplateString, 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条浏览记录 ListObject 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一次调用写入整个集合性能好得多。ListProduct productList getFromDb(); ListObject productJsonList productList.stream() .map(this::toJson) .collect(Collectors.toList()); redisTemplate.opsForList().rightPushAll(product:list, productJsonList);如果一次性数据量太大比如几万条甚至更多即便用rightPushAll也可能因为单条命令过大撑爆Redis的协议缓冲区。稳妥做法是分批写每批1000到3000条左右ListListObject partitions Lists.partition(productJsonList, 1000); for (ListObject 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再用的时候手动反序列化。在代码里常见这样一段ListObject list redisTemplate.opsForList().range(key, 0, -1); String json objectMapper.writeValueAsString(list); ListUser users objectMapper.readValue(json, new TypeReferenceListUser() {});这里反复出现的objectMapper.readValue(json, new TypeReferenceListUser(){})就是热词里jsonserializer.deserializelistobject想表达的意思。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字符串转List失败这个也是热门问题。你从Redis里取出来的往往是一个JSON字符串想转成ListMapString, Object时直接强转ClassCastException。正确做法是用Jackson的TypeReferenceString json redisTemplate.opsForValue().get(list:data); ListMapString, Object list objectMapper.readValue(json, new TypeReferenceListMapString, Object() {});注意不要用objectMapper.readValue(json, List.class)这样得到的List里每个元素是LinkedHashMapMap里面的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; ListObject 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设置强密码和必要的网络白名单别等被扫描到再来补救。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价