分布式ID生成系统

文章转载自公众号“达达京东到家技术”

背景

在分布式系统中,经常需要对大量的数据、消息、http 请求等进行唯一标识,例如:对于分布式系统,服务间相互调用需要唯一标识,调用链路分析的时候需要使用这个唯一标识。这个时候数据库自增主键已经不能满足需求,需要一个能够生成全局唯一 ID 的系统,这个系统需要满足以下需求:

  • 全局唯一:不能出现重复 ID。
  • 高可用:ID 生成系统是基础系统,被许多关键系统调用,一旦宕机,会造成严重影响。

经典方案

1. UUID

UUID 是 Universally Unique Identifier 的缩写,它是指在一定范围内 (从特定的名字空间到全球) 唯一的机器生成的标识符,UUID 是 32位的16 进制数字,长为 128 位,例如:3F2504E0-4F89-11D3-9A0C-0305E82C3301。

UUID 经由一定的算法机器生成,为了保证 UUID 的唯一性,规范定义了包括网卡 MAC 地址、时间戳、名字空间 (Namespace)、随机或伪随机数、时序等元素,以及从这些元素生成 UUID 的算法。UUID 的复杂特性在保证了其唯一性的同时,意味着只能由计算机生成。

优点:

  • 本地生成 ID,不需要进行远程调用,时延低,性能高。

缺点:

  • UUID 过长,很多场景不适用,比如用 UUID 做数据库索引字段。
  • 没有排序,无法保证趋势递增。

2. Flicker方案

这个方案是由 Flickr 团队提出,主要思路采用了 MySQL 自增长 ID 的机制 (auto_increment + replace into)

# 数据表
CREATE TABLE Ticket64 (
id bigint(20) unsigned NOT NULL auto_increment,
stub har(1) NOT NULL default '',
PRIMARY KEY(id)
UNIQUE KEY stub (stub)
) ENGINE=MyISAM;
# 每次业务使用下列SQL读写MySQL得到ID号
REPLACE INTO Tickets64(stub) VALUES ('a');
SELECT LAST_INSERT_ID();

replace into 跟 insert 功能类似,不同点在于:replace into 首先尝试插入数据到表中,如果发现表中已经有此行数据 (根据主键或者唯 - 索引判断) 则先删除此行数据,然后插入新的数据, 否则直接插入新数据。

为了避免单点故障,最少需要两个数据库实例,通过区分 auto_increment 的起始值和步长来生成奇偶数的 ID。

Server1:
auto-increment-increment  = 2
auto-increment-offset = 1

Server2:
auto-increment-increment = 2
auto-increment-offset = 2

优点:

  • 充分借助数据库的自增 ID 机制,可靠性高,生成有序的 ID。

缺点:

  • ID 生成性能依赖单台数据库读写性能。

  • 依赖数据库,当数据库异常时整个系统不可用。

  • 对于依赖 MySQL 性能问题,可用如下方案解决:

在分布式环境中我们可以部署多台机器,每台设置不同的初始值,并且步长为机器台数,比如部署 N 台,每台的初始值就为 0,1,2,3...N-1,步长为 N。

分布式ID生成器部署

以上方案虽然解决了性能问题,但是也存在很大的局限性:

  • 系统扩容困难:系统定义好步长之后,增加机器之后调整步长困难。

  • 数据库压力大:每次获取一个 ID 都必须读写一次数据库。

3. snowflake方案

这种方案生成一个 64bit 的数字,64bit 被划分成多个段,分别表示时间戳、机器编码、序号。

snowflake-64bit

ID为64bit 的long 数字,由三部分组成:

  • 41位的时间序列(精确到毫秒,41位的长度可以使用69年)。

  • 10位的机器标识(10位的长度最多支持部署1024个节点)。

  • 12位的计数顺序号(12位的计数顺序号支持每个节点每毫秒产生4096个ID序号)。

优点:

  • 时间戳在高位,自增序列在低位,整个ID是趋势递增的,按照时间有序。

  • 性能高,每秒可生成几百万ID。

  • 可以根据自身业务需求灵活调整bit位划分,满足不同需求。

缺点:
依赖机器时钟,如果机器时钟回拨,会导致重复ID生成。

在单机上是递增的,但是由于涉及到分布式环境,每台机器上的时钟不可能完全同步,有时候会出现不是全局递增的情况。

4. TDDL 序列生成方式

TDDL 是阿里的分库分表中间件,它里面包含了全局数据库 ID 的生成方式,主要思路:

  • 使用数据库同步ID信息。
  • 每次批量取一定数量的可用ID在内存中,使用完后,再请求数据库重新获取下一批可用ID,每次获取的可用ID数量由步长控制,实际业务中可根据使用速度进行配置。
  • 每个业务可以给自己的序列起个唯一的名字,隔离各个业务系统的ID。
## 数据表设计
seqName    varchar(100)    序列名称,主键
cur_value    bigint(20)         当前值
step             int                    步长,根据实际情况设置

优点:

  • 相比Flicker方案,大大降低数据库写压力,数据库不再是性能瓶颈。
  • 相比Flicker方案,生成ID性能大幅度提高,因为获取一个可用号段后在内存中直接分配,相对于每次读取数据库性能提高了几个量级。
  • 不同业务不同的ID需求可以用seqName字段区分,每个seqName的ID获取相互隔离,互不影响。

缺点:

  • 强依赖数据库,当数据库异常时整个系统不可用。

发号器实现方案

综合对比以上四种实现方案,以及我们的业务需求,最后决定采用第三种方案。
主要原因:

  • 业务需求:业务要求生成的 ID 要有递增趋势,全局唯一,并且为数字。
  • 系统考虑:第三种方案性能高,稳定性高,对外部资源依赖少。
    依据实际业务需求和系统规划,对算法进行局部调整,实现了发号器 snowflake 方案。

发号器 snowflake 方案

发号器 snowflake 方案中对 bit 的划分做了如下调整:

  • 36 bit 时间戳,使用时间秒
  • 5 bit 机器编码
  • 22 bit 序号

机器编码维护:
机器编码是不同机器之间产生唯一 ID 的重要依据,不能重复,一旦重复,就会导致有相同机器编码的服务器生成的 ID 大量重复。 如果部署的机器只是少量的,可以人工维护,如果大量,手动维护成本高,考虑到自动部署、运维等等问题,机器编码最好由系统自动维护,有以下两个方案可供选择:

  • 使用 MySQL 自增 ID 特性,用数据表,存储机器的 mac 地址或者 ip 来维护。
  • 使用 ZooKeeper 持久顺序节点的特性。

这里我们使用 ZooKeeper 持久顺序节点特性来配置维护 WORKID发号器的启动顺序如下:

  • 启动发号器服务,连接 ZooKeeper, 检查根节点 id_generator 是否存在,如果不存在就创建系统根节点。
  • 检查根节点下当前机器是否已经注册过 (是否有该顺序子节点)。
  • 如果有注册,直接取回自己的 WORKID。如果没注册,在根节点下创建一个持久顺序节点,取回顺序号做 WORKID。

一旦取回 WORKID,缓存在本地文件中,后续直接使用,不再与 ZooKeeper 进行任何交互,此方案对 ZooKeeper 依赖极小。

发号器

时钟问题:

snowflake方案依赖系统时钟,如果机器时钟回拨,就有可能生成重复ID,为了保证ID唯一性,必须解决时钟回拨问题。

可以采取以下几种方案解决时钟问题:

  • 关闭系统NTP同步,这样就不会产生时钟调整。

  • 系统做出判断,在时钟回拨这段时间,不生成ID,直接返回ERROR_CODE,直到时钟追上,恢复服务。

//发生回拨,本次最新时间小于上次时间
if (timestamp < this.lastTimestamp) {
        throw new GeneratIdException("时钟回拨,拒绝生成ID");
}
  • 系统做出判断,如果遇到超过容忍限度的回拨,上报报警系统,并把自身从集群节点中摘除
// 发生回拨,本次时间小于上次时间
if (timestamp < this.lastTimestamp) {
        long delay = lastTimestamp - timestamp;
        // 如果偏差比较小,则等待
        if (delay < 10) {
            Thread.sleep(delay);
        }
        timestamp = this.timeGen();
       // 如果还没好,报警
       if (timestamp < this.lastTimestamp) {
           timeCallBackProcess(timestamp, this.lastTimestamp);
       } else {
           // 重新分配ID
           long id = nextSeqId();
       }
}
  • 统做兼容处理,由于nfp网络回拨都是几十毫秒到几百毫秒,极少数到秒级别,这种回拨会产生以下几种结果:
    系统中缓存最近几秒内最后的发号序号(具体范围请根据实际需要确定),存储格式为:时间秒-序号。
    • 前秒数不变: 当前是8:30秒100毫秒,ntp回拨50毫秒,当前时间变成8:30秒50毫秒,这个时候秒数没变,我们算法的时间戳部分不会产生重复,就不影响系统继续发号
    • 当前秒数向前:当前是8:30秒800毫秒,ntp 向前调整300毫秒,当前时间变成8:31秒100毫秒,由于这个时间还没发过号,不会生成重复的ID
    • 当前秒数向后:当前是8:30秒100毫秒,ntp回拨150毫秒,当前时间变成8:29秒950毫秒,这个时候秒发生回退,就可能产生重复ID。产生重复的原因在于秒回退后,算法的时间戳部分使用了已经用过的时间戳,但是算法的序号部分,并没有回退到29秒那个时间对应的序号,依然使用当前的序号,如果序号也同时回退到29秒时间戳所对应的最后序号,就不会重复发号。解决方案如下:
Map<Long, Long> map = new ConcurrenthashMap<Long, Long>();
// 发生回拨,本次更新时间小于上次时间
    if (timestampSec < this.lastTimestampSec) {
    // 有缓存
        if (map.get(timestampSec) != null) {
            this.sequence = map.get(timestampSec);
            this.nextId();
            map.put(timestampSec, this.sequence);
        } else {
            throw new GeneratIdException("时钟回退,拒绝生成ID");
        }
    }

闰秒处理:

闰秒,是指为保持协调世界时接近于世界时时刻,由国际计量局统一规定在年底或年中(也可能在季末)对协调世界时增加或减少1秒的调整。由于地球自转的不均匀性和长期变慢性(主要由潮汐摩擦引起的),会使世界时(民用时)和原子时之间相差超过到±0.9秒时,就把协调世界时向前拨1秒(负闰秒,最后一分钟为59秒)或向后拨1秒(正闰秒,最后一分钟为61秒),闰秒一般加在公历年末或公历六月末。

在闰秒产生的时候系统会出现秒级时间调整,下面我们来分析闰秒对发号器的影响:

  • 负闰秒:当前23:59:58的下一秒就是第二天的00:00:00,00:00:00 这个时间我们还没产生过ID,不会产生重复的,对发号器没影响。

  • 正闰秒:当天23:59:59的下一秒当记为23:59:60,然后才是第二天的00:00:00。由于我们系统时间戳部分取的从某个时间点(1970年1月1日)到现在的秒数,是一个数字,只要这个数字不重复,就不会产生重复的ID。如果在闰秒发生一段时间后ntp时间同步(为了规避闰秒风险,很多公司闰秒前关闭ntp同步,闰秒后打开ntp同步),这个时候系统时钟回拨,可以使用解决时钟回拨的方案进行处理。

部署结构

为了实现高可用,避免单点故障,系统部署采用集群水平部署,前置使用Nginx做负载均衡,发号器使用Spring Boot框架,web服务器使用Spring Boot内嵌Tomcat, 发号器和Nginx之间进行心跳检测。


部署结构

Tomcat调优

使用APR

Tomcat支持三种接收请求的处理方式:BIO、NIO、APR, 性能上 BIO<NIO<APR。APR简单理解,就是从操作系统级别解决异步IO问题,大幅度的提高服务器的处理和响应性能,也是Tomcat运行高并发应用的首选模式。使用APR首先要安装系统依赖库,接着在Spring Boot程序中增加ARP配置开启APR(这里有一个配置变量来控制是否开启)


使用APR

开发中遇到的问题

整个开发过程都非常顺利,测试的时候tps也很高,心情很愉快,世界很美好,突然一个意外出现,发现存在full gc现象,有内存溢出? 于是分析了好几遍程序,也没找到明显的线索,只能开始jvm调试旅程。

pingpoint 监控图:

pingpoint监控图

(上图中红色部署表示full gc)
JVM调试最直接的就是获取full gc时的jvm dump文件,以及gc log进行分析:

为了获取dump文件,在jvm参数中加上:

Jvm参数

参数介绍
参数介绍

配置上面的虚拟机参数后,虚拟机gc的时候会把gc相关信息输出到文件gc.log中,full gc前后,会生成当时虚拟机的内存dump文件。从pingpoint监控图中可以看出full gc是发生在持久区域。

使用jmap 工具,获取JVM堆内存信息如下:
jmap -heap pid


jmap

从上图可以看出,使用的堆内存很少,总的堆内存只有0.84% 使用,其它使用指标也都在正常范围,系统装载的类也不多,没有内存泄露。

继续分析gc log:

gc log

从gc log 中寻找线索:

gc log

这里发现了以下线索:

  • 从 [Full GC (Metadata GC Threshold)看出,的确产生了full gc,原因 Metadata GC Threshold。

  • [Metaspace: 34773K->34773K(1081344K)] full gc前后metaspace的size没有变化说明此区域已经满了,释放不出内存。

  • 仔细分析gc log,发现2次full gc记录,第一次full gc [Metaspace: 20897K->20897K(1069056K),这个值比第2次的要小很多。


两次full gc原因都是 Metadata GC Threshold类型,说明pingpoint监控到的full gc是元空间引发的full gc,并非内存泄露引起,但是这个值才34m,距离最大值1081m,还有很大空间,为什么会full gc?

经过查阅官方资料,发现MetaspaceSize的默认大小是21807104b,也就是21296k,而发生GC的时候,元空间已经使用了34722K,从而产生full gc。

方法区:

方法区也是所有线程共享。主要用于存储类的信息、常量池、方法数据、方法代码等。方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。其实,移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap,字符串常量转移到了java heap,类的静态变量(class statics)转移到了java heap。

在JDK8中,classe metadata(the virtual machines internal presentation of Java class),被存储在叫做Metaspace的native memory。一些新的flags被加入:-XX:MetaspaceSize,class metadata的初始空间配额,以bytes为单位,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当的降低该值;如果释放了很少的空间,就会在不超过MaxMetaspaceSize(如果设置了的话)的情况下,适当的提高该值。

在虚拟机参数中增加MetaspaceSize初始化大小,-XX:MetaspaceSize=128m,重新启动项目,不再有full gc出现。

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

推荐阅读更多精彩内容

  • 本文主要介绍在一个分布式系统中, 怎么样生成全局唯一的 ID 一, 问题描述 在分布式系统存在多个 Shard 的...
    hanayona阅读 1,982评论 0 5
  • 转载:细聊分布式ID生成方法 一、需求缘起 几乎所有的业务系统,都有生成一个记录标识的需求,例如: (1)消息标识...
    meng_philip123阅读 2,559评论 0 17
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,574评论 18 139
  • http://www.99banzou.com/product/78578.html 像我这样的人 (Live) ...
    脑子阅读 1,231评论 0 0
  • 这是一盘黑暗的料理,每一个见到的人都是触目惊心,他们不知道该如何下嘴,更不敢评判,只因为他们不敢,但一道妩媚的...
    雨中落尘阅读 271评论 0 0