前几天线上出了一个数据治理的Bug。我们的数据同步任务,偶尔会丢数据,每天大概有几十条记录同步失败。这个问题不是必现的,时好时坏,很难复现。

我排查了一夜,从数据源查到目标库,从同步程序查到网络,最后发现竟然是一个很隐蔽的编码问题。这个Bug藏得很深,涉及到字符集、数据类型、批量处理等多个环节。

今天详细记录这次Bug的排查过程,包括问题现象、排查思路、根因分析和解决方案,以及数据治理中的一些经验教训。希望能帮大家避免类似的坑。

一、问题现象

那天下午,数据组的同学找我说,数据看板上的数字对不上。目标库里的数据量,比源库少了几十条。而且每天少的数量不一样,有时候20条,有时候50条,没有规律。

我们的数据同步任务是这样的:从业务库(MySQL)抽取数据,经过清洗转换,写入到数据仓库(也是MySQL)。用的是自己写的同步程序,全量+增量同步,每天跑一次。

我先看了同步任务的日志,发现任务显示成功,没有报错。但数据确实少了。这就奇怪了,任务成功了,为什么数据会少?

我开始排查。

二、排查过程

第一步:对比源库和目标库

我先写了一个脚本,对比源库和目标库的数据,找出哪些记录丢了。

对比发现,丢失的记录有一个共同点:这些记录的某个字段(商品描述)里,包含了一些特殊字符,比如emoji表情、生僻字、特殊符号。

哦?这看起来像是字符集的问题。但我们的数据库都是utf8mb4,应该支持emoji啊。

我进一步检查,发现丢失的记录里,特殊字符都出现在字段的末尾,而且是4字节的utf8mb4字符(比如emoji)。

第二步:检查同步程序的读取逻辑

我去看同步程序的代码。程序是用Java写的,用JDBC连接MySQL,读取源库数据,然后批量写入目标库。

读取逻辑看起来没问题:

String sql = "SELECT id, name, description FROM products";
ResultSet rs = stmt.executeQuery(sql);
while (rs.next()) {
    Product p = new Product();
    p.setId(rs.getLong("id"));
    p.setName(rs.getString("name"));
    p.setDescription(rs.getString("description"));
    list.add(p);
}

我加了日志,打印读取到的记录数和丢失记录的内容。发现源库读取是正常的,丢失的记录都能读到,description字段的内容也完整。

那问题出在写入环节?

第三步:检查写入逻辑

写入逻辑是批量插入:

String sql = "INSERT INTO products (id, name, description) VALUES (?, ?, ?)";
PreparedStatement ps = conn.prepareStatement(sql);
for (Product p : list) {
    ps.setLong(1, p.getId());
    ps.setString(2, p.getName());
    ps.setString(3, p.getDescription());
    ps.addBatch();
}
ps.executeBatch();

看起来也没问题。我加了日志,打印批量插入的数量和返回结果。发现executeBatch()返回的成功数,比list的大小少了几条。而且没有抛异常。

这就奇怪了,executeBatch()为什么会静默失败?

我查了一下JDBC的文档,发现executeBatch()的行为取决于驱动的配置。默认情况下,如果批量中的某一条失败,驱动可能会继续执行后面的,返回每个语句的结果(成功或失败),而不是抛异常。

我打印了executeBatch()的返回数组,发现丢失的那几条,返回值是-3(EXECUTE_FAILED)。也就是说,这几条插入失败了,但程序没有检查返回值,以为都成功了。

找到问题了!但为什么这几条会插入失败?

第四步:定位插入失败的原因

我把失败的记录单独拿出来,手动执行插入,看报什么错。

手动执行时报错:

Incorrect string value: '\xF0\x9F\x98\x80' for column 'description' at row 1

哦!果然是字符集的问题。虽然数据库是utf8mb4,但连接的字符集不是utf8mb4!

我检查了JDBC连接配置,发现连接URL里没有指定characterEncoding,而且useServerPrepStmts=true(使用服务端预处理语句)。

问题出在这里:当useServerPrepStmts=true时,PreparedStatement的参数是用二进制协议发送的,字符集由连接的charactersetclient决定。如果连接的字符集是utf8(3字节),而不是utf8mb4(4字节),那么4字节的emoji就会被截断或者报错。

但为什么有时候成功有时候失败?因为不是所有记录都有4字节字符,只有包含emoji或生僻字的记录才会失败。这就解释了为什么每天丢失的数量不一样。

第五步:为什么连接字符集不对

我检查了数据库的配置,发现:

  • 数据库的charactersetdatabase是utf8mb4
  • 但服务器的charactersetserver是utf8(3字节的utf8,不是utf8mb4)

JDBC连接时,如果没有指定characterEncoding,驱动会自动检测服务器的charactersetserver。因为服务器是utf8,所以连接的字符集也是utf8,不是utf8mb4。

这就导致了:数据库能存utf8mb4,但连接用的是utf8,4字节字符传不过去。

而且,我们的同步程序有两个连接,一个读源库,一个写目标库。源库的连接因为某种原因(可能是驱动版本不同)自动用了utf8mb4,所以读取正常;目标库的连接用了utf8,所以写入失败。

这就是为什么读取正常但写入失败,而且问题很隐蔽。

三、根因分析

排查了一夜,终于找到了根本原因。总结一下:

直接原因: 目标库的JDBC连接字符集是utf8(3字节),不是utf8mb4(4字节),导致包含4字节字符(emoji、生僻字)的记录插入失败。

为什么没发现: 批量插入executeBatch()没有检查返回值,失败的记录被静默忽略了,程序显示成功。

为什么时好时坏: 只有包含4字节字符的记录才会失败,每天的数据不同,失败的数量也不同。

为什么连接字符集不对: 服务器的charactersetserver配置成了utf8而不是utf8mb4,JDBC驱动自动检测时用了服务器的配置。

这个问题涉及了多个环节:数据库配置、JDBC连接参数、批量处理、错误处理。任何一个环节做好了,都能避免这个问题。

四、解决方案

找到原因后,我从几个方面做了修复。

1. 修复连接字符集

在JDBC连接URL里显式指定characterEncoding=utf8mb4:

jdbc:mysql://host:3306/db?useUnicode=true&characterEncoding=utf8mb4&useServerPrepStmts=true

注意:一定要显式指定,不要依赖自动检测。即使服务器配置是utf8mb4,也建议显式指定,避免环境差异导致问题。

2. 修复数据库配置

修改MySQL配置文件,把charactersetserver改成utf8mb4:

[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci

然后重启MySQL。这样所有新连接默认都是utf8mb4。

3. 检查批量插入的返回值

修改代码,检查executeBatch()的返回值,失败的记录要记录日志并重试:

int[] results = ps.executeBatch();
for (int i = 0; i < results.length; i++) {
    if (results[i] == Statement.EXECUTE_FAILED) {
        // 记录失败的记录,重试或告警
        log.error("插入失败: {}", list.get(i));
    }
}

不要假设批量插入都成功,一定要检查返回值。

4. 增加数据校验

在同步任务结束后,增加数据校验步骤:对比源库和目标库的记录数和关键字段,不一致就告警。这样即使有数据丢失,也能及时发现,而不是等用户发现。

5. 修复历史数据

对丢失的记录,重新同步。写了一个修复脚本,找出所有包含4字节字符的记录,重新写入目标库。

五、数据治理的经验教训

这次Bug给了我很多教训,也总结了一些数据治理的经验。

1. 字符集一定要统一

这是最基本也是最容易出问题的地方。数据库、表、字段、连接、应用程序,所有环节的字符集都要统一,建议都用utf8mb4。

  • 数据库:charactersetserver=utf8mb4
  • 表和字段:CHARACTER SET utf8mb4 COLLATE utf8mb4unicodeci
  • 连接:JDBC URL指定characterEncoding=utf8mb4
  • 应用程序:字符串处理用UTF-8

不要混用utf8和utf8mb4,MySQL的utf8是3字节的,不是真正的UTF-8,支持不了emoji和生僻字。

2. 批量操作一定要检查结果

批量插入、批量更新,不要假设都成功。一定要检查返回值,失败的要记录、重试、告警。静默失败是数据丢失的常见原因。

3. 数据同步要有校验

数据同步任务不能只看任务成功失败,还要校验数据的一致性。同步完之后,对比源和目标的记录数、关键字段、抽样数据,不一致就告警。

可以用数据质量工具(比如Great Expectations、Apache Griffin)做自动化校验。

4. 监控要完善

  • 监控同步任务的成功率和耗时
  • 监控源和目标的数据量差异
  • 监控失败记录的数量
  • 设置告警阈值,异常时及时通知

不要等用户发现数据不对了才去查,要主动监控。

5. 日志要详细

排查问题的时候,日志是最重要的线索。同步程序要记录:

  • 读取了多少条记录
  • 写入了多少条,失败了多少条
  • 失败记录的详细信息(ID、错误原因)
  • 每个步骤的耗时

有了详细的日志,排查问题才能快。

6. 测试要覆盖边界情况

测试数据要包含各种边界情况:特殊字符、空值、超长字符串、emoji、生僻字、并发写入等。很多Bug只在边界情况下出现,正常测试发现不了。

六、线上问题排查的方法论

这次排查也让我总结了一些线上问题排查的方法论。

1. 先复现,再排查

尽量先复现问题,有了稳定的复现步骤,排查就快了。如果不能复现,就加日志、加监控,收集更多信息。

2. 从数据流向一步步查

数据从源到目标,经过了哪些环节?读取、转换、写入、网络、存储。从上游到下游,一步步排查,缩小范围。

3. 对比正常和异常

找出正常的记录和异常的记录有什么不同。这次就是通过对比,发现丢失的记录都有特殊字符,从而定位到字符集问题。

4. 不要假设,要验证

不要假设"数据库是utf8mb4就没问题""批量插入不会失败"。要实际检查配置、查看日志、验证行为。很多Bug就出在你认为"不可能"的地方。

5. 看日志、看文档、看源码

遇到不懂的行为,查官方文档,看源码实现。这次executeBatch()的静默失败,就是查了JDBC文档才明白的。

七、写在最后

一个字符集的小问题,导致了数据丢失,排查了一夜。这告诉我们,数据治理无小事,任何一个环节的疏忽都可能导致大问题。

总结这次的教训:

  • 字符集统一用utf8mb4,连接显式指定
  • 批量操作检查返回值,不要静默失败
  • 数据同步要有校验和监控
  • 日志要详细,测试要覆盖边界
  • 排查问题要一步步来,不要假设

数据治理是一个细致活,需要耐心和严谨。每一个数据都关系到业务决策,数据丢失或错误可能导致严重后果。我们要对数据有敬畏之心,做好每一个环节。

2022年了,数据越来越重要,数据治理也越来越受重视。希望我的这次踩坑经历,能帮大家避免类似的问题。

最后,感谢那晚和我一起排查的同事,也感谢咖啡的陪伴。线上问题不可怕,可怕的是不总结、不改进。每次排查都是一次学习,把经验记录下来,才能不断进步。

祝大家的系统数据准确,永不丢数。