传统的单体架构的时候,咱们基本是单库而后业务单表的结构。每一个业务表的ID通常咱们都是从1增,经过AUTO_INCREMENT=1
设置自增起始值,可是在分布式服务架构模式下分库分表的设计,使得多个库或多个表存储相同的业务数据。这种状况根据数据库的自增ID就会产生相同ID的状况,不能保证主键的惟一性。html
如上图,若是第一个订单存储在 DB1 上则订单 ID 为1,当一个新订单又入库了存储在 DB2 上订单 ID 也为1。咱们系统的架构虽然是分布式的,可是在用户层应是无感知的,重复的订单主键显而易见是不被容许的。那么针对分布式系统如何作到主键惟一性呢?java
UUID (Universally Unique Identifier),通用惟一识别码的缩写。UUID是由一组32位数的16进制数字所构成,因此UUID理论上的总数为 16^32=2^128,约等于 3.4 x 10^38。也就是说若每纳秒产生1兆个UUID,要花100亿年才会将全部UUID用完。node
生成的UUID是由 8-4-4-4-12
格式的数据组成,其中32个字符和4个连字符' - ',通常咱们使用的时候会将连字符删除 uuid.toString().replaceAll("-","")
。git
目前UUID的产生方式有5种版本,每一个版本的算法不一样,应用范围也不一样。github
基于时间的UUID - 版本1: 这个通常是经过当前时间,随机数,和本地Mac地址来计算出来,能够经过 org.apache.logging.log4j.core.util
包中的 UuidUtil.getTimeBasedUuid()
来使用或者其余包中工具。因为使用了MAC地址,所以可以确保惟一性,可是同时也暴露了MAC地址,私密性不够好。redis
DCE安全的UUID - 版本2 DCE(Distributed Computing Environment)安全的UUID和基于时间的UUID算法相同,但会把时间戳的前4位置换为POSIX的UID或GID。这个版本的UUID在实际中较少用到。算法
基于名字的UUID(MD5)- 版本3 基于名字的UUID经过计算名字和名字空间的MD5散列值获得。这个版本的UUID保证了:相同名字空间中不一样名字生成的UUID的惟一性;不一样名字空间中的UUID的惟一性;相同名字空间中相同名字的UUID重复生成是相同的。spring
随机UUID - 版本4 根据随机数,或者伪随机数生成UUID。这种UUID产生重复的几率是能够计算出来的,可是重复的可能性能够忽略不计,所以该版本也是被常用的版本。JDK中使用的就是这个版本。sql
基于名字的UUID(SHA1) - 版本5 和基于名字的UUID算法相似,只是散列值计算使用SHA1(Secure Hash Algorithm 1)算法。docker
咱们 Java中 JDK自带的 UUID产生方式就是版本4根据随机数生成的 UUID 和版本3基于名字的 UUID,有兴趣的能够去看看它的源码。
public static void main(String[] args) { //获取一个版本4根据随机字节数组的UUID。 UUID uuid = UUID.randomUUID(); System.out.println(uuid.toString().replaceAll("-","")); //获取一个版本3(基于名称)根据指定的字节数组的UUID。 byte[] nbyte = {10, 20, 30}; UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte); System.out.println(uuidFromBytes.toString().replaceAll("-","")); } 复制代码
获得的UUID结果,
59f51e7ea5ca453bbfaf2c1579f09f1d
7f49b84d0bbc38e9a493718013baace6
复制代码
虽然 UUID 生成方便,本地生成没有网络消耗,可是使用起来也有一些缺点,
是否是必定要基于外界的条件才能知足分布式惟一ID的需求呢,咱们能不能在咱们分布式数据库的基础上获取咱们须要的ID?
因为分布式数据库的起始自增值同样因此才会有冲突的状况发生,那么咱们将分布式系统中数据库的同一个业务表的自增ID设计成不同的起始值,而后设置固定的步长,步长的值即为分库的数量或分表的数量。
以MySQL举例,利用给字段设置auto_increment_increment
和auto_increment_offset
来保证ID自增。
假设有三台机器,则DB1中order表的起始ID值为1,DB2中order表的起始值为2,DB3中order表的起始值为3,它们自增的步长都为3,则它们的ID生成范围以下图所示:
经过这种方式明显的优点就是依赖于数据库自身不须要其余资源,而且ID号单调自增,能够实现一些对ID有特殊要求的业务。
可是缺点也很明显,首先它强依赖DB,当DB异常时整个系统不可用。虽然配置主从复制能够尽量的增长可用性,可是数据一致性在特殊状况下难以保证。主从切换时的不一致可能会致使重复发号。还有就是ID发号性能瓶颈限制在单台MySQL的读写性能。
Redis实现分布式惟一ID主要是经过提供像 INCR 和 INCRBY 这样的自增原子命令,因为Redis自身的单线程的特色因此能保证生成的 ID 确定是惟一有序的。
可是单机存在性能瓶颈,没法知足高并发的业务需求,因此能够采用集群的方式来实现。集群的方式又会涉及到和数据库集群一样的问题,因此也须要设置分段和步长来实现。
为了不长期自增后数字过大能够经过与当前时间戳组合起来使用,另外为了保证并发和业务多线程的问题能够采用 Redis + Lua的方式进行编码,保证安全。
Redis 实现分布式全局惟一ID,它的性能比较高,生成的数据是有序的,对排序业务有利,可是一样它依赖于redis,须要系统引进redis组件,增长了系统的配置复杂性。
固然如今Redis的使用性很广泛,因此若是其余业务已经引进了Redis集群,则能够资源利用考虑使用Redis来实现。
Snowflake,雪花算法是由Twitter开源的分布式ID生成算法,以划分命名空间的方式将 64-bit位分割成多个部分,每一个部分表明不一样的含义。而 Java中64bit的整数是Long类型,因此在 Java 中 SnowFlake 算法生成的 ID 就是 long 来存储的。
这样的划分以后至关于在一毫秒一个数据中心的一台机器上可产生4096个有序的不重复的ID。可是咱们 IDC 和机器数确定不止一个,因此毫秒内能生成的有序ID数是翻倍的。
Snowflake 的Twitter官方原版是用Scala写的,对Scala语言有研究的同窗能够去阅读下,如下是 Java 版本的写法。
package com.jajian.demo.distribute; /** * Twitter_Snowflake<br> * SnowFlake的结构以下(每部分用-分开):<br> * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br> * 1位标识,因为long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,因此id通常是正数,最高位是0<br> * 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截) * 获得的值),这里的的开始时间截,通常是咱们的id生成器开始使用的时间,由咱们程序来指定的(以下下面程序IdWorker类的startTime属性)。41位的时间截,可使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br> * 10位的数据机器位,能够部署在1024个节点,包括5位datacenterId和5位workerId<br> * 12位序列,毫秒内的计数,12位的计数顺序号支持每一个节点每毫秒(同一机器,同一时间截)产生4096个ID序号<br> * 加起来恰好64位,为一个Long型。<br> * SnowFlake的优势是,总体上按照时间自增排序,而且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID做区分),而且效率较高,经测试,SnowFlake每秒可以产生26万ID左右。 */ public class SnowflakeDistributeId { // ==============================Fields=========================================== /** * 开始时间截 (2015-01-01) */ private final long twepoch = 1420041600000L; /** * 机器id所占的位数 */ private final long workerIdBits = 5L; /** * 数据标识id所占的位数 */ private final long datacenterIdBits = 5L; /** * 支持的最大机器id,结果是31 (这个移位算法能够很快的计算出几位二进制数所能表示的最大十进制数) */ private final long maxWorkerId = -1L ^ (-1L << workerIdBits); /** * 支持的最大数据标识id,结果是31 */ private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits); /** * 序列在id中占的位数 */ private final long sequenceBits = 12L; /** * 机器ID向左移12位 */ private final long workerIdShift = sequenceBits; /** * 数据标识id向左移17位(12+5) */ private final long datacenterIdShift = sequenceBits + workerIdBits; /** * 时间截向左移22位(5+5+12) */ private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits; /** * 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095) */ private final long sequenceMask = -1L ^ (-1L << sequenceBits); /** * 工做机器ID(0~31) */ private long workerId; /** * 数据中心ID(0~31) */ private long datacenterId; /** * 毫秒内序列(0~4095) */ private long sequence = 0L; /** * 上次生成ID的时间截 */ private long lastTimestamp = -1L; //==============================Constructors===================================== /** * 构造函数 * * @param workerId 工做ID (0~31) * @param datacenterId 数据中心ID (0~31) */ public SnowflakeDistributeId(long workerId, long datacenterId) { if (workerId > maxWorkerId || workerId < 0) { throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId)); } if (datacenterId > maxDatacenterId || datacenterId < 0) { throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId)); } this.workerId = workerId; this.datacenterId = datacenterId; } // ==============================Methods========================================== /** * 得到下一个ID (该方法是线程安全的) * * @return SnowflakeId */ public synchronized long nextId() { long timestamp = timeGen(); //若是当前时间小于上一次ID生成的时间戳,说明系统时钟回退过这个时候应当抛出异常 if (timestamp < lastTimestamp) { throw new RuntimeException( String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp)); } //若是是同一时间生成的,则进行毫秒内序列 if (lastTimestamp == timestamp) { sequence = (sequence + 1) & sequenceMask; //毫秒内序列溢出 if (sequence == 0) { //阻塞到下一个毫秒,得到新的时间戳 timestamp = tilNextMillis(lastTimestamp); } } //时间戳改变,毫秒内序列重置 else { sequence = 0L; } //上次生成ID的时间截 lastTimestamp = timestamp; //移位并经过或运算拼到一块儿组成64位的ID return ((timestamp - twepoch) << timestampLeftShift) // | (datacenterId << datacenterIdShift) // | (workerId << workerIdShift) // | sequence; } /** * 阻塞到下一个毫秒,直到得到新的时间戳 * * @param lastTimestamp 上次生成ID的时间截 * @return 当前时间戳 */ protected long tilNextMillis(long lastTimestamp) { long timestamp = timeGen(); while (timestamp <= lastTimestamp) { timestamp = timeGen(); } return timestamp; } /** * 返回以毫秒为单位的当前时间 * * @return 当前时间(毫秒) */ protected long timeGen() { return System.currentTimeMillis(); } } 复制代码
测试的代码以下
public static void main(String[] args) { SnowflakeDistributeId idWorker = new SnowflakeDistributeId(0, 0); for (int i = 0; i < 1000; i++) { long id = idWorker.nextId(); // System.out.println(Long.toBinaryString(id)); System.out.println(id); } } 复制代码
雪花算法提供了一个很好的设计思想,雪花算法生成的ID是趋势递增,不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是很是高的,并且能够根据自身业务特性分配bit位,很是灵活。
可是雪花算法强依赖机器时钟,若是机器上时钟回拨,会致使发号重复或者服务会处于不可用状态。若是恰巧回退前生成过一些ID,而时间回退后,生成的ID就有可能重复。官方对于此并无给出解决方案,而是简单的抛错处理,这样会形成在时间被追回以前的这段时间服务不可用。
不少其余类雪花算法也是在此思想上的设计而后改进规避它的缺陷,后面介绍的百度 UidGenerator 和 美团分布式ID生成系统 Leaf 中snowflake模式都是在 snowflake 的基础上演进出来的。
百度的 UidGenerator 是百度开源基于Java语言实现的惟一ID生成器,是在雪花算法 snowflake 的基础上作了一些改进。UidGenerator以组件形式工做在应用项目中, 支持自定义workerId位数和初始化策略,适用于docker等虚拟化环境下实例自动重启、漂移等场景。
在实现上,UidGenerator 提供了两种生成惟一ID方式,分别是 DefaultUidGenerator 和 CachedUidGenerator,官方建议若是有性能考虑的话使用 CachedUidGenerator 方式实现。
UidGenerator 依然是以划分命名空间的方式将 64-bit位分割成多个部分,只不过它的默认划分方式有别于雪花算法 snowflake。它默认是由 1-28-22-13 的格式进行划分。可根据你的业务的状况和特色,本身调整各个字段占用的位数。
其中 workId (机器 id),最多可支持约420w次机器启动。内置实现为在启动时由数据库分配(表名为 WORKER_NODE),默认分配策略为用后即弃,后续可提供复用策略。
DROP TABLE IF EXISTS WORKER_NODE; CREATE TABLE WORKER_NODE ( ID BIGINT NOT NULL AUTO_INCREMENT COMMENT 'auto increment id', HOST_NAME VARCHAR(64) NOT NULL COMMENT 'host name', PORT VARCHAR(64) NOT NULL COMMENT 'port', TYPE INT NOT NULL COMMENT 'node type: ACTUAL or CONTAINER', LAUNCH_DATE DATE NOT NULL COMMENT 'launch date', MODIFIED TIMESTAMP NOT NULL COMMENT 'modified time', CREATED TIMESTAMP NOT NULL COMMENT 'created time', PRIMARY KEY(ID) ) COMMENT='DB WorkerID Assigner for UID Generator',ENGINE = INNODB; 复制代码
DefaultUidGenerator 就是正常的根据时间戳和机器位还有序列号的生成方式,和雪花算法很类似,对于时钟回拨也只是抛异常处理。仅有一些不一样,如以秒为为单位而再也不是毫秒和支持Docker等虚拟化环境。
protected synchronized long nextId() { long currentSecond = getCurrentSecond(); // Clock moved backwards, refuse to generate uid if (currentSecond < lastSecond) { long refusedSeconds = lastSecond - currentSecond; throw new UidGenerateException("Clock moved backwards. Refusing for %d seconds", refusedSeconds); } // At the same second, increase sequence if (currentSecond == lastSecond) { sequence = (sequence + 1) & bitsAllocator.getMaxSequence(); // Exceed the max sequence, we wait the next second to generate uid if (sequence == 0) { currentSecond = getNextSecond(lastSecond); } // At the different second, sequence restart from zero } else { sequence = 0L; } lastSecond = currentSecond; // Allocate bits for UID return bitsAllocator.allocate(currentSecond - epochSeconds, workerId, sequence); } 复制代码
若是你要使用 DefaultUidGenerator 的实现方式的话,以上划分的占用位数可经过 spring 进行参数配置。
<bean id="defaultUidGenerator" class="com.baidu.fsg.uid.impl.DefaultUidGenerator" lazy-init="false"> <property name="workerIdAssigner" ref="disposableWorkerIdAssigner"/> <!-- Specified bits & epoch as your demand. No specified the default value will be used --> <property name="timeBits" value="29"/> <property name="workerBits" value="21"/> <property name="seqBits" value="13"/> <property name="epochStr" value="2016-09-20"/> </bean> 复制代码
而官方建议的性能较高的 CachedUidGenerator 生成方式,是使用 RingBuffer 缓存生成的id。数组每一个元素成为一个slot。RingBuffer容量,默认为Snowflake算法中sequence最大值(2^13 = 8192)。可经过 boostPower 配置进行扩容,以提升 RingBuffer 读写吞吐量。
Tail指针、Cursor指针用于环形数组上读写slot:
Tail指针 表示Producer生产的最大序号(此序号从0开始,持续递增)。Tail不能超过Cursor,即生产者不能覆盖未消费的slot。当Tail已遇上curosr,此时可经过rejectedPutBufferHandler指定PutRejectPolicy
Cursor指针 表示Consumer消费到的最小序号(序号序列与Producer序列相同)。Cursor不能超过Tail,即不能消费未生产的slot。当Cursor已遇上tail,此时可经过rejectedTakeBufferHandler指定TakeRejectPolicy
CachedUidGenerator采用了双RingBuffer,Uid-RingBuffer用于存储Uid、Flag-RingBuffer用于存储Uid状态(是否可填充、是否可消费)。
因为数组元素在内存中是连续分配的,可最大程度利用CPU cache以提高性能。但同时会带来「伪共享」FalseSharing问题,为此在Tail、Cursor指针、Flag-RingBuffer中采用了CacheLine 补齐方式。
RingBuffer填充时机
初始化预填充 RingBuffer初始化时,预先填充满整个RingBuffer。
即时填充 Take消费时,即时检查剩余可用slot量(tail - cursor),如小于设定阈值,则补全空闲slots。阈值可经过paddingFactor来进行配置,请参考Quick Start中CachedUidGenerator配置。
周期填充 经过Schedule线程,定时补全空闲slots。可经过scheduleInterval配置,以应用定时填充功能,并指定Schedule时间间隔。
Leaf是美团基础研发平台推出的一个分布式ID生成服务,名字取自德国哲学家、数学家莱布尼茨的著名的一句话:“There are no two identical leaves in the world”,世间不可能存在两片相同的叶子。
Leaf 也提供了两种ID生成的方式,分别是 Leaf-segment 数据库方案和 Leaf-snowflake 方案。
Leaf-segment 数据库方案,是在上文描述的在使用数据库的方案上,作了以下改变:
原方案每次获取ID都得读写一次数据库,形成数据库压力大。改成利用proxy server批量获取,每次获取一个segment(step决定大小)号段的值。用完以后再去数据库获取新的号段,能够大大的减轻数据库的压力。
各个业务不一样的发号需求用 biz_tag
字段来区分,每一个biz-tag的ID获取相互隔离,互不影响。若是之后有性能需求须要对数据库扩容,不须要上述描述的复杂的扩容操做,只须要对biz_tag分库分表就行。
数据库表设计以下:
CREATE TABLE `leaf_alloc` ( `biz_tag` varchar(128) NOT NULL DEFAULT '' COMMENT '业务key', `max_id` bigint(20) NOT NULL DEFAULT '1' COMMENT '当前已经分配了的最大id', `step` int(11) NOT NULL COMMENT '初始步长,也是动态调整的最小步长', `description` varchar(256) DEFAULT NULL COMMENT '业务key的描述', `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-segment 为了解决 TP999(知足千分之九百九十九的网络请求所须要的最低耗时)数据波动大,当号段使用完以后仍是会hang在更新数据库的I/O上,TP999 数据会出现偶尔的尖刺的问题,提供了双buffer优化。
简单的说就是,Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,而且在这期间进来的请求也会由于DB号段没有取回来,致使线程阻塞。若是请求DB的网络和DB的性能稳定,这种状况对系统的影响是不大的,可是假如取DB的时候网络发生抖动,或者DB发生慢查询就会致使整个系统的响应时间变慢。
为了DB取号段的过程可以作到无阻塞,不须要在DB取号段的时候阻塞请求线程,即当号段消费到某个点时就异步的把下一个号段加载到内存中,而不须要等到号段用尽的时候才去更新号段。这样作就能够很大程度上的下降系统的 TP999 指标。详细实现以下图所示:
采用双buffer的方式,Leaf服务内部有两个号段缓存区segment。当前号段已下发10%时,若是下一个号段未更新,则另启一个更新线程去更新下一个号段。当前号段所有下发完后,若是下个号段准备好了则切换到下个号段为当前segment接着下发,循环往复。
每一个biz-tag都有消费速度监控,一般推荐segment长度设置为服务高峰期发号QPS的600倍(10分钟),这样即便DB宕机,Leaf仍能持续发号10-20分钟不受影响。
每次请求来临时都会判断下个号段的状态,从而更新此号段,因此偶尔的网络抖动不会影响下个号段的更新。
对于这种方案依然存在一些问题,它仍然依赖 DB的稳定性,须要采用主从备份的方式提升 DB的可用性,还有 Leaf-segment方案生成的ID是趋势递增的,这样ID号是可被计算的,例如订单ID生成场景,经过订单id号相减就能大体计算出公司一天的订单量,这个是不能忍受的。
Leaf-snowflake方案彻底沿用 snowflake 方案的bit位设计,对于workerID的分配引入了Zookeeper持久顺序节点的特性自动对snowflake节点配置 wokerID。避免了服务规模较大时,动手配置成本过高的问题。
Leaf-snowflake是按照下面几个步骤启动的:
为了减小对 Zookeeper的依赖性,会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,刚好机器出现问题须要重启时,能保证服务可以正常启动。
上文阐述过在类 snowflake算法上都存在时钟回拨的问题,Leaf-snowflake在解决时钟回拨的问题上是经过校验自身系统时间与 leaf_forever/${self}
节点记录时间作比较而后启动报警的措施。
美团官方建议是因为强依赖时钟,对时间的要求比较敏感,在机器工做时NTP同步也会形成秒级别的回退,建议能够直接关闭NTP同步。要么在时钟回拨的时候直接不提供服务直接返回ERROR_CODE,等时钟追上便可。或者作一层重试,而后上报报警系统,更或者是发现有时钟回拨以后自动摘除自己节点并报警。
在性能上官方提供的数据目前 Leaf 的性能在4C8G 的机器上QPS能压测到近5w/s,TP999 1ms。
以上基本列出了全部经常使用的分布式ID生成方式,其实大体分类的话能够分为两类:
一种是类DB型的,根据设置不一样起始值和步长来实现趋势递增,须要考虑服务的容错性和可用性。
另外一种是类snowflake型,这种就是将64位划分为不一样的段,每段表明不一样的涵义,基本就是时间戳、机器ID和序列数。这种方案就是须要考虑时钟回拨的问题以及作一些 buffer的缓冲设计提升性能。
并且可经过将三者(时间戳,机器ID,序列数)划分不一样的位数来改变使用寿命和并发数。
例如对于并发数要求不高、指望长期使用的应用,可增长时间戳位数,减小序列数的位数. 例如配置成{"workerBits":23,"timeBits":31,"seqBits":9}时, 可支持28个节点以总体并发量14400 UID/s的速度持续运行68年。
对于节点重启频率频繁、指望长期使用的应用, 可增长工做机器位数和时间戳位数, 减小序列数位数. 例如配置成{"workerBits":27,"timeBits":30,"seqBits":6}时, 可支持37个节点以总体并发量2400 UID/s的速度持续运行34年。
参考: