说出你心中的分布式ID
说出你心中的分布式ID
ID每张表都会涉及到, 正常情况下只要是业务表, 就肯定会涉及到ID , 因为ID是 表与表之间关系的枢纽, 同时也是用户动作触发追溯到表数据的重要途径.
单机情况下,我们会想到UUID, 但是在集群分布式场景,已经满足不了需求,
我所知道的分布式ID, 主要分三类:
- 基于中间件的自增ID
- 数据库自增ID
- redis自增
- 号段模式
- 滴滴 TinyId
- 美团Leaf
- 雪花算法
- 美团Leaf
- 百度Uidgenerator
自增ID
Redis自增ID
原理就是利用 incr 命令来实现原子性的自增
set seq_id 1 // 初始化自增ID为1
OK
incr seq_id // 增加1, 并返回
(integer) 2
incr seq_id // 增加1, 并返回
(integer) 3
这种模式效率挺高的,但是考虑到redis 持久化, redis 支持两种模式 RDB 和AOF持久化模式
RDB是快照进行持久化的, 如果光用RDB, 重启服务是会出现ID重复的
数据库自增ID
CREATE DATABASE `SEQID`;
CREATE TABLE SEQID.SEQUENCE_ID (
id bigint(20) unsigned NOT NULL auto_increment,
stub char(10) NOT NULL default '',
PRIMARY KEY (id),
UNIQUE KEY stub (stub)
) ENGINE=MyISAM;
begin;
replace into SEQUENCE_ID (stub) VALUES ('anyword');
select last_insert_id();
commit;
插入我们用的是replace, replace会先看是否存在stub指定值一样的数据, 如果存在则先delete再insert, 如果不存在则直接insert.
虽然可行, 但是, 业务系统每次需要一个ID时, 都需要请求数据库获取, 性能低, 并且如果此数据库实例下线了, 那么将影响所有的业务系统.
为了解决数据库可靠性问题, 我们可以使用数据库多主模式的分布式ID生成方案.
数据库多主模式
如果我们两个数据库组成一个主从模式集群, 正常情况下可以解决数据库可靠性问题, 但是如果主库挂掉后, 数据没有及时同步到从库, 这个时候会出现ID重复的现象. 我们可以使用双主模式集群, 也就是两个Mysql实例都能单独的生产自增ID, 这样能够提高效率, 但是如果不经过其他改造的话, 这两个Mysql实例很可能会生成同样的ID. 需要单独给每个Mysql实例配置不同的起始值和自增步长.
第一台Mysql实例配置:
set @@auto_increment_offset = 1; -- 起始值
set @@auto_increment_increment = 2; -- 步长
第二台Mysql实例配置:
set @@auto_increment_offset = 2; -- 起始值
set @@auto_increment_increment = 2; -- 步长
result
第一台Mysql:
1,3,5,7,9 ...
第二台Mysql
2,4,6,8,10 ...
这种场景可以封装成调用服务, 通过rpc 的方式比如DistributIdService 来提供一个接口为其他服务提供ID, DistributIdService 随机从两个mysql实例中获取ID
这种方案只要有一个mysql实例存在都可以正常运行.但是中方式不好扩展, 性能下降就要新增mysql服务.
现在如果要新增一个实例mysql3, 要怎么操作呢? 第一, mysql1、mysql2的步长肯定都要修改为3, 而且只能是人工去修改, 这是需要时间的. 第二, 因为mysql1和mysql2是不停在自增的, 对于mysql3的起始值我们可能要定得大一点, 以给充分的时间去修改mysql1, mysql2的步长. 第三, 在修改步长的时候很可能会出现重复ID, 要解决这个问题, 可能需要停机才行.
为了解决上面的问题, 以及能够进一步提高DistributIdService的性能, 我们有了号段模式
号段模式
这种模式, 其实就是一次性从数据库获取批数据. 这样减少了与持久层的交互, 大大提升服务效率.
意思就是DistributIdService 每次从数据库拿(0, 1000], 这个范围内 1000个ID, 业务应用获取时候, 只需要应用本地 从1 开始自增并返回, 直到1000,再从数据获取一下.
CREATE TABLE id_generator (
id int(10) NOT NULL,
current_max_id bigint(20) NOT NULL COMMENT '当前最大id',
increment_step int(10) NOT NULL COMMENT '号段的长度',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
自增的逻辑被添加到应用层, 由DistributIdService 代理. 所以, 这里只要记录id最大值.
这种方案不在强依赖数据, 但是如果服务崩了就会出现ID 空洞. 最简单的解决方案就是集群提高可用性. 这样的话会存在:
多个DistributIdService节点同时请求数据库获取号段, 那么这个时候需要利用乐观锁来进行控制
可以加一个version 字段控制一下
update id_generator set current_max_id=#{newMaxId}, version=version+1 where version = #{version}
为了进一步提高性能, 依然采用数据库多主从的方式, 进行部署.如果有2个Mysql 实例
第一个Mysql: [1, 999]
1,3,5,7,9 ...999
第一个Mysql: [2, 1000]
2,4,6,8,10 ...1000
滴滴在此基础上又做了修改, 将自增逻辑从 DistributIdService 移到 业务调用方本地. 这样只需要获取号段即可, 不用每次调用DistributIdService.
更详细的可以参考滴滴开源的TinyId: https://github.com/didi/tinyid/wiki
有趣的是, 滴滴声称自己有1000w+的qps. 而在推荐友商项目时候标明百度uid-generator: qps可达600w+ [doge]
美团Leaf同样有号段模式Leaf开源
雪花算法
上面的都是基于自增这种模式的. 我们来说下 雪花算法(Snowflake)
snowflake是twitter开源的分布式ID生成算法, 是一种算法, 所以它和上面的三种生成分布式ID机制不太一样, 它不依赖数据库.
生成分布式ID的每台机器在每毫秒内生成不一样的ID.
核心思想是: 分布式ID固定是一个long型的数字, 一个long型占8个字节, 也就是64个bit, 原始snowflake算法中对于bit的分配如下图:
0 - 41位时间戳 - 5位数据中心标识 - 5位机器标识 - 12位序列号
-
第一个bit位是标识部分, 在java中由于long的最高位是符号位, 正数是0, 负数是1, 一般生成的ID为正数, 所以固定为0.
-
时间戳部分占41bit, 这个是毫秒级的时间, 一般实现上不会存储当前的时间戳, 而是时间戳的差值(当前时间-固定的开始时间), 这样可以使产生的ID从更小值开始;41位的时间戳可以使用69年, (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69年
-
工作机器id (workId) 占10bit, 这里比较灵活, 比如, 可以使用前5位作为数据中心机房标识, 后5位作为单机房机器标识, 可以部署1024个节点.
序列号部分占12bit, 支持同一毫秒内同一个节点可以生成4096个ID
/**
* twitter的snowflake算法 -- java实现
*
* @author beyond
* @date 2016/11/26
*/
public class SnowFlake {
/**
* 起始的时间戳
*/
private final static long START_STMP = 1480166465631L;
/**
* 每一部分占用的位数
*/
private final static long SEQUENCE_BIT = 12; //序列号占用的位数
private final static long MACHINE_BIT = 5; //机器标识占用的位数
private final static long DATACENTER_BIT = 5;//数据中心占用的位数
/**
* 每一部分的最大值
*/
private final static long MAX_DATACENTER_NUM = -1L ^ (-1L << DATACENTER_BIT);
private final static long MAX_MACHINE_NUM = -1L ^ (-1L << MACHINE_BIT);
private final static long MAX_SEQUENCE = -1L ^ (-1L << SEQUENCE_BIT);
/**
* 每一部分向左的位移
*/
private final static long MACHINE_LEFT = SEQUENCE_BIT;
private final static long DATACENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT;
private final static long TIMESTMP_LEFT = DATACENTER_LEFT + DATACENTER_BIT;
private long datacenterId; //数据中心
private long machineId; //机器标识
private long sequence = 0L; //序列号
private long lastStmp = -1L;//上一次时间戳
public SnowFlake(long datacenterId, long machineId) {
if (datacenterId > MAX_DATACENTER_NUM || datacenterId < 0) {
throw new IllegalArgumentException("datacenterId can't be greater than MAX_DATACENTER_NUM or less than 0");
}
if (machineId > MAX_MACHINE_NUM || machineId < 0) {
throw new IllegalArgumentException("machineId can't be greater than MAX_MACHINE_NUM or less than 0");
}
this.datacenterId = datacenterId;
this.machineId = machineId;
}
/**
* 产生下一个ID
*
* @return
*/
public synchronized long nextId() {
long currStmp = getNewstmp();
if (currStmp < lastStmp) {
throw new RuntimeException("Clock moved backwards. Refusing to generate id");
}
if (currStmp == lastStmp) {
//相同毫秒内, 序列号自增
sequence = (sequence + 1) & MAX_SEQUENCE;
//同一毫秒的序列数已经达到最大
if (sequence == 0L) {
currStmp = getNextMill();
}
} else {
//不同毫秒内, 序列号置为0
sequence = 0L;
}
lastStmp = currStmp;
return (currStmp - START_STMP) << TIMESTMP_LEFT //时间戳部分
| datacenterId << DATACENTER_LEFT //数据中心部分
| machineId << MACHINE_LEFT //机器标识部分
| sequence; //序列号部分
}
private long getNextMill() {
long mill = getNewstmp();
while (mill <= lastStmp) {
mill = getNewstmp();
}
return mill;
}
private long getNewstmp() {
return System.currentTimeMillis();
}
public static void main(String[] args) {
SnowFlake snowFlake = new SnowFlake(2, 3);
for (int i = 0; i < (1 << 12); i++) {
System.out.println(snowFlake.nextId());
}
}
}
优点:
毫秒数在高位, 自增序列在低位, 整个ID都是趋势递增的;
不依赖数据库等第三方系统, 以服务的方式部署, 稳定性更高, 生成ID的性能也是非常高的;
可以根据自身业务特性分配bit位, 非常灵活.
缺点:
- 强依赖机器时钟, 如果机器上时钟回拨, 会导致发号重复或者服务会处于不可用状态.
这里比较难搞定的是workId 总不能人工确定吧. 容易出错你说是不是. 看看别人是怎么做的
百度 uid-generator
github地址: uid-generator
sign | delta seconds | work node id | sequence |
---|---|---|---|
1bit | 28bits | 22bits | 13bits |
如上图所示, UidGenerator默认ID中各数据位的含义如下:
- sign(1bit): 固定1bit符号标识, 即生成的UID为正数.
- delta seconds (28 bits): 当前时间, 相对于时间基点"2016-05-20"的增量值, 单位: 秒, 最多可支持约8.7年(注意: (a)这里的单位是秒, 而不是毫秒! (b)注意这里的用词, 是“最多”可支持8.7年, 为什么是“最多”, 后面会讲).
- worker id (22 bits): 机器id, 最多可支持约420w次机器启动. 内置实现为在启动时由数据库分配, 默认分配策略为用后即弃, 后续可提供复用策略.
- sequence (13 bits): 每秒下的并发序列, 13 bits可支持每秒8192个并发(注意下这个地方, 默认支持qps最大为8192个).
通过阅读UidGenerator的源码可知, UidGenerator的具体实现有两种选择, 即 DefaultUidGenerator 和 CachedUidGenerator
先建表
DROP DATABASE IF EXISTS `xxxx`;
CREATE DATABASE `xxxx` ;
use `xxxx`;
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会在集成用它生成分布式ID的实例启动的时候, 往这个表中插入一行数据, 得到的id值就是准备赋给workerId的值. 由于workerId默认22位, 那么, 集成DefaultUidGenerator生成分布式ID的所有实例重启次数是不允许超过4194303次(即2^22-1), 否则会抛出异常.
另外, 如果时间有任何的回拨,那么直接抛出异常. 非常果断, 感觉如果真抛异常了 那影响还是挺恐怖的.
美团(Leaf)
github地址: Leaf
非常全面, 即支持号段模式, 也支持snowflake模式.
这里介绍一下Leaf的snowflake, 滴滴TinyId号段模式是在Leaf的基础上改动的大致一样.
Leaf-snowflake方案完全沿用snowflake方案的bit位设计, 即是“1+41+10+12”的方式组装ID号. 对于workerID的分配, 当服务集群数量较小的情况下, 完全可以手动配置. Leaf服务规模较大, 动手配置成本太高. 所以使用Zookeeper持久顺序节点的特性自动对snowflake节点配置wokerID.
Leaf-snowflake是按照下面几个步骤启动的:
- 启动Leaf-snowflake服务, 连接Zookeeper, 在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点).
- 如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号), 启动服务.
- 如果没有注册过, 就在该父节点下面创建一个持久顺序节点, 创建成功后取回顺序号当做自己的workerID号, 启动服务.
除了每次会去ZK拿数据以外, 也会在本机文件系统上缓存一个workerID文件. 当ZooKeeper出现问题, 恰好机器出现问题需要重启时, 能保证服务能够正常启动. 这样做到了对三方组件的弱依赖. 一定程度上提高了SLA.
解决时钟问题
参见上图整个启动流程图, 服务启动时首先检查自己是否写过ZooKeeper leaf_forever节点:
- 若写过, 则用自身系统时间与leaf_forever/${self}节点记录时间做比较, 若小于leaf_forever/${self}时间则认为机器时间发生了大步长回拨, 服务启动失败并报警.
- 若未写过, 证明是新服务节点, 直接创建持久节点leaf_forever/${self}并写入自身系统时间, 接下来综合对比其余Leaf节点的系统时间来判断自身系统时间是否准确, 具体做法是取leaf_temporary下的所有临时节点(所有运行中的Leaf-snowflake节点)的服务IP: Port, 然后通过RPC请求得到所有节点的系统时间, 计算sum(time)/nodeSize.
- 若abs( 系统时间-sum(time)/nodeSize ) < 阈值, 认为当前系统时间准确, 正常启动服务, 同时写临时节点leaf_temporary/${self} 维持租约.
- 否则认为本机系统时间发生大步长偏移, 启动失败并报警.
- 每隔一段时间(3s)上报自身系统时间写入leaf_forever/${self}.
最后
本人使用的也是基于美团的Leaf方案 , 当然是在leaf-core改造的rpc服务. http比较迷幻, rpc还是快的. 在看源码的时候我发现一个问题. 在 snowflake 模式下.
curl http://localhost:8080/api/snowflake/get/{key}
这里key 只是象征性的传了一下. 并没有用到.我在自己的改造服务中任然保留这一块. 传了 每个服务的 applicationName, 这样可以保证 和 号段模式统一.
源码在barm-tree项目中: https://github.com/AllenAlan/barm/tree/master/barm-tree
就这些了, 很简单吧. 感谢陪伴. 欢迎关注, 转发, 评论, 点赞, 收藏~