分库分表背景下,千万级订单id的生成策略详解

一、背景介绍

随着业务数据量增加,数据库一般会存在单表的性能瓶颈,因此需要对一个数据表甚至一整个数据库做拆分,那么拆分后,如何保证多个子表之间订单id的唯一性呢?这时候是否还能够依赖于数据表自身的主键自增策略呢?

对于分表,我们采用sharding-jdbc来解决,配置一些策略,我们在业务代码层面无需改动,只需要简单的配置,sharding-jdbc会根据策略将数据映射到不同的数据表中,比如以下这段代码

@GetMapping("/incadd") 
public Incorder add(int userid){ 
    Incorder incorder = new Incorder(); 
    incorder.setUserid(userid); 
    mapper.insert(incorder); 
    return incorder; 
}

我们在sharding-jdbc是根据userId字段取余,偶数到一个表,奇数到一个表,结果如下:

我们发现,两个子表之间id产生了重复,这就对全局订单系统造成了严重性问题,因此我们要做的事情就是对于整个订单系统生成全局唯一的主键id

二、解决方案

在这里,我们会一一介绍各种方案,并分析各种方案的优缺点

1、起始点分段

设置表2的起始点,再来跑试试……

#用以下sql,或者客户端工具设置: 
ALTER TABLE incorder_1 AUTO_INCREMENT=10;

这样,表2就会从10开始自增,自增过程如下:

表1:1,2,3...           表2:10,11,12...

优点:

  • 简单容易,数据库层面设置,代码是不需要动的

缺点:

  • 边界的切分(比如这里是以10为边界)人为维护,操作复杂,触发器自动维护可以实现但在高并发下不推荐
  • 所以像上面的以10为边界,当表1增长到10了,我们就又要手动将表2的边界重新设计,比如设置成40,表1设置成30

2、分段步长自增

--查看 
show session variables like 'auto_inc%'; 
show global variables like 'auto_inc%'; 

--设定自增步长 
set session auto_increment_increment=2; 
--设置起始值 
set session auto_increment_offset=1; 
--全局的 
set global auto_increment_increment=2; 
set global auto_increment_offset=1

对于id自增的步长,我们可以按照以上语句进行修改

那这有什么问题呢?

  • 影响范围不可控,要么 session 每次设置,忘记会出乱子。要么全局设置,影响全库所有表。结论:不可取!!!

3、Sequence特性

仅限于oracle和sqlserver,主流mysql不支持

-- 创建一个sequence: 
create sequence sequence_name as int minvalue 1 maxvalue 1000000000 start with 1 increment by 1 no cache; 
-- sequence的使用: 
sequence_name.nextval sequence_name.currval 
-- 在表中使用sequence: 
insert into incorder_0 values(sequence_name.nextval,userid);

4、从业务层面去规避id重复

不用自增,自定义id,加上业务属性,从业务细分角度对并发性降维。

例如淘宝,在订单号中加入用户id。

加上用户id后,并发性维度降低到单个用户,每个用户的下单速度变的可控

具体而言:时间戳+userid,业务角度,一个正常用户不可能1毫秒内下两个单子,即便有说明是刻意刷单,应该被前端限流。

@GetMapping("/busiadd") 
public Strorder busiadd(int userid){ 
    Strorder order = new Strorder(); 
    order.setId(System.currentTimeMillis()+"-"+userid); 
    order.setUserid(userid); 
    strorderMapper.save(order); 
    return order; 
}

缺点:

  • 自定义ID的生成依赖于业务规则,如果业务规则发生变化,可能需要调整ID生成策略。
  • 如果所有请求都集中在少数几个用户上,可能会导致这些用户的ID生成成为性能瓶颈。

5、集中式分配

集中式分配主要思想是专门维护一个类似于“计数器”的功能,这个计数器专门用来生产id,生产一个,计数器加1,每次需要id就调用奇数器的nextId

5.1、在数据库中,通过一张max表集中分配

CREATE TABLE `maxid` ( 
    `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 
    `name` varchar(50) DEFAULT NULL, 
    `nextid` bigint(20) DEFAULT NULL, 
    PRIMARY KEY (`id`) 
);

insert into maxid(name,nextid) values ('orders',1000);

创建函数

DROP FUNCTION getid; 

-- 创建函数 
CREATE FUNCTION getid(table_name VARCHAR(50)) 
RETURNS BIGINT(20) 
BEGIN 
    -- 定义变量 
    DECLARE id BIGINT(20); 
    -- 给定义的变量赋值 
        update maxid set nextid=nextid+1 where name = table_name; 
        SELECT nextid INTO id FROM maxid WHERE name = table_name; 
    -- 返回函数处理结果 
    RETURN id; 
END

Mapper调整id策略,借助mybatis的SelectKey生成id,注意Before=true

@Insert({"insert into strorder (id,userid)", "values (#{id},#{userid,jdbcType=INTEGER})" })
@SelectKey(statement="SELECT getid('orders') from dual", keyProperty="id", before=true, resultType=String.class) 
int getIdSave(Strorder record);

优缺点:

  • 不需要借助任何中间件,数据库内部解决
  • 表性能问题感人,下单业务如果事务过长,会造成锁等待

5.2、通过redis的inc原子属性来实现

@GetMapping("/redisId") 
public Strorder redisId(int userid){ 
    Strorder order = new Strorder();             
    order.setId(template.opsForValue().increment("next_order_id").toString()); 
    order.setUserid(userid); 
    strorderMapper.save(order); 
    return order; 
}

优缺点:

  • 需要额外的中间件 redis
  • db 相比不够直观,不方便查看当前增长的 id 值,需要额外连接 redis 服务器读取
  • 性能不是问题, redis 得到业界验证和认可
  • redis 集群的可靠性要求很高,禁止出现故障,否则全部入库被阻断
  • 数据一致性需要注意,尽管 redis 有持久策略, down 机恢复时需要确认和当前库中最大 id 的一致性

6、UUID

@GetMapping("/uuid") 
public Strorder uuid(int userid){ 
    Strorder order = new Strorder(); 
    order.setId(UUID.randomUUID().toString()); 
    order.setUserid(userid); 
    strorderMapper.save(order); 
    return order; 
}

优点:

  • 本地生成,无需远程调用
  • 基本可以认为没有性能上限,适合在大规模分布式系统中使用
  • 唯一性极高

缺点:

  • 长度过长,总共128位
  • 由于UUID过长,作为主键建立索引查询效率低

7、雪花算法

UUID 能保证时空唯一,但是过长且是字符,雪花算法由Twitter发明,是一串数字。

Snowflake是一种约定,它把时间戳、工作组 ID、工作机器 ID、自增序列号组合在一起,生成一个 64bits 的整数 ID,能够使用 (2^41)/(1000*60*60*24*365) = 69.7 年,每台机器每毫秒理论最 多生成 2^12 个 ID

接下来我们将对他的结构分别介绍

1 bit:固定为0
二进制里第一个bit如果是 1,表示负数,但是我们生成的 id都是正数,所以第一个 bit 统一都是 0。
41 bit:时间戳,单位毫秒
41 bit 可以表示的数字多达 2^41 - 1,也就是可以标识 2 ^ 41 - 1 个毫秒值。
注意!这个时间不是绝对时间戳,而是相对值,所以需要定义一个系统开始上线的起始时间
10 bit:哪台机器产生的
代表的是这个服务最多可以部署在 2^10 台机器上,也就是 1024 台机器。
官方定义,前5 个 bit 代表机房 id,后5 个 bit 代表机器 id。 这10位是机器维度,可以根据公司的实际情况自由定制。
12 bit:自增序列
同1毫秒内,同一机器,可以产生2 ^ 12 - 1 = 4096个不同的 id。
优缺点:
  • 不依赖第三方介质例如 Redis 、数据库,本地程序生成分布式自增 ID
  • 只能保证在工作组中的机器生成的 ID 唯一,不同组下可能会重复
  • 时间回拨后(理解为:某种情况下,时间重置了),生成的 ID 就会重复,所以需要保持时间是网络同步的。
下面是实现雪花算法的java代码,可以引入到你的项目中直接调用
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class Snowflake {

    /** 序列的掩码,12个1,也就是(0B111111111111=0xFFF=4095) */
    private static final long SEQUENCE_MASK = 0xFFF;

    /** 系统起始时间,这里取2020-01-01 */
    private long startTimeStamp = 1577836800000L;

    /** 上次生成 ID 的时间截 */
    private long lastTimestamp = -1L;

    /** 工作机器 ID(0~31) */
    private long workerId;

    /** 数据中心 ID(0~31) */
    private long datacenterId;

    /** 毫秒内序列(0~4095) */
    private long sequence = 0L;

    /**
     * @param datacenterId 数据中心 ID (0~31)
     * @param workerId 工作机器 ID (0~31)
     */
    public Snowflake(@Value("${snowflake.datacenterId}") long datacenterId, @Value("${snowflake.workerId}") long workerId) {
        if (workerId > 31 || workerId < 0) {
            throw new IllegalArgumentException("workId必须在0-31之间,当前 =" + workerId);
        }
        if (datacenterId > 31 || datacenterId < 0) {
            throw new IllegalArgumentException("datacenterId必须在0-31之间,当前 =" + datacenterId);
        }
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }

    /**
     * 加锁,线程安全
     *
     * @return long 类型的 ID
     */
    public synchronized long nextId() {
        long timestamp = currentTime();
        // 如果当前时间小于上一次 ID 生成的时间戳,说明系统时钟回退过这个时候应当抛出异常
        if (timestamp < lastTimestamp) {
            throw new RuntimeException("时钟回退!时间差=" + (lastTimestamp - timestamp));
        }
        // 同一毫秒内,序列增加
        if (lastTimestamp == timestamp) {
            // 超出阈值。思考下为什么这么运算?
            sequence = (sequence + 1) & SEQUENCE_MASK;
            // 毫秒内序列溢出
            if (sequence == 0) {
                // 自旋等待下一毫秒
                while ((timestamp = currentTime()) <= lastTimestamp);
            }
        } else {
            // 已经进入下一毫秒,从0开始计数
            sequence = 0L;
        }
        // 赋值为新的时间戳
        lastTimestamp = timestamp;
        // 移位拼接
        long id = ((timestamp - startTimeStamp) << 22) | (datacenterId << 17) | (workerId << 12) | sequence;
        System.out.println("new id = " + id);
        System.out.println("bit id = " + toBit(id));
        return id;
    }

    /**
     * 返回当前时间,以毫秒为单位
     */
    protected long currentTime() {
        return System.currentTimeMillis();
    }

    /**
     * 转成二进制展示
     *
     * @param id long 类型的 ID
     * @return String类型的二进制字符串
     */
    public static String toBit(long id) {
        String bit = StringUtils.leftPad(Long.toBinaryString(id), 64, "0");
        return bit.substring(0, 1) + " - " + bit.substring(1, 42) + " - " + bit.substring(42, 52) + " - " + bit.substring(52, 64);
    }

    public static void main(String[] args) {
        Snowflake idWorker = new Snowflake(1, 1);
        for (int i = 0; i < 10; i++) {
            long id = idWorker.nextId();
            System.out.println(id);
            System.out.println(toBit(id));
        }
    }
}

猜你喜欢

转载自blog.csdn.net/qq_46248151/article/details/144382177
今日推荐