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

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值