【51CTO.com原创稿件】 随着互联网产业的蓬勃发展,在互联网应用上产生的数据也是与日俱增。产生大量的交易记录和行为记录,它们的存放和分析是我们需要面对的问题。
图片来自 Pexels
例如:单表中出现了,动辄百万甚至千万级别的数据。“分表分库”就成为解决上述问题的有效工具。今天和大家一起看看,如何进行分表分库以及期间遇到的问题吧。
为什么会分表分库
数据库数据会随着业务的发展而不断增多,因此数据操作,如增删改查的开销也会越来越大。
再加上物理服务器的资源有限(CPU、磁盘、内存、IO 等)。最终数据库所能承载的数据量、数据处理能力都将遭遇瓶颈。
换句话说需要合理的数据库架构来存放不断增长的数据,这个就是分库分表的设计初衷。目的就是为了缓解数据库的压力,最大限度提高数据操作的效率。
数据分表
如果单表的数据量过大,例如千万级甚至更多,那么在操作表的时候就会加大系统的开销。
每次查询会消耗数据库大量资源,如果需要多表的联合查询,这种劣势就更加明显了。
以 MySQL 为例,在插入数据的时候,会对表进行加锁,分为表锁定和行锁定。
无论是哪种锁定方式,都意味着前面一条数据在操作表或者行的时候,后面的请求都在排队,当访问量增加的时候,都会影响数据库的效率。
那么既然一定要分表,那么每张表分配多大的数据量比较合适呢?这里建议根据业务场景和实际情况具体分析。
一般来说 MySQL 数据库单表记录最好控制在 500 万条(这是个经验数字)。既然需要将数据从一个表分别存放到多个表中,那么来看看下面两种分表方式吧。
垂直分表
根据业务把一个表中的字段(Field)分到不同的表中。这些被分出去的数据通常根据业务需要,例如分出去一些不是经常使用的字段,一些长度较长的字段。
一般被拆分的表的字段数比较多。主要是避免查询的时候出现因为数据量大而造成的“跨页”问题。
一般这种拆分在数据库设计之初就会考虑,尽量在系统上线之前考虑调整。已经上线的项目,做这种操作是要慎重考虑的。
水平分表
将一个表中的数据,按照关键字(例如:ID)(或取 Hash 之后)对一个具体的数字取模,得到的余数就是需要存放到的新表的位置。
用 ID 取模的分表方式分配记录
ID 分别为 01-04 的四条记录,如果分配到 3 个表中,那么对 3 取模得到的余数分别是:
- ID:01 对 3 取模余数为 1 ,存到“表 1”。
- ID:02 对 3 取模余数为 2 ,存到“表 2”。
- ID:03 对 3 取模余数为 3 ,存到“表 3”。
- ID:04 对 3 取模余数为 1 ,存到“表 1”。
当然这里只是一个例子,实际情况需要对 ID 做 Hash 之后再计算。同时还可以针对不同表所在的不同的数据库的资源来设置存储数据的多少。针对每个表所在的库的资源设置权值。
用这种方式存放数据以后,在访问具体数据的时候需要通过一个 Mapping Table 获取对应要响应的数据来自哪个数据表。目前比较流行的数据库中间件已经帮助我们实现了这部分的功能。
也就是说不用大家自己去建立这个 Mapping Table,在做查询的时候中间件帮助你实现了 Mapping Table 的功能。所以,我们这里只需要了解其实现原理就可以了。
Mapping Table 协助分表
水平拆分还有一种情况是根据数据产生的前后顺序来拆分存放。例如,主表只存放最近 2 个月的信息,其他比较老旧的信息拆分到其他的表中。通过时间来做数据区分。更有甚者是通过服务的地域来做数据区分的。
按照时间做的数据分表
需要注意的是由于分表造成一系列记录级别的问题,例如 Join 和 ID 生成,事务处理,同时存在这些表需要跨数据库的可能性:
- Join:需要做两次查询,把两次查询的结果在应用层做合并。这种做法是最简单的,在应用层设计的时候需要考虑。
- ID:可以使用 UUID,或者用一张表来存放生成的 Sequence,不过效率都不算高。UUID 实现起来比较方便,但是占用的空间比较大。
Sequence 表的方式节省了空间,但是所有的 ID 都依赖于单表。这里介绍一个大厂用的 Snowflake 的方式。
Snowflake 是 Twitter 开源的分布式 ID 生成算法,结果是一个 long 型的 ID。
其核心思想是:使用 41bit 作为毫秒数,10bit 作为机器的 ID(5 个 bit 是数据中心,5 个 bit 的机器 ID),12bit 作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是 0。
Snowflake 示意图
排序/分页:数据分配到水平的几个表中的时候,做排序和分页或者一些集合操作是不容易的。
这里根据经验介绍两种方法。对分表的数据先进行排序/分页/聚合,再进行合并。对分表的数据先进行合并再做排序/分页/聚合。
事务:存在分布式事务的可能,需要考虑补偿事务或者用 TCC(Try Confirm Cancel)协助完成,这部分的内容我们下面会为大家介绍。
数据分库
说完了分表,再来谈谈分库。每个物理数据库支持数据都是有限的,每一次的数据库请求都会产生一次数据库链接,当一个库无法支持更多访问的时候,我们会把原来的单个数据库分成多个,帮助分担压力。
这里有几类分库的原则,可以根据具体场景进行选择:
- 根据业务不同分库,这种情况都会把主营业务和其他功能分开。例如可以分为订单数据库,核算数据库,评论数据库。
- 根据冷热数据进行分库,用数据访问频率来划分,例如:近一个月的交易数据属于高频数据,2-6 个月的交易数据属于中频数据,大于 6 个月的数据属于低频数据。
- 根据访问数据的地域/时间范围进行分库。
单个表会分到不同的数据库中
通常数据分库之后,每一个数据库包含多个数据表,多个数据库会组成一个 Cluster/Group,提高了数据库的可用性,并且可以把读写做分离。
Master 库主要负责写操作,Slave 库主要负责读操作。在应用访问数据库的时候会通过一个负载均衡代理,通过判断读写操作把请求路由到对应的数据库。
如果是读操作,也会根据数据库设置的权重或者平均分配请求。另外,还有数据库健康监控机制,定时发送心跳检测数据库的健康状况。
如果 Slave 出现问题,会启动熔断机制停止对其的访问;如果 Master 出现问题,通过选举机制选择新的 Master 代替。
主从数据库简图
数据库扩容
分库之后的数据库会遇到数据扩容或者数据迁移的情况。这里推荐两种数据库扩容的方案。
主从数据库扩容
我们这里假设有两个数据库集群,每个集群分别有 M1 S1 和 M2 S2 互为主备。
两个数据库集群示意图
由于 M1 和 S1 互为主备所以数据是一样的,M2 和 S2 同样。把原有的 ID %2 模式切换成 ID %4 模式,也就是把两个数据集群扩充到 4 个数据库集群。
负载均衡器直接把数据路由到原来两个 S1 和 S2 上面,同时 S1 和 S2 会停止与 M1 和 M2 的数据同步,单独作为主库(写操作)存在。
这些修改不需要重启数据库服务,只需要修改代理配置就可以完成。由于 M1 M2 S1 S2 中会存在一些冗余的数据,可以后台起服务将这些冗余数据删除,不会影响数据使用。
两个集群中的两个主从,分别扩展成四个集群中的四个主机
此时,再考虑数据库可用性,将扩展后的 4 个主库进行主备操作,针对每个主库都建立对应的从库,前者负责写操作,后者负责读操作。下次如果需要扩容也可以按照类似的操作进行。
从两个集群扩展成四个集群
双写数据库扩容
在没有数据库主从配置的情况下的扩容,假设有数据库 M1 M2 如下图:
扩展前的两个主库
需要对目前的两个数据库做扩容,扩容之后是 4 个库如下图。新增的库是 M3,M4 路由的方式分别是 ID%2=0 和 ID%2=1。
新增两个主库
这个时候新的数据会同时进入 M1 M2 M3 M4 四个库中,而老数据的使用依旧从 M1 M2 中获取。
与此同时,后台服务对 M1 M3,M2 M4 做数据同步,建议先做全量同步再做数据校验。
老库给新库做数据同步
当完成数据同步之后,四个库的数据保持一致了,修改负载均衡代理的配置为 ID%4 的模式。此时扩容就完成了,从原来的 2 个数据库扩展成 4 个数据库。
当然会存在部分的数据冗余,需要像上面一个方案一样通过后台服务删除这些冗余数据,删除的过程不会影响业务。
数据同步以后做 Hash 切分
分布式事务原理
架构设计的分表分库带来的结果是我们不得不考虑分布式事务,今天我们来看看分布式事务需要记住哪两个原理。
CAP
互联网应用大多会使用分表分库的操作,这个时候业务代码很可能会同时访问两个不同的数据库,做不同的操作。同时这两个操作有可能放在同一个事务中处理。
这里引出分布式系统的 CAP 理论,他包括以下三个属性:
一致性(Consistency):分布式系统中的所有数据,同一时刻有同样的值。
业务代码往数据库 01 这个节点写入记录 A,数据库 01 把 A 记录同步到数据库 02,业务代码再从数据库 02 中读出的记录也是 A。那么两个数据库存放的数据就是一致的。
一致性简图
可用性(Availability):分布式系统中一部分节点出现故障,分布式系统仍旧可以响应用户的请求。
假设数据库 01 和 02 同时存放记录 A,由于数据库 01 挂掉了,业务代码不能从中获取数据。
那么业务代码可以从数据库 02 中获取记录 A。也就是在节点出现问题的时候,还保证数据的可用性。
可用性简图
分区容错性(Partition tolerance):假设两个数据库节点分别在两个区,而两个区的通讯发生了问题。就不能达成数据一致,这就是分区的情况,我就需要从 C 和 A 之间做出选择。
是选择可用性(A),获取其中一个区的数据。还是选择一致性(C),等待两个区的数据同步了再去获取数据。
这种情况的前提是两个节点的通讯失败了,写入数据库 01 记录的时候,需要锁住数据库 02 记录不让其他的业务代码修改,直到数据库 01 记录完成修改。因此 C 和 A 在此刻是矛盾的。两者不能兼得。
分区容错简图
BASE
Base 原理广泛应用在数据量大,高并发的互联网场景。一起来看看都包含哪些:
基本可用(Basically Available): 不会因为某个节点出现问题就影响用户的请求。
即使在流量激增的情况下,也会考虑通过限流降级的办法保证用户的请求是可用的。
比如,电商系统在流量激增的时候,资源会向核心业务倾斜,其他的业务降级处理。
软状态( Soft State):一条数据如果存在多个副本,允许副本之间同步的延迟,在较短时间内能够容忍不一致。这个正在同步并且还没有完成同步的状态称为软状态。
最终一致性( Eventual Consistency):最终一致性是相对于强一致性来说的,强一致性是要保证所有的数据都是一致的,是实时同步。
而最终一致性会容忍一小段时间数据的不一致,但过了这段时间以后数据会保证一致。其包含以下几种“一致性”:
①因果一致性(Causal Consistency)
如果有两个进程 1 和 2 都对变量 X 进行操作,“进程 1” 写入变量 X,“进程 2”需要读取变量 X,然后用这个 X 来计算 X+2。
这里“进程 1”和“进程 2” 的操作就存在因果关系。“进程 2” 的计算依赖于进程 1 写入的 X,如果没有 X 的值,“进程 2”无法计算。
两个进程对同一变量进行操作
②读己之所写(Read Your Writes)
“进程 1”写入变量 X 之后,该进程可以获取自己写入的这个值。
进程写入的值的同时获取值
③会话一致性(Session Consistency)
如果一个会话中实现来读己之所写。一旦数据更新,客户端只要在同一个会话中就可以看到这个更新的值。
多进程在同一会话需要看到相同的值
④单调写一致性(Monotonic Write Consistency)
“进程 1”如果有三个操作分别是 1,2,3。“进程 2”有两个操作分别是 1,2。当进程请求系统时,系统会保证按照进程中操作的先后顺序来执行。
多进程多操作通过队列方式执行
分布式事务方案
说完了分布式的原理,再来提一下分布式的方案。由于所处场景不一样,所以方案也各有不同,这里介绍两种比较流行的方案,两段式和 TCC(Try,Confirm,Cancel)。
两阶段提交
顾名思义,事务会进行两次提交。这里需要介绍两个概念,一个是事务协调者,也叫事物管理器。
它是用来协调事务的,所有事务什么时候准备好了,什么时候可以提交了,都由它来协调和管理。
另一个是参与者,也叫资源管理器。它主要是负责处理具体事务的,管理者需要处理的资源。例如:订票业务,扣款业务。
第一阶段(准备阶段):事务协调者(事务管理器)给每个参与者(资源管理器)发送 Prepare 消息,发这个消息的目的是问“大家是不是都准备好了,我们马上就要执行事务了”。
参与者会根据自身业务和资源情况进行检查,然后给出反馈。这个检查过程根据业务内容不同而不同。
例如:订票业务,就要检查是否有剩余票。扣款业务就要检查,余额是否足够。一旦检查通过了才能返回就绪(Ready)信息。
否则,事务将终止,并且等待下次询问。由于这些检查需要做一些操作,这些操作可能再之后回滚时用到,所以需要写 redo 和 undo 日志,当事务失败重试,或者事务失败回滚的时候使用。
第二阶段(提交阶段):如果协调者收到了参与者失败或者超时的消息,会给参与者发送回滚(rollback)消息;否则,发送提交(commit)消息。两种情况处理如下:
情况 1,当所有参与者均反馈 yes,提交事务:
- 协调者向所有参与者发出正式提交事务的请求(即 commit 请求)。
- 参与者执行 commit 请求,并释放整个事务期间占用的资源。
- 各参与者向协调者反馈 ack(应答)完成的消息。
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。
情况 2,当有一个参与者反馈 no,回滚事务:
- 协调者向所有参与者发出回滚请求(即 rollback 请求)。
- 参与者使用第一阶段中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源。
- 各参与者向协调者反馈 ack 完成的消息。
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务。
两个阶段提交事务示意图
TCC(Try,Confirm,Cancel)
对于一些要求高一致性的分布式事务,例如:支付系统,交易系统,我们会采用 TCC。
它包括,Try 尝试,Confirm 确认,Cancel 取消。看下面一个例子能否帮助大家理解。
假设我们有一个转账服务,需要把“A 银行”“A 账户”中的钱分别转到“B银行”“B 账户”和“C 银行”“C 账户”中去。
假设这三个银行都有各自的转账服务,那么这次转账事务就形成了一次分布式事务。
我们来看看用 TCC 的方式如何解决:
转账业务示意图
首先是 Try 阶段,主要检测资源是否可用,例如检查账户余额是否足够,缓存,数据库,队列是否可用等等。
并不执行具体的逻辑。如上图,这里从“A 账户”转出之前要检查,账户的总金额是否大于 100,并且记录转出金额和剩余金额。
对于“B 账户”和“C 账户”来说需要知道账户原有总金额和转入的金额,从而可以计算转入后的金额。
这里的交易数据库设计除了有金额字段,还要有转出金额或者转入金额的字段,在 Cancel 回滚的时候使用。
Try 阶段示意图
如果 Try 阶段成功,那么就进入 Confirm 阶段,也就是执行具体的业务逻辑。
这里从“A 账户”转出 100 元成功,剩余总金额=220-100=120,把这个剩余金额写入到总金额中保存,并且把交易的状态设置为“转账成功”。
“B 账户”和“C 账户”分别设置总金额为 80=50+30 和 130=60+70,也把交易状态设置为“转账成功”。则整个事务完成。
Confirm 阶段示意图
如果 Try 阶段没有成功,那么服务 A B C 都要做回滚的操作。对于“A账户”来说需要把扣除的 100 元加回,所以总金额 220=120+100。
那么“B 服务”和“C 服务”需要把入账的金额从总金额里面减去,也就是 50=80-30 和 60=130-70。
Cancel 阶段示意图
TCC 接口实现
这里需要注意的是,需要针对每个服务去实现 Try,Confirm,Cancel 三个阶段的代码。
例如上面所说的检查资源,执行业务,回滚业务等操作。目前有很多开源的架构例如:ByteTCC、TCC-transaction 可以借鉴。
TCC 实现接口示意图
TCC 可靠性
TCC 通过记录事务处理日志来保证可靠性。一旦 Try,Confirm,Cancel 操作的时候服务挂掉或者出现异常,TCC 会提供重试机制。另外如果服务存在异步的情况可以采用消息队列的方式通信保持事务一致。
重试机制示意图
分库表中间件介绍
如果觉得分表分库之后,需要考虑的问题很多,可以使用市面上的现成的中间件帮我们实现。
这里介绍几个比较常用的中间件:
- 基于代理方式的有 MySQL Proxy 和 Amoeba。
- 基于 Hibernate 框架的有 Hibernate Shards。
- 基于 JDBC 的有当当 Sharding-JDBC。
- 基于 MyBatis 的类似 Maven 插件式的蘑菇街 TSharding。
另外着重介绍 Sharding-JDBC 的架构,它的构成和“服务注册中心”很像。
Sharding-JDBC 会提供一个 Sharding-Proxy 做代理,他会连接一个注册中心(registry center),一旦数据库的节点挂接到系统中,会在这个中心注册,同时也会监控数据库的健康状况做心跳检测。
而 Sharding-Proxy 本身在业务代码(Business Code)请求数据库的时候可以协助做负载均衡和路由。
同时 Sharding-Proxy 本身也可以支持被 MySQL Cli 和 MySQL Workbench 查看。
实际上如果我们理解了分表分库的原理之后,实现并不难,很多大厂都提供了产品。
Sharding-Proxy 实现原理图
总结
因为数据量的上升,为了提高性能会对系统进行分表分库。从分表来说,有水平分表和垂直分表两种方式。
可以根据业务,冷热数据等来进行分库,分库以后通过主从库来实现读写分离。
如果对分库之后数据库做扩容,有两种方式,主从数据库扩容和双写数据库扩容。
分表分库会带来分布式事务,我们需要掌握 CAP 和 BASE 原理,同时介绍了两阶段提交和 TCC 两个分布式事务方案。最后,介绍了流行的分表分库中间件,以及其实现原理。
作者:崔皓
简介:十六年开发和架构经验,曾担任过惠普武汉交付中心技术专家,需求分析师,项目经理,后在创业公司担任技术/产品经理。善于学习,乐于分享。目前专注于技术架构与研发管理。
【51CTO原创稿件,合作站点转载请注明原文作者和出处为51CTO.com】