分表分库之一:分布式数据库的常见用法
尽量减少事务边界
采用分库分表的方式将业务数据拆分后,如果每条SQL语句中都能带有分库分表键,分布式服务层对于SQL解析后都能精准地将这条SQL语句推送到该数据所在的数据库上执行,数据库将执行的结果再返回给分布式服务层,分布式服务层再将结果返回给应用,整个数据库访问的过程跟之前的单机数据库操作没有任何差别。这个是在数据进行了分库分表拆分后,SQL语句执行效率最高的方式。
但不是所有的业务场景在进行数据库访问时每次都能带分库分表键的。例如:
这个例子就出现了我们所说的全表扫描。
“事务边界”:的定义,所谓的事务边界即是指单个SQL语句在后端数据库上同时执行的数量。即一条SQL语句同时被推送到后端所有数据库中运行。事务边界的数量越大,会给系统带来以下弊端:
- 系统的锁冲突概率越高。
- 系统越难以扩展。如果每一次的SQL请求都需要全表扫描执行,你会发现整个平台的数据库连接数量是取决于后端单个数据库的连接能力,也就意味着整个数据库的能力是无法通过增加后端数据库实例来扩展的。
- 整体性能越低。
异构索引表尽量降低全表扫描频率
“异构索引表”:采用异步机制将原表内的每一次创建或更新,都换另一个维度保存一份完整的数据表或索引表。本质上是“拿空间换时间”。
在有了订单索引表后,查询分成2次查询如下图:
实现对数据的异步索引创建有多种实现方式:
一种:是从数据库层采用数据复制的方式实现;
另一种:如下图在应用层实现,在这一层实现异构索引数据的创建,就必然会带来分布式事务的问题。可以认为是一个MySQL的数据触发器+分发管道。数据从源数据库想目标数据库的过程中,可能需要些过滤和转换。
一、垂直分表
垂直分表在日常开发和设计中比较常见,通俗的说法叫做“大表拆小表”,拆分是基于关系型数据库中的“列”(字段)进行的。通常情况,某个表中的字段比较多,可以新建立一张“扩展表”,将不经常使用或者长度较大的字段拆分出去放到“扩展表”中。
1.1、时间结构
1.1.1、平板式
常见用年份、月份、日等来分表,如年表、月表、日表等。类似如下:
article_200901
article_200902
article_200903
用年来分还是用月可自定,但用日期的话表就太多了,也没这必要。一般建议是按月分就可以。
这种分法,其难处在于,假设我要列20条数据,结果这三张表里都有2条,那么业务上很有可能要求读三次表。如果时间长了,有几十张表,而每张表是0条,那不就是要读完整个系统的表才行么?另外这个结构,要作分页是比较难实现的。
主键:在这个系统中,主键是13位带毫秒的时间戳,不要用自动编号,否则难以通过主键定位到表,也可以在查询时带上时间,但比较烦琐。
1.1.2、 归档式
该种分表方式常用于存储的数据只关注最近一段时间的内的,对历史数据查询很少或者说基本不被使用。为了让当前数据库的性能提高将历史数据移动到历史表中,一方面作为归档二方面提高性能。类似:
article_old
article_new
为了解决平板式的缺点,可以采用时间归档式设计,可以看到这个系统只有两张表。一张是旧文章表,一张是新文章表,新文章表放2个月的信息,每天定期把2个月中的最早一天的文章归入旧表中。这样一方面可以解决性能问题,因为一般新闻发布系统读取的都是新的内容,旧的内容读取少;第二可以委婉地解决功能问题,比如平板式所说的问题,在归档式中最多也只需要读2张表就完成了。
归档式的缺点在于旧表容量还是相对比较大,如果业务允许,可对旧表中的超旧内容进行再归档或直接清理掉。
1.2、版块结构
如果按照文章的所属版块进行拆表,比如新闻、体育版块拆表,一方面可以使每个表数据量分离,另一方面是各版块之间相互影响可降到最低。假如新闻版块的数据表损坏或需要维护,并不会影响到体育版块的正常工作,从而降低了风险。版块结构同时常用于bbs这样的系统。
板块结构也有几种分法:
1.2.1、 对应式
对于版块数量不多,而且较为固定的形式,就直接对应就好。比如新闻版块,可以分出新闻的目录表,新闻的文章表等。
news_category
news_article
sports_category
sports_article
可看到每一个版块都对应着一组相同的表结构,好处就是一目了然。在功能上,因为版块之间还是有一些隔阂,所以需要联合查询的需求不多,开发上比时间结构的方式要轻松。
主键:依旧要考虑的,在这个系统中,主键是版块+时间戳,单纯的时间戳或自动编号也能用,查询时要记得带上版块用于定位表。
1.2.2、冷热式
对应式的缺点是,如果版块数量很大而且不确定,那要分出的表数量就太多了。举个例子:百度贴吧,如果按一个词条一个表设计,那得有多少张表呢?
用这样的方式吧。
tieba_汽车
tieba_飞机
tieba_火箭
tieba__unite
这个表汽车、火箭表是属于热门表,定义为新建的版块放在unite表里面,待到其超过一万张主贴的时候才开对应表结构。因为在贴吧这种系统中,冷门版块肯定比热门版块多得多,这些冷门版块通常只有几张帖子,为它们开表也太浪费了;同时热门版块数量和访问量等,又比冷门版块多得多,非常有特点。
unite表还可以扩展成哈希表,利用词条的md5编码,可以分成n张表,我算了一下,md5前一位可分36张表,两位即是1296张表,足够了。
tieba_unite_ab
tieba_unite_ac
…
1.3、哈希结构
哈希结构通常用于博客之类的基于用户的场合,在博客这样的系统里有几个特点,1是用户数量非常多,2是每个用户发的文章数量都较少,3是用户发文章不定期,4是每个用户发得不多,但总量仍非常之大。基于这些特点,用以上所说的任何一种分表方式都不合适,一没有固定的时效不宜用时间拆,二用户很多,而且还偏偏都是冷门,所以也不宜用版块(用户)拆。
哈希结构在上面有所提及,既然按每个用户不好直接拆,那就把一群用户归进一个表好了。
blog_aa
blog_ab
blog_ac
…
如上所说,md5取前两位哈希可以达到1296张表,如果觉得不够,那就再加一位,总数可达46656张表,还不够?
表的数量太多,要创建这些表也是挺麻烦的,可以考虑在程序里往数据库insert之前,多执行一句判断表存在与否并创建表的语句,很实用,消耗也并不很大。
主键:依旧要考虑的,在这个系统中,主键是用户ID+时间戳,单纯的时间戳或自动编号也能用,但查询时要记得带上用户名用于定位表。
1.4、mysql提高的4种分区表方式
mysql分表见《mysql分区表之二:MySQL的表的四种分区类型介绍》
二、垂直分库
垂直分库在“微服务”盛行的今天已经非常普及了。基本的思路就是按照业务模块来划分出不同的数据库,而不是像早期一样将所有的数据表都放到同一个数据库中。如下图:
在高并发场景下,垂直分库一定程度上能够突破IO、连接数及单机硬件资源的瓶颈,是大型分布式系统中优化数据库架构的重要手段。
三、水平分表
水平分表也称为横向分表,比较容易理解,就是将表中不同的数据行按照一定规律分布到不同的数据库表中(这些表保存在同一个数据库中),这样来降低单表数据量,优化查询性能。最常见的方式就是通过主键或者时间等字段进行Hash和取模后拆分。如下图所示:
水平分表,能够降低单表的数据量,一定程度上可以缓解查询性能瓶颈。但本质上这些表还保存在同一个库中,所以库级别还是会有IO瓶颈。所以,一般不建议采用这种做法。
四、水平分库分表
水平分库分表与上面讲到的水平分表的思想相同,唯一不同的就是将这些拆分出来的表保存在不同的数据中。这也是很多大型互联网公司所选择的做法。如下图:
某种意义上来讲,有些系统中使用的“冷热数据分离”(将一些使用较少的历史数据迁移到其他的数据库中。而在业务功能上,通常默认只提供热点数据的查询),也是类似的实践。
在高并发和海量数据的场景下,分库分表能够有效缓解单机和单库的性能瓶颈和压力,突破IO、连接数、硬件资源的瓶颈。当然,投入的硬件成本也会更高。同时,这也会带来一些复杂的技术问题和挑战(例如:跨分片的复杂查询,跨分片事务等)。
4.1、分表策略(路由)
一旦进行分片,就会面临“数据路由”的问题,来了一个请求,要将请求路由到对应的数据库分片上。互联网常用的数据路由方法有三种:
(1)一个是按照数据范围路由,比如有两个分片,一个范围是0-1亿,一个范围是1亿-2亿,这样来路由。
这个方式的优点是非常的简单,并且扩展性好,假如两个分片不够了,增加一个2亿-3亿的分片即可。
这个方式的缺点是:虽然数据的分布是均衡的,每一个库的数据量差不多,但请求的负载会不均衡。例如有一些业务场景,新注册的用户活跃度更高,大范围的分片请求负载会更高。
(2)二个是按照取模路由,比如有两个分片,数据模2寻库即可。
这个方式的优点是路由方式很简单,数据分布也是均衡的,请求负载也是均衡的。
这个方式的缺点是如果两个分片数据量过大,要变成三个分片,数据迁移会比较麻烦,即扩展性会受限。
(3)三个是按照一致性hash算法路由,如根据id值计算其一致性hash的结果,落在哪个分区上。
这个方式的优点是比较(2)的有点在扩容时,数据迁移的量相对就少。。
这个方式的缺点是如果两个分片数据量过大,要变成三个分片,数据迁移会比较麻烦,即扩展性会受限。
(4)四个是路由服务。前面三个数据路由方法均有一个缺点,业务线需要耦合路由规则,如果路由规则发生变化,业务线是需要配合升级的。路由服务可以实现业务线与路由规则的解耦,业务线每次访问数据库之前先调用路由服务,来知道数据究竟存放在哪个分库上。
五、垂直分库带来的问题和解决思路
5.1、跨库join的问题
在拆分之前,系统中很多列表和详情页所需的数据是可以通过sql join来完成的。而拆分后,数据库可能是分布式在不同实例和不同的主机上,join将变得非常麻烦。而且基于架构规范,性能,安全性等方面考虑,一般是禁止跨库join的。那该怎么办呢?
首先要考虑下垂直分库的设计问题,如果可以调整,那就优先调整。如果无法调整的情况,下面笔者将结合以往的实际经验,总结几种常见的解决思路,并分析其适用场景。
跨库Join的几种解决思路
1、全局表
所谓全局表,就是有可能系统中所有模块都可能会依赖到的一些表。比较类似我们理解的“数据字典”。为了避免跨库join查询,我们可以将这类表在其他每个数据库中均保存一份。同时,这类数据通常也很少发生修改(甚至几乎不会),所以也不用太担心“一致性”问题。
2、字段冗余
这是一种典型的反范式设计,在互联网行业中比较常见,通常是为了性能来避免join查询。
举个电商业务中很简单的场景:
“订单表”中保存“卖家Id”的同时,将卖家的“Name”字段也冗余,这样查询订单详情的时候就不需要再去查询“卖家用户表”。
字段冗余能带来便利,是一种“空间换时间”的体现。但其适用场景也比较有限,比较适合依赖字段较少的情况。最复杂的还是数据一致性问题,这点很难保证,可以借助数据库中的触发器或者在业务代码层面去保证。
当然,也需要结合实际业务场景来看一致性的要求。就像上面例子,如果卖家修改了Name之后,是否需要在订单信息中同步更新呢?
3、数据同步
定时A库中的tab_a表和B库中tbl_b有关联,可以定时将指定的表做同步。当然,同步本来会对数据库带来一定的影响,需要性能影响和数据时效性中取得一个平衡。这样来避免复杂的跨库查询。笔者曾经在项目中是通过ETL工具来实施的。
4、系统层组装
在系统层面,通过调用不同模块的组件或者服务,获取到数据并进行字段拼装。说起来很容易,但实践起来可真没有这么简单,尤其是数据库设计上存在问题但又无法轻易调整的时候。
具体情况通常会比较复杂。下面笔者结合以往实际经验,并通过伪代码方式来描述。
4.1、简单的列表查询的情况(每个表(库),一次查询,在应用层组合)
伪代码很容易理解,先获取“我的提问列表”数据,然后再根据列表中的UserId去循环调用依赖的用户服务获取到用户的RealName,拼装结果并返回。
有经验的读者一眼就能看出上诉伪代码存在效率问题。循环调用服务,可能会有循环RPC,循环查询数据库…不推荐使用。再看看改进后的:
这种实现方式,看起来要优雅一点,其实就是把循环调用改成一次调用。当然,用户服务的数据库查询中很可能是In查询,效率方面比上一种方式更高。(坊间流传In查询会全表扫描,存在性能问题,传闻不可全信。其实查询优化器都是基本成本估算的,经过测试,在In语句中条件字段有索引的时候,条件较少的情况是会走索引的。这里不细展开说明,感兴趣的朋友请自行测试)。
4.2、系统层组装小结
简单字段组装的情况下,我们只需要先获取“主表”数据,然后再根据关联关系,调用其他模块的组件或服务来获取依赖的其他字段(如例中依赖的用户信息),最后将数据进行组装。
通常,我们都会通过缓存来避免频繁RPC通信和数据库查询的开销。
列表查询带条件过滤的情况
在上述例子中,都是简单的字段组装,而不存在条件过滤。看拆分前的SQL:
这种连接查询并且还带条件过滤的情况,想在代码层面组装数据其实是非常复杂的(尤其是左表和右表都带条件过滤的情况会更复杂),不能像之前例子中那样简单的进行组装了。试想一下,如果像上面那样简单的进行组装,造成的结果就是返回的数据不完整,不准确。
有如下几种解决思路:
-
查出所有的问答数据,然后调用用户服务进行拼装数据,再根据过滤字段state字段进行过滤,最后进行排序和分页并返回。
这种方式能够保证数据的准确性和完整性,但是性能影响非常大,不建议使用。
-
查询出state字段符合/不符合的UserId,在查询问答数据的时候使用in/not in进行过滤,排序,分页等。过滤出有效的问答数据后,再调用用户服务获取数据进行组装。
这种方式明显更优雅点。笔者之前在某个项目的特殊场景中就是采用过这种方式实现。
5.2、跨库事务(分布式事务)的问题
按业务拆分数据库之后,不可避免的就是“分布式事务”的问题。以往在代码中通过spring注解简单配置就能实现事务的,现在则需要花很大的成本去保证一致性。
见《分布式事务之一:整体介绍》
5.3、扩展性架构实践
数据库的扩展要结合分片策略(路由)走的,
5.3.1、如取模算法
原来数据库水平切分成N个库,现在要扩容成2N个库,要解决这个问题。就是
第二个步骤修改配置,此时扩容完成,原来是4个分片,修改配置后变成8个分片,这个过程没有数据的迁移。原来偶数的那一部分现在变成了两个部分,一部分是0,一部分是2,奇数的部分现在变成1和3。0库和2库没有数据冲突,只是扩容之后在短时间内双主的可用性这个特性丢失掉了。
第三个步骤还要做一些收尾操作:把旧的双主给解除掉,为了保证可用性增加新的双主同步,原来拥有全部的数据,现在只为一半的数据提供服务了,我们把多余的数据删除掉,结尾这三个步骤可以事后慢慢操作。整个扩容在过程在第二步提升从库,修改配置其实就秒级完成了,非常的帅气。
这个方案的缺点是只能实现N库到2N 库的扩容,2变4、4变8,不能实现2库变3库,2库变5库的扩容,如何能够实现这种扩容呢?
数据库扩展性方面有很多的需求,例如刚才说的2库扩3库,2库扩5库。产品经理经常变化需求,扩充表的属性也是经常的事情,今年的数据库大会同行也介绍了一些使用触发器来做online schema change的方案,但是触发器的局限性在于:
第一、触发器对数据库性能的影响比较大;
第二、触发器只能在同一个库上才有效,而互联网的场景特点是数据量非常大,并发量非常大,库都分布在不同的物理机器上,触发器没法弄。
最后还有一类扩展性需求,底层存储介质发生变化,原来是mongodb存储,现在要变为mysql存储,这也是扩展性需求(虽然很少),这三类需求怎么扩展?
5.3.2、其它算法分片
方法是导库,迁移数据,迁移数据有几种做法,第一种停服务,如果大家的业务能够接受这种方法,强烈建议使用这种方法,例如有一些游戏公司,晚上一点到两点服务器维护,可能就是在干分区或者合区这类导库的事情。
如果业务上不允许停服务,想做到平滑迁移,双写法可以解决这类问题。
(1)双写法迁移数据的第一步是升级服务,原来的服务是写一个库,现在建立新的数据库,双写。比如底层存储介质的变化,我们原来是mongo数据库,现在建立好新的mysql数据库,然后对服务的所有写接口进行双库写升级。
(2)第二步写一个小程序去进行数据的迁移。比如写一个离线的程序,把两个库的数据重新分片,分到三个库里。也可能是把一个只有三个属性的用户表导到五个属性的数据表里面。这个数据迁移要限速,导完之后两个库的数据一致吗?只要提前双写,如果没有什么意外,两边的数据应该是一致的。
什么时候会有意外呢?在导某一条数据的过程当中正好发生了一个删除操作,这个数据刚被服务双写删除,又被迁移数据的程序插入到了新库中,这种非常极限的情况下会造成两边的数据不一致。
(3)建议第三步再开发一个小脚本,对两边的数据进行比对,如果发现了不一致,就将数据修复。当修复完成之后,我们认为数据是一致的,再将双写又变成单写,数据完成迁移。
这个方式的优点:
第一、改动是非常小的,对服务的影响比较小,单写变双写,开发两个小工具,一个是迁移程序,从一个库读数据,另外一个库插进去;还有一个数据校验程序,两个数据进行比对,改动是比较小的。
第二、随时可回滚的,方案风险比较小,在任何一个步骤如果发现问题,可以随时停止操作。比如迁移数据的过程当中发现不对,就把新的数据库干掉,重新再迁。因为在切换之前,所有线上的读服务和写服务都是旧库提供,只有切了以后,才是新库提供的服务。这是我们非常帅气的一个平滑导库的方式。