全局唯一序号生成方案

全局唯一序列号设计方案

系统唯一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 年。

​ 再有,程序获取操作系统时间会耗费较多时间,相比于随机数和常数来说,性能相差太远,这是制约它生成性能的最大因素。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值