難道主鍵除了自增就是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·架構組 福爾斯
相關文章
相關標籤/搜索