[Mybatis]用AOP和mybatis來實現一下mysql讀寫分離

小記

看了看博客園發現有一陣子沒寫東西了,今天寫點最近折騰的東西吧,由於工作的原因,平時就Springboot的技術棧用得不多,甚至現在對springboot的使用還不如我以前在學校的時候懂得多,沒辦法,工作裏的東西是首要的,我也在努力擺脫這種困境,平時積累點知識,防止和外面脫節。這是作爲一個打工人和程序員應該有的意識。

搭一個簡單的mysql主從集羣(1主2從)

原本想用virtualbox來自己開三臺虛擬機來弄的,但是virtualbox我也使得不怎麼溜,固定ip的問題解決了但是各個虛擬機之間的通信依舊有問題,與其在這裏浪費時間我還不如在一臺機器上模擬,於是我在我上學的時候用的阿里雲服務器上用docker搭建了一個簡單的mysql集羣,集羣搭建就不細說了,三個節點對應三個docker容器,對應服務器的三個不同的端口,用來模擬集羣。

集羣搭建完,數據源的配置文件就可以寫出來了。

spring:
  datasource:
    master:
      pool-name: master
      driver-class-name: com.mysql.jdbc.Driver
      url: jdbc:mysql://aliyunserver:33307/test?useUnicode=true&characterEncoding=utf-8&useSSL=false
      username: root
      password: 123456
      maximum-pool-size: 10
      minimum-idle: 5
    slave1:
      pool-name: slave1
      driver-class-name: com.mysql.jdbc.Driver
      url: jdbc:mysql://aliyunserver:33308/test?useUnicode=true&characterEncoding=utf-8&useSSL=false
      username: root
      password: 123456
      maximum-pool-size: 10
      minimum-idle: 5
    slave2:
      pool-name: slave2
      driver-class-name: com.mysql.jdbc.Driver
      url: jdbc:mysql://aliyunserver:33309/test?useUnicode=true&characterEncoding=utf-8&useSSL=false
      username: root
      password: 123456
      maximum-pool-size: 10
      minimum-idle: 5
  application:
    name: mysql-test
server:
  port: 8080
mybatis:
  config-location: mybatis.xml
  mapper-locations: mapper/*.xml

配置數據源

有了數據源,那麼就來配置數據源,這裏需要配置三個數據源,分別是主節點,從節點1,從節點2,這裏暫且不討論有一個節點掛了的情況。這三個節點由一個方法來進行管理,也就是下面的dynamicDataSource();

@Configuration
public class DataSourceConfig {

    /**
     * 主庫
     * */
    @Bean("master")
    @ConfigurationProperties(prefix = "spring.datasource.master")
    public DataSource master(){
        return DruidDataSourceBuilder.create().build();
    }
    /**
     * 從庫1
     * */
    @Bean("slave1")
    @ConfigurationProperties(prefix = "spring.datasource.slave1")
    public DataSource slave1(){
        return DruidDataSourceBuilder.create().build();
    }

    @Bean("slave2")
    @ConfigurationProperties(prefix = "spring.datasource.slave2")
    public DataSource slave2(){
        return DruidDataSourceBuilder.create().build();
    }

    /**
     * 實例化數據源路由
     * */
    @Bean(name = "dynamicDatasource")
    public DataSourceRouter dynamicDataBase(@Qualifier("master")DataSource master,
                                            @Qualifier("slave1")DataSource slave1,
                                            @Qualifier("slave2")DataSource slave2){
        DataSourceRouter dynamicDataBase = new DataSourceRouter();
        Map<Object,Object> targetDataSources = new HashMap<Object, Object>(3);
        targetDataSources.put(DBType.MASTER,master());
        targetDataSources.put(DBType.SLAVE1,slave1());
        targetDataSources.put(DBType.SLAVE2,slave2());
        dynamicDataBase.setTargetDataSources(targetDataSources);
        //設置默認
        dynamicDataBase.setDefaultTargetDataSource(master());
        return dynamicDataBase;
    }
}


然後還有和mybatis相關的配置,一併加上。

@Configuration
@EnableTransactionManagement
public class MybatisConfig {

    @Resource(name = "dynamicDatasource")
    private DataSource dynamicDatasource;


    @Bean
    public SqlSessionFactory sqlSessionFactory() throws Exception{
        SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
        bean.setDataSource(dynamicDatasource);
        bean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
        return bean.getObject();
    }

    @Bean
    public PlatformTransactionManager platformTransactionManager(){
        return new DataSourceTransactionManager(dynamicDatasource);
    }
}

DBType是一個枚舉類,用來區分主從。

public enum DBType {
    /**
     * 主庫
     * */
    MASTER,
    /**
     * 從庫1
     * */
    SLAVE1,
    /**
     * 從庫2
     * */
    SLAVE2
}

DataSourceRouter用來路由數據源的,在寫它之前還需要寫一個動態獲取數據源的類,這個類裏面一般可以設置一定的策略,比如在讀的時候設置輪詢來平均對每個從庫的讀取壓力。例如我這裏兩個從庫那我設置對2取模來輪詢切換數據源,但是我這裏有弊端,那就是我這裏不能動態地調整,比如增加一個數據源我就要改動代碼,當然現在業界有解決辦法,這裏暫時不談。

public class DataSourceContextHolder {

    /**
     * 兩種操作數據庫的方式
     * */
    public static final String MASTER = "write";

    public static final String SLAVE = "read";

    private static final ThreadLocal<DBType> context = new ThreadLocal<>();

    /**
     * 計數器,用來對2取模決定用哪個從庫
     * */
    private static final AtomicInteger counter = new AtomicInteger(-1);
    
    public static void set(DBType dbType){
        if(dbType==null||StringUtils.isEmpty(dbType)){
            throw new NullPointerException("DataSourceType 爲空");
        }
        context.set(dbType);
    }

    public static DBType get(){
        return context.get();
    }

    /**
     * 切換到主數據源
     * */
    public static void setMaster(){
        set(DBType.MASTER);
    }

    /**
     * 切換到從節點數據源
     * */
    public static void setSlave(){
        int index = counter.getAndIncrement() % 2;
        if (index == 0){
            set(DBType.SLAVE1);
        }else {
            set(DBType.SLAVE2);
        }
    }
    /**
     * 移除
     * */
    public static void clear(){
        context.remove();
    }
}

然後再到數據源路由

public class DataSourceRouter extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContextHolder.get();
    }
}

用AOP來實現動態切換數據源

數據源的部分到上面爲止就搞定了,那麼現在的問題是,我們需要怎麼來切換數據源,總不可能在每個讀的方法裏都挨個調切換的方法,那麼這時候AOP就排上用場了,我們知道切換數據源是根據這個操作是讀還是寫來的,那麼自然對應到業務裏就是是否涉及到操作數據了,而業務自然就是在Service層來開刀了。

  • 定義讀操作和寫操作的註解
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Master {
}

@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Slave {
}

有了這兩個註解,就能在Service層對應的方法上來標記這個方法是讀還是寫了。然後就是切面了。

@Slf4j
@Aspect
@Component
public class DataSourceAspect {

    @Pointcut("@annotation(com.example.mysql.annotation.Slave) && execution(* com.example.mysql.service.impl..*.*(..))")
    public void readPointcut(){}

    @Pointcut("@annotation(com.example.mysql.annotation.Master) && execution(* com.example.mysql.service.impl..*.*(..))")
    public void writePointcut(){}

    @Before("readPointcut()")
    public void readBefore(JoinPoint joinPoint){
        DataSourceContextHolder.setSlave();
        String className = joinPoint.getTarget().getClass().getName();
        String methodName = joinPoint.getSignature().getName();
        log.info("{}-{} use slave datasource",className,methodName);
        DataSourceContextHolder.clear();

    }

    @After("readPointcut()")
    public void readAfter(JoinPoint joinPoint){
        DataSourceContextHolder.setMaster();
        String className = joinPoint.getTarget().getClass().getName();
        String methodName = joinPoint.getSignature().getName();
        log.info("after read,{}-{} switch to master datasource",className,methodName);
        DataSourceContextHolder.clear();
    }

    @Before("writePointcut()")
    public void writeBefore(JoinPoint joinPoint){
        DataSourceContextHolder.setMaster();
        String className = joinPoint.getTarget().getClass().getName();
        String methodName = joinPoint.getSignature().getName();
        log.info("{}-{} use master datasource",className,methodName);
        DataSourceContextHolder.clear();
    }
}

測試

我的測試比較簡單,一個用戶表裏有id和username兩個字段,一個讀操作和寫操作。

public interface UserDao {

    /**
     * 獲取用戶名
     * */
    String getUserName(int id);

    /**
     * 添加用戶
     * */
    void addUser(User user);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mysql.dao.UserDao">
    <select id="getUserName" parameterType="java.lang.Integer" resultType="java.lang.String">
    select username from user where id = #{id}
  </select>

    <insert id="addUser" parameterType="com.example.mysql.model.User">
        insert into user(id,username) values(#{id},#{username})
    </insert>
</mapper>

然後在Service層裏來調用,順便設置對應的註解。

public interface UserService {

    String getUserName(int userId);

    boolean addUser(User user);
}


@Service
@Slf4j
public class UserServiceImpl implements UserService {

    @Autowired
    private UserDao userDao;

    @Slave
    @Override
    public String getUserName(int userId) {
        return userDao.getUserName(userId);
    }

    @Master
    @Override
    public boolean addUser(User user) {
        try {
            userDao.addUser(user);
            return true;
        }catch (Exception e){
            log.error(e.getMessage());
            return false;
        }
    }
}

Controller層我就不寫了,我預先在表裏加了三條數據,分別是:

id	username
1	thyin
2	xxx
3	yyy

然後調用一下getUserName的接口,http://localhost:8080/user/api/getUserName/1,通過日誌可以看到使用的從庫的數據源:

avatar

然後測試一下寫的接口,http://localhost:8080/user/api/addUser?id=6&username=iqy,再看日誌。

avatar

再檢查主庫是否有記錄,並檢查從庫是否同步。

主庫:

avatar

從庫1:

avatar

從庫2:

avatar

ok。

總結

這裏我只是簡單暴力地做了一個讀寫分離的,但實際工作中這個肯定不夠,後面我會整理一下mycat的使用,以及分表分庫的知識,再接再厲。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章