每一秒钟的时间都值得铭记

0%

基于Spring JDBCTemplate的数据库读写分离和主从复制

数据库读写分离是很多互联网系统中用于提升访问性能的一种手段,其主要实现方式,便是将互联网系统对数据库的读操作和写操作分离在不同的数据库中,从而减少了数据库系统对锁资源的竞争,提升了系统数据库的性能。

为什么要读写分离?

如果一个系统的数据量并不多,对系统性能要求也不是很高,那么单体数据库结构完全可以胜任系统对数据的操作需求。

但是如果系统的数据量很多,且对数据库的并发操作很多,那么单体数据库结构就可能会成为系统性能降低的关键点。

为什么?

在考虑为什么的时候,我们首先要明白数据库并发情况下的锁操作。

当数据库执行查询操作的时候,事务会对数据添加共享锁(读锁),一个数据在添加了共享锁(读锁)的情况下,可以由另外的事务继续对该数据添加共享锁(读锁),但是却不允许对该数据库添加排他锁(写锁)。

当数据库执行更新操作的时候,事务会对数据添加排他锁(写锁),同时不允许其他事务对该数据库添加任意锁(读锁和写锁)。

这就导致单体数据库结构在同时有事务执行查询操作和更新操作的时候,会不断对数据添加共享锁或者是排他锁。

其中,数据库执行更新操作对资源的消耗远大于查询操作,更新操作耗时更长,且排他锁(写锁)导致查询操作无法获取锁而阻塞,从而导致了性能的降低。

怎么解决性能问题?

我们可以使用读写分离的方式来解决单体数据库的性能问题。

其主要思路为:

1、将系统的读操作和写操作分别使用两个数据库来实现,其中主数据库用于执行写操作,从数据库用于读操作。

2、使用脚本或者中间件从主数据库中同步数据到从数据库,从而保证了两个数据库的数据最终一致性。

读写分离的好处显而易见,即便系统对主数据库进行写操作,也丝毫不影响系统对从数据库执行读操作,两个数据库之间并没有对数据的锁竞争,从而避免了大量的资源消耗。

但是读写分离的缺点也很明显,因为从主数据库上同步数据到从数据库需要一定的时间,那么在对主数据库执行完成写操作后,可能无法立即从从数据库中读取到即时的信息,也就是数据的同步存在一定的时间延迟(这个时间延迟在系统上可能是以秒为单位),这就要求系统对数据的及时性要求不是那么高。

读写分离的具体实现

读写分离的实现机制非常简单,因为读写分离需要操作两个数据库,所以在系统中需要定义两个数据库源,写操作使用主数据库源,读操作使用从数据库源即可。

我们可以使用 Spring JdbcTemplate来实现一个简单的读写分离系统。

相关依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<!--Lombox-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.22</version>
<scope>provided</scope>
</dependency>
<!-- Spring -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.3.23</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>5.3.23</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<version>5.3.23</version>
<scope>test</scope>
</dependency>
<!-- MySql -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.32</version>
</dependency>
<!-- 连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.0.9</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>

Spring 配置

简单的Spring项目使用 application.xml 作为配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!--写操作JdbcTemplate对象,注入主数据库源对象-->
<bean id="writeJdbc" class="org.springframework.jdbc.core.JdbcTemplate">
<property name="dataSource" ref="dataSourceMaster"/>
</bean>

<!--读操作JdbcTemplate对象,注入从数据库源对象-->
<bean id="readJdbc" class="org.springframework.jdbc.core.JdbcTemplate">
<property name="dataSource" ref="dataSourceSlave"/>
</bean>

<!--定义主数据库源配置-->
<bean id="dataSourceMaster" class="com.alibaba.druid.pool.DruidDataSource">
<property name="driverClassName" value="com.mysql.jdbc.Driver"/>
<property name="url" value="jdbc:mysql:///db_master"/>
<property name="username" value="root"/>
<property name="password" value="admin"/>
</bean>

<!--定义从数据库源配置-->
<bean id="dataSourceSlave" class="com.alibaba.druid.pool.DruidDataSource">
<property name="driverClassName" value="com.mysql.jdbc.Driver"/>
<property name="url" value="jdbc:mysql:///db_slaves"/>
<property name="username" value="root"/>
<property name="password" value="admin"/>
</bean>

<!--包扫描-->
<context:component-scan base-package="com.hrp"/>

DAO 层

由于篇幅有限,在这里省略了接口 UserDao 接口和 User 实体类的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Repository
public class UserDaoImpl implements UserDao {

@Autowired()
private JdbcTemplate writeJdbc;

@Autowired()
private JdbcTemplate readJdbc;

// 从主数据库写入一个对象数据
@Override
public void saveUser(User user) {
writeJdbc.update("insert into user(username,password,email) values(?,?,?)", user.getUsername(),
user.getPassword(), user.getEmail());
}

// 从主数据库读取所有数据
@Override
public List<User> selectList() {
return writeJdbc.query("select * from user", new BeanPropertyRowMapper<>(User.class));
}

// 从从数据库读取所有数据
@Override
public List<User> findAll() {
return readJdbc.query("select * from user", new BeanPropertyRowMapper<>(User.class));
}
}

我们可以简单使用一个测试方式来测试主从数据库是否使用成功。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:application.xml")
public class UserDaoTest {

@Autowired
private UserDao userDao;

@Test
public void demo01(){
// 给主数据库写入一条数据
userDao.saveUser(new User("hrp", "111111", "he@qq.com"));
// 从从数据库读取数据
List<User> list1 = userDao.findAll();
System.out.println("从数据库中的数据:" + list1.size());
for (User user : list1) {
System.out.println(user);
}
// 从主数据库读取数据
List<User> list2 = userDao.selectList();
System.out.println("主数据库中的数据:" + list2.size());
for (User user : list2) {
System.out.println(user);
}
}
}

我们执行之后,可以看到写入到主数据的数据写入成功,但是从从数据库读取的数据依然为空(这是因为从数据库没有从主数据库同步数据的缘故),而从主数据库则能够读取到先前写入的数据。

由此可见,系统使用两个数据源分别进行读写操作是成功了的。

面向切面编程

DAO注入两个不同数据源的 JdbcTemplate 对象的实现方式非常简洁明了,但是缺点也很明显,必须由开发者自己指定数据源,对 业务代码逻辑有入侵。

我们可以使用 Spring AOP面向切面编程,使系统底层在执行 SQL 的时候自动切换对应的数据库源,从而将数据库源的选择逻辑从业务代码中移除出去。

添加AOP依赖

SpringAOP需要添加相关依赖,在上述的 pom.xml 文件中添加新的 Maven 依赖即可。

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
<version>1.5.4</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.7</version>
</dependency>

Spring 配置

application.xml 文件也需要做一些简单的修改,主要是修改注入 JdbcTemplate 对象的数据源对象,以及开启 aop相关配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!-- 开启 AOP 注解支持 -->
<context:annotation-config />
<aop:aspectj-autoproxy/>

<!--动态数据源、主从库选择-->
<bean id="dynamicDataSource" class="com.hrp.DynamicDataSource"/>

<!-- 配置Jdbc模板 -->
<bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate">
<property name="dataSource" ref="dynamicDataSource"/>
</bean>

<bean id="dataSourceMaster" class="com.alibaba.druid.pool.DruidDataSource">
<property name="driverClassName" value="com.mysql.jdbc.Driver"/>
<property name="url" value="jdbc:mysql:///db_master"/>
<property name="username" value="root"/>
<property name="password" value="admin"/>
</bean>

<bean id="dataSourceSlave" class="com.alibaba.druid.pool.DruidDataSource">
<property name="driverClassName" value="com.mysql.jdbc.Driver"/>
<property name="url" value="jdbc:mysql:///db_slaves"/>
<property name="username" value="root"/>
<property name="password" value="admin"/>
</bean>

<context:component-scan base-package="com.hrp"/>

定义数据源注解

1
2
3
4
5
6
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataSource {
boolean master() default true;
}

数据源路由选择器

Spring 中提供了 AbstractRoutingDataSource 抽象类作为数据源路由的选择器,开发者可以重写其中的方法,实现自定义路由选择策略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@Component
public class DynamicDataSource extends AbstractRoutingDataSource {

/**
* 主库
*/
@Autowired
private DataSource dataSourceMaster;
/**
* 从库
*/
@Autowired
private DataSource dataSourceSlave;

private static final String DEFAULT = "default";
private static final String SLAVE = "slave";

private static final ThreadLocal<LinkedList<String>> dataSourceHolder = ThreadLocal.withInitial(LinkedList::new);

@Override
public void afterPropertiesSet() {
Map<Object, Object> dataSources = new HashMap<>();
dataSources.put(DEFAULT, dataSourceMaster);
dataSources.put(SLAVE, dataSourceSlave);
// 设置一个默认的数据源,保证路由选择器在无法选择的情况下可以使用默认数据源执行操作
this.setDefaultTargetDataSource(dataSourceMaster);
// 设置一个 Map 结构的数据源集,后续使用重写 determineCurrentLookupKey 方法返回对应的 Key值,即可实现路由选择
this.setTargetDataSources(dataSources);
super.afterPropertiesSet();
}

@Override
protected Object determineCurrentLookupKey() {
// 选择需要使用的路由 Key 值
LinkedList<String> dataSourceKeys = dataSourceHolder.get();
return dataSourceKeys.pollFirst();
}


/**
* 选择使用主库,并把选择放到当前ThreadLocal的栈顶
*/
public static void useMaster() {
LinkedList<String> dataSourceKeys = dataSourceHolder.get();
dataSourceKeys.offerFirst(DEFAULT);
}

/**
* 选择使用从库,并把选择放到当前ThreadLocal的栈顶
*/
public static void useSlave() {
LinkedList<String> dataSourceKeys = dataSourceHolder.get();
dataSourceKeys.offerFirst(SLAVE);
}

}

AOP切面

AOP 切面主要做的事情便是拦截所有有 @DataSource 注解的方法,并根据注解值选择相关的路由。

没有 @DataSource 注解的方法则会使用默认的数据源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Aspect
@Component
public class DataSourceAspect {

@Pointcut(value = "@annotation(com.hrp.DataSource)")
private void dataSource() {
}

@Around(value = "dataSource() ", argNames = "point")
public Object doAround(ProceedingJoinPoint point) throws Throwable {
MethodSignature methodSignature = (MethodSignature) point.getSignature();
Method method = methodSignature.getMethod();
// 这个方法才是目标对象上有注解的方法
Method realMethod = point.getTarget().getClass().getDeclaredMethod(methodSignature.getName(), method.getParameterTypes());
DataSource annotation = realMethod.getAnnotation(DataSource.class);
if (annotation != null && !annotation.master()) {
// 只有当注解指明使用从数据库的时候才使用
DynamicDataSource.useSlave();
} else {
// 否则优先使用主数据库进行读写操作
DynamicDataSource.useMaster();
}
return point.proceed();
}

}

最终我们使用同样的测试方法,可以得到同样的测试结果,说明我们使用注解来选择路由的方式成功了,从而将读写分离的代码从业务代码中隔离了开来,保证了代码的健壮和简洁。

后续我们只需要保证从数据库能够实时同步主数据库的数据,即可实现主从数据库读写分离的完整版本。

MySQL 主从复制

我们可以使用 Docker 来快速部署 MySQL主从服务器,并实现主从复制。

配置文件

在本地目录创建并编辑docker.cnf 文件并使用 Docker将配置文件复制到 MySQL 实例中。

需要注意,有些Docker镜像的配置文件是 my.cnf,开发者需要根据具体情况自己判断 MySQL的配置文件

  • 1、设置主数据库配置
1
2
3
cd /tmp/mysql
mkdir master
vim /tmp/mysql/master/docker.cnf

/tmp/mysql/master/docker.cnf文件内容

1
2
3
[mysqld]
server-id=1
log-bin=mysql-bin
  • 2、设置从数据库配置
1
2
3
cd /tmp/mysql
mkdir slave
vim /tmp/mysql/slave/docker.cnf

/tmp/mysql/slave/docker.cnf文件内容

1
2
3
4
[mysqld]
server-id=2
log-bin=mysql-slave-bin
relay_log=edu-mysql-relay-bin

启动数据库

我们首先使用 Docker 启动两个 MySQL 实例,并将端口设置为 33073308

1
2
3
docker run -p 3307:3306 --name master -v /tmp/mysql/master/docker.cnf:/etc/mysql/conf.d/docker.cnf -e TZ=Asia/Shanghai -e MYSQL_ROOT_PASSWORD=admin -d mysql:5.7

docker run -p 3308:3306 --name slave -v /tmp/mysql/slave/docker.cnf:/etc/mysql/conf.d/docker.cnf -e TZ=Asia/Shanghai -e MYSQL_ROOT_PASSWORD=admin -d mysql:5.7

初始化数据库

在两个数据库分别执行创建数据库和建表的 SQL 语句。

1
2
3
4
5
6
7
8
9
CREATE DATABASE `db_ms`;
USE `db_ms`;
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '唯一ID',
`username` varchar(255) NOT NULL COMMENT '账号',
`password` varchar(255) NOT NULL COMMENT '账号密码',
`email` varchar(255) DEFAULT NULL COMMENT '电子邮件',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

主数据库(master)

登录 master 数据库,并使用授权语句创建一个新的账号,并授予权限。

1
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'slave'@'%' IDENTIFIED BY '123456';

登录 master 数据库,查询数据库具体信息。

1
show master status;

使用 Docker 命令查看 master 数据库镜像的 IP

1
docker inspect --format={{.NetworkSettings.IPAddress}} master

从数据库(slave)

登录 slave数据库,设置主库信息,这里设置的相关信息,都是上面几个步骤设置或查询到的数据。

1
2
3
4
5
6
7
CHANGE MASTER TO 
MASTER_HOST = '172.17.0.3',
MASTER_PORT = 3306,
MASTER_USER = 'slave',
MASTER_PASSWORD = '123456',
MASTER_LOG_FILE = 'mysql-bin.000003',
MASTER_LOG_POS = 603;

重点:MASTER_PORT = 3306,这个配置是并不是使用 Docker启动 MySQL实例的外部端口 3307,而是 Docker实例的内部端口,所以需要配置为 3306,同理,MASTER_HOST 配置是也是 Docker 对应的 IP 地址。

登录 slave数据库,启动从库。

1
START SLAVE;

启动后等1-3秒,使用命令查看从库状态。

1
show slave status;

查询的数据中,两个字段对应的值如下,则说明启动成功:

Slave_IO_Running Slave_SQL_Running
Yes Yes

测试主从复制

测试数据,在 master 数据库执行 SQL 语句,立刻就可以在 slave 数据库中查询到对应的数据信息。

1
INSERT INTO `user`(username,password,email) VALUES('herenpeng','111111','he@qq.com');

总结

Spring JdbcTemplate 实现读写分离并不困难,MySQL`数据库的主从配置也只需要修改一些配置文件即可简单实现。

本文只设置了一个主数据库和一个从数据库,真实环境中,可能会设置一主多从的情况,同学们可以根据本文的思路,自己尝试一下一主多从的读写分离和数据库同步。

坚持原创技术分享,您的支持将鼓励我继续创作!
-------------这是我的底线^_^-------------