難道主鍵除了自增就是GUID?支援k8s等分散式場景下的id生成器瞭解下

福祿網路技術團隊發表於2020-09-10

背景

主鍵(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. 分散式支援。

經過優化後的雪花演算法可以完美支援以上特性。

下圖是雪花演算法的構成圖:

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·架構組 福爾斯

相關文章