原来文章地址:https://www.toutiao.com/a6545626478447428103/?tt_from=weixin&utm_campaign=client_share&article_category=stock×tamp=1524029012&app=news_article&utm_source=weixin&iid=26214166927&utm_medium=toutiao_android&wxshare_count=1
作者介绍
杨彪,蚂蚁金服技术专家,《分布式服务架构:原理、设计与实战》和《可伸缩服务架构:框架与中间件》作者。近10年互联网和游戏行业工作经验,曾在酷我音乐盒、人人游戏和掌趣科技等上市公司担任核心研发职位,做过日活跃用户量达千万的项目,也做过多款月流水千万以上的游戏。
本文节选自《可伸缩服务架构:框架与中间件》一书,作者:李艳鹏、杨彪、李海亮、贾博岩、刘淏
这里介绍设计分库分表框架时应该考虑的设计要点,并给出相应的解决方案,为后面实现分库分表框架dbsplit提供理论支撑。
一、整体的切分方式
简单来说,数据的切分就是通过某种特定的条件,将我们存放在同一个数据库中的数据分散存放到多个数据库(主机)中,以达到分散单台设备负载的效果,即分库分表。
数据的切分根据其切分规则的类型,可以分为如下两种切分模式。
垂直(纵向)切分:把单一的表拆分成多个表,并分散到不同的数据库(主机)上。
水平(横向)切分:根据表中数据的逻辑关系,将同一个表中的数据按照某种条件拆分到多台数据库(主机)上。
一个数据库由多个表构成,每个表对应不同的业务,垂直切分是指按照业务将表进行分类,将其分布到不同的数据库上,这样就将数据分担到了不同的库上(专库专用)。
案例如下:
————–+————–+——————
用户信息(User)+ 交易记录(Pay)+ 商品(Commodity)|
————–+————–+——————
针对以上案例,垂直切分就是根据每个表的不同业务进行切分,比如User表、Pay表和Commodity表,将每个表切分到不同的数据库上。
垂直切分的优点如下:
拆分后业务清晰,拆分规则明确。
系统之间进行整合或扩展很容易。
按照成本、应用的等级、应用的类型等将表放到不同的机器上,便于管理。
便于实现动静分离、冷热分离的数据库表的设计模式。
数据维护简单。
垂直切分的缺点如下:
部分业务表无法关联(Join),只能通过接口方式解决,提高了系统的复杂度。
受每种业务的不同限制,存在单库性能瓶颈,不易进行数据扩展和提升性能。
事务处理复杂。
垂直切分除了用于分解单库单表的压力,也用于实现冷热分离,也就是根据数据的活跃度进行拆分,因为对拥有不同活跃度的数据的处理方式不同。
我们可将本来可以在同一个表中的内容人为地划分为多个表。所谓“本来”,是指按照关系型数据库第三范式的要求,应该在同一个表中,将其拆分开就叫作反范化(Denormalize)。
例如,对配置表的某些字段很少进行修改时,将其放到一个查询性能较高的数据库硬件上;对配置表的其他字段更新频繁时,则将其放到另一个更新性能较高的数据库硬件上。
这里我们再举一个例子:在微博系统的设计中,一个微博对象包括文章标题、作者、分类、创建时间等属性字段,这些字段的变化频率低,查询次数多,叫作冷数据。而博客的浏览量、回复数、点赞数等类似的统计信息,或者别的变化频率比较高的数据,叫作活跃数据或者热数据。
我们把冷热数据分开存放,就叫作冷热分离,在MySQL的数据库中,冷数据查询较多,更新较少,适合用MyISAM引擎,而热数据更新比较频繁,适合使用InnoDB存储引擎,这也是垂直拆分的一种。
我们推荐在设计数据库表结构时,就考虑垂直拆分,根据冷热分离、动静分离的原则,再根据使用的存储引擎的特点,对冷数据可以使用MyISAM,能更好地进行数据查询;对热数据可以使用InnoDB,有更快的更新速度,这样能够有效提升性能。
其次,对读多写少的冷数据可配置更多的从库来化解大量查询请求的压力;对于热数据,可以使用多个主库构建分库分表的结构,请参考下面关于水平切分的内容,后续的三四五章提供了不同的分库分表的具体实施方案。
注意,对于一些特殊的活跃数据或者热点数据,也可以考虑使用Memcache、Redis之类的缓存,等累计到一定的量后再更新数据库,例如,在记录微博点赞数量的业务中,点赞数量被存储在缓存中,每增加1000个点赞,才写一次数据。
与垂直切分对比,水平切分不是将表进行分类,而是将其按照某个字段的某种规则分散到多个库中,在每个表中包含一部分数据,所有表加起来就是全量的数据。
简单来说,我们可以将对数据的水平切分理解为按照数据行进行切分,就是将表中的某些行切分到一个数据库表中,而将其他行切分到其他数据库表中。
这种切分方式根据单表的数据量的规模来切分,保证单表的容量不会太大,从而保证了单表的查询等处理能力,例如将用户的信息表拆分成User1、User2等,表结构是完全一样的。我们通常根据某些特定的规则来划分表,比如根据用户的ID来取模划分。
例如,在博客系统中,当读取博客的量很大时,就应该采取水平切分来减少每个单表的压力,并提升性能。
以微博表为例,当同时有100万个用户在浏览时,如果是单表,则单表会进行100万次请求,假如是单库,数据库就会承受100万次的请求压力;假如将其分为100个表,并且分布在10个数据库中,每个表进行1万次请求,则每个数据库会承受10万次的请求压力,虽然这不可能绝对平均,但是可以说明问题,这样压力就减少了很多,并且是成倍减少的。
水平切分的优点如下:
单库单表的数据保持在一定的量级,有助于性能的提高。
切分的表的结构相同,应用层改造较少,只需要增加路由规则即可。
提高了系统的稳定性和负载能力。
水平切分的缺点如下:
切分后,数据是分散的,很难利用数据库的Join操作,跨库Join性能较差。
拆分规则难以抽象。
分片事务的一致性难以解决。
数据扩容的难度和维护量极大。
综上所述,垂直切分和水平切分的共同点如下:
存在分布式事务的问题。
存在跨节点Join的问题。
存在跨节点合并排序、分页的问题。
存在多数据源管理的问题。
在了解这两种切分方式的特点后,我们就可以根据自己的业务需求来选择,通常会同时使用这两种切分方式,垂直切分更偏向于业务拆分的过程,在技术上我们更关注水平切分的方案。
二、水平切分方式的路由过程和分片维度
这里讲解水平切分的路由过程和分片维度。
我们在设计表时需要确定对表按照什么样的规则进行分库分表。例如,当有新用户时,程序得确定将此用户的信息添加到哪个表中;同理,在登录时我们需要通过用户的账号找到数据库中对应的记录,所有这些都需要按照某一规则进行路由请求,因为请求所需要的数据分布在不同的分片表中。
针对输入的请求,通过分库分表规则查找到对应的表和库的过程叫作路由。例如,分库分表的规则是user_id % 4,当用户新注册了一个账号时,假设用户的ID是123,我们就可以通过123 % 4 = 3确定此账号应该被保存在User3表中。当ID为123的用户登录时,我们可通过123 % 4 = 3计算后,确定其被记录在User3中。
对数据切片有不同的切片维度,可以参考Mycat提供的切片方式(见本书3.4节),这里只介绍两种最常用的切片维度。
1)按照哈希切片
对数据的某个字段求哈希,再除以分片总数后取模,取模后相同的数据为一个分片,这样的将数据分成多个分片的方法叫作哈希分片。
按照哈希分片常常应用于数据没有时效性的情况,比如所有数据无论是在什么时间产生的,都需要进行处理或者查询,例如支付行业的客户要求可以对至少1年以内的交易进行查询和退款,那么1年以内的所有交易数据都必须停留在交易数据库中,否则就无法查询和退款。
如果这家公司在一年内能做10亿条交易,假设每个数据库分片能够容纳5000万条数据,则至少需要20个表才能容纳10亿条交易。在路由时,我们根据交易ID进行哈希取模来找到数据属于哪个分片,因此,在设计系统时要充分考虑如何设计数据库的分库分表的路由规则。
这种切片方式的好处是数据切片比较均匀,对数据压力分散的效果较好,缺点是数据分散后,对于查询需求需要进行聚合处理。
2)按照时间切片
与按照哈希切片不同,这种方式是按照时间的范围将数据分布到不同的分片上的,例如,我们可以将交易数据按照月进行切片,或者按照季度进行切片,由交易数据的多少来决定按照什么样的时间周期对数据进行切片。
这种切片方式适用于有明显时间特点的数据,例如,距离现在1个季度的数据访问频繁,距离现在两个季度的数据可能没有更新,距离现在3个季度的数据没有查询需求。
针对这种情况,可以通过按照时间进行切片,针对不同的访问频率使用不同档次的硬件资源来节省成本:假设距离现在1个季度的数据访问频率最高,我们就用更好的硬件来运行这个分片;假设距离现在3个季度的数据没有任何访问需求,我们就可以将其整体归档,以方便DBA操作。
在实际的生产实践中,按照哈希切片和按照时间切片都是常用的分库分表方式,并被广泛使用,有时可以结合使用这两种方式,例如:对交易数据先按照季度进行切片,然后对于某一季度的数据按照主键哈希进行切片。
三、分片后的事务处理机制
本节讲解分片后的事务处理机制。
由于我们将单表的数据切片后存储在多个数据库甚至多个数据库实例中,所以依靠数据库本身的事务机制不能满足所有场景的需要。
但是,我们推荐在一个数据库实例中的操作尽可能使用本地事务来保证一致性,跨数据库实例的一系列更新操作需要根据事务路由在不同的数据源中完成,各个数据源之间的更新操作需要通过分布式事务处理。
这里只介绍实现分布式操作一致性的几个主流思路,保证分布式事务一致性的具体方法请参考《分布式服务架构:原理、设计与实战》中第2章的内容。
主流的分布式事务解决方案有三种:两阶段提交协议、最大努力保证模式和事务补偿机制。
1)两阶段提交协议
两阶段提交协议将分布式事务分为两个阶段,一个是准备阶段,一个是提交阶段,两个阶段都由事务管理器发起。
基于两阶段提交协议,事务管理器能够最大限度地保证跨数据库操作的事务的原子性,是分布式系统环境下最严格的事务实现方法。符合J2EE规范的AppServer(例如:Websphere、Weblogic、Jboss等)对关系型数据库数据源和消息队列都实现了两阶段提交协议,只需在使用时配置即可。如图3-9所示。
关于分库分表,这有一套大而全的轻量级架构设计思路
图3-9
但是,两阶段提交协议也带来了性能方面的问题,难于进行水平伸缩,因为在提交事务的过程中,事务管理器需要和每个参与者进行准备和提交的操作的协调,在准备阶段锁定资源,在提交阶段消费资源。
但是由于参与者较多,锁定资源和消费资源之间的时间差被拉长,导致响应速度较慢,在此期间产生死锁或者不确定结果的可能性较大。因此,在互联网行业里,为了追求性能的提升,很少使用两阶段提交协议。
另外,由于两阶段提交协议是阻塞协议,在极端情况下不能快速响应请求方,因此有人提出了三阶段提交协议,解决了两阶段提交协议的阻塞问题,但仍然需要事务管理器在参与者之间协调,才能完成一个分布式事务。
2)最大努力保证模式
这是一种非常通用的保证分布式一致性的模式,很多开发人员一直在使用,但是并未意识到这是一种模式。最大努力保证模式适用于对一致性要求并不十分严格但是对性能要求较高的场景。
具体的实现方法是,在更新多个资源时,将多个资源的提交尽量延后到最后一刻处理,这样的话,如果业务流程出现问题,则所有的资源更新都可以回滚,事务仍然保持一致。
唯一可能出现问题的情况是在提交多个资源时发生了系统问题,比如网络问题等,但是这种情况是非常罕见的,一旦出现这种情况,就需要进行实时补偿,将已提交的事务进行回滚,这和我们常说的TCC模式有些类似。
下面是使用最大努力保证模式的一个样例,在该样例中涉及两个操作,一个是从消息队列消费消息,一个是更新数据库,需要保证分布式的一致性。
(1)开始消息事务。
(2)开始数据库事务。
(3)接收消息。
(4)更新数据库。
(5)提交数据库事务。
(6)提交消息事务。
这时,从第1步到第4步并不是很关键,关键的是第5步和第6步,需要将其放在最后一起提交,尽最大努力保证前面的业务处理的一致性。
到了第5步和第6步,业务逻辑处理完成,这时只可能发生系统错误,如果第5步失败,则可以将消息队列和数据库事务全部回滚,保持一致。如果第5步成功,第6步遇到了网络超时等问题,则这是唯一可能产生问题的情况。
在这种情况下,消息的消费过程并没有被提交到消息队列,消息队列可能会重新发送消息给其他消息处理服务,这会导致消息被重复消费,但是可以通过幂等处理来保证消除重复消息带来的影响。
当然,在使用这种模式时,我们要充分考虑每个资源的提交顺序。我们在生产实践中遇到的一种反模式,就是在数据库事务中嵌套远程调用,而且远程调用是耗时任务,导致数据库事务被拉长,最后拖垮数据库。
因此,上面的案例涉及的是消息事务嵌套数据库事务,在这里必须进行充分评估和设计,才可以规避事务风险。
3)事务补偿机制
显然,在对性能要求很高的场景中,两阶段提交协议并不是一种好方案,最大努力保证模式也会使多个分布式操作互相嵌套,有可能互相影响。这里,我们给出事务补偿机制,其性能很高,并且能够尽最大可能地保证事务的最终一致性。
在数据库分库分表后,如果涉及的多个更新操作在某一个数据库范围内完成,则可以使用数据库内的本地事务保证一致性;对于跨库的多个操作,可通过补偿和重试,使其在一定的时间窗口内完成操作,这样就可以实现事务的最终一致性,突破事务遇到问题就滚回的传统思路。
如果采用事务补偿机制,则在遇到问题时,我们需要记录遇到问题的环境、信息、步骤、状态等,后续通过重试机制使其达到最终一致性,详细内容可以参考《分布式服务架构:原理、设计与实战》第2章,彻底理解ACID原理、CAP理论、BASE原理、最终一致性模式等内容。
无论使用上面哪种方法实现分布式事务,都需要对分库分表的多个数据源路由事务,一般通过对Spring环境的配置,为不同的数据源配置不同的事务管理器(TransactionManager)。
这样,如果更新操作在一个数据库实例内发生,便可以使用数据源的事务来处理。对于跨数据源的事务,可通过在应用层使用最大努力保证模式和事务补偿机制来达成事务的一致性。
当然,有时我们需要通过编写程序来选择数据库的事务管理器,根据实现方式的不同,可将事务路由具体分为以下三种。
1)自动提交事务路由
自动提交事务通过依赖JDBC数据源的自动提交事务特性,对任何数据库进行更新操作后会自动提交事务,不需要开发人员手工操作事务,也不需要配置事务,实现起来很简单,但是只能满足简单的业务逻辑需求。
在通常情况下,JDBC在连接创建后默认设置自动提交为true,当然,也可以在获取连接后手工修改这个属性,代码如下:
connnection conn = null;
try{
conn = getConnnection();
conn.setAutoCommit(true);
// 数据库操作
……………………………
conn.commit();
}catch(Throwable e){
if(conn!=null){
try {
conn.rollback();
} catch (SQLException e1) {
e1.printStackTrace();
}
}
throw new RuntimeException(e);
}finally{
if(conn!=null){
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
我们基本不需要使用原始的JDBC API来改变这些属性,这些操作一般都会被封装在我们使用的框架中。本书3.6节介绍的开源数据库分库分表框架dbsplit默认使用的就是这种模式。
2)可编程事务路由
我们在应用中通常采用Spring的声明式的事务来管理数据库事务,在分库分表时,事务处理是个问题,在一个需要开启事务的方法中,需要动态地确定开启哪个数据库实例的事务,也就是说在每个开启事务的方法调用前就必须确定开启哪个数据源的事务。下面使用伪代码来说明如何实现一个可编程事务路由的小框架。
首先,通过Spring配置文件展示可编程事务小框架是怎么使用的: