news 2026/10/2 5:30:05

UDP不可靠?如何在保留低延迟的同时补齐可靠性

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
UDP不可靠?如何在保留低延迟的同时补齐可靠性

写网络相关的东西这么多年,听得最多的一句话就是“UDP不可靠,所以不能用”。这句话本身不算错,但很多人把它理解成“UDP是一块废料”,这就跑偏了。UDP的“不可靠”是有具体含义的:它不保证报文一定到达、不保证到达顺序、也不保证没有重复。但无数实时音视频、游戏同步、物联网上报、组播业务都跑在UDP上,还有人专门用iperf3拿UDP打流去测设备极限能力。这说明“可靠性”不是一个只有TCP能提供的属性,而是一个可以在UDP之上通过设计去解决的问题。无论你是刚接触网络协议栈的嵌入式工程师,还是正在排查线上丢包问题的服务端开发,这篇文章想讲的,就是UDP到底哪里不靠谱,以及我们怎么在保留UDP低延迟优势的同时,把该有的可靠性补回来。

1. 先搞清楚:UDP的“不靠谱”到底是因为什么

1.1 丢包从来都不是协议本身“打”丢的

先说一个很多人容易误解的点:UDP协议本身不丢包,它只是不知道包丢了。网络里的报文从一个网卡到另一个网卡,中间要经过各种队列——发送缓冲区、路由器的转发队列、交换机的端口缓存、接收端网卡的环形缓冲区。任何一个队列写满,新来的报文就会被直接丢弃。TCP遇到这种情况会触发重传逻辑,因为TCP维护了每个报文的发送状态;UDP没有这个状态,发给网络之后就不管了,所以丢包对UDP来说既不可感知,也不可挽回。

以我自己的压测经验为例,用iperf3跑UDP高频小包,经常发现丢包率飙升。原因是发送端网卡把包丢给了接收端,接收端网卡的Ring Buffer瞬间被填满,而上层的应用程序还没来得及把数据从内核缓冲区取走,新到的包就只能drop。这类丢包最坑的一点在于,它并不代表链路质量差,纯粹是接收端消费速度跟不上。排查的时候如果你只盯着光模块告警或线路误码,大概率找不出问题。

真正要记住的结论是:UDP丢包的根因,几乎都是路径上某个缓冲环节容量不足,或者某个转发节点的处理能力达到上限。协议栈本身没有重传覆盖,导致这个现象被“原样暴露”给了上层。

1.2 乱序、重复、校验错误同样常见

除了丢包,UDP业务还经常遇到另外三件事:乱序、重复、校验失败丢弃。

乱序的典型来源是多路径。网络里源和目的之间不一定只有一条路径,路由器做负载均衡时可以按报文分别转发到不同链路,先发的包可能走了一条较长的路径,后发的包反而先到。TCP在接收端会靠序列号把乱序的报文重新排好,UDP没有这个机制,应用拿到什么顺序就是什么顺序。如果你在做GNSS数据转发、行情推送这类对顺序敏感的业务,裸用UDP几乎必然会出现数据错乱。

重复包的出现频率比很多人想象的高。尤其是在组播或者经过某些NAT网关时,重传、重放或者冗余链路切换都可能让同一个UDP报文被复制一份。对幂等的场景问题不大,但对“收到一条指令就执行一次动作”的下行控制场景,重复包会直接导致重复操作。

还有一个必须提的细节:UDP校验和是可选的。IPv4下如果校验和字段置0,表示发送端不计算校验和,接收端也不校验,报文里的数据有没有在传输中被改坏,双方完全不知道。即便校验和开着,一旦校验失败,接收端的处理方式就是静默丢弃,同样不会通知发送方。这些可靠性的“缺位”叠加在一起,才是UDP整套问题的全貌。

1.3 把痛点收敛成一句话

结合上面说的,UDP的可靠性痛点可以归纳为四个方面:报文可能丢失、可能乱序、可能重复、可能静默损坏。用寄快递来打比方,TCP是“挂号信”,每封信有回执,没收到就补发,收件方还能按顺序整理;UDP更像是往一个公共邮筒里投明信片,你投入之后,能不能到、什么时候到、会不会丢,都不好说。很多业务必须用UDP,不是因为大家喜欢这种“明信片”,而是因为挂号信的流程太重了。

2. 既然TCP可靠,为什么还要在UDP上做文章

2.1 实时业务最怕的不是丢一个包,而是等一个重传

如果只用“可靠”一个维度选协议,那TCP确实吊打UDP。但现实中的业务往往还要付代价。TCP的可靠性是拿延迟、吞吐和头部开销换的:建立连接要握手,发送每个报文要等ACK,超时未确认就要重传。重传本身没问题,问题在于重传的是旧数据。对视频通话、实时游戏、工业控制这类场景,迟到的旧数据几乎没有价值,用户只会看到画面卡顿、操作漂移或控制滞后。

举个例子,你在视频会议里丢了一帧画面,最理想的做法是马上显示下一帧,而不是停下来等丢失的那一帧重新传过来。TCP的“可靠重传”在这里反而成了负担。UDP天然不重传,应用层自己决定哪些数据值得重传、哪些过期就丢,这种取舍能力就是它最大的价值。

2.2 组播和广播是TCP完全做不到的能力

TCP是一对一的连接,一个socket只能和一个远端通信。但现实里大量业务是“一份数据发给很多接收端”,典型的有视频分发、灯光控制、工业集群同步。这类需求要靠IP组播解决,而组播在传输层几乎只能选择UDP,因为TCP的面向连接模型根本没法在多个接收端之间维护会话状态。IGMP负责管理组播组成员关系,组播包沿着树状拓扑分发,配合UDP可以让数据同时触达成百上千个节点。如果你有网内多点同步的需求,UDP不是备选方案,而是唯一可行的方案。

2.3 头部开销和连接状态带来的效率差异

UDP头只有8字节,TCP头最少也要20字节,还没算可选字段。在小报文、高频次、海量连接的场景里,这12字节的差距会被放大。更重要的是,TCP为了维护连接,发送端要保序、要滑动窗口、要管理拥塞控制状态;接收端要为每个连接维护序号、缓冲区。单条连接看不出差别,但到了网关、负载均衡器或者嵌入式设备上,这些状态会让性能和内存消耗成倍上升。

我做过嵌入式平台的以太网UDP测试,Zynq这类ARM+FPGA架构上跑TCP协议栈,任务调度和内存管理的开销都不小;换成UDP,中断负担小,处理路径短,同样一份数据能测出更低的时延抖动。很多设备对功耗和实时性要求苛刻,选择UDP就是选中了它的“轻”。

2.4 必须说清楚的另一面

选择UDP不等于选择放弃,而是把“可靠性”的成本从协议栈转移到了应用层。TCP已经替你封装好的序列号、ACK、重传、拥塞控制,在UDP时代都要应用层自己实现。这一句话才是全文的核心:UDP可靠性的解决方案,本质上是在应用层重新实现一套“可控版TCP机制”,但只挑需要的部分做,不需要的就不做。接下来这部分,我会把最常用的几种补法逐一展开。

3. 在应用层重写“可靠性”:方法、案例、代码思路

3.1 最基础三件套:序号、ACK、超时重传

想让UDP变得可靠,先不要追求复杂框架,把三个基础机制先搭起来:报文序号、确认号、超时重传。

报文序号的作用是让接收方能感知丢包和乱序。每次发送时在应用层包头里塞一个单调递增的序号,接收端收到后检查序号是否连续,若不连续说明中间有包丢失;若后到的小于已经收到的最大序号,说明乱序。这比直接用现有数据拼业务逻辑要扎实得多,因为你在协议层面拥有了“检测异常”的能力。

ACK是接收方向发送方反馈“我收到哪了”的机制。收到合法的报文后,接收方回一个确认包,携带当前已收到的最大连续序号。发送方看到ACK推进,就知道前面的包都安全到达了。

超时重传是兜底手段。发送方为每个发出但未确认的报文维护一个计时器,超时后如果没有收到对应ACK,就把报文重发。这个超时时间不能拍脑袋定,建议先基于历史RTT估算:记录每次ACK到达时间与发送时间的差值,取加权平均值,再留出一倍左右的冗余。如果网络抖动大,重传超时可以设得更宽裕,否则频繁超时会浪费带宽。

实际工程里,你的报文头可以像下面这样设计:

字段 长度 说明 Magic 2字节 固定0x55AA,用于快速识别合法报文 PacketID 4字节 单调递增序号 PacketCount 2字节 组包时的总包数 PacketIndex 2字节 当前分包序号 PayloadType 1字节 数据类型标记 PayloadLength 2字节 载荷长度 Payload N字节 业务数据

Magic不是必须的,但我建议保留。UDP是裸报文,端口对了就能收,没有类似TCP握手那样的会话校验,Magic能在应用层过滤掉大量噪声包和程序bug产生的乱流,排查问题的时候特别有用。

3.2 前向纠错:不重传也能兜底

重传有一个天然缺陷:要等。如果每条数据都靠超时重传保障,那端到端延迟至少多一个RTT。实时性要求高的场景往往忍受不了这个等待,这时候可以考虑前向纠错FEC。

FEC的思路是在发送数据之外再额外发送冗余信息。以Reed-Solomon这类编码为例,把每K个数据包编码成K+M个包,接收端只要收到其中任意K个,就能还原出全部K个原始包。换句话说,链路丢包不超过M包的情况下,接收端不需要发任何请求,就能完整恢复数据,全程没有等待重传的过程。

FEC的参数要结合实际丢包率来定。M越大,抗丢包能力越强,但带宽开销也越大。一个真实项目的建议是:先跑一段时间的iperf3或者客户端日志统计丢包率,比如平均丢包率2%,那可以按每100个原始包附加10到15个冗余包来配置;如果网络条件波动大,冗余比例再往上调。实测下来,FEC对缓解突发丢包特别有效,代价是持续占用带宽,不适合传输超大文件。

3.3 成熟协议给我们的经验:RUDP、KCP、UDT、QUIC

自己从零写可靠机制容易踩坑,先看看现成协议是怎么设计的,会省很多事。

RUDP这个名词出现过很多版本,最常见的是把TCP式的ARQ机制搬到UDP上,支持序号、ACK、重传,也可以做部分可靠——比如只保证关键包可靠,普通包直接丢。这个思路很适合游戏和实时通话,因为这类业务里有些数据必须到达,有些则可以跳过。

KCP是另一个基础上发展起来的可靠ARQ协议,它吸收了TCP的部分思想,但调整了重传策略,通过快速重传、选择性重传和更小的RTT权重,让相同丢包环境下比TCP更快感知丢包并恢复。面向游戏同步这类低延迟场景时,KCP往往比把TCP调优半天效果更直接。

UDT则面向另一个极端,专门为高带宽远距离网络设计。它结合了基于速率和基于窗口的拥塞控制,能在广域网链路上跑出比TCP更高的吞吐。如果你要做大数据跨地域传输,UDT是很好的参考对象。

QUIC是目前综合方案里离生产环境最近的,它把可靠传输、多路复用、加密全部搬到了UDP之上,同时通过连接迁移等设计解决了传统TCP的很多痛点。它的核心启示在于:可靠性的实现位置完全可以放在用户态,放在UDP之上,而不是必须放进内核态操作系统的TCP/IP协议栈。

之所以介绍这些协议,不是让你立刻套用,而是希望你在设计自己的方案时有个坐标系:延迟容忍度、丢包恢复速度、带宽利用率,不同协议各有取舍。最怕的就是闭门造车,自己写一个又慢又笨的ACK方案,还不知道世界上已经有现成答案。

3.4 关于“分包和组包”的实现提醒

热门词里反复出现“C# UDP发送分包组包”,这个点在工程里确实容易翻车。UDP报文如果超过路径MTU,IP层会尝试分片,而IP分片一旦有一片丢了,整个报文都会被上层丢弃。最稳妥的做法是在应用层主动控制包大小。

假设底层MTU是1500字节,扣除IP头20字节和UDP头8字节,UDP载荷建议控制在1472字节以内,实际做应用层分包时我通常会再抠一点,设置到1400字节左右,留出安全余量。大消息要拆成多个小块发送,接收端根据PacketCount和PacketIndex把分片暂存下来,全部到齐后按索引重组。这里有几个容易踩的坑:一是要设置超时清理不完整分片,否则接收端内存会被长期占用;二是乱序到达的分片不能简单用序号相等来判断是否重复,要做去重;三是在Zynq这类FPGA+ARM平台上,DMA描述符的维护和中断处理节奏经常会掩盖分包问题,所以我习惯在测试时打印收到分片的序号范围,而不是只看消息是否完整。

3.5 什么数据值得重传,什么数据不值得

补齐UDP可靠性前,一定要先想清楚业务对“旧数据”的态度。状态型数据,比如设备当前温度、实时位置、最新订单状态,发送新值比重发旧值更有意义,重传只会增加网络负担。事件型数据,比如指令、日志、支付确认,丢了就必须补,否则业务断裂。

可靠UDP设计和裸TCP最大的差异就在这:TCP对所有数据一视同仁地保证按序不丢,而可靠UDP可以按业务规则做选择。关键指令用ACK重传,视频帧只做FEC防护不重传,控制流的状态新值直接覆盖旧值。正是这种“可变可靠性”,让UDP在高效和多业务适配性上具备了TCP难以企及的优势。

4. 用工具把不靠谱的量出来:测试方法论实测干货

4.1 iperf3 UDP打流参数详解

排查可靠性问题,第一步是量化问题。iperf3是使用频率最高的工具,UDP模式下测试命令非常简单。先起服务端:

iperf3 -u -s

再在客户端发起打流:

iperf3 -u -c 192.168.10.20 -b 500M -t 30 -i 5

这里的参数含义分别是:-u表示UDP模式,-c指定服务端IP,-b指定目标带宽,-t指定测试时长,-i指定每几秒打印一次中间结果。需要注意-b的单位是bit/s,不写M时默认是bit/s,容易看走眼。

为什么测试UDP可靠性要用打流而不是简单的ping?因为ping只能测到几百字节的小包往返情况,而UDP业务在满带宽下表现如何,必须把流量灌进去才知道。TCP有重传和拥塞控制会掩盖丢包,UDP则是“裸奔”,打进去多少收到多少,差距一目了然。压测时还需要注意,服务端要先启动,UDP没有连接,服务端第一个收到的包来自谁,后续就是谁的客户端。

4.2 看懂报告:丢包率、抖动、带宽到底怎么评价

iperf3测试结束后会输出一段汇总信息,核心字段包括带宽、抖动、丢包数。我每次都会仔细看这几项:

  • 带宽:实际达到的速率,反映链路和设备能扛多少流量。
  • Jitter抖动:以毫秒为单位的报文到达时间变化,对音视频业务尤其关键。
  • Lost/Total Datagrams:丢失包数占总发送包数的比例,就是你要算的丢包率。

如果业务是语音通话,端到端丢包率不要超过1%,视频可以放宽到2%到3%,游戏对战通常希望在1%以下。抖动方面,通话类业务一般要求控制在30ms以内,超过这个值就会出现卡顿感。要强调的是,单次打流数据参考意义有限,因为网络状况是波动的,我习惯至少测三组不同带宽参数,例如分别打100M、300M、500M,才能看出丢包率是不是随负载线性恶化。

如果发现小包场景下丢包严重,看一下CPU软中断。UDP小包很容易达到每秒几十万甚至上百万包,CPU来不及处理时,网卡驱动会直接把后续报文丢在收包队列里。这时候丢包率和链路质量无关,而是设备转发能力的瓶颈暴露了。

4.3 UDP“端口测试”到底是什么

很多人问我,UDP端口怎么测试,用nc连一下不就知道了?这里有个误区:TCP端口连通性测试是靠谱的,因为有三次握手;UDP没有握手,一个包发过去,对端收到后并没有回复,即使端口开放,发送方也无法从协议层面确认。所以UDP端口“通不通”,本质上是必须用应用层逻辑来验证。

常用的验证方式是让业务本身回一个ACK。比如你向对端UDP端口发送一条自定义探测报文,对端收到后在应用层回一条确认消息,发送方收到确认才算“通”。如果只是用nc盲发:

nc -u 192.168.10.20 7777

能发出去不代表对端收到了,更不能代表应用正常。这种测试只能用来验证网络路径上有没有明显的ICMP端口不可达错误,不能作为功能验证依据。

4.4 嵌入式Zynq平台测试特有问题

Zynq平台做以太网UDP测试是很多硬件工程师的日常。这类平台跑UDP时,瓶颈往往不在PHY芯片,而在PL侧的收发逻辑和PS侧的中断处理。实测中我最常遇到的情况是:表面看UDP收发正常,但高负载时偶发丢包,或者接收端收到的数据顺序和发送端不一致。排查时一定要把DMA描述符的环深、搬运地址对齐、校验和卸载开关都检查一遍。如果FPGA侧直接把UDP包构好交给PS,协议栈的校验和可以由硬件计算也可以软件计算,开关状态不同,结果差异很大。测试用例建议多覆盖几个包长,比如64字节、512字节、1400字节,分别观察吞吐曲线,很多驱动问题只在特定包长区间暴露。

4.5 别忘了Wireshark和sockperf

有时候iperf3测不出问题的细节,可以同时抓包。Wireshark里看UDP流,重点看Time列的时间间隔是否稳定,以及有没有大量的重复包或乱序到达。抓包本身会影响时序,所以要在低负载下抓第一轮,确认协议交互逻辑,再用统计工具做高负载压测。

sockperf是一个更细粒度的网络性能测试工具,常用于延迟和抖动测量,比iperf3更适合评估UDP在低延迟场景中的表现。netperf也可以测UDP,但使用便利性不如iperf3,我一般在需要非常规包长或自定义测试模式下才会切过去。

5. 踩过的坑:UDP可靠性问题排查实战

5.1 排查丢包的标准套路

一上来就怀疑链路丢包是新手常犯的错误。正规操作是按下面这个顺序排查,每一步都能过滤掉一批可能原因。

第一步看本机统计。执行netstat -su查看UDP协议栈统计,如果packet receive errors和packets to unknown port received数量异常大,说明问题出在接收端;ifconfig里的RX dropped异常则说明网卡层已经在丢包。

第二步分段压测。先在本机回环地址127.0.0.1上跑一次iperf3,排除本机协议栈的问题;再换直连线、换交换机端口,把链路设备逐段拆离,就能定位到具体是哪一段产生了丢包。

第三步调缓冲区。网卡的Ring Buffer可以用ethtool查看和调整:

ethtool -g eth0 ethtool -G eth0 rx 4096 tx 4096

应用层也要对应调大UDP接收缓冲区,Linux下可以临时设置sysctl -w net.core.rmem_max=8388608,在程序里再通过SO_RCVBUF设置接收缓冲。实测中这一套组合拳能解决大部分高负载丢包问题。

第四步才考虑链路质量问题,检查光功率、误码率和告警计数器。很多人把顺序搞反,先查传输设备,折腾半天最后发现是接收端缓冲区太小。

5.2 高并发场景下的UDP缓存破裂问题

高并发下的UDP,最隐蔽的问题是接收缓冲区溢出。UDP没有流量控制,发送方可以以任意速率灌包,接收端内核缓冲区一旦满,新到达的包直接丢弃。这个机制导致的现象非常容易误判:业务偶尔出现数据缺失,但不影响协议层——因为UDP压根不会报错,应用只是少了一段数据。

排查时重点看/proc/net/udp里的drops字段是否增长。如果增长很快,说明要么接收线程处理能力不足,要么接收缓冲区配置太小。处理这类问题,除了调大缓冲区,还可以看接收线程是否绑定了CPU核,IO密集的网络处理线程最好用pthread_setaffinity_np设置亲和性,能明显降低处理延迟。

5.3 别让防火墙和MTU背黑锅

UDP端口测试不通时,有相当一部分情况是防火墙悄悄丢弃了报文。Linux的iptables默认策略很可能直接drop UDP,而你不一定能感知到。测试时先检查两端防火墙规则,确认没有被拦截,再继续往下查。MAC地址策略下,完整确认对方端口吗?看到什么才叫通?

MTU问题也很常见。UDP报文超过MTU并且设置了IP分片标志,路径上某个设备不支持分片就会直接丢弃。遇到大包发送失败或者丢包集中在报文长度偏高的情况,优先检查MTU,可以用ping带-M do -s参数探测最大可用报文长度,确定路径MTU后,再调整应用层分包大小。

5.4 故障排查速查表

我把日常排查UDP可靠性问题的高频场景整理成一张表,遇到对应现象可以直接按表索骥:

现象大概率原因优先检查项
高带宽下持续丢包接收缓冲区太小或CPU处理不过来netstat -su、ethool Ring Buffer
小包高频场景严重丢包PPS达到网卡或CPU上限CPU软中断占用率、结合调整批收包
偶发乱序多路径路由或负载均衡抓包观察时间列、调整应用排序逻辑
UDP端口测试不通防火墙拦截、NAT映射失效两端防火墙规则、服务端应用ACK验证
大包发送失败或收不到MTU分片丢弃ping -M do 探测MTU、调整应用分包长度
收包顺序错乱接收端处理多线程竞争加序列号、单线程分发或按序号排序

5.5 不同场景下的选型建议

踩过这些坑之后,我的选型建议基本稳定下来了。业务对实时性要求极高、接收端处理速度较慢时,优先考虑轻量FEC加UDP普通发送,避免重传带来的等待。业务要求可靠但也要求低延迟的,优先考虑成熟协议,比如KCP或者QUIC,而不是自己从零写ARQ。业务对可靠性和顺序都有严格要求,同时延迟要求并不苛刻,那不如直接上TCP,因为TCP已经把这些核心机制实现得非常完善,别再重复造轮子了。

真正让我推荐UDP的场景,永远是“实时性优先、且明知会丢失部分数据也愿意接受”的业务。这些场景下的可靠性不是协议栈单方面给的,而是应用层通过精心设计的序号、ACK、重传和FEC策略去赢得的。

我个人在实际操作中最深的体会是,UDP可靠性问题解决得漂不漂亮,往往不取决于你会不会写重传逻辑,而取决于你对自己业务有多了解。有没有状态数据可以被新值覆盖?哪些事件必须不丢?网络错误发生的表现是丢包还是乱序?这些搞清楚之后,UDP就不是一个只能靠“碰运气”的协议,而是一个高速、灵活、可控的传输平台。下次再有人跟你说UDP不可靠,你可以把iperf3的测试报告甩给他,再告诉他:可靠性是可以自己设计的,关键看怎么用。

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

可用性“几个九”对照表:SLA停机时间到底怎么算?

做运维、做架构、写SLA,绕不开一个词:可用性。前阵子有朋友问我:"合同上写可用性99.99%,一年到底能挂多久?"我说52分钟出头。他又问:"那要是99.999%呢?"我说一年只能挂5分钟…

作者头像 李华
网站建设 2026/10/2 5:28:50

SAP订单状态管理:系统状态、用户状态与订单状态的区别与排障

做SAP这么多年,我发现自己被问得最多的,从来不是某个事务代码怎么配,而是“这个订单为什么不能收货了”。你打开CO03一看,系统状态明明白白写着REL(已释放),逻辑上应该一切正常。但业务人员就说…

作者头像 李华
网站建设 2026/10/2 5:28:50

工业Agent与实时控制:为什么现阶段是伪命题及务实落地路径

我入行工业自动化快十五年,从PLC、DCS一路做到边缘计算和工业AI,这几年眼看着“工业Agent”这个词被反复炒热。不少团队拿着大模型、强化学习框架,说要让AI智能体直接接管产线上的实时控制回路。每次听到这种方案,我的第一反应都是…

作者头像 李华
网站建设 2026/10/2 5:28:47

OCT眼底图像视网膜内囊肿液检测:YOLO数据集构建与训练实战

1. 项目背景:为什么盯上视网膜内囊肿液检测1.1 OCT影像在眼科诊断里的真实位置光学相干断层扫描(OCT)这个技术,说到底就是一种无创的“光学活检”。它利用低相干光干涉原理,把视网膜的层状结构扫出来,分辨率…

作者头像 李华
网站建设 2026/10/2 5:28:42

AI工程实战指南:从零搭建可落地的智能工单分类系统

做AI工程两年,从零开始摸爬滚打,踩过无数坑,今天把这些真实经验写出来。很多教程都在讲Python、TensorFlow、模型调参,却没人告诉你从“写业务代码”到“搞定一个AI项目落地”中间到底要经历什么。这篇文章不是教科书,…

作者头像 李华
网站建设 2026/10/2 5:28:38

Oracle 19c OPatch 升级指南:p6880880 替换与避坑

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

作者头像 李华