全局唯一 ID 如何生成?

ID 生成,提到这个词可能最快想到的就是 MySQL 数据库 insert 时的自增ID。当业务访问量剧增,单表的 insert 性能遇到瓶颈时,我们就必须使用分库、分表机制来分担这些负载到单个MySQL表上,但是我们都知道MySQL表中的主键自增是基于单表的,如果2个表中有2个自增字段,那他们默认都是从1开始自增,每次增加步长为1,那么这在有些业务场景下就不满足需求了。比如订单系统,必须要保证订单ID是全局唯一的,也就是所有订单库、表中的ID不能相同,不能用2个相同的订单ID代表不同的订单信息。

那么,我们有没有办法来解决这个问题,那肯定是有的,我想所有的方案不外乎这3种,第一,直接使用MySQL来解决,第二,依赖其他中间件生成唯一 ID(比如 redis),第三,业务中使用算法实现唯一ID。我们接下来分别来阐述这3种不同的解决方案,及其对应的优缺点。

第一,直接使用MySQL来解决。当生成的ID用于插入MySQL表中时,也可以采用这种办法,但是这种方式会时后期的MySQL维护带来更多的麻烦。就是针对每个表使用不同的步长,步长为所有表的个数。比如, table_1 中数据 ID 分别为 1,4,7;table_2 中数据 ID 分别为 2,5,8;table_3 中数据 ID 分别为 3,6,9。如果我们使用单实例自己维护这个自增步长还是很麻烦的,不管是通过代码,还是MySQL server 配置,这都将给后续的扩容制造了不少的麻烦。不过,现在分布式MySQL集群中就是使用了类似这样的策略,集群内部已经对这个自增步长进行了维护,减少了开发、运维同学维护的复杂度。

第二,依赖其他中间件生成唯一 ID。Redis 提供了高性能的 INCR 方法,可以对一个数字进行自增。为了支持扩展性,我们可以把自增的 key 分为 900 个,ID 前3位来标识 key 编号,气候跟对应 key 的自增值。比如,100100,101100,102100,102101。如果单个 Redis 节点无法承载压力,可以把这些 key 分布在不同的 Redis 节点上,性能方面应该还是可以的。

第三,业务中使用算法实现唯一ID。其中具有代表性的有 twitter 的开源实现 snowflake (https://github.com/twitter-archive/snowflake)。把ID 分为这么几段:时间戳 + IDC_id + server_id + incr 。为了在bigint范围内表示更多的数,把时间戳的起始时间设定为2018.01.01 日即可,时间戳可以是毫秒级,也可以是秒级,可以根据并发量来评估。IDC_id / server_id 组合来表示每个 server 都有唯一的 id。incr 是每个秒(或者毫秒,依赖于第一个时间戳字段的定义)内每个 server 自身维护的一个从 0 开始的自增数。

为了提升服务性能,我们要尽可能把ID字段设计为整型,这样无论是查询计算,还是存储都是非常高效的。前两种方法,没有太多位数的占位符,所以完全可以用 bigint 类型来表示ID,以满足各种业务的数据扩张。但是第三种有好几个固定位数的占位符,那么可表示的数字虽然很多,但是由于每个 ID都有一个时间戳的占位,那么也就是说在bigint范围内,可以预见的这个 ID生成器的最大可用时间。这个我们需要心理有底,不然业务跑2年,发现ID已经到达bigint的最大值了。

那么,我们来预估下这种 ID 到底可用时间是多久?bigint 是64位,时间戳使用毫秒级。incr 占 12 位(可表示4096),IDC_id / server_id 2个共占 10位(可表示1024)。这样每个 server 每秒可生成的最大ID数为 4096*1000=400万+,即使是现在高性能的服务器,这个量级也是非常充裕的了。那么每秒所有server可生成的最大ID数为 4096*1000*1024=41亿,这个QPS完全可以满足当今最大的互联网公司业务需求了。那么时间戳占位为:64-12-10=42,可表示的最大值为 2 的 42 次方,也就是 4.3x10的12次方,转换为毫秒单位即可表示100年,对于一个系统来说足够用,如果100年之后不适用,那再重构吧。

使用到全局唯一 ID 的场景可能有这么两种,其一是插入 MySQL 表时使用,相当于数据的唯一标识;其二是其他场景,比如某个服务的全局版本号等等,这种场景通常只需要保证ID唯一即可,不需要特别关注后续查询时可能存在的问题。前面已经介绍了我们的 ID 生成算法具有非常好的性能,与其对应的MySQL 数据也可能进行了分库分表以提升服务整体性能。这时,我们根据ID 查找数据,就需要根据 ID 知道这个 ID 对于的信息存放在哪个库、哪个表。那么,插入表时,我们可以根据生成的ID取模把不同的数据分布到不同的表中,查询时使用相同的策略定位表。

但是,直接使用上面算法生成的ID取模会面临另外一个问题,比如:要获取一个用户的前10条订单信息。如果这些订单不在同一个表中,那么获取10条数据还需要执行10条SQL,那样效率就太低了。这也是我们常常在分库分表时面临的最大也是最严峻的挑战。这里不详细展开分库分表的细节,通常按照 UID 取模,然后进行分库分表,这样相同的 UID 对应的数据就在同一个表中。所以,我们生成的 ID 就要带有分库分表信息,这里我们假设使用UID分库分表,那么就又有2种可能的方法:1, 把库/表的序号拼接到之前生成的ID的前面;2, 把UID末几位(比如:6位)追加到之前生成的ID的后面。至于到底放前与放后,从逻辑上来说都行的通,只是库、表的序号属于元信息,拼接到最前面,看起来比较整齐。

那么,我们需要为分库分表信息留出一些位。之前ID生成规则(incr 占12位,IDC_id/server_id 10位,时间戳毫秒级)可以表示41亿的次的性能上限,这在多数的业务中是根本达不到这么大的,所以我们可以重新按照现在BAT的量级来估算。首先按秒计算,IDC_id/server_id 继续保持 10 位,incr 调整为 16 位,这样单个 server 每秒最大可以到 65535;所有的IDC/server可以到 65535*1024=67107840(6700万)。还剩余的位数:64 - 10 - 16 = 38,为了长期可用,我们还是要保证时间戳有效期为100年,那么需要为时间戳保留 32 位,最后剩余的位数是 38 - 32 = 6,可表示最大值为 64,即可分64个表,这个数有点小,不足够我们扩展。如果换为 35 年,最多可剩余 38 - 30 = 8,可表示最大值为 256,这个分表个数对于BAT的核心业务来说可能还是有点小的。

但是,到了这里,我们应该根据具体业务的重要性与可预估的增长量来评估,以上的算法是否可以满足实际场景。如果可以满足,那当然就更好了;如果满足不了,那么接着往下看。

看淘宝的订单ID,每个用户的所有订单ID,最后6个数字都是相同的,我估计那就是用户ID 的最后6位,并且用这6位来标识分库分表信息。6位十进制数字,即代表可以标识 100 万个表,这个扩展性是非常大的。那么上面说的第三种方式不能使用了,因为没有那么多位数可用。那么久回退到第二种,借助中间件(Redis等组件),为每个后缀分派一个key,然后分别进行递增。当然,在业务量足够大的场景,整个ID生成一定是一个基础服务,这个服务可以实现类似 Redis 这样的自增功能,并保证高性能、持久化的特性,这样就不需要依赖外部组件也是可以的。

总结:

1. 在MySQL集群模式下,现在多数情况还是使用设定不同的步长,或者设定不同的起始值,有集群自身维护,使用者不需要太多关心。在单实例MySQL架构下,需要DBA或者业务代码的支持,不利于后期维护,所以不建议使用。

2. 依赖外部组件生成ID,这个可以提供非常好的扩展性,但是由于依赖外部组件需要保证服务间的网络延迟要小。同时也根据分库分表的考虑,介绍了淘宝订单ID的生成。

3. 如果不想依赖外部组件,我们可以使用第三种方式,即基于时间戳来生成全局唯一ID。但是,由于时间戳占用了比较长的位,并且我们要加入分库分表标识,所以需要根据具体业务的情况来选择每个字段的占位数。

4. 如果不需要分库分表标识,那么就采用第三种方式比较合适了,因为这种方式是性能最高的,也是最可靠的。

5. 如果想使用第三种方式,但是还要加入比较长的分库分表标识,那就最后可以把ID标识为字符串类型。对于公司的核心业务建议不要这样做,因为这些ID会在很多的业务场景中使用,对空间与计算性能来说都需要更多的资源。



28
Dec 2018
AUTHOR WiFeng
CATEGORY Web
COMMENTS No Comments

添加新评论 »

   点击刷新验证码