敬请指正

才疏学浅,若有不对之处烦请指正

前言

最近线上一个 DataX 同步任务翻车了——从 PostgreSQL 往 StarRocks 搬数据,8116 条记录里有 6 条莫名其妙地“消失”了。报错信息写的是 NULL value in non-nullable column,但源库里这些字段明明有值啊?

排查了一圈才发现,两个数据库对 VARCHAR(255) 的理解压根不是一回事。一个按字符算,一个按字节算,中文一进来直接超长,数据就这么无声无息地丢了。

这种 Bug 特别阴险——大部分数据正常,只有极少数行出问题,任务还显示成功。要不是这次刚好没配 max_filter_ratio 导致任务直接失败,可能到现在都没人发现数据少了。

参考链接

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 条正常写入

嗯……源库有值,目标库说是 NULL,这就很离谱了。

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 的处理逻辑
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,超长了。

等等,两边不都是 VARCHAR(255) 吗?PostgreSQL 能存 363 个字符,StarRocks 怎么就存不下了?

3. 根因分析

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 字节

差了整整 3 倍,这还怎么玩。

那源库里怎么会有 363 个字符的数据?

PostgreSQL 对 VARCHAR(n) 的长度校验是严格的,正常情况下不允许插入超过 n 个字符的数据。源表中出现 363 字符的历史数据,可能的原因:

  • 该列曾通过 ALTER TABLE 缩短过长度,但旧数据未清理
  • 源列实际类型是 TEXT,在自动建表逻辑中被误映射为 VARCHAR(255)

无论哪种情况,这些数据同步到 StarRocks 时,UTF-8 编码后远超 255 字节,StarRocks 不会截断,而是直接将值置为 NULL,再加上 NOT NULL 约束,就报错了。

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

4. 问题代码

后端项目中,根据源表 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 也会有同样的问题。

5. 修复方案

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

修复规则

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

关于乘以几的问题

n * 3 覆盖了绝大多数中文和拉丁字符。但 Emoji 和部分生僻字在 UTF-8 下占 4 字节,如果业务数据中 Emoji 较多,建议改为 n * 4,或对长文本列直接使用 STRING。

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

完整映射规则:

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

1048576(1 MB)是 StarRocks 2.1+ VARCHAR 的最大字节长度限制。STRING 类型等价于 VARCHAR(65533)。

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

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

6.1 大部分数据正常

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

补充

max_filter_ratio 在 DataX starrockswriter 中默认值为 0,即不容忍任何错误行。只有手动设为非 0 值(如 0.001)时,才会出现”任务成功但数据丢失”的现象。

6.2 DataX 统计信息会误导

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

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

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

6.3 报错信息有误导性

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

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

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

7. 总结

根因链

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,错误行被静默丢弃,数据无声丢失

经验教训

  1. 跨库同步 VARCHAR 必须换算长度。PG/MySQL 的 n 是字符数,StarRocks 的 n 是字节数,建议映射时统一乘以 3。
  2. StarRocks 超长不截断,直接置 NULL。这个行为比报错更危险,因为配了 max_filter_ratio 后数据会静默丢失,DataX 还显示”读写失败总数: 0”。生产环境 max_filter_ratio 建议设为 0。
一句话总结

VARCHAR(n) 的 n 不是通用概念,字符数 ≠ 字节数。