在“低成本和高性能的MySQL云数据库的架构探索”一文中,我们介绍了UMP的系统结构和各个组件的功能,本文里,我们会进一步来探索RabbitMQ和ZooKeeper在系统中的应用以及proxy服务器的实现,整个系统如何实现容灾、读写分离、分库分表等功能,介绍资源管理、隔离和调度等技术,以及在保障用户数据安全上的做法。
RabbitMQ
RabbitMQ是一个用Erlang开发的工业级的消息队列产品。集群中各节点间的通信(不包括SQL查询、日志等大数据流的传输,这些还是直接走TCP的)都通过RabbitMQ,作为消息通讯的中间件来使用,来保证消息发送的可靠性。
集群初始化时会在RabbitMQ中为集群里的每个节点创建一个队列,作为节点的“信箱”。节点间发送消息时不管对方在不在线,只要写消息到对方的“信箱”里即可,接下来由对方节点上运行的RabbitMQ客户端接收消息,调用相应的处理例程。消息处理完后,客户端会回复一个ACK包到RabbitMQ,从“信箱”中删除这条消息。基于RabbitMQ可以实现RPC,客户端除了回复ACK包给RabbitMQ删除Request消息外,还向发送者的“信箱”写入一条Reply消息。RabbitMQ是支持事务的,可以保证删除Request消息和写Reply消息在一个原子操作中完成。
图1 节点之间通过RabbitMQ实现RPC
如果接收者在处理消息的过程中崩溃了,那么消息还会存储在RabbitMQ中,重启后,消息会再次推送过来,由接收者继续处理。
RabbitMQ可以保证消息被发送出去,被接收者处理,但不幸的是,无法保证消息只被发送/处理一次,主要原因在于RabbitMQ不支持XA。首先,发送者将消息写到MQ和在本地写一条日志不能在同一个事务中完成,如果发送者将消息写到MQ之后,在本地写日志之前崩溃了,重启后无法确定消息是否被发送,只能尝试重发;同样,消息的接收方无法将处理消息和从MQ中删除消息放在同一个事务中完成,如果消息的接收方在处理完消息之后,从MQ中删除消息之前崩溃了,那么重启后仍然会继续收到并处理这个消息。
因此消息的接受方在处理消息时需要保证幂等性(idempotent),即同一条消息被处理多遍不会有副作用,比如controller向agent发送备份命令时可以捎带上一次备份的时间点,agent检查这个时间点一致后再执行备份操作,这样可以保证同一条备份命令被发送多次时不会创建多个备份。
利用RabbitMQ的路由功能(Exchange)还可以实现消息广播,例如系统中会创建一个叫proxy的Exchange,类型配置为’fanout’,当有新的proxy服务器注册时,节点的“信箱”就会绑定到该Exchange上。这样当controller服务器需要向所有的proxy服务器发送通知时,比如执行主备切换操作,发送到Exchange上的消息会写入所有proxy服务器的“信箱”中。
RabbitMQ还实现了一种镜像队列(mirrored queue)的算法提供HA。创建队列时可以通过传入“x-ha-policy”参数设置队列为镜像队列,镜像队列会存储在多个Rabbit MQ节点上,并配置成一主多从的结构,可以通过“x-ha-policy-params”参数来具体指定master节点和slave节点的列表。所有发送到镜像队列上的操作,比如消息的发送和删除,都会先在master节点上执行,再通过一种叫GM(Guaranteed Multicast)的原子广播(atomic broadcast)算法同步到各slave节点。GM算法通过两阶段的提交,可以保证master节点发送到所有slave节点上的消息要么全部执行成功,要么全部失败;通过环形的消息发送顺序,即master节点发送消息给一个slave节点,这个slave节点依次发送给下一个slave节点,最终消息回到master节点,保证了主从节点上的负载差别不大。
ZooKeeper
ZooKeeper在分布式集群中提供分布式锁、名字服务等,它把分布式集群比做动物园,而自己则扮演动物园管理员的角色。ZooKeeper最早是由Yahoo!开发,应用在Hadoop软件栈中发挥Google Chubby的作用,我们在项目中单独使用ZooKeeper,实现三个功能:
1.作为全局的配置服务器。配置文件原先是放在本地的,变更配置需要到所有的节点上去修改,这不仅是重复性的工作而且容易出错。放在ZooKeeper上后,所有节点都监视配置文件的变化,文件一旦被修改,所有节点都会重新加载并触发相应动作。
2.提供分布式锁。集群中部署了多个controller服务器通过热备实现HA,但这些controller服务器不能同时执行同一个操作。例如,一个MySQL实例挂掉后,如果所有的controller服务器都去跟踪处理并且发起主备切换流程,proxy服务器和agent服务器就会收到多条切换的命令,集群就乱套了。因此简单起见,我们规定同一时间,整个集群中多个controller服务器只能选举出一个leader,由这个leader负责发起各种系统任务。Leader的选举功能就是通过ZooKeeper的分布式锁功能实现的。
3.监控所有MySQL实例。我们为MySQL服务器开发了一个ZooKeeper客户端插件,启动后会连接到ZooKeeper服务器上并创建一个临时节点,如果MySQL进程死掉,经过5秒的超时时间,这个临时节点就会被删除,从而被后台运行的监控daemon检查到,如果死掉的MySQL进程是主库的话则触发主从切换流程,是从库的话则从库的读权重被设置为0。
容灾
当MySQL服务器出现故障时,系统会执行对用户透明的故障恢复过程,用户感知不到主库宕机和上线事件,proxy服务器向用户隐藏了这些事件,提供给用户的是一直可用的数据库连接。
对每个用户,系统中都会维护主库和从库两个MySQL实例,而把主从库的复制(Replication)关系配置成Dual Master结构,即两个MySQL实例都把对方设置为自己的Master,从对方读取数据更新,复制到本地,这样向其中任意一个MySQL实例写入数据,都会更新到另一个实例上。Dual Master结构存在的问题是,如果两个MySQL实例同时修改同一行数据,就会有发生冲突的可能性,最终写到两个实例中的数据版本不相同,因此为了保证数据的一致性还需要保持"single write",即只向主库中写入数据,这点由proxy服务器来保证。
当主库宕机后,MySQL插件在ZooKeeper上保持的临时节点会因为会话超时而被删除掉,controller服务器检测到这一事件后,会发起主从切换操作,在路由表中把主库标记为不可用状态,并通过RabbitMQ通知所有的proxy服务器执行切换。
当宕机的主库再次上线时,策略会稍微复杂一点。这时候从库中的数据比主库要新一些,主库需要一段时间执行更新,当主库的版本接近从库时,controller服务器会发送停写命令到从库,等待主库和从库状态完全一致后,发起主从切换操作,在路由表中恢复主库为活动状态并通知proxy服务器把写操作切回主库上,全部完成后再把从库修改为可写状态。从上述过程可以看出,把主从库的复制关系配置为Dual Master结构,简化了执行主从切换的步骤。
上述过程中,宕机的主库再次上线会使用户感受到短时间的不可写,进一步的,proxy服务器端可以通过捕捉错误,延迟重试的方法屏蔽掉这个问题。
读写分离
我们还实现了对用户透明的读写分离。当功能的开关打开时,proxy服务器会解析用户传入的SQL语句,将写操作发送到主库,读操作负载均衡的分发到主库与从库上执行。为了避免用户刚写入数据到主库,在同步到从库之前就去读从库,从而读不到或者读到旧版本的情况出现,我们在每次写操作发生后都会添加一个计时器,用户每次写操作后300毫秒内读任何数据都会强行分发到主库。通过主从多线程复制技术,300毫秒基本可以保证数据从主库同步到从库,而这个值也可以在配置中调节。
proxy服务器还需要解析MySQL连接相关的属性,例如用户通过连接参数或者“use database”语句设置的默认库,以及通过set语句设置的会话变量(session variables)等,将这些参数设置到主库和从库的连接上,并记录到一张内存表中,当与后台数据库新建连接或与断开重连时,会重新设置这些环境参数,避免让用户感知到差异。
分库分表
我们还实现了对用户透明的分库分表(shard / horizontal partition)。在创建用户账号的时候就需要指定类型为多实例,并设置实例的个数,会创建多组MySQL实例。用户建表时需要指定分库分表的规则,规则中需要指定分库分表的字段(partition key),partition key怎么映射到分表上去,分表怎么映射到多个实例上去。这些规则可以通过在建表语句前添加SQL注释的方式的传入。
首先,proxy服务器会对用户传入的SQL语句进行语法分析,抽取出重写和分发SQL语句所需要的信息,例如SQL语句操作的表名,插入语句中每条记录里partition key所对应的值(必须包含该值),查询语句中的where子句中的条件,order by、group by语句中的字段,以及limit语句中对结果条数的限制等。目前,支持的SQL限于insert,select,update,delete这四种DML语句的基本形式,表连接和嵌套select查询目前还不支持,order by和group by也限于单个字段,这些地方还要继续投入人力去实现与完善。
下一步,是将SQL语句重写为到各个分表上去执行的子语句的形式,主要是表名替换和where条件改写,接着将子语句并发的发送到对应的分表上去执行。
最后,是接收与合并各个子表上返回的执行结果。为了避免查询语句的结果集过大撑爆proxy服务器的内存,或者是在用户只需要一部分结果的情况下减小通迅开销,我们对查询结果得接收与合并过程做了一些优化。通过设置缓冲区大小,可以限制MySQL实例每次返回的结果行数,当所有分表上都返回部分结果后,就开始执行归并排序,并将排好序的结果返回给用户,当来自某个分表的结果都用完后,再去读socket填充缓冲区,获取下一批结果。整个过程比较类似于搜索引擎中将查询分发到检索服务器再进行结果合并的过程。