一、分布式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 算法<br>
* 分布式系统中,有一些需要使用全局唯一ID的场景,有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。
*
* <p>
* snowflake的结构如下(每部分用-分开):<br>
*
* <pre>
* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
* </pre>
* <p>
* 第一位为未使用(符号位表示正数),接下来的41位为毫秒级时间(41位的长度可以使用69年)<br>
* 然后是5位datacenterId和5位workerId(10位的长度最多支持部署1024个节点)<br>
* 最后12位是毫秒内的计数(12位的计数顺序号支持每个节点每毫秒产生4096个ID序号)
* <p>
* 并且可以通过生成的id反推出生成时间,datacenterId和workerId
* <p>
* 参考:http://www.cnblogs.com/relucent/p/4955340.html
*
* @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,在持久化之前自动设置值.
* <p>
* 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