小記
看了看博客園發現有一陣子沒寫東西了,今天寫點最近折騰的東西吧,由於工作的原因,平時就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,通過日誌可以看到使用的從庫的數據源:
然後測試一下寫的接口,http://localhost:8080/user/api/addUser?id=6&username=iqy,再看日誌。
再檢查主庫是否有記錄,並檢查從庫是否同步。
主庫:
從庫1:
從庫2:
ok。
總結
這裏我只是簡單暴力地做了一個讀寫分離的,但實際工作中這個肯定不夠,後面我會整理一下mycat的使用,以及分表分庫的知識,再接再厲。