今天遇到了一个问题,现在互联网公司都是在分布式环境中,那么他们怎么保证订单号或者支付流水号是全局唯一(分布式ID)的呢?下面是笔者整理的一些常见的解决方案。

对于全局唯一ID传统的做法是使用UUID或者数据库自增ID,但是现在大部分互联网公司使用的都是Mysql数据库。UUID太长且无序不太适合作为Mysql数据库的主键(和Mysql存储引擎以及索引有关)。使用数据库自增ID,当并发量很高的情况又存在性能瓶颈,并且数据库进行分库分表后,可能会出现ID冲突的问题。那么应该怎样生成全局的唯一ID呢(也叫做分布式ID)?

java生成全球唯一pid java生成全局唯一id_java

1、使用UUID生成分布式ID

优点:
实现方式简单,本地生成,性能高,没有网络消耗。

public static void main(String[] args) {
        System.out.println(UUID.randomUUID());
        //75a90ba3-8bc9-4b37-992f-2d205de1b704
    }

缺点:
1、UUID太长,占用存储空间多。
2、UUID作为主键建立索引和基于索引进行查询存在性能问题,尤其是在InnoDB存储引擎下,UUID的无序性会导致索引位置频繁变动,导致分页。
注:UUID可能重复吗?
UUID理论上来说是可能重复的,经过16^32+1次生成之后会产生一次重复的值。但这是一个特别大的值,需要经过很长、很长、很长时间后可能出现一次重复值,所以UUID可以看成是不可重复的值。

2、基于数据库自增ID

单节点数据库生成分布式ID
需要一台单独的数据库服务器,创建一个张表:

CREATE TABLE sequence(
	id bigint(20) unsigned NOT NULL auto_increment,
	stub char(1) NOT NULL default '',
	PRIMARY KEY(id),
	UNIQUE KEY stub(stub)
) ENGINE=MyISAM;
REPLACE INTO sequence(stub) VALUES('a');
SELECT LAST_INSERT_ID();

sub字段设置为唯一索引,同一stub值在sequence表中只有一条记录。使用MyISAM引擎而不是InnoDB获得更高的性能。MYISAM使用的是表锁,对表的读写是串行的,所以不用担心并发访问读取到同一个ID。

利用数据库主键生成的分布式ID虽然是可以保证全局唯一,并发量不高的情况下可以使用,但是当并发量很高的情况下,数据库生成分布式ID显然是不够用了,并且数据库故障时,造成业务系统也不可用了。单节点数据库不够用,也许你可能会说,使用多节点数据库不就行了,下面我们说一下使用多节点数据库生成分布式ID的情况。
多节点数据库生成分布式ID
多节点部署数据库,当然我们这里使用的是双主模式的数据库,因为主从数据库之间数据不同步的情况下会出现ID重复的现象,这也就违背了我们全局ID唯一的特点。为了多个数据库(这里用两个数据库举例)生成不重复的ID,我们可以使用给每台mysql数据库设置初始值和步长的方式来配置数据库。
第一台Mysql1实例:

set @@auto_increment_offset = 1;//初始值
set @@auto_increment_increment = 2;//步长

第二 台Mysql2实例:

set @@auto_increment_offset = 2;//初始值
set @@auto_increment_increment = 2;//步长

配置完成后mysql数据库生成的ID序列如下:
mysql1:1,3,5,7,9…
mysql2:2,4,6,8,10…

既然有两个数据库,这里就需要创建一个应用程序来随机选择一个数据库获取分布式ID,然后由应用程序返回分布式ID给调用方。当其中的一个数据库不可用时,还有另一个数据库提供服务。但是数据库支持的并发量是有限的,当需要更高的并发量时,又该怎么处理呢?如果你想到了扩展数据库服务器数量,那么又会引入一个新的问题。如果要增加数据库,那么之前每台数据库设置好的初始值和步长,就需要人工去修改了。这就需要停掉前面配置的所有数据库,去统一修改配制,这样是极其不方便的,并且我们为了生成自增的ID去扩展很多数据库服务器,不觉得有些大材小用吗?这就需要我们从新的角度考虑生成分布式ID的方案。

号段模式

既然上面我们已经提到了,需要开发一个应用程序来获取数据库生成的分布式ID,那么我们为什么不可以把自增的逻辑放到这个应用程序中呢。我们可以给这个应用程序起个名字叫generatorIdService,方便我们后面的描述。

号段模式的大致意思是,generatorIdService服务每次从数据库获取ID时,获取ID的一段范围,表示generatorIdService服务获取到了这段范围的所有ID的使用权,比如[1,1000],表示当前生成的ID从这段范围内自增,每次generatorIdService服务从这段范围内返回一个自增的ID(可以通过AutomicLong实现)。这样就不用每次获取Id时都去请求数据库。直到ID自增到1000时,也就表示当前这段的ID使用完了,再去数据库取下一段的ID。
数据库表结构如下:

CREATE TABLE sequence(
  id int(10) NOT NULL auto_increment,
  current_max_id bigint(20) NOT NULL COMMENT '当前最大id',
  increment_step int(10) NOT NULL COMMENT '号段的长度',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

数据库中只记录当前自增ID的最大值和号段的长度,自增ID的逻辑被转移到generatorIdService服务中了,对数据库的依赖性大大降低了,如果数据库某个时间点不可用了,generatorIdService服务因为号段中的数据没有使用完,也可以支撑一小段时间。如果数据库重启了,应用中没有使用完的号段,将不会在被使用,而是从数据中新产生的号段开始使用。

generatorIdService服务集群化处理
为了保证我们服务的高可用,我们需要generatorIdService服务部署在集群的环境下来使用,业务系统获取ID时会随机选择一个generatorIdService服务获取ID,集群的generatorIdService服务连接同一个数据库,当并发的访问我们的数据库时,我们可以给数据添加一个version字段利用乐观锁的方式进行控制,保证数据的正确性。

update sequenceset current_max_id=#{newMaxId}, version=version+1 where version = #{version}

当然,你想把数据库做成集群化来实现更高的可用性也是可以的,可以使用我们上边提到的数据库多主的方式,使每个数据获取指定步长的数据即可。
形成的号段范围序列如下:
mysql1 [1,1999]自增序列是1,3,5,7,9…
mysql2 [2,2000]自增序列是2,4,6,8,10…
滴滴的开源项目TinyId使用的就是这个原理:https://github.com/didi/tinyid/wiki/tinyid原理介绍

3、基于Redis生成分布式ID

Redis是单线程,天然的保证原子性,可以使用Redis的原子操作incr和incrby来实现。
实现获取自增ID的方式如下:

@RestController
public class OrderController {
    @Autowired
    private RedisTemplate redisTemplate;
    //基于redis实现分布式全局ID原理
    /***
     * 前缀=当前日期+五位自增ID
     * 202005151936-00001
     * 相同毫秒,最多只能生成99999的订单。
     */
    @RequestMapping("/order")
    public String order(String key){
        RedisAtomicLong redisAtomicLong = new RedisAtomicLong(key, redisTemplate.getConnectionFactory());
        return "202005151936"+String.format("%1$05d",redisAtomicLong.incrementAndGet());
    }
}

Redis的优点:

  • Redis性能优于数据库
  • 生成的ID天然排序,对分页或者需要排序的结果很有帮助

缺点:

  • 如果系统中没有使用到Redis,增加Redis后增加了系统的复杂度
  • 编码和配置的工作量增加

其实redis实现和数据库自增ID实现的思想都是相同的,只是redis的性能要比数据库好很多。
可以提前生成分布式ID放到redis缓存中,这样可以更好的提高并发量,但是要做好redis持久化(AOF每条写命令),保证redis重启后不会出现ID重复的现象,同时需要设置失效时间(24h)。
如果想使用redis集群的话,也是可用通过给redis设置初始值和步长的方式保证获得全局的分布式ID。
设置初始值和步长的方式如下:

@RestController
public class OrderController {
    @Autowired
    private RedisTemplate redisTemplate;
    //基于redis实现分布式全局ID原理
    /***
     * 前缀=当前日期+五位自增ID
     * 202005151936-00001
     * 相同毫秒,最多只能生成99999的订单。
     */
    @RequestMapping("/order")
    public String order(String key){
        RedisAtomicLong redisAtomicLong = new RedisAtomicLong(key, redisTemplate.getConnectionFactory());
		//设置起始值
		redisAtomicLong.set(10);
        //设置步长+10
        redisAtomicLong.addAndGet(9);
        return "202005151936"+String.format("%1$05d",redisAtomicLong.incrementAndGet());
    }
}

如果集群中有5台机器,则生成的ID序列如下:
redis01:初始值1,步长5,生成的ID序列1,6,11,16,21…
redis02:初始值2,步长5,生成的ID序列2,7,12,17,22…
redis03:初始值3,步长5,生成的ID序列3,8,13,18,23…
redis04:初始值4,步长5,生成的ID序列4,9,14,19,24…
redis05:初始值5,步长5,生成的ID序列5,10,15,20,25…

4、雪花算法

上面说了半天,总的来说都是使用自增的思想,来生成分布式ID的,下面我们介绍一个比较有名的算法-snowflake.

snowflake是twitter开源的分布式ID生成算法,不需要依赖数据库。

核心的思想:ID是固定长度的long类型的数字,一个long类型占8个字节64位。

下图是snowflake算法的bit位分配方式:

java生成全球唯一pid java生成全局唯一id_面试_02

  • 第一个bit位是标识位,java中long的最高位是符号位,正数是0,符数是1,一般生成的ID是正数,所以固定是0。
  • 时间戳部分占41个bit位,表示毫秒级的时间,用来存储时间戳的差值(当前时间减去固定的开始时间),41位的时间戳可以使用69年,(1L<<41)/(1000L6060*24)=69年。
  • 工作机器ID占用10个bit位,设置比较灵活,可以自己进行分配,比如可以用前5位用作数据中心机房的标识(32个机房),后5位用来标识机器的标识(32个机器),总的来说可以标识1024个机器ID
  • 序列号占用12个bit位,每毫秒可以生成2^12=4096个序列号。

这里是github提供的一个用java实现的雪花算法:https://github.com/beyondfengyu/SnowFlake 国内很多互联网公司,生成的分布式ID都是基于snowflake算法实现的,这里以百度的uid-generator进行举例说明。

5、百度(uid-generator)

github地址:https://github.com/baidu/uid-generator 百度使用雪花算法时,对字节分配进行了调整。

java生成全球唯一pid java生成全局唯一id_redis_03

  • sign(1bit):固定标识符为0,表示生成ID为正数。
  • delta seconds(28bit):时间戳(当前时间减去固定的开始时间),表示秒级时间,最多可以支持约8.7年。
  • worker node id(22bit):工作节点ID(workId),可以支持最多约420万次启动。内置实现为启动时由数据库分配,默认分配策略为用后即弃(重启后,使用新生成的workId,丢弃之前生成的workId),后续可提供服务策略。
  • sequence(13):每秒并发的可以产生8192个序列。

UidGenerator提供了两种生Id的生成器:
1、默认的ID生成器(DefaultUidGenerator):
在系统启动的时候,会向一张名为worker_node的表中插入一条数据,得到Id作为workId的值。因为默认是22位,所以应用实例重启的次数不应该超过2^22(4194303)次,否则会抛出异常。

  • 系统通过synchronized保证线程安全。
  • 如果有时间回拨问题,抛出异常。
  • 并发生成ID时,通过sequence自增,一秒可生成2^13-1个自增序列,如果超过了这个值,那么就自旋到下一秒去生成ID,新的一秒sequence的起始值为0。

2、可缓存的Id生成器(CacheUidGenerator):
在系统启动时除了初始化上一步中workerId,还会处理RingBuffer,根据boostPower的值确定RingBuffer的size。RingBuffer默认的paddingFactor为50,意思就是说,当RingBuffer中ID的数量少于50%时,就会触发一个线程,向RingBuffer中填充ID(调用BufferPaddingExecutor的paddingBuffer()方法,线程通过AtomicBoolean的变量running控制并发问题),直到填充满为止。
CacheUidGenerator还提供了scheduleInterval属性,可以开启定时任务去填充RingBuffer。通过ScheduledExecutorService实现定时任务填充ID,默认不开启定时填充任务,可以指定任务时间间隔,默认是五分钟。
RingBuffer是一个环形数组,数组中每个元素称为一个slot。RingBuffer容量,默认是sequence的最大值(2^n),可以通过boostPower配置进行扩容,来提高RingBuffer的读写吞吐量。

  • tail指针
    表示Producer生产的最大序号(从0开始递增),tail不能超过Cursor,即生产者不能覆盖未消费的slot。当tail追上curosr时,可以通过rejectedPutBufferHandler指定PutRejectPolicy。
  • cursor指针
    表示consumer消费的最小序号,cursor不能超过tail,即消费者不能消费未生产的slot。当cursor追上tail时,可以通过rejectedTakeBufferHandler指定TakeRejectPolicy。

java生成全球唯一pid java生成全局唯一id_数据库_04


Uid-RingBuffer用于存储Uid,Flag-RingBuffer用于存储Uid的状态,表示当前是否可以填充、是否可以消费。

由于数组元素在内存中是连续分配的,可最大利用CPU cache来提升性能。同时带来「伪共享」FalseSharing问题,为此在Tail、cursor指针、Flag-RingBuffer中采用CacheLine补齐方式。
RingBuffer填充时机:
1、初始化填充
RingBuffer在初始化时,预先填充满整个RingBuffer。
2、即时填充
Take消费时,当剩余的slot(tail-cursor)数量小于指定的阈值时,则会补全空闲的slots,阈值是可以通过paddingFactor进行配置。
3、周期性填充
通过Schedule线程,定时补全空闲的slots。可以通过scheduleInterval进行配置,用在定时任务填充RingBuffer,并且Schedule可以设置时间间隔,默认是5分钟。

其实国内开源的分布式ID生成方案还有很多,这里列举出几个供大家参考使用。
滴滴TinyId:https://github.com/didi/tinyid/ 美团Leaf:https://github.com/Meituan-Dianping/Leaf 微信Seqsvr(go语言实现):https://github.com/qichengzx/seqsvr

声明:本文根据个人理解,总结分布式ID生成方案的实现方式,如有错误请指正。