Parquet过滤优化实战:谓词下推、统计信息与布隆过滤器
1. 项目概述:为什么“过滤”是Parquet文件的灵魂操作
Parquet不是一张静态的硬盘快照,而是一套精密设计的、为高效筛选而生的数据组织系统。当你看到“Parquet Best Practices: The Art of Filtering”这个标题,别被“Art”这个词迷惑——它不是玄学,而是指在数据湖、数仓或ETL流水线中,把“过滤”这件事做到极致所积累的一整套可验证、可复现、有物理依据的工程直觉。我过去三年在金融风控和电商用户行为分析两个高并发、大数据量场景里,反复重构过27次Parquet读取逻辑,最深的体会是:90%的查询性能瓶颈,不在于计算引擎多快,而在于你有没有让Parquet在磁盘上就替你筛掉99%的字节。核心关键词——列式存储、谓词下推、统计信息、页脚元数据、布隆过滤器、分区裁剪——这些不是PPT术语,而是你写完WHERE user_id = 'U123456'之后,底层真正发生的事。这篇文章适合三类人:一是刚从CSV/JSON转战数据湖、还在用df.filter()硬扛的Python工程师;二是负责搭建离线数仓、需要设计分区策略和压缩方案的数仓工程师;三是正在排查“为什么Spark作业跑了40分钟却只处理了10GB数据”的运维或SRE。它不讲Parquet格式规范(RFC文档比这详细),也不堆砌API参数,只聚焦一个动作:如何让每一次WHERE条件,在尽可能早、尽可能低的层级,触发尽可能多的物理跳过。下面所有内容,都来自真实集群日志、parquet-tools命令行解析结果、以及Spark UI中Stage级别的Input Size与Records Read对比图。
2. 核心设计思路拆解:过滤不是“查”,而是“跳”
2.1 过滤的本质:一次多层级的“物理跳过决策链”
很多人误以为Parquet过滤就是“读出所有行,再用CPU逐行判断”。这是对列式存储的根本性误解。真正的Parquet过滤是一条自底向上的决策链,每一层都可能提前终止数据加载:
第1层:文件级跳过(File-level Skip)
基于文件路径中的分区信息。例如,表按dt=2024-01-01/ds=prod/分层存储,当查询WHERE dt = '2024-01-02'时,整个dt=2024-01-01目录直接不打开。这是成本最低、收益最高的跳过,但依赖严格的分区命名规范和查询谓词与分区字段的完全匹配。第2层:行组级跳过(Row Group-level Skip)
Parquet文件由多个Row Group组成(默认1MB大小)。每个Row Group头部嵌入该组内各列的统计信息(Statistics):min/max值、空值计数、是否已排序等。当查询WHERE amount > 10000时,引擎会先读取每个Row Group的amount列min/max,若max ≤ 10000,则整个Row Group被跳过——连磁盘IO都不发起。第3层:页级跳过(Page-level Skip)
Row Group内部按列切分为Page(默认通常为64KB)。每个Page也带独立的min/max和空值统计。当Row Group未被整体跳过,但其中某些Page的min/max已确定不满足条件时,这些Page被跳过。这是细粒度控制的关键,尤其对长文本或高基数ID列有效。第4层:布隆过滤器加速(Bloom Filter Acceleration)
针对高基数精确匹配(如WHERE user_id = 'U123456'),统计信息的min/max无意义(因为所有ID的min/max几乎一样)。此时需启用布隆过滤器(Bloom Filter),它是一个极小的、概率性的存在性检查结构。若BF返回“不存在”,则该Page绝对不包含目标值,直接跳过;若返回“可能存在”,才加载该Page并做精确匹配。实测在10亿用户ID场景下,开启BF后IN ('U1','U2','U3')类查询提速3.8倍。
提示:这四层跳过不是并行发生的,而是严格串行决策。引擎必须先完成文件级裁剪,再读取剩余文件的Row Group元数据,再逐个评估Row Group,再进入Page级。任何一层的“跳过”都意味着后续层级完全不执行,因此优化必须从顶层开始。
2.2 为什么“谓词下推”是命脉?——避免反模式的血泪教训
“谓词下推”(Predicate Pushdown)是指将SQL中的WHERE条件,尽可能下沉到Parquet读取层执行,而非在内存中由Spark或Pandas做二次过滤。它的价值不是“语法糖”,而是物理层面的IO节省。我曾接手一个线上告警任务,原逻辑是:
df = spark.read.parquet("s3://data/large_table/") df_filtered = df.filter(col("status") == "active").filter(col("created_at") >= "2024-01-01")表面看没问题,但Spark UI显示:Input Size 2.1TB,Records Read 18亿,Shuffle Write 0。问题在哪?spark.read.parquet()默认不启用谓词下推!它把整个2.1TB数据全拉进内存,再用CPU过滤。后来改成:
df = spark.read.parquet("s3://data/large_table/") \ .filter("status = 'active' AND created_at >= '2024-01-01'") # 单字符串谓词,强制下推Input Size骤降至87GB,Records Read 2.3亿,作业时间从38分钟压到4分12秒。根本原因:单字符串谓词能被Spark Catalyst直接解析为Parquet扫描器可识别的过滤表达式;而链式.filter()调用会被Catalyst视为DataFrame API操作,延迟到执行阶段。
注意:不同引擎下推能力差异极大。Spark 3.0+支持大部分标准SQL谓词;Trino需配置
hive.parquet.use-column-names=true才能正确映射列名;Pandas的pd.read_parquet()默认不支持下推,必须用filters=参数显式传入嵌套列表(如[("status", "==", "active")])。
2.3 分区设计不是“按日期分就行”,而是“按查询模式建索引”
分区(Partitioning)常被简单理解为“把数据按字段值拆成子目录”。但高阶实践把它当作第一层物理索引。关键原则是:分区字段必须是高频、高选择性的过滤条件,且其值分布要足够均匀。我们曾犯过一个典型错误:将用户行为表按country分区。结果发现:country='CN'占数据量82%,country='US'占9%,其余192个国家加起来仅9%。查询WHERE country IN ('JP','KR','TW')时,引擎仍需扫描全部192个冷门国家目录,IO效率极低。后来重构为两级分区:ds=20240101/country_group=asia/,将东亚国家归入同一组,同时保留ds作为主分区。这样,WHERE ds = '20240101' AND country IN ('JP','KR')只需打开1个目录,而非3个。
更进一步,分区字段应避免使用高基数、易变字段。例如,user_id绝对不能做分区字段——它有数十亿唯一值,会导致数百万子目录,严重拖慢NameNode或S3 List操作。正确的做法是:用user_id % 1000生成user_shard作为二级分区,配合布隆过滤器实现精准ID查询。
3. 核心细节解析与实操要点:从元数据到压缩的硬核控制
3.1 统计信息(Statistics):你的免费索引,但必须亲手激活
Parquet的min/max统计是过滤的基石,但它默认不写入!很多工具(如旧版PyArrow、部分Spark写入配置)会跳过统计信息生成,导致所有Row Group级跳过失效。必须显式开启:
Spark写入时:
df.write \ .option("parquet.enable.dictionary", "true") \ .option("parquet.compression", "SNAPPY") \ .option("parquet.statistics.enabled", "true") \ # 关键! .mode("overwrite") \ .parquet("s3://data/output/")parquet.statistics.enabled=true是开关,但还不够。还需确保parquet.page.size(默认1MB)和parquet.block.size(默认128MB)设置合理——Page越小,统计越精细,但元数据开销越大;Block越大,压缩率越高,但Row Group跳过粒度越粗。我们生产环境采用page.size=64KB+block.size=256MB,在IO跳过率和元数据体积间取得平衡。PyArrow写入时:
import pyarrow.parquet as pq table = pa.Table.from_pandas(df) pq.write_table( table, "output.parquet", statistics=True, # 必须设为True use_dictionary=True, compression="SNAPPY", data_page_size=65536, # 64KB write_batch_size=100000 )statistics=True是硬性要求。若用pq.write_to_dataset(),还需传入use_legacy_dataset=False以启用新式统计收集。
实操心得:统计信息不是万能的。对
STRING类型,min/max是字典序比较,"apple"<"banana"成立,但"100"<"20"为真(因字符'1'<'2'),这会导致数值型字符串过滤失效。解决方案:写入前将数值型字符串转为INT64或DOUBLE;或对字符串列启用字典编码(Dictionary Encoding),此时统计基于字典ID而非原始字符串,更稳定。
3.2 布隆过滤器(Bloom Filter):为精确匹配而生的轻量级加速器
布隆过滤器是解决高基数列(如UUID、手机号、加密ID)精确匹配的终极方案。它不存储原始值,而用k个哈希函数将每个值映射到一个位数组(bit array)的k个位置,置为1。查询时,对目标值做同样哈希,若任一位置为0,则该值绝对不存在;若全为1,则大概率存在(有极小误报率,但绝无漏报)。
启用方式:
Spark 3.2+支持:df.write \ .option("parquet.bloom.filter.enabled#user_id", "true") \ .option("parquet.bloom.filter.expected.ndv#user_id", "1000000000") \ # 预估10亿去重值 .parquet("s3://data/users/")expected.ndv(Expected Number of Distinct Values)是关键参数。设得太小(如100万),BF位数组过小,误报率飙升;设得太大(如1000亿),位数组过大,浪费空间且初始化慢。我们的经验公式:BF size (bytes) ≈ 1.5 * expected_ndv / 8。预估10亿ID,BF约180MB/文件,但换来的是99.2%的Page跳过率。验证是否生效:
用parquet-tools查看元数据:parquet-tools meta s3://data/users/part-00000-xxx.snappy.parquet | grep -A 10 "BloomFilter"若输出含
bloom_filter_offset和bloom_filter_length,说明已写入。再用parquet-tools dump检查具体Page:parquet-tools dump --page-info s3://data/users/part-00000-xxx.snappy.parquet | grep -A 5 "user_id"查看
has_bloom_filter: true是否出现。
注意:布隆过滤器只对
EQUALS和IN类精确匹配有效,对LIKE、BETWEEN无效。且它增加写入开销约15%-20%,务必在写入吞吐可接受的前提下启用。
3.3 列式压缩与编码:让过滤更快,而不是让文件更小
压缩的目标常被误解为“减小存储”。在Parquet中,压缩的核心目标是提升过滤速度。因为解压是按Page进行的,更高效的压缩意味着更少的CPU cycles用于解压,从而更快进入统计判断环节。
首选SNAPPY,而非GZIP或ZSTD:
GZIP压缩率高(~3x),但解压慢,且不支持随机访问——读取Page 5必须先解压Page 1-4。SNAPPY压缩率中等(~2.2x),但解压速度是GZIP的5倍以上,且支持Page级随机解压。ZSTD在压缩率和速度间折中,但Parquet生态支持不如SNAPPY成熟。我们所有生产表统一用SNAPPY。字典编码(Dictionary Encoding)是列式存储的隐藏王牌:
对低基数STRING列(如status,category),字典编码将重复字符串映射为整数ID,存储ID数组+字典表。好处有三:① 字符串min/max变为整数min/max,统计更准确;② Page内ID高度重复,SNAPPY压缩率暴增;③ 查询WHERE status = 'paid'时,引擎只需在字典表中查找'paid'对应ID,再在ID数组中搜索该ID,比逐字节比对字符串快一个数量级。启用方式:Spark中parquet.enable.dictionary=true(默认开启);PyArrow中use_dictionary=True。避免对高基数列启用字典编码:
若对user_id(10亿唯一值)启用字典,字典表本身就会超过1GB,且每次写入需维护哈希表,CPU飙升。此时应关闭字典,改用PLAIN编码+布隆过滤器。
4. 实操过程与核心环节实现:从零构建一个可过滤的Parquet表
4.1 场景设定:电商订单宽表,日增量2.4TB,需支持5类高频查询
我们以一个真实的电商订单宽表为例,字段包括:order_id(STRING),user_id(STRING),product_id(STRING),amount(DOUBLE),status(STRING),created_at(TIMESTAMP),ds(STRING, 分区字段)。日增量2.4TB,需支撑以下查询:
WHERE ds = '20240101' AND status = 'paid'(日结报表)WHERE user_id = 'U123456789'(用户订单查询)WHERE product_id IN ('P001','P002') AND amount > 500(爆款商品分析)WHERE created_at BETWEEN '2024-01-01' AND '2024-01-07'(周报)WHERE ds >= '20240101' AND amount < 10(小额订单监控)
目标:通过Parquet优化,使查询1-4的95%分位响应时间≤3秒,查询5因范围大,目标≤15秒。
4.2 步骤1:分区策略设计——双层分区+时间聚簇
主分区:
ds(字符串格式,如'20240101')
所有查询都带ds条件,且数据按天写入,天然适合。注意:必须用STRING而非DATE类型,避免Hive Metastore兼容问题;且值必须为YYYYMMDD格式,确保字典序=时间序,使ds >= '20240101'能利用min/max跳过旧分区。二级分区:
status(枚举值:'pending','paid','shipped','cancelled')status只有4个值,高选择性(如'cancelled'仅占0.3%),且查询1高频使用。二级分区后,WHERE ds='20240101' AND status='paid'只需打开1个子目录,而非扫描整个ds=20240101/下所有文件。时间聚簇(Clustering):在
created_at上排序写入
Parquet不支持索引,但可通过写入时排序实现物理聚簇。对created_at列排序后,同一Row Group内的created_at值高度连续,BETWEEN查询的Row Group跳过率大幅提升。Spark中:df.orderBy("ds", "status", "created_at") \ .write \ .partitionBy("ds", "status") \ .option("parquet.enable.dictionary", "true") \ .option("parquet.statistics.enabled", "true") \ .option("parquet.bloom.filter.enabled#user_id", "true") \ .option("parquet.bloom.filter.expected.ndv#user_id", "500000000") \ .option("parquet.bloom.filter.enabled#product_id", "true") \ .option("parquet.bloom.filter.expected.ndv#product_id", "10000000") \ .mode("overwrite") \ .parquet("s3://data/orders/")orderBy必须放在write前,且顺序与partitionBy一致,确保数据物理局部性。
4.3 步骤2:写入参数调优——平衡速度、大小与过滤性
| 参数 | 推荐值 | 理由 | 我们的实测效果 |
|---|---|---|---|
parquet.block.size | 256MB | 太小(128MB)导致Row Group过多,元数据膨胀;太大(512MB)降低跳过粒度 | Row Group平均大小248MB,跳过率提升12% |
parquet.page.size | 64KB | 平衡Page级跳过精度与元数据开销 | created_atBETWEEN查询Row Group跳过率从68%→89% |
parquet.compression | SNAPPY | 解压速度优先 | CPU使用率下降35%,端到端延迟降22% |
parquet.dictionary.page.size | 1MB | 字典编码页大小,避免小字典频繁刷新 | status列字典命中率99.9% |
parquet.writelegacyformat | false | 启用新式Parquet格式,支持更多特性 | 兼容Spark 3.2+和Trino 400+ |
实操心得:不要迷信“最大压缩率”。我们曾测试ZSTD(level=10),文件体积比SNAPPY小18%,但解压CPU耗时高2.3倍,最终查询延迟反而增加7%。过滤场景下,解压速度比压缩率重要10倍。
4.4 步骤3:读取端配置——让引擎真正“懂”你的Parquet
写入优化只是基础,读取端配置决定能否发挥全部威力:
Spark SQL:
SET spark.sql.hive.convertMetastoreParquet=true; -- 启用Catalyst原生Parquet读取器 SET spark.sql.parquet.filterPushdown=true; -- 强制谓词下推(默认true,但显式声明防误) SET spark.sql.parquet.mergeSchema=false; -- 避免Schema合并开销 SET spark.sql.adaptive.enabled=true; -- 自适应查询,动态优化Join和ShuffleTrino:
在etc/catalog/hive.properties中:hive.parquet.use-column-names=true hive.parquet.ignore-stats=false hive.parquet.use-bloom-filters=true关键是
ignore-stats=false,否则Trino会忽略Parquet文件中的统计信息,退化为全量扫描。Pandas(PyArrow后端):
import pyarrow.dataset as ds dataset = ds.dataset("s3://data/orders/", format="parquet") # 构建filters:注意是嵌套列表,AND用同一层列表,OR用子列表 filters = ds.field("ds") == "20240101" filters = filters & (ds.field("status") == "paid") # 或更复杂的:filters = (ds.field("user_id") == "U123456") | (ds.field("product_id") == "P001") table = dataset.to_table(filter=filters)filter=参数是谓词下推的唯一途径,df.query()或df[df.status=='paid']均无效。
4.5 步骤4:验证与压测——用数据证明优化有效
优化不是靠感觉,必须量化验证。我们建立三级验证体系:
元数据层验证:
用parquet-tools检查单个文件:# 检查统计信息是否写入 parquet-tools meta part-00000-xxx.snappy.parquet | grep -E "(min|max|num_nulls)" # 检查布隆过滤器是否存在 parquet-tools meta part-00000-xxx.snappy.parquet | grep "BloomFilter" # 检查Page级统计 parquet-tools dump --page-info part-00000-xxx.snappy.parquet | head -20确保每列都有min/max,且
user_id列有BloomFilter偏移量。引擎层验证(Spark UI):
提交查询后,进入Spark UI → SQL tab → 点击对应Job → 查看Physical Plan。健康状态应显示:*Filter (isnotnull(ds#1) && (ds#1 = 20240101))前有*Scan parquet ...,且Input Size远小于Total Size。若Input Size接近Total Size,说明下推失败,需检查谓词写法或配置。业务层压测:
用time命令对同一查询压测10次,取中位数:# 优化前 time spark-sql -e "SELECT COUNT(*) FROM orders WHERE ds='20240101' AND status='paid';" # 优化后 time spark-sql -e "SELECT COUNT(*) FROM orders WHERE ds='20240101' AND status='paid';"我们的结果:
查询 优化前(秒) 优化后(秒) 提速 Input Size减少 Q1 128.4 2.7 47.6x 98.3% Q2 215.1 3.9 55.2x 99.1% Q3 89.6 4.2 21.3x 95.3% Q4 167.8 8.1 20.7x 92.7% Q5 42.3 13.6 3.1x 67.8%
5. 常见问题与排查技巧实录:那些文档不会写的坑
5.1 问题1:明明写了filters=,Pandas读取却还是全量加载?
现象:PyArrow代码中dataset.to_table(filter=...),但htop显示Python进程内存暴涨,日志显示Reading 2.1TB of data。
根因:filters=只对pyarrow.dataset有效,对pd.read_parquet()无效!后者是Pandas封装层,不传递下推逻辑。
解决方案:
- 彻底弃用
pd.read_parquet(),改用pyarrow.dataset:import pyarrow.dataset as ds dataset = ds.dataset("path/to/parquet", format="parquet") table = dataset.to_table(filter=ds.field("ds") == "20240101") # 正确 df = table.to_pandas() # 转为Pandas仅在最后一步 - 若必须用
pd.read_parquet(),只能靠filesystem参数配合use_threads=True提升IO,但无法跳过。
排查技巧:在
to_table()调用前加print(dataset.files),确认只列出匹配分区的文件;若列出全部文件,说明filter=未生效或分区路径未被识别。
5.2 问题2:WHERE created_at > '2024-01-01'不跳过旧Row Group?
现象:created_at列有min/max统计,但查询仍扫描所有Row Group。
根因:created_at是TIMESTAMP类型,但Parquet中存储为INT96或INT64(微秒/毫秒时间戳),而查询字符串'2024-01-01'被引擎解析为DATE类型,类型不匹配导致统计无法比较。
解决方案:
- 写入时统一用
TIMESTAMP_MICROS逻辑类型,并确保查询用相同精度:-- 正确:用微秒时间戳字符串 WHERE created_at > '2024-01-01 00:00:00.000000' -- 或用cast强制转换 WHERE created_at > CAST('2024-01-01' AS TIMESTAMP) - 更稳妥:写入前将
created_at转为DATE类型(仅存日期),若需时间精度,拆分为date和hour两列分别分区和统计。
5.3 问题3:布隆过滤器启用后,IN查询反而变慢?
现象:对user_id启用BF后,WHERE user_id IN ('U1','U2','U3')耗时从2.1秒增至3.8秒。
根因:IN列表过短(<5个值),引擎未触发BF优化路径,而是回退到逐个Page的字典查找;同时BF元数据加载增加了额外开销。
解决方案:
- BF对
IN查询的优化阈值因引擎而异。Spark中,IN列表长度≥10时才启用BF;Trino中为≥5。 - 对短
IN列表,应依赖字典编码+统计信息:确保user_id列已启用字典编码(use_dictionary=True),且IN值在字典中存在,则引擎可快速定位Page。 - 生产实践中,我们将短
IN查询(≤5个ID)走user_id字典路径,长IN(≥10个ID)走BF路径,通过应用层路由。
5.4 问题4:分区字段ds用'2024-01-01'格式,但WHERE ds >= '2024-01-01'不跳过?
现象:ds分区为ds=2024-01-01/目录,但范围查询未跳过旧分区。
根因:分区值'2024-01-01'是字符串,>=比较按字典序,'2024-01-02'>'2024-01-01'成立,但'2024-01-10'<'2024-01-02'(因'1'<'2'),导致ds >= '2024-01-02'时,ds='2024-01-10'被错误跳过。
解决方案:
- 强制用
YYYYMMDD格式:写入时将ds转为'20240101',确保字典序=时间序。 - 或改用
DATE类型分区:Spark中df.withColumn("ds", col("event_time").cast("date")),再partitionBy("ds"),此时ds是DATE类型,>=比较按日期语义。 - 验证:
ls s3://data/orders/ | grep -E "ds=202401[0-9]{2}" | sort,确认目录名严格升序。
5.5 问题5:统计信息显示min=100, max=200,但WHERE amount > 150仍扫描所有Row Group?
现象:amount列统计正常,但过滤无跳过。
根因:amount是DOUBLE类型,而统计信息的min/max是浮点数,存在精度丢失。Parquet中DOUBLE的min/max统计可能被截断为100.0和200.0,但实际数据有150.00000000000003,引擎为安全起见,不信任浮点统计。
解决方案:
- 对金额类字段,强制转为
DECIMAL:df.withColumn("amount", col("amount").cast("decimal(18,2)"))。DECIMAL的统计是精确的。 - 或改用
INT:将金额乘以100存为分,amount_cents,统计绝对精确。 - 若必须用
DOUBLE,可接受一定误差,但需知其局限。
6. 工具选型与生态适配:不同引擎下的过滤能力地图
Parquet是格式标准,但各引擎对过滤特性的支持程度天差地别。这不是“谁更好”,而是“谁更适合你的栈”。
6.1 Spark:企业级首选,但配置是艺术
- 优势:谓词下推最成熟,支持复杂嵌套谓词(
WHERE a.b.c = 1),自动处理分区裁剪、统计跳过、BF,且与Delta Lake、Iceberg深度集成。 - 短板:对
STRING列的字典编码统计,若字符串含Unicode特殊字符,min/max可能异常;需升级至3.3+版本修复。 - 关键配置:
spark.sql.parquet.filterPushdown=true(默认true)spark.sql.hive.convertMetastoreParquet=true(启用Catalyst原生读取器)spark.sql.adaptive.enabled=true(自适应优化,动态调整过滤策略)
6.2 Trino:即席查询利器,但元数据敏感
- 优势:启动快,无状态,特别适合BI工具直连;对
BETWEEN、IN类查询优化激进。 - 短板:极度依赖Hive Metastore的分区元数据准确性;若
ALTER TABLE ADD PARTITION后未MSCK REPAIR TABLE,分区不被识别,导致全表扫描。 - 关键配置:
hive.parquet.ignore-stats=false(必须false!)hive.parquet.use-column-names=true(列名映射)hive.parquet.use-bloom-filters=true(BF开关)
6.3 PyArrow/DuckDB:轻量级之王,但需手动管理
- 优势:零依赖,纯Python,DuckDB内置Parquet支持,
SELECT * FROM 'file.parquet' WHERE ...语法即开即用,对单文件过滤极快。 - 短板:无分布式能力,不支持跨文件分区裁剪;DuckDB的BF支持尚在实验阶段(v0.9+)。
- 适用场景:本地开发、小规模ETL、Notebook探索。
- 最佳实践:用
pyarrow.dataset构建逻辑视图,filter=参数是生命线;DuckDB中确保PRAGMA enable_object_cache开启,复用元数据。
6.4 Flink:流批一体,但过滤是弱项
- 现状:Flink 1.17+支持Parquet读取,但谓词下推能力较弱,主要依赖分区裁剪,统计跳过支持有限。
- 建议:Flink作业中,过滤尽量前置到Kafka或Pulsar消费者层;Parquet作为归档存储,查询走Spark/Trino。
最后分享一个小技巧:在CI/CD中加入Parquet健康检查。用PyArrow写一个脚本,遍历新生成的Parquet文件,校验:① 每列是否有min/max;② 高基数列是否有BF;③ 分区目录名是否符合
YYYYMMDD格式;④ Row Group大小是否在200-300MB区间。不通过则阻断发布。我们靠这个脚本,在上线前拦截了17次因配置错误导致的过滤失效事故。
