目的
解决大型分布式互联网项目主键生成的技术方案。
解决方案
解决方案从网上找了有以下几种。接下来分别说下这几种方式的优劣。
-
数据库自增长序列或字段
如果单单用数据库的某个列的自增长来完成,就不是大型分布式互联网项目的解决方案了,因为数据库自增长每秒的速度在3000+左右(依服务器性能而定,这里只能只数量级),像双十一每秒都十几万单的量,肯定不能满足要求。 -
UUID
使用 UUID to Int64 的方法
这种方式使用很少,因为目前大型互联网公司使用MySQL数据库比较多,而UUID当做主键会因为主键存储在非叶子节点的随机性,将导致查询在叶子节点的数据时的随机性,将导致大量的随机IO,严重影响查询的效率。比较适合的做法是把 uuid 作为逻辑主键,物理主键依然使用 自增ID。 -
Redis 生成 ID
本人在工作中并没有使用过Redis生成ID。网上使用Redis生成ID有以下方案。
可以使用Redis集群来获取更高的吞吐量。假如一个集群中有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
这个,随便负载到哪个机确定好,未来很难做修改。但是3-5台服务器基本能够满足器上,都可以获得不同的ID。但是步长和初始值一定需要事先需要了。使用Redis集群也可以方式单点故障的问题。
另外,比较适合使用Redis来生成每天从0开始的流水号。比如订单号=日期+当日自增长号。可以每天在Redis中生成一个Key,使用INCR进行累加。
优点:
1)不依赖于数据库,灵活方便,且性能优于数据库。
2)数字ID天然排序,对分页或者需要排序的结果很有帮助。
缺点:
1)如果系统中没有Redis,还需要引入新的组件,增加系统复杂度。
2)需要编码和配置的工作量比较大。
- Twitter 的 snowflake 算法
这个在项目中有使用过。总体感觉良好。
snowflake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是0。
snowflake算法可以根据自身项目的需要进行一定的修改。比如估算未来的数据中心个数,每个数据中心的机器数以及统一毫秒可以能的并发数来调整在算法中所需要的bit数。
优点:
1)不依赖于数据库,灵活方便,且性能优于数据库。
2)ID按照时间在单机上是递增的。
缺点:
1)在单机上是递增的,但是由于涉及到分布式环境,每台机器上的时钟不可能完全同步,也许有时候也会出现不是全局递增的情况。需要同步机器时钟。
- 利用 zookeeper 生成唯一 ID
zookeeper主要通过其znode数据版本来生成序列号,可以生成32位和64位的数据版本号,客户端可以使用这个版本号来作为唯一的序列号。
很少会使用zookeeper来生成唯一ID。主要是由于需要依赖zookeeper,并且是多步调用API,如果在竞争较大的情况下,需要考虑使用分布式锁。因此,性能在高并发的分布式环境下,也不甚理想。 - MongoDB 的 ObjectId
MongoDB的ObjectId和snowflake算法类似,需要引入新的中间件,使用较少。
建议方案
如果在并发不是特别大的情况下,建议还是采用 MySQL实现方式。
在只使用单数据库时,使用自增主键ID无疑是最适合的。但在集群、主从架构上时就会有一些问题,比如 : 主键的全局唯一。
在集群环境下除自增ID外的其它创建主键的方案。
-
通过应用程序生成一个 GUID,然后和数据一起插入切分后的集群
优点 : 维护简单,实现也容易
缺点 : 应用的计算成本较大,且 GUID 的长度比较长,占用数据库存储空间较大,涉及到应用的开发 -
通过独立的应用程序事先在数据库中生成一系列唯一的 ID,各应用程序通过接口或者自己去读取再和数据一起插入到切分后的集群中
优点 : 全局唯一主键简单,维护相对容易。
缺点 : 实现复杂,需要应用开发,且 ID表 要频繁查和频繁更新,插入数据时,影响性能。 -
通过中心数据库服务器利用数据库自身的自增类型 (如 MySQL 的 auto_increment 字段),或者自增对象 (如 Oracle 的 Sequence) 等先生成一个唯一ID 再和数据一起插入切分后的集群 (不推荐)。
优点 : 没有特别明显的优点
缺点 : 实现较为复杂,且整体可用性维系在这个中心数据库服务器上,一旦这里 crash,所有的集群都无法进行插入操作 -
通过集群编号加集群内的自增 (auto_increment类型) 两个字段 共同组成唯一主键 (虽然是两个字段,但是这方式存储空间最小,仅仅多了一个 smallint 两个字节)。
优点 : 实现简单,维护也比较简单,对应用透明
缺点 : 引用关联操作相对比较复杂,需要两个字段,主键占用空间较大,在使用 InnoDB 的时候这一点的副作用很明显 -
通过设置每个集群中自增ID起始点 (auto_increment_offset),将各个集群的ID进行绝对的分段来实现全局唯一。当遇到某个集群数据增长过快后,通过命令调整下一个ID起始位置跳过可能存在的冲突。
优点 : 实现简单,且比较容易根据 ID 大小直接判断出数据处在哪个集群,对应用透明
缺点 : 维护相对较复杂,需要高度关注各个集群 ID 增长状况 -
通过设置每个集群中自增 ID 起始点 (auto_increment_offset) 以及 ID 自增步长 (auto_increment_increment),让目前每个集群的起始点错开 1,步长选择大于将来基本不可能达到的切分集群数,达到将 ID 相对分段的效果来满足全局唯一的效果 (避免重合需要多种方案结合)
实现 : N台数据库,第一台 mysql 主键从1 开始每次加 N,第二台从2开始每次加 N,以此类推,在获取数据时如果第一台 Server 获取失败,则从第二台 Server 上获取
优点 : 实现简单,后期维护简单,对应用透明
缺点 : 第一次设置相对较为复杂
HP方案
主键服务在业务系统中频繁被使用,必须保证主键生成非常快速,因此设计了客户端使用本地缓存的策略,客户端每次拿到一个主键值当1000使用,当主键值尾数到999时再重新从主键服务器上获取新的主键值。当主键服务器挂掉时,只能依赖本地的缓存策略了。服务可用性相对就没那么高了,但是能满足绝大多数要求。
注:
客户端已经设计了IdentityUtil工具类,采用静态注入的方式,每次获取主键值时通过客户端工具类获取。
对集群来说主键值不是严格有序的。
使用规范
1.pom.xml添加依赖项
<dependency>
<groupId>com.xx.jr.framework</groupId>
<artifactId>framework.identity.service</artifactId>
<version>0.0.1</version>
</dependency>
<dependency>
<groupId>com.xx.jr.framework</groupId>
<artifactId>framework.commons</artifactId><!--这里 framework 要替换成各子系统的 commons-->
<version>0.0.1</version>
</dependency>
2.通过dubbo引入主键服务
在/src/main/resources/conf/finance-framework-dubbo.xml 文件中添加主键服务配置。
<dubbo:reference id="identityService" interface="com.xx.finance.framework.identity.service.IdentityService" version="1.0.0" />
3.注入主键服务
在/src/main/resources/conf/finance-framework-context.xml 文件中添加主键工具类配置
<bean id="identityUtil" class="com.xx.finance.framework.identity.utils.IdentityUtil">
<property name="identityService" ref="identityService" />
</bean>
4.代码中使用主键
Long smsId = IdentityUtil.generateId( SystemNames.SYSTEM, SystemNames.SUB_SYSTEM_USER_CENTER, FrameworkConstants.SYSTEM_SUB_MODULE_SMS, SMSTables.TABLE_SMS );
注:
SystemNames.SYSTEM、SystemNames.SUB_SYSTEM_USER_CENTER 在 finance.framework.commons 里面定义
FrameworkConstants.SYSTEM_SUB_MODULE_SMS在当前子系统的commons包里面定义
SMSTables.TABLE_SMS在当前模块的impl里面定义
HA方案
HA是目前互联网中经常使用的方案-雪花算法。如自然界的雪花一样,没有两片雪花是完全相同的。snowflake算法表示生成的ID如雪花般独一无二。以下是雪花算法的组成结构。大致由:首位无效符、时间戳差值,机器(进程)编码,序列号四部分组成。
为什么首位是无效位?因为二进制里第一个 bit 为如果是 1,那么都是负数,但是我们生成的 id 都是正数,所以第一个 bit 统一都是 0。
特点(自增、有序、适合分布式场景)
- 时间位:可以根据时间进行排序,有助于提高查询速度。41 bit表示时间戳,单位为毫秒,可以表达2^41-1个毫秒值,也就是69年的时间。
- 机器id位:适用于分布式环境下对多节点的各个节点进行标识,可以具体根据节点数和部署情况设计划分机器位10位长度,如划分5位表示进程位等。如果是10则可以表示该主键服务可以部署在1024台机器上。其中可以用5位表示机房号(32个机房),则剩下5位则可以表示每个机房32台机器。
- 序列号位:是一系列的自增id,可以支持同一节点同一毫秒生成多个ID序号,12位的计数序列号支持每个节点每毫秒产生4096个ID序号。
snowflake算法可以根据项目情况以及自身需要进行一定的修改。
优点
(1)高性能高可用:生成时不依赖于数据库,完全在内存中生成。
(2)容量大:每秒中能生成数百万的自增ID。
(3)ID自增:存入数据库中,索引效率高。
缺点
(1)依赖与系统时间的一致性,如果系统时间被回调,或者改变,可能会造成id冲突或者重复。
(2)雪花算法在单机系统上ID是递增的,但是在分布式系统多节点的情况下,所有节点的时钟并不能保证不完全同步,所以有可能会出现不是全局递增的情况。
(3)实际中我们的机房并没有那么多,我们可以改进改算法,将10bit的机器id优化,成业务表或者和我们系统相关的业务。
Java代码实现
public class IdWorker {
//因为二进制里第一个 bit 为如果是 1,那么都是负数,但是我们生成的 id 都是正数,所以第一个 bit 统一都是 0。
//机器ID 2进制5位 32位减掉1位 31个
private long workerId;
//机房ID 2进制5位 32位减掉1位 31个
private long datacenterId;
//代表一毫秒内生成的多个id的最新序号 12位 4096 -1 = 4095 个
private long sequence;
//设置一个时间初始值 2^41 - 1 差不多可以用69年
private long twepoch = 1585644268888L;
//5位的机器id
private long workerIdBits = 5L;
//5位的机房id
private long datacenterIdBits = 5L;
//每毫秒内产生的id数 2 的 12次方
private long sequenceBits = 12L;
// 这个是二进制运算,就是5 bit最多只能有31个数字,也就是说机器id最多只能是32以内
private long maxWorkerId = -1L ^ (-1L << workerIdBits);
// 这个是一个意思,就是5 bit最多只能有31个数字,机房id最多只能是32以内
private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
private long workerIdShift = sequenceBits;
private long datacenterIdShift = sequenceBits + workerIdBits;
private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
private long sequenceMask = -1L ^ (-1L << sequenceBits);
//记录产生时间毫秒数,判断是否是同1毫秒
private long lastTimestamp = -1L;
public long getWorkerId(){
return workerId;
}
public long getDatacenterId() {
return datacenterId;
}
public long getTimestamp() {
return System.currentTimeMillis();
}
public IdWorker(long workerId, long datacenterId, long sequence) {
// 检查机房id和机器id是否超过31 不能小于0
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;
this.sequence = sequence;
}
// 这个是核心方法,通过调用nextId()方法,让当前这台机器上的snowflake算法程序生成一个全局唯一的id
public synchronized long nextId() {
// 这儿就是获取当前时间戳,单位是毫秒
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
System.err.printf(
"clock is moving backwards. Rejecting requests until %d.", lastTimestamp);
throw new RuntimeException(
String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
lastTimestamp - timestamp));
}
// 下面是说假设在同一个毫秒内,又发送了一个请求生成一个id
// 这个时候就得把seqence序号给递增1,最多就是4096
if (lastTimestamp == timestamp) {
// 这个意思是说一个毫秒内最多只能有4096个数字,无论你传递多少进来,
//这个位运算保证始终就是在4096这个范围内,避免你自己传递个sequence超过了4096这个范围
sequence = (sequence + 1) & sequenceMask;
//当某一毫秒的时间,产生的id数 超过4095,系统会进入等待,直到下一毫秒,系统继续产生ID
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0;
}
// 这儿记录一下最近一次生成id的时间戳,单位是毫秒
lastTimestamp = timestamp;
// 这儿就是最核心的二进制位运算操作,生成一个64bit的id
// 先将当前时间戳左移,放到41 bit那儿;将机房id左移放到5 bit那儿;将机器id左移放到5 bit那儿;将序号放最后12 bit
// 最后拼接起来成一个64 bit的二进制数字,转换成10进制就是个long型
return ((timestamp - twepoch) << timestampLeftShift) |
(datacenterId << datacenterIdShift) |
(workerId << workerIdShift) | sequence;
}
/**
* 当某一毫秒的时间,产生的id数 超过4095,系统会进入等待,直到下一毫秒,系统继续产生ID
* @param lastTimestamp
* @return
*/
private long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
//获取当前时间戳
private long timeGen(){
return System.currentTimeMillis();
}
/**
* main 测试类
* @param args
*/
public static void main(String[] args) {
System.out.println(1&4596);
System.out.println(2&4596);
System.out.println(6&4596);
System.out.println(6&4596);
System.out.println(6&4596);
System.out.println(6&4596);
// IdWorker worker = new IdWorker(1,1,1);
// for (int i = 0; i < 22; i++) {
// System.out.println(worker.nextId());
// }
}
}