news 2026/9/6 14:01:18

微服务链路追踪实战:从traceId到全链路排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
微服务链路追踪实战:从traceId到全链路排查

很多微服务项目前期都是“有日志就行”,单服务阶段一个接口报错,翻一下日志就能定位。但服务数量一多,尤其一个请求要经过网关、鉴权、订单、库存、支付好几个服务之后,排查问题就变成了“先找请求路径,再看具体节点”。链路追踪解决的核心问题不是“加一组监控”,而是把分布式环境里散落在多个进程中的日志、耗时和异常串起来,让一个请求从入口到出口的完整路径可以被还原。

这也是为什么“微服务一多,就必须做链路追踪”不是一句口号,而是规模变大之后被现实逼出来的选择。真正开始排查线上问题的人都会发现:大多数时候,时间不是花在“改代码修Bug”上,而是花在“这个请求到底经过了哪些服务、卡在哪一步”上。

1. 服务一多,原来的排查方式为什么失效

1.1 请求路径从“可预期”变成“不可预期”

单体架构时代,一个请求进来,逻辑清楚,出错路径基本固定。查日志只需要看一个进程,从上往下翻,出错位置往往就在最后几行。到了微服务阶段,一个接口的前后依赖关系会变得非常复杂。网关转发,鉴权服务校验身份,订单服务创建订单,库存服务扣减库存,支付服务发起支付,中间可能还夹着消息队列、缓存、分布式任务和第三方接口。

服务少的时候,团队里两三个人还能靠经验记住主要调用路径。一旦服务数量涨到十个、二十个,调用关系图会超出任何人的记忆范围。同一个接口在不同时段可能走不同分支,某个分支慢会拖累整个接口,某个新上线的服务可能临时修改了内部调用方式,但这些变化不会主动通知所有下游使用者。

我曾经处理过一个商品详情页变慢的问题。页面接口本身很简单,但下面的子接口有十多个分支调用,有的查缓存,有的回源数据库,有的调搜索服务,有的调评价服务。某个评分服务在高峰期多了一个慢查询,页面整体耗时从200毫秒涨到3秒。如果不看调用链,你只能从页面最外层接口一层一层往下猜。

1.2 日志分散,只看“最后一个服务的报错”解决不了问题

很多人一开始做分布式排查,还是沿用单体的老经验:先看报错,再回推原因。这个思路在微服务环境里经常失效。

原因在于,报错往往出现在最下游的服务,但真正的问题可能在上游。比如订单服务调用库存服务,库存服务返回了一个“库存不足”的异常。看起来问题在库存侧,但如果继续追,会发现是因为订单服务传了一个错误的商品ID,或者是上游请求里的店铺ID与商品维度不匹配。没有统一的traceId,你想把订单服务和库存服务的日志按时间对齐起来,难度极高。

日志分散也是一个大问题。微服务的日志通常会落在不同虚拟机、不同容器、不同pod里。出了问题,先要去日志平台里搜索,再把多个服务的时间和节点拼起来。这个过程非常依赖运维系统做得好不好。如果日志平台本身没有把traceId作为索引字段,排查一次像做一个手工拼图游戏。

更关键的是,日志记录的内容经常不在同一个时间粒度上。A服务记录的是入口时间,B服务记录的是处理完成时间,中间的网络耗时、排队时间和重试时间在单条日志里完全看不出来。

1.3 微服务排障的真实成本:大头不是“修”,而是“找”

做微服务时间长了,你会发现一个规律:真正修Bug的时间往往很短,改代码可能只需要几分钟,但“找到这个Bug到底发生在哪”可能需要几小时甚至几天。

举一个很常见的场景:某个下单接口突然变慢,用户已经开始投诉。网关超时时间设置的是5秒,现在大量请求超过5秒,网关直接返回504。订单服务日志显示请求进来了,但执行到一半就超时退出。库存服务日志显示某个查询数据库的SQL耗时接近4秒。你以为问题定位在数据库慢查询,但继续查下去,会发现是因为Redis里某个商品维度的缓存Key长时间未命中,缓存回填逻辑又同时被多个线程触发,数据库连接池被打满,最后导致正常的库存查询被阻塞。

这个过程中,你至少要看三个服务的日志:网关、订单服务、库存服务,可能还要查Redis监控和数据库连接池监控。真正动手修只需要把缓存回填的并发限制改一下,但定位这个位置,可能需要好几个小时。

我一般会建议团队做一个简单统计:线上问题的平均定位耗时是多少。如果已经超过30分钟,而且其中80%的时间是在“找路径”而不是“验证修复方案”,那就说明没有链路追踪已经变成一个明显的效率瓶颈。

2. 没有链路追踪时,线上故障的三种典型形态

2.1 接口超时:你以为A挂了,其实是B慢

这是最典型的微服务故障形态。现象是用户请求超时,直觉反应是某个服务宕机了。但实际查下来,A服务没有宕机,只是A服务还在等待B服务的响应。

比如A服务接到请求后,需要调用B服务获取用户信息。B服务本身没有挂,但它依赖的数据库连接池资源不足,每个请求排队等待了3秒。A服务自身的处理能力没有问题,但因为下游响应慢,在线程池资源固定且不会无限等待的情况下,A服务的线程也被占满,最终导致大量请求超时。

只看A服务的监控时,你看到的是线程池活跃数很高,容易误判为“A服务需要扩容”。只有把调用链打开,才能看到A服务的耗时大头在“下游调用等待”上,B服务才是导致延迟的根源。

2.2 数据不一致:同一个操作在两个服务里结果不同

微服务拆分之后,一个业务流程往往要跨多个服务,但跨服务之间并没有单体时代那种单库事务保障。比如订单状态更新成功,库存没有扣减;用户看到订单已支付,但支付回调没有及时通知订单服务;退款操作在订单系统成功,在财务系统里却没有生成凭证。

这类问题最麻烦的地方是:每个服务都记录了成功日志,你无法从单个服务判断到底是谁先谁后,谁调用谁失败,补偿逻辑有没有被触发。如果没有链路追踪,你只能拿业务流水号或订单号,在各个服务的数据库里手工比对,效率非常低。有了调用链,至少可以看到这个订单请求经过的完整节点,以及每一步的返回结果和耗时,判断范围能缩小很多。

2.3 单个服务都正常,但用户就是觉得慢

还有一种情况很隐蔽:不是某一个服务明显变慢,而是所有服务的平均耗时都在正常范围内,但用户从入口到出口的总耗时就很高。

原因是服务之间的网络往返、序列化、反序列化、网关转发、负载均衡、以及多次的RPC调用叠加在一起。比如一个操作需要串行调用4个服务,每个服务只花50毫秒,看起来都不算慢,但加上4次网络开销和等待时间,总耗时可能超过500毫秒。如果再叠加一层网关和一次外部HTTP调用,用户体感就会明显变差。

这在单服务视角里是看不出来的。只有把整个调用链展示出来,看到总耗时和各个Span的耗时占比,才能意识到瓶颈不在任何单个节点,而在于调用链路的“节点之间”。

3. 链路追踪到底做了什么:从traceId到完整调用链

3.1 核心概念:Trace、Span、traceId和parentId

链路追踪有三个核心概念,理解了它们,后面看文档和排查问题都会顺畅很多。

Trace,指一次请求从入口到出口的完整过程。在调用链上,它是一个树状结构,根节点是入口请求,一级一级往下展开。

Span,指Trace中的一个独立处理单元。一次HTTP请求、一次数据库访问、一次消息发送,都可以是一个Span。每个Span记录了开始时间、结束时间、状态、操作名称、服务名称等关键信息。

traceId,是连接整个Trace的全局唯一标识。同一个请求经过的所有服务,都会把traceId记录在自己的日志和Span里。这样,无论日志散落在哪里,只要按traceId搜索,就能把整条请求串起来。

parentId,用来标记一个Span由哪个父Span发起。通过parentId,系统才能还原出调用层级,知道哪个节点先调用、哪个节点是它的下游。

链路追踪系统的价值,就是围绕traceId把分散的Span聚合成一棵调用树,再统一展示起来。

3.2 上下文是怎么传递的:请求头、RPC Attachment和异步消息

链路追踪能跑通,关键前提是“traceId要跟着请求走”。在不同场景里,传递的方式不一样。

HTTP调用,通常通过请求头传递。比如网关在入口生成traceId,放到请求头里,下游服务接收到后从请求头读取,再继续传给更下游的服务。

RPC调用,比如Dubbo这类框架,一般通过RPC的Attachment或附加参数传递。Spring Cloud Alibaba、Spring Cloud Sleuth这些组件已经封装好了自动透传逻辑,但用得不对还是会有坑,比如拦截器没有生效、附加上下文被覆盖、自定义ThreadLocal没有清理干净等。

消息队列,比如RocketMQ、Kafka,一般会选择把traceId放到消息的Header里。消费端收到消息后,从Header里取出traceId,把消费逻辑也纳入同一条调用链里。

这里最重要的认知是:链路追踪不是“只有接入组件才有用”,还要关注上下文在“创建新线程、跨进程、跨队列”时是否被正确传递。很多链路断掉,不是链路追踪系统不行,而是业务代码里上下文丢了。

3.3 数据链路:探针、上报、聚合、存储、展示

链路追踪系统的完整工作流程,通常包含五个环节。

第一步,探针或SDK。它可以是一个Java Agent,也可以是一个第三方SDK。侵入程度各有不同,核心作用是拦截关键调用,生成Span。

第二步,上报。探针把生成的Span数据异步发送给Collector或收集端。这里要注意,上报一般不能阻塞业务线程,否则会反过来拖慢请求。

第三步,聚合。同一个traceId的Span会被收集端汇总,通过parentId还原调用关系。

第四步,存储。聚合后的数据会写入存储层,常见的包括Elasticsearch、MySQL、H2、ClickHouse等。生产环境一般会优先考虑长期存储能力较强的方案。

第五步,展示。通过Web界面查看调用链、服务拓扑、耗时分布、错误节点。

开源工具里,SkyWalking、Zipkin、Jaeger、Pinpoint、Cat都有各自的定位。SkyWalking在Java生态里比较流行,使用Java Agent接入,对代码侵入极低;Zipkin和Jaeger轻量,适合和Spring Cloud生态结合,但要自己搞定采集器、存储和UI;Pinpoint对调用细节展示很丰富,但资源占用通常偏高;Cat偏向日志分析型,需要前期投入工程化改造。

选型不需要迷信哪个最强,重点看团队的运维能力、存储设施和对“侵入程度”的容忍度。我个人会更关注一个问题:接入之后,能不能在不改业务代码的情况下停掉或降采样,避免链路追踪变成生产环境的“另一种故障源”。

3.4 只靠调用关系还不够,要能看耗时、状态和异常点

链路追踪如果只展示“A调用了B”,那价值有限。真正能帮助排查问题的,是每个Span的耗时、状态码、异常栈、成功失败标志,以及缺陷调用有没有触发重试。

举个例子:一个接口调用了三次下游服务,第一次失败后自动重试,第二次成功。如果只展示调用链,你看到的是一条成功的链路。但如果你把注意力放在耗时上,会看到调用链里多了一个耗时很高的Span,这个多余耗时就是一次失败重试造成的。这类信息在容量评估、超时设置和接口调优时非常关键。

到了生产环境,还要考虑采样问题。全量上报会带来很大的存储和网络开销。一般开发环境可以全量采样,生产环境按流量设置采样率,比如1%或10%,具体要看你们对“可回溯性”的要求和数据量。低采样率能接受,但不要低到“出问题根本查不到那一天的数据”的程度。

4. 从零落地的实操路径:先日志traceId,再接入开源系统

4.1 第一步:先给日志统一加上traceId

哪怕你的团队暂时没有精力接入完整链路追踪系统,我也建议先做这一步:让每个日志文件里都能看到traceId。

具体做法不复杂:在网关或所有请求入口处生成一个traceId,放到SLF4J的MDC(Mapped Diagnostic Context)里;再在日志格式里加上这个字段,比如%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] [%level] %logger - %msg%n。这样每一行日志里都会带上traceId,后续在日志平台上按traceId搜索,能把同一个请求经过的所有服务日志串起来。

这里最容易忽略的是异步线程。接口里开启线程池做计算时,子线程里的MDC上下文不会自动从父线程复制过去。如果你不处理,子线程里打印的日志traceId就是空的,链路又断了。常见处理方式是使用线程池装饰器,在提交任务时把父线程的MDC内容复制到子线程,任务执行完再恢复。

4.2 第二步:选一个开源链路追踪工具,先跑通单机Demo

日志里有了traceId,是“靠日志串链路”。如果服务数量一多,这个方式仍然很费劲,因为要从日志平台里来回搜索,耗时也高。这时候就该考虑接入链路追踪系统了。

选型之前先想清楚几个前提:团队主要技术栈是什么?有没有统一的运维平台?存储用什么?能不能接受Agent方式接入?

Java技术栈里,如果追求低侵入,可以先试SkyWalking,用Java Agent方式接入,业务代码基本不用动。如果是Spring Cloud生态,而且团队对中间件理解比较深,也可以考虑Zipkin或Jaeger,搭配Spring Cloud Sleuth或Micrometer Tracing使用。

不管选哪个,第一件事都是先在测试环境跑通一个最小Demo:部署一个提供HTTP接口的服务,手动发起几次请求,确认在UI上能看到完整调用链、节点耗时和日志关联,再去考虑跨服务传递和批量部署。

4.3 第三步:网关和RPC调用怎么透传上下文

很多团队接入完链路追踪系统后,发现了一个奇怪现象:服务内部有调用链,但跨服务就断了。最常见的原因是网关没有透传traceId,或者RPC框架的上下文传递配置不对。

网关本身就是流量入口,它负责生成traceId并向下游透传。这里要注意,网关不能每转发一次就重新生成一个traceId,否则所有下游服务记录的traceId五花八门,还是串不成链。生成规则应该是:入口请求里如果已经带了traceId,就继续沿用;如果不带,网关新生成一个,写进请求头,再一路传下去。

在RPC调用里,要关注框架是否已经把traceId放进Attachment或请求头里。如果框架自带支持,通常不需要额外处理;如果用的是自研RPC、历史遗留框架,就要手动在拦截器里取traceId并传递。排查的时候,优先看两个方向:一个是网关到第一个服务的头部信息有没有带traceId,另一个是服务间调用时框架有没有保留上游头部信息。

4.4 第四步:异步任务、消息队列和定时任务怎么衔接

异步场景是最容易让链路追踪“断链”的地方。线程池、MQ Consumer、定时任务、回调,如果不在这些入口处理好traceId的传递和恢复,链路永远只会显示一半。

消息队列是比较典型的情况。生产端在发送消息时,把traceId放到消息的Header里;消费端接收到消息后,先从Header里取出traceId,放入当前线程的MDC,再执行业务逻辑。这样,一条消息的消费处理也能挂到同一条调用链上。

定时任务特殊一点,它通常不是由用户请求触发,而是由调度中心触发。这类场景要建立自己的“根Trace”,可以为每次调度生成一个traceId,调度任务里所有子任务都继承这个traceId,方便事后回溯“某次定时任务为什么跑了很久、某个批次为什么处理失败”。很多团队在排查定时任务相关问题时,会发现链路追踪里根本没有定时任务的数据,原因就是当时没有做入口Trace初始化。

4.5 第五步:采样率、存储和保留策略要提前设计

链路追踪接入后,最怕的就是“存储压力过大”。

生产环境建议先按业务量估算数据量。一个请求平均产生多少个Span,日请求量有多少,再乘上采样率,就能粗略估算存储需求。如果日请求量达到百万级别,全量采集会产生大量数据,存储成本会迅速上升。这时候就要考虑降采样,比如按百分比采样,或者按接口重要性设置不同采样策略。

我一般会建议:开发环境全量采样,方便排查;预发环境按50%或100%采样,用来做上线前的链路验证;生产环境核心交易链路做较高采样,日志类、查询类、非核心链路做低采样。具体数字不是死的,要结合团队存储能力和诉求来调。

采样率调低带来的问题是:线上出故障时,可能恰好没有采到那条请求。这对存储压力大的团队来说属于可接受的取舍,但如果你所在的业务对可观测性要求很高,建议宁可多花存储成本,也至少保证核心交易链路的全量采样。

5. 落地后最常见的问题和排查顺序

5.1 链路只显示半截:先怀疑上下文丢失

链路追踪接入完成后,最常见的现象是:同一个请求,前面几个服务有链路,到某个服务之后就没有了。这种情况我首先会怀疑“请求在进入那个服务时,traceId没有传递过去”。

检查顺序一般是:

  1. 先看不完整链路的边界是在哪个服务。
  2. 再看这个服务是通过什么方式被调用的,HTTP还是RPC,还是MQ。
  3. HTTP就查请求头里有没有带traceId,RPC就查Attachment里有没有,MQ就查消息Header里有没有。
  4. 确认调用方有没有做透传,被调用方有没有从对应位置读取。
  5. 最后确认这个服务里有没有异步线程,中间有没有重启线程池、新开Thread、使用CompletableFuture并行调用。

排查过程中,很多问题不是出在框架,而是业务代码里“手动开了线程、又没把MDC内容复制过去”。这种问题用单机日志不一定能发现,因为日志能打印,但traceId是缺的,或者跟主链路对不上。

5.2 traceId在日志里没打出来:查MDC生命周期和线程隔离

有时候链路系统里能看到Trace,但日志文件里每一行traceId都是空的,或者同一个请求在不同行里traceId不一样。这种情况,基本是MDC的使用问题。

重点检查三处:

  • 日志格式里是否真的引入了%X{traceId}
  • traceId是否在拦截器或过滤器里放进了MDC,并且有没有被后续代码提前清除。
  • 异步线程里有没有复制主线程的MDC上下文,执行完之后有没有清理。

MDC底层是ThreadLocal,天然具备线程隔离性,但正因如此,在线程复用场景里如果不注意清理,很容易出现“traceId串了”的现象:本来没有traceId的任务,复用了上一个任务残留的traceId。所以,入线程时复制MDC,出线程时清空MDC,这两个步骤都要有。

5.3 链路有了,但看不出谁慢:看Span父子关系和耗时占比

链路追踪系统把Trace展示出来后,很多人只会看最后的耗时,然后凭感觉判断瓶颈。更可靠的判断方式是看整条链路的耗时分布。

比如一个接口总耗时3秒,但调用链里第一个Span显示网关耗时0.5秒,订单服务耗时2秒,库存服务耗时0.3秒。那重点就应该放在订单服务里,继续看它的子Span,到底是在查询数据库、调用外部HTTP、还是写消息队列消耗了大部分时间。

还有一个容易被忽略的是“在等待”的时间。RPC调用的Span耗时通常会包含“发送请求到拿到响应”的整个过程,其中既有下游执行耗时,也有网络耗时和序列化耗时。如果发现这个Span耗时很大,但是下游服务的实际执行时间很小,那就要关注客户端连接池、网络抖动、超时重试等问题了。

5.4 接入后性能下降:检查探针、采样和高频接口CPU

链路追踪系统本身也有开销,尤其是在Java Agent场景下,字节码增强和Span采集会带来一定的CPU、内存、网络消耗。接入之后如果发现服务性能下降,优先看这些方面:

  • 探针日志是否过多,默认日志级别是不是DEBUG。
  • 高频接口是否没有做采样,每个请求都生成大量Span。
  • 是否开启了过于细粒度的插件,比如数据库调用、Redis调用、HTTP调用全部拦截,导致Span数量爆炸。
  • 采集端上报的网络路径是否稳定,如果上报链路阻塞,探针内部缓冲区会积压,带来内存压力。

排查这类问题时,先关闭非必要插件,再降低采样率,然后再看CPU和内存是否恢复正常。如果问题依旧,就要去看探针自身运行时有没有报错、有没有和业务代码冲突。

6. 什么时候不得不做,什么时候可以再等等

6.1 判断标准不是服务数量,而是排障成本

“微服务一多就必须做链路追踪”这句话里的“多”,并没有一个绝对数字,更多是看排障成本。

我习惯用一组条件来判断:

指标大概状态建议动作
服务数量5个以下,团队小可以先做日志traceId,链路追踪系统可选
服务数量10到20个,跨两三个团队建议正式接入链路追踪
平均单次线上问题定位耗时超过30分钟建议尽快接入
单条请求跨服务深度稳定超过3层建议接入
跨团队协作排障频率每个月至少一次建议接入
高峰流量下出现超时、数据不一致次数每周都有必须接入

这些条件不要求全部满足。只要排障成本已经明显影响到线上恢复速度,链路追踪就值得投入。

6.2 轻量替代方案:统一日志格式+按traceId检索

如果团队暂时没有人力运维完整的链路追踪系统,可以先做一个轻量方案:统一日志格式,把所有服务的日志都输出成结构化JSON或带固定字段的文本,traceId单独作为一个字段,统一接入日志平台。

这样做的好处是:至少你能在出问题时,按traceId搜索到一个请求经过的所有日志。排查路径仍然靠人工一步步串,但比没有强很多。缺点也很明显:不能自动生成调用拓扑,不能自动还原Span耗时,异常定位还是依赖经验和耐心。

所以,轻量方案适合服务数量还在可控范围、链路深度不深的阶段。一旦服务规模扩大、线上问题开始频繁出现,还是要尽快切到正式链路追踪系统。

6.3 分阶段落地,不要想一口气做到位

链路追踪项目如果一开始就追求大而全,反而容易失败。我更建议分阶段往前走。

第一阶段,只做日志traceId。所有服务统一日志格式,入口生成traceId并透传,日志平台支持按traceId检索。

第二阶段,接一个开源链路追踪系统,先在一两个核心服务上试点,跑通跨服务调用链,确认UI和存储逻辑都没问题。

第三阶段,逐步扩大到网关、RPC、MQ、定时任务等场景,打开采样和告警,把“按traceId查问题”变成团队默认的排查习惯。

第四阶段,再考虑把链路追踪和现有监控系统、告警系统、日志平台联动起来,形成完整的可观测体系。

这套路径的优点是每一步都有明确产出,不会在接入阶段就把资源烧光。真正落地的时候,最该盯住的不是“看起来全不全面”,而是任何一个关键接口是否都能在几秒钟内还原完整调用链,以及团队成员是不是已经养成了“先看链路,再写结论”的习惯。

很多项目最后不是死在技术选型上,而是死在“出了故障,却没人能快速说清请求到底卡在哪一步”。链路追踪的价值,就是让排障从“靠经验猜”变成“靠数据查”。

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

量子计算为何比超算快1亿亿倍:从量子叠加与并行性解析

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

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

API实测对比DeepSeek与GPT:中文推理、代码生成与模型选型指南

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

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

MCN机构AI脚本批量生成实战:从工具选型到SOP全流程

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

作者头像 李华
网站建设 2026/9/6 13:55:53

采埃孚与英伟达联合开发车载AI系统:技术架构与量产挑战解析

简介:这是一份关于采埃孚(ZF)与英伟达(NVIDIA)联合开发人工智能系统的技术资料,PDF格式,适用于自动驾驶、智能系统及汽车电子领域的研究人员、工程师和学习者,可作为系统开发与行业合…

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

RK3588多模态车内Agent:语音视觉手势融合与仲裁实践

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

作者头像 李华