94

干货 | 分布式架构系统生成全局唯一序列号的一个思路

 6 years ago
source link: http://mp.weixin.qq.com/s/F7WTNeC3OUr76sZARtqRjw
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

作者简介 

丁宜人,10年java开发经验。携程技术中心基础业务研发部用户中心资深java工程师,负责携程账号的基础服务和相关框架组件研发。之前在惠普公司供职6年,负责消息中间件产品研发。

一、相关背景

分布式架构下,唯一序列号生成是我们在设计一个系统,尤其是数据库使用分库分表的时候常常会遇见的问题。当分成若干个sharding表后,如何能够快速拿到一个唯一序列号,是经常遇到的问题。

在携程账号数据库迁移MySql过程中,我们对用户ID的生成方案进行了新的设计,要求能够支撑携程现有的新用户注册体量。

本文通过携程用户ID生成器的实现,希望能够对大家设计分库分表的唯一id有一些新的思路。

二、特性需求

  1. 支持高并发

  2. 能够体现一定属性

  3. 高可靠,容错单点故障

三、业内方案

生成ID的方法有很多,来适应不同的场景、需求以及性能要求。

常见方式有:

1、利用数据库递增,全数据库唯一。

优点:明显,可控。

缺点:单库单表,数据库压力大。

2、UUID, 生成的是length=32的16进制格式的字符串,如果回退为byte数组共16个byte元素,即UUID是一个128bit长的数字,一般用16进制表示。

优点:对数据库压力减轻了。

缺点:但是排序怎么办?

此外还有UUID的变种,增加一个时间拼接,但是会造成id非常长。

3、twitter在把存储系统从MySQL迁移到Cassandra的过程中由于Cassandra没有顺序ID生成机制,于是自己开发了一套全局唯一ID生成服务:Snowflake。

1  41位的时间序列(精确到毫秒,41位的长度可以使用69年)
2  10位的机器标识(10位的长度最多支持部署1024个节点) 
3  12位的计数顺序号(12位的计数顺序号支持每个节点每毫秒产生4096个ID序号) 最高位是符号位,始终为0。

优点:高性能,低延迟;独立的应用;按时间有序。

缺点:需要独立的开发和部署。

4、Redis生成ID

当使用数据库来生成ID性能不够要求的时候,我们可以尝试使用Redis来生成ID。这主要依赖于Redis是单线程的,所以也可以用生成全局唯一的ID。可以用Redis的原子操作INCR和INCRBY来实现。

可以使用Redis集群来获取更高的吞吐量。假如一个集群中有5台Redis。可以初始化每台Redis的值分别是1,2,3,4,5,然后步长都是5。各个Redis生成的ID为:

A:1,6,11,16,21

B:2,7,12,17,22

C:3,8,13,18,23

D:4,9,14,19,24

E:5,10,15,20,25

比较适合使用Redis来生成每天从0开始的流水号。比如订单号=日期+当日自增长号。可以每天在Redis中生成一个Key,使用INCR进行累加。

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

数字ID天然排序,对分页或者需要排序的结果很有帮助。

使用Redis集群也可以防止单点故障的问题。

如果系统中没有Redis,还需要引入新的组件,增加系统复杂度。

需要编码和配置的工作量比较大,多环境运维很麻烦,

在开始时,程序实例负载到哪个redis实例一旦确定好,未来很难做修改。

5.   Flicker的解决方案

因为MySQL本身支持auto_increment操作,很自然地,我们会想到借助这个特性来实现这个功能。

Flicker在解决全局ID生成方案里就采用了MySQL自增长ID的机制(auto_increment + replace into + MyISAM)。

6.还有其他一些方案,比如京东淘宝等电商的订单号生成。因为订单号和用户id在业务上的区别,订单号尽可能要多些冗余的业务信息,比如:

滴滴:时间+起点编号+车牌号

淘宝订单:时间戳+用户ID

其他电商:时间戳+下单渠道+用户ID,有的会加上订单第一个商品的ID。

而用户ID,则要求含义简单明了,包含注册渠道即可,尽量短。

四、最终方案

最终我们选择了以flicker方案为基础进行优化改进。具体实现是,单表递增,内存缓存号段的方式。

首先建立一张表,像这样:

SEQUENCE_GENERATOR_TABLE

id   stub

1    192.168.1.1

其中id是自增的,stub是服务器ip

因为新数据库采用mysql,所以使用mysql的独有语法 replace to来更新记录来获得唯一id,例如这样:

REPLACE INTO SEQUENCE_GENERATOR_TABLE (stub) VALUES ("192.168.1.1");

再用SELECT id FROM SEQUENCE_GENERATOR_TABLEWHERE stub = "192.168.1.1";   把它拿回来。

到上面为止,我们只是在单台数据库上生成ID,从高可用角度考虑,接下来就要解决单点故障问题。

这也就是为什么要有这个机器ip字段呢?就是为了防止多服务器同时更新数据,取回的id混淆的问题。

所以,当多个服务器的时候,这个表是这样的:

id   stub

5    192.168.1.1

2    192.168.1.2

3    192.168.1.3

4    192.168.1.4

每台服务器只更新自己的那条记录,保证了单线程操作单行记录。

这时候每个机器拿到的分别是5,2,3,4这4个id。

至此,我们似乎解决这个服务器隔离,原子性获得id的问题,也和flicker方案基本一致。

但是追根溯源,在原理上,方案还是依靠数据库的特性,每次生成id都要请求db,开销很大。我们对此又进行优化,把这个id作为一个号段,而并不是要发出去的序列号,并且这个号段是可以配置长度的,可以1000也可以10000,也就是对拿回来的这个id放大多少倍的问题。

OK,我们从DB一次查询操作的开销,拿回来了1000个用户id到内存中了。

现在的问题就是要解决同一台服务器在高并发场景,让大家顺序拿号,别拿重复,也别漏拿。

这个问题简单来说,就是个保持这个号段对象隔离性的问题。

AtomicLong是个靠谱的办法。

当第一次拿回号段id后,扩大1000倍,然后赋值给这个变量atomic,这就是这个号段的第一个号码。

atomic.set(n * 1000);

并且内存里保存一下最大id,也就是这个号段的最后一个号码

currentMaxId = (n + 1) * 1000;

一个号段就形成了。

此时每次有请求来取号时候,判断一下有没有到最后一个号码,没有到,就拿个号,走人。

Long uid = atomic.incrementAndGet();

如果到达了最后一个号码,那么阻塞住其他请求线程,最早的那个线程去db取个号段,再更新一下号段的两个值,就可以了。

这个方案,核心代码逻辑不到20行,解决了分布式系统序列号生成的问题。

这里有个小问题,就是在服务器重启后,因为号码缓存在内存,会浪费掉一部分用户ID没有发出去,所以在可能频繁发布的应用中,尽量减小号段放大的步长n,能够减少浪费。

经过实践,性能的提升远远重要于浪费一部分id。

如果再追求极致,可以监听spring或者servlet上下文的销毁事件,把当前即将发出去的用户ID保存起来,下次启动时候再捞回内存即可。

五、上线效果

运行5个多月,十分稳定。

SOA服务平均响应时间 0.59毫秒;

客户端调用平均响应时间2.52毫秒;

附流程图:

Image

推荐阅读:

Image

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK