分布式全局唯一id实现-1DB步长&Mybatis-plus雪花算法优化

前言: 开发过程中通常需要一个全局唯一id,对数据进行标识,以便于对数据的统计,因为考虑到主键索引的性能问题,使用数字型效率更高,id 的生成要么借助数据库,要么借助程序内部完成,本文通过数据库和程序两个维度进行探讨。

全局唯一id 的生成策略

1 借助数据库生成:

1.1 使用mysql 自增id,设置自增初始值和步长来实现不同的数据库id 生成的不同:
需要 修改每个mysql 服务端my.cnf 配置文件中的步长和初始值,因为mysql 中步长:auto_increment_increment ,自增初始值:auto_increment_offset 的默认值都是1, 即使通过mysql 语句全局修改步长和初始值:

 -- 全局级别
SET GLOBAL auto_increment_increment=50;
SET GLOBAL auto_increment_offset = 10;
-- 会话级别
SET SESSION auto_increment_increment=50;
SET SESSION auto_increment_offset = 10;
	

但是在重启mysql 之后,这些配置一会回到默认值1,因为通过语句设置的值仅仅保存在了内存中;要想永久生效,必须修改mysql 服务端的配置文件,增加步长和初始值的设置:

# 设置步长为100
auto-increment-increment=100
# 设置自增初始值 6
auto-increment-offset=6

这样设置之后,所有的表都将遵循初始值为6,步长为100 完成id 的自增;从而也避免了在table 表级别设置AUTO_INCREMENT=10 ,其实并没有从10开始完成递增的问题;

1.2 创建表
以user 表为例:

CREATE TABLE `applet_user1` (
  `id` BIGINT (20) UNSIGNED NOT NULL AUTO_INCREMENT  COMMENT '主键',
  `user_name` varchar(64) DEFAULT NULL COMMENT '用户名称',
  `secret` varchar(64) DEFAULT NULL COMMENT '用户密码',
  `status` int(11) DEFAULT '1' COMMENT '状态 默认1',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

设置id BIGINT ,无符号, 最大支持64位,无符号id区间为0~2^64

注意:mysql 中提供的步长和自增初始值,无法对单个表或者单个数据库设置,目前只提供了服务级别的全局配置,也即如果要通过自增初始值和步长 这种方式,多个数据库需要部署多个mysql 实例,对每个mysql 实例的步长和初始值完成设置。

2 借助程序生成:

基于程序生成id的策略比较灵活,除了大名鼎鼎的UUID之外,比较常用的有雪花算法,和数据库分段id 。以下先介绍mybatisplus 雪花算法生成的id,并在其基础上对于workId 的生成方式进行改进。

2.1 mybatisplus id 雪花生成算法介绍:
mybatisplus 雪花算法生成的id 由 41位的毫秒时间戳+10机器位(5位机器id+5位数据中心id)+12 的序列号位,进行id 的计算,当统一毫秒内生成的id 超过了12位 序列号的最大id 则程序阻塞到下一毫秒后继续进行id 的生成;
雪花id 生成算法:
mybatisplus 中默认通过DefaultIdentifierGenerator 类中的nextId 方法进行id 生成:

package com.baomidou.mybatisplus.core.incrementer;

import com.baomidou.mybatisplus.core.toolkit.Sequence;

public class DefaultIdentifierGenerator implements IdentifierGenerator {
    private final Sequence sequence;

    public DefaultIdentifierGenerator() {
    	// 构造id 生成器(默认使用)
        this.sequence = new Sequence();
    }

    public DefaultIdentifierGenerator(long workerId, long dataCenterId) {
    	// 构造id 生成器(使用自定义的 机器位id 和数据中心id)
        this.sequence = new Sequence(workerId, dataCenterId);
    }

    public DefaultIdentifierGenerator(Sequence sequence) {
        this.sequence = sequence;
    }

    public Long nextId(Object entity) {
    // id 生成
        return this.sequence.nextId();
    }
}

具体的id 交由sequence 进行生成:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package com.baomidou.mybatisplus.core.toolkit;

import java.lang.management.ManagementFactory;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.concurrent.ThreadLocalRandom;
import org.apache.ibatis.logging.Log;
import org.apache.ibatis.logging.LogFactory;

public class Sequence {
    private static final Log logger = LogFactory.getLog(Sequence.class);
    private final long twepoch = 1288834974657L;
    private final long workerIdBits = 5L;
    private final long datacenterIdBits = 5L;
    private final long maxWorkerId = 31L;
    private final long maxDatacenterId = 31L;
    private final long sequenceBits = 12L;
    private final long workerIdShift = 12L;
    private final long datacenterIdShift = 17L;
    private final long timestampLeftShift = 22L;
    private final long sequenceMask = 4095L;
    private final long workerId;
    private final long datacenterId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;

    public Sequence() {
        this.datacenterId = getDatacenterId(31L);
        this.workerId = getMaxWorkerId(this.datacenterId, 31L);
    }

    public Sequence(long workerId, long datacenterId) {
        Assert.isFalse(workerId > 31L || workerId < 0L, String.format("worker Id can't be greater than %d or less than 0", 31L), new Object[0]);
        Assert.isFalse(datacenterId > 31L || datacenterId < 0L, String.format("datacenter Id can't be greater than %d or less than 0", 31L), new Object[0]);
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }

    protected static long getMaxWorkerId(long datacenterId, long maxWorkerId) {
        StringBuilder mpid = new StringBuilder();
        mpid.append(datacenterId);
        String name = ManagementFactory.getRuntimeMXBean().getName();
        if (StringUtils.isNotBlank(name)) {
            mpid.append(name.split("@")[0]);
        }

        return (long)(mpid.toString().hashCode() & '\uffff') % (maxWorkerId + 1L);
    }

    protected static long getDatacenterId(long maxDatacenterId) {
        long id = 0L;

        try {
            InetAddress ip = InetAddress.getLocalHost();
            NetworkInterface network = NetworkInterface.getByInetAddress(ip);
            if (network == null) {
                id = 1L;
            } else {
                byte[] mac = network.getHardwareAddress();
                if (null != mac) {
                    id = (255L & (long)mac[mac.length - 2] | 65280L & (long)mac[mac.length - 1] << 8) >> 6;
                    id %= maxDatacenterId + 1L;
                }
            }
        } catch (Exception var7) {
            logger.warn(" getDatacenterId: " + var7.getMessage());
        }

        return id;
    }

    public synchronized long nextId() {
    	// 获取当前系统时间戳
        long timestamp = this.timeGen();
        if (timestamp < this.lastTimestamp) {
        	// 当前系统时间戳 比最近一次生成id 的系统时间戳要小,说明发生时钟回退(系统的时间被修改到了以前的时间)
            long offset = this.lastTimestamp - timestamp;
            if (offset > 5L) {
            	// 如果回退时间大于5毫秒则直接报错
                throw new RuntimeException(String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds", offset));
            }
			// 回退的时间比较小,则进行阻塞,offset *2 的毫秒数后 ,重新获取当前时间
            try {
                this.wait(offset << 1);
                timestamp = this.timeGen();
                if (timestamp < this.lastTimestamp) {
                    throw new RuntimeException(String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds", offset));
                }
            } catch (Exception var6) {
                throw new RuntimeException(var6);
            }
        }
		// 如果是同一毫秒内进行id 的获取
        if (this.lastTimestamp == timestamp) {
        	// 则通过12位序列化进行id 的获取
            this.sequence = this.sequence + 1L & 4095L;
            if (this.sequence == 0L) {
            	// 如果发现12的序列号已经都时间完毕,则阻塞到下一时间毫秒后在进行id 的获取
                timestamp = this.tilNextMillis(this.lastTimestamp);
            }
        } else {
        	// 如果不是在同一毫秒内则初始一个sequence  (返回 最小值和界限之间的均匀分布值)
            this.sequence = ThreadLocalRandom.current().nextLong(1L, 3L);
        }
		// 更新时间戳
        this.lastTimestamp = timestamp;
        // 64 位 安位组合完成id 的生成
        return timestamp - 1288834974657L << 22 | this.datacenterId << 17 | this.workerId << 12 | this.sequence;
    }

    protected long tilNextMillis(long lastTimestamp) {
        long timestamp;
        for(timestamp = this.timeGen(); timestamp <= lastTimestamp; timestamp = this.timeGen()) {
        }

        return timestamp;
    }

    protected long timeGen() {
        return SystemClock.now();
    }
}

2.2 mybatisPlus 雪花算法workId 位的问题:
从代码中可以看出,同一个服务,在同一时间生成的id 是不会重复的;但是现在系统都是使用了docker 容器化 的集群实例部署,如果同一个服务如订单服务,通过集群的方式部署了5个实例,这5个实例所在的机器配置都相同,此时就有可能,id 生成使用的10位机器位id 是相同,如果此时多台服务同时进行insert 就会造成因为id 重复,无法插入;
既然是由于10位的机器位id 相同造成了id 的重复,那么就需要优化来使得 多个实例之间获取到的 workId 不相同,从而避免id 的重复问题;

优化方向:通过对每个服务设置机器数要生成的区间段,然后在区间段内轮询生成 机器id 和数据中心id 的方式 来覆盖,mybatisPlus 默认的机器位id 和数据中心id ,从而使得每台服务机器位id 的不同,以此满足多个服务同时对一张表进行插入操作。



import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import java.util.Collections;

// spring 读取配置并生成对应的bean 到容器中
@Component
public class IdWorkerConfig {
	// 每个spring 服务的名称可以在bootstrap.yml 配置
	//spring:
	//  application:
    //  # 应用名称
    //    name: xxxx
    @Value("${spring.application.name}")
    private String applicationName;
    // 每个服务配置自己的机器数所用的区间段,区间段的大小表示最大可以支持的服务实例个数 
    // 如:snowid:
  	//      start: 200
    //      end: 249
    @Value("${snowid.start}")
    private Integer start;
    @Value("${snowid.end}")
    private Integer end;
	// redis bean
    @Autowired
    public RedisTemplate redisTemplate;


    /**
     * 自定义workerId,保证该应用的ID不会重复
     *
     * @return 新的id生成器
     */
    @Bean
    public DefaultIdentifierGenerator defaultIdentifierGenerator() {
        String MAX_ID = applicationName + "-worker-id";
        // 获取机器数id
        Long maxId = this.getWorkerId(MAX_ID);
        String maxIdStr = Long.toBinaryString(maxId);
        // 将数据补全为10位
        maxIdStr = StringUtils.leftPad(maxIdStr, 10, "0");

        // 从中间进行拆分
        String datacenterStr = maxIdStr.substring(0, 5);
        String workerStr = maxIdStr.substring(5, 10);

        // 将拆分后的数据转换成dataCenterId和workerId
        long dataCenterId = Integer.parseInt(datacenterStr, 2);
        long workerId = Integer.parseInt(workerStr, 2);
        // 覆盖原有mybatis-plus 的机器数和数据中心id
        return new DefaultIdentifierGenerator(workerId, dataCenterId);
    }

    /**
     * LUA脚本获取workerId,保证每个节点获取的workerId都不相同
     *
     * @param key 当前微服务的名称
     * @return workerId
     */
    private Long getWorkerId(String key) {
        Integer workId = getWorkMod(key);

        String luaStr = "local isExist = redis.call('exists', KEYS[1])\n" +
                "if isExist == 1 then\n" +
                "    local workerId = redis.call('get', KEYS[1])\n" +
//                "    workerId = (workerId + 1) % 1024\n" +
                "    workerId = " + workId + "\n" +
                "    redis.call('set', KEYS[1], workerId)\n" +
                "    return workerId\n" +
                "else\n" +
//                "    redis.call('set', KEYS[1], 0)\n" +
//                "    return 0\n" +
                "    redis.call('set', KEYS[1], " + workId + ")\n" +
                "    return " + workId + "\n" +
                "end";
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        // 以下两种二选一即可
        redisScript.setScriptText(luaStr);
        //redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("redis/redis_worker_id.lua")));
        redisScript.setResultType(Long.class);
        return (Long) redisTemplate.execute(redisScript, Collections.singletonList(key));
    }
	// 本次所需的机器数序号
    private Integer getWorkMod(String key) {
        // 步长
        Integer length = end - start + 1;
        // 起始值
        Integer beginIndex = start;
        // 获取本次要用的workId
        Object obj = redisTemplate.opsForValue().get(key);
        if (null == obj) {
            // 第一次直接设置初始值
            return beginIndex;
        }
        // 获取 workId + 为下一次的机器位
        Integer workId = (Integer) obj;
        workId++;
        // 取模
        Integer mod = workId % length;
        // 要增加的区间值
        Integer add = start - (mod / length) * length;
        mod += add;
        // 返回本次启动,服务所需的机器id
        return mod;

    }


}

然后在对应的实体中 将id 修改为通过雪花算法生成:

@TableId(value = "id", type = IdType.ASSIGN_ID )
	private String id;

优化后的方案可以满足全局唯一id的生成,同样支持不同服务同时对相同表进行数据插入;但是请注意,由于机器数的id 是通过轮询段区间的方式进行了生成,加入段区间为0-49 ,相同服务的实例个数为5,则极端情况下,其中一个服务始终没有进行过重启,另外4个服务最多支持((49-5)/4)11次的启动,如果多于11次 则会造成生成的机器数id 和 始终没有经过重启服务的机器数id 重复的问题,从而造成全局id 可能重复问题;对于此种情况我们需要在每次服务发版时,都要对所有的服务实例完成发版,让其在重启时可以获取新的机器数序号;

3 参考:
3.1 雪花算法ID重复的分析与在项目中的解决;
3.2 自定义ID生成器

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值