引言
业务场景: 日志、交易流水表或者其他数据量大的表,通过日期进行了水平分表,需要通过日期参数,动态的查询数据。
实现思路:利用MybatisPlus
的动态表名插件DynamicTableNameInnerInterceptor
,实现Sql执行时,动态的修改表名。
实现步骤:在数据库预先创建好各年份或者月份的表之后,在配置类统一配置拦截器MybatisPlusInterceptor
需要处理的动态表。
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
DynamicTableNameInnerInterceptor dynamicTableNameYearInnerInterceptor = new DynamicTableNameInnerInterceptor();
// 添加表名处理器
dynamicTableNameYearInnerInterceptor.setTableNameHandler(
//可以传多个表名参数,指定哪些表使用YearTableNameHandler处理表名称
new YearTableNameHandler("t_trans_sub")
);
//可以传递多个拦截器,即:可以传递多个表名处理器TableNameHandler
interceptor.addInnerInterceptor(dynamicTableNameYearInnerInterceptor);
I 分表策略
1.1 配置类添加动态表名
提供按年分表和按月分表的分表策略: 在数据库预先创建好各年份或者月份的表之后,需要分表的到这里配置下。
@Configuration
public class MybatisPlusDynamicTableNameConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor = new DynamicTableNameInnerInterceptor();
dynamicTableNameInnerInterceptor.setTableNameHandler(
//可以传多个表名参数,指定哪些表使用MonthTableNameHandler处理表名称
new MonthTableNameHandler("t_trans_flow","t_trans_flow_act")
);
//以拦截器的方式处理表名称
interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
DynamicTableNameInnerInterceptor dynamicTableNameYearInnerInterceptor = new DynamicTableNameInnerInterceptor();
dynamicTableNameYearInnerInterceptor.setTableNameHandler(
//可以传多个表名参数,指定哪些表使用YearTableNameHandler处理表名称
new YearTableNameHandler("t_trans_sub")
);
//可以传递多个拦截器,即:可以传递多个表名处理器TableNameHandler
interceptor.addInnerInterceptor(dynamicTableNameYearInnerInterceptor);
return interceptor;
}
}
1.2 实现动态表名接口
如果没有赋值的, 默认操作服务器时间当前月份或者年份的表。
- 实现年份动态表名处理器(
YearTableNameHandler
)
/**
* 按年份参数,组成动态表名
*/
public class YearTableNameHandler implements TableNameHandler {
//用于记录哪些表可以使用该年份动态表名处理器(即哪些表按年分表)
private List<String> tableNames;
//构造函数,构造动态表名处理器的时候,传递tableNames参数
public YearTableNameHandler(String ...tableNames) {
this.tableNames = Arrays.asList(tableNames);
}
//每个请求线程维护一个年份数据,避免多线程数据冲突。所以使用ThreadLocal
private static final ThreadLocal<String> YEAR_DATA = new ThreadLocal<>();
//设置请求线程的年份数据
public static void setData(String month) {
YEAR_DATA.set(month);
}
//删除当前请求线程的年份数据
public static void removeData() {
YEAR_DATA.remove();
}
//动态表名接口实现方法
@Override
public String dynamicTableName(String sql, String tableName) {
if (this.tableNames.contains(tableName)){
if (YEAR_DATA.get()==null)
{
LocalDate date = LocalDate.now();
return tableName + "_" + date.format(DateTimeFormatter.ofPattern("yyyy")); //表名增加年份后缀
}
return tableName + "_" + YEAR_DATA.get(); //表名增加年份后缀
}else{
return tableName; //表名原样返回
}
}
}
- 实现月份动态表名处理器(
MonthTableNameHandler
)
/**
* 按月份参数,组成动态表名
*/
public class MonthTableNameHandler implements TableNameHandler {
//用于记录哪些表可以使用该月份动态表名处理器(即哪些表按月分表)
private List<String> tableNames;
//构造函数,构造动态表名处理器的时候,传递tableNames参数
public MonthTableNameHandler(String ...tableNames) {
this.tableNames = Arrays.asList(tableNames);
}
//每个请求线程维护一个month数据,避免多线程数据冲突。所以使用ThreadLocal
private static final ThreadLocal<String> MONTH_DATA = new ThreadLocal<>();
//设置请求线程的month数据
public static void setData(String month) {
MONTH_DATA.set(month);
}
//删除当前请求线程的month数据
public static void removeData() {
MONTH_DATA.remove();
}
//动态表名接口实现方法
@Override
public String dynamicTableName(String sql, String tableName) {
if (this.tableNames.contains(tableName)){
if (MONTH_DATA.get()==null)
{
LocalDate date = LocalDate.now();
return tableName + "_" + date.format(DateTimeFormatter.ofPattern("yyyyMM")); //表名增加月份后缀
}
return tableName + "_" + MONTH_DATA.get(); //表名增加月份后缀
}else{
return tableName; //表名原样返回
}
}
}
1.3 用法
//执行数据操作之前设置月份(实际场景下该参数从请求参数中解析)
MonthTableNameHandler.setData("202208");
studentMapper.selectById(1);//以id=1查询student202208这张表
//阅后即焚,将ThreadLocal当前请求线程的数据移除
MonthTableNameHandler.removeData();
II 分库分表
非必须勿使用分库分表:如数据库确实成为性能瓶颈时,在设计分库分表方案时应充分考虑方案的扩展性,或者考虑采用成熟热门的分布式数据库解决方案,如 TiDB。
TiDB 数据库,针对 TiKV 中数据的打散,是基于 Range 的方式进行,将不同范围内的[StartKey,EndKey)分配到不同的 Region 上。
2.1 什么是分库分表?
- 分表:将一个表中的数据按照某种规则分拆到多张表中,降低锁粒度以及索引树,提升数据查询效率。
水平拆分:将总体数据按照某种维度(时间、用户)分拆到多个库中或者表中。
规则:按照时间划分、按照用户ID划分、按照业务能力划分,如订单按照(日期、用户 ID、区域)分库分表。
- 分库:将一个数据库中的数据按照某种规则分拆到多个数据库中,以缓解单服务器的压力(CPU、内存、磁盘、IO)。
避免热点数据对于单库单表造成压力,尽可能保证数据流量在各个库表中保持等量分配。
2.2 垂直拆表
大表拆小表: 将一张表中数据的不同”字段“分拆到多张表中,比如商品库将商品基本信息、商品库存、卖家信息等分拆到不同库表中。
将不常用的,数据较大,长度较长(比如 text 类型字段)的字段拆分到“扩展表“,表和表之间通过”主键外键“进行关联。好处:降低表数据规模,提升查询效率,也避免查询时数据量太大造成的“跨页”问题。
2.3 垂直拆库
- 垂直拆库:将一个系统中的不同业务场景进行拆分,比如订单表、用户表、商品表。
- 好处:降低单数据库服务的压力(物理存储、内存、IO 等)、降低单机故障的影响面
2.4 水平拆表
- 水平拆表:将数据按照某种维度拆分为多张表
- 好处:降低锁粒度,一定程度提升查询性能。
- 缺点:由于多张表还是从属于一个库,仍然会有 IO 性能瓶颈。
- 水平拆分手段:range 分库分表(时间范围分库分表)、hash 分库分表(“主键”进行 hash 来计算数据存储的库表索引)。
时间范围分库分表的缺点:
- 需要提前建库或表。
- 数据热点问题:当前时间的数据集中落在某个库表。
- 分页查询问题:库表中间分界线查询复杂。
2.5 水平拆库
水平拆库:将数据按照某种维度分拆到多个库中
好处:降低单机单库的压力,提升读写性能。
2.6 案例:hash分库分表
- 独立 hash: 通过主键计算hash值,然后hash值分别对库数和表数进行取余操作获取到库索引和表索引。
缺点:库和表的hash计算中存在公共因子,导致数据偏斜问题。
- 基因法: 用原分片键中的某些基因(例如前四位)作为库的计算因子,而使用另外一些基因作为表的计算因子。
计算hash 值的片段保持充分的随机性,避免造成严重数据偏斜问题。
- 关系表冗余:把分片键和库表索引建立一张索引表(“路由关系表”),为每个库表指定一个权限,通过权重的比例来调整数据的写入,从而实现库表数据偏斜率调整。
索引表可以通过存储在 redis 来优化性能。
每次查询操作,先去路由表中查询到数据所在的库表索引,然后再到库表中查询详细数据。
缺点:缺点是每次查询操作,需要先读取一次路由关系表,所以请求耗时可能会有一定增加。需要引入分布式事务保证数据一致性,极端情况可能带来数据的不一致。
- 分段索引关系表: 按照号段式建立区间索引,将分片键的区间对应库的关系通过关系表记录下来,每次查询操作,先去路由表中查询到数据所在的库表索引,然后再到库表中查询详细数据。
- 一致性 Hash 法