千万级并发架构下,如何进行关系型数据库的分库分表

39 篇文章 6 订阅
20 篇文章 2 订阅


最近项目上线后由于用户量的剧增,导致数据库的数据量剧增,随之而来的就是海量数据存储的问题,针对最近解决数据的优化过程,谈谈sql语句的优化以及数据库分库分表的方案。
建议大家先阅读一下数据库的优化方案 《数据库大数据量的优化方案》,里面从

  • 1.优化现有数据库:
    • 数据库设计之初的考虑
    • sql编写的注意
    • 分区、分库、分表、分库
  • 2.升级数据库类型
  • 3.更换nosql或habas

以上三个方面去提供方案和优化的办法和思路
本文主要讲一下具体如何去分库分表的方案


面试常见问题

在研究如何分库分表之前我们先回答几个常见的面试问题:

  • 问题1:什么是分库分表

当表中数据量过大时,整个查询效率就会降低分非常明显,这时为了提升查询效率,就要将一个表中的数据分散到多个数据库的多个数据表当中。

  • 问题2:分库分表有哪些方式呢?

分库分表包含:分库和分表两个部分,而这两个部分可以统称为数据分片,其目的都是将数据拆分为不同的存储单元。另外,从分拆的角度上,可以分为垂直分片水平分片

  • 垂直分片:按照业务来对数据进行分片,又称纵向分片。其核心理念就是专库专用。在拆分之前,一个数据库由多个数据表组成,每个表对应不同的业务。而拆分之后,则是按照业务将表进行归类,分布到不同的数据库或表中,从而将压力分散至不同的数据库或表中。能够解决数据库数据文件过大的问题,但是不能从根本上 上解决查询问题。
  • 水平分片:从数据角度将一个表中的数据拆分到不同的库或表中,这样可以从根本上解决由于数据量过大导致的查询效率过低的问题。
  • 问题3:分片策略
  • 按照主键取模,如按照id%2=0和 id%2=1的方式分散数据到不同的表中(具体和几取模应该由你分散的表的数量来决定 ,如果你拆分成4张表,那么就要和4取模),好处就是:均匀存储数据,但是不易扩展
  • 按照范围分片:比较好扩展,但是数据不平均
  • 按照时间分片:比较容易将热点数据区分出来
  • 按照枚举值分片:例如按照地区分片
  • hash环分片
  • 问题4:多大数据量就需要分库分表

当单表行数超过500W行或者单表数据容量超过2G时,就会对查询性能产生较大影响,这个时候建议对表进行优化。

  • 问题5:分库分表常用的组件

Mycat、ShardingSphere

  • 问题6:分库分表之后的执行流程

在这里插入图片描述

红色部分就是分库分表之后的执行流程

  • sql解析:解析sql执行的内容,如解析从哪个表哪个库中获取数据。
    SQL语句经过解析引擎的词法解析、语法解析,形成语法树
    在这里插入图片描述
  • 路由引擎:
    在这里插入图片描述

1.Mysql数据库海量数据带来的性能问题

目前几乎所有的互联网公司都是采用mysql这个开源数据库,根据阿里巴巴的《Java开发手册》上提到的,当单表行数超过500W行或者单表数据容量超过2G时,就会对查询性能产生较大影响,这个时候建议对表进行优化。

其实500W数据只是一个折中的值,具体的数据量和数据库服务器配置以及mysql配置有关,因为Mysql为了提升性能,会把表的索引装载到内存,innodb_buffer_pool_size 足够的情况下,mysql能把全部数据加载进内存,查询不会有问题。

但是,当单表数据库到达某个量级的上限时,导致内存无法存储其索引,使得之后的 SQL 查询会产生磁盘 IO,从而导致性能下降。当然,这个还有具体的表结构的设计有关,最终导致的问题都是内存限制,这里,增加硬件配置,可能会带来立竿见影的性能提升。

innodb_buffer_pool_size 包含数据缓存、索引缓存等。

2.Mysql常见的优化手段

当然,我们首先要进行的优化是基于Mysql本身的优化,常见的优化手段有:

  • 增加索引,索引是直观也是最快速优化检索效率的方式。
  • 基于Sql语句的优化,比如最左匹配原则,用索引字段查询、降低sql语句的复杂度等
  • 表的合理设计,比如符合三范式、或者为了一定的效率破坏三范式设计等
  • 数据库参数优化,比如并发连接数、数据刷盘策略、调整缓存大小
  • 数据库服务器硬件升级
  • mysql主从复制方案,实现读写分离

这些常见的优化手段,在数据量较小的情况下效果非常好,但是数据量到达一定瓶颈时,常规的优化手段已经解决不了实际问题,那怎么办呢?
下面就开始进入我们本文的核心部分

3.大数据表优化方案

对于大数据表的优化最直观的方式就是减少单表数据量,所以常见的解决方案是:

  • 分库分表,大表拆小表。
  • 冷热数据分离,所谓的冷热数据,其实就是根据访问频次来划分的,访问频次较多的数据是热数据,访问频次少的数据是冷数据。冷热数据分离就是把这两类数据分离到不同的表中,从而减少热数据表的大小。

其实在很多地方大家都能看到类似的实现,比如去一些网站查询订单或者交易记录,默认只允许查询1到3个月,3个月之前的数据,基本上大家都很少关心,访问频次较少,所以可以把3个月之前的数据保存到冷库中。

  • 历史数据归档,简单来说就是把时间比较久远的数据分离出来存档,保证实时库的数据的有效生命周期。

其实这些解决方案都是属于偏业务类的方案,并不完全是技术上的方案,所以在实施的时候,需要根据业务的特性来选择合适的方式。

4.详解分库分表

分库分表是非常常见针对单个数据表数据量过大的优化方式,它的核心思想是把一个大的数据表拆分成多个小的数据表,这个过程也叫(数据分片),它的本质其实有点类似于传统数据库中的分区表,比如mysql和oracle都支持分区表机制。

分库分表是一种水平扩展手段,每个分片上包含原来总的数据集的一个子集。这种分而治之的思想在技术中很常见,比如多CPU、分布式架构、分布式缓存等等,像redis cluster集群时,slot槽的分配就是一种数据分片的思想。

数据库分库分表一般有两种实现方式:

  • 水平拆分,基于表或字段划分,表结构不同,有单库的分表,也有多库的分库。
  • 垂直拆分,基于数据划分,表结构相同,数据不同,也有同库的水平切分和多库的切分。

4.1 垂直拆分

垂直 拆分也有两种方式:

  • 1.单库的垂直拆分
  • 2.多个数据库的垂直拆分

4.1.1 单库的垂直拆分

单个表的字段数量建议控制在20~50个之间,之所以建议做这个限制,是因为如果字段加上数据累计的长度超过一个阈值后,数据就不是存储在一个页上,就会产生分页的问题,而这个问题会导致查询性能下降。

所以如果当某些业务表的字段过多时,我们一般会拆去垂直拆分的方式,把一个表的字段拆分成多个表,如:把一个订单表垂直拆分成一个订单主表和一个订单明细表。

在Innodb引擎中,单表字段最大限制为1017

4.1.2 多库垂直拆分

多库垂直拆分实际上就是把存在于一个库中的多个表,按照一定的纬度拆分到多个库中。

这种拆分方式在微服务架构中也是很常见,基本上会按照业务纬度拆分数据库,同样该纬度也会影响到微服务的拆分,基本上服务和数据库是独立的。
在这里插入图片描述

多库垂直拆分最大的好处就是实现了业务数据的隔离。其次就是缓解了请求的压力,原本所有的表在一个库的时候,所有请求都会打到一个数据库服务器上,通过数据库的拆分,可以分摊掉请求,在这个层面上提升了数据库的吞吐能力。

但是这种解决方式并没有解决单表数据量过大的问题,所以我们还需要通过水平拆分的方式把大表数据做数据分片。

4.2 水平拆分

水平切分也可以分成两种,一种是单库的,一种是多库的。

4.2.1 单库水平分表

假如我们把一张有1万条数据的用户信息表,按照某种规则 拆分成4张表,每张表的数据量是2500条,如下所示:
在这里插入图片描述

我们通过两个简单的案例去理解一下单库的水平 拆分是一个怎样的概念。
银行的交易流水表,所有进出的交易都需要登记这张表,因为绝大部分时候客户都是查询当天的交易和一个月以内的交易数据,所以我们根据使用频率把这张表拆分成三张表:

  • 当天表:只存储当天的数据。
  • 当月表:我们在夜间运行一个定时任务,前一天的数据,全部迁移到当月表。用的是insert into select,然后delete。
  • 历史表:同样是通过定时任务,把登记时间超过30天的数据,迁移到history历史表(历史表的数据非常大,我们按照月度,每个月建立分区)。

费用表:消费金融公司跟线下商户合作,给客户办理了贷款以后,消费金融公司要给商户返费用,或者叫提成,每天都会产生很多的费用的数据。为了方便管理,我们每个月建立一张费用表,例如fee_detail_202201 …fee_detail_202202 … fee_detail_202212。
这种分表的方式和分区一样,虽然可以一定程度上解决单表的查询性能,但是并不能解决单机存储瓶颈的问题。

4.2.2 多库水平分表

多库水平分表,其实有点类似于分库分表的综合实现方案,从分表来说是减少了单表的数据量,从分库层面来说,降低了单个数据库访问的性能瓶颈。
在这里插入图片描述

4.2.3 常见的水平分表策略

分库更多的是关注业务的耦合度,也就是每个库应该放那些表,是由业务耦合度来决定的,这个在前期做领域建模的时候都会先考虑好,所以问题不大,只是分库之后带来的其他问题,我们在后续内容中来分析。
而分表这块,需要考虑的问题会更多一些,也就是我们应该根据什么样的策略来水平分表?这里就需要涉及到分表策略了,下面简单介绍几种最常见的分片策略。

哈希取模分片

哈希分片,其实就是通过表中的某一个字段进行hash算法得到一个哈希值,然后通过取模运算确定数据应该放在哪个分片中。这种方式非常适合随机读写的场景中,它能够很好的将一个大表的数据随机分散到多个小表。
在这里插入图片描述

这种分表策略我们可能或遇到一些问题:hash取模运算有个比较严重的问题,假设根据当前数据表的量以及增长情况,我们把一个大表拆分成了4个小表,看起来满足目前的需求,但是经过一段时间的运行后,发现四个表不够,需要再增加4个表来存储,这种情况下,就需要对原来的数据进行整体迁移,这个过程非常麻烦。

同时考虑在分布式缓存下,如一个Redis集群中,如果我们把一条数据经过Hash,然后再根据集群节点数取模得出应该放在哪个节点,这种做法的缺陷在于:扩容(增加一个节点)之后,有大量缓存失效。

可能说这么多,一般的hash取模分配算法的弊端还是不明显,我们举个具体的例子探讨一下:
比如你有 N 个 cache 服务器(当然也可以取数据库服务器为例子)(后面简称 cache ),那么如何将一个对象 object 映射到 N 个 cache 上呢,你很可能会采用类似下面的通用方法计算 object 的 hash 值,然后均匀的映射到到 N 个 cache ;

hash(object)%N

一切都运行正常,再考虑如下的两种情况;

  • 一个 cache 服务器,编号:m down 掉了(在实际应用中必须要考虑这种情况),这样所有映射到 cache m 的对象都会失效,怎么办,需要把 cache m 从 cache 中移除,这时候 cache 是 N-1 台,映射公式变成了 hash(object)%(N-1) ;

  • 由于访问加重,需要添加 cache ,这时候 cache 是 N+1 台,映射公式变成了 hash(object)%(N+1) ;

这意味着突然之间几乎所有的 cache 都失效了。对于服务器而言,这是一场灾难,洪水般的访问都会直接冲向后台服务器;(造成缓存雪崩机制)。

一般为了减少这种方式带来的数据迁移的影响,我们会采用一致性hash算法

一致性hash算法

hash取模算法,实际上对目标表或者目标数据库进行hash取模,一旦目标表或者数据库发生数量上的变化,就会导致所有数据都需要进行迁移,为了减少这种大规模的数据影响,才引入了一致性hash算法。

简单来说,一致性哈希将整个哈希值空间组织成一个虚拟的圆环,如假设某哈希函数H的值空间为0~2^32-1(即哈希值是一个32位无符号整形)

就是我们通过0-2^32 - 1的数字组成一个虚拟的圆环,圆环的正上方的点代表0,0点右侧的第一个点代表1,以此类推,2、3、4、5、6……直到2^32- 1,也就是说0点左侧的第一个点代表2^32-1。我们把这个由2的32次方个点组成的圆环称为hash环。
在这里插入图片描述

说了这一堆还是没说到一致性hash算法,那一致性hash算法和上面的虚拟环有什么关系呢?

继续回到前面我们讲解hash取模的例子,假设现在有四个表,table_1、table_2、table_3、table_4,在一致性hash算法中,取模运算不是直接对这四个表来完成,而是对2^32来实现,公式如下:
$
hash(table编号)mod 2^{32}
$
通过上述公式算出的结果一定是一个0到2^32-1之间的一个整数,然后在这个数对应的位置标注目标表,四个表通过hash取模之后分别落在hash环的某个位置上。
在这里插入图片描述

接下来我们就需要考虑把一条数据保存到某个目标表中,怎么做呢?

当添加一条数据时,同样通过hash和hash环取模运算得到一个目标值,然后根据目标值所在的hash环的位置顺时针查找最近的一个目标表,把数据存储到这个目标表中即可。
在这里插入图片描述

读到此处,我们其实 不难发现一致性 hash算法不是直接面向目标表,而是面向hash环,这样的好处就是当需要删除某张表或者增加表的时候,对于整个数据变化的影响是局部的,而不是全局。

举个简单的例子帮助大家理解一下这句话的含义:
假设我们发现需要增加一张表table_04
在这里插入图片描述

通过上图我们可以看到增加一个表,并不会对其他四个已经产生了数据的表造成影响,原来已经分片的数据完全不需要做任何改动。

如果需要删除一个节点,同样只会影响删除节点本身的数据,前后表的数据完全不受影响。

为对象选择机器

同时,如果我们搭建的是数据库集群,我们同样的将对象和机器都放置到同一个hash环后,在hash环上顺时针查找距离这个对象的hash值最近的机器,即是这个对象所属的机器。
在这里插入图片描述

例如,对于对象o2,顺序针找到最近的机器是c1,故机器c1会缓存对象o2。而机器c2则缓存o3,o4,机器c3则缓存对象o1。

是不是感觉这种分表策略已经很ok了?其实上面我们所考虑的都是理想状态即目标表是能够均衡的分布在整个hash环中,但实际情况有可能是下图所示的样子
在这里插入图片描述

这种就是hash环偏斜的现象,这种现象导致的问题就是大量的数据都会保存到同一个表中,导致数据分配极度不均匀,从而使我们的分表效果不明显。

例如:系统中只有两台服务器,此时必然造成大量数据集中到Node 2上,而只有极少量会定位到Node 1上。其环分布如下:
在这里插入图片描述

针对这种情况我们也不是没办法解决

hash环偏斜

我们的目标是保证目标节点要均匀的分布在整个hash环中。

但是真实的节点就只有4个,如何均匀分布呢?

最简单的方法就是,把这四个节点分别复制一份出来分散到这个hash环中,这个复制出来的节点叫虚拟节点,根据实际需要可以虚拟出多个节点出来。
在这里插入图片描述
映射关系:缓存数据 ➜ 虚拟节点 ➜ 真实节点
具体做法:可以在服务器IP或主机名的后面增加编号来实现,例如上面的情况,可以为每个服务节点增加三个虚拟节点,于是可以分为 RedisService1#1、 RedisService1#2、 RedisService1#3、 RedisService2#1、 RedisService2#2、 RedisService2#3
在这里插入图片描述
对于hash环来说,节点越多,数据分布越平稳。所以采用虚拟节点的方式,将一个节点虚拟成多个节点,保证环上有1000~2000个节点最佳。
一般10个Redis服务器的集群,每个节点可以虚拟100-200个节点,保证环上有1000-2000个节点;
一般5个Redis集群,则每个节点虚拟200-400个节点,保证节点数是1000-2000之间,这样才能保证数据分布均衡。

引入虚拟节点后,会提高节点的均衡度,还会提高系统的稳定性。

按照范围分片

按范围分片,其实就是基于数据表的业务特性,按照某种范围拆分,这个范围的有很多含义,比如:

  • 时间范围,比如我们按照数据创建时间,按照每一个月保存一个表。基于时间划分还可以用来做冷热数据分离,越早的数据访问频次越少。
  • 区域范围,区域一般指的是地理位置,比如一个表里面存储了来自全国各地的数据,如果数据量较大的情况下,可以按照地域来划分多个表。
  • 数据范围,比如根据某个字段的数据区间来进行划分。

时间范围以及区域范围都很好理解,我们看看数据范围,如下所示:
在这里插入图片描述

范围分片最终要的是选择一个合适的分片键,这个是否合适来自于业务需求。
如果你的数据来自全国各地,同时又要统计每个地区销售量,此时我们就可以按照城市和地域划分。

5.一致性hash算法的实现

把上面的原理再给大家总结一下:
先构造一个长度为2^32 的整数环(这个环被称为一致性Hash环),根据节点名称的Hash值(其分布为[0, 2^32 -1])将服务器节点放置在这个Hash环上,然后根据数据的Key值计算得到其Hash值(其分布也为[0, 2^32 -1]),接着在Hash环上顺时针查找距离这个Key值的Hash值最近的服务器节点,完成Key到服务器的映射查找。

这种算法解决了普通余数Hash算法伸缩性差的问题,可以保证在上线、下线服务器的情况下尽量有多的请求命中原来路由到的服务器。

Everything have two sides,一致性Hash算法比普通Hash算法更具有伸缩性,但是同时其算法实现也更为复杂,本文就来研究一下,如何利用Java代码实现一致性Hash算法。在开始之前,先对一致性Hash算法中的几个核心问题进行一些探究。

5.1 数据结构的选取

一致性Hash算法最先要考虑的一个问题是:构造出一个长度为2^32 的整数环,根据节点名称的Hash值将服务器节点放置在这个Hash环上。

那么,整数环应该使用何种数据结构,才能使得运行时的时间复杂度最低?

5.1.1 解决方案1:排序+List

第一种思路是:算出所有待加入数据结构的节点名称的Hash值放入一个数组中,然后使用某种排序算法将其从小到大进行排序,最后将排序后的数据放入List中,采用List而不是数组是为了结点的扩展考虑。

之后,待路由的结点,只需要在List中找到第一个Hash值比它大的服务器节点就可以了 ,比如服务器节点的Hash值是[0,2,4,6,8,10],带路由的结点是7,只需要找到第一个比7大的整数,也就是8,就是我们最终需要路由过去的服务器节点。

如果暂时不考虑前面的排序,那么这种解决方案的时间复杂度:

(1)最好的情况是第一次就找到,时间复杂度为O(1)

(2)最坏的情况是最后一次才找到,时间复杂度为O(N)

平均下来时间复杂度为O(0.5N+0.5),忽略首项系数和常数,时间复杂度为O(N)。
在这里插入图片描述

可以得出一个结论:排序算法要么稳定但是时间复杂度高、要么时间复杂度低但不稳定,看起来最好的归并排序法的时间复杂度仍然有O(N * logN),稍微耗费性能了一些。

既然排序操作比较耗性能,那么能不能不排序?可以的,所以进一步的,有了第二种解决方案。

5.1.2 解决方案2:遍历+List

解决方案使用List不变,不过可以采用遍历的方式:

(1)服务器节点不排序,其Hash值全部直接放入一个List中

(2)带路由的节点,算出其Hash值,由于指明了"顺时针",因此遍历List,比待路由的节点Hash值大的算出差值并记录,比待路由节点Hash值小的忽略

(3)算出所有的差值之后,最小的那个,就是最终需要路由过去的节点

在这个算法中,看一下时间复杂度:

1、最好情况是只有一个服务器节点的Hash值大于带路由结点的Hash值,其时间复杂度是O(N)+O(1)=O(N+1),忽略常数项,即O(N)

2、最坏情况是所有服务器节点的Hash值都大于带路由结点的Hash值,其时间复杂度是O(N)+O(N)=O(2N),忽略首项系数,即O(N)

所以,总的时间复杂度就是O(N)。其实算法还能更改进一些:给一个位置变量X,如果新的差值比原差值小,X替换为新的位置,否则X不变。这样遍历就减少了一轮,不过经过改进后的算法时间复杂度仍为O(N)。

总而言之,这个解决方案和解决方案一相比,总体来看,似乎更好了一些。

5.1.3 解决方案3:二叉查找树

抛开List这种数据结构,另一种数据结构则是使用 二叉查找树 。对于树不是很清楚的朋友可以简单看一下这篇文章树形结构。

当然我们不能简单地使用二叉查找树,因为可能出现不平衡的情况。平衡二叉查找树有AVL树、红黑树等,这里使用红黑树,选用红黑树的原因有两点:

1、红黑树主要的作用是用于存储有序的数据,这其实和第一种解决方案的思路又不谋而合了,但是它的效率非常高

2、JDK里面提供了红黑树的代码实现TreeMap和TreeSet

另外,以TreeMap为例,TreeMap本身提供了一个tailMap(K fromKey)方法,支持从红黑树中查找比fromKey大的值的集合,但并不需要遍历整个数据结构。

使用红黑树,可以使得查找的时间复杂度降低为O(logN),比上面两种解决方案,效率大大提升。

为了验证这个说法,我做了一次测试,从大量数据中查找第一个大于其中间值的那个数据,比如10000数据就找第一个大于5000的数据(模拟平均的情况)。看一下O(N)时间复杂度和O(logN)时间复杂度运行效率的对比:
在这里插入图片描述

可以看到,数据查找的效率,TreeMap是完胜的,其实再增大数据测试也是一样的,红黑树的数据结构决定了任何一个大于N的最小数据,它都只需要几次至几十次查找就可以查到。
我只演示给大家看一下100万条数据进行查找,所用的时间
在这里插入图片描述

很明显TreeMap效率是最高的,测试代码也给大家贴出来:

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.TreeMap;

public class Test {
    public static void generateNums(int numSize, List<Integer> list) {
        for (int i = 0; i < numSize; i++) {
            list.add(i);
        }
    }

    public static void generateNums(int numSize, TreeMap<Integer, Integer> treeMap) {
        for (int i = 0; i < numSize; i++) {
            treeMap.put(i, i);
        }
    }

    public static void main(String[] args) {
        List<Integer> arrayList = new ArrayList<>();
        Integer numsSize=1000000;
        generateNums(numsSize, arrayList);
        List<Integer> linkList = new LinkedList<>();
        generateNums(numsSize, linkList);
        TreeMap<Integer, Integer> treeMap = new TreeMap<>();
        generateNums(numsSize, treeMap);
        Integer index = 99886;
        Long sT = System.currentTimeMillis();
        System.out.println("ArrayList获取下标为16666的值:" + arrayList.get(index));
        Long eT = System.currentTimeMillis();
        System.out.println("ArrayList运行时间为:" + (eT - sT) + "ms");
        System.out.println("----------------------------------------");
        sT = System.currentTimeMillis();
        System.out.println("linkList获取下标为16666的值:" + linkList.get(index));
        eT = System.currentTimeMillis();
        System.out.println("linkList运行时间为:" + (eT - sT) + "ms");
        System.out.println("----------------------------------------");
        sT = System.currentTimeMillis();
        System.out.println("treeMap获取下标为16666的值:" + treeMap.get(index));
        eT = System.currentTimeMillis();
        System.out.println("treeMap运行时间为:" + (eT - sT) + "ms");
    }
}

当然,明确一点,有利必有弊,根据我另外一次测试得到的结论是, 为了维护红黑树,数据插入效率TreeMap在三种数据结构里面是最差的,且插入要慢上5~10倍 。

5.2 Hash算法的实现

5.2.1 不带虚拟节点

import java.util.SortedMap;
import java.util.TreeMap;

/**
 * 不带虚拟节点的一致性Hash算法
 */
public class ConsistentHashingWithoutVirtualNode {

    //待添加入Hash环的服务器列表
    private static String[] servers = {"192.168.0.0:111", "192.168.0.1:111",
            "192.168.0.2:111", "192.168.0.3:111", "192.168.0.4:111"
    };


    //key表示服务器的hash值,value表示服务器
    private static SortedMap<Integer, String> sortedMap = new TreeMap<Integer, String>();

    //程序初始化,将所有的服务器放入sortedMap中
    static {
        for (int i = 0; i < servers.length; i++) {
            int hash = getHash(servers[i]);
            System.out.println("[" + servers[i] + "]" + "加入集合中, 其Hash值为" + hash);
            sortedMap.put(hash, servers[i]);
        }
        System.out.println();
    }

    //得到应当路由到的结点
    private static String getServer(String key) {
        //得到该key的hash值
        int hash = getHash(key);
        //得到大于该Hash值的所有Map
        SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
        if (subMap.isEmpty()) {
            //如果没有比该key的hash值大的,则从第一个node开始
            Integer i = sortedMap.firstKey();
            //返回对应的服务器
            return sortedMap.get(i);
        } else {
            //第一个Key就是顺时针过去离node最近的那个结点
            Integer i = subMap.firstKey();
            //返回对应的服务器
            return subMap.get(i);
        }
    }

    //使用FNV1_32_HASH算法计算服务器的Hash值,这里不使用重写hashCode的方法,最终效果没区别
    private static int getHash(String str) {
        final int p = 16777619;
        int hash = (int) 2166136261L;
        for (int i = 0; i < str.length(); i++)
            hash = (hash ^ str.charAt(i)) * p;
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;

        // 如果算出来的值为负数则取其绝对值
        if (hash < 0)
            hash = Math.abs(hash);
        return hash;
    }

    public static void main(String[] args) {
        String[] keys = {"太阳", "月亮", "星星"};
        for (int i = 0; i < keys.length; i++)
            System.out.println("[" + keys[i] + "]" + "的hash值为" + getHash(keys[i])
                    + ",被路由到结点[" + getServer(keys[i]) + "]");
    }
}

5.2.2 虚拟节点来改善一致性Hash算法

使用虚拟结点可以极大的改善hash环偏斜的问题。

我们简单的思考一下:
1、一个真实结点如何对应成为多个虚拟节点?

2、虚拟节点找到后如何还原为真实结点?

这两个问题其实有很多解决办法,这里有一种简单的办法,给每个真实结点后面根据虚拟节点加上后缀再取Hash值,比如"192.168.0.0:111"就把它变成"192.168.0.0:111&&VN0"到"192.168.0.0:111&&VN4",VN就是Virtual Node的缩写,还原的时候只需要从头截取字符串到"&&"的位置就可以了。

package com.example.subtreasury.util;

import java.util.LinkedList;
import java.util.List;
import java.util.SortedMap;
import java.util.TreeMap;

public class ConsistentHashingWithVirtualNode {

    //待添加入Hash环的服务器列表
    private static String[] servers = {"192.168.0.0:111", "192.168.0.1:111",
            "192.168.0.2:111", "192.168.0.3:111", "192.168.0.4:111"
    };
    /**
     * 真实结点列表,考虑到服务器上线、下线的场景,即添加、删除的场景会比较频繁,这里使用LinkedList会更好
     */
    private static List<String> realNodes = new LinkedList<String>();
    /**
     * 虚拟节点,key表示虚拟节点的hash值,value表示虚拟节点的名称
     */
    private static SortedMap<Integer, String> virtualNodes = new TreeMap<Integer, String>();
    /**
     * 虚拟节点的数目,这里写死,为了演示需要,一个真实结点对应5个虚拟节点
     */
    private static final int VIRTUAL_NODES = 5;

    static {
        // 先把原始的服务器添加到真实结点列表中
        for (int i = 0; i < servers.length; i++)
            realNodes.add(servers[i]);
        // 再添加虚拟节点,遍历LinkedList使用foreach循环效率会比较高
        for (String str : realNodes) {
            for (int i = 0; i < VIRTUAL_NODES; i++) {
                String virtualNodeName = str + "&&VN" + String.valueOf(i);
                int hash = getHash(virtualNodeName);
                System.out.println("虚拟节点[" + virtualNodeName + "]被添加, hash值为" + hash);
                virtualNodes.put(hash, virtualNodeName);
            }
        }
        System.out.println();
    }

    /**
     * 使用FNV1_32_HASH算法计算服务器的Hash值,这里不使用重写hashCode的方法,最终效果没区别
     *
     * @param str
     * @return
     */
    private static int getHash(String str) {
        final int p = 16777619;
        int hash = (int) 2166136261L;
        for (int i = 0; i < str.length(); i++)
            hash = (hash ^ str.charAt(i)) * p;
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;

        // 如果算出来的值为负数则取其绝对值
        if (hash < 0)
            hash = Math.abs(hash);
        return hash;
    }

    /**
     * 得到应当路由到的结点
     */
    private static String getServer(String node) {
        // 得到带路由的结点的Hash值
        int hash = getHash(node);
        // 得到大于该Hash值的所有Map
        SortedMap<Integer, String> subMap =
                virtualNodes.tailMap(hash);
        // 第一个Key就是顺时针过去离node最近的那个结点
        Integer i = subMap.firstKey();
        // 返回对应的虚拟节点名称,这里字符串稍微截取一下
        String virtualNode = subMap.get(i);
        return virtualNode.substring(0, virtualNode.indexOf("&&"));
    }

    public static void main(String[] args) {
        String[] nodes = {"127.0.0.1:1111", "221.226.0.1:2222", "10.211.0.1:3333"};
        for (int i = 0; i < nodes.length; i++)
            System.out.println("[" + nodes[i] + "]的hash值为" +
                    getHash(nodes[i]) + ", 被路由到结点[" + getServer(nodes[i]) + "]");
    }
}

在这里插入图片描述

可以看到我里面演示都是服务器结点,因为本文都是演示分表操作,所以我们只需要将该类表里的内容改为表名即可:

    private static String[] servers = {"user_01", "user_02",
            "user_03", "user_04"
    };

注意里面要严格对应你数据库的表名

6.分库分表实战

我们以用户表为例,该表主要提供注册、登录、查询、修改等功能。
在这里插入图片描述

我们在分表之前,需要了解业务层面对这个表的使用情况,然后再决定使用什么样的方案,否则脱离业务去设计技术方案是耍流氓
用户端: 前台访问量较大,主要涉及两类请求:

  • 用户登录,面向C端,对可用性和一致性要求较高,主要通过email、phone来查询用户信息,1%的请求属于这种类型
  • 用户信息查询,登录成功后,通过uid来查询用户信息,99%属于这种类型。

运营端: 主要是运营后台的信息访问,需要支持根据性别、手机号、注册时间、用户昵称等进行分页查询,由于是内部系统,访问量较低,对可用性一致性要求不高。

那么我们直接通过主键id进行分表;

由于99%的请求是基于id进行用户信息查询,所以毫无疑问我们选择使用id进行水平分表。

那么这里我们采用uid的hash取模方法来进行分表,根据id进行一致性hash取模运算得到目标表进行存储。
在这里插入图片描述

那么我们按照上图的结构,分别复制user_info表,重新命名为01~04
在这里插入图片描述

当完成上述动作后,就需要开始开始落地实施,这里需要考虑在数据添加、修改、删除时,要正确路由到目标数据表,其次是老数据的迁移。

老数据迁移,一般我们是写一个脚本或者一个程序,把旧表中的数据查询出来,然后根据分表规则重新路由分发到新的表中,这里不是很复杂,就不做展开说明,我们重点说一下数据添加/修改/删除的路由。

在实施之前,我们需要先考虑一个非常重要的问题,就是在单个表中,我们使用递增主键来保证数据的唯一性,但是如果把数据拆分到了四个表,每个表都采用自己的递增主键规则,就会存在重复id的问题,也就是说递增主键不是全局唯一的。
我们需要知道一个点是,user_info虽然拆分成了多张表,但是本质上它应该还是一个完整的数据整体,当id存在重复的时候,就失去了数据的唯一性,因此我们需要考虑如何生成一个全局唯一ID。

在分布式架构下有很多优秀的分布式id方案可以供你选择,为了保证本文知识的专业性,我将 分布式id的解决方案单独拿出来讲解了一下,从理论到实践,大家可以先去学一下,再回来继续向下学习本文 。

6.1 分库分表之后的数据DML操作

DML是什么?

数据操作语言(DML),例如:INSERT(插入)、UPDATE(修改)、DELETE(删除)语句。

相关的还有:

  • 数据定义语言(DDL),例如:CREATE、DROP、ALTER等语句。
  • 数据查询语言(DQL),例如:SELECT语句。(一般不会单独归于一类,因为只有一个语句)。
  • 数据控制语言(DCL),例如:GRANT、REVOKE等语句。
  • 事务控制语言(TCL),例如:COMMIT、ROLLBACK等语句。

SQL语言包括四类种主要程序设计语言类别的语句:数据定义语言(DDL),数据操作语言(DML)及数据控制语言(DCL)还有事务控制语言(TCL)。

有序需要用到全局id,所以在user表需要添加一个唯一id的字段。
在这里插入图片描述

配置完成之后,在如下代码中引入signal方法。

import com.example.subtreasury.config.MybatisPlusConfig;
import com.example.subtreasury.po.User;
import com.example.subtreasury.service.IUserService;
import com.example.subtreasury.util.ConsistentHashUtils;
import com.example.subtreasury.util.ConsistentHashingWithoutVirtualNode;
import com.example.subtreasury.util.SnowFlakeUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author astupidcoder
 * @since 2022-05-16
 */
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
    @Autowired
    IUserService userInfoService;

    @PostMapping("signal")
    public void signal(@RequestBody User userInfo) {
        SnowFlakeUtils snowFlakeUtils = new SnowFlakeUtils(2, 3);
        Long bizId = snowFlakeUtils.nextId();
        userInfo.setBizId(bizId);
        String table = ConsistentHashingWithoutVirtualNode.getServer(bizId.toString());
        log.info("UserInfoController.signal:{}", table);
        MybatisPlusConfig.TABLE_NAME.set(table);
        userInfoService.save(userInfo);
    }
}

当然,我就默认各位使用的是mybatis-plus啦,不会配置 或者基础用法不是太会的可以简单的阅读以下:《springBoot集成mybatisPlus》
然后我们在加一下mybatis拦截器,针对user表进行拦截和替换,从而实现动态表的路由。

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TableNameHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.DynamicTableNameInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;

@Configuration
@MapperScan("com.example.subtreasury.mapper")
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", (sql, tableName) -> TABLE_NAME.get());
        dynamicTableNameInnerInterceptor.setTableNameHandlerMap(tableNameHandlerMap);
        interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
        return interceptor;
    }
}

里面有个注意事项:
在这里插入图片描述

接下来我们就可以很轻松的通过bizId来查询我们所需要的数据

    @PostMapping("getUserInfoByBizId")
    public Object getUserInfoByBizId(@RequestParam(value = "bizId") Long bizId) {
        String table = ConsistentHashingWithoutVirtualNode.getServer(bizId.toString());
        log.info("UserInfoController.signal:{}", table);
        MybatisPlusConfig.TABLE_NAME.set(table);
        QueryWrapper queryWrapper = new QueryWrapper();
        queryWrapper.eq("biz_id", bizId);
        List<User> users = userInfoService.list(queryWrapper);
        return users;
    }

在这里插入图片描述

在这里插入图片描述

读到此处相信你应该会了一个简单的分表操作了,你以为就结束啦,光实现可不行,我们还要继续想想这种方法会带来什么问题 ,千万不要等上线了出了问题在排查,那带来的损失不可估量。

6.2 非分片键查询

我们对user_info表的分片,是基于biz_id来实现的,也就是意味着如果我们想查询某张表的数据,必须先要使用biz_id路由找到对应的表才能查询到。

那么问题来了,如果查询的字段不是分片键(也就是不是biz_id),比如本次分库分表实战案例中,运营端查询就有根据名字、手机号、性别等字段来查,这时候我们并不知道去哪张表查询这些信息。
所以我们需要对非分片键和分片键建立映射关系

6.2.1 非分片键和分片键建立映射关系

有一种很简单的办法就是建立查询字段和分片键的映射索引:
name -> biz_id 建立映射,相当于建立一个简单的索引,当基于name查询数据时,先通过映射表查询出name对应的biz_id,再通过biz_id定位到目标表。

映射表的只有两列,可以承载很多的数据,当数据量过大时,也可以对映射表做水平拆分。

同时这种映射关系其实就是k-v键值对的关系,所以我们可以使用k-v缓存来存储提升性能,如:redis,monogodb等nosql。
同时因为这种映射关系的变更频率很低,所以缓存命中率很高,性能也很好。

6.3 用户端数据库和运营端数据库进行分离

运营端的查询可能不止于单个字段的映射来查询,可能更多的会涉及到一些复杂查询,以及分页查询等,这种查询本身对数据库性能影响较大,很可能影响到用户端对于用户表的操作,所以一般主流的解决方案就是把两个库进行分离。

由于运营端对于数据的一致性和可用性要求不是很高,也不需要实时访问数据库,所以我们可以把C端用户表的数据同步到运营端的用户表,而且用户表可以不需要做分表操作,直接全量查表即可。

当然,如果运营端的操作性能实在是太慢了,我们还可以采用ElasticSearch搜索引擎来满足后台复杂查询的需求。

7.实际应用场景

在实际应用中,并不是一开始就会想到未来会对这个表做拆分,因此很多时候我们面临的问题是在数据量已经达到一定瓶颈的时候,才开始去考虑这个问题。

所以分库分表最大的难点不是在于拆分的方法论,而是在运行了很长时间的数据库中,如何根据实际业务情况选择合适的拆分方式,以及在拆分之前对于数据的迁移方案的思考。而且,在整个数据迁移和拆分过程中,系统仍然需要保持可用。

对于运行中的表的分表,一般会分为三个阶段。

7.1 阶段一,新老库双写

由于老的数据表肯定没有考虑到未来分表的设计,同时随着业务的迭代,可能有些模型也需要优化,因此会设计一个新的表来承载老的数据,而这个过程中,需要做几件事情

数据库表的双写,老的数据库表和新的数据库表同步写入数据,事务的成功以老的模型为准,查询也走老的模型

通过定时任务对数据进行核对,补平差异

通过定时任务把历史数据迁移到新的模型中

7.2 阶段二,以新的模型为准

到了第二个阶段,历史数据已经导完了,并且校验数据没有问题。

仍然保持数据双写,但是事务的成功和查询都以新模型为准。
定时任务进行数据核对,补平数据差异

7.3 阶段三,结束双写

到了第三个阶段,说明数据已经完全迁移好了,因此。

取消双写,所有数据只需要保存到新的模型中,老模型不需要再写入新的数据。

如果仍然有部分老的业务依赖老的模型,所以等到所有业务都改造完成后, 再废除老的模型。

8.分库分表后带来的问题

在享受分库分表带来性能提升的同时,我们也会面临不少麻烦
我们首先要面对的就是分布式事务的问题

8.1 分布式事务问题

分库分表之后,原本在一个库中的事务,变成了跨越多个库,如何保证跨库数据的一致性问题,也是一个常见的难题。
在这里插入图片描述

如上图所示,用户创建订单时,需要在订单库中保存一条订单记录,并且修改库存库中的商品库存,这里就涉及到跨库事务的一致性问题。也就是说我怎么保证当前两个事务操作要么同时成功,要么同时失败。
对于分布式事务的原理以及解决方案我已经做出了详细的说明和解释,因为篇幅较长,请大家务必耐心的读完,我同样是从原理到实现做出说明,并且附有完整的实现代码:

8.2 跨库查询

比如查询在合同信息的时候要关联客户数据,由于是合同数据和客户数据是在不同的数据库,那么我们肯定不能直接使用join的这种方式去做关联查询。

我们有几种主要的解决方案:

  • 字段冗余,比如我们查询合同库的合同表的时候需要关联客户库的客户表,我们可以直接把一些经常关联查询的客户字段放到合同表,通过这种方式避免跨库关联查询的问题。
  • 数据同步:比如商户系统要查询产品系统的产品表,我们干脆在商户系统创建一张产品表,通过ETL或者其他方式定时同步产品数据。
  • 全局表(广播表) 比如基础数据被很多业务系统用到,如果我们放在核心系统,每个系统都要去关联查询,这个时候我们可以在所有的数据库都存储相同的基础数据。
  • ER表(绑定表),我们有些表的数据是存在逻辑的主外键关系的,比如订单表order_info,存的是汇总的商品数,商品金额;订单明细表order_detail,是每个商品的价格,个数等等。或者叫做从属关系,父表和子表的关系。他们之间会经常有关联查询的操作,如果父表的数据和子表的数据分别存储在不同的数据库,跨库关联查询也比较麻烦。所以我们能不能把父表和数据和从属于父表的数据落到一个节点上呢?比如order_id=1001的数据在node1,它所有的明细数据也放到node1;order_id=1002的数据在node2,它所有的明细数据都放到node2,这样在关联查询的时候依然是在一个数据库。

上面的思路都是通过合理的数据分布避免跨库关联查询,实际上在我们的业务中,也是尽量不要用跨库关联查询,如果出现了这种情况,就要分析一下业务或者数据拆分是不是合理。如果还是出现了需要跨库关联的情况,那我们就只能用最后一种办法。

  • 系统层组装
    在不同的数据库节点把符合条件数据的数据查询出来,然后重新组装,返回给客户端。

8.3 排序、翻页、函数计算等问题

跨节点多库进行查询时,会出现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之类的函数在进行计算的时候,也需要先在每个分片上执行相应的函数,然后将各个分片的结果集进行汇总和再次计算,最终将结果返回。

8.4 全局唯一id问题

推荐直接使用分布式Id的解决方案,在上面已经提到了,再次附上相关学习链接(ps:并不是我非要单独拿出来,主要是我怕本文牵扯知识过多,容易让读者失去关注点)

8.5 多数据源的问题

分库分表之后,难免会存在一个应用配置多个数据源。

另外,数据库层面有可能会设计读写分离的方案,也使得一个应用会访问多个数据源,并且还需要实现读写分离的动态路由。

而这些问题在每个应用系统中都会存在并且需要解决,所以为了提供统一的分库分表相关问题的解决方案,引入了很多的开源技术。
目前市面上分库分表的中间件相对来说说比较多,比如

  • Cobar,淘宝开源的分库分表组件,目前基本上没有维护了。
  • Sharding-Sphere,当当开源的一个分库分表组件,已经捐献给了Apache基金会
  • Atlas, 奇虎360开源的分库分表组件,也是没怎么维护了
  • Mycat,从阿里cobar升级而来,由开源组织维护。
  • Vitess,谷歌开源的分库分表组件

目前很多公司选择较多的是Mycat或者Sharding-Sphere,Sharding-Sphere场景更加丰富,后面我会专门出一个文章来看看Sharding-Sphere的使用原理以及实战

至此,如何进行分库分表就到此结束啦,欢迎大家一起来探讨。

  • 2
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ZNineSun

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值