生成分布式ID算法.

一、分布式ID

1. 为什么需要分布式全局唯一ID?

在复杂的分布式系统中,往往需要对大量的数据和消息进行唯一标识。如在美团点评的金融、支付、餐饮、酒店等产品的系统中数据日渐增长,对数据分库分表后需要有一个唯一ID来标识某一条数据或消息,此时一个能够生成全局唯一ID的系统是非常必要的。

2. 那么分布式ID生成需要满足哪些条件

  • 全局唯一:分布式系统下必须要保证ID是全局唯一的,这是最基本的要求;
  • 高性能:高可用低延时,ID生成响应要快,否则反倒会成为业务瓶颈;
  • 高可用:100%的可用性是骗人的,但是也要无限接近于100%的可用;
  • 好接入:要秉着拿来即用的设计原则,在系统设计和实现上要尽可能的简单;
  • 趋势递增:由于多数公司使用的是MySQL InnoDB作为存储引擎,所以ID最好趋势递增。

二、一般通用方案

1. UUID

String uuid = UUID.randomUUID().toString().replaceAll(“-“,””);

  • UUID的标准形式:包含32个16进制数字,以连字号分为五段,形式为8-4-4-12的36个字符;
  • 优点:代码简单,性能非常高(本地生成,没有网络消耗),保证唯一(重复概率极低可以忽略);
  • 缺点:生成的ID是无序的字符串,无法保证趋势递增,且没有一定的业务含义;还有就是长度过长入且无序,会严重消耗MySQL数据库性能。

为什么无序的UUID会导致入库性能变差呢?

  • 作为主键过长,MySQL官方有明确的建议主键尽量越短越好,36个字符长度的UUID不符合要求;
  • 字符串且无序,MySQL InnoDB引擎的索引底层数据结构是B+树,每一次新的UUID插入表,都会对主键底层的B+树索引进行很大的修改,插入完全无序,不但会导致一些中间节点产生分裂,也会白白创造出很多不饱和的节点,这样大大降低了数据库插入的性能,还会增加读取磁盘的次数。

2. 数据库自增主键

  • 单点模式自增ID

在分布式里,数据库的自增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即可。此种方式优点:实现简单,ID单调自增,数值类型查询速度快;缺点:1)强依赖DB,存在单点问题,一旦数据库宕机,整个业务不可用;2)单点数据库压力大,无法扛住高并发场景;3)信息安全问题,比如暴露订单量等。

  • 集群式自增ID

对上面单点模式做优化,改成集群模式自增ID,也就是多个MySQL实例各自生成自增ID。要设置起始值和自增步长,保证生成ID不会重复。

  • 优点:解决了ID生成的单点问题,同时平衡了负载;
  • 缺点:系统后续水平扩容比较困难,增加机器可能要修改步长,起始值也不容易设置。数据库压力还是很大,每次获取ID都得读写一次数据库,非常影响性能,依旧无法满足高并发场景。

3. 基于Redis生成全局ID策略

因为Redis是单线程的天生保证原子性, 利用Redis的incr命令实现ID的原子性自增。

127.0.0.1:6379> set seq_id 1 // 初始化自增ID为1 OK 127.0.0.1:6379> incr seq_id // 增加1,并返回递增后的数值 (integer) 2

集群分布式,可以使用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

优点:效率高,不依赖数据库,可以扛住高并发场景;

缺点:要考虑Redis的持久化问题,Redis支持RDB和AOF两种持久化的方式。

  • RDB会定时打一个快照,如果打完快照后,连续自增了几次,还没来得及做下一次快照持久化,此时Redis挂掉了,重启Redis后会出现ID重复的情况。
  • AOF会对每条写命令进行持久化,即使Redis挂掉了也不会出现ID重复的情况,但是由于incr命令的特殊性,会导致Redis重启恢复数据时间过长。

三、雪花算法 - Snowflake

1. 概述

Twitter的分布式ID生成算法,开源后广受国内大厂的好评,经测试snowflake每秒能够生产26万个自增可排序的ID。

特点:

  • 生成结果是一个64bit大小的Long类型整数;
  • 生成ID能够按照时间有序生成;
  • 不会产生ID碰撞并且效率较高。

2. 结构

雪花算法

结构:符号位(1bit)+ 时间戳(41bit)+ 机器ID(5bit)+ 数据中心(5bit)+ 自增序列号(12bit)

  • 符号位:Java中Long的最高位是符号位代表正负,正数是0,负数是1,一般生成ID都为正数,所以默认为0;
  • 时间戳:毫秒级的时间,不建议存当前时间戳,而是用(当前时间戳 - 固定开始时间戳)的差值,可以使产生的ID从更小的值开始;41位的时间戳可以使用69年,(1L << 41) / (1000L 60 60 24 365) = 69年;
  • 工作机器ID:用来记录工作机器ID。可以部署 2^{10} = 1024个节点,包括5位datacenterId和5位workerId。可以灵活配置,机房或者机器号组合都可以;
  • 序列号:自增值,支持同一毫秒内同一个节点(同一个机器)可以生成4096个ID。

因为时间戳是递增的,序列号也是递增的,所以雪花算法可以保证所有生成的ID按时间趋势递增,整个分布式系统内不会产生重复ID(因为有 datacenterId 和 workId来做区分)。

3. 源码

Java版(Hutool):

/** * Twitter的Snowflake 算法
* 分布式系统中,有一些需要使用全局唯一ID的场景,有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。 * *

* snowflake的结构如下(每部分用-分开):
* *

 * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 * 
*

* 第一位为未使用(符号位表示正数),接下来的41位为毫秒级时间(41位的长度可以使用69年)
* 然后是5位datacenterId和5位workerId(10位的长度最多支持部署1024个节点)
* 最后12位是毫秒内的计数(12位的计数顺序号支持每个节点每毫秒产生4096个ID序号) *

* 并且可以通过生成的id反推出生成时间,datacenterId和workerId *

* 参考:http://www.cnblogs.com/relucent/p/4955340.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
`/* * @author Looly `

`* @since 3.0.1 */ `
`public class Snowflake implements Serializable {
private static final long serialVersionUID = 1L; // 开始时间戳
private final long twepoch; // 机器标识位数
private final long workerIdBits = 5L; // 数据中心标识位数
private final long dataCenterIdBits = 5L; // 最大支持机器节点数0~31,一共32个 最大支持数据中心节点数0~31,一共32个
@SuppressWarnings({"PointlessBitwiseExpression", "FieldCanBeLocal"})
private final long maxWorkerId = -1L ^ (-1L << workerIdBits); @SuppressWarnings({"PointlessBitwiseExpression", "FieldCanBeLocal"})
private final long maxDataCenterId = -1L ^ (-1L << dataCenterIdBits); // 序列号12位
private final long sequenceBits = 12L; // 机器节点左移12位
private final long workerIdShift = sequenceBits; // 数据中心节点左移17位
private final long dataCenterIdShift = sequenceBits + workerIdBits; // 时间毫秒数左移22位
private final long timestampLeftShift = sequenceBits + workerIdBits + dataCenterIdBits;
@SuppressWarnings({"PointlessBitwiseExpression", "FieldCanBeLocal"})
private final long sequenceMask = -1L ^ (-1L << sequenceBits);// 4095
private final long workerId;
private final long dataCenterId;
private final boolean useSystemClock;
private long sequence = 0L;
private long lastTimestamp = -1L;
/** * 构造 * *
@param workerId 终端ID *
@param dataCenterId 数据中心ID */
public Snowflake(long workerId, long dataCenterId) {
this(workerId, dataCenterId, false);
} /** * 构造 * *
@param workerId 终端ID *
@param dataCenterId 数据中心ID *
@param isUseSystemClock 是否使用
{@link SystemClock} 获取当前时间戳 */
public Snowflake(long workerId, long dataCenterId, boolean isUseSystemClock) { this(null, workerId, dataCenterId, isUseSystemClock); }
/** *
@param epochDate 初始化时间起点(null表示默认起始日期),后期修改会导致id重复,如果要修改连workerId dataCenterId,慎用 *
@param workerId 工作机器节点id *
@param dataCenterId 数据中心id *
@param isUseSystemClock 是否使用{@link SystemClock} 获取当前时间戳 *
@since 5.1.3 */
public Snowflake(Date epochDate, long workerId, long dataCenterId, boolean isUseSystemClock) {
if (null != epochDate) {
this.twepoch = epochDate.getTime();
} else { // Thu, 04 Nov 2010 01:42:54 GMT
this.twepoch = 1288834974657L; }
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than {}} or less than 0", maxWorkerId));
}
if (dataCenterId > maxDataCenterId || dataCenterId < 0) { throw new IllegalArgumentException(String.format("datacenter Id can't be greater than {} or less than 0", maxDataCenterId)); }
this.workerId = workerId; this.dataCenterId = dataCenterId; this.useSystemClock = isUseSystemClock; }
/** * 根据Snowflake的ID,获取机器id * *
@param id snowflake算法生成的id *
@return 所属机器的id */
public long getWorkerId(long id) {
return id >> workerIdShift & ~(-1L << workerIdBits); }
/** * 根据Snowflake的ID,获取数据中心id * *
@param id snowflake算法生成的id *
@return 所属数据中心 */
public long getDataCenterId(long id) {
return id >> dataCenterIdShift & ~(-1L << dataCenterIdBits);
}
/** * 根据Snowflake的ID,获取生成时间 * *
@param id snowflake算法生成的id *
@return 生成的时间 */
public long getGenerateDateTime(long id) {
return (id >> timestampLeftShift & ~(-1L << 41L)) + twepoch; }
/** * 下一个ID * *
@return ID */
public synchronized long nextId() {
long timestamp = genTime();
if (timestamp < lastTimestamp) {
// 如果服务器时间有问题(时钟后退) 报错。
throw new IllegalStateException(String.format("Clock moved backwards. Refusing to generate id for {}ms", (lastTimestamp - timestamp))); }
if (lastTimestamp == timestamp) { sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp); }
} else { sequence = 0L; }
lastTimestamp = timestamp;
return ((timestamp - twepoch) << timestampLeftShift) | (dataCenterId << dataCenterIdShift) | (workerId << workerIdShift) | sequence; }
/** * 下一个ID(字符串形式) * *
@return ID 字符串形式 */
public String nextIdStr() { return Long.toString(nextId());
} // ------------------------------------------------------------------------------------------------------------------------------------
Private method start
/** * 循环等待下一个时间 * *
@param lastTimestamp 上次记录的时间 *
@return 下一个时间 */
private long tilNextMillis(long lastTimestamp) { long timestamp = genTime();
while (timestamp <= lastTimestamp) { timestamp = genTime(); } return timestamp; }
/** * 生成时间戳 * * @return 时间戳 */
private long genTime() { return this.useSystemClock ? SystemClock.now() : System.currentTimeMillis(); } }`

4. 工程落地经验

分布式整合雪花算法:

IdGenerateInvoker.java

/** * 分布式环境下数据库主键的ID生成器 * 实现了对分布式友好的Snowflake算法,并且会在Spring IOC容器初始化的时候注册成为单例Bean. * 如果需要自定义生成策略,请实现本接口并将其实例注册到容器中,并在{@link PrimaryKey}的strategy属性指定. * / public interface IdGenerateInvoker { /* * 获取下一个永不重复的id. * * @return id. */ Serializable nextId(); }

SnowFlakeIdGenerateInvoker.java

/** * ======分布式数据库主键ID生成器基于SnowFlake算法的实现. * 本实现类的对象将会在程序启动的时候自动在Spring IOC容器中注册为Bean. * 如果想在您的持久化过程中由系统自动设置主键字段的值,只需要在具体的POJO字段上添加@PrimaryKey注解,详见{@link PrimaryKey}. * 其默认打开,并且在未指定具体id生成算法的前提下,它将自动使用SnowFlake算法生成id,在持久化之前自动设置值. *

* SnowFlake算法的实现依赖于workerId和dataCenterId两个值的,系统默认使用5L作为其初始值. * 如果您想要在分布式环境使用本算法的实现,建议您在application.yml中配置这2个属性的值. */ @Component public class SnowFlakeIdGenerateInvoker implements IdGenerateInvoker { @Autowired private SnowFlakeConfig snowFlakeConfig; private Snowflake snowflake; @PostConstruct public void init() { long wokerid = snowFlakeConfig.getWorkerId(); long dataCenterId = snowFlakeConfig.getDataCenterId(); this.snowflake = new Snowflake(wokerid, dataCenterId); } @Override public synchronized Serializable nextId() { return snowflake.nextId(); } }

SnowFlakeConfig.java

  • 公司workerId使用位长为8位,取的是机器IP地址后三位;
  • dataCenterId使用位长为两位,配置在配置文件中。

/** * 关于SnowFlake的配置. * WorkerId和DataCenterId的值请保证集群中的每个节点不相同. * WorkerId取ip地址最后三位,dataCenterId配置在properties. */ @Component @Validated @Slf4j @ConfigurationProperties(prefix = “snowflake”) public class SnowFlakeConfig { @Value(“${spring.application.name}”) private String appName; private long workerId; @NotNull private long dataCenterId; @PostConstruct public void init() { try { String ipAddr = NetUtil.getHostIpAddr(); log.info(“current ip :{}”, ipAddr); if (!StringUtils.isEmpty(ipAddr)) { String[] ipStep = ipAddr.split(“\.”); this.workerId = Long.valueOf(ipStep[3]); } else { log.warn(“ip address parse error.”); } } catch (Exception e) { throw new RuntimeException(e); } log.info(“Snowflake:[workerId:{} from ip address tail, dataCenterId:{} from properties] of micro service [{}] has been initialized.” , workerId, dataCenterId, appName == null ? “” : appName.toUpperCase()); } public long getWorkerId() { return workerId; } public void setWorkerId(long workerId) { this.workerId = workerId; } public long getDataCenterId() { return dataCenterId; } public void setDataCenterId(long dataCenterId) { this.dataCenterId = dataCenterId; } }

application.properties

spring.application.name=ncs-case # snowflake配置 #snowflake.worker-id= snowflake.data-center-id=2

5. 雪花算法优缺点

优点:

  • 毫秒数在高位,自增序列在低位,整个ID都是趋势递增的;
  • 不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的;
  • 可以根据自身业务特性分配bit位,非常灵活。

缺点:

  • 依赖机器时钟,如果机器时钟回拨,会导致重复ID生成;
  • 在单机上是递增的,但是由于设计到分布式环境,每台机器上的时钟不可能完全同步,有时候会出现不是全局递增的情况(此缺点可以认为无所谓,一般分布式ID只要求趋势递增,并不会严格要求递增,90%的需求都只要求趋势递增)。

补充解决时钟回拨思路:因为机器的原因会发生时间回拨,我们的雪花算法是强依赖机器时间的,如果时间发生回拨,有可能会生成重复的ID,在我们上面的nextId中用当前时间和上一次的时间进行判断,如果当前时间小于上一次的时间那么肯定是发生了回拨,普通的算法会直接抛出异常。这里我们可以对其进行优化,一般分为两个情况:

  • 如果时间回拨时间较短,比如配置5ms以内,那么可以直接等待一定的时间,让机器时间追上来;
  • 如果时间的回拨时间较长,我们不能接受这么长的阻塞等待,那么又有两个策略,直接拒绝,抛出异常,打日志,或者通知RD时钟回滚。

四、其他方式

在这里插入图片描述

  • 百度开源的分布式唯一ID生成器UidGenerator
  • 美团点评分布式ID生成系统 Leaf