暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

如何实现分库分表插件

清幽之地的博客 2021-04-20
465

前言

随着系统数据量的日益增长,在说起数据库架构和数据库优化的时候,我们难免会常常听到分库分表这样的名词。

当然,分库分表有很多的方法论,比如垂直拆分、水平拆分;也有很多的中间件产品,比如MyCat、ShardingJDBC。

根据业务场景选择合适的拆分方法,再选择一个熟悉的开源框架,就能帮助我们完成项目中所涉及到的数据拆分工作。

本文并不打算就这些方法论和开源框架展开深入的探讨,笔者想讨论另外一个场景:

如果系统中需要拆分的表并不多,只是1个或者少量的几个,我们是否值得引入一些相对复杂的中间件产品;特别是,如果我们对它们的原理不甚了解,是否有信心驾驭它们 ?

基于此,如果你的系统中有少量的表需要拆分,也没有专门的资源去研究开源组件,那么我们可以自己来实现一个简单的分库分表插件;当然,如果你的系统比较复杂,业务量较大,还是采用开源组件或者团队自研组件来解决这事较为稳妥。

一、原理

分库分表这事说简单也简单,说复杂那也挺复杂...

简单是因为它的核心流程比较明确。就是解析SQL语句,然后根据预先配置的规则,重写或路由到真实的数据库表中去;

复杂在于,SQL语句复杂且灵活,比如分页、去重、排序、分组、聚合、关联查询等操作,如何正确的解析它们。

所以就算是 ShardingJDBC
,在官网中也明确了支持项和不支持项。

二、注解式配置

相对于复杂的配置文件,我们采用较为轻便的注解式配置,它的定义如下:

  1. @Target({ElementType.TYPE})

  2. @Retention(RetentionPolicy.RUNTIME)

  3. @Documented

  4. public @interface Sharding {

  5. String tableName(); //逻辑表名

  6. String field(); //分片键

  7. String mode(); //算法模式

  8. int length() default 0; //分表数量

  9. }

那么,在哪里使用它呢 ?比如我们的用户表需要分表,那就在User这个实体对象上标注。

  1. @Data

  2. @Sharding(tableName = "user",field = "id",mode = "hash",length = 16)

  3. public class User {

  4. private Long id;

  5. private String name;

  6. private String address;

  7. private String tel;

  8. private String email;

  9. }

这就说明了,我一共有 16 张用户表,根据用户ID,使用Hash算法来计算它的位置。

当然,我们不止有Hash算法,还可以根据日期范围来定义。

  1. @Data

  2. @Sharding(tableName = "car",field = "creatTime",mode = "range")

  3. public class Car {

  4. private long id;

  5. private String number;

  6. private String brand;

  7. private String creatTime;

  8. private long userId;

  9. }

三、分片算法

在这里,笔者实现了两种分片方式,就是 HashAlgorithmRangeAlgorithm
 。

1、范围分片

如果你的系统中有使用冷热数据分离,我们可以按照日期将不同月的数据分散到不同的表中。

比如车辆的创建时间是 2019-12-1015:30:00
,这条数据将会被分配到 car_201912
这张表中去。

我们通过截取时间的年月部分,然后再加上逻辑表名即可。

  1. public class RangeAlgorithm implements Algorithm {

  2. @Override

  3. public String doSharding(String tableName, Object value,int length) {

  4. if (value!=null){

  5. try{

  6. DateUtil.parseDateTime(value.toString());

  7. String replace = value.toString().substring(0, 7).replace("-", "");

  8. String newName = tableName+"_"+replace;

  9. return newName;

  10. }catch (DateException ex){

  11. logger.error("时间格式不符合要求!传入参数:{},正确格式:{}",value.toString(),"yyyy-MM-dd HH:mm:ss");

  12. return tableName;

  13. }

  14. }

  15. return tableName;

  16. }

  17. }

2、Hash分片

在Hash分片算法中,我们可以先判断表的数量,是不是2的幂次方。如果不是,就通过算数方式获取下标,如果是呢,就通过位运算的方式获取下标。当然了,这是在HashMap源码中学到的哦。

  1. public class HashAlgorithm implements Algorithm {

  2. @Override

  3. public String doSharding(String tableName, Object value,int length) {

  4. if (this.isEmpty(value)){

  5. return tableName;

  6. }else{

  7. int h;

  8. int hash = (h = value.hashCode()) ^ (h >>> 16);

  9. int index;

  10. if (is2Power(length)){

  11. index = (length - 1) & hash;

  12. }else {

  13. index = Math.floorMod(hash, length);

  14. }

  15. return tableName+"_"+index;

  16. }

  17. }

  18. }

四、拦截器

配置和分片算法都有了,接下来就是重头戏了。在这里,我们使用 Mybatis拦截器
将它们派上用场。

常年CRUD的我们,都知道一条业务SQL肯定逃不出它们的范围。其中,在业务上我们的删除功能一般都是逻辑删除,所以,基本上不会有DELETE操作。

相较而言,新增和修改SQL都比较简单且格式固定,查询SQL往往比较灵活且复杂。所以,在这里笔者定义了两个拦截器。

不过,在介绍拦截器之前,我们有理由要了解另外两个东西:SQL语法解析器和分片算法处理器。

1、JSqlParser

JSqlParser
负责解析SQL语句,并转化为Java类的层次结构。我们可以先看个简单的例子来认识它。

  1. public static void main(String[] args) throws JSQLParserException {


  2. String insertSql = "insert into user (id,name,age) value(1001,'范闲',20)";

  3. Statement parse = CCJSqlParserUtil.parse(insertSql);

  4. Insert insert = (Insert) parse;


  5. String tableName = insert.getTable().getName();

  6. List<Column> columns = insert.getColumns();

  7. ItemsList itemsList = insert.getItemsList();

  8. System.out.println("表名:"+tableName+" 列名:"+columns+" 属性:"+itemsList);

  9. }

  10. 输出: 表名:user 列名:[id, name, age] 属性:(1001, '范闲', 20)

我们可以看到, JSqlParser
可以解析出SQL的语法信息。相应的,我们也可以更改对象内容,从而达到修改SQL语句的目的。

2、算法处理器

我们的分片算法有多个,具体应该调用哪一个是在程序运行期来决定的。所以,我们使用一个Map先将算法注册起来,然后根据分片模式来调用它。这也是策略模式的体现。

  1. @Component

  2. public class AlgorithmHandler {

  3. private Map<String, Algorithm> algorithm = new HashMap<>();

  4. @PostConstruct

  5. public void init(){

  6. algorithm.put("range",new RangeAlgorithm());

  7. algorithm.put("hash",new HashAlgorithm());

  8. }

  9. public String handler(String mode,String name,Object value,int length){

  10. return algorithm.get(mode).doSharding(name, value,length);

  11. }

  12. }

3、拦截器

我们知道,MyBatis允许你在已映射语句执行过程中的某一点进行拦截调用。

如果你对它的原理还不熟悉,那么可以先看看笔者的文章:Mybatis拦截器的原理

整体来看,它的流程如下:

  • 通过 Mybatis
    拦截待执行的SQL;

  • 通过 JSqlParser
    解析SQL,获取逻辑表名等;

  • 调用分片算法获取真实表名;

  • 修改SQL,并修改 BoundSql

  • Mybatis
    执行修改后的SQL,达成目的。

比如,对于 insert
语句,它的核心代码如下:

五、查询及分页

事实上,新增和修改都比较简单,较为复杂的是查询语句。

但是,我们的插件并不在于要满足所有的查询语句,而是可以根据真实的业务场景来扩展修改。

不过分页功能基本上是逃不开的。拿 PageHelper
为例,它的原理也是通过 Mybatis
拦截器来实现的。如果它和我们的分表插件在一起,可能会产生冲突。

所以在分表插件中,笔者也集成了分页功能,基本上和 PageHelper
一样,但并未直接使用它。另外,对于查询来说,在查询条件中是否带有分片键,也是很关键的地方。

1、查询

在范围算法中,在业务上我们要求只查询特定某一个月或者近几个月的数据即可;在Hash算法中,我们则要求每次都带有主键。

但第二个条件往往不能成立,业务方也满足不了每次都必须带有主键。

针对这种情况,我们只能遍历所有的表,查询符合条件的数据,然后再汇总返回;

这种方式的缺点显而易见,性能较差。还有一种方式就是可以将常用的查询条件与分片键建立映射关系,在查询时先根据查询条件找到分片键的字段值,然后再根据分片键查询。

2、分页

如上所言,插件中集成了分页功能,实现流程与 PageHelper
一样,但考虑到冲突,并未直接使用。

六、其他

事实上,笔者在想本文的标题时,着实比较苦恼。因为 分库分表
在业界是一个词,但本文插件并不涉及分库,仅有的只是分表操作而已,不过本文的重点是思路,最终还是叫了 分库分表
,还请盆友们见谅,不要叫我标题党~

由于篇幅所限,文中只有少量的代码,如果感兴趣的盆友可以去 https://github.com/taoxun/sharding
获取完整Demo。

笔者的代码中,包含了一些测试用例和建表SQL,创建完表后直接运行项目即可。


文章转载自清幽之地的博客,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论