百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

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

cac55 2024-10-20 04:22 10 浏览 0 评论

背景

主件(Primary Key),用于唯一标识表中的每一条数据。所以,一个合格的主见的最基本要求应该是唯一性。

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

优点如下:

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

缺点如下:

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

GUID

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

优点如下:

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

缺点如下:

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

GUID最大的缺点是无序,因为数据库主键默认是聚集索引,无序的数据将导致涉及到排序场景时性能降低。虽然可以根据算法生成有序的GUID,但对应的存储空间占用还是比较大的。

概念介绍

所以,本文的重点来了。如果能优化自增和GUID的缺点,是不是就算是一个更好的选择呢。一个好的主键需要具备如下特性:

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

经过优化后的雪花算法可以完美支持以上特性。

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

雪花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 月第一次海湾战争期间,部署在沙特宰荷兰的美国爱国者导弹系统未能成功追踪和拦截来袭的伊拉克飞毛腿导弹。结果飞毛腿导弹击中美国军营。

损失: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(#34;机器码取值范围为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的构成,以及编码过程中可能遇到的坑已分享完毕。

原文链接:https://www.cnblogs.com/fulu/p/13643388.html

相关推荐

正点原子开拓者FPGA开发板资料连载第四十章 SD卡图片显示实验

1)实验平台:正点原子开拓者FPGA开发板2)摘自《开拓者FPGA开发指南》关注官方微信号公众号,获取更多资料:正点原子3)全套实验源码+手册+视频下载地址:http://www.openedv.c...

东芝存储改名为铠侠了,铠侠microSD卡128GB全网首测

作为一个数码爱好者,平时总爱把玩各种科技数码产品,最近又迷上了口袋云台相机,大疆OsmoPocket、飞宇口袋相机、SnoppaVmate口袋相机什么的,不过这类产品由于设计的机身体积很小(毕竟为...

SD存储卡卡面上奇奇怪怪的图标,你知道几个?

现在对高像素照片、连拍、4K甚至8K的需求越来越多,对存储卡的传输速度、容量等,要求也越来越多了。但是,看到SD存储卡卡面上奇奇怪怪的图标,让人非常迷惑。这篇文章让你简单认识这些图标和奇奇怪怪的数字。...

拍摄4K视频上选!铠侠 EXCERIA PLUS microSD卡

大家好,我是波导终结者。今天跟大家分享的是铠侠的EXCERIAPLUS极至光速microSDXCUHS-1存储卡,名字有点长,但是不用担心,我会帮大家梳理好存储卡的选购建议。有不少刚入门的朋友...

高速稳定,一卡多用:铠侠极至光速microSD存储卡评测

Hello,大家好,我是小胖子。半个月前收到了KIOXIA铠侠寄来的一张256GB的TF卡,用了大半个月,让我们看看这款产品表现如何吧。其实很多人并不太了解铠侠,问我铠侠是什么品牌,好不好。其实,东芝...

读速205MB/s、V30规格,雷克沙SILVER系列存储卡再添新成员

IT之家6月19日消息,雷克沙今日推出3款SILVER系列SD/microSD存储卡新品,支持4K60fps录像。据介绍,该系列存储卡均符合V30标准,其中micr...

相机、无人机拍视频,选择SD存储卡有什么需要知道的?

本文章不涉及产品推荐导购行为,致力于给到小白带来基础知识。相机一般使用SD卡,无人机一般使用microSD卡(也叫TF卡),使用的标准和图标标识是一样的。相机、无人机拍视频,选择SD存储卡有什么需要知...

PNY推出适用Switch 2的microSD Express卡,读取速度高达890MB/s

任天堂Switch2开始预订,其比前代产品变得更加昂贵,各种配件的价格都高于预期,这也包括转向microSDExpress存储。此时,PNY推出了新款microSDExpress闪存卡。新款mi...

SD卡迎来25周年:全球售出120亿张,容量翻50万倍

IT之家5月21日消息,科技媒体betanews今天(5月21日)发布博文,报道称SD卡迎来了25周年的生日。自2000年首款SD存储卡问世以来,已走过25个年头...

微单相机买一款什么样的SD卡才够用?写入速度更为关键

最近,评价君朋友发现自己的卡拍摄视频时候总断流,于是感觉写入速度应该是不够的,打算换卡,评价君正好跟他说道说道。目前的SD存储卡,很多只标注读取速度,比如95MB/s,80MB/s等等,而没有写写入速...

金士顿Canvas Go!Plus 系列存储卡评测

前言2020年,金士顿推出了CanvasGo!Plus系列存储卡,凭借其优秀的读写速度和稳定性获得了广大用户的认可。时隔5年,金士顿推出了其全新升级产品:SDG4/SDCG4,可选容量覆盖64GB...

TF卡速度等级|MK米客方德(tf卡速度等级图)

TF卡(TransFlash卡,又称MicroSD卡)是一种常见的便携式存储媒体,广泛用于智能手机、相机、平板电脑等设备中。TF卡的性能通常由速度等级来衡量,这些等级反映了TF卡的数据传输速度。拓优星...

关于SD卡,看这张表就够了(sd卡的作用)

这里是溢图科技(原“相机笔记”)。这两天有不少存储产品促销,随之而来的就是关于SD卡的一些提问。文章以前已经写过很多了,这里主要给大家看一张表格:上面就是SD卡协会官方制作的“族谱”,明确给出了不同版...

轻量化储存的首选——凯侠极致光速256G microSD存储卡实测

对于摄影师而言,我们经常会接触到相关存储设备,像照片拍摄中给相机安装的SD卡,视频录制中外录高规格画面的SSD等,都属于专业的存储介质,被应用于商业拍摄、电影级别拍摄之中。而针对生活中我们日常用于拍摄...

首发1569元,读取速度可达250MB/s,闪迪推出最新2TB至尊超极速存储卡

近日,闪迪(SanDisk)正式发布了其最新的2TB至尊超极速microSDXCUHS-I存储卡。据悉,这款存储卡的读取速度可达250MB/s,写入速度则达到150MB/s。这意味着用户在处理高分辨...

取消回复欢迎 发表评论: