6.list【由浅入深-redis】
文章目录第一章深入理解 Redis List从基础模型到阻塞队列1 Redis List 的数据模型与核心特性1.1 什么是 Redis List1.2 核心特性剖析2 基础操作命令插入与查询2.1 插入命令LPUSH、LPUSHX、RPUSH、RPUSHX2.2 范围查询与长度LRANGE、LLEN3 元素修改与删除命令3.1 索引访问与修改LINDEX、LSET3.2 元素插入与删除LINSERT、LPOP、RPOP、LREM、LTRIM4 阻塞命令BLPOP、BRPOP5 命令复杂度总结与实践建议第二章Redis List 的内部编码与演进1 从 ziplist 与 linkedlist 到 quicklist 的演进1.1 早期双编码机制ziplist 与 linkedlist1.2 现代 Redis 的统一编码quicklist第三章Redis List 的核心应用场景与实战1 消息队列与多频道订阅模型1.1 阻塞式生产者-消费者模型1.2 分频道的消息队列模拟 Topic/Channel2 数据展示与分页微博 Timeline 场景2.1 场景描述与基础实现2.2 性能瓶颈与优化策略3 List 结构选型总结栈与队列的抉择第一章深入理解 Redis List从基础模型到阻塞队列1 Redis List 的数据模型与核心特性1.1 什么是 Redis ListRedis 的 List列表类型是用来存储多个有序的字符串的集合。从底层结构来看它并非是一个简单的数组而是更接近于双端队列deque。在逻辑上列表的左侧left称为头部右侧right称为尾部。在 Redis 中我们可以对列表两端进行插入push和弹出pop操作还可以获取指定范围的元素列表、获取指定索引下标的元素等。它非常灵活既可以充当栈Stack也可以充当队列Queue在实际开发中有着广泛的应用场景。1.2 核心特性剖析要想熟练掌握 List必须深刻理解它的三个核心特性第一元素是有序的。如果把元素位置颠倒顺序被清除那么此时的 List 和之前的 List 是不等价的。Redis 的 List 索引分为正数下标和倒数下标。正数下标从0开始倒数下标从-1开始。这意味着如果一个列表有 5 个元素lindex key 4和lindex key -1获取的是同一个尾部元素。第二区分获取和删除的区别。获取操作如LINDEX、LRANGE只会返回元素的值不会改变列表的长度而删除操作如LPOP、LREM会弹出或移除元素导致列表长度发生变化。此外LINDEX如果遇到下标越界会优雅地返回nil但如果是修改操作LSET遇到越界则会直接报错。第三元素是允许重复的。这一点与 Hash 类型不同Hash 的 field 不能重复。在 List 中可以存在多个相同的元素例如[a, b, c, e, a, d]中包含了两个a元素。2 基础操作命令插入与查询2.1 插入命令LPUSH、LPUSHX、RPUSH、RPUSHXLPUSH左侧插入将⼀个或者多个元素从左侧放⼊头插到 list 中。语法LPUSH key element [element ...]时间复杂度只插⼊⼀个元素为 (O(1))插⼊多个元素为 (O(N))(N) 为插⼊元素个数。返回值插⼊后 list 的⻓度。注意如果 key 已经存在并且 key 对应的 value 类型不是 list此时 LPUSH 命令会报错。另外插入多个元素时比如LPUSH key 9 10 11会依次头插最终列表顺序为11 10 9。示例redis LPUSH mylist world (integer) 1 redis LPUSH mylist hello (integer) 2 redis LRANGE mylist 0 -1 1) hello 2) worldLPUSHX左侧插入但要求 key 存在在 key 存在时将⼀个或者多个元素从左侧放⼊头插到 list 中。不存在直接返回 0。语法LPUSHX key element [element ...]时间复杂度只插⼊⼀个元素为 (O(1))插⼊多个元素为 (O(N))。返回值插⼊后 list 的⻓度。示例redis LPUSH mylist World (integer) 1 redis LPUSHX mylist Hello (integer) 2 redis LPUSHX myotherlist Hello (integer) 0 redis LRANGE mylist 0 -1 1) Hello 2) World redis LRANGE myotherlist 0 -1 (empty array)RPUSH右侧插入将⼀个或者多个元素从右侧放⼊尾插到 list 中。语法RPUSH key element [element ...]时间复杂度只插⼊⼀个元素为 (O(1))插⼊多个元素为 (O(N))。返回值插⼊后 list 的⻓度。示例redis RPUSH mylist world (integer) 1 redis RPUSH mylist hello (integer) 2 redis LRANGE mylist 0 -1 1) world 2) helloRPUSHX右侧插入但要求 key 存在在 key 存在时将⼀个或者多个元素从右侧放⼊尾插到 list 中。语法RPUSHX key element [element ...]时间复杂度只插⼊⼀个元素为 (O(1))插⼊多个元素为 (O(N))。返回值插⼊后 list 的⻓度。示例redis RPUSH mylist World (integer) 1 redis RPUSHX mylist Hello (integer) 2 redis RPUSHX myotherlist Hello (integer) 0 redis LRANGE mylist 0 -1 1) World 2) Hello redis LRANGE myotherlist 0 -1 (empty array)2.2 范围查询与长度LRANGE、LLENLRANGE获取区间元素获取从 start 到 end 区间的所有元素左闭右闭。语法LRANGE key start stop时间复杂度(O(SN))(S) 是 start 偏移量(N) 是 start 到 end 的范围。返回值指定区间的元素。细节如果下标越界Redis 会优雅地处理不会像 C 或 Java 那样抛出异常。比如给一个超出范围的区间它会尽可能获取能获取到的内容如果区间完全无效如LRANGE mylist 5 10且 list 只有 3 个元素则返回空数组。这一点更贴近 Python 的切片处理方式。鲁棒性更强容错能力更强鲁棒性你对我越粗鲁我就表现的越棒。示例LLEN获取列表长度获取 list ⻓度。语法LLEN key时间复杂度(O(1))。返回值list 的⻓度。示例redis LPUSH mylist World (integer) 1 redis LPUSH mylist Hello (integer) 2 redis LLEN mylist (integer) 23 元素修改与删除命令3.1 索引访问与修改LINDEX、LSETLINDEX获取指定索引元素获取从左数第 index 位置的元素。语法LINDEX key index时间复杂度(O(N))(N) 是索引的偏移量。返回值取出的元素或者 nil。细节LINDEX可以很好地处理下标越界的情况直接返回nil。示例redis LPUSH mylist World (integer) 1 redis LPUSH mylist Hello (integer) 2 redis LINDEX mylist 0 Hello redis LINDEX mylist -1 World redis LINDEX mylist 3 (nil)LSET根据下标修改元素语法LSET key index element时间复杂度(O(N))(N) 是索引的偏移量。返回值成功返回OK。细节与LINDEX不同LSET如果遇到下标越界会直接报错而不是像lindex那样返回nil示例redis RPUSH mylist one two three (integer) 3 redis LSET mylist 0 newone OK redis LSET mylist 10 error (error) ERR index out of range3.2 元素插入与删除LINSERT、LPOP、RPOP、LREM、LTRIMLINSERT特定位置插入在特定位置插⼊元素。语法LINSERT key BEFORE | AFTER pivot element时间复杂度(O(N))(N) 是 pivot 距离头尾的距离。返回值插⼊后的 list ⻓度。如果基准值 pivot 不存在返回-1。细节LINSERT 进行插入的时候要根据基准值找到对应的位置。从左往右找找到一个符合基准值的位置即可如果有多个相同的 pivot只操作第一个。比如在Hello和World之间插入There。示例redis RPUSH mylist Hello (integer) 1 redis RPUSH mylist World (integer) 2 redis LINSERT mylist BEFORE World There (integer) 3 redis LRANGE mylist 0 -1 1) Hello 2) There 3) WorldLPOP / RPOP左右弹出从 list 左侧/右侧取出元素即头删/尾删。语法LPOP key/RPOP key时间复杂度(O(1))。返回值取出的元素或者 nil。细节在当前的 Redis 5 版本中LPOP和RPOP都没有count参数。从 Redis 6.2 版本开始新增了count参数代表这次要弹出几个元素。示例redis RPUSH mylist one two three four five (integer) 5 redis LPOP mylist one redis LPOP mylist two redis LPOP mylist three redis LRANGE mylist 0 -1 1) four 2) five redis RPOP mylist five redis LRANGE mylist 0 -1 1) fourLREM删除指定元素语法LREM key count element时间复杂度(O(K))(K) 是元素个数。返回值被删除元素的个数。细节LREM命令的count参数决定了删除策略count 0从左往右删删除最多count个值等于element的元素。count 0从右往左删删除最多count的绝对值个值等于element的元素。count 0删除所有值等于element的元素。示例redis RPUSH mylist a b c a b c (integer) 6 redis LREM mylist 1 b (integer) 1 redis LRANGE mylist 0 -1 1) a 2) c 3) a 4) b 5) cLTRIM保留指定区间保留 start 和 stop 之间的元素区间外的元素直接被删除。语法LTRIM key start stop时间复杂度(O(K))(K) 是元素个数。返回值成功返回OK。细节LTRIM可以用于截断列表常用于控制列表长度例如只保留最新的 100 条记录。示例redis RPUSH mylist one two three four five (integer) 5 redis LTRIM mylist 1 3 OK redis LRANGE mylist 0 -1 1) two 2) three 3) four4 阻塞命令BLPOP、BRPOPBLPOP和BRPOP分别是LPOP和RPOP的阻塞版本。与普通弹出命令相比它们最大的特点在于当列表中没有元素时不会立即返回nil而是让当前客户端进入等待状态直到有元素可弹出或达到超时时间。语法BLPOP key [key ...] timeout BRPOP key [key ...] timeoutkey [key ...]可以指定一个或多个列表键。timeout超时时间单位为秒阻塞与非阻塞的差异在列表中有元素的情况下阻塞和非阻塞表现是一致的。两者都会立即弹出元素并返回。如果列表中没有元素非阻塞版本会立即返回nil但阻塞版本会根据timeout阻塞一段时间。期间 Redis 可以执行其他命令但要求执行该命令的客户端会表现为阻塞状态。示例说明例如客户端 A 执行BLPOP mylist 10如果mylist中没有元素客户端 A 就进入等待状态但 Redis 服务器仍然正常工作客户端 B 可以执行其他命令。如果客户端 B 在这 10 秒内向mylist插入了元素Redis 就会把元素弹出并返回给客户端 AA 立即结束等待如果一直没有元素A 就在超时后收到nil。阻塞的是客户端 A 的这条连接而不是整个 Redis 服务器。超时时间设定此处可以指定一个 key 或者多个 key每个 key 对应一个 list。如果这些 list 中有一个非空BLPOP就能移除该 list 中的元素并立即返回。如果这些 list 都为空此时就需要阻塞等待等其他客户端往这些 list 中插入元素了。此处还可以指定超时时间单位是秒。Redis 6 中超时时间允许设置成小数Redis 5 中超时时间只能是整数。timeout 为 0 表示无限阻塞。重要特性虽然看起来阻塞耗时很久但实际上并不会对 Redis 服务器产生负面影响Redis 采用单线程模型阻塞命令会挂起当前客户端但不会阻塞整个 Redis 服务。多键遍历与公平性命令中如果设置了多个键那么会从左向右进行遍历键一旦有一个键对应的列表中可以弹出元素命令立即返回。如果多个客户端同时对多个键执行 pop则最先执行命令的客户端会得到弹出的元素。例如多个客户端等待同一个列表时当列表中有新元素加入先执行等待命令的客户端会优先拿到元素。返回值返回的结果相当于一个 pair二元组一方面是告诉当前的元素来自于哪个 key另一方面告诉我们取得的数据是什么。示例针对一个非空列表进行操作redis RPUSH list1 a b c (integer) 3 redis BLPOP list1 0 1) list1 2) a针对一个空列表进行操作redis EXISTS list1 list2 (integer) 0 redis BLPOP list1 100 (nil) (等待 100 秒后返回 nil)如果在此期间其他客户端执行了LPUSH list1 foo则当前客户端会立即返回1) list1 2) foo针对多个 key 进行操作redis DEL list1 list2 (integer) 0 redis RPUSH list1 a b c (integer) 3 redis BRPOP list1 list2 0 1) list1 2) c5 命令复杂度总结与实践建议有关列表的命令已经介绍完毕下表是这些命令的作用和时间复杂度开发人员可以参考操作类型命令时间复杂度添加rpush key value [value ...](O(k))k 是元素个数lpush key value [value ...](O(k))k 是元素个数linsert key before | after pivot value(O(n))n 是 pivot 距离头尾的距离查找lrange key start end(O(sn))s 是 start 偏移量n 是 start 到 end 的范围lindex key index(O(n))n 是索引的偏移量llen key(O(1))删除lpop key(O(1))rpop key(O(1))lrem key count value(O(k))k 是元素个数ltrim key start end(O(k))k 是元素个数修改lset key index value(O(n))n 是索引的偏移量阻塞操作blpop/brpop(O(1))实践建议Redis 中的 List 是一个双端队列从两头插入/删除元素都是非常高效\(O(1)\)的。搭配使用rpush和lpop就相当于队列了先进先出。搭配使用rpush和rpop就相当于栈了后进先出。利用BLPOP和BRPOP可以轻松实现阻塞队列、生产者-消费者模型是构建消息队列的利器。第二章Redis List 的内部编码与演进1 从 ziplist 与 linkedlist 到 quicklist 的演进1.1 早期双编码机制ziplist 与 linkedlist在早期的 Redis 版本中List 类型的内部编码并不是单一的而是根据数据特征在两种结构之间自动切换目的是在内存占用和操作性能之间寻找平衡。ziplist压缩列表当列表的元素个数小于list-max-ziplist-entries配置默认 512 个同时列表中每个元素的长度都小于list-max-ziplist-value配置默认 64 字节时Redis 会选用 ziplist 来作为列表的内部编码实现以极大减少内存消耗。ziplist 是一块连续的内存空间所有的元素紧凑排列非常适合存储小数据量。127.0.0.1:6379 rpush listkey e1 e2 e3 OK 127.0.0.1:6379 object encoding listkey ziplistlinkedlist双向链表当列表类型无法满足 ziplist 的条件时比如元素个数超过 512或者某个元素的长度超过 64 字节Redis 会使用 linkedlist 作为列表的内部实现。链表在插入和删除节点时非常高效但每个节点都需要额外的指针来维持前后关系内存开销较大且容易产生内存碎片。# 元素个数超过 512 时内部编码变为 linkedlist 127.0.0.1:6379 rpush listkey e1 e2 e3 ... 省略 e512 e513 OK 127.0.0.1:6379 object encoding listkey linkedlist # 某个元素长度超过 64 字节时内部编码变为 linkedlist 127.0.0.1:6379 rpush listkey one string is bigger than 64 bytes ... OK 127.0.0.1:6379 object encoding listkey linkedlist1.2 现代 Redis 的统一编码quicklist随着 Redis 的发展ziplist 和 linkedlist 的切换机制逐渐被一种更优的结构取代。目前的 Redis 中List 类型已经只有 quicklist 一种内部编码了。quicklist 本质上是 ziplist 和 linkedlist 的结合体。它是一个由多个 ziplist 节点组成的双向链表。换句话说它把一个大的链表分割成多个小的 ziplist 片段。这样做的好处是内存效率高每个节点内部使用 ziplist避免了普通链表每个节点都要存前后指针的巨大开销。操作性能好整体上是个双向链表头尾插入删除依然保持 (O(1))同时由于单个 ziplist 不会太大避免了超大型连续内存分配带来的性能抖动。无论你插入的元素个数有多少、元素有多大List 的底层都会使用 quicklist 结构来适应各种场景。通过OBJECT ENCODING命令查看时不再会出现 ziplist 或 linkedlist而是统一显示quicklist。第三章Redis List 的核心应用场景与实战1 消息队列与多频道订阅模型1.1 阻塞式生产者-消费者模型Redis 可以使用lpushbrpop命令组合实现经典的阻塞式生产者-消费者模型队列。生产者客户端使用lpush从列表左侧插入元素或者使用rpush从右侧插入。消费者多个消费者客户端使用brpop命令阻塞式地从队列中“争抢”队首元素。这个模型的关键在于“争抢”和“阻塞”。由于brpop是阻塞操作当列表为空时消费者会挂起等待一旦生产者push了新元素Redis 就会唤醒其中一个消费者。只有一个消费者能“抢到”元素这保证了每条消息只会被消费一次类似于包子铺里只有最先伸手的那个人能拿到包子。通过多个客户端来保证消费的负载均衡和高可用性。轮询效果与公平性假设消费者执行顺序是 1、2、3。当前元素到达之后首先是消费者 1 抢到元素按照执行brpop命令的先后顺序来决定谁获取到的。消费者 1 拿到元素之后也就从brpop命令中返回了。如果消费者 1 还想继续消费就需要重新执行brpop。此时再来一个新的元素就是消费者 2 拿到该元素消费者 2 拿到之后再次重新执行brpop再来一个新元素就是消费者 3 拿到该元素。这种设定就能构成一个“轮询”式的效果在宏观上实现了消费者之间的负载均衡。1.2 分频道的消息队列模拟 Topic/Channel在真实的业务中我们往往需要处理多种类型的消息。Redis 同样使用lpushbrpop命令但通过不同的键模拟频道的概念。不同的消费者可以通过brpop不同的键值实现订阅不同频道的理念。例如我们可以设置多个列表channel:video、channel:danmaku、channel:like、channel:comment。生产者使用lpush channel:video发布视频使用lpush channel:danmaku发布弹幕。消费者可以使用brpop channel:video 0只消费视频数据或者使用brpop channel:video channel:danmaku 0同时监听多个频道。这种多列表/多频道的设计非常常见类似发布订阅中的 channel/topic。它的优势在于解耦有多个通道如果某种数据发生问题比如弹幕服务挂了不会对其他数据造成影响。同时消费者也实现了按需订阅非常灵活。2 数据展示与分页微博 Timeline 场景2.1 场景描述与基础实现微博 Timeline 需要解决两件事微博内容存哪里微博顺序怎么排。做法是Hash 存微博详情mblog:1保存标题、时间、正文。List 存微博 IDuser:1:mblogs保存该用户微博的排列顺序。HSET mblog:1 title 第一篇微博 timestamp 1476536196 content 今天学习 Redis HSET mblog:2 title 第二篇微博 timestamp 1476536296 content 今天学习 List LPUSH user:1:mblogs mblog:1 LPUSH user:1:mblogs mblog:2 # List 从左到右为 [mblog:2, mblog:1]最新微博在前 LRANGE user:1:mblogs 0 9 # 返回 [mblog:2, mblog:1] HGETALL mblog:2 HGETALL mblog:1注意List 里存的是mblog:2这样的字符串不是微博正文Redis 不会自动根据它去查 Hash需要程序再读一次。分页就是换LRANGE的下标范围。每页 10 条时第 1 页LRANGE user:1:mblogs 0 9第 2 页LRANGE user:1:mblogs 10 19第 (p) 页start (p - 1) * 10stop p * 10 - 1流程始终是先查 List 拿微博 ID再按 ID 查微博详情。2.2 性能瓶颈与优化策略问题一1 n 网络往返。一页 10 条微博需要1 次LRANGE拿 10 个 ID再 10 次HGETALL拿详情共 11 次命令。如果逐条发送、逐条等待网络往返会很多。优化方式Pipeline把 10 条HGETALL一起发送统一收结果。减少网络等待但仍是 10 条命令不保证原子执行。改用 String 存 JSON微博详情序列化成字符串用MGET mblog:1 mblog:2 ...一次取多条。MGET不能读 Hash。问题二中间分页慢。List 不是数组不能直接跳到中间。例如 10000 条微博执行LRANGE key 4990 4999虽然只返回 10 条也要先遍历到中间位置。优化方式拆分长列表。每 1000 条拆成一个 keyuser:1:mblogs:0存0~999user:1:mblogs:1存1000~1999user:1:mblogs:2存2000~2999……user:1:mblogs:9存9000~9999要读4990~4999就找user:1:mblogs:4段内读990~999。如果一页跨两个分段就分别读取再合并新增微博时也要维护分段规则。好处缩短单次遍历距离。代价程序管理更复杂。3 List 结构选型总结栈与队列的抉择在实际开发中List 组合使用的场景非常清晰选择列表类型时请参考以下原则同侧存取lpushlpop或者rpushrpop为栈后进先出。异侧存取lpushrpop或者rpushlpop为队列先进先出。如果需要阻塞等待使用blpop/brpop替代lpop/rpop。如果保存固定数量的近期记录配合ltrim限制长度。