前言
我们知道,数据库的写和读大致上是遵循二八定律的。尤其是针对互联网业务,读的操作要比写操作的概率高更多。为了消除读写锁冲突,缓解数据库压力,提高读写性能,我们提出了读写分离的数据库架构:将数据库分为了主(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代码。最后,放出源码(各位猿友给点分吧,嘻嘻,如果实在没有就留下联系方式):
参考资料: