背景
最近在研究雪花算法,在研究同时,想了一个问题,在高并发的情况下,一秒内,雪花算法能生成多少个ID。
闲话少说,开撸。
概述
而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移到Cassandra(由Facebook开发一套 开源分布式NoSQL数据库系统)因
为Cassandra没有顺序ID生成机制,所以开发了这样一套 全局唯一-ID生成服务。
Twitter的分布式雪花算法SnowFlake,经测试snowflake每秒能够产生26万个自增可排序的ID
- twitter的SnowFlake 生成ID能够按照时间有序生成
- SnowFlake算法生 成id的结果是一个64bit大小的整数, 为- 个Long型(转换成字符串后长度最多19)。
- 分布式系统内不会产生ID碰撞(由datacenter数据中心和workerld机器id作区分)并且效率较高。.
分布式ID
1. 为什么需要分布式全局唯一ID以及分布式ID的业务需求?
在复杂的分布式系统中,往往需要对大量的数据和消息进行唯一标识。如在美团点评的金融、支付、餐饮、酒店等产品的系统中数据日渐增长,对数据分库分表后需要有一个唯一ID来标识一条数据或消息,此时一个能够生成全局唯一ID的系统是非常必要的。
2. ID生成规则部分硬性要求
- 全局唯一:不能出现重复的ID号,既然是唯一标识,这是最基本的要求;
- 趋势递增:在MySql的InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能;
- 单调递增:保证下一个ID一定大于上一个ID,例如事务版本号、IM增量消息、排序等特殊需求;
- 信息安全:如果ID是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可,如果是订单号就更加危险,竞对可以直接知道我们一天的单量;
- 含时间戳:这样就能够在开发中快速了解分布式ID的生成时间。
3. ID生成系统的可用性要求
- 高可用:发一个获取分布式ID的请求,服务器就要保证99.999%的情况下给我创建一个唯一分布式ID;
- 低延迟:发一个获取分布式ID的请求,服务器就要快,极速;
- 高QPS:假如并发一口气10万个创建分布式ID请求同时发过来,服务器要顶得住且一下子成功创建10万个分布式ID。
分布式ID解决方案
1. UUID
是什么?UUID的标准形式:包含32个16进制数字,以连字号分为五段,形式为8-4-4-12的36个字符。
优点:性能非常高,本地生成,没有网络消耗。
缺点:入数据库性能差。
为什么无序的UUID会导致入库性能变差呢?
1)无序,无法预测它的生成顺序,不能生成递增有序的数字。首先分布式ID一般都会作为主键,但是MySql官方推荐主键要尽量越短越好,UUID每一个都很长,所以不是很推荐。
2)主键,ID作为主键时在特定的环境下会存在一些问题,比如做DB主键的场景下,UUID就非常不适用MySql,官方有明确的建议要尽量越短越好,36个字符长度的UUID不符合要求。
3)索引,B+树索引的分裂。既然分布式ID是主键,然后主键是包含索引的,然后MySql的索引是通过b+树来实现的,每一次新的UUID数据的插入,为了查询的优化,都会对索引底层的b+树进行修改,因为UUID数据是无序的,所以每一次UUID数据的插入都会对主键底层的b+树进行很大的修改,这一点很不好。插入完全无序,不但会导致一些中间节点产生分裂,也会白白创造出很多不饱和的节点,这样大大降低了数据库插入的性能。
如果只是考虑唯一性,UUID是OK的。
2. 数据库自增主键
单机应用
在分布式里,数据库的自增ID机制的主要原理是:数据库自增ID和MySql数据库的replace into实现的。
replace into 跟 insert 功能类似,不同点在于:replace into首先尝试插入数据列表中,如果发现表中已经有此行数据(根据主键或唯一索引)判断是否存在,若有则先删除再插入,否则直接插入新数据。REPLACE INTO的含义是插入一条记录,如果表中唯一索引的值遇到冲突,则替换老数据。
CREATE TABLE t_test (
id bigint(20) unsigned not null auto_increment primary key,
stub char(1) not null default '',
unique key stub (stub)
)
select * from t_test;
replace into t_test (stub) values('a');
select last_insert_id();
集群分布式
那数据库自增ID机制适合作分布式ID吗?答案是不太适合的。
- 系统水平扩展比较困难,比如定义好了步长和机器台数之后,如果要添加机器该怎么做?假设现在只有一台机器发号是1到99999,步长是1,这时候需要扩容机器一台。可以这么做:把第二台机器的初始值设置得比第一台超过很多,貌似还好,现在想象一下如果我们线上有100台机器,这个时候要扩容该怎么做?简直是噩梦,所以系统水平扩展方案复杂难以实现。
- 数据库压力还是很大,每次获取ID都得读写一次数据库,非常影响性能,不符合分布式ID里面的延迟低和要高QPS的规则(在高并发下,如果都去数据库里获取ID,那是非常影响性能的)。
3. 基于Redis生成全局ID策略
- 单机应用,因为Redis是单线程的天生保证原子性,可以使用原子操作INCR和INCRBY来实现。
- 集群分布式,可以使用Redis集群来获取更高的吞吐量,注意:在Redis集群情况下,同样和MySql一样需要设置不同的增长步长,同时key一定要设置有效期。
假如一个集群中有5台Redis,可以初始化每台Redis的值分别是1,2,3,4,5 然后步长都是5,每个redis生成的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
4. 雪花算法 - Snowflake
号段解析:
- 符号位(1 bit):不使用,因为二进制中最高位是符号位,1表示负数,0表示整数,生成的ID一般都是正数,所以最高位固定为0;
- 时间戳位(41 bit):用来记录时间戳,毫秒级。41位可以表示2^{41}-1个数字,如果只用来表示正整数(计算机中正数包含0),可以表示的数值范围是:0至2^{41}-1,减1是因为可表示的数值范围是从0开始算的,而不是1。也就是说41位可以表示2^{41}-1个毫秒的值,转化成单位年差不多是69年;
- 工作进程位(10bit):工作机器ID,用来记录工作机器ID。可以部署 2^{10} = 1024个节点,包括5位datacenterId和5位workerId。5位(bit)可以表示的最大正整数是2^{5}-1 = 31,即可以用0、1、2、3 … 31这32个数字,来表示不同的datacenterId或workerId;
- 序列号位(12bit):序列号,用来记录同毫秒内产生的不同ID。12位可以表示的最大正整数是2^{12}-1 = 4095,即可以用0、1、2、3 … 4094 这4095个数字,来表示同一机器同一时间截(毫秒)内产生的4095个ID序号
- SnowFlake可以保证所有生成的ID按时间趋势递增,整个分布式系统内不会产生重复ID(因为有 datacenterId 和 workId来做区分)。
源码
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.CountDownLatch;
/**
* @description:
* @author: Janson Lin
* @time: 2021/4/14 17:02
*/
public class MySnowflake {
/**
* 起始的时间戳
*/
private final static long twepoch = 1618391581881L;
/**
* 每一部分占用的位数
*/
private final static long workerIdBits = 5L;
private final static long datacenterIdBits = 5L;
private final static long sequenceBits = 12L;
/**
* 每一部分的最大值
*/
private final static long maxWorkerId = -1L ^ (-1L << workerIdBits);
private final static long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
private final static long maxSequence = -1L ^ (-1L << sequenceBits);
/**
* 每一部分向左的位移
*/
private final static long workerIdShift = sequenceBits;
private final static long datacenterIdShift = sequenceBits + workerIdBits;
private final static long timestampShift = sequenceBits + workerIdBits + datacenterIdBits;
private long datacenterId; // 数据中心ID
private long workerId; // 机器ID
private long sequence = 0L; // 序列号
private long lastTimestamp = -1L; // 上一次时间戳
public MySnowflake(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;
}
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException(String.format(
"Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & maxSequence;
if (sequence == 0L) {
timestamp = tilNextMillis();
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return (timestamp - twepoch) << timestampShift // 时间戳部分
| datacenterId << datacenterIdShift // 数据中心部分
| workerId << workerIdShift // 机器标识部分
| sequence; // 序列号部分
}
private long tilNextMillis() {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
private long timeGen() {
return System.currentTimeMillis();
}
public static void main(String[] args) {
MySnowflake mySnowflake = new MySnowflake(0,0);
Map<Long, Long> map = new ConcurrentHashMap<>();
Set<Long> set = new ConcurrentSkipListSet<>();
//线程数量
int CURRENT_NUMBER = 400;
final CountDownLatch latch= new CountDownLatch(CURRENT_NUMBER);
try {
Long startTime = System.currentTimeMillis();
for (int i = 0; i < CURRENT_NUMBER; i++) {
new Thread(() -> {
for (int j = 0; j < 10000; j++) {
long id =mySnowflake.nextId();
set.add(id);
}
latch.countDown();
}).start();
}
latch.await();//阻塞当前线程直到latch中数值为零才执行
Long endTime = System.currentTimeMillis();
System.out.println(set.size());
System.out.println((double) (endTime - startTime)/1000);
} catch (Exception e) {
e.printStackTrace();
}
}
}
知识拓展:
雪花算法中的有些补码的运算操作,>> , >>> 和 << ,知识点传送门。
执行结果:(并发400W个ID,只需要1.093秒)
4000000
1.093
雪花算法优缺点
优点:
- 毫秒数在高位,自增序列在低位,整个ID都是趋势递增的;
- 不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的;
- 可以根据自身业务特性分配bit位,非常灵活。
缺点:
- 依赖机器时钟,如果机器时钟回拨,会导致重复ID生成;
- 在单机上是递增的,但是由于设计到分布式环境,每台机器上的时钟不可能完全同步,有时候会出现不是全局递增的情况(此缺点可以认为无所谓,一般分布式ID只要求趋势递增,并不会严格要求递增,90%的需求都只要求趋势递增)。
补充解决时钟回拨思路:因为机器的原因会发生时间回拨,我们的雪花算法是强依赖机器时间的,如果时间发生回拨,有可能会生成重复的ID,在我们上面的nextId中用当前时间和上一次的时间进行判断,如果当前时间小于上一次的时间那么肯定是发生了回拨,普通的算法会直接抛出异常。这里我们可以对其进行优化,一般分为两个情况:
- 如果时间回拨时间较短,比如配置5ms以内,那么可以直接等待一定的时间,让机器时间追上来;
- 如果时间的回拨时间较长,我们不能接受这么长的阻塞等待,那么又有两个策略,直接拒绝,抛出异常,打日志,或者通知RD时钟回滚。