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浪费 |
| 已废弃的ziplist+linkedlist | 老版本 | 转换不可逆 | 升级前的存量数据要关注 |
你可以用OBJECT ENCODING yourkey直接看当前编码。我建议在压测阶段就把这个命令跑一遍,确认你的数据规模落在哪个编码区间——因为不同编码下,同一条命令的实际耗时可能差好几倍。这不是理论问题,是实打实会体现在P99延迟上的东西。
1.3 什么场景该用List,什么场景趁早换
List的定位其实很清晰:需要保持顺序、且只在两端操作的线性结构。符合这个描述的,用List非常合适:轻量任务队列、最新N条动态、操作日志的定长窗口、简单的撤销栈。这些都只碰两头,复杂度都是O(1),稳得很。
反过来,以下几种情况我会明确建议换结构。需要按分数排序、按时间范围查询的,用ZSet,List做不了范围打分。需要多消费者分组、需要ACK确认、需要消息回溯的,用Stream,List的"弹出即删除"语义天生不适合做可靠消息。需要频繁按下标随机读写中间元素的,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:后进先出,这是栈(stack)
- LPUSH + RPOP:先进先出,这是队列(queue)
- RPUSH + 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(N+M),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不存在返回-1,key不存在返回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,这是它比"循环LPOP+sleep"优雅的根本原因。
第二,在MULTI/EXEC事务里和Lua脚本里,阻塞命令不阻塞,立即返回nil。这个坑我见过有人踩:把BLPOP包在事务里想实现"有就取没有就等",结果永远拿到nil,还以为命令坏了。
第三,timeout为0表示永久阻塞。生产环境我不建议用0,因为一旦网络抖动或者客户端异常,连接会一直挂着,慢慢把连接池耗光。给个几十秒的超时,配合客户端自己的重试逻辑,更稳。
第四,多个key的检查是有顺序的。BLPOP queue:high queue:low 5会优先看high,high空了才看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唤醒时所有进程都在抢同一个key,Redis的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(S+N),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就要警惕,但这不是硬标准,得结合你的实例规格和访问模式来判断。
拆分思路有几种。按时间分片:每小时一个key,queue: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,拆key |
| DEL大List卡住整个实例 | 同步删除 | 慢日志、延迟监控 | 改用UNLINK或LTRIM分批 |
| 消费顺序错乱 | 生产者消费者端方向配错 | 检查LPUSH配的是LPOP还是RPOP | 统一方向,队列用LPUSH+RPOP |
| LRANGE结果与预期不符 | 负索引理解错 | 复习索引规则 | -1是最后一条,-2是倒数第二条 |
| LSET报index out of range | 索引超出当前长度 | LLEN对比索引 | 先判断长度再set,或改用LINSERT |
6.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本身不复杂,复杂的是人对它的假设——把这些假设显式地检查一遍,问题就少了一大半。