Netty写数据链路拆解:从writeAndFlush到SocketChannel的完整流程
做网络编程时间长了会在“写数据”这件事上形成一个错觉写不出去就往Channel上调用一行writeAndFlush完事。直到线上出现发送队列积压、连接写半包甚至内存不断攀升的问题才意识到这条链路里其实藏了不少东西。这篇我把Netty从业务线程调用写操作到字节真正交付到NIO SocketChannel 的整个过程拆开主要基于4.1版本源码核心围绕 pipeline、ChannelOutboundBuffer 和 NioEventLoop 这三层展开。如果你刚开始啃Netty源码先别急着把EventLoop、Promise、Future这些概念全都搞明白。盯住一个字写。同一个消息对象是怎么从 tail 传到 head怎么进入写缓冲区怎么被标记为可flush最终怎么通过JDK的SocketChannel.write交给内核这条链路完整走一遍很多诡异的线上问题都能正面推理出原因。1. 一次writeAndFlush调用在pipeline中的流转轨迹1.1 从Channel入口到TailContext的第一次交接很多刚接触Netty的读者会以为channel.writeAndFlush(msg)是直接在某个Unsafe上执行其实不是。它的入口是AbstractChannel.writeAndFlush内部调用的是pipeline.writeAndFlush(msg)。也就是说一个出站数据要经历的并不是什么神秘通道而是和入站数据完全同一套pipeline机制只不过方向相反。DefaultChannelPipeline.writeAndFlush会从 tail 开始往前找。为什么要从tail开始因为pipeline里每个handler都持有两个相邻节点的引用出站事件的传播顺序就是“从内部往外部”tail是最里面的一个节点它先拿到这个消息然后由每个handler决定继续传还是就地拦下。这一步关键在AbstractChannelHandlerContext.writeAndFlush里。如果你去翻4.1.x的源码会发现它最终调到一个重载方法write(Object msg, boolean flush, ChannelPromise promise)。flush为true时它找的是身上带有出站写标记的handler找到后调用invokeWriteAndFlush(msg, promise)。这个方法会真正触达用户自定义Handler的write回调。1.2 Handler改写msg与HeadContext的最终落点在出站链路上Handler往往不只是“传递”消息还经常承担编码工作。比如你业务层传进一个自定义的Java对象出站方向某个Encoder会把它转成ByteBuf再调用ctx.write(buf, promise)继续往下传。如果同一个Handler里没有调用ctx.write消息就断在这层了——这是新手写Netty Handler时最容易犯的错自测时发现对端收不到任何数据多半就是这个原因。消息一路往前最终到达 HeadContext。看HeadContext.write这个方法里头其实特别简单Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { unsafe.write(msg, promise); }也就是说真正写数据的入口是AbstractUnsafe.write。在这个方法里Netty会先做一件事filterOutboundMessage(msg)。对NioSocketChannel而言这个方法会把不是ByteBuf或FileRegion的消息过滤掉甚至尝试把堆外消息转换处理之后才把消息扔进写缓冲区。这里的“写缓冲区”就是下节要聊的 ChannelOutboundBuffer它是整条写链路里最核心的数据结构也是你排查内存积压问题第一个要盯的地方。另外write和flush在源码层面是严格分开的。write只负责“入队”真正让内核发送是另一个入口flush。所以writeAndFlush只是前者和后者的语法糖理解这一点是理解后面所有缓冲机制的前提。2. ChannelOutboundBuffer从addMessage到addFlush的缓冲核心2.1 环形Entry数组与三根指针的语义ChannelOutboundBuffer 不是一个简单的队列它内部维护了一个环形Entry数组。每个Entry是一个链表节点同时也承载消息本体和对应的ChannelPromise。你往里丢消息时addMessage会把msg包装成Entry放到tailIndex指向的位置。它同时维护了三个关键索引tailIndex 下一个Entry写入的位置 flushedEntry 当前第一个已被标记为可flush的Entry unflushedEntry 当前第一个还没flush的Entry如果你只看这三个索引会发现一个关键事实未flush的消息和已flush但还没写完的消息其实共享同一个环形空间只是索引位置不同。addMessage只会移动tailIndex不会移动flushedEntry。直到你调用addFlushflushedEntry才会追到unflushedEntry的位置把这一批消息统一标为“该发送了”。所以channel.write(msg)和channel.flush()分离的意义就很清楚了你可以连续写10个小消息再调用一次flush让10条消息作为一个批处理进入真正的发送循环大幅降低系统调用次数。这种设计在低延迟高吞吐的服务里非常实用。2.2 current、progress、remove三个方法配合推进EventLoop真正发送数据时不是遍历整个数组而是只看current()public ByteBuf current() { Entry entry buffer[flushedEntry]; if (entry ! null) { return entry.buf; } return null; }这里有一个细节Entry里既存msg又存buf。在addMessage的时候如果msg本身是ByteBufnetty会把entry.buf指过去后面对外暴露的发送对象其实是buf。这样设计是为了兼容FileRegion这类非ByteBuf消息对于文件发送类型current里可能是null发送逻辑会走到另一条分支。当JDK层的SocketChannel完整写完一个ByteBufNetty调用remove()把当前Entry清空flushedEntry前移一位如果只写了一半就调用progress(amount)记录当前已写的字节数等下一次OP_WRITE事件再接着写。这三个方法配合得极其紧密是理解“部分写”机制的核心。2.3 释放引用计数的正确时机很多同学问ByteBuf到底什么时候释放答案就在remove()里。当消息被完整写入Socket后Entry会把自己持有的msg和promise清理干净同时触发一次decrementPendingOutboundBytes对引用计数的ByteBuf做一次release。如果消息是被取消或者连接关闭时仍滞留队列也会走失败释放的逻辑。我在调试一个服务时见过一个典型问题业务侧在Handler里把同一个ByteBuf同时传给了两个channel写出结果两边都释放导致Double Free。这类问题如果在remove的回调上看引用计数变化会清晰得多。3. NioSocketChannel的部分写与OP_WRITE触发机制3.1 写自旋为什么不是写一次就完事当消息进了ChannelOutboundBuffer真正离开Netty、进入内核靠的是AbstractNioByteChannel.doWrite。这一步要面对一个NIO程序员都绕不开的现实JDK的SocketChannel.write(ByteBuffer)不保证一次写完整个缓冲区。Socket发送缓冲区满了它会返回0或者只写了一部分。Netty的处理办法是“自旋 OP_WRITE注册”。看doWrite的主循环关键点在于writeSpinCount默认值是16。也就是说一个ByteBuf最多允许循环写16次如果16次之内还没写完就不再傻乎乎地空转而是注册OP_WRITE等内核通知可以继续写。while (!in.isEmpty()) { ByteBuf buf in.current(); if (buf null) { // FileRegion场景 in.remove(); continue; } final int readableBytes buf.readableBytes(); int writeSpinCount config().getWriteSpinCount(); int writtenBytes 0; boolean setOpWrite false; while (writtenBytes readableBytes writeSpinCount 0) { int localWrittenBytes ch.write(buf); if (localWrittenBytes 0) { writtenBytes localWrittenBytes; } else if (localWrittenBytes 0) { setOpWrite true; break; } else { throw new IOException(... ); } writeSpinCount --; } if (writtenBytes readableBytes) { in.remove(); } else { in.progress(writtenBytes); if (setOpWrite) { break; } } }这里in.progress(writtenBytes)很重要它不移动ByteBuf的readerIndex也不会让Entry被移除。ByteBuf内部的readerIndex是JDK底层写操作自己推进的Netty记录下来只是为了统计数据用下次OP_WRITE触发时数据从ByteBuf当前readerIndex继续写。3.2 从setOpWrite到clearOpWrite的循环避免当doWrite发现写不进去时会调用setOpWrite()在Selector上注册唤醒事件。这里最需要留意的是OP_WRITE是一个很“危险”的事件它几乎总是可写如果不加控制Selector会立刻通知、立刻触发flush、然后又立刻进入写方法形成CPU空转。Netty对此的处理是在 NioEventLoop 处理 OP_WRITE 事件时不会直接无止境地调用flush而是通过forceFlush在事件循环里保证每一次OP_WRITE只对应一次写尝试。当数据真正写完时clearOpWrite()会把这个兴趣位去掉。因此如果某个连接长时间都在setOpWrite而又写不出去基本就是对端处理不过来或者网络拥塞了这种连接在你用ss或netstat排查时会看到大量的Send-Q积压。另外writeSpinCount并不总是越大越好。调大自旋次数能减少OP_WRITE事件的切换但会占用EventLoop线程的时间其他连接会被拖累。线上遇到某条连接写不出去时我一般不会优先去调这个数而是先看目标端的消费能力和TCP窗口。3.3 FileRegion与零拷贝分支对于大文件发送Netty没有让你把整个文件读进ByteBuf而是直接传FileRegion。doWrite里对buf null的情况会走FileRegion.transferTo这是JDK底层基于sendfile的零拷贝优化。这条分支和ByteBuf的写循环走的是两套逻辑但也一样受写自旋和OP_WRITE控制。想排查大文件传输半截断掉的问题重点就在transferTo返回值和进度记录上。4. 水位线从isWritable变化到背压全链条4.1 totalPendingSize是怎么记账的ChannelOutboundBuffer 里的积压不只是“消息条数”Netty更关心的是“字节总量”。每次addMessage之后都会调用incrementPendingOutboundBytes(size)这里的size就是这条消息预计会占用的字节数。这样设计的原因很直接服务端内存压力主要来源于写缓冲区的总大小而不是Entry数量。反过来当消息被完整移除时decrementPendingOutboundBytes会扣掉相应字节。注意progress()记录的部分写进度不会减少totalPendingSize因为它只是表示“写了多少”但消息还没彻底发送完毕内存还没释放。所以你看pending值高的时候要先判断到底是消息本身太大还是某个大消息被卡在部分写状态。4.2 高水位低水位的切换逻辑Netty提供了一个配置类WriteBufferWaterMark默认低水位32KB、高水位64KB。当一个channel的totalPendingSize超过高水位时内部会把unwritable标记为true此时channel.isWritable()返回false同时触发一次fireChannelWritabilityChanged把事件沿着pipeline的入站方向传播出去。当积压降到低水位以下才移回writable状态再次通知业务Handler。源码里这段逻辑非常直观public void incrementPendingOutboundBytes(long size) { long newWriteBufferSize totalPendingSize size; totalPendingSize newWriteBufferSize; if (newWriteBufferSize channel.config().getWriteBufferHighWaterMark()) { setUnwritable(); } } public void decrementPendingOutboundBytes(long size) { long newWriteBufferSize totalPendingSize - size; totalPendingSize newWriteBufferSize; if (newWriteBufferSize channel.config().getWriteBufferLowWaterMark()) { setWritable(); } }你可以在业务Handler里监听channelWritabilityChanged当isWritable变为false时停掉对端的消息生产或者丢弃非关键的实时数据避免内存被打爆。这是Netty应用层最常见的背压姿势之一。4.3 自动关闭读事件与对端保护水位线只是背压的一部分。另一个天然的背压手段是暂停读。当一条连接写不出去时如果还继续拼命从对端读数据消息进得来出不去内存照样会涨。Netty里有一个autoRead配置在写缓冲区不可写时结合ChannelConfig的调整会取消OP_READ兴趣位相当于让对端发送的TCP窗口变满从而把压力反推给对方。这段联动逻辑并不复杂isWritable()为false时doBeginRead会判断是否还要继续注册读事件。所以如果你发现某个连接不再读数据了先查它是不是已经处于不可写状态而不是一上来就怀疑Selector出问题。5. 写线程模型EventLoop与外部线程的切换代价5.1 为什么不能直接在业务线程写缓冲区ChannelOutboundBuffer 不是线程安全的所有对它的操作包括addMessage、addFlush、remove都必须在channel所属的EventLoop线程里执行。但业务线程往往不在EventLoop上于是 Netty 在 AbstractChannelHandlerContext 中做了一个引人注目的判断if (executor.inEventLoop()) { if (flush) { next.invokeWriteAndFlush(msg, promise); } else { next.invokeWrite(msg, promise); } } else { // 构建 WriteAndFlushTask塞入 EventLoop 的任务队列 safeExecute(executor, task, promise, msg); }如果你在业务线程里直接调用channel.writeAndFlush实际上这个写操作不会立刻执行而是被包装成了一个WriteTask或WriteAndFlushTask投递到EventLoop的MPSC任务队列里排队等待EventLoop处理。这也是为什么很多人会在Flush之后立刻去读Future的状态发现还没完成——因为线程切换还没发生。5.2 跨线程写带来的回调线程问题跨线程写还有一个容易被忽略的后果ChannelPromise的完成回调最终一定是在EventLoop线程里触发的。也就是说你给writeAndFlush添加的listener会在EventLoop线程上执行。如果在listener里做了耗时的IO操作比如查数据库、调远程接口整个EventLoop会被卡住同一线程上的所有其他channel都会跟着遭殃。我之前遇到过一次线上毛刺某个服务里有人习惯在writeFuture的listener里做一次Redis读取结果触顶时上千个channel共用同一个EventLoop其他连接全部延迟飙升。排查思路很简单看EventLoop线程的栈基本一眼就定位了。正确做法是把耗时操作丢回业务线程池或者完全异步化。5.3 减少跨线程失败的通用策略跨线程写本身没有错但高频跨线程写会对MPSC队列造成压力。有一些服务会把同一个Channel的写入集中到固定几个业务线程再批量write然后flush一次这是一个很实用的优化。还有一个细节如果你在业务线程里频繁调用writeAndFlush每一次都会产生一个task在小消息高并发场景下task创建数量会非常可观GC压力也随之增大。这种情况下考虑先调用write累积让EventLoop上形成一次批量flush往往比疯狂用writeAndFlush更合理。6. 实测中值得留意的坑和调优建议6.1 水位线别照着默认值用默认的32KB/64KB水位线对很多小消息服务来说是够用的但如果你一个单条消息就是几百KB还没开始发就触顶了。网上能看到一个常见做法是调高水位比如改成256KB/512KB这个方向没问题但千万别只改不测。水位设太高内存积压风险大设太低又会导致isWritable频繁抖动业务Handler跟着反复触发背压逻辑白白浪费CPU。建议根据单条消息大小乘以合理的积压上限来估算。设置方式channel.config().setWriteBufferWaterMark( new WriteBufferWaterMark(256 * 1024, 512 * 1024));6.2 报错Connection reset by peer的常见场景写数据时最常遇到的两个异常Connection reset by peer和Broken pipe。很多人下意识认为是Netty的问题其实绝大多数是对端直接关闭了连接而你还在往它写。这种情况可以理解为一个正在写数据的通道被对端硬掐了。按我的经验与其纠结异常本身不如做好连接异常关闭后的清理并关注对端应用是否有慢消费、卡顿、死锁。6.3 用ChannelOutboundBuffer的视图排查积压调试写积压问题时可以在断点里直接观察ChannelOutboundBuffer的totalPendingSize或者通过channel.unsafe().outboundBuffer()拿到内部结构。不过生产环境没法这样断点。更实用的办法是定期打印channel.isWritable()和待写字节数把积压趋势记录下来看是不是在持续增长。如果pending一直涨优先看对端TCP窗口和消费速度再回头查自己的生产节奏。6.4 关于小消息合并与零拷贝还有一个很常见的优化点如果业务是高频率发送几十字节的小消息这些消息在TCP层会非常浪费。你可以在业务层用CompositeByteBuf把多个消息拼成一个或者用Netty自带的聚合能力在Encoder里攒一批再写。这不仅是减少系统调用也能降低ChannelOutboundBuffer的Entry数量从根上减少内存碎片。我做过一个压测对比同样吞吐下小消息聚合后再写内存占用能下降近一半整机GC明显改善。写数据这条链路真正深入之后你会发现它其实是一套完整的缓冲、背压、事件驱动的系统工程。每个看似繁琐的机制最后都是为了让网络IO在不可靠的TCP连接上尽量高效、可控地运转。遇到线上异常时先判断问题落在哪一层是pipeline传丢了是ChannelOutboundBuffer积压了还是JDK Socket写不进去了。逐层排查总比盲目调参舒服得多。

相关新闻

FPGA逻辑分析仪:纯硬件VGA实时波形显示

FPGA逻辑分析仪:纯硬件VGA实时波形显示

简介:本资源是一套基于FPGA实现的简易逻辑分析仪完整工程源码,面向数字电路与嵌入式系统初学者、FPGA课程设计学生及硬件开发入门者,用于理解时序采集、触发控制与VGA实时显示等核心概念。工程支持16路数字信号采样,提供开关可调的…

2026/10/17 2:07:32 阅读更多 →
AI数学成果如何验证?高等研究院咨询小组实操解析

AI数学成果如何验证?高等研究院咨询小组实操解析

1. 事件背景与核心矛盾拆解1.1 一个不寻常的机构设置意味着什么高等研究院设立“数学与人工智能咨询小组”这件事,乍看像是一个普通的学术委员会挂牌,但如果你在科研管理或AI研发一线待过,就会意识到这个动作背后的信号非常明确:A…

2026/10/17 2:07:32 阅读更多 →
ECharts数据大屏开发实战:原生JavaScript构建可视化看板

ECharts数据大屏开发实战:原生JavaScript构建可视化看板

简介:一套基于ECharts的数据大屏完整项目源码,面向需要快速搭建可视化大屏的前端开发者和数据分析人员,涵盖HTML布局、CSS响应式样式、JavaScript图表配置以及地图绘制等核心环节,能帮助读者理解从页面骨架到图表动态更新的完整实…

2026/10/17 2:07:32 阅读更多 →

最新新闻

短效代理:高频网络任务的“临时通行证”

短效代理:高频网络任务的“临时通行证”

“短效代理是有效时长较短、会频繁自动更换的网络代理资源,单IP存活时间通常在几十秒到几十分钟之间,也可按请求次数或固定会话周期切换。与长效静态代理不同,它通过高频轮换让每次请求尽量来自不同出口,降低被目标网站识别、限流…

2026/10/17 2:43:39 阅读更多 →
厚浆型环氧煤沥青漆:地下城防御体系中的“加厚装甲”城墙

厚浆型环氧煤沥青漆:地下城防御体系中的“加厚装甲”城墙

一、地下城的共同敌人:暗湿与隐蔽厚浆型环氧煤沥青漆的核心主场有一个共同标签:不见光、长期接触水或土壤腐蚀介质。无论是埋在土里的管道、泡在污水里的池壁,还是浸在海水的码头桩基,都处在高湿度、高盐分、微生物活跃的隐蔽环境…

2026/10/17 2:43:39 阅读更多 →
【C++入门】编译链接模型 - 09 动态库为什么要同时考虑链接期和运行期

【C++入门】编译链接模型 - 09 动态库为什么要同时考虑链接期和运行期

博主介绍:程序喵大人 35 - 资深C/C/Rust/Android/iOS客户端开发10年大厂工作经验嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手《C20高级编程》《C23高级编程》等多本书籍著译者更多原创精品文章,首发gzh,见文末👇&#x…

2026/10/17 2:43:39 阅读更多 →
注意力机制与 Transformer:一切的地基

注意力机制与 Transformer:一切的地基

注意力机制与 Transformer:一切的地基2017 年,八个人发了一篇论文,标题嚣张得像在挑衅:Attention Is All You Need——“注意力,就够了”。当时大家精心搭了好多年的循环、门控、记忆结构,被这一句话全扔了…

2026/10/17 2:43:39 阅读更多 →
矿热炉炉况智能诊断与预警方案

矿热炉炉况智能诊断与预警方案

文档版本:V2.0  发布日期:2026-10-14  编制:洛阳西仪智能科技有限公司适用范围:矿热炉 APC 项目的建模技术路线设计 网址:www.xiyizn.cn一、问题定义翻电石、塌料、刺火、跑眼、电极事故——这些异常工况的共同…

2026/10/17 2:43:39 阅读更多 →
Oracle数据库练习题解析:常见查询陷阱与实战技巧

Oracle数据库练习题解析:常见查询陷阱与实战技巧

你翻开这套 Oracle 数据库练习题的时候,可能跟我当年一样,第一反应不是题目难,而是答案让人看不懂。题解看得懂又会做,才是刷题的正确姿势。说实话,Oracle 的 SQL 题刷起来特别容易卡在几个地方:日期比较为…

2026/10/17 2:42:39 阅读更多 →

日新闻

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

简介:《DeepSeek使用方法.pdf》是一份面向DeepSeek入门及进阶用户的完整使用指南,重点围绕V3与R1两款模型的能力差异与适用场景展开,帮助读者根据任务类型与成本选择合适的模型。资源共1个PDF文件,压缩包大小约311KB,篇…

2026/10/17 0:00:25 阅读更多 →
200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

简介:这是一份聚焦DeepSeek提示词优化的《200Deepseek润色指令》PDF文档,共收录两百余条可直接套用的润色指令,涵盖语法修正、风格统一、逻辑梳理、表达精炼等维度,并针对商务写作、学术论文、广告文案等场景给出差异化建议。文档…

2026/10/17 0:00:25 阅读更多 →
OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

简介:本资源是一份基于OpenCV的本科课程设计完整报告,面向计算机视觉初学者、图像处理学习者及高校通信/信息类专业学生,解决物体智能计数与检测结果结构化记录的实际问题。项目覆盖图像预处理、轮廓检测、边界框绘制、轨迹栏交互式参数调节、…

2026/10/17 0:00:25 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/16 21:35:51 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/17 1:44:59 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/16 15:04:24 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/16 21:54:38 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/16 3:49:43 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/16 14:23:36 阅读更多 →