架构设计:系统存储(11)——MySQL主从方案业务连接透明化(上)
1、MySQL主从方案业务层的问题
在之前的文章中,我们提到MySQL一主多从集群模式下,对上层业务系统的访问带来了一些问题。本编文章中我们将深入分析这个问题,并介绍如何对这个问题进行改进。MySQL一主多从集群对上层业务系统带来的主要问题是,上层业务系统需要自行控制本次MySQL数据操作需要访问MySQL集群中的哪个节点。产生这个问题的主要原因,是因为MySQL一主多从集群本身并没有提供现成功能,将集群中的节点打包成统一服务并向外提供。
在上图所示的MySQL集群中,有一个Master节点负责所有的写事务操作,还有两个Salve节点分别负责订单模块的读操作和用户模块的读操作,而这个架构方案中由于没有中间管理层,所以到底访问哪一个MySQL服务节点的判断工作全部需要由上层业务系统自行判断。那么解决这个问题的思路也就比较清晰了:我们需要通过一些手段自行为业务层的访问增加一个中间层,以减少业务开发人员的维护工作。
2、改进方式一:使用Spring套件屏蔽细节
如果您的工程使用了Spring组件,那这个问题可以使用Spring配置问题进行改善。但这个方式只能算是改善问题,不能算作完全解决了问题。这是因为虽然通过Spring配置后,业务开发人员不需要再为“访问哪个数据库节点”操碎了心,但是Spring的配置文件依然是存在于业务系统中,当下层MySQL集群节点发生变化时,业务系统就需要改变配置信息并且重新部署;当MySQL集群现有节点发生故障时,上层业务系统也需要变更配置信息并重新部署。这种配置的方法并不能实现数据访问逻辑的完全脱耦。
下面我们给出一个示例,在这个示例中我们使用spring 3.X 版本 + hibernate 4.X 版本 + c3p0 + MySQL JDBC 实现在业务系统中访问数据库节点的规则配置。
如上图所示,我们在业务系统中建立了两个数据源:writeSessionFactory、readSessionFactory,分别负责业务数据的写操作和读操作。当下面的MySQL集群增加新的读节点或者集群中现有节点发生变化时,spring的配置文件也要做相应的配置变化:
- 写操作涉及的数据源和AOP点配置
<!-- 工程中和读写数据源分离无关的Spring配置信息,在这里就不进行赘述了 -->
......
<!-- 这个数据源连接到maseter节点, 作为写操作的数据源-->
<bean id="writedataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close">
<property name="driverClass"><value>${writejdbc.driver}</value></property>
<property name="jdbcUrl"><value>${writejdbc.url}</value></property>
<property name="user"><value>${writejdbc.username}</value></property>
<property name="password"><value>${writejdbc.password}</value></property>
<property name="minPoolSize"><value>${writec3p0.minPoolSize}</value></property>
<property name="maxPoolSize"><value>${writec3p0.maxPoolSize}</value></property>
<property name="initialPoolSize"><value>${writec3p0.initialPoolSize}</value></property>
<property name="maxIdleTime"><value>${writec3p0.maxIdleTime}</value></property>
<property name="acquireIncrement"><value>${writec3p0.acquireIncrement}</value></property>
</bean>
<!-- 数据层会话工厂和数据源的映射关系 -->
<bean id="writeSessionFactory" class="org.springframework.orm.hibernate4.LocalSessionFactoryBean">
<property name="dataSource" ref="writedataSource" />
<property name="namingStrategy">
<bean class="org.hibernate.cfg.ImprovedNamingStrategy" />
</property>
<property name="hibernateProperties">
<props>
<prop key="hibernate.dialect">${writehibernate.dialect}</prop>
<prop key="hibernate.show_sql">${writehibernate.show_sql}</prop>
<prop key="hibernate.format_sql">${writehibernate.format_sql}</prop>
<prop key="hibernate.hbm2ddl.auto">${writehibernate.hbm2ddl.auto}</prop>
<prop key="hibernate.current_session_context_class">org.springframework.orm.hibernate4.SpringSessionContext</prop>
</props>
</property>
</bean>
<bean id="writetransactionManager" class="org.springframework.orm.hibernate4.HibernateTransactionManager">
<property name="sessionFactory" ref="writeSessionFactory" />
</bean>
<!--
AOP设置,在templateSSHProject.dao.writeop包或者子包中
涉及以下名字的方法,都要开启事务托管。
并且在抛出任何异常的情况下,spring都要回滚事务
-->
<aop:config>
<aop:pointcut id="writedao" expression="execution(* templateSSHProject.dao.writeop..*.* (..))" />
<aop:advisor advice-ref="writetxAdvice" pointcut-ref="writedao" />
</aop:config>
<tx:advice id="writetxAdvice" transaction-manager="writetransactionManager">
<tx:attributes>
<tx:method name="save*" rollback-for="java.lang.Exception" propagation="REQUIRED" />
<tx:method name="update*" rollback-for="java.lang.Exception" propagation="REQUIRED" />
<tx:method name="delete*" rollback-for="java.lang.Exception" propagation="REQUIRED" />
<tx:method name="modify*" rollback-for="java.lang.Exception" propagation="REQUIRED" />
<tx:method name="create*" rollback-for="java.lang.Exception" propagation="REQUIRED" />
<tx:method name="remove*" rollback-for="java.lang.Exception" propagation="REQUIRED" />
<tx:method name="*" read-only="true" />
</tx:attributes>
</tx:advice>
......
- 读操作涉及的数据源和AOP点配置,和写操作数据源配置类似,各位读者只需要注意不同点
......
<!-- 这个数据源连接到salve节点, 作为读操作的数据源 -->
<bean id="readdataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource" destroy-method="close">
<property name="driverClass"><value>${readjdbc.driver}</value></property>
<property name="jdbcUrl"><value>${readjdbc.url}</value></property>
<property name="user"><value>${readjdbc.username}</value></property>
<property name="password"><value>${readjdbc.password}</value></property>
<property name="minPoolSize"><value>${readc3p0.minPoolSize}</value></property>
<property name="maxPoolSize"><value>${readc3p0.maxPoolSize}</value></property>
<property name="initialPoolSize"><value>${readc3p0.initialPoolSize}</value></property>
<property name="maxIdleTime"><value>${readc3p0.maxIdleTime}</value></property>
<property name="acquireIncrement"><value>${readc3p0.acquireIncrement}</value></property>
</bean>
<!-- 数据层会话工厂和数据源的映射关系,基本上和write的设置一致 -->
<bean id="readSessionFactory" class="org.springframework.orm.hibernate4.LocalSessionFactoryBean">
<property name="dataSource" ref="readdataSource" />
<property name="namingStrategy">
<bean class="org.hibernate.cfg.ImprovedNamingStrategy" />
</property>
<property name="hibernateProperties">
<props>
<prop key="hibernate.dialect">${readhibernate.dialect}</prop>
<prop key="hibernate.show_sql">${readhibernate.show_sql}</prop>
<prop key="hibernate.format_sql">${readhibernate.format_sql}</prop>
<prop key="hibernate.hbm2ddl.auto">${readhibernate.hbm2ddl.auto}</prop>
<prop key="hibernate.current_session_context_class">org.springframework.orm.hibernate4.SpringSessionContext</prop>
</props>
</property>
</bean>
<bean id="readtransactionManager" class="org.springframework.orm.hibernate4.HibernateTransactionManager">
<property name="sessionFactory" ref="readSessionFactory" />
</bean>
<!--
AOP设置,和写操作事务不同的点也在这里
这里不需要设置任何方法方法开启事务托管
甚至不需要配置这段AOP切面
-->
<aop:config>
<aop:pointcut id="readdao" expression="execution(* templateSSHProject.dao.readop..*.* (..))" />
<aop:advisor advice-ref="readtxAdvice" pointcut-ref="readdao" />
</aop:config>
<tx:advice id="readtxAdvice" transaction-manager="readtransactionManager">
<tx:attributes>
<tx:method name="*" read-only="true" />
</tx:attributes>
</tx:advice>
......
要看懂以上的Spring配置信息,首先请确定您接触过Spring组件。以上配置主要使用XML文件定义的方式指定需要使用“写数据源”的方法名,使用AOP切面为这些指定的方法名开启事务托管。在实际使用过程中,各位读者也可以使用Java代码注解的方式在指定的包范围内标记需要开启事务托管的方法。只需要在配置文件中增加一段说明信息“<tx:annotation-driven transaction-manager=”writetransactionManager” proxy-target-class=”false”/>”
这样的读写数据源分离的方式,只会影响业务开发人员在数据层的操作,对显示层和业务逻辑层没有任何影响。这里再给出工程中数据层部分的包结构,这个结构和AOP配置中的切面扫描点“expression”有关。从以下给出的结构图可以看出,在数据层进行的数据读操作和数据写操作是分离的,这样可以避免业务开发人员在编写代码时发生混淆(例如在负责读操作的工程包中进行写操作)
当然针对各位读者自身的业务形态,您也可以将两个数据源合并在一起混合使用,不过这可能会加重业务开发人员的维护工作。将读写操作数据源合并的方式也很简单,基本上不需要更改任何配置,只需要将两个sessionFactory注入到同一个AbstractRelationalDBDAO——为DAO层设置的公共父类。
...... public abstract class AbstractReadRelationalDBDAO ...... { // AbstractRelationalDBDAO类的其它部分都省略了 ...... @Autowired @Qualifier("readSessionFactory") private SessionFactory readSessionFactory; @Autowired @Qualifier("writeSessionFactory") private SessionFactory sessionFactory; ...... } ......
本示例实现的Spring配置中只有一个负责写操作数据源和一个负责读操作数据源。那么从理论上讲这种Spring配置方式只能适应一主一从的MySQL集群。当MySQL集群中的节点发生,例如增加了一个Salve从节点,业务工程就会增加一个从节点的数据源配置信息,并且在工程的数据层(DAO层)增加新的代码包。这显然是很有问题的,甚至可以说整个业务工程基本无法维护,这是因为再稳定的MySQL集群也只能保证5个9的系统可用性(即99.999%),另外现在主流的集群思想中本来就是假设集群中的节点随时可能出现问题,而业务系统显然不可能在无法预知的情况下随时改变配置信息并重新部署。
3、改进方式二:透明化中间层
那么有没有什么办法能够解决以上的问题呢?既突破以上Spring配置方式只适应一主一从MySQL集群的瓶颈,又不增加业务开发人员的维护难度,还能适应下层数据集群随时发生的节点故障。当然是有办法的,使用我们已经在负载均衡专题介绍过的LVS,我们可以为MySQL集群中的多个读节点构造一个透明层,使得它们可以作为一个整体,并使用一个统一的访问地址向上层业务系统提供数据读取服务。
如果您还不清楚LVS配置方式,可以参见我另外一专题中,专门介绍LVS的几篇文章:《架构设计:负载均衡层设计方案(4)——LVS原理》、《架构设计:负载均衡层设计方案(5)——LVS单节点安装》。需要注意的是,这里选择的负载方案应该工作在网络协议的下层,例如OSI七层模型的链路层或者传输层。这是因为上层系统连接MySQL服务节点主要基于TCP/IP协议而不是基于HTTP协议,例如MySQL的多数客户端软件(MySQL-Front、Navicat等)都使用MySQL原生连接协议,这个协议就是基于TCP/IP协议的,再例如绝大多数Java应用程序连接和调用MySQL操作所基于的JDBC API,也是基于TCP/IP协议。所以这里使用的负载均衡方案不能使用Nginx这样只支持Http协议的组件,而LVS组件可以很好的适应技术需求。
以上的改进方案中,我们只对MySQL集群中的读操作节点进行了改进,但是整个集群还是没有足够的稳定保证。这是因为MySQL集群中写操作节点目前还只有单个节点承载工作,新加入的LVS负载节点也只有单个节点承载工作。如果在生产环境下,以上这些节点出现故障无法工作将导致整个MySQL集群崩溃。进一步的改进方式,就是为集群中的写操作节点和LVS负载节点增加热备方案,如下图所示:
上图中我们使用Heartbeat + DRBD第三方组件的方式,为MySQL Master节点复制了一个可以即时切换的处于“准备”状态的备用节点。Heartbeat组件的作用和之前我们介绍过的keepalived组件类似,它用于监控两个(或多个)节点的工作状态,并在满足宕机的判断条件时完成浮动IP的切换和备用服务的启动工作。DRBD组件是一个工作在Linunx系统下的,可以完成实时文件差异化同步的磁盘块映射软件,类似的软件还有RSync。
有了Heartbeat + DRBD第三方组件的支持,就可以保证当MySQL集群中的写操作节点不能提供服务时,另一个等待工作的备份写操作节点能够及时的接过工作任务,并且这个备份节点上的数据库表数据和之前崩溃的写操作节点上的数据库表数据是一致的。这个方案还可以更换成Keepalived + RSync的第三方组件方案。
LVS节点的高可用方案,在之前的文章中已经介绍过了。不清楚的读者可以参考我另一篇文章《架构设计:负载均衡层设计方案(7)——LVS + Keepalived + Nginx安装及配置》,只不过文章中的需要被保证高可用性的组件由Nginx替换成了MySQL服务。不过这样的读节点组织方式,也有一些问题:虽然这些读节点通过负载均衡的方式可以分担各自的工作压力,但是这些读操作节点不能按照上层业务的不同,分模块提供独立的、有个性的查询操作服务。