欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

全局唯一序号生成方案

程序员文章站 2022-06-09 19:37:26
全局唯一序列号设计方案系统唯一ID是我们在设计一个系统的时候常常会遇见的问题,也常常为这个问题而纠结。生成ID的方法有很多,适应不同的场景、需求以及性能要求。所以有些比较复杂的系统会有多个ID生成的策略。一、全局唯一ID具备下面几个特性1、全局唯一性:不能出现重复的ID2、趋势递增:按照一定规则有序递增3、单调递增:保证下一个ID一定大于上一个ID4、信息安全:特定场景下连续递增ID的安全性单调递增与信息安全两个特性是互斥的,无法同时满足分布式系统架构中,除了需要满足ID生成自身的需求外,还...

全局唯一序列号设计方案

系统唯一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】雪花算法原理

  1. 1bit,不用,因为二进制中最高位是符号位,1表示负数,0表示正数。生成的id一般都是用整数,所以最高位固定为0。
  2. 41bit-时间戳,用来记录时间戳,毫秒级。
    41位可以表示2199023255552个数字,
    如果只用来表示正整数(计算机中正数包含0),可以表示的数值范围是:0 至 2199023255552,减1是因为可表示的数值范围是从0开始算的,而不是1。
    也就是说41位可以表示2199023255552个毫秒的值,转化成单位年则是69年
  3. 10bit-工作机器id,用来记录工作机器id。
    可以部署在1024个节点,包括5位datacenterId和5位workerId
    5位(bit)可以表示的最大正整数是31,即可以用0、1、2、3、…31这32个数字,来表示不同的datecenterId或workerId
  4. 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

相关标签: 分布式