前言
在如今的互联网应用中,数据量越来越大,导致某个表可能要占用很大的物理存储空间,为了解决该问题,使用数据库分片技术。将一个数据库进行拆分为多张相同的表,通过数据库中间件连接。如果数据库中该表选用ID自增策略,则可能产生重复的ID,此时应该使用分布式ID生成策略来生成ID。
基本原则
而分布式ID首先要保证的几个基本要求有:
-
全局唯一
这个最好理解,作为唯一标识,肯定不能出现重复,这是最基本的要求。 -
趋势递增
由于我们的分布式ID,是用来标识数据唯一性的,所以多数时候会被定义为主键或者唯一索引。而多数情况下我们使用的是MySQL数据库,存储引擎为innodb,innodb的默认索引结构为B树,对于BTree索引来讲,数据以自增顺序来写入的话,b+tree的结构不会时常被打乱重塑,存取效率是最高的,在主键的选择上面我们应该尽量使用有序的主键保证写入性能。 -
单调递增
保证下一个ID一定大于上一个ID,例如事务版本号、IM增量消息、排序等特殊需求。 -
信息安全
由于数据是递增的,所以,恶意用户的可以根据当前ID推测出下一个,非常危险,所以,我们的分布式ID尽量做到不易被破解。如果ID是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可;如果是订单号就更危险了,竞对可以直接知道我们一天的单量。所以在一些应用场景下,会需要ID无规则、不规则。 -
含时间戳
方便在开发中快速了解这个id的生成时间。
可用性要求
在分布式id生成策略中,不仅仅要保证几个基本原则,还对可用性有着很强的要求。
-
高可用
发送一个生成id的请求,服务器就要保证99.9999%情况下创建一个可用的分布式id。比如说在一个业务场景下,不能因为id生成不了而造成插入数据操作全部不可用。 -
低延迟
发送一个生成id的请求,就要快速的返回这个id,不能说是等个好几秒才生成。 -
高QPS
比如有个活动,10万个人来同时进行下单操作,那么服务器要顶住压力同时创建10万个id。
说完这些分布式id要保证的硬性要求,下面来介绍几种分布式id的生成策略。
UUID
UUID由以下几部分的组合:
- 当前日期和时间,UUID的第一个部分与时间有关,如果你在生成一个UUID之后,过几秒又生成一个UUID,则第一个部分不同,其余相同。
- 时钟序列。
- 全局唯一的IEEE机器识别号,如果有网卡,从网卡MAC地址获得,没有网卡以其他方式获得。
组合成8-4-4-12的36个字符串,如:3db1064b-2356-4a95-9429-3cc477879bd3.
-
优点:
- 本地生成,没有网络消耗。
-
缺点:
- 首先分布式id一般都会作为主键,但是安装mysql官方推荐主键要尽量越短越好,UUID每一个都很长,所以不是很推荐。
- 既然分布式id是主键,然后主键是包含索引的,然后mysql的索引是通过b+树来实现的,每一次新的UUID数据的插入,为了查询的优化,都会对索引底层的b+树进行修改,因为UUID数据是无序的,所以每一次UUID数据的插入都会对主键地城的b+树进行很大的修改,这一点很不好。
- 信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。
数据库自增
当服务使用的数据库只有单裤单表时,可以使用多主模式利用数据库的auto_increment来生成全局唯一递增id。
设置好两台服务器的起始值和补偿,来进行id生成。
- 优点:
- 简单,无需任何程序附加操作
- 保持订场增量
- 缺点:
- 高并发下性能不佳,主键产生的性能上限是数据库服务器单机的上限
- 水平扩展困难,严重依赖数据库,扩容需要停机
redis
redis单线程的,所以操作为原子操作。利用incr/incrby命令实现原子性的自增可以生成唯一的递增ID。
例如一个集群中有5台Redis。可以初始化每台Redis的值分别是1,2,3,4,5,步长为5。
那么生成的id为:
A:1,6,11,16,21
B:2,7,12,17,22
C:3,8,13,18,23
D:4,9,14,19,24
E:5,10,15,20,25
- 优点:
- 性能较好,生成的id自增
- 缺点:
- 依赖于redis,水平扩容困难,配置复杂
- 需要考虑持久化问题
twitter的snowflake算法
snowflake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。
下图是snowflake的基本组成。
- 最高位是符号位,始终为0,不可用。
- 41位的时间序列,精确到毫秒级,41位的长度可以使用69年。时间位还有一个很重要的作用是可以根据时间进行排序。
- 10位的机器标识,10位的长度最多支持部署1024个节点。
- 12位的计数序列号,序列号即一系列的自增id,可以支持同一节点同一毫秒生成多个ID序号,12位的计数序列号支持每个节点每毫秒产生4096个ID序号。
具体源码地址:github.com/beyondfengy…
具体实现的话可以使用hutool工具包中的IdUtil工具类来生成。
//参数1为终端ID
//参数2为数据中心ID
Snowflake snowflake = IdUtil.createSnowflake(1, 1);
long id = snowflake.nextId();
- 优点:
- 毫秒数在高位,自增序列在低位,整个id都是趋势递增的。
- 不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成id的性能也是非常高的。
- 可以根据自身业务特性分配bit未,非常灵活
- 缺点:
- 依赖机器时钟,如果机器时钟回拨,会导致重复id生成
- 在单机上是递增的,但是由于设计到分布式环境,每台机器上的时钟不可能完全同步,有时候会出现不是全局递增的情况
美团的Leaf-segment
美团的Leaf-segment方案,实际上是在上面介绍的数据库自增ID方案上的一种改进方案,它可生成全局唯一、全局有序的ID,可以用于:事务版本号、IM聊天中的增量消息、全局排序等业务中。
美团的Leaf-segment对数据库自增ID方案做了如下改变:
- 原方案每次获取ID都得读写一次数据库,造成数据库压力大。改为利用proxy server批量获取,每次获取一个segment(step决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力;
- 各个业务不同的发号需求用biz_tag字段来区分,每个biz-tag的ID获取相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,不需要上述描述的复杂的扩容操作,只需要对biz_tag分库分表就行。
数据库表设计如下:
CREATE TABLE `leaf_alloc` (
`biz_tag` varchar(128) NOT NULL DEFAULT '' COMMENT '用来区分业务',
`max_id` bigint(20) NOT NULL DEFAULT '1' COMMENT '表示该biz_tag目前所被分配的ID号段的最大值',
`step` int(11) NOT NULL COMMENT '表示每次分配的号段长度',
`description` varchar(256) DEFAULT NULL COMMENT '描述',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;
原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。
读写数据库的频率从1减小到了1/step,大致架构如下图所示:
- 优点:
- Leaf服务可以很方便的线性扩展,性能完全能够支撑大多数业务场景
- ID号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求
- 容灾性高:Leaf服务内部有号段缓存,即使DB宕机,短时间内Leaf仍能正常对外提供服务
- 可以自定义max_id的大小,非常方便业务从原有的ID方式上迁移过来
- 缺点:
- ID号码不够随机,能够泄露发号数量的信息,不太安全
- TP999数据波动大,当号段使用完之后还是会hang在更新数据库的I/O上,tg999数据会出现偶尔的尖刺
- DB宕机会造成整个系统不可用
- Leaf-segment方案可以生成趋势递增的ID,同时ID号是可计算的,但不适用于订单ID生成场景。比如竞对在两天中午12点分别下单,通过订单id号相减就能大致计算出公司一天的订单量,这个是不能忍受的
美团的Leaf-snowflake
严格来说,Leaf-snowflake方案是Twittersnowflake改进版,它完全沿用snowflake方案的bit位设计(如上图所示),即是“1+41+10+12”的方式组装ID号。
对于workerID的分配,当服务集群数量较小的情况下,完全可以手动配置。Leaf服务规模较大,动手配置成本太高。所以使用Zookeeper持久顺序节点的特性自动对snowflake节点配置wokerID。
Leaf-snowflake是按照下面几个步骤启动的:
- 启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点);
- 如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务;
- 如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。
-
弱依赖ZooKeeper
- 除了每次会去ZK拿数据以外,也会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动。这样做到了对三方组件的弱依赖。一定程度上提高了SLA
-
解决时钟问题
- 因为这种方案依赖时间,如果机器的时钟发生了回拨,那么就会有可能生成重复的ID号,需要解决时钟回退的问题。
详细启动流程:
参见上图整个启动流程图,服务启动时首先检查自己是否写过ZooKeeper leaf_forever节点:
- 若写过,则用自身系统时间与leaf_forever/{self}时间则认为机器时间发生了大步长回拨,服务启动失败并报警;
- 若未写过,证明是新服务节点,直接创建持久节点leaf_forever/${self}并写入自身系统时间,接下来综合对比其余Leaf节点的系统时间来判断自身系统时间是否准确,具体做法是取leaf_temporary下的所有临时节点(所有运行中的Leaf-snowflake节点)的服务IP:Port,然后通过RPC请求得到所有节点的系统时间,计算sum(time)/nodeSize;
- 若abs( 系统时间-sum(time)/nodeSize ) < 阈值,认为当前系统时间准确,正常启动服务,同时写临时节点leaf_temporary/${self} 维持租约;
- 否则认为本机系统时间发生大步长偏移,启动失败并报警;
- 每隔一段时间(3s)上报自身系统时间写入leaf_forever/${self}。
由于强依赖时钟,对时间的要求比较敏感,在机器工作时NTP同步也会造成秒级别的回退,建议可以直接关闭NTP同步。要么在时钟回拨的时候直接不提供服务直接返回ERROR_CODE,等时钟追上即可。或者做一层重试,然后上报报警系统,更或者是发现有时钟回拨之后自动摘除本身节点并报警。如下:
//发生了回拨,此刻时间小于上次发号时间
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
try {
//时间偏差大小小于5ms,则等待两倍时间
wait(offset << 1);//wait
timestamp = timeGen();
if (timestamp < lastTimestamp) {
//还是小于,抛异常并上报
throwClockBackwardsEx(timestamp);
}
} catch (InterruptedException e) {
throw e;
}
} else {
//throw
throwClockBackwardsEx(timestamp);
}
}
//分配ID
小结
以上Snowflake、美团的Leaf、还有文中没有提到百度的uid-generator都是现在比较常用方案,大都是基于Snowflake再做一定的优化。不过具体用哪个方案还是要根据具体的业务、并发量等等做选择,不能说是无脑的直接取用Leaf或者uid-generator。我目前所在做的项目也是用的公司自己封装的一套类似于Snowflake的一种生成策略,因为项目的并发数和数据量并没有互联网项目的那么大,所以说选择合适适用于自己项目的方案才是最OK的。
完!
都看到着了,点个赞点个关注再走呗~
参考: