随着互联网、移动应用和物联网等领域的迅猛发展,数据量呈现出爆炸式增长,对数据库的性能和扩展性提出了更高的要求。MySQL 作为数据库领域的中流砥柱,不断进化以适应这些变化。主从同步及读写分离作为 MySQL 应对高并发和大数据挑战的关键技术,已经成为众多企业构建高性能数据库架构的标配。了解和掌握这两项技术,不仅能让你紧跟数据库技术发展的潮流,还能为你的项目和业务带来显著的性能提升和成本优化。下面,我们就一同走进 MySQL 主从同步及读写分离的世界。
一、使用虚拟机搭建三台MySQL服务
由于我已经提前搭建好一台环境,所以直接跳过搭建,如果没有搭建的话可以参考此篇文章:
在VMware中安装CentOS7(超详细的图文教程)_在vmware上安装centos-CSDN博客
右键点击管理-》克隆
克隆完成之后登录虚拟机
当你在虚拟机中完成克隆操作后,新克隆的服务器通常会继承原虚拟机的 IP 地址,为了避免 IP 冲突,你需要对新克隆服务器的 IP 地址进行修改。不同的操作系统,修改 IP 地址的方式也有所不同,以下为你分别介绍常见操作系统的修改方法:
CentOS 7 及以上版本
1. 查看网络配置文件
网络配置文件通常存放在 /etc/sysconfig/network-scripts/
目录下,以 ifcfg-
开头,后面跟着网络接口名(如 eth0
、ens33
等)。使用以下命令查看文件:
收起
bash
ls /etc/sysconfig/network-scripts/
2. 编辑网络配置文件
使用文本编辑器(如 vi
或 nano
)打开对应的网络配置文件,例如 ifcfg-ens33
:
收起
bash
vi /etc/sysconfig/network-scripts/ifcfg-ens33
3. 修改配置参数
将文件中的 IP 地址、子网掩码、网关等信息修改为你需要的值。示例如下:
收起
plaintext
TYPE=Ethernet
PROXY_METHOD=none
BROWSER_ONLY=no
BOOTPROTO=static # 使用静态 IP 地址
DEFROUTE=yes
IPV4_FAILURE_FATAL=no
IPV6INIT=yes
IPV6_AUTOCONF=yes
IPV6_DEFROUTE=yes
IPV6_FAILURE_FATAL=no
IPV6_ADDR_GEN_MODE=stable-privacy
NAME=ens33
UUID=xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # 可保留原 UUID 或重新生成
DEVICE=ens33
ONBOOT=yes # 开机自动启用网络接口
IPADDR=192.168.1.100 # 修改为新的 IP 地址
NETMASK=255.255.255.0 # 修改为对应的子网掩码
GATEWAY=192.168.1.1 # 修改为对应的网关地址
DNS1=8.8.8.8 # 修改为对应的 DNS 服务器地址
4. 保存并退出文件
在 vi
编辑器中,按下 Esc
键,输入 :wq
并回车保存退出。
5. 重启网络服务
使用以下命令重启网络服务使配置生效:
收起
bash
systemctl restart network
通过docker下载MySQL镜像
sudo docker pull mysql:5.7
如果遇到拉取失败可以尝试下面的方法
第一步更改docker配置:
sudo vi /etc/docker/daemon.json
第二步:
{
"registry-mirrors": [
"https://2a6bf1988cb6428c877f723ec7530dbc.mirror.swr.myhuaweicloud.com",
"https://docker.m.daocloud.io",
"https://hub-mirror.c.163.com",
"https://mirror.baidubce.com",
"https://your_preferred_mirror",
"https://dockerhub.icu",
"https://docker.registry.cyou",
"https://docker-cf.registry.cyou",
"https://dockercf.jsdelivr.fyi",
"https://docker.jsdelivr.fyi",
"https://dockertest.jsdelivr.fyi",
"https://mirror.aliyuncs.com",
"https://dockerproxy.com",
"https://mirror.baidubce.com",
"https://docker.m.daocloud.io",
"https://docker.nju.edu.cn",
"https://docker.mirrors.sjtug.sjtu.edu.cn",
"https://docker.mirrors.ustc.edu.cn",
"https://mirror.iscas.ac.cn",
"https://docker.rainbond.cc"
]
}
第三步重启docker:
[vagrant@localhost etc]$ sudo systemctl daemon-reload
[vagrant@localhost etc]$ sudo systemctl restart docker
第四步拉取镜像:
sudo docker pull mysql:5.7
如拉取失败更改DNS配置
sudo vi /etc/resolv.conf
新增nameserver 8.8.8.8
Nameserver8.8.4.4
启动MySQL镜像:
sudo docker pull mysql:5.7
启动MySQL容器:
docker run -d --name mysql-new -e MYSQL_ROOT_PASSWORD=123456
设置账户可以被本机navicate连接:
第一步:登录进入MySQL容器内部
docker exec -it mysql-new mysql -u root -p
输入密码之后:
GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' IDENTIFIED BY '123456' WITH GRANT OPTION;
完成容器配置
二、配置可以本机连接
docker exec -it mysql-new bash
设置账户可以被本机navicate连接:
先进入MySQL内部
mysql -uroot -p123456
输入密码123456登录MySQL内部
GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' IDENTIFIED BY '123456' WITH GRANT OPTION;
完成容器配置,这时候就可以通过navicat连接虚拟机的MySQL了
其他MySQL服务器重复以上步骤就可以
三、配置主从同步
一、进入容器
docker exec -it mysql-new bash
二、修改文件配置
Vim etc/my.cof
新增以下内容:
log-bin=mysql-bin #开启日志
server-id=1 #设置唯一编号
binlog-ignore-db=mysql #忽略数据库名称
binlog-ignore-db=information_schema #忽略数据库名称
binlog-do-db=test #要同步的数据库名称
binlog_format=STATEMENT
exc之后wq保存并退出
执行docker restart mysql-new 重启MySQL服务
三、进入MySQL内部
一、配置主MySQL
mysql -uroot -p123456
给从机创建账号:后续从机使用此账号进行数据同步
GRANT REPLICATION SLAVE ON *.* TO 'slave'@'%' IDENTIFIED BY '123456';
show master status;
记录对应的file和Position
二、配置从MySQL
登录从MySQL内部
Vim etc\my.cof
新增以下内容:
log-bin=mysql-bin
server-id=2
binlog-ignore-db=mysql
binlog-ignore-db=information_schema
binlog-do-db=test
binlog_format=STATEMENT
进入MySQL内部:
mysql -uroot -p123456
执行此命令:
-- 停止从库复制进程
STOP SLAVE;
-- 重置从库配置
RESET SLAVE;
-- 重新配置主从连接
CHANGE MASTER TO MASTER_HOST='192.168.60.139',
MASTER_USER='slave',
MASTER_PASSWORD='123456',
MASTER_PORT=3306,
MASTER_LOG_FILE='mysql-bin.000002',MASTER_LOG_POS=154;
-- 启动从库复制进程
START SLAVE;
-- 检查从库状态
SHOW SLAVE STATUS\G
配置主从同步完成
四、验证主从同步
主表
从表
在主表新增
888 老八 江西省
8888 Lucy 八佰陆i
此时从表也新增两条数据,主从同步完成。
五、停止主从同步
如何停止从服务复制功能
stop slave;
如何重新配置主从 (即清除之前的主从配置)
stop slave;
reset master;
六、Java代码集成MySQL读写分离
实体类:
package com.wwy.readwrite.domain.po;
import lombok.Data;
@Data
public class User {
/**
* 用户id
*/
private String id;
/**
* 用户名
*/
private String name;
/**
* 地址
*/
private String address;
}
package com.wwy.readwrite.mapper;
import com.wwy.readwrite.domain.po.User;
public interface UserMapper{
void insertUser(User user);
User selectUserById(String id);
User selectUserByName(String name);
}
<?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.wwy.readwrite.mapper.UserMapper">
<insert id="insertUser">
insert into user (id,name,address) value (#{id},#{name},#{address});
</insert>
<select id="selectUserById" resultType="com.wwy.readwrite.domain.po.User">
select * from user where id = #{id}
</select>
<select id="selectUserByName" resultType="com.wwy.readwrite.domain.po.User">
select * from user where name = #{name}
</select>
</mapper>
logging:
level:
com.itheima: debug
pattern:
dateformat: HH:mm:ss
mybatis:
mapper-locations: classpath*:mapper/*.xml
master:
datasource:
jdbc-url: jdbc:mysql://192.168.60.139:3306/test?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL
username: root
password: 123456 #这里写真实的主库密码
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 30
minimum-idle: 10
max-lifetime: 1800000
connection-timeout: 30000
idle-timeout: 600000
connection-test-query: SELECT 1
read-only: false
slave:
datasource:
jdbc-url: jdbc:mysql://192.168.60.140:3306/test?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL
username: root
password: 123456 #这里写真实的从库密码
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 30
minimum-idle: 10
max-lifetime: 1800000
connection-timeout: 30000
idle-timeout: 600000
connection-test-query: SELECT 1
read-only: true
读写分离相关配置:
在resource目录下新增mybatis-config.xml配置文件
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<!-- 配置全局属性 -->
<settings>
<!-- 使用jdbc的getGeneratedKeys获取数据库自增主键值 -->
<setting name="useGeneratedKeys" value="true" />
<!-- 使用列别名替换列名 默认:true -->
<setting name="useColumnLabel" value="true" />
<!-- 开启驼峰命名转换:Table{create_time} -> Entity{createTime} -->
<setting name="mapUnderscoreToCamelCase" value="true" />
<!-- 打印查询语句 -->
<setting name="logImpl" value="STDOUT_LOGGING" />
</settings>
<plugins>
<!-- 使Mybatis拦截器生效,如果没有下面配置的话,Mybatis拦截器就不会生效-->
<plugin interceptor="com.wwy.readwrite.config.DynamicDataSourceInterceptor"/>
</plugins>
</configuration>
配置数据源:
package com.wwy.readwrite.config;
/**
* @author 王伟羽
* @program: readwrite-demo
* @description:
* @create: 2025/02/08
*/
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.LazyConnectionDataSourceProxy;
import javax.sql.DataSource;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* dao层配置
*/
@Configuration
@MapperScan(basePackages = "com.example.masterslavedb.dao", sqlSessionFactoryRef = "sqlSessionFactoryBean")
public class SpringDaoConf {
/**
* 主库数据源
* @return
*/
@Bean(name = "masterDataSource")
@ConfigurationProperties(prefix = "master.datasource")
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();
}
/**
* 从库数据源
* @return
*/
@Bean(name = "slaveDataSource")
@ConfigurationProperties(prefix = "slave.datasource")
public DataSource slaveDataSource() {
return DataSourceBuilder.create().build();
}
/**
* 我们自己实现的动态数据源
* @return
*/
@Bean(name = "dynamicDataSource")
public DynamicDataSource dynamicDataSource() {
DynamicDataSource result = new DynamicDataSource();
Map<Object, Object> targetDataSources = new HashMap<Object, Object>();
targetDataSources.put("master", masterDataSource());
targetDataSources.put("slave", slaveDataSource());
result.setTargetDataSources(targetDataSources);
return result;
}
/**
* 因为要在确定了sql语句之后才能确定选择哪个数据源,所以我们需要一个懒加载数据源
* @return
*/
@Bean(name = "dataSourceProxy")
public LazyConnectionDataSourceProxy dataSourceProxy() {
LazyConnectionDataSourceProxy result = new LazyConnectionDataSourceProxy();
result.setTargetDataSource(dynamicDataSource());
return result;
}
@Bean(name = "sqlSessionFactoryBean")
public SqlSessionFactoryBean sqlSessionFactoryBean() throws IOException {
SqlSessionFactoryBean result = new SqlSessionFactoryBean();
result.setDataSource(dataSourceProxy());
//设置entity包扫描路径
result.setTypeAliasesPackage("com.wwy.readwrite.domain.po");
//注入mybatis配置
result.setConfigLocation(new PathMatchingResourcePatternResolver().getResource("classpath:mybatis-config.xml"));
//设置sql映射文件包扫描路径
result.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
return result;
}
@Bean(name = "transactionManager")
public DataSourceTransactionManager transactionManager() {
return new DataSourceTransactionManager(dataSourceProxy());
}
}
配置数据源路由器,使其找到对应的数据源:
package com.wwy.readwrite.config;
/**
* @author 王伟羽
* @program: readwrite-demo
* @description:
* @create: 2025/02/08
*/
import com.wwy.readwrite.utlis.DynamicDataSourceHolder;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
/**
* 数据源路由器,用于自动将sql语句分发给对应的数据源
* 比如将增删改sql语句分发给主库数据源,将查询sql语句分发给从库数据源
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceHolder.getDbType();
}
}
设置拦截器,拦截sql请求并分析其对应的方法
package com.wwy.readwrite.config;
/**
* @author 王伟羽
* @program: readwrite-demo
* @description:
* @create: 2025/02/08
*/
import com.wwy.readwrite.utlis.DynamicDataSourceHolder;
import org.apache.ibatis.executor.keygen.SelectKeyGenerator;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* mybatis拦截器
*/
@Intercepts(
//mybatis会将增删改的操作封装在update里面,所以insert和delete就不需要写了
注意这里的Executor所属的包是org.apache.ibatis.executor
{@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class DynamicDataSourceInterceptor implements Interceptor {
private static final Logger LOGGER = LoggerFactory.getLogger(DynamicDataSourceInterceptor.class);
private static final Pattern PATTERN = Pattern.compile(".*insert\\u0020.*|.*delete\\u0020.*|.*update\\u0020.*");
@Override
public Object intercept(Invocation invocation) throws Throwable {
//判断本次执行是否被事务管理
boolean synchronizationActive = TransactionSynchronizationManager.isActualTransactionActive();
//获取mybatis传过来的参数
Object[] args = invocation.getArgs();
//sql是属于哪种操作(增删改查)往往是保存在第一个对象中
MappedStatement ms = (MappedStatement) args[0];
String lookupKey = DynamicDataSourceHolder.DB_MASTER;
if (!synchronizationActive) {
//如果是读操作
if (ms.getSqlCommandType().equals(SqlCommandType.SELECT)) {
//selectKey为自增id查询主键 SELECT LAST_INSERT_ID()方法,使用主库
if (ms.getId().contains(SelectKeyGenerator.SELECT_KEY_SUFFIX)) {
lookupKey = DynamicDataSourceHolder.DB_MASTER;
} else {
BoundSql boundSql = ms.getSqlSource().getBoundSql(args[1]);
String sql = boundSql.getSql().toLowerCase(Locale.CHINA)
.replaceAll("[\\t\\n\\r]", " ");//将所有的制表符换行符回车符替换掉
//匹配sql语句是否为增删改
if (PATTERN.matcher(sql).matches()) {
lookupKey = DynamicDataSourceHolder.DB_MASTER;
} else {
lookupKey = DynamicDataSourceHolder.DB_SLAVE;
}
}
}
} else {
//用事务管理的操作一般都是写操作,因此我们用主库
lookupKey = DynamicDataSourceHolder.DB_MASTER;
}
LOGGER.debug("设置方法[{}], use[{}], Strategy, SqlCommandType[{}]...",
ms.getId(), lookupKey, ms.getSqlCommandType().name());
DynamicDataSourceHolder.setDbType(lookupKey);
return invocation.proceed();
}
}
package com.wwy.readwrite.utlis;
/**
* @author 王伟羽
* @program: readwrite-demo
* @description:
* @create: 2025/02/08
*/
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* 一个工具类,mybatis拦截器会把获取主库还是获取从库的通知放在这个类的contextHolder中,
* 数据源路由器再从这个contextHolder中获取通知
*/
public class DynamicDataSourceHolder {
private static final Logger LOGGER = LoggerFactory.getLogger(DynamicDataSourceHolder.class);
private static ThreadLocal<String> contextHolder = new ThreadLocal<String>();
public static final String DB_MASTER = "master";
public static final String DB_SLAVE = "slave";
/**
* 获取线程的DbType
* @return
*/
public static String getDbType() {
String result = contextHolder.get();
if (result == null) {
result = DB_MASTER;
}
return result;
}
/**
* 设置线程的DbType
* @param dbType
*/
public static void setDbType(String dbType) {
LOGGER.info("使用的数据源为:" + dbType);
contextHolder.set(dbType);
}
/**
* 清除数据源类型
*/
public static void removeDbType() {
contextHolder.remove();
}
}
配置完成之后测试:
package com.wwy.readwrite.mapper;
import com.wwy.readwrite.domain.po.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class UserMapperTest {
@Autowired
private UserMapper userMapper;
@Test
void testInsert() {
User user = new User();
user.setId("999");
user.setName("测试插入主库");
user.setAddress("测试");
userMapper.insertUser(user);
}
@Test
void testQueryByIds() {
User user = userMapper.selectUserByName("测试插入主库");
System.out.println("user = " + user);
}
}
执行插入方法
可以看到此时是直接使用主库连接的,因为主从还设置了主从同步,所以从库也会有新增的数据
执行查询逻辑:
可以看到此时查询的数据源就变成了从库,至此已完成读写分离。
代码地址: