简介

Gaea是小米中国区电商研发部研发的基于mysql协议的数据库中间件,目前在小米商城大陆和海外得到广泛使用,包括订单、社区、活动等多个业务。Gaea支持分库分表、sql路由、读写分离等基本特性,更多详细功能可以参照下面的功能列表。其中分库分表方案兼容了mycat和kingshard两个项目的路由方式。Gaea在设计、实现阶段参照了mycat、kingshard和vitess,并使用tidb parser作为内置的SQL parser,在此表达诚挚感谢。为了方便使用和学习Gaea,我们也提供了详细的使用和设计文档,也欢迎大家多多参与。

功能列表

基础功能

小米嵌入式开发平台(小米数据库中间件Gaea)(1)

分库、分表功能

架构图

小米嵌入式开发平台(小米数据库中间件Gaea)(2)

多租户的设计与实现

背景

gaea多租户是为了实现一套gaea集群,可以接入多个业务系统的不同数据库,方便部署、运维。gaea多租户为软多租户,一个租户称为一个namespace,多个namespace之间存在于一套gaea proxy集群内,所以是一种软隔离。我们也可以为一些重要等级业务系统单独部署一套gaea集群,甚至一套业务系统对应一套gaea集群实现物理隔离。

接入方式

mysql的授权方式为用户名 密码 ip 数据库,接入gaea的情况下,授权方式为用户名 密码确定唯一一个namespace,ip则在白名单IP/IP段起作用,如果未配置白名单IP,则默认对所有IP生效。所以,不同的业务系统,用户名可以有相同的,但是用户名 密码要保证是唯一的,密码我们内部是根据一定的规则随机生成的,并且会校验是否重复。

实现原理

主要结构

在授权阶段还未能确定对应的namespace,所以user和Namespace的配置是分别加载的,授权的实现主要依赖于UserManager结构体,其定义如下

type UserManager struct { users map[string][]string // key: user name, value: user password, same user may have different password, so array of passwords is needed userNamespaces map[string]string // key: username Password, value: name of namespace }

配置加载过程

在系统初始化阶段,会依次加载对应user、namespace配置到一个全局Manager内,其中user部分用以授权检查,整个配置通过滚动数组的方式实现了无锁热加载,具体实现可以参照gaea配置热加载实现原理一章。

校验过程

其中users同一个用户名对应一个string数组,用以处理同一用户名不同密码的情形,而userNamespaces则用以通过用户名 密码快速获取对应的namespace名称。在验证阶段,首先通过CheckUser检查用户名是否存在,不存在则直接授权失败。然后,通过CheckPassword,依次对比确定是否可以找到对应密码,如果找不到,则最终授权失败;如果找到,则授权检查通过并记录对应的会话信息。

结语

gaea的多租户确实为部署、运维带来了不少方便,后续也会考虑支持kubernetes的部署、调度多租户等,但是当下的多租户结构不会发生太大变化。

gaea配置热加载设计与实现

背景

我们在开始设计gaea的时候列了几个关键词: 配置热加载、多租户、读写分离、分库分表、路由sql,配置热加载就是其中一个非常重要的目标。以往基于配置文件的方式,存在以下几个问题: DBA 使用起来比较容易出错、多个代理的配置文件维护比较麻烦、配置更新到生效的过程比较漫长、通过接口更新配置也存在文件和内存中配置可能不一致的情况。而gaea配置热加载就是希望能解决以上弊端并通过一个统一的平台进行配置管理。

设计之初,我们就把配置分为静态配置和动态配置。静态配置即在运行时不需要、也不可以进行修改的配置内容,比如监听端口、etcd地址、log路径等。动态配置即为运行时需要不断进行更改的配置,比如增删namespace、namespace内实例、用户、配置项的变更等等,动态配置是配置热加载的主角。

配置结构

动态配置最开始加载为models里对应的结构,比如models.Namespace,但是程序运行使用的配置为proxy/server包下的Manager。

动态配置对应结构如下:

// Manager contains namespace manager and user manager type Manager struct { switchIndex util.boolIndex namespaces [2]*NamespaceManager users [2]*UserManager statistics *StatisticManager }

Manager是一个全局变量,负责动态配置的管理,按照配置的适用范围分为用户配置、namespace配置和打点统计配置,而switchIndex则作为滚动数组进行配置滚动的标识。

Namespace配置

主要包含名称、数据库白名单、用户属性、sql指纹、执行计划、分片配置等。

// NamespaceManager namespace manager type NamespaceManager struct { namespaces map[string]*Namespace } // Namespace is struct driected used by server type Namespace struct { name string allowedDBs map[string]Bool defaultPhyDBs map[string]string // logicDBName-phyDBName sqls map[string]string //key: sql fingerprint slowSQLTime int64 // Session slow sql time, millisecond, default 1000 allowips []util.IPInfo Router *router.Router slices map[string]*backend.Slice // key: slice name userProperties map[string]*UserProperty // key: user name ,value: user's properties defaultCharset string defaultCollationID mysql.CollationID slowSQLCache *cache.LRUCache errorSQLCache *cache.LRUCache backendSlowSQLCache *cache.LRUCache backendErrorSQLCache *cache.LRUCache planCache *cache.LRUCache }

UserManager配置

主要包含auth阶段所需要的结构化信息

// UserManager means user for auth // username password是全局唯一的, 而username可以对应多个namespace type UserManager struct { users map[string][]string // key: user name, value: user password, same user may have different password, so array of passwords is needed userNamespaces map[string]string // key: UserName Password, value: name of namespace }

StatisticManager配置

作为Manager里的一个子模块,用以打点统计各项指标,并非配置热加载的配置项,在此不进行过多讨论。

配置初始化

通过InitManager,加载所有的namespace,初始化users、namespaces、statistics,构造全局manager(globalManager)

配置变更接口

gaea的配置变更没有针对每个配置项分别进行接口封装,而是基于配置完整替换 被替换配置动态资源延迟关闭的策略。无论是某一个还是多个配置项发生变化或者新增、删除namespace,对于gaea来说,都会构建一份新的配置并构造后端的动态资源。当新配置滚动为当前使用的配置之后,旧版本的动态资源在延迟60秒之后主动释放,进行比如关闭套接字、文件描述符等的操作。所以gaea的配置变更可以理解为,只要配置项实现了构造、延迟关闭功能,都可以纳入配置热加载模块内,进行统一管理。

滚动数组实现无锁化

滚动数组是配置热加载过程中经常使用的技巧,通过用空间换时间的方式,规避配置获取和配置变更之间的竟态条件。Manager中的switchIndex即为当前生效配置的下标,switchIndex的类型为BoolIndex,其对应的Get、Set方法均为原子操作,这样就保证了二元数组对应配置的切换为原子过程,而配置复制和赋值永远都是变更当前未在使用的另一元素,即通过写时复制 原子切换实现了配置的无锁化。为了防止多次滚动,导致丢失配置,在进行配置更改时,需要持有全局锁,保证同一时间,只有一个namespace进行配置变更。

延迟关闭回收动态资源

配置在提交之后,新配置生效,老配置需要进行资源回收。通过一个单独的goroutine,在sleep一段时间之后(尽最大努力保证请求得到应答),调用各个单独项的Close,回收资源。

两阶段提交保证一致性

一个集群会包含多台gaea-proxy,为了保证多台gaea-proxy快速生效相同的配置,故而引入了两阶段提交的配置变更方式,其中协调者为gaea-cc。第一阶段: gaea-cc调用各个gaea-proxy的prepare接口,gaea-proxy在prepare阶段首先复制一份当前的全量配置,然后从etcd加载对应namespace的最新的配置,最后更新对应的全量配置;第二阶段: gaea-cc如果在prepare阶段发生错误(任何一个gaea-proxy报错)则直接报错,prepare成功后则调用gaea-proxy的commit接口,gaea-proxy在commit接口只进行一次简单的配置切换,这样prepare工作重、commit工作非常轻量,可以很大程度上提升配置变更成功的几率。如果commit失败,则gaea-cc也是直接报错,对应的web平台上看到错误后可以决定是否停止变更或者重新发起一次变更(多次发送相同配置幂等)。

集群配置一致性校验

通过两阶段提交配置后,当前所有gaea-proxy的生效配置是相同的。为了方便验证: 1.配置是否发生变化 2.是否所有gaea-proxy的最新配置已经生效,gaea-proxy提供了获取当前配置签名的接口。通过该接口,DBA可以直接通过管理平台查看到各个gaea-proxy前后及当前配置的md5签名,保证配置变更的执行效果符合预期。

gaea proxy后端连接池的设计与实现

理想的连接池

基于go实现连接池的方式有很多种,比如通过chan、通过map 锁的方式,但是从使用者的角度来看,一个优秀的连接池我认为有以下几个特性: 1.有最大连接数和初始连接数限制 2.实际连接数可以在上述范围内动态伸缩 3.有限时间内获取连接且连接可用。在这三个基础之上,可能还包含一些其他非必须特性比如在运行时改变连接数最大限制、暴露连接池的一些状态信息等等。gaea的连接池是基于vitess的resource pool进行封装并添加了连接重试的功能,相关代码在backend和util目录下。

连接池的创建、使用

定义

ConnectionPool定义

// ConnectionPool means connection pool with specific addr type ConnectionPool struct { mu sync.RWMutex connections *util.ResourcePool addr string user string password string db string charset string collationID mysql.CollationID capacity int // capacity of pool maxCapacity int // max capacity of pool idleTimeout time.Duration }

NewConnectionPool定义

// NewConnectionPool create connection pool func NewConnectionPool(addr, user, password, db string, capacity, maxCapacity int, idleTimeout time.Duration, charset string, collationID mysql.CollationID) *ConnectionPool { cp := &ConnectionPool{addr: addr, user: user, password: password, db: db, capacity: capacity, maxCapacity: maxCapacity, idleTimeout: idleTimeout, charset: charset, collationID: collationID} return cp }

Open定义

// Open open connection pool without error, should be called before use the pool func (cp *ConnectionPool) Open() { if cp.capacity == 0 { cp.capacity = DefaultCapacity } if cp.maxCapacity == 0 { cp.maxCapacity = cp.capacity } cp.mu.Lock() defer cp.mu.Unlock() cp.connections = util.NewResourcePool(cp.connect, cp.capacity, cp.maxCapacity, cp.idleTimeout) return }

每一个连接作为一个资源单位,connections存放所有的连接资源,类型为ResourcePool。capacity定义连接池的初始容量、maxCapacity定义连接池的最大容量,实际后端连接数量会根据使用情况在[0,maxCapacity]之间浮动。idleTimeout为连接空闲关闭时间,当连接不活跃时间达到该值时,连接池将会与后端mysql断开该连接并回收资源。

创建

外部调用者通过NewConnectionPool函数创建一个连接池对象,然后通过Open函数,初始化连接池并与后端mysql建立实际的连接(有的连接池或长连接会在第一次请求时才去建立连接)。连接池的connect方法作为资源池初始化的工厂方法,所以如果你要基于该resource pool实现其他池子时,需要实现该工厂方法。

使用

通过连接池的Get方法可以获取一个连接,Get的入口参数包含context,该context最初设计用来传入一些全局上下文,比如超时上下文,目前gaea的获取连接超时时间是固定的,所以超时上下文也是基于该context在内部构造的。为防止上层一直阻塞在获取连接处,超过getConnTimeout未获取到连接会报超时错误,从而避免发生更严重的状况。如果发生超时次数过多,可以通过配置平台调整最大连接数大小,不同namespace的最大连接数其实是一个经验值,根据不同的业务状态有所不同,支持动态实时调整。

拿到连接后,连接池会主动调用tryReuse,用来保证连接是自动提交的状态。应用层需要主动调用initBackendConn,初始化与mysql有关的状态信息,包括use db、charset、session variables、sql mode等。为了提升效率会检测后端连接与前端会话的对应变量值,不一致才会进行设置,在设置的时候也是通过批量发送sql的方式,尽最大可能减少与后端mysql的网络交互。

连接使用完成后,需要手动调用recycleBackendConn回收连接,注意: 事务相关的连接是在commit或者rollback的时候进行统一释放。

动态维护连接

动态维护连接其实包含两部分,一部分维护连接池容量,不活跃的连接要主动关闭。连接池的Open函数会调用NewResourcePool函数,NewResourcePool函数会启动一个Timer定时器,定时器通过定期检测连接活跃时间与空闲时间的差值,决定是否关闭连接、回收资源从而实现动态调整连接池的容量。另一部分是保证获取到的连接是有效连接,这里在通过writeEphemeralPacket向后端mysql连接写数据时,如果报错含有"broken pipe"即连接可能无效,则会进行重试,直到连接成功或者重试次数达到三次报错,通过主动监测和连接重试,我们不需要进行定期ping后端连接,就可以保证后端连接是有效的。

总结

gaea的连接池实现还是相对简单可依赖的,在使用gaea的过程中,最好将后端mysql的wait_timeout值设置为比gaea idleTimeout长,可以减少不必要的连接重试。

prepare的设计与实现

背景

应用端使用prepare主要考虑通过固定sql模板,在执行sql时只传输参数,减少数据包传输大小,提升sql执行效率。对于非分库分表的情况,我们可以直接通过转发execute(对应后端连接可能是prepare execute close)的方式进行支持。但是对于分库分表的情形,需要计算路由,重写sql,支持起来会非常麻烦。商城目前的分库分表中间件是mycat,而mycat是支持prepare的,而gaea的prepare方案也是参照mycat,即将prepare statements的执行转换为sql的执行,然后在应答阶段,根据文本的应答内容构造二进制应答内容,返回给客户端,从而统一了分库分表的处理逻辑。

prepare

gaea在接到preprae请求后,首先计算参数个数、参数偏移位置和stmt-id。然后根据以上数据,构造statement对象并保存在SessionExecutor的stmts内,stmts为一个map,key为stmt-id,value即为构造的statement对象。

prepare阶段主要是计算、保存execute需要使用的变量信息,prepare应答数据内也会包含这些变量信息。

execute

execute请求时会携带prepare应答返回的stmt-id,服务端根据stmt-id从SessionExecutor的stmts中查询对应的statement信息。根据statement信息的参数个数、偏移和execute上传的参数值,进行关联绑定,然后rewrite一条同等含义的sql。同时,为了安全性考虑,也会进行特殊字符过滤,防止比如sql注入的发生。

生成sql之后,无论是分表还是非分表,我们都可以调用handleQuery进行统一的处理,避免了因为要支持prepare,而存在两套计算分库、分表路由的逻辑。

处理完成之后,需要进行文本应答协议到二进制应答协议的转换,相关实现在BuildBinaryResultset内。

execute执行完成之后,执行ResetParams,重新初始化send_long_data对应的args字段,病返回应答。

send_long_data

send_long_data不是必须的,但是如果execute有多个参数,且不止一个参数长度比较大,一次execute可能达到mysql max-payload-length,但是如果分多次,每次只发送一个,这样就绕过了max-payload-length,send_long_data就是基于这样的背景产生的。

客户端发送send_long_data报文,会携带stmt-id、param-id(参数位置),我们根据stmt-id参数可以检索prepare阶段存储的stmt信息,根据param-id和对应上送的数据,可以建立一个k-v映射,存储在Stmt.args(interface slice)。在上述execute执行阶段,也会根据参数位置从Stmt.args查询对应位置的参数值,进行关联绑定。

send_long_data不需要应答。

close

close的处理逻辑比较简单,服务端收到close请求后,删除prepare阶段stmt-id及其数据的对应关系。

总结

gaea对于prepare的处理初衷还是考虑协议的兼容和简化处理逻辑,对于client->proxy->mysql这样接口来说,client->proxy是prepare协议,proxy->mysql是文本协议,所以整体来看在gaea环境下使用prepare性能提升有限,还是建议直接使用sql。

,