1. 程式人生 > >資料庫讀寫分離,主從同步實現方法

資料庫讀寫分離,主從同步實現方法

前言

眾所周知,隨著使用者量的增多,資料庫操作往往會成為一個系統的瓶頸所在,而且一般的系統“讀”的壓力遠遠大於“寫”,因此我們可以通過實現資料庫的讀寫分離來提高系統的效能。

實現思路

通過設定主從資料庫實現讀寫分離,主資料庫負責“寫操作”,從資料庫負責“讀操作”,根據壓力情況,從資料庫可以部署多個提高“讀”的速度,藉此來提高系統總體的效能。

基礎知識

要實現讀寫分離,就要解決主從資料庫資料同步的問題,在主資料庫寫入資料後要保證從資料庫的資料也要更新。

主從資料庫同步的實現思路如圖:
主從同步

主伺服器master記錄資料庫操作日誌到Binary log,從伺服器開啟i/o執行緒將二進位制日誌記錄的操作同步到relay log(存在從伺服器的快取中),另外sql執行緒將relay log日誌記錄的操作在從伺服器執行。
記住這張圖,接下來基於這個圖實際設定主從資料庫。

主從資料庫設定的具體步驟

首先要有兩個資料庫伺服器master、slave(也可以用一個伺服器安裝兩套資料庫環境執行在不同埠,slave也可以舉一反三設定多個),我們窮人就買虛擬雲伺服器玩玩就行 0.0。以下操作假設你的兩臺伺服器上都已經安裝好了mysql服務。

1.開啟mysql資料庫配置檔案

vim /etc/my.cnf

2.在主伺服器master上配置開啟Binary log,主要是在[mysqld]下面新增:

server-id=1
log-bin=master-bin
log-bin-index=master-bin.index

如圖:
master my.cnf

3.重啟mysql服務

service mysql restart

ps:重啟方式隨意

4.檢查配置效果,進入主資料庫並執行

mysql> SHOW MASTER STATUS;

可以看到下圖表示配置沒問題,這裡面的File名:master-bin.000001 我們接下來在從資料庫的配置會使用:

5.配置從伺服器的 my.cnf

在[mysqld]節點下面新增:
master status

server-id=2
relay-log-index=slave-relay-bin.index
relay-log=slave-relay-bin

這裡面的server-id 一定要和主庫的不同,如圖:
slave my.cnf
配置完成後同樣重啟從資料庫一下

service mysql restart

6.接下來配置兩個資料庫的關聯

首先我們先建立一個操作主從同步的資料庫使用者,切換到主資料庫執行:

mysql> create user repl;
mysql> GRANT REPLICATION SLAVE ON *.* TO 'repl'@'從xxx.xxx.xxx.xx' IDENTIFIED BY 'mysql';
mysql> flush privileges;

這個配置的含義就是建立了一個數據庫使用者repl,密碼是mysql, 在從伺服器使用repl這個賬號和主伺服器連線的時候,就賦予其REPLICATION SLAVE的許可權, *.* 表面這個許可權是針對主庫的所有表的,其中xxx就是從伺服器的ip地址。
進入從資料庫後執行:

mysql> change master to master_host='主xxx.xxx.xxx.xx',master_port=3306,master_user='repl',master_password='mysql',master_log_file='master-bin.000001',master_log_pos=0;

這裡面的xxx是主伺服器ip,同時配置埠,repl代表訪問主資料庫的使用者,上述步驟執行完畢後執行start slave啟動配置:

mysql> start slave;

start slave
停止主從同步的命令為:

mysql> stop slave;

檢視狀態命令,\G表示換行檢視

mysql> show slave status \G; 

可以看到狀態如下:
slave status
這裡看到從資料庫已經在等待主庫的訊息了,接下來在主庫的操作,在從庫都會執行了。我們可以主庫負責寫,從庫負責讀(不要在從庫進行寫操作),達到讀寫分離的效果。

我們可以簡單測試:

在主資料庫中建立一個新的資料庫:

mysql> create database testsplit;

在從資料庫檢視資料庫:

mysql> show databases;

可以看到從資料庫也有testsplit這張表了,這裡就不上圖了,親測可用。在主資料庫插入資料,從資料庫也可以查到。
至此已經實現了資料庫主從同步

程式碼層面實現讀寫分離

上面我們已經有了兩個資料庫而且已經實現了主從資料庫同步,接下來的問題就是在我們的業務程式碼裡面實現讀寫分離,假設我們使用的是主流的ssm的框架開發的web專案,這裡面我們需要多個數據源。

在此之前,我們在專案中一般會使用一個數據庫使用者遠端操作資料庫(避免直接使用root使用者),因此我們需要在主從資料庫裡面都建立一個使用者mysqluser,賦予其增刪改查的許可權:
mysql> GRANT select,insert,update,delete ON *.* TO 'mysqluser'@'%' IDENTIFIED BY 'mysqlpassword' WITH GRANT OPTION;

然後我們的程式裡就用mysqluser這個使用者操作資料庫:

1.編寫jdbc.propreties

#mysql驅動
jdbc.driver=com.mysql.jdbc.Driver
#主資料庫地址
jdbc.master.url=jdbc:mysql://xxx.xxx.xxx.xx:3306/testsplit?useUnicode=true&characterEncoding=utf8
#從資料庫地址
jdbc.slave.url=jdbc:mysql://xxx.xxx.xxx.xx:3306/testsplit?useUnicode=true&characterEncoding=utf8
#資料庫賬號
jdbc.username=mysqluser
jdbc.password=mysqlpassword

這裡我們指定了兩個資料庫地址,其中的xxx分別是我們的主從資料庫的ip地址,埠都是使用預設的3306

2.配置資料來源

在spring-dao.xml中配置資料來源(這裡就不累贅介紹spring的配置了,假設大家都已經配置好執行環境),配置如下:

<?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:context="http://www.springframework.org/schema/context"
    xsi:schemaLocation="http://www.springframework.org/schema/beans
    http://www.springframework.org/schema/beans/spring-beans.xsd">
    <!-- 配置整合mybatis過程 -->
    <!-- 1.配置資料庫相關引數properties的屬性:${url} -->
    <context:property-placeholder location="classpath:jdbc.properties" />
    <!-- 掃描dao包下所有使用註解的型別 -->
    <context:component-scan base-package="c n.xzchain.testsplit.dao" />
    <!-- 2.資料庫連線池 -->
    <bean id="abstractDataSource" abstract="true" class="com.mchange.v2.c3p0.ComboPooledDataSource"
    destroy-method="close">
        <!-- c3p0連線池的私有屬性 -->
        <property name="maxPoolSize" value="30" />
        <property name="minPoolSize" value="10" />
        <!-- 關閉連線後不自動commit -->
        <property name="autoCommitOnClose" value="false" />
        <!-- 獲取連線超時時間 -->
        <property name="checkoutTimeout" value="10000" />
        <!-- 當獲取連線失敗重試次數 -->
        <property name="acquireRetryAttempts" value="2" />
    </bean>
    <!--主庫配置-->
    <bean id="master" parent="abstractDataSource">
        <!-- 配置連線池屬性 -->
        <property name="driverClass" value="${jdbc.driver}" />
        <property name="jdbcUrl" value="${jdbc.master.url}" />
        <property name="user" value="${jdbc.username}" />
        <property name="password" value="${jdbc.password}" />
    </bean>
    <!--從庫配置-->
    <bean id="slave" parent="abstractDataSource">
        <!-- 配置連線池屬性 -->
        <property name="driverClass" value="${jdbc.driver}" />
        <property name="jdbcUrl" value="${jdbc.slave.url}" />
        <property name="user" value="${jdbc.username}" />
        <property name="password" value="${jdbc.password}" />
    </bean>
    <!--配置動態資料來源,這裡的targetDataSource就是路由資料來源所對應的名稱-->
    <bean id="dataSourceSelector" class="cn.xzchain.testsplit.dao.split.DataSourceSelector">
        <property name="targetDataSources">
            <map>
                <entry value-ref="master" key="master"></entry>
                <entry value-ref="slave" key="slave"></entry>
            </map>
        </property>
    </bean>
    <!--配置資料來源懶載入-->
    <bean id="dataSource" class="org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy">
        <property name="targetDataSource">
            <ref bean="dataSourceSelector"></ref>
        </property>
    </bean>

    <!-- 3.配置SqlSessionFactory物件 -->
    <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
        <!-- 注入資料庫連線池 -->
        <property name="dataSource" ref="dataSource" />
        <!-- 配置MyBaties全域性配置檔案:mybatis-config.xml -->
        <property name="configLocation" value="classpath:mybatis-config.xml" />
        <!-- 掃描entity包 使用別名 -->
        <property name="typeAliasesPackage" value="cn.xzchain.testsplit.entity" />
        <!-- 掃描sql配置檔案:mapper需要的xml檔案 -->
        <property name="mapperLocations" value="classpath:mapper/*.xml" />
    </bean>

    <!-- 4.配置掃描Dao介面包,動態實現Dao介面,注入到spring容器中 -->
    <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
        <!-- 注入sqlSessionFactory -->
        <property name="sqlSessionFactoryBeanName" value="sqlSessionFactory" />
        <!-- 給出需要掃描Dao介面包 -->
        <property name="basePackage" value="cn.xzchain.testsplit.dao" />
    </bean>
</beans>

說明:
首先讀取配置檔案jdbc.properties,然後在我們定義了一個基於c3p0連線池的父類“抽象”資料來源,然後配置了兩個具體的資料來源master、slave,繼承了abstractDataSource,這裡面就配置了資料庫連線的具體屬性,然後我們配置了動態資料來源,他將決定使用哪個具體的資料來源,這裡面的關鍵就是DataSourceSelector,接下來我們會實現這個bean。下一步設定了資料來源的懶載入,保證在資料來源載入的時候其他依賴的bean已經載入好了。接著就是常規的配置了,我們的mybatis全域性配置檔案如下

3.mybatis全域性配置檔案

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
  PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <!-- 配置全域性屬性 -->
    <settings>
        <!-- 使用jdbc的getGeneratedKeys獲取資料庫自增主鍵值 -->
        <setting name="useGeneratedKeys" value="true" />

        <!-- 使用列別名替換列名 預設:true -->
        <setting name="useColumnLabel" value="true" />

        <!-- 開啟駝峰命名轉換:Table{create_time} -> Entity{createTime} -->
        <setting name="mapUnderscoreToCamelCase" value="true" />
        <!-- 列印查詢語句 -->
        <setting name="logImpl" value="STDOUT_LOGGING" />
    </settings>
    <plugins>
        <plugin interceptor="cn.xzchain.testsplit.dao.split.DateSourceSelectInterceptor"></plugin>
    </plugins>
</configuration>

這裡面的關鍵就是DateSourceSelectInterceptor這個攔截器,它會攔截所有的資料庫操作,然後分析sql語句判斷是“讀”操作還是“寫”操作,我們接下來就來實現上述的DataSourceSelector和DateSourceSelectInterceptor

4.編寫DataSourceSelector

DataSourceSelector就是我們在spring-dao.xml配置的,用於動態配置資料來源。程式碼如下:

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

/**
 * @author lihang
 * @date 2017/12/6.
 * @description 繼承了AbstractRoutingDataSource,動態選擇資料來源
 */
public class DataSourceSelector extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return DynamicDataSourceHolder.getDataSourceType();
    }
}

我們只要繼承AbstractRoutingDataSource並且重寫determineCurrentLookupKey()方法就可以動態配置我們的資料來源。
編寫DynamicDataSourceHolder,程式碼如下:

/**
 * @author lihang
 * @date 2017/12/6.
 * @description
 */
public class DynamicDataSourceHolder {

    /**用來存取key,ThreadLocal保證了執行緒安全*/
    private static ThreadLocal<String> contextHolder = new ThreadLocal<String>();
    /**主庫*/
    public static final String DB_MASTER = "master";
    /**從庫*/
    public static final String DB_SLAVE = "slave";

    /**
     * 獲取執行緒的資料來源
     * @return
     */
    public static String getDataSourceType() {
        String db = contextHolder.get();
        if (db == null){
            //如果db為空則預設使用主庫(因為主庫支援讀和寫)
            db = DB_MASTER;
        }
        return db;
    }

    /**
     * 設定執行緒的資料來源
     * @param s
     */
    public static void setDataSourceType(String s) {
        contextHolder.set(s);
    }

    /**
     * 清理連線型別
     */
    public static void clearDataSource(){
        contextHolder.remove();
    }
}

這個類決定返回的資料來源是master還是slave,這個類的初始化我們就需要藉助DateSourceSelectInterceptor了,我們攔截所有的資料庫操作請求,通過分析sql語句來判斷是讀還是寫操作,讀操作就給DynamicDataSourceHolder設定slave源,寫操作就給其設定master源,程式碼如下:

import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.executor.keygen.SelectKeyGenerator;
import org.apache.ibatis.mapping.BoundSql;
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.Locale;
import java.util.Properties;

/**
 * @author lihang
 * @date 2017/12/6.
 * @description 攔截資料庫操作,根據sql判斷是讀還是寫,選擇不同的資料來源
 */
@Intercepts({@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 DateSourceSelectInterceptor implements Interceptor{

    /**正則匹配 insert、delete、update操作*/
    private static final String REGEX = ".*insert\\\\u0020.*|.*delete\\\\u0020.*|.*update\\\\u0020.*";

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        //判斷當前操作是否有事務
        boolean synchonizationActive = TransactionSynchronizationManager.isSynchronizationActive();
        //獲取執行引數
        Object[] objects = invocation.getArgs();
        MappedStatement ms = (MappedStatement) objects[0];
        //預設設定使用主庫
        String lookupKey = DynamicDataSourceHolder.DB_MASTER;;
        if (!synchonizationActive){
            //讀方法
            if (ms.getSqlCommandType().equals(SqlCommandType.SELECT)){
                //selectKey為自增主鍵(SELECT LAST_INSERT_ID())方法,使用主庫
                if (ms.getId().contains(SelectKeyGenerator.SELECT_KEY_SUFFIX)){
                    lookupKey = DynamicDataSourceHolder.DB_MASTER;
                }else {
                    BoundSql boundSql = ms.getSqlSource().getBoundSql(objects[1]);
                    String sql = boundSql.getSql().toLowerCase(Locale.CHINA).replace("[\\t\\n\\r]"," ");
                    //如果是insert、delete、update操作 使用主庫
                    if (sql.matches(REGEX)){
                        lookupKey = DynamicDataSourceHolder.DB_MASTER;
                    }else {
                        //使用從庫
                        lookupKey = DynamicDataSourceHolder.DB_SLAVE;
                    }
                }
            }
        }else {
            //一般使用事務的都是寫操作,直接使用主庫
            lookupKey = DynamicDataSourceHolder.DB_MASTER;
        }
        //設定資料來源
        DynamicDataSourceHolder.setDataSourceType(lookupKey);
        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        if (target instanceof Executor){
            //如果是Executor(執行增刪改查操作),則攔截下來
            return Plugin.wrap(target,this);
        }else {
            return target;
        }
    }

    @Override
    public void setProperties(Properties properties) {

    }
}

通過這個攔截器,所有的insert、delete、update操作設定使用master源,select會使用slave源。

接下來就是測試了,我這是生產環境的程式碼,直接列印日誌,小夥伴可以加上日誌後測試使用的是哪個資料來源,結果和預期一樣,這樣我們就實現了讀寫分離~

ps:我們可以配置多個slave用於負載均衡,只需要在spring-dao.xml中新增slave1、slave2、slave3……然後修改dataSourceSelector這個bean,

<bean id="dataSourceSelector" class="cn.xzchain.o2o.dao.split.DataSourceSelector">
        <property name="targetDataSources">
            <map>
                <entry value-ref="master" key="master"></entry>
                <entry value-ref="slave1" key="slave1"></entry>
                <entry value-ref="slave2" key="slave2"></entry>
                <entry value-ref="slave3" key="slave3"></entry>
            </map>
        </property>

在map標籤中新增slave1、slave2、slave3……即可,具體的負載均衡策略我們在DynamicDataSourceHolder、DateSourceSelectInterceptor中實現即可。

最後整理一下整個流程:
1.專案啟動後,在依賴的bean載入完成後,我們的資料來源通過LazyConnectionDataSourceProxy開始載入,他會引用dataSourceSelector載入資料來源。
2.DataSourceSelector會選擇一個數據源,我們在程式碼裡設定了預設資料來源為master,在初始化的時候我們就預設使用master源。
3.在資料庫操作執行時,DateSourceSelectInterceptor攔截器攔截了請求,通過分析sql決定使用哪個資料來源,“讀操作”使用slave源,“寫操作”使用master源。

寫在後面

現在很多讀寫分離中介軟體已經大大簡化了我們的工作,但是自己實現一個小體量的讀寫分離有助於我們進一步理解資料庫讀寫分離在業務上的實現,呼~