1. 分布式ID策略需要解决的问题
在数据库分库分表的情况下,若每个数据库的自增策略都为autoincreament逐一自增,会造成被拆分的表中出现记录id一样的情况
![](https://i-blog.csdnimg.cn/blog_migrate/c4a591f08dcd2d2217fdd5ee4d806194.png)
2. 更改分布式数据库自增策略
2.1 解决方案
如果此数据表在后续不继续拆分,我们可以通过修改不同数据库中的表的自增步长解决这个问题,步长即为分表的数量
![](https://i-blog.csdnimg.cn/blog_migrate/c8331ec466a1ed67b6d853e050f01fe5.png)
2.2 优缺点
优点
1.无需依赖除DB外其它资源
2.id始终单调递增,索引的维护代价小,通过主键进行查询的效率高
缺点
1.生成id需要性能消耗
2.主从切换时可能造成id重复生成的情况
3.不灵活,如果后续要继续分表,则只能重新设置每个表的步长
3. UUID
3.1 UUID
UUID (Universally Unique Identifier),通用唯一识别码的缩写,是由一组32位数的16进制数字所构成。
生成的UUID是由 8-4-4-4-12格式的数据组成,其中32个字符和4个连字符' - '。
3.2 基于时间的UUID
这个一般是通过当前时间,随机数,和本地Mac地址来计算出来,可以通过 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()来使用或者其他包中工具。
3.3 DCE安全的UUID
DCE(Distributed Computing Environment)分布式计算环境安全的UUID和基于时间的UUID算法相同,但会把时间戳的前4位置换为POSIX(可移植性操作系统)的UID(用户id)或GID(组织id)。
3.4 基于名字的UUID(MD5)
基于名字的UUID通过计算字节数组MD5散列值得到。
byte[] nbyte = {10, 20, 30};
UUID uuidFromBytes = UUID.nameUUIDFromBytes(nbyte);
public static UUID nameUUIDFromBytes(byte[] name) {
MessageDigest md;
try {
md = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException nsae) {
throw new InternalError("MD5 not supported", nsae);
}
byte[] md5Bytes = md.digest(name);
md5Bytes[6] &= 0x0f; /* clear version */
md5Bytes[6] |= 0x30; /* set to version 3 */
md5Bytes[8] &= 0x3f; /* clear variant */
md5Bytes[8] |= 0x80; /* set to IETF variant */
return new UUID(md5Bytes);
}
3.5 随机UUID
根据随机数,或者伪随机数生成UUID。这种UUID产生重复的概率是可以计算出来的,但是重复的可能性可以忽略不计,因此该版本也是被经常使用的版本。JDK中使用的就是这个版本
UUID uuid = UUID.randomUUID();
3.6 基于名字的UUID(SHA1)
和前面基于名字的UUID算法类似,只是散列值计算使用SHA1(Secure Hash Algorithm 1)算法
3.7 优缺点
优点
生成方便,没有网络和io消耗
缺点
1.不易于存储:UUID太长,16字节128位,通常以36长度的字符串表示(32字符和4个连字符),很多场景不适用。
2.信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,暴露使用者的位置。
3.对MySQL索引不利:在InnoDB引擎下,UUID的无序性可能会引起数据位置频繁变动,严重影响性能。
4. 使用redis实现
4.1 解决方案
通过redis 中INCR 和 INCRBY 这样的自增原子命令,在单机redis服务下,由于Redis自身的单线程的特点所以能保证生成的 ID 肯定是唯一有序的。
4.2 优缺点
优点
1.只涉及内存操作,性能高
2.生成的数据是有序的,对排序业务有利
3.目前基本上所有系统都集成了redis,因此适用性较高
缺点
单机redis环境下虽然能确保id唯一有效,但是在高并发的环境下,我们需要搭建redis集群处理id生成问题,因此又会出现像数据库分库分表时出现的id重复问题,此时也需要设置每台redis的id自增步长。
5. 雪花算法-Snowflake
5.1 介绍
雪花算法是由Twitter开源的分布式ID生成算法,以划分命名空间的方式将 64-bit位分割成多个部分,每个部分代表不同的含义
1. 第1位占用1bit,其值始终是0,可看做是符号位不使用。
2. 第2位开始的41位是时间戳,41-bit位可表示2^41个数,代表毫秒数。
3. 中间的10-bit位可表示机器数,即2^10 = 1024台机器。
4. 最后12-bit位是自增序列,可表示2^12 = 4096个数
![](https://i-blog.csdnimg.cn/blog_migrate/a7e522b87eed47cd27c1cc703ea73b70.png)
相当于在一毫秒一台机器上可产生4096个有序的不重复的ID
Java版代码
/**
* 获得下一个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();
}
5.2 优缺点
优点
1.生成的ID是趋势递增,不依赖数据库等第三方系统
2.生成ID的性能非常高,而且可以根据自身业务特性分配bit位,非常灵活。
缺点
雪花算法强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。
如果恰巧回退前生成过一些ID,而时间回退后,生成的ID就有可能重复。
官方对于此并没有给出解决方案,而是简单的抛错处理,这样会造成在时间被追回之前的这段时间服务不可用。
6. 百度-UidGenerator
6.1 介绍
在雪花算法上做了一些改进
UidGenerator 提供了两种生成唯一ID方式,分别是 DefaultUidGenerator 和 CachedUidGenerator,官方建议如果有性能考虑的话使用 CachedUidGenerator 方式实现。
以划分命名空间的方式将 64-bit位分割成多个部分,只不过它的默认划分方式有别于雪花算法 snowflake。它默认是由 1-28-22-13 的格式进行划分。可自己调整各个字段占用的位数。
1.第1位仍然占用1bit,其值始终是0。
2.第2位开始的28位是时间戳,28-bit位可表示2^28个数,这里不再是以毫秒而是以秒为单位。
3.中间的 workId (数据中心+工作机器,可以其他组成方式)则由 22-bit位组成,可表示 2^22 = 4194304个工作ID。
4.最后由13-bit位构成自增序列,可表示2^13 = 8192个数。
![](https://i-blog.csdnimg.cn/blog_migrate/4a615abc235013c9243e65f827fc878d.png)
其中 workId (机器 id),机器每启动一次就会消耗一个workerid,最多可支持约420w次机器启动。在启动时由数据库分配,默认分配策略为用后即弃,后续可提供复用策略
ROP 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;
6.2 DefaultUidGenerator
DefaultUidGenerator 就是正常的根据时间戳和机器位还有序列号的生成方式,和雪花算法很相似,对于时钟回拨也只是抛异常处理。
仅有一些不同,如以秒为为单位而不再是毫秒
实现代码
//实现策略
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);
}
可通过 spring 划分的占用位数
//可配置每个部分的bit位数
<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>
6.3 CachedUidGenerator
使用 RingBuffer 缓存生成的id。数组每个元素成为一个slot。
/** 常量配置 */
private static final int START_POINT = -1;
private static final long CAN_PUT_FLAG = 0L;
private static final long CAN_TAKE_FLAG = 1L;
public static final int DEFAULT_PADDING_PERCENT = 50;
/** RingBuffer 的 slot 的大小,每个 slot 持有一个 UID */
private final int bufferSize;
private final long indexMask;
/** 存 UID 的数组 */
private final long[] slots;
/** 存放 UID 状态的数组(是否可读或者可写,或是否可填充、是否可消费) */
private final PaddedAtomicLong[] flags;
/** Tail: 要产生的最后位置序列 */
private final AtomicLong tail = new PaddedAtomicLong(START_POINT);
/** Cursor: 要消耗的当前位置序列 */
private final AtomicLong cursor = new PaddedAtomicLong(START_POINT);
/** 触发填充缓冲区的阈值 */
private final int paddingThreshold;
/** 放置 缓冲区的拒绝策略 拒绝方式为打印日志 */
private RejectedPutBufferHandler rejectedPutHandler = this::discardPutBuffer;
/** 获取 缓冲区的拒绝策略 拒绝方式为抛出异常并打印日志 */
private RejectedTakeBufferHandler rejectedTakeHandler = this::exceptionRejectedTakeBuffer;
/** 填充缓冲区的执行者 */
private BufferPaddingExecutor bufferPaddingExecutor;
RingBuffer容量,默认为Snowflake算法中sequence最大值(2^13 = 8192),可通过 boostPower 配置进行扩容,以提高 RingBuffer 读写吞吐量。
<!-- RingBuffer size扩容参数, 可提高UID生成能力.即每秒产生ID数上限能力 -->
<!-- 默认:3,原bufferSize=2^13, 扩容后bufferSize = 2^13 << 3 = 65536 -->
<property name="boostPower" value="3"/>
CachedUidGenerator采用了双RingBuffer,Uid-RingBuffer用于存储Uid、Flag-RingBuffer用于存储Uid状态(是否可填充、是否可消费)。
![](https://i-blog.csdnimg.cn/blog_migrate/6ce6f0a02b837f66f5890ce24dcd62e7.png)
Tail指针、Cursor指针用于环形数组上读写slot
Tail指针 表示Producer生产的最大序号(此序号从0开始,持续递增)。Tail不能超过Cursor,即生产者不能覆盖未消费的slot。当Tail已赶上curosr,此时可通过rejectedPutBufferHandler指定PutRejectPolicy
Cursor指针 表示Consumer消费到的最小序号(序号序列与Producer序列相同)。Cursor不能超过Tail,即不能消费未生产的slot。当Cursor已赶上tail,此时可通过rejectedTakeBufferHandler指定TakeRejectPolicy
由于数组元素在内存中是连续分配的,可最大程度利用CPU cache以提升性能。但同时会带来「伪共享」FalseSharing问题,为此在Tail、Cursor指针、Flag-RingBuffer中采用了CacheLine 补齐方式。
FalseSharing伪共享问题
CPU缓存
CPU缓存一般分为三级,L1,L2,L3。其中L1和L2只能被一个内核单独使用,是内核私有缓存,而L3则是被单个插槽上面的所有CPU核共享的,三者有以下简单关系:
1.越靠近CPU的缓存就越小,速度也越快
2.小缓存的全部数据是大缓存的一部分
3.越常使用的数据放在离CPU内核更近的位置
再有就是主存也就是内存空间,它被所有插槽上面的CPU内核共享,拥有所有三级缓存的数据,同时也是最慢的。
以上描述的大致结构图如下
![](https://i-blog.csdnimg.cn/blog_migrate/ecf697658b3896b39b0238e9ba2b9d25.png)
当CPU进行运算的时候,会逐级寻找需要的数据,走得越远,耗时就越长
RFO(Request For Owner):
当两个CPU核心需要使用一条共享数据时,一般不会把数据放在三级缓存或者内存中,而是在自己的一级或者二级缓存中,这时候如果某CPU内核要对这条数据进行修改时,就会向其它所有持有该数据的内核发送一个RFO请求,宣布自己占有这条记录,其它内核不能修改,此CPU内核改变这条数据之后,又会将数据同步到L3缓存中,而其它内核缓存中的原始数据全部失效,同步L3缓存中的新数据,这个过程会对极大地降低性能。
什么样的数据是共享数据--同一缓存行中的数据
缓存行:缓存系统中的存储单位是缓存行,通常为64字节
1.一个Java的 long 类型是8字节,因此在一个缓存行中可以存8个long类型的变量。
2.如果访问一个long 数组,当数组中的一个值被加载到缓存中,它会额外加载另外7个
结合以上三块内容,我们便可以对以下图片进行简单讨论:
图中XY两条数据位于同一缓存行,而两条数据都是会被频繁修改的数据,当Core1想要修改X时,会向Core2发送一次RFO请求,同理当Core2想要修改Y时,也会发送一次RFO请求。这种轮番争夺数据占有权的行为非常影响性能。而且如果在这种频繁修改的情况下,此时一个线程想要获取这条缓存行中的数据,可能出现Core1和Core 2中的数据都还未来得及更新而成为失效数据,导致只有L3中的数据是有效的,从前面的CPU缓存部分可知读L3中的数据性能相对来说是很低的,更坏的情况是如果Core1和Core2不在同一插槽,则最后只能从内存中读取数据,效率是最低的。
这就是伪共享问题
![](https://i-blog.csdnimg.cn/blog_migrate/ede4eef2ce5928dd8342a68fe6b6eec2.png)
解决伪共享问题--让两条数据位于不同缓存行
代码示例
public class FalseSharing {
public static void main(String[] args) throws InterruptedException {
long timeSum = 0;
//执行1000次,取平均时间
for (int m = 0; m < 1000; m++) {
CountDownLatch downLatch = new CountDownLatch(2);
//数组中的两个元素改变1000000次
long times = 100*10000L;
//存放元素的数组
VolatileLong[] volatileLongs = {new VolatileLong(), new VolatileLong()};
//两个线程分别改变数组中两个元素的属性值并记录消耗时间
long start = System.currentTimeMillis();
new Thread(()-> {
for (int i = 0; i < times; i++) {
volatileLongs[0].X.incrementAndGet();
}
downLatch.countDown();
},"XThread").start();
new Thread(()-> {
for (int i = 0; i < times; i++) {
volatileLongs[1].X.incrementAndGet();
}
downLatch.countDown();
},"YThread").start();
downLatch.await();
long end = System.currentTimeMillis();
timeSum += (end - start);
}
System.out.println("总耗时为"+timeSum+"平均消耗时间为"+timeSum/1000+"ms");
}
//数组中元素类,分为填充六个long型属性和不填充两种情况
static class VolatileLong{
public volatile AtomicLong X = new AtomicLong(1);
//填充缓存行属性
// public long x1,x2,x3,x4,x5,x6;
}
}
测试结果
![](https://i-blog.csdnimg.cn/blog_migrate/4588a76372996ecd9ee199020483d2cd.png)
Ringbuffer填充时机
初始化预填充 RingBuffer初始化时,预先填充满整个RingBuffer。
即时填充 Take消费时,即时检查剩余可用slot量(tail - cursor),如小于设定阈值,则补全空闲slots。阈值可通过paddingFactor来进行配置。
周期填充 通过Schedule线程,定时补全空闲slots。可通过scheduleInterval配置,以应用定时填充功能,并指定Schedule时间间隔。
7. 美团Leaf
7.1 Leaf-segment 数据库方案
方案背景
上述数据库方案 每次获取ID时都要读写一次数据库,这种频繁IO会导致数据库压力大
Leaf-segment解决方式
从数据库中批量获取id, 每次获取一个segment(step决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。
不同业务id自增序列不一样怎么办
不同的业务发号需求用 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;
DB取号段过程优化
为了DB取号段的过程能够做到无阻塞,当号段消费到某个点时就异步的把下一个号段加载到内存中,而不需要等到号段用尽的时候才去更新号段。
如下图所示
![](https://i-blog.csdnimg.cn/blog_migrate/f6f55dd7036c59010ca782999889b121.png)
leaf采用双buffer模式,服务内部有两个号段缓存区segment。当前号段已下发10%时,如果下一个号段未更新,则另起一个线程去更新下一个号段。当当前号段准备好了则切换到下个号段为当前号段继续下发id。这样就不会出现当前号段用尽时线程拿不到id而阻塞的问题。
7.2 Leaf-snowflake方案
Leaf-snowflake方案完全沿用snowflake方案的bit位设计,对于workerID的分配引入了Zookeeper持久顺序节点的特性自动对snowflake节点配置 wokerIlD。避免了服务规模较大时,动手配置成本太高的问题。
Leaf-snowflake启动步骤
启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点)。
如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务。
如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。
实现架构如下图
![](https://i-blog.csdnimg.cn/blog_migrate/95bcc90cd592c29d4e07173c10700085.png)
8. 几种ID方案对比(结果可能有偏差)
数据库自增方案 | UUID | Redis | 雪花算法 | 百度CacheGenerator | 美团leaf-segment | |
10*10000次 | 44962ms | 584ms | 8575ms | 2013ms | 14ms | 207ms |
100*10000次 | >5min | 857ms | 65978ms | 2197ms | 138ms | 314ms |
1000*10000次 | >50min | 2957ms | >10min | 4413ms | 1425ms | 891ms |
5000*10000次 | >500min | 11747ms | >20min | 14177ms | 6865ms | 2202ms |
![](https://i-blog.csdnimg.cn/blog_migrate/3cc8e09ae99152c33ab5f2d7d39c029a.png)
9. 总结
单纯依赖DB的id分发方案效率最低且会暴露信息(一天有多少订单)
redis测试结果没有理想中的好,理论上应该是80000到100000ps
百度在只有小几百万id生成的情况下性能要优于美团,但在大几百万甚至更大的id生成量的情况下性能要明显低于美团
10. 作者的话
基本上都是从网上学习收录过来的,加上了一点自己的想法和总结,欢迎批评指正。