全局唯一序号生成方案
全局唯一序列号设计方案
系统唯一ID是我们在设计一个系统的时候常常会遇见的问题,也常常为这个问题而纠结。生成ID的方法有很多,适应不同的场景、需求以及性能要求。所以有些比较复杂的系统会有多个ID生成的策略。
一、全局唯一ID具备下面几个特性
1、全局唯一性:不能出现重复的ID
2、趋势递增:按照一定规则有序递增
3、单调递增:保证下一个ID一定大于上一个ID
4、信息安全:特定场景下连续递增ID的安全性
单调递增与信息安全两个特性是互斥的,无法同时满足
分布式系统架构中,除了需要满足ID生成自身的需求外,还需要高可用性,高性能。
二、方案一:数据库自增长序列或字段
比如oracle的sequence,mysql的auto_increment
【1】、优点
实现简单
能够保证唯一性
能够保证递增性
【2】、缺点
扩展性差,性能有上限
可用性难以保证,有单点故障的风险
数据合并,数据迁移比较麻烦
三、方案二:UUID
常见的方式。可以利用数据库也可以利用程序生成,一般来说全球唯一。
【1】、优点
实现简单
性能高,本地生成,不会有网络开销
数据合并,数据迁移比较简单
【2】、缺点
没有排序,无法保证趋势递增
可读性差,不直观
UUID过长,往往用32或48位字符串表示,作为主键建立索引查询效率低
四、方案三:Redis生成ID
当使用数据库来生成ID性能达不到要求的时候,可以采用Redis来生成ID。利用Redis的原子操作 INCR和INCRBY来实现。使用Redis集群可以获取更高的吞吐量,还可以防止单点故障问题。假如一个集群中有3台Redis。可以初始化每台Redis的值分别是1,2,3,然后步长都是3(主Redis机器数)。每个Redis生成的ID为:
A:1,4,7,10,13
B:2,5,8,11,14
C:3,6,9,12,15
【1】、优点
不依赖于数据库,性能优于数据库
数字ID排序,对分页或排序很有帮助
【2】、缺点
1、如果系统中没有Redis,还需要引入新的组件,增加系统复杂度
2、需要编码和配置的工作量比较大
五、方案四:数据库segment
通过一个序列表记录当前序列号,机器每次从序列表中获取一定步长的序列数然后缓存再本地,等用完后再重新从步长表获取,本地可以用的序号数可以再做放大,如放大1000倍 X 1000;那么实际上本地可用的序列个数=步长大小 X 放大倍数。读写数据库的频率从1减小到了1/(step * 放大倍数)
【1】、优点:
大大降低了数据库的访问频率
【2】、缺点:
性能较差,例如几百个应用同时来获取这条序列记录的话行锁等待时间长
TP999数据波动大,当号段使用完之后还是会hang在更新数据库的I/O上,tg999数据会出现偶尔的尖刺。
DB宕机会造成整个系统不可用。
【3】、优化方案:双buffer
取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。如果请求DB的网络和DB的性能稳定,这种情况对系统的影响是不大的,但是假如取DB的时候网络发生抖动,或者DB发生慢查询就会导致整个系统的响应时间变慢。
为此,我们希望DB取号段的过程能够做到无阻塞,不需要在DB取号段的时候阻塞请求线程,即当号段消费到某个点时就异步的把下一个号段加载到内存中。而不需要等到号段用尽的时候才去更新号段。这样做就可以很大程度上的降低系统的TP999指标。
六、方案五:数据库自增序列改进方案
跟通过数据库步长的方式类似,生成一张序列表,表结构如下所示,当应用需要获取序号的时候向该表中插入一条数据,记录访问应用的ip信息,然后得到一个唯一的自增的id,这个【(id-1)X 应用设置的步长】 就是应用起始的序列号,【id X 应用设置的步长】就是应用最大的序列号;例如每台机器可得步长为1000,那么插入完记录后该应用得到的序列范围就是0-1000;当这1000个序号用完后就在访问步长表再插入一条记录,得到新的id重新计算新的序列号,同时删除该机器ip id比当前id小的记录。
CREATE TABLE `coupon_no_sequence` (
`ID` bigint(20) 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(2) NOT NULL COMMENT 'node type: ACTUAL or CONTAINER',
PRIMARY KEY (`ID`)
) ENGINE=InnoDB AUTO_INCREMENT=1659 DEFAULT CHARSET=utf8 COMMENT='券号序列表';
实例:券号生成策略
实现代码
【1】、spring配置
<bean id="couponService" class="com.suning.ebuy.cous.components.CouponSequenceService" lazy-init="false">
<!-- ID号分段的最大值,如果不配置,代码中默认是999999999999-->
<property name="segmentMaxIdValue" value="999999999999"/>
<!-- 每个ID号分段的ID数(必须是10的整数次幂,如果不配置,代码中默认是1000) -->
<property name="perSegmentQty" value="1000"/>
<!--号段表名-->
<property name="sequenceTableName" value="coupon_no_sequence"/>
<property name="idSegmentDao" ref="IdSegmentDao"/>
</bean>
【2】、获取券号的生成方法
券号获取服务,前面补0补齐13位并在前面拼接上券类型的标记
public class CouponSequenceService {
/**
* 当前序号
*/
private long currentId = 0L;
/**
* 当前分段最大的序号
*/
private long currentMaxId = 0L;
/**
* 最大段号
*/
private long segmentMaxIdValue = 999999999999L;
/**
* 每段段长
*/
private int perSegmentQty = 1000;
/**
* 序列表表名
*/
private String sequenceTableName = "id_segement";
private IdSegmentDao idSegmentDao;
/**
*
* 功能描述: 获取券号
*
* @param
* @return
* @see [相关类/方法](可选)
* @since [产品/模块版本](可选)
*/
public synchronized String getNextLongSequence() {
// 如果本地没有序号剩余,获取新的分段好,并重置起始序号
if (this.currentId == this.currentMaxId) {
this.getNextSegmentAndReset();
}
// 本地序号有剩余,直接+1
++this.currentId;
return Long.toString(this.currentId);
}
/**
*
* 功能描述: 获取新的分段号
*
* @param
* @return
* @see [相关类/方法](可选)
* @since [产品/模块版本](可选)
*/
private void getNextSegmentAndReset() {
try {
long segment = assignIdSegment(sequenceTableName);
if (segment > this.segmentMaxIdValue) {
throw new UidGenerateException("ID超过了最大范围!");
}
// mysql id 是从0开始,因此当前起始序号 = (id -1) * 段长
this.currentId = (segment - 1L) * (long) this.perSegmentQty;
// 最大的序列号 = id * 端长
this.currentMaxId = segment * (long) this.perSegmentQty;
} catch (Exception e) {
throw new UidGenerateException("获取ID号分段异常", e);
}
}
/**
*
* 功能描述: 从数据库获取段号,段号即id
*
* @param tableName
* @return
* @see [相关类/方法](可选)
* @since [产品/模块版本](可选)
*/
@Transactional
public long assignIdSegment(String tableName) {
IdSegementEntity idSegementEntity = new IdSegementEntity();
idSegementEntity.setHostName(NetUtils.getLocalAddress());
idSegementEntity.setTableName(tableName);
// 插入一条记录
idSegmentDao.addIdSegment(idSegementEntity);
// 拿到id = 段号
Long idSegment = this.idSegmentDao.queryIdSegmentByIP(idSegementEntity);
idSegementEntity.setId(idSegment);
// 删除该ip id < idSegment 的记录
idSegmentDao.deleteIdSegment(idSegementEntity);
return idSegment;
}
public long getSegmentMaxIdValue() {
return segmentMaxIdValue;
}
public void setSegmentMaxIdValue(long segmentMaxIdValue) {
this.segmentMaxIdValue = segmentMaxIdValue;
}
public int getPerSegmentQty() {
return perSegmentQty;
}
public void setPerSegmentQty(int perSegmentQty) {
this.perSegmentQty = perSegmentQty;
}
public String getSequenceTableName() {
return sequenceTableName;
}
public void setSequenceTableName(String sequenceTableName) {
this.sequenceTableName = sequenceTableName;
}
public IdSegmentDao getIdSegmentDao() {
return idSegmentDao;
}
public void setIdSegmentDao(IdSegmentDao idSegmentDao) {
this.idSegmentDao = idSegmentDao;
}
}
【3】、SQL
<!--删除过期的ID号段-->
<sql id="delete_id_segment" jdbcTimout="3">
<![CDATA[
DELETE FROM ${TABLENAME} WHERE HOST_NAME = :HOSTNAME AND ID < :ID
]]>
</sql>
<!--查询正在使用的ID号段-->
<sql id="query_id_segment_by_ip" jdbcTimout="3">
<![CDATA[
SELECT t.ID FROM ${TABLENAME} t WHERE t.HOST_NAME = :HOSTNAME ORDER BY t.ID DESC LIMIT 1
]]>
</sql>
<!--新增ID号段-->
<sql id="insert_id_segment" jdbcTimout="3">
<![CDATA[
INSERT INTO ${TABLENAME} (HOST_NAME,PORT,TYPE) VALUES (:HOSTNAME,:PORT,:TYPE)
]]>
</sql>
七、方案六:雪花算法
【1】雪花算法原理
- 1bit,不用,因为二进制中最高位是符号位,1表示负数,0表示正数。生成的id一般都是用整数,所以最高位固定为0。
- 41bit-时间戳,用来记录时间戳,毫秒级。
41位可以表示2199023255552个数字,
如果只用来表示正整数(计算机中正数包含0),可以表示的数值范围是:0 至 2199023255552,减1是因为可表示的数值范围是从0开始算的,而不是1。
也就是说41位可以表示2199023255552个毫秒的值,转化成单位年则是69年 - 10bit-工作机器id,用来记录工作机器id。
可以部署在1024个节点,包括5位datacenterId和5位workerId
5位(bit)可以表示的最大正整数是31,即可以用0、1、2、3、…31这32个数字,来表示不同的datecenterId或workerId - 12bit-序列号,序列号,用来记录同毫秒内产生的不同id。
12位(bit)可以表示的最大正整数是4095,即可以用0、1、2、3、…4094这4095个数字,来表示同一机器同一时间截(毫秒)内产生的4095个ID序号。
由于在Java中64bit的整数是long类型,所以在Java中SnowFlake算法生成的id就是long来存储的。
【2】Snowflake 存在的问题
snowflake 不依赖数据库,也不依赖内存存储,随时可生成 ID,这也是它如此受欢迎的原因。但因为它在设计时通过时间戳来避免对内存和数据库的依赖,所以它依赖于服务器的时间。上面我们提到了 Snowflake 的 4 段结构,实际上影响 ID 大小的是较高位的值,由于最高位固定为 0,遂影响 ID 大小的是中位的值,也就是时间戳。
试想,服务器的时间发生了错乱或者回拨,这就直接影响到生成的 ID,有很大概率生成重复的 ID且一定会打破递增属性。这是一个致命缺点,你想想,支付订单和购买订单的编号重复,这是多么严重的问题!
另外,由于它的中下位和末位 bit 数限制,它每毫秒生成 ID 的上限严重受到限制。由于中位是 41 bit 的毫秒级时间戳,所以从当前起始到 41 bit 耗尽,也只能坚持 70 年。
再有,程序获取操作系统时间会耗费较多时间,相比于随机数和常数来说,性能相差太远,这是制约它生成性能的最大因素。
本文地址:https://blog.csdn.net/khuangliang/article/details/109632631
下一篇: JAVA中的SPI思想介绍