分布式数据库


高性能数据库集群的设计方式有很多种,其中比较重要的有两种方式,一种是读写分离,其本质是将访问压力分散到集群中的过个节点上,但是存储压力并没有分散;第二种方式是分库分表,用来分散从存储,降低单机存储压力。

读写分离

读写分离适用于读多写少的业务场景,其基本原理是将数据库读写操作分散到不同的节点上,常用的策略有“主从”,“主主”

读写分离的基本操作为

  1. 数据库服务器建立主从集群
  2. 数据库master负责读写操作,slave负责读操作
  3. 数据库master通过复制将数据同步到slave,每台数据库内容相同
  4. 业务服务器将写操作发给master,读操作发到slave上

上述过程并不难理解,但是这里面的关键问题是,主从数据如何保持一致性。即当有写出操作发生时,master如何将数据同步给slave。以 MySQL 为例,主从复制延迟可能达到 1 秒,如果有大量数据同步,延迟 1 分钟也是有可能的。主从复制延迟会带来一个问题:如果业务服务器将数据写入到数据库主服务器后立刻(1 秒内)进行读取,此时读操作访问的是从机,主机还没有将数据复制过来,到从机读取数据是读不到最新数据的,业务上就可能出现问题。例如,用户刚注册完后立刻登录,业务服务器会提示他“你还没有注册”,而用户明明刚才已经注册成功了。

数据一致性

解决数据同步问题有几种常用的方法:

  1. 写操作完成后的读操作全部指向master 例如,注册账号完成后,登录时读取账号的读操作也发给数据库主服务器。显然这种方式对业务代码并不友好,程序要需要在代码中实现这段逻辑,和业务代码强绑定,难以维护。
  2. 读slave失败后转而去读master 这种方式也叫做“二次读取”,二次读取和业务无绑定,只需要对底层数据库访问的 API 进行封装即可,实现代价较小,不足之处在于如果有很多二次读取,将大大增加主机的读操作压力。例如,黑客暴力破解账号,会导致大量的二次读取操作,主机可能顶不住读操作的压力从而崩溃。
  3. 关键业务读写均指向master,非关键业务采用读写分离 这是比较常用的一种方式,重要的业务例如注册+登录全部由master完成,非重要业务及时数据短暂的不一致,也并不会对业务造成太大的影响。

实现方式

想要实现上述选择数据库读写的逻辑有很多种方法,简单做法可以在App Server和DB集群中间做一个适配层,适配层对业务层保持透明,业务代码可以像调用DB一样调用适配层接口,适配层和DB集群的通信则被封装在自己内部,这样可以将业务逻辑和DB调用的逻辑隔离开,DB的变化(比如主从切换)对业务无感知,只需在适配层修改即可,如下图所示:

适配层的实现可以采用代码形式,比如封装一个jar包提供各业务方调用;也可以独立出一套中间件系统出来,做数据库连接池。前者比较轻量,后者的工作量会很大,复杂度也会比前者高出一个数量级。一般情况下建议采用程序语言封装的方式,或者使用成熟的开源数据库中间件。如果是大公司,可以投入人力去实现数据库中间件,因为这个系统一旦做好,接入的业务系统越多,节省的程序开发投入就越多,价值也越大。

分库分表

读写分离分散了数据库请求的压力,但是没有分散存储压力,当数据量达到千万甚至上亿条的时候,单台数据库服务器的存储能力会成为系统的瓶颈,主要体现在这几个方面:

  1. 数据量太大,读写的性能会下降,即使有索引,索引也会变得很大,性能同样会下降。
  2. 数据文件会变得很大,数据库备份和恢复需要耗费很长时间。
  3. 数据文件越大,极端情况下丢失数据的风险越高(例如,机房火灾导致数据库主备机都发生故障)。

基于上述原因,单个数据库的存储数据量不能太大,需要控制在一定范围内,当数据量持续增加时,需要考虑将数据分散存储在多台DB服务器上。分散存储的方法主要有两个,一个是分库,一个是分表

分库 (Partition)

分库(Partition)是指是指按照业务模块将数据分散到不同的数据库服务器上,例如下面一台DB中同时存放了用户,订单,评论三个业务模块的数据

我们可以将用户,订单,评论三个模块的数据分开放到三台不同的数据库服务器上,这样单台服务器的存储压力就会大幅下降。但与此同时,分库也带来了更多的问题:

  1. JOIN操作问题

    业务分库后,原本在同一个库中的表分散到不同数据库中,导致无法进行JOIN查询,例如下面SQL语句

     <!-- Join customer table with payment table -->
     SELECT 
     customer.customer_id, first_name,last_name,email,
     amount,payment_date
     FROM customer
     INNER JOIN payment ON payment.customer_id = customer.customer_id;
     WHERE last_name='Patricia';
    

    上面查询语句中用来查询某个用户的交易记录,first_name,last_name,email来自customer表,amountpayment_date来自payment表,两个表之间通过customer_id进行关联。现在由于分库的原因,使这两张表分别位于不同的数据库中,无法做JOIN查询,因此只能现在用户表中将Patricia的记录查出来,得到其customer_id,然后再到payment表中将该customer_id的记录查出来。

  2. 事物问题

    分库带来的另一个问题是事物的原子性问题,业务分库后,表分散到不同的数据库中,无法通过事务统一修改。虽然数据库厂商提供了一些分布式事务的解决方案(例如,MySQL 的 XA),但性能实在太低,与高性能存储的目标是相违背的。例如,用户下订单时需要扣除商品库存,如果订单数据和商品数据在同一个库中,则可以保证下单操作的原子性,库存状态和订单状态会保持一致,要么都成功要么都失败,而分库之后则无法保证状态一致性了,需要业务代码来保证操作的原子性。例如,先扣商品库存,扣成功后生成订单,如果因为订单数据库异常导致生成订单失败,业务程序又需要将商品库存加上;而如果因为业务程序自己异常导致生成订单失败,则商品库存就无法恢复了,需要人工通过日志等方式来手工修复库存异常

分表 (Sharding)

分表(Sharding)用来解决单表数据量过大的问题,比如淘宝几亿商品如果只存在一张表里,肯定是无法满足性能要求的,此时就要对单表数据进行拆分。单表拆分的方式有两种,一种是垂直分表,另一种是水平分表。

  • 所谓垂直切分,是指切分后的两个表,他们记录数相同但包含不同的列。例如,上图中的垂直切分,会把表切分为两个表,一个表包含 ID、name、age、sex 列,另外一个表包含 ID、nickname、description 列
  • 所谓水平切分,是指切分后的两个表,他们记录数不同,但是均包含相同的列。例如,上图中的水平切分,会把表分为两个表,两个表都包含 ID、name、age、sex、nickname、description 列,但是一个表包含的是 ID 从 1 到 999999 的行数据,另一个表包含的是 ID 从 1000000 到 9999999 的行数据

当单表切分为多表后,新的表和旧的表可以在同一个库中,也可以分散到不同的库中,优先考虑不分库的策略,如果性能得不到提升,再考虑分库的设计。另外,上面这个示例比较简单,只考虑了一次切分的情况,实际架构设计过程中并不局限切分的次数,可以切两次,也可以切很多次,就像切蛋糕一样,可以切很多刀。

  1. 垂直分表

    垂直分表适合将表中不常用且占了大量空间的属性的列拆分出去。例如,前面图中的 nickname 和 description 字段,假设我们是一个交友网站,用户在筛选其他用户的时候,主要是用 age 和 sex 两个字段进行查询,而 nickname 和 description 两个字段主要用于展示,一般不会在业务查询中用到。description 本身又比较长,因此我们可以将这两个字段独立到另外一张表中,这样在查询 age 和 sex 时,就能带来一定的性能提升。

    垂直分表引入的复杂性主要体现在表操作的数量要增加。例如,原来只要一次查询就可以获取 name、age、sex、nickname、description,现在需要两次查询,一次查询获取 name、age、sex,另外一次查询获取 nickname、description。

  2. 水平分表

水平分表适合表行数特别大的表,有的公司要求单表行数超过 5000 万就必须进行分表,这个数字可以作为参考,但并不是绝对标准,关键还是要看表的访问性能。对于一些比较复杂的表,可能超过 1000 万就要分表了;而对于一些简单的表,即使存储数据超过 1 亿行,也可以不分表。但不管怎样,当看到表的数据量达到千万级别时,就需要开始考虑分表了,因为这很可能是架构的性能瓶颈或者隐患。

查询算法

水平分表后,当查询某条数据时,涉及到路由算法,即找到该条数据位于哪个库中,常用的路由算法有:

  1. 范围路由

    基于范围的路由选取有序的数据列(例如,整形、时间戳等)作为路由的条件,不同分段分散到不同的数据库表中。以最常见的用户ID为例,路由算法可以按照1000000的范围大小进行分段,1 ~999999 放到数据库1的表中,1000000 ~ 1999999 放到数据库2的表中,以此类推。

    范围路由设计的复杂点主要体现在分段大小的选取上,分段太小会导致切分后子表数量过多,增加维护复杂度;分段太大可能会导致单表依然存在性能问题,一般建议分段大小在100万2000万之间,具体需要根据业务选取合适的分段大小。范围路由的优点是可以随着数据的增加平滑地扩充新的表。例如,现在的用户是 100 万,如果增加到 1000 万,只需要增加新的表就可以了,原有的数据不需要动。

    范围路由的一个比较隐含的缺点是分布不均匀,假如按照 1000 万来进行分表,有可能某个分段实际存储的数据量只有 1000 条,而另外一个分段实际存储的数据量有 900 万条。

  2. Hash路由

    选取某个列(或者某几个列组合也可以)的值进行 Hash 运算,然后根据 Hash 结果分散到不同的数据库表中。同样以用户ID为例,假如我们一开始就规划了10个数据库表,路由算法可以简单地用user_id % 10 的值来表示数据所属的数据库表编号。例如,ID985的用户放到编号为5的子表中,ID10086的用户放到编号为6的字表中。Hash 路由设计的复杂点主要体现在初始表数量的选取上,表数量太多维护比较麻烦,表数量太少又可能导致单表性能存在问题。而用了 Hash 路由后,增加字表数量是非常麻烦的,所有数据都要重分布。

    Hash 路由的优缺点和范围路由基本相反,Hash 路由的优点是表分布比较均匀,缺点是扩充新的表很麻烦,所有数据都要重分布。

  3. 配置路由

    配置路由就是路由表,用一张独立的表来记录路由信息。同样以用户ID为例,我们新增一张user_router表,这个表包含user_idtable_id两列,根据user_id就可以查询对应的table_id。配置路由的缺点就是必须多查询一次,会影响整体性能;而且路由表本身如果太大(例如,几亿条数据),性能同样可能成为瓶颈,如果我们再次将路由表分库分表,则又面临一个死循环式的路由算法选择问题。

SQL操作

分库分表后,原先单表的SQL操作将不再管用,查询将会变的格外复杂,总的来说,分表后的SQL操作需要对各表的查询结果进行再拼装处理,才能得到新的结果,具体来说

  1. join操作

    水平分表后,数据分散在多个表中,如果需要与其他表进行join查询,需要在业务代码或者数据库中间件中进行多次join查询,然后将结果合并。

  2. count 操作

    水平分表后,虽然物理上数据分散到多个表中,但某些业务逻辑上还是会将这些表当作一个表来处理。例如,获取记录总数用于分页或者展示,水平分表前用一个count()就能完成的操作,在分表后就没那么简单了。常见的处理方式有下面两种:

    (1) count() 相加, 具体做法是在业务代码或者数据库中间件中对每个表进行count()操作,然后将结果相加。这种方式实现简单,缺点就是性能比较低。例如,水平分表后切分为 20 张表,则要进行20 次 count(*)操作,如果串行的话,可能需要几秒钟才能得到结果。

    (2) 记录数表, 具体做法是新建一张表,假如表名为“记录数表”,包含table_namerow_count 两个字段,每次插入或者删除子表数据成功后,都更新“记录数表”。这种方式获取表记录数的性能要大大优于 count()相加的方式,因为只需要一次简单查询就可以获取数据。缺点是复杂度增加不少,对子表的操作要同步操作“记录数表”,如果有一个业务逻辑遗漏了,数据就会不一致;且针对“记录数表”的操作和针对子表的操作无法放在同一事务中进行处理,异常的情况下会出现操作子表成功了而操作记录数表失败,同样会导致数据不一致。此外,记录数表的方式也增加了数据库的写压力,因为每次针对子表的insertdelete操作都要update记录数表,所以对于一些不要求记录数实时保持精确的业务,也可以通过后台定时更新记录数表。定时更新实际上就是count()相加”和“记录数表”的结合,即定时通过 count() 相加计算表的记录数,然后更新记录数表中的数据。

  3. order by 操作

    水平分表后,数据分散到多个子表中,排序操作无法在数据库中完成,只能由业务代码或者数据库中间件分别查询每个子表中的数据,然后汇总进行排序。

Resouce