Background 背景
在开发过大规模分布式存储系统后,我们发现分布式带来的问题之一就是随着系统规模和复杂度的上升或者是总体访问量的增加整个系统变得极其脆弱,这里的脆弱指的是系统微弱的抖动都会造成整体延迟的升高,长尾效应极其明显。最终发现要将交互式服务的延迟分布中的尾端也保持在很低的水平上会变得非常有挑战性。在近期有幸拜读了 Jeffrey Dean 的《The Tail at Scale》这篇 paper,似乎才有管中窺豹,可見一斑的感觉。
长尾问题产生的最主要原因即为抖动 (Variability) ,大规模在线服务需要在一堆不可预测的组件的基础上创建一个响应可预测的整体;paper 《The Tail at Scale》中将这样的系统称为 "lantency tail-tolerant" 或者简称为 "tail-tolerant"。那么我们首先来看一下抖动产生的原因。
抖动 (Variability) 产生的原因
结合《The Tail at Scale》和最近的工程实践从软件和硬件层面来来阐述一下几点原因: (这里需要图)
软件层
- 资源共享(Shared resources)
机器可能是由竞争相同共享资源(比如是 CPU core, 处理器缓存,内存带宽和网络带宽)的多个应用共享,同时相同应用的不同请求也可能发生资源竞争;
- 守护进程(Daemons)
通常后台守护进程可能只会使用有限的资源,但是在被调度起来的时候可能会产生数毫秒的卡壳(hiccups);
- 全局性的资源共享(Global resource sharing)
运行在多个不同机器上的应用程序可能会竞争全局性的资源(比如网络交换机和共享的文件系统);
- 维护性动作(Maintenance activities)
后台活动(比如分布式文件系统中的数据重构,像 Bigtable这样的存储系统中的周期性日志 compaction,支持垃圾回收机制的语言的周期性垃圾回收)可能会导致延迟产生周期性的高峰;
- 队列机制(Queueing)
存在于中间服务器和网络交换机的多层队列机制会放大这种抖动。
硬件层
硬件的发展也可能会成为加重抖动的原因,比如如下几个:
- 电力控制(Power limits)
现代 CPU 设计的可以临时超过其平均功率限制运行,但是如果这种情况持续很长时间就会通过节流来降低产生的热量影响;
- 垃圾回收(Garbage collection)
固态存储设备ᨀ供了快速的随机访问,但是大量数据块的周期性垃圾回收需求,即使是在普通的写入频度下仍可能会导致读延迟增加 100 倍;
- 能源管理(Energy management)
很多支持节电模式的设备在节省了大量能源的同时也增加了从非活动模式转换到活动模式带来的额外延迟
首先我们了解了抖动的产生原因。其次回想在我们开发的在大规模在线服务中,降低延迟的一种通用技术手段 -- 并行化。
我们采用简单的 shared-nothing 架构,如图
简单理解并行化,就是将操作分为多个子操作在多台机器上并行执行,每个子操作操作数据集中的一部分。并行化是通过将请求从根节点发射到大量的叶子节点服务器执行来完成的,同时会通过请求分发树对响应结果进行归并。为保证服务尽快响应,所有子操作必须要在一个严格的截止期限内完成。简单来说就是增加系统的扇出,来利用更多资源。但是实际情况是独立组件的延迟抖动,从整个服务的角度上看会被放大;比如刚才的简单分布式架构,单个存储服务器大部分情况下都可以在 10ms 内完成响应,但是也只能保证 99% 的延迟在 10 ms 内。如果用户请求只是在一个这样的服务上进行处理,100 个请求里可能有 1 个会很慢(可能到达 100 ms )。这就带来了一概率计算问题。
概率计算
如图
这个图就展示了在上述假设下,服务级的延迟在规模增大情况下,发生延迟异常的概率变化情况。如果一个用户请求必须要收集来自 100 个这样的并行服务器响应的话,那么 63% 的用户请求将会花费超过 10ms 的时间(图中标记为 X 的点)。
这个 63% 是怎么来的呢?
这很重要,但是其实很好计算: 首先我们先引入监控指标中 bucket 的概念,通常我们使用 histogram 来记录延迟分布情况。histogram 中文的含义是直方图,我们在学习概率统计的时候都学习过直方图。直方图是对数据如何分布的一种总结方式——有多少值是高的,有多少是低的,有多少介于两者之间。
通常使用 prometheus 中是这样的:
通常我们都认为并行请求中的每个分支(扇出)请求是独立事件,独立事件代表着每个分支请求在客户端视角都是互不影响的,
那么我们设单个分支请求延迟命中前 99% bucket 的概率为 p, 一个完整的请求需要扇出 n 次,那么这 n 次扇出完全被 cover 住的概率为 P。那么有:
即根据以上例子
每次全部扇出请求在前 99% latncy 的 bucket 的概率约等于 36.6%,我们就认为它一定发生,那么也就意味着有 63% 的扇出将超过前 99% latncy 的 bucket 所代表的延迟。这里我们就延伸出监控指标中的 p50 p99 p999 p9999 的概念。这个问题也就变成了 n次扇出请求均落在 p99 内的概率。
论文中还讨论即使是在单个服务器级别上 10000 个请求中只有一个超过一秒延迟的情况下,对于一个具有 2000 个这样的服务器的服务来说,仍然将会有大概五分之一的请求延迟会超过 1 秒(图中标记为 O 的点)。这点抖动足以拉高你的长尾!
解决办法
通过前面的讨论,其实一个高性能系统是不可能不出现抖动问题的,我们只能尽力避免单体抖动和单体抖动对集群的影响,因为计算机系统本质就是调度任务,排队理论...这些交织形成的一个体系。下面我们说一下《The Tail at Scale》中提到的解决办法。
在单体上我们能做些什么?
- 根据服务类别进行差异化及更高层次的队列机制 (Differentiating service classes and higher-level queuing)
根据服务类别进行差异化就可以在那些非交互式请求之前优先调度那些用户正在等待的请求。保持底层队列的短小可以让上层策略更快产生效果;比如,Google 集群级文件系统的存储服务器会让操作系统的磁盘队列长度始终保持在非常低的水平,同时维护了一个自己的优先级队列来缓存磁盘请求。该队列允许服务器跳过那些更早到来的非延迟敏感的批处理操作,优先往下传送那些高优先级的交互式请求。
- 降低队头阻塞 (Reducing head-of-line blocking)
队头阻塞(Head-of-line blocking, HOL)是一种出现在缓存式通信网络交换中的一种现象。交换通常由缓存式输入端口、一个交换架构以及缓存式输出端口组成。当在相同的输入端口上到达的包被指向不同的输出端口的时候就会出现队头阻塞。由于输入缓存以及交换设计的 FIFO 特性,交换架构在每一个周期中只能交换缓存头部的包。如果某一缓存头部的包由于拥塞而不能交换到一个输出端口,那么该缓存中余下的包也会被队头包所阻塞,即使这些包的目的端口并没有拥塞。如果将需要长时间运行的请求(long-running request),注意⚠️是长时间运行的请求,切分成一系列的小请求,就可以让它与其他短时间即可运行完的请求( short-running request)交叉执行,有时对于系统来说是非常有用的;比如,Google 的网页搜索系统就会使用类似的分时机制来防止少数开销极高的查询请求影响到其他请求。这里结合工程实践一定要把握好切分扇出的粒度,因为可以预期的,这是一条二次曲线。
- 管理后台任务和对同步中断 (Managing background activities and synchronized disruption)
后台任务会产生显著的 CPU,磁盘或网络负载;像基于日志的 (log-oriented) 存储系统中的 log compaction以及支持自动垃圾回收的语言的垃圾回收活动。可以通过综合使用限流 (throttling),将重量级操作切分为小操作以及在总体负载较低时触发这些后台活动降低交互请求的延迟。对于具有大规模扇出 (fan-out) 的服务,在多个机器上进行后台活动的同步有时对于系统来说是非常有用的。这种同步会导致每台机器上同时出现短暂性的活动爆发,这样就只会影响到那些出现在这个短暂的时间区间内的请求。相比之下,如果没有同步,总有一些机器在进行后台活动,从而导致所有的请求会产生延迟长尾。目前为止的讨论中一直未涉及到缓存这个话题。尽管对于很多系统来说,高效的缓存层是非常有用的甚至是必需的,但是它并没有直接解决长尾延迟问题,除非可以保证将应用程序的整个工作集完全放入缓存。
在分布式上我们又能做些什么?
直到这里我们花费了大量的时间阐述了抖动的原因以及长尾不可消除理论,终于我们要引出我们今天所要讲的 BackupRequest 理论,这里我觉得是《The Tail at Scale》最重要的部分!Jeffrey Dean 最终还是认为现代 web 服务的规模和复杂性使得消除所有延迟抖动是不可能的。我也赞同此观点。因此在 Google 都是通过开发 "tail-tolerant" 技术来掩盖或绕过延迟问题,而不是试图完全消除它。我们将这些技术分为两大类:
- 第一类是专注于请求内部的立即响应技术,操作对象的时间尺度为数十毫秒;
- 第二类为跨请求的 long-term adaptations,所工作的时间尺度从数十秒到数分钟,是为了掩盖持续时间更长的现象的影响。
这里我们着重讲解第一类。
还记得我们开头的那个简单分布式架构图么?假设它的大多数请求都是 read,并且数据切割为 master-slave 多副本放置。那么我们可以使用副本来减少单个请求的延迟抖动。
Hedged requests 对冲请求
一种用来抑制延迟抖动的简单方法就是将相同请求发送给多个副本,然后使用最先返回的那个作为结果。我们将这些请求称为“对冲请求”,因为客户端会首先发送一个请求到被认为是最合适的那个副本,这里的合适由业务场景定义,随后在短暂的延迟后会再发送一个请求。一旦接收到响应结果客户端会忽略剩余未完成的请求。虽然该技术的这种简单实现版本会引入不可接受的额外负载,但是它的一些变种实现却可以在对负载略有影响的情况下得到很好的延迟降低效果。比如可以将第二个请求延迟到发出的第一个请求在超过该类请求 p95 延迟还未返回后再发送。这样就在大大降低了延迟的同时,把额外负载限制在大概 5%上。这项技术之所以可行是因为延迟通常不是请求本身固有的而是由其他形式的干扰造成的。比如,Google 的某个基准测试程序会从跨越 100 个不同机器的 Bigtable 系统中读取 1000 个 key 所对应的 value 值。通过在 10ms 的延迟后发送一个对冲请求,将检索 1000 个 key 的 p999 延迟从1800ms 降到了 74ms,同时仅多发了 2% 的请求。还可以通过让后发请求优先级低于主请求来进一步降低对冲请求的开销!
现在我们重新计算一个请求“成功”的概率,这里“成功”指的是能在 P99 延迟时间返回,这里在实践中有个前提:
这样的收益会更大,但是这个也没有限制的那么死,可以是一个浮动区间。那么我们还是设单个分支请求延迟命中前 99% bucket 的概率为 p, 一个完整的请求需要扇出 n 次,那么这 n 次扇出完全被 cover 住的概率为 P,其中第二次请求返回的概率期望是 Ep。
即根据以上例子,通常我们认为 Ep 还是前一次请求的成功概率。那么改造之前 p99 的例子,我们在 p95 线设置对冲请求:
通过“再来一次”的方法,我们可以把请求到 p99 的概率提升到 77.9%,但是我们却没做什么~~~就提升了 41.3% 的概率。同理,此方法也可以设置在 p999 p9999 上。
工程实践
- brpc backup request
brpc 的 backup_request 实现比较直接。 Channel 开启备份请求,Channel 将请求发送到其中一台服务器,当 ChannelOptions.backup_request_ms 毫秒后没有返回响应时,则发送到另一台服务器,取先返回的响应。合理设置 backup_request_ms 后,大多数情况下只需要发送一个请求,不会对后端服务造成额外的压力。
- Kitex backup request
客户端在一段时间内还没收到返回,发起重试请求,任一请求成功即算成功。Backup Request 的等待时间 RetryDelay 建议配置为 TP99,一般远小于配置的超时时间 Timeout。
总结
总结在 BackupRequest 中的收益,我们在延迟敏感的服务中可以减少服务的延迟波动,当然弊端是要接受额外的系统开销,并且在整个系统负载高的情况下需要谨慎再谨慎!并且对于非幂等性操作不要用 BackupRequest。
我们通过 Jeffrey Dean 的论文了解了抖动原因以及解决方案,长尾的原因以及解决方案。当然我的解读还是片面的,希望大家可以继续阅读原文总结并一起交流。