后端接口性能优化分析-数据库优化(中):https://developer.aliyun.com/article/1413678
Inner join 、left join、right join
Inner join 、left join、right join,优先使用Inner join,如果是left join,左边表结果尽量小
- Inner join 内连接,在两张表进行连接查询时,只保留两张表中完全匹配的结果集
- left join 在两张表进行连接查询时,会返回左表所有的行,即使在右表中没有匹配的记录。
- right join 在两张表进行连接查询时,会返回右表所有的行,即使在左表中没有匹配的记录。
在SQL查询中,Inner join、Left join和Right join是三种主要的连接方式。它们的优先级并没有固定的规定,但在很多情况下,我们首选Inner join。原因如下:
- 结果集较小:Inner join返回的是两个表中共同拥有的记录,因此结果集相对较小,查询效率较高。而Left join和Right join会返回两个表中所有的记录,结果集较大,对于大数据量的查询,性能会受到影响。
- 准确性:Inner join只返回两个表中确实存在的记录,避免了可能出现的重复数据或错误数据。而Left join和Right join可能会返回一个表中的所有记录,即使它们在另一个表中没有匹配的记录,这可能会导致数据不一致或错误。
- 易于理解和维护:Inner join是最简单和直观的连接方式,容易理解和实现。相比之下,Left join和Right join在某些情况下可能需要额外的处理或解释,增加了代码的复杂性和维护成本。当然,这并不是说Inner join在所有情况下都是最佳选择。
根据实际需求和数据特点,Left join和Right join在某些场景下可能更合适。例如,当需要查询一个表中的所有记录,以及对另一个表进行关联查询时,可以使用Left join;而在需要查询一个表中的特定记录,以及对另一个表进行关联查询时,可以使用Right join。总之,要根据具体需求和数据特点来选择合适的连接方式。
都满足SQL需求的前提下,推荐优先使用Inner join(内连接),如果要使用left join,左边表数据结果尽量小,如果有条件的尽量放到左边处理。
反例:
select * from tab1 t1 left join tab2 t2 on t1.size = t2.size where t1.id > 2;
正例:
select * from (select * from tab1 where id > 2) t1 left join tab2 t2 on t1.size = t2.size;
在适当的时候,使用覆盖索引
覆盖索引能够使得你的SQL语句不需要回表,仅仅访问索引就能够得到所有需要的数据,大大提高了查询效率。
慎用distinct关键字
distinct 关键字一般用来过滤重复记录,以返回不重复的记录。在查询一个字段或者很少字段的情况下使用时,给查询带来优化效果。但是在字段很多的时候使用,却会大大降低查询效率。
反例:
SELECT DISTINCT * from user;
正例:
select DISTINCT name from user;
理由:
- 带distinct的语句cpu时间和占用时间都高于不带distinct的语句。因为当查询很多字段时,如果使用distinct,数据库引擎就会对数据进行比较,过滤掉重复数据,然而这个比较、过滤的过程会占用系统资源,cpu时间。
删除冗余和重复索引
反例:
KEY `idx_userId`(`userId`) KEY `idx_userId_age`(`userId`,`age`)
正例:
//删除userId索引,因为组合索引(A,B)相当于创建了(A)和(A,B)索引 KEY `idx_userId_age`(`userId`,`age`)
理由:
- 重复的索引需要维护,并且优化器在优化查询的时候也需要逐个地进行考虑,这会影响性能的。
where子句中考虑使用默认值代替null
select * from user where age is not null
正例:
//设置0为默认值 select * from user where age>0;
理由:
- 并不是说使用了is null 或者 is not null 就会不走索引了,这个跟mysql版本以及查询成本都有关。
如果mysql优化器发现,走索引比不走索引成本还要高,肯定会放弃索引,这些条件 !=,>isnull,isnotnull经常被认为让索引失效,其实是因为一般情况下,查询的成本高,优化器自动放弃索引的。
如果把null值,换成默认值,很多时候让走索引成为可能,同时,表达意思会相对清晰一点。
exist&in的合理利用
假设表A表示某企业的员工表,表B表示部门表,查询所有部门的所有员工,很容易有以下SQL:
select * from A where deptId in (select deptId from B);
这样写等价于:
先查询部门表B select deptId from B 再由部门deptId,查询A的员工 select * from A where A.deptId = B.deptId
可以抽象成这样的一个循环:
List<> resultSet; for(int i = 0; i < B.length ; i++){ for(int j = 0; j < A.length ; j++){ if(A[i].id == B[j].id){ resultSet.add(A[i]); break; } } }
显然,除了使用in,我们也可以用exists实现一样的查询功能,如下:
select * from A where exists (select 1 from B where A.deptId = B.deptId);
因为exists查询的理解就是,先执行主查询,获得数据后,再放到子查询中做条件验证,根据验证结果(true或者false),来决定主查询的数据结果是否得意保留。
那么,这样写就等价于:
select * from A,先从A表做循环 select * from B where A.deptId = B.deptId,再从B表做循环.
同理,可以抽象成这样一个循环:
List<> resultSet; for(int i = 0; i < A.length ; i++){ for(int j = 0; j < B.length ; j++){ if(A[i].id == B[j].id){ resultSet.add(A[i]); break; } } }
数据库最费劲的就是跟程序链接释放。假设链接了两次,每次做上百万次的数据集查询,查完就走,这样就只做了两次;相反建立了上百万次链接,申请链接释放反复重复,这样系统就受不了了。即mysql优化原则,就是小表驱动大表,小的数据集驱动大的数据集,从而让性能更优。
因此,我们要选择最外层循环小的,也就是,如果B的数据量小于A,适合使用in,如果B的数据量大于A,即适合选择exist。
索引不适合建在有大量重复数据的字段上,如性别这类型数据库字段。
因为SQL优化器是根据表中数据量来进行查询优化的,如果索引列有大量重复数据,Mysql查询优化器推算发现不走索引的成本更低,很可能就放弃索引了。
深分页
limit深分页问题,会导致慢查询。
limit深分页为什么会变慢
limit深分页为什么会导致SQL变慢呢?假设我们有表结构如下:
CREATE TABLE account ( id int(11) NOT NULL AUTO_INCREMENT COMMENT '主键Id', name varchar(255) DEFAULT NULL COMMENT '账户名', balance int(11) DEFAULT NULL COMMENT '余额', create_time datetime NOT NULL COMMENT '创建时间', update_time datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), KEY idx_name (name), KEY idx_create_time (create_time) //索引 ) ENGINE=InnoDB AUTO_INCREMENT=1570068 DEFAULT CHARSET=utf8 ROW_FORMAT=REDUNDANT COMMENT='账户表';
你知道以下SQL
,执行过程是怎样的嘛?
select id,name,balance from account where create_time> '2020-09-19' limit 100000,10;
这个SQL的执行流程:
- 通过普通二级索引树
idx_create_time
,过滤create_time
条件,找到满足条件的主键id
。 - 通过
主键id
,回到id主键索引树
,找到满足记录的行,然后取出需要展示的列(回表过程) - 扫描满足条件的
100010
行,然后扔掉前100000
行,返回。
limit
深分页,导致SQL
变慢原因有两个:
limit
语句会先扫描offset+n
行,然后再丢弃掉前offset
行,返回后n
行数据。也就是说limit 100000,10
,就会扫描100010
行,而limit0,10
,只扫描10
行。limit 100000,10
扫描更多的行数,也意味着回表更多的次数。
如何优化深分页问题?
我们可以通过减少回表次数来优化。一般有标签记录法和延迟关联法。
标签记录法
就是标记一下上次查询到哪一条了,下次再来查的时候,从该条开始往下扫描。就好像看书一样,上次看到哪里了,你就折叠一下或者夹个书签,下次来看的时候,直接就翻到啦。
假设上一次记录到100000
,则SQL可以修改为:
select id,name,balance FROM account where id > 100000 limit 10;
这样的话,后面无论翻多少页,性能都会不错的,因为命中了id索引
。但是这种方式有局限性:需要一种类似连续自增的字段。
延迟关联法
延迟关联法,就是把条件转移到主键索引树,然后减少回表。如下:
select acct1.id,acct1.name,acct1.balance FROM account acct1 INNER JOIN (SELECT a.id FROM account a WHERE a.create_time > '2020-09-19' limit 100000, 10) AS acct2 on acct1.id= acct2.id;
优化思路就是,先通过idx_create_time
二级索引树查询到满足条件的主键ID
,再与原表通过主键ID
内连接,这样后面直接走了主键索引了,同时也减少了回表。
大事务
为了保证数据库数据的一致性,在涉及到多个数据库修改操作时,我们经常需要用到事务。而使用spring
声明式事务,又非常简单,只需要用一个注解就行@Transactional
,如下面的例子:
@Transactional public int createUser(User user){ //保存用户信息 userDao.save(user); passCertDao.updateFlag(user.getPassId()); return user.getUserId(); }
这块代码主要逻辑就是创建个用户,然后更新一个通行证pass
的标记。如果现在新增一个需求,创建完用户,调用远程接口发送一个email
消息通知,很多小伙伴会这么写:
@Transactional public int createUser(User user){ //保存用户信息 userDao.save(user); passCertDao.updateFlag(user.getPassId()); sendEmailRpc(user.getEmail()); return user.getUserId(); }
这样实现可能会有坑,事务中嵌套RPC远程调用,即事务嵌套了一些非DB操作。如果这些非DB操作耗时比较大的话,可能会出现大事务问题。
所谓大事务问题就是,就是运行时间长的事务。由于事务一致不提交,就会导致数据库连接被占用,即并发场景下,数据库连接池被占满,影响到别的请求访问数据库,影响别的接口性能。
大事务引发的问题主要有:接口超时、死锁、主从延迟等等。因此,为了优化接口,我们要规避大事务问题。我们可以通过这些方案来规避大事务:
- RPC远程调用不要放到事务里面
- 一些查询相关的操作,尽量放到事务之外
- 事务中避免处理太多数据
海量数据
MySQL 换 ElasticSearch
在后台管理页面中,通常需要对列表页进行多条件检索。MySQL 无法满足多条件检索的需求,原因有两点。第一点是,拼接条件检索的查询SQL非常复杂且需要进行定制化,难以进行维护和管理。第二点是,条件检索的查询场景非常灵活,很难设计合适的索引来提高查询性能,并且难以保证查询能够命中索引。
相比之下,ElasticSearch是一种天然适合于条件检索场景的解决方案。无论数据量的大小,对于列表页查询和检索等场景,推荐首选ElasticSearch。
可以将多个表的数据异构到ElasticSearch中建立宽表,并在数据更新时同步更新索引。在进行检索时,可以直接从ElasticSearch中获取数据,无需再查询数据库,提高了检索性能。
MySQL 换 HBase
MySQL并不适合大数据量存储,若不对数据进行归档,数据库会一直膨胀,从而降低查询和写入的性能。针对大数据量的读写需求,可以考虑以下方法来存储订单数据。
首先,将最近1年的订单数据存储在MySQL数据库中。这样可以保证较高的数据库查询性能,因为MySQL对于相对较小的数据集来说是非常高效的。
其次,将1年以上的历史订单数据进行归档,并将这些数据异构(转储)到HBase中。HBase是一种分布式的NoSQL数据库,可以存储海量数据,并提供快速的读取能力。
在订单查询接口上,可以区分近期数据和历史数据,使得上游系统能够根据自身的需求调用适当的订单接口来查询订单详情。
在将历史订单数据存储到HBase时,可以设置合理的RowKey。RowKey是HBase中数据的唯一标识,在查询过程中可以通过RowKey来快速找到目标数据。通过合理地设置RowKey,可以进一步提高HBase的查询性能。
通过将订单数据分别存储在MySQL和HBase中,并根据需求进行区分查询,可以满足大数据量场景的读写需求。MySQL用于存储近期数据,以保证查询性能;而HBase用于存储归档的历史数据,并通过合理设置的RowKey来提高查询性能。
冗余设计
通过冗余更多的数据,我们可以提高查询性能,这是常见的优化方案。除了引入新的表外,还可以在表中冗余其他表的字段,以减少关联查询的次数。
历史数据归档
MySQL并不适合存储大数据量,如果不对数据进行归档,数据库会持续膨胀,从而降低查询和写入的性能。为了满足大数据量的读写需求,需要定期对数据库进行归档。
在进行数据库设计时,需要事先考虑到对数据归档的需求,为了提高归档效率,可以使用ctime(创建时间)进行归档,例如归档一年前的数据。
在将数据库数据归档之前,如果有必要,一定要将数据同步到Hive中,这样以后如果需要进行统计查询,可以使用Hive中的数据。如果归档的数据还需要在线查询,可以将过期数据同步到HBase中,这样数据库可以提供近期数据的查询,而HBase可以提供历史数据的查询。可参考上述MySQL转HBase的内容。
分库分表
单表数据量太大
一个表的数据量达到好几千万或者上亿时,加索引的效果没那么明显啦。性能之所以会变差,是因为维护索引的B+
树结构层级变得更高了,查询一条数据时,需要经历的磁盘IO变多,因此查询性能变慢。
一棵B+树可以存多少数据量
大家是否还记得,一个B+树大概可以存放多少数据量呢?
InnoDB存储引擎最小储存单元是页,一页大小就是16k
。
B+树叶子存的是数据,内部节点存的是键值+指针。索引组织表通过非叶子节点的二分查找法以及指针确定数据在哪个页中,进而再去数据页中找到需要的数据;
假设B+树的高度为2
的话,即有一个根结点和若干个叶子结点。这棵B+树的存放总记录数为=根结点指针数*单个叶子节点记录行数。
- 如果一行记录的数据大小为1k,那么单个叶子节点可以存的记录数 =16k/1k =16.
- 非叶子节点内存放多少指针呢?我们假设主键ID为bigint类型,长度为8字节(面试官问你int类型,一个int就是32位,4字节),而指针大小在InnoDB源码中设置为6字节,所以就是8+6=14字节,16k/14B =16*1024B/14B = 1170
因此,一棵高度为2的B+树,能存放1170 * 16=18720条这样的数据记录。同理一棵高度为3的B+树,能存放1170 *1170 *16 =21902400,也就是说,可以存放两千万左右的记录。B+树高度一般为1-3层,已经满足千万级别的数据存储。
如果B+树想存储更多的数据,那树结构层级就会更高,查询一条数据时,需要经历的磁盘IO变多,因此查询性能变慢。
如何解决单表数据量太大,查询变慢的问题
一般超过千万级别,我们可以考虑分库分表了。
分库分表可能导致的问题:
多表联查问题
之前在库中只存在一张表,所以非常轻松的就能进行联表查询获取数据,但是此时做了水平分表后,同一张业务的表存在多张小表,这时再去连表查询时具体该连接哪张呢?
①如果分表数量是固定的,直接对所有表进行连接查询,但这样性能开销较大,还不如不分表。
②如果不想用①,或分表数量会随时间不断变多,那就先根据分表规则,去确定要连接哪张表后再查询。
第一条好理解,第二条是啥意思呢?好比现在是按月份来分表,那在连表查询前,就先确定要连接哪几张月份的表,才能得到自己所需的数据,确定了之后再去查询对应表即可
聚合操作
之前因为只有一张表,所以进行sum()、count()....、order by、gorup by....
等各类聚合操作时,可以直接基于单表完成,但此刻似乎行不通了呀?对于这类聚合操作的解决方案如下:
- ①放入第三方中间件中,然后依赖于第三方中间件完成,如
ES
。 - ②定期跑脚本查询出一些常用的聚合数据,然后放入
Redis
缓存中,后续从Redis
中获取。 - ③首先从所有表中统计出各自的数据,然后在
Java
中作聚合操作。
前面两种操作比较好理解,第三种方案是什么意思呢?比如count()函数,就是对所有表进行统计查询,最后在Java中求和,好比分组、排序等工作,先从所有表查询出符合条件的数据,然后在Java中通过Stream流进行处理。
上述这三种方案都是比较合理且常规的方案,最好是选择第一种,这也是一些大企业选用的方案。
跨库join问题
因为将不同业务的表拆分到了不同的库中,而往往有些情况下可能会需要其他业务的表数据,在单库时直接join
连表查询相应字段数据即可,但此时已经将不同的业务表放到不同库了,这时咋办?
- ①在不同的库需要数据的表中冗余字段,把常用的字段放到需要要数据的表中,避免跨库连表。
- ②
Java
系统中组装数据,通过调用对方服务接口的形式获取数据,然后在程序中组装后返回。
往往垂直分库的场景中,第二种方案是最常用的,因为分库分表的项目中,Java
业务系统那边也绝对采用了分布式架构,因此通过调用对端API
接口来获取数据,是分布式系统最为常见的一种现象。
分布式事务问题
分布式事务应该是分布式系统中最核心的一个问题,这个问题绝对不能出现,一般都要求零容忍,也就是所有分布式系统都必须要解决分布式事务问题,否则就有可能造成数据不一致性。
在之前单机的MySQL中,数据库自身提供了完善的事务管理机制,通过begin、commit/rollback的命令可以灵活的控制事务的提交和回滚,在Spring要对一组SQL操作使用事务时,也只需在对应的业务方法上加一个@Transactional注解即可,但这种情况在分布式系统中就不行了。
为什么说MySQL的事务机制会在分布式系统下失效呢?因为InnoDB的事务机制是建立在Undo-log日志的基础上完成的,以前只有一个Undo-log日志,所以一个事务的所有变更前的数据,都可以记录在同一个Undo-log日志中,当需要回滚时就直接用Undo-log中的旧数据覆盖变更过的新数据即可。
但垂直分库之后,会存在多个MySQL节点,这自然也就会存在多个Undo-log日志,不同库的变更操作会记录在各自的Undo-log日志中,当某个操作执行失败需要回滚时,仅能够回滚自身库变更过的数据,对于其他库的事务回滚权,当前节点是不具备该能力的,所以此时就必须要出现一个事务管理者来介入,从而解决分布式事务问题。
其中常用的可能有 Seata 和 最终一致性事务模式 的方案。
Seata
是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务。
整体的事务逻辑是两阶段提交的模型,主要由三个重要的组件组成:
- TC:Transaction Coordinator 事务协调器,管理全局的分支事务的状态,用于全局性事务的提交和回滚。
- TM:Transaction Manager 事务管理器,用于开启、提交或者回滚【全局事务】。
- RM:Resource Manager 资源管理器,用于分支(即每一个微服务,它是嵌在服务中的)事务上的资源管理,向TC注册分支事务,上报分支事务的状态,接受TC的命令来提交或者回滚分支事务
- 传统XA协议实现2PC方案的 RM 是在数据库层,RM本质上就是数据库自身;
- Seata的RM是以jar包的形式嵌入在应用程序里面
TC 为单独部署的 Server 服务端,TM 和 RM 为嵌入到应用中的 Client 客户端
Seata事务处理
AT模式
该模式适合的场景:
- 基于支持本地
ACID
事务的关系型数据库。 - Java 应用,通过
JDBC
访问数据库。
生命周期描述:
- A服务的
TM
向TC
申请一个全局事务,全局事务创建成功并生成一个全局唯一的XID
。 - A服务的
RM
向TC
注册分支事务 - A服务执行分支事务,对数据库做操作
- A服务开始远程调用B服务,并把
XID
在微服务调用链路的上下文中传播。 - A服务会调用B服务,形成调用链接,这也是分布式事务形成的由来。
- B服务的
RM
向TC
注册分支事务,并将其纳入XID
对应的全局事务的管辖。 - B服务执行分支事务,向数据库做操作
- B服务又开始调用C服务,又形成一个调用链,这样ABC三个服务形成调用链。
- C服务的
RM
也向TC
注册分支事务,并将其XID
纳入全局事务管理中,这样TC
会把A、B、C服务串联起来,保证在一个事务里管理。 - 全局事务调用链处理完毕,
TM
根据有无异常向 TC 发起针对 XID 的全局提交(Commit)
或回滚(Rollback)
决议。 TC
调度XID
下管辖的全部分支事务完成提交(Commit)
或回滚(Rollback)
请求。
但是分布式事务还是存在一个问题就是,微服务场景下,配置了统一全局异常处理,导致seata在AT模式下无法正常回滚问题。
原因:服务A调用服务B, 服务B发生异常,由于全局异常处理的存在@ControllerAdvice, seata 无法拦截到B服务的异常,从而导致分布式事务未生效。
解决思路:配置了全局异常处理,所以rpc
一定会有返回值, 所以在每个全局事务方法最后, 需要判断rpc
是否发生异常发生异常则抛出 RuntimeException
。
场景实例
简单的用户下单场景,4个子工程分别是**Bussiness** (事务发起者)、 **Order** (创建订单) 、**Product** (扣减库存) 和 **Account** (扣减账户余额)。
下图中黄色区域理解为各自独立的微服务被TC纳入了全局事务管理中,整个流程变成了一个原子操作。
用户进行下单,需要进行三个业务调用,Bussiness表示的是业务的发起方,也是我们的一个服务,发起了一个全局事务TM,接着调用商品服务开始扣减库存,Bussiness在调用订单服务,订单服务又会调用账户服务。此时假设商品服务扣减库存成功,订单服务创建订单成功,但是账户服务扣减账户余额失败,出现了异常,此时账户服务的RM即分支事务资源管理器向TC事务报告状态产生异常,此时TC会通知其余的RM回滚事务。当所有的RM均正常则提交事务。
最终一致性事务模式 + 可靠消息
描述: 可靠消息模式采用一个可靠的消息中间件作为中介,事务的发起方在完成本地事务后向可靠的消息中间件发起消息,事务消费方在收到消息后处理消息,该方式强调的是双方最终的数据一致性。
流程: 订单服务将消息发送给订单的消息队列,库存服务去监听订阅订单服务的消息队列,并从中消费消息。这种方式需要考虑消息的生产者发送到消息队列,再由消费者去消费消息,中间都有可能因为网络原因导致数据的不一致性。
- 本地事务提交后可以使用主动触发方式对本地消息表进行保存与推送。
- 库存服务在接收到消息并且处理完业务逻辑后,通过消息确认机制,回复ACK保证消息的消费成功。如果库存服务没有回复ACK,则消息中间件在没收到ACK是将进行重复投递。
- 当消息被成功消费,库存服务可以回调一个订单服务的确认API,订单服务从本地消息表中删除或者更新其状态
- 在订单服务中,如果重复性把本地消息发到库存服务,则需要消息的消费者(库存服务)提供消息的幂等性支持。
分布式事务的选择
关于一致性:
一致性就是数据保持一致性,在分布式系统中,可以理解为多个节点中的数据的值是一致的,而一致性分为强一致性和弱一致性/最终一致性(本身也是弱一致性的特殊表现形式)。
- 强一致性:是程度最高的一种要求,也是最难实现的。系统中的某个数据被更新后,后续任何对该数据的操作都是及时更新后的值。
- 弱一致性:系统中某个数据被更新后,后续对该数据的操作可能得到更新后得知,也可能是更新前的值,但经过“不一致的时间窗口”后,后续对该数据的操作都是更新后的值。
- 最终一致性:在一段时间后,数据会最终达到一致性状态,这个状态时弱一致性的特殊形式。
场景对比:
模拟一个简单个新用户注册送福利,即营销拉新活动。用户服务与营销服务作为两个独立的服务,假设选择以seata作为分布式事务的解决方案,此时发生的场景:在用户注册环节或者营销服务发放福利环节任意一个环节出异常,那么都会导致用户的注册失败,这就是很不友好了,我们期望的是尽管营销服务出现问题那么应该不会影响用户的注册,福利可以通过后期补发,所以这种场景,消息事务方案更具有优势。
分页问题
以MySQL
数据库为例,如果是在之前的单库环境中,可以直接通过limit index,n
的方式来做分页,而水平分库后由于存在多个数据源,因此分页又成为了一个难题,比如10
条数据为1
页,那如果想要拿到某张表的第一页数据,就必须通过如下手段获取:
这种方式可以是可以,但略微有些繁杂,同时也会让拓展性受限,比如原本有两个水平分库的节点,因此只需要从两个节点中拿到第一页数据,然后再做一次过滤即可,但如果水平库从两节点扩容到四节点,这时又要从四个库中各自拿10条数据,然后做过滤操作,读取前十条数据显示,这显然会导致每次扩容需要改动业务代码,对代码的侵入性有些强,所以合理的解决方案如下:
①常用的分页数据提前聚合到ES或中间表,运行期间跑按时更新其中的分页数据。
②利用大数据技术搭建数据中台,将所有子库数据汇聚到其中,后续的分页数据直接从中获取。
③上述聊到的那种方案,从所有字库中先拿到数据,然后在Service层再做过滤处理。
上述第一种方案是较为常用的方案,但这种方案对数据实时性会有一定的影响,使用该方案必须要能接受一定延时。第二种方案是最佳的方案,但需要搭建完善的大数据系统作为基础,成本最高。第三种方案成本最低,但对拓展性和代码侵入性的破坏比较严重。
ID主键唯一性问题
在之前的单库环境时,对于一张表的主键通常会选用整数型字段,然后通过数据库的自增机制来保证唯一性,但在水平分库多节点的情况时,假设还是以数据库自增机制来维护主键唯一性,这就绝对会出现一定的问题,可能会导致多个库中出现ID
相同、数据不同的情况,如下:
上述两个库需要存储不同的数据,当插入数据的请求被分发到对应节点时,如果再依据自增机制来确保ID唯一性,因为这里有两个数据库节点,两个数据库各自都维护着一个自增序列,因此两者ID值都是从1开始往上递增的,这就会导致前面说到的ID相同、数据不同的情况出现,那此时又该如何解决呢?如下:
这时可以根据水平库节点的数量来设置自增步长,假设此时有两个库,那自增步长为2
,两个库的ID
起始值为:{DB1:1}、{DB2:2}
,最终达到上图中的效果,无论在插入数据的操作落入哪个节点,都能够确保ID
的唯一性。当然,保障分布式系统下ID
唯一性的解决方案很多,如下:
- ①通过设置数据库自增机制的起始值和步长,来控制不同节点的
ID
交叉增长,保证唯一性。 - ②在业务系统中,利用特殊算法生成有序的分布式
ID
,比如雪花算法、Snowflake
算法等。 - ③利用第三方中间件生产
ID
,如使用Redis
的incr
命令、或创建独立的库专门做自增ID
工作。
上述这几种方案都是较为主流的分布式ID生成的方案,同时也能够保证ID的有序性,能够最大程度上维护索引的性能,相对来说第一种方案成本最低,但是会限制节点的拓展性,也就是当后续扩容时,数据要做迁移,同时要重新修改起始值和自增步长。
一般企业中都会使用第二种方案,也就是通过分布式ID生成的算法,在业务系统中生成有序的分布式ID
读写分离
增加MySQL数据库的从节点来实现负载均衡,减轻主节点的查询压力,让主节点专注于处理写请求,保证读写操作的高性能。
物理机
- 如果数据库服务器内存、硬件资源,或者网络资源配置不是很好,就会慢一些哈。这时候可以升级配置。这就好比你的计算机有时候很卡,你可以加个内存条什么的一个道理。
- 如果数据库压力本身很大,比如高并发场景下,大量请求到数据库来,数据库服务器CPU占用很高或者IO利用率很高,这种情况下所有语句的执行都有可能变慢的哈。