一、问题背景
之前公司在使用datax时,需要从rds同步数据到hive,但是数据库中的主键id是uuid类型的字符串,使用datax默认的字符串分隔方式,其实会有很大的问题,所以官方也不推荐使用。
二. 分析源码
跟踪底层源码最终可以定位到这个RangeSplitUtil类上
public static String[] doAsciiStringSplit(String left, String right, int expectSliceNumber) {
int radix = 128;
BigInteger[] tempResult = doBigIntegerSplit(stringToBigInteger(left, radix),
stringToBigInteger(right, radix), expectSliceNumber);
String[] result = new String[tempResult.length];
//处理第一个字符串(因为:在转换为数字,再还原的时候,如果首字符刚好是 basic,则不知道应该添加多少个 basic)
result[0] = left;
result[tempResult.length - 1] = right;
for (int i = 1, len = tempResult.length - 1; i < len; i++) {
result[i] = bigIntegerToString(tempResult[i], radix);
}
return result;
}
/**
* 由于只支持 ascii 码对应字符,所以radix 范围为[1,128]
*/
public static BigInteger stringToBigInteger(String aString, int radix) {
if (null == aString) {
throw new IllegalArgumentException("参数 bigInteger 不能为空.");
}
checkIfBetweenRange(radix, 1, 128);
BigInteger result = BigInteger.ZERO;
BigInteger radixBigInteger = BigInteger.valueOf(radix);
int tempChar;
int k = 0;
for (int i = aString.length() - 1; i >= 0; i--) {
tempChar = aString.charAt(i);
if (tempChar >= 128) {
throw new IllegalArgumentException(String.format("根据字符串进行切分时仅支持 ASCII 字符串,而字符串:[%s]非 ASCII 字符串.", aString));
}
result = result.add(BigInteger.valueOf(tempChar).multiply(radixBigInteger.pow(k)));
k++;
}
return result;
}
/**
* 把BigInteger 转换为 String.注意:radix 和 basic 范围都为[1,128], radix + basic 的范围也必须在[1,128].
*/
private static String bigIntegerToString(BigInteger bigInteger, int radix) {
if (null == bigInteger) {
throw new IllegalArgumentException("参数 bigInteger 不能为空.");
}
checkIfBetweenRange(radix, 1, 128);
StringBuilder resultStringBuilder = new StringBuilder();
List<Integer> list = new ArrayList<Integer>();
BigInteger radixBigInteger = BigInteger.valueOf(radix);
BigInteger currentValue = bigInteger;
BigInteger quotient = currentValue.divide(radixBigInteger);
while (quotient.compareTo(BigInteger.ZERO) > 0) {
list.add(currentValue.remainder(radixBigInteger).intValue());
currentValue = currentValue.divide(radixBigInteger);
quotient = currentValue;
}
Collections.reverse(list);
if (list.isEmpty()) {
list.add(0, bigInteger.remainder(radixBigInteger).intValue());
}
Map<Integer, Character> map = new HashMap<Integer, Character>();
for (int i = 0; i < radix; i++) {
map.put(i, (char) (i));
}
// String msg = String.format("%s 转为 %s 进制,结果为:%s", bigInteger.longValue(), radix, list);
// System.out.println(msg);
for (Integer aList : list) {
resultStringBuilder.append(map.get(aList));
}
return resultStringBuilder.toString();
}
看过源码之后其实可以很清楚的了解到它的思路
- stringToBigInteger方法:主键id最小值-left,主键id最大值-right,将字符串整体看做一个128进制数(根据ascii码位数),依次将每个字符的数值乘以对应位置n的128的n次方,累加求和。最终可以得到2个bigint值,将这两个当做区间的左右边界
- bigIntegerToString:根据需要切分的个数,求出步长 step =(right-left)/ splitnum , 求出切分点 (left + step + 1),并将切分点重新按照步骤1的方式,反向除128求出每位对应的acsii码对应的字符,拼接起来组成一个新的字符串
- 拼接sql, 切点1 <= splitkey < 切点2 and ...
但这里有一个很严重的问题,我们都知道UUID的字符串其实是只有字母,数字和中划线组成的,但是当你将一个UUID的字符串转成int时,加上一个步长,重新除128计算出来的ascii码并不是刚好落在字母,数字和中划线上,这就导致你计算出来的切割点的字符串id并不是一个标准的UUID字符串,而是一个乱码的字符串,如下所示:
可以自己尝试一下
doAsciiStringSplit("0122ed89-adb0-4599-84b7-89cfeb544637","8d89bb48-b290-4d92-a9af-80b3f8bfc036", 5)
返回的分隔点结果为:
0122ed89-adb0-4599-84b7-89cfeb544637
:eLVA[\u001E(-b('\u0006z\u001Ai\u000EtG\rN%@G\u00048sw(X4W\u001A\u000Bp\u0003
E\u0019fz\u001DR\u0004\u0017-bkk]G\u0001\u001Cd/`bghI`P8\u0004\u0007kN3y\u007Fa,O
ON\u0001\u001DyHj\u0006-c/04\u0013gP9jz8\u0001+Rz\u001C7\u0014\u0018.D3\u001Ce6i\u001A
Z\u0002\u001BAU?Ou-cru\n`N\u0004\u000F&\u0014\r\u001An\\\u0013h6$(q:2?K\f%e
8d89bb48-b290-4d92-a9af-80b3f8bfc036
这样的分隔点,使用mysql语句去做查询的结果肯定是有问题的。因为我们知道mysql的字符排序与其设置的字符排序集有关,但如果按照ascii码大小排序来说,他这个切分的结果也是不准确的, '0' < ':' < 'E' < 'O' < 'Z' > '8' ,实际在数据库排序集utf8mb4_general_ci下的结果是 ':' < '0' < '8' < 'E' < 'O' < 'Z',因此使用原生切分后的切点作为范围去查询,在这个例子中就会出现有些task没有数据,有些task分到了全部的数据 ;肯定也会存在部分切点范围有交集的情况,就会多读数据,存在重复数据。
三. 改造源码
其实datax本身的思路没有什么问题,就是没有考虑到这个ascii码反向转换后的问题,因此我基于这个思路,改造了一下他的源码。
主要想法:UUID其实去掉中划线之后,是一个32位的16进制数(主要由0-9,a-f构成);而数据库中0-9,a-z的排序结果是一定的,即0<9<a<z
1、将原先的left和right去掉中划线后,转换为16进制数
2、求步长,求出切点大小
3、将计算后的切点的十六进制整数转为字符串,并按照UUID格式在相应的位置上加上中划线
public static String[] doUUIDStringSplit(String left, String right, int expectSliceNumber) {
BigInteger leftInteger = new BigInteger(left.replace("-", ""), 16);
BigInteger rightInteger = new BigInteger(right.replace("-", ""), 16);
BigInteger[] tempResult = doBigIntegerSplit(leftInteger, rightInteger, expectSliceNumber);
String[] result = new String[tempResult.length];
result[0] = left;
result[tempResult.length - 1] = right;
for (int i = 1, len = tempResult.length - 1; i < len; i++) {
result[i] = bigIntegerToString(tempResult[i]);
}
return result;
}
private static String bigIntegerToString(BigInteger bigInteger) {
String hex = bigInteger.toString(16);
List<String> list = new ArrayList<>();
Pattern pattern = Pattern.compile("([a-z0-9]{8})([a-z0-9]{4})([a-z0-9]{4})([a-z0-9]{4})([a-z0-9]{12})");
Matcher matcher = pattern.matcher(hex);
List<String> list = new ArrayList<>();
if (matcher.matches()){
for (int i = 1; i <=matcher.groupCount(); i++){
list.add(matcher.group(i));
}
}
return StringUtils.join(list, "-");
}
查看切分之后的结果
取一个范围跨度大的例子:
doUUIDStringSplit("0122ed89-adb0-4599-84b7-89cfeb544637","d65e165d-d69a-47da-b41e-4549401b0ab0", 5)
切分后的结果:
0122ed89-adb0-4599-84b7-89cfeb544637
2bc85c1a-82ab-dfa6-8e32-7c1b62af3a50
566dcaab-57a7-79b3-97ad-6e66da0a2e68
8113393c-2ca3-13c0-a128-60b251652280
abb8a7cd-019e-adcd-aaa3-52fdc8c01698
d65e165d-d69a-47da-b41e-4549401b0ab0
四、大数据量测试
首先准备一个测试表,多线程生成UUID入库,根据改良后的字符串分割进行计算切点
数据总数:
min(id), max(id):
取5个分片,生成切分点:
('0000049e-8bda-4111-a06e-c25f42403f57' <= id AND id < '333336ba-a3b7-dacb-9a1b-4fef4f6ed44e')
('333336ba-a3b7-dacb-9a1b-4fef4f6ed44e' <= id AND id < '666668d6-bb95-7485-93c7-dd7f5c9d6945')
('666668d6-bb95-7485-93c7-dd7f5c9d6945' <= id AND id < '99999af2-d373-0e3f-8d74-6b0f69cbfe3b')
('99999af2-d373-0e3f-8d74-6b0f69cbfe3b' <= id AND id < 'cccccd0e-eb50-a7f9-8720-f89f76fa9331')
('cccccd0e-eb50-a7f9-8720-f89f76fa9331' <= id AND id <= 'ffffff2b-032e-41b3-80cd-862f84292827')
分别count数量:
切点范围 | 切点1 | 切点2 | 切点3 | 切点4 | 切点5 |
count | 1602683 | 1604252 | 1603538 | 1602121 | 1607406 |
最后将每个cout数量累加计算,可得和实际的数据量大小相等。
五、总结
这种方式其实也不是特别的完美,分隔的后每个task分到的数据也不一定会很均衡尤其在数据量不大的情况下,在数据量大的情况下会分布均匀一些,但是至少克服了原先字符串分隔会出现的,重复读数据或者少读数据的情况。
如果真的想要做到每个task分布均匀的话,就需要借助自己去实现mysql的rownum函数,以及具体代码实现,这里提供一些思路
1、求出left,right对应的rownum
2、计算步长,以及切点对应的rownum
3、根据切点对应的rownum去数据库查询对应步长的id
SELECT id from
(
SELECT
@rownum:=@rownum+1 AS rownum,
t.id
FROM
(SELECT @rownum:=0) r,
(SELECT id from table order by id asc) t
) a where a.rownum = {切点的rownum}