写点什么

Cobar SQL 审计的设计与实现

用户头像
小楼
关注
发布于: 2021 年 03 月 22 日

背景介绍

Cobar 简介

Cobar 是阿里开源的一款数据库中间件产品。

在业务高速增长的情况下,数据库往往成为整个业务系统的瓶颈,数据库中间件的出现就是为了解决数据库瓶颈而产生的一种中间层产品。

在软件工程中,没有什么问题是加一层中间层解决不了的,如果有,再加一层。

一款 proxy 类型(本文不讨论 client SDK 类型的数据库中间件)的数据库中间件具备以下能力:

  • 支持数据库的透明代理,做到用户无感知

  • 能够水平、垂直拆分数据库和表,横向扩展数据库的容量和性能

  • 读和写的分离,降低主库压力

  • 复用数据库连接,降低数据库的连接消耗

  • 能够检测数据库集群的各种故障,做到快速 failover

  • 足够稳定可靠,性能足够好

而本文的主角 Cobar 除了读写分离外其他特性都支持的很好,而且基于 Cobar 开发读写分离的特性并不是一件很难的事。

SQL 审计

笔者有幸也曾在公司内的 Cobar 上做过定制开发,开发的功能是 SQL 审计。

从数据库产品的运营角度看,统计分析执行过的 SQL 是一个必要的功能;从安全角度看,信息泄露、异常 SQL 也需要被审计。

SQl 审计需要审计哪些信息?通过调研,大致确定要采集执行的 SQL、执行时间、来源 host、返回行数等几个维度。

SQL 审计的需求很简单,但就算是一个很简单的需求放在数据库中间件的高并发、低延迟,单机 QPS 可达几万到十几万的场景下都需要谨慎考虑,严格测试。

举个例子,获取操作系统时间,在 Java 中直接调用 System.currentTimeMillis(); 就可以,但在 Cobar 中如果这么获取时间,就会导致性能损耗非常严重(怎么解决?去 Cobar 的 github 仓库上看看代码吧)。

技术方案

大方向

经调研,SQL 审计实现的方向大致有两种

  • 一种是比较容易想到的直接修改 Cobar 代码,在需要收集信息的地方埋点

  • 另一种是阿里云数据库提供的方案,通过抓取数据库的通信流量进行分析。

考虑到技术的复杂度,我们选择了较为简单的第一种实现方式。

SQL 审计在 Cobar 中属于“锦上添花”的需求,不能因为这个功能导致 Cobar 性能下降,更不能导致 Cobar 不可用,所以必须遵循以下两点:

  • 性能尽可能接近无 SQL 审计版本

  • 无论如何不能造成 Cobar 不可用

对于性能的损耗,没有度量就没法优化,于是使用sysbench(一种数据库基准测试工具)来对现在版本的 Cobar 进行压测。

Cobar 部署在 4C8G 的机器上,mysql 部署在性能足够好的物理机上,压出了5.5w/s的基准,后续的版本都和这个数值进行比对。

由于采取了侵入 Cobar 代码的方式,想对 Cobar 造成影响最小,就需要保持代码最小的修改,于是采取了 agent 的方案。

这样可以保持代码的最小修改,只需要打点采集并传输给 agent,向远端传输审计信息的逻辑就只需要在 agent 中处理即可,向远端传输信息几乎在一开始就确定了用 kafka,这样也能保持 Cobar 不引入新的第三方依赖,保持代码的干净(要知道 Cobar 的第三方依赖只有 log4j),让 kafka 和 Cobar 保持在两个 JVM 中,更是一种隔离。于是有了下图的架构初稿


通过上图梳理出了两个关键技术点:线程通信和进程通信。

进程通信容易理解,为什么这里还涉及线程通信?

首先 Cobar 的 execute 线程是执行 SQL 的主线程,如果在这个线程中去进行进程通信,那性能肯定被消耗的体无完肤。于是只能丢给审计线程去做,这样对 Cobar 的性能影响最小。

进程间通信

先说进程间的通信,这块稍微简单点,我们只需要罗列出可用的进程间通信方式,然后对比优缺点,选择一个合适的使用即


首先 Cobar 是 Java 编写,于是我们框定了范围:TCP、UDP、UnixDomainSocket、文件。

经过调研,UnixDomainSocket 与平台相关性太强,且没有官方的实现,只有第三方的实现(如 junixsocket),测试下来,不同 linux 的版本支持都不一致,所以这里直接排除。

写文件会导致高 IO,甚至有写满磁盘的风险,毕竟在如此高的并发之下,遂排除。

最终在 TCP 和 UDP 中选择,考虑性能 UDP 比 TCP 好,且 TCP 还得自己解决粘包问题,于是我们选择了 UDP。其实想想,SQL 审计需求类似日志收集、metric 上报,许多日志收集、metric 上报都是采取 UDP 的方式。

线程间通信

如果说进程间通信拍拍脑袋就能决定,是因为他并不直接影响 Cobar,他是审计线程与 agent 进程间的通信。然而线程间的通信则直接决定了对 Cobar 的性能影响,必须谨慎。

线程间通信必须通过一个中间的缓冲buffer来中转,我们对这个 buffer 有如下要求

  • 有界,无界就可能会导致内存溢出

  • 投递不能阻塞,阻塞会导致夯住主线程,极大影响 Cobar 性能

  • 可以无序,为了保证 Cobar 可用性,甚至可以在极端情况下丢失一些数据

  • 线程安全,高并发下如果线程不安全,数据就会错乱

  • 高性能

Java 内置队列

Java 中内置的队列可以充当这个 buffer


有界的只有 ArrayBlockingQueue 和 LinkedBlockingQueue,然而他们都是加锁的,直觉告诉我,他的性能不会太好。

想到 Java 中 CurrentHashMap 和 LongAdder 都是通过分段来解决锁冲突的,于是打算使用多个 ArrayBlockingQueue 来构造这个 buffer


实测下来,只达到了 4.7w/s,性能损失约 10%

Disruptor

Java 内置的队列属于有锁队列,那么有没有不加锁且有界的队列呢?搜索后发现了一款开源的无锁队列实现Disruptor,大量的产品如 Log4j2 等都使用了 Disruptor。它是一种环形的数据结构,使用了 Java 中的CAS代替了锁,且有许多细节上的性能优化,导致他的性能非常强悍。


但很可惜的是,在测试时发现当 Disruptor 的 buffer 写满之后,再写就会阻塞,这和我们的需求不符合,如果主线程发生阻塞将是灾难性的,于是放弃。

SkyWalking 的 RingBuffer

刚好当时组内同学在研究SkyWalking,SkyWalking 是一款开源的应用性能监控系统,包括指标监控,分布式追踪,分布式系统性能诊断。

他的原理是利用 Java 的字节码修改技术在调用处插入埋点,采集信息上报。和 Cobar 的采集上报过程类似。

那么他的 RingBuffer 是如何实现的呢?其实非常简单,缓冲区就是一个数组,每次投递时获取一个没有写入数据的数组下标即可,在多线程下只要保证获取的下标不会被两个线程同时获取即可。数据的写入速度快慢就看这个下标获取是否高效即可,如下图:


获取数组下标和 Disruptor 类似也是使用了 CAS,但他实现非常简单,甚至有点粗糙,但他可以在写满时选择是阻塞、覆盖或是忽略,我们选择覆盖这个策略,在极端情况下丢掉老数据来换取 Cobar 的可用性。我们测试了一下使用多个 SkyWalking 的 RingBuffer 的场景,结果只有3w/s,损失 45%性能。

于是我们对这个 Ringbuffer 进行了一些优化


这个优化主要是将 CAS 换成 incrementAndGet,这样就能利用到 JDK8 对 incrementAndGet 的优化,在 JDK8 之前,incrementAndGet 底层也是 CAS,但在 JDK8 之后,incrementAndGet 使用了fetch-and-add(CPU 指令),性能要强劲很多。这块具体的介绍和代码可以参考《一种极致性能的缓冲队列》

除了这个主要的优化外,还参考 Disruptor 进行对 SkyWalking 进行了缓存行填充优化,最后达到了5.4w/s,性能损失仅仅 1.8%,非常给力,于是使用了这个版本的 Ringbuffer 作为 Cobar SQL 审计的缓存区。

优化后的 Ringbuffer 也回馈给了 SkyWalking 社区,SkyWalking 作者赞赏这是一个“intersting contribution”。

总结

Cobar 的 SQL 审计在上线后稳定支撑了公司所有 Cobar 集群,是承载最高 QPS 的系统之一。

回头来看对性能的极致追求可能或许过于"偏执",创造的收益在旁人眼里看来并没有那么大,加一台机器就能搞定的事情非要搞这么复杂。但这份“偏执”却是我们对技术最初的追求,生活不止眼前的苟且,还有诗和远方。


欢迎关注我的公众号“捉虫大师



发布于: 2021 年 03 月 22 日阅读数: 13
用户头像

小楼

关注

还未添加个人签名 2018.09.19 加入

欢迎关注我的公众号“捉虫大师”

评论

发布
暂无评论
Cobar SQL审计的设计与实现