1. 背景

线上有一个数据接入任务,通过 Airflow 调度 DataX 将 PostgreSQL 的某张主数据表全量同步到 StarRocks。

某天任务突然失败,Airflow 日志报错:

报错信息
1
2
3
4
5
java.io.IOException: Failed to flush data to StarRocks.
too many filtered rows
Error: NULL value in non-nullable column 'name'.
Error: NULL value in non-nullable column 'short_name'.
Error: NULL value in non-nullable column 'biz_scope'.

关键信息:

  • 报错发生在 StarRocks Stream Load 写入阶段,不是 DataX reader 读取阶段
  • 报错的列(nameshort_namebiz_scope)在 StarRocks 目标表中定义为 NOT NULL
  • 源库 PostgreSQL 中这些列 确实有值,不是 NULL
  • 只有少量行(6 条)受影响,其余 8110 条正常写入

2. 排查过程

2.1 第一反应:源数据是 NULL?

看到 NULL value in non-nullable column,第一反应是源库数据就是 NULL。

直接查源库:

1
2
3
SELECT id, name, length(name) as name_len
FROM public.t_master_data
WHERE id IN (10001, 10002, 10003);

结果:三条数据的 name 都有值,长度为 363 个字符,内容是一段包含中文、特殊字符的长字符串。

排除

源数据不是 NULL,问题不在源端。

2.2 第二反应:分隔符冲突?

name 的值包含大量特殊字符(<>?:"{}|+_)(*&^%$#@!~ 等),而 StarRocks Stream Load 使用的是 CSV 模式,配置了自定义分隔符:

1
2
3
4
"loadProps": {
"column_separator": "\\x01",
"row_delimiter": "\\x02"
}

怀疑 name 值中包含 \x01\x02,导致 Stream Load 解析时列错位。

验证方式:将 loadProps 改为 JSON 格式,彻底避免分隔符问题:

1
2
3
4
"loadProps": {
"format": "json",
"strip_outer_array": "true"
}

结果:换成 JSON 格式后,报错完全一样。同样的行、同样的列、同样的 NULL 错误。

排除

不是分隔符冲突的问题。CSV 和 JSON 两种格式都报同样的错,说明问题在更上游。

2.3 第三反应:DataX reader 读出来就是 NULL?

既然源库有值,Stream Load 收到的却是 NULL,那是不是 DataX 的 PostgreSQL reader 在读取时把值丢了?

查看 DataX reader 源码,VARCHAR 类型的处理逻辑:

1
2
3
4
5
// 伪代码:DataX reader 读取 VARCHAR 字段
case Types.VARCHAR:
String rawData = resultSet.getString(columnIndex);
record.addColumn(new StringColumn(rawData));
break;

resultSet.getString() 对于正常的 VARCHAR 数据不会返回 NULL(除非源数据本身就是 NULL)。而且 DataX 的统计信息显示 读出记录总数 8116,读写失败总数 0,说明 reader 端读取完全正常。

排除

DataX reader 正常读到了所有数据,包括那 6 条”问题行”。数据在 reader 端没有丢失。

2.4 关键转折:对比源表和目标表的列定义

前面三个方向都排除了,回到最基本的问题:StarRocks 说收到了 NULL,但数据确实发过去了,那只有一种可能——StarRocks 在写入时主动丢弃了这个值

什么情况下 StarRocks 会把一个非 NULL 的值变成 NULL?值超过了列定义的长度限制。

查 StarRocks 目标表的列定义:

1
2
3
4
5
SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'target_db'
AND TABLE_NAME = 't_master_data'
AND COLUMN_NAME IN ('name', 'short_name');
列名 类型 最大长度 允许 NULL
name VARCHAR 255 NO
short_name VARCHAR 255 NO

再看源数据的实际长度:

id name 长度(字符数)
10001 363
10002 363
10003 363
破案了

源数据 name 有 363 个字符,StarRocks 的 name 列定义为 VARCHAR(255)363 > 255,超长了。

2.5 根因确认:VARCHAR(n) 的语义差异

StarRocks 目标表是根据 PostgreSQL 源表的 DDL 自动生成的,两边都是 VARCHAR(255)。但问题在于:

核心问题

PostgreSQL 和 StarRocks 对 VARCHAR(n) 中 n 的定义不同:

  • PostgreSQL:n 表示 字符数,一个中文算 1 个字符
  • StarRocks:n 表示 字节数,UTF-8 下一个中文占 3 个字节

所以同样是 VARCHAR(255)

数据库 最多存储中文字符数 最多存储字节数
PostgreSQL 255 个 ~765 字节
StarRocks ~85 个 255 字节

而且 PostgreSQL 对 VARCHAR 长度的校验本身就比较宽松,源表中已经存在超过 255 个字符的历史数据(363 字符)。这些数据同步到 StarRocks 时,因为 UTF-8 编码后远超 255 字节,StarRocks 不会截断,而是直接将值置为 NULL,再加上 NOT NULL 约束,就报错了。

同样的问题也影响了 short_namebiz_scope 列。

3. 问题代码

后端项目中,根据源表 DDL 自动生成 StarRocks 建表语句的逻辑,直接将源库的 VARCHAR(n) 原样映射为 SR 的 VARCHAR(n),没有考虑两个数据库对长度单位的差异。

1
2
3
4
5
// 伪代码:自动建表的类型映射逻辑
if (sourceType == "VARCHAR") {
// ❌ 直接照搬长度,没有考虑字符数 vs 字节数的差异
targetType = "VARCHAR(" + sourceLength + ")";
}

这个问题不仅影响 PostgreSQL,MySQL 的 VARCHAR(n) 同样是字符数语义(MySQL 5.0+ 之后),直接映射到 StarRocks 也会有同样的问题。

4. 修复方案

修改自动建表的类型映射逻辑

修复规则

源库(PG / MySQL)的 VARCHAR(n) 映射到 StarRocks 时,长度应乘以 3,即 VARCHAR(n)VARCHAR(n * 3)

1
2
3
4
5
6
7
8
9
10
11
// 伪代码:修复后的类型映射逻辑
if (sourceType == "VARCHAR" || sourceType == "CHAR") {
long targetLength = sourceLength * 3;
if (targetLength <= 65533) {
targetType = "VARCHAR(" + targetLength + ")";
} else {
targetType = "STRING";
}
} else if (sourceType in ["TEXT", "MEDIUMTEXT", "LONGTEXT", "TINYTEXT"]) {
targetType = "STRING";
}

完整映射规则:

源库类型 条件 StarRocks 类型
VARCHAR(n) n * 3 ≤ 65533 VARCHAR(n * 3)
VARCHAR(n) n * 3 > 65533 STRING
CHAR(n) n * 3 ≤ 65533 VARCHAR(n * 3)
CHAR(n) n * 3 > 65533 STRING
TEXT STRING
MEDIUMTEXT(MySQL) STRING
LONGTEXT(MySQL) STRING
TINYTEXT(MySQL) STRING

65533 是 StarRocks VARCHAR 的最大字节长度限制。

5. 为什么这个 Bug 特别隐蔽?

这个 Bug 有几个特点让它很难被发现:

5.1 大部分数据正常

8116 条数据中只有 6 条超长,占比不到 0.1%。如果 DataX 配置了 errorLimitmax_filter_ratio,这些行会被 静默丢弃,任务显示成功,但数据已经丢了。

5.2 DataX 统计信息会误导

当配置了 max_filter_ratio 时,DataX 的任务统计会显示:

1
2
读出记录总数: 8116
读写失败总数: 0

看起来完全成功,但实际上 StarRocks 只写入了 8110 条。DataX 认为自己成功了,因为 Stream Load 在 max_filter_ratio 范围内返回了成功。

5.3 报错信息有误导性

NULL value in non-nullable column 'name' 这个错误信息会让人以为源数据是 NULL,或者 DataX 传输过程中丢了数据。实际上数据完整传过去了,是 StarRocks 在写入时因为超长主动丢弃了值

5.4 源表定义和目标表定义”看起来一样”

两边都是 VARCHAR(255),表面上完全一致,但语义不同(字符 vs 字节)。如果不了解这个差异,很难想到是长度问题。

6. 总结

6.1 根因链

1
2
3
4
5
6
7
8
源库 VARCHAR(n) 的 n = 字符数
↓ 自动建表时原样映射
目标库 StarRocks VARCHAR(n) 的 n = 字节数
↓ 源数据包含中文/特殊字符,UTF-8 编码后字节数远超 n
↓ StarRocks Stream Load 写入时,超长值被置为 NULL(不截断)
↓ 目标列定义为 NOT NULL
↓ 触发 "NULL value in non-nullable column" 报错
↓ 如果配了 max_filter_ratio,错误行被静默丢弃,数据无声丢失

6.2 经验教训

  1. 不同数据库对 VARCHAR(n) 的长度语义不同。PostgreSQL 和 MySQL 是字符数,StarRocks 是字节数。跨库同步时必须做长度换算,建议统一乘以 3(UTF-8 最大字节数)。

  2. StarRocks 对超长 VARCHAR 的处理是置 NULL 而非截断。这个行为和 MySQL 的 strict mode 类似,但更隐蔽,因为它不会在 DataX 层面报错,只在 Stream Load 的 Error URL 里才能看到。

  3. 不要依赖 max_filter_ratio 来”容忍”错误。它会让数据静默丢失,DataX 任务显示成功但实际数据不完整。生产环境建议设为 0,有问题就报错。

  4. DataX 的”读写失败总数: 0”不代表数据完整。Stream Load 在 max_filter_ratio 范围内返回成功,DataX 就认为全部成功了,不会感知到被过滤的行。

一句话总结

跨数据库同步时,VARCHAR(n) 的 n 不是一个通用概念。字符数 ≠ 字节数,这个差异会导致数据静默丢失。