聊聊大厂关于分布式Id的实现方案|8月更文挑战

755 阅读11分钟

这是我参与8月更文挑战的第3天,活动详情查看:8月更文挑战

最近在工作中用到了分布式唯一Id,于是乎翻阅了很多文章,学习到了很多实现方案,还有许多大厂的实现方案,废话不多说,我们来学习一下。

何为分布式Id

Id是数据的唯一标识,比较传统、我们熟知做法是利用UUID或者数据库的自增Id,由于UUID是无序性、没有丝毫可读性的,所以对于数据库来说,存储UUID来作为主键Id,是没有任何意义且查询性能极差的,相比较还是数据库的自增Id比较合适,但是随着数据量的不断增加,增加到配置主从库也扛不住的时候,就必须要对数据进行分表了吧,那么问题就出现了,一旦分表,每个表中的数据都会进行自增长,很有可能出现Id冲突。这时,就需要一个单独的机制来负责生成唯一Id,生成出来的Id就叫做分布式Id。下面就来分析一下分布式Id的多种生成机制。

UUID和数据库自增长Id上面已经简单说过了,UUID就是无序、无意义的字符串,用作分布式Id的话可读性差且效率低下;使用数据库自增长Id的话, 需要一个单独的数据库实例,虽然可行,但是数据量大的时候性能会差,而且访问量激增时,数据库一旦扛不住,嗝屁了,下线了,后果可想而知,用它来实现分布式服务风险比较大,也不推荐!

分布式Id实现方案

数据库多主模式

上边说了数据库自增长Id的方式不推荐,其实是单数据库实例不推荐,那我们可以优化嘛,白的不行整啤的对不对,单的既然不行,那就搞个集群嘛,换成主从模式集群。如果害怕单主节点挂掉没法用,那就做双主模式集群,说白了就是两个数据库实例,都让它去单独的生产自增Id,一个挂了还有另一个。是不是可靠多了。

那这就会有人问,两个数据库实例的自增ID都从1开始,不还是会有可能造成Id重复嘛,是的,不过有办法解决:

设置起始值增长步长

咳咳,开个题外话:

mysql中有自增长字段,在做数据库的主主同步时需要设置自增长的两个相关配置:auto_increment_offset和auto_increment_increment。
auto_increment_offset表示自增长字段从那个数开始,他的取值范围是1 .. 65535
auto_increment_increment表示自增长字段每次递增的量,其默认值是1,取值范围是1 .. 65535
在主主同步配置时,需要将两台服务器的auto_increment_increment增长量都配置为2,而要把auto_increment_offset分别配置为1和2.
这样才可以避免两台服务器同时做更新时自增长字段的值之间发生冲突。

那么我们可以这样配置:

数据库1:

set @@auto_increment_offset = 1;     -- 起始值
set @@auto_increment_increment = 2;  -- 步长

数据库2:

set @@auto_increment_offset = 2;     -- 起始值
set @@auto_increment_increment = 2;  -- 步长

那么两个数据库实例返回的主键Id就是:

1、3、5、7、9
2、4、6、8、10

这种方式其实在公司体量比较小、访问量也不大的时候,完全可以扛得住,实现扛不住还可以再扩容增加数据库节点,两个不行就仨,下面是三个数据库的时候:

但是,这种实现方案有一定的缺陷:

当已经存在两个数据库实例,要新增一个实例的时候,我们要人工去修改前两个实例的自增长步长为3,这是需要时间的,而且,前两个实例还在不停自增长,对于实例3的起始值我们可能要定得大一点,必须要比前两的实例自增长Id都要大好多才行,否则还是会出现重复Id;在修改步长的时候也很有可能会出现重复Id,要解决这个问题,可能需要停机,这可以说是麻烦的。

号段模式

号段模式可以理解成从数据库批量获取Id,这样的话,可以将批量获取的Id缓存到本地,那么也就不需要每次都去访问数据库,将大大提供业务应用获取Id的效率。 具体方案是每次从数据库取出一个号段范围,例如 (1,1000] 代表1000个Id,具体的分布式Id服务将本号段生成1~1000的自增Id并加载到内存,依次返回即可,而不需要每次都请求数据库,一直到本地自增到1000时,也就是当前号段已经被用完时,才去数据库重新获取下一号段。我们可以这样去设计数据库表:

CREATE TABLE id_generator (
  id int(10) NOT NULL,
  current_max_id bigint(20) NOT NULL COMMENT '当前最大id',
  increment_step int(10) NOT NULL COMMENT '号段的长度',
  biz_type VARCHAR (64) NOT NULL COMMENT '业务类型', 
  version int(20) NOT NULL COMMENT '版本号', 
  PRIMARY KEY ('id')
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
  • current_max_id代表当前最大的可用id
  • increment_step代表号段的长度,可以根据每个业务的qps来设置一个合理的长度
  • 这里我们增加了biz_type,这个代表业务类型,不同的业务的id隔离
  • version是一个乐观锁,每次更新都加上version,能够保证并发更新的正确性

那么我们可以通过如下几个步骤来获取一个可用的号段:

  • A.查询当前的current_max_id信息:select id, current_max_id, increment_step, version from id_generator where biz_type='test';
  • B.计算新的current_max_id: new_max_id = current_max_id + increment_step
  • C.更新DB中的current_max_id :update id_generator set current_max_id =#{new_max_id} , verison=version+1 where id=#{id} and current_max_id=#{current_max_id} and version=#{version};
  • D.如果更新成功,则可用号段获取成功,新的可用号段为(current_max_id, new_max_id]
  • E.如果更新失败,则号段可能被其他线程获取,回到步骤A,进行重试

为了提供数据库层的高可用,需要对数据库使用多主模式进行部署,对于每个数据库来说要保证生成的号段不重复,这就需要利用最开始的思路,再在刚刚的id_generator表中增加起始值和步长,比如如果现在是两台数据库实例,那么

数据库1将生成号段(1,1001],自增的时候序列为1,3,4,5,7....

数据库2将生成号段(2,1002],自增的时候序列为2,4,6,8,10...

号段模式的好处就是不会频繁操作数据库,对数据库的压力会小一些。

redis模式

使用redis来生成分布式Id,其实和利用数据库自增Id类似,可以利用redis中的incr命令来实现原子性的自增与返回,比如:

127.0.0.1:6379> set seq_id 1    // 初始化自增ID为1 
OK
127.0.0.1:6379> incr seq_id    // 增加1,并返回 
(integer) 2
127.0.0.1:6379> incr seq_id   // 增加1,并返回 
(integer) 3

使用redis的性能是非常好的,另外redis是单线程的,没有线程安全问题,能保证Id趋势递增,但是如果redis需要迁移的话,需要保证迁移过程中的数据一致性,难度较大,而且还要考虑持久化的问题,redis支持RDB和AOF两种持久化的方式,如果使用RDB方式,持久化相当于定时打一个快照进行持久化,如果打完快照后,又自增了几次,还没来得及做下一次快照持久化,这个时候redis挂掉了,重启redis后会出现Id重复;如果使用AOF方式, 持久化相当于对每条写命令进行持久化,如果redis挂掉了,不会出现Id重复的现象,但是这种方式重启redis恢复数据时间过长。

雪花算法(Snowflake)模式

雪花算法(Snowflake)是twitter开源的分布式ID生成算法,是一种算法,所以它和上面的几种分布式Id的生成机制不太一样,它不依赖数据库。 核心思想是:分布式ID固定是一个long型的数字,一个long型占8个字节,也就是64个bit,原始snowflake算法中对于bit的分配如下图:

  • 第一个bit位(1bit): 是标识部分,Java中long的最高位是符号位代表正负,正数是0,负数是1,一般生成Id都为正数,所以固定为0
  • 时间戳部分(41bit): 毫秒级的时间 ,一般不会存储当前的时间戳,而是时间戳的差值(当前时间-固定的开始时间),这样可以使产生的Id从更小值开始;41位的时间戳可以使用69年,(1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69年
  • 工作机器id(10bit):也被叫做workId,这个可以灵活配置,机房标识或者机器号组合都可以,10位可以部署1024个节点
  • 序列号部分(12bit),自增值,支持支持同一毫秒内同一个节点可以生成4096个Id

根据这个算法的逻辑,只需要将这个算法用编程语言实现出来,封装为一个工具方法,那么各个业务系统可以直接使用该工具方法来获取分布式ID,只需保证每个业务系统有自己的工作机器id即可,而不需要单独去搭建一个获取分布式Id的应用。

snowflake算法实现起来也并不难,提供一个github上用Java实现的:github.com/beyondfengy…

这里用Python实现一个:

import time
import logging


# 64 位 ID 的划分 WORKER_ID_BITS = 5
DATACENTER_ID_BITS = 5
SEQUENCE_BITS = 12

# 最大取值计算 MAX_WORKER_ID = -1 ^ (-1 << WORKER_ID_BITS)  # 2**5-1 0b11111 MAX_DATACENTER_ID = -1 ^ (-1 << DATACENTER_ID_BITS)

# 移位偏移计算 WOKER_ID_SHIFT = SEQUENCE_BITS
DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS
TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS

# 序号循环掩码 SEQUENCE_MASK = -1 ^ (-1 << SEQUENCE_BITS)

# Twitter 元年时间戳 TWEPOCH = 1288834974657


class IdWorker(object):
    """  用于生成 Id """
 def __init__(self, datacenter_id, worker_id, sequence=0):
        """  初始化  :param datacenter_id: 数据中心(机器区域)id   :param worker_id: 机器id   :param sequence: 起始 序号
 """ # sanity check  if worker_id > MAX_WORKER_ID or worker_id < 0:
            raise ValueError( 'worker_id 值越界 ' )

        if datacenter_id > MAX_DATACENTER_ID or datacenter_id < 0:
            raise ValueError( 'datacenter_id 值越界 ' )

        self.worker_id = worker_id
        self.datacenter_id = datacenter_id
        self.sequence = sequence

        self.last_timestamp = -1   # 上次计算的时间戳
 def _gen_timestamp(self):
        """  生成整数时间戳  :return :int timestamp """  return int(time.time() * 1000)

    def get_id(self):
        """  获取新 ID  :return : """ timestamp = self._gen_timestamp()

        # 时钟回拨  if timestamp < self.last_timestamp:
            logging.error( 'clock is moving backwards. Rejecting requests until {}' .format(self.last_timestamp))
            raise Exception( "clock is moving backwards. Rejecting requests until {}" .format(self.last_timestamp))

        if timestamp == self.last_timestamp:
            self.sequence = (self.sequence + 1) & SEQUENCE_MASK
            if self.sequence == 0:
                timestamp = self._til_next_millis(self.last_timestamp)
        else:
            self.sequence = 0

        self.last_timestamp = timestamp

        new_id = ((timestamp - TWEPOCH) << TIMESTAMP_LEFT_SHIFT) | (self.datacenter_id << DATACENTER_ID_SHIFT) | \
                 (self.worker_id << WOKER_ID_SHIFT) | self.sequence
        return new_id

    def _til_next_millis(self, last_timestamp):
        """  等到下一毫秒
 """ timestamp = self._gen_timestamp()
        while timestamp <= last_timestamp:
            timestamp = self._gen_timestamp()
        return timestamp


if __name__ == '__main__' :
    worker = IdWorker(1, 2, 0)
    for i in range(10):
        print(worker.get_id())

不过在大厂里,其实并没有直接使用雪花算法(snowflake),而是进行了改造,因为snowflake算法中最难实践的就是工作机器id,原始的snowflake算法需要人工去为每台机器去指定一个机器id,并配置在某个地方从而让snowflake从此处获取机器id。但是在大厂里,机器是很多的,人力成本太大且容易出错,所以大厂对snowflake进行了改造。

百度(uid-generator)

github地址:uid-generator

uid-generator使用的就是snowflake算法,只是在生产机器id,也叫做workId时与原始的snowflake算法有所不同。uid-generator中的workId是由uid-generator自动生成的,并且考虑到了应用部署在docker上的情况,在uid-generator中用户可以自己去定义workId的生成策略,默认提供的策略是:应用启动时由数据库分配。说的简单一点就是:应用在启动时会往数据库表(uid-generator需要新增一个WORKER_NODE表)中去插入一条数据,数据插入成功后返回的该数据对应的自增唯一id就是该机器的workId,而数据由host,port组成。

对于uid-generator中的workId,占用了22个bit位,时间占用了28个bit位,序列化占用了13个bit位,需要注意的是,和原始的snowflake不太一样,时间的单位是秒,而不是毫秒,workId也不一样,同一个应用每重启一次就会消费一个workId。

具体可参考github.com/baidu/uid-g…

美团(Leaf)

github地址:Leaf   

There are no two identical leaves in the world.(世界上没有两片完全相同的树叶。) — 莱布尼茨

用叶子作为分布式唯一Id框架的名字,可以说非常恰当了。

美团的Leaf非常全面,即支持号段模式,也支持snowflake模式。号段模式这里就不介绍了,和上面的分析类似,github文档传送门:中文文档 | English Document 。 具体Leaf 设计文档见: leaf 美团分布式ID生成服务 。

Leaf中的snowflake模式和原始snowflake算法的不同点,也主要在workId的生成,Leaf中workId是基于ZooKeeper的顺序Id来生成的,每个应用在使用Leaf-snowflake时,在启动时都会都在Zookeeper中生成一个顺序Id,相当于一台机器对应一个顺序节点,也就是一个workId。

滴滴(Tinyid)

Tinyid是滴滴出行的产物,Github地址:github.com/didi/tinyid, Tinyid也是用Java开发,基于数据库号段算法实现,Tinyid扩展了leaf-segment算法,支持了多db(master),同时提供了java-client(sdk)使id生成本地化,获得了更好的性能与可用性。Tinyid在滴滴客服部门使用,均通过tinyid-client方式接入,每天生成亿级别的id。

这里就不详细介绍了,文档很全github.com/didi/tinyid… 。

总结

本文只是简单介绍一下每种分布式Id实现方式,想详细了解或者使用的,还是要系统的去评估每种方式的优缺点及自己的具体业务需求,比如我,综合比较了一番,认真评估了一下业务需求,决定使用redis模式实现,由于产品方提出的需求,又加了过期时间,每天自动归零,前缀加"年月日"组合而成,哈哈哈,告辞。

最后,感谢女朋友在工作和生活中的包容、理解与支持 !