news 2026/10/9 1:06:42

虚拟机搭建Hadoop三节点分布式集群完整实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
虚拟机搭建Hadoop三节点分布式集群完整实战指南

简介:这是一份基于 Ubuntu 系统的 Hadoop 分布式环境搭建全流程教学资料,适合大数据初学者、云计算课程设计学生以及需要自建集群的开发者。内容从虚拟机安装、SSH 免密登录配置、共享文件夹挂载,到 JDK 环境变量设置与 Hadoop 参数调优,完整覆盖了从零搭建大数据处理平台的关键环节。压缩包共包含 93 个文件,以 Java 源码、class 编译文件、XML 配置、JAR 依赖包为主,辅以 txt 说明、png 实验截图和 docx 实验报告,便于对照步骤操作和复盘排错。附赠的 Cloud-Computing-course-design-master 项目提供了倒排索引、矩阵相乘、Dijkstra 等经典 Hadoop 算法实验,可作为扩展练习或课程设计参考。资源包约 222MB,已有 112 人学习下载,目录结构清晰,既能按顺序跟随搭建,也可直接借鉴其中的实验报告与代码。

1. 为什么把Hadoop分布式环境搭在虚拟机上:一套配置,三台节点,零物理机成本

想在简历上写“熟悉Hadoop分布式集群”,但手边只有一台电脑,连三台物理机都凑不齐——这是绝大多数人卡在起点的真实原因。用虚拟机搭一个三节点的Hadoop分布式环境,是目前成本最低、也最能还原生产环境路径的做法:它有真实的IP网络、真实的主机名解析、SSH远程登录,而且比hadoop伪分布式搭建多出“数据分散在多台节点”的关键差异。我见过不少人在伪分布式上跑通WordCount就以为懂了分布式,等面试官问“DataNode独立进程怎么被远程拉起”时直接卡壳。这套教程覆盖虚拟机安装、SSH免密登录、共享文件夹挂载、JDK环境配置到Hadoop安装与配置的完整链路,适合正在做大数据课程设计、准备Hadoop面试或打算入行大数据平台运维的从业者照着一路搭完。

2. Ubuntu虚拟机与共享文件夹挂载:从ISO镜像到三台可用的集群节点

2.1 创建第一台Ubuntu虚拟机:版本选择与网络模式

Hadoop对Ubuntu版本的挑剔程度其实很低,20.04、22.04、24.04 LTS都能稳定跑。我一般建议装Ubuntu Server版而不是桌面版,因为集群节点不需要图形界面,省下几百MB内存给Java进程用。如果你手头只有桌面版ISO,装完把图形界面关掉也行,但纯Server版遇到的问题最少。网络模式这里非常关键,我踩过一次大坑:桥接模式下虚拟机IP会跟宿主机局域网抢地址,DHCP一变IP,Hadoop配置文件里的主机名就全废了。所以第一台虚拟机就用NAT模式,VMware会维护一个虚拟网段,三台虚拟机之间互通,宿主机也能SSH进去,完全满足集群练习需求。

安装过程里记住两件事:一是用户名不要带空格和特殊字符,后面所有配置文件的路径都会引用这个用户名;二是在分区步骤选择LVM默认方案即可,不用手动分盘,Hadoop的数据目录默认挂在/下,后面改配置时心里有数就行。装完进入系统后先做一次快照,命名“ubuntu-base”,这一步是整个搭建过程的后悔药。后续任何一步配置改崩了,不用重装系统,两分钟就能回滚到干净状态。

2.2 克隆出三台节点:hostname、静态IP与网络互通

集群最小规模是三台:一台master跑NameNode和ResourceManager,两台node跑DataNode和NodeManager。VMware里右键模板机,选择“管理-克隆”,创建链接克隆即可。链接克隆省磁盘,依赖母机存在;如果要把集群复制到别的电脑,用完整克隆更稳妥。克隆完成后分别改三台的主机名和IP,规划如下:

角色主机名IP地址运行进程
mastermaster192.168.111.10NameNode, ResourceManager, SecondaryNameNode
node1node1192.168.111.11DataNode, NodeManager
node2node2192.168.111.12DataNode, NodeManager

IP网段以你实际的VMware NAT网段为准,我这里以192.168.111.x举例。修改主机名用hostnamectl set-hostname master并重启,或者编辑/etc/hostname。Ubuntu 22.04及之后的版本用netplan管理网络,改静态IP的方式是编辑/etc/netplan/00-installer-config.yaml:

network: version: 2 ethernets: ens33: dhcp4: false addresses: - 192.168.111.10/24 routes: - to: default via: 192.168.111.2 nameservers: addresses: - 192.168.111.2 - 223.5.5.5

修改后执行sudo netplan apply让配置生效。这里最容易翻车的是网卡名,不同虚拟机网卡可能是ens33也可能是ens160,先执行ip addr看清实际设备名再改。三台机器的/etc/hosts都要加上全部主机映射,否则后面Hadoop启停脚本会因解析不了主机名直接抛异常:

192.168.111.10 master 192.168.111.11 node1 192.168.111.12 node2

改完后三台互相ping master、ping node1能通,才算进入下一环节。

2.3 共享文件夹挂载:把JDK和Hadoop安装包从宿主机送进虚拟机

把宿主机的安装包传进虚拟机,常见做法是开共享文件夹。VMware菜单栏选择“虚拟机-设置-选项-共享文件夹”,启用后添加一个宿主机目录,比如D:\bigdata_setup,把JDK和Hadoop的tar包都放进去。虚拟机里用vmware-hgfsclient查看当前共享了哪些目录:

vmware-hgfsclient # 输出: bigdata_setup

然后手动挂载到/mnt/hgfs。Ubuntu 22.04用的是open-vm-tools,需要确认vmhgfs-fuse存在,不存在就安装open-vm-tools-desktop包:

sudo apt update sudo apt install -y open-vm-tools-desktop sudo mkdir -p /mnt/hgfs sudo vmhgfs-fuse .host:/ /mnt/hgfs -o allow_other -o uid=1000,gid=1000

-o uid=1000,gid=1000指归档给第一个普通用户,否则挂载后目录属主是root,普通用户没权限读取安装包。想让重启后自动挂载,把一行写进/etc/fstab:

.host:/ /mnt/hgfs fuse.vmhgfs-fuse defaults,allow_other,uid=1000,gid=1000 0 0

验证方式是ls /mnt/hgfs/bigdata_setup能看到jdk和hadoop的tar.gz包。这里有个血泪经验:如果在VMware设置里开启了共享但仍看不到,多半是open-vm-tools版本太老,sudo apt upgrade open-vm-tools后重启虚拟机再试。挂载完成后,把安装包复制到/home/你的用户名/下备用,后面两台节点直接scp分发就行,不用重新挂载。

3. SSH免密登录配置:让master能直接ssh到每一台节点

3.1 为什么Hadoop必须配免密:start-all.sh背后的远程启动机制

很多人以为SSH免密登录是Hadoop的“可选优化”,其实它是启动脚本的正常工作方式。执行start-dfs.sh时,脚本通过SSH登录到集群每一台节点,远程执行hadoop-daemon.sh来拉起DataNode和NodeManager进程。如果没配免密,脚本会中途停下来等密码输入,每次启动集群都要手动输好几遍密码,这在生产环境完全不可接受。SSH密钥本质上是一对非对称加密的钥匙:私钥留在master上,公钥分发到各个节点的authorized_keys文件里,登录时对方拿公钥验证你的身份,验证通过直接放行。

我见过有人图省事把master的私钥复制到所有节点上,这是完全错误的方向。私钥一旦泄露,任何一台节点被攻破就等于整个集群失守。正确做法是私钥只留一份在master,公钥广播到所有节点。三节点集群里master也必须自己登录自己,因为SecondaryNameNode的checkpoint过程要用localhost方式连接本机,所以本机免密也不能漏。

3.2 生成密钥对并用ssh-copy-id分发公钥

在master节点上用当前用户执行密钥生成命令,注意这个用户的home目录就是之后运行Hadoop的目录,不要用root操作。生成公钥的算法和注释参数可以直接参考下面这组:

ssh-keygen -t rsa -b 4096 -P "" -f ~/.ssh/id_rsa

参数含义:-t rsa指定密钥类型为RSA,-b 4096指定密钥长度4096位,-P ""把私钥口令设为空,这样ssh登录时才不会二次询问密码,-f指定生成路径。如果你以前生成过密钥想重新生成,先删除~/.ssh/id_rsa和~/.ssh/id_rsa.pub再执行,或者直接用-y从私钥重新提取公钥。

生成之后把公钥分发给三台节点。这里直接用ssh-copy-id一把梭:

ssh-copy-id master ssh-copy-id node1 ssh-copy-id node2

第一次执行会提示确认主机指纹,输入yes并输入目标机器的用户密码即可。ssh-copy-id会把~/.ssh/id_rsa.pub追加到对方~/.ssh/authorized_keys,完成公钥注册后立刻关闭远程修改权限。如果列表里还有别的端口或用户,可以指定完整形式:ssh-copy-id -p 22 用户名@主机名。

3.3 验证免密登录与两个经典报错

检查是否配成功的命令极简:

ssh master hostname ssh node1 hostname ssh node2 hostname

三台都直接返回对应主机名而不是要密码,就算通过。第一个常见报错是执行后仍提示输入密码,排查顺序是:当前用户的~/.ssh目录权限是否为700,authorized_keys权限是否为600;/home/用户目录权限不能是777。SSH的权限检查异常严格,权限过大直接拒绝用key登录,所以务必执行一次chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys。第二个报错是Host key verification failed,这通常发生在有机器重装或IP被复用后,解决方式是清掉known_hosts里对应的旧指纹重新连接,或者用ssh-keyscan批量写入新指纹:

ssh-keygen -R master ssh-keygen -R node1 ssh-keygen -R node2 ssh-keyscan -t rsa master node1 node2 >> ~/.ssh/known_hosts

ssh-keyscan把三台节点的主机公钥一次性追加到known_hosts,免去第一次连接时逐个确认。配完SSH后记得再拍一次快照,命名“ssh-done”,后面改配置文件就靠这个快照兜底。如果之后用VSCode连远程服务器做调试,这套密钥也可以直接复用。

4. JDK安装与环境变量设置:版本决定成败,路径决定生死

4.1 选JDK 8还是JDK 11:与Hadoop版本的兼容边界

Hadoop 3.x官方支持矩阵明确列出JDK 8和JDK 11,社区里跑生产任务的主力也是这两个版本。如果你下载的Hadoop是3.3.x,我建议直接上JDK 8,理由是Hadoop生态里的Spark、Hive、Zookeeper对JDK 8的兼容验证最充分,遇到诡异的内存或序列化问题时排查成本低。JDK 17以上不是不能用,但MapReduce的反射机制偶发兼容告警,真出问题你会花大量时间在“是不是JDK太新”的排查上。这里不纠结Oracle JDK和OpenJDK,两者在Hadoop场景下行为一致。强烈不建议用apt install default-jdk图省事,因为apt默认版本可能跟随系统源变化,今天装的是11,过两天源更新就变了,三台节点版本不一致是集群日常翻车源头之一。

去JDK官网下载jdk-8uXXX-linux-x64.tar.gz,放到共享文件夹后用tar解压,好处是路径完全可控。有些教程让你下载rpm包或deb包,在Ubuntu上会额外引入包管理器层面和库依赖问题,tar.gz才是跨发行版最稳的方式。

4.2 安装JDK并写入/etc/profile.d:三台节点共用一套环境

解压前先规划目标目录,我的习惯是把所有大数据软件集中在/usr/local下,方便维护。操作如下:

sudo mkdir -p /usr/local/src sudo tar -zxvf jdk-8uXXX-linux-x64.tar.gz -C /usr/local/src sudo ln -sf /usr/local/src/jdk1.8.0_XXX /usr/local/jdk8

使用软链接/usr/local/jdk8的好处是以后升级JDK时只需改链接指向,不必修改任何配置文件。接下来写入环境变量。不要直接改/etc/profile,那是全局登录脚本,改乱了影响系统所有用户;正确做法是在/etc/profile.d/下新建脚本,Ubuntu登录时会自动source该目录下所有.sh文件:

sudo tee /etc/profile.d/jdk.sh << 'EOF' export JAVA_HOME=/usr/local/jdk8 export PATH=$PATH:$JAVA_HOME/bin export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar EOF source /etc/profile

如果之前已经用apt装过OpenJDK,执行dpkg -l | grep jdk查看已安装包,用sudo apt purge openjdk-*彻底卸载。否则/usr/bin/java可能还指向旧版本,java -version显示的版本会跟echo $JAVA_HOME不一致,这是环境变量配置失败最常见的表象。宁可多花两分钟卸载干净再测,也不要留着两个JDK互相干扰。

4.3 验证java与javac:环境变量没生效时的排查链路

验证命令和预期输出如下:

java -version | head -n 1 # java version "1.8.0_XXX" which java # /usr/local/jdk8/bin/java echo $JAVA_HOME # /usr/local/jdk8

换了新终端后环境变量不生效,常见原因有两个:一是新终端没有重新加载profile.d,登录一次或source /etc/profile即可;二是你改的是某个用户家目录下的.bashrc而当前登录用的是别的用户。排查链路建议按顺序走:先echo $JAVA_HOME,为空就去检查profile.d脚本是否在正确目录且有可执行权限;非空但java -version版本不对,执行sudo update-alternatives --config java手动切换版本。

三台节点的环境同步我不建议一台台重敲命令,效率低且容易手滑打错。比较稳的做法是在master配置完成后打包分发:把/usr/local/jdk8软链接实际指向的目录打成tar包,scp到node1和node2后解压到位,再创建同样的软链接和profile.d脚本。如果你打算写脚本批量执行,可以把以上所有命令整理成一段bash脚本放到共享文件夹里,三台节点分别执行,注意软链接路径和用户名要按实际环境改好再跑。

5. Hadoop核心配置与启动避坑:五个文件决定集群生死

5.1 Hadoop解压与环境变量:HADOOP_HOME和PATH一次配到位

Hadoop的安装方式跟JDK类似,从官网下载hadoop-3.3.x.tar.gz(注意3.x版本已经是编译好的二进制包,不需要自己编译,网上搜“hadoop已编译jar包”主要解决的是源码编译场景,普通安装直接用官方包就行)。解压到/usr/local/src并创建软链接:

sudo tar -zxvf hadoop-3.3.x.tar.gz -C /usr/local/src sudo ln -sf /usr/local/src/hadoop-3.3.x /usr/local/hadoop

接着在/etc/profile.d/hadoop.sh里配置HADOOP_HOME:

sudo tee /etc/profile.d/hadoop.sh << 'EOF' export HADOOP_HOME=/usr/local/hadoop export PATH=$PATH:$HADOOP_HOME/bin:$HADOOP_HOME/sbin EOF source /etc/profile

很多新手会在这一步漏掉sbin目录,导致start-dfs.sh执行时提示“command not found”。另外必须修改$HADOOP_HOME/etc/hadoop/hadoop-env.sh里的JAVA_HOME:

# 找到行: export JAVA_HOME= # 改为: export JAVA_HOME=/usr/local/jdk8

hadoop-env.sh是Hadoop启动脚本直接加载的文件,它不会自动读取系统的JAVA_HOME环境变量,不显式写死的话,集群启动时会用默认的/usr/bin/java。如果这里指向了不存在的路径,启动脚本会报“Error: JAVA_HOME is not set and could not be found”,这条报错在Hadoop面试题里经常被拿来考启动流程。

5.2 五个核心配置文件的参数对照:core-site、hdfs-site、yarn-site、mapred-site、workers

配置文件都在$HADOOP_HOME/etc/hadoop/下。核心配置共五个文件,我把参数和值整理成一张表,按这个改不会有遗漏:

文件名参数名值说明
core-site.xmlfs.defaultFShdfs://master:9000默认文件系统的NameNode地址
core-site.xmlhadoop.tmp.dir/usr/local/hadoop/tmp元数据、格式化数据存放根目录
hdfs-site.xmldfs.replication2三节点设2份副本,留一台冗余
hdfs-site.xmldfs.namenode.name.dirfile:///usr/local/hadoop/tmp/dfs/nameNameNode元数据目录
hdfs-site.xmldfs.datanode.data.dirfile:///usr/local/hadoop/tmp/dfs/dataDataNode数据块目录
yarn-site.xmlyarn.resourcemanager.hostnamemasterResourceManager所在的节点
yarn-site.xmlyarn.nodemanager.aux-servicesmapreduce_shuffleMapReduce的shuffle辅助服务
mapred-site.xmlmapreduce.framework.nameyarn指定MapReduce跑在Yarn框架上

hdfs-site.xml中core-site和hdfs-site需要改默认的临时目录,默认的/tmp目录在系统重启后会被清空,HDFS元数据丢失会让DataNode直接起不来。workers文件在Hadoop 3.x里不再是slaves,这个老是有人搞混——文件内容列出所有DataNode所在机器的主机名,每行一个:

# 文件路径: /usr/local/hadoop/etc/hadoop/workers node1 node2

注意不要在这里写master。master上的NameNode是HDFS的调度中枢,DataNode的职责只属于节点。格式化的目的就是让NameNode在dfs.namenode.name.dir指向的目录里生成current/VERSION等元数据文件,DataNode首次启动时会读取这个clusterID并写入自己的数据目录。

5.3 格式化NameNode与启动顺序:三个高频踩坑现场

首次启动前需要格式化NameNode,这一步只在第一次执行:

hdfs namenode -format

格式化成功后日志末尾会出现“Storage initialized”的提示。看到FileSystem is already an instance of DistributedFileSystem这种旧版本遗留信息不用紧张,只要没有ERROR就算通过。接下来启动集群:

start-dfs.sh start-yarn.sh

执行顺序上start-dfs.sh必须先跑,它是整个集群的地基;再执行start-yarn.sh负责计算资源。也可以直接start-all.sh一把起,但不建议,拆开跑的好处是出问题时能更快定位是HDFS还是Yarn挂了。

踩坑第一条,DataNode日志报Incompatible clusterIDs:格式化NameNode之后又重复格式化了几次,每次格式化都会生成新的clusterID,DataNode启动时发现自己的clusterID和NameNode对不上就拒绝注册。解决方式是把所有节点的hadoop.tmp.dir指向的目录全部删除,重新只格式化一次,然后重启集群。踩坑第二条,NodeManager一直起不来:多半是yarn-site.xml里的yarn.nodemanager.aux-services值配错,或者mapred-site.xml没有把framework指定成yarn。踩坑第三条,DataNode报Storage directory not initialized:hadoop.tmp.dir指向的目录属主是root或者目录不存在,执行mkdir -p /usr/local/hadoop/tmp && sudo chown -R 你的用户名:你的用户名 /usr/local/hadoop后重试。启动完成后随手jps看一眼进程,master上应该有至少NameNode、ResourceManager、SecondaryNameNode三个进程,node上应该有DataNode和NodeManager,缺了哪个就去看对应的log文件排查。

6. 集群功能验证与维护技巧:用WordCount证明集群真的能用

6.1 启动HDFS与Yarn并查看进程

启动命令执行完后,进程状态确认用jps:

jps # master上应有: # NameNode # SecondaryNameNode # ResourceManager

然后检查HDFS的健康状态:

hdfs dfsadmin -report

回显里能直接看到节点数量、存储容量和副本状态,DataNode数量应该是两台。如果你用的是Hadoop 3.x,Web界面访问http://master:9870查看NameNode状态,http://master:8088查看Yarn的任务调度界面。从宿主机浏览器访问这两个地址时,用的就是之前配置的NAT网段IP,不需要额外设置。这一步能直观看到DataNode心跳上报的数据块信息,比黑匣子式地看日志强得多。

6.2 用WordCount跑通第一个分布式任务

集群能启动不代表能跑计算,MapReduce作业才能真正验证提交链路。先在HDFS上创建输入目录,把测试文件传进去:

echo "hadoop distributed computing is fun" > /tmp/test.txt hdfs dfs -mkdir -p /input hdfs dfs -put /tmp/test.txt /input/ hdfs dfs -cat /input/test.txt

执行WordCount程序:

hadoop jar /usr/local/hadoop/share/hadoop/mapreduce/hadoop-mapreduce-examples-3.3.x.jar wordcount /input /output

执行期间注意观察终端输出:Job运行会有map 100% reduce 100%的进度条,最终提交成功后出现completed successfully。结果输出目录/output不能预先存在,否则直接报错。查看结果:

hdfs dfs -cat /output/part-r-00000

能看到每个单词出现了几次,代表你的三节点集群真实吃下了这个任务。跑完记得清理测试数据:hdfs dfs -rm -r /output,免得下次跑任务时报目录已存在。

6.3 日常维护的三个习惯

第一个习惯是大改配置前先打快照。虚拟机快照是分布式环境下最便宜的后悔药,改坏一个配置不用在日志里翻来覆去找原因,回滚快照一分钟搞定。第二个习惯是经常看日志目录。Hadoop的日志在$HADOOP_HOME/logs/下,DataNode的日志文件名形如hadoop-用户-datanode-主机名.log,报错排查时先看这个文件,比在任何论坛搜报错都准。第三个习惯是HDFS磁盘空间管理。测试阶段跑几轮作业后,检查hdfs dfsadmin -report里的剩余空间,及时hdfs dfs -rm -r清理输出目录,避免磁盘写满后DataNode进入异常状态。

集群跑通只是起点。下一步建议先补充HDFS的高可用设计,理解NameNode的单点问题;再往前一步就是Hadoop和Zookeeper整合实战,用Zookeeper做自动故障切换。我自己的习惯是每次配置改动都记录到笔记里并打一次快照,现在这套三节点虚拟机已经成了随手能用的实验台。希望帮到你。

本文还有配套的精品资源,点击获取

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

STM32车用传感器实战指南:从物理换能到数据闭环

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

作者头像 李华
网站建设 2026/10/9 1:06:29

ESP32-P4嵌入式LLM推理:从0.61到4.31 tok/s的四层硬件-软件协同优化

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

作者头像 李华
网站建设 2026/10/9 1:06:22

ARM64内核page fault排查实录:vmalloc释放后访问与use-after-free定位

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

作者头像 李华
网站建设 2026/10/9 1:06:22

驱动工程师进阶:吃透规格书,告别盲目抄代码

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

作者头像 李华
网站建设 2026/10/9 1:06:12

思科CCNA英文词汇集:从术语入门到实战解题

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

作者头像 李华
网站建设 2026/10/9 1:06:10

基于P2P的局域网即时通信系统:UDP广播发现与TCP文件传输实战

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

作者头像 李华