之前了解了分布式存储的两个核心问题:数据冗余和数据分片,以及在传统关系型数据库中是如何解决的。
当我们面临高并发的查询数据请求时,可以使用主从读写分离的方式,部署多个从库分摊读压力;
当存储的数据量达到瓶颈时,我们可以将数据分片存储在多个节点上,降低单个存储节点的存储压力,此时我们的架构变成了下面的样子:
image.png
可以看到通过分库分表和主从读写分离的方式,解决了数据库的扩展性问题,但是数据库咋分库分表之后,我们在使用数据库存在很多限制,比如说查询的时候必须带着分区键;一些聚合类的查询比如count(),性能较差,需要考虑使用计数器等其他解决方案。
分库还有一个问题,就是主键的全局唯一性的问题,那么在分库分表后如何生成全局唯一的数据库主键?
数据库的主键要如何选择?
数据库中的每一条记录都需要一个唯一的标识,依据数据库的第二范式,数据库中每一个表中都需要有一个唯一的主键,其他数据元素和主键一一对应。
那么关于主键的选择就成为一个关键点一般来讲,有2种选择方式:
1.使用业务字段作为主键,比如对于用户表来说,可以使用手机号,email或者身份证号作为主键。
2.使用生成的唯一ID作为主键。
对于大部分场景来说,第一种场景并不适用,比如评论表就很难找到一个业务字段作为主键。而对于用户表来说,我们需要考虑的是作为主键的业务字段能否唯一标识一个人,一个人可以有多个email和手机号,一旦出现变更email或者手机号的情况,就需要变更所有引用的外键信息,所以不合适。
身份证号码确实是用户的唯一标识,但是由于它的隐私属性,并不是一个用户系统的必须属性,并且已有的身份证号码是会变更的,比如1999年时身份证号码就从15位变成18位。
因此,更倾向于使用生成的ID作为数据库的主键,唯一而且一旦生成就不会变更,可以随意引用。
在单库单表的场景下,我们可以使用数据库的自增字段作为ID,这样最简单,但是当数据库分库分表吼,使用自增字段就无法保证ID的全局唯一性了。建议搭建发号器服务来生成全局唯一的ID。
基于Snowflake算法搭建发号器
从历年所经历的项目中,主要使用的是变种的Snowflake算法来生成业务需要的ID的,本讲的重点就是运用它解决ID全局唯一性的问题。
既然提到全局唯一性,怎么不提UUID呢
UUID(Universally Unique Identifier,通用唯一标识码)不依赖于任何第三方系统,所以在性能和可用性上都比较好,一般会使用它生成Request ID来标记单次请求,但如果用它来作为数据库主键,会存在以下几个问题:
首先,生成的ID最好具有单调递增性,也就是有序的,二UUID不被这个特点。为什么要是有序的呢?因为在系统设计时,ID有可能成为排序的字段,举个例子。
比如要实现一套评论的系统时,你一般会设计两个表,一张评论表,存储评论的详细信息,其中有ID字段,评论的内容,评论人ID,评论内容ID等,以ID字段作为分区键;另一个是评论列表,存储着内容ID和评论ID的对应关系,以内容ID为分区键。
我们在获取内容的评论列表时,需要按照时间倒序排列,因为ID是时间上有序的,所以我们就可以按照评论ID倒叙排列。而如果评论ID不是在时间上有序的话,我们就需要在评论列表再存储一个多余的创建时间的列用作排序,假设内容ID、评论ID和时间都使用8字节存储,我们就要多出50%的存储时间字段,造成了存储空间上的浪费。
另一个原因在于ID有序会提升数据的写入内容。
MySQL InnoDB存储引擎使用B+树存储索引数据,而主键也是一种索引。索引数据在B+树种是有序排列的,如下图,途中的2,10,26都是记录的ID,也是索引数据。
image.png
这时,当插入的下一条记录的ID是递增的时候,比如30,数据库只需要把它追加到后面就好了。但是如果插入的数据是无需的,比如ID是13,那么数据库要查找13应该插入的位置,再挪动13后面的数据,造成了多余的数据移动的开销。
image.png
我们知道机械磁盘在完成随机的写时,需要先做“寻道”找到写入的位置,也就是让磁头找到对应的磁道,这个过程是非常耗时的,而顺序写就不需要寻道,会大大提升索引的写入性能。
UUID不能作为ID的另一个原因是它不具备业务含义,在现实中使用的ID都包含有一些有意义的数据。UUID是由32个16进制数字组成的字符串,如果作为数据库主键使用比较耗费空间。
而Twitter提出的Snowflake算法可以完全弥补UUID存在的不足,因为日它㠲算法简单容易实现,也满足ID所需要的全局唯一性,单调递增性,还包含一定的业务上的意义。
Snowflake的核心思想是将64bit的二进制数字分成若干部分,每一部分都存储有特定含义的数据,比如说时间戳、机器ID、序列号等,最终生成全局唯一的有序ID。它的标准算法是这样的:
image.png
从上面这图可以看到,41位的时间戳大概可以支撑pwo(2,41)/1000/60/60/24/365年,大约69年。
如果你的系统部署在多个机房,那额10位的机器ID可以继续划分为23位的IDC标示(可以支撑4个或者8个IDC机房)和78位的机器ID(支持128~256台机器);12位的序列号代表着每个节点每毫秒最多可以生成4096的ID。
不同公司也会根据自身业务的特点对snowflake算法做一些改造,比如说减少序列号的位数增加机器ID的位数以支持单IDC更多的机器,也可以在其中假如业务ID字段来区分不同的业务。比如:
选择这个组成规则,主要是因为我在单机房只部署一个发号器的节点,并且使用KeepAlive保证可用性。业务信息指的是项目中哪个业务模块使用,比如用户模块生成ID,内容模块生成的ID,把它加入进来,一是希望不同业务发出来的ID可以不同,二是因为在出现任何问题时可以反解ID,知道是哪一个业务发出来的ID。
那么了解了Snowflake算法的原理后,我们如何把它工程化生成全局唯一的ID呢?一般来说我们会有两种算法的实现方式:
一种是嵌入到业务代码里,也就是分布在业务服务器中,这种方案的好处是业务代码在使用的时候不需要跨网络调用,性能上会好一些,但是需要更多的机器ID位数来支持更多的业务服务器。另外由于业务服务器的数量很多,我们很难保证机器ID的唯一性,所以就需要引入ZooKeeper等分布式一致性组件来保证每次机器重启时都能获得唯一的机器ID。
另一个部署方式是作为独立的服务部署,也就是发号器服务。业务在使用发号器的时候就需要多一次的网络调用,但是内网的调用对于性能的损耗有限,却可以减少机器ID的位数,如果发号器以主备方式部署,同时运行的只有一个发号器,那么机器ID就可以省略,这样可以留更多的位数给最后的自增信息位。即使需要机器ID,因为发号器部署实例数有限,那么就可以把机器ID写在发号器的配置文件里,这样既可以保证机器ID的唯一性,也无需引入第三方组件了, 性能方面,但实例但cpu可以达到两万每秒。
snowflake算法最大的缺点是它依赖于系统的时间戳,一旦系统时间不准,就可能生成重复的ID。所以如果我们发现系统时钟不准,就可以让发号器暂时拒绝发号,直到时钟准确为止。
另外,如果请求发号器的QPS不高,比如说发号器每毫秒只发一个ID,就会造成生成ID的末位永远是1,那么在分库分表的时候如果使用ID作为分区键就会造成库表分配的不均匀。解决方法主要有两个:
1.时间戳不记录毫秒而是记录秒,这样在一个时间区间里可以多发出几个号,避免出现分库分表时数据库分配不均。
2.生成的序列号的起始号可做一下随机,这一秒是21,下一秒是30,这样就会尽量的均衡了。
上面对snowflake算法进行了一定的改造,要做到:1.让算法中的ID生成规则符合自己业务的特点;2.为了解决诸如时间回拨等问题。
网友评论