分库分表的主键怎么设计?
MySQL的自增主键只能保证单表自增,分库分表后我们需要一个全局唯一ID作为主键,这样的全局唯一ID有几个值得关注的特性:
-
在数据量很大的时候,还能保持全局唯一
-
在QPS很高的时候,仍然能快速生成
-
在一定程度内满足递增的特性
-
可以结合一些具有业务语义的信息
UUID
UUID 是指 Universally Unique Identifier,翻译为中文是通用唯一识别码。UUID 是由一组32位数的16进制数字所构成,因此 UUID 理论上的总数为 1 6 32 = 2 128 16^{32}=2^{128} 1632=2128。举一个形象的例子,假如每秒消耗100万个UUID,需要100亿年才会用完,显然这足够用了。
UUID的标准形式为32个16进制数字,以连字号分为五段,形式为8-4-4-4-12的32个字符,如:123e4567-e89b-12d3-a456-426655440000。到目前为止业界一共有5种方式生成UUID:
-
基于时间的UUID:通过当前时间戳、随机数和机器MAC地址计算得到。由于在算法中使用了MAC地址,这个版本的UUID可以保证在全球范围的唯一性。但与此同时,使用MAC地址会带来安全性问题。
-
DCE安全的UUID:DCE(Distributed Computing Environment)安全的UUID和基于时间的UUID算法类似,只不过会把时间戳的前4位置换为POSIX的UID或GID,此版本的UUID在实际中应用较少。
-
基于名字的UUID(MD5):使用的是 md5 进行 hash 算法。这个版本的UUID保证了相同名字空间中不同名字生成的UUID的唯一性;不同名字空间中的UUID的唯一性;相同名字空间中相同名字的UUID重复生成是相同的。
-
随机UUID:根据随机数,或者伪随机数生成UUID。这种UUID产生重复的概率是可以计算出来的。
-
基于名字的UUID(SHA1):使用的是 SHA1 进行 hash 算法。
因为时间戳和随机数的唯一性,版本1、2、4总是生成唯一的标识符。若希望对给定的一个字符串总是能生成相同的 UUID,使用版本3或版本5。
-
优点:简单,本地生成,没有网络消耗
-
缺点:
-
太长。16字节128位,通常以36长度的字符串表示,很多场景不适用。
-
不是递增/有序的,这导致页分裂的概率增加,一般来说,我们倾向于在数据库中使用自增主键,因为这样可以迫使数据库的树朝着一个方向增长,而不会造成中间叶节点分裂,这样插入性能最好。另外,有序性在需要用到分库分表主键进行范围查询的时候也能提高效率。
-
数据库生成
基于单表的自增主键
所有主键ID都从一个单表里取,这个单表就负责生成主键,显然这会存在单点风险,性能也不高,一旦挂了整个数据库就无法写入。如果引入主从复制保证可用性,那么主从不一致就有可能导致重复主键。
基于多表的自增主键
这种方案就是让各个分表仍然用MySQL的自增主键,只不过各个分表起始值不一样,步长=分表数。比如总共10个分表,那么各个分表起始值就是1,2,3…10,步长为10,则第一个表主键ID就是1,11,21,以此类推得到其他表的主键序列。
这个方案最大的问题就是可扩展性不足,当新增一张表时需要暂停写入,设置步长和新表的起始值。
批量发号器
第一种方案每次获取主键ID都要查库,这样数据库压力是很大的。其实在不需要严格单调递增的场景中,我们完全可以一次性批量获取主键ID,用完之后再去数据库获取新的号段。我们新建一张单表segment
,用table_name
来区分不同的表,max_id
表示该表当前分配出去的ID最大值,step
表示号段长度。我们利用中间代理服务器实现发号器的功能,每次中间代理服务器从segment
获取一批ID,然后应用服务从中间代理服务器获取下一个分库分表主键ID。如下图所示:
当t1号段用完时,会去加载另一个长度为step=100的号段,假设另外两台号段都没有更新,这个时候第一台机器新加载的号段就应该是1001~1100。即
Begin
UPDATE segment SET max_id = max_id+step WHERE table_name = t1;
SELECT * FROM segment WHERE table_name = t1;
Commit
-
优点:
-
代理服务器易于扩展
-
代理服务器有一定容灾性,当segment所在数据库挂了时,代理服务器缓存的号段仍然可以提供一段时间内的写入服务。另外我们可以采用主从复制的方案进一步提高segment的可用性。
-
-
缺点:
-
ID不够随机,容易看出发号数量的信息,泄露商业机密(如订单量级)
-
代理服务器拿下一个号段时可能因为网络波动而导致整个系统响应时间变长。不过这个可以通过提前读取下一个号段来优化,我们在用了当前号段50%左右的时候就可以预读下一个号段到代理服务器中。
-
雪花算法
雪花算法 (SnowFlake )算法,是 Twitter 开源的分布式 id 生成算法。因为有句话叫“自然界中并不存在两片完全一样的雪花”,所以雪花算法也表示生成的ID如雪花般独一无二。主要原理是:采用 64 位来表示一个 ID,其中 1bit 保留,中间41bit 表示时间戳,最后10bit 作为机器 ID,12bit 作为序列号。
我们可以根据需求自定义各个字段的含义和长度。比如10bit 机器ID可以分别表示1024台机器,如果我们对IDC划分有需求,还可以将10-bit分5-bit给IDC,分5-bit给工作机器。这样就可以表示32个IDC,每个IDC下可以有32台机器。41bit时间戳ID可以表示 ( 1 < < 41 ) / ( 1000 ∗ 3600 ∗ 24 ∗ 365 ) = 69 (1<<41)/(1000*3600*24*365)=69 (1<<41)/(1000∗3600∗24∗365)=69年的毫秒数,如果时间戳ID、序列号或者机器号不够用,那么增加对应段的长度即可。
在bit可以自定义的情况下,我们还可以加入业务定义bit段,比如分库分表键。假设我们采用买家 ID 来进行分库分表,我们可以加入第五段,存放买家ID。这种“主表内嵌分库分表键”的方案对于查询很友好。
-
优点:
-
毫秒数在高位,自增序列在低位,整个ID都是趋势递增的
-
不依赖数据库等第三方系统,生成性能高
-
可以根据业务特性自定义bit位
-
-
缺点:
-
机器数较大时,还是需要依赖zookeeper协调各个节点的ID生成
-
强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。
-
在低频场景下容易出现序列号几乎没有增长,这会导致数据只落到某一张表里面的情况。
- 为了解决这种问题,第一种方案是序列号部分不再从 0 开始增长,而是从一个随机数开始增长。还有一个策略就是序列号从上一时刻的序列号开始增长,达到上限就从 0 重新开始,这样要比随机数更可控一点,性能也会更好一点。
-
如何解决时钟回拨?
美团 Leaf 引入了zookeeper来解决这个问题,如下图所示:
服务启动时首先检查自己是否写过ZooKeeper leaf_forever节点:
- 若写过,则用自身系统时间与leaf_forever/{self}节点记录时间做比较,若小于leaf_forever/{self}时间则认为机器时间发生了大步长回拨,服务启动失败并报警。
- 若未写过,证明是新服务节点,直接创建持久节点leaf_forever/{self}并写入自身系统时间,接下来综合对比其余Leaf节点的系统时间来判断自身系统时间是否准确,具体做法是取leaf_temporary下的所有临时节点(所有运行中的Leaf-snowflake节点)的服务IP:Port,然后通过RPC请求得到所有节点的系统时间,计算sum(time)/nodeSize。
- 若abs( 系统时间-sum(time)/nodeSize ) < 阈值,认为当前系统时间准确,正常启动服务,同时写临时节点leaf_temporary/{self} 维持租约。
- 否则认为本机系统时间发生大步长偏移,启动失败并报警。
- 每隔一段时间(3s)上报自身系统时间写入leaf_forever/{self}。
简而言之,在校验本机时间与上次发ID时间、zk上所有节点的平均时间发现有异常时就报警。
如今互联网上各类文章满天飞,但是大部分要不是寥寥数语,让人过目即忘;要不是过多细枝末节又没有实操,让人不知所云。我将从个人学习和工作经历出发,给大家带来深入浅出的技术解析。我的文章力求简短精悍,尽量结合实战,以便大家在碎片时间即可充分吸收,后续还能学以致用。
欢迎大家关注我的微信公众号,所有文章第一时间更新~