1. 从「管道」到「消息队列」,Redis List 是什么
先给刚接触 Redis 的同学一个直观类比:List 就像一条双向管道,你可以从左边塞数据,也可以从右边塞数据;可以从左边取数据,也可以从右边取数据。但不是随意取,它是有序、可重复、按插入顺序排列的一组字符串集合。这个「有序」很关键,它跟 Set(无序集合)、Hash(键值对集合)的本质区别就在这儿。
我在实际项目里第一次用 Redis List,是做一个延迟任务队列的雏形。当时的场景是:多个服务节点同时生产一批任务,需要一个地方先存着,然后由固定的消费端按顺序处理。用数据库表当然也能做,但高频写入、频繁状态更新会把 MySQL 拖得很累,而且还要自己管理锁和游标。换成 Redis List 之后,生产端只管LPUSH,消费端只管RPOP,一个晚上的改造量,就把数据库的压力卸掉了大半。
那 List 到底解决了什么问题?简单说三点:
- 顺序性保证:插入顺序就是存储顺序,这对「先来先处理」的业务天然友好。
- 双端操作:既能当栈(同端进同端出),也能当队列(一端进另一端出),灵活性远超普通数组。
- 阻塞语义:Redis 提供了阻塞版的弹出命令(
BRPOP/BLPOP),消费端可以一直等着,而不用自己写轮询逻辑,这就为「可靠消息队列」打下了基础。
适合谁来学?我觉得三类人最应该吃透 List:一是后端开发,尤其是做过队列、做过排行榜、做过分页列表的;二是面试准备者,Redis 数据类型是面试必考,List 的实现原理是高频追问点;三是系统设计初学者,因为从 List 出发,你可以一路延伸到「延迟队列」「分布式锁」「限流器」这些高级话题。这篇文章不啰嗦理论,直接带你从底层原理到实战代码过一遍,重点讲清楚每个操作背后的「为什么」。
2. 底层结构与存储演进:为什么 List 能做到爽快存取
很多教程只讲命令,不讲底层,导致出了问题完全无从下手。比如「为什么我的 List 变成了一个很大的对象?」「为什么明明只是几个元素,内存却涨得离谱?」这些答案都在底层结构里。
2.1 从 linkedlist 到 quicklist,再到 listpack
Redis 3.2 之前,List 的底层有两种编码:ziplist和linkedlist。元素少、长度短的时候用 ziplist(连续内存块,省内存但插入删除要搬数据),元素一多就切换成双向链表 linkedlist(每个节点独立分配内存,插入删除方便但内存碎片多、指针开销大)。
3.2 版本引入了quicklist,把两者结合起来:quicklist 是一个双向链表,但每个节点内部是一段 ziplist。也就是说,宏观上是链表,微观上是压缩列表。这样既减少了节点数量,又能充分利用压缩列表的紧凑存储。
到了 Redis 7.0,ziplist 被正式替换为listpack,quicklist 的内部节点也从 ziplist 换成了 listpack。listpack 的核心改进在于:每个元素都记录了自己的长度,遍历时不需要像 ziplist 那样反向依赖前一个元素的长度来定位,彻底避免了「连锁更新」这一经典痛点。
注意:普通用户其实感知不到这些变化,但在面试里被问到「Redis List 底层是什么结构」时,能说出这个演进过程,跟只说一句「是双向链表」完全是两个水平。
2.2 quicklist 的配置参数和内存优化
如果你用的是 Redis 5.0 以上版本,可以通过配置来调节 quicklist 的行为:
list-max-listpack-size:控制 quicklist 单个节点的最大大小,默认是 8KB(正数表示字节数,负数表示元素个数,比如 -5 表示最多 5 个元素)。节点太大,插入删除时要搬的数据就多;节点太小,链表节点数变多,指针开销上升。list-compress-depth:控制两端不压缩的节点个数,默认是 0(不压缩)。设置为 1 表示首尾各 1 个节点不压缩,其余全部用 LZF 算法压缩。
实际调优经验:如果你的 List 只用来做消息队列,消息体小、频率高,可以把list-max-listpack-size调大,减少节点数,降低遍历成本;如果 List 很大且很少随机访问,list-compress-depth设为 1 或 2 能省不少内存。我曾在两台 8G 内存的云服务器上做过测试,对一个大 List 开启压缩深度为 1 之后,内存占用从 2.1G 降到了 1.4G,效果非常明显。
2.3 编码转换的触发条件
用OBJECT ENCODING命令可以看一个 key 当前的编码:
127.0.0.1:6379> RPUSH mylist "a" "b" "c" (integer) 3 127.0.0.1:6379> OBJECT ENCODING mylist "listpack"当元素数量或单个元素长度超过阈值时,编码会从 listpack 转为 quicklist。这个转换是 Redis 自动完成的,你不需要干预,但了解它能帮你判断什么时候 List 已经「长大了」,什么时候该考虑拆 key。
3. 核心命令与使用场景:一条条命令吃透
3.1 写入端命令:LPUSH 与 RPUSH
LPUSH key element [element...]:把一个或多个元素从左侧插入,返回插入后列表的长度。RPUSH key element [element...]:从右侧插入。
一个很容易绕晕的点是「LPUSH 之后,LRANGE 输出的顺序是反的」。比如:
127.0.0.1:6379> RPUSH queue "task1" "task2" "task3" (integer) 3 127.0.0.1:6379> LRANGE queue 0 -1 1) "task1" 2) "task2" 3) "task3"RPUSH是按顺序从右往里塞,所以task1在最左边。但如果你换成LPUSH:
127.0.0.1:6379> LPUSH queue2 "task1" "task2" "task3" 127.0.0.1:6379> LRANGE queue2 0 -1 1) "task3" 2) "task2" 3) "task1"这是因为每次LPUSH都把新元素插到最左边,最后插入的task3就到了队首。记住这个特性,很多人的 bug 都出在「我以为 LPUSH 会按顺序排」上。
关于这个特性,在「消息队列」的场景下我个人的推荐是:生产者用LPUSH,消费者用BRPOP。BRPOP 从右边弹出,弹出的顺序就是 LPUSH 的逆序,等于说消费者拿到的是最早进入队列的消息。这在语义上天然构成一个 FIFO(先进先出)队列,直觉上好理解,而且不会搞混顺序。如果你用RPUSH+BLPOP也完全等价,核心是一端进、另一端出,但团队内部约定要统一,否则换人接手很容易把生产消费写反。
3.2 读取端命令:LRANGE、LINDEX、LLEN
LRANGE key start stop:返回指定区间内的元素,0是第一个,-1是最后一个是常见组合。LINDEX key index:按下标获取单个元素,负数从尾部倒数。LLEN key:返回列表长度,时间复杂度 O(1)。
LRANGE经常被拿来当分页用。比如每页 10 条,第 2 页就是LRANGE key 10 19。但这里有个性能坑:LRANGE 是 O(N) 操作,当 list 特别长时,取尾部数据需要遍历。避免方式一个是控制列表长度,另一个是优先考虑用逆向索引,比如LRANGE key -20 -1取最后 20 条,这种操作在某些底层实现下会比从头数快得多。
3.3 弹出端命令:LPOP、RPOP 和阻塞版
LPOP key:从左侧弹出一个元素。RPOP key:从右侧弹出一个元素。BLPOP key [key...] timeout:阻塞版 LPOP。如果列表为空,客户端会一直等待,直到有数据或超时。timeout 单位为秒,传0表示无限等待。BRPOP key [key...] timeout:同理,从右侧阻塞弹出。
阻塞弹出的价值在于:消费端不需要「轮询 + 休眠」,而是让 Redis 主动来通知。这不仅省了 CPU,也更实时。比如你用 Java 的while(true) { String msg = redisTemplate.opsForList().rightPop("queue"); }这种方式,如果队列空了,这个循环就会高频空转,对 Redis 和业务线程都是巨大的浪费。改成rightPop("queue", 10, TimeUnit.SECONDS)阻塞等待,线程就「挂起」在那里,有数据或者超时才返回。
如果同时传入多个 key,比如BRPOP queue1 queue2 queue3 0,Redis 会从左到右检查哪个 key 非空,就阻塞在第一个非空的 key 上。这个特性可以实现简单的优先级队列语义。
3.4 删除与裁剪:LREM、LTRIM
LREM key count element:删除列表中等于 element 的元素,count 正数从头删,负数从尾删,0 删全部。LTRIM key start stop:只保留指定区间内的元素,区间外的全部裁剪。
用LTRIM配合RPUSH可以做一个「只保留最近 N 条」的日志列表:
RPUSH audit_log "2025-01-01: login" RPUSH audit_log "2025-01-01: logout" LTRIM audit_log -100 -1每次插入后裁剪一把,列表长度就稳定在 100 条以内,内存可控。我做过一个操作审计功能,就是靠这个组合,让 Redis 帮我们扛住了每秒几千条写入,而且数据永远只保留最近 200 条。
3.5 其他冷门但有用的命令
RPOPLPUSH source destination:从 source 的右侧弹出一个元素,并把它从左侧压入 destination。这是个原子操作,最经典的应用是「备份/确认队列」:从待处理队列弹出,同时压入处理中队列,等业务成功后再删除处理中队列的对应元素。如果业务失败,还能从处理中队列恢复到待处理队列。LMOVE source destination LEFT|RIGHT LEFT|RIGHT:RPOPLPUSH的通用版本,可以控制左右方向。Redis 6.2 之后推荐用这个。LINSERT key BEFORE|AFTER pivot element:在某个参考元素前后插入元素。
4. 实操:从安装连接到完整存取 List 集合
4.1 准备 Redis 环境
Windows 用户注意,官方 Redis 是不支持 Windows 的,但微软和第三方维护过移植版。现在最省事的方式是Windows 下用 WSL 2 装 Linux 再装 Redis,或者直接用 Docker:
docker run --name redis-dev -p 6379:6379 -d redis:7.2Linux 下源码编译安装的步骤是:
wget https://download.redis.io/releases/redis-7.2.4.tar.gz tar xzf redis-7.2.4.tar.gz cd redis-7.2.4 make make install PREFIX=/usr/local/redis装完之后验证一下:
redis-server --version redis-cli -h 127.0.0.1 -p 6379 ping看到PONG就说明环境OK了。
联网不顺畅时,源码包下载经常是第一个坑。如果下载地址连不上,没事,GitHub 上搜 Redis 官方仓库的 release 里有对应 tag 的 tar.gz,挑国内可达的镜像拉就行。
4.2 Java 客户端:Spring Data Redis 操作 List
Java 生态里最常见的客户端是 Spring Data Redis(底层是 Lettuce 或 Jedis)。我直接给一段实测可用的代码。
先配置连接:
spring: data: redis: host: 127.0.0.1 port: 6379 # password: yourpassword database: 0 timeout: 3s lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0然后创建一个测试类:
@Service public class ListService { @Resource private RedisTemplate<String, Object> redisTemplate; public void demoSaveAndRead() { String key = "demo:list"; ListOperations<String, Object> ops = redisTemplate.opsForList(); // 从左侧批量压入,注意顺序会反转 ops.leftPushAll(key, "任务1", "任务2", "任务3"); // 从右侧弹出一条(FIFO 语义) Object msg = ops.rightPop(key); System.out.println("消费: " + msg); // 查看当前所有元素 List<Object> all = ops.range(key, 0, -1); System.out.println("剩余: " + all); // 阻塞弹出,队列空时最多等5秒 Object blocked = ops.rightPop(key, 5, TimeUnit.SECONDS); System.out.println("阻塞消费: " + blocked); } }代码层面有三个点要特别注意:
- RedisTemplate 的默认序列化器是 JdkSerializationRedisSerializer,它会把对象序列化成二进制,存进去之后在 Redis 里面看到的是
\xAC\xED\x00\x05t\x00...这种鬼东西,既占空间又没法排查。我建议单独建一个StringRedisTemplate或者自定义RedisTemplate<String, String>,用 String 序列化。 - 如果业务需要存 JSON,可以在 ValueSerializer 上用
GenericJackson2JsonRedisSerializer,但要注意带上@class类型信息,否则反序列化时可能丢失类型。 rightPop(key, timeout, unit)是阻塞操作,在网络抖动或者 Redis 性能下降的时候,它会一直挂住,所以 timeout 一定要设置合理,我一般设 3~10 秒。
4.3 Python 客户端:redis-py 操作 List
Python 端的 redis-py 用起来更直接:
import redis import time r = redis.Redis(host='127.0.0.1', port=6379, db=0, decode_responses=True) key = "py:list" r.delete(key) # 从右侧插入 r.rpush(key, "user:1001", "user:1002", "user:1003") # 从左侧阻塞弹出,最多等10秒 msg = r.blpop(key, timeout=10) print("consume:", msg) # ('py:list', b'user:1001') # 查看长度和内容 print("len:", r.llen(key)) print("all:", r.lrange(key, 0, -1)) # 生产-消费:生产者从左侧推,消费者从右侧阻塞弹出 r.lpush("task:queue", "job-1") task = r.brpop("task:queue", timeout=0) print("task:", task)decode_responses=True这个参数我每次都写,作用是让返回结果自动从 bytes 转成 str。如果不加,拿到的全是b'xxx'格式,排查问题的时候会多很多烦心事。
4.4 Go 客户端:go-redis 操作 List
Go 项目里我常用 go-redis/v9,代码长这样:
package main import ( "context" "fmt" "time" "github.com/redis/go-redis/v9" ) func main() { ctx := context.Background() rdb := redis.NewClient(&redis.Options{ Addr: "127.0.0.1:6379", Password: "", DB: 0, }) key := "go:list" rdb.Del(ctx, key) // 从左侧压入 rdb.LPush(ctx, key, "item-1", "item-2", "item-3") // 从右侧弹出 val, err := rdb.RPop(ctx, key).Result() fmt.Println("poped:", val, err) // 阻塞从右侧弹出,最多等 5 秒 val2, err := rdb.BRPop(ctx, 5*time.Second, key).Result() fmt.Println("blocked pop:", val2, err) // 获取全部元素 items, _ := rdb.LRange(ctx, key, 0, -1).Result() fmt.Println("remaining:", items) }4.5 用 Redis 可视化客户端查看 List
命令行里调试 list 还行,数据一多就不直观了。我一般用 RedisInsight 或 Another Redis Desktop Manager。连接上之后,找到对应的 key,能看到 List 内部每个元素的下标和值,排查问题时比敲命令高效好几倍。
但有一点提醒:可视化工具默认展示的是「反序列化后的值」,如果你的 key 是用 JDK 序列化写进去的,工具里显示的会是一串乱码。如果你看到类似\xAC\xED开头的内容,不要慌,说明你的 RedisTemplate 配置有问题,去看 5.2 节的解决方案。
5. 进阶实战:从「能存取」到「用得优雅」
5.1 用 Redis List 实现消息队列,可靠性和坑
最简单的消息队列就是LPUSH+BRPOP,但真要投产,这套方案有几个绕不开的短板:
- 消息丢失:
BRPOP弹出消息后,如果消费端崩了,这条消息就没了。这是最典型的问题,因为 Redis List 的弹出不是事务性的,「弹出」和「业务处理」之间没有原子保证。 - 重复消费:消费端处理完但没来得及确认,另一个消费者又把同一条消息弹走了,就会重复处理。
- 没有延迟消息:List 的每个元素没有 TTL 属性,做不到「多少秒后才能被消费」。要实现延迟队列,一般要拿 sorted set 按时间戳排序,再轮询取到期的元素。
- ack 机制缺失:List 本身没有「确认消费」的语义,需要你自己用
RPOPLPUSH把消息备份到另一个队列来补偿。
我自己的做法是:坚持用RPOPLPUSH把「待处理队列」和「处理中队列」串联起来。业务方从待处理队列弹出消息并推入处理中队列,处理完成后从处理中队列删除;如果处理失败,做一个定时任务把处理中超时的消息重新放回待处理队列。这套方案能覆盖 90% 的内部消息场景,只有真正需要「精确一次投递」的高等级场景,才需要引入 Redis Stream 或者 Kafka。
5.2 序列化方式对存取的影响
一个 list 里存进去的值,最终在 Redis 里都是字节。那么你用 String 序列化还是 JSON 还是 JDK 序列化,直接影响的是:
- 同样的内容占多大内存
- 能不能被可视化工具和命令行直观识别
- 跨语言访问会不会出问题
我踩过的最大一个坑:Java 服务用 JdkSerializationRedisSerializer 写入 list,然后 Python 服务去读,读出来全是一串看不懂的字节。排查到后面发现,根本不是加密问题,纯粹是序列化协议不兼容。后来统一改成 JSON 字符串存进去,Java 写 Python 读,干干净净。
如果你用的是StringRedisTemplate,实际上每个元素都是字符串,存取对象时要自己先JSON.toJSONString(obj)再存,取出时再parseObject。这个多一步操作看起来麻烦,但换来的是跨语言兼容和可读性,这笔账还是划算。
5.3 List 做分页列表的陷阱
用LRANGE key offset limit做分页,这个模式在小数据量时用起来很顺手。比如用户关注列表:
LPUSH user:1001:follows user:2001 user:2002 user:2003 LRANGE user:1001:follows 0 9 # 第一页 LRANGE user:1001:follows 10 19 # 第二页但这里有两个隐藏问题:
- LRANGE 的 start 和 stop 是下标区间,不是「limit」。
LRANGE key 10 19和 SQL 里面的LIMIT 10, 10很像,但如果你写LRANGE key 0 10,你会拿到 11 条而不是 10 条,手一抖就数组越界。 - 大列表分页后期性能会变差。List 是双向链表变种,定位越靠后的元素成本越高。列表超过几万条之后,深分页延迟会很感人。如果确实需要频繁深分页,考虑换 ZSet 按分数排序,或者直接在数据库里完成分页,把 Redis 当成缓存层。
5.4 结合分布式锁的场景:List 也能当锁的存储介质
虽然分布锁常用 String + SETNX 实现,但 List 在某些轻量场景下也能当锁用。比如多实例同时消费一个 List,为了保证「一条消息只被一个消费者拿到」,最简单的做法是LPOP本身是原子的,多个消费者并发弹出,Redis 的原子性天然保证不会弹出同一条数据。这个特性很实用,比自己在应用层加锁优雅得多:
// 两个消费者实例同时执行,不会拿到同一条消息 String msg = redisTemplate.opsForList().leftPop("task:queue");但是注意,leftPop只是「消息不被重复弹出」,不保证「业务不重复处理」。如果你要保证业务幂等,还是得给每条消息加唯一 ID,消费时去重。
5.5 缓存治理视角下 List 的 Key 设计
在生产环境治理缓存时,我发现很多同事对 List 的 key 设计过于随意。比如user_list、list_123,这种 key 没有业务维度、没有分隔符规范,时间一长根本不知道对应哪个业务。
我建议的 key 设计格式:业务名:对象类型:ID:用途。
举例:
follow:user:1001:recentqueue:order:pay:timeoutaudit:admin:2008:ops
这样的好处是:排查问题时能快速定位;按前缀扫描时能批量管理;配合SCAN命令翻页遍历时不会误伤其他业务的数据。
另一个治理经验是给 List key 设置过期时间时要很谨慎。EXPIRE可以设置到 key 上,但 List 是高频写入的,续期逻辑一旦忘了写,很可能是大批数据在新的一轮活动直接「蒸发」。我的做法是:如果 List 是「临时攒一批处理一批」模式,就大胆设置 TTL;如果是长期累积的模式(比如操作日志),就靠LTRIM控制上限,而不是靠过期时间。
5.6 Redis 主从和持久化:数据安全不可忽视
Redis List 里的数据如果在主从环境下没有正确配置,会面临两个风险。
第一是主从复制的延迟。一个消费者连接的是从库,生产者写入主库;如果主从同步延迟了,消费者可能读不到刚写入的元素。解决方案是:消费端只连接主库,或者接受一定的延迟容忍。我见过有人把读写全部分离到不同节点,结果队列偶现「消息延迟几十毫秒」,排查了半天才发现是复制延迟。
第二是持久化策略。Redis 默认 RDB 快照,如果 Redis 崩溃,可能丢失最后一次快照之后写入的数据。对消息队列来说,丢消息是不能忍的。所以生产环境我通常开启 AOF,并且配置appendfsync everysec,把丢失窗口压缩到 1 秒以内。如果持久化开关是关闭的、又跑在云厂商那类内存型实例上,那真就相当于拿着布袋装水——微博上有句话说得很难听但很真实:「Redis without persistence is just a cache」,做队列必须开持久化。
6. 常见问题与排查技巧实录
先放一张问题速查表,后面挑典型问题展开讲。
| 问题现象 | 可能原因 | 快速解决 |
|---|---|---|
| 可视化客户端看到乱码 | JDK 默认序列化 | 换 StringRedisTemplate / JSON 序列化 |
| LRANGE 返回顺序跟预期相反 | LPUSH 后顺序反转 | 统一用 RPUSH+LPOP 或 LPUSH+RPOP |
| BRPOP 一直阻塞不返回 | key 为空或连接异常 | 检查 key 是否存在、Redis 是否可达 |
| List 内存增长飞快 | 未做 LTRIM 裁剪 | 写入后定期 LTRIM 保留窗口 |
| 消息被重复消费 | 无 ack 机制 | 用 RPOPLPUSH 做处理中队列 |
| 连接不上 Redis | 防火墙/认证/配置 | 检查 ping、AUTH、网络 |
| 阻塞命令导致线程池耗尽 | 消费线程太多且超时太长 | 减小 timeout、限流消费线程数 |
6.1 连接不上 Redis 的排查路径
结合热搜词里那批「黑马点评连接不上 redis」「redis 安装配置」的搜索需求,我猜多数人是卡在「本地装好了却发现程序连不上」。排查路径我按顺序说一下:
redis-cli ping通不通。如果不通,检查服务有没有起来:Linux 用ps -ef | grep redis,Windows 检查服务管理器。- 通但程序连不上,先看
redis.conf里的bind配置。默认bind 127.0.0.1 -::1表示只允许本机连。如果你要跨机器访问或者 docker 端口映射,改成bind 0.0.0.0或者注释掉这行,然后重启 Redis。 - 开启了
protected-mode yes会禁止非本机访问且没有密码的情况。要么配置密码requirepass yourpassword,要么暂时关掉保护模式。生产环境必须加密码。 - 云服务器在安全组里放行 6379 端口。很多人在本机测试没问题,上到云上就连接超时,八成是安全组没放行。
- 如果你用 Docker 跑 Redis,检查是否做了端口映射:
docker ps看6379/tcp -> 0.0.0.0:6379那一行,没有映射的话宿主机根本访问不到容器内端口。
6.2 阻塞命令把连接卡死
BRPOP超时时间设得太长,消费连接数又开得多,会导致 Redis 客户端连接池被「挂住」的连接占满。我踩过一次:一个服务开了 50 个线程消费队列,每个线程都用rightPop(key, 30, SECONDS),结果高峰期一过,连接池被 50 个阻塞连接占满,新请求全部排队。
解决办法有两个思路:一个是把 timeout 缩短到 3~5 秒,让线程循环起来,及时释放连接;另一个是给 RedisTemplate 的 Lettuce 连接池调大max-active,保证即使有阻塞连接,也得留出余量给普通读写。更彻底的办法是不用阻塞命令,而是用「定时轮询 + 非阻塞 LPOP」,牺牲一点实时性,换连接资源的安全。
6.3 大 Key 清理和 List 拆分
一个 List 到了几十万条,会变成所谓的「大 Key」。大 Key 的危害是:
- 删除时可能阻塞 Redis 主线程(
DEL一个大 key 会秒级卡顿) - 迁移、备份都变慢
- 单 key 占用过多内存,集群模式下会造成数据倾斜
我处理过一个 60 万条的 list,用DEL删的时候 Redis 直接卡了 4 秒。正确做法是用LTRIM key 1 0渐进式裁剪,虽然也会耗时,但比一次性删除温和;或者用UNLINK命令异步删除,让后台线程慢慢回收内存。
如果业务上确实需要很长的 List,可以考虑分片存储:比如按用户 ID 哈希到 10 个 key,每个 key 前缀加_0到_9,读取时按需只查其中一个分片。代价是分页和全量遍历变麻烦,但能避免单个 key 过大带来的连锁问题。
6.4 序列化陷阱:为什么存进去 JSON 取出来是对象
用GenericJackson2JsonRedisSerializer的时候,它会在 JSON 里塞一个@class字段。如果两个服务用的实体类包名不一样,反序列化时会直接报错。我做微服务改造时踩过这个坑:A 服务写入@class: com.old.package.Order,B 服务里根本没有这个类,一读取就ClassNotFoundException。
解决办法是:跨服务共享数据时,统一约定用字符串或最朴素的 Map/JSON,不要依赖 Redis 自动反序列化成具体类型。写到 Redis 之前就转成 JSON 字符串,读取出来之后再用自己的ObjectMapper或Gson转成业务对象。这样虽然多写几行代码,但换来的是服务间解耦和跨语言兼容。
6.5 从面试题视角看 List 优先级
热搜名单里「redis面试题」出现频率很高,我说几个跟 List 强相关的经典题,你自己检查会不会:
- 怎么用 Redis 实现一个延迟队列?答案核心:sorted set 按时间戳排序,轮询取到期元素。List 本身做不到延迟,因为元素没有 TTL。
BRPOPLPUSH和RPOPLPUSH的区别是什么?前者是阻塞版,如果 source 为空则阻塞等待。它们都能实现消息备份。- List 和 Stream 的定位差异是什么?如果只是简单队列,List 够用;如果需要消费组、消息确认、持久化游标等,需要 Stream。
- 为什么说 List 不适合做「粉丝列表」这种大集合?因为随机访问(LRANGE)在链表结构上是 O(N),而且元素多了内存碎片明显。粉丝列表用 set 或 zset 更合适。
这些问题看似简单,但每一次「为什么」背后,都对应着底层结构、命令设计的细节。建议你动手把这些场景都写一遍代码,比背八股文强得多。
7. 一点个人体会
用 Redis List 这些年,我最大的感触是:它不是一个万能容器,而是一把恰好解决了某些场景痛点的瑞士军刀。你会用LPUSH/BRPOP不代表就会用 List,真正值钱的是知道什么时候该用它、什么时候该把它换掉。
比如,我见过有人拿 List 存每天几百万条日志,结果内存爆了;也见过有人拿 List 存几十万粉丝 ID,结果深分页慢到用户投诉。这些都不是 List 的错,而是选型错了。List 最适合的是「先入先出」「滑动窗口」「临时积攒」这类天然有序、有边界(可以裁剪)的数据流。要是数据量大且需要随机读取,不如去看看 ZSet、Stream,甚至退回到 MySQL 也未必是坏事。
最后再分享一个小技巧:排查 List 问题的时候,记得用DEBUG OBJECT key看 serializedlength,能快速估算锁内存大小;用MEMORY USAGE key能直接看到当前 key 占了多少字节。这两个命令在 Redis 4.0 之后就有,但很多人根本没用过。
List 的知识点不算难,但真的是那种「会了不难,难了不会」的东西。你把底层结构捋顺了,把阻塞、序列化、持久化这些周边问题都过一遍,Redis 面试这一块基本就稳了一半。剩下的,就是多写代码、多踩坑、多总结。