怒而改造了数据访问层,sharding-jdbc+mybatis-plus几个实践总结

719 阅读6分钟

一、前言

上年接手了一个新项目,这个项目经历多N个团队离职和交接,累计了大量正常人类无法理解的逻辑。考虑到对接数据模块问题突出,决定先把ORM组件梳理下:sharding-jdbc和mybatis-plus。mybatis-plus稍微改下下也是可以支持分表的,但是业务上分库分片的地方太多,所以继续使用sharding-jdbc作为分库分表组件。这篇文章把其中的一些通用的技巧简单总结下。

二、代码环境

基础pom配置如下:

<!-- ShardingSphere -->
        <!-- Sharding-JDBC -->
        <dependency>
            <groupId>org.apache.shardingsphere</groupId>
            <artifactId>shardingsphere-jdbc-core</artifactId>
            <version>5.3.0</version>
        </dependency>

        <dependency>
            <groupId>org.yaml</groupId>
            <artifactId>snakeyaml</artifactId>
            <version>1.33</version>
        </dependency>

        <!-- commons-lang3 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.10</version>
        </dependency>

        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.6.5</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>2.7.10</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
            <version>8.0.21</version>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.3.1</version>
        </dependency>

三、几个小技巧

1、mybatis-plus

  • 自动填充updateTime和createTime

    为什么要需要这俩字段且自动填充呢?三个原因,一是记录,新增和修改的地方逻辑很多,经常有遗漏,自动填充方便统一记录,确保时间准确 二是mysql要接入hive,用updateTime做增量统计。三是方便做数据归档。

拦截器添加剑时间
public class OperationInterceptor implements MetaObjectHandler {

    private static final String CREATE_TIME = "createTime";
    private static final String UPDATE_TIME = "updateTime";

    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, CREATE_TIME, LocalDateTime::now, LocalDateTime.class);
        this.strictInsertFill(metaObject, UPDATE_TIME, LocalDateTime::now, LocalDateTime.class);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, UPDATE_TIME, LocalDateTime::now, LocalDateTime.class);
    }
}

添加注解
@TableName("t_course")
@TableName("t_course")
@Data
@ToString
public class Course {

    @TableId(value = "cid",type = IdType.ASSIGN_ID)
    private Long cid;

    private Long userId;

    private int status;
		
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    //注意是INSERT_UPDATE,插入和更新都会填充字段
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}


配置注入
@Configuration
@MapperScan(basePackages = "com.*.**.mapper")
 public class MybatisPlusConfig{
 	@Bean
	 public OperationInterceptor dataOperationInterceptor() {
      return new OperationInterceptor();
 	}
 }
  • 移除xml

为什么要移除xml配置呢?实践中发现,配置了数据字段后,还需要经常在xml中在配置一遍;把sql写到xml中,容易遗漏逗号括号等,尤其是一些带复杂判断和拼接的sql。 望着茫茫几千行的sql拼接语句加几百个xml文件,开发中出现了很多配置错误,我经常跟同事吐槽,这考验的不是技术,而是眼力,但是老夫眼力真的不好。 因此这次优化直接把xml配置都干掉,直接使用lamda表达式实现增删改查,配合mybatis-x-generator插件新增表,效果是在团队内部消灭了低级的sql配置错误。

截屏2024-11-02 22.15.19.png

官网配置教程: baomidou.com/guides/wrap…

--- query
LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.ne(User::getName, "老王");

--- update
LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.notLike(User::getName, "王");
  • 唯一键逻辑处理

在网路不稳定,重试等场景,幂等是常见的业务要求。旧项目使用redis锁做幂等判断,但是无法做到100%的可靠,因为redis自己不稳定,而且锁的有效期不好判断,我们有的数据是跨年插入的,因此必须有数据库唯一键约束兜底。

这里有两个操作,新增或者更新,逻辑删除,删除后新增。唯一键存在的情况下,新增会报数据约束异常,逻辑删除的唯一一个键无法插入。唯一键必须和删除标志联合唯一,删除标志使用当前行id值作为删除标志。示例如下:

@TableName("t_course")
@Data
@ToString
public class Course {

    @TableId(value = "id",type = IdType.ASSIGN_ID)
    private Long id;

    private Long userId;

    private Long corderNo;

    private String cname;

    private String brief;

    private double price;

    private int status;

    @TableLogic(delval = "id")
    private Integer isDelete;

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}

自动生成的删除语句如下:

UPDATE t_course_1 SET   update_time=?,  is_delete=id WHERE id=? AND is_delete=0 ::: [2024-11-03T11:35:18.845, 1852911715678773249]

2、sharding-jdbc

  • 多字段复杂分片

    场景:按日期分表,有creatTime没有账单日期则写入当月的表,如果带有账单日期settle_time则根据账单日期写入对应的日期表。

    Sharing.yml

rules:
 - !SHARDING
   tables:
     t_course:
       actualDataNodes: db$->{0..1}.t_course
       keyGenerateStrategy:
          column: cid
          keyGeneratorName: alg-snowflake
       tableStrategy:
         complex:
           shardingColumns: settle_time,create_time
           shardingAlgorithmName: t_course_complex_custom_algorithm
       databaseStrategy:
         standard:
           shardingColumn: cid
           shardingAlgorithmName: standard-range-db
   shardingAlgorithms:
     t_course_complex_custom_algorithm:
          type: STANDARD_COMPLEX_TB
          props:
             strategy: complex
     standard-range-db:
          type: STANDARD_TEST_DB
   keyGenerators:
      alg-snowflake:
        type: SNOWFLAKE
public class TableComplexKeysShardingAlgorithm implements ComplexKeysShardingAlgorithm<Comparable<?>> {

    /**
     * 订单id列名
     */
    private static final String COLUMN_SETTLE_TIME = "settle_time";
    /**
     * 客户id列名
     */
    private static final String COLUMN_CREATE_TIME = "create_time";

    @Override
    public Collection<String> doSharding(Collection<String> availableTargetNames, ComplexKeysShardingValue<Comparable<?>> shardingValue) {
        if (!shardingValue.getColumnNameAndRangeValuesMap().isEmpty()) {
            throw new RuntimeException("不支持除了=和in的操作");
        }


        String logicTableName = shardingValue.getLogicTableName();

        // 获取订单id
        Collection<Comparable<?>> settleTime = shardingValue.getColumnNameAndShardingValuesMap().getOrDefault(COLUMN_SETTLE_TIME, new ArrayList<>(1));
        // 获取客户id
        Collection<Comparable<?>> createTimes = shardingValue.getColumnNameAndShardingValuesMap().getOrDefault(COLUMN_CREATE_TIME, new ArrayList<>(1));


        List<String> result = new ArrayList<>();
        if (CollectionUtils.isEmpty(settleTime)) {
            result.add("bill");
        } else {
            result.add("bill" + settleTime.toArray()[0]);
        }
        return result;

    }


    @Override
    public String getType() {
        return "STANDARD_COMPLEX_TB";
    }

    @Override
    public Properties getProps() {
        return null;
    }

    @Override
    public void init(Properties properties) {

    }
}

  • hint使用

shardingsphere.apache.org/document/5.…

强制走某个库

  @Select("/* SHARDINGSPHERE_HINT: DATA_SOURCE_NAME=db1 */select * from t_course where cid=#{cid}")
    List<Course> findCourse(@Param("cid") Long cid);

在强一致性场景,读写分离策略下,读主库:

   @Select("/* SHARDINGSPHERE_HINT: WRITE_ROUTE_ONLY=false */select * from t_course where cid=#{cid}")
    List<Course> findCourse(@Param("cid") Long cid);
  • spi的使用

5.3以前版本的spi示例在官方文档描述还是比较清楚的,5.3版本难点在于找不到spi的接口文档,没办法只能去源代码中找。具体方法是github.com/apache/shar…,找到5.3的tag切换过去,模仿自带的加解密算法实现。

截屏2024-11-06 08.44.07.png

  • 自定义加密算法

一般大公司有自己的加密算法,或者对接政府用国密,这时候需要自定义加密算法。示例如下:

rules:
 - !ENCRYPT
   tables:
     t_user:
       columns:
         password:
           cipherColumn: password_encrypt
           encryptorName: encryptor-customize
   encryptors:
     encryptor_aes:
       type: AES
       props:
         aes-key-value: 123456abc
         digest-algorithm-name: SHA-1
     encryptor-customize:
        type: sha256
        props:
          aes-key-value: pre

META-INF下的文件名为org.apache.shardingsphere.encrypt.spi.EncryptAlgorithm

截屏2024-11-06 08.47.35.png 代码如下:

@Slf4j
public final class Sha256Encryptor implements StandardEncryptAlgorithm<Object, String> {

    private Properties props;

    @Override
    public Properties getProps() {
        return this.props;
    }

    @Override
    public void init(Properties props) {
        this.props = props;
    }

    public String encrypt(Object plaintext, EncryptContext encryptContext) {
        if (null == plaintext) {
            return null;
        }
        log.info("Sha256Encryptor encrypt,value:{}", plaintext);

        return DigestUtils.sha256Hex(String.valueOf(plaintext));
    }


    public Object decrypt(String cipherValue, EncryptContext encryptContext) {
        log.info("Sha256Encryptor decrypt,value:{}", cipherValue);
        return cipherValue;
    }


    public String getType() {
        return "sha256";
    }

}

  • 自定义主键生成算法

跟加密算法一样,公司也有自定义的,或者雪花算法扛不住qps,需要自定义性能更好的。

截屏2024-11-06 08.50.06.png

3、封装成starter

以上逻辑大部分是业务通用的,为了方便其他服务使用,将其中的功能封装了starter。示例如下:

  • 代码迁移

image-20241107083843572.png

@Configuration
@MapperScan(basePackages = "com.example.mapper")
public class MyAutoConfig {

    //其他插件。。。。

    @Bean
    public OperationInterceptor dataOperationInterceptor() {
        return new OperationInterceptor();
    }

}

添加spi和autoconfig配置

image-20241107083921592.png

  • 使用方直接通过pon引入这个starter就可以

三、结尾

实际场景还有很多其他改造,如sql日志、数据库配置存储等,改造思想一样,但是通用性差一点,这里就不列出了。

如果这篇文章对您有帮助,记得点赞加关注!您的支持是我继续创作的动力!