一、背景说明
准备将项目中读取数据库操作切换到从数据库上,调研方案后最终决定借助ThreadLocal实现,在测试过程中遇到间歇性异常情况,经排查是由于相同的insert、update语句会间歇性的命中到从库数据库,最终导致异常。
二、读写分离配置
原理 : 在spring-jdbc.jar包中提供了可动态切换数据源的实现类(AbstractRoutingDataSource),首先在项目中配置双数据库数据源(主数据源、从数据源)。使用AOP对dao层方法进行切割,根据方法名称判断,当方法名称为只读方法时将ThreadLocal中存储的数据源标签设置为read数据源( 从数据源名称)。重写AbstractRoutingDataSource中的determineCurrentLookupKey钩子方法,读取ThreadLocal中存储的数据源标签名称返回,最终实现数据库的读写分离。
ThreadLocal工具类代码如下所示
public class DbTagUtil {
private static ThreadLocal<String> dbTagLocal = new ThreadLocal<String>();
public static final String READ = "read";
public static final String WRITE = "write";
public static String getDbTag() {
String db = dbTagLocal.get();
if (db == null) {
/**默认写库**/
db = WRITE;
}
return db;
}
/**
* 赋值
* @param tag
*/
public static void setTag(String tag) {
dbTagLocal.set(tag);
}
/**
* 清空
*/
public static void clearTag() {
dbTagLocal.remove();
}
}
借助Spring的AbstractRoutingDataSource 实现动态数据源切换,配置如下所示:
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
public class DataSourceProxy extends AbstractRoutingDataSource {
/**模版模式中 钩子实现**/
@Override
protected Object determineCurrentLookupKey() {
return DbTagUtil.getDbTag();
}
}
Dao层方法切面实现方式
import org.springframework.aop.MethodBeforeAdvice;
public class DbAopCutting implements MethodBeforeAdvice {
private List<String> methodKeys;
@Override
public void before(Method method, Object[] args, Object target)
throws Throwable {
String methodName = method.getName().toLowerCase();
if (null != methodKeys ) {
for (String key : methodKeys) {
if (methodName.startsWith(key.toLowerCase())) {
DbTagUtil.setTag(DbTagUtil.READ);
return;
}
}
}
DbTagUtil.setTag(DbTagUtil.WRITE);
}
public void setMethodKeys(List<String> methodKeys) {
this.methodKeys = methodKeys;
}
}
mybatis 配置文件,配置方式如下所示:
<bean id="writeDataSource" class="org.apache.commons.dbcp.BasicDataSource" abstract="true" destroy-method="close" lazy-init="true">
......
</bean>
<bean id="readDataSource" class="org.apache.commons.dbcp.BasicDataSource" abstract="true" destroy-method="close" lazy-init="true">
......
</bean>
<bean id="dataSource" class="com.timer.bin.DataSourceProxy">
<property name="targetDataSources">
<util:map key-type="java.lang.String">
<entry key="read" value-ref="readDataSource" />
<entry key="write" value-ref="writeDataSource"/>
</util:map>
</property>
<property name="defaultTargetDataSource" ref="writeDataSource" />
</bean>
<bean id="dbAopCutting" class="com.timer.bin.DbAopCutting">
<property name="methodKeys">
<list>
<value>select</value>
<value>query</value>
</list>
</property>
</bean>
<aop:aspectj-autoproxy/>
<aop:config>
<aop:pointcut expression="execution(* com.timer.bin.dao..*.*(..))" id="dbCutPoint"/>
<aop:advisor advice-ref="dbAopCutting" pointcut-ref="dbCutPoint"/>
</aop:config>
借助以上配置实现读写分离
三、问题说明
错误信息如下所示:
Caused by: java.sql.SQLException: The MySQL server is running with the --read-only option so it cannot execute this statement
四、问题排查
借助输出调试日志方式,在相应的determineCurrentLookupKey和DbAopCutting.before方法中添加调试日志,最终发现出现异常时,在ThreadLocal获取到的数据源标签为read从数据源,并且获取数据源日志先于DbAopCutting.before方法中日志输出,结合其他输出日志最终排查出,出现异常的方法由于在Service层方法上加了事务。
事务在开启时就会获取dataSource,而DbAopCutting切面还未执行,所以此时在ThreadLocal中获取到的数据源标签为上一次SQL执行时存储的数据源标签,具体是主或从不定,最终导致程序出现间歇性异常问题。
五、修改方案
对DbAopCutting切面进行修改,增加afterReturning 增强,在方法正常执行完成后,立即将ThreadLocal中存储的数据源标签清除,保证ThreadLocal用完即删。
import org.springframework.aop.MethodBeforeAdvice;
public class DbAopCutting implements MethodBeforeAdvice {
private List<String> methodKeys;
......
@Override
public void afterReturning(Object o, Method method, Object[] objects, Object o1) throws Throwable {
DbTagUtil.clearTag();
}
}