资讯动态

Redis List底层编码与生产级队列实践全解析

发布时间:2026/10/26 11:50:01 来源:尧图企业网站定制
1. 先把List这个结构看明白再谈命令Redis List命令这一块看起来是最没门槛的左边推进去、右边弹出来五个单词就能拼出一个队列。但我这几年带人、做评审、处理线上问题的经历里出问题最多的恰恰是List——不是不会用是用得太顺手顺手到没人去想它底层到底怎么存、阻塞到底阻塞在哪、消息弹出去之后丢了算谁的。这篇文章我打算把List从命令语义、底层编码、队列落地、分页限流、内存调优到排障完整过一遍。适合两类人看一是刚把Redis跑起来、正在啃五大数据结构的新手二是已经在生产里用List做队列或时间线、但心里对边界条件没底的老手。我会尽量把每个参数背后的取舍讲清楚而不是丢一张命令表让你自己背。1.1 从一次消息堆积说起早些年我在一个内部任务调度系统里见过一次典型的List误用。当时的做法很简单上游用LPUSH往一个固定key里塞任务下游起了八个消费进程用BRPOP取。平时跑得好好的某天上游有个批量导入任务一口气推了四十多万条下游开始出现消费延迟延迟从秒级涨到分钟级最后运维那边告警说有个key的内存占用飙升。排查下来根因有三层。第一层是消费端用了单条BRPOP一条一条取网络往返占了大部分时间吞吐上不去。第二层是这个key从来没做过长度约束上游推得比下游消费得快队列长度无限膨胀。第三层最隐蔽——这个List的元素是较长的JSON字符串每个几百字节quicklist的节点被撑得很大删除中间节点时内存回收不干脆出现了明显的内存碎片。这件事之后我形成了一个习惯只要看到List被当作队列用第一反应是问三个问题——谁在生产、谁在消费、长度上限是多少。没有这三个答案代码写得再漂亮也是埋雷。List本身没问题问题是太多人把它当成一个可以无限塞东西的数组而忘了它同时也是一个吃内存、会影响主线程的结构。1.2 底层编码决定了你的性能上限要理解List的行为得先知道它在内存里长什么样。早年的实现分两种编码元素少且短的时候用ziplist一块连续内存省指针开销超过阈值就转成linkedlist标准双向链表每个节点独立分配。这个设计的痛点是转换瞬间内存翻倍、而且转换之后再也退不回去一块大List占了内存就赖着不放。后来改成了quicklist思路是把双向链表和紧凑存储结合起来整体是一个双向链表但每个链表节点内部装的不是单个元素而是一块紧凑的连续存储早期是ziplist较新版本换成了listpack。这样既保留了链表两端O(1)插入删除的能力又避免了每个元素都带一堆指针开销。再往后小规模的List甚至能整体用一块listpack存下来连链表结构都省了。这个演进对使用者的实际影响是什么我给你捋一下编码形态触发条件大致特点什么时候会踩坑listpack整体元素数量少、单元素短内存最省随机访问快元素变多会触发编码转换转换有瞬时开销quicklist超出小列表阈值两端操作快中间操作要遍历节点大小配置不当会导致内存碎片或CPU浪费已废弃的ziplistlinkedlist老版本转换不可逆升级前的存量数据要关注你可以用OBJECT ENCODING yourkey直接看当前编码。我建议在压测阶段就把这个命令跑一遍确认你的数据规模落在哪个编码区间——因为不同编码下同一条命令的实际耗时可能差好几倍。这不是理论问题是实打实会体现在P99延迟上的东西。1.3 什么场景该用List什么场景趁早换List的定位其实很清晰需要保持顺序、且只在两端操作的线性结构。符合这个描述的用List非常合适轻量任务队列、最新N条动态、操作日志的定长窗口、简单的撤销栈。这些都只碰两头复杂度都是O(1)稳得很。反过来以下几种情况我会明确建议换结构。需要按分数排序、按时间范围查询的用ZSetList做不了范围打分。需要多消费者分组、需要ACK确认、需要消息回溯的用StreamList的弹出即删除语义天生不适合做可靠消息。需要频繁按下标随机读写中间元素的List的LINDEX和LSET都是O(N)数据量一大就废这种需求本质上是数组应该考虑别的方案或者干脆拆小。还有一个容易忽略的点List不适合存超大单元素。有人拿List存文件内容、存大段HTML单个元素几兆这种用法会让内存分配变得非常不友好而且一旦涉及阻塞弹出单次网络传输的数据量也很吓人。单元素控制在KB级别以内是比较舒服的区间。2. 命令家族全景把方向语义彻底捋直List的命令看着多其实可以按操作位置和是否阻塞两个维度切成四块。左端、右端、阻塞、非阻塞交叉组合就基本覆盖了。真正让人翻车的不是命令本身是方向搞反——尤其是LPUSH配LPOP还是配RPOP决定了你这是栈还是队列很多线上顺序错乱的bug根子就在这。2.1 两端操作的对称性与方向陷阱先建立一个心智模型把List想象成一根横着的管道左端是head右端是tail。LPUSH从左边塞进去RPUSH从右边塞进去LPOP从左边取RPOP从右边取。四者两两组合行为完全不同LPUSH LPOP后进先出这是栈stackLPUSH RPOP先进先出这是队列queueRPUSH LPOP也是先进先出等价于上面的队列RPUSH RPOP后进先出反向栈我在代码评审里见过最多的错误是生产者用了LPUSH消费者也用LPOP然后疑惑为什么任务执行顺序跟提交顺序反了。这不是bug这是栈的语义。想清楚你要的是栈还是队列再决定两端怎么配。还有一个细节LPUSH key a b c这种一次推多个值的写法最终在列表里的顺序是c b a从左到右因为每个元素都是依次从左边插入的。很多人第一次看到这个结果会愣一下。同理RPUSH key a b c得到的是a b c符合直觉。这个差异在批量推消息的时候很要命——如果你的消费端假设了顺序推的时候方向错了整个处理链路就乱了。批量推还有个性能点LPUSH key v1 v2 ... v1000虽然是一条命令但时间复杂度是O(N)N是元素个数。一次性推十万条会明显卡住主线程。我的做法是分批单次控制在几百到一千个元素之间配合pipeline既减少往返又不会造成单条命令过长。LPUSHX和RPUSHX是仅当key存在时才推这两个命令在实现给已存在的会话追加记录这类场景时有用可以避免误创建一堆空列表。注意它们一次只能推一个元素不支持批量。2.2 弹出、删除与那些不常用的命令LPOP和RPOP在较新版本里支持带count参数一次弹多个返回一个数组。这个改进很实用消费端一次取10条、50条比循环单条弹效率高得多。但要留意返回值的差异不带count返回单个字符串或nil带count返回数组key不存在时返回nil而不是空数组这点容易写出空指针。LREM key count element是删除指定值count的符号决定方向正数从头往尾删最多count个负数从尾往头删0表示全删。复杂度是O(NM)N是列表长度M是被删元素个数。在大列表上做LREM要谨慎它会遍历。LTRIM key start stop是裁剪只保留指定区间。这个命令我特别喜欢因为它和LPUSH组合起来就是天然的定长窗口LPUSH latest:feed xxx之后紧跟LTRIM latest:feed 0 999永远只保留最新1000条。两步操作建议放在一个pipeline或者Lua脚本里避免中间态被读到。LTRIM的复杂度是O(N)N是被删除的元素数所以如果你只是裁掉尾部几个代价很小如果一次裁掉几十万那一下会明显卡。LINSERT key BEFORE|AFTER pivot element是在某个值前后插入O(N)需要遍历找pivot。pivot不存在返回-1key不存在返回0。这个命令用得不多但在在某个标记位后面插入一条的场景里挺顺手。LSET key index element按下标改值O(N)索引越界会直接报ERR index out of range注意捕获。LPOS是较新版本引入的用来查找元素位置支持RANK第几个匹配、COUNT返回几个、MAXLEN最多扫描多少个。在没有它之前找位置只能LRANGE全拿出来在客户端遍历数据量大时很蠢。LPOS的MAXLEN参数特别值得用它把扫描范围限死避免在超长列表上做全量遍历。LMOVE source destination LEFT|RIGHT LEFT|RIGHT是原子性的从A弹一个推到B替代了早期的RPOPLPUSH。它的价值在于原子性——弹出和推入之间不会有其他客户端插进来。LMPOP则是多key版本一次从多个key里挑第一个非空的弹适合多个优先级队列合并消费的场景。2.3 阻塞命令的真实行为BLPOP key [key ...] timeout和BRPOP是List做队列的核心。它的语义是按顺序检查这些key第一个非空的就弹出来返回全都空就阻塞直到有数据或超时。返回值是一个两元素数组[key, value]注意是数组不是字符串很多客户端封装会在这里做转换用之前确认一下。几个必须记住的行为特征第一阻塞的是客户端连接不是服务端轮询。服务端会把这条连接挂起有新数据推入时才唤醒。所以它不消耗CPU这是它比循环LPOPsleep优雅的根本原因。第二在MULTI/EXEC事务里和Lua脚本里阻塞命令不阻塞立即返回nil。这个坑我见过有人踩把BLPOP包在事务里想实现有就取没有就等结果永远拿到nil还以为命令坏了。第三timeout为0表示永久阻塞。生产环境我不建议用0因为一旦网络抖动或者客户端异常连接会一直挂着慢慢把连接池耗光。给个几十秒的超时配合客户端自己的重试逻辑更稳。第四多个key的检查是有顺序的。BLPOP queue:high queue:low 5会优先看highhigh空了才看low。这就是用List实现优先级队列的标准手法——高优先级一个key低优先级一个key消费端按优先级顺序列出。第五客户端的socket超时必须大于阻塞超时。这个是新手最容易踩的。你设了BLPOP超时30秒但客户端连接超时是10秒那第10秒连接就被自己断了报一个看起来莫名其妙的超时错误。两边的数值要配套客户端超时留出余量。BLMOVE和BLMPOP是阻塞版的移动和多key弹出做可靠队列时BLMOVE是主力下面单独讲。3. 用List搭队列从玩具到能上生产用List做队列的入门代码五行就能写完但从能跑到敢上生产中间隔着消息可靠性、消费失败处理、堆积监控三座山。这一节我把这几个问题拆开讲给出可以直接抄的方案。3.1 一个最小可用的队列长什么样最朴素的版本是这样生产端LPUSH queue:task payload消费端BRPOP queue:task 30。这套代码在测试环境跑得欢但放到生产上有三个洞。第一个洞是弹出即删除——BRPOP返回的那一刻这条消息在Redis里已经没了如果消费进程在处理过程中崩溃这条消息就永远消失了。第二个洞是没有长度上限生产快了消费慢了就无限堆积。第三个洞是没法感知堆积程度等到内存告警才发现。改进思路很直接把弹出换成移动。用BLMOVE queue:task queue:task:processing RIGHT LEFT 30消息从待处理队列移到处理中队列处理成功后再LREM queue:task:processing 1 payload删掉。这样即使消费进程挂了消息还躺在processing队列里可以由一个巡检任务捞回来重新入队。这套方案的代价是多了一次写操作和一次删除操作吞吐会比裸BRPOP低一些但对绝大多数业务来说完全够用。如果吞吐要求极高、又能接受极少量丢失那就用裸BRPOP但要在业务层面做好幂等和补偿。关于长度上限我一般会在生产端加一个保护推之前先LLEN看一眼超过阈值就报警或者降级。更严格的做法是用LTRIM做硬截断但要小心别把还没消费的消息裁掉了——这取决于你推的是哪一端裁的又是哪一端。如果生产用LPUSH、消费用RPOP那新消息在左边你要裁的是左边会把最新消息裁掉等于丢新保旧这在队列场景下通常是错的。所以LTRIM更适合最新N条这种展示型场景不适合任务队列。任务队列的长度控制应该靠监控和限流而不是硬截断。3.2 消费端的批量取与并发控制单条BRPOP在高吞吐下效率不高因为每条消息一次网络往返。改成批量用LMPOP 1 queue:task LEFT COUNT 50较新版本支持或者用LRANGE 0 49加LTRIM 50 -1的组合后者不是原子的多消费者并发时会重复消费需要额外加锁。我在实际项目里更倾向的方案是用LMOVE做成取一批的模式——先LRANGE queue:task 0 49看一眼有哪些然后对每个用LMOVE原子地移到processing队列移成功的才处理。这样既批量又安全代价是多了N次往返可以用pipeline把它们打包。并发控制这块有个经验消费进程数量不是越多越好。我见过一个场景开了五十个消费进程结果因为每次BRPOP唤醒时所有进程都在抢同一个keyRedis的QPS被打得很高但实际吞吐没上去多少。一般按照消费能力/单进程吞吐来定宁少勿多不够再加。另外阻塞命令会占用连接连接池的大小要覆盖住阻塞的消费线程数别出现队列里有消息但没连接可用的尴尬。3.3 消息丢失的几个真实场景我把这几年遇到过的丢失场景列一下都是血泪场景一消费成功但删除失败。处理完业务逻辑准备LREM删processing队列里的记录这时候网络断了。结果是消息被处理了但记录还在巡检任务过一会儿又把它捞出来重跑一遍。这不是丢失是重复但只要业务做了幂等就没问题。反过来说如果业务没做幂等这比丢失更麻烦。场景二弹出后进程被kill。用的是裸BRPOP消息弹出来在内存里进程被OOM killer干掉消息没了。这个只能靠移动而非弹出的方案解决。场景三key被误删或者过期。有人给队列key设了TTL想着没人消费就自动清理结果消费慢的时候队列直接消失。队列key永远不要设过期时间这是铁律。场景四主从切换时的复制延迟。主节点刚LPUSH成功就返回给客户端了但还没同步到从节点就宕机切换这条消息就没了。这是异步复制的固有问题对消息可靠性要求极高的场景要么用支持同步复制的配置代价是延迟要么换成有持久化保障的消息中间件。Redis的定位是缓存和轻量队列不要拿它当金融级消息总线用。4. 分页、定长窗口与限流List的进阶用法队列是List最广为人知的用途但它还有一批不太被提起的用法在业务里其实很常见。这一节聊三个时间线分页、定长窗口、简易限流。4.1 时间线分页的坑与解法最新动态列表这类需求很多人的第一反应是LRANGE key start stop配合页码。第一页LRANGE 0 9第二页LRANGE 10 19看着很完美。问题在于如果用户翻页的间隙有新动态插到了头部那么原来的第10条就变成了第11条第二页的第一条会跟第一页的最后一条重复反过来如果有动态被删就会漏掉一条。这是基于偏移量分页的通病不是Redis独有的但List场景下更容易撞上因为头部插入太频繁了。解法是改成游标分页不要传页码传上一页最后一条的标识服务端先LPOS找到它的位置再从这个位置往后取。这样即使中间有插入删除翻页的锚点是稳定的。代价是LPOS本身是O(N)的长列表上会慢。如果列表长度可控比如几千条这个代价能接受如果动辄几十万条就老实用ZSet按时间戳做范围查询别在List上硬扛。这也是我前面说的——选结构要看数据规模不能一套方案打天下。还有一种折中做法给每条动态一个单调递增的ID翻页时用ID做锚点服务端用LPOS找位置。因为新动态ID更大插入位置总在头部对尾部的历史数据影响小实际上偏移量分页在只翻前几页的场景下问题不大很多产品也就这么用了。但如果你的用户会翻到很深的页还是老老实实做游标。4.2 定长窗口LTRIM的正确姿势只保留最近N条是List的经典用法实现就是LPUSH加LTRIM。写法很简短# 推入一条新记录 LPUSH feed:user:1001 {\id\:123,\action\:\login\} # 只保留最新的1000条 LTRIM feed:user:1001 0 999这两条命令必须连着执行否则中间会有一个短暂的列表变长了的状态。并发场景下两个客户端同时LPUSH再同时LTRIM结果通常还是对的因为LTRIM的区间是绝对位置最后执行的那次会把列表裁到1000条但会有短暂的抖动。要严格保证的话用Lua脚本包起来或者用pipeline减少往返。LTRIM的开销取决于裁掉多少元素裁1条是O(1)裁10万条是O(10万)。所以我建议每次LPUSH之后都紧跟一次LTRIM让列表始终维持在目标长度附近每次只裁掉一两条成本极低。千万不要攒着不裁等列表涨到几十万再一次性裁那一下会明显卡住。还有一个隐藏问题如果这个key长时间不再有写入LTRIM就不会执行列表也不会缩。所以窗口的过期是逻辑上的、靠写入驱动的不是时间驱动的。如果业务上需要30天没动态就清空得额外用一个定时任务去删key或者设TTL。这里TTL是可以用的因为这是展示型数据不是队列。4.3 用List做简易限流限流这个话题通常归ZSet或专门的限流中间件管但用List也能做一个粗糙但够用的版本思路是固定窗口计数每个时间窗口一个key每次请求LPUSH一条标记然后用LLEN看当前窗口内的请求数超过阈值就拒绝。# 假设限流是每分钟100次 LPUSH rl:user:1001:202401011200 1 EXPIRE rl:user:1001:202401011200 70 LLEN rl:user:1001:202401011200这个方案简单粗暴缺点是窗口边界效应明显——第59秒打满100次第61秒又打满100次两秒内实际过了200次。要平滑一点就得用滑动窗口那List就不合适了得上ZSet存时间戳。另外每次请求都要LPUSH一条列表长度会涨到阈值大小内存开销不算小高QPS场景下要掂量。我个人的判断是List做限流适合低QPS、粗粒度的内部接口保护比如每分钟限制几十次的后台任务触发。真正的用户级限流还是用专门的方案别在List上造轮子容易造出边界情况一堆的轮子。5. 内存与性能调优参数背后的取舍List用起来简单但它的内存表现和配置参数关系很大。这一节讲三个东西复杂度表的真实含义、quicklist的两个关键配置、大key的识别与拆分。5.1 复杂度表不能只看字母文档上写LPUSH是O(1)LRANGE是O(SN)LINDEX是O(N)。这些字母背后是什么体感我换算一下在一个一百万元素的List上做LINDEX需要沿着链表走大约一百万个节点虽然每个节点是一块紧凑内存、遍历很快但累计起来也是几十毫秒级别。同样规模下LRANGE取前100个元素是从头开始走100个元素很快但LRANGE取中间100个要先走到起点就慢了。所以我在设计时会给List定一个规模红线超过几万个元素的List就不要再做下标访问和中间插入了只允许两端操作。如果业务确实需要按下标随机访问那说明这个数据结构选错了应该考虑分片或者换结构。还有一个容易被忽略的点LPUSH key a b c d的复杂度是O(N)不是O(1)N是本次推入的元素个数。一次推一万个元素主线程要处理这一万个元素的插入这段时间其他命令都得排队。批量操作要么控制在几百个以内要么用pipeline分成多条命令让主线程有机会处理其他请求。5.2 两个必须调的配置quicklist有两个配置直接决定内存和性能的平衡很多人从来没调过用的是默认值。list-max-listpack-size老版本叫list-max-ziplist-size控制每个链表节点内部装多少数据。正数表示最多装多少个元素负数表示按字节数限制-1是4KB、-2是8KB、-3是16KB、-4是32KB、-5是64KB。默认值是128意思是每个节点最多128个元素。这个值怎么选节点太大插入删除时要搬运的数据多而且内存分配不灵活节点太小链表节点数量多指针开销占比高。我的经验是元素小几十字节的时候用正数控制个数128到256都行元素大几百字节以上的时候用负数按字节控制选-2或-3让每个节点控制在8到16KB。这个区间的依据是内存分配的页大小和CPU缓存的平衡属于实践中比较舒服的甜点区。list-compress-depth控制中间节点是否压缩。默认0是不压缩。设成1表示首尾各1个节点不压缩、中间的全压缩设成2就是首尾各2个。压缩用的是LZF算法省内存但费CPU读取时要解压。什么时候开当你的List很长、而且访问集中在两端时开压缩很划算——中间那些很少碰的数据压缩掉省下来的内存很可观。但如果你的业务会频繁LRANGE整个列表解压开销就会冒出来。我的默认建议是队列类的List只在两端操作可以开depth 1展示类需要全量读取的List保持0。5.3 大key的识别与拆分大key是Redis运维的老话题了List的大key有两种形态元素个数特别多比如几百万条或者单元素特别大比如几MB的字符串。前者的问题是操作慢、删除卡顿后者的问题是网络传输和内存分配都不友好。识别方法很简单LLEN看长度LINDEX抽几个看元素大小或者用MEMORY USAGE key看整体占用。业内的经验阈值一般是元素个数超过5000、或者总大小超过10MB就要警惕但这不是硬标准得结合你的实例规格和访问模式来判断。拆分思路有几种。按时间分片每小时一个keyqueue:task:2024010112消费端按时间顺序扫。按业务分片不同业务线不同key避免互相影响。按hash分片把一个大List拆成N个用一致性哈希分散。还有一种最简单的如果这个List其实是当队列用那就该在消费端解决——提升消费速度、或者限制生产速度让队列长度天然保持在低位根本不让它长大。删除大key这件事要单独说一句。直接DEL一个百万元素的List会阻塞主线程很久。正确做法是用UNLINK异步删除后台线程慢慢回收或者用LTRIM分批裁——每次裁一万条隔一会儿再裁一次把一次大阻塞摊成多次小阻塞。6. 常见问题排查实录理论和方案讲完了这一节是我实际处理过的具体问题做成速查表方便你对着看。6.1 现象与原因速查现象可能原因排查手段处理方式BLPOP一直返回nil在事务或Lua里调用看调用上下文移出事务改用独立连接阻塞命令报超时但数据明明有客户端socket超时小于阻塞超时对比两边配置客户端超时设为阻塞超时的1.5倍以上队列长度持续增长消费速度跟不上生产监控LLEN趋势、消费耗时加消费者、批量取、优化单条处理内存占用远超预期元素过大或quicklist节点配置不当MEMORY USAGE、OBJECT ENCODING调list-max-listpack-size拆keyDEL大List卡住整个实例同步删除慢日志、延迟监控改用UNLINK或LTRIM分批消费顺序错乱生产者消费者端方向配错检查LPUSH配的是LPOP还是RPOP统一方向队列用LPUSHRPOPLRANGE结果与预期不符负索引理解错复习索引规则-1是最后一条-2是倒数第二条LSET报index out of range索引超出当前长度LLEN对比索引先判断长度再set或改用LINSERT6.2 几个我踩过的具体坑第一个坑把阻塞命令放在连接池里。BRPOP会占住连接最长timeout秒如果连接池只有10个连接、起了20个消费线程那后面的线程永远拿不到连接。表现是看起来在跑但队列不动日志里没有任何错误。解决方式要么给消费线程单独配一个小连接池要么用非阻塞的LPOP加退避重试。我现在倾向于给阻塞消费者单独开连接跟业务查询的连接池彻底隔开。第二个坑负数索引的边界。LRANGE key 0 -1取全部这个大家都知道。但LRANGE key -5 -1取最后5条如果列表只有3条返回的是这3条而不是报错。这个行为是对的但如果你的代码假设一定返回5条就会出问题。凡是用负索引的地方都要想清楚列表不足时的表现。第三个坑LPOS的MAXLEN设太小。LPOS有个MAXLEN参数限制扫描长度我见过有人为了性能设了个100结果元素在100位之后永远找不到。这个参数要根据你的数据分布来估不能拍脑袋。第四个坑以为BRPOP是公平的。多个消费者阻塞在同一个key上新数据到达时是先阻塞先被唤醒但不保证严格公平也不保证每个消费者都拿到均等份额。如果你的业务要求严格分配得自己在上层做控制别指望Redis。第五个坑队列key带上了业务前缀但忘了统一。生产端写的是queue:task消费端写的是queue:tasks一个字母之差排查半天。这种低级错误我在两个项目里都见过建议把key名抽成常量两边引用同一个常量。提示所有涉及到方向的操作LPUSH/RPUSH、LPOP/RPOP、LEFT/RIGHT在写代码时都用常量或者枚举包一层不要散落在各处硬编码字符串。方向搞反的bug排查成本极高因为现象往往是顺序不对而不是报错很容易被误判成业务逻辑问题。最后分享一个我常用的自检脚本思路上线前对每个List key跑一遍检查输出它的长度、编码、内存占用、TTL设置、以及最近有没有被LTRIM过。这套检查放进发布流程里能挡掉大部分低级配置错误。List本身不复杂复杂的是人对它的假设——把这些假设显式地检查一遍问题就少了一大半。

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

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

免费获取报价 →
↑