从公众号转载,关注微信公众号掌握更多技术动态
---------------------------------------------------------------
一、高性能数据库简介
1.高性能数据库方式
读写分离:将访问压力分散到集群中的多个节点,没有分散存储压力
分库分表:既可以分散访问压力,又可以分散存储压力
2.为啥不用表分区
-
如果SQL不走分区键,很容易出现全表锁;
-
在分区表实施关联查询,就是一个灾难;
-
分库分表,自己掌控业务场景与访问模式,可控;分区表,工程师写了一个SQL,自己无法确定MySQL是怎么玩的,不可控;
二、读写分离——提升数据库读性能
可以缓解订单系统、账户系统、购物车系统等等功能mysql的并发压力
读写分离的基本原理是将数据库的读写操作分散到不同的节点
1.读写分离的基本实现
数据库服务器搭建主从集群,一主一从、或者一主多从,数据库主机负责读写操作,从机只负责读操作。数据库主机通过复制将数据同步到从机,每台数据库服务器都存储了所有的业务数据。业务服务器将写操作发给数据库主机,将读操作发给数据库从机。
需要注意的是,这里用的是“主从集群”,而不是“主备集群”。“从机”的“从”可以理解为“仆从”,仆从是要帮主人干活的,“从机”是需要提供读数据的功能的;而“备机”一般被认为仅仅提供备份功能,不提供访问功能。
2.读写分离引起的复杂性
(1)复制延迟
一般会把从库落后的时间作为一个重点的数据库指标做监控和报警,正 常的时间是在毫秒级别,一旦落后的时间达到了秒级别就需要告警了。
以 MySQL 为例,主从复制延迟可能达到 1 秒,如果有大量数据同步,延迟 1 分钟也是有可能的。主从复制延迟会带来一个问题:如果业务服务器将数据写入到数据库主服务器后立刻(1 秒内)进行读取,此时读操作访问的是从机,主机还没有将数据复制过来,到从机读取数据是读不到最新数据的,业务上就可能出现问题。解决主从复制延迟有几种常见的方法:
-
写操作后的读操作指定发给数据库主服务器(缓存标记法)。例如,注册账号完成后,登录时读取账号的读操作也发给数据库主服务器。这种方式和业务强绑定,对业务的侵入和影响较大,如果哪个新来的程序员不知道这样写代码,就会导致一个 bug。可以利用一个缓存记录必须读主的数据。当写请求发生时:
-
写主库
-
将哪个库,哪个表,哪个主键三个信息拼装一个key设置到cache里,这条记录的超时时间,设置为“主从同步时延”
查询时:
-
cache里有这个key,说明1s内刚发生过写请求,数据库主从同步可能还没有完成,此时就应该去主库查询
-
cache里没有这个key,说明最近没有发生过写请求,此时就可以去从库查询
-
读从机失败后再读一次主机。这就是通常所说的“二次读取”,二次读取和业务无绑定,只需要对底层数据库访问的 API 进行封装即可,实现代价较小,不足之处在于如果有很多二次读取,将大大增加主机的读操作压力。
-
关键业务读写操作全部指向主机,非关键业务采用读写分离。例如,对于一个用户管理系统来说,注册 + 登录的业务读写操作全部访问主机,用户的介绍、爱好、等级等业务,可以采用读写分离,因为即使用户改了自己的自我介绍,在查询时却看到了自我介绍还是旧的,业务影响与不能登录相比就小很多,还可以忍受。
-
写操作完成后,跳转到无关页面,类似订单支付的“支付完成”页面,其实这个页面没有任何有效的信息,就是告诉你支付成功,然后再放一些广告什么的。你如果想再看刚刚支付完成的订单,需要手动点一下,这样就很好地规避了主从同步延迟的问题。
(2)分配机制
将读写操作区分开来,然后访问不同的数据库服务器,一般有两种方式:程序代码封装和中间件封装。由于数据库中间件的复杂度要比程序代码封装高出一个数量级,一般情况下建议采用程序语言封装的方式,或者使用成熟的开源数据库中间件。
-
程序代码封装。程序代码封装指在代码中抽象一个数据访问层,实现读写操作分离和数据库服务器连接的管理。
-
中间件封装。中间件封装指的是独立一套系统出来,实现读写操作分离和数据库服务器连接的管理。对于业务服务器来说,访问中间件和访问数据库没有区别。中间件需要支持多种编程语言,因为数据库中间件对业务服务器提供的是标准 SQL 接口。数据库中间件要支持完整的 SQL 语法和数据库服务器的协议(例如,MySQL 客户端和服务器的连接协议),实现比较复杂,细节特别多,很容易出现 bug,需要较长的时间才能稳定。数据库中间件自己不执行真正的读写操作,但所有的数据库操作请求都要经过中间件,中间件的性能要求也很高。
(3)区分连接池
-
数据库连接池需要区分:读连接池,写连接池
-
如果要保证读高可用,读连接池要实现故障自动转移
3.从库的数量
是不是无限制地增加从库的数量就可以抵抗大量的并发呢?实际上并不 是的。因为随着从库数量增加,从库连接上来的 IO 线程比较多,主库也需要创建同样多的 log dump 线程来处理复制的请求,对于主库资源消耗比较高,同时受限于主库的网络带 宽,所以在实际使用中,一般一个主库最多挂 3~5 个从库。
三、分库分表(最后选择的优化方案)——数据库数据量大
分表分库规则在设计时需要考虑数据分布均匀,避免单库或者单表数据倾斜。
1.分库分表简介
如果预计三年后的数据量根本达不到这个级别,请不要在创建表时就分库分表。单表行数超过1000万行或者单表容量超过4GB,才推荐分库分表。
(1)分库分表的原因
当数据量达到千万甚至上亿条的时候,单台数据库服务器的存储能力会成为系统的瓶颈,主要体现在这几个方面:
-
数据量太大,读写的性能会下降,即使有索引,索引也会变得很大,性能同样会下降。
-
数据文件会变得很大,数据库备份和恢复需要耗费很长时间。
-
数据文件越大,极端情况下丢失数据的风险越高(例如,机房火灾导致数据库主备机都发生故障)。
基于上述原因,单个数据库服务器存储的数据量不能太大,需要控制在一定的范围内。为了满足业务数据存储的需求,就需要将存储分散到多台数据库服务器上。
(2)分库分表的选择
数据量大,就分表;并发高,就分库。查询慢,只要减少每次查询的数据总量就可以了,分表就可以解决问题;应对高并发的问题,一个数据库实例撑不住,就把并发请求分散到多个实例中去。
-
IO瓶颈
-
第一种:磁盘读IO瓶颈,热点数据太多,数据库缓存放不下,每次查询会产生大量的IO,降低查询速度->分库和垂直分表
-
第二种:网络IO瓶颈,请求的数据太多,网络带宽不够 ->分库
-
-
CPU瓶颈
-
第一种:SQl问题:如SQL中包含join,group by, order by,非索引字段条件查询等,增加CPU运算的操作->SQL优化,建立合适的索引,在业务Service层进行业务计算。
-
第二种:单表数据量太大,查询时扫描的行太多,SQl效率低,增加CPU运算的操作。->水平分表。
-
(3)分库分表步骤
根据容量(当前容量和增长量)评估分库或分表个数 -> 选key(均匀)-> 分表规则(hash或range等)-> 执行(一般双写)-> 扩容问题(尽量减少数据的移动)。
2.业务分库
业务分库也叫垂直分库指的是按照业务模块将数据分散到不同的数据库服务器。例如,一个简单的电商网站,包括用户、商品、订单三个业务模块,可以将用户数据、商品数据、订单数据分开放到三台不同的数据库服务器上。这种方式在微服务架构中非常常用。微服务架构的核心思想是将一个完整的应用按照业务功能拆分成多个可独立运行的子系统,这些子系统称为“微服务”。垂直分库的理念与微服务的理念不谋而合,可以将原本完整的数据按照微服务拆分系统的方式,拆分成多个独立的数据库,使得每个微服务系统都有各自独立的数据库,从而可以避免单个数据库节点压力过大,影响系统的整体性能,如下图所示。
以字段为依据,按照一定策略(hash、range等),将一个库中的数据拆分到多个库中。系统绝对并发量上来了,分表难以根本上解决问题,并且还没有明显的业务归属来垂直分库的情况下。
(1)join 操作问题
业务分库后,原本在同一个数据库中的表分散到不同数据库中,导致无法使用 SQL 的 join 查询。
例如:“查询购买了化妆品的用户中女性用户的列表”这个功能,虽然订单数据中有用户的 ID 信息,但是用户的性别数据在用户数据库中,如果在同一个库中,简单的 join 查询就能完成;但现在数据分散在两个不同的数据库中,无法做 join 查询,只能采取先从订单数据库中查询购买了化妆品的用户 ID 列表,然后再到用户数据库中查询这批用户 ID 中的女性用户列表,这样实现就比简单的 join 查询要复杂一些。
系统中所有模块都可能依赖的一些表,为了避免库join查询,可以将这类表在每个数据库中都保存一份。这些数据通常很少修改,所以不必担心一致性的问题。
(2)事务问题
原本在同一个数据库中不同的表可以在同一个事务中修改,业务分库后,表分散到不同的数据库中,无法通过事务统一修改。虽然数据库厂商提供了一些分布式事务的解决方案(例如,MySQL 的 XA),但性能实在太低,与高性能存储的目标是相违背的。
例如,用户下订单的时候需要扣商品库存,如果订单数据和商品数据在同一个数据库中,我们可以使用事务来保证扣减商品库存和生成订单的操作要么都成功要么都失败,但分库后就无法使用数据库事务了,需要业务程序自己来模拟实现事务的功能。例如,先扣商品库存,扣成功后生成订单,如果因为订单数据库异常导致生成订单失败,业务程序又需要将商品库存加上;而如果因为业务程序自己异常导致生成订单失败,则商品库存就无法恢复了,需要人工通过日志等方式来手工修复库存异常。
(3)成本问题
业务分库同时也带来了成本的代价,本来 1 台服务器搞定的事情,现在要 3 台,如果考虑备份,那就是 2 台变成了 6 台。基于上述原因,对于小公司初创业务,并不建议一开始就这样拆分,主要有几个原因:
-
初创业务并没有真正的存储和访问压力,业务分库并不能为业务带来价值。业务分库后,表之间的 join 查询、数据库事务无法简单实现了。
-
业务分库后,因为不同的数据要读写不同的数据库,代码中需要增加根据数据类型映射到不同数据库的逻辑,增加了工作量。
2.分表
将不同业务数据分散存储到不同的数据库服务器,能够支撑百万甚至千万用户规模的业务,但如果业务继续发展,同一业务的单表数据也会达到单台数据库服务器的处理瓶颈。例如,淘宝的几亿用户数据,如果全部存放在一台数据库服务器的一张表中,肯定是无法满足性能要求的,此时就需要对单表数据进行拆分。单表数据拆分有两种方式:垂直分表和水平分表(表的数量一般是2的n次幂)。示意图如下:
单表进行切分后,是否要将切分后的多个表分散在不同的数据库服务器中,可以根据实际的切分效果来确定。因为单表切分为多表后,新的表即使在同一个数据库服务器中,也可能带来可观的性能提升,如果性能能够满足业务要求,是可以不拆分到多台数据库服务器的,毕竟业务分库也会引入很多复杂性的问题;如果单表拆分为多表后,单台服务器依然无法满足性能要求,那就不得不再次进行业务分库的设计了。
(1)垂直分表
垂直分表适合将表中某些不常用且占了大量空间的列拆分出去。例如,前面示意图中的 nickname 和 description 字段,假设我们是一个婚恋网站,用户在筛选其他用户的时候,主要是用 age 和 sex 两个字段进行查询,而 nickname 和 description 两个字段主要用于展示,一般不会在业务查询中用到。description 本身又比较长,因此我们可以将这两个字段独立到另外一张表中,这样在查询 age 和 sex 时,就能带来一定的性能提升。如果属性过多,可以有多个扩展表。
-
将长度较短,访问频率较高的属性尽量放在一个表里,这个表暂且称为主表;
-
将字段较长,访问频率较低的属性尽量放在一个表里,这个表暂且称为扩展表;
-
经常一起访问的属性,也可以放在一个表里;
优点
-
可以使得行数据变小,一个数据块( Block )就能存放更多的数据,在查询时就会减少 I/O 次数(每次查询时读取的 Block 就少)
-
可以达到最大化利用 Cache 的目的,具体在垂直拆分的时候可以将不常变的字段放一起,将经常改变的放一起
缺点
-
主键出现冗余,需要管理冗余列
-
会引起表连接 JOIN 操作(增加 CPU 开销)可以通过在业务服务器上进行 join 来减少数据库压力
-
依然存在单表数据量过大的问题(需要水平拆分)
-
事务处理复杂
垂直分表引入的复杂性主要体现在表操作的数量要增加。例如,原来只要一次查询就可以获取 name、age、sex、nickname、description,现在需要两次查询,一次查询获取 name、age、sex,另外一次查询获取 nickname、description。
(2)水平分表
水平分表适合表行数特别大的表,有的公司要求单表行数超过 5000 万就必须进行分表,这个数字可以作为参考,但并不是绝对标准,关键还是要看表的访问性能。对于一些比较复杂的表,可能超过 1000 万就要分表了;而对于一些简单的表,即使存储数据超过 1 亿行,也可以不分表。(预计三年内可达到单表最大容量2GB)
当然,如果拆分出来的表都存储在同一个数据库节点上,那么当请求量过大的时候,毕竟单台服务器的处理能力是有限的,数据库仍然会成为系统的瓶颈,所以为了解决这个问题,就出现了水平数据分片的解决方案。
水平拆分的优点是:
-
不存在单库大数据和高并发的性能瓶颈
-
应用端改造较少
-
提高了系统的稳定性和负载能力
缺点是:
-
分片事务一致性难以解决
-
跨节点 Join 性能差,逻辑复杂
-
数据多次扩展难度跟维护量极大
核心概念
-
数据节点:数据节点是数据分片中一个不可再分的最小单元(表),它由数据源名称和数据表组成,例如DB_1.t_order_1、DB_2.t_order_2 就表示一个数据节点。
-
逻辑表:逻辑表是指具有相同结构的水平拆分表的逻辑名称。比如我们将订单表t_order 分表拆分成 t_order_0 ··· t_order_9等10张表,这时我们的数据库中已经不存在 t_order这张表,取而代之的是若干的t_order_n表。在代码中SQL依然按 t_order来写,而在执行逻辑SQL前将其解析成对应的数据库真实执行的SQL。此时 t_order 就是这些拆分表的逻辑表。
-
广播表:广播表是一类特殊的表,其表结构和数据在所有分片数据源中均完全一致。与拆分表相比,广播表的数据量较小、更新频率较低,通常用于字典表或配置表等场景。由于其在所有节点上都有副本,因此可以大大降低JOIN关联查询的网络开销,提高查询效率。需要注意的是,对于广播表的修改操作需要保证同步性,以确保所有节点上的数据保持一致。比如和订单表关联的城市表
-
绑定表:绑定表是那些具有相同分片规则的一组分片表,由于分片规则一致所产生的的数据落地位置相同,在JOIN联合查询时能有效避免跨库操作。比如:t_order 订单表和 t_order_item 订单项目表,都以 order_no 字段作为分片键,并且使用 order_no 进行关联,因此两张表互为绑定表关系。
①Sharding Key选择
分库分表还有一个重要的问题是,选择一个合适的列或者说是属性,作为分表的依据,这个属性一般称为 Sharding Key。
比如归档历史订单的方法,它的 ShardingKey 就是订单完成时间。每次查询的时候,查询条件中必须带上这个时间,我们的程序就知道,三个月以前的数据查订单历史表,三个月内的数据查订单表,这就是一个简单的按照时间范围来分片的算法。
选择这个 Sharding Key 最重要的参考因素是业务是如何访问数据的。
②路由算法
水平分表后,某条数据具体属于哪个切分后的子表,需要增加路由算法进行计算,这个算法会引入一定的复杂性。常见的路由算法有:
范围路由:选取有序的数据列(例如,整形、时间戳等)作为路由的条件,不同分段分散到不同的数据库表中。以最常见的用户 ID 为例,路由算法可以按照 1000000 的范围大小进行分段,1 ~ 999999 放到数据库 1 的表中,1000000 ~ 1999999 放到数据库 2 的表中,以此类推。
范围路由设计的复杂点主要体现在分段大小的选取上,分段太小会导致切分后子表数量过多,增加维护复杂度;分段太大可能会导致单表依然存在性能问题,一般建议分段大小在 100 万至 2000 万之间,具体需要根据业务选取合适的分段大小。
范围路由的优点是可以随着数据的增加平滑地扩充新的表。例如,现在的用户是 100 万,如果增加到 1000 万,只需要增加新的表就可以了,原有的数据不需要动。
范围路由的一个比较隐含的缺点是分布不均匀,假如按照 1000 万来进行分表,有可能某个分段实际存储的数据量只有 1000 条,而另外一个分段实际存储的数据量有 900 万条。
Hash 路由:选取某个列(或者某几个列组合也可以)的值进行 Hash 运算,然后根据 Hash 结果分散到不同的数据库表中。同样以用户 ID 为例,假如我们一开始就规划了 10 个数据库表,路由算法可以简单地用 user_id % 10 的值来表示数据所属的数据库表编号,ID 为 985 的用户放到编号为 5 的子表中,ID 为 10086 的用户放到编号为 6 的字表中。
Hash 路由设计的复杂点主要体现在初始表数量的选取上,表数量太多维护比较麻烦,表数量太少又可能导致单表性能存在问题。而用了 Hash 路由后,增加字表数量是非常麻烦的,所有数据都要重分布。
Hash 路由的优缺点和范围路由基本相反,Hash 路由的优点是表分布比较均匀,缺点是扩充新的表很麻烦,所有数据都要重分布。
配置路由:配置路由就是路由表,用一张独立的表来记录路由信息。同样以用户 ID 为例,我们新增一张 user_router 表,这个表包含 user_id 和 table_id 两列,根据 user_id 就可以查询对应的 table_id。
配置路由设计简单,使用起来非常灵活,尤其是在扩充表的时候,只需要迁移指定的数据,然后修改路由表就可以了。
配置路由的缺点就是必须多查询一次,会影响整体性能;而且路由表本身如果太大(例如,几亿条数据),性能同样可能成为瓶颈,如果我们再次将路由表分库分表,则又面临一个死循环式的路由算法选择问题。
(3)带来的问题
①join 操作
水平分表后,数据分散在多个表中,如果需要与其他表进行 join 查询,需要在业务代码或者数据库中间件中进行多次 join 查询,然后将结果合并。
②count() 操作
水平分表后,虽然物理上数据分散到多个表中,但某些业务逻辑上还是会将这些表当作一个表来处理。例如,获取记录总数用于分页或者展示,水平分表前用一个 count() 就能完成的操作,在分表后就没那么简单了。常见的处理方式有下面两种:
-
count() 相加:具体做法是在业务代码或者数据库中间件中对每个表进行 count() 操作,然后将结果相加。这种方式实现简单,缺点就是性能比较低。例如,水平分表后切分为 20 张表,则要进行 20 次 count(*) 操作,如果串行的话,可能需要几秒钟才能得到结果。
-
记录数表:具体做法是新建一张表,假如表名为“记录数表”,包含 table_name、row_count 两个字段,每次插入或者删除子表数据成功后,都更新“记录数表”。这种方式获取表记录数的性能要大大优于 count() 相加的方式,因为只需要一次简单查询就可以获取数据。缺点是复杂度增加不少,对子表的操作要同步操作“记录数表”,如果有一个业务逻辑遗漏了,数据就会不一致;且针对“记录数表”的操作和针对子表的操作无法放在同一事务中进行处理,异常的情况下会出现操作子表成功了而操作记录数表失败,同样会导致数据不一致。此外,记录数表的方式也增加了数据库的写压力,因为每次针对子表的 insert 和 delete 操作都要 update 记录数表,所以对于一些不要求记录数实时保持精确的业务,也可以通过后台定时更新记录数表。定时更新实际上就是“count() 相加”和“记录数表”的结合,即定时通过 count() 相加计算表的记录数,然后更新记录数表中的数据。
③order by 操作
水平分表后,数据分散到多个子表中,排序操作无法在数据库中完成,只能由业务代码或者数据库中间件分别查询每个子表中的数据,然后汇总进行排序。
④分页处理
水平分库后,分页查询的问题比较突出,因为有些分页查询需要遍历所有库。举个例子,假设要按时间顺序展示某个商家的订单,每页有 100 条记录,由于是按商家查询,需要遍历所有数据库。假设库数量是 8,我们来看下水平分库后的分页逻辑:
如果是取第 1 页数据,需要从每个库里按时间顺序取前 100 条记录,8 个库汇总后 共有 800 条,然后对这 800 条记录在应用里进行二次排序,最后取前 100 条;如果取第 10 页数据,则需要从每个库里取前 1000(100*10)条记录,汇总后共有 8000 条记录,然后对这 8000 条记录进行二次排序后,取第 900 到 1000 之间的 记录。
在分库情况下,对于每个数据库,要取更多的记录,并且汇总后,还要在 应用里做二次排序,越是靠后的分页,系统要耗费更多的内存和执行时间。而在不分库的情 况下,无论取哪一页,只要从单个 DB 里取 100 条记录即可,也无需在应用内部做二 次排序,非常简单。那么如何解决分库情况下的分页问题呢?这需要具体情况具体分析:
如果是为前台应用提供分页,可以限定用户只能看到前面 n 页(这个限制在业务上 也是合理的,一般看后面的分页意义不大,如果一定要看,可以要求用户缩小范围重新 查询);
如果是后台批处理任务要求分批获取数据,可以加大分页的大小,比如设定每次获 取 5000 条记录,这样可以有效减少分页的访问次数;分库设计时,一般还有配套的大数据平台负责汇总所有分库的记录,所以有些分页查询可以考虑走大数据平台。
场景1(前提:取余法)
原序列:(1,2,3,4,5,6,7,8),需要取出limit 2,2 ,即:(3,4)
第1次查询
(1,3,5,7) -> limit 2,2 -> 改写成 limit 1,2 -> (3,5)
(2,4,6,8) -> limit 2,2 -> 改写成 limit 1,2 -> (4,6)
最小值为3
第2次查询
(1,3,5,7) -> between 3 and 5 -> (3,5)
(2,4,6,8) -> between 3 and 6 -> (4,6)
将第2次查询的结果合并:
(3,4,5,6) ->取头开始,取pageSize即2个元素 -> (3,4) 正确
场景2(前提:取余法)
原序列:(1,2,3,4,5,6,7,8),需要取出limit 1,2 ,即:(2,3)
第1次查询
(1,3,5,7) -> limit 1,2 -> 改写成 limit 0,2 -> (1,3) --注:因为1/2除不尽,这里向下取整了
(2,4,6,8) -> limit 1,2 -> 改写成 limit 0,2 -> (2,4)
最小值为1
第2次查询
(1,3,5,7) -> between 1 and 3 -> (1,3)
(2,4,6,8) -> between 1 and 4 -> (2,4)
将上面的结果合并:
(1,2,3,4) -> (2,3) (注:起始点,第1次查询改写时,向下取整了,所以这里要向移1位,从第2个数字开始取pagesize条数据)
⑤非partition key的查询问题
端上除了partition key只有一个非partition key作为条件查询
端上除了partition key不止一个非partition key作为条件查询
(4)数据冗余
①如何进行数据冗余
方案一:服务同步双写
由服务层同步写冗余数据
-
业务方调用服务,新增数据
-
服务先插入T1数据
-
服务再插入T2数据
-
服务返回业务方新增数据成功
优点:
-
不复杂,服务层由单次写,变两次写
-
数据一致性相对较高(因为双写成功才返回)
缺点:
-
请求的处理时间增加(要插入两次,时间加倍)
-
数据仍可能不一致,例如第二步写入T1完成后服务重启,则数据不会写入T2
方案二:服务异步双写
系统对处理时间比较敏感
数据的双写并不再由服务来完成,服务层异步发出一个消息,通过消息总线发送给一个专门的数据复制服务来写入冗余数据
-
业务方调用服务,新增数据
-
服务先插入T1数据
-
服务向消息总线发送一个异步消息(发出即可,不用等返回,通常很快就能完成)
-
服务返回业务方新增数据成功
-
消息总线将消息投递给数据同步中心
-
数据同步中心插入T2数据
优点:
-
请求处理时间短(只插入1次)
缺点:
-
系统的复杂性增加了,多引入了一个组件(消息总线)和一个服务(专用的数据复制服务)
-
因为返回业务线数据插入成功时,数据还不一定插入到T2中,因此数据有一个不一致时间窗口(这个窗口很短,最终是一致的)
-
在消息总线丢失消息时,冗余表数据会不一致
不管是服务同步双写,还是服务异步双写,服务都需要关注“冗余数据”带来的复杂性。如果想解除“数据冗余”对系统的耦合,引出常用的第三种方案。
方案三:线下异步双写
为了屏蔽“冗余数据”对服务带来的复杂性,数据的双写不再由服务层来完成,而是由线下的一个服务或者任务来完成
-
业务方调用服务,新增数据
-
服务先插入T1数据
-
服务返回业务方新增数据成功
-
数据会被写入到数据库的log中
-
线下服务或者任务读取数据库的log
-
线下服务或者任务插入T2数据
优点:
-
数据双写与业务完全解耦
-
请求处理时间短(只插入1次)
缺点:
-
返回业务线数据插入成功时,数据还不一定插入到T2中,因此数据有一个不一致时间窗口(这个窗口很短,最终是一致的)
-
数据的一致性依赖于线下服务或者任务的可靠性
②如何进行数据冗余
高并发的情况下,实时一致性很难,方法论是:最终一致性。实现方式是:异步检测,异步修复。
方案一:线下扫描全量数据法
线下启动一个离线的扫描工具,不停的比对正表T1和反表T2,如果发现数据不一致,就进行补偿修复。
优点:
-
比较简单,开发代价小
-
线上服务无需修改,修复工具与线上服务解耦
缺点:
-
扫描效率低,会扫描大量的“已经能够保证一致”的数据
-
由于扫描的数据量大,扫描一轮的时间比较长,即数据如果不一致,不一致的时间窗口比较长
方案二:线下扫描增量数据法
只扫描“可能存在不一致可能性”的数据,而不是每次扫描全部数据,以提高效率
每次只扫描增量的日志数据,就能够极大提高效率,缩短数据不一致的时间窗口,如
-
写入正表T1
-
第一步成功后,写入日志log1
-
写入反表T2
-
第二步成功后,写入日志log2
当然,我们还是需要一个离线的扫描工具,不停的比对日志log1和日志log2,如果发现数据不一致,就进行补偿修复
优点:
-
虽比方法一复杂,但仍然是比较简单的
-
数据扫描效率高,只扫描增量数据
缺点:
-
线上服务略有修改(代价不高,多写了2条日志)
-
虽然比方法一更实时,但时效性还是不高,不一致窗口取决于扫描的周期
方案三:线上实时检测“消息对”法
实时检测一致性并进行修复
这次不是写日志了,而是向消息总线发送消息
-
写入正表T1
-
第一步成功后,发送消息msg1
-
写入反表T2
-
第二步成功后,发送消息msg2
这次不是需要一个周期扫描的离线工具了,而是一个实时订阅消息的服务不停的收消息。
假设正常情况下,msg1和msg2的接收时间应该在3s以内,如果检测服务在收到msg1后没有收到msg2,就尝试检测数据的一致性,不一致时进行补偿修复。
优点:
-
效率高
-
实时性高
缺点:
-
方案比较复杂,上线引入了消息总线这个组件
-
线下多了一个订阅总线的检测服务
(5) SQL解析
分库分表后在应用层面执行一条 SQL 语句时,通常需要经过以下六个步骤:
SQL 解析 -> 执⾏器优化 -> SQL 路由 -> SQL 改写 -> SQL 执⾏ -> 结果归并 。
①SQL解析
SQL解析过程分为词法解析和语法解析两步,比如下边查询用户订单的SQL,先用词法解析将这条SQL拆解成不可再分的原子单元。在根据不同数据库方言所提供的字典,将这些单元归类为关键字,表达式,变量或者操作符等类型。
SELECT order_no FROM t_order where order_status > 0 and user_id = 10086
接着语法解析会将拆分后的SQL关键字转换为抽象语法树,通过对抽象语法树遍历,提炼出分片所需的上下文,上下文包含查询字段信息(Field)、表信息(Table)、查询条件(Condition)、排序信息(Order By)、分组信息(Group By)以及分页信息(Limit)等,并标记出 SQL中有可能需要改写的位置。
②SQL路由
通过上边的SQL解析得到了分片上下文数据,在匹配用户配置的分片策略和算法,就可以运算生成路由路径,将 SQL 语句路由到相应的数据节点上。
简单点理解就是拿到分片策略中配置的分片键等信息,在从SQL解析结果中找到对应分片键字段的值,计算出 SQL该在哪个库的哪个表中执行,SQL路由又根据有无分片健分为 分片路由 和 广播路由。
有分⽚键的路由叫分片路由,细分为直接路由、标准路由和笛卡尔积路由这3种类型。
标准路由
标准路由是最推荐也是最为常⽤的分⽚⽅式,它的适⽤范围是不包含关联查询或仅包含绑定表之间关联查询的SQL。
当 SQL分片健的运算符为 = 时,路由结果将落⼊单库(表),当分⽚运算符是 BETWEEN 或 IN 等范围时,路由结果则不⼀定落⼊唯⼀的库(表),因此⼀条逻辑SQL最终可能被拆分为多条⽤于执⾏的真实SQL。
SELECT * FROM t_order where t_order_id in (1,2)
SQL路由处理后
SELECT * FROM t_order_0 where t_order_id in (1,2) SELECT * FROM t_order_1 where t_order_id in (1,2)
直接路由
直接路由是直接将SQL路由到指定⾄库、表的一种分⽚方式,而且直接路由可以⽤于分⽚键不在SQL中的场景,还可以执⾏包括⼦查询、⾃定义函数等复杂情况的任意SQL。
笛卡尔积路由
笛卡尔路由是由⾮绑定表之间的关联查询产生的,比如订单表t_order 分片键是t_order_id 和用户表t_user分片键是t_order_id ,两个表的分片键不同,要做联表查询,会执行笛卡尔积路由,查询性能较低尽量避免走此路由模式。
SELECT * FROM t_order_0 t LEFT JOIN t_user_0 u ON u.user_id = t.user_id WHERE t.user_id = 1
SELECT * FROM t_order_0 t LEFT JOIN t_user_1 u ON u.user_id = t.user_id WHERE t.user_id = 1
SELECT * FROM t_order_1 t LEFT JOIN t_user_0 u ON u.user_id = t.user_id WHERE t.user_id = 1
SELECT * FROM t_order_1 t LEFT JOIN t_user_1 u ON u.user_id = t.user_id WHERE t.user_id = 1
无分⽚键的路由又叫做广播路由,可以划分为全库表路由、全库路由、 全实例路由、单播路由和阻断路由这 5种类型。
③SQL改写
SQL经过解析、优化、路由后已经明确分片具体的落地执行的位置,接着就要将基于逻辑表开发的SQL改写成可以在真实数据库中可以正确执行的语句。比如查询 t_order 订单表,我们实际开发中 SQL是按逻辑表 t_order 写的。
SELECT * FROM t_order
这时需要将分表配置中的逻辑表名称改写为路由之后所获取的真实表名称。
SELECT * FROM t_order_n
④结果归并
将从各个数据节点获取的多数据结果集,合并成一个大的结果集并正确的返回至请求客户端,称为结果归并。而我们SQL中的排序、分组、分页和聚合等语法,均是在归并后的结果集上进行操作的。
3.水平分库分表
以字段为依据,按照一定策略(hash、range等),将一个库中的数据拆分到多个库中。场景:系统绝对并发量上来了,分表难以根本上解决问题,并且还没有明显的业务归属来垂直分库。
水平数据分片与数据分片区别在于:水平数据分片首先将数据表进行水平拆分,然后按照某一分片规则存储在多台数据库服务器上。从而将单库的压力分摊到了多库上,从而避免因为数据库硬件资源有限导致的数据库性能瓶颈。
-
每个库的结构都一样;
-
每个库的数据都不一样,没有交集;
-
所有库的并集是全量数据;
4.DBLink
随着业务复杂程度的提高、数据规模的增长,越来越多的公司选择对其在线业务数据库进行垂直或水平拆分,甚至选择不同的数据库类型以满足其业务需求。与此同时,业务的数据被“散落”在各个数据库实例中。如何方便地对这些数据进行汇总查询,已经成为困扰用户的一大问题。
例如,一家电商创业公司,最初的会员、商品、订单数据全部都存放在一个SQLServer实例中。但随着会员数量和交易规模的不断增长,单个SQLServer实例已经支撑不了巨大的业务压力,同时基于成本考虑,将商品和订单表从原来的SQLServer中拆分出来,分别存放到两个不同的MySQL实例中。原先用户连接到一个实例上即可执行一条SQL来关联汇总查询这三张表的数据,但现在由于数据库拆分,无法简易实现这一操作。针对这类问题,提供了一套基于DBLink的解决方案,用户通过一条SQL就能实现跨越多个数据库实例的查询。
可以在当前登录的Oracle上,建立一个DBLink指向另一个远程的Oracle数据库表。
-
跨数据库查询中的DBLink,是一个指向用户的任意数据库实例的虚拟连接,是数据库实例的别名:
-
DBLink和数据库实例一一对应,对于MySQL来说,对应的就是MySQL数据库所在的ip:port
-
DBLink可以指向MySQL、SQLServer、PostgreSQL、Oracle、Redis等;
用户需在SQL语句的库表名前加上DBLink前缀(DBLink.库.表),即可实现跨数据库查询。DBLink的名字由英文字母、数字和下划线组成
DBLink/database/table对应关系数据库系统通常把数据组织成层次结构,如:database、schema、table等,以方便命名空间隔离和权限管理。跨库查询也是类似,它以dblink、database和table这三层结构来组织。
在跨库查询中,用户访问一个表需要指定全名称,即:dblink.database.table。然而,不同数据库类型具有不同的层次组织,为了实现统一查询,需要将这些不同层次结构统一起来,形成DBLink、database和table三层结构。
四、分库分表案例演示
1.分库维度怎么定
这个字段选择的标准是,尽量避免应用代码和 SQL 性能受到影响。具体地说,就是现有的 SQL 在分库后,它的访问尽量落在单个数据库里,否则原来的单库访问就变成了多库扫 描,不但 SQL 的性能会受到影响,而且相应的代码也需要进行改造。
具体到订单数据库的拆分,首先会想到按照用户 ID 来进行拆分。这个结论是没错, 但最好还是要有量化的数据支持,不能拍脑袋。这里最好的做法是,先收集所有 SQL,挑选出 WHERE 语句中最常出现的过滤字段,比 如说这里有三个候选对象,分别是用户 ID、订单 ID 和商家 ID,每个字段在 SQL 中都会出 现三种情况:
-
单 ID 过滤,比如说“用户 ID=?”;
-
多 ID 过滤,比如“用户 ID IN(?,?,?)”;
-
该 ID 不出现。
最后分别统计这三个字段的使用情况,假设共有 500 个 SQL 访问订单库,3 个候选 字 段出现的情况如下:
从这张表来看,结论非常明显,应该选择用户 ID 来进行分库。不过,这只是静态分析。每个 SQL 访问的频率是不一样的,所以还要分析每个 SQL 的实际访问量。在项目中,分析了 Top15 执行次数最多的 SQL (它们占总执行次数 85%,具有足够 代表性),按照执行的次数,如果使用用户 ID 进行分库,这些 SQL 85% 会落到单个数据 库,13% 落到多个数据库,只有 2% 需要遍历所有的数据库。所以说,从 SQL 动态执行次 数的角度来看,用户 ID 分库也明显优于使用其他两个 ID 进行分库。
2.数据怎么分
-
根据 ID 范围进行分库,比如把用户 ID 为 1 ~ 999 的记录分到第一个库,1000 ~ 1999 的分到第二个库,以此类推。
-
根据 ID 取模进行分库,比如把用户 ID mod 10,余数为 0 的记录放到第一个库,余数 为 1 的放到第二个库,以此类推。
在实践中,为了运维方便,选择 ID 取模进行分库的做法比较多。同时为了数据迁移方便, 一般分库的数量是按照倍数增加的,比如说,一开始是 4 个库,二次分裂为 8 个,再分成 16 个。这样对于某个库的数据,在分裂的时候,一半数据会移到新库,剩余的可以不用 动。
与此相反,如果每次只增加一个库,所有记录都要按照新的模数做调整。在这个项目中,结合订单数据的实际情况,最后采用的是取模的方式来拆分记录。补充说明:按照取模进行分库,每个库记录数一般比较均匀,但也有些数据库,存在超 级 ID,这些 ID 的记录远远超过其他 ID。比如在广告场景下,某个大广告主的广告数可 能占很大比例。如果按照广告主 ID 取模进行分库,某些库的记录数会特别多,对于这些 超级 ID,需要提供单独库来存储记录。
3.分几个库
分库数量,首先和单库能处理的记录数有关。一般来说,MySQL 单库超过了 5000 万条记录,Oracle 单库超过了 1 亿条记录,DB 的压力就很大(当然这也和字段数量、字段长度 和查询模式有关系)。
在满足前面记录数量限制的前提下,如果分库的数量太少,达不到分散存储和减轻 DB 性能压力的目的;如果分库的数量太多,好处是单库访问性能好,但对于跨多个库的访问, 应用程序需要同时访问多个库,如果我们并发地访问所有数据库,就意味着要消耗更多的线 程资源;如果是串行的访问模式,执行的时间会大大地增加。另外,分库数量还直接影响了硬件的投入,多一个库,就意味着要多投入硬件设备。所以, 具体分多少个库,需要做一个综合评估,一般初次分库,建议你分成 4~8 个库。在项目中拆分为了 6 个数据库,这样可以满足较长一段时间的订单业务需求。
4.分库路由
分库从某种意义上来说,意味着 DB Schema 改变了,必然会影响应用,但这种改变和业 务无关,所以我们要尽量保证分库相关的逻辑都在数据访问层进行处理,对上层的订单服务 透明,服务代码无需改造。当然,要完全做到这一点会很困难。那么具体哪些改动应该由 DAL(数据访问层)负责, 哪些由订单服务负责,这里我给你一些可行的建议:对于单库访问,比如查询条件已经指定了用户 ID,那么该 SQL 只需访问特定库即可。此时应该由 DAL 层自动路由到特定库,当库二次分裂时,我们也只需要修改取模因子就 可以了,应用代码不会受到影响。对于简单的多库查询,DAL 层负责汇总各个分库返回的记录,此时它仍对上层应用透 明。对于带聚合运算的多库查询,比如说带 groupby、orderby、min、max、avg 等关键 字,建议可以让 DAL 层汇总单个库返回的结果,然后由上层应用做进一步的处理。
5.最终架构
-
上层应用通过订单服务访问数据库;
-
分库代理实现了分库相关的功能,包括聚合运算、订单 ID 到用户 ID 的映射,做到分库 逻辑对订单服务透明;
-
Lookup 表用于订单 ID 和用户 ID 的映射,保证订单服务按订单 ID 访问时,可以直接 落到单个库,Cache 是 Lookup 表数据的缓存;
-
DDAL 提供库的路由,可以根据用户 ID 定位到某个库,对于多库访问,DDAL 支持可 选的多线程并发访问模式,并支持简单的记录汇总;
-
Lookup 表初始化数据来自于现有的分库数据,当新增订单记录时,由分库代理异步写 入。
6.如何安全落地
订单表是系统的核心业务表,它的水平拆分会影响到很多业务,订单服务本身的代码改造也 很大,很容易导致依赖订单服务的应用出现问题。在上线时,必须谨慎考虑。所以,为了保证订单水平分库的总体改造可以安全落地,整个方案的实施过程如下:
-
首先实现 Oracle 和 MySQL 两套库并行,所有数据读写指向 Oracle 库,通过数 据同步程序,把数据从 Oracle 拆分到多个 MySQL 库,比如说 3 分钟增量同步一次。
-
其次选择几个对数据实时性要求不高的访问场景(比如访问历史订单),把订单 服务转向访问 MySQL 数据库,以检验整套方案的可行性。
-
最后经过大量测试,如果性能和功能都没有问题,我们再一次性把所有实时读写访问 转向 MySQL,废弃 Oracle。
这里把上线分成了两个阶段:第一阶段,把部分非实时的功能切换到 MySQL,这个 阶段主要是为了验证技术,它包括了分库代理、DDAL、Lookup 表等基础设施的改造;第 二阶段,主要是验证业务功能,我们把所有订单场景全面接入 MySQL。1 号店两个阶段的 上线都是一次性成功的,特别是第二阶段的上线,100 多个依赖订单服务的应用,通过简 单的重启就完成了系统的升级,中间没有出现一例较大的问题。