案例 | 得物:彩虹桥架构演进之路-性能篇

eb5f2d841deae1d660fe4b14fcd02401.jpeg

目录

ShardingSphere

一、前言

二、改造前的架构

    1. Proxy 三层模块

    2. BIO 模式下的问题

三、改造后的架构

    1. 自研 NIO 数据库驱动

    2. 自研 NIO 数据库连接池

    3. 跳过不必要的编解码

    4. 全链路异步化

    5. 兼容性

四、性能表现

五、总结

01前言

一年前的《彩虹桥架构演进之路》侧重探讨了稳定性和功能性两个方向。在过去一年中,尽管业务需求不断增长且流量激增了数倍,彩虹桥仍保持着零故障的一个状态,算是不错的阶段性成果。而这次的架构演进,主要分享一下近期针对性能层面做的一些架构调整和优化。其中最大的调整就是 Proxy-DB 层的线程模式从 BIO 改造成了性能更好的 NIO。下面会详细介绍一下具体的改造细节以及做了哪些优化。

阅读本文预计需要 20~30 分钟,整体内容会有些枯燥难懂,建议阅读前先看一下上一篇彩虹桥架构演进的文章(彩虹桥架构演进之路)以及 MySQL 协议相关基础知识。

02  改造前架构

彩虹桥是基于 Apache ShardingSphere 深度二次开发的,本次改造主要是针对 ShardingSphere Proxy 模式的架构调整,主要改动的模块是 Backend 层,也就是与数据库交互的模块,从原来的 HikariCP+JDBC 改造成自研连接池&驱动,然后就是 Fronted->Core->Backend 层的主链路异步化改造。得益于 ShardingSphere 优秀的架构,ShardingSphere 内核层&功能层可以直接复用,下面是一些核心改造点的详细介绍。

先来复习一下彩虹桥的全景架构图:

ac29f5f5a30c92c7de31da333d31fde8.png

534826a6e5d2f69c0c524826dff35f26.jpeg

Proxy 三层模块

ShardingSphere

针对 Proxy 这一层,可以大致分成 Frontend、Core、Backend 三层:

Frontend-服务暴露层:使用 Netty 作为服务器,按照 MySQL 协议对接收&返回的数据进行编解码。

Core-功能&内核层:通过解析、改写、路由等内核能力实现数据分片、读写分离、影子库路由等核心功能。

Backend-底层DB交互层:通过 JDBC 实现与数据库交互、对结果集改列、归并等操作。

eb0729173fdcfddc72cfa528909baf51.jpeg

BIO模式下的问题

ShardingSphere

这里 Core 层为纯计算操作,而 Frontend、Backend 都涉及 IO 操作,Frontend 层使用 Netty 暴露服务为 NIO 模式,但是 Backend 使用了数据库厂商提供的传统 JDBC 驱动,为 BIO 模式。所以 Proxy 的整体架构还是 BIO 模式。在 BIO 模型中,每个连接都需要一个独立的线程来处理。这种模型有一些明显的缺点:

  • 高资源消耗:每个请求创建独立线程,伴随大量线程开销。线程切换与调度额外消耗 CPU。

  • 扩展性受限:受系统线程上限影响,处理大量并发连接时,性能急剧下降。

  • I/O阻塞:BIO 模型中,读/写操作均为阻塞型,导致线程无法执行其他任务,造成资源浪费。

  • 复杂的线程管理:线程管理和同步问题增加开发和维护难度。

我们看最简单的一个场景:在 JDBC 在发起请求后,当前线程会一直阻塞直到数据库返回数据,当出现大量慢查或者数据库出现故障时,会导致大量线程阻塞,最终雪崩。在上一篇彩虹桥架构演进文章中,我们做了一些改进来避免了 BIO 模型下的一些问题,比如使用线程池隔离来解决单库阻塞导致全局雪崩的问题。

7bf11c8da3a2e83963187cd32dea1267.png

但是随着逻辑库数量的增多,最终导致 Proxy 的线程数膨胀。系统的可伸缩性和吞吐量都受到了挑战。因此有必要将现有的基于 JDBC 驱动的阻塞式连接升级为采用 NIO(非阻塞 I/O)方式连接数据库。

03  改造后的架构

  • BIO->NIO

想把 Proxy 整体架构从 BIO->NIO,最简单的方式就是把传统的 BIO 数据库驱动 JDBC 换成 NIO 的数据库驱动,但是在调研过后发现开源的 NIO 驱动并不多,而且基本上没有什么最佳实践。最后在参考 ShardingSphere 社区之前做的调研后(https://github.com/apache/shardingsphere/issues/13957),决定使用 Vertx 来替换 JDBC。最开始使用 Vert.x 的原因,第一是 Vertx 的异步编码方式更友好,编码复杂度相对较低,第二是因为它实现了主流数据库的驱动。但最终的结果不尽人意,由于 Vertx 相关抽象化的架构,导致链路较长时,整个调用栈深非常夸张。最终压测出来的吞吐量提升只有 5% 不到,而且存在很多兼容性问题。于是推倒重来,决定自研数据库驱动和连接池。

  • 跳过不必要的编解码阶段

由于 JDBC 驱动会自动把 MySQL 的字节数据编解码成 Java 对象,然后 Proxy 再把这些结果集经过一些加工(元信息修正、结果集归并)后再进行编码返回给上游。如果自研驱动的话,就可以把编解码流程控制的更细致一些,把 Proxy 不需要加工的数据直接转发给上游,跳过无意义的编解码。后面会介绍一下哪些场景是不需要 Proxy 对结果集进行加工的。

ef325ccdf895d8339f586899cf1f6056.jpeg

自研NIO数据库驱动

ShardingSphere

数据库驱动主要是封装了与 DB 层交互协议,封装成高级 API。下面 2 张图是 java.sql 包中的 Connection 和 Statement 的一些核心接口。

54c371e3c3661f62a014c6e285a182ff.png

所以首先我们需要了解一下,如何与数据库进行数据交互,以 MySQL 为例,使用 Netty 连接 MySQL,简单的交互流程如下。

2dabf5802df605a9f6da87989e4d2d7b.png

使用 Netty 与 MySQL 连接建立后,我们要做的就是按照 MySQL 协议规定的数据格式,先鉴权后再发送具体的命令包即可。下面是 MySQL 官方文档中鉴权流程和命令执行流程:

  • 鉴权流程:

    https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase.html

  • 执行命令流程:

    https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_command_phase.html

下面就是按照 MySQL 的文档,去实现编解码 Handle,我们简单看一下实现的代码。

518b75c5dced575fd676377b8d2bd850.png

  • decode 解码

就是针对 MySQL 返回的数据包解码,根据长度解析出 Palyload 封装成 MySQLPacketPayload 传给对应的 Handle 处理。

  • encode 编码

把具体的命令类转换成具体的 MySQL 数据包,这里的 MySQLPacket 有多个实现类,跟 MySQL的Command 类型一一对应。

现在还需要一个类似 java.sql.Connection 的实现类,来组装 MySQLPacket 并写入到 Netty 通道中,并且解析编码后的 MySQLPacketPayload 转换成 ResultSet。

37c02e480b848e8f295bb12396750730.png

bf3544d7d0442742adab7404289a0d0a.png

看起来比较简单,交互流程和传统的 JDBC 几乎一样,但是由于现在是异步化流程,所有的 Response 都是通过回调返回,所以这里有 2 个难点:

由于 MySQL 在上一条命令没结束前无法接受新的命令,所以如何控制单个连接的命令串行化?

如何将 MySQL 返回的数据包和发起命令的 Request 一一绑定?

首先 NettyDbConnection 引入了一个无锁化非阻塞队列 ConcurrentLinkedQueue。

a34a63b19240cd728f7a47d22995c61c.png

在发送 Command 时,如何没有正在进行中的 Command,则直接发送,如果有正在进行中的 Command,直接扔到队列中,等待上一条 Command 处理完成后推动下一条命令的执行。保证了单个连接命令串行化。

其次,NettyDbConnection 在执行命令时,传入一个 Promise,在 MySQL 数据包全部返回后,这个 Promise 将会被设置完成,即可于发起命令的 Request 一一绑定。

4b3e56835b6e9963b2738611758e2b05.png

cd3b9368e01affc538494edc3b880986.jpeg

自研NIO数据库连接池

ShardingSphere

前面介绍了 NettyDbConnection 这个类,实现了与 MySQL 的交互,并且提供了执行 SQL 的高级 API,但实际使用过程中,不可能每次都创建一个连接执行完 SQL 就关闭。所以需要对 NettyDbConnection 进行池化,统一管理连接的生命周期。其功能类似于传统连接池 HikariCP,在完成基本能力的基础上,做了很多性能优化。

  • 连接生命周期管控

  • 连接池动态伸缩

  • 完善的监控

  • 连接异步保活

  • 超时控制

  • EventLoop 亲和性

这里除了 EventLoop 亲和性,其他几个功能只要用过传统的数据库连接池应该都比较熟悉,这里不做过多展开。这里主要针对 EventLoop 亲和性展开介绍一下。

在文章开头我们说到 Proxy 的三层模块,Frontend、Core、Backend,如果现在我们把 Backend 层于数据库交互的组件换成了我们自研的驱动,那么 Proxy 就即是Netty Server,也是Netty Client,所以 Frontend 和 Backend 可以共用一个 EventLoopGroup。为了降低线程上下文切换,在单个请求从 Frontend 接收、经过 Core 层计算后转发到 MySQL ,再到接收 MySQL 服务响应,以及最终的回写给 Client 端,这一些列操作尽量放在一个 EventLoop 线程中处理。

54685ec49a89eeccd946ccd5c874e960.png

具体的做法就是 Backend 在选择与数据库连接时,优先选择与当前 EventLoop 绑定的连接。也就是前面提到的 EventLoop 亲和性,这样就能保证大部分场景下一次请求从头到尾都由同一个 EventLoop 处理,下面我们看一下具体的代码实现。

在 NettyDbConnectionPool 类中使用一个 Map 存储连接池中的空闲连接,Key 为 EventLoop,Value 为当前 EventLoop 绑定的空闲连接队列。

669b114663e35b922934161ffa1e3cc7.png

在获取时,优先获取当前 EventLoop 绑定的连接,如果当前 EventLoop 未绑定连接,则会借用其他 EventLoop 的连接。

539862b90eb960f17bda9229bbc654b7.png

为了提高 EventLoop 命中率,需要注意几点配置:

  • EventLoop 线程数量尽量与 CPU 核心数保持一致。

  • 连接池最大连接数超过 EventLoop 线程数越多,EventLoop 命中率越高。

下面放一张压测环境(8C16G、连接池最大连接数 10~30)的命中率监控,大部分保持在 75% 左右。

fdea66eba4a79dcba8b333d017bde5a1.png

b61e54e3cd1fd0e08b62df6eca223816.jpeg

跳过不必要的编解码

ShardingSphere

前面说到,有部分 SQL 的结果集是不需要 Proxy 进行加工的,也就是可以直接把 MySQL 返回的数据流原封不动转发给上游,直接省去编解码操作。那什么 SQL 是不需要 Proxy 进行加工的呢,我们举个例子说明一下。

假设逻辑库 A 里面有一张表 User 做了分库,分了 2 个库 DB1 和 DB2,分片算法是 user_id%2。

  • SQL 1

SELECT id, name FROM user WHERE user_id in (1, 2)

  • SQL 2

SELECT id, name FROM user WHERE user_id in (1)

很显然 SQL 1由于有 2 个分片 Value,最终匹配到了 2 个节点,SQL 2 只会匹配到 1 个节点。

355c5ac123d489be41643e61563688b2.png

104b4140c1ce7a056dc14ce91d1f9b4b.jpeg

全链路异步化

ShardingSphere

Backend 层用自研连接池+驱动替换原先的 HikariCP+JDBC 后,从 Frontend-Core-Backend 全链路涉及到阻塞的操作需要全部替换成异步化编码,也就是通过 Netty 的 Promise 和 Future 来实现。

4ca327f8383de77ba129440ee93f78f8.png

由于部分场景拿到 Future 时,可能当前 Future 已经完成了,如果每次都是无脑的加 Listener 会让调用栈加长,所以我们定义了一个通用的工具类来处理 Future,即 future.isDone() 时直接执行,反之才会 addListener,最大化降低整个调用栈的深度。

52491b470e9c3b8140effe458e081249.png

4c8a9ed3bcc50d0341acbe1c6b5b141d.jpeg

兼容性

ShardingSphere

除了以上基本代码的改造外,还需要做大量的兼容工作:

  • 特殊数据库字段类型处理

  • JDBC URL 参数兼容

  • ThreadLocal 相关数据全部需要迁移至 ChannelHandlerContext 中

  • 日志 MDC、TraceContext 相关数据传递

  • ……

04  性能表现

经过几轮性能压测后,NIO架构相较于BIO架构性能有较大提升:

  • 整体最大吞吐量提升 67%

  • LOAD 下降 37% 左右

  • 高负载情况下 BIO 多次出现进程夯住现象,NIO 相对较稳定

  • 线程数减少 98% 左右

05  总结

NIO 架构的改造工作量相当巨大,中间也经历了一些曲折,但是最终的结果令人满意。得益于 ShardingShpere 本身内核层面的高性能加上本次 NIO 改造后,彩虹桥在 DAL 中间件性能层面基本上可以算是第一梯队了。

*文/新一

觉得文章对你有帮助,欢迎评论转发点赞~
本文由得物技术授权转载

如何加入 ShardingSphere 社区成为贡献者?

  1. 社区答疑:积极在社区中进行答疑、分享技术、帮助群内的其他开源爱好者解决问题。

  2. 代码贡献:社区整理了简单且容易上手的任务,非常适合新人做代码贡献。可以查阅新手任务列表:

  3. https://github.com/apache/shardingsphere/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22%2Cdiscussion+no%3Aassignee

  4. 内容贡献:发布 ShardingSphere 相关的内容,比如安装部署教程、使用经验、案例实践等,形式不限,欢迎扫码投稿给社区助手。

  5. 社区布道:积极参与社区活动、成为社区志愿者、帮助社区宣传、为社区发展提供有效建议等。

  6. 官方文档贡献:发现文档的不足、优化文档,持续更新文档等方式参与社区贡献。通过文档贡献,让开发者熟悉如何提交 PR 和真正参与到社区的建设。

97fdc82f8448bf43d751dd5e798033b1.jpeg

长按识别回复“志愿者”了解更多吧~

关于 Apache ShardingSphere

Apache ShardingSphere 是一款分布式 SQL 事务和查询引擎,可通过数据分片、弹性伸缩、加密等能力对任意数据库进行增强。

点击阅读原文了解更多~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值