Spring - 數據庫讀寫分離

Spring - 數據庫讀寫分離

 一般情況下應用程序對數據庫操作都是讀多寫少,造成數據庫讀取數據時壓力比較大,那麼讀寫分離、數據庫集羣等解決方案就出現了。一個作爲負責寫數據的主庫,稱爲寫庫,其它都作爲負責讀取數據的從庫,稱之爲讀庫。與此同時我們需要保證以下幾點:

  1. 讀庫與寫庫數據一致,可通過主從同步實現。
  2. 寫數據只能通過寫庫(主庫),讀數據只能通過讀庫(從庫)。

1.讀寫分離解決方案

1.1 應用層解決

 第一種方式是從應用層去實現讀寫分離的策略,通過程序去控制對應操作數據源。
 優點:

  1. 多數據源更靈活,由程序完成;
  2. 不需要引入中間件,減少學習和搭建成本;
  3. 理論上支持任何數據庫;

 缺點:

  1. 由開發獨立完成,運維不可管控;
  2. 無法動態變更數據源;
    在這裏插入圖片描述
1.2 中間件解決

 第二種方式是利用中間件去實現讀寫分離的策略,通過中間件代理去分配操作數據源。
 優點:

  1. 程序無法任何改動,開發無感知;
  2. 動態變更數據源不需要重啓程序;

 缺點:

  1. 由於依賴於中間件,會導致切換數據庫困難;
  2. 由於利用中間件做代理,故性能有所下降;
    在這裏插入圖片描述

2.Mysql主從配置

2.1 Mysql主從複製原理

在這裏插入圖片描述

 我們在使用讀寫分離的前提下需要保證主從數據的一致性,所以主從數據同步是讀寫分離的前提。這裏我們先來了解一下Mysql主從複製的原理,這裏借用網上一張圖片可以讓我們更容易明白。

  1. master將數據改變記錄到二進制日誌(binarylog)中,也即是配置文件log-bin指定的文件(這些記錄叫做二進制日誌事件,binary log events)
  2. slave將master的binary logevents拷貝到它的中繼日誌(relay log)
  3. slave重做中繼日誌中的事件,將改變反映它自己的數據(數據重演)
2.2 Master主庫配置

 首先我們需要打開mysql目錄下的my.cnf配置文件,在[mysqld]下添加或修改以下配置

#指定主庫serverid
server-id=1
#打開二進制日誌
log-bin= master-bin
#指定同步的數據庫,若不指定則同步全部數據庫(根據具體需求而定)
binlog-do-db=my_test_db

 配置完成保存後,重啓mysql服務,進入mysql執行show master status命令,可以看到以下信息,主要。
在這裏插入圖片描述
 記錄好以上File以及Position信息後,我們在主庫上創建一個用於數據同步的用戶。

create user u_replication identified by 'replication123456';
grant replication slave on *.* to 'u_replication'@'%';
flush privileges;
2.3 Slave從庫配置

 同樣我們打開從庫機器上mysql目錄下的my.cnf,在[mysqld]下添加或修改以下配置。

#指定從庫serverid,不可重複
server-id=2

 配置完成後,登錄mysql執行以下命令

change master to
master_host='127.0.0.1',//主庫服務器ip
master_user='u_replication ',//主庫數據同步用戶
master_password='replication123456',
master_port=3306,
master_log_file='master-bin.000002',//File
master_log_pos=5891;//Position

 執行成功後,執行以下命令啓動slave同步並可查看狀態

# 啓動slave同步
start slave;
# 查看同步狀態
show slave status \G;

在這裏插入圖片描述
 圖中兩項若都爲Yes,則表明主從配置啓動成功。在以上過程中可能由於環境不同造成一些問題,最基本的是要保證mysql主從服務的數據庫版本保持一致以及server_id唯一,另外比較常見的錯誤是由於複製的UUID重複造成的。
Fatal error:The slave I/O thread stops because master and slave have equal MySQL server UUIDS; these UUIDs must be different for replication to work.
 出現以上問題可以嘗試通過以下幾種方案解決:

  1. 查看server_id是否相同
    show variables like 'server_id';
  2. 查看auto.cnf文件
    show variables like 'server_uuid';
    cat /var/lib/mysql/auto.cnf
    以下命令在從服務器執行(可通過find / -name "auto.cnf"查找文件)
    mv /var/lib/mysql/auto.cnf /var/lib/mysql/auto.cnf.bk
    systemctl restart mysql

 以上完成之後即可驗證主從同步是否成功。

3.應用層實現讀寫分離

 應用層實現讀寫分離的方式有很多種,這裏主要介紹一下我所瞭解的三種:多數據源配置、自定義Mybatis插件、AOP動態切換。這裏先來介紹下這三種方案的數據源變更策略。

  1. 多數據源配置:將讀庫寫庫作爲多種數據源看待,然後分別映射不同文件操作數據庫。
  2. 自定義Mybatis插件:根據事務策略+SQL命令類型變更數據源。
  3. AOP動態切換:根據事務策略+操作方法名前綴變更數據源。
3.1 多數據源配置

 這種方式從編碼層面難度比較低,只需要在mybatis配置文件中將主庫和從庫作爲兩個數據源去負責映射不同目錄下的mapper文件即可。這種方式比較明顯的一個缺點就是,同一個實體類操作mapper和對應sql的xml會有兩份,分別是讀和寫操作,如果這些文件是由mybatis-generator工具生成則需要自己手動拆分開,比較繁瑣。

spring-mybatis.xml
	<bean id="dataSourceWrite" class="com.alibaba.druid.pool.DruidDataSource"
		init-method="init" destroy-method="close">
		<!-- 基本屬性driverClassName、 url、user、password -->
		<property name="driverClassName" value="${db.driver}" />
		<property name="url" value="${db.master.url}" />
		<property name="username" value="${db.master.username}" />
		<property name="password" value="${db.master.password}" />
		<!-- 配置初始化大小、最小、最大 -->
		<!-- 通常來說,只需要修改initialSize、minIdle、maxActive -->
		<property name="initialSize" value="${db.master.initialSize}" />
		<property name="minIdle" value="${db.master.minIdle}" />
		<property name="maxActive" value="${db.master.maxActive}" />
		<!-- 配置獲取連接等待超時的時間 -->
		<property name="maxWait" value="${db.master.maxWait}" />
		<!-- 默認值是 true ,當從連接池取連接時,驗證這個連接是否有效 -->
		<property name="testOnBorrow" value="true" />
		<!-- 指明連接是否被空閒連接回收器(如果有)進行檢驗.如果檢測失敗,則連接將被從池中去除.
		注意: 設置爲true後如果要生效,validationQuery參數必須設置爲非空字符串 -->
		<property name="testWhileIdle" value="true" />
		<!-- 默認值是 flase, 當從把該連接放回到連接池的時,驗證這個連接是否有效 -->
		<property name="testOnReturn" value="false" />
		<!--用來驗證從連接池取出的連接,在將連接返回給調用者之前.如果指定,則查詢必須是一個SQL SELECT並且必須返回至少一行記錄-->
		<property name="validationQuery" value="SELECT 'x'" />
		<!-- 配置一個連接在池中最小生存的時間,單位是毫秒 -->
		<property name="minEvictableIdleTimeMillis" value="30000" />
		<property name="removeAbandoned" value="true" />
		<property name="removeAbandonedTimeout" value="180" />
		<!-- 關閉abanded連接時輸出錯誤日誌 -->  
		<property name="logAbandoned" value="${db.master.logAbandoned}" />  
		<!-- 配置間隔多久才進行一次檢測,檢測需要關閉的空閒連接,單位是毫秒 -->
		<property name="timeBetweenEvictionRunsMillis" value="60000" />
		<!-- 解密密碼必須要配置的項 -->
		<!-- <property name="filters" value="config" /> <property name="connectionProperties" 
			value="config.decrypt=true" /> -->
	</bean>

	<bean id="dataSourceRead" class="com.alibaba.druid.pool.DruidDataSource"
		init-method="init" destroy-method="close">
		<!-- 基本屬性driverClassName、 url、user、password -->
		<property name="driverClassName" value="${db.driver}" />
		<property name="url" value="${db.slave.url}" />
		<property name="username" value="${db.slave.username}" />
		<property name="password" value="${db.slave.password}" />
		<!-- 配置初始化大小、最小、最大 -->
		<!-- 通常來說,只需要修改initialSize、minIdle、maxActive -->
		<property name="initialSize" value="${db.slave.initialSize}" />
		<property name="minIdle" value="${db.slave.minIdle}" />
		<property name="maxActive" value="${db.slave.maxActive}" />
		<!-- 配置獲取連接等待超時的時間 -->
		<property name="maxWait" value="${db.slave.maxWait}" />
		<!-- 默認值是 true ,當從連接池取連接時,驗證這個連接是否有效 -->
		<property name="testOnBorrow" value="true" />
		<!-- 指明連接是否被空閒連接回收器(如果有)進行檢驗.如果檢測失敗,則連接將被從池中去除.
		注意: 設置爲true後如果要生效,validationQuery參數必須設置爲非空字符串 -->
		<property name="testWhileIdle" value="true" />
		<!-- 默認值是 flase, 當從把該連接放回到連接池的時,驗證這個連接是否有效 -->
		<property name="testOnReturn" value="false" />
		<!--用來驗證從連接池取出的連接,在將連接返回給調用者之前.如果指定,則查詢必須是一個SQL SELECT並且必須返回至少一行記錄-->
		<property name="validationQuery" value="SELECT 1 " />
		<!-- 配置一個連接在池中最小生存的時間,單位是毫秒 -->
		<property name="minEvictableIdleTimeMillis" value="30000" />
		<!-- 超過時間限制是否回收 -->  
		<property name="removeAbandoned" value="true" />
		<!-- 超時時間;單位爲秒。180秒=3分鐘 -->  
		<property name="removeAbandonedTimeout" value="180" />
		<!-- 關閉abanded連接時輸出錯誤日誌 -->  
		<property name="logAbandoned" value="${db.slave.logAbandoned}" />  
		<!-- 配置間隔多久才進行一次檢測,檢測需要關閉的空閒連接,單位是毫秒 -->
		<property name="timeBetweenEvictionRunsMillis" value="60000" />
		<!-- 解密密碼必須要配置的項 -->
		<!-- <property name="filters" value="config" /> <property name="connectionProperties" 
			value="config.decrypt=true" /> -->
	</bean>
	
	<!-- spring和MyBatis完美整合,不需要mybatis的配置映射文件 -->
	<bean id="sqlSessionFactoryWrite" class="org.mybatis.spring.SqlSessionFactoryBean">
		<property name="dataSource" ref="dataSourceWrite" />
		<!-- 自動掃描mapping.xml文件 -->
		<property name="mapperLocations" value="classpath:mapper/write/*.xml"></property>
	</bean>
	<!-- spring和MyBatis完美整合,不需要mybatis的配置映射文件 -->
	<bean id="sqlSessionFactoryRead" class="org.mybatis.spring.SqlSessionFactoryBean">
		<property name="dataSource" ref="dataSourceRead" />
		<!-- 自動掃描mapping.xml文件 -->
		<property name="mapperLocations" value="classpath:mapper/read/*.xml"></property>
	</bean>

	<!-- DAO接口所在包名,Spring會自動查找其下的類 -->
	<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
		<property name="basePackage" value="com.ithzk.rws.dao.write" />
		<property name="sqlSessionFactoryBeanName" value="sqlSessionFactoryWrite"></property>
	</bean>
	<!-- DAO接口所在包名,Spring會自動查找其下的類 -->
	<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
		<property name="basePackage" value="com.ithzk.rws.dao.read" />
		<property name="sqlSessionFactoryBeanName" value="sqlSessionFactoryRead"></property>
	</bean>

	<!-- (事務管理)transaction manager, use JtaTransactionManager for global tx -->
	<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
		<property name="dataSource" ref="dataSourceRead" />
	</bean>

	<!-- 配置事務屬性 -->
	<tx:advice id="txAdvice" transaction-manager="transactionManager" >
		<tx:attributes>
			<tx:method name="save*" propagation="REQUIRED" />
			<tx:method name="update*" propagation="REQUIRED" />
			<tx:method name="remove*" propagation="REQUIRED" />
			<tx:method name="get*" read-only="true" /> 
			<tx:method name="list*" read-only="true" />
			<tx:method name="count*" read-only="true" />
		</tx:attributes>
	</tx:advice>
	<!-- 配置事務的切點,並把事務切點和事務屬性不關聯起來 -->
	<aop:config >
		<aop:pointcut expression="execution(* com.ithzk.rws..service.impl.*.*(..))"
			id="txPointCut" />
		<aop:advisor advice-ref="txAdvice" pointcut-ref="txPointCut" order="2"/>
	</aop:config>
	
3.2 自定義Mybaits插件

自定義插件的方式主要是通過Mybatis框架提供的Interceptor攔截器去動態控制操作的目標數據源,在設計這種解決方案前我們需要了解Mybatis框架提供的幾個類以及其中的屬性:AbstractRoutingDataSourceLazyConnectionDataSourceProxy。看過相關源碼的同學應該很容易就發現其實不管是多數據源的配置還是動態切換本質都和targetDataSources這個集合有關,其實本身Mybatis就會根據情況去使用不用的目標數據源,從而達到想要的效果。

 這裏由於涉及數據庫配置,順便帶過一下加解密配置相關工具類。

DesUtils.java
package com.ithzk.rws.utils;

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.DESKeySpec;
import java.security.SecureRandom;

/**
 * @author hzk
 * @date 2019/3/26
 */
public class DesUtils {

    private static final String DES = "DES";
    private static final String KEY = "4YztMHI7PsT4rLZN";
//    private static final String KEY = "rws";

    private DesUtils() {}

    private static byte[] encrypt(byte[] src, byte[] key) throws Exception {
        SecureRandom sr = new SecureRandom();
        DESKeySpec dks = new DESKeySpec(key);
        SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(DES);
        SecretKey secretKey = keyFactory.generateSecret(dks);
        Cipher cipher = Cipher.getInstance(DES);
        cipher.init(Cipher.ENCRYPT_MODE, secretKey, sr);
        return cipher.doFinal(src);
    }

    private static byte[] decrypt(byte[] src, byte[] key) throws Exception {
        SecureRandom sr = new SecureRandom();
        DESKeySpec dks = new DESKeySpec(key);
        SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(DES);
        SecretKey secretKey = keyFactory.generateSecret(dks);
        Cipher cipher = Cipher.getInstance(DES);
        cipher.init(Cipher.DECRYPT_MODE, secretKey, sr);
        return cipher.doFinal(src);
    }

    private static String byte2hex(byte[] b) {
        String hs = "";
        String temp = "";
        for (int n = 0; n < b.length; n++) {
            temp = (java.lang.Integer.toHexString(b[n] & 0XFF));
            if (temp.length() == 1){
                hs = hs + "0" + temp;
            }
            else{
                hs = hs + temp;
            }
        }
        return hs.toUpperCase();

    }

    private static byte[] hex2byte(byte[] b) {
        if ((b.length % 2) != 0)
            throw new IllegalArgumentException("length not even");
        byte[] b2 = new byte[b.length / 2];
        for (int n = 0; n < b.length; n += 2) {
            String item = new String(b, n, 2);
            b2[n / 2] = (byte) Integer.parseInt(item, 16);
        }
        return b2;
    }

    private static String decode(String src, String key) {
        String decryptStr = "";
        try {
            byte[] decrypt = decrypt(hex2byte(src.getBytes()), key.getBytes());
            decryptStr = new String(decrypt);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return decryptStr;
    }

    private static String encode(String src, String key){
        byte[] bytes = null;
        String encryptStr = "";
        try {
            bytes = encrypt(src.getBytes(), key.getBytes());
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        if (bytes != null)
            encryptStr = byte2hex(bytes);
        return encryptStr;
    }

    /**
     * 解密
     */
    public static String decode(String src) {
        return decode(src, KEY);
    }

    /**
     * 加密
     */
    public static String encode(String src) {
        return encode(src, KEY);
    }

    public static void main(String[] args){
        System.out.println(encode("root"));
        System.out.println(encode("123"));
    }
}

EncryptPropertyPlaceholderConfigurer.java
package com.ithzk.rws.utils;

import org.springframework.beans.factory.config.PropertyPlaceholderConfigurer;

/**
 * @author hzk
 * @date 2019/3/26
 */
public class EncryptPropertyPlaceholderConfigurer extends PropertyPlaceholderConfigurer{

    private String[] encryptPropNames = {"db.master.username","db.slave.username","db.master.password","db.slave.password"};

    @Override
    protected String convertProperty(String propertyName, String propertyValue) {
        if(isEncryptProp(propertyName)){
            return DesUtils.decode(propertyValue);
        }else{
            return propertyValue;
        }
    }

    private boolean isEncryptProp(String propertyName){
        for (String encryptPropName:encryptPropNames) {
            if(encryptPropName.equals(propertyName)){
                return true;
            }
        }
        return false;
    }
}

 我們需要實現動態切換數據源,需要通過AbstractRoutingDataSource類來改變當前操作路由鍵,由於我們需要考慮到線程安全所以在操作路由鍵的時候需要通過ThreadLocal保證線程安全。

DynamicDataSourceHolder.java
package com.ithzk.rws.utils.dynamic;

/**
 * @author hzk
 * @date 2019/3/26
 */
public class DynamicDataSourceHolder {

    public static final ThreadLocal<String> contextHolder = new ThreadLocal<>();
    public static final String DB_MASTER = "master";
    public static final String DB_SLAVE = "slave";

    /**
     * 獲取路由Key
     * @return
     */
    public static String getRouteKey(){
        String routeKey = contextHolder.get();
        if(null == routeKey){
            routeKey = DB_MASTER;
        }
        return routeKey;
    }

    /**
     * 設置路由Key
     */
    public static void setRouteKey(String routeKey){
        contextHolder.set(routeKey);
        System.out.println("切換到數據源:"+routeKey);
    }
}

DynamicDataSource.java
package com.ithzk.rws.utils.dynamic;

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

/**
 * 通過AbstractRoutingDataSource實現動態切換數據源,需重寫determineCurrentLookupKey方法
 * 由於DynamicDataSource是單例的,線程不安全的,所以採用ThreadLocal保證線程安全,由DynamicDataSourceHolder完成。
 * @author hzk
 * @date 2019/3/26
 */
public class DynamicDataSource extends AbstractRoutingDataSource {

    /**
     * 在spring容器中查詢對應key來應用爲數據源
     * @return
     */
    @Override
    protected Object determineCurrentLookupKey() {
        return DynamicDataSourceHolder.getRouteKey();
    }
}

 上面這些類已經可以讓我們自定義去設置所需操作的目標數據源,這裏我們只需在最後一步提供一個目標數據源設置插件即可達到我們要實現的讀寫分離的目的。這裏我們需要依賴Mybatis提供的Interceptor攔截器去對SQL操作進行攔截處理。根據相對應的SQL操作類型改變目標數據源。

DynamicDataSourcePlugin.java
package com.ithzk.rws.utils.dynamic;


import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.executor.keygen.SelectKeyGenerator;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.util.Properties;

/**
 * 讀寫分離路由插件
 * @author hzk
 * @date 2019/3/26
 */
@Intercepts(
        //update 增刪改 query 查詢
        {@Signature(type = Executor.class,method = "update",args = {MappedStatement.class,Object.class}),
        @Signature(type = Executor.class,method = "query",args = {MappedStatement.class,Object.class, RowBounds.class, ResultHandler.class})
        }
)
public class DynamicDataSourcePlugin implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        //判斷操作是否存在事務
        boolean active = TransactionSynchronizationManager.isActualTransactionActive();
        //默認讓routeKey爲MASTER
        String routeKey = DynamicDataSourceHolder.DB_MASTER;
        //第一個參數爲MappedStatement對象,第二參數爲傳入的參數
        Object[] args = invocation.getArgs();
        MappedStatement mappedStatement = (MappedStatement) args[0];

        if(active){
            //帶事務操作操作主庫
            routeKey = DynamicDataSourceHolder.DB_MASTER;
        }else{
            //判斷讀方法
            if(mappedStatement.getSqlCommandType().equals(SqlCommandType.SELECT)){
                //如果使用select_last_insert_id函數
                if(mappedStatement.getId().contains(SelectKeyGenerator.SELECT_KEY_SUFFIX)){
                    routeKey = DynamicDataSourceHolder.DB_MASTER;
                }else{
                    routeKey = DynamicDataSourceHolder.DB_SLAVE;
                }
            }
        }
        //設置確定的路由Key
        DynamicDataSourceHolder.setRouteKey(routeKey);
        System.out.println("使用["+invocation.getMethod().getName()+"]方法,使用["+routeKey+"]策略,執行的SQL命令["+mappedStatement.getSqlCommandType().name()+"]");
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target,this);
    }

    @Override
    public void setProperties(Properties properties) {

    }
}

 上面幾步完成之後,其實我們離讀寫分離只差一步,那就是在配置文件中配置好這些。這裏需要注意的是,我們這裏的DynamicDataSourcePlugin只做了一些簡單的邏輯判斷,各位需要根據實際情況去完善自己的邏輯代碼。

spring-mybatis.xml
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
	   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:tx="http://www.springframework.org/schema/tx"
	   xmlns:aop="http://www.springframework.org/schema/aop"
	   xmlns:context="http://www.springframework.org/schema/context"
	   xsi:schemaLocation="http://www.springframework.org/schema/beans
                        http://www.springframework.org/schema/beans/spring-beans-3.1.xsd
                        http://www.springframework.org/schema/tx
                        http://www.springframework.org/schema/tx/spring-tx.xsd
                        http://www.springframework.org/schema/aop
                        http://www.springframework.org/schema/aop/spring-aop.xsd
						http://www.springframework.org/schema/context
                        http://www.springframework.org/schema/context/spring-context-3.1.xsd  ">

	<context:component-scan base-package="com.ithzk.rws">
		<context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller" />
	</context:component-scan>

	<!-- 配置數據庫相關參數properties屬性-->
	<bean class="com.ithzk.rws.utils.EncryptPropertyPlaceholderConfigurer">
		<property name="locations">
			<list>
				<value>classpath:jdbc.properties</value>
			</list>
		</property>
	</bean>
	<!-- 數據庫連接池-->
	<bean id="abstractDataSource" class="com.alibaba.druid.pool.DruidDataSource" abstract="true" init-method="init" destroy-method="close">
		<!-- 配置初始化大小、最小、最大 -->
		<!-- 通常來說,只需要修改initialSize、minIdle、maxActive -->
		<property name="initialSize" value="${db.master.initialSize}" />
		<property name="minIdle" value="${db.master.minIdle}" />
		<property name="maxActive" value="${db.master.maxActive}" />
		<!-- 配置獲取連接等待超時的時間 -->
		<property name="maxWait" value="${db.master.maxWait}" />
		<!-- 默認值是 true ,當從連接池取連接時,驗證這個連接是否有效 -->
		<property name="testOnBorrow" value="true" />
		<!-- 指明連接是否被空閒連接回收器(如果有)進行檢驗.如果檢測失敗,則連接將被從池中去除.
		注意: 設置爲true後如果要生效,validationQuery參數必須設置爲非空字符串 -->
		<property name="testWhileIdle" value="true" />
		<!-- 默認值是 flase, 當從把該連接放回到連接池的時,驗證這個連接是否有效 -->
		<property name="testOnReturn" value="false" />
		<!--用來驗證從連接池取出的連接,在將連接返回給調用者之前.如果指定,則查詢必須是一個SQL SELECT並且必須返回至少一行記錄-->
		<property name="validationQuery" value="SELECT 'x'" />
		<!-- 配置一個連接在池中最小生存的時間,單位是毫秒 -->
		<property name="minEvictableIdleTimeMillis" value="30000" />
		<property name="removeAbandoned" value="true" />
		<property name="removeAbandonedTimeout" value="180" />
		<!-- 關閉abanded連接時輸出錯誤日誌 -->
		<property name="logAbandoned" value="${db.master.logAbandoned}" />
		<!-- 配置間隔多久才進行一次檢測,檢測需要關閉的空閒連接,單位是毫秒 -->
		<property name="timeBetweenEvictionRunsMillis" value="60000" />
		<property name="poolPreparedStatements" value="true" />
		<property name="maxPoolPreparedStatementPerConnectionSize" value="50" />
		<property name="filters" value="stat" />
	</bean>

	<!-- 主庫 -->
	<bean id="master" parent="abstractDataSource">
		<!-- 基本屬性driverClassName、 url、user、password -->
		<property name="driverClassName" value="${db.driver}" />
		<property name="url" value="${db.master.url}" />
		<property name="username" value="${db.master.username}" />
		<property name="password" value="${db.master.password}" />
	</bean>

	<!-- 從庫 -->
	<bean id="slave" parent="abstractDataSource">
		<!-- 基本屬性driverClassName、 url、user、password -->
		<property name="driverClassName" value="${db.driver}" />
		<property name="url" value="${db.slave.url}" />
		<property name="username" value="${db.slave.username}" />
		<property name="password" value="${db.slave.password}" />
	</bean>

	<!-- 配置動態路由 -->
	<bean id="dynamicDataSourceRouting" class="com.ithzk.rws.utils.dynamic.DynamicDataSource">
		<property name="targetDataSources">
			<map>
				<entry key="master" value-ref="master"/>
				<entry key="slave" value-ref="slave"/>
			</map>
		</property>
		<!-- 設置默認的數據源 -->
		<property name="defaultTargetDataSource" ref="master"/>
	</bean>

	<!-- 配置數據源 -->
	<bean id="dataSource" class="org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy">
		<property name="targetDataSource" ref="dynamicDataSourceRouting"/>
	</bean>

	<bean id="sqlSessionFactoryWrite" class="org.mybatis.spring.SqlSessionFactoryBean">
		<property name="dataSource" ref="dataSource" />
		<!-- 配置mybatis全局配置文件 -->
		<property name="configLocation" value="classpath:mybatis-config.xml" />
		<!-- 自動掃描mapping.xml文件 -->
		<property name="mapperLocations" value="classpath:mapper/*.xml"/>
	</bean>

	<!-- 動態掃描Dao -->
	<bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
		<property name="basePackage" value="com.ithzk.rws.dao"/>
	</bean>

	<!-- (事務管理)transaction manager, use JtaTransactionManager for global tx -->
	<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
		<property name="dataSource" ref="dataSource" />
	</bean>
	<!-- 配置事務屬性 -->
	<tx:advice id="txAdvice" transaction-manager="transactionManager" >
		<tx:attributes>
			<!--定義查詢方法都是隻讀的 -->
			<tx:method name="get*" read-only="true" />
			<tx:method name="list*" read-only="true" />
			<tx:method name="count*" read-only="true" />
			<tx:method name="query*" read-only="true" />
			<tx:method name="find*" read-only="true" />
			<tx:method name="select*" read-only="true" />

			<!-- 主庫執行操作,事務傳播行爲定義爲默認行爲 -->
			<tx:method name="save*" propagation="REQUIRED" />
			<tx:method name="update*" propagation="REQUIRED" />
			<tx:method name="remove*" propagation="REQUIRED" />
			<tx:method name="delete*" propagation="REQUIRED" />
			<tx:method name="insert*" propagation="REQUIRED" />

			<!--其他方法使用默認事務策略 -->
			<tx:method name="*" />
		</tx:attributes>
	</tx:advice>
	<!-- 配置事務的切點,並把事務切點和事務屬性不關聯起來 -->
	<aop:config >
		<aop:pointcut id="txPointCut" expression="execution(* com.ithzk.rws.service.impl.*.*(..))" />
		<!-- 應用事務策略到Service切面 -->
		<aop:advisor advice-ref="txAdvice" pointcut-ref="txPointCut" order="2"/>
	</aop:config>

</beans>


mybatis-config.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTO Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>

    <!-- 配置全局屬性 -->
    <settings>
        <!-- 使用jdbc的generatedKeys獲取數據庫自增主鍵 -->
        <setting name="useGeneratedKeys" value="true"/>
        <!-- 使用列別名替換列名 默認true -->
        <setting name="useColumnLabel" value="true"/>
        <!-- 開啓駝峯命名轉換 Table{create_time} -> Entity{createTime} -->
        <setting name="mapUnderscoreToCamelCase" value="true"/>
        <!-- 打印sql語句 -->
        <setting name="logImpl" value="STDOUT_LOGGING"/>
    </settings>

    <!-- 配置路由插件 -->
    <plugins>
        <plugin interceptor="com.ithzk.rws.utils.dynamic.DynamicDataSourcePlugin"/>
    </plugins>
</configuration>
3.3 AOP動態切換

 上面第二種方法我們藉助了Mybaits框架提供的插件去變更目標數據源,其實我們通過SpringAOP我們也可以達到這種效果,這裏我們利用AOP根據事務策略的配置去改變目標數據源,大家看看其中的區別。這裏改動不多,唯一的區別就是將插件變更爲了AOP操作。

DataSourceAspect.java(替代DynamicDataSourcePlugin)
package com.ithzk.rws.utils.aop;

import com.ithzk.rws.utils.dynamic.DynamicDataSourceHolder;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.springframework.transaction.interceptor.NameMatchTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionAttribute;
import org.springframework.transaction.interceptor.TransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.util.PatternMatchUtils;
import org.springframework.util.ReflectionUtils;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * 如果事務管理中配置了事務策略,則採用配置的事務策略中的標記了ReadOnly的方法是用Slave,其它使用Master。
 * 如果沒有配置事務管理的策略,則採用方法名匹配的原則,以query、find、get開頭方法用Slave,其它用Master。
 * @author hzk
 * @date 2019/3/27
 */
public class DataSourceAspect {

    private List<String> slaveMethodPattern = new ArrayList<String>();

    private static final String[] defaultSlaveMethodStart = new String[]{ "query", "find", "get","select","list","count","select" };

    private String[] slaveMethodStart;

    /**
     * 讀取事務管理中的策略
     *
     * @param txAdvice
     * @throws Exception
     */
    @SuppressWarnings("unchecked")
    public void setTxAdvice(TransactionInterceptor txAdvice) throws Exception {
        if (txAdvice == null) {
            //未配置事務管理策略
            return;
        }
        //獲取到策略配置信息
        TransactionAttributeSource transactionAttributeSource = txAdvice.getTransactionAttributeSource();
        if (!(transactionAttributeSource instanceof NameMatchTransactionAttributeSource)) {
            return;
        }
        //使用反射技術獲取到NameMatchTransactionAttributeSource對象中的nameMap屬性值
        NameMatchTransactionAttributeSource matchTransactionAttributeSource = (NameMatchTransactionAttributeSource) transactionAttributeSource;
        Field nameMapField = ReflectionUtils.findField(NameMatchTransactionAttributeSource.class, "nameMap");
        //設置該字段可訪問(穿透)
        nameMapField.setAccessible(true);
        Map<String, TransactionAttribute> map = (Map<String, TransactionAttribute>) nameMapField.get(matchTransactionAttributeSource);

        for (Map.Entry<String, TransactionAttribute> entry : map.entrySet()) {
            //ReadOnly只讀策略加入到slaveMethodPattern
            if (!entry.getValue().isReadOnly()) {
                continue;
            }
            slaveMethodPattern.add(entry.getKey());
        }
    }

    /**
     * 在進入Service方法之前執行
     * @param point 切面對象
     */
    public void before(JoinPoint point) {
        // 獲取到當前執行的方法名
        String methodName = point.getSignature().getName();

        boolean isSlave = false;

        if (slaveMethodPattern.isEmpty()) {
            //當前Spring容器中沒有配置事務策略,採用方法名匹配方式
            isSlave = isSlave(methodName);
        } else {
            // 使用策略規則匹配
            for (String mappedName : slaveMethodPattern) {
                if (isMatch(methodName, mappedName)) {
                    isSlave = true;
                    break;
                }
            }
        }

        if (isSlave) {
            // 標記爲讀庫
            DynamicDataSourceHolder.setRouteKey(DynamicDataSourceHolder.DB_MASTER);
        } else {
            // 標記爲寫庫
            DynamicDataSourceHolder.setRouteKey(DynamicDataSourceHolder.DB_SLAVE);
        }
    }

    /**
     * 判斷是否爲讀庫
     *
     * @param methodName
     * @return
     */
    private Boolean isSlave(String methodName) {
        // 方法名以query、find、get開頭的方法名走從庫
        return StringUtils.startsWithAny(methodName, getSlaveMethodStart());
    }

    /**
     * 通配符匹配
     *
     * Return if the given method name matches the mapped name.
     * <p>
     * The default implementation checks for "xxx*", "*xxx" and "*xxx*" matches, as well as direct
     * equality. Can be overridden in subclasses.
     *
     * @param methodName the method name of the class
     * @param mappedName the name in the descriptor
     * @return if the names match
     * @see org.springframework.util.PatternMatchUtils#simpleMatch(String, String)
     */
    protected boolean isMatch(String methodName, String mappedName) {
        return PatternMatchUtils.simpleMatch(mappedName, methodName);
    }

    /**
     * 用戶指定slave的方法名前綴
     * @param slaveMethodStart
     */
    public void setSlaveMethodStart(String[] slaveMethodStart) {
        this.slaveMethodStart = slaveMethodStart;
    }

    public String[] getSlaveMethodStart() {
        if(this.slaveMethodStart == null){
            // 沒有指定,使用默認
            return defaultSlaveMethodStart;
        }
        return slaveMethodStart;
    }
}

 這裏配置文件需要修改的只有aop配置這方面,將我們的DataSourceAspect配置在切面管理器中,並且可以將mybatis-config.xml中配置路由插件去除。

spring-mybatis.xml
<!-- 配置事務的切點,並把事務切點和事務屬性不關聯起來 -->
	<aop:config >
		<aop:pointcut id="txPointCut" expression="execution(* com.ithzk.rws.service.impl.*.*(..))" />
		<!-- 應用事務策略到Service切面 -->
		<aop:advisor advice-ref="txAdvice" pointcut-ref="txPointCut" order="2"/>
		<!-- 將切面應用到自定義的切面處理器上,-9999保證該切面優先級最高執行 -->
		<aop:aspect ref="dataSourceAspect" order="-9999">
			<aop:before method="before" pointcut-ref="txPointCut" />
		</aop:aspect>
	</aop:config>

	<!-- 定義AOP切面處理器 -->
	<bean id="dataSourceAspect" class="com.ithzk.rws.utils.aop.DataSourceAspect">
		<!-- 指定事務策略 -->
		<property name="txAdvice" ref="txAdvice"/>
		<property name="slaveMethodStart" value="query,find,get,select,list,count,select"/>
	</bean>
3.4 一主多從

 當我們數據訪問量不斷增大時,我們可能會選擇使用更多的從庫去減緩數據庫查詢的壓力,避免單臺服務宕機造成的不必要損失。這時我們就會採取一主多從的策略,那麼我們應用層也需要作出對應的改變,以3.23.3爲例,我們只需要修改我們自定義的DynamicDataSource類中路由Key的選取方式即可。

DynamicDataSource.java
package com.ithzk.rws.utils.dynamic;

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

import javax.sql.DataSource;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 通過AbstractRoutingDataSource實現動態切換數據源,需重寫determineCurrentLookupKey方法
 * 由於DynamicDataSource是單例的,線程不安全的,所以採用ThreadLocal保證線程安全,由DynamicDataSourceHolder完成。
 * @author hzk
 * @date 2019/3/26
 */
public class DynamicDataSource extends AbstractRoutingDataSource {

    private static final int TURN_MAX_COUNT = 888;

    private Integer slaveCount;

    /**
     * 輪詢計數,初始爲-1,AtomicInteger是線程安全的
     */
    private AtomicInteger counter = new AtomicInteger(-1);

    /**
     * 讀庫路由鍵倉庫
     */
    private List<Object> slaveDataSources = new ArrayList<Object>(0);

    @Override
    protected Object determineCurrentLookupKey() {
        if (DynamicDataSourceHolder.DB_MASTER.equals(DynamicDataSourceHolder.getRouteKey())) {
            Object key = DynamicDataSourceHolder.getRouteKey();
            System.out.println("當前數據源爲: " + key);
            return key;
        }
        Object key = getSlaveKey();
        System.out.println("當前數據源爲: " + key);
        return key;

    }

    /**
     * 初始化讀庫路由鍵倉庫
     */
    @SuppressWarnings("unchecked")
    @Override
    public void afterPropertiesSet() {
        super.afterPropertiesSet();

        // 反射獲取父類AbstractRoutingDataSource中私有屬性resolvedDataSources
        Field field = ReflectionUtils.findField(AbstractRoutingDataSource.class, "resolvedDataSources");
        field.setAccessible(true);

        try {
            Map<Object, DataSource> resolvedDataSources = (Map<Object, DataSource>) field.get(this);
            //數據源總數 = 讀庫數量 + 寫庫數量(這裏一主多從 寫庫數量即爲1)
            this.slaveCount = resolvedDataSources.size() - 1;
            for (Map.Entry<Object, DataSource> entry : resolvedDataSources.entrySet()) {
                if (DynamicDataSourceHolder.DB_MASTER.equals(entry.getKey())) {
                    continue;
                }
                slaveDataSources.add(entry.getKey());
            }
        } catch (Exception e) {
            System.out.println("DynamicDataSource -> afterPropertiesSet Exception:"+e);
        }
    }

    /**
     * 輪詢算法實現
     * @return 從庫路由鍵
     */
    private Object getSlaveKey() {
        // 獲取偏移量
        Integer index = counter.incrementAndGet() % slaveCount;
        // 固定偏移量範圍避免數值越界
        if (counter.get() > TURN_MAX_COUNT) {
            // 重置偏移量
            counter.set(-1);
        }
        return slaveDataSources.get(index);
    }

}

3.5 總結

 以上三種方式都有自己的優缺點,相互之間存在差異化,具體如何選擇大家根據自己的需求決定。三種方法看似不同,其實本質都是一樣,對源碼認真剖析定能知其一二。我自己在這條道路上也還有很多需要努力學習的,我的所有文章目的都是出於學習分享以及記錄,如果有不正確的地方還希望大家見諒並指出。
【該章節github地址】

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章