Spark打包提交避坑指南:从ClassNotFoundException到集群部署
你有没有过这种经历在 IDE 里点一下运行Spark 程序跑得顺顺利利RDD 输出整整齐齐可一旦打成 jar 包用 spark-submit 提交到集群立刻就抛出一堆 ClassNotFoundException或者任务卡在 SUBMITTED 状态半天没动静。我最早接手 Spark 开发时在这个环节上反复折腾了三四个版本最后才发现问题根本不在于代码逻辑而是打包和提交方式没弄对。这篇内容解决的场景很具体本地运行测试、jar 包本地模式提交、jar 包集群模式提交。无论刚入门 Spark 写 WordCount 的新手还是已经写完一个分析任务、准备把它丢到测试环境的开发者只要能耐心跟着走一遍基本能把“代码能跑”和“真正能交付”之间的这段路彻底打通。我不会堆概念只讲我在实际提交过程中用过的命令、踩过的坑以及出问题后一套可复用的排查思路。1. 打包这件事先把“解决什么问题”想清楚很多人一上来就敲mvn package然后拿着生成的 jar 去提交以为万事大吉。但打包这件事本质上是在回答一个很现实的问题你分发出去的代码到底能不能在别人的 JVM 里跑起来1.1 从一段最常见的报错说起ClassNotFoundException 的真相我接手过好几个临时救火的任务第一屏日志几乎都是同一个东西Exception in thread main java.lang.ClassNotFoundException: org.apache.spark.sql.SparkSession或者更隐蔽一点的java.lang.NoSuchMethodError: scala.collection.immutable.List.map很多同事看到这个第一反应是“集群环境坏了”“Spark 没装好”其实 90% 的情况下都猜错了根源。先理解 Spark 任务的运行模型driver 负责解析代码、切分任务executor 负责真正执行计算。这两个角色通常是完全独立的 JVM甚至在集群模式下可能分布在不同的物理机器上。你在本地 IDE 里点运行IDE 会把你整个项目的 classpath 全部塞进同一个 JVM所以啥都能找到。但提交 jar 包时Spark 只会把那个 jar 分发到各个节点JVM 不会自动带上你 pom 文件里声明的那些第三方依赖。我把这比作“把一份菜谱快递给厨师但没把食材一起寄过去”。代码的类就是菜谱依赖 jar 就是食材缺了哪一样菜都做不出来。1.2 薄 jar 和 fat jar 的依赖边界所以打包时必须先搞清楚你到底要打哪种 jar类型包含哪些内容典型使用场景主要缺点薄 jarthin jar只有你自己的 class 文件集群已内置 Spark 及第三方依赖对你线上环境要求极高几乎必然缺依赖胖 jarfat jar自己代码 第三方依赖一并打包独立提交到 YARN、Kubernetes 等集群文件体积大可能存在同名类冲突新手最容易犯的错就是把 Spark 相关的依赖也打进 jar 里。这些依赖集群节点上本来就有你硬塞进去反而容易和集群内置版本冲突搞出NoSuchMethodError这种连报错都找不准的问题。正确做法是把 Spark 相关依赖标记为provided只在编译、测试时用打包时不打进去。第三方库比如连接池、JSON 解析库、业务依赖才需要打进 jar 里。1.3 判断依赖要不要打进去的三个标准我不太喜欢背配置所以习惯直接用三个问题来判断这个依赖是不是 Spark 自带的如果是说明集群会提供别打进 jar。这个依赖是不是代码运行所必需的、但集群相对不一定有如果是最好打进 jar。项目里有没有同一个依赖的多个版本如果有打包时做版本统一或重定位否则就算打进去也会互踩。如果项目用的是 Maven常见配置长这样核心就是scopeprovideddependency groupIdorg.apache.spark/groupId artifactIdspark-sql_2.12/artifactId version3.3.1/version scopeprovided/scope /dependency打包时用 shade 插件把所有非 provided 依赖合并成最终的可执行 jar。这样产出的就是一个结构相对完整的 fat jar。2. 第一关本地运行测试的正确打开方式本地测试看起来很基础但我真见过不少人在这一步卡了一两天。核心原因是他们把所有希望都押在了“在 IDE 里跑通”而不是“用提交命令把 jar 跑通”。这两件事完全不一样。2.1 本地模式帮你验证什么Spark 的本地模式其实很强大。它在单个 JVM 里同时跑 driver 和 executor线程模拟分布式执行。对于调试数据格式、SQL 结果、UDF 逻辑效率极高。常用的写法是val spark SparkSession.builder() .appName(LocalTest) .master(local[*]) .getOrCreate()local[*]表示用本机所有 CPU 核心来跑如果想要固定并发度也可以写成local[2]或local[4]。但本地模式能验证的东西有一个边界它验证的是算法和逻辑不是部署和分发。所谓“本地能跑”只代表你的数据处理逻辑正确不代表你的依赖边界正确、资源参数合理。2.2 本地测试容易翻车的环境差异本地测试最常见的坑在于路径体系。本地模式下spark.read.text(/home/user/input.txt)是直接读本机文件但同样代码提交到集群driver 和 executor 不一定在同一台机器这个路径对 executor 来说很可能不存在。所以本地测试时最好刻意养成写 URL 前缀的习惯本地用file://集群内用hdfs://或对象存储路径。还有个容易被忽略的点是文件权限。本地你用自己的账号跑文件随便读写到了集群执行用户可能是另一个账号目录权限不对任务会在PartitionDiscovery或者FileSystem阶段卡很久然后抛权限异常。所以本地测试时我一般会把输入输出目录权限设置成755/644这类宽松一点的提前模拟出集群里的最低要求。2.3 从“在 IDE 里跑”切换到“用打好的 jar 跑”这里是我最想强调的本地测试的最终形态一定是在命令行里用 spark-submit 把打好的 jar 提交起来跑一遍而不是在 IDE 里右键运行。原因很简单IDE 运行会把开发环境的整个 classpath 喂进去掩盖掉很多打包问题。而命令行提交时你只有一个 jar系统读到多少就是多少缺了依赖立刻现原形。这一步虽然看起来多此一举但能早点暴露后面集群提交才可能出现的问题代价极低。3. 打好的 jar 怎么在本地模式下提交一条命令背后发生了什么本地模式下提交 jar实际上是我在整个交付流程里最常用的快速验证手段。它比 IDE 运行更接近真实部署场景又比集群提交快得多、好调试得多。3.1 一行命令把 jar 提交到本地模式跑起来假设你打好了user-job-1.0.jar主类全名是com.example.demo.WordCountJob典型的本地提交命令长这样spark-submit \ --class com.example.demo.WordCountJob \ --master local[4] \ --driver-memory 2g \ target/user-job-1.0.jar \ file:///home/user/data/input.txt \ file:///home/user/data/output这段命令的含义是让 Spark 以本地 4 个并发线程的规模启动一个应用driver 内存在当前 JVM 中最多分配 2G然后把user-job-1.0.jar作为唯一应用包传给主类的main方法。spark-submit后面的两个路径会被当作普通参数传给程序而不是 Spark 的配置项这一点很多人一开始会搞混。这条命令背后Spark 会在你本地启动一个 JVM 进程driver 跑起来后又用线程模拟出若干个 executor所以stdout里能直接看到 executor 的日志。好处是出错了你直接看屏幕就行不用去集群翻聚合日志。3.2 本地提交能暴露哪些 IDE 里看不见的问题我用这种方式抓到过至少三类问题第一类是资源路径写死。比如代码里写死了/tmp/data.jsonIDE 跑的时候因为当前工作目录是项目根目录凑巧能读到但命令行提交时工作目录和 IDE 里的并不一定一致路径解析直接失败。第二类是序列化类没注册。本地模式下某些 RDD 操作内置的序列化器能兜底命令行提交后虽然还在本地但配置环境更干净NotSerializableException反而更容易露出来。第三类是fat jar 里的同名依赖冲突。IDE 启动时 classpath 顺序是 IDE 控制的命令行提交时则是 jar 内部的加载顺序可能之前一直被 IDE 藏住的重复类突然冒出来。所以千万别小看这一步它是所有“预演”里最接近真实部署、又最低成本的一环。3.3 我常用的本地提交验证清单每次在本地提交 jar 验证时我都会过一个固定清单应用退出码是否为 0日志中是否有 ERROR 或 Exception。输出目录是否生成文件条数是否符合预期。是否出现依赖类相关报错比如ClassNotFound、NoSuchMethod。性能是否明显异常比如数据量很小却执行了很久。连续跑第二遍是否仍然稳定排除临时文件残留造成的偶发问题。如果清单全绿我才会放心把同一个 jar 提交到集群。4. 集群模式提交jar 包要过几道门槛到了集群提交这一步才是真正考验“工程交付”能力的地方。很多代码本地能跑上集群就“薛定谔地跑”主要原因是集群模式下的进程分布、资源约束、日志位置和本地完全不是一个概念。4.1 client 与 cluster 两种部署模式到底是哪里不一样集群提交的第一件事是选--deploy-mode。最常用的两种client和cluster。模式driver 运行在哪里日志在哪里适合场景client提交命令所在的那台机器基本直接输出到提交终端调试、小任务、交互式分析cluster集群内部通常是 ApplicationMaster 容器需要到 YARN 日志聚合或 Spark History Server 去翻生产任务、定时调度、需要漂移的场景我的经验是调试用 client生产用 cluster。client 模式下 driver 在你眼前跑异常信息一目了然适合调试cluster 模式下 driver 搬进集群崩溃了会自动根据策略尝试重试适用生产。如果生产上用了 client 模式提交任务的机器一断网或重启driver 也跟着没了任务直接中断这在调度场景下极其危险。4.2 一份可以直接抄作业的集群提交命令以 YARN 集群为例一个典型的 cluster 模式提交大概长这样spark-submit \ --class com.example.demo.WordCountJob \ --master yarn \ --deploy-mode cluster \ --driver-memory 2g \ --executor-memory 4g \ --executor-cores 2 \ --num-executors 20 \ --conf spark.yarn.queuedefault \ --conf spark.serializerorg.apache.spark.serializer.KryoSerializer \ hdfs:///user/me/jars/user-job-1.0.jar \ hdfs:///user/me/data/input \ hdfs:///user/me/data/output我先解释几个容易被误解的参数--num-executors申请多少个 executor 容器。别拍脑袋写先看队列资源配额比如队列能用的总内存是 100G你每个 executor 4G那最多约 25 个但还要算上 driver 和系统 overhead。--executor-memory单个 executor 的内存。Spark 实际向 YARN 申请的内存会在这个值之上额外加一部分 overhead默认约为max(384MB, 10% × executor-memory)。如果你的值卡着资源上限很容易提交后一直 ACK 但是起不来。--conf spark.yarn.queue指定 YARN 队列不同团队通常有不同的队列乱提交可能会被权限拒绝或者抢不到资源。另外注意jar 包路径我写成了hdfs:///user/me/jars/user-job-1.0.jar。生产环境这样最稳因为所有节点都能从 HDFS 读到该包如果只写本地路径file:///...只有提交机自己能读到executor 在其他机器就会拿不到代码。实际原理是 YARN 会把 HDFS 上的 jar 分发到每个容器的工作目录所以共享存储路径非常关键。4.3 提交后怎么判断任务真的启动没很多新手提交后盯着屏幕发呆不知道任务到底有没有起来。我的通用判断步骤是先看命令行反馈。如果提交后打印了applicationId说明 YARN 已经接收了应用。用yarn application -status application_xxx查看状态关键看tracking URL和state。打开tracking URL指向的 Spark Web UI看有没有出现 job、stage、executor 信息。看到RUNNING状态但没有任何 job说明应用还在初始化不一定是卡住。如果状态一直是ACCEPTED多半是资源没分配到去查队列余量和各节点资源使用情况。这一整套动作下来基本能从黑暗的日志丛林里找到一条清晰的路径。5. 我踩过的坑打包与提交阶段最容易翻车的几个环节最后这部分不写教程式流程只记录我真实踩过、帮别人擦过屁股的几个高频坑每个都附上排查链路。5.1 本地能跑、集群一上就 ClassNotFound 的排查全链路有一次某任务的 jar 在本地模式提交一切正常cluster 模式一下去就报ClassNotFoundException: com.example.common.MyHelper。我当时没有直接去调配置而是按这条链路排先确认出的 jar 里有没有这个类。命令jar tf user-job-1.0.jar | grep MyHelper。如果连包都没进那就是打包时漏了依赖或者该依赖被provided挡住了。如果 jar 里确实有再用unzip -l或者解压出来看目录结构检查是不是有多层目录嵌套问题比如 class 路径从com/example变成BOOT-INF/classes/com/example。这种情况常见于用 Spring Boot 插件打的包Spark 认不出来。再查 classpath 差异。用mvn dependency:tree看本地环境解析出来的依赖树比照集群 Spark 内置版本。最后尝试手动指定 classpath 打印在驱动里临时加一行把System.getProperty(java.class.path)输出到日志对比节点上的实际内容。排查的核心不是“网上搜个参数”而是先搞清楚类是没进 jar还是进了 jar 但没被加载。5.2 依赖冲突的根源一个大 jar 里的多份同名类fat jar 也不是万能的。最大的坑是同一个类在 jar 内出现了多个版本加载到哪个完全取决于 classpath 扫描顺序因此在不同节点上表现还可能不一致。我遇到过一次典型的 Scala 版本错位项目里用的spark-sql_2.12打成 provided但业务代码里有一个工具库依赖了 Scala 2.11 的某个类最终 fat jar 里混进了两套 Scala 标准库的同名类。结果是本地跑没事集群上部分 executor 一执行就挂报错还是最阴间的NoSuchMethodError因为编译器按 2.12 生成的调用签名在 2.11 的类库里找不到对应方法。解决思路是两件事同时做第一确认 Scala 版本和 Spark 发行版一致第二对真正冲突的第三方库做重定位。用 shade 插件时可以把某个库挪到内部命名空间relocation patterncom.google.common/pattern shadedPatternshaded.com.google.common/shadedPattern /relocation重定位之后你的 jar 和集群内既有依赖就不会互相踩踏但重定位只适合第三方库绝不要对 Spark 自身做这种操作否则你就是在制造一个新的坑。5.3 提交参数里的隐性陷阱内存、并行度与日志位置很多任务起不来的原因不在代码在参数。我最常见到的问题是内存估得太细导致 executor 数量被压得很低。比如一个任务看起来只需要 2G 内存但真实跑起来有大量落地中间结果2G 根本不够频繁 GC 导致任务跑了几个小时没结束。我的应对套路是先保守提--executor-memory 4g跑一个数据子集验证时间再按数据量线性放大。不要一开始就追求最优参数先把任务“跑通”再“跑快”。并行度方面--num-executors和--executor-cores不是越大越好。executor 数量太多每个 Executor 分到的数据块太碎调度反而有额外开销单 executor 内核数太多又会把内存切分得乱七八糟。通常一个 executor 2-4 核比较实在但具体数字还是要以实测为准。还有一个隐蔽问题cluster 模式下日志不回到提交终端。你以为任务挂了其实它可能在集群里正常跑只是日志去了聚合目录或者 History Server。找日志第一站是 YARN 的log aggregation不是提交命令的窗口。我见过有人为了“看日志”硬把生产任务改成 client 模式结果提交机一断网整个线上任务就中止了这个操作一定要避免。写到这里说点最真实的体会从“在 IDE 里跑通”到“在集群上稳定跑完”中间隔着的不是一步而是一整套工程习惯。先把依赖边界划清楚再老老实实把 jar 在本地模式提交一遍最后才往集群送是我这两年最想推荐给所有人的顺序。别怕慢这一步省掉的时间远比你线上踩坑之后再回头发狠要少得多。

相关新闻

PyTorch Loss函数五大隐性陷阱与工程级调试指南

PyTorch Loss函数五大隐性陷阱与工程级调试指南

1. 为什么PyTorch的loss函数总在“悄悄”改变你的训练结果?刚接触PyTorch时,我写的第一段训练循环里只有一行:loss F.cross_entropy(output, target)。模型跑起来了,准确率也上去了——我以为这就搞定了。直到某天,我…

2026/10/17 23:26:24 阅读更多 →
OpenResearch:Claude Code、Codex、OpenCode、Cursor 多工具协同与成本核算实战

OpenResearch:Claude Code、Codex、OpenCode、Cursor 多工具协同与成本核算实战

1. 从"OpenResearch"说起:一个被热搜词堆出来的真实需求"OpenResearch"这个词第一次出现在我视野里,是在几个开发者群里被反复转发的一张截图——有人把 Claude Code、Codex、OpenCode、Cursor 这几个工具的使用记录、模型调用日志、…

2026/10/17 23:26:24 阅读更多 →
AI Agent在航空管理中的落地实战:四大场景与架构剖析

AI Agent在航空管理中的落地实战:四大场景与架构剖析

1. 从“人海战术”到“决策闭环”:AI Agent到底改变了航空管理的什么我到现在还记得那个凌晨两点。某航司运行控制中心(AOC)大厅里,电话声此起彼伏,三条航线因为前站雷雨流控陆续返航,值班签派员一手拿着对…

2026/10/17 23:26:24 阅读更多 →

最新新闻

完整环境配置清单:ZenTorch、ZenDNN、PyTorch 与 vLLM 四件套版本兼容表(Phi-4-reasoning-plus-w4a16-llmcompressor 专用)

完整环境配置清单:ZenTorch、ZenDNN、PyTorch 与 vLLM 四件套版本兼容表(Phi-4-reasoning-plus-w4a16-llmcompressor 专用)

【免费下载链接】Phi-4-reasoning-plus-w4a16-llmcompressor 项目地址: https://ai.gitcode.com/hf_mirrors/amd/Phi-4-reasoning-plus-w4a16-llmcompressor 点击查看 免费下载 本文面向初次部署 AMD CPU 量化推理模型的新手,围绕 Phi-4-reasoning-plus…

2026/10/18 0:24:12 阅读更多 →
MiniMax M3 API 全平台价格对比:官方、OpenRouter 与七牛云怎么选(2026)

MiniMax M3 API 全平台价格对比:官方、OpenRouter 与七牛云怎么选(2026)

/* 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:24:12 阅读更多 →
零基础学Blender:中文手册带你掌握建模、渲染与动画

零基础学Blender:中文手册带你掌握建模、渲染与动画

简介:Blender中文教程手册是一份面向零基础用户的完整入门指南,从界面布局、框架逻辑到核心操作流程均有详细讲解,内容覆盖3D建模、材质贴图、动画制作与渲染输出等关键技能,同时也能满足有一定基础用户的进阶学习需求。手册内容源…

2026/10/18 0:24:12 阅读更多 →
服务器运维:K3S 下 Jenkins 安装及配置的完整操作笔记(TaoToken 统一 Key 接入实践)

服务器运维:K3S 下 Jenkins 安装及配置的完整操作笔记(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/18 0:24:12 阅读更多 →
开发者指南:mlx_lm API集成NVIDIA-Nemotron-3.5-Lightning-30B-A3B-mxfp4的进阶技巧

开发者指南:mlx_lm API集成NVIDIA-Nemotron-3.5-Lightning-30B-A3B-mxfp4的进阶技巧

【免费下载链接】NVIDIA-Nemotron-3.5-Lightning-30B-A3B-mxfp4 项目地址: https://ai.gitcode.com/hf_mirrors/mlx-community/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-mxfp4 点击查看 免费下载 本文面向刚接触 Apple Silicon 本地大模型的新手,讲解如何…

2026/10/18 0:24:12 阅读更多 →
从零开发轻量记事本:IndexedDB存储、自动保存与同步实践

从零开发轻量记事本:IndexedDB存储、自动保存与同步实践

记事本这玩意儿,看着简单,做起来全是坑。之前我接了一个内部工具需求,要做一款能随手记录、随时翻查、离线也不慌的轻量记事本,产品代号叫“轻记”。从UI线框到功能实现,前后大概花了三周时间,中间踩了不少…

2026/10/18 0:23:12 阅读更多 →

日新闻

基于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/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/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 阅读更多 →