记一次Java字符集问题排查
问题描述
Oceanbase历史库平台最近接入了一些从Mysql库导入的项目,但是在校验的过程中发现有几列数据不一致的情况,对于一个数据迁移工具来说,这是绝对不能接受的错误,于是我们赶紧去查看了错误日志,发现错误的数据长这样:
在线库:鎱曠鑺?
历史库:鎱曠鑺?
怎么看起来一毛一样?我们打印出了两个字符串的unicode看一下:
source[0] = 93b1
source[1] = 66e0
source[2] = e716 //不一致的编码
source[3] = 947a
source[4] = 3f
target[0] = 93b1
target[1] = 66e0
target[2] = e6f7 //不一致的编码
target[3] = 947a
target[4] = 3f
原来是中间那个不可见字符不一样,那是什么原因导致的呢?
问题分析
迁移程序读取和写入数据的逻辑很简单,对于字符串类型的数据,reader从ResultSet中通过getString方法得到一个Java的String对象,writer把这个String对象通过调用PreparedStatement.setString方法来写入目的端。
这个报错的迁移任务的源端Mysql库使用的是gbk字符集(database和table级别都是),而Oceanbase历史库使用的是utf8(database和table级别都是)。我们的迁移程序没有修改任何字符集设置,也就意味着字符串数据会以gbk编码从mysql server端传输到mysql java client,在getString时会以gbk编码解析成String对象(unicode字符),在setString时,String对象又会从unicode字符编码成utf-8格式写入Oceanbase,在这个过程中虽然有字符集的转化,但应该是无损的。
在Unicode标准中,e000—f8ff这段区间称为Private Use Area,是预留给第三方机构自定义字符的,这一段区间里的code是不会分配给标准unicode字符的。我们在分析问题的过程中发现,Java 1.6和1.7版本的gbk编码对于Private Use Area的处理不一样,其中的一部分code用gbk编码后的值在两个版本里不一致。以我们上面发现的那个不一致的unicode字符(e716)为例,分别用Java 1.6和1.7使用gbk编码(调用getBytes("gbk"))的结果如下:
1.6
gbk_bytes[0] = 0xa6
gbk_bytes[1] = 0x92
1.7
gbk_bytes[0] = 0xa7
gbk_bytes[1] = 0x50
而utf8编码在1.6和1.7上是一致的,同样的unicode字符在两个版本上编码是一样的。基于这个发现,我们分析一下这个不一致具体是怎样发生的。
首先,源端Mysql传回来的字节流是这样:
source_bytes[0] = 0xa6 //gbk
source_bytes[1] = 0x92
在我们的迁移平台中,迁移和校验是两个不同的任务,它们可能被两个不同的迁移客户端执行,迁移客户端部署的机器中一部分是Java 1.6一部分是Java 1.7。这个迁移任务正好被一个运行在Java 1.7上的客户端执行,于是这个gbk字节流被解码成了e6f7
然后编码成utf8写入了Oceanbase:
utf8-bytes[6] = 0xee
utf8-bytes[7] = 0x9b
utf8-bytes[8] = 0xb7
然后校验任务正好被一个运行在Java 1.6上的客户端执行,校验任务会分别从Mysql端和Oceanbase端读取数据然后进行比对。从Mysql传回来的字节流依然是0x96 0x92
,但是Java 1.6会把它解码成e716
。而从Oceanbase读出来的就是上面写进去的utf-8字节码,解码出来依然是e6f7
,于是就产生了不一致。
解决方法
这个问题有两个解决方法
1. 把所有部署迁移客户端的机器都升级到同一个Java版本。
2. 读取的时候发init_sql set character_set_results=utf8;
设置results使用utf8字符编码,让数据库帮我们做转码,这样可以避免不同Java版本编码不一致的问题。当然如果迁移和校验之间数据库升级了并且编码行为发生了变化,那依然会存在这个问题。