阅读 210

数据库读写分离的坑,你已经在坑了 まんが,マンガ

数据库读写分离 まんが,マンガ

本篇文章是在 数据源读写分离 基础上发现的一个小问题。

目录

  • 背景
  • 知识储备
  • 问题现象
  • 解决方案
  • 问题总结

背景

Springboot集成mybatis使用HikariCP连接MySQL,进行读写分离。

知识储备

在上篇文章中,我们实现了数据源的读写分离,也实现了我们想要的效果. 但是存在一个小小的瑕疵。接下来,我需要介绍一下稍微深入的知识。

在我们配置动态数据源的时候,继承了一个类 AbstractRoutingDataSource,我们先看一下这个类有什么作用。

package com.fxb.doraemon.human;

import org.springframework.beans.factory.InitializingBean;
import org.springframework.jdbc.datasource.AbstractDataSource;
import org.springframework.jdbc.datasource.lookup.DataSourceLookup;
import org.springframework.jdbc.datasource.lookup.JndiDataSourceLookup;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;


/**
 * DataSource的实现:根据一个指定的key,调用各种的目标的数据源。 之后,通常根据一些线程的事务上下文来指定数据源。
 */
public abstract class AbstractRoutingDataSourceTest extends AbstractDataSource implements InitializingBean {

    /**
     * 目标数据源
     */
    @Nullable
    private Map<Object, Object> targetDataSources;

    /**
     * 默认的数据源
     */
    @Nullable
    private Object defaultTargetDataSource;

    /**
     * ??
     */
    private boolean lenientFallback = true;

    /**
     * 数据源查找??
     */
    private DataSourceLookup dataSourceLookup = new JndiDataSourceLookup();

    @Nullable
    private Map<Object, DataSource> resolvedDataSources;

    /**
     * 被处理之后的数据源??
     */
    @Nullable
    private DataSource resolvedDefaultDataSource;


    /**
     * 根据指定查找的key获取目标数据源的映射,
     * 映射的值可以是一个DataSource的实例,也可以是String。如果是String的话,会通过setDataSourceLookup方法进行解析。
     * 指定的key可以是任意类型,这个类仅仅实现了通用的查找过程,具体的key标识将由resolveSpecifiedLookupKey(Object)方法和
     * determineCurrentLookupKey()进行解析。
     * 也就是说:
     * #1.如果我们自己定义了Map中的value是字符串的话,就需要重写setDataSourceLookup这个方法进行返回正确的数据源。
     * #2.如果我们自己定义了Map中的key的话,我们就需要重写resolveSpecifiedLookupKey方法和determineCurrentLookupKey()。
     */
    public void setTargetDataSources(Map<Object, Object> targetDataSources) {
        this.targetDataSources = targetDataSources;
    }

    /**
     * 指定默认的数据源(如果存在的话)
     * 如果我们自己定义了Map中的value是字符串的话,就需要重写setDataSourceLookup这个方法进行返回正确的数据源。
     * 如果根据指定的key在targetDataSources中找不到Datasource的时候,就用使用这个默认的数据源。
     */
    public void setDefaultTargetDataSource(Object defaultTargetDataSource) {
        this.defaultTargetDataSource = defaultTargetDataSource;
    }

    /**
     * 如果找不到指定的Datsource的时候,可以通知指定lenientFallback来确定是否使用默认数据源
     * true: 找不到就会使用默认数据源
     * false: 仅在key为null的时候进行回退。即当key为null的时候才使用默认的数据源,否则就会抛出IllegalStateException异常
     */
    public void setLenientFallback(boolean lenientFallback) {
        this.lenientFallback = lenientFallback;
    }

    /**
     * 解析setTargetDataSource中数据源名称是dataSource的情况,默认值是JndiDataSourceLookup。
     */
    public void setDataSourceLookup(@Nullable DataSourceLookup dataSourceLookup) {
        this.dataSourceLookup = (dataSourceLookup != null ? dataSourceLookup : new JndiDataSourceLookup());
    }


    @Override
    public void afterPropertiesSet() {
        if (this.targetDataSources == null) {
            throw new IllegalArgumentException("Property 'targetDataSources' is required");
        }
        this.resolvedDataSources = new HashMap<>(this.targetDataSources.size());
        this.targetDataSources.forEach((key, value) -> {
            Object lookupKey = resolveSpecifiedLookupKey(key);
            DataSource dataSource = resolveSpecifiedDataSource(value);
            this.resolvedDataSources.put(lookupKey, dataSource);
        });
        if (this.defaultTargetDataSource != null) {
            this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
        }
    }

    /**
     * 解析指定的key,对应着setTargetDataSources#2的情况。
     * 默认是直接返回
     */
    protected Object resolveSpecifiedLookupKey(Object lookupKey) {
        return lookupKey;
    }

    /**
     * 将指定的数据源对象解析为DataSource实例
     * 默认是通过:setDataSourceLookup进行解析。
     * 如果是字符串的话,可以用通过setDataSourceLookup设置自定义的dataSourceLookup。
     */
    protected DataSource resolveSpecifiedDataSource(Object dataSource) throws IllegalArgumentException {
        if (dataSource instanceof DataSource) {
            return (DataSource) dataSource;
        } else if (dataSource instanceof String) {
            return this.dataSourceLookup.getDataSource((String) dataSource);
        } else {
            throw new IllegalArgumentException(
                    "Illegal data source value - only [javax.sql.DataSource] and String supported: " + dataSource);
        }
    }


    @Override
    public Connection getConnection() throws SQLException {
        return determineTargetDataSource().getConnection();
    }

    @Override
    public Connection getConnection(String username, String password) throws SQLException {
        return determineTargetDataSource().getConnection(username, password);
    }

    @Override
    @SuppressWarnings("unchecked")
    public <T> T unwrap(Class<T> iface) throws SQLException {
        if (iface.isInstance(this)) {
            return (T) this;
        }
        return determineTargetDataSource().unwrap(iface);
    }

    @Override
    public boolean isWrapperFor(Class<?> iface) throws SQLException {
        return (iface.isInstance(this) || determineTargetDataSource().isWrapperFor(iface));
    }

    /**
     * 检索当前目标数据源。
     * 调用determineCurrentLookupKey获取key,在targetDataSources中进行查找,是否要会回退,使用默认数据源。
     * 如果找不到数据源就抛出IllegalStateException异常。
     */
    protected DataSource determineTargetDataSource() {
        Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
        Object lookupKey = determineCurrentLookupKey();
        DataSource dataSource = this.resolvedDataSources.get(lookupKey);
        if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
            dataSource = this.resolvedDefaultDataSource;
        }
        if (dataSource == null) {
            throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
        }
        return dataSource;
    }

    /**
     * 确定当前查找建。
     * 通常会实现这个方法类检测线程绑定的事务上下文.
     * 返回的key需要与targetDatasources这个Map中的key的类型进行匹配,由resolveSpecifiedLookupKey进行解析。
     */
    @Nullable
    protected abstract Object determineCurrentLookupKey();
}
复制代码

那么这个类的执行过程是怎么样的呢?

当程序要获取数据库的连接的时候,就会调用这个AbstractRoutingDataSourcegetConnection(),当然,实际上,是通过DataSource.getConnection()进行调用的。

问题现象

问题就出现在下面的代码中

protected DataSource determineTargetDataSource() {
    Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
    Object lookupKey = determineCurrentLookupKey();
    DataSource dataSource = this.resolvedDataSources.get(lookupKey);
    if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
        dataSource = this.resolvedDefaultDataSource;
    }
    if (dataSource == null) {
        throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
    }
    return dataSource;
}
复制代码

接下来,我仔细的演示一遍。 当我在进行保存操作的时候,调用determineCurrentLookupKey()的时候并没有获取到lookupKey.导致在获取数据源的时候,使用的是默认数据源(恰好默认数据源是Master),而我们使用的是Master数据源(巧合)。如下图

如果我把默认的数据源改成Slave1呢?-> 虽然我们在日志里打印的是master,但是我们实际上使用的是slave1

其实这个问题看日志的时候,也可以看出来的。

保存用户的时候,使用的master,打印的日志是: 使用的数据源是: master. 但是! 上面还有两句话

HikariPool-1 - Starting...
HikariPool-1 - Start completed.
复制代码

而在查询用户的时候, 使用的数据源是slave2.在下面打印的是:

HikariPool-2 - Starting...
HikariPool-2 - Start completed.
复制代码

好了,现象就描述到这里. 到底什么原因呢?

后来发现,我在业务代码中Service层的save方法上加上了Transactional注解。其他方法上没加。我把注解加到类上发现也会出现这样的问题。这里就涉及到了一个问题: 查询方法上要不要加Transactional注解呢?

我的理解是: @1:如果一个查询方法里,涉及到多次对数据库的查询操作,建议添加注解@Transactional(readOnly=true)

@2:具体为什么加上注解Transactional就会产生这样的问题还没有研究过。 猜测可能是因为开启事务的时候是在进入方法之前操作的,但是我们的读写分离呢,是在进入代理方法之后确定的数据源。所以呢,就导致了会提前调用determineTargetDataSource方法,而我们使用AOP织入的时候,已经确定了数据源,不会再次调用determineTargetDataSource方法了.

@3:新问题: 上图中为什么前两次打印了HikariPool-2 - Starting... HikariPool-2 - Start completed.,之后的查询没有打印这句话呢?

解决方案

解决方案一

修改织入的位置:

@Pointcut("@annotation(com.fxb.doraemon.human.annotation.Master) " +
    "|| execution(public * com.fxb.doraemon.human.service..*.save*(..)) " +
    "|| execution(public * com.fxb.doraemon.human.rest..*.insert*(..)) " +
    "|| execution(public * com.fxb.doraemon.human.rest..*.update*(..)) " +
    "|| execution(public * com.fxb.doraemon.human.rest..*.edit*(..)) " +
    "|| execution(public * com.fxb.doraemon.human.rest..*.delete*(..)) " +
    "|| execution(public * com.fxb.doraemon.human.rest..*.del*(..)) " +
    "|| execution(public * com.fxb.doraemon.human.rest..*.remove*(..)) ")
public void writePointcut() {
}
复制代码

这种解决方案有个问题,就是在一次查询操作涉及两次查询的时候,还想开启事务的时候,就不能get*,select*来命名方法了。 还需要在方法上使用注解指定使用的数据源。

问题总结

不要只看现象,更要看本质。问题解决了,多看一个内部的运行机理。

留下的坑

@1:查询方法要不要添加事务注解。如果一个查询方法里,涉及到多次对数据库的查询操作,建议添加注解@Transactional(readOnly=true) @2:具体为什么加上注解Transactional就会产生这样的问题还没有研究过。 @3:新问题: 上图中为什么前两次打印了HikariPool-2 - Starting... HikariPool-2 - Start completed.,之后的查询没有打印这句话呢?

最后

如果想了解最新动态,欢迎关注公众号: 方家小白. 欢迎一起交流学习。

期待你的关注

关注下面的标签,发现更多相似文章
评论