HDFS存储成本优化:主流压缩算法对比与选型实践

发布时间:2026/10/20 4:41:17

HDFS存储成本优化:主流压缩算法对比与选型实践 我们平时维护大数据集群最绕不开的一件事就是存储成本。尤其是HDFS上堆了几百TB甚至PB级的数据每次看到告警说“磁盘空间不足”第一反应都是赶紧清理临时目录但治标不治本。真正能长期见效的手段之一就是在数据落盘时选择合适的压缩算法。不过压缩算法选型这个事网上资料虽然多多数都停留在“Gzip压缩率高、Snappy速度快”这类笼统结论真要落到生产环境要考虑的可不只是这两个维度。这篇文章我从HDFS实际存储和计算的角度把主流的几个压缩算法放在一起做个对比结合我这边运维和调优的实践经历聊聊选型思路、踩过的坑以及一些可以直接参考的参数配置。1. 为什么HDFS上的数据压缩不是简单套个格式1.1 存储、网络、CPU三笔账要一起算很多刚接触大数据的同学以为压缩就是为了省磁盘其实不全对。在HDFS上做压缩本质是在用CPU换存储空间和网络带宽。写入数据时先压缩再落盘读取数据时先解压再计算整个过程CPU多承担了一些工作量但磁盘IO和网络传输的压力会明显降下来。我举个实际场景。某个业务线每天产生几十亿条日志原始文本格式大约占300GB空间。如果直接存储不动一周下来就是2TB出头再加上副本机制默认3副本实际占用的物理存储接近6TB。如果改用Snappy压缩压缩比大约在2到3倍之间可能降到1TB多配合副本后约3TB多如果换成Gzip或Bzip2压缩比能达到4到5倍物理占用会更低。但注意压缩比高不代表一定划算。如果这个目录的数据后续经常要被Spark或Hive动态查询那每次查询都要耗费大量时间解压。Gzip虽然能把几百GB压成几十GB解压时CPU开销也不小尤其在高并发查询场景下压缩带来的CPU开销可能会成为新的瓶颈。所以在HDFS上做压缩第一原则是看你的数据是“写一次读多次”还是“写入频繁读也频繁”前者可以偏向压缩率后者必须偏向解压速度和并发能力。1.2 压缩算法与数据切片的爱恨纠葛HDFS的核心设计思想是把大文件切分成128MB或256MB的块分布式存储、并行计算。MapReduce或Spark读数据时一个split对应一个块理想情况是一个块一个任务并行处理。但这里有个特别关键的细节有些压缩格式不支持“从中间开始解压”。什么意思呢比如Gzip它的压缩流是一个整体要解压第2个数据块的起始位置必须从头开始把前面的数据全部解压。因此当Gzip文件被切分到多个HDFS块时虽然存储上分开了逻辑上却没法并行读取——每个任务都得从这个文件的最开始去解压相当于只用一个任务串行读整个文件。这在MapReduce里是最典型的“数据本地性失效”并行动能全被浪费。相比之下Snappy和LZO如果不做特殊处理其实也类似不天然可分片。但LZO有配套的索引文件工具生成一个.lzo.index后就可以按块切分Snappy在SequenceFile或Parquet这类容器格式中由于内部按块组织压缩数据天然支持从块边界开始解压所以也不存在“不能切片”的问题。Bzip2则很特别它本身就支持块级独立压缩每个块大约900KB压缩数据内部带有块边界标记所以Bzip2压缩文件天生可切片。这一条基本决定了如果你的数据是纯文本存储比如TextFile格式又希望通过Hive或MapReduce直接做并行查询压缩算法最好选Bzip2或者带索引的LZO如果用Parquet、ORC这类列式存储Snappy和LZO都是好选择因为文件格式已经保证了内部块的可分割性。2. 主流压缩算法横向对比与选型思路2.1 各算法核心指标一览我在测试环境分别用一份真实的业务日志文本约10GB、一份数值型表数据约2.5亿行以及一组JSON格式的事件数据做了压测。这里不贴全部测试数字了直接整理成对比表会更直观。算法压缩比越高越好压缩速度解压速度是否原生可切片综合CPU开销Gzip4.1中等中等否中高Bzip25.5慢慢是高LZO2.5快快需建索引低Snappy2.2很快很快容器格式内支持低Zstandard3.5可配置快否中LZ42.0极快极快容器格式内支持极低表格里压缩比是文本类数据的典型值如果是数值型数据或压缩率很低的结构化数据压缩比会小一些。但这张表足够帮我们建立直觉压缩比和速度基本是跷跷板但在具体应用场景下我们会更看重解压速度和可切片性。2.2 从使用场景反推算法选型场景一冷数据归档很少被读取存储空间紧张。这类数据的特点是“写入一次基本不读万一要读也能等”。选Bzip2通常是最优解它能给出最好的压缩率。缺点就一个字慢。无论是压缩还是解压速度都不理想比如压缩10GB文本Gzip大约两到三分钟Bzip2可能要十五分钟以上。但如果这个数据三个月才被拉出来审计一次这点时间完全能接受。场景二热数据频繁被查询分析。比如数仓里的ODS层明细表每天被大量任务读取。这时候选Snappy的性价比最高。压缩速度极快解压速度也快虽然压缩比只有2倍左右但CPU开销很低能让查询引擎的瓶颈重新回到磁盘扫描和计算上而不是解压上。对于Spark这类内存计算框架Snappy几乎可以做到解压透明化。场景三需要精确控制切片粒度的非容器格式数据。如果你还在用TextFile又希望文件可以分割并行处理LZO配索引是我用得最多的方案。Hadoop生态对LZO的支持比较成熟压缩速度比Snappy稍慢压缩比稍高配一个索引文件后MapReduce和Spark都能按块并行读。缺点是部署稍麻烦需要在集群上安装native的LZO库生产环境初始化时要多费点功夫。场景四数据要跨集群传输或写入对象存储做长期留存。这类场景我偏向Zstandard。Zstandard是较新的算法压缩率接近Gzip但速度快得多而且支持压缩级别动态调节。比如传输前用Zstd级别3快速压缩能减少不少网络IO如果留给后续分析解压也很快。唯一麻烦的是老版本Hadoop生态原生库不一定带Zstd需要确认版本兼容性。2.3 关于Snappy多说几句Snappy是Google开源的无损压缩算法设计目标就是“极速而非最小体积”。它不追求极限压缩率而是把解压速度做到亚毫秒级别内部实现大量使用SIMD指令优化每个字节的处理开销极小。在Hadoop生态里Snappy几乎成了默认选择。查一下Hive或Spark的默认配置就会发现很多压缩参数默认值就是Snappy。一个很直观的原因Hadoop上的数据访问频率极高用户希望查询任务的大部分时间花在真正处理数据上而不是在解压上。Snappy在这方面的表现非常稳定CPU占用低、响应快。弱点也存在。Snappy的压缩文件如果不经过容器格式整理HDFS照样无法并行切片。我见过不少同学直接往HDFS上写.snappy后缀的纯文本文件结果跑MapReduce时只能一个任务串行读数据量一大就慢得离谱。所以Snappy的正确用法要么是配合SequenceFile要么配合Parquet/ORC等列式存储。3. 压缩和存储格式两者密不可分3.1 行式存储与列式存储对压缩的影响很多初学阶段的认知里压缩算法和数据格式是两回事。但在选型时这两者必须联动考虑。行式存储比如TextFile或SequenceFile数据按行排列存储。压缩时是对整块字节流做处理算法选择的自由度大但可切片性是个大问题。如果处理不好就容易出现“文件不能并行读”的性能灾难。列式存储比如Parquet和ORC压缩的对象从“整块字节流”变成了“一列的同类型数据”。这个差别很关键同一列的数据类型一致数值、枚举、时间字段在编码后往往有很好的重复度压缩率天然比整行混合数据高。以Parquet为例它内部按行组Row Group组织数据每个列块可以独立编码和压缩。由于每一列的数据模式更单一即使是用Snappy这种高速度低压缩率的算法实际压缩效果也比直接Snappy压缩整个文本好很多。ORC类似它还支持额外的字典编码和Run-Length编码在低基数枚举值字段上压缩效果特别明显。所以现在的实践趋势是用Parquet/ORC这类列式存储内部配Snappy压缩压缩率介于“纯文本Gzip”和“纯文本Snappy”之间但压缩和解压速度远快于Gzip且天然支持切片。这个组合对于大多数数仓分析场景几乎是性价比最优解。3.2 表存储参数配置示例如果用的是Hive一张Parquet表加上Snappy压缩建表语句长这样CREATE TABLE user_event_log ( user_id STRING, event_type STRING, event_time TIMESTAMP, event_detail MAPSTRING, STRING ) PARTITIONED BY (dt STRING) STORED AS PARQUET TBLPROPERTIES ( parquet.compression SNAPPY );如果是ORC表可以显式指定压缩格式CREATE TABLE user_event_log_orc ( user_id STRING, event_type STRING, event_time TIMESTAMP, event_detail MAPSTRING, STRING ) PARTITIONED BY (dt STRING) STORED AS ORC TBLPROPERTIES ( orc.compress SNAPPY );注意这里有个细节Parquet的配置参数在不同的计算引擎里名字可能不同。Hive里允许在TBLPROPERTIES里指定parquet.compressionImpala里可能用PARQUET_COMPRESSION_CODEC的会话选项。如果在Spark里用DataFrame写入Parquet你需要这样配置df.write.option(compression, snappy).mode(overwrite).parquet(/path/to/table)我踩过的坑是有时候明明代码里指定了压缩格式写完以后去HDFS上看文件后缀看不出任何信息。这时不要怀疑配置没生效Parquet文件在文件末尾会有一段metadata记录了压缩算法直接检查文件footer才是准确的做法。可以用parquet-tools查看parquet-tools meta hdfs://nameservice/data/table/dt2024-01-01/part-00000.parquet输出里会列出各列的压缩方式确认是Snappy即可。3.3 压缩级别参数的灵活使用Zstandard还有个特色是compression level可调从1到22级别越高压缩率越好但速度越慢。Hadoop生态里配置Zstd后可以通过参数dfs.datanode.compression.codec.zstd.level去控制级别。通常建议是3到9之间线上我一般用3追求速度如果对压缩率更敏感可以调到9性能损失也能接受。Gzip其实也有类似的权衡但它的级别调节范围有限默认的6级别是综合考虑后的平衡点。一般不建议为了压缩率把级别调到9压缩时间会成倍增加压缩率提升却只有3%到5%不划算。4. 生产环境压缩配置的完整落地指南4.1 从HDFS、MapReduce到Spark的逐层配置理论聊了不少实际配置到底怎么做我按数据链路往下游一层一层看。首先是HDFS本身是否启用压缩。其实HDFS作为一个分布式文件系统默认是不对文件做任何自动压缩的它只负责存块。压缩行为发生在写入数据的计算引擎那一层。不过可以确认一下集群是否安装了对应的Codec库用下面的命令检查hadoop checknative输出会列出gzip、bzip2、snappy、lzo、zstd等是否available。这一步排查问题常会用到。比如有大任务提交后报“Compression codec SnappyCodec not found”的错误十有八九就是native库没装好。然后是MapReduce层面。可以在mapred-site.xml里配置中间结果压缩和输出压缩。中间结果压缩很多人会忽略但Shuffle阶段数据量巨大启用压缩能减少网络传输效果很明显property namemapreduce.map.output.compress/name valuetrue/value /property property namemapreduce.map.output.compress.codec/name valueorg.apache.hadoop.io.compress.SnappyCodec/value /property property namemapreduce.output.fileoutputformat.compress/name valuetrue/value /property property namemapreduce.output.fileoutputformat.compress.codec/name valueorg.apache.hadoop.io.compress.SnappyCodec/value /property这里建议Map输出用Snappy因为它是中间数据硬压缩没必要快才是关键。最终输出格式再看需求选择。Spark层面如果是Spark SQL写Parquet在spark-defaults.conf里配置spark.sql.parquet.compression.codecsnappy spark.sql.orc.compression.codecsnappy如果Spark作业是用TextFile写文本文件可以设置spark.hadoop.mapreduce.output.fileoutputformat.compresstrue spark.hadoop.mapreduce.output.fileoutputformat.compress.codecorg.apache.hadoop.io.compress.SnappyCodec spark.hadoop.mapreduce.output.fileoutputformat.compress.typeBLOCKcompress.typeBLOCK也很重要。它表示对HDFS块级别做压缩而不是Record级别或None级别。BLOCK类型配合容器格式很多时候才能正常发挥切片能力。4.2 压缩相关的参数计算与实际效果验证为了让大家有直观感受我举一个实际调优的案例。某离线任务每天处理大约3亿条PV日志原始文本每个小时约120GB一天下来接近1.5TB按3副本存储物理占用4.5TB。优化前直接存TextFile不压缩HDFS占用4.5TB每天跑全量统计任务需要扫描4.5TB数据耗时约2小时20分钟。优化方案数据落地改成Parquet存储压缩算法Snappy分区按小时。优化后单日数据压缩到约600GB物理占用1.8TB存储成本直接降了60%。因为Parquet按列存储查询只扫需要的列实际扫描数据量从1.5TB降到大约200GB统计任务耗时降到35分钟左右。这个案例中压缩算法选Snappy其实不算最优因为Parquet文件如果换Zstandard压缩率更高但解压速度稍慢。经过压测在这个查询模式偏简单的场景里Snappy和Zstandard在最终任务耗时上差距不超过5%而Snappy的压缩阶段耗时更短。考虑到后续还有其他任务频繁读同一份数据最终保留Snappy。4.3 数据写入压缩时要注意的写入放大压缩本身不产生额外数据但要注意的是压缩格式和处理框架如果选得不匹配可能会产生写入放大的问题。例如向一个原本压缩率很高的字段做小文件大量写入文件太小导致无法合并压缩块元数据开销相对增大实际有效内容占比反而低。这里建议开启Hive或Spark的小文件合并机制把碎片化写入合并成大文件再压缩。否则即便压缩算法再好文件数量一多NameNode内存就报警了性能一样上不去。5. 实际操作中常见的问题与排查经验5.1 Codec找不到或ClassNotFound这是最常遇到的问题。报错信息类似java.io.IOException: Compression codec org.apache.hadoop.io.compress.SnappyCodec not found.这种绝大多数不是Jar包缺失而是native库缺失。Hadoop的压缩Codec依赖本地C/C库比如Snappy、LZO、Zstd都是。解决方法是先在每台节点上安装对应的native库比如在CentOS上通过yum install snappy snappy-devel然后重新编译或更新Hadoop的native库目录。安装完以后用前面提到的hadoop checknative验证。LZO稍微特殊它的Hadoop支持包通常需要单独下载或编译里面包含了hadoop-lzo的Jar以及native库。配好后还要在core-site.xml里显式注册Codecproperty nameio.compression.codecs/name valueorg.apache.hadoop.io.compress.GzipCodec,org.apache.hadoop.io.compress.DefaultCodec,org.apache.hadoop.io.compress.BZip2Codec,org.apache.hadoop.io.compress.SnappyCodec,com.hadoop.compression.lzo.LzopCodec/value /property而且LZO建索引的步骤很容易被忽略。写完.lzo文件后需要跑一下索引命令否则文件不可分片hadoop jar /path/to/hadoop-lzo.jar com.hadoop.compression.lzo.DistributedLzoIndexer /path/to/data/file.lzo建议这一步在日常ETL脚本的末尾自动执行不然下游Spark作业读到没有索引的LZO文件就抓瞎了。5.2 SequenceFile里的压缩类型选择如果还在用SequenceFile它有三种压缩模式无压缩、记录级压缩Record Compress、块级压缩Block Compress。块级压缩对一大块记录统一压缩压缩率高且CPU开销相对合理是生产环境唯一推荐的选择。记录级压缩是指对每一条记录的value单独压缩。数据量一大每条记录都带压缩头部开销高、压缩率差。我曾经见过一个同学把SequenceFile设为Record压缩一个20GB的文件实际压缩后还有16GB明显没起到省空间的效果。改成Block压缩后立刻降到6GB。选型时优先保证Block压缩。5.3 ORC与Parquet在压缩上的天然差异虽然现在很多团队主推Parquet但ORC在Hive场景下尤其是大量聚合类SQL有它的独到之处。ORC内置了更丰富的编码方式比如对字符串列做字典编码对数字列做增量编码这些编码发生在压缩之前会大幅提高后续压缩算法的效率。所以在相同的Snappy压缩条件下ORC的压缩率通常略好于Parquet。但Parquet在Spark生态里的兼容性和谓词下推能力更强。我的看法是如果在以Hive为重心的数仓ORC可以优先如果Spark SQL为主Parquet用起来更顺手。两者都支持Snappy选型前做好小规模压测才是稳的。5.4 文件格式不变动时临时切换压缩算法有人会问历史数据已经用Gzip存了好几个月的文件能不能不重新写数据就换算法答案是不能。压缩算法是在文件字节流层面做的想换算法必须重写数据。不过如果只做临时验证可以考虑在建临时表时改变压缩设置SET hive.exec.compress.outputtrue; SET mapreduce.output.fileoutputformat.compress.codecorg.apache.hadoop.io.compress.SnappyCodec; INSERT OVERWRITE TABLE temp_table SELECT * FROM old_gzip_table;但注意这种方式的成本就是全量读、再全量写对集群IO是一次考验。大表建议放在低峰期执行并且控制并发度。5.5 压缩对数据倾斜和查询计划的隐性影响压缩文件不可分片这个坑带来的不只是性能损失有时候还会引发数据倾斜。一个大的Gzip文件在小集群上跑任务所有数据只能由一个Map处理完即便集群有几千个CPU核心这个任务也只会用到一个CPU去解压。我曾遇到一个跑了几小时的任务后来发现瓶颈就在一个1.8GB的Gzip压缩文件上。排查方法很简单到HDFS上看文件的存储情况和任务日志。如果发现一个split的Input Size特别大而其它split很小多半就是文件不可分片。尽早发现尽早重写。5.6 压缩算法对比压测的几个建议最后说一下如何做一次靠谱的压测。不要只看官方的benchmark那通常是在标准测试集上的结果和你真实数据差别可能很大。我建议这样操作从生产环境抽1GB到10GB真实数据覆盖业务内的典型数据形态。分别用Gzip、Snappy、LZO、Zstd、Bzip2压缩记录压缩后文件大小。用Spark或Hive对压缩后的数据跑同一套SQL包含扫描、过滤、聚合记录总耗时。观察集群CPU、磁盘IO和网络IO确认瓶颈不在资源冲突。连续压测三天取平均值避免瞬时状况干扰结论。压测时还要注意YARN的资源调度会影响测试结果最好在低峰期进行。我用这种方式帮不同的业务线选过多次压缩方案每次结论都不同但过程是一致的用真实数据说话别信经验主义。6. 小结与实践体会聊了这么多整理一下我的选型逻辑如果数据要长期归档、极少读取选Bzip2或高等级Zstd如果数据是分析热数据用Parquet/ORC配Snappy如果还在用文本文件做数据湖且要求文件可切分选LZO配索引如果数据要跨集群传输Zstd是个好选择。我个人在实际维护集群过程中的体会是压缩算法没有绝对的好坏每次选型都要回到数据形态、访问频率、计算引擎、集群资源这四个维度去权衡。而且一旦定了方案尽量写进ETL模板和表规范里避免后面的人随意覆盖。加了一个parquet.compression参数就能省下60%存储的案例我这边发生过不止一次收益远比想象中明显。最后再分享一个小技巧。上线新的压缩方案前先拿过去30天的元数据统计一下哪些表读取最频繁、哪些表占存储最大。读取频繁的表优先保证解压速度占存储最大的表重点看压缩率。集中优化Top 10的表集群整体压力往往立刻就能降下来这也是投入产出比最高的优化路径。
延伸阅读

更多相关文章

2026/10/20 4:36:17

个人词典误伤语境?Entune语境感知分层纠错实践

做听写类产品久了会发现一个反直觉的现象:用户亲手维护的个人词典,往往是误伤率最高的出错来源。听起来很矛盾。语音识别遇到专有名词和生僻词时不听话,于是产品给你一个“个人词典”,把“云界”“凯格宁”这种词加进去&#xff0…

2026/10/20 4:36:17

109天刷完LeetCode面试经典150:题单节奏、高频考点与真实踩坑复盘

坐在这张书桌前的时间,算到今天刚好第109天。别人问起刷题这件事,最容易被低估的门槛其实不是题目难度,而是你还能不能在第100天的时候继续翻开同一份题单。我这个系列刷的很慢,LeetCode面试经典150,从数组的第一题开始…

2026/10/20 4:36:17

体育大模型如何重构网球自主训练:从数亿融资看AI运动硬件新赛道

看到这家AI运动硬件公司完成数亿B轮融资的消息时,我下意识翻了下手边那套落灰的网球装备。作为长期关注运动智能硬件的从业者,这两年见过太多打着AI旗号的产品:有的智能跳绳只能计个数,有的AI健身镜练了半天还是那套模板动作。而这…

2026/10/20 5:56:25

字母象形与五行水象:从W/S/M到命名设计的认知工具

把“字母象形”和“阴阳五行”放在一起琢磨,原本是一件很容易被人当成故弄玄虚的事;但如果你手里刚好有一串字母——w,s,m,f,o,e,n,i——并且试图解释它们为什么都跟“水”有关系,这事就变得特别有意思。我先说结论:这串对应关系不…

2026/10/20 5:56:25

PC寄存器详解:CPU程序计数器原理与跨架构实践

1. 什么是PC寄存器?它不是“电脑”,而是CPU的“导航员”很多人第一次看到“PC寄存器”这个词,下意识以为是“Personal Computer”的缩写,结果一查发现完全不是一回事——这恰恰暴露了计算机底层概念最容易被表层符号误导的典型陷阱…

2026/10/20 5:56:25

螺旋桨流体仿真:从几何清理到流场收敛的完整拆解

简介:这份资源面向机械工程、航空航天及船舶方向的工程师与学生,提供螺旋桨流体仿真的完整工程资料,帮助理解并实践基于SolidWorks Flow Simulation的CFD分析流程。压缩包共105个文件,约92.06MB,包含4个sldprt零件模型…

2026/10/20 5:56:25

Flink State TTL机制深度解析:防止状态膨胀的生产实践

1. 为什么Flink的State会“长胖”——从一次线上内存告警说起上周三凌晨两点,某实时风控系统的告警群突然炸了:TaskManager堆内存使用率连续5分钟突破92%,GC频率飙升到每秒3次。值班同事紧急拉取JVM堆转储,发现一个叫userSessionS…

2026/10/20 5:56:25

PC寄存器:CPU指令执行流的物理锚点与控制核心

1. 这个“PC寄存器”到底在CPU里干啥?别再被教科书绕晕了你拆开一台老式台式机,盯着那块布满金手指的主板,可能根本不会想到——就在CPU芯片内部那个比芝麻还小的硅片角落里,正有一个叫程序计数器(Program Counter Reg…

2026/10/20 5:51:25

零基础科研绘图:用Paperxie从流程图到工程图一次搞定

前阵子帮一位同门改论文,技术内容本身没什么大问题,但审稿意见里一条“图件质量有待提高”直接让返修多花了三周。我把流程图重新画了四版,才明白一个道理:科研人缺的从来不是思路,而是一套能把思路转成专业图表的工具…

2026/10/19 18:06:35

金融AI落地指南:从调研报告到技术选型与工程实践

简介:这份PDF报告聚焦2025年金融服务业中国AI现状与趋势,面向金融科技从业者、AI战略规划人员及关注行业数字化转型的研究者,帮助读者把握生成式AI在银行、支付、交易等场景中的落地路径与投资方向。资源包内含1个PDF文件,大小约1…

2026/10/20 1:05:37

单片机C运行时从启动到多任务:中断安全与共享资源冲突

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

2026/10/20 1:05:37

GCC编译四步详解:预处理、编译、汇编、链接实战

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

2026/10/20 1:05:37

FIW线是什么?FEP注塑线与普通氟塑料线的区别及应用指南

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

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑