跟我学shardingjdbc之分布式主键及其自定义
本文是 “跟我学Sharding-JDBC” 系列的第三篇,我将带领读者一起了解下Sharding-JDBC的分布式主键,并实现业务性更强的自定义主键。
首先了解下,什么是分布式主键。
传统的关系型数据库,如MySQL中,数据库本身自带自增主键生成机制,但在分布式环境下,由于分库分表导致数据水平拆分后无法使用单表自增主键,因此我们需要一种全局唯一id生成策略作为分布式主键。
当前业界已经有不少成熟的方案能够解决分布式主键的生成问题,如:UUID、SnoWflake算法(Twitter)、Leaf算法(美团点评)等。
UUID
UUID是Universally Unique Identifier的缩写,它是在一定的范围内(从特定的名字空间到全球)唯一的机器生成的标识符。
UUID具有如下特点:
- 经由一定的算法机器生成,算法定义了网卡MAC地址、时间戳、名字空间(Namespace)、随机或伪随机数、时序等元素,以及从这些元素生成UUID的算法。UUID的复杂特性在保证了其唯一性的同时,意味着只能由计算机生成。
- 非人工指定,非人工识别。UUID的复杂性决定了“一般人“不能直接从一个UUID知道哪个对象和它关联。
- 在特定的范围内重复的可能性极小。
UUID能够保证最少在3000+年内不会重复。因此它的唯一性是很可靠的。但也有不足之处,就是可读性差,不能直接用来做分片键并进行取模分库表的操作,需要进行额外的开发,如:转换UUID为unicode/ASCII码,对数字进行叠加后取模。
SnoWflake
雪花算法(SnoWflake)是Twitter公布的分布式主键生成算法,也是ShardingSphere默认提供的配置分布式主键生成策略方式。在ShardingSphere的类路径为:io.shardingsphere.core.keygen.DefaultKeyGenerator
SnoWflake能够保证不同进程主键的不重复性,以及相同进程内主键的有序性。
在同一个进程中,SnoWflake首先是通过时间位保证不重复,如果时间相同则是通过序列位保证。 同时由于时间位是单调递增的,且各个服务器如果大体做了时间同步,那么生成的主键在分布式环境可以认为是总体有序的,这就保证了对索引字段的插入的高效性。例如MySQL的Innodb存储引擎的主键。
雪花算法生成的主键的二进制表示形式包含4部分,从高位到低位分别为:1bit符号位、41bit时间戳位、10bit工作进程位以及12bit序列号位。
雪花算法能够保证全局唯一,同时也存在一些问题,如时钟回拨可能导致产生重复序列。为了解决这个问题,ShardingSphere默认分布式主键生成器提供了一个最大容忍的时钟回拨毫秒数。
如果时钟回拨的时间超过最大容忍的毫秒数阈值,则程序报错;如果在可容忍的范围内,默认分布式主键生成器会等待时钟同步到最后一次主键生成的时间后再继续工作。 最大容忍的时钟回拨毫秒数的默认值为0,可通过调用静态方法DefaultKeyGenerator.setMaxTolerateTimeDifferenceMilliseconds()设置。
其他方案
这里再简单介绍下其他的分布式主键生成的方案。
Leaf算法
对Leaf算法,推荐访问这个链接 Leaf——美团点评分布式ID生成系统
Redis计数器
我们还可以通过第三方的组件的特性二次开发自己的分布式id生成器。如:使用Redis的 INCR key自增计数器,它是 Redis 的原子性自增操作最直观的模式,其原理相当简单:每当某个操作发生时,向 Redis 发送一个 INCR 命令。
比如在一个 web 应用中,想知道用户在一年中每天的点击量,那么只要将用户ID及相关的日期信息作为键,并在每次用户点击页面时,执行一次自增操作即可。
它有着多种扩展模式,如:
- 通过组合使用 INCR 和 EXPIRE达到只在规定的生存时间内进行计数(counting)的目的
- 客户端通过使用 GETSET 命令原子性地获取计数器当前值并将计数器清零,更多信息请参考 GETSET 命令。
- 通过用其他自增/自减操作,比如 DECR 和 INCRBY ,用户可以在完成业务操作之后增加或减少计数器的值,如在游戏中的记分器就是一个典型的场景。
它的优点在于:
- 不依赖数据库且性能优于数据库。
- ID天然有序,对分页或者需要排序的场景很友好。
但是它还存在如下的缺点:
- 如果系统中没有Redis需要引入Redis增加了系统复杂度。
- 需要额外的编码和配置工作。
但总体来讲,这是个不错的方案,分布式环境下,我们通过集群Redis能够保证生成器高可用运行,集群之间通过复制能够保证序列生成不会有单点故障。
Zookeeper
通过利用zookeeper的持久顺序节点特性,多个客户端同时创建同一节点,zk可以保证有序的创建,创建成功并返回的path类似于/root/generateid0000000001这样的节点,能够看到是顺序有规律的。利用这个特性,我们能够实现基于zk的分布式id生成器。
不过一般我们很少会使用zookeeper来生成唯一ID。主要是由于需要依赖zookeeper,并且是多步调用API,如果在竞争较大的情况下,需要考虑使用分布式锁。因此,在高并发的分布式环境下,性能不甚理想。
MySQL自增id
这种方式很好理解,就是建立一张序列表,执行插入操作,并获取记录的id值。
它的优点如下:
- 容易理解,开发量不多,且性能可以接受。
- 通过自增主键生成的ID天然排序,对分页或者需要排序的结果很有帮助。
同时它存在如下的缺点:
- 不同数据库语法的和实现不同,如果需要切换数据库或多数据库版本支持的时候需要在每个库中单独处理。
- 在单数据库或读写分离或一主多从的情况下,只有一个主库可以生成。有单点故障风险。
- id的生成与数据库的性能强关联。
- 如果存在数据的迁移,则id序列表也需要同步迁移。
- 分表分库场景下会有麻烦。
当然这些问题都有针对的解决方案:
- 对于不同的数据库,只需要将id的生成作为单独的服务开发,不同的业务通过接口调用id生成,屏蔽后方的实现细节
- 针对主库单点,可以改造为多Master架构
- 如果条件允许,使用高性能磁盘及主机部署数据库
- 通过双写操作的方式进行数据迁移
- 分库分表场景下,只需要在每个数据分片上设置对应表的序列生成表即可,序列表与业务表使用相同的分片规则,这样就能保证序列与业务是一一对应的,在每个片上,都是唯一且自增的。
我的选择
通过了解各种分布式主键生成策略,我最终选择了Redis的计数器作为自定义分布式主键的核心技术方案。
原因如下:
- 业务id如果直接使用UUID、snowflake等可读性较差,需要有业务属性,最好能直观的看到分片属性
- 业务中本身就引入了Redis集群,不需要额外的依赖
- Redis方案开发简单且可靠性强
基于Redis的分布式主键的自定义开发
到此,我们对主流的分布式主键的生成策略进行了分析后选定了使用Redis的计数器进行开发,接下来就讲解下如何实现业务友好的自定义分布式主键。
id格式解析
首先解析一下最终生成的ID的格式,举个例子,如:生成订单号如下:
OD00000101201903251029141503200002
从左往右依次为:
业务编码(2位) + 库下标(2位)+ 表下标(4位)
+ 序列版本号(默认为01,2位)+ 时间戳(yyMMddHHmmssSSS,精确到毫秒,15位)
+ 机器id(2位)
+ 序列号(5位)
共32位。
这个格式的id对于业务而言,可读性更好,能够直观的看到是哪个业务的id,分布在哪个片上,是哪个时间生成的,比纯数字的更加直观。
开发过程-01-定义分布式主键格式
首先,我们定义分布式主键的格式,这里通过枚举实现。
新建名为 DbAndTableEnum 的库表规则枚举类,根据上述id的格式,分别定义属性如下
public enum DbAndTableEnum {
/**
* 用户信息表 UD+db+table+01+yyMMddHHmmssSSS+机器id+序列号id
* 例如:UD000000011902261230103345300002 共 2+6+2+15+2+5=32位
*/
T_USER("t_user", "user_id", "01", "01", "UD", 2, 2, 4, 4, 16, "用户数据表枚举"),
T_NEW_ORDER("t_new_order", "order_id", "01", "01", "OD", 2,2, 4, 4, 8, "订单数据表枚举");
/**分片表名*/
private String tableName;
/**分片键*/
private String shardingKey;
/**系统标识*/
private String bizType;
/**主键规则版本*/
private String idVersion;
/**表名字母前缀*/
private String charsPrefix;
/**分片键值中纯数字起始下标索引,第一位是0,第二位是1,依次类推*/
private int numberStartIndex;
/**数据库索引位开始下标索引*/
private int dbIndexBegin;
/**表索引位开始下标索引*/
private int tbIndexBegin;
/**分布所在库数量*/
private int dbCount;
/**分布所在表数量-所有库中表数量总计*/
private int tbCount;
/**描述*/
private String desc;
...省略getter setter 构造方法...
这里我根据属性,定义了我的demo中需要使用的两个枚举,分别为用户表、订单表的主键枚举。以用户表举例:
T_USER("t_user", // 用户逻辑表名
"user_id", // 用户表分片键
"01", // 系统标识默认为01
"01", // 主键规则默认为01
"UD", // 用户表前缀
2, // 分片键值中纯数字起始下标,默认为2
2, // 数据库索引位开始下标索引,同上,默认第二位
4, // 分片数量,eg:分4库
4, // 每个分片中分表数量,每个片上4表
16, // 所有分片的分表总数
"用户数据表枚举"), // 描述
在不同的业务中,可以根据对应的业务定义对应的id枚举,原则是:开发阶段一定能够知道当前id是为哪个业务准备的,也能够事先预估好数据的容量。
开发过程-02-定义序列生成器接口并实现
定义一个抽象序列接口,方便扩展
public interface SequenceGenerator {
/**
* @param targetEnum
* @param dbIndex
* @param tbIndex
* @return
*/
String getNextVal(DbAndTableEnum targetEnum, int dbIndex, int tbIndex);
}
由于我们使用了Redis作为序列生成器,因此只需要编写SequenceGenerator的实现类,利用Redis的计数器实现序列生成操作getNextVal()即可。
@Component(value = "redisSequenceGenerator")
public class RedisSequenceGenerator implements SequenceGenerator {
/**序列生成器key前缀*/
public static String LOGIC_TABLE_NAME = "sequence:redis:";
/**序列长度=5,不足5位的用0填充*/
public static int SEQUENCE_LENGTH = 5;
/**序列最大值=90000*/
public static int sequence_max = 90000;
@Autowired
StringRedisTemplate stringRedisTemplate;
/**
* redis序列获取实现方法
* @param targetEnum
* @param dbIndex
* @param tbIndex
* @return
*/
@Override
public String getNextVal(DbAndTableEnum targetEnum, int dbIndex, int tbIndex) {
//拼接key前缀
String redisKeySuffix = new StringBuilder(targetEnum.getTableName())
.append("_")
.append("dbIndex")
.append(StringUtil.fillZero(String.valueOf(dbIndex), ShardingConstant.DB_SUFFIX_LENGTH))
.append("_tbIndex")
.append(StringUtil.fillZero(String.valueOf(tbIndex), ShardingConstant.TABLE_SUFFIX_LENGTH))
.append("_")
.append(targetEnum.getShardingKey()).toString();
String increKey = new StringBuilder(LOGIC_TABLE_NAME).append(redisKeySuffix).toString();
long sequenceId = stringRedisTemplate.opsForValue().increment(increKey);
//达到指定值重置序列号,预留后10000个id以便并发时缓冲
if (sequenceId == sequence_max) {
stringRedisTemplate.delete(increKey);
}
// 返回序列值,位数不够前补零
return StringUtil.fillZero(String.valueOf(sequenceId), SEQUENCE_LENGTH);
}
}
由于用到了StringRedisTemplate作为Redis操作工具,因此需要引入Redis并配置对应的参数,具体方法此处不赘述,请移步我的另一篇文章 《springboot整合redis小结》。
分析一下代码逻辑,首先拼接了序列在redis中的key,将当前记录所在的库、表下标以及当前的表名和分片键名称拼接在一起,在最前面拼接好当前key的功能,最终生成的key如下:
sequence:redis:t_new_order_dbIndex00_tbIndex0001_order_id
这个key表示:redis生成的sequence序列,序列所属表为t_new_order,分片键为order_id,序列所属库下标为00库,所属表下标为0001表。
开发过程-03-实现自定义的KeyGen自定义主键生成器
上面的操作中,我们实现了核心的自增序列生成器,下面的内容中我们着手开发对业务暴露的生成器KeyGenerator的核心逻辑。
新建一个类,KeyGenerator.java标记为spring的一个Component。由于我们的业务基本上使用了Spring Boot框架,因此我开发的时候均通过Spring Bean的方式进行类定义。如果你要在非Spring框架中使用,需要自行完成Redis的连接等操作。
由于此处的逻辑较多,我只放核心的业务,完整的代码烦请移步github的项目页,本节的代码已经上传,sql脚本也同步更新了。项目地址:snowalker-shardingjdbc-demo
/**
* 根据路由id生成内部系统主键id,
* 路由id可以是内部其他系统主键id,也可以是外部第三方用户id
* @param targetEnum 待生成主键的目标表规则配置
* @param relatedRouteId 路由id或外部第三方用户id
* @return
*/
public String generateKey(DbAndTableEnum targetEnum, String relatedRouteId) {
if (StringUtils.isBlank(relatedRouteId)) {
throw new IllegalArgumentException("路由id参数为空");
}
StringBuilder key = new StringBuilder();
/** 1.id业务前缀*/
String idPrefix = targetEnum.getCharsPrefix();
/** 2.id数据库索引位*/
String dbIndex = getDbIndexAndTbIndexMap(targetEnum, relatedRouteId).get("dbIndex");
/** 3.id表索引位*/
String tbIndex = getDbIndexAndTbIndexMap(targetEnum, relatedRouteId).get("tbIndex");
/** 4.id规则版本位*/
String idVersion = targetEnum.getIdVersion();
/** 5.id时间戳位*/
String timeString = DateUtil.formatDate(new Date());
/** 6.id分布式机器位 2位*/
String distributedIndex = getDistributedId(2);
/** 7.随机数位*/
String sequenceId = sequenceGenerator.getNextVal(targetEnum, Integer.parseInt(dbIndex), Integer.parseInt(tbIndex));
/** 库表索引靠前*/
return key.append(idPrefix)
.append(dbIndex)
.append(tbIndex)
.append(idVersion)
.append(timeString)
.append(distributedIndex)
.append(sequenceId).toString();
}
该方法为外部业务调用的生成主键的核心API,方法声明为:
generateKey(DbAndTableEnum targetEnum, String relatedRouteId)
第一个参数为需要生成id的目标表的数据源/数据表枚举,第二个参数为相对路由id。
这里解释一下相对路由id的含义。
在实际开发中,我们需要将外部的id转换为内部的id使用,这样既可以保证数据的分布均匀,又有利于数据安全。如:根据支付宝uid生成系统内部的用户id。对外交互使用支付宝uid,内部统一使用内部的用户id。
继续我们的逻辑,当我们有了内部的用户id之后,通过内部用户id生成业务表id,如:账户id、订单id等。由于账户id、用户id使用同一个相对路由id(内部用户id),账户信息与订单信息使用了相同的路由规则,因此它们会位于同一个数据分片上,这样就能在业务上保证同一个用户的业务信息都在同一个数据分片上,单库事务得以继续使用,同库内的join操作也能够支持。由于所有的数据都在一个数据分片上,因此少了跨片join及跨片的归并操作,查询效率大幅度提升。
代码逻辑很清晰,就是按位填充对应的参数,其中时间戳使用SimpleDateFormat的format方法获取,这里使用ThreadLocal包装SimpleDateFormat保证线程安全。
我们着重看下如何获取库表索引及分布式机器位,
获取库表索引
通过方法 getDbIndexAndTbIndexMap 获取数据库的库表下标,代码如下:
/**
* 根据已知路由id取出库表索引,外部id和内部id均 进行ASCII转换后再对库表数量取模
* @param targetEnum 待生成主键的目标表规则配置
* @param relatedRouteId 路由id
* @return
*/
private Map<String, String> getDbIndexAndTbIndexMap(DbAndTableEnum targetEnum,String relatedRouteId) {
Map<String, String> map = new HashMap<>();
/** 获取库索引*/
String preDbIndex = String.valueOf(
getDbIndexByMod(
relatedRouteId,
targetEnum.getDbCount(),
targetEnum.getTbCount()));
String dbIndex = StringUtil.fillZero(preDbIndex, ShardingConstant.DB_SUFFIX_LENGTH);
/** 获取表索引*/
String preTbIndex = String
.valueOf(StringUtil.getTbIndexByMod(relatedRouteId,targetEnum.getDbCount(),targetEnum.getTbCount()));
String tbIndex = StringUtil
.fillZero(preTbIndex,ShardingConstant.TABLE_SUFFIX_LENGTH);
map.put("dbIndex", dbIndex);
map.put("tbIndex", tbIndex);
return map;
}
public static long getDbIndexByMod(Object obj,int dbCount,int tbCount) {
long tbRange = getModValue(obj, tbCount);
BigDecimal bc = new BigDecimal(tbRange);
BigDecimal[] results = bc.divideAndRemainder(new BigDecimal(dbCount));
return (long)results[0].intValue();
}
/**
* 先对指定对象取ASCII码后取模运算
* @param obj
* @param num
* @return
*/
public static long getModValue(Object obj,long num) {
String str = getAscII(obj == null?"":obj.toString());
BigDecimal bc = new BigDecimal(str);
BigDecimal[] results = bc.divideAndRemainder(new BigDecimal(num));
return (long)results[1].intValue();
}
首先转换外部id为ASCII码,通过该ASCII码对库取商,对表取余,得到库表下标,并拼接到主键中,如图:
此方案是针对ShardingJDBC的分片模式的,在ShardingJDBC中,每个分片中的数据库表的结构是相同的,如:
db_00--
|--t_order_0000
|--t_order_0001
db_01--
|--t_order_0000
|--t_order_0001
db_02--
|--t_order_0000
|--t_order_0001
db_03--
|--t_order_0000
|--t_order_0001
获取分布式机器id
接着看下如何获取分布式机器id。
/**
* 生成id分布式机器位
* @return 分布式机器id
* length与hostCount位数相同
*/
private String getDistributedId(int length, int hostCount) {
return StringUtil
.fillZero(String.valueOf(getIdFromHostName() % hostCount), length);
}
/**
* 适配分布式环境,根据主机名生成id
* 分布式环境下,如:Kubernates云环境下,集群内docker容器名是唯一的
* 通过 @See org.apache.commons.lang3.SystemUtils.getHostName()获取主机名
* @return
*/
private Long getIdFromHostName(){
//unicode code point
int[] ints = StringUtils.toCodePoints(SystemUtils.getHostName());
int sums = 0;
for (int i: ints) {
sums += i;
}
return (long)(sums);
}
这里我们通过StringUtils.toCodePoints(SystemUtils.getHostName());获取到当前主机名的unicode值,并将每个字符的unicode值相加,这里只要保证我们服务器的名称是唯一的,则codePoint值就是唯一的。例如:使用K8S进行部署的环境下,生成的docker容器的名称是集群内唯一的,保证了getIdFromHostName()返回值的唯一性。
我们用主机名生成的codePoint值对全局主机数量进行取模操作,即可获取当前id位于哪台机器上。
又由于在整个序列中添加了精确到毫秒的时间戳以及使用了Redis的计数器,能够大幅度的支撑高并发环境下的主键生成策略。只要不存在时钟回拨,系统稳定的情况下,不存在主键碰撞的情况。
加餐:关于codePoint
我们之所以将主机名称转为CodePoint并叠加各个字符的CodePoint值,原因在于Unicode中每个字符的codePoint值是不同的,因此我们可以确定不同的主机名的CodePoint值也是不同的,因此可以根据该CodePoint的值去做机器节点的取模计算。
首先了解下什么是CodePoint,CodePoint(中文叫代码点). wiki上关于CodePoint的解释
CodePoint不同于pointCode, 前者是字符编码的术语。后者更类似IP地址,用于标志网络结点地址,wiki上关于PointCode的解释。
ASCII字符集由于使用7bit表示字符,因此有128个CodePoint.
Extended ASCII字符集(扩展ASCII字符集)使用了8bit表示字符,因此有256个CodePoint.
而最新版Unicode6.2则拥有0x0~0x10FFFF个CodePoint. 总数可以达到1,114,112个,而目前全球只使用了110,182个来表示全世界所有语言的字符。这里可以看到Unicode的强大之处了,它真正做到了统一编码。
我们可以认为CodePoint就是不同字符集用来表示字符的所有整数的范围,且起点都是0.
举例
这里以一个实例进行讲解,准备这样一个字符串:snowalker朝闻道夕死可矣
解析这个字符串每个字符的codePoint并叠加,代码如下:
String snowalker = "snowalker朝闻道夕死可矣";
int [] snowalkerCodePoints = StringUtils.toCodePoints(snowalker);
long sum = 0;
for (int i = 0; i < snowalkerCodePoints.length; i++) {
sum += snowalkerCodePoints[i];
System.out.println("i=" + i + "--snowalkerCodePoints[" + i + "]=" + snowalkerCodePoints[i]);
}
System.out.println("sum=" + sum);
long sum2 = 0;
for (int i = 0; i < snowalkerCodePoints.length; i++) {
sum2 += snowalkerCodePoints[i];
System.out.println("原生方式--i=" + i + "--snowalkerCodePoints[" + i + "]=" + snowalker.codePointAt(i));
}
System.out.println("sum2=" + sum2);
两种方式,分别为org.apache.commons.lang3.StringUtils.toCodePoints(String string) 以及 java.lang.String.codePointAt(int index)。
org.apache.commons.lang3.StringUtils.toCodePoints(String string)解析字符串后返回一个codePoint数组,遍历数组并叠加。
java.lang.String.codePointAt(int index)从字符串的起始下标开始到结束下标为止,遍历字符串的每个元素的codePoint并叠加。
运行程序,控制台打印如下:
i=0--snowalkerCodePoints[0]=115
i=1--snowalkerCodePoints[1]=110
i=2--snowalkerCodePoints[2]=111
i=3--snowalkerCodePoints[3]=119
i=4--snowalkerCodePoints[4]=97
i=5--snowalkerCodePoints[5]=108
i=6--snowalkerCodePoints[6]=107
i=7--snowalkerCodePoints[7]=101
i=8--snowalkerCodePoints[8]=114
i=9--snowalkerCodePoints[9]=26397
i=10--snowalkerCodePoints[10]=38395
i=11--snowalkerCodePoints[11]=36947
i=12--snowalkerCodePoints[12]=22805
i=13--snowalkerCodePoints[13]=27515
i=14--snowalkerCodePoints[14]=21487
i=15--snowalkerCodePoints[15]=30691
sum=205219
原生方式--i=0--snowalkerCodePoints[0]=115
原生方式--i=1--snowalkerCodePoints[1]=110
原生方式--i=2--snowalkerCodePoints[2]=111
原生方式--i=3--snowalkerCodePoints[3]=119
原生方式--i=4--snowalkerCodePoints[4]=97
原生方式--i=5--snowalkerCodePoints[5]=108
原生方式--i=6--snowalkerCodePoints[6]=107
原生方式--i=7--snowalkerCodePoints[7]=101
原生方式--i=8--snowalkerCodePoints[8]=114
原生方式--i=9--snowalkerCodePoints[9]=26397
原生方式--i=10--snowalkerCodePoints[10]=38395
原生方式--i=11--snowalkerCodePoints[11]=36947
原生方式--i=12--snowalkerCodePoints[12]=22805
原生方式--i=13--snowalkerCodePoints[13]=27515
原生方式--i=14--snowalkerCodePoints[14]=21487
原生方式--i=15--snowalkerCodePoints[15]=30691
sum2=205219
可以看到,两种方式获取到的unicode的codePoint是相同的,通过这些方式我们就可以完成很多需求,如:本文中我们就是通过这种方式去解析主机名并转换为集群节点id。也可以通过这个方法,进行分片算法的开发,思路为:遍历主键的所有元素,叠加元素的codePoint并对库表取模,进行数据的分片。
总结
到这里,我们就完成了自定义分布式主键的自定义操作,详细的代码请访问:
在本文中,我们分析了多种分布式主键的生成策略及其优缺点,最终选择了Redis作为序列的生成器。并基于Redis序列生成器开发了可读性更好的主键生成工具,在接下来的文章中,我将使用该主键生成器,配合Sharding-JDBC的自定义分库分表策略,将Sharding-JDBC的使用更加推向实战化。希望本文的思路能够对读者开发自己的主键生成组件有所启发。