1、背景
MySQL是OPPO使用最广泛的关系数据库,不同编程语言的微服务都是通过MySQL官方的SDK直连真实的数据库实例。这种最传统的使用方式,会给业务开发和数据库运维带来一系列影响效率和稳定性的问题。
- 不合理的数据库访问,比如不恰当的连接池设置、高危SQL无拦截、无限流和熔断等治理能力
- 无弹性伸缩能力,单机数据库性能或者容量不足时,扩容非常繁琐低效
- 缺乏常用的功能特性,不支持读写分离、影子库表、单元化架构、数据库加密
- 不支持跨语言,并且与应用服务强耦合,编码和升级都非常困难
以上问题,我们讲通过CDAS来解决单MySQL带来的一系列问题。
2、CDAS产品简介
MySQL在并发能力、稳定性、高可用方面久经考验,是绝大多数网联网产品首选的OLTP场景存储组件,但单机MySQL在超高并发、海量存储、OLAP能力上先天不足,因此当数据到达一定量级后一般采用分库分表的方式来水平扩展MySQL,提升系统的整体处理能力。CDAS基于分片理念设计,目的是解决MySQL单机性能瓶颈,对外提供超过并发、海量存储、支持HTAP的MySQL服务。在一组MySQL集群前搭建一套高可用的代理 + 计算集群,提供分片能力、自动化的弹性伸缩能力、读写分离、影子库、数据加密存储能力,为用户提供一体化的产品。
CDAS Proxy使用Java语言开发,基于开源产品Apache ShardingSphere Proxy改造而来,并添加了许多高级特性来支持内部业务。我们的开发理念是依托开源的成熟产品来搭建服务,从社区汲取营养的同时也把发现的问题、特性回馈给社区,积极参与、和社区共同发展。
2.1 CDAS产品特点
(1)稳定性
CDAS进行了大量的基准测试、调优,实现了特殊的动态队列 + 线程池模型来保证Proxy的总体并发程度不会由于线程数过多导致上下文切换频繁从而性能下降,同时Proxy在长时间高负载的场景下不会产生Full GC导致业务中断,目前正式环境已有多个QPS:5000+的业务场景使用了CDAS,表现稳定。
(2)高度可扩展
我们建议业务在申请分片表时预估未来35年的数据总量来设定分片总数,前期可申请12个数据库,数据量变大后扩容。理论上可扩展性和分片总数相关,最大可扩展分片数相等的数据库实例,最大可扩展到250TB,同时Proxy集群也支持在线扩容。
(3)平台化运维
由云平台统一运维、部署,MySQL和Proxy都支持自动化流程申请和变更,自动接入元数据管理系统。
(4)兼容MySQL语法
支持绝大多数MySQL语法。
- 路由至单数据分片100%兼容
- 支持分页、去重、排序、分组、聚合、关联等常见查询场景
(5)功能丰富
- 多种分片算法
- 自增分布式ID
- 读写分离,可设置主从同步容忍阈值
- 影子库
- HTAP
- 完善的监控信息:审计日志、慢日志、鉴权
3.核心设计
CDAS的核心目标是解决用户海量数据存储、访问问题,聚焦到分片场景中,主要体现在解析、路由、重写、聚合等逻辑 。
3.1 内核架构
站在高处看Proxy内核,主要分为连接接入模块、I/O多路复用模块、解析路由执行引擎3个大的部分,左侧可以看到线程模型也分为3块,实际涉及线程资源的还有其他子逻辑,如并行执行线程池等,接下来将围绕多个模型和执行流程了解内核架构的细节
3.2 线程模型内核入口层基于Netty实现,从整体上看是一个典型的Netty使用场景,内核图从可以看到主要分为3个部分。
(1)Boss Thread负责Accept connection,也就是接受、建立连接,客户端一般都会使用连接池技术,因此建立连接的请求不会太多,单线程即可处理
(2)I/O Threads即Netty EventLoopGroup ,负责编解码、发起auth认证,基于Epoll事件驱动 I/O多路复用,一个线程可处理多个连接,共CPU核心数 * 2个线程。
(3)Worker Threads负责执行、回写数据等核心流程。Worker线程整体是同步的,其中Parser\Router\Rewriter\RateLimter是纯CPU计算,Executor Engine、ResultManager底层基于JDBC规范,JDBC目前对外暴露的是一个同步的调用模型,在数据库未响应前线程状态为blocked,因此Worker线程池的配置决定了Proxy整体的并发能力。 线程资源在操作系统中是相对昂贵的资源,在CPU核心数固定的情况下,线程数量过大会耗费大量内存和导致上下文频繁切换,降低性能。我们在压测过程中额外关注了线程数对服务的影响,找到了一个合适的计算公式。
PlaintextMath.min(cpuNum * X, maxThreadsThreshold)
- X:默认75,可配置
- maxThreadsThreshold:默认800,可配置
同时我们自研了DynamicBlockingQueue,在队列积压任务到一定阈值后提前创建新线程执行任务。
3.3 连接模型
在接受客户端连接后,Proxy内部会为每个客户端连接维护1个Backend Connection的逻辑连接。Backend Connection的connections列表保存执行过程中用到的真实连接,请求执行完毕或事务结束后清空connections列表,释放物理连接。同时,Proxy内部使用连接池技术来保存物理连接,减少连接建立时间并控制总的连接数量
3.4 事务模型接下来聊一下在事务场景中的执行流程。
3.4.1 单库事务假设有以下事务场景:
Plaintextbegin; select * from t_order where order_id = 1; update t_order set gmt_modify = now() where order_id = 1; update t_order_item set cnt = 2 where order_id = 1; commit/rollback;
事务过程中一共会和数据库顺序交互5次。 语句1:begin时Proxy并不知道后面要执行什么语句,无法路由到RDS,只是在连接上记录了一个begin状态语句2:执行带分片键的select,还是以16分片为例,最终语句会路由到t_order_1这张表中,获取connection_db1,先执行begin,再执行select,并放入逻辑连接的连接列表中
connection_db1为t_order_1表所在数据库上的连接
语句3、语句4:路由到t_order_1,t_order_item_1,和语句2路由到相同DB,复用connection_db1执行语句5:单纯的commit或rollback语句,取出当前逻辑连接的所有RDS连接,挨个执行commit/rollback在事务执行过程中,是否会产生分布式事务完全由用户SQL语句来控制,如果事务执行过程中不会切换数据库,则会退化成单纯的RDS transaction,保持完整的ACID特性,如果事务执行过程中出现路由到多个DB的情况,则会产生分布式事务。
3.4.2 分布式事务目前分布式事务的解决方案主要有以下3种
(1)最终一致
业务发起的最终一致性方案,如Seata\TCC等,业务有感,多用于跨服务调用场景,如订单和库存系统,某一环提交失败需特定逻辑来整体回滚,不适合Proxy场景
(2)强一致XA
多用于跨服务调用场景,目前存在性能不佳,协调器单点\Recovery锁占用等问题,不适合高并发场景使用
(3)厂商提供分布式事务
分布式数据库厂商如OceanBase本身会对数据进行分片,事务执行过程中操作了多个分布式节点从而引入分布式事务。针对单个分布式库内的事务保证ACID,Proxy后端基于MySQL协议,无法在多个RDS间实现这类事务。
(4)现状
CDAS Proxy并没有提供XA来保证跨RDS实例的强一致性,也没有在内部支持最终一致性。如果触发多库事务则分开提交,有部分提交成功、部分失败的风险,因此建议业务方在设计事务时尽量不再RDS集群内部产生跨库事务。
3.5 分片模型
以4库16分片场景为例阐述CDAS分片方案和传统方案的区别
传统方案每个DB中的分片名都是从后缀0开始的,每个库中有相同的分片数量。有以下劣势:
- 热点分片处理困难
- 无法针对分片进行迁移
- 分片名无意义,无法自解释
针对以上问题,我们采用了类似范围分片的思路来优化了分片名。
分片名是唯一的,位于不同DB中的分片名互不相同,这种设计方式与Redis/MongoDB中不同slot位于不同实例上很相似,有利于扩展。
问题 | 解决方式 |
---|---|
热点分片 | 热点分片可以通过扩展1个新RDS来解决,如添加DB5,将某个流量特别大的热点迁移到DB5 |
分片迁移 | 单个分片迁移不影响其他分片,也不会被分片算法影响 |
分片名无意义 | 通过分片名唯一让每个分片能表述它在整体集合中的位置 |
t_order_1为热点分片或DB1压力过大时迁移t_order_1到新的DB5中:
当然这种迁移方式并不适用于所有场景,如表t_order、t_order_item的分片算法完全相同,则他们一定落到同一个DB中,如果迁移过程中只迁移其中一张表势必会导致不必要的分布式事务,因此迁移时会将绑定关系的表一并迁移。3.6 执行流程 以最简单的场景举例,逻辑表:t_order分16片,分片键:order_id
Plaintextselect * from t_order where order_id=10
(1)解析
Worker线程拿到这条语句后首先要做的是解析SQL语句,基于开源产品Antlr实现语法树解析。
Plaintext{type:select,table:t_order,condition: order_id=10}
(2)路由
在获取到分片表名和条件后,根据表名获取分片规则并计算分片
Plaintextorder_id mod 16 => 10 mod 16 => 10
得到真实的table:t_order_10(3)重写重写流程会将逻辑表名替换为真实表名,并计算真实位于哪个DB中。
Plaintextselect * from t_order_10 where order_id=10
(4)限流
限流功能可以控制某类SQL的瞬时并发度,目前仅对部分场景进行了限流,如OLAP请求。
(5)执行
语句将会发往真实的数据库中执行,目前OLTP请求使用MySQL官方Connector/J发送,OLAP请求使用ClickHouse官方SDK发送,并且AP流量不会路由+重写,因为在整体设计上ClickHouse集群中的分布式表名和逻辑表名是相同的
(6)聚合结果
聚合的主要场景是查询请求被路由到了超过一个真实表的场景,如
Plaintextselect * from t_order where order_id in (10,11)
这种情况一条SQL最终到DB层面会是两次子请求
Plaintextselect * from t_order_10 where order_id in (10,11)select * from t_order_11 where order_id in (10,11)
聚合时,通过JDBC API遍历两个子请求的ResultSet,在内存中新建一个逻辑ResultSet,当逻辑ResultSet被遍历时会触发子ResultSet被遍历,最终将逻辑ResultSet编码在MySQL协议包发往客户端。如果SQL语句中包含order by,聚合时会在内存里面进行归并。内存里面排序需要将所有行数据加载到内存中排序,内存压力会很大,因此虽然有排序能力,我们还是不建议在请求分裂多个子请求的场景中使用order by语法。
3.7 HTAPHTAP的是指混合OLTP(Online Transactional Processing)和 OLAP(Online Analytical Processing),在数据库中同时支持在线事务请求和后台分析请求。 MySQL是一个典型的OLTP型数据库,不太适合OLAP型业务,因为分析语句一般需要执行复杂的聚合和查询大量数据,这可能会影响其在OLTP上的稳定性。同时OLAP数据库一般在存储方式上采用列存来最大化压缩效率、空间占用,而MySQL采用行存,一般情况下须先将整行记录查询出来再过滤出某些列,在执行分析请求时会有明显的IO读放大,效率不高。CDAS通过DTS(数据传输)服务将MySQL各分片数据传输并聚合到ClickHouse(分析型列存数据库),形成统一的数据视图。AP流量到达Proxy后会被转发到ClickHouse,数据响应后再以MySQL数据包的形式返回给客户端,通过这种方式,CDAS实现了基于MySQL协议的HTAP能力。
4.部署架构
部署拓扑图如下:
现代架构中业务方对高可用的要求越来越高,目前大多数重要的服务都会采用多机房主备或互为主备的方案来部署,一般采用双机房或双中心化方案,保证其中一个区域挂掉后仍然能对外服务。在设计之初CDAS考虑到作为L7层的数据流量入口拥有极高的高可用要求,因此允许用户申请双机房Proxy集群,保证其中1个机房挂掉后另1个机房依然可以承接流量对外服务,实现机房级的高可用。业务系统通过L4负载均衡来和Proxy建立连接,L4和Proxy紧密协调保证优雅下线以及新实例发现,并感知Proxy节点的健康状态,及时下线不可用节点。同时由L4来做同机房优先路由、流量转发,Proxy后的所有MySQL集群都采用semi-sync的高可用架构模式。这种部署架构使CDAS具备了机房级灾备的能力。
5.性能测试
在测试过程中我们尤其关注了性能方面的损耗,并对Proxy代理、直连RDS进行了对比测试,这样就能直观的得到性能损耗程度。
5.1 压测工具 内部压测平台(内部基于JMeter)
5.2 压测方式 Java SDK + 连接池方式压测 主要分为两组
- 压测Proxy,Proxy连接2个RDS集群
- 压测单个RDS集群,作为Proxy性能对比的基准
压测语句:参考SysBench的数据库压测语句,由于分片场景一般不会直接使用主键ID分片,且主键ID在InnoDB中查询效率高于二级索引,因此添加sharding_k列进行分片测试,更符合实际的应用场景。
PlaintextCREATE TABLE `sbctest_x` ( `id` bigint(11) NOT NULL, `sharding_k` bigint(11) NOT NULL, `k` int(11) DEFAULT NOT NULL, `name` varchar(100) NOT NULL, `ts` timestamp NULL DEFAULT NULL, `dt` datetime DEFAULT NULL, `c` char(100) DEFAULT NULL, `f` float DEFAULT NULL, `t` tinyint(4) DEFAULT NULL, `s` smallint(6) DEFAULT NULL, PRIMARY KEY (`id`), KEY `sharding_k_idx` (`sharding_k`), KEY `k_idx` (`k`), ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 压测SQL #QPS 非事务的简单查询 SELECT COUNT(1) FROM $table WHERE sharding_k=? #TPS 包含 1条查询,4条变更语句 BEGIN; SELECT c FROM $table WHERE sharding_k=? UPDATE $table SET k=k+1 WHERE sharding_k=? UPDATE $table SET c=?,ts=?,dt=? WHERE sharding_k=? DELETE FROM $table WHERE sharding_k=? INSERT INTO $table (id,sharding_k,k,name,ts,dt,c,pad,f,t,s) VALUES(?,?,?,?,?,?,?,?,?,?,?) COMMIT;
5.3 压测报告 以8C16G规格为例:SSD\100分片\单片250W行\数据量大于InnoDB buffer pool
(1) QPS 场景 Proxy QPS只有RDS QPS的1/2,压测过程中观察到RT约为2倍 QPS场景主要是大量非事务查询的场景,Proxy的性能损耗约1/2,QPS压测过程中我们观察到Proxy后的RDS实例CPU使用率较低,于是将Proxy的CPU规格提升1倍后,Proxy QPS立即提升1倍,接近直连RDS的性能。 (2) TPS 场景 由于TPS涉及事务提交、log等操作,I/O频繁,瓶颈也主要在I/O,即使是SSD磁盘性能也都不高。Proxy后挂载了2个RDS实例,TPS在各并发用户数中都明显高于直连单个RDS,且在并发用户数增加后可达到其2倍,性能损耗几乎可忽略。 (3) 结论 如果业务场景中非事务查询的请求占绝大多数,且RDS的CPU利用率较高,建议选择Proxy的规格时CPU要高于RDS的规格如果业务场景中事务执行请求较多,Proxy不会成为性能瓶颈,规格可和RDS保持一致
6.案例
目前支持了多个在线业务,拥有完善的指标展示、报警机制,我们例举个业务的控制台界面及监控图。 Proxy列表:
单个节点TPS:
单个节点QPS:
在多个产品的使用过程中CDAS表现稳定,我们将会继续往更大流量的场景推广,服务更多的业务方。
7.总结与展望
CDAS为数据分片而生,构建基于MySQL分片场景的内部标准,通过MySQL协议实现跨语言可访问,统一使用方式,简化使用成本。 未来我们将持续专注于CDAS的性能提升和功能补充,主要体现在下面2个方面:
-
ShardingSphere社区已计划将Proxy的数据库驱动从MySQL Connnector/J 替换为事件驱动组件vertx-mysql-client(一款Reactive MySQL Client),基于Netty实现和MySQL交互I/O多路复用、事件驱动替换后worker线程池执行语句时将从blocking等待变为事件驱动,少量的线程即可支持大量的并发,我们将持续关注社区的开发进度、积极参与。
-
CDAS和MySQL交互过程中会自动解码Rows数据包,产生大量的对象并且都是朝生夕灭的,导致GC压力增大,同时编解码也会耗费大量CPU时间。 Rows数据包在非解密等特殊场景外都可以基于Netty ByteBuf实现零拷贝发送,这是最佳的方案。我们计划在开源版本替换为vertx-mysql-client后对这部分逻辑进行优化,使绝大多数的请求的响应时间能达到接近4层负载均衡的性能。