难道主键除了自增就是GUID?支持k8s等分布式场景下的id生成器了解下

背景

主键(Primary Key),用于惟一标识表中的每一条数据。因此,一个合格的主键的最基本要求应该是惟一性。java

那怎么保证惟一呢?相信绝大部分开发者在刚入行的时候选择的都是数据库的自增id,由于这是一种很是简单的方式,数据库里配置下就好了。但自增主键优缺点都很明显。git

优势以下:程序员

  1. 无需编码,数据库自动生成,速度快,按序存放。
  2. 数字格式,占用空间小。

缺点以下:github

  1. 有数量限制。存在用完的风险。
  2. 导入旧数据时,可能会存在id重复,或id被重置的问题。
  3. 分库分表场景处理过于麻烦。

GUIDredis

GUID,全局惟一标识符,是一种有算法生成的二进制长度为128位的数字标识符,在理想状况下,任何计算机和计算机集群都不会生成两个相同的GUID,因此能够保证惟一性。但也是有优缺点的。分别以下:算法

优势以下:docker

  1. 分布式场景惟一。
  2. 跨合并服务器数据合并方便。

缺点以下:数据库

  1. 存储空间占用较大。
  2. 无序,涉及到排序的场景下性能较差。

GUID最大的缺点是无序,由于数据库主键默认是汇集索引,无序的数据将致使涉及到排序场景时性能下降。虽然能够根据算法生成有序的GUID,但对应的存储空间占用仍是比较大的。服务器

概念介绍

因此,本文的重点来了。若是能优化自增和GUID的缺点,是否是就算是一个更好的选择呢。一个好的主键须要具有以下特性:架构

  1. 惟一性。
  2. 递增有序性。
  3. 存储空间占用尽可能小。
  4. 分布式支持。

通过优化后的雪花算法能够完美支持以上特性。

下图是雪花算法的构成图:

20200904171521

雪花id组成由1位符号位+41位时间戳+10位工做机器id+12位自增序号组成,总共64比特组成的long类型。

1位符号位 :由于long的最高位是符号位,正数为0,负数为1,我们要求生成的id都是正数,因此符号位值设置0。

41位时间戳 :41位能表示的最大的时间戳为2199023255552(1L<<41),则可以使用的时间为2199023255552/(1000606024365)≈69年。到这里可能会有人百思不得姐,时间戳2199023255552对应的时间应该是2039-09-07 23:47:35,距离如今只有不到20年的时间,为何笔者算出来的是69年呢?

其实时间戳的算法是1970年1月1日到指点时间所通过的毫秒或秒数,那我们把开始时间从2020年开始,就能够延长41位时间戳能表达的最大时间。

10位工做机器id :这个表示的是分布式场景中,集群中的每一个机器对应的id,因此我们须要给每一个机器编号。10位的二进制最大支持1024个机器节点。

12位序列号 :自增值,毫秒级最大支持4096个id,也就是每秒最大可生成4096000个id。说个题外话,若是用雪花id当成订单号,淘宝的双十一的每秒的订单量有这个多吗?

到这里,雪花id算法的结构已经介绍完了,那怎么根据这个算法封装成可使用的组件呢?

开发方案

做为一个程序员,根据算法逻辑写代码这属于基础操做,但写以前,还须要把算法里可能存在的坑想清楚,我们再来一块儿来过一遍雪花id的结构。

首先,41位的时间戳部分没有特别须要注意的,起始时间你用1970也是能够的,反正也够用十几二十年(二十年以后的事,关我屁事)。或者,你以为你的系统能够会运行半个世纪以上,那就把当前离你最近的时间做为起始时间吧。

其次,10位的工做机器id,你能够每一个机器编个号,0-1023随便选,但人工搞这件事好像有点傻,若是就两三台机器,人工配下也无所谓。但是,docker或者k8s环境下,怎么配呢?因此,我们须要一个自动分配机器id的功能,在程序启动的时候,分配一个未使用的0-1023的值给当前节点。同时,可能会存在某个节点重启的状况,或者频繁发版的状况,这样每次都生成一个新的未使用的id会很快用完这1024个编号。因此,我们还须要实现机器id自动回收的功能。

总结一下,自动分配机器id的算法需限制生成的最大数量,既然有最大数量限制,因为节点重启致使的从新分配,可能会很快用完全部的编号,那么,我们算法就必须支持编号回收的功能。实现这个功能的方式有不少种,但都须要借助数据库或者中间件,java平台的可能用zookeeper比较多,也有用数据库来实现的(百度和美团的的分布式id算法就是基于雪花算法,借助数据库实现的),因为笔者是基于.net平台平台开发,这里就借助redis来实现这个方案。

首先,程序启动时,调用redis的incr命令,获取一个自增的key的值,判断key值是否小于或等于雪花id容许的最大机器id编号,若是知足条件,说明当前编号暂未使用,则此key的值即为当前节点的workid,同时,
借助redis的有序集合命令,将key值添加进有序集合中,并将当前时间的对应的时间戳做为score。而后借助后台服务,每隔指定的时间刷新key的score。

之因此须要定时刷新score,是由于咱们能够根据score来判断指定的key对应的机器节点是否还存在。好比,程序设置的5分钟刷新下score,则key的score对应的时间戳若是是5分钟以前的,则表示这个key对应的节点掉线了。则这个key就能够被再次分配给其余的节点了。

因此,当调用redis的incr命令返回的值大于1024,则表示0-1023之间的全部编号都已经被用完了,则咱们能够调用redisu获取指定score区间的命令来获取score大于五分钟的id,获得的id则是能够被再次使用的。这样就完美解决了机器id回收复用的问题。

最后,也是一个不容忽视的坑,时钟回拨。在正式解释这个概念的时候,我们先来看一个故事,准确的说,应该算事故。

1991 年 2 月第一次海湾战争期间,部署在沙特宰赫兰的美国爱国者导弹系统未能成功追踪和拦截来袭的伊拉克飞毛腿导弹。结果飞毛腿导弹击中美国军营。

20200904181734

损失:28 名士兵死亡,100 多人受伤

故障缘由:时间计算不精确以及计算机算术错误致使了系统故障。从技术角度来说,这是一个小的截断偏差。当时,负责防卫该基地的爱国者反导弹系统已经连续工做了100个小时,每工做一个小时,系统内的时钟会有一个微小的毫秒级延迟,这就是这个失效悲剧的根源。爱国者反导弹系统的时钟寄存器设计为24位,于是时间的精度也只限于24位的精度。在长时间的工做后,这个微小的精度偏差被渐渐放大。在工做了100小时后,系统时间的延迟是三分之一秒。

0.33 秒对常人来讲微不足道。可是对一个须要跟踪并摧毁一枚空中飞弹的雷达系统来讲,这是灾难性的。飞毛腿导弹空速达4.2马赫(每秒1.5千米),这个”微不足道的”0.33秒至关于大约 600 米的偏差。在宰赫兰导弹事件中,雷达在空中发现了导弹,但因为时钟偏差没能精确跟踪,反导导弹于是没有发射拦截。

由于毫秒级的时间延迟,致使这么大的损失。试想一下,若是我们写的代码,致使了公司财物上的损失,会不会被抓去祭天呢?因此,时钟回拨的问题,我们须要重视。那说了这么一大段废话,什么是时钟回拨呢?

简单讲,计算机内部的计时器在长时间运行时,不能保证100%的精确,存在过快或者过慢的问题,因此,就须要一个时间同步的机制,在时间同步的过程当中,可能将当前计算机的时间,往回调整,这就是时钟回拨(我的理解,若有错误,可移步评论区),参考文献:https://zhuanlan.zhihu.com/p/150340199。

那么机器回拨的问题改怎么解决呢?君且耐心往下看。

本人在编写实现雪花算法的代码前,翻阅了挺多实现雪花算法的开源代码,有一大部分给出的解决方案是等。好比说,获取到的时间戳小于上一时间对应的时间戳,则写个死循环进行判断,直到当前获取的时间戳大于上一个时间对应的时间戳。一般来说,这样的做法没问题,由于理论上因为机器缘由致使的时间回拨不会差的太多,基本上都是毫秒级的,对于程序来说,并不会有太大影响。可是,这依然不是一个健壮的解决方案。

为何这样说呢?不知道你们有没有听过冬令时和夏令时。相信绝大部分人不太了解这个,由于我们天朝用的都是北京时间。但若是你在国外生活或者工做过,可能就会了解冬令时或夏令时,具体的概念我就不会说了,有兴趣的请自行百度。这里我只阐述一个现象,就是使用夏令时的国家会存在时钟回拨一个小时的状况。若是你在生成id的时候,写的是死循环来解决回拨的话,那么,我真的没法想象你会不会被祭天,反正我会。

我的以为要从根本上解决这个问题,最好的办法仍是切换一个新的workid。但若是直接按照我上面所描述的直接获取5分钟之前回收的workid则仍是会出现问题,可能会存在在时钟回拨以前,这个workid刚刚离线,那么此时若是将这个workid从新分配给一个时钟回拨1小时的节点,则很是有可能出现重复的id。因此,我们在从有序列表中获取已经被回收的workid时,可顺序获取,即获取离线时间最久的workid。

编码思路也说完了,那怎么一块儿来看看具体的代码实现。

SnowflakeIdMaker类是实现此方案的主要代码,具体以下所示:

public class SnowflakeIdMaker : ISnowflakeIdMaker
{
    private readonly SnowflakeOption _option;
    static object locker = new object();
    //最后的时间戳
    private long lastTimestamp = -1L;
    //最后的序号
    private uint lastIndex = 0;
    /// <summary>
    /// 工做机器长度,最大支持1024个节点,可根据实际状况调整,好比调整为9,则最大支持512个节点,可把多出来的一位分配至序号,提升单位毫秒内支持的最大序号
    /// </summary>
    private readonly int _workIdLength;
    /// <summary>
    /// 支持的最大工做节点
    /// </summary>
    private readonly int _maxWorkId;

    /// <summary>
    /// 序号长度,最大支持4096个序号
    /// </summary>
    private readonly int _indexLength;
    /// <summary>
    /// 支持的最大序号
    /// </summary>
    private readonly int _maxIndex;

    /// <summary>
    /// 当前工做节点
    /// </summary>
    private int? _workId;

    private readonly IServiceProvider _provider;


    public SnowflakeIdMaker(IOptions<SnowflakeOption> options, IServiceProvider provider)
    {
        _provider = provider;
        _option = options.Value;
        _workIdLength = _option.WorkIdLength;
        _maxWorkId = 1 << _workIdLength;
        //工做机器id和序列号的总长度是22位,为了使组件更灵活,根据机器id的长度计算序列号的长度。
        _indexLength = 22 - _workIdLength;
        _maxIndex = 1 << _indexLength;

    }

    private async Task Init()
    {
        var distributed = _provider.GetService<IDistributedSupport>();
        if (distributed != null)
        {
            _workId = await distributed.GetNextWorkId();
        }
        else
        {
            _workId = _option.WorkId;
        }
    }

    public long NextId(int? workId = null)
    {
        if (workId != null)
        {
            _workId = workId.Value;
        }
        if (_workId > _maxWorkId)
        {
            throw new ArgumentException($"机器码取值范围为0-{_maxWorkId}");
        }

        lock (locker)
        {
            if (_workId == null)
            {
                Init().Wait();
            }
            var currentTimeStamp = TimeStamp();
            if (lastIndex >= _maxIndex)
            {
                //若是当前序列号大于容许的最大序号,则表示,当前单位毫秒内,序号已用完,则获取时间戳。
                currentTimeStamp = TimeStamp(lastTimestamp);
            }
            if (currentTimeStamp > lastTimestamp)
            {
                lastIndex = 0;
                lastTimestamp = currentTimeStamp;
            }
            else if (currentTimeStamp < lastTimestamp)
            {
                //throw new Exception("时间戳生成出现错误");
                //发生时钟回拨,切换workId,可解决。
                Init().Wait();
                return NextId();
            }
            var time = currentTimeStamp << (_indexLength + _workIdLength);
            var work = _workId.Value << _workIdLength;
            var id = time | work | lastIndex;
            lastIndex++;
            return id;
        }
    }
    private long TimeStamp(long lastTimestamp = 0L)
    {
        var current = (DateTime.Now.Ticks - _option.StartTimeStamp.Ticks) / 10000;
        if (lastTimestamp == current)
        {
            return TimeStamp(lastTimestamp);
        }
        return current;
    }
}

以上代码中重要逻辑都有注释,在此不具体讲解。只说下几个比较重要的地方。

首先,在构造函数中,从IOptions中获取配置信息,而后根据配置中的WorkIdLength的值,来计算序列号的长度。可能会有人不明白这样设计的缘由,因此须要这里我稍微展开下。笔者在开发初版的时候,工做机器的长度和序列号的长度是彻底根据雪花算法规定的,也就是工做机器id的长度是10,序列号的长度是12,这样设计会存在一个问题。在上文中我已经提到,10位的机器id最大支持1024个节点,12位的序列号最大支持每毫秒生成4096个id。但若是将机器id的长度改成9,序列号的长度改成13,那么机器最大支持512个节点,理论上也够用。13位的序列号则理论上每毫秒能生成8192。因此经过这样的设计,能够大大提升单节点生成id的效率和性能,以及单位时间内生成的数量。

另外,在Init方法中,尝试着获取IDistributedSupport接口的实例,这个接口有两个方法。代码以下:

public interface IDistributedSupport
{
    /// <summary>
    /// 获取下一个可用的机器id
    /// </summary>
    /// <returns></returns>
    Task<int> GetNextWorkId();
    /// <summary>
    /// 刷新机器id的存活状态
    /// </summary>
    /// <returns></returns>
    Task RefreshAlive();
}

这样设计的目的也是为了让有兴趣的读者能够更方便的根据本身的实际状况进行扩展。上文提到了,我是依赖与redis来实现机器id的动态分配的, 也许会有部分人但愿用数据库的方法,那么你只须要实现IDistributedSupport接口的方法就好了。下面是此接口的实现类的代码:

public class DistributedSupportWithRedis : IDistributedSupport
{
    private IRedisClient _redisClient;
    /// <summary>
    /// 当前生成的work节点
    /// </summary>
    private readonly string _currentWorkIndex;
    /// <summary>
    /// 使用过的work节点
    /// </summary>
    private readonly string _inUse;

    private readonly RedisOption _redisOption;

    private int _workId;
    public DistributedSupportWithRedis(IRedisClient redisClient, IOptions<RedisOption> redisOption)
    {
        _redisClient = redisClient;
        _redisOption = redisOption.Value;
        _currentWorkIndex = "current.work.index";
        _inUse = "in.use";
    }

    public async Task<int> GetNextWorkId()
    {
        _workId = (int)(await _redisClient.IncrementAsync(_currentWorkIndex)) - 1;
        if (_workId > 1 << _redisOption.WorkIdLength)
        {
            //表示全部节点已所有被使用过,则从历史列表中,获取当前已回收的节点id
            var newWorkdId = await _redisClient.SortedRangeByScoreWithScoresAsync(_inUse, 0,
                GetTimestamp(DateTime.Now.AddMinutes(5)), 0, 1, Order.Ascending);
            if (!newWorkdId.Any())
            {
                throw new Exception("没有可用的节点");
            }
            _workId = int.Parse(newWorkdId.First().Key);
        }
        //将正在使用的workId写入到有序列表中
        await _redisClient.SortedAddAsync(_inUse, _workId.ToString(), GetTimestamp());
        return _workId;
    }
    private long GetTimestamp(DateTime? time = null)
    {
        if (time == null)
        {
            time = DateTime.Now;
        }
        var dt1970 = new DateTime(1970, 1, 1);
        return (time.Value.Ticks - dt1970.Ticks) / 10000;
    }
    public async Task RefreshAlive()
    {
        await _redisClient.SortedAddAsync(_inUse, _workId.ToString(), GetTimestamp());
    }
}

以上便是本人实现雪花id算法的核心代码,调用也很简单,首先在Startup加入以下代码:

services.AddSnowflakeWithRedis(opt =>
{
     opt.InstanceName = "aaa:";
     opt.ConnectionString = "10.0.0.146";
     opt.WorkIdLength = 9;
     opt.RefreshAliveInterval = TimeSpan.FromHours(1);
});

在须要调用的时候,只须要获取ISnowflakeIdMaker实例,而后调用NextId方法便可。

idMaker.NextId()

结尾

至此,雪花id的构成,以及编码过程当中可能遇到的坑已分享完毕。
若是您以为文章或者代码对您有所帮助,欢迎点击文章的【推荐】,或者,git给个小星星也是能够的。

git地址:https://github.com/fuluteam/ICH.Snowflake

福禄ICH·架构组 福尔斯