UMP(Unified MySQL Platform)系统是淘宝核心系统数据库团队开发的低成本和高性能的MySQL云数据方案,关键模块采用Erlang语言实现。系统中包含了controller服务器、proxy服务器、agent服务器、API/Web服务器、日志分析服务器、信息统计服务器等组件,并且依赖于Mnesia、LVS、RabbitMQ、ZooKeeper等开源组件。
在“低成本和高性能的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填充缓冲区,获取下一批结果。整个过程比较类似于搜索引擎中将查询分发到检索服务器再进行结果合并的过程。
图2 Proxy服务器的实现层次
为了提升性能,SQL的解析、重写以及合并多个MySQL服务器返回的结果集均是用C++实现,通过NIF接口方式被Erlang语言编写的状态机调用。
资源管理
我们参考了VMware DRS等云计算系统中资源管理的方法,实现了一套资源池机制来管理数据库服务器上的CPU、内存、磁盘等计算资源。管理员先按照整个集群所有服务器的机型、所在机房等因素划分多个资源池,服务器上的agent进程启动后会注册到controller节点上,管理员再通过web管理界面将每台服务器加入到合适的资源池中。
分配实例的单位是资源池,管理员可以根据应用部署在哪些机房、需要的计算资源等因素分别指定主库、从库所在的资源池,实例管理服务再从资源池中选择负载较轻的服务器来创建实例。后期我们还将开发资源池内的调度管理,如果资源池中一台服务器的负载长期明显高于其他服务器,调度进程会将其中的MySQL实例迁出到低负载的机器上。
除了将服务器划分为资源池,在每台服务器内部,我们也结合Cgroup将它的资源进一步的细化以方便管理和隔离。例如,一台16核,48G的服务器,我们会将它的资源划分到16个进程组中,相当于每个进程组分配到一个CPU核和2G的内存,这样一个进程组中可以放入8个内存规格为256M的MySQL进程,而一个需要4G内存的MySQL进程可以通过合并两个进程组来实现。Cgroup可以限制每个进程组使用资源的上限,也可以保证进程组之间相互隔离。还有一点是,这种资源管理方式是可能造成碎片的,例如向16个进程组每个组里都分配一个内存256M的MySQL进程,这时总共才占用4G内存,服务器上还有44G空闲内存,但此时已经无法分配出一个内存4G的MySQL进程了,这个问题可以通过Buddy System来解决。
资源调度
目前系统中支持三种规格的用户:
第一种是数据量和流量比较小的用户,例如博客站点、小应用以及开发中的应用。多个小用户可以共享同一个MySQL实例,每个用户一个库,单机可以支持几百到上千个小用户,但文件数量过多会对系统性能有不利的影响。
第二种是中等规模的用户,每个用户独占一个MySQL实例,每个实例占用的内存从256M到32G不等。用户的内存空间和磁盘空间也是可以调节的,当前机器满足不了用户对资源的要求时,可以迁移到资源有空闲或者更高配的服务器上。
图3通过实例迁移实现资源调度
第三种是需要分库分表的用户,用户可以占有多个独立的MySQL实例。这些实例可以同其他实例共存在同一台物理机上,也可以因为业务数据量规模的增长每个实例独占一台物理机。
用户的规格可以在创建的时候指定,也可以通过迁移工具升级或降级。我们使用了集团中间件团队开发的愚公系统,这是一个全量复制结合bin log分析进行增量复制的工具,可以实现在不停机的情况下动态扩容、缩容和迁移。目前,用户规格的升级和降级需要在控制台上触发,将来,我们希望可以基于用户过去一段时间数据库使用情况的统计信息进行自动化的调度。
资源隔离
当多个用户共享同一个MySQL实例,或者是多个MySQL实例共享同一台物理机时,资源隔离显得尤为重要。例如某用户执行了一条IO操作非常多的SQL语句,例如没有为字段设置索引造成在一张大表上进行全表扫描,会严重影响其他用户的体验。目前我们采用在数据库服务器上用Cgroup限制MySQL进程资源,以及在proxy服务器端限制QPS相结合的方法进行资源隔离。
第一种方法是,是通过建立进程组,利用Cgroup的cpuset、memcg以及blkio子模块分别限制用户的MySQL进程最大可以使用的CPU使用率、内存和IOPS。这种方法适用于多个MySQL实例共享同一台物理机的情况。
第二种方法,是通过在数据库端部署的agent服务器分析MySQL进程的slow query log,采集和汇总用户最近执行的SQL语句的开销,并定期将信息反馈到controller服务器,controller服务器将数据同用户的配额进行比较,如果明显超出,会通知proxy端通过增加延迟的方法去限制用户的QPS,达到了减小该用户消耗的系统资源的目的。这种方法比较适用于多个用户共享同一个MySQL实例的情况,因为无法使用Cgroup进行进程间的限制。
数据安全
用户和企业的安全部门都会比较关心数据的安全问题,我们实现了多种方法保证用户数据的安全性:
后期,我们还会保留MySQL实例的bin log和slow query log,这样用户在误操作删除数据又没有备份的情况可以通过bin log工具恢复数据,后台会定期运行slow query log分析工具,对用户SQL执行过程中索引使用情况、IO操作数量等进行分析,指导用户改进SQL语句。
结束语
在工程实践中,我们坚持着不去重复发明轮子的原则,充分利用开源的、成熟的技术和工具。例如我们在Erlang的网络编程框架上实现高性能的proxy服务器,基于RabbitMQ实现消息中间件,使用ZooKeeper管理服务器心跳,也充分利用了集团内部成熟的数据备份、迁移、扩容/缩容方案及其他bin log工具。这一原则使得我们可以将有限的资源关注在降低成本和改善用户体验上。