背景介绍

该消息中间件由三个子系统组成:客户端SDK、Server和控制管理中心(Admin)。我们所说的主备主要是针对Server,而Server的节点是局部有状态的,具体而言就是发布无状态,但是拉取有状态。因此,对于发布而言,由于分布式部署所以本身具备高可用性;但是对于拉取,就存在单点的可用性问题。

主备切换的关键问题

实现主备切换,需要解决两个核心问题:

  1. 解决主备之间状态检测、通信和决策问题。
    通过心跳检测来得知互相之间的状态,一旦得知对方状态异常需要接管对方的资源继续提供服务。
  2. 解决资源转移的问题。
    通过资源共享机制或者资源同步冗余方式来解决资源转移的问题。

任何一套主备切换方案都需要解决这两个基本问题。

主备切换思路

  1. 故障检测
  2. 状态和决策
  3. 资源切换

方案比较

  1. Zookeeper

    如果使用Zookeeper,每组主备Server需要部署Zookeeper,作为Zookeeper Server;SDK作为Zookeeper Client,建立和主备Server的连接,并且监控Zookeeper Server的事件。如果发生不可用,则可以通过监听到的事件,来触发主备切换。那么其实,我们使用Zookeeper只解决了第一个问题。至于第二个问题,如果是Single模式,资源转移可以通过DB中间件来共享资源解决;如果是Master-Slave模式,则需要Server自身的开发来解决。

  2. 负载均衡软件(LVS、HAProxy、Nginx)+ 高可用软件(Keepalived、Heartbeat)

    这种方案是用于负载均衡Server的高可用,Keepalived和Heartbeat也只是解决了第一个问题;第二个问题是负载均衡软件来解决。

  3. 自研
    自研方案一

    1. Admin作为观察者,建立和每一个主备server的的连接,并通过心跳检测监控连接状态。Admin管理每组主备的关系和其激活状态。需要考虑Admin本身的高可用性。
    2. Server的备机启动项区别于主机,并且只提供拉取服务,其他服务拒绝。另外,备机在恢复时,需要先停止服务,然后同步消费状态;Server的主机在恢复时,首先要从Admin拉取消费状态信息,并更新到DB。
    3. SDK定时从飞鸽Admin拉取飞鸽Server的信息,如果发现Slave被激活,则建立同Slave的连接,剔除Master连接;如果发现Master被激活,则建立同Master的连接,待连接建立成功后,再剔除Slave连接。

    主备切换序列图:

    主备切换序列图:

    自研方案二

    1. 在SDK和Server之间加一个飞鸽Proxy,其负责主备切换,并且负责路由,这样可以轻量化SDK。因为目前server的连接是带了鉴权状态的,因此就需要将鉴权功能上移到proxy。或者考虑是否可以去除连接的状态?连接是否有状态会影响proxy本身的高可用方案,如果连接无状态,直接采用F5即可;如果有状态,则proxy本身的高可用方案又是个难题了。
    2. Proxy保持这和Master和Slave的长连接,并且做心跳检测,检测达到切换条件,则触发切换动作。
    3. Proxy方案对现有飞鸽方案的冲击比较大,改动面比较多。

测试环境

应用结构:
JWS+2次库表写入+分库分表
目标:TPS=5000
测试工具:Jmeter、Sysbench
并发线程:16

性能目标

TPS>=5000

现状

采用Jmeter在16并发场景下测试2分钟,最终并发在1200-1300之间,距离性能目标比较遥远。

优化过程

  1. 首先考虑可能是代码某个地方有问题,暂时没有考虑数据库,因为根据经验不应该这么低。所以,开始反复注释怀疑代码(不包括访问数据库的代码),但实际测试发现数据并无任何改变。
  2. 开始怀疑可能是数据库插入操作比较耗时。注释数据库插入代码后,发现TPS明显提升至14000左右,因此可以断定瓶颈是在数据库插入造作。
  3. 采用Jmeter对MySQL进行基准测试,测试得出当前MySQL的插入TPS<2800,并且观察数据库服务器IO uitil保持在80%以上,因此断定当前MySQL配置已经达到顶峰。
  4. 检查MySQL配置。根据《MySQL配置原理和技巧(ver 0.2).ppt》检查关键配置项,最终发现general-log配置为1,修改为0后,性能提升300左右,达到3100多。
  5. 担心Jmeter测试数据不准,采用Sysbench对MySQL做性能测试,发现TPS依然是3100,最终说明现有MySQL最高性能也就是3100了。
  6. 应用每次调用会做两次数据库插入操作,因此按照3100换算后,可以推算出应用的TPS不会高于1500,和测试情况相符。向DBA询问线上MySQL的写入性能数据为5000,则推算线上应用TPS应低于2500,依然无法达标。
  7. 采用异步队列+批量插入的方式,最终将TPS提升到8000以上。

优化方案

  1. 应用内部构建异步队列,用于存储请求消息。所有的请求,提交到异步队列即立刻返回。
  2. 构建调度线程,调度频率为100毫秒;调度线程负责计算每个工作线程需要处理的请求消息数,并提交工作任务到工作线程池,并同步等待其完成。
  3. 工作线程池负责消费异步队列中的消息,其线程数是3。
  4. 每条工作线程根据其要处理的请求消息数,决定批量进行Insert操作的数量,而不是一个请求Insert一次。

注:MySQL插入性能优化可参见:http://tech.uc.cn/?p=634

无论是分析问题还是学习知识,都是有规律可循的,遵循这个规律会事半功倍。这个规律,我们就可以称之为方法论。总结起来就是三个关键词:What、How、Why。但是二者不同之处在于三个关键点的组织顺序不一样。

分析问题

方法论:Why -> What -> How

举例来说,现在我们要解决这样一个问题(需求):设计一个消息中间件的主备方案。

问题很明确,但是我们要怎么快速、清晰的分析,并最终解决这个问题呢?让我们根据方法论来一步一步分析。

  1. Why

    我们为什么要做主备方案呢?因为想避免在A主机宕机的场景下,客户端程序依然可以正常的发布和拉取消息。其实Why很简单,但是其却很重要。不分析清楚Why,就失去了驱动力;没有驱动力,你的上级会提供资源支持你解决这个问题吗?下级能够更合理的设计和开发吗?显然不能!

  2. What

    主备方案是为了提升应用的高可用。可是这是比较笼统的一个目标,并不是很清晰,比如究竟要达到什么程度的高可用呢?是要所有功能都要高可用?还是某些功能高可用?确定了这些细节,那么我们就搞清楚了What,也就是我们最终会把这个问题解决到什么程度?是全部解决?还是部分解决?总结起来What的关键如下:

    1. 我们在什么场景下,保证(或提供)什么;
    
    2. 我们在什么场景下,不保证(或不提供)什么。
    

    假设最终我们分析后,确定我们是要保证在任何一台主机宕机的情况下,依然能够正常提供消息的发布和拉取。继续分析,假设本身是集群部署,那么对于发布消息来说,本身就是高可用的,因为A主机不能发布消息,我发布到B、C…主机就可以了。但是对于拉取消息来说,如果底层不是共用存储,一旦A主机宕机,那么其消息就无法拉取,很明显不具备高可用。

    经过上边的分析,那么其实我们就明白了,我们的核心目标是要实现消息拉取功能的高可用性。

  3. How

    搞清楚了What,就要开始动手了。动手之前,需要考虑清楚怎么做。通常来讲,主备方案需要解决两个核心问题:

    1. 故障检测
    
    2. 状态和决策
    

    具体到每一个核心问题究竟要怎么解决?采用什么技术解决?这些都是How要解决的问题。关于主备方案的详细设计过程,参见博文:

学习知识

方法论:What -> How -> Why

人类学习一个技能或者一个知识,总是遵循由浅入深的规律,What -> How -> Why。下边拿我们学习Spring框架的来说。

  1. What

    Spring是什么?它的主要特点是什么?它能帮助我们干什么?知道了这些,你就可以在可以说:我了解Spring。如果你先工作中暂时用不到,认为没必要深入学习,那知道What就够了。将来在做方案决策的时候,就可以进行简单的方案对比。

  2. How

    Spring怎么用于开发?功能怎么搭建?依赖怎么管理?知道了这些,你就可以说:我熟悉Spring,能够独立的应用Spring进行应用开发。

  3. Why

    Spring为什么要采用依赖注入?为什么要采用插件式设计?为什么要配置优先?更深一点,为什么某段代码是这么设计?知道了这些,那么你就可以说:我精通Spring,甚至可以编写类似的MVC框架。

建议:阅读本文之前,最好对于TCP的发送、重发以及ACK机制有所了解。

问题描述

最近在一个消息中间件系统(该消息中间件由客户端SDK和服务端Server组成)的性能测试时,发现每个请求的响应时间大概在40ms-50ms之间,这明显过大了。最终定位,是因为SDK没有禁用TCP的Nagle算法导致的。但其根本原理是因为TCP的Delayed Ack机制和Nagle Algorithm相互影响导致的。

概念详解

TCP-Delayed Ack
目的:用于防止只发送一个单独的Ack包,而是希望发送的包是一个Ack+一份数据组成一个包,这样能较少交互次数,减少网络资源消耗。这个设定是基于对于一般交互场景的一个基本假设:数据接收方会生成一个响应给数据发送方。
原理:当数据接收方收到一个TCP包之后,先不回应ACK包,而是等待一定的时间(本例中是40ms),直到

  1. 数据接收方发送一份响应数据
    当数据接收方,收到足够的数据,进行业务处理后,一般会返回业务响应,这时会立即返回:业务响应+ACK。
  2. 接收到连续的TCP包
    当服务接收端接收到1号TCP包后,会先延迟发送1号包的ACK,等待2号包到来,2号包到达时,则立即返回一次ACK;当3号包到来时,会延迟3号包的ACK,等待4号包到来,4号包到达时,则立即返回一次ACK……
    那么,这样就造成偶数序号的TCP包到达的时候,就会立即返回一次ACK;而奇数序号的包到达时,则延迟ACK响应,等待后续的偶数包到来。
  3. 超时
    超过40ms。

TCP-Nagle Algorithm
目的:用于防止发送大量的小包,降低网络资源消耗。
原理:当数据发送方写入TCP缓冲区的数据小于MSS(最大报文长度),则暂不发送,等待写入的数据达到MSS再发送;除非在等待的过程中,发送端发送出去的所有TCP报文均已被ACK,这样就可以不用等待写入数据达到MSS,直接发送出去了。我们称没有达到MSS的报文为小包的话,那么其实TCP-Nagle Algorithm就保证了一个连接在一个时刻,有且只能有一个没有被确认的小包。
关于以上两点,可以参考这篇文章,介绍的很详细:http://www.stuartcheshire.org/papers/NagleDelayedAck/

过程分析

该消息中间件系统在做性能测试时,SDK没有禁用Nagle算法,而Server端禁用了;测试时的消息长度为消息头(12)+消息体(14)Byte,TCP MSS是1460Byte;服务端和SDK交互采用类似TCP三次握手的确认机制来保证高可靠性。那么考虑如下过程:

  1. SDK端发送第一条消息,写入了TCP Buffer,虽然未达到MSS,但是因为没有需要确认的包,所以会立即发送;
  2. Server端收到TCP包后,就延迟ACK响应;同时SDK端由于启用了Nagle算法,并且存在没有ACK的包,因此处于等待中;
  3. Server端业务层解包进行业务处理,处理完成后,立即发送业务响应,并捎带ACK返回给SDK;
  4. SDK端收到了Server端返回的响应和ACK后,立即回复业务确认消息,并捎带ACK;
  5. 由于Server端收到业务确认消息后,不用再返回响应给SDK,因此延迟ACK确认;
  6. SDK端发送第二条消息,但是因为启用了Nagle算法,所以必须等待ACK。直到第5步的延迟确认到达,则立即发送第二条消息。

Tcpdump抓包如下:

1
2015-05-25 18:42:49.736897 IP 100.84.52.90.58237 > 100.84.73.45.9090: P 71:101(30) ack 20 win 16420
E..FU3@.=..OdT4ZdTI-.}#...(.R.mpP.@$.).......)....
queueAThis is test 
2015-05-25 18:42:49.737763 IP 100.84.73.45.9090 > 100.84.52.90.58237: P 20:39(19) ack 101 win 46
E..;Fn@.@...dTI-dT4Z#..}R.mp..)	P...F].......*......SUCCESS
2015-05-25 18:42:49.744738 IP 100.84.52.90.58237 > 100.84.73.45.9090: P 101:113(12) ack 39 win 16415
E..4U4@.=..`dT4ZdTI-.}#...)	R.m.P.@.d........4......
2015-05-25 18:42:49.784810 IP 100.84.73.45.9090 > 100.84.52.90.58237: . ack 113 win 46
E..(Fo@.@..1dTI-dT4Z#..}R.m...).P.......
2015-05-25 18:42:49.785438 IP 100.84.52.90.58237 > 100.84.73.45.9090: P 113:143(30) ack 39 win 16415
E..FU5@.=..MdT4ZdTI-.}#...).R.m.P.@..........)....
queueAThis is test 
2015-05-25 18:42:49.785450 IP 100.84.73.45.9090 > 100.84.52.90.58237: . ack 143 win 46
E..(Fp@.@..0dTI-dT4Z#..}R.m...)3P.......
2015-05-25 18:42:49.786147 IP 100.84.73.45.9090 > 100.84.52.90.58237: P 39:58(19) ack 143 win 46
E..;Fq@.@...dTI-dT4Z#..}R.m...)3P...F].......*......SUCCESS
2015-05-25 18:42:49.790470 IP 100.84.52.90.58237 > 100.84.73.45.9090: P 143:173(30) ack 58 win 16410
E..(Fo@.@..1dTI-dT4Z#..}R.m...).P.......

总结:通过以上过程可以发现,由于第5步的延迟ACK,导致SDK发送消息延迟,最终导致了性能下降。如果SDK端关闭的Nagle算法,SDK就可以立即发送第二条消息,而不会受到Nagle算法的约束,一直等待第5步的ACK到达。

代码说明

在Netty中,可以通过设置tcpNoDelay选项,开启或者禁用Nagle算法。

1
2
3
4
5
6
7
8
// 用来禁用TCP的Nagle算法。
bootstrap.setOption("tcpNoDelay", true);

// Netty底层其实就是使用JDK中的Socket.class的setTcpNoDelay方法来设置。
/**
* Enable/disable TCP_NODELAY (disable/enable Nagle's algorithm).
*/

public void setTcpNoDelay(boolean on) throws SocketException;

概述

零拷贝是我们经常听到的一个术语,要解释清楚这个术语,我个人觉得需要说明三个场景。

  1. Java中的DirectBuffer;
  2. Java中的transferTo,底层就是Linux的sendFile;
  3. Linux中的Direct IO;

本系列分三篇来讲解。

DirectBuffer的特点

Java中的DirectBuffer的具体用法和API,此处不再赘述。我们主要理解DirectBuffer的原理。

  1. DirectBuffer是分配在JVM堆外的进程地址空间的,因此,相对于在JVM堆中分配的堆内Buffer,也叫做堆外Buffer。这个地方的堆外,从操作系统的角度讲其实就是JVM进程的地址空间,那么自然也就属于用户空间了,堆也属于JVM进程地址空间。为什么我们要强调用户空间呢?其实是后边的篇幅说明DirectBuffer的“零拷贝”意义。
  2. DirectBuffer其实是JVM本地代码经过malloc系统调用,在当前JVM进程的地址空间申请的一段内存空间。它的引用依然保存在JVM堆中,只是其存储空间在堆外而已。DirectBuffer的引用一般是直接在JVM堆中的老年代创建的,因此,其回收的时机只有在JVM进行Full GC时。基于这个回收特点,我们需要特别注意,不要频繁的分配大量的DirectBuffer,否则很容易导致内存溢出,而且这个时候的溢出,不是堆溢出,而是本地内存溢出。

DirectBuffer的好处

DirectBuffer带来的性能的提升,其根本原因是节省了一次从堆内空间到堆外空间的Buffer拷贝,下图可以说明这个过程。其实省去的就是红线所示的从堆内存到本地内存的拷贝。

##sendFile

待补充……

##DirectIO
待补充……