这一段链路里没有任何“计算错误”。数字都是对的,SQL 都能跑,坏在两种 CSV 方言之间——写的一方和读的一方对“什么叫一列”的理解不一样。

01装载失败只有两种长相

Doris 侧的报错基本只有两类:

text
actual column number in csv file is less than schema column number
too many filtered rows        →  NumberLoadedRows: 0

第一条是列数不对,第二条是列内容解析不过。而这里有个让我吃过亏的默认值:Stream Load 的 max_filter_ratio 默认是 0,意思是“任何一行解析失败,就整批拒绝”。所以我看到的症状是“全部行被过滤、加载零行”,而真正坏的只有少数几行——报错把规模说大了,容易让人以为整份文件都不对。

读这类报错的正确姿势是先问“它是拒绝还是丢弃”:列数不匹配是结构性拒绝(整批无法解析),filtered rows 是行级丢弃(其它行本来可以进)。两种修法完全不同。

02Spark 的 CSV writer 会写 N 次表头

这是最隐蔽的一个。header=true 的语义不是“整个输出写一次表头”,而是每个 writer 打开自己的文件时写一次。所以一次多分区输出会产生 N 个 part 文件、每个都带表头;我再用 hdfs dfs -getmerge 把它们顺序拼成一个文件,结果就是数据中间夹着若干行表头文本。

这些行喂给 Doris 就是典型的 filtered rows:表头文字落到了数值列上。

scala
df.coalesce(1).write.option("header", "true").csv(path)

coalesce(1) 是把“单文件、单表头”变成前提条件的唯一可靠办法。代价是导出这一步不再并行——对服务层这种小结果集我接受。

更糟的是仓库里当时同时存在两条互相矛盾的写法:遗留导出 SQL 写的是 OPTIONS ('header' 'false') 并且不 coalesce(指望 getmerge 拼出来干净),而加载脚本用 -H "format: csv_with_names"(要求文件必须有表头)。两条路径各自都能跑通一部分表,合起来就是“不知道哪份 CSV 该不该带表头”。我最后统一到一条规则上:导出侧 coalesce(1) + 带表头,加载侧 csv_with_names。

03分隔符模式不认引号

Stream Load 用的是 -H "column_separator:,"——纯分隔符切分,不实现 CSV 的引号转义。而质量规则结果表里有一列 message 是自由文本(规则生成的说明、Deequ 返回的诊断串),里面出现逗号或换行就会把一行劈成多列、或者把多行并成一行。

修法不是换格式,是在导出时把危险字符替掉:

sql
regexp_replace(message, '[\\r\\n,]+', ' ')

这是个“降级换可控”的决定:message 只用来人读,不参与任何指标计算,所以丢掉原始换行不影响正确性。同样的处理我没有用在金额、枚举、id 这些列上——它们是契约的一部分,不该被清洗。

04列名是两套方言

质量结果由 Scala case class 直接 toDF() 写出,列名取字段名,所以是 camelCase:batchId、runTime、totalRules、qualityScore。Doris 的表结构是 snake_case:batch_id、run_time、total_rules、quality_score。

Stream Load 按位置装载,靠 -H "columns:..." 显式声明每列落到哪个字段,所以位置对了就能进。但这里埋着一个雷:表头文本仍然是 camelCase。任何“按表头名自动映射”的加载方式都会全列对不上,症状恰好就是前面那条 less than schema column number。

我的处置是让导出器做表头自适应(按 DDL 列序输出 snake_case 表头),而不是让人记住“这份文件的名字不能直接当列名用”。

还有一类更朴素的错:表结构里定义了一列 rank,而来源视图根本没有它,导出就少一列。补法是导出时开窗生成:

scala
val w = Window.orderBy(col("pagerank_score").desc)
df.withColumn("rank", row_number().over(w))

注意名次是导出层衍生的,不在湖仓结果里。这点要写进表的说明,否则下次会有人在湖侧再算一遍,两边口径就分叉了。

05同一族的另一件事:schema 不能靠推断

装载之外,建表那侧有两条语法/类型限制把我挡了很久:

text
Partition column types may not be specified in Create Table As Select (CTAS)
Cannot safely cast review_score "STRING" to "INT"

第一条是 Spark SQL 的硬约束:CTAS 里分区列的类型必须由 SELECT 结果推断,不许声明。所以“声明式建表 + 动态分区写入”必须拆两步——先 CREATE TABLE ... PARTITIONED BY (order_year INT, order_month INT),再 INSERT OVERWRITE TABLE ... PARTITION (order_year, order_month)。想省事写成一条就必然报错。

第二条更值得记:inferSchema=true 是采样推断。评分列里有空值、又曾因为采集层把行切碎而出现文本落在数值位上,采样窗口只要命中一次,整列就推成 STRING,写进声明为 INT 的目标表时被拒。修法就是不再让推断决定契约——显式 CAST。

这两条合起来是同一句话:分区列类型和数值列类型都必须由我声明,不能交给“看起来对”。

06这条契约没有写在一处

列序、列名、是否带表头、哪些列允许清洗——这四件事目前分别活在导出 SQL、Scala 写出代码、Doris DDL 和加载脚本里,靠人保持一致。正确形态应该是从 DDL 单一来源生成导出投影,让表头和列序天然对齐;我做到了“按 DDL 列序输出”,但没做到“从 DDL 读取列序”,所以 DDL 一改仍然要手工跟。这条按未完成记着。

07相关文章