几乎所有系统都对延迟有一定要求,这里延迟定义为系统响应输入所需的时间。(非停机的计算是存在的,但它们几乎没有实际应用。)延迟需求出现在各种各样的问题领域,如飞机飞行控制 (http://copter.ardupilot.com/)、语音通信 (https://queue.org.cn/detail.cfm?id=1028895)、多人游戏 (https://queue.org.cn/detail.cfm?id=971591)、在线广告 (http://acuityads.com/real-time-bidding/) 和科学实验 (http://home.web.cern.ch/about/accelerators/cern-neutrinos-gran-sasso)。
分布式系统——其中计算发生在通过传递消息来通信和协调其行动的多个联网计算机上——提出了特殊的延迟考虑因素。近年来,金融交易的自动化推动了对具有挑战性延迟要求(通常以微秒甚至纳秒为单位衡量;见表 1)和全球地理分布的分布式系统的需求。自动化交易为不断缩小的延迟要求的工程挑战提供了一个窗口,这对其他领域的软件工程师可能很有用。
本文重点关注延迟(相对于吞吐量、效率或其他一些指标)是主要设计考虑因素之一的应用。换句话说,“低延迟系统”是指那些延迟是衡量成功的主要标准,并且通常是最难设计的约束条件。本文介绍了低延迟系统的例子,这些例子说明了驱动延迟的外部因素,然后讨论了一些构建低延迟运行系统的实用工程方法。
要理解延迟对应用的影响,首先重要的是理解驱动需求的外部真实世界因素。以下来自金融行业的例子说明了一些真实世界因素的影响。
2003 年,我在一家大型银行工作,这家银行刚刚部署了一个新的基于 Web 的机构外汇交易系统。报价和交易引擎是一个 J2EE (Java 2 Platform, Enterprise Edition) 应用程序,运行在 WebLogic 服务器和 Oracle 数据库之上,其响应时间可靠地低于两秒——足够快以确保良好的用户体验。
大约在银行网站上线的同时,一个多银行在线交易平台也启动了。在这个新平台上,客户会提交一个 RFQ(询价请求),该请求将被转发给多个参与银行。每家银行都会回复报价,客户会选择接受哪一个。
我的银行启动了一个项目,以连接到新的多银行平台。理由是,既然两秒的响应时间对于网站上的用户来说已经足够好了,那么对于新平台来说也应该足够好,因此可以重用相同的报价和交易引擎。然而,在上线几周内,银行赢得的 RFQ 百分比出奇地小。根本原因是延迟。当两家银行报出相同的价格(这种情况经常发生)时,第一个响应会显示在列表的顶部。大多数客户会等待查看一些不同的报价,然后点击列表顶部的报价。结果是,最快的银行通常会赢得客户的业务——而我的银行不是最快的。
报价生成过程中最慢的部分发生在数据库查询中,这些查询加载客户定价参数。在报价引擎中添加缓存并优化代码中的其他一些“热点”将报价延迟降低到大约 100 毫秒的范围。凭借更快的引擎,银行能够在竞争激烈的报价平台上占据可观的市场份额——但市场仍在不断发展。
到 2006 年,一种新的货币交易风格开始流行起来。客户不再发送特定的请求,银行回复报价,而是客户希望银行发送流式报价。这种流式报价交易风格在某些对冲基金中尤其受欢迎,这些对冲基金正在开发自动化交易策略——应用程序将接收来自多家银行的报价流,并自动决定何时交易。在许多情况下,交易双方的人工操作员都不再参与。
要理解这种新的竞争态势,重要的是了解银行如何计算他们向客户收取的外汇交易费率。最大的银行在所谓的银行同业市场相互交易货币。在该市场设定的汇率最具竞争力,并构成提供给客户的汇率(加上一些加价)的基础。每次银行同业汇率发生变化时,每家银行都会重新计算并重新发布相应的客户汇率报价。如果客户接受报价(即,请求根据报价的汇率进行交易),银行可以立即与银行同业市场执行对销交易,从而最大限度地降低风险并锁定少量利润。然而,报价更新缓慢的银行存在风险。一个简单的例子可以说明这一点
假设欧元/美元的银行同业现货市场汇率为 1.3558 / 1.3560。(术语现货表示约定的货币将在两个工作日内交换。货币可以交易以在未来任何双方约定的日期交割,但现货市场在交易数量方面最活跃。)报价中包含两个汇率:一个用于买入(买入价),另一个用于卖出(卖出价或卖出价)。在这种情况下,银行同业市场参与者可以卖出一个欧元并获得 1.3558 美元的回报。相反,可以以 1.3560 美元的价格买入一个欧元。
假设两家银行 A 和 B 是银行同业市场的参与者,并且正在向同一家对冲基金客户 C 发布报价。两家银行都在他们向客户报出的汇率中增加了 0.0001 的保证金——因此两家银行都向客户 C 发布了 1.3557 / 1.3561 的报价。然而,银行 A 更新报价的速度比银行 B 快,银行 A 大约需要 50 毫秒,而银行 B 大约需要 250 毫秒。银行 A 和银行 B 与他们的共同客户 C 之间大约有 50 毫秒的网络延迟。银行 A 和银行 B 都大约需要 10 毫秒来确认订单,而对冲基金 C 大约需要 10 毫秒来评估新的报价并提交订单。表 2 分解了事件的顺序。
这种新的流式报价交易风格的净效应是,任何比竞争对手明显慢的银行都可能在市场价格变化且其报价更新不够快时遭受损失。与此同时,那些能够最快更新报价的银行获得了可观的利润。延迟不再仅仅是运营效率或市场份额的因素——它直接影响了交易台的损益。随着 2000 年代中期交易量和交易速度的增加,这些利润和损失变得相当大。(你能低到什么程度?表 3 显示了一些系统和应用程序在九个数量级上的近似延迟示例。)
为了提高延迟,我的银行将其报价和交易引擎拆分为不同的应用程序,并用 C++ 重写了报价引擎。从银行同业市场到银行再到其客户的网络中每个跃点增加的小延迟现在变得非常重要,因此银行升级了防火墙并采购了专用电信电路。网络升级加上更快的报价引擎使终端到终端的报价延迟降至 10 毫秒以下,对于那些物理位置靠近我们在纽约、伦敦或香港的设施的客户而言。交易业绩和利润也随之上升——当然,市场仍在不断发展。
给定应用程序的延迟要求可以通过多种方式解决,并且每个问题都需要不同的解决方案。但是,有一些共同的主题。首先,通常需要先测量延迟才能改进延迟。其次,优化通常需要深入到抽象层以下,并适应物理基础设施的现实。最后,有时可以重构算法(甚至问题定义本身)以实现低延迟。
解决大多数优化问题(不仅仅是涉及软件的问题)的第一步是测量当前系统的性能。从最高级别开始,测量端到端延迟。然后测量每个组件或处理阶段的延迟。如果任何阶段占用了异常大的延迟部分,那么进一步分解它并测量其子阶段的延迟。目标是找到系统中对总延迟贡献最大的部分,并将优化工作集中在那里。然而,这在实践中并不总是那么简单。
例如,假设一个应用程序响应通过网络接收的客户报价请求。客户端快速连续发送 100 个报价请求(在前一个响应收到后立即发送下一个请求),并报告总共过去了 360 毫秒——或平均每次请求的服务时间为 3.6 毫秒。应用程序的内部结构被分解并使用相同的 100 个报价测试集进行测量
• 从网络读取输入消息并解析 - 5 微秒
• 查找客户资料 - 3.2 毫秒(3,200 微秒)
• 计算客户报价 - 15 微秒
• 记录报价 - 20 微秒
• 将报价序列化为响应消息 - 5 微秒
• 写入网络 - 5 微秒
正如本例清楚地表明的那样,显著降低延迟意味着解决查找客户资料所需的时间。快速检查表明,客户资料是从数据库加载并在本地缓存的。进一步的测试表明,当资料在本地缓存(一个简单的哈希表)中时,响应时间通常低于 1 微秒,但是当缓存未命中时,加载资料需要几百毫秒。3.2 毫秒的平均值几乎完全是由一次非常慢的响应(大约 320 毫秒)引起的,这是由缓存未命中造成的。同样,客户端报告的 3.6 毫秒平均响应时间实际上是一个非常慢的响应(350 毫秒)和 99 个快速响应(每个响应大约 100 微秒)。
平均值和异常值
大多数系统的延迟在不同事件之间都存在一些差异。在某些情况下,差异(尤其是最高延迟的异常值)驱动着设计,远比平均情况更重要。重要的是要理解哪种延迟的统计度量适用于特定问题。例如,如果您正在构建一个交易系统,当延迟低于某个阈值时可以赚取少量利润,但当延迟超过该阈值时会遭受巨大损失,那么您应该测量峰值延迟(或者,或者,超过阈值的请求的百分比),而不是平均值。另一方面,如果系统的价值或多或少与延迟成反比,那么即使这意味着存在一些很大的异常值,测量(和优化)平均延迟也更有意义。
你在测量什么?
精明的读者可能已经注意到,报价服务器应用程序内部测量的延迟与客户端应用程序报告的延迟不太相符。这很可能是因为它们实际上没有测量相同的东西。考虑以下简化的伪代码
(在客户端应用程序中)
for (int i = 0; i < 100; i++){
RequestMessage requestMessage = new RequestMessage(quoteRequest);
long sentTime = getSystemTime();
sendMessage(requestMessage);
ResponseMessage responseMessage = receiveMessage();
long quoteLatency = getSystemTime() - sentTime;
logStats(quoteLatency);
}
(在报价服务器应用程序中)
while (true){
RequestMessage requestMessage = receive();
long receivedTime = getSystemTime();
QuoteRequest quoteRequest = parseRequest(requestMessage);
long parseTime = getSystemTime();
long parseLatency = parseTime - receivedTime;
ClientProfile profile = lookupClientProfile(quoteRequest.client);
long profileTime = getSystemTime();
long profileLatency = profileTime - parseTime;
Quote quote = computeQuote(profile);
long computeTime = getSystemTime();
long computeLatency = computeTime - profileTime;
logQuote(quote);
long logTime = getSystemTime();
long logLatency = logTime - computeTime;
QuoteMessage quoteMessage = new QuoteMessage(quote);
long serializeTime = getSystemTime();
long serializationLatency = serializeTime - logTime;
send(quoteMessage);
long sentTime = getSystemTime();
long sendLatency = sentTime - serializeTime;
logStats(parseLatency, profileLatency, computeLatency,
logLatency, serializationLatency, sendLatency);
}
请注意,客户端应用程序测量的经过时间包括通过网络传输请求的时间,以及将响应传输回来的时间。另一方面,报价服务器仅测量从报价到达到发送时(或更准确地说,当 send 方法返回时)所经过的时间。客户端测量的平均响应时间与报价服务器的等效测量值之间 350 微秒的差异可能是由网络引起的,但也可能是客户端或服务器内部延迟的结果。此外,根据编程语言和操作系统,检查系统时钟和记录延迟统计信息可能会引入实际延迟。
这种方法很简单,但当与代码分析工具结合使用以查找最常执行的代码和资源争用时,它通常足以识别延迟优化的第一个(并且通常是最容易的)目标。但是,重要的是要牢记这种局限性。
通过网络流量捕获测量分布式系统延迟
分布式系统对延迟测量提出了一些额外的挑战——以及一些机会。在系统分布在多个服务器上的情况下,很难关联相关事件的时间戳。网络本身可能是系统延迟的重要贡献者。消息中间件和操作系统的网络堆栈可能是延迟的复杂来源。
与此同时,将整个系统分解为在独立服务器上运行的单独进程可以更容易地准确测量系统组件之间通过网络的某些交互。许多网络设备(例如交换机和路由器)都提供了机制,用于制作遍历设备的数据的带时间戳的副本,而对设备的性能影响最小。大多数操作系统在软件中也提供了类似的功能,尽管延迟实际流量的风险略高。带时间戳的网络流量捕获(通常称为数据包捕获)可以成为一个有用的工具,可以更精确地测量两个系统部分之间何时交换消息。这些测量可以在不修改应用程序本身的情况下获得,并且通常对整个系统的性能影响很小。(请参阅 http://wireshark.org 和 http://www.tcpdump.org。)
时钟同步
跨分布式系统以短时间尺度测量性能的挑战之一是时钟同步。一般来说,要测量从服务器 A 上的应用程序传输消息到消息到达服务器 B 上的第二个应用程序所经过的时间,需要检查消息发送时服务器 A 的时钟时间和消息到达时服务器 B 的时钟时间,然后减去这两个时间戳以确定延迟。如果服务器 A 和服务器 B 上的时钟不同步,那么计算出的延迟实际上将是真实延迟加上服务器 A 和服务器 B 之间的时钟偏差。
这在现实世界中什么时候是一个问题?大多数商用服务器主板中使用的石英振荡器的实际漂移率约为 10^-5,这意味着振荡器预计每秒漂移 10 微秒。如果不加以纠正,它可能会在一整天内增快或减慢多达一秒。对于以毫秒或更短的时间尺度运行的系统,时钟偏差可能会使测量的延迟毫无意义。可以使用漂移率明显较低的振荡器,但如果没有某种形式的同步,它们最终会漂移开。需要某种机制来使每个服务器的本地时钟与某个公共参考时间对齐。
分布式系统的开发人员至少应了解 NTP(网络时间协议),并鼓励他们学习 PTP(精确时间协议)以及 GPS 等外部信号的使用,以在实践中获得高精度时间同步。那些需要在亚微秒级时间精度的用户将希望熟悉 PTP 的硬件实现(尤其是在网络接口上),以及从每个内核的本地时钟中提取时间信息的工具。(请参阅 https://tools.ietf.org/html/rfc1305、https://tools.ietf.org/html/rfc5905、http://www.nist.gov/el/isd/ieee/ieee1588.cfm 和 https://queue.org.cn/detail.cfm?id=2354406。)
现代软件工程建立在抽象之上,这些抽象允许程序员管理日益庞大的系统的复杂性。抽象通过简化或概括底层系统的某些方面来实现这一点。但这并非没有代价——简化本质上是一个有损过程,并且一些丢失的细节可能很重要。此外,抽象通常根据功能而不是性能来定义。
在应用程序深处的某个地方,电流在半导体中流动,光脉冲沿着光纤传播。程序员很少需要从这些角度考虑他们的系统,但如果他们概念化的视图偏离现实太远,他们可能会遇到不愉快的意外。
以下四个示例说明了这一点
• TCP 在字节序列的交付方面提供了对 UDP(用户数据报协议)的有用抽象。TCP 确保字节将按发送顺序交付,即使丢失了一些底层 UDP 数据报。但是,不保证每个字节的传输延迟(从字节写入发送应用程序中的 TCP 套接字到从相应接收应用程序的套接字中读取字节的时间)。在某些情况下(特别是当丢失了一个中间数据报时),给定 UDP 数据报中包含的数据可能会从交付到应用程序的时间延迟很长时间,而其前面的丢失数据会被恢复。
• 云托管提供虚拟服务器,可以按需创建,而无需精确控制硬件的位置。应用程序或管理员可以在不到一分钟的时间内在“云”上创建一个新的虚拟服务器——这在数据中心组装和安装物理硬件时是不可能实现的壮举。然而,与物理服务器不同,云服务器的位置或其在网络拓扑中的位置可能无法精确得知。如果分布式应用程序依赖于服务器之间快速交换消息,那么这些服务器的物理邻近性可能会对整体应用程序性能产生重大影响。
• 线程允许开发人员将问题分解为可以允许并发运行的单独指令序列,但受某些排序约束,并且可以在共享资源(例如内存)上运行。这允许开发人员利用多核处理器,而无需直接处理调度和核心分配问题。然而,在某些情况下,上下文切换和在内核之间传递数据的开销可能超过并发带来的优势。
• 分层存储和缓存一致性协议允许程序员编写使用大量虚拟内存(在现代商用服务器中达到太字节级)的应用程序,同时在请求可以由最近的缓存服务时体验到纳秒级的延迟。这种抽象隐藏了最快内存的容量非常有限(例如,寄存器文件在几千字节的数量级),而已交换到磁盘的内存可能会产生数十毫秒的延迟。
这些抽象中的每一个都非常有用,但可能会对低延迟应用程序产生意想不到的后果。有一些实际步骤可以采取来识别和缓解由这些抽象引起的延迟问题。
消息传递和网络协议
基于 IP 的网络的近乎普遍存在意味着,无论使用哪种消息传递产品,在底层,数据都作为一系列离散数据包通过网络传输。网络性能特征和应用程序的需求可能差异很大——因此,对于延迟敏感的分布式系统,消息中间件几乎肯定不是一刀切的。
这里没有什么可以替代深入了解底层原理。例如,如果应用程序在专用网络(您控制硬件)上运行,通信遵循发布者/订阅者模型,并且应用程序可以容忍一定的数据丢失率,那么原始多播可能会比任何基于 TCP 的中间件提供显着的性能提升。如果应用程序分布在很长的距离上,并且数据顺序并不重要,那么基于 UDP 的协议可能会在不因重新发送丢失的数据包而停顿方面提供优势。如果正在使用基于 TCP 的消息传递,那么值得记住的是,它的许多参数(尤其是缓冲区大小、慢启动和 Nagle 算法)都是可配置的,并且“开箱即用”的设置通常针对吞吐量而不是延迟进行了优化 (https://queue.org.cn/detail.cfm?id=2539132)。
位置
当处理短时间尺度和/或长距离时,信息传播速度不能超过光速的物理约束是一个非常现实的考虑因素。最大的两个证券交易所 NASDAQ 和 NYSE 分别在新泽西州卡特雷特和马瓦的数据中心运行其匹配引擎。光线需要 185 微秒才能传播这两个位置之间 55.4 公里的距离。折射率为 1.6 且路径稍长(约 65 公里)的玻璃光纤中的光线需要将近 350 微秒才能完成相同的单程旅行。鉴于现在可以在 10 微秒或更短的时间尺度上做出交易决策所涉及的计算,信号传播延迟不可忽视。
线程
将问题分解为可以并发执行的多个线程可以大大提高性能,尤其是在多核系统中,但在某些情况下,它实际上可能比单线程解决方案慢。
具体来说,多线程代码会以下列三种方式产生开销
• 当多个线程对相同的数据进行操作时,需要控制以确保数据保持一致。这可能包括获取锁或实现读或写屏障。在多核系统中,这些并发控制要求在内核之间传递消息时暂停线程执行。如果一个锁已被一个线程持有,那么寻求该锁的其他线程将需要等待,直到第一个线程完成。如果多个线程频繁访问相同的数据,则可能会出现严重的锁争用。
• 同样,当多个线程对相同的数据进行操作时,数据本身必须在内核之间传递。如果多个线程访问相同的数据,但每个线程仅对其执行少量计算,则在内核之间移动数据所需的时间可能超过花费在其上操作的时间。
• 最后,如果线程数多于内核数,则操作系统必须定期执行上下文切换,其中停止在给定内核上运行的线程,保存其状态,并允许另一个线程运行。上下文切换的成本可能很高。如果线程数远远超过内核数,则上下文切换可能是延迟的重要来源。
一般来说,应用程序设计应以代表底层问题固有的并发性的方式使用线程。如果问题包含可以在隔离状态下执行的重大计算,则需要更多的线程。另一方面,如果计算之间存在高度的相互依赖性,或者(最坏的情况)如果问题本质上是串行的,那么单线程解决方案可能更有意义。在这两种情况下,都应使用分析工具来识别过度的锁争用或上下文切换。无锁数据结构(现在可用于多种编程语言)是另一种值得考虑的替代方案 (https://queue.org.cn/detail.cfm?id=2492433)。
还值得注意的是,内核、内存和 I/O 的物理排列可能不是均匀的。例如,在现代 Intel 微处理器上,某些内核可以比其他内核以低得多的延迟与外部 I/O(例如,网络接口)交互,并且在某些内核之间交换数据比其他内核更快。因此,显式地将特定线程绑定到特定内核可能是有利的 (https://queue.org.cn/detail.cfm?id=2513149)。
分层存储和缓存未命中
所有现代计算系统都使用分层数据存储——少量快速内存与多层较大(但较慢)的内存相结合。最近访问的数据被缓存,以便后续访问更快。由于大多数应用程序都表现出在短时间内多次访问相同内存的趋势,因此这可以大大提高性能。但是,为了获得最大收益,应将以下三个因素纳入应用程序设计
• 总体上使用更少的内存(或至少在应用程序的延迟敏感部分中使用更少的内存)会增加所需数据在缓存之一中可用的概率。特别是,对于特别延迟敏感的应用程序,将应用程序设计为使频繁访问的数据适合 CPU 的缓存中可以显着提高性能。规格各不相同,但例如,Intel 的 Haswell 微处理器为每个内核提供 32 KB 的 L1 数据缓存,为整个 CPU 提供高达 40 MB 的共享 L3 缓存。
• 如果可以重用,则应避免重复分配和释放内存。与重复新分配的对象或数据结构相比,分配一次并重用的对象或数据结构更有可能出现在缓存中。在自动管理内存的环境中进行开发时尤其如此,因为释放的内存的垃圾回收引起的开销可能很大。
• 由于现代处理器中缓存的架构,内存中数据结构的布局可能会对性能产生重大影响。虽然细节因平台而异,并且超出本文的范围,但通常最好将数组作为数据结构而不是链表和树,并且最好使用按顺序访问内存的算法,因为这些算法允许硬件预取器(尝试在应用程序请求之前抢先将数据从主内存加载到缓存中)最有效地运行。另请注意,将由不同内核并发操作的数据应进行结构化,使其不太可能落入同一缓存行(最新的 Intel CPU 使用 64 字节的缓存行),以避免缓存一致性争用。
关于过早优化的注意事项
刚刚介绍的优化应被视为更广泛的设计过程的一部分,该过程应考虑到其他重要目标,包括功能正确性、可维护性等。请记住 Knuth 关于过早优化是万恶之源的名言;即使在最注重性能的环境中,程序员也很少应该关心确定正确的线程数或最佳数据结构,直到经验测量表明应用程序的特定部分是热点。相反,重点应该是确保在设计过程的早期就理解性能要求,并且系统架构具有足够的可分解性,以便在优化变得必要时可以对延迟进行详细测量。此外(如下一节所述),最有用的优化可能根本不在应用程序代码中。
到目前为止,所提出的优化仅限于提高给定功能需求集的系统性能。也可能存在一些机会来改变系统更广泛的设计,甚至改变系统的功能需求,使其仍然满足总体目标,但能显著提高性能。延迟优化也不例外。特别是,通常有机会通过牺牲一些效率来换取延迟的改善。
本文将介绍效率和延迟之间权衡取舍的三个真实世界示例,然后介绍一个需求本身就为重新设计提供最佳机会的示例。
推测性预计算
在某些情况下,牺牲效率来换取延迟是可能的,尤其是在远低于其峰值容量运行的系统中。特别是,提前计算可能的输出可能是有利的,尤其是在系统大部分时间处于空闲状态,但必须在输入到达时快速响应的情况下。
一个真实世界的例子可以在一些公司使用的系统中找到,这些公司根据诸如盈利公告之类的新闻进行股票交易。假设市场预期苹果公司每股收益在 9.45 美元到 12.51 美元之间。交易系统的目标是在收到苹果公司的实际收益后,如果收益低于 9.45 美元,则卖出一定数量的苹果股票,如果收益高于 12.51 美元,则买入一定数量的股票,如果收益落在预期范围内,则不采取任何行动。买卖股票的行为始于向交易所提交订单。订单包括(除其他事项外)客户是否希望买入或卖出的指示、要买入或卖出的股票的标识符、所需的股份数量以及客户希望买入或卖出的价格。在苹果公司公告发布之前的整个下午,客户将收到源源不断的市场数据消息,这些消息表明苹果公司股票当前的交易价格。
这种交易系统的传统实现方式是缓存市场价格数据,并在收到收益数据后,决定是买入还是卖出(或两者都不),构建订单,并将该订单序列化为字节数组,以便放入消息的有效负载中并发送到交易所。
另一种实现方式执行大部分相同的步骤,但对每个市场数据更新都执行这些步骤,而不是仅在收到收益数据时才执行。具体而言,当收到每个市场数据更新消息时,应用程序会按当前价格构建两个新订单(一个买入,一个卖出),并将每个订单序列化为消息。这些消息被缓存但未发送。当下一个市场数据更新到达时,旧的订单消息被丢弃,并创建新的订单消息。当收益数据到达时,应用程序只需决定发送哪个(如果有的话)订单消息。
第一种实现方式显然效率更高(它浪费的计算量要少得多),但在延迟最重要的时候(即,在收到收益数据时),第二种算法能够更快地发送出适当的订单消息。请注意,此示例展示了应用程序级别的预计算;在流水线处理器中存在类似的branch prediction(分支预测)过程,该过程也可以优化(通过引导式分析),但这不在本文的讨论范围之内。
保持系统热状态
在某些低延迟系统中,输入之间可能会出现长时间的延迟。在这些空闲期间,系统可能会变得“冷”。关键指令和数据可能会从缓存中被逐出(重新加载需要花费数百纳秒),本应处理延迟敏感型输入的线程被上下文切换出去(恢复需要花费数十微秒),CPU 可能会切换到省电状态(退出需要花费几毫秒),等等。从效率的角度来看,这些步骤中的每一步都是有意义的(当没有事情发生时,为什么要让 CPU 全速运行?),但当输入数据到达时,所有这些步骤都会带来延迟惩罚。
在输入事件之间系统可能会空闲数小时或数天的情况下,还可能存在潜在的操作问题:配置或环境更改可能以某种重要方式“破坏”了系统,而这种破坏在事件发生之前不会被发现——而那时修复已经为时已晚。
解决这两个问题的常见方法是生成连续的虚拟输入数据流,以保持系统“热状态”。虚拟数据需要尽可能地逼真,以确保它使正确的数据保留在缓存中,并检测到对环境的破坏性更改。但是,虚拟数据需要与合法数据可靠地区分,以防止下游系统或客户端感到困惑。
冗余处理
在许多系统中,通过多个独立的系统实例并行处理相同的数据是很常见的,这主要是为了提高所赋予的弹性。如果某些组件发生故障,用户仍然会收到所需的结果。低延迟系统也获得了并行冗余处理带来的相同弹性优势,但也可以使用这种方法来减少某些类型的可变延迟。
所有非平凡复杂性的真实世界计算过程,即使在输入数据相同的情况下,延迟也存在一些差异。这些差异可能是由线程调度的细微差异、显式随机行为(例如以太网的指数退避算法)或其他不可预测的因素引起的。其中一些差异可能相当大:页面错误、垃圾回收、网络拥塞等都可能导致偶尔的延迟,这些延迟比相同输入的典型处理延迟大几个数量级。
运行多个独立的系统实例,并结合一种允许最终接收者接受第一个产生的结果并丢弃后续冗余副本的协议,既提供了减少停机的频率的好处,又避免了一些较大的延迟。
流处理和短路
考虑一个新闻分析系统,其需求被理解为“构建一个应用程序,可以尽可能快地从新闻稿文档中提取公司盈利数据”。另外,还规定新闻稿将通过 FTP 推送到系统。因此,该系统被设计为两个应用程序:一个通过 FTP 接收文档,另一个解析文档并提取盈利数据。在该系统的第一个版本中,使用开源 FTP 服务器作为第一个应用程序,第二个应用程序(解析器)假定它将接收到完整形成的文档作为输入,因此它在文档完全到达之前不会开始解析文档。
对系统性能的测量表明,虽然解析通常在几毫秒内完成,但通过 FTP 接收文档可能需要数十毫秒,从第一个数据包到达到最后一个数据包到达。此外,盈利数据通常出现在文档的第一段中。
在多步骤过程中,后续阶段可能在先前阶段完成之前开始处理,有时称为面向流或流水线处理。如果可以从部分输入计算输出,则这可能特别有用。考虑到这一点,开发人员将他们的总体目标重新构想为“构建一个可以尽快向客户端交付盈利数据的系统”。这个更广泛的目标,结合新闻稿将通过 FTP 到达的理解,以及可以从文档的第一部分(即在文档的其余部分到达之前)提取盈利数据的理解,导致了系统的重新设计。
FTP 服务器被重写为在文档的各个部分到达时将其转发给解析器,而不是等待整个文档。同样,解析器被重写为对传入数据流而不是单个文档进行操作。结果是,在许多情况下,盈利数据可以在文档开始到达后几毫秒内提取出来。这在不加快解析算法内部实现速度的情况下,将总体延迟(客户端观察到的)减少了几十毫秒。
虽然延迟要求在各种软件应用中都很常见,但金融交易行业和为其提供数据的新闻媒体部门拥有一个竞争特别激烈的生态系统,这对低延迟分布式系统提出了具有挑战性的需求。
与大多数工程问题一样,构建有效的低延迟分布式系统首先要清楚地理解问题。下一步是测量实际性能,然后在必要时进行改进。在这个领域,改进通常需要某种程度的深入到常见软件抽象的底层,并牺牲一定程度的效率来换取延迟的改善。
喜欢它,讨厌它?请告诉我们
Andrew Brook 是 Selerity 的首席技术官,Selerity 是一家实时新闻、数据和内容分析提供商。此前,他在两家大型投资银行领导电子货币交易系统的开发,并创立了一家 pre-dot-com 初创公司,为敏捷制造商提供人工智能驱动的调度软件。他的专长在于将分布式实时系统技术和数据科学应用于现实世界的业务问题。他发现 Wireshark 比 PowerPoint 更有趣。
© 2015 1542-7730/14/0300 $10.00
最初发表于 Queue vol. 13, no. 4—
在 数字图书馆 中评论本文
Marc Brooker, Ankush Desai - AWS 的系统正确性实践
构建可靠且安全的软件需要一系列方法来推理系统正确性。除了行业标准测试方法(例如单元测试和集成测试)外,AWS 还采用了模型检查、模糊测试、基于属性的测试、故障注入测试、确定性模拟、基于事件的模拟以及执行跟踪的运行时验证。形式化方法一直是开发过程的重要组成部分——也许最重要的是,形式化规范作为测试预言机,为 AWS 的许多测试实践提供正确的答案。正确性测试和形式化方法仍然是 AWS 的关键投资领域,在这些领域已经看到的卓越回报加速了投资。
Achilles Benetopoulos - 数据中心计算机的中间表示
我们已经达到了分布式计算无处不在的程度。内存应用程序数据大小正在超过单台机器的容量,因此需要将其在集群上进行分区;在线服务具有高可用性要求,只有将系统部署为多个冗余组件的集合才能满足这些要求;高持久性要求只能通过数据复制来满足,有时甚至跨越广阔的地理距离。
David R. Morrison - 模拟:分布式系统中未被充分利用的工具
模拟在 AI 系统的发展中发挥着巨大作用:我们需要一种高效、快速且经济高效的方式来训练 AI 代理在我们的基础设施中运行,而模拟绝对提供了这种能力。
Matt Fata, Philippe-Joseph Arida, Patrick Hahn, Betsy Beyer - 从公司到云端:谷歌的虚拟桌面
超过四分之一的 Google 员工使用内部、数据中心托管的虚拟桌面。这种本地部署的产品位于公司网络中,允许用户从世界任何地方远程开发代码、访问内部资源和使用 GUI 工具。在其最显着的功能中,虚拟桌面实例可以根据手头的任务调整大小,具有持久的用户存储,并且可以在公司数据中心之间移动以跟随出差的 Google 员工。直到最近,我们的虚拟桌面都托管在使用名为 Ganeti 的自研开源虚拟集群管理系统的 Google 公司网络上的商用硬件上。今天,这项重要且对 Google 至关重要的工作负载在 GCP(Google Compute Platform)上运行。