AOP實現MySql數據庫的讀寫分離—支持一主多從

前言

我們知道,數據庫的寫和讀大致上是遵循二八定律的。尤其是針對互聯網業務,讀的操作要比寫操作的概率高更多。爲了消除讀寫鎖衝突,緩解數據庫壓力,提高讀寫性能,我們提出了讀寫分離的數據庫架構:將數據庫分爲了主(master)從(slave)庫,一個主庫用於寫數據,多個從庫完成讀數據的操作,主從庫之間通過某種機制進行數據的同步,是一種常見的數據庫架構。

主從複製

要想實現讀寫分離,首先得實現主從數據庫之間的數據複製功能,也就是主從複製。

原理

這裏的數據庫使用的MySql,主從複製是通過監聽MySql的二進制日誌實現的,其原理如下圖所示:

在這裏插入圖片描述

  • master將數據改變記錄到二進制日誌(binary log)中,也即是配置文件log-bin指定的文件(這些記錄叫做二進制日誌事件,binary log events)

  • slave將master的binary log events拷貝到它的中繼日誌(relay log)

  • slave重做中繼日誌中的事件,將改變反映它自己的數據(數據重演)

通過以上方式,數據同步雖具有一定延遲性,但對於一般的應用尚可接受。高併發的同步策略不在此討論範圍內。

實現

注意事項
  • 主DB server和從DB server數據庫的版本一致

  • 主DB server和從DB server數據庫數據一致[ 這裏就會可以把主的備份在從上還原,也可以直接將主的數據目錄拷貝到從的相應數據目錄]

  • 主DB server開啓二進制日誌,主DB server和從DB server的server_id都必須唯一

今天時間比較緊張,暫時先將功能都列出來,已經經過驗證了。

主庫配置

修改主庫的my.ini(如果是linux系統,則是my.cnf)文件,我的主庫mysql在windows系統上,my.ini位置爲:C:\ProgramData\MySQL\MySQL Server 5.7

開啓主從複製,主庫的配置

log-bin = mysql3306-bin

指定主庫serverid

server-id=101

指定同步的數據庫,如果不指定則同步全部數據庫

binlog-do-db=mybatis_1128

保存上述配置,重啓mysql服務,然後執行SQL語句查詢狀態:

SHOW MASTER STATUS

在這裏插入圖片描述

需要記錄下Position值,需要在從庫中設置同步起始值,File的值也需要在從庫的配置中使用。

授權用戶slave01使用123456密碼登錄mysql:

grant replication slave on *.* to 'slave01'@'192.168.20.138' identified by '123456';
flush privileges;
從庫配置

在my.ini(或my.cnf)文件中修改:

指定serverid,只要不重複即可,從庫也只有這一個配置,其他都在SQL語句中操作:

server-id=102

執行以下SQL語句:

CHANGE MASTER TO
 master_host='192.168.20.1',
 master_user='slave01',
 master_password='123456',
 master_port=3306,
 master_log_file='mysql3306-bin.000006',
 master_log_pos=1120;

啓動Slave同步:

START SLAVE;

查看同步狀態:

SHOW SLAVE STATUS;

在這裏插入圖片描述

如果不是yes,需要檢查一下上面的change master to的執行語句是否與主庫匹配上了。上述配置執行完畢之後,就可以在主庫中修改一下數據,檢查從庫是否同步了。

讀寫分離

讀寫分離的實現主要有兩種實現方式,一種是通過中間件的形式如Mycat,另外一種就是在應用層解決。

原理

之前公司使用的讀寫分離,是在底層封裝了兩個sqlSessionFactory,我們在Service實現類中進行增刪改的時候,需要顯示的去調用讀、寫這兩個sqlSessionFactory。這種操作對我們來說,具有一定的侵入性,也不利於後期的改造,比如想要實現多個從庫的操作就不支持。使用模式如下圖所示:

公司使用的讀寫分離

上面的模式耦合了代碼和數據庫,不利於後期的擴展。爲了解藕底層數據庫與Service實現類之間的侵入性,同時能夠支持多個從庫,這裏的改造選擇使用AOP的解決方案,使用模式如下圖所示:

在這裏插入圖片描述

使用以上兩種解決方案,優點是多數據源切換方便,由程序自動完成,理論上支持任何數據庫。缺點則是運維人員參與不到,只能通過程序員完成;不能動態增加數據源(使用AOP方案目前實現了更改配置文件即可增加從庫)。如果數據庫掛掉了,需要修改應用的配置並重新上線。

另外一種解決方案則是使用中間件如MyCat等,使用模式如下圖所示:

在這裏插入圖片描述

MyCat作爲中間件,它只是一個代理,本身並不進行數據存儲,需要連接後端的MySQL物理服務器,支持分庫分表,DBA可以參與進來進行性能優化;可以動態添加數據源,不需要重啓應用,對程序透明。缺點是應用依賴於中間件,切換數據庫比較困難(不過一般情況下切換數據庫種類發生的概率還是比較小的);由於中間件做了中轉代理,性能會有一定的下降。

認識了以上幾個方案之後,接下來本篇文章將通過AOP實現多數據源的支持,後面如果有機會,將嘗試使用中間件。下面的實現都具有超詳細的說明,廢話不多說,首先來看數據庫變量的配置文件。

實現

數據庫變量配置

將數據庫連接池中的變量都存放在db.properties文件中,這裏使用了1主2從,具體配置如下:

#讀寫分離,一主多從數據庫-----start
jdbc.master.driver=com.mysql.jdbc.Driver

jdbc.master.url=jdbc:mysql://127.0.0.1:3306/lc_mall?useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true
jdbc.master.username=root
jdbc.master.password=123456

jdbc.slave01.url=jdbc:mysql://192.168.20.138:3306/lc_mall?useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true
jdbc.slave01.username=root
jdbc.slave01.password=123456

jdbc.slave02.url=jdbc:mysql://192.168.20.139:3306/lc_mall?useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true
jdbc.slave02.username=root
jdbc.slave02.password=123456
#讀寫分離,兩個數據庫-------end

#數據庫連接池配置---start
db.initialSize=20
db.maxActive=50
db.maxIdle=20
db.minIdle=10
db.maxWait=10
db.defaultAutoCommit=true
db.minEvictableIdleTimeMillis=3600000
#數據庫連接池配置---end
數據庫連接池配置

在以下配置當中,通過定義一個parentDataSource的bean,抽出公共屬性,然後使用連接池bean的parent屬性,繼承parentDataSource。

<!--加載數據庫配置文件-->
    <bean id="propertyConfigurer"
        class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
        <property name="order" value="2"/>
        <property name="ignoreUnresolvablePlaceholders" value="true"/>
        <property name="locations">
            <list>
                <value>classpath:db.properties</value>
            </list>
        </property>
        <property name="fileEncoding" value="utf-8"/>
    </bean>

    <!--抽取數據庫連接池的公共屬性-->
    <bean id="parentDataSource" class="org.apache.commons.dbcp.BasicDataSource" abstract="true">
        <property name="driverClassName" value="${jdbc.master.driver}"/>
        <!-- 連接池啓動時的初始值 -->
        <property name="initialSize" value="${db.initialSize}"/>
        <!-- 連接池的最大值 -->
        <property name="maxActive" value="${db.maxActive}"/>
        <!-- 最大空閒值.當經過一個高峯時間後,連接池可以慢慢將已經用不到的連接慢慢釋放一部分,一直減少到minIdle爲止 -->
        <property name="maxIdle" value="${db.maxIdle}"/>
        <!-- 最小空閒值.當空閒的連接數少於閥值時,連接池就會預申請去一些連接,以免洪峯來時來不及申請 -->
        <property name="minIdle" value="${db.minIdle}"/>
        <!-- 最大建立連接等待時間。如果超過此時間將接到異常。設爲-1表示無限制 -->
        <property name="maxWait" value="${db.maxWait}"/>
        <!--#給出一條簡單的sql語句進行驗證 -->
        <!--<property name="validationQuery" value="select getdate()" />-->
        <property name="defaultAutoCommit" value="${db.defaultAutoCommit}"/>
        <!-- 回收被遺棄的(一般是忘了釋放的)數據庫連接到連接池中 -->
        <!--<property name="removeAbandoned" value="true" />-->
        <!-- 數據庫連接過多長時間不用將被視爲被遺棄而收回連接池中 -->
        <!--<property name="removeAbandonedTimeout" value="120" />-->
        <!-- #連接的超時時間,默認爲半小時。 -->
        <property name="minEvictableIdleTimeMillis" value="${db.minEvictableIdleTimeMillis}"/>
        <!--# 失效檢查線程運行時間間隔,要小於MySQL默認-->
        <property name="timeBetweenEvictionRunsMillis" value="40000"/>
        <!--# 檢查連接是否有效-->
        <property name="testWhileIdle" value="true"/>
        <!--# 檢查連接有效性的SQL語句-->
        <property name="validationQuery" value="SELECT 1 FROM dual"/>
    </bean>
    <!--讀寫分離之主庫-->
    <bean id="masterDataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close" parent="parentDataSource">
        <property name="url" value="${jdbc.master.url}"/>
        <property name="username" value="${jdbc.master.username}"/>
        <property name="password" value="${jdbc.master.password}"/>

    </bean>
    <!--讀寫分離之從庫1-->
    <bean id="slave01DataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close" parent="parentDataSource">
        <property name="url" value="${jdbc.slave01.url}"/>
        <property name="username" value="${jdbc.slave01.username}"/>
        <property name="password" value="${jdbc.slave01.password}"/>
    </bean>

    <!--讀寫分離之從庫2-->
    <bean id="slave02DataSource" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close" parent="parentDataSource">
        <property name="url" value="${jdbc.slave02.url}"/>
        <property name="username" value="${jdbc.slave02.username}"/>
        <property name="password" value="${jdbc.slave02.password}"/>
    </bean>
AOP相關配置

包括與Mybatis的整合,對讀、寫事務策略配置,對AOP切面的配置等:

<!-- 定義數據源,使用自己實現的數據源 -->
    <bean id="dataSource" class="com.lcmall.dynamicdbsource.DynamicDataSource">
        <!-- 設置多個數據源 -->
        <property name="targetDataSources">
            <map key-type="java.lang.String">
                <!-- 這個key需要和程序中的key一致 -->
                <entry key="master" value-ref="masterDataSource"/>
                <entry key="slave01" value-ref="slave01DataSource"/>
                <entry key="slave02" value-ref="slave02DataSource"/>
            </map>
        </property>
        <!-- 設置默認的數據源,這裏默認走寫庫 -->
        <property name="defaultTargetDataSource" ref="masterDataSource"/>
    </bean>

    <!-- spring和MyBatis完美整合 -->
    <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <property name="dataSource" ref="dataSource"/>
        <property name="mapperLocations" value="classpath:mappers/*Mapper.xml"></property>
        <!--引入mybatis配置文件,不能使用import resource的方式-->
        <property name="configLocation" value="classpath:mybatis-config.xml"></property>
        <!-- 分頁插件 -->
        <property name="plugins">
            <array>
                <bean class="com.github.pagehelper.PageHelper">
                    <property name="properties">
                        <value>
                            dialect=mysql
                        </value>
                    </property>
                </bean>
            </array>
        </property>

    </bean>
    <!-- DAO接口所在包名,Spring會自動查找其下的類 ,自動掃描了所有的XxxxMapper.xml對應的mapper接口文件,只要Mapper接口類和Mapper映射文件對應起來就可以了-->
    <bean name="mapperScannerConfigurer" class="org.mybatis.spring.mapper.MapperScannerConfigurer">
        <property name="basePackage" value="com.lcmall.dao"/>
    </bean>


    <!-- 使用@Transactional進行聲明式事務管理需要聲明下面這行 -->
    <tx:annotation-driven transaction-manager="transactionManager" proxy-target-class="true" />
    <!-- 事務管理 -->
    <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource" ref="dataSource"/>
        <property name="rollbackOnCommitFailure" value="true"/>
    </bean>

    <!-- 定義事務策略 -->
    <tx:advice id="txAdvice" transaction-manager="transactionManager">
        <tx:attributes>
            <!--定義查詢方法都是隻讀的 -->
            <tx:method name="query*" read-only="true" />
            <tx:method name="find*" read-only="true" />
            <tx:method name="get*" 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="delete*" propagation="REQUIRED" />
            <tx:method name="del*" propagation="REQUIRED" />

            <!--其他方法使用默認事務策略 -->
            <tx:method name="*" />
        </tx:attributes>
    </tx:advice>

    <!-- 定義AOP切面處理器 -->
    <bean class="com.lcmall.dynamicdbsource.DataSourceAspect" id="dataSourceAspect">
        <!-- 指定事務策略 -->
        <property name="txAdvice" ref="txAdvice"/>
        <!-- 指定slave方法的前綴(非必須) -->
        <property name="slaveMethodStart" value="query,find,get,select"/>
    </bean>
    <aop:config>
        <!-- 定義切面,所有的service的所有方法 -->
        <aop:pointcut id="txPointcut" expression="execution(* com.lcmall.service..*.*(..))" />
        <!-- 應用事務策略到Service切面 -->
        <aop:advisor advice-ref="txAdvice" pointcut-ref="txPointcut"/>
        <!-- 將切面應用到自定義的切面處理器上,-9999保證該切面優先級最高執行 -->
        <aop:aspect ref="dataSourceAspect" order="-9999">
            <aop:before method="before" pointcut-ref="txPointcut" />
        </aop:aspect>
    </aop:config>
動態決定多數據源的關鍵點

使用AOP實現讀寫分離的一個關鍵點是使用Spring提供的AbstractRoutingDataSource類,通過AOP決定需要哪個庫的key,最終進行讀或者寫操作。封裝類如下:

/**
 * 動態切換數據源,基於AOP實現
 * 由於DynamicDataSource是單例的,線程不安全的,所以採用ThreadLocal保證線程安全,由DynamicDataSourceHolder完成。
 * @author wlc
 */
public class DynamicDataSource extends AbstractRoutingDataSource {

    private static final Logger LOGGER = LoggerFactory.getLogger(DynamicDataSource.class);

    private Integer slaveCount;

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

    /**記錄讀庫的key,默認爲4個*/
    private List<Object> slaveDataSources = new ArrayList<>(4);

    /**
     * 重寫AbstractRoutingDataSource的方法,根據拿到的key從配置文件中查找對應的數據庫連接池
     * @return
     */
    @Override
    protected Object determineCurrentLookupKey() {
        // 使用DynamicDataSourceHolder保證線程安全,並且得到當前線程中的數據源key
        if (DynamicDataSourceHolder.isMaster()) {
            Object key = DynamicDataSourceHolder.getDataSourceKey();
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("當前DataSource的key爲: " + key);
            }
            return key;
        }
        Object key = getSlaveKey();
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug("當前DataSource的key爲: " + key);
        }
        return key;

    }

    /**
     * 獲取讀庫的key
     */
    @SuppressWarnings("unchecked")
    @Override
    public void afterPropertiesSet() {
        super.afterPropertiesSet();

        // 由於父類的resolvedDataSources屬性是私有的子類獲取不到,需要使用反射獲取
        Field field = ReflectionUtils.findField(AbstractRoutingDataSource.class, "resolvedDataSources");
        // 設置可訪問
        field.setAccessible(true);

        try {
            Map<Object, DataSource> resolvedDataSources = (Map<Object, DataSource>) field.get(this);
            // 讀庫的數據量等於數據源總數減去寫庫的數量
            this.slaveCount = resolvedDataSources.size() - 1;
            for (Map.Entry<Object, DataSource> entry : resolvedDataSources.entrySet()) {
                if (DynamicDataSourceHolder.MASTER.equals(entry.getKey())) {
                    continue;
                }
                slaveDataSources.add(entry.getKey());
            }
        } catch (Exception e) {
            LOGGER.error("afterPropertiesSet error! ", e);
        }
    }

    /**
     * 輪詢算法實現從庫的使用
     *
     * @return
     */
    public Object getSlaveKey() {
        // 得到的下標爲:0、1、2、3……
        Integer index = counter.incrementAndGet() % slaveCount;
        // 以免超出Integer範圍
        if (counter.get() > 9999) {
            // 還原
            counter.set(-1);
        }
        return slaveDataSources.get(index);
    }
}

封裝類中使用的工具類如下:

/**
 * 使用ThreadLocal記錄當前線程中數據源的key
 * @author wlc
 */
public class DynamicDataSourceHolder {
    /**寫庫對應的數據源key*/
    public static final String MASTER = "master";

    /**讀庫對應的數據源key*/
    private static final String SLAVE = "slave";

    /**使用ThreadLocal記錄當前線程的數據源key*/
    private static final ThreadLocal<String> holder = new ThreadLocal<>();

    /**
     * 設置數據源key
     * @param key
     */
    public static void putDataSourceKey(String key) {
        holder.set(key);
    }

    /**
     * 獲取數據源key
     * @return
     */
    public static String getDataSourceKey() {
        return holder.get();
    }

    /**
     * 標記寫庫
     */
    public static void markMaster(){
        putDataSourceKey(MASTER);
    }

    /**
     * 標記讀庫
     */
    public static void markSlave(){
        putDataSourceKey(SLAVE);
    }

    /**
     * 是否爲主庫
     * @return
     */
    public static boolean isMaster(){
        if(StringUtils.equals(holder.get(),MASTER)){
            return true;
        }
        return false;
    }
}
AOP切面

該類控制了使用Master還是Slave。

  • 如果事務管理中配置了事務策略,則採用配置的事務策略中的標記了ReadOnly的方法是用Slave,其它使用Master。
  • 如果沒有配置事務管理的策略,則採用方法名匹配的原則,以query、find、get、select開頭方法用Slave,其它用Master。
/**
 * 定義數據源的AOP切面
 * @author wlc
 */
public class DataSourceAspect {
    private List<String> slaveMethodPattern = new ArrayList<>();
    /**設置默認的方法起始名數組,如果spring沒有注入,則使用這個。用來判斷是否爲從數據庫*/
    private static final String[] defaultSlaveMethodStart = new String[]{ "query", "find", "get","select"};
    /**通過spring注入值*/
    private String[] slaveMethodStart;

    /**
     * 讀取事務管理中的策略
     *
     * @param txAdvice
     * @throws Exception
     */
    @SuppressWarnings("unchecked")
    public void setTxAdvice(TransactionInterceptor txAdvice) throws Exception {
        if (txAdvice == null) {
            // 沒有配置事務管理策略
            return;
        }
        //從txAdvice獲取到策略配置信息
        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);
        //獲取nameMap的值
        Map<String, TransactionAttribute> map = (Map<String, TransactionAttribute>) nameMapField.get(matchTransactionAttributeSource);

        //遍歷nameMap
        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.markSlave();
        } else {
            // 標記爲寫庫
            DynamicDataSourceHolder.markMaster();
        }
    }

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

    /**
     * 通配符匹配
     *
     * The default implementation checks for "xxx*", "*xxx" and "*xxx*" matches, as well as direct
     * 
     */
    protected boolean isMatch(String methodName, String mappedName) {
        return PatternMatchUtils.simpleMatch(mappedName, methodName);
    }

    /**
     * 用戶指定slave的方法名前綴,在spring配置文件中注入
     * @param slaveMethodStart
     */
    public void setSlaveMethodStart(String[] slaveMethodStart) {
        this.slaveMethodStart = slaveMethodStart;
    }

    /**
     * 獲取配置的讀取從庫方法
     * @return
     */
    public String[] getSlaveMethodStart() {
        if(this.slaveMethodStart == null){
            // 沒有指定,使用默認
            return defaultSlaveMethodStart;
        }
        return slaveMethodStart;
    }
}

如果想要刪除或者添加從庫,只需在數據庫配置文件中添加一個slave即可,不用修改Java代碼。最後,放出源碼(各位猿友給點分吧,嘻嘻,如果實在沒有就留下聯繫方式):

源碼:內有福利,哈哈

參考資料:

http://www.iteye.com/topic/1127642

http://634871.blog.51cto.com/624871/1329301

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