随着互联网的高速发展,带来了海量数据存储的问题,比如像物联网行业,每个智能终端每天进行数据采集和上报,每天能够产几千万甚至上亿的数据。在互联网电商行业,或者一些O2O平台,每天也能产生上千万的订单数据,这些量级的数据在传统的关系型数据库中已经无法支撑了,那么如何解决海量数据存储和计算等问题,在业内引入了分布式存储和分布式计算等解决方案,特别是NoSql的生态,我在前面讲过的k-v数据库、文档数据库、图形数据库等,都是比较主流的分布式数据库解决方案。
即便如此,关系型数据库仍然有它不可替代的特性,所以关系型数据库仍然是核心业务的基础数据平台,因此关系型数据库必然会面临数据量日益增长带来的海量数据处理问题。
Mysql数据库海量数据带来的性能问题
目前几乎所有的互联网公司都是采用mysql这个开源数据库,根据阿里巴巴的《Java开发手册》上提到的,当单表行数超过500W行或者单表数据容量超过2G时,就会对查询性能产生较大影响,这个时候建议对表进行优化。
其实500W数据只是一个折中的值,具体的数据量和数据库服务器配置以及mysql配置有关,因为Mysql为了提升性能,会把表的索引装载到内存,innodb_buffer_pool_size 足够的情况下,mysql能把全部数据加载进内存,查询不会有问题。
但是,当单表数据库到达某个量级的上限时,导致内存无法存储其索引,使得之后的 SQL 查询会产生磁盘 IO,从而导致性能下降。当然,这个还有具体的表结构的设计有关,最终导致的问题都是内存限制,这里,增加硬件配置,可能会带来立竿见影的性能提升。
innodb_buffer_pool_size 包含数据缓存、索引缓存等。
Mysql常见的优化手段
当然,我们首先要进行的优化是基于Mysql本身的优化,常见的优化手段有:
- 增加索引,索引是直观也是最快速优化检索效率的方式。
- 基于Sql语句的优化,比如最左匹配原则,用索引字段查询、降低sql语句的复杂度等
- 表的合理设计,比如符合三范式、或者为了一定的效率破坏三范式设计等
- 数据库参数优化,比如并发连接数、数据刷盘策略、调整缓存大小
- 数据库服务器硬件升级
- mysql大家主从复制方案,实现读写分离
这些常见的优化手段,在数据量较小的情况下效果非常好,但是数据量到达一定瓶颈时,常规的优化手段已经解决不了实际问题,那怎么办呢?
大数据表优化方案
对于大数据表的优化最直观的方式就是减少单表数据量,所以常见的解决方案是:
分库分表,大表拆小表。
-
冷热数据分离,所谓的冷热数据,其实就是根据访问频次来划分的,访问频次较多的数据是热数据,访问频次少的数据是冷数据。冷热数据分离就是把这两类数据分离到不同的表中,从而减少热数据表的大小。
其实在很多地方大家都能看到类似的实现,比如去一些网站查询订单或者交易记录,默认只允许查询1到3个月,3个月之前的数据,基本上大家都很少关心,访问频次较少,所以可以把3个月之前的数据保存到冷库中。
历史数据归档,简单来说就是把时间比较久远的数据分离出来存档,保证实时库的数据的有效生命周期。
其实这些解决方案都是属于偏业务类的方案,并不完全是技术上的方案,所以在实施的时候,需要根据业务的特性来选择合适的方式。
详解分库分表
分库分表是非常常见针对单个数据表数据量过大的优化方式,它的核心思想是把一个大的数据表拆分成多个小的数据表,这个过程也叫(数据分片),它的本质其实有点类似于传统数据库中的分区表,比如mysql和oracle都支持分区表机制。
分库分表是一种水平扩展手段,每个分片上包含原来总的数据集的一个子集。这种分而治之的思想在技术中很常见,比如多CPU、分布式架构、分布式缓存等等,像前面我们讲redis cluster集群时,slot槽的分配就是一种数据分片的思想。
如图6-1所示,数据库分库分表一般有两种实现方式:
- 水平拆分,基于表或字段划分,表结构不同,有单库的分表,也有多库的分库。
- 垂直拆分,基于数据划分,表结构相同,数据不同,也有同库的水平切分和多库的切分。
<center>图6-1</center>
垂直拆分
垂直拆分有两种,一种是单库的垂直拆分,另一种是多个数据库的垂直拆分。
单库垂直分表
单个表的字段数量建议控制在20~50个之间,之所以建议做这个限制,是因为如果字段加上数据累计的长度超过一个阈值后,数据就不是存储在一个页上,就会产生分页的问题,而这个问题会导致查询性能下降。
所以如果当某些业务表的字段过多时,我们一般会拆去垂直拆分的方式,把一个表的字段拆分成多个表,如图6-2所示,把一个订单表垂直拆分成一个订单主表和一个订单明细表。
<center>图6-2</center>
在Innodb引擎中,单表字段最大限制为1017
参考: https://dev.mysql.com/doc/mysql-reslimits-excerpt/5.6/en/column-count-limit.html
多库垂直分表
多库垂直拆分实际上就是把存在于一个库中的多个表,按照一定的纬度拆分到多个库中,如图6-3所示。这种拆分方式在微服务架构中也是很常见,基本上会按照业务纬度拆分数据库,同样该纬度也会影响到微服务的拆分,基本上服务和数据库是独立的。
<center>图6-3</center>
多库垂直拆分最大的好处就是实现了业务数据的隔离。其次就是缓解了请求的压力,原本所有的表在一个库的时候,所有请求都会打到一个数据库服务器上,通过数据库的拆分,可以分摊掉请求,在这个层面上提升了数据库的吞吐能力。
水平拆分
垂直拆分的方式并没有解决单表数据量过大的问题,所以我们还需要通过水平拆分的方式把大表数据做数据分片。
水平切分也可以分成两种,一种是单库的,一种是多库的。
单库水平分表
如图6-4所示,表示把一张有10000条数据的用户表,按照某种规则拆分成了4张表,每张表的数据量是2500条。
<center>图6-4</center>
两个案例:
银行的交易流水表,所有进出的交易都需要登记这张表,因为绝大部分时候客户都是查询当天的交易和一个月以内的交易数据,所以我们根据使用频率把这张表拆分成三张表:
当天表:只存储当天的数据。
当月表:我们在夜间运行一个定时任务,前一天的数据,全部迁移到当月表。用的是insert into select,然后delete。
历史表:同样是通过定时任务,把登记时间超过30天的数据,迁移到history历史表(历史表的数据非常大,我们按照月度,每个月建立分区)。
费用表:消费金融公司跟线下商户合作,给客户办理了贷款以后,消费金融公司要给商户返费用,或者叫提成,每天都会产生很多的费用的数据。为了方便管理,我们每个月建立一张费用表,例如fee_detail_201901……fee_detail_201912。
但是注意,跟分区一样,这种方式虽然可以一定程度解决单表查询性能的问题,但是并不能解决单机存储瓶颈的问题。
多库水平分表
多库水平分表,其实有点类似于分库分表的综合实现方案,从分表来说是减少了单表的数据量,从分库层面来说,降低了单个数据库访问的性能瓶颈,如图6-5所示。
<center>图6-5</center>
常见的水平分表策略
分库更多的是关注业务的耦合度,也就是每个库应该放那些表,是由业务耦合度来决定的,这个在前期做领域建模的时候都会先考虑好,所以问题不大,只是分库之后带来的其他问题,我们在后续内容中来分析。
而分表这块,需要考虑的问题会更多一些,也就是我们应该根据什么样的策略来水平分表?这里就需要涉及到分表策略了,下面简单介绍几种最常见的分片策略。
哈希取模分片
哈希分片,其实就是通过表中的某一个字段进行hash算法得到一个哈希值,然后通过取模运算确定数据应该放在哪个分片中,如图6-6所示。这种方式非常适合随机读写的场景中,它能够很好的将一个大表的数据随机分散到多个小表。
<center>图6-6</center>
hash取模的问题
hash取模运算有个比较严重的问题,假设根据当前数据表的量以及增长情况,我们把一个大表拆分成了4个小表,看起来满足目前的需求,但是经过一段时间的运行后,发现四个表不够,需要再增加4个表来存储,这种情况下,就需要对原来的数据进行整体迁移,这个过程非常麻烦。
一般为了减少这种方式带来的数据迁移的影响,我们会采用一致性hash算法。
一致性hash算法
在前面我们讲的hash取模算法,实际上对目标表或者目标数据库进行hash取模,一旦目标表或者数据库发生数量上的变化,就会导致所有数据都需要进行迁移,为了减少这种大规模的数据影响,才引入了一致性hash算法。
如图6-7所示,简单来说,一致性哈希将整个哈希值空间组织成一个虚拟的圆环,如假设某哈希函数H的值空间为0-232-1(即哈希值是一个32位无符号整形),什么意思呢?
就是我们通过0-232-1的数字组成一个虚拟的圆环,圆环的正上方的点代表0,0点右侧的第一个点代表1,以此类推,2、3、4、5、6……直到232-1,也就是说0点左侧的第一个点代表232-1。我们把这个由2的32次方个点组成的圆环称为hash环。
<center>图6-7</center>
那一致性hash算法和上面的虚拟环有什么关系呢?继续回到前面我们讲解hash取模的例子,假设现在有四个表,table_1、table_2、table_3、table_4,在一致性hash算法中,取模运算不是直接对这四个表来完成,而是对232来实现。
hash(table编号)%232
通过上述公式算出的结果一定是一个0到232-1之间的一个整数,然后在这个数对应的位置标注目标表,如图6-8所示,四个表通过hash取模之后分别落在hash环的某个位置上。
<center>图6-8</center>
好了,到目前为止,我们已经把目标表与hash环联系在了一起,那么接下来我们需要把一条数据保存到某个目标表中,怎么做呢?如图6-9所示,当添加一条数据时,同样通过hash和hash环取模运算得到一个目标值,然后根据目标值所在的hash环的位置顺时针查找最近的一个目标表,把数据存储到这个目标表中即可。
<center>图6-9</center>
不知道大家是否发现了一致性hash的好处,就是hash运算不是直接面向目标表,而是面向hash环,这样的好处就是当需要删除某张表或者增加表的时候,对于整个数据变化的影响是局部的,而不是全局。举个例子,假设我们发现需要增加一张表table_04,如图6-10所示,增加一个表,并不会对其他四个已经产生了数据的表造成影响,原来已经分片的数据完全不需要做任何改动。
如果需要删除一个节点,同样只会影响删除节点本身的数据,前后表的数据完全不受影响。
<center>图6-10</center>
hash环偏斜
上述设计有一个问题,理论情况下我们目标表是能够均衡的分布在整个hash环中,但实际情况有可能是图6-11所示的样子。也就是产生了hash环偏斜的现象,这种现象导致的问题就是大量的数据都会保存到同一个表中,倒是数据分配极度不均匀。
<center>图6-11</center>
为了解决这个问题,必须要保证目标节点要均匀的分布在整个hash环中,但是真实的节点就只有4个,如何均匀分布呢?最简单的方法就是,把这四个节点分别复制一份出来分散到这个hash环中,这个复制出来的节点叫虚拟节点,根据实际需要可以虚拟出多个节点出来,如图6-12所示。
<center>图6-12</center>
按照范围分片
按范围分片,其实就是基于数据表的业务特性,按照某种范围拆分,这个范围的有很多含义,比如:
- 时间范围,比如我们按照数据创建时间,按照每一个月保存一个表。基于时间划分还可以用来做冷热数据分离,越早的数据访问频次越少。
- 区域范围,区域一般指的是地理位置,比如一个表里面存储了来自全国各地的数据,如果数据量较大的情况下,可以按照地域来划分多个表。
- 数据范围,比如根据某个字段的数据区间来进行划分。
如图6-7所示,表示按照数据范围进行拆分。
<center>图6-7</center>
范围分片最终要的是选择一个合适的分片键,这个是否合适来自于业务需求,比如之前有个学员是在做智能家居的,他们卖的是硬件设备,这些设备会采集数据上报到服务器上,当来自全国范围的数据统一保存在一个表中后,数据量达到了亿级别,所以这种场景比较适合按照城市和地域来拆分。
分库分表实战
为了让大家理解分库分表以及实操,我们通过一个简单的案例来演示一下。代码详见:springboot-split-table-example项目
假设存在一个用户表,用户表的字段如下。
该表主要提供注册、登录、查询、修改等功能。
<center>图6-8</center>
该表的具体的业务情况如下(需要注意,在进行分表之前,需要了解业务层面对这个表的使用情况,然后再决定使用什么样的方案,否则脱离业务去设计技术方案是耍流氓)
用户端: 前台访问量较大,主要涉及两类请求:
- 用户登录,面向C端,对可用性和一致性要求较高,主要通过login_name、email、phone来查询用户信息,1%的请求属于这种类型
- 用户信息查询,登录成功后,通过uid来查询用户信息,99%属于这种类型。
运营端: 主要是运营后台的信息访问,需要支持根据性别、手机号、注册时间、用户昵称等进行分页查询,由于是内部系统,访问量较低,对可用性一致性要求不高。
根据uid进行水平分表
由于99%的请求是基于uid进行用户信息查询,所以毫无疑问我们选择使用uid进行水平分表。那么这里我们采用uid的hash取模方法来进行分表,具体的实施如图6-9所示,根据uid进行一致性hash取模运算得到目标表进行存储。
<center>图6-9</center>
按照图6-9的结构,分别复制user_info表,重新命名为01~04,如图6-10所示。
<center>图6-10</center>
如何生成全局唯一id
当完成上述动作后,就需要开始开始落地实施,这里需要考虑在数据添加、修改、删除时,要正确路由到目标数据表,其次是老数据的迁移。
老数据迁移,一般我们是写一个脚本或者一个程序,把旧表中的数据查询出来,然后根据分表规则重新路由分发到新的表中,这里不是很复杂,就不做展开说明,我们重点说一下数据添加/修改/删除的路由。
在实施之前,我们需要先考虑一个非常重要的问题,就是在单个表中,我们使用递增主键来保证数据的唯一性,但是如果把数据拆分到了四个表,每个表都采用自己的递增主键规则,就会存在重复id的问题,也就是说递增主键不是全局唯一的。
我们需要知道一个点是,user_info虽然拆分成了多张表,但是本质上它应该还是一个完整的数据整体,当id存在重复的时候,就失去了数据的唯一性,因此我们需要考虑如何生成一个全局唯一ID。
如何实现全局唯一ID
全局唯一ID的特性就是能够保证ID的唯一性,那么基于这个特性,我们可以轻松找到很多的解决方案。
- 数据库自增ID(定义全局表)
- UUID
- Redis的原子递增
- Twitter-Snowflake算法
- 美团的leaf
- MongoDB的ObjectId
- 百度的UidGenerator
分布式ID的特性
- 唯一性:确保生成的ID是全局唯一的。
- 有序递增性:确保生成的ID是对于某个用户或者业务是按一定的数字有序递增的。
- 高可用性:确保任何时候都能正确的生成ID。
- 带时间:ID里面包含时间,一眼扫过去就知道哪天的数据
数据库自增方案
在数据库中专门创建一张序列表,利用数据库表中的自增ID来为其他业务的数据生成一个全局ID,那么每次要用ID的时候,直接从这个表中获取即可。
CREATE TABLE `uid_table` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`business_id` int(11) NOT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE (business_type)
)
在应用程序中,每次调用下面这段代码,就可以持续获得一个递增的ID。
begin;
REPLACE INTO uid_table (business_id) VALUES (2);
SELECT LAST_INSERT_ID();
commit;
其中,replace into是每次删除原来相同的数据,同时加1条,就能保证我们每次得到的就是一个自增的ID
这个方案的优点是非常简单,它也有缺点,就是对于数据库的压力比较大,而且最好是独立部署一个DB,而独立部署又会增加整体的成本,这个在美团的leaf里面设计了一个很巧妙的设计方案,后面再讲
优点:
- 非常简单,利用现有数据库系统的功能实现,成本小,有DBA专业维护。
- ID号单调自增,可以实现一些对ID有特殊要求的业务。
缺点:
- 强依赖DB,当DB异常时整个系统不可用,属于致命问题。配置主从复制可以尽可能的增加可用性,但是数据一致性在特殊情况下难以保证。主从切换时的不一致可能会导致重复发号。
- ID发号性能瓶颈限制在单台MySQL的读写性能。
UUID
UUID的格式是: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 8-4-4-4-12共36个字符,它是一个128bit的二进制转化为16进制的32个字符,然后用4个-
连接起来的字符串。
UUID的五种生成方式
- 基于时间的UUID(date-time & MAC address): 主要依赖当前的时间戳及机器mac地址,因此可以保证全球唯一性。(使用了Mac地址,因此会暴露Mac地址和生成时间。)
- 分布式安全的UUID(date-time & group/user id)将版本1的时间戳前四位换为POSIX的UID或GID。
- 基于名字空间的UUID-MD5版(MD5 hash & namespace),基于指定的名字空间/名字生成MD5散列值得到,标准不推荐。
- 基于随机数的UUID(pseudo-random number):基于随机数或伪随机数生成。
- 基于名字空间的UUID-SHA1版(SHA-1 hash & namespace):将版本3的散列算法改为SHA1
在Java中,提供了基于MD5算法的UUID、以及基于随机数的UUID。
优点:
- 本地生成,没有网络消耗,生成简单,没有高可用风险。
缺点:
- 不易于存储:UUID太长,16字节128位,通常以36长度的字符串表示,很多场景不适用。
- 信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。
- 无序查询效率低:由于生成的UUID是无序不可读的字符串,所以其查询效率低。
- UUID不适合用来做数据库的唯一ID,如果用UUID做主键,无序的不递增,大家都知道,主键是有索引的,然后mysql的索引是通过b+树来实现的,每一次新的UUID数据的插入,为了查询的优化,都会对索引底层的b+树进行修改,因为UUID数据是无序的,所以每一次UUID数据的插入都会对主键的b+树进行很大的修改,严重影响性能
雪花算法
SnowFlake 算法,是 Twitter 开源的分布式 id 生成算法。其核心思想就是:使用一个 64 bit 的 long 型的数字作为全局唯一 id。雪花算法比较常见,在百度的UidGenerator、美团的Leaf中,都有用到雪花算法的实现。
如图6-11所示,表示雪花算法的组成,一共64bit,这64个bit位由四个部分组成。
第一部分,1bit位,用来表示符号位,而ID一般是正数,所以这个符号位一般情况下是0。
第二部分,占41 个 bit:表示的是时间戳,是系统时间的毫秒数,但是这个时间戳不是当前系统的时间,而是当前
系统时间-开始时间
,更大的保证这个ID生成方案的使用的时间!-
那么我们为什么需要这个时间戳,目的是为了保证有序性,可读性,我一看我就能猜到ID是什么时候生成的。
41位可以241 - 1表示个数字,
如果只用来表示正整数(计算机中正数包含0),可以表示的数值范围是:0 至 241-1,减1是因为可表示的数值范围是从0开始算的,而不是1。
也就是说41位可以表示241-1个毫秒的值,转化成单位年则是(241-1)/1000 * 60 * 60 * 24 *365=69年,也就是能容纳69年的时间
-
第三部分,用来记录工作机器id,id包含10bit,意味着这个服务最多可以部署在 2^10 台机器上,也就是 1024 台机器。
其中这10bit又可以分成2个5bit,前5bit表示机房id、5bit表示机器id,意味着最多支持2^5个机房(32),每个机房可以支持32台机器。
-
第四部分,第四部分由12bit组成,它表示一个递增序列,用来记录同毫秒内产生的不同id。
那么我们为什么需要这个序列号,设想下,如果是同一毫秒同一台机器来请求,那么我们怎么保证他的唯一性,这个时候,我们就能用到我们的序列号,
目的是为了保证同一毫秒内同一机器生成的ID是唯一的,这个其实就是为了满足我们ID的这个高并发,就是保证我同一毫秒进来的并发场景的唯一性
12位(bit)可以表示的最大正整数是2^12-1=4095,即可以用0、1、2、3、....4094这4095个数字,来表示同一机器同一时间截(毫秒)内产生的4095个ID序号。
12位2进制,如果全部都是1的情况下,那么最终的值就是4095,也就是12bit能够存储的最大的数字是4095.
<center>图6-11</center>
分库分表之后的数据DML操作
有序需要用到全局id,所以在user_info表需要添加一个唯一id的字段。
<center>图6-12</center>
配置完成之后,在如下代码中引入signal方法。
@Slf4j
@RestController
@RequestMapping("/users")
public class UserInfoController {
@Autowired
IUserInfoService userInfoService;
SnowFlakeGenerator snowFlakeGenerator=new SnowFlakeGenerator(1,1,1);
@PostMapping("/batch")
public void user(@RequestBody List<UserInfo> userInfos){
log.info("begin UserInfoController.user");
userInfoService.saveBatch(userInfos);
}
@PostMapping
public void signal(@RequestBody UserInfo userInfo){
Long bizId=snowFlakeGenerator.nextId();
userInfo.setBizId(bizId);
String table=ConsistentHashing.getServer(bizId.toString());
log.info("UserInfoController.signal:{}",table);
MybatisPlusConfig.TABLE_NAME.set(table);
userInfoService.save(userInfo);
}
}
并且,需要增加一个mybatis拦截器,针对user_info表进行拦截和替换,从而实现动态表的路由。
@Configuration
public class MybatisPlusConfig {
public static ThreadLocal<String> TABLE_NAME = new ThreadLocal<>();
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
interceptor.addInnerInterceptor(paginationInnerInterceptor);
DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor = new DynamicTableNameInnerInterceptor();
Map<String, TableNameHandler> tableNameHandlerMap = new HashMap<>();
tableNameHandlerMap.put("user_info", (sql, tableName) -> TABLE_NAME.get());
dynamicTableNameInnerInterceptor.setTableNameHandlerMap(tableNameHandlerMap);
interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
return interceptor;
}
}
至此,一个基础的分库分表的演练就完成了,但问题仍然还未完全解决。
非分片键查询
我们对user_info表的分片,是基于biz_id来实现的,也就是意味着如果我们想查询某张表的数据,必须先要使用biz_id路由找到对应的表才能查询到。
那么问题来了,如果查询的字段不是分片键(也就是不是biz_id),比如本次分库分表实战案例中,运营端查询就有根据名字、手机号、性别等字段来查,这时候我们并不知道去哪张表查询这些信息。
非分片键和分片键建立映射关系
第一种解决办法就是,把非分片键和分片键建立映射关系,比如login_name -> biz_id 建立映射,相当于建立一个简单的索引,当基于login_name查询数据时,先通过映射表查询出login_name对应的biz_id,再通过biz_id定位到目标表。
映射表的只有两列,可以成再很多的数据,当数据量过大时,也可以对映射表做水平拆分。 同时这种映射关系其实就是k-v键值对的关系,所以我们可以使用k-v缓存来存储提升性能。
同时因为这种映射关系的变更频率很低,所以缓存命中率很高,性能也很好。
用户端数据库和运营端数据库进行分离
运营端的查询可能不止于单个字段的映射来查询,可能更多的会涉及到一些复杂查询,以及分页查询等,这种查询本身对数据库性能影响较大,很可能影响到用户端对于用户表的操作,所以一般主流的解决方案就是把两个库进行分离。
由于运营端对于数据的一致性和可用性要求不是很高,也不需要实时访问数据库,所以我们可以把C端用户表的数据同步到运营端的用户表,而且用户表可以不需要做分表操作,直接全量查表即可。
当然,如果运营端的操作性能实在是太慢了,我们还可以采用ElasticSearch搜索引擎来满足后台复杂查询的需求。
实际应用中会遇到的问题
在实际应用中,并不是一开始就会想到未来会对这个表做拆分,因此很多时候我们面临的问题是在数据量已经达到一定瓶颈的时候,才开始去考虑这个问题。
所以分库分表最大的难点不是在于拆分的方法论,而是在运行了很长时间的数据库中,如何根据实际业务情况选择合适的拆分方式,以及在拆分之前对于数据的迁移方案的思考。而且,在整个数据迁移和拆分过程中,系统仍然需要保持可用。
对于运行中的表的分表,一般会分为三个阶段。
阶段一,新老库双写
由于老的数据表肯定没有考虑到未来分表的设计,同时随着业务的迭代,可能有些模型也需要优化,因此会设计一个新的表来承载老的数据,而这个过程中,需要做几件事情
- 数据库表的双写,老的数据库表和新的数据库表同步写入数据,事务的成功以老的模型为准,查询也走老的模型
- 通过定时任务对数据进行核对,补平差异
- 通过定时任务把历史数据迁移到新的模型中
阶段二,以新的模型为准
到了第二个阶段,历史数据已经导完了,并且校验数据没有问题。
- 仍然保持数据双写,但是事务的成功和查询都以新模型为准。
- 定时任务进行数据核对,补平数据差异
阶段三,结束双写
到了第三个阶段,说明数据已经完全迁移好了,因此。
- 取消双写,所有数据只需要保存到新的模型中,老模型不需要再写入新的数据。
- 如果仍然有部分老的业务依赖老的模型,所以等到所有业务都改造完成后, 再废除老的模型。
分库分表后带来的问题
分库分表带来性能提升的好处的同时,也带来了很多的麻烦,
分布式事务问题
分库分表之后,原本在一个库中的事务,变成了跨越多个库,如何保证跨库数据的一致性问题,也是一个常见的难题。如图6-13所示,用户创建订单时,需要在订单库中保存一条订单记录,并且修改库存库中的商品库存,这里就涉及到跨库事务的一致性问题。也就是说我怎么保证当前两个事务操作要么同时成功,要么同时失败。
<center>图6-13</center>
跨库查询
比如查询在合同信息的时候要关联客户数据,由于是合同数据和客户数据是在不同的数据库,那么我们肯定不能直接使用join的这种方式去做关联查询。
我们有几种主要的解决方案:
- 字段冗余,比如我们查询合同库的合同表的时候需要关联客户库的客户表,我们可以直接把一些经常关联查询的客户字段放到合同表,通过这种方式避免跨库关联查询的问题。
- 数据同步:比如商户系统要查询产品系统的产品表,我们干脆在商户系统创建一张产品表,通过ETL或者其他方式定时同步产品数据。
- 全局表(广播表) 比如基础数据被很多业务系统用到,如果我们放在核心系统,每个系统都要去关联查询,这个时候我们可以在所有的数据库都存储相同的基础数据。
- ER表(绑定表),我们有些表的数据是存在逻辑的主外键关系的,比如订单表order_info,存的是汇总的商品数,商品金额;订单明细表order_detail,是每个商品的价格,个数等等。或者叫做从属关系,父表和子表的关系。他们之间会经常有关联查询的操作,如果父表的数据和子表的数据分别存储在不同的数据库,跨库关联查询也比较麻烦。所以我们能不能把父表和数据和从属于父表的数据落到一个节点上呢?比如order_id=1001的数据在node1,它所有的明细数据也放到node1;order_id=1002的数据在node2,它所有的明细数据都放到node2,这样在关联查询的时候依然是在一个数据库。
上面的思路都是通过合理的数据分布避免跨库关联查询,实际上在我们的业务中,也是尽量不要用跨库关联查询,如果出现了这种情况,就要分析一下业务或者数据拆分是不是合理。如果还是出现了需要跨库关联的情况,那我们就只能用最后一种办法。
- 系统层组装
在不同的数据库节点把符合条件数据的数据查询出来,然后重新组装,返回给客户端。
排序、翻页、函数计算等问题
跨节点多库进行查询时,会出现limit分页,order by排序的问题。比如有两个节点,节点1存的是奇数id=1,3,5,7,9……;节点2存的是偶数id=2,4,6,8,10……
执行select * from user_info order by id limit 0,10
需要在两个节点上各取出10条,然后合并数据,重新排序。
max、min、sum、count之类的函数在进行计算的时候,也需要先在每个分片上执行相应的函数,然后将各个分片的结果集进行汇总和再次计算,最终将结果返回。
全局唯一ID
全局唯一id的问题,前面已经说了,水平分表之后,需要考虑全局唯一id设计问题。
多数据源的问题
分库分表之后,难免会存在一个应用配置多个数据源。
另外,数据库层面有可能会设计读写分离的方案,也使得一个应用会访问多个数据源,并且还需要实现读写分离的动态路由。
而这些问题在每个应用系统中都会存在并且需要解决,所以为了提供统一的分库分表相关问题的解决方案,引入了很多的开源技术。
分库分表解决方案
目前市面上分库分表的中间件相对来说说比较多,比如
- Cobar,淘宝开源的分库分表组件,目前基本上没有维护了。
- Sharding-Sphere,当当开源的一个分库分表组件,已经捐献给了Apache基金会
- Atlas, 奇虎360开源的分库分表组件,也是没怎么维护了
- Mycat,从阿里cobar升级而来,由开源组织维护。
- Vitess,谷歌开源的分库分表组件
目前很多公司选择较多的是Mycat或者Sharding-Sphere,所以我重点介绍Sharding-Sphere的使用和原理。
对于同类技术的选择,无非就是看社区活跃度、技术的成熟度、以及功能和当前需求是否匹配。