news 2026/9/26 13:03:11

Redis 5.0 Stream 消息队列:原理、消费模型与生产避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis 5.0 Stream 消息队列:原理、消费模型与生产避坑指南

当面试官抛出“谈谈 Redis 5.0 中的 Stream 消息队列”这句话时,我真心建议你别急着背命令。很多候选人张口就是 XADD 加消息、XREAD 读消息、XREADGROUP 开消费组,流畅得像在念手册,但只要追问一句“消息 ID 为什么要带毫秒时间戳”“PEL 和 ACK 是怎么配合出可靠投递的”“消息堆积到内存上限怎么办”,立刻卡壳。Stream 是 Redis 5.0 引入的第一个真正意义上的原生消息队列数据结构,它把日志的有序性、消费组的分组投递、消息确认机制、Pending 待处理列表全部揉进了一个内存结构里,但它的边界也很清晰:它是个轻量、低延迟、可依赖的队列原语,不是一个能扛住海量堆积的分布式消息系统。这篇文章我想从“面试官为什么会问这个”出发,把 Stream 的原理、命令、消费模型、可靠性边界和选型判断一次梳理清楚,无论你是应付面试还是真的要在生产环境里选型,都能有直接的参考价值。

1. 面试官真正想听什么:先讲清 Redis 做消息队列的历史包袱

1.1 老一辈方案:LPUSH + BRPOP 与 PUB/SUB 各自的死穴

在 Stream 出现之前,Redis 生态里最常见的“消息队列”就是 List 结构:生产者用 LPUSH 把消息塞进队列,消费者用 BRPOP 阻塞弹出。这套方案的优点非常明显,三行代码就能实现一个 FIFO,延迟极低,阻塞读取不占用 CPU 轮询。但它的缺陷同样致命:消息弹出即消失,如果消费者在处理业务时宕机,这条消息就永久丢了;没有 ACK 机制,你根本不知道对方到底处理完没有;也没法让多个消费者组成一个彼此配合的“消费组”,大家只能抢同一个队列,谁先 BRPOP 谁拿走,拿了不确认也没办法。

PUB/SUB 就更极端,它是一种广播模型,消费者不在线时消息直接丢弃,连存储的概念都没有。Redis 官方文档对 PUB/SUB 的定位就是 fire and forget,订阅者挂了就是挂了,消息不会等你回来。所以“Redis 不适合做消息队列”这个结论,在 5.0 之前基本成立。

1.2 5.0 之前“Redis + 外部队列”的尴尬处境

生产环境的真实情况是:小项目拿 List 硬撑,然后自己在业务代码里写补偿任务,定时扫表去捞那些“不确定有没有被处理”的消息;规模稍微上来一点,要么引入 RabbitMQ,靠它的 exchange 路由和 ACK 机制搞定复杂分发,要么引入 Kafka,用分区日志和消费者组解决高吞吐和海量堆积。

但没有哪个团队愿意只为了一个“偶尔会丢消息的队列”就去搭一套三节点 Kafka。Redis 如果能原生提供消费组、ACK 和消息重放能力,很多中等量级的场景完全不需要额外引入一套重量级中间件。这个需求一直存在,只是 antirez 在 5.0 才端出了完整的答案。

1.3 antirez 给出的答案:Stream 到底整合了哪几件事

Stream 在 Redis 5.0 中落地,本质上是一套整合方案,它同时提供了四样东西:

  • 基于日志的结构:消息追加写入、天然有序、支持按消息 ID 做范围切片回放,像 Kafka 的 partition log,但没有分区概念;
  • 消费组:多个消费者可以加入同一个组,组内消息不会重复投递给两个人,类似 Kafka 的 consumer group;
  • 显式 ACK 与 Pending 列表:消费后需要手动确认,没确认的消息会留在待处理列表中,超时可以转移给其他消费者,实现“至少一次投递”;
  • 整套东西构建在内存 + Radix Tree 上:单命令延迟可以做到微秒到亚毫秒级。

一句话概括:Stream 是一个“简化版的 Kafka 单分区 + 消息确认机制,但跑在 Redis 内存里”的东西。这个定位想清楚了,后面所有命令和面试追问都顺理成章。

2. Stream 的骨架:消息 ID、Radix Tree 与 Entry 结构

2.1 消息 ID 为什么是“毫秒时间戳-序号”

Stream 里每条消息都有一个全局唯一的 ID,默认格式是“毫秒时间戳-同毫秒内序号”,比如1680000000000-0。这种设计不是拍脑袋定的,它同时解决了好几个问题。

第一,单调递增。时间戳保证跨毫秒递增,序号保证同一毫秒内不冲突,所以 ID 天然有序且全局唯一。第二,不依赖任何分布式协调器。Redis 是单线程处理命令,自己读系统时间、自己维护自增序列,不需要像 ZooKeeper 或雪花算法那样去协调节点。第三,有序 ID 是 Stream 一切能力的基础。XADD 追加靠它,XRANGE 范围查询靠它,消费者维护游标也靠它,甚至后面讲到的 PEL 认领机制也完全围绕 ID 展开。

还有一个容易忽略的点:消息 ID 是允许客户端自定义的。自定义时它必须大于当前 Stream 里已存在的最大 ID,否则直接报错。这个限制带来一个很实际的用途:如果业务希望消息按“业务发生时间”而不是“Redis 接收时间”排序,就可以用业务毫秒时间戳作为 ID 前缀。但要注意,一旦写进去就无法回头,顺序就固定了。

2.2 底层数据结构:为什么是 Radix Tree

Stream 的存储核心是 antirez 自己实现的 rax,也就是 Radix Tree(基数树)。为什么不用 Sorted Set?跳表做有序遍历也不错,但每个节点要维护多层指针,内存开销比 Stream 这种大量紧凑消息的场景大得多。为什么不用 Hash?Hash 完全无序,做不了按 ID 范围查询,也没有前缀共享。基数树把大量公共前缀压缩到同一条路径上,内存效率高,而且天然支持以 ID 为 key 的有序遍历、插入和删除。

内部实现上,不要把它想成一棵树对应一条消息。实际是基数树的节点会存放一批连续的 Stream 条目,每条 Entry 本质上是一个 field-value 集合,类似一个小型的 Hash 结构,在较新的 Redis 版本中这些条目使用 listpack 做紧凑编码。理解了“这是一棵有序树”之后,很多复杂度结论可以自己推出来:XADD 是 O(log N) 的插入,XRANGE 是 O(log N + M) 的范围读取,在千万级消息下依然能保持高吞吐。

2.3 一条 XADD 消息的完整旅程

拿一个最普通的例子:

> XADD sensor:temp * device-id 8 temperature 21.5 "1680000000000-0"

*表示让 Redis 自动生成消息 ID,后面跟的是 field-value 对。Redis 收到这条命令后大致经历五步:

  1. 获取当前毫秒时间戳,和 Stream 里已存在的最大 ID 比较;
  2. 如果时间戳不大于最大 ID 的毫秒部分,就在最大 ID 的序号基础上加 1,否则序号从 0 开始;
  3. 把新 ID 和 Entry 结构写入 Radix Tree;
  4. 如果 XADD 带了 MAXLEN 或 MINID 参数,执行对应的裁剪逻辑;
  5. 唤醒所有正在 BLOCK 等待该 Stream 的 XREAD / XREADGROUP 客户端。

这个流程面试官如果追问“ID 到底怎么生成的”,你能把这五步讲清楚,基本就能证明你真的深入过源码或认真研究过内部机制,而不是只背了命令格式。

3. 生产端进阶操作:XADD、MAXLEN/MINID 与数据安全

3.1 XADD 的完整参数形态

XADD 比表面看起来要复杂一点,完整语法是:

XADD key [NOMKSTREAM] [MAXLEN | MINID [= | ~] threshold [LIMIT count]] <* | id> field value [field value ...]

几个容易被忽略的参数:

  • NOMKSTREAM:Stream 不存在时不自动创建,直接返回空。这是防止误操作导致 Redis key 爆炸的好习惯;
  • MAXLEN:限制 Stream 最大长度,超出部分删除最老消息;
  • MINID:限制最小 ID,删除小于该 ID 的消息,这个参数是 Redis 6.2 才加的;
  • ~表示近似模式,=表示精确模式。

几乎所有把 Stream 用于生产的团队都会在 XADD 里带 MAXLEN,因为它是内存结构,不设上限早晚 OOM。

3.2 MAXLEN 的精确与近似:性能差异很明显

这里有一个性能分水岭。MAXLEN 1000是精确模式,每次 XADD 后都保证长度不超过 1000,代价是可能需要逐条删除最老节点,最坏情况下写入复杂度会变成 O(N)。MAXLEN ~ 1000是近似模式,允许 Stream 长度在 1000 左右浮动,Redis 会在删除效率最高的时机批量裁剪整个宏节点,删除量可能会多出一小截,但写入性能明显更好。

生产建议很直接:能接受近似裁剪就尽量用近似,尤其在高写入量场景,精确 MAXLEN 会成为写入路径上的隐藏瓶颈。我自己在项目里给事件日志流配置的是MAXLEN ~ 500000,实际长度会在 50 万上下浮动几千条,业务完全可接受。

参数行为适用场景
MAXLEN 1000每次写入后精确截断长度必须严格控制时
MAXLEN ~ 1000宏节点边界批量删除高吞吐写入,允许少量超出
MINID 1680000000000-0删除 ID 小于阈值的消息按业务时间清理旧数据

3.3 消息持久化的三个档位,以及“会不会丢”的真实答案

聊 Stream 绕不开“消息到底会不会丢”,这取决于 Redis 持久化配置。

持久化配置崩溃丢失窗口说明
默认 RDB 快照上次快照之后新写入的消息可能全丢不适合承载核心消息链路
AOF everysec最多丢 1 秒推荐选择,性能与可靠性平衡
AOF always每条命令都落盘最稳,但高写入 QPS 会明显下降

要特别强调一个容易忽略的点:消费者组的状态,包括每个 group 的 last-delivered-id 和每个消费者的 Pending 列表,也会一并被持久化。这意味着“消费者读走但还没 ACK”的消息,只要 Redis 本身没有丢数据,即使进程重启也能在恢复后通过 XCLAIM 找回来。Stream 的可靠性设计其实是成体系的,不是裸奔。

4. XREAD 消费模型:游标语义、$ 陷阱与阻塞读取

4.1 没有消费组时的基础消费

先看最简单的消费方式,XREAD:

> XREAD COUNT 10 BLOCK 5000 STREAMS sensor:temp 0
  • COUNT 10表示最多返回 10 条;
  • BLOCK 5000表示没有新消息时最多阻塞 5 秒,单位是毫秒;
  • STREAMS sensor:temp 0表示从 ID 为 0 的位置开始读,也就是从全部历史消息开始消费。

你可以把 XREAD 返回的最后一条消息 ID 记下来,下一次调用时传给 STREAMS 参数,这样就实现了一个自己维护游标的消费者。这种模式最直观,也最容易理解 Stream 的有序性。

4.2 “$ 不是游标,是每次调用时的锚点”这个坑

这是无消费组模式最容易踩的坑。很多人写循环消费代码时习惯这样:

> XREAD BLOCK 0 STREAMS sensor:temp $

$的语义是“从当前 Stream 最后一条消息之后开始读”,它只在本次调用时生效,而不是像游标那样永久记住位置。更麻烦的是,如果你在一次阻塞返回后,处理消息花了很长时间,这段时间内新到达的消息在下一次调用时已经变成“存量数据”,此时再用$解析到的是最新最后一条 ID,中间那批消息就会被直接跳过。

所以$适合一次性订阅“此刻之后的新消息”这种场景,不适合作为循环消费的游标。正确做法永远是在代码里维护“上次处理到的最后 ID”,异常恢复时从那里继续。这个细节面试官如果追问,就是考验你有没有真正写过 Stream 的消费循环。

4.3 阻塞读取的内部机制,以及多消费者下要注意的事

XREAD 的 BLOCK 语义和 BRPOP 一样,Redis 主线程不会被阻塞住,而是在事件循环里挂起等待者,等 Stream 有新写入时主动把数据推给等待的 socket。多个阻塞客户端可以同时挂在同一个 Stream 上,只要有新消息,它们会分别唤醒。

但 XREAD 这种无消费组模式本质上是“独立消费者”模式,每个消费者各自维护游标,彼此没有协调关系。没有消息确认、没有待处理列表,消费者崩溃后游标可能回退或丢失,消息就断了。所以生产环境里,凡是要求“至少要处理一次”的场景,我都推荐直接上消费组,也就是下一章的内容。

5. 消费组机制:XREADGROUP、XACK、PEL 与故障转移

5.1 创建组,并理解组游标的初始化

先创建一个消费组:

> XGROUP CREATE sensor:temp group1 0
  • group1是组名;
  • 0表示从第一条历史消息开始投递;
  • 如果想只处理新消息,传$;
  • 如果 Stream 不存在,默认会报错,加MKSTREAM参数会自动创建空流。

创建组之后,多个消费者进程用同一个组名来消费。组内消息不会重复分配,同一时刻一条消息只会被投递给组内一个消费者。这是 Stream 和 XREAD 模式最大的分水岭。

5.2 XREADGROUP 和 PEL 的关系,理解“投递”的原子动作

消费组模式下的读命令是:

> XREADGROUP GROUP group1 consumer-1 COUNT 10 STREAMS sensor:temp >

关键点就在>这个符号上。它的含义是“只读组内还没有被投递过的新消息”。当你读到消息后,Redis 会原子地做两件事:

  1. 更新组级的 last-delivered-id,记录组已经投递到哪了;
  2. 把消息 ID 加入当前消费者自己的 Pending 列表,也就是 PEL。

这里必须强调:消息一旦进入 consumer-1 的 PEL,组内其他人通过>就再也读不到它了。只有当 consumer-1 显式调用 XACK,消息才会从 PEL 移除。

如果你把>换成一个具体的消息 ID,语义立刻变成另一种:不是读新消息,而是从该消费者自己的 PEL 里重新读取待确认消息。这就为“消费失败后重试”留下了原生入口。

5.3 XACK 与“至少一次投递”的本质

> XACK sensor:temp group1 1680000000000-0 1680000000000-1

XACK 会把对应消息从 PEL 移除。这套“读走 + 确认”的组合,实际实现了消息队列里最经典的“至少一次投递”语义:正常情况下每条消息被处理且确认;如果消费者在处理过程中崩溃,消息留在 PEL,等待被其他消费者认领重投。

代价就是重复消费会真实发生。你无法保证“业务处理完成”和“发送 ACK”这两个动作原子发生,所以业务侧必须自己做好幂等。面试里把这个逻辑讲通,比背一百遍“Stream 不会丢消息”都有说服力。

5.4 故障转移:XCLAIM 与 XAUTOCLAIM 的完整区别

PEL 里的消息怎么转移给其他消费者?用 XCLAIM:

> XCLAIM sensor:temp group1 consumer-2 60000 1680000000000-0

这里的60000是 min-idle-time,表示该消息至少在 PEL 里空闲 60 秒才能被认领,防止消费者 A 只是处理得慢一点就被别人抢走。认领成功后,消息进入 consumer-2 的 PEL,consumer-2 处理完必须再 XACK。如果消费者 A 其实已经处理完了只是忘了 ACK,认领会造成重复投递,这就是为什么我反复强调幂等。

XCLAIM 一次只能认领手动指定的 ID,工具性质强。Redis 6.2 之后新增的 XAUTOCLAIM 则更主动:可以传一个起始 ID,自动扫描 PEL,把超时未确认的消息批量认领给指定消费者,并且返回下一次要扫描的游标。面试时如果你能主动补一句“XAUTOCLAIM 是 6.2 才引入的,5.0 里没有”,加分效果很明显,说明你不是只看过一篇文章。

5.5 通过 XPENDING 和 XINFO 透视消费组的健康状态

生产排查“消费卡住”时,这三条命令是第一步:

> XPENDING sensor:temp group1 > XINFO GROUPS sensor:temp > XINFO CONSUMERS sensor:temp group1
  • XPENDING 返回待确认消息总数、最小/最大 ID、各消费者的分布情况;
  • XINFO GROUPS 返回每个组的消费者数量、pending 数量、lag 指标;
  • XINFO CONSUMERS 返回每个消费者各自的 pending 数和空闲时间。

我日常排查积压的顺序是:先看 XINFO GROUPS 的 lag 和 pending,如果 pending 大量堆积,再用 XPENDING 看消息集中落在哪个消费者,最后用 XINFO CONSUMERS 判断那个消费者是不是已经失联。这套排查链在实战中非常实用。

6. 面试追问三件套:重复消费、消息丢失与堆积瓶颈

6.1 重复消费:一旦确认是“至少一次”,就要接受这件事

重复消费来自两个典型路径:

  1. 消费者处理完业务但 ACK 前崩溃,消息留在 PEL,被 XCLAIM 转给其他消费者,后者会重复处理;
  2. Redis 主从切换或手工 XCLAIM FORCE 转移,也可能造成重复投递。

解决方案不是试图消灭重复,而是让消费逻辑幂等。最简单粗暴的方案就是拿消息 ID 做去重键。Stream 的消息 ID 全局唯一且有序,写数据库时直接建唯一索引,重复消费时会撞索引报错,业务异常也能自动拦截。这个思路和 Kafka 场景下的幂等设计完全一致。

6.2 消息丢失:从持久化到主动裁剪,拆开三层看

把“丢消息”拆开看,其实是三个层次的问题:

  • 第一层:生产端写入成功,但 Redis 崩溃且尚未持久化。对策在上文的持久化配置,AOF everysec 能接受丢 1 秒,业务不敏感就直接用;真把 Stream 当核心链路,上 AOF always 但吞吐会打折。
  • 第二层:消费者已读未 ACK,Redis 崩溃。只要 AOF/RDB 没丢数据,PEL 会完整恢复,消息不会丢,只是需要 XCLAIM 重新投递。
  • 第三层:MAXLEN/MINID 主动裁剪历史消息。这是设计时就要接受的取舍,不属于 Redis 的 bug,是“主动放弃”。

严格来说,纯 Redis Stream 无法提供 Kafka 那种集群级多副本容灾。需要跨机房容灾,请换真正的分布式消息系统,别把 Stream 硬架到核心资金链路。

6.3 堆积瓶颈:Stream 最不能碰的软肋

这是面试里最值得展开的一层。Kafka 敢让你堆积几百 GB 数据,因为它是磁盘顺序写,数据可以先在 Page Cache 里流转,满了自然落盘;Redis Stream 的所有数据都在内存里,堆积意味着内存持续上涨。一条消息哪怕只有 200 字节,100 万条就是 200MB,再多几个业务流,Redis 服务器很快告急。

应对措施就是前面反复强调过的原则:

  • 消费端建立 lag 告警,积压突增要能自动报警;
  • XADD 必须带 MAXLEN 或 MINID,从源头限制流长度;
  • 不要把 Stream 当成“海量缓冲池”,它更适合“高速短队列”:消息进来后尽快消费,确认完尽快离开;
  • 如果业务确实需要长期堆积,趁早把消息搬运到真正的分布式 MQ,而不是硬让 Redis 扛。

7. 从面试回到生产:Stream 和 Kafka / RabbitMQ 的选型边界

7.1 三种方向的核心差异对照

维度Redis StreamRabbitMQKafka
存储内存,可 RDB/AOF 持久化磁盘磁盘日志
典型延迟亚毫秒级毫秒级毫秒级
堆积能力弱,内存是硬上限中,受磁盘配置影响强,可海量堆积
消费组有,但无自动重平衡有,AMQP 语义有,支持分区 rebalance
路由能力无 exchange 概念灵活路由、多协议基于分区 key
消息重放XRANGE 任意范围回放能力有限支持任意 offset 重置
确认机制显式 XACK + PEL显式 ACK/NACK自动或手动 offset 提交
运维成本低,复用 Redis 集群中高,集群和元数据管理重

7.2 什么场景放心用 Stream

  • 团队已经有 Redis,不想再为小流量场景维护一套独立 MQ;
  • 消息量不大但延迟敏感,几千到几十万条每秒仍在单节点内存承受范围;
  • 需要消费组 + ACK + 超时重投,又不想要 Kafka 的运维复杂度;
  • 典型例子:秒杀削峰、站内通知、短期埋点事件处理、分布式任务派发。

7.3 什么情况果断放弃 Stream

  • 每日千万级以上且可能长时间堆积,Stream 的内存模型撑不住;
  • 需要跨机房多副本容灾、需要分区扩展,Stream 单 key 的模型先天受限;
  • 需要 exactly-once、复杂路由或流式计算生态,RabbitMQ 和 Kafka 各自有更合适的定位。

7.4 生产避坑清单,按优先级排序

  1. consumer 名字必须全局唯一。多个进程共用一个 consumer 名等于共用同一个 PEL,组内消费语义直接混乱。建议用进程实例 ID 加随机后缀。
  2. 循环消费不要反复用$。自己维护“最后处理到的 ID”,出错恢复时才知道从哪里继续。
  3. 高写入场景别用精确 MAXLEN。用~近似裁剪,否则隐藏的 O(N) 删除会让你很难受。
  4. 盯 XINFO STREAM 的 length 和 radix-tree-keys。这两个字段是发现堆积的早期信号。
  5. 集群模式下注意 key 的 slot。Stream 相关命令要求操作同一个 key,跨 slot 无法原子完成,必要时用 Hash Tag 约束。
  6. XCLAIM 重投必须配套幂等处理。不能想当然认为“重投一次就算成功”。

最后说句实在话。我早前把 Stream 当 Kafka 用过一次,往里面灌了 800 万条埋点,Redis 内存直接涨了两个 G,那晚盯着监控心里发慌。从那以后我给自己定了条规矩:Stream 里的消息生命周期尽量不超过 30 分钟,超了必须划走。面试里如果能把这个故事和前面的原理串起来讲,面试官基本能判断出你不只是背了文档,而是真在线上摸过它的脾气。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 13:03:06

Claude-Code 完全指南:TaoToken 统一 Key 接入与 CLAUDE.md 配置实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 13:01:28

2026年Java开发者必备!这7个IDEA插件搭配TaoToken让开发效率翻倍

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 13:00:51

用Dify搭建RAG知识库与带记忆Agent:痛风饮食监督系统实战

痛风快十年&#xff0c;饭桌上的每一筷子都是跟身体的谈判。我一直想做一个自己的“痛风知识库”——把所有医生建议、嘌呤数据、忌口原则、常见饮食误区整理成一个能随时问、随处查的系统&#xff0c;再给这个知识库配上一个叫“吃不停的Agent”的助手。它管的不只是查嘌呤表&…

作者头像 李华
网站建设 2026/9/26 13:00:45

基于Flask的企业员工日程安排与考勤签到系统开发实践

1. 项目全景与需求拆解1.1 这个系统到底解决什么问题先聊点实在的。很多企业到现在还在用微信群接龙、Excel表格、纸质签到本来管理员工日程和考勤。你们别笑&#xff0c;我接过好几个真实项目&#xff0c;有的公司几十号人&#xff0c;每天早上靠行政在群里发消息问“今天谁请…

作者头像 李华
网站建设 2026/9/26 13:00:32

智慧交通数据治理:破解异构、时效、关联、质量四重困境

在智慧交通项目里摸爬滚打这几年&#xff0c;我最深的感触是&#xff1a;大家嘴上都在讲数据治理&#xff0c;实际动手时却经常被同一批问题反复绊倒。不管是卡口过车数据、浮动车GPS轨迹&#xff0c;还是信号灯控制日志、公交IC卡刷卡流水&#xff0c;单看任何一路数据似乎都挺…

作者头像 李华
网站建设 2026/9/26 12:59:16

VSCode 配置详解:离线版安装插件与 TaoToken 统一 Key 接入

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华