前几天线上出了一个数据治理的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年了,数据越来越重要,数据治理也越来越受重视。希望我的这次踩坑经历,能帮大家避免类似的问题。
最后,感谢那晚和我一起排查的同事,也感谢咖啡的陪伴。线上问题不可怕,可怕的是不总结、不改进。每次排查都是一次学习,把经验记录下来,才能不断进步。
祝大家的系统数据准确,永不丢数。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录