千万级并发架构下,关系型数据库应该如何优化?,Java程序员资源

图片

垂直拆分


垂直拆分有两种,一种是单库的垂直拆分,另一种是多个数据库的垂直拆分。

单库垂直分表

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

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

图片

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

参考:https://dev.mysql.com/doc/mysql-reslimits-excerpt/5.6/en/column-count-limit.html

多库垂直分表

多库垂直拆分实际上就是把存在于一个库中的多个表,按照一定的纬度拆分到多个库中,如图6-3所示。这种拆分方式在微服务架构中也是很常见,基本上会按照业务纬度拆分数据库,同样该纬度也会影响到微服务的拆分,基本上服务和数据库是独立的。

图片

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

水平拆分


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

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

单库水平分表

如图6-4所示,表示把一张有10000条数据的用户表,按照某种规则拆分成了4张表,每张表的数据量是2500条。

图片

两个案例:

银行的交易流水表,所有进出的交易都需要登记这张表,因为绝大部分时候客户都是查询当天的交易和一个月以内的交易数据,所以我们根据使用频率把这张表拆分成三张表:

当天表:只存储当天的数据。

当月表:我们在夜间运行一个定时任务,前一天的数据,全部迁移到当月表。用的是insert into select,然后delete。

历史表:同样是通过定时任务,把登记时间超过30天的数据,迁移到history历史表(历史表的数据非常大,我们按照月度,每个月建立分区)。

费用表:消费金融公司跟线下商户合作,给客户办理了贷款以后,消费金融公司要给商户返费用,或者叫提成,每天都会产生很多的费用的数据。为了方便管理,我们每个月建立一张费用表,例如fee_detail_201901……fee_detail_201912。

但是注意,跟分区一样,这种方式虽然可以一定程度解决单表查询性能的问题,但是并不能解决单机存储瓶颈的问题。

多库水平分表

多库水平分表,其实有点类似于分库分表的综合实现方案,从分表来说是减少了单表的数据量,从分库层面来说,降低了单个数据库访问的性能瓶颈,如图6-5所示。

图片

常见的水平分表策略

=========

分库更多的是关注业务的耦合度,也就是每个库应该放那些表,是由业务耦合度来决定的,这个在前期做领域建模的时候都会先考虑好,所以问题不大,只是分库之后带来的其他问题,我们在后续内容中来分析。

而分表这块,需要考虑的问题会更多一些,也就是我们应该根据什么样的策略来水平分表?这里就需要涉及到分表策略了,下面简单介绍几种最常见的分片策略。

哈希取模分片


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

图片

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环。

图片

那一致性hash算法和上面的虚拟环有什么关系呢?继续回到前面我们讲解hash取模的例子,假设现在有四个表,table_1、table_2、table_3、table_4,在一致性hash算法中,取模运算不是直接对这四个表来完成,而是对232来实现。

hash(table编号)%232

通过上述公式算出的结果一定是一个0到232-1之间的一个整数,然后在这个数对应的位置标注目标表,如图6-8所示,四个表通过hash取模之后分别落在hash环的某个位置上。

图片

好了,到目前为止,我们已经把目标表与hash环联系在了一起,那么接下来我们需要把一条数据保存到某个目标表中,怎么做呢?如图6-9所示,当添加一条数据时,同样通过hash和hash环取模运算得到一个目标值,然后根据目标值所在的hash环的位置顺时针查找最近的一个目标表,把数据存储到这个目标表中即可。

图片

不知道大家是否发现了一致性hash的好处,就是hash运算不是直接面向目标表,而是面向hash环,这样的好处就是当需要删除某张表或者增加表的时候,对于整个数据变化的影响是局部的,而不是全局。举个例子,假设我们发现需要增加一张表table_04,如图6-10所示,增加一个表,并不会对其他四个已经产生了数据的表造成影响,原来已经分片的数据完全不需要做任何改动。

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

图片

hash环偏斜

上述设计有一个问题,理论情况下我们目标表是能够均衡的分布在整个hash环中,但实际情况有可能是图6-11所示的样子。也就是产生了hash环偏斜的现象,这种现象导致的问题就是大量的数据都会保存到同一个表中,倒是数据分配极度不均匀。

图片

为了解决这个问题,必须要保证目标节点要均匀的分布在整个hash环中,但是真实的节点就只有4个,如何均匀分布呢?最简单的方法就是,把这四个节点分别复制一份出来分散到这个hash环中,这个复制出来的节点叫虚拟节点,根据实际需要可以虚拟出多个节点出来,如图6-12所示。

图片

按照范围分片


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

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

如图6-7所示,表示按照数据范围进行拆分。

图片

范围分片最终要的是选择一个合适的分片键,这个是否合适来自于业务需求,比如之前有个学员是在做智能家居的,他们卖的是硬件设备,这些设备会采集数据上报到服务器上,当来自全国范围的数据统一保存在一个表中后,数据量达到了亿级别,所以这种场景比较适合按照城市和地域来拆分。

分库分表实战

======

为了让大家理解分库分表以及实操,我们通过一个简单的案例来演示一下。代码详见:springboot-split-table-example项目

假设存在一个用户表,用户表的字段如下。

该表主要提供注册、登录、查询、修改等功能。

图片

该表的具体的业务情况如下(需要注意,在进行分表之前,需要了解业务层面对这个表的使用情况,然后再决定使用什么样的方案,否则脱离业务去设计技术方案是耍流氓)

用户端:前台访问量较大,主要涉及两类请求:

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

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

根据uid进行水平分表


由于99%的请求是基于uid进行用户信息查询,所以毫无疑问我们选择使用uid进行水平分表。那么这里我们采用uid的hash取模方法来进行分表,具体的实施如图6-9所示,根据uid进行一致性hash取模运算得到目标表进行存储。

图片

按照图6-9的结构,分别复制user_info表,重新命名为01~04,如图6-10所示。

图片

如何生成全局唯一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。那么我们为

《一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义》

【docs.qq.com/doc/DSmxTbFJ1cmN1R2dB】 完整内容开源分享

什么需要这个序列号,设想下,如果是同一毫秒同一台机器来请求,那么我们怎么保证他的唯一性,这个时候,我们就能用到我们的序列号,目的是为了保证同一毫秒内同一机器生成的ID是唯一的,这个其实就是为了满足我们ID的这个高并发,就是保证我同一毫秒进来的并发场景的唯一性12位(bit)可以表示的最大正整数是2^12-1=4095,即可以用0、1、2、3、…4094这4095个数字,来表示同一机器同一时间截(毫秒)内产生的4095个ID序号。

12位2进制,如果全部都是1的情况下,那么最终的值就是4095,也就是12bit能够存储的最大的数字是4095.

图片

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


有序需要用到全局id,所以在user_info表需要添加一个唯一id的字段。

图片

配置完成之后,在如下代码中引入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表进行拦截和替换,从而实现动态表的路由。

@Configurationpublic 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搜索引擎来满足后台复杂查询的需求。

实际应用中会遇到的问题


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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值