细数Cobar十大糟点,无需入门直接放弃

白菜Java自习室 涵盖核心知识

1. Cobar 概述

Cobar 是由 Alibaba 开源的 MySQL 分布式处理中间件,它可以在分布式的环境下看上去像传统数据库一样提供海量数据服务。

cobar.png

Cobar 解决的问题

分布式:Cobar 的分布式主要是通过将表放入不同的库来实现:

  1. Cobar 支持将一张表水平拆分成多份分别放入不同的库来实现表的水平拆分;
  2. Cobar 也支持将不同的表放入不同的库;
  3. 多数情况下,用户会将以上两种方式混合使用;

HA:在用户配置了 MySQL 心跳的情况下,Cobar 可以自动向后端连接的 MySQL 发送心跳,判断 MySQL 运行状况,一旦运行出现异常,Cobar 可以自动切换到备机工作。但需要强调的是:

  1. Cobar 的主备切换有两种触发方式,一种是用户手动触发,一种是 Cobar 的心跳语句检测到异常后自动触发。那么,当心跳检测到主机异常,切换到备机,如果主机恢复了,需要用户手动切回主机工作,Cobar 不会在主机恢复时自动切换回主机,除非备机的心跳也返回异常;
  2. Cobar 只检查 MySQL 主备异常,不关心主备之间的数据同步,因此用户需要在使用 Cobar 之前在 MySQL 主备上配置双向同步;

2. Cobar 十大糟点

Cobar 是阿里巴巴研发的关系型数据的分布式处理系统,该产品成功替代了原先基于 Oracle 的数据存储方案,目前已经接管了 3000+个 MySQL 数据库的 schema,平均每天处理近 50 亿次的 SQL 执行请求。

50 亿有多大?上面这个简单的数字描述,已立刻让我们程序型的大脑短路。恨不得立刻百度 Cobar,立刻 Download,立刻熬夜研究。做个简单的推算,50 亿次请求转换为每个 schema 每秒的数据访问请求即 TPS,于是我们得到一个让自己不能相信的数字:20 TPS,每秒不到 20 个访问

Cobar 最重要的特性是分库分表。Cobar 可以让你把一个 MySQL 的 Table 放到 10 个甚至 100 个位于不同物理机上的 MySQL 服务器上去存储,而在用户看来是一张表(逻辑表)。这样功能很有价值。比如:我们有 1 亿的订单,则可以划分为 10 个分片,存储到 2-10 个物理机上。每个 MySQL 服务器的压力减少,而系统的响应时间则不会增加。看上去很完美的功能,而且潜意识里,执行这句 SQL:

SELECT count(*) FROM orders;
复制代码

100%的人都会认为:会返回 1 条数据,但事实上,Cobar 会返回 N 条数据,N=分片个数

接下来我们继续执行 SQL:

SELECT count(*) FROM orders ORDER BY order_date; 
复制代码

你会发现奇怪的乱序现象,而且结果还随机,这是因为,Cobar 只是简单的把上述 SQL 发给了后端 N 个分片对应的 MySQL 服务器去执行,然后把结果集直接输出

再继续看看,我们常用的 Limit 分页的结果…可以么?答案是:不可以。

这个问题可以在客户端程序里做些工作来解决。所以随后出现了 Cobar Client。据我所知,很多 Cobar 的使
用者也都是自行开发了类似 Cobar Client 的工具来解决此类问题。从实际应用效果来说,一方面,客户端编程方式解决,困难度很高,Bug 率也居高不下;另一方面,对于 DBA 和运维来说,增加了困难度。
当你发现这个问题的严重性,再回头看看 Cobar 的官方文档,你怅然若失,四顾茫然。

2.1. 糟点一:Cobra 会假死

可以做个简单的小实验,假如你的分片表中配置有表 company,则打开 mysql 终端,执行下面的 SQL:

SELECT sleep(500) FROM company;
复制代码

此 SQL 会执行等待 500 秒,你再努力以最快的速度打开 N 个 mysql 终端,都执行相同的 SQL,确保 N>当前 Cobra 的执行线程数:

SHOW @@threadpool;
复制代码

的所有 Processor1-E 的线程池的线程数量总和,然后你再执行任何简单的 SQL,或者试图新建立连接,都会无法响应,此时:

SHOW @@threadpool;
复制代码

里面看到 TASK_QUEUE_SIZE 已经在积压中。 不可能吧,据说 Cobra 是 NIO 的非阻塞的,怎么可能阻塞!别激动,去看看代码,Cobra 前端是 NIO 的,
而后端跟 Mysql 的交互,是阻塞模式
,其 NIO 代码只给出了框架,还未来得及实现。

2.2. 糟点二:高可用的陷阱

Cobra 假死的的秘密背后,还隐藏着一个更为“强大”的秘密,那就是假死以后,Cobra 的频繁主从切换问题。我们看看 Cobra 的一个很好的优点——“高可用性”的实现机制,下面解释了 Cobra 如何实现高可用性:

分片节点 dn2_M1 配置了两个 dataSource,并且配置了心跳检测(heartbeat)语句,在这种配置下,每个
dataNode 会定期对当前正在使用的 dataSource 执行心跳检测,默认是第一个,频率是 10 秒钟一次,当心跳检测失败以后,会自动切换到第二个 dataSource 上进行读写,假如 Cobra 发生了假死,则在假死的 1 分钟内,
Cobra 会自动切换到第二个节点上,因为假死的缘故,第二个节点的心跳检测也超时。于是,1 分钟内 Cobra 频繁来回切换
,懂得 MySQL 主从复制机制的人都知道,在两个节点上都执行写操作意味着什么?——可能数据一致性被破坏,谁也不知道那个机器上的数据是最新的

还有什么情况下,会导致心跳检测失败呢?这是一个不得不说的秘密:当后端数据库达到最大连接后,会对
新建连接全部拒绝,此时,Cobar 的心跳检测所建立的新连接也会被拒绝,于是,心跳检测失败,于是,一切都悄悄的发生了。

2.3. 糟点三:看上去很美的自动切换

Cobar 很诱人的一个特性是高可用性,高可用性的原理是数据节点 DataNode 配置引用两个 DataSource,
并做心跳检测,当第一个 DataSource 心跳检测失败后,Cobar 自动切换到第二个节点,当第二个节点失败以后,又自动切换回第一个节点,一切看起来很美,无人值守,几乎没有宕机时间。

在真实的生产环境中,我们通常会用至少两个 Cobar 实例组成负载均衡,前端用硬件或者 HAProxy 这样的
负载均衡组件,防止单点故障,这样一来,即使某个 Cobar 实例死了,还有另外一台接手,某个 Mysql 节点死了,
切换到备节点继续,至此,一切看起来依然很美,喝着咖啡,听着音乐,领导视察,你微笑着点头——No
problem,Everything is OK! 直到有一天,某个 Cobar 实例果然如你所愿的死了,不管是假死还是真死,你按照早已做好的应急方案,优雅的做了一个不是很艰难的决定——重启那个故障节点,然后继续喝着咖啡,听着音乐,轻松写好故障处理报告发给领导,然后又度过了美好的一天。

你忽然被深夜一个电话给惊醒,你来不及发火,因为你的直觉告诉你,这个问题很严重,大量的订单数据发
生错误很可能是昨天重启 cobar 导致的数据库发生奇怪的问题。你努力排查了几个小时,终于发现,主备两个库都在同时写数据,主备同步失败,你根本不知道那个库是最新数据,紧急情况下,你做了一个很英明的决定,停止昨天故障的那个 cobar 实例,然后你花了 3 个通宵,解决了数据问题。

这个陷阱的代价太高,不知道有多少同学中枪过,反正我也是躺着中枪过了。若你还不清楚为何会产生这个陷阱,现在我来告诉你:

  • Cobar 启动的时候,会用默认第一个 Datasource 进行数据读写操作;
  • 当第一个 Datasource 心跳检测失败,会切换到第二个 Datasource;
  • 若有两个以上的 Cobar 实例做集群,当发生节点切换以后,你若重启其中任何一台 Cobar,就完美调入陷阱;

那么,怎么避免这个陷阱?目前只有一个办法,节点切换以后,尽快找个合适的时间,全部集群都同时重启,避免隐患。为何是重启而不是用节点切换的命令去切换?想象一下 32 个分片的数据库,要多少次切换?

2.4. 糟点四:只实现了一半的 NIO

NIO 技术用作 JAVA 服务器编程的技术标准,已经是不容置疑的业界常规做法,若一个 Java 程序员,没听
说过 NIO,都不好意思说自己是 Java 人。所以 Cobar 采用 NIO 技术并不意外,但意外的是,只用了一半。

Cobar 本质上是一个“数据库路由器”,客户端连接到 Cobar,发生 SQL 语句,Cobar 再将 SQL 语句通过后端与 MySQL 的通讯接口 Socket 发出去,然后将结果返回给客户端的 Socket 中。下面给出了 SQL 执行过程简要逻辑:

SQL -> FrontConnection -> Cobar -> MySQLChanel -> MySQL 
复制代码

FrontConnection 实现了 NIO 通讯,但 MySQLChanel 则是同步的 IO 通讯,原因很简单,指令比较复杂,
NIO 实现有难度,容易有 BUG。后来最新版本 Cobar 尝试了将后端也 NIO 化,大概实现了 80%的样子,但没有
完成,也存在缺陷。

由于前端 NIO,后端 BIO,于是另一个有趣的设计产生了——两个线程池,前端 NIO 部分一个线程池,后
端 BIO 部分一个线程池。各自相互不干扰,但这个设计的结果,导致了线程的浪费,也对性能调优带来很大的困难。
由于后端是 BIO,所以,也是 Cobar 吞吐量无法太高、另外也是其假死的根源

2.5. 糟点五:阻塞、又见阻塞

Cobar 本质上类似一个交换机,将后端 Mysql 的返回结果数据经过加工后再写入前端连接并返回,于是前
后端连接都存在一个“写队列”用作缓冲,后端返回的数据发到前端连接 FrontConnection 的写队列中排队等待被发送,而通常情况下,后端写入的的速度要大于前端消费的速度,在跨分片查询的情况下,这个现象更为明显,于是写线程就在这里又一次被阻塞

解决办法有两个,增大每个前端连接的“写队列”长度,减少阻塞出现的情况,但此办法只是将问题抛给了使用者,要是使用者能够知道这个写队列的默认值小了,然后根据情况进行手动尝试调整也行,但 Cobar 的代码中并没有把这个问题暴露出来,比如写一个告警日志,队列满了,建议增大队列数。于是绝大多数情况下,大家就默默的排队阻塞,无人知晓。

2.6. 糟点六:又爱又恨的 SQL 批处理模式

正如一枚硬币的正反面无法分离,一块磁石怎样切割都有南北极,爱情中也一样,爱与恨总是纠缠着,无法理顺,而 Cobar 的 SQL 批处理模式,也恰好是这样一个令人又爱又恨的个性。

通常的 SQL 批处理,是将一批 SQL 作为一个处理单元,一次性提交给数据库,数据库顺序处理完以后,再
返回处理结果,这个特性对于数据批量插入来说,性能提升很大,因此也被普遍应用。JDBC 的代码通常如下:

    String sql = "insert into travel_record (id,user_id,travel_date,fee,days) values(?,?,?,?,?)";
    ps = con.prepareStatement(sql);
    for (Map<String, String> map : list) {
        ps.setLong(1, Long.parseLong(map.get("id")));
        ps.setString(2, (String) map.get("user_id"));
        ps.setString(3, (String) map.get("travel_date"));
        ps.setString(4, (String) map.get("fee"));
        ps.setString(5, (String) map.get("days"));
        ps.addBatch();
    }
    ps.executeBatch();
    con.commit();
    ps.clearBatch();
复制代码

但 Cobar 的批处理模式的实现,则有几个地方是与传统不同的:

  • 提交到 cobar 的批处理中的每一条 SQL 都是单独的数据库连接来执行的;
  • 批处理中的 SQL 并发执行。

并发多连接同时执行,则意味着 Batch 执行速度的提升,这是让人惊喜的一个特性,但单独的数据库连接并
发执行,则又带来一个意外的副作用,即事务跨连接了,若一部分事务提交成功,而另一部分失败,则导致脏数据问题
。看到这里,你是该“爱”呢还是该“恨”?

我们继续看看 Cobar 的逻辑,SQL 并发执行,其实也是依次获取独立连接并执行,因此
还是有稍微的时间差,若某一条失败了,则 cobar 会在会话中标记”事务失败,需要回滚“,下一个没执行的
SQL 就抛出异常并跳过执行,客户端就捕获到异常,并执行 rollback,回滚事务。

绝大多数情况下,数据库正常运行,此刻没有宕机,因此事务还是完整保证了,但万一恰好在某个 SQL commit 指令的时候宕机,于是杯具了,部分事务没有完成,数据没写入。但这个概率有多大呢?一条 insert insert 语句执行 commit 指令的时间假如是
50 毫秒,100 条同时提交,最长跨越时间是 5000 毫秒,即 5 秒中,而这个 C 指令的时间占据程序整个插入逻辑的时间的最多 20%,假如程序批量插入的执行时间占整个时间的 20%(已经很大比例了),那就是 20%×
20%=4%的概率,假如机器的可靠性是 99.9%,则遇到失败的概率是 0.1%×4%=十万分之四。十万分之四,意味着 99.996%的可靠性,亲,可以放心了么?

另外一个问题,即批量执行的 SQL,通常都是 insert 的,插入成功就 OK,失败的怎么办?通常会记录日志,重新找机会再插入,因此建议主键是能日志记录的,用于判断数据是否已经插入。
最后,假如真要多个 SQL 使用同一个后端 MYSQL 连接并保持事务怎么办?就采用通常的事务模式,单条执
行 SQL,这个过程中,Cobar 会采用 Session 中上次用过的物理连接执行下一个 SQL 语句,因此,整个过程是与通常的事务模式完全一致。

2.7. 糟点七:竟然有数据库死锁

说起死锁,貌似我们大家都只停留在很久远的回忆中,只在教科书里看到过,也看到过关于死锁产生的原因
以及破解方法,只有 DBA 可能会偶尔碰到数据库死锁的问题。但很多用了 Cobar 的同学后来经常发现一个奇怪的问题,SQL 很久没有应答,百思不得其解,无奈之下找 DBA 排查后发现竟然有数据库死锁现象,而且比较频繁发生。

要搞明白为什么 Cobar 增加了数据库死锁的概率,只能从源码分析,当一个 SQL 需要拆分为多条 SQL 去到多个分片上执行的时候,这个执行过程是并发执行的,即 N 个 SQL 同时在 N 个分片上执行,这个过程抽象为教科书里的事务模型,就变成一个线程需要锁定 N 个资源并执行操作以后,才结束事务。当这 N 个资源的锁定顺序是随机的情况下,那么就很容易产生死锁现象。而恰好 Cobar 并没有保证 N 个资源的锁定顺序,于是我们再次荣幸“中奖”。

2.8. 糟点八:出乎意料的连接池

数据库连接池,可能是仅次于线程池的我们所最依赖的“资源池”,其重要性不言而喻,业界也因此而诞生了多个知名的开源数据库连接池。我们知道,对于一个 MySQL Server 来说,最大连接通常是 1000-3000 之间,这些连接对于通常的应用足够了。

通常每个应用一个 Database 独占连接,因此足够用了,而到了 Cobar 的分表分库这里,就出现了问题,因为 Cobar 对后端 MySQL 的连接池管理是基于分片——Database 来实现的,而不是整个 MySQL 的连接池共享,以一个分片数为 100 的表为例,假如 50 个分片在 Server1 上,就意味着 Server1 上的数据库连接被切分为 50 个连接池,每个池是 20 个左右的连接,这些连接池并不能互通,于是,在分片表的情况下,我们的并发能力被严重削弱。明明其他水池的水都是满的,你却只能守着空池子等待。

2.9. 糟点九:无奈的热装载

Cobar 有一个优点,配置文件热装载,不用重启系统而热装载配置文件,但这里存在几个问题,其中一个问题是很多人不满的,即每次重载都把后端数据库重新断连一次,导致业务中断,而很多时候,大家改配置仅仅是为了修改分片表的定义,规则,增加分片表或者分片定义,而不会改变数据库的配置信息,这个问题由来已久,但却不太好修复。

2.10. 糟点十:不支持读写分离

不支持读写分离,可能熟悉相关中间件的同学第一反应就是惊讶,因为一个 MySQL Proxy 最基本的功能就
是提供读写分离能力,以提升系统的查询吞吐量和查询性能。

但的确 Cobar 不支持读写分离,而且根据 Cobar 的配置文件,要实现读写分离,还很麻烦。可能有些人认为,因为无法保证读写分离的时延,无法确定是否能查到之前写入的数据,因此读写分离并不重要。但实际上,Mycat 的用户里,几乎没有不使用读写分离功能的,后来还有志愿者增加了强制查询语句走主库(写库)的功能,以解决刚才那个问题。

当大批软件工程师开始觉醒,用互联网思维思考和规划自己的人生,第四次工业革命才拉开序幕—— Mycat 闪耀登场