FreeSWITCH高可用实战:keepalived双节点主备与VIP漂移
简介这是一份面向运维工程师与VoIP系统架构人员的Freeswitch高可用方案实践资料围绕Keepalived构建主备切换机制解决通信服务单点故障导致的中断问题。压缩包共4个文件约38KB包含2个Shell脚本、1份安装手册文本和1张Visio逻辑图分别用于健康检查与故障切换辅助、Keepalived部署配置说明以及主备切换全流程的可视化梳理。资源重点呈现主备节点配置、虚拟IP绑定、自定义健康检查脚本触发故障转移等核心设计并配有逻辑图帮助理解从检测到接管的完整链路。目前已有669人学习下载适合需要快速搭建Freeswitch HA环境、理清切换逻辑并落地部署的技术人员参考。1. 从一次凌晨三点的告警说起FreeSWITCH 高可用到底要解决什么凌晨三点监控群里弹出一条告警核心语音节点进程还在但 SIP 注册全部超时呼叫接通率掉到零。值班同事重启服务花了六分钟这六分钟里所有外呼、坐席签入、IVR 全部中断。事后复盘发现问题不在 FreeSWITCH 本身而在于这套系统从一开始就是单点——没有 VIP 漂移没有备节点接管连健康检查都是靠人肉盯监控。这件事之后我把 FreeSWITCH 的 HA 方案重新做了一遍核心目标很明确让主节点故障时备用节点能在秒级接管虚拟 IP 和信令端口业务侧几乎无感知。这套方案适合正在做语音平台毕设、呼叫中心课设或者需要给现有 FreeSWITCH 集群补高可用能力的同学和工程师。它不追求大厂级别的多活架构而是用 keepalived 双节点主备把「单点故障」这个最致命的问题先解决掉方案完整、可复现、能写进设计文档也能直接照着搭环境。2. 需求拆解与技术选型为什么是 keepalived 而不是其他方案2.1 先明确 FreeSWITCH 的故障边界做 HA 之前得先想清楚到底要防哪些故障。FreeSWITCH 的故障大致分三层。第一层是进程级比如 sofia 模块崩溃、内存泄漏导致进程被 OOM kill这类问题重启进程就能恢复。第二层是节点级比如整台机器宕机、网卡故障、系统盘写满这时候进程重启没用必须换节点。第三层是网络级比如交换机抖动、上行链路中断这种最麻烦因为节点本身活着但对外不可达。keepalived 能覆盖的是第二层和部分第三层。它通过 VRRP 协议在多个节点之间选举出一个 MASTER由 MASTER 持有虚拟 IPVIP所有 SIP 信令和 RTP 媒体都往这个 VIP 上送。MASTER 挂了BACKUP 在几百毫秒到几秒内检测到并抢占 VIPFreeSWITCH 继续在备节点上服务。这里有个关键认知keepalived 管的是「IP 层面的接管」它不负责 FreeSWITCH 进程本身的重启所以健康检查脚本必须自己写把进程状态、端口监听、甚至 SIP 响应都纳入检查范围。2.2 选型对比keepalived、CorosyncPacemaker 还是 DNS 轮询常见的几种 HA 思路放在一起比一下选型逻辑就清楚了。方案切换速度配置复杂度对 FreeSWITCH 的侵入性适用规模keepalived VRRP秒级低无纯网络层双节点主备Corosync Pacemaker秒级高需要资源代理多节点集群DNS 轮询分钟级受 TTL 影响低无无状态服务应用层双写注册取决于实现很高强侵入超大规模DNS 轮询的问题在于 TTL 缓存客户端可能几分钟都还指着旧 IP对语音这种实时业务不可接受。Corosync Pacemaker 功能强但配置和维护成本对中小规模项目来说过重而且 FreeSWITCH 的资源代理脚本要自己写坑不少。keepalived 的优势是轻、稳、文档多双节点主备场景下几乎是最优解。我一般会建议节点数不超过四个、以主备或一主多备为目标的项目直接用 keepalived别上 Pacemaker 给自己找麻烦。2.3 网络与端口规划VIP 方案要落地网络规划必须先定。假设主节点 IP 是 192.168.10.11备节点是 192.168.10.12VIP 用 192.168.10.100。FreeSWITCH 默认的 SIP 端口 5060UDP/TCP、TLS 端口 5061、RTP 端口段 16384-32768这些都要跟着 VIP 走。也就是说FreeSWITCH 的 sofia profile 里要绑定 VIP 而不是物理网卡 IP或者绑定 0.0.0.0 让所有地址都监听再由 keepalived 决定 VIP 在谁身上。这里有个容易忽略的点RTP 媒体流。SIP 信令走 VIP 没问题但 RTP 是端到端协商的如果 FreeSWITCH 在 SIP 里通告的是物理 IP切换后媒体路径就断了。所以 profile 里的ext-sip-ip和ext-rtp-ip必须配置成 VIP或者用auto配合正确的网络环境。这一步没做对会出现「信令通了但没声音」的经典翻车现场。3. 总体架构设计双节点主备与 VIP 漂移的职责划分3.1 架构分层与数据流整套架构分三层。最底层是网络层由 keepalived 的 VRRP 实例维护 VIP 归属MASTER 节点周期性发送 VRRP 通告BACKUP 节点监听超时未收到就触发选举。中间层是服务层FreeSWITCH 实例绑定 VIP 或 0.0.0.0配合健康检查脚本向 keepalived 汇报自身状态。最上层是业务层SIP 客户端、网关、坐席话机统一配置 VIP 作为注册和呼叫地址它们不关心背后是哪台机器。数据流是这样的话机向 VIP:5060 发 REGISTERMASTER 上的 FreeSWITCH 处理并回 200 OK呼叫建立时SIP 信令走 VIPRTP 媒体按 SDP 协商的地址传输。MASTER 故障时keepalived 把 VIP 漂到 BACKUP话机下一轮 REGISTER 或呼叫请求就落到新 MASTER 上。已经建立的呼叫会断这是主备方案的固有代价要保通话得做媒体中继或会话复制那是另一个量级的工程。3.2 健康检查脚本的设计逻辑keepalived 自带的MISC_CHECK或VRRP_SCRIPT可以调用外部脚本脚本返回 0 表示健康非 0 表示异常。检查脚本要分层次写不能只看进程在不在。#!/bin/bash # freeswitch_health_check.sh # 返回 0 表示健康非 0 表示异常触发 keepalived 降权或切换 # 第一层进程是否存在 if ! pgrep -x freeswitch /dev/null; then echo FreeSWITCH process not found exit 1 fi # 第二层SIP 端口是否监听 if ! ss -lun | grep -q :5060; then echo SIP port 5060 not listening exit 1 fi # 第三层fs_cli 是否能正常响应 if ! fs_cli -x status 2/dev/null | grep -q UP; then echo FreeSWITCH status check failed exit 1 fi # 第四层检查 sofia profile 是否处于 RUNNING if ! fs_cli -x sofia status 2/dev/null | grep -q RUNNING; then echo Sofia profile not running exit 1 fi exit 0这个脚本的关键在于分层递进。进程在但端口没监听说明启动卡住了端口在但 fs_cli 无响应说明事件系统挂了fs_cli 正常但 sofia profile 没起来说明 SIP 栈有问题。每一层都对应不同的故障模式脚本里用echo输出原因方便在/var/log/messages里定位。脚本执行频率建议 2 到 3 秒一次超时时间设 5 秒这样故障检测能在 10 秒内完成。3.3 keepalived 配置的核心参数keepalived 的配置文件分全局段和 VRRP 实例段。全局段定义通知机制和脚本路径实例段定义 VIP、优先级、认证和检查脚本。# /etc/keepalived/keepalived.conf global_defs { router_id FS_HA_NODE # 节点标识主备要不同 script_user root # 执行检查脚本的用户 enable_script_security # 启用脚本安全检查 } vrrp_script chk_freeswitch { script /etc/keepalived/freeswitch_health_check.sh interval 3 # 每 3 秒检查一次 timeout 5 # 脚本超时 5 秒 fall 2 # 连续失败 2 次才判定异常 rise 2 # 连续成功 2 次才恢复 weight -30 # 检查失败时优先级减 30 } vrrp_instance VI_FS { state MASTER # 备节点改为 BACKUP interface eth0 # 物理网卡名按实际改 virtual_router_id 51 # 主备必须一致 priority 100 # 主节点 100备节点 90 advert_int 1 # VRRP 通告间隔 1 秒 authentication { auth_type PASS auth_pass FsHa2024 # 主备密码一致 } virtual_ipaddress { 192.168.10.100/24 dev eth0 } track_script { chk_freeswitch } notify_master /etc/keepalived/notify.sh master notify_backup /etc/keepalived/notify.sh backup notify_fault /etc/keepalived/notify.sh fault }priority是选举的核心主节点 100、备节点 90差值要大于weight的绝对值否则健康检查失败降权后可能不足以触发切换。fall和rise控制抖动设太小会频繁切换设太大切换迟钝2 到 3 是比较稳的区间。notify_master等脚本用来在状态变化时做额外动作比如发告警、记录日志、或者触发 FreeSWITCH 重新加载配置。4. 落地实现从环境准备到主备切换验证4.1 双节点环境准备与 FreeSWITCH 配置调整两台机器装好同版本 FreeSWITCH配置尽量保持一致。关键改动在 sofia profile 里把 SIP 和 RTP 的对外地址指向 VIP。!-- /etc/freeswitch/sip_profiles/external.xml 片段 -- param nameext-sip-ip value192.168.10.100/ param nameext-rtp-ip value192.168.10.100/ param namesip-port value5060/ param namertp-start-port value16384/ param namertp-end-port value32768/ext-sip-ip决定 SIP 消息里 Via 和 Contact 头带哪个地址ext-rtp-ip决定 SDP 里通告的媒体地址。两个都设成 VIP切换后对端不需要改任何配置。如果内网外网都有还要配ext-sip-ip和internal-sip-ip分别对应不同 profile。改完用fs_cli -x reloadxml和fs_cli -x sofia profile external restart生效。4.2 主备切换的完整验证流程搭好之后必须做切换测试不能等真出事。验证分四步。第一步确认初始状态。在主节点执行ip addr show eth0应该能看到 VIP 192.168.10.100。在备节点执行同样命令看不到 VIP。用fs_cli -x sofia status确认 profile 是 RUNNING。第二步模拟进程故障。在主节点执行systemctl stop freeswitch观察 keepalived 日志tail -f /var/log/messages | grep Keepalived。正常情况下健康检查连续失败两次后主节点优先级降到 70备节点 90 抢占VIP 漂移。整个过程应该在 10 秒内完成。第三步验证业务。用 SIP 客户端向 VIP 注册看是否成功。拨一个测试号码确认信令和媒体都正常。这时候ip addr在备节点上应该能看到 VIP。第四步恢复主节点。重新启动 FreeSWITCH健康检查通过后优先级恢复但默认不会自动抢回 VIP因为 keepalived 的nopreempt没配的话会抢占。如果希望主节点恢复后自动接管保持默认如果希望稳定不频繁切换在备节点配置里加nopreempt。4.3 状态通知与日志排查notify脚本是排查问题的关键。每次状态变化都记一条日志包含时间、节点、新旧状态。#!/bin/bash # /etc/keepalived/notify.sh STATE$1 LOG/var/log/keepalived_state.log echo $(date %Y-%m-%d %H:%M:%S) - Node $(hostname) transitioned to $STATE $LOG # 切换到 MASTER 时可以触发 FreeSWITCH 重载 if [ $STATE master ]; then fs_cli -x reloadxml 2/dev/null fs_cli -x sofia profile external restart 2/dev/null fi这个脚本在 MASTER 状态时重载 FreeSWITCH 配置确保 SIP 地址绑定正确。日志文件要配合 logrotate 做轮转不然时间长了会撑满磁盘。排查切换问题时先看/var/log/messages里 keepalived 的 VRRP 状态变化再看keepalived_state.log确认 notify 脚本有没有执行最后看 FreeSWITCH 的fs_cli -x sofia status确认服务层状态。5. 避坑与常见问题那些让我熬夜的细节5.1 VIP 漂移了但 SIP 注册全部失败现象是 keepalived 显示切换成功VIP 已经在备节点上但话机注册全部超时。原因通常是 FreeSWITCH 的 sofia profile 还绑定在旧 IP 上或者ext-sip-ip没配成 VIP导致 SIP 响应里的 Contact 地址还是物理 IP客户端收到后往错误地址发后续请求。解决办法是检查 profile 配置确保ext-sip-ip和ext-rtp-ip都是 VIP切换后执行sofia profile external restart让绑定生效。我一般会在 notify 脚本里强制重启 profile避免手动遗漏。5.2 健康检查脚本误判导致频繁切换现象是 keepalived 日志里频繁出现 MASTER/BACKUP 切换业务时好时坏。原因是检查脚本太敏感比如fs_cli偶尔响应慢就返回失败或者fall设成 1一次失败就切换。解决办法是把fall调到 2 或 3脚本里加超时控制fs_cli调用用timeout 3包一层。另外检查脚本本身要轻量别在里面做复杂查询否则脚本执行时间超过interval会互相干扰。5.3 双主脑裂两台机器同时持有 VIP现象是两台机器都认为自己是 MASTERVIP 冲突网络时通时断。原因是 VRRP 通告被防火墙拦截或者virtual_router_id配错导致两个实例互相看不见。解决办法是确认防火墙放行 VRRP 协议IP 协议号 112和组播地址 224.0.0.18检查主备的virtual_router_id和auth_pass完全一致。如果网络环境不支持组播可以用unicast_peer配置单播通告。5.4 RTP 媒体单通或无声现象是呼叫能建立但一方或双方听不到声音。原因是 SDP 里通告的 RTP 地址不是 VIP切换后媒体流还往旧地址发。解决办法是确认ext-rtp-ip配置正确并且 FreeSWITCH 的acl和nat相关配置允许 VIP 网段。如果前面有 NAT还要配ext-rtp-ip为公网 VIP 并开启apply-nat-acl。这个坑最隐蔽因为信令看起来完全正常。5.5 切换后已建立的通话中断现象是切换瞬间所有在通话的呼叫全部断线。这是主备方案的固有特性因为会话状态没有同步。如果业务要求切换不断话需要做会话复制或媒体中继成本很高。通常的做法是接受这个代价在业务层做重拨引导或者把切换时间压缩到最短减少影响面。我一般会在方案文档里明确写清楚这个限制避免验收时被质疑。6. 验收与答辩怎么证明这套 HA 方案真的可靠验收这套方案核心就一句话用数据证明切换够快、业务够稳。我一般会准备一张验收表把测试项、预期结果、实测结果列清楚答辩时直接展示。测试项操作预期结果判定标准初始状态主备分别执行 ip addr主有 VIP备无符合进程故障切换主节点 stop freeswitchVIP 漂到备节点10 秒内完成端口故障切换主节点封禁 5060 端口VIP 漂到备节点15 秒内完成业务连续性切换后 SIP 注册注册成功3 次内成功呼叫验证切换后拨测信令媒体正常接通率 100%恢复回切主节点恢复VIP 按策略回切符合配置测试的时候有个技巧用tcpdump抓 VRRP 报文确认通告间隔和优先级字段符合预期。tcpdump -i eth0 vrrp -vv能看到 VRRP 版本、优先级、通告间隔。答辩时如果老师问「你怎么知道切换真的发生了」抓包记录比日志更有说服力。另一个重点是故障注入的多样性。别只测 stop 进程还要测 kill -9、断网、磁盘写满、CPU 打满这些场景。我一般会写一个测试脚本把常见故障模拟一遍记录每次的切换时间和业务影响。这些数据整理成表格就是方案文档里最有价值的部分。还有一点答辩时一定会被问到「为什么不做过多的节点」。这时候要能说清楚双节点主备的切换逻辑最简单脑裂风险最低维护成本最小。三节点以上要考虑投票机制、数据同步、会话一致性复杂度指数上升。对于课程设计或中小规模项目双节点是性价比最高的选择。把选型理由讲清楚比堆功能更能体现设计能力。从那以后我每次做 HA 方案都会先把故障场景列全再写健康检查脚本最后才配 keepalived。顺序反了后面全是补丁。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

多智能体LLM系统重塑交易决策:TradingAgents-CN架构与实战解析

多智能体LLM系统重塑交易决策:TradingAgents-CN架构与实战解析

去年我在技术圈第一次看到TradingAgents-CN这个项目时,第一反应是:这不就是把几个大模型凑在一起开视频会议吗?直到自己动手跑了一遍才发现,这种判断过于粗暴。交易这个场景,天然是多因素交织的决策场——技术面、基本…

2026/10/18 10:43:50 阅读更多 →
大模型微调实战:基于DeepSeek的农业智能问答系统落地

大模型微调实战:基于DeepSeek的农业智能问答系统落地

真正把大模型塞进农业场景,比想象中要麻烦得多。去年帮一家农业信息化公司做技术方案,客户的诉求很直白:把散落在各级农技站、老专家脑子里的种植经验,变成一个农户随时能问、能听得懂的智能问答工具。做过这类项目的人应该深有体…

2026/10/18 10:43:50 阅读更多 →
2025年Safari与iOS版本检测新思路:从UA正则到能力特征矩阵

2025年Safari与iOS版本检测新思路:从UA正则到能力特征矩阵

还记得前几年做移动端适配的时候,判断 Safari 和 iOS 版本最省事的办法就是正则解析navigator.userAgent:看到iPhone OS 15_4就知道是 iOS 15.4,看到Version/16.0 Safari/604.1就知道是 Safari 16。这套玩法在 2025 年已经越来越不顶用了&…

2026/10/18 10:43:50 阅读更多 →

最新新闻

YOLO11深度学习果树害虫识别系统:从目标检测原理到毕设实战全解析

YOLO11深度学习果树害虫识别系统:从目标检测原理到毕设实战全解析

果树害虫识别这个毕设方向,这几年一直很火,但它跟常见的“人脸识别”“车牌识别”还不一样,它有非常实在的农业场景:果园里虫害爆发往往是从局部开始的,靠人工巡园很难及时发现,一旦扩散,损失就…

2026/10/18 11:30:05 阅读更多 →
基于Python的3D-CT肺结节检测算法复现:从数据到FROC的完整实战

基于Python的3D-CT肺结节检测算法复现:从数据到FROC的完整实战

简介:这是一套面向计算机、人工智能、自动化等专业学生与从业者的3D-CT影像肺结节检测项目源码,源自个人毕业设计,答辩评审分达98分,代码经调试测试可稳定运行,适合作为期末大作业、课程设计或毕业设计的参考范例&…

2026/10/18 11:30:05 阅读更多 →
msvcr120.dll丢失怎么办?VC++运行库安装与dll修复指南

msvcr120.dll丢失怎么办?VC++运行库安装与dll修复指南

你正开着某个老软件或者游戏,突然蹦出来一个“无法启动此程序,因为计算机中丢失 MSVCR120.dll”的提示,紧接着程序闪退,桌面上只剩一个报错框。这种场景我这些年处理过的电脑里至少遇到上百次了。对大多数普通用户来说&#xff0c…

2026/10/18 11:30:05 阅读更多 →
AIS解码全链路解析:从NMEA句子到船位经纬度的协议还原技术

AIS解码全链路解析:从NMEA句子到船位经纬度的协议还原技术

简介:面向AIS(船舶自动识别系统)开发与学习的小型资料包,适合需要快速理解VHF信号接收、ITU-R M.1371报文解码以及AIS数据解析的工程师或爱好者。资源共3个文件,包含2个txt文本和1个cpp源文件,压缩包仅5KB&…

2026/10/18 11:30:05 阅读更多 →
前端人如何用React思维理解并开发AI Agent

前端人如何用React思维理解并开发AI Agent

1. 这不是调参,也不是写 prompt:前端人第一次接触 Agent 的真实困惑“Agent 开发不是模型训练”——这句话我第一次在某次内部技术分享会上听到时,下意识摸了摸键盘,心想:那它到底是什么?当时我刚用 React …

2026/10/18 11:30:05 阅读更多 →
OpenMontage实测:5.4万Star的开源视频剪辑工具,真实工作流表现如何

OpenMontage实测:5.4万Star的开源视频剪辑工具,真实工作流表现如何

最近刷开源社区的动态,眼尖的人应该都看到了OpenMontage挂在趋势榜上的那个数字:5.4万Star。对于一个视频剪辑类工具来说,这个量级放在任何商业软件身上都算现象级,更别说它还是个打着开源旗号的项目。我的第一反应其实有点复杂—…

2026/10/18 11:29:05 阅读更多 →

日新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/18 0:00:55 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/18 0:00:55 阅读更多 →

月新闻

我发现了一个新思路:用 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/18 0:55:02 阅读更多 →
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/17 17:22:31 阅读更多 →
黑夜航拍船只数据集训练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 阅读更多 →