数据库读写分离是很多互联网系统中用于提升访问性能的一种手段,其主要实现方式,便是将互联网系统对数据库的读操作和写操作分离在不同的数据库中,从而减少了数据库系统对锁资源的竞争,提升了系统数据库的性能。
为什么要读写分离?
如果一个系统的数据量并不多,对系统性能要求也不是很高,那么单体数据库结构完全可以胜任系统对数据的操作需求。
但是如果系统的数据量很多,且对数据库的并发操作很多,那么单体数据库结构就可能会成为系统性能降低的关键点。
为什么?
在考虑为什么的时候,我们首先要明白数据库并发情况下的锁操作。
当数据库执行查询操作的时候,事务会对数据添加共享锁(读锁),一个数据在添加了共享锁(读锁)的情况下,可以由另外的事务继续对该数据添加共享锁(读锁),但是却不允许对该数据库添加排他锁(写锁)。
当数据库执行更新操作的时候,事务会对数据添加排他锁(写锁),同时不允许其他事务对该数据库添加任意锁(读锁和写锁)。
这就导致单体数据库结构在同时有事务执行查询操作和更新操作的时候,会不断对数据添加共享锁或者是排他锁。
其中,数据库执行更新操作对资源的消耗远大于查询操作,更新操作耗时更长,且排他锁(写锁)导致查询操作无法获取锁而阻塞,从而导致了性能的降低。
怎么解决性能问题?
我们可以使用读写分离的方式来解决单体数据库的性能问题。
其主要思路为:
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
| <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.16.22</version> <scope>provided</scope> </dependency>
<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>
<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
| <bean id="writeJdbc" class="org.springframework.jdbc.core.JdbcTemplate"> <property name="dataSource" ref="dataSourceMaster"/> </bean>
<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
| <context:annotation-config /> <aop:aspectj-autoproxy/>
<bean id="dynamicDataSource" class="com.hrp.DynamicDataSource"/>
<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); this.setTargetDataSources(dataSources); super.afterPropertiesSet(); }
@Override protected Object determineCurrentLookupKey() { LinkedList<String> dataSourceKeys = dataSourceHolder.get(); return dataSourceKeys.pollFirst(); }
public static void useMaster() { LinkedList<String> dataSourceKeys = dataSourceHolder.get(); dataSourceKeys.offerFirst(DEFAULT); }
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 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
|
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
实例,并将端口设置为 3307
和 3308
。
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
数据库,查询数据库具体信息。
使用 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-3
秒,使用命令查看从库状态。
查询的数据中,两个字段对应的值如下,则说明启动成功:
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`数据库的主从配置也只需要修改一些配置文件即可简单实现。
本文只设置了一个主数据库和一个从数据库,真实环境中,可能会设置一主多从的情况,同学们可以根据本文的思路,自己尝试一下一主多从的读写分离和数据库同步。