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

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

cac55 2024-10-20 04:22 16 浏览 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

相关推荐

服务器用的CPU和个人电脑用的CPU有什么区别?一篇文章告诉你!

服务器cpu和普通cpu的区别你的电脑CPU是‘短跑健将’,服务器CPU却是‘铁人三项选手’——它不追求瞬间爆发力,而要7×24小时扛住千军万马的数据洪流!想知道为什么企业机房敢收天价服务费?答案全藏...

“吃鸡”新版本第1天,玩家进入游戏点击“立即更新”,后悔了!

欢迎诸位小伙伴们来到天哥开讲的《和平精英》“精英小课堂”~每逢两三个月,这款游戏就会迎来一次大版本迭代更新,很多朋友会在第一时间更新版本,前往全新的主题模式里一探究竟。不过也有一些老玩家并不会立刻更新...

中关村在线·aigo存储杯《无畏契约》全国高校争霸赛招募启事

以青春之名,燃电竞之火1赛事背景与宗旨在金秋送爽的9月,芊芊学子们即将回归校园生活。为了给精彩的校园生活锦上添花,由中关村在线与aigo存储联合主办的《无畏契约》全国高校争霸赛正式启幕,旨在为全国高...

【生肖狗】9.7-9.10提醒:人算不如天算,转变即是转机

九月上旬的风,带着秋意的清爽,也带着几分不可捉摸的变数。对于生肖狗的朋友们来说,9月7日到9月10日这四天,格外需要留意“计划与变化”的碰撞——你们向来习惯提前规划,做事稳妥周全...

转转客服IM系统的WebSocket集群架构设计和部署方案

本文由转转技术李帅分享,原题“转转客服IM的WebSocket集群部署方案”,下文有修订和重新排版。1、引言转转作为国内头部的二手闲置交易平台,拥有上亿的用户。用户在使用转转app遇到问题时,一般可以...

上线3天Steam好评率86%,《时间旅者:重生曙光》开启生存恐怖新篇章

这里究竟发生了什么?末日降临,真正的故事悄然启幕。目前,生存恐怖类游戏《时间旅者:重生曙光(Cronos:TheNewDawn)》已在PC(Steam、EpicGamesStore)、P...

什么神仙洗衣机让我一天有28小时?拆开松下「大四洗」藏了啥秘密

说起家庭洗衣的烦恼,想必很多人都有过类似的经历:贴身内衣要单独洗,宝宝的口水巾得小心呵护,宠物玩具怕藏污纳垢,床单被套又体积庞大,把这些东西混在一起洗担心越洗越脏,分开洗又得反复操作,洗完烘、烘完再洗...

爆料人挖出GTA6注册的奇葩域名 延续经典讽刺风格

等待《侠盗猎车手6》的日子跨越了数个春秋,在游戏圈期盼着这部可能成为史上最重磅游戏的过程中,每过一段时间就会有些许消息浮出水面。最新线索来自数据挖掘者Tez2在GTA论坛的发现,他可能偶然发现了关于...

跟着故事去旅行——读《驼峰间:旅行、探险与征服》

作者:郭冰茹《驼峰间》记录了旅行家伊本·白图泰有生之年流传的一则寓言,说一对父子被关进了监狱,有一天儿子问父亲他们每天吃的都是些什么肉,父亲说有牛、羊和骆驼,并且详细地描述了每种动物的特点。但不管父亲...

前端工程师需要熟悉的Linux服务器(SSH 终端操作)指令

在Linux服务器管理中,SSH(SecureShell)是远程操作的核心工具。以下是SSH终端操作的常用命令和技巧,涵盖连接、文件操作、系统管理等场景:一、SSH连接服务器1.基本连接...

跳票6年后,「丝之歌」首发把Steam服务器干爆了 | 玩点好的

文丨果脯樱花隧道昨天晚上22点,「鸽」了6年的《空洞骑士:丝之歌》终于上线,算是了却不少玩家的执念。毕竟,这款游戏实在让人等了太多太多年,而且曾有过多次定档后跳票的「案底」,不知道把多少人都整出了P...

对标魔兽失败!腾讯版“魔兽”运营一年多后,宣布国际服凉凉

大家好,这里是正惊游戏,我是正惊小弟。有很多游戏都想干掉《魔兽世界》,但是大部分魔兽杀手都知道自己不是魔兽的对手,不过是想蹭一下人气而已。腾讯也有一款曾经想对标魔兽的大作,可是上线才一年半国际服就宣布...

408 Request Timeout:服务器等待客户端发送请求的时间过长。

408RequestTimeout是HTTP状态码之一,表示客户端在发送请求时,服务器等待的时间过长,最终放弃了处理该请求。此问题通常与网络延迟、客户端配置、服务器设置或者应用程序的性能有关...

梦幻西游:9.9维护解读,全新时间服锁定129级

梦幻西游:9.9维护解读,全新时间服锁定129级9月9日维护解读。1、教师节活动开启,一共7天。挂机,答题,收笔墨纸砚,收海马,搞起来。或者是提前收点家具,教师节期间体力珍贵,家具会涨价。又或者是教师...

只是拆掉一面墙,空间就立马大变样,这种设计思路,值得学习

你有没有过这样的经历?刚买的房子户型图看起来方方正正,装修完却发现——玄关鞋柜只能塞在角落,进门就撞墙;餐厅正好在过道中间,吃饭像走流程;明明有四个房间,却有一个空着没用,像块食之无味的鸡肋;客餐厅之...

取消回复欢迎 发表评论: