1. 程式人生 > 其它 >【daisy-framework】SpringBoot+MyBatis+Druid 動態多資料來源

【daisy-framework】SpringBoot+MyBatis+Druid 動態多資料來源

技術標籤:設計架構動態資料來源SpringBoot讀寫分離Druid連線池高併發

前言

Github:https://github.com/yihonglei/daisy-framework/tree/master/daisy-springboot-framework(daisy工程)

概述

多資料來源主要解決高併發讀寫分離或多庫在一個應用處理系統業務。

多資料來源中心思想一樣,但是根據自己業務情況有很多種寫法實現多資料來源,也有很多知名或不知名的

開源分散式 ORM 框架設計時候就按照多資料來源設計的,使用時候天然就支援。

多資料來源個人覺得要滿足如下基本使用功能:

1、有多個業務庫資料來源時,需要能夠明確切換到具體的操作業務資料庫;

2、主從讀寫分離,預設寫走主庫,讀走從庫,同時可以指定讀強制走主庫,因為有時候為了避免主從延遲,

部分讀操作能夠支援指定強制走主庫;

一 前置準備

假如我有兩個庫,jpeony 和 order 庫,其中 jpeony 業務用的一主兩從,order 用的一主一從,具體配置格式如下。

application.yml

資料來源配置檔案,未放置 druid 的其他屬性,具體程式碼見 Github。

# DataSource
spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driverClassName: com.mysql.jdbc.Driver
    druid:
      jpeony:
        master:
          url: jdbc:mysql://127.0.0.1:3306/jpeony?characterEncoding=utf-8&useSSL=false
          username: root
          password: 123456
        slave01:
          url: jdbc:mysql://127.0.0.1:3306/jpeony?characterEncoding=utf-8&useSSL=false
          username: root
          password: 123456
        slave02:
          url: jdbc:mysql://127.0.0.1:3306/jpeony?characterEncoding=utf-8&useSSL=false
          username: root
          password: 123456
      order:
        master:
          url: jdbc:mysql://127.0.0.1:3306/order?characterEncoding=utf-8&useSSL=false
          username: root
          password: 123456
        slave:
          url: jdbc:mysql://127.0.0.1:3306/order?characterEncoding=utf-8&useSSL=false
          username: root
          password: 123456

DruidProperties

SpringBoot 工程讀取 druid 的相關屬性配置的值。

package com.jpeony.common.config.properties;

import com.alibaba.druid.pool.DruidDataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

/**
 * druid 配置屬性
 *
 * @author yihonglei
 */
@Configuration
public class DruidProperties {
    @Value("${spring.datasource.druid.initialSize}")
    private int initialSize;

    @Value("${spring.datasource.druid.minIdle}")
    private int minIdle;

    @Value("${spring.datasource.druid.maxActive}")
    private int maxActive;

    @Value("${spring.datasource.druid.maxWait}")
    private int maxWait;

    @Value("${spring.datasource.druid.timeBetweenEvictionRunsMillis}")
    private int timeBetweenEvictionRunsMillis;

    @Value("${spring.datasource.druid.minEvictableIdleTimeMillis}")
    private int minEvictableIdleTimeMillis;

    @Value("${spring.datasource.druid.maxEvictableIdleTimeMillis}")
    private int maxEvictableIdleTimeMillis;

    @Value("${spring.datasource.druid.validationQuery}")
    private String validationQuery;

    @Value("${spring.datasource.druid.testWhileIdle}")
    private boolean testWhileIdle;

    @Value("${spring.datasource.druid.testOnBorrow}")
    private boolean testOnBorrow;

    @Value("${spring.datasource.druid.testOnReturn}")
    private boolean testOnReturn;

    public DruidDataSource dataSource(DruidDataSource datasource) {
        /* 配置初始化大小、最小、最大 */
        datasource.setInitialSize(initialSize);
        datasource.setMaxActive(maxActive);
        datasource.setMinIdle(minIdle);

        /* 配置獲取連線等待超時的時間 */
        datasource.setMaxWait(maxWait);

        /* 配置間隔多久才進行一次檢測,檢測需要關閉的空閒連線,單位是毫秒 */
        datasource.setTimeBetweenEvictionRunsMillis(timeBetweenEvictionRunsMillis);

        /* 配置一個連線在池中最小、最大生存的時間,單位是毫秒 */
        datasource.setMinEvictableIdleTimeMillis(minEvictableIdleTimeMillis);
        datasource.setMaxEvictableIdleTimeMillis(maxEvictableIdleTimeMillis);

        /*
         * 用來檢測連線是否有效的sql,要求是一個查詢語句,常用select 'x'。如果validationQuery為null,testOnBorrow、testOnReturn、testWhileIdle都不會起作用。
         */
        datasource.setValidationQuery(validationQuery);
        /* 建議配置為true,不影響效能,並且保證安全性。申請連線的時候檢測,如果空閒時間大於timeBetweenEvictionRunsMillis,執行validationQuery檢測連線是否有效。 */
        datasource.setTestWhileIdle(testWhileIdle);
        /* 申請連線時執行validationQuery檢測連線是否有效,做了這個配置會降低效能。 */
        datasource.setTestOnBorrow(testOnBorrow);
        /* 歸還連線時執行validationQuery檢測連線是否有效,做了這個配置會降低效能。 */
        datasource.setTestOnReturn(testOnReturn);
        return datasource;
    }
}

二 多資料來源配置

DataSourceTypeEnum

資料來源列舉。

package com.jpeony.common.enums;

/**
 * @author yihonglei
 */
public enum DataSourceTypeEnum {
    JPEONY_MASTER,
    JPEONY_SLAVE01,
    JPEONY_SLAVE02,
    ORDER_MASTER,
    ORDER_SLAVE
}

MultipleDataSourceContextHolder

執行緒持有的資料來源設定。

package com.jpeony.common.datasource;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * @author yihonglei
 */
public class MultipleDataSourceContextHolder {
    public static final Logger logger = LoggerFactory.getLogger(MultipleDataSourceContextHolder.class);
    private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();

    public static void setDataSourceType(String dsType) {
        CONTEXT_HOLDER.remove();
        CONTEXT_HOLDER.set(dsType);
    }

    public static String getDataSourceType() {
        return CONTEXT_HOLDER.get();
    }

    public static void clearDataSourceType() {
        CONTEXT_HOLDER.remove();
    }
}

MultipleDataSource

多資料來源路由器。

package com.jpeony.common.datasource;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

/**
 * @author yihonglei
 */
public class MultipleDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return MultipleDataSourceContextHolder.getDataSourceType();
    }
}

DruidDataSourceConfig

druid 多資料來源配置,將配置檔案中的資料來源構建為 DataSource,最後放入AbstractRoutingDataSource 路由中。

package com.jpeony.common.config;

import com.alibaba.druid.pool.DruidDataSource;
import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
import com.alibaba.druid.spring.boot.autoconfigure.properties.DruidStatProperties;
import com.alibaba.druid.util.Utils;
import com.jpeony.common.config.properties.DruidProperties;
import com.jpeony.common.datasource.MultipleDataSource;
import com.jpeony.common.enums.DataSourceTypeEnum;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

import javax.servlet.*;
import javax.sql.DataSource;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * @author yihonglei
 */
@Configuration
public class DruidDataSourceConfig {
    @Bean
    @ConfigurationProperties("spring.datasource.druid.jpeony.master")
    public DataSource jpeonyMasterDataSource(DruidProperties druidProperties) {
        DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
        return druidProperties.dataSource(dataSource);
    }

    @Bean
    @ConfigurationProperties("spring.datasource.druid.jpeony.slave01")
    public DataSource jpeonySlave01DataSource(DruidProperties druidProperties) {
        DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
        return druidProperties.dataSource(dataSource);
    }

    @Bean
    @ConfigurationProperties("spring.datasource.druid.jpeony.slave02")
    public DataSource jpeonySlave02DataSource(DruidProperties druidProperties) {
        DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
        return druidProperties.dataSource(dataSource);
    }

    @Bean
    @ConfigurationProperties("spring.datasource.druid.order.master")
    public DataSource orderMasterDataSource(DruidProperties druidProperties) {
        DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
        return druidProperties.dataSource(dataSource);
    }

    @Bean
    @ConfigurationProperties("spring.datasource.druid.order.slave")
    public DataSource orderSlaveDataSource(DruidProperties druidProperties) {
        DruidDataSource dataSource = DruidDataSourceBuilder.create().build();
        return druidProperties.dataSource(dataSource);
    }

    @Bean(name = "multipleDataSource")
    @Primary
    public MultipleDataSource dataSource(DataSource jpeonyMasterDataSource, DataSource jpeonySlave01DataSource,
                                         DataSource jpeonySlave02DataSource, DataSource orderMasterDataSource,
                                         DataSource orderSlaveDataSource) {
        // 資料來源
        Map<Object, Object> targetDataSources = new HashMap<>(16);
        targetDataSources.put(DataSourceTypeEnum.JPEONY_MASTER, jpeonyMasterDataSource);
        targetDataSources.put(DataSourceTypeEnum.JPEONY_SLAVE01, jpeonySlave01DataSource);
        targetDataSources.put(DataSourceTypeEnum.JPEONY_SLAVE02, jpeonySlave02DataSource);
        targetDataSources.put(DataSourceTypeEnum.ORDER_MASTER, orderMasterDataSource);
        targetDataSources.put(DataSourceTypeEnum.ORDER_SLAVE, orderSlaveDataSource);

        // 路由資料來源
        MultipleDataSource multipleDataSource = new MultipleDataSource();
        multipleDataSource.setTargetDataSources(targetDataSources);
        return multipleDataSource;
    }

    @SuppressWarnings({"rawtypes", "unchecked"})
    @Bean
    @ConditionalOnProperty(name = "spring.datasource.druid.statViewServlet.enabled", havingValue = "true")
    public FilterRegistrationBean removeDruidFilterRegistrationBean(DruidStatProperties properties) {
        // 獲取web監控頁面的引數
        DruidStatProperties.StatViewServlet config = properties.getStatViewServlet();
        // 提取common.js的配置路徑
        String pattern = config.getUrlPattern() != null ? config.getUrlPattern() : "/druid/*";
        String commonJsPattern = pattern.replaceAll("\\*", "js/common.js");
        final String filePath = "support/http/resources/js/common.js";
        // 建立filter進行過濾
        Filter filter = new Filter() {
            @Override
            public void init(javax.servlet.FilterConfig filterConfig) throws ServletException {
            }

            @Override
            public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                    throws IOException, ServletException {
                chain.doFilter(request, response);
                // 重置緩衝區,響應頭不會被重置
                response.resetBuffer();
                // 獲取common.js
                String text = Utils.readFromResource(filePath);
                // 正則替換banner, 除去底部的廣告資訊
                text = text.replaceAll("<a.*?banner\"></a><br/>", "");
                text = text.replaceAll("powered.*?shrek.wang</a>", "");
                response.getWriter().write(text);
            }

            @Override
            public void destroy() {
            }
        };
        FilterRegistrationBean registrationBean = new FilterRegistrationBean();
        registrationBean.setFilter(filter);
        registrationBean.addUrlPatterns(commonJsPattern);
        return registrationBean;
    }
}

三 AOP 攔截動態切換

在正式分析路由實現前,需要先明確自定義的註解。

這裡自己定義了一個 DB 註解,用於在 Mapper 上面指定庫,還要一個 UseMaster 註解,用於強制走主庫指定。

package com.jpeony.common.annotation;

import java.lang.annotation.*;

/**
 * 修飾 Mapper 介面,攔截指定資料來源
 *
 * @author yihonglei
 */
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DB {
    /**
     * 資料來源名稱
     */
    String name() default "";
}
package com.jpeony.common.annotation;

import java.lang.annotation.*;

/**
 * 強制使用主庫
 *
 * @author yihonglei
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface UseMaster {
}

Mapper 上關於 DB 和 UseMaster 註解的使用。

package com.jpeony.core.mapper;

import com.jpeony.common.annotation.DB;
import com.jpeony.common.annotation.UseMaster;
import com.jpeony.common.constant.DBConstant;
import com.jpeony.core.pojo.domain.TestDO;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;

/**
 * Mapper介面
 *
 * @author yihonglei
 */
@DB(name = DBConstant.JPEONY)
public interface TestMapper {
    /**
     * MyBatis 註解形式
     */
    @Select("select * from test where id = #{id}")
    TestDO queryTestById(@Param("id") int id);

    @UseMaster
    @Select("select * from test where id = #{id}")
    TestDO queryTestByIdMaster(@Param("id") int id);

    @Update("update test set test_name = #{testName} where id = #{id}")
    int updateTestById(@Param("id") int id, @Param("testName") String testName);

    /**
     * MyBatis XML方式
     */
    TestDO queryTestByIdXml(@Param("id") int id);
}

DBConstant 用於配置對應的資料庫名,在 Mapper 上使用的時候,需要進行顯示指定用的庫,因為在是路由的依據。

package com.jpeony.common.constant;

/**
 * 資料庫名
 *
 * @author yihonglei
 */
public class DBConstant {
    public final static String JPEONY = "jpeony";

    public final static String ORDER = "order";
}

DataSourceAop

根據上面這些配置,通過 AOP 攔截 Mapper,然後路由切換資料來源的邏輯。

1、aop 攔截 mapper 下的所有類方法;

2、通過反射,獲取 Mapper 上 DB 指定的資料庫名,用於匹配對應配置的主庫或從庫;

3、如果方法上使用了 UseMaster 強制走主或者 update、insert、delete 寫操作,直接通過資料庫名匹配找到對應的主庫,

如果是走從庫的,需要匹配出資料庫對應的多個從庫,然後隨機選擇一個從庫,實現強制主或寫操作走主庫,其餘預設走從庫操作。

4、這裡還做了一個相容,當不配置主庫的時候,預設選擇一個從庫做主庫使用,

其實當沒配置主庫的時候,可以強制拋異常處理,也沒有必要切換到從庫連線,這個切換應該是由 資料庫主從本身自己去做。

5、這個路由有一個弊端,當使用 MyBatis 的 xml 配置方式時,如果是寫操作,需要顯示的去指定走主庫,要不然切不到主庫去。

處理方式可以從方法名著手,比如 updateXXX,insertXXX,deleteXXX,editXXX,這樣去命名方法名然後切主庫,

就是寫起程式碼來規範太多了,規範太多煩人。

package com.jpeony.common.datasource;

import com.jpeony.common.annotation.DB;
import com.jpeony.common.annotation.UseMaster;
import com.jpeony.common.enums.DataSourceTypeEnum;
import com.jpeony.common.enums.ErrorCodeEnum;
import com.jpeony.common.exception.DBException;
import com.jpeony.common.utils.MatchUtils;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Update;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Random;

/**
 * @author yihonglei
 */
@Aspect
@Order(1)
@Component
public class DataSourceAop {
    protected Logger logger = LoggerFactory.getLogger(getClass());
    private final Random random = new Random();

    @Pointcut("execution(* com.jpeony.core.mapper..*.*(..))")
    public void dsPointCut() {
    }

    @Around("dsPointCut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        String targetDataSource = getTargetDataSource(point);
        MultipleDataSourceContextHolder.setDataSourceType(targetDataSource);

        try {
            return point.proceed();
        } finally {
            MultipleDataSourceContextHolder.clearDataSourceType();
        }
    }

    private String getTargetDataSource(ProceedingJoinPoint point) {
        String dbName = getDBName(point).toUpperCase();
        boolean useMaster = getUseMaster(point);
        String targetDataSource = StringUtils.EMPTY;

        DataSourceTypeEnum[] values = DataSourceTypeEnum.values();
        ArrayList<String> slaves = new ArrayList<>(values.length);
        for (DataSourceTypeEnum dst : values) {
            if (useMaster) {
                boolean match = MatchUtils.matchDataSource(dbName + MatchUtils.PATTERN_MATCH_MASTER, dst.name());
                if (match) {
                    targetDataSource = dst.name();
                    break;
                }
            } else {
                // Match all slaves
                boolean match = MatchUtils.matchDataSource(dbName + MatchUtils.PATTERN_MATCH_SLAVE, dst.name());
                if (match) {
                    slaves.add(dst.name());
                }
            }
        }

        if (StringUtils.isNotBlank(targetDataSource)) {
            return targetDataSource;
        }

        if (CollectionUtils.isEmpty(slaves)) {
            throw new DBException(ErrorCodeEnum.DATA_SOURCE_ERROR);
        }

        return slaves.get(random.nextInt(slaves.size()));
    }

    private String getDBName(ProceedingJoinPoint point) {
        Class<?> targetClass = point.getTarget().getClass();
        DB dbName = AnnotationUtils.findAnnotation(targetClass, DB.class);
        if (dbName == null) {
            throw new DBException(targetClass.getName() + ", no specified database");
        }
        return dbName.name();
    }

    private boolean getUseMaster(ProceedingJoinPoint point) {
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();

        UseMaster useMaster = method.getAnnotation(UseMaster.class);
        Update update = method.getAnnotation(Update.class);
        Insert insert = method.getAnnotation(Insert.class);
        Delete delete = method.getAnnotation(Delete.class);

        return (useMaster != null || update != null || insert != null || delete != null);
    }
}

四 測試

package com.jpeony.test.mapper;

import com.jpeony.core.mapper.TestMapper;
import com.jpeony.core.pojo.domain.TestDO;
import com.jpeony.test.BaseServletTest;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;

/**
 * Mapper測試
 *
 * @author yihonglei
 */
@Slf4j
public class MapperTest extends BaseServletTest {
    @Autowired
    private TestMapper testMapper;

    @Test
    public void testAnnotation() {
        TestDO testDO = testMapper.queryTestById(1);
        log.info("testDO annotation={}", testDO);
    }

    @Test
    public void testUseMaster() {
        TestDO testDO = testMapper.queryTestByIdMaster(1);
        log.info("testDO useMaster={}", testDO);
    }

    @Test
    public void testUpdate() {
        int i = testMapper.updateTestById(1, "oneone");
        log.info("testUpdate={}", i);
    }

    @Test
    public void testXml() {
        TestDO testDO = testMapper.queryTestByIdXml(1);
        log.info("testDO xml={}", testDO);
    }
}

五 總結

1、如果要新在配置一個數據源需要修改那些地方?

a. application.yml 按要求配置上你的資料來源;

b.DBConstant 定義資料庫名,比如 X;

c.DataSourceTypeEnum 加上你的資料來源型別主從命名,X_MASTER,X_SLAVE01,X_SLAVE02 依次類推;

d. DruidDataSourceConfig 將新資料來源構建 DataSource,加入到 資料來源路由中;

e. Mapper 裡面直接指定用就行了;

支援多個庫資料來源,支援一主多從,自動讀寫分離,也可強制走主,注意 Mapper XML 配置方式,

需要使用 UseMaster 強制指定走主,一般實際業務系統,單表處理業務多一些,用MyBatis註解方式會

更簡潔方便些,像後臺管理哪些關聯大查詢,用 MyBatis XML 好一些。