LMAX是一种新型零售金融交易平台,它能够以很低的延迟(latency)产生大量交易(吞吐量). 这个系统是建立在JVM平台上,核心是一个业务逻辑处理器,它能够在一个线程里每秒处理6百万订单. 业务逻辑处理器完全是运行在内存中(in-memory),使用事件源驱动方式(event sourcing). 业务逻辑处理器的核心是Disruptors,这是一个并发组件,能够在无锁的情况下实现网络的Queue并发操作。他们的研究表明,现在的所谓高性能研究方向似乎和现代CPU设计是相左的。(JVM伪共享)
过去几年我们不断提供这样声音:免费午餐已经结束。我们不再能期望在单个CPU上获得更快的性能,因此我们需要写使用多核处理的并发软件,不幸的是, 编写并发软件是很难的,锁和信号量是很难理解的和难以测试,这意味着我们要花更多时间在计算机上,而不是我们的领域问题。诸如各种并发模型,如Actors和软事务STM(Software Transactional Memory), 目的是更加容易使用,但是任然还是带来了bugs和复杂性.
我很惊讶听到去年3月QCon上一个演讲, LMAX是一种新的零售的金融交易平台。它的业务创新是他的一个零售平台,允许任何人交易一系列的金融衍生产品。这样的平台需要非常低的延迟,非常快速的处理,因为市场变化很快,一个零售平台增加了复杂性,因为它必须为很多人做到这一点。所以结果是更多的用户,有很多的交易,所有这些都需要被快速处理。
鉴于多核心思想的转变,这种苛刻的性能自然会提出一个明确的并行编程模型,实际上这是他们的出发点。但QCon引起人们注意的是,这不是他们最终的目标。事实上,他们提出仅仅使用一个线程处理所有的客户的所有的交易,在通用的硬件上达到了每秒处理6百万订单。
通过低延迟处理大量交易,取得低延迟和高吞吐量,而且没有并发代码的复杂性,他们是怎么做到呢?现在LMAX已经产品化一段时间了,现在应该可以揭开其神秘而迷人的面纱了。
结构如图:
image从最高层次看,架构有三个部分:
- 业务逻辑处理器business logic processor;
- 输入input disruptor;
- 输出output disruptors;
业务逻辑处理器处理所有的应用程序的业务逻辑,这是一个单线程的Java程序来响应方法调用并产生输出事件,因此这是一个简单的Java程序,不需要任何平台框架就可以运行(需要JVM),这就保证其很容易运行在测试环境中。
Input Disruptor是用来处理输入消息的,输入消息从网络中接收,需要进行反序列化(unmarshaled),需要进行replicated避免单点故障,需要journaled来记录消息日志从而能够进行故障恢复。Output Disruptors用来处理输出消息,这些消息需要进行序列化以便于网络传输。Input Disruptor和output disruptor都是多线程的,因为他们设计到大量的IO操作,这些IO操作很慢而且相互独立。
业务逻辑处理器 Business Logic Processor
全部驻留在内存中 Keeping it all in memory
业务逻辑处理器按顺序(方法调用的形式)接受输入消息,然后运行其中的业务逻辑,并产生输出事件,整个操作都是在内存中,没有数据库或其他持久化存储。将所有数据驻留在内存中有两个重要好处:首先是快,没有IO,也没有事务,因为所有的处理都是按顺序执行的,第二个好处是简化编程,没有对象/关系数据库的映射,所有代码都可以使用Java对象模型编写,不必为映射到数据库做任何妥协。
由于一切都在内存中处理,因此需要任职考虑的问题是万一Crash了怎么办?可伸缩性再好的系统也会受到很多其他因素的影响,比如掉电。处理这个问题的核心是“事件”(Event Sourcing )机制,这意味着业务逻辑处理器的当前状态完全可以通过处理输入事件来推导出来。只要输入事件保存在一个持久存储(这是输入Input Disruptor的作业之一)中,就可以通过重播事件来重新创建业务逻辑处理的当前状态。
可以基于NOSQL处理事务性事件存储
理解这个的一个好方法就是对于一个版本控制系统。版本控制系统处理一系列的提交,任何时候你都可以通过应用这些提交来构建一个工作拷贝。VCS比业务逻辑处理器更复杂,因为它们必须支持分支,而业务逻辑处理器是一个简单的序列。
因此,从理论上讲,您总是可以通过重新处理所有事件来重建业务逻辑处理器的状态,但是实践中重建所有事件是耗时的,因此,正如版本控制系统一样,LMAX可以创建业务逻辑处理器状态的快照并从快照中恢复。在每天晚上系统不繁忙时构建快照,通过快照重新启动业务逻辑处理器的速度很快,一个完整的重新启动,包括重新启动JVM、加载最近的快照和重放一天事件,不到一分钟。
如果业务逻辑处理器在下午2时崩溃,通过快照启动恢复的方式依然不够快。LMAX的方案是保持多个业务逻辑处理器同时运行,一个输入事件由多个业务处理器处理,但只有一个业务处理输出会被保留。如果一个处理器处理失败,则切换到另外一个,这种故障转移是使用事件驱动(Event Sourcing)的另外一个好处。
通过事件复制机制(replicas),多个业务处理器可以在微秒间切换。除了每天晚上创建快照,它们还每天晚上重启业务逻辑处理器。复制机制(replicas)允许他们在没有停机的情况下这样做,因此系统将7x24小时全天候处理交易。
事件机制的价值不仅体现在允许完全在内存中进行处理,对于系统诊断测试也是有相当大的优势。如果系统发生意外的行为,团队会将事件顺序复制到其开发环境中,并在那里重播。 这使得他们能够比在大多数环境中更容易地检查发生的事情。
从基础诊断功能可扩展到业务诊断。有一些业务任务,如风险管理,需要大量的计算,而不需要处理订单。一个例子就是根据目前的交易仓位,获得风险状况前20位客户的名单。 团队通过复制域模型并在那里执行计算来处理这个问题,在那里它不会干扰核心订单处理。这些分析域模型可以具有不同的数据模型,将不同的数据集保存在内存中,并在不同的机器上运行。
性能优化 Tuning performance
到目前为止,我已经解释过,业务逻辑处理器速度的关键是在内存中顺序地执行所有的事情。只要做到这一点(并没有什么真正愚蠢的,并行就聪明吗?),开发人员就可以编写能够处理10K TPS的代码。然后他们发现只需要使用良好的代码和小方法就可以使其达到100K TPS的范围。当然,JVM Hotspot的缓存微调,让其更加优化也是必须的。
再过一个数量级就更巧妙了一些。有几件事LMAX团队发现有助于提升性能。一个是编写java集合的自定义实现,这些实现被小心的设计为缓存友好。一个例子是使用原始的Java longs作为散列映射关键字,并带有一个专门编写的数组支持的Map实现(LongToObjectHashMap)。一般来说,他们发现数据结构的选择通常会造成很大的差别,大多数程序员只是抓住上次使用的List,而不是考虑哪种实现方式适合于这种场景。
达到顶级性能的另一个技术是性能测试。我早就注意到,人们谈论很多技术来提高性能,但真正有效的一件事是测试它。即使是优秀的程序员也容易编写出性能差的应用,所以最好的程序员更喜欢使用性能分析器和编写测试用例。 LMAX团队还发现,将编写性能测试作为一项纪律能够更易提升应用性能。
编程模型 Programming Model
Business logic processor的一个重要特点就是不和任何外部服务进行交互。因为调用外部服务的速度会比较慢,processor又是单线程的,因此外部服务会拖慢整个processor的速度。Processor只和event交互,要么接受一个event,要么产生一个event。怎么理解呢?举个例子就明白了,比如电商网站通过信用卡来订购商品。普通青年的做法就很直接,先获取订单信息,通过银联的外部服务来验证信用卡信息是否有效(这意味着信用卡号如果有问题,根本就不会生成订单),然后生成订单信息入库,这两步放在一个操作里。由于信用卡验证服务是一个外部服务,因此操作往往会被阻塞较长的一段时间。
Lmax则另辟蹊径,它把整个操作分为两个,第一个操作是获取用户填写的订单。这个操作的结果是产生一个“信用卡验证请求”的事件。第二个操作是当它接受一个“信用卡验证成功响应”的事件,生成订单入库。Processor在完成第一个操作之后会接下来执行另外其他的事件,直到“信用卡验证成功响应”事件被插入input disruptor并被processor选取。至于lmax如何根据“信用卡验证请求”输出事件生成另外一个输入事件-“信用卡验证成功响应”,这则是通过output disruptor的多线程来完成的。因此可以看出lmax青睐单线程的态度并不固执,而是有自己的原则:IO密集型操作用多线程,CPU密集型用单线程。
在这种事件驱动的异步风格下工作有些不寻常 - 尽管使用异步来提高应用程序的响应速度是一种熟悉的技术且它还有助于业务流程更具弹性,但您必须更加明确地考虑远程应用程序可能发生的不同情况。
这个编程模型第二个特点在于错误处理 - 传统模式下会话和数据库事务提供了一个有用的错误处理能力。如果有什么出错,很容易抛出任何东西,这个会话能够被丢弃。如果一个错误发生在数据库端,你可以回滚事务。
LMAX的内存结构在输入事件中是持久的,所以如果出现错误,不要让内存处于不一致的状态。但是没有自动回滚功能。因此,LMAX团队非常重视确保输入事件在进行内存中持久状态变化之前完全有效。他们发现测试是在投入生产之前消除这些问题的关键。
Input and Output Disruptors
business logic processor是单线程工作的,,在processor可以正常进行工作之前还是有很多任务需要做的。Processor的输入本质上是网络消息,为了便于business logic processor处理,这些网络消息在送达processor之前需要进行反序列化(unmarshaled)。Event Sourcing的工作依赖于记录输入事件,因此输入消息的日志需要被持久化。
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可以看成一个事件监听或消息机制,在队列中一边生产者放入消息,另外一边消费者并行取出处理。当你进入这个队列内部查看,发现其实是一个真正的单个数据结构:一个ring buffer。 每个生产者和消费者都有一个序数计算器,以显示当前缓冲工作方式,每个生产者消费者写入自己次序计数器,能够读取对方的计数器,通过这种方式,生产者能够读取消费者的计算器确保其在没有锁的情况下使用,类似地消费者也要通过计数器在另外一个消费者完成后确保它一次只处理一次消息。
Figure 3: The input disruptor coordinates one producer and four consumers如上图,每个生产者/消费者都拥有一个序号,这个序号表示该生产者/消费者正在处理ring buffer的哪个slot。每个生产者/消费者都只能拥有自己序号的写权限,对于其它消费者/生产者的序号只能读取而不能更改。基于这种方法,生产者可以不断读取其它消费者的序号来检查生产者想要写入的slot是否被占用,这种方法实际上就是的lock-free,避免了加锁。类似的,一个消费者也可以通过观察其他消费者的序号来确保不会重复处理某些消息。
Output disruptors也类似于此,只不过output disruptor的两个消费者marshaller和publisher必须是顺序执行的,也就是说ring buffer里的消息必须经过marshaller处理之后才能由publisher公布出去。Publisher发布出去的事件被组织成了若干个topics,每个事件只会被转发到订阅了该主题的receivers。
disruptor不但适合一个生产者多个消费者,也适合多个生产者。在这种情况下它任然不需要加锁。
disruptor设计的好处是如果遇到问题导致消费落后,消费者就能更容易地赶上。比如在15号solt有一个unmarshaler(反序列化)问题,而接受者在31号solt,它能够从16-30号一次性批量抓取,这种数据批读取能力加快消费者处理,降低整体延迟性。
在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的伪共享。
Figure 4: The LMAX architecture with the disruptors expandedDisruptor可以作为一个单独的组件被使用,而不只是用在lmax中,现在lmax已经开源了这个组件。作为一件金融交易软件公司,lmax的行为的确令人称道,也希望更多的公司愿意交流或分享自己的架构,毕竟技术是在交流中促进的。回过头来看,乐意开源或者愿意分享的公司(比如在infoQ中分享)往往技术上都比较领先。从个人来讲,技术人员也应该愿意进行分享,毕竟这是一个在业界建立自己声誉的好机会。
Queues and their lack of mechanical sympathy
LMAX架构引起了人们的关注,因为这是一个与大多数人所想的高性能系统完全不同的实现方式。到目前为止,我已经谈到它是如何工作的,但是还没有深入研究它是以这种方式开发的。这个故事本身就很有趣,因为这个架构并不只是拿来演讲的。它是经过很长的时间,在团队意识到传统方案的缺陷后设计的替代方案。
许多商业系统都有自己的核心架构,依赖于通过事务数据库协调的多个活动会话,LMAX团队也熟悉这些知识,并确信它不适用于LMAX。这个评估是建立在Betfair(成立LMAX的母公司)的经验基础之上的,这是一家体育博彩公司,它处理很多人的体育投赌事件,这是一个相当大的并发访问,锁竞争十分激烈,传统数据库几乎无法应付,这些让他们相信必须寻找另外一个途径来突破,他们现在接近目标了。
他们最初的想法与大多数业界方案一样,为获得高性能是使用现在流行的并发编程。这意味着允许多线程并行处理多个订单。而这些线程必须互相通信。处理订单的条件等都需要进行线程间通信来确定。
早期他们探索了Actor模型和SEDA。Actor模型依靠独立的活动对象和自己的线程通过队列相互通信。很多人发现这种并发模型要比基于锁定原语的事情更容易处理。
该团队建立了一个Actor模型原型,进行性能测试,他们发现的是处理器会花费更多时间在管理队列,而不是去做真正应用逻辑,队列访问成了真正瓶颈。
当追求性能达到这种程度,现代硬件构造原理成为很重要的必须了解的知识了,马丁汤普森喜欢用的一句话是“机制偏爱(mechanical sympathy)”,这词来自赛车驾驶,它反映的是赛车手对汽车有一种与生俱来的感觉,使他们能够感受到如何发挥它到最好状态。许多程序员包括我承认我也陷入这样一个阵营:不会认为编程如何与硬件等底层机制交互是值得研究的。
现代的CPU延迟是影响性能的主导因素之一,在CPU如与内存的交互中,CPU具有多层次的缓存(一级二级),每级速度都明显加快。因此,如果要提高速度,将您的代码和数据加载到这些缓存中很重要。
在某个层次, Actor模型能够帮助你,你可以把一个演员当作自己的对象,把代码和数据聚类在一起,这是缓存的一个自然单位。但Actor需要沟通,他们通过排队来进行沟通 - 而LMAX团队则认为这是干扰缓存的队列。(JVM伪分享的问题)。
为什么队列干扰了缓存呢?解释是这样的: 为了将数据放入队列,你需要写入队列,类似地,为了从队列取出数据,你需要移除队列 - 也是一种写,客户端也许不只一次写入同样数据结构,处理写通常需要锁,但是如果锁使用了,会引起切换到底层系统的场景,当这个发生后,处理器会丢失它的缓存中的数据。
他们得出的结论是为了够获得最好的缓存性能, 你需要设计一个CPU核写任何内存的设计,多个读是良好的,处理器会非常快,而队列失败在one-writer原则,队列不符合单写者原则。(JVM伪共享)
这样的分析导致LMAX团队得出一系列结论,导致他们设计出disruptor, 能够遵循single-writer约束。 其次,它引发了探索单线程业务逻辑方法的想法,提出了一个单线程如果没有并发管理,可以走多快的问题。
单个线程的本质是:确保你每个CPU核运行一个线程,缓存配合,尽可能的使用高速缓存而不是主内存。这就意味着代码和数据需要尽可能的一致访问,将代码和数据放在一起的小对象也可以作为一个单元在缓存之间进行交换,从而简化缓存管理并提高性能。
LMAX体系结构的一个重要组成部分是使用性能测试。考虑和放弃基于Actor的方法来自对原型进行构建和性能测试。类似地,通过性能测试来实现提高各种组件性能很重要。Mechanical sympathy是非常有价值的 - 它有助于形成你能做出什么改进的假设,并且引导你向前迈进,而不是落后 - 最终的测试将给你令人信服的证据。
然而,这种风格的性能测试并不是一个很好理解的话题。 LMAX团队经常强调,开发有意义的性能测试往往比开发产品代码更困难。除非考虑到CPU的高速缓存行为,否则测试低级并发组件是没有意义的。
一个特别的经验是编写针对零组件的测试的重要性,以确保性能测试足够快以真正衡量真实组件正在做什么。编写快速的测试代码并不比编写快速生产代码容易,而且容易得到错误的结果,因为测试的速度并不像要测试的组件那么快。
Should you use this architecture?
乍一看,这个架构是非常小众的,驱动低延迟的交易系统,大多数应用并不需要6百万TPS。
但是我对这个架构着迷的原因是它的设计,它移除了很多其他大多数编程系统的复杂性,传统围绕事务性的关系数据库会话并发模型并不简单,对象/关系数据库映射ORM工具Object/relational mapping tools能够帮助减轻这种麻烦,但是它不能解决全部问题,大多数企业性能微调还是要纠结于SQL.
现在你能得到服务器更多的主内存,比我们过去这些老家伙得到的磁盘还要多,越来越多应用能够将他们的工作全部置于内存中,这样消除了复杂性和性能低问题. 事件源驱动(Event Sourcing)提供了一种内存(in-memory)系统的解决方案, 在单个线程运行业务解决了并发性能。LMAX的经验建议只要你需要少于几百万TPS,你就有足够的性能提升余地。
这里也是相似于CQRS,一种事件驱动, in-memory风格的命令系统(尽管LMAX当前没有使用CQRS)
那么是不是表示你不应该走上这条道路呢?对于像这样鲜为人知的技术来说,这总是一个棘手的问题,因为这个行业需要更多的时间去探索它的界限。
一个例子是,一个领域模型,处理一个事务总是有可能改变如何处理另一个事务。对于彼此独立的事务来说,不太需要协调,因此使用并行运行的独立处理器变得更具吸引力。
LMAX致力于研究事件如何改变世界。许多网站更多的是关于获取现有的信息存储,并将这些信息的各种组合呈现出来 - 例如媒体网站。这里的构建挑战往往集中在如何正确的使用缓存。
LMAX另外一个特点是这是一个后台系统,有理由考虑如何在一个交互模型中应用它,比如日益增长的Web应用,当异步通讯在WEB应用越来越多时,这将改变我们的编程模型。
对于大多数团队来说,这些变化需要一些习惯。大多数人倾向于用同步的方式来理解编程,而不习惯处理异步。然而,异步通信是提升响应速度的重要工具。看看在javascript世界中广泛使用地异步通信(使用AJAX和node.js)是否会鼓励更多的人了解异步编程。 LMAX团队发现,虽然花了一些时间来适应异步风格,但它很快变得自然而且容易。特别是在这种方法下,错误处理更容易。
LMAX团队当然认为花力气协调事务性关系数据库的日子已经屈指可数。你可以使用一种更加容易方式编写程序而且比传统集中式中央数据库运行得更快,为什么视而不见呢?
就我而言,我觉得这是一个非常令人兴奋的故事。我的大部分目标是专注于复杂的业务领域软件。像这样的架构提供了很好的问题分离,使人们可以专注于领域驱动设计,并保持很好的平台复杂性。领域对象和数据库之间的紧密耦合一直是一个恼人的方法 - 这样的方法提出了一个出路。
附录
LMAX设计
很多架构师都面临这么一个问题:如何设计一个高吞吐量,低延时的系统?面对这个问题,各位都有自己的答案。但面对这个问题,大家似乎渐渐形成了一个共识:并发是解决之道。大家似乎都这么认为:对于服务器而言,由于多核越来越普遍,因此我们的程序必须要充分利用多线程,为了让多线程工作得更好,必须有一个与之匹配的高效的并发模型。于是各种各样的并发模型被提出来,比如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. 写的代码要缓存友好。
什么样的代码是缓存友好的代码?这个一言难尽。但总的原则就是,保持访问的局部性,也就是说尽可能使一段时间内的访问保持在一个狭小的内存范围内。常用的一个做法就是,先统一分配一个对象池,然后复用对象池中的对象,不要每次都是重新分配新的对象。
image上图显示了各个层次的缓存的访问效率,提醒我们要对缓存敏感。
4. 要时刻牢记,代码要干净,简练。
- Hotspot虚拟机喜欢短小,简练的代码;
- 如果CPU的分支预测不准确,那么CPU流水线会被阻断;
- 复杂的代码是一个危险的信号,这意味着你有可能没有正确理解问题的领域(DDD里的概念);
- 世界上的事情都不会很复杂,除了扣税的方法。
- 多花点时间考虑一下你的领域模型。
采用正确的方法来实现并发。
记住这么几个原则:
- 责任单一:一个类只干一件事,一个方法也只干一件事,不要臃肿的类或方法。
- 了解你的数据结构和关系基数(一对一的关系?一对多?还是多对多)
- 让关系来完成工作,比如“书架”和“书”之间存在一个“attach”的关系,既然如此,我们可以让“书架”有一个方法叫attach,用来处理添加书本的工作,这就是让关系来完成工作。这实际上也是DDD里面的一些设计原则。
实现并发需要考虑两件事:资源互斥和变化可见(让结果以一个正确的顺序出现)。
并发的实现一般有两种方法:第一个方法是用锁来保证,另一个方法是借助于CAS进行无锁编程。使用锁会导致内核态的切换,但总可以确保任何时刻总有一个进程会被执行(相比之下Lock-Free如果代码逻辑出现问题,有可能所有线程都处在自旋等待状态,无法前进),锁也增加了编程的难度。而借助于CAS的Lock-Free则始终是运行在用户态的(节省了效率,避免了无谓的上下文切换),相比于锁,它的编程难度更加大。下面图形象地表达了Lock和Lock-Free之间的区别:
image这些原则大部分都是老生常谈,但很容易被人忽略,总之这些原则提醒我们:
- 很多程序员对现代服务器的硬件有着一个错误的认识或者根本没有认识,他们根本就不知道单线程所能达到的性能高度。
- 对于现代处理器,缓存丢失才是性能的最大杀手。
- 架构设计时,把并发放到infrastructure层里去考虑,这样一方面使得应用层的编写避免了并发编程的复杂性,另一方面由于并发放在了相对单纯的infrastructure层,避免了来自应用层的乱七八糟的干扰,更容易优化。
- 牢记上述3条原则,一旦你实现了这3条,那恭喜你,你已经进入了理想王国:
单线程:
- 所有的一切都在内存中;
- 优雅的模式;
- 易于测试的代码;
- 不用担心infrastructure和集成的问题。
JVM伪共享
伪共享False sharing。内存缓存系统中基本单元是高速缓存行(Cache lines),CPU会把数据从内存加载到高速缓存中 ,这样可以获得更好的性能,高速缓存默认大小是64 Byte为一个区域,一个区域在一个时间点只允许一个核心操作,也就是说不能有多个核心同时操作一个缓存区域。
因为高速缓存是64字节,而Hotspot JVM的对象头是两个部分组成,第一部分是由24字节的hash code和8字节的锁等状态标识组成,第二部分是指向该对象类的引用。基本类型字节如下:
doubles (8) and longs (8)
ints (4) and floats (4)
shorts (2) and chars (2)
booleans (1) and bytes (1)
references (4/8)
因此,一个高速缓存64字节可以放下多个字段,如果这多个字段位于同一个高速缓存区,虽然它们是类的不同字段,如下代码:
Class A{
int x;
int y;
}
x和y被放在同一个高速缓存区,如果一个线程修改x;那么另外一个线程修改y,必须等待x修改完成后才能实施。虽然两个线程修改各种独立变量,但是因为这些独立变量被放在同一个高速缓存区,性能就影响了。当多核CPU线程同时修改在同一个高速缓存行各自独立的变量时,会不自不觉地影响性能,这就发生了伪共享False sharing,伪共享是性能的无声杀手。
解决方案是将高速缓存剩余的字节填充填满(padding),确保不发生多个字段被挤入一个高速缓存区。Disruptor在RingBuffer中实现了填充,用1毫秒的延时得到100K+ TPS吞吐量。
网友评论