一. 为什么需要切表
在实现这个功能是首先考虑的是,为什么我们需要进行分库分表。
关系型数据库本身比较容易成为系统的瓶颈(再深入则为关系型数据库的读写一般都为对文件的I/O操作,而硬盘I/O是整个计算机系统中性能比较差的一个部分)。所以当查询维度较多,即使添加了从库,优化了索引,很多操作的性能仍然无法满足需求时,则需要对数据库进行切分。
数据库分布式核心内容就是数据切分(Sharding),以及切分后对数据的定位,整合。依据切分类型,可分为垂直(纵向)切分和水平(横向)切分。
1. 垂直切分
垂直切分常见垂直分库和垂直分表。
(1) . 垂直分库依据业务耦合性,将关联度低的不同表存储在不同的数据库中。做法同大系统和多个小系统类似,按照业务分类进行独立划分。与“微服务治理”的做法类似。例如:
主要数据库—-一个集群
日志库—-一个集群
报表库—-一个集群
(2) . 垂直分表则是基于数据库中的“列”进行,某个表的字段过多,则可以新建一张扩展表,将不常使用的字段或者字段长度较大的字段拆分出去。例如:[用户基本信息表,用户详情表,用户地址表] 。通过“大表拆小表”避免了跨页问题(MySQL底层通过数据页存储,一条记录占用空间过大会导致跨页,造成额外的性能开销)。另外数据库以行为单位将数据加载到内存中,当字段长度较短时且访问频率高时,内存能加载更多数据,命中率更高,从而减少对磁盘的I/O。
优点:解决业务层面耦合。
微服务治理类似的分级管理,维护,监控和扩展。
高并发下提高一定的性能优化
缺点:部分表无法join,只能接口聚合,提高了开发难度
分布式事务处理复杂
依然存在单表过大问题
2. 水平切分
当一个应用无法以再细粒度的垂直切分,或者切分后的数据量行数巨大,存在单库读写,存储性能瓶颈,此时就需要水平切分。水平切分分为库内分表和分库分表,根据表内数据内在的逻辑关系,将同一个表按不同的条件分散到多个数据库或多个表中,每个表中只包含一部分数据,从而使得单个表的数据量变小,达到分布式的效果。
如:日志数据库或者报表数据库(以时间为粒度):以年份分库(log_2018,log_2019)
以月份分表(log_2019.log_2019_01,log_2019.log_2019_02)
优点:单库数据量保持在一定程度,高并发性能好,系统稳定性和负载能力强。
应用端改造小,不需要拆分业务模块
缺点:跨分片的事务一致性难以保证
跨库的Join关联查询性能较差
数据多次扩展难度和维护量极大
(1) . 根据数值范围切分
按照时间区间或者ID区间来切分:按日期将不同月甚至是日的数据分散到不同的库中;将userId为19999的记录分到第一个库,1000020000的分到第二个库,以此类推。某种意义上,某些系统中使用的”冷热数据分离”,将一些使用较少的历史数据迁移到其他库中,业务功能上只提供热点数据的查询,也是类似的实践。
优点:单表大小可控
天然便于水平扩展,后期如果相对整个分片集群扩容时,只需要添加节点即可,无需迁移数据
使用分片字段进行范围查找时,连续分片可以快速定位分片进行快速查询,避免了跨分片查询问题。
缺点:热点数据成为性能瓶颈。连续分片可能存在数据热点,如按时间反字段分片时,最近时间段的数据可能会被频繁访问。而历史数据可能很少被查询。
(2) . 根据数据取模
一般采用Hash取模Mod的切分方式,如:将Partner表根据partner_id 切分到4个库中,余数为0的放到第一个库中,余数为1的放到第二个库中,以此类推。这样同一个用户的数据会分散到同一个库中,如果查询条件带有partner_id,则可明确定位到相应库中查询。
优点:数据分片相对均匀,不容易出现热点和并发访问的瓶颈。
缺点:后期分片集群扩容时,需要迁移旧的数据(使用一致性HASH算法则可以较好的避免这个问题)
容易面临跨分片查询的复杂问题。如果频繁使用了不带partner_id的查询条件,则无法定位数据库,从而向4个分片数据库发起查询,再在内存中合并数据,取最小集返回给应用,分库反而成为瓶颈。
3. 引发的各种问题
数据切分有可能会引发各种事物一致性等问题,在这里不做过多赘述,只是为了记录在代码层面上如何优化水平切分业务。
二. 使用AOP对相关业务表进行水平切分
上面提到了一个水平切分,在这里用实际案例:将报表以时间为维度切分为多表。
在我实际操作时,遇到了Aspect不生效的问题,google了很久之后莫名其妙解决了,至今未发现问题在哪里。
1. 引入依赖1
2
3
4
org.springframework.boot
spring-boot-starter-aop
由于Spring的属性[ spring.aop.auto=true ] 默认开启为true,也就是引入依赖后默认开启AOP。所以无须做过多Config操作1
2
3
4
5
6
7
8
9/**
* 声明一个以注解,在需要进行分表的方法中进行AOP切面
*
* /
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TimeSpiltTable {
}
3. 编写Aspect1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34/**
* 带时间切分表的AOP,格式为{##_yyyy_MM}.
*/
@Slf4j
@Component
@Aspect
public class TimeSpiltAspect{
/**
* 定义注解声明的方法,并进行切入
*/
@Pointcut("@annotation(com.exmaple.aop.annotation.TimeSpiltTable)")
public void pointcut(){
}
@Before("pointcut()")
public void concatTimeToTableName(JoinPoint joinPoint){
//获取参数数组
Object[] args = joinPoint.getArgs();
try {
for (Object parameter : args) {
//若为Map
if (parameter instanceof Map) {
Map map = (Map) parameter;
//这里是业务代码,获取传入的参数Map中的表名,然后再获取当前时间,转换为{yyyy_MM}格式,再拼接表名。
//重新赋值
map.put(Constants.DB_TABLE_NAME_PARAM, tableName);
}
}
} catch (Exception e) {
log.info("concatTimeToTableName Exception,exception={}, args={} ,", e.getMessage(), JSON.toJSON(args));
}
}
}
这一部分的代码为什么这样做,因为本项目使用的是MyBatis,我们进行了大量的分库分表,所以需要用MyBatis拼接SQL来进行定位不同的数据库和数据表。为了方便传入MyBatis,Mapper使用的参数统一为Map。所以在这里其实是对Mapper进行了AOP,并将Mapper方法的参数中的表名获取,再拼接成为分表的SQL。
为什么使用AOP去做这件事呢,因为这个表和这个业务原本就已经存在,只是在系统发展过程中,单表不满足性能需求,则进行了切分,使用AOP来做这一件事,第一是高效,这些代码编写不过几分钟。第二是不影响原本的代码,直接进行了扩展而已。
不过这种AOP做法也会有缺陷(无考证):就是在AOP不生效时,将会查询到没有分表后的原表名,可能导致程序错误,此时排查定位对维护的人有一定的难度。