分布式系统唯一ID生成技术方案

前言

在应用程序中,唯一ID是一个系统必备的功能。体现在:

  • 单体应用:单体应用用于唯一标识用户、订单等的唯一标识需要保证应用内唯一。
  • 半分布式系统:分区分服的游戏应用,在合服时为了避免数据冲突需要保证所有的ID全局唯一。
  • 全分布式系统:大型的电商、外卖、金融系统等的订单ID、用户ID等需要全局唯一。
    单体应用的唯一ID可以简单使用数据库的自增ID来实现即可,在这里我们不予以讨论。我们讨论的是在半分布式和全分布式系统中的唯一ID生成的方案。

ID生成系统的需求

  1. 全局唯一性:不能出现重复的ID,这是最基本的要求。
  2. 趋势递增:MySQL InnoDB引擎使用的是聚集索引,由于多数RDBMS使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应尽量使用有序的主键保证写入性能。
  3. 单调递增:保证下一个ID一定大于上一个ID。几乎所有的关系性数据库都使用B+Tree来进行数据在硬盘上的存储,为了避免叶分裂,需要保证新的ID总是大于之前所有的ID。
  4. 信息安全:如果ID是连续递增的,恶意用户就可以很容易的窥见订单号的规则,从而猜出下一个订单号,如果是竞争对手,就可以直接知道我们一天的订单量。所以在某些场景下,需要ID无规则。

技术方案

1. UUID

UUID是指在一台机器在同一时间中生成的数字在所有机器中都是唯一的。按照开放软件基金会(OSF)制定的标准计算,用到了以太网卡地址、纳秒级时间、芯片ID码和许多可能的数字。
UUID由以下几部分的组成:
(1)当前日期和时间。
(2)时钟序列。
(3)全局唯一的IEEE机器识别号,如果有网卡,从网卡MAC地址获得,没有网卡以其他方式获得。
标准的UUID格式为:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (8-4-4-4-12),以连字号分为五段形式的36个字符,示例:550e8400-e29b-41d4-a716-446655440000
很多语言中都提供了原生的生成UUID的方法。比如:
C#

new GUID()

JAVA

UUID.randomUUID()

优点

  • 性能非常高:本地生成,没有网络消耗。

缺点

  • 不易存储:UUID太长,16字节128位,通常以36长度的字符串表示。
  • 信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。
  • ID作为主键时在特定的环境会存在一些问题,比如做DB主键的场景下,效率太低。

2. MongoDB

MongoDB的ObjectId 采用12字节的存储空间,每个字节两位16进制数字,是一个24位的字符串。
生成规则如下:
[0,1,2,3] [4,5,6] [7,8] [9,10,11]
时间戳 |机器码 |PID |计数器

  • 前四字节是时间戳,可以提供秒级别的唯一性。
  • 接下来三字节是所在主机的唯一标识符,通常是机器主机名的散列值。
  • 接下来两字节是产生ObjectId的PID,确保同一台机器上并发产生的ObjectId是唯一的。
    前九字节保证了同一秒钟不同机器的不同进程产生的ObjectId时唯一的。
  • 最后三字节是自增计数器,确保相同进程同一秒钟产生的ObjectId是唯一的。

优点

缺点

  • 不易存储:ObjectId 太长,12字节96位,通常以24长度的字符串表示。
  • ID作为主键时在特定的环境会存在一些问题,比如做DB主键的场景下,效率太低。
  • ID作为索引时,导致索引的数据量太大,浪费磁盘空间。

3. 数据库

使用数据库的ID自增策略,如 MySQL 的 auto_increment。

优点

  • 数据库生成的ID绝对有序

缺点

  • 需要独立部署数据库实例
  • 有网络请求,速度慢
  • 有单点故障的风险。要解决这个问题,就得引入集群,进一步增加系统的复杂度。

4. Redis

Redis的所有命令操作都是单线程的,本身提供像 incr 和 increby 这样的自增原子命令,所以能保证生成的 ID 肯定是唯一有序的。
优点

  • 不依赖于数据库,灵活方便,且性能优于数据库

缺点

  • 如果系统中没有Redis,还需要引入新的组件,增加系统复杂度。
  • 有单点故障的风险。要解决这个问题,就得引入集群,进一步增加系统的复杂度。

5. 百度UidGenerator

UidGenerator是百度开源的分布式ID生成器,基于于Snowflake算法的实现。
具体可以参考官网:https://github.com/baidu/uid-generator/blob/master/README.zh_cn.md

6. 美团Leaf

Leaf 是美团开源的分布式ID生成器,能保证全局唯一性、趋势递增、单调递增、信息安全,但也需要依赖关系数据库、Zookeeper等中间件。
具体可以参考官网:https://tech.meituan.com/2017/04/21/mt-leaf.html

7. Snowflake

Twitter 实现了一个全局ID生成的服务 Snowflake:https://github.com/twitter-archive/snowflake

SnowflakeID生成规则

如上图的所示,Twitter 的 Snowflake 算法由下面几部分组成:

  • 1位符号位:
    最高位为 0,表示正数;因为所有的ID都是正数。
  • 41位时间戳(毫秒级):
    需要注意的是此处的 41 位时间戳并非存储当前时间的时间戳,而是存储时间戳的差值(当前时间戳 - 起始时间戳),这里的起始时间戳由程序来指定,所以41位毫秒时间戳最多可以使用 (1 << 41) / (1000x60x60x24x365) = 69年。
  • 10位数据机器位:
    包括5位数据标识位和5位机器标识位,这10位决定了分布式系统中最多可以部署 1 << 10 = 1024 个节点。超过这个数量,生成的ID就有可能会冲突。
  • 12位毫秒内的序列:
    这 12 位计数支持每个节点每毫秒(同一台机器,同一时刻)最多生成 1 << 12 = 4096个ID。

加起来刚好64位,为一个Int64型的整数。
优点

  • 简单高效,没有网络请求,生成速度快。
  • 时间戳在高位,自增序列在低位,整个ID是趋势递增的,按照时间有序递增。
  • 灵活度高,可以根据业务需求,调整bit位的划分,满足不同的需求。
  • 生成的是一个Int64的整形,作为数据库的索引,效率非常高。

缺点

  • 依赖机器的时钟,如果服务器时钟回拨,会导致重复ID生成。
  • 在分布式环境上,每个服务器的时钟不可能完全同步,有时会出现不是全局递增的情况。

在大多数情况下,Snowflake算法能够满足应用需求。以下是我使用Go和C#实现的可以灵活配置各个部分所占位数的Snowflake算法。
Go

/*
用于生成唯一的、递增的Id。生成的规则如下:
1、生成的Id包含一个固定前缀值
2、为了生成尽可能多的不重复数字,所以使用int64来表示一个数字,其中:
0 000000000000000 0000000000000000000000000000 00000000000000000000
第一部分:1位,固定为0
第二部分:共PrefixBitCount位,表示固定前缀值。范围为[0, math.Pow(2, PrefixBitCount))
第三部分:共TimeBitCount位,表示当前时间距离基础时间的秒数。范围为[0, math.Pow(2, TimeBitCount)),以2019-1-1 00:00:00为基准则可以持续到2025-07-01 00:00:00
第四部分:共SeedBitCount位,表示自增种子。范围为[0, math.Pow(2, SeedBitCount))
3、总体而言,此规则支持每秒生成math.Pow(2, SeedBitCount)个不同的数字,并且在math.Pow(2, TimeBitCount)/60/60/24/365年的时间范围内有效
*/

package idUtil

import (
    "fmt"
    "math"
    "sync"
    "time"
)

// Id生成器
type IdGenerator struct {
    prefixBitCount uint  // 前缀所占的位数
    minPrefix      int64 // 最小的前缀值
    maxPrefix      int64 // 最大的前缀值

    timeBitCount    uint  // 时间戳所占的位数
    baseTimeUnix    int64 // 基础时间
    maxTimeDuration int64 // 最大的时间范围

    seedBitCount uint  // 自增种子所占的位数
    currSeed     int64 // 当前种子值
    minSeed      int64 // 最小的种子值
    maxSeed      int64 // 最大的种子值

    mutex sync.Mutex // 锁对象
}

func (this *IdGenerator) getTimeStamp() int64 {
    return time.Now().Unix() - this.baseTimeUnix
}

func (this *IdGenerator) generateSeed() int64 {
    this.mutex.Lock()
    defer this.mutex.Unlock()

    if this.currSeed >= this.maxSeed {
        this.currSeed = this.minSeed
    } else {
        this.currSeed = this.currSeed + 1
    }

    return this.currSeed
}

// 生成新的Id
// prefix:Id的前缀值。取值范围必须可以用创建对象时指定的前缀值的位数来表示,否则会返回参数超出范围的错误
// 返回值:
// 新的Id
// 错误对象
func (this *IdGenerator) GenerateNewId(prefix int64) (int64, error) {
    if prefix < this.minPrefix || prefix > this.maxPrefix {
        return 0, fmt.Errorf("前缀值溢出,有效范围为【%d,%d】", this.minPrefix, this.maxPrefix)
    }

    stamp := this.getTimeStamp()
    seed := this.generateSeed()
    id := prefix<<(this.timeBitCount+this.seedBitCount) | stamp<<this.seedBitCount | seed

    return id, nil
}

// 创建新的Id生成器对象(为了保证Id的唯一,需要保证生成的对象全局唯一)
// prefixBitCount + timeBitCount + seedBitCount <= 63
// prefixBitCount:表示id前缀的位数
// timeBitCount:表示时间的位数
// seedBitCount:表示自增种子的位数
// 返回值:
// 新的Id生成器对象
// 错误对象
func New(prefixBitCount, timeBitCount, seedBitCount uint) (*IdGenerator, error) {
    // 之所以使用63位而不是64,是为了保证值为正数
    if prefixBitCount+timeBitCount+seedBitCount > 63 {
        return nil, fmt.Errorf("总位数%d超过63位,请调整所有值的合理范围。", prefixBitCount+timeBitCount+seedBitCount)
    }

    obj := &IdGenerator{
        prefixBitCount: prefixBitCount,
        timeBitCount:   timeBitCount,
        seedBitCount:   seedBitCount,
    }

    obj.minPrefix = 0
    obj.maxPrefix = int64(math.Pow(2, float64(prefixBitCount))) - 1
    obj.baseTimeUnix = time.Date(2019, time.January, 1, 0, 0, 0, 0, time.Local).Unix()
    obj.maxTimeDuration = (int64(math.Pow(2, float64(timeBitCount))) - 1) / 86400 / 365
    obj.currSeed = 0
    obj.minSeed = 0
    obj.maxSeed = int64(math.Pow(2, float64(seedBitCount))) - 1
    fmt.Printf("Prefix:[%d, %d], Time:%d Year, Seed:[%d, %d]\n", obj.minPrefix, obj.maxPrefix, obj.maxTimeDuration, obj.minSeed, obj.maxSeed)

    return obj, nil
}

C#


/*
用于生成唯一的、递增的Id。生成的规则如下:
1、生成的Id包含一个固定前缀值
2、为了生成尽可能多的不重复数字,所以使用int64来表示一个数字,其中:
0 000000000000000 0000000000000000000000000000 00000000000000000000
第一部分:1位,固定为0
第二部分:共PrefixBitCount位,表示固定前缀值。范围为[0, math.Pow(2, PrefixBitCount))
第三部分:共TimeBitCount位,表示当前时间距离基础时间的秒数。范围为[0, math.Pow(2, TimeBitCount)),以2019-1-1 00:00:00为基准则可以持续到2025-07-01 00:00:00
第四部分:共SeedBitCount位,表示自增种子。范围为[0, math.Pow(2, SeedBitCount))
3、总体而言,此规则支持每秒生成math.Pow(2, SeedBitCount)个不同的数字,并且在math.Pow(2, TimeBitCount)/60/60/24/365年的时间范围内有效
*/

using System;

namespace Util
{
    /// <summary>
    /// Id生成助手类
    /// </summary>
    public class IdUtil
    {
        /// <summary>
        /// 前缀所占的位数
        /// </summary>
        public Int32 PrefixBitCount { get; set; }

        /// <summary>
        /// 最小的前缀值
        /// </summary>
        private Int64 MinPrefix { get; set; }

        /// <summary>
        /// 最大的前缀值
        /// </summary>
        private Int64 MaxPrefix { get; set; }

        /// <summary>
        /// 时间戳所占的位数
        /// </summary>
        public Int32 TimeBitCount { get; set; }

        /// <summary>
        /// 自增种子所占的位数
        /// </summary>
        public Int32 SeedBitCount { get; set; }

        /// <summary>
        /// 当前种子值
        /// </summary>
        private Int64 CurrSeed { get; set; }

        /// <summary>
        /// 最小的种子值
        /// </summary>
        private Int64 MinSeed { get; set; }

        /// <summary>
        /// 最大的种子值
        /// </summary>
        private Int64 MaxSeed { get; set; }

        /// <summary>
        /// 锁对象
        /// </summary>
        private Object LockObj { get; set; }

        /// <summary>
        /// 创建Id助手类对象(为了保证Id的唯一,需要保证生成的对象全局唯一)
        /// prefixBitCount + timeBitCount + seedBitCount <= 63
        /// </summary>
        /// <param name="prefixBitCount">表示id前缀的位数</param>
        /// <param name="timeBitCount">表示时间的位数</param>
        /// <param name="seedBitCount">表示自增种子的位数</param>
        public IdUtil(Int32 prefixBitCount, Int32 timeBitCount, Int32 seedBitCount)
        {
            // 之所以使用63位而不是64,是为了保证值为正数
            if (prefixBitCount + timeBitCount + seedBitCount > 63)
            {
                throw new ArgumentOutOfRangeException(String.Format("总位数{0}超过63位,请调整所有值的合理范围。", (prefixBitCount + timeBitCount + seedBitCount).ToString()));
            }

            this.PrefixBitCount = prefixBitCount;
            this.TimeBitCount = timeBitCount;
            this.SeedBitCount = seedBitCount;

            this.MinPrefix = 0;
            this.MaxPrefix = (Int64)System.Math.Pow(2, this.PrefixBitCount) - 1;
            this.CurrSeed = 0;
            this.MinSeed = 0;
            this.MaxSeed = (Int64)System.Math.Pow(2, this.SeedBitCount) - 1;
            Console.WriteLine(String.Format("Prefix:[{0},{1}], Time:{2} Year, Seed:[{3},{4}]", this.MinPrefix, this.MaxPrefix, (Int64)(System.Math.Pow(2, this.TimeBitCount) / 60 / 60 / 24 / 365), this.MinSeed, this.MaxSeed));
            this.LockObj = new Object();
        }

        private Int64 GetTimeStamp()
        {
            DateTime startTime = new DateTime(2019, 1, 1);
            DateTime currTime = DateTime.Now;
            return (Int64)System.Math.Round((currTime - startTime).TotalSeconds, MidpointRounding.AwayFromZero);
        }

        private Int64 GenerateNewSeed()
        {
            lock (this.LockObj)
            {
                if (this.CurrSeed >= this.MaxSeed)
                {
                    this.CurrSeed = this.MinSeed;
                }
                else
                {
                    this.CurrSeed += 1;
                }

                return this.CurrSeed;
            }
        }

        /// <summary>
        /// 生成新的Id
        /// </summary>
        /// <param name="prefix">Id的前缀值。取值范围必须可以用初始化时指定的前缀值的位数来表示,否则会抛出ArgumentOutOfRangeException</param>
        /// <returns>新的Id</returns>
        public Int64 GenerateNewId(Int64 prefix)
        {
            if (prefix < this.MinPrefix || prefix > this.MaxPrefix)
            {
                throw new ArgumentOutOfRangeException(String.Format("前缀的值溢出,有效范围为【{0},{1}】", this.MinPrefix.ToString(), this.MaxPrefix.ToString()));
            }

            Int64 stamp = this.GetTimeStamp();
            Int64 seed = this.GenerateNewSeed();

            return (prefix << (this.TimeBitCount + this.SeedBitCount)) | (stamp << this.SeedBitCount) | seed;
        }
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容