本文转载自:LMAX系统架构 ,(非常感谢作者yfx416分享好文)

  

  很多架构师都面临这么一个问题:如何设计一个高吞吐量,低延时的系统?面对这个问题,各位都有自己的答案。但面对这个问题,大家似乎渐渐形成了一个共识:并发是解决之道。大家似乎都这么认为:对于服务器而言,由于多核越来越普遍,因此我们的程序必须要充分利用多线程,为了让多线程工作得更好,必须有一个与之匹配的高效的并发模型。于是各种各样的并发模型被提出来,比如Actor模型,比如SEDA模型(Actor模型的表弟),比如Software Transactional Memory模型(准确得讲,STM和其他两个模型所处在的视角是不一样的,Actor和SEDA更多是一种编程模型,而STM更类似一种思想,其实我们常用到的Lock-Free机制都包含了STM的思想在里头)。

这些模型得到了广泛讨论和应用。但这些模型都有一个讨厌之处-麻烦。这个麻烦是由多线程复杂的天性带来的,很难避免。除了麻烦,这些模型还忽视了另外一个问题,由于这个问题的忽视,可能导致这些模型在解决高性能问题的道路上走到了一个错误的方向。这个问题就是JVM的伪共享问题。所谓JVM的伪共享,简单来说,就是JVM的每一个操作指令都是基于一个缓存行,同一个缓存行中的数据是不能同时被多个线程同时修改的,也就是说,如果多线程各自操作的数据位于同一个缓存行,那么这几个线程访问数据时实际上被加上了一把隐形的锁,它们实际上在顺序地访问数据。(如果你看过JDK Concurrent的实现,你可以看到有些类很奇怪得加了很多无用的padding成员,这就是为了填充缓存行,从而绕过JVM的伪共享)。由于JVM伪共享的存在,使得多线程在某些情况下成了一个摆设。这也就是说大多数情况下我们的枪炮瞄错了方向,我们通常认为没有充分利用多线程压榨多个CPU的能力是造成性能问题的原因,实际上缓存问题才是性能杀手。

于是LMAX就做了一个大胆的尝试。既然多线程在JVM中有可能成为摆设,而且又这么麻烦,那么干脆回到单线程来吧。用单线程来实现一个高吞吐量,低延时的系统?听起来很疯狂,但实际上是可能的。LMAX就用单线程实现了一个吞吐量达到百万TPS的系统。

这里讲LMAX是单线程,并不是它完全只有一个线程,LMAX组件还是有用到多线程。只不过LMAX充分认识到了单线程的意义,在某些组件中大胆得采用单线程的架构,这就是LMAX所谓的单线程。LMAX决定组件是否采用单线程的依据很简单,如果某一个组件是IO密集型的,那么这个组件的设计就使用多线程。如果某一个组件是CPU密集型的,那么该组件就使用单线程的设计。这么做的理由很简单,IO密集型的组件的操作一般都很慢,往往会阻塞线程,因此使用多线程来竞争执行,有提高的余地。而CPU密集型的操作,如果采用多线程的设计,一方面可能会陷入JVM伪共享的陷阱,另一方面多线程之间的同步会带来开发的复杂性,同时多线程会竞争某些资源,比如队列等等,这些竞争会对计算机cache命中造成扰动,而且有可能引入锁这种性能杀手,与这两点相比,多线程带来的好处相当有限,因此就采用单线程。

【LMAX的原则】

LMAX的设计令人耳目一新,它的设计也向我们分享了高性能计算中的几个重要经验或者说原则:

1. 所有的架构师和开发人员都应该具有良好的Mechanical Sympathy(这个单词不太好翻译,“机制共鸣”?)所谓Mechanical Sympathy,实际上就是指架构设计者应该对现代操作系统,现代服务器的底层运行机制有良好的理解和认识,设计的时候充分考虑到这些机制,能够和它们产生共鸣。这很容易理解,如果一个架构师对底层机制的认识不够深入或者还停留在过去,那么很难想象这样的架构师能设计出一个基于现代服务器的高效系统来。LMAX在文档中向我们分享了几点对现代服务器的认识:

1.1 内存

衡量内存有两个指标:Bandwidth和Latency。所谓bandwidth指的是内存在单位时间内通过内存总线的数据量,它计算公式是bandwidth = 传输倍率*总线位宽*工作频率/8,单位是Bytes/s(字节每秒)。传输倍率指的是内存在一个脉冲周期内传输数据的次数,比如DDR一个脉冲周期内可以在上升沿传递一次数据,在下降沿传递一次数据,而SDRAM只能在脉冲周期的上升沿传递数据。工作频率的是内存的工作频率,比如133Mhz等等。

而Latency是指内存总线发出访问请求到内存总线返回数据之间的延迟时间,单位是纳秒。

这两个指标描述了内存性能的两个方面,bandwidth描述了内存可以以多快的速度来传递数据,反映了吞吐量,而latency从更底层的细节描述了内存的物理性能。一个内存的bandwidth说明的仅仅是内存在内存边界的传输的速率,而数据在内存内部的流动速度是靠latency来决定。这就像赶飞机,bandwidth就好像是T3航站楼的门的大小,门的大小决定了T3每秒能够接纳的旅客数量。而安检的速度就是latency,它也会影响你最终登上飞机的时间。Bandwidth加上latency才能完全描述内存整个环节的性能。最终内存的性能可以用内存性能 = (bandwidth*latency)来近似描述(Little's Law)。

尽管硬件技术一日千里,但这些年来,服务器内存的延迟并没有发生数量级的变化。但是内存的bandwidth还是获得了很大的进步,因此整体而言内存的性能还是有比较大的提高。

另外内存的容量也是越来越大,144G大小的内存配置也是相当普遍。

1.2 CPU

对于CPU而言,单纯的提高主频的方法已经走到尽头,Intel的主频可能会在Ghz这个量级上停留很长的一段时间。

CPU的核数是越来越多,24核的服务器也很普遍。

CPU的缓存机制也越来越强大,一方面CPU缓存变大了,另一方面Intel又提出了Smart cache等概念,相比于传统的L1 cache, L2 cache又提高了一步。

1.3 网络

服务器本地的网络响应时间非常快,处于sub 10 microseconds这个级别。(10 ms在操作系统中一般是一个时钟滴答,sub 10 microseconds意味着小于一个时钟滴答,我们知道Linux的延时,线程切换都是基于时钟滴答的,也就是说本地网络速度是很快的,对于大多数的应用来讲,几乎可以忽略不计)。

广域网的带宽是比较便宜的。

10GigE(10Gbps的以太网卡)的服务器非常普遍。

Multi-cast技术越来越得到关注,应用也越来越多。

1.4 存储

硬盘是新一代的磁带。磁盘对于顺序访问的速度是非常快的。
对于并发的随机访问,考虑采用SSD。

SSD的接口一般都是PCI总线接口,速度更快。

2. 把工作放到内存中来

尽可能把一些数据都放到内存中来,避免和磁盘的低效交互。

3. 写的代码要缓存友好。

什么样的代码是缓存友好的代码?这个一言难尽。但总的原则就是,保持访问的局部性,也就是说尽可能使一段时间内的访问保持在一个狭小的内存范围内。常用的一个做法就是,先统一分配一个对象池,然后复用对象池中的对象,不要每次都是重新分配新的对象。

LMAX系统架构-LMLPHP

上图显示了各个层次的缓存的访问效率,提醒我们要对缓存敏感。

4. 要时刻牢记,代码要干净,简练

Hotspot虚拟机喜欢短小,简练的代码;

如果CPU的分支预测不准确,那么CPU流水线会被阻断;

复杂的代码是一个危险的信号,这意味着你有可能没有正确理解问题的领域(DDD里的概念);

世界上的事情都不会很复杂,除了扣税的方法。

5. 多花点时间考虑一下你的领域模型。

记住这么几个原则:

责任单一:一个类只干一件事,一个方法也只干一件事,不要臃肿的类或方法。

了解你的数据结构和关系基数(一对一的关系?一对多?还是多对多)

让关系来完成工作,比如“书架”和“书”之间存在一个“attach”的关系,既然如此,我们可以让“书架”有一个方法叫attach,用来处理添加书本的工作,这就是让关系来完成工作。这实际上也是DDD里面的一些设计原则。

6. 采用正确的方法来实现并发。

实现并发需要考虑两件事:

资源互斥和变化可见(让结果以一个正确的顺序出现)

并发的实现一般有两种方法:

第一个方法是用锁来保证,另一个方法是借助于CAS进行无锁编程。

使用锁会导致内核态的切换,但总可以确保任何时刻总有一个进程会被执行(相比之下Lock-Free如果代码逻辑出现问题,有可能所有线程都处在自旋等待状态,无法前进),锁也增加了编程的难度。

而借助于CAS的Lock-Free则始终是运行在用户态的(节省了效率,避免了无谓的上下文切换),相比于锁,它的编程难度更加大。下面图形象地表达了Lock和Lock-Free之间的区别:

LMAX系统架构-LMLPHP

这些原则大部分都是老生常谈,但很容易被人忽略,总之这些原则提醒我们:

1) 很多程序员对现代服务器的硬件有着一个错误的认识或者根本没有认识,他们根本就不知道单线程所能达到的性能高度。

2) 对于现代处理器,缓存丢失才是性能的最大杀手。

3)架构设计时,把并发放到infrastructure层里去考虑,这样一方面使得应用层的编写避免了并发编程的复杂性,另一方面由于并发放在了相对单纯的infrastructure层,避免了来自应用层的乱七八糟的干扰,更容易优化。

4) 牢记上述3条原则,一旦你实现了这3条,那恭喜你,你已经进入了理想王国:

单线程;

所有的一切都在内存中;

优雅的模式;

易于测试的代码;

不用担心infrastructure和集成的问题。

太完美了!

【LMAX的架构

接下来就来谈谈LMAX具体的架构,LMAX正是基于上述原则下的产物。

LMAX系统架构-LMLPHP

LMAX的总体架构就如上图,它分为三个部分:

l Business logic processor

l Input disruptor

l Output disruptors

Business logic processor是一个单线程的java进程,用来处理方法调用,产生输出事件。由于它是一个单线程的简单的java程序,因此它除了JVM本身之外不依赖于任何其他的framework,这就使得我们很容易把它放入一个测试环境进行测试,这就是所谓的“易于测试的代码”。

Input Disruptor是用来处理输入消息的,输入消息从网络中接收,需要进行反序列化(unmarshaled),需要进行replicated避免单点故障,需要journaled来记录消息日志从而能够进行故障恢复。

Output Disruptors用来处理输出消息,这些消息需要进行序列化以便于网络传输。

Input Disruptor和output disruptor都是多线程的,因为他们设计到大量的IO操作,这些IO操作很慢而且相互独立。

Business logic processor

Business logic processor的整个处理都是放在内存中的,这样带来的好处很多:

l 速度快,一切尽在内存,没有缓慢的磁盘IO

l 简单,因为所处理的都是java对象模型,不需要进行数据库和java对象之间的映射

由于一切尽在内存中,因此一个需要认真考虑的问题是如果Business logic processor发生了crash怎么办?Lmax解决这个问题的思路很直接,它在input distruptor上运行了一个定期的任务,该任务(journal任务)的职责就是在一个合适的时间(比如每天的夜里12点)生成一个输入信息的快照,只要有了这些输入信息的快照,processor在crash之后只要一被重启,它会重新再处理一遍这些输入信息,处理之后它就能够把整个系统带回到crash之前的最新状态。

Business logic processor还有一个优势就是易于诊断,比如团队发现了在线的产品上的一些问题,一个简单的做法是利用replicated任务产生的副本,把它放到一个安全的测试机器上,然后重新运行processor进行debug,这不会影响产品。也就是说business logic processor好像一台录像机,只要记录了拷贝,它可以在任何地方进行回放。这个特性非常吸引人,这也是为什么近些年来event sourcing这样的方法得到重视的原因。

为了达到性能最大化,Lmax甚至实现了自己的数据结构,比如自己的List等等。

Business logic processor的一个重要特点就是不和任何外部服务进行交互。因为调用外部服务的速度会比较慢,processor又是单线程的,因此外部服务会拖慢整个processor的速度。Processor只和event交互,要么接受一个event,要么产生一个event。怎么理解呢?举个例子就明白了,比如电商网站通过信用卡来订购商品。普通青年的做法就很直接,先获取订单信息,通过银联的外部服务来验证信用卡信息是否有效(这意味着信用卡号如果有问题,根本就不会生成订单),然后生成订单信息入库,这两步放在一个操作里。由于信用卡验证服务是一个外部服务,因此操作往往会被阻塞较长的一段时间。

Lmax则另辟蹊径,它把整个操作分为两个,第一个操作是获取用户填写的订单。这个操作的结果是产生一个“信用卡验证请求”的事件。第二个操作是当它接受一个“信用卡验证成功响应”的事件,生成订单入库。Processor在完成第一个操作之后会接下来执行另外其他的事件,直到“信用卡验证成功响应”事件被插入input disruptor并被processor选取。至于lmax如何根据“信用卡验证请求”输出事件生成另外一个输入事件-“信用卡验证成功响应”,这则是通过output disruptor的多线程来完成的。因此可以看出lmax青睐单线程的态度并不固执,而是有自己的原则:IO密集型操作用多线程,CPU密集型用单线程。

这样异步的工作方式带来一个问题是如何实现事务?相比传统做法,lmax需要做更多的工作。

Input and Output Disruptor

business logic processor是单线程工作的,在processor可以正常进行工作之前还是有很多任务需要做的。Processor的输入本质上是网络消息,为了便于business logic processor处理,这些网络消息在送达processor之前需要进行反序列化(unmarshaled)。Event Sourcing的工作依赖于记录输入事件,因此输入消息的日志需要被持久化。

LMAX系统架构-LMLPHP

Figure 2: The activities done by the input disruptor (using UML activity diagram notation)

如上图,由于replicator和journaler涉及到大量的IO,因此速度相对比较慢。而business logic processor的中心思想就是避免任何IO。这三个任务相对比较独立,它们必须在business logic processor处理消息之前完成,因此这三个任务很适合并发。

为了处理这个并发,lmax开发了一个特殊的并发组件 – disruptor。

粗略理解,你可以把disruptor看作一组队列,生产者向某些队列中放入对象,这些对象会被广播发送到若干个独立分开的下行队列中,消费者就会并行得从这些下行队列中获取对象进行处理。如果你进一步深入到disruptor的内部,你会发现实际上并不是一组队列,而只是一个单独的数据结构-ring buffer。

LMAX系统架构-LMLPHP

Figure 3: The input disruptor coordinates one producer and four consumers

如上图,每个生产者/消费者都拥有一个序号,这个序号表示该生产者/消费者正在处理ring buffer的那个slot。每个生产者/消费者都只能拥有自己序号的写权限,对于其它消费者/生产者的序号只能读取而不能更改。基于这种方法,生产者可以不断读取其它消费者的序号来检查生产者想要写入的slot是否被占用,这种方法实际上就是的lock-free,避免了加锁。类似的,一个消费者也可以通过观察其他消费者的序号来确保不会重复处理某些消息。

Output disruptor和input disruptor是类似的,只不过output disruptor的两个消费者marshaller和publisher必须是顺序执行的,也就是说ring buffer里的消息必须经过marshaller处理之后才能由publisher公布出去。Publisher发布出去的事件被组织成了若干个topics,每个事件只会被转发到订阅了该主题的receivers。

LMAX系统架构-LMLPHP

Figure 4: The LMAX architecture with the disruptors expanded

如上图,深绿色的模块表示生产者,深蓝色的模块表示消费者,output disruptor实际上包含了若干个ring buffer,每个ring buffer对应一个topic,output disruptors 中的publisher和input disruptor中的receiver构成了一个典型的pub/sub系统,这个系统并没有在图中显式注明。

上图中描述看起来整个系统似乎都是单生产者+多消费者的模式,但实际上disruptor也可配置成多生产者+多消费者的模式,在这种模式下,input disruptor/output disruptor上的消息接收组件可以有若干个实例(每个实例也有可能是多线程的),即使在多生产者+多消费者模式下,disruptor依然不需要锁。

Lmax的这种设计带来一个好处,如果某个消费者发生了问题从而成为其它消费者的拖累,它也能够很快赶上来。还是看Figure 3中的例子,假设un-marshaller在处理slot 15时产生了问题,速度特别慢,但一旦un-marshaller处理完了slot 15从其中脱身,那么下一次执行,它就会一次性读取slot 16到slot 31之间所有的数据从而加快消息的消费速度(至于消息的实际处理速度则是另外一回事,一旦消息被读走了,它至少在ring buffer中不再是拖累者)。

在Figure 3的例子中,journaler,replicator和un-marshaller各自只有一个实例,lmax在默认设置下的确是这样,但是lmax也可以运行多个组件实例,比如journaller组件可以运行两个实例,一个处理奇数slot,一个处理偶数slot。是否运行多个实例取决于IO操作的独立性和IO的阻塞时间。

Ring buffer是很大的,input ring buffer拥有20 million个slot,每个output ring buffer也拥有4 million个slot。序号是一个64位的长整形。Ring Buffer的大小为2的整数次方,这样有利于做取余运算(sequence number % buffer size)把序号映射成slot号码。像很多其它的系统一样,disruptors每天深夜做定期的重启,这么做的主要原因是回收内存,尽可能降低在繁忙时段的昂贵的垃圾回收的可能性。

Journaler的主要工作就是持久化存储所有的事件,这样便于当系统出现故障时可以从日志进行恢复。Lmax没有用数据库来作为持久化存储,而只是采用文件系统。它们把事件流写入磁盘,由于现代磁盘对于顺序存储的速度很快,而对随机存储的速度很慢,因此lmax的这种做法的性能并不会很差,即使没有用数据库。

前面我提到lmax会运行多个实例节点组成一个cluster来支持快速failover。Replicator用来保持这些实例节点的同步。Lmax节点之间的所有通讯采用的IP广播,因此备用节点不需要知道主节点的IP地址。只有主节点运行一个replicator并侦听输入事件。Replicator负责广播这些input event给备用节点。一旦主节点发生宕机,主节点的心跳信号就会丢失,那么另一个备用节点就会变成主节点,接着这个新的主节点就会开始侦听输入事件,并启动自己的replcator。每个节点是一个完整的lmax实例,有自己的disruptor,自己的journaler,自己的un-marshaller。

由于IP广播消息并不能确保消息的到达顺序。主节点负责决定广播消息的顺序。

Un-marshaller用于把网络上的事件顺序转化成business logic processor可以调用的java对象。和其它的消费者有所不同,un-marshaller需要改变ring buffer中的数据。这里写(更改数据)时需要遵守一个原则,那就是每个对象的writable field只能允许众多并行消费者(也就是un-marshaller)之中的一个来写,这个原则的目的就是为了避免jvm的伪共享。

Disruptor可以作为一个单独的组件被使用,而不只是用在lmax中,现在lmax已经开源了这个组件。作为一件金融交易软件公司,lmax的行为的确令人称道,也希望更多的公司愿意交流或分享自己的架构,毕竟技术是在交流中促进的。回过头来看,乐意开源或者愿意分享的公司(比如在infoQ中分享)往往技术上都比较领先。从个人来讲,技术人员也应该愿意进行分享,毕竟这是一个在业界建立自己声誉的好机会。

延伸阅读:Disruptor:High performance alternative to bounded queues for exchanging data between concurrent threads

05-11 15:15