sharding-jdbc系列(三):分布式主键

在我们进行开发工作时,数据库表主键自动生成是一个基本的需求,而且大多数数据库也提供了基本的解决方案,比如mysql的自增主键、Oracle的自增序列。但是我们进行了分库分表后,同一个逻辑表内的不同实际表之间的自增键由于无法互相感知而产生重复主键。

目前有许多第三方解决方案可以完美解决这个问题,如UUID等依靠特定算法自生成不重复键,或者通过引入主键生成服务等。为了方便用户使用、满足不同用户不同使用场景的需求, ShardingSphere不仅提供了内置的分布式主键生成器,例如UUID、SNOWFLAKE,还抽离出分布式主键生成器的接口,方便用户自行实现自定义的自增主键生成器。

下面介绍几种分布式id的生成策略:

1、UUID

UUID(Universally Unique Identifier)的标准型式包含32个16进制数字,以连字号分为五段,形式为8-4-4-4-12的36个字符,示例:cc8fd628-ef02-426d-a954-89294591024c

java中java.util包中直接提供了生成UUID的方法:

UUID.randomUUID().toString()

优点:

  • 因为是本地生成的,所以没有网络消耗,性能非常高。

缺点:

  • 太长了!是一个36长度的长字符串,不利于存储。
  • 不太安全,UUID基于mac地址生成,会造成mac地址泄露。
  • 不适用于作为主键,以mysql为例,mysql官方建议主键长度越短越好,而UUID则是36位,不建议适用;并且UUID因为其无序性,如果作为主键插入时会引起数据位置的频繁变动,严重影响性能。

2、数据库生成

以mysql为例,在设置主键时可以通过设置自动递增来保证ID自增。

优点:

  • 实现简单,基于数据库功能实现,不需要编码,对开发成本小。
  • ID有顺序递增,在某些业务场景下非常适用,适用于作为mysql的主键,也是mysql官方推荐的主键生成策略。

缺点:

  • 对数据库依赖太高,当数据库异常时,整个系统不可用。
  • 性能不高,主要性能限制为单台mysql的读写性能。

对于以上的缺点性能问题,可以有以下的方案来进行解决:

多部署几台机器,每台机器设置不用的初始值,步长和机器数量相同。例如2台机器server1、server2,server1初始值为1,server2初始值为2,步长都为2,则server1的号段为1、3、5、7、9......,server2的号段为2、4、6、8、10......。同理,如果部署N台机器,则每台的初始值依次为1,2,3,4,5....N,步长为N,则整体架构图如下:

 

3.雪花算法(Snowflake)

Snowflake来源于Twitter,原理是生成一个64bit大小的长整型。sharding-jdbc如果分片键不设置值,则默认使用Snowflake生成一个值。先说说优点:

  • SnowFlake生成ID能够按照时间有序生成
  • SnowFlake算法生成id的结果是一个64bit大小的整数,换算为长整形为18位
  • 分布式系统内不会产生重复id

原理:

编号由四部分组成,从高位到低位(从左到右)分别是:

  • 符号位:长度1bit,等于0。
  • 时间戳:长度41bit,从 2016/11/01 零点开始的毫秒数(1480166465631L),支持 2 ^41 /365/24/60/60/1000=69.7年。
  • 工作进程编号:长度10bit,所以最多支持2 ^10也就是1024个进程。
  • 序列号(自增编号):长度12bit,每毫秒从 0 开始自增,支持2 ^12也就是 4096 个编号。

可见,每个工作进程每毫秒可以产生最多4096个ID,则每秒可以产生4096000个。

放一波代码:

public class IdGenerator implements KeyGenerator {

    /**
     * 时间偏移量,从2016年11月1日零点开始
     */
    public static final long EPOCH = 1540000000000L;

    /**
     * 自增量占用比特
     */
    private static final long SEQUENCE_BITS = 12L;
    /**
     * 工作进程ID比特
     */
    private static final long WORKER_ID_BITS = 10L;
    /**
     * 自增量掩码(最大值)
     */
    private static final long SEQUENCE_MASK = (1 << SEQUENCE_BITS) - 1;
    /**
     * 工作进程ID左移比特数(位数)
     */
    private static final long WORKER_ID_LEFT_SHIFT_BITS = SEQUENCE_BITS;
    /**
     * 时间戳左移比特数(位数)
     */
    private static final long TIMESTAMP_LEFT_SHIFT_BITS = WORKER_ID_LEFT_SHIFT_BITS + WORKER_ID_BITS;

    /**
     * 上一次的序列号,解决并发量小总是偶数的问题
     */
    private long lastSequence = 0L;

    private static TimeService timeService = new TimeService();

    /**
     * 工作进程ID
     */
    private static long workerId;

    /**
     * 最后自增量
     */
    private long sequence;

    /**
     * 最后生成编号时间戳,单位:毫秒
     */
    private long lastTime;

    static {
        /**
         * 浏览 IPKeyGenerator 工作进程编号生成的规则后,感觉对服务器IP后10位(特别是IPV6)数值比较约束。
         * 有以下优化思路:
         * 因为工作进程编号最大限制是 2^10,我们生成的工程进程编号只要满足小于 1024 即可。
         * 1.针对IPV4:
         * ....IP最大 255.255.255.255。而(255+255+255+255) < 1024。
         * ....因此采用IP段数值相加即可生成唯一的workerId,不受IP位限制
         *
         * @Author DogFc
         */
        InetAddress address;
        try {
            address = InetAddress.getLocalHost();
        } catch (final UnknownHostException e) {
            throw new IllegalStateException("Cannot get LocalHost InetAddress, please check your network!");
        }
        byte[] ipAddressByteArray = address.getAddress();
        long workerId = 0L;
        // IPV4
        if (ipAddressByteArray.length == 4) {
            for (byte byteNum : ipAddressByteArray) {
                workerId += byteNum & 0xFF;
            }
            // IPV6
        } else if (ipAddressByteArray.length == 16) {
            for (byte byteNum : ipAddressByteArray) {
                workerId += byteNum & 0B111111;
            }
        } else {
            throw new IllegalStateException("Bad LocalHost InetAddress, please check your network!");
        }
        IdGenerator.workerId = workerId;
    }

    @Override
    public Number generateKey() {
        // 保证当前时间大于最后时间。时间回退会导致产生重复id
        long currentMillis = timeService.getCurrentMillis();
        Preconditions.checkState(lastTime <= currentMillis,
                "Clock is moving backwards, last time is %d milliseconds, current time is %d milliseconds", lastTime,
                currentMillis);
        // 获取序列号
        if (lastTime == currentMillis) {
            if (0L == (sequence = ++sequence & SEQUENCE_MASK)) { // 当获得序号超过最大值时,归0,并去获得新的时间
                currentMillis = waitUntilNextTime(currentMillis);
            }
        } else {
            // 根据上一次sequence决定本次序列从0还是1开始,保证低并发时奇偶交替
            if (lastSequence == 0) {
                sequence = 1L;
            } else {
                sequence = 0L;
            }
        }
        lastSequence = sequence;
        // 设置最后时间戳
        lastTime = currentMillis;

        // 生成编号
        return ((currentMillis - EPOCH) << TIMESTAMP_LEFT_SHIFT_BITS) | (workerId << WORKER_ID_LEFT_SHIFT_BITS)
                | sequence;
    }

    /**
     * 不停获得时间,直到大于最后时间
     *
     * @param lastTime 最后时间
     * @return 时间
     */
    private long waitUntilNextTime(final long lastTime) {
        long time = timeService.getCurrentMillis();
        while (time <= lastTime) {
            time = timeService.getCurrentMillis();
        }
        return time;
    }
}

说明:

1、算法思路整体较为简单,主要的操作就是对workId、自增序号、还有时钟回拨的处理( 如果时钟回拨的时间超过最大容忍的毫秒数阈值,则程序报错;如果在可容忍的范围内,默认分布式主键生成器会等待时钟同步到最后一次主键生成的时间后再继续工作。 最大容忍的时钟回拨毫秒数的默认值为0,可通过属性设置)。

2、上面的算法在SnowFlake的基础上进行了两个优化。

  • 优化了wordId的设置策略,采用分别对ipv4、ipv6的ip取不同位进行处理,具体策略可见注释。
  • 优化了SnowFlake在低并发情况下生成的id为偶数的问题,这个问题的原因就是因为最后几位表示的是同一毫秒下的并发编号自增,当并发较低时,最后几位始终都是0,所以最后生成的ID均为偶数,上面的算法加入了一个自增量sequence字段,根据上一次sequence决定本次序列从0还是1开始,保证了低并发下奇偶交替。

 

  • 5
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值