第33章:MongoDB WiredTiger 存储引擎——从 B-Tree 到缓存淘汰
1. 项目背景业务场景本地生活电商的 DBA 发现一个诡异的现象——高峰期 MongoDB 的磁盘 IOPS 飙升至 15000但数据写入量明明只有 5000 IOPS。多出来的 10000 IOPS 是哪里来的查看 WiredTiger 的统计指标后发现——pages evicted by application threads数量飙升意味着 WiredTiger 缓存放不下工作集频繁地把脏页刷到磁盘来腾空间给新数据而这些被淘汰的页很快又被查询读回来——造成了大量的读-淘汰-再读的磁盘抖动。还有一个隐藏成本——用了 snappy 压缩算法CPU 使用量比预期高了 30%而实际数据体积只节省了 20%。如果换用 zstd 压缩能多节省 15% 的空间但 CPU 再涨 20%——怎么权衡痛点WiredTiger 是 MongoDB 的内核但多数人对它只知其名。缓存怎么淘汰、B-Tree 怎么组织、Journal 怎么保证崩溃恢复、压缩算法怎么选——这些存储引擎的内部机制如果不理解就只能在 serverStatus 的数字面上看问题看不懂根因。2. 项目设计小胖看着 Grafana 里 WiredTiger 的 eviction 曲线大师缓存淘汰eviction是啥为什么缓存满了不直接报错而是要淘汰旧数据大师WiredTiger 的缓存就像你办公桌上的书架——桌面上只能放固定数量的书缓存大小当你需要看一本新书时如果桌面满了你得先拿走一本不常用的书淘汰。淘汰的策略是优先扔掉最久没用的LRU但如果那本书被修改过还没存回书库脏页需要先写回刷盘才能丢。技术映射WiredTiger 的缓存淘汰机制Clean Page 淘汰直接从内存中丢弃无 IO——因为数据在磁盘上已有最新副本。Dirty Page 淘汰必须先写入磁盘再丢弃产生 IO——因为内存中的版本比磁盘新。小胖那我看到的 10000 额外 IOPS 就是脏页淘汰产生的大师对。当你的工作集大于 WiredTiger 缓存时脏页淘汰成为主要磁盘 IO 来源——这叫工作集膨胀。监控的关键是wiredTiger.cache.tracked dirty bytes in the cache和pages evicted by application threads。如果 eviction rate 持续 0说明缓存不够用。小白B-Tree 呢MySQL 用 BTreeWiredTiger 也用 B-Tree有什么不同大师相同的都是平衡多路查找树——数据有序存储按页组织通过指针连接。WiredTiger 的 B-Tree 特色在于页内记录Row-store和列式存储Column-store两种格式——MongoDB 用 Row-store。页的大小默认为 4KB——比 InnoDB 的 16KB 小得多适应更多小文档的场景。磁盘镜像Disk Image和内存镜像In-Memory不同——磁盘上页是压缩的读到内存后才解压。所以 WiredTiger 的内存消耗 未压缩的页数量 × 4KB。技术映射WiredTiger 页 B-Tree 的节点。每个页在磁盘上是压缩存储的在内存中是未压缩的。缓存中同时存在 Clean只读不写和 Dirty已修改未刷盘两种页状态。小胖那 Checkpoint 是什么跟 MySQL 的 checkpoint 一样吗大师Checkpoint 是 WiredTiger 创建一致性快照的机制。每 60 秒默认或 Journal 文件达到 2GB 时触发一次。Checkpoint 做两件事——把所有脏页刷盘、更新元数据——使数据库进入一个新的一致性状态。崩溃恢复时WiredTiger 从最近的 Checkpoint 开始重放 Journal 日志恢复之后的操作。技术映射Checkpoint 是 WiredTiger 持久化的核心环节。Checkpoint 期间的 CPU 和磁盘 IO 会短时间内升高——这就是 MongoDB 每 60 秒会出现一个性能毛刺的根源。大师总结记住四组概念——B-Tree 是数据组织方式缓存淘汰是内存管理机制Journal 是崩溃恢复保障Checkpoint 是周期性持久化。四者构成 WiredTiger 的完整运转闭环。3. 项目实战3.1 环境准备使用 Docker MongoDB 并挂载自定义配置以调优 WiredTiger 参数。dockercompose-fmongodb-lab/docker-compose.ymlps3.2 分步实现步骤一查看 WiredTiger 核心指标// wt-stats.js —— 提取 WiredTiger 关键运行指标use adminvarsdb.serverStatus()print( WiredTiger 存储引擎指标 )// 1. 缓存varcaches.wiredTiger.cacheprint(缓存:)print( 配置最大: (cache[maximum bytes configured]/1024/1024/1024).toFixed(2) GB)print( 当前使用: (cache[bytes currently in the cache]/1024/1024/1024).toFixed(2) GB)print( 脏数据: ((cache[tracked dirty bytes in the cache]||0)/1024/1024).toFixed(0) MB)print( 使用率: (cache[bytes currently in the cache]/cache[maximum bytes configured]*100).toFixed(1)%)// 2. 页淘汰print(\n页淘汰:)print( 淘汰(应用线程): (cache[pages evicted by application threads]||0))print( 淘汰(后台线程): (cache[pages evicted by eviction server]||0))print( 因淘汰而刷脏: (cache[pages currently queued for eviction]||0))// 如果 application thread eviction 0缓存压力大——应用线程被迫介入淘汰// 3. Checkpointvarwts.wiredTigerprint(\nCheckpoint:)print( 耗时(ms): (wt[checkpoint][most recent time msecs]||N/A))print( 上次时间: (wt[checkpoint][last checkpoint]||N/A))// 4. 事务print(\n事务:)print( 事务冲突: (wt[transaction][transaction conflicts]||0))print( 写冲突: (wt[transaction][write conflicts]||0))// 5. 压缩print(\n压缩统计:)print( 页读入: (wt[block-manager][blocks read]||0))print( 页写出: (wt[block-manager][blocks written]||0))print( 预读: (wt[block-manager][blocks pre-loaded]||0))步骤二压测不同压缩算法目标对比 snappy、zlib、zstd 在不同场景下的表现。// compression-benchmark.js —— 压缩算法对比测试// 创建三个相同的集合分别用不同压缩算法// 注意集合的压缩算法在创建时指定通过 storageEngine 选项// 在 mongod 的配置文件中指定默认压缩// wiredTiger:// collectionConfig:// blockCompressor: snappy # 或 zlib / zstd / none// 或者在创建集合时指定db.createCollection(products_snappy,{storageEngine:{wiredTiger:{configString:block_compressorsnappy}}})db.createCollection(products_zstd,{storageEngine:{wiredTiger:{configString:block_compressorzstd}}})db.createCollection(products_none,{storageEngine:{wiredTiger:{configString:block_compressornone}}})// 插入相同的数据各 10 万条vartestDoc{name:压缩测试商品,description:这是描述.repeat(50)}for(varcollof[products_snappy,products_zstd,products_none]){vardocs[]for(vari0;i100000;i){docs.push(Object.assign({_id:i,price:Math.random()*1000},testDoc))if(docs.length5000){db[coll].insertMany(docs,{ordered:false})docs[]}}}// 对比三个集合的存储大小[products_snappy,products_zstd,products_none].forEach(function(c){varstatsdb[c].stats()print(c: (stats.storageSize/1024/1024).toFixed(1) MB (压缩(stats.storageSize/(stats.size||1)*100).toFixed(0)%))})// 结果解读// snappy: 压缩快CPU低、压缩率中等 → 适合高吞吐低延迟场景// zstd: 压缩率高CPU高、压缩慢 → 适合存储优先、读大于写的场景// none: 无压缩、CPU零开销 → 适合纯内存工作集数据远小于缓存大小步骤三缓存大小调优实验目标观察不同 cacheSizeGB 下的淘汰行为。# 在 mongod 启动参数中调整缓存大小# docker run ... mongo:8.0 --wiredTigerCacheSizeGB 1.0# 或修改 /etc/mongod.conf:# storage:# wiredTiger:# engineConfig:# cacheSizeGB: 1.0# 建议缓存在物理内存的 50%-70% 之间# 8GB 内存服务器cacheSizeGB 设为 4-5.5# 32GB 内存服务器cacheSizeGB 设为 16-22// 观察缓存压力指标是否改善varbeforedb.serverStatus().wiredTiger.cacheprint(调整前 eviction:,before[pages evicted by application threads])print(调整前 dirty:,(before[tracked dirty bytes in the cache]||0)/1024/1024,MB)// 调大 cacheSizeGB 后重新运行观察// 期望application thread eviction 降为 0dirty bytes 比例降低步骤四Checkpoint 行为观察与调优// checkpoint-monitor.js —— 观察 Checkpoint 的频率和影响// 查看 Checkpoint 配置varparamsdb.adminCommand({getParameter:*})print(Checkpoint 间隔(秒):,params[wiredTigerEngineRuntimeConfig].match(/checkpoint\(wait(\d)/)?.[1]||默认60)// checkpoint(wait60,log_size2GB) → 每 60 秒 或 Journal 达 2GB 时触发// 调优建议// 批量写入场景增大 log_size 以减少 checkpoint 频率// 低延迟场景减小 wait 以提高 checkpoint 频率减少单次脏页堆积量// 示例配置在 mongod.conf 中// storage:// wiredTiger:// engineConfig:// journalCompressor: snappy// checkpoint:// wait: 300 # 5 分钟// logSize: 4 # 4GB// 注意checkpoint 频率越低崩溃恢复时需要重放的 Journal 越多恢复时间越长步骤五JournalWAL机制理解// journal-explained.js —— Journal 是 WiredTiger 的预写日志// Journal 工作原理// 1. 写操作到达 → 先写入 Journal顺序写快// 2. 写入内存中的 B-Tree 页标记为 Dirty// 3. Checkpoint 周期性地将 Dirty 页刷入磁盘的数据文件// 4. 崩溃恢复时 → 读取最近的 Checkpoint 重放 Journal 恢复到崩溃前状态// 查看 Journal 统计varjournaldb.serverStatus().wiredTiger.logprint(Journal 写入(字节):,journal[total log buffer size bytes prepared]||N/A)print(Journal 同步:,journal[log sync operations]||N/A)// log sync operations 是 Journal 写入磁盘的次数// 频繁的 sync 意味着 writeConcern: jtrue 被大量使用// Journal 性能影响// - MongoDB 默认每 100ms 自动 sync 一次 Journal// - j:true 会在每次写入时立即 sync强制刷新到磁盘// - 对延迟敏感的写入接口避免使用 j:true除非需要绝对数据安全步骤六源码追踪——WiredTiger 缓存淘汰路径// 源码关键路径在 GDB 中追踪// 文件src/mongo/db/storage/wiredtiger/wiredtiger_record_store.cpp// 函数调用链// WiredTigerRecordStore::insertRecord()// → WT_SESSION::insert() // 调用 WT 底层 API// → __wt_btree_insert() // B-Tree 插入// → __wt_cache_eviction() // 如果需要腾空间 → 淘汰// 在 GDB 中打断点// (gdb) break __wt_cache_eviction// 观察淘汰被触发的时机和淘汰的页面数量3.3 完整代码清单文件用途mongodb-lab/scripts/ch33-wt-stats.jsWiredTiger 指标提取mongodb-lab/scripts/ch33-compression-bench.js压缩算法对比mongodb-lab/scripts/ch33-cache-tuning.js缓存调优实验mongodb-lab/scripts/ch33-checkpoint-monitor.jsCheckpoint 观察mongodb-lab/config/mongod-wt-tune.confWiredTiger 调优配置模板3.4 测试验证use admin// 1. 缓存指标可读varcachedb.serverStatus().wiredTiger.cacheprint(缓存最大:,cache[maximum bytes configured]?PASS:FAIL)print(淘汰数:,cache[pages evicted]?PASS:FAIL)// 2. 压缩算法对比有效varcolls[products_snappy,products_zstd,products_none]colls.forEach(function(c){varsdb[c].stats()print(c 存储:,s.storageSize,bytes,s.storageSize0?PASS:FAIL)})// 3. Checkpoint 信息可查varwtdb.serverStatus().wiredTigerprint(Checkpoint:,wt.checkpoint?PASS:FAIL)print(\n WiredTiger 验证完成 )4. 项目总结4.1 WiredTiger 核心概念速查概念作用关键参数/指标B-Tree 页数据存储的最小单元默认 4KB/页内存中未压缩缓存Cache工作内存中缓存的 B-Tree 页cacheSizeGB默认 RAM×50%-1GB脏页淘汰腾出内存给新数据pages evicted by application threadsCheckpoint周期性持久化一致性快照默认 60s 或 Journal 2GB 触发JournalWAL崩溃恢复的预写日志默认 100ms sync 一次压缩算法磁盘数据压缩snappy快/ zstd高压缩率/ none4.2 适用场景WiredTiger 调优适用写入密集型应用——cacheSizeGB 和工作集匹配度是关键。存储成本敏感——用 zstd 压缩最大化磁盘利用率。延迟敏感——调小 checkpoint 间隔避免毛刺用 snappy 降低 CPU。崩溃恢复时间敏感——调小 checkpoint 间隔减少 Journal 重放量。4.3 注意事项注意事项说明cacheSizeGB 不能等于物理内存留至少 1-2GB 给操作系统和文件系统缓存压缩算法创建后不可更改只能在新建集合时指定或通过 reshard 重建Checkpoint 期间性能毛刺大量脏页刷盘的 IO 高峰是正常的eviction 0 不一定坏事后台 eviction server 正常淘汰 应用线程被迫淘汰才是问题4.4 常见踩坑经验故障案例一cacheSizeGB 设太小导致写入卡死某团队在 64GB 服务器上设了cacheSizeGB: 2写入高峰期 95% 的请求在等待 eviction 完成——应用线程被迫停在__wt_cache_eviction中等待脏页刷盘P99 延迟 30 秒。解决cacheSizeGB调到 40GB内存的 60%eviction 几乎消失。故障案例二zlib 压缩导致 CPU 爆满某大数据团队对所有集合用了block_compressorzlib日均写入 5 亿条日志时 CPU 100%数据压缩率确实高30%但写入吞吐下降 70%。解决日志集合换用 snappyCPU 省 50%压缩率仍有 20%核心交易集合用 zstd。故障案例三透明大页未禁用导致内存碎片Linux 默认开启透明大页THPWiredTiger 的 4KB 页与 2MB 大页冲突——内存碎片严重、bytes currently in the cache远小于 cacheSizeGB 但 eviction 已发生。解决echo never /sys/kernel/mm/transparent_hugepage/enabledMongoDB 官方强烈建议禁用 THP。4.5 思考题如果 WiredTiger 缓存的 80% 都是脏页会有什么后果为什么 MongoDB 默认把脏页比例控制在 20% 以内writeConcern: {j:true}在 WiredTiger 层面实际做了什么操作为什么 j:true 比 w:1 慢 10-100 倍答案将在第 34 章末尾揭晓上一章思考题答案GDB 中打印std::vectorBSONObj用p vec.size()看大小p vec[0]看第一个元素。打印前 5 个用循环或 GDB Python 脚本。大 vector 可用p vec._M_impl._M_start5打印前 5 个元素的原始指针。MONGO_COMPILER_NOINLINE等宏用于精细控制编译器的内联和优化行为——在性能关键路径上过度内联会导致代码膨胀和指令缓存失效NOINLINE强制函数保持独立在调试模式下被内联的函数无法在 GDB 中打断点所以调试版本用--optoff禁用内联。延伸阅读与资源MongoDB 实战进阶与内核修炼python入门Rquests从菜鸟脚本到企业级SDK的网络实战圣经Milvus向量数据库实战修炼从 0 到 1精通向量检索与生产落地后端工程师的 AI 转型第一课Ollama 与私有化大模型实战10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析

相关新闻

三大财务报表还不会看?资产、利润、现金流的分析顺序一次讲清

三大财务报表还不会看?资产、利润、现金流的分析顺序一次讲清

很多企业每个月都会出资产负债表、利润表和现金流量表,但管理层拿到报表后,往往只关注收入、净利润和银行余额。 结果就是: 利润增长了,却不知道钱为什么没有回来; 现金增加了,却分不清是经营赚来的&…

2026/10/1 0:30:09 阅读更多 →
NanoBanana2:AI图像生成模型的技术解析与应用实践

NanoBanana2:AI图像生成模型的技术解析与应用实践

1. NanoBanana2技术解析与核心特性NanoBanana2作为Google DeepMind推出的新一代AI图像生成模型,代表了当前扩散模型技术的前沿水平。这个模型最显著的特点是将专业级图像生成能力与闪电般的响应速度完美结合,解决了传统高质量图像生成模型速度慢、迭代成…

2026/10/16 14:52:25 阅读更多 →
MCP+LLM+Agent架构:企业AI落地的关键技术解析

MCP+LLM+Agent架构:企业AI落地的关键技术解析

1. 项目概述:MCPLLMAgent技术如何重塑企业AI架构最近半年,我参与了三个不同行业的企业AI中台建设项目,发现一个共性现象:传统基于单一模型的AI解决方案越来越难以满足复杂业务需求。某零售客户的原对话系统在促销季因无法处理突发…

2026/10/13 22:56:12 阅读更多 →

最新新闻

EasyExcel百万级数据导出实战:从内存优化到异步架构

EasyExcel百万级数据导出实战:从内存优化到异步架构

做后端开发这些年,Excel导出算是我见过“翻车率”最高的功能之一。尤其是当数据量从几千行涨到几十万行时,很多系统就像被卡住喉咙一样,要么内存直接溢出,要么前端页面卡死,要么用户等了好几分钟最后下载下来一个损坏的…

2026/10/16 14:52:02 阅读更多 →
双指针算法核心逻辑与四种经典模型:从O(n²)到O(n)的优化实战

双指针算法核心逻辑与四种经典模型:从O(n²)到O(n)的优化实战

刷算法题这段时间,双指针应该是我用得最频繁的一类解法。它不算某个具体算法,更像是一套代码组织思路——在数组、链表、字符串这类线性结构上同时维护两个指针来遍历、比较或计算。许多一眼看去要两层循环暴力枚举的问题,换成双指针后一遍扫…

2026/10/16 14:52:02 阅读更多 →
三维空间建模与体积守恒:工艺品制作题深度解析

三维空间建模与体积守恒:工艺品制作题深度解析

1. 项目概述:一道被低估的“工艺建模题”到底在考什么很多人第一次看到洛谷P5729题——[深基5.例7]工艺品制作,下意识觉得这是道“模拟题”,顶多算个带点小逻辑的编程练习。但我在某高校信息学实训课带过三届学生,也参与过多个青少…

2026/10/16 14:52:02 阅读更多 →
Matio动态库免编译集成:读写.mat文件与VS/CMake配置实践

Matio动态库免编译集成:读写.mat文件与VS/CMake配置实践

简介:matio动态库是一套专门读写MATLAB MAT文件的开源C语言库,适合需要在未安装MATLAB的环境中处理.mat数据的科研人员、数据分析师或C/C开发者。资源自带hdf5与zlib依赖,解压后即可在Windows、Linux等平台使用,免去额外配置。包体…

2026/10/16 14:52:02 阅读更多 →
Flutter鸿蒙开发实战:下拉刷新与滚动条组件适配指南

Flutter鸿蒙开发实战:下拉刷新与滚动条组件适配指南

先聊一个这两年非常多开发者问我的问题:做鸿蒙上的应用,为什么还要选Flutter?我自己的答案是,如果团队手里已经有一套成熟的Flutter业务代码,那鸿蒙端直接跑这套代码,比从头用ArkTS重写一遍要省太多事了。再…

2026/10/16 14:52:02 阅读更多 →
Python异步编程中的信号量:用asyncio.Semaphore实现并发限流与协程保护

Python异步编程中的信号量:用asyncio.Semaphore实现并发限流与协程保护

如果你写过异步爬虫,大概率遇到过这样的情况:用asyncio.gather一次性丢出去几百个协程,结果目标网站开始返回超时错误、服务端直接断开连接,或者更惨——你的 IP 被临时封禁。这不是代码逻辑出错,而是你没给并发加闸门…

2026/10/16 14:50:59 阅读更多 →

日新闻

个人 AI Agent 平台接入 TaoToken:统一 Key 与 API 通道的配置大纲

个人 AI Agent 平台接入 TaoToken:统一 Key 与 API 通道的配置大纲

/* 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 0:00:05 阅读更多 →
信号与系统频域分析作业解题指南

信号与系统频域分析作业解题指南

1. 作业背景与核心要求解析信号与系统作为电子信息类专业的核心基础课,其作业设计往往直接关联课程的核心知识框架。2026年春季第六次作业的布置,通常对应着课程进展到频域分析的关键阶段。根据常规教学进度推测,本次作业可能涉及以下核心内容…

2026/10/16 0:00:05 阅读更多 →
Django MVT实战:从零构建图书借阅系统,彻底搞懂Model-View-Template分层

Django MVT实战:从零构建图书借阅系统,彻底搞懂Model-View-Template分层

1. 从一个"四不像"的练手项目说起如果你已经看完了Django的官方教程前几章,跟着敲完了投票应用,大概会有一种"我好像会了,又好像什么都不会"的割裂感。教程里每个知识点都是拆开的——这一章讲模型,下一章讲视…

2026/10/16 0:00:05 阅读更多 →

周新闻

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

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

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

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

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

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

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

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

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

2026/10/15 13:48:15 阅读更多 →

月新闻

我发现了一个新思路:用 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/15 0:14:36 阅读更多 →
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 阅读更多 →