news 2026/9/26 6:23:34

Spring Boot注解实现RabbitMQ生产者与消费者:从入门到实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot注解实现RabbitMQ生产者与消费者:从入门到实战

做后端开发的同学,只要接触过消息队列,大概率都用过 RabbitMQ。但很多人还是在靠 RabbitTemplate 手动 new、写一堆监听容器和消息适配器,代码又长又绕。其实用 Spring 的注解方式,也就是 @RabbitListener 配合 @EnableRabbit,就能把生产者和消费者的整套链路写得干干净净。今天这篇,我把自己在生产环境里用注解实现 RabbitMQ 消费者和生产者的完整经验、参数取舍和踩坑记录整理出来,希望能帮你少走弯路。

这篇东西适合谁?适合刚接触 RabbitMQ、正准备在 Spring Boot 项目里接入消息队列的开发者,也适合已经把 RabbitMQ 跑起来、但监听代码写得比较乱、想重构的人。我会先讲为什么推荐注解方式,再讲环境和 Bean 怎么配,最后给出完整的生产者消费者实现、事务注解配合方案、常见问题排查,以及动态队列、延迟队列这些进阶玩法。

1. 为什么用注解方式搭建 RabbitMQ 生产者与消费者

1.1 注解方式解决的核心痛点

先说说不用注解时是什么体验。很多老项目里,消费者是这样写的:定义一个 SimpleMessageListenerContainer,设置 connectionFactory、queueName、messageListener,再 new 一个 MessageListenerAdapter,把处理逻辑塞进去。代码本身不复杂,但一旦队列变多,每个队列都要配置一遍容器,工厂方法里全是重复样板代码。我见过一个项目里光监听容器就写了十几个,每次加队列都要复制粘贴,改连接配置还要挨个改,非常痛苦。

注解方式的核心价值,是把“监听一个队列”这个动作抽象成一行注解。你只需要在一个普通方法上标注 @RabbitListener(queues = "xxx"),Spring 就会自动帮你创建监听容器、绑定消息转换器、处理并发和确认逻辑。消费者类的代码量可以压缩到原来的十分之一,而且每个队列的逻辑天然内聚在对应方法里,维护性强很多。

生产者的代码同样被简化。RabbitTemplate 由 Spring Boot 自动配置好,注入进来直接 convertAndSend 就行。配合自定义转换器,可以把普通 Java 对象自动转成 JSON,也可以把消息头里的业务字段直接绑定到方法参数上。整套链路从“处理一堆底层 API”变成“写自己的业务方法”,这才是注解方式最大的意义。

1.2 核心注解角色说明

要实现注解式生产者和消费者,你需要认识这几个核心角色:

  • @EnableRabbit:开启 RabbitMQ 注解支持,一般放在启动类或者配置类上。漏掉它,所有 @RabbitListener 都不会生效。
  • @RabbitListener:标注在方法上,声明这个方法监听哪个队列、用哪种确认模式、并发多少个消费者。
  • @RabbitHandler:配合 @RabbitListener 使用,用于同一个监听类里根据消息类型分发到不同方法。
  • RabbitTemplate:发送消息的核心模板,Spring Boot 自动配置后可以直接注入。

我第一眼看到 @RabbitListener 时觉得它就是个普通注解,实际用下来才发现它的设计很巧妙。它可以把 Channel 和 DeliveryTag 也直接注入到方法参数里,配合手动确认非常方便。你根本不需要自己去容器里翻监听器,只要把方法参数写对就行。

下面用一个表格把注解方式和传统编程式方式做个对比:

对比维度注解方式编程式方式
代码量一个注解加一个方法容器、监听器、适配器三件套
可读性业务逻辑一目了然被底层配置淹没
扩展性加队列只需加方法加队列要复制容器配置
手动确认方法参数注入 Channel需要自定义 MessageListener
并发配置yaml 统一配置每个容器单独设置

1.3 注解方式与编程式 API 的对比

有人可能会问,编程式 API 真的那么不能碰吗?其实不是。注解方式适合绝大多数业务开发场景,尤其是队列数量多、逻辑变化快、团队人员流动大的项目。它把“基础设施”部分交给 Spring 管理,大家只需要遵守约定写监听方法,学习成本很低。

但编程式 API 也不是一无是处。比如你需要非常精确地控制每个队列的并发策略、自定义多个不同的 listener container,或者做复杂的消息过滤,编程式更灵活。我个人的原则是:默认用注解,遇到极端定制需求再降级到编程式。不要为了炫技全部手写,也别因为怕“黑魔法”而拒绝注解,按业务复杂度来选即可。

2. 环境准备与基础配置

2.1 工程依赖与 @EnableRabbit

入门第一步,先引入 Spring Boot 的 AMQP 依赖。Maven 项目里加这一段:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>

引入依赖后,Spring Boot 会自动创建 RabbitTemplate、ConnectionFactory 等核心 Bean。再在启动类上加 @EnableRabbit,开启注解驱动:

@SpringBootApplication @EnableRabbit public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }

我在实际项目中经常看到有人漏掉 @EnableRabbit。漏掉它不会启动报错,但所有 @RabbitListener 方法都静默不执行,排查起来相当费劲。所以写完启动类后,第一步先确认这个注解在。

2.2 连接参数与消息序列化配置

连接参数在 application.yml 里统一配置:

spring: rabbitmq: host: 127.0.0.1 port: 5672 username: admin password: admin123 virtual-host: /dev publisher-confirm-type: correlated publisher-returns: true listener: simple: acknowledge-mode: auto prefetch: 10 concurrency: 5 max-concurrency: 10

这里有几个参数要解释清楚。virtual-host 是虚拟主机,相当于 RabbitMQ 里的命名空间,不同业务隔离用的。很多新人在这里栽跟头,账号密码都对,但虚拟主机不对,连接直接被拒绝。

publisher-confirm-type 是发送端确认模式,correlated 表示每条消息都可以收到 Broker 的确认回调。publisher-returns 是消息投递失败时的返还回调,配合 confirm 使用,可以做到发送端可靠投递。

注意一点:Spring Boot 2.x 的配置形如 spring.rabbitmq.publisher-confirms,3.x 改成了 publisher-confirm-type。如果你照着老博客抄,很可能配置不生效。

消息序列化默认是 Java 序列化,玩起来不方便而且有安全风险。我一般会单独配一个 Jackson 转换器:

@Bean public MessageConverter messageConverter() { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return new Jackson2JsonMessageConverter(objectMapper); }

配置完后,生产者发送普通对象,消费者方法参数直接接收对象,不用手动处理字节数组。

2.3 使用 Bean 声明队列、交换机与绑定关系

注解方式只解决消费和生产的问题,队列、交换机、绑定关系还是要提前声明。声明方式我用 Bean,而不是在管理后台手动创建。好处是配置跟着代码走,新环境部署后自动创建,不会出现线上队列缺一个 routing key 的情况。

@Configuration public class OrderRabbitConfig { @Bean public Queue orderQueue() { return QueueBuilder.durable("order.queue").build(); } @Bean public DirectExchange orderExchange() { return new DirectExchange("order.exchange"); } @Bean public Binding orderBinding(Queue orderQueue, DirectExchange orderExchange) { return BindingBuilder.bind(orderQueue) .to(orderExchange) .with("order.routing.key"); } }

这里要注意 durable。durable 表示队列持久化,RabbitMQ 重启后队列还能回来。交换机也是一样,RabbitMQ 集群重启后,临时队列和交换机全部消失,生产环境必须设置 durable。

最后一个建议:队列名、交换机名、路由键不要乱写。项目里可以建一个常量类统一管理,避免一个地方改成 “order.queue” 另一个地方写成 “orderQueue”。消息中间件这个东西,名字对不上,消息就悄悄丢了。

3. 生产者和消费者的注解实现全流程

3.1 用 RabbitTemplate 发送消息

生产者这边,核心就是注入 RabbitTemplate 然后发送。我在订单系统里是这样写的:

@Service public class OrderMessageProducer { private final RabbitTemplate rabbitTemplate; public OrderMessageProducer(RabbitTemplate rabbitTemplate) { this.rabbitTemplate = rabbitTemplate; } public void sendOrderMessage(OrderMessage message) { rabbitTemplate.convertAndSend( "order.exchange", "order.routing.key", message ); } }

convertAndSend 方法会自动用前面配置的 Jackson 转换器把 OrderMessage 转成 JSON。调用方不用关心序列化细节,这点对团队协作特别友好。

如果你要做发送确认,可以设置回调:

rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> { if (!ack) { log.error("消息发送失败,cause={}", cause); } }); rabbitTemplate.setReturnsCallback(returned -> { log.error("消息路由失败,msg={}", returned.getMessage()); });

这里要注意,设置了 confirm 回调后,convertAndSend 的返回不会等待确认结果。需要给每条消息一个全局唯一 ID,推荐使用 CorrelationData。这样才能把失败的消息对到原始业务上,做补偿处理。

3.2 用 @RabbitListener 接收消息

消费者就更好写了。定义订单消息处理方法:

@Component public class OrderMessageConsumer { @RabbitListener(queues = "order.queue") public void handleOrderMessage(OrderMessage message) { log.info("收到订单消息,orderId={}", message.getOrderId()); // 处理业务逻辑 } }

方法参数直接就是 OrderMessage 对象,不需要自己做 JSON 反序列化。Spring 会根据转换器的类型自动匹配。

如果你的消费者类里需要根据消息格式分发给不同方法,可以在类上标注 @RabbitListener(queues = "order.queue"),然后在多个方法上标 @RabbitHandler,Spring 会根据参数类型自动路由到对应方法。这个特性在处理同一队列里多种消息类型时很实用。

3.3 事务注解与消息发送一致性

很多业务场景是“先写数据库,后发消息”。比如创建订单成功后,要发一条消息给积分服务。新手最容易写成:

@Transactional public void createOrder(Order order) { orderMapper.insert(order); rabbitTemplate.convertAndSend("order.exchange", "order.routing.key", order); }

我跟你讲,这里有一个性能与一致性的大坑:数据库事务和 RabbitMQ 消息事务是两个独立的资源。@Transactional 只是让数据库操作和发消息操作在同一个 Spring 事务里登记,如果发送消息成功、后续提交数据库事务时失败,就会造成“消息发出去了但订单没落库”的畸形数据。

Spring 官方提供了 RabbitTransactionManager,可以让消息发送纳入当前事务的同步逻辑。配置起来需要手工搞定 RabbitTemplate 和事务管理器之间的绑定,比较绕。我的建议是:如果只是普通场景,维持上面的写法问题不大,消息发了就发了;如果是订单这种不能丢的业务,可以考虑本地消息表方案:先写本地消息表,再通过定时任务扫描发送,发送成功后标记状态。这样数据库和消息最终一致。

当然,注解本身确实能帮上忙。比如消费者方法里,如果处理逻辑中既有数据库写入、又需要确保失败时消息回到队列重试,就可以在 @RabbitListener 方法上加 @Transactional。Spring 的监听容器会识别事务,如果方法抛出运行时异常,RabbitMQ 就不会确认这条消息,达到自动重试的效果。

3.4 手动确认、并发消费与 @SneakyThrows

默认的 acknowledge-mode 是 auto,也就是监听方法没有抛异常就自动确认,抛异常就自动拒绝并重新入队。这个模式适合大多数场景,但如果你希望处理完成后立即 ack,或者需要批量处理等精确实控,就用手动确认。

先修改配置:

spring: rabbitmq: listener: simple: acknowledge-mode: manual

然后消费者方法里注入 Channel 和 DeliveryTag:

@RabbitListener(queues = "order.queue") public void handleOrderMessage( OrderMessage message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException { try { log.info("处理订单:{}", message.getOrderId()); channel.basicAck(tag, false); } catch (Exception e) { channel.basicNack(tag, false, true); log.error("消费失败,重回队列", e); } }

这里方法签名里直接 throws IOException,是因为 Channel 的 basicAck 方法会抛 IOException。很多同事第一次写的时候会纠结 try-catch,其实可以借助 Lombok 的 @SneakyThrows 注解,让代码更干净:

@RabbitListener(queues = "order.queue") @SneakyThrows(IOException.class) public void handleOrderMessage( OrderMessage message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long tag) { try { channel.basicAck(tag, false); } catch (Exception e) { channel.basicNack(tag, false, true); } }

@SneakyThrows 的作用是让受检异常不用在方法声明里写出来,编译器也不会强迫你处理。它适合在这种框架回调方法里用,因为框架根本不关心你声明了什么异常,它只关心方法是否正常返回。当然,滥用也不好,业务代码该处理的异常还是要处理。

并发消费配置也很关键。上面 yaml 里的 concurrency 和 max-concurrency 表示每个监听容器的最小、最大消费者线程数。RabbitMQ 是 push 模式,服务端会把消息推给消费者,所以并发太高可能造成本地线程堆积,需要配合 prefetch 来做流控。prefetch 是每个消费者尚未确认的消息数量上限,设置为 10 意味着单消费者最多预取 10 条,处理完一条再取一条,避免一下全捞出来把内存打爆。

4. 常见问题与避坑指南

4.1 @RabbitListener 没有生效怎么办

这是问的人最多的问题。方法写好了,消息也发送了,但消费者方法就是不执行。我总结了三个最常见原因。

第一,没有 @EnableRabbit。启动类或者配置类上必须有这个注解,Spring 才会去解析 @RabbitListener。第二,消费者 Bean 没有被 Spring 管理。比如类上忘记加 @Component 或者 @Service,Spring 根本不会注册这个 Bean。第三,队列不存在或者没有绑定。如果消费者监听的是一个从未声明的队列,默认情况下 RabbitMQ 不会自动创建队列,消息发过去会被直接丢弃。

排查思路很简单:第一步看启动日志有没有 “Initializing RabbitListenerEndpoint” 之类的输出;第二步看 RabbitMQ 管理界面队列是否创建成功;第三步给监听方法加日志,确认是否真的调用。

还有一种比较隐蔽的情况,就是注解属性写到了常量上被 IDEA 误报。比如 @RabbitListener(queues = queueName) 中 queueName 不是编译期常量,IDEA 会提示 “attribute value must be constant”。解决办法有两个:一是把队列名直接写成字符串字面量;二是用 SpEL 表达式指向 Bean。

4.2 消息丢失、重复消费与顺序消费

先说丢失。RabbitMQ 丢消息主要发生在三个环节:生产者发到 Broker 的路上、Broker 内部持久化、消费者处理时崩溃。生产者端,需要开 confirm 模式;Broker 端,队列和消息都要 durable,发送时设置持久化消息;消费者端,要么等业务处理完再手动确认,要么保证处理本身是幂等的。

重复消费几乎是分布式消息队列的固有话题。原因很多:网络闪断、消费者处理完还没来得及 ack 就宕机、消息重新入队等等。我的经验是:不要在业务层逃避重复消费,而是把幂等设计到数据模型里。比如消费订单消息时,先查一下唯一业务号是否处理过,或者用 Redis setnx 做幂等标记。任何消息中间件都不能保证绝对不重,只能保证不丢、按需重试。

顺序消费这块,RabbitMQ 的做法是:同一队列同一消费者按顺序消费,但要保证消息顺序,生产者就必须把有顺序依赖的消息发到同一个队列、指定同一个路由键,并且消费者只能有一个,或者用分片策略。多消费者环境下,顺序很难保证。实在要顺序,通常会把队列拆分,一条业务链路只走一个队列。

4.3 消费端异常与重试策略

消费者异常时,如果 acknowledge-mode 是 auto,默认行为是拒绝消息并重新入队。这样如果消息本身会导致永久异常,就会变成死循环,日志刷到怀疑人生。我见过一个项目因此 CPU 飙到接近 100%。

正确做法是配置重试策略。Spring Boot 提供了简单可用的重试参数:

spring: rabbitmq: listener: simple: retry: enabled: true max-attempts: 3 initial-interval: 1s multiplier: 2 max-interval: 10s

这里表示最多重试 3 次,间隔从 1 秒开始成倍增加,最大到 10 秒。重试还是失败,消息会交给 RecoveryContext。你可以自定义 RecoveryCallback,把彻底失败的消息转到死信队列,或者打日志告警。

更精细的做法是配合死信交换机。在声明业务队列时,指定 x-dead-letter-exchange,这样超过最大重试的消息会进入死信队列,由专门消费者做人工补偿。

4.4 Docker 部署 RabbitMQ 后虚拟主机与权限的坑

Docker 部署 RabbitMQ 很方便,但很多人部署完发现管理后台能打开,admin 账号却能操作得很有限,或者直接不能创建虚拟主机。这个坑十有八九是虚拟主机和权限没有设置好。

比如你运行了:

docker run -d --name rabbitmq \ -p 5672:5672 -p 15672:15672 \ rabbitmq:3-management

默认安装后 guest 用户只能从 localhost 访问。如果你用浏览器打开 15672 能登录,但 Spring Boot 从宿主机连接 5672 却连不上,很可能就是 guest 的远程访问限制导致的。解决办法是创建自己的账号:

docker exec -it rabbitmq rabbitmqctl add_user admin admin123 docker exec -it rabbitmq rabbitmqctl set_user_tags admin administrator docker exec -it rabbitmq rabbitmqctl set_permissions -p / admin ".*" ".*" ".*"

但还有一个更隐蔽的问题:admin 账号默认只有 “/” 虚拟主机的权限。/dev 虚拟主机需要手动创建并授权:

docker exec -it rabbitmq rabbitmqctl add_vhost /dev docker exec -it rabbitmq rabbitmqctl set_permissions -p /dev admin ".*" ".*" ".*"

Spring 配置里的 virtual-host 必须写对,否则就会出现“管理界面能用、程序连不上”的诡异现象。你可以在管理后台的 Admin 页面里看到用户和权限列表,确认自己有没有权限访问目标虚拟主机。

5. 进阶玩法:从固定队列到动态队列

5.1 用 SpEL 动态指定监听队列

固定队列写法很简单,但有些场景希望队列名由服务启动时动态生成,比如多租户系统每个租户一个队列。@RabbitListener 的 queues 属性支持 SpEL 表达式,可以引用 Spring Bean。

先在配置类里声明一个动态队列:

@Bean public Queue dynamicOrderQueue() { return QueueBuilder.durable("order.queue." + env.getActiveProfiles()[0]).build(); }

然后监听时这样写:

@RabbitListener(queues = "#{dynamicOrderQueue.name}") public void handleDynamicOrder(OrderMessage message) { // ... }

这里通过 #{dynamicOrderQueue.name} 获取 Bean 的 name 属性,也就是队列名。注意,queues 属性值理论上你传一个常量字符串就行,一旦出现 “attribute value must be constant” 报错,就可以直接用这种 SpEL 方式解决。

再配合注解和 SpEL,你还能做更复杂的条件判断,比如根据消息头路由到不同处理方法。不过要提醒一句,SpEL 虽然强,但可读性会下降,团队里最好约定好使用范围,别满屏都是魔法表达式。

5.2 基于 DLX 实现延迟队列

延迟队列是 RabbitMQ 使用频率很高的玩法。典型场景是下单后三十分钟未支付关单、超时自动确认收货。官方现在的推荐方案是延迟消息插件,但最通用稳妥的还是用 TTL 加死信队列。

声明一个带死信参数的队列:

@Bean public Queue delayOrderQueue() { return QueueBuilder.durable("order.delay.queue") .withArgument("x-message-ttl", 30000) .withArgument("x-dead-letter-exchange", "order.exchange") .withArgument("x-dead-letter-routing-key", "order.timeout") .build(); }

生产者先发消息到 delayOrderQueue,消息在这里等 30 秒,到期后 RabbitMQ 自动把它投递到 order.exchange,路由键是 order.timeout。业务队列监听这个路由键即可:

@RabbitListener(queues = "order.timeout.queue") public void handleOrderTimeout(OrderMessage message) { // 处理超时关单 }

这种方案的缺点是一个 TTL 值是队列级的,不同延迟时间要建不同队列。如果你需要秒级延迟,用官方延迟插件更合适。

5.3 消息队列选型与注解方式的边界

写到这里,不少人会问:RabbitMQ 这么好用,为什么网上还有人说它不如 Kafka、RocketMQ?其实这是定位问题。RabbitMQ 是消息中间件,强调灵活的路由、多协议支持、可靠投递;Kafka 更像数据流平台,适合高吞吐、日志采集、实时计算;RocketMQ 则在金融支付、延迟消息、事务消息上有很多越级能力。

从注解开发体验看,Spring Boot 对 RabbitMQ 的支持是最成熟的。同样是消息队列,Kafka 的 Spring 注解风格略有区别,RocketMQ 还依赖自己的一套 Starter。如果你在业务系统里追求低延迟、灵活路由和快速落地,RabbitMQ 加注解是性价比很高的选型。

但注解方式再好,也只是“入口”简洁。真正的可靠性依然要靠持久化配置、确认机制、幂等设计和监控告警。不要因为注解太好用,就忽略了底层原理。

6. 写在最后:我的实操心得

最后分享一点个人体会。刚开始用注解实现 RabbitMQ 时,我也踩过不少坑,最大的感受是:注解把代码写简单了,但没把原理简化。如果一个项目里只追求“能通”,不看 ack、prefetch、持久化、死信这些参数背后的语义,线上早晚会给你颜色看。

我现在的新项目,统一约定生产者走 RabbitTemplate 加 confirm 回调,消费者统一用 @RabbitListener,默认 auto ack,但每个方法保证幂等;对不能接受重复的业务,手动确认加死信队列。队列、交换机、绑定全部代码化,环境隔离靠虚拟主机。这套组合在业务系统里跑得非常稳定。

后台看到监听方法不触发、消息偶尔丢失这种问题,先别急着换中间件。把基础配置翻一遍,把虚拟主机权限查一遍,把 ack 模式理清楚,大多数问题都能自己解决。希望这篇内容正好能帮到你。

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

航班管理系统开题答辩指南:选题设计到高频问题详解

每年到毕业设计开题季&#xff0c;我都会被周围的学生问同一个问题&#xff1a;“航班管理系统这个题目到底能不能选&#xff1f;答辩时老师一般会问什么&#xff1f;”说实话&#xff0c;这个题目在计算机专业里算是常青树&#xff0c;搜索热度高&#xff0c;参考资料多&#…

作者头像 李华
网站建设 2026/9/26 6:22:23

Hermes深度解析:不是模块图,而是意图驱动的状态机

1. 别被“60秒看懂”骗了&#xff1a;Hermes根本不是一张图能讲清的工具你点开那张所谓“60秒看懂Hermes”的信息图时&#xff0c;大概率已经踩进了第一个认知陷阱——把Hermes当成一个静态功能模块图来理解。热搜词里反复出现的“五大模块”“三层记忆”“学习循环”&#xff…

作者头像 李华
网站建设 2026/9/26 6:22:03

Google Agent白皮书实战指南:构建可信赖的生产级智能体系统

简介&#xff1a;本资源是面向软件开发者的Google《Agents Companion》白皮书配套实践包&#xff0c;聚焦AI代理技术从概念验证到企业级落地的工程化路径&#xff0c;解决开发者在架构设计、生态集成与生产部署中的关键认知断层。压缩包为9KB的ZIP文件&#xff0c;共含3个精简文…

作者头像 李华
网站建设 2026/9/26 6:21:10

电商用户行为预测与可视化:Django+LSTM完整实战

1. 这个选题为什么值得做&#xff1a;需求与价值拆解1.1 电商用户行为数据&#xff1a;最典型的大数据场景淘宝用户购物数据在我看来是所有大数据入门场景里最“教科书”的一个。它天然具备高维、稀疏、强时序三个特征&#xff1a;一条原始行为记录里至少包含用户ID、商品ID、类…

作者头像 李华
网站建设 2026/9/26 6:20:58

SolidWorks Motion仿真核心:从齿轮配合到真实接触的工程闭环

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

作者头像 李华