目录
image
开篇词 | 万变不离其宗,性能优化也有章可循
image一 基础设施优化 (6讲)
01 | CPU缓存:怎样写代码能够让CPU执行得更快?
CPU 的多级缓存
image提升数据缓存的命中率.
提升多核 CPU 下的缓存命中率。
小结:
CPU 缓存分为数据缓存与指令缓存.
对于数据缓存,我们应在循环体中尽量操作同一块内存上的数据,由于缓存是根据 CPU Cache Line 批量操作数据的,所以顺序地操作连续内存数据时也有性能提升。
对于指令缓存,有规律的条件分支能够让 CPU 的分支预测发挥作用,进一步提升执行效率.对于多核系统,如果进程的缓存命中率非常高,则可以考虑绑定 CPU 来提升缓存命中率.
02 | 内存池:如何提升内存分配的效率?
隐藏的内存池
image小结:
进程申请内存的速度,以及总内存空间都受到内存池的影响。知道这些隐藏内存池的存在,是提升分配内存效率的前提。
隐藏着的 C 库内存池,对进程的内存开销有很大的影响。当进程的占用空间超出预期时,你需要清楚你正在使用的是什么内存池,它对每个线程预分配了多大的空间。
不同的 C 库内存池,都有它们最适合的应用场景,例如 TCMalloc 对多线程下的小内存分配特别友好,而 Ptmalloc2 则对各类尺寸的内存申请都有稳定的表现,更加通用。
内存池管理着堆内存,它的分配速度比不上在栈中分配内存。只是栈中分配的内存受到生命周期和容量大小的限制,应用场景更为有限。
然而,如果有可能的话,尽量在栈中分配内存,它比内存池中的堆内存分配速度快很多!
03 | 索引:如何用哈希表管理亿级对象?
Ptmalloc2 为子线程预分配了 64MB 内存池,虽然增大了内存消耗,但却加快了分配速度,这就是以空间换时间的思想。
使用更多的空间建立索引,换取更短的查询时间.
为什么选择哈希表?
image哈希函数的执行时间是常量,数组的随机访问也是常量,时间复杂度就是 O(1)。 |i
位图是哈希的变种
image
内存结构与序列化方案
事实上对于动态(元素是变化的)哈希表,我们无法避免哈希冲突
1 链接法
2 开放寻址法
一、数据的长度是固定的
image二、数据的长度并不固定
image降低哈希表的冲突概率
第一个办法是调优哈希函数,第二个办法就是扩容。
image
小结
今天我们介绍了如何用哈希表管理上亿条数据。为什么选择哈希表?
因为哈希表的运行时间不随着业务规模增长而变化。位图本质上是哈希表的变种,不过它常用于配合主索引,快速判断数据的状态。
因为哈希表本身没办法找到关键字相邻的下一个元素,所以哈希表不支持范围查询与遍历。
如果业务需要支持范围查询时,我们需要考虑红黑树、B 树等索引,它们其实并不慢。当索引太大,必须将一部分从内存中移到硬盘时,B 树就是一个很好的选择。
使用哈希表,你要注意几个关键问题。生产环境一定要考虑容灾,而把哈希表原地序列化为文件是一个解决方案,它能保证新进程快速恢复哈希表。
解决哈希冲突有链接法和开放寻址法,而后者更擅长序列化数据,因此成为我们的首选 。
亿级数据下,我们必须注重内存的节约使用。数亿条数据会放大节约下的点滴内存,再把它们用于提升哈希数组的大小,就可以通过降低装载因子来减少哈希冲突,提升速度.
优化哈希函数也是降低哈希冲突的重要手段,我们需要研究关键字的特征与分布,设计出快速、使关键字均匀分布的哈希函数。
在课程的第四部分,集群的负载均衡也用到了哈希函数及其设计思想,只不过,哈希桶从一段内存变成了一台服务器。再延伸说一点,哈希表、红黑树等这些索引都使用了以空间换时间的思想。判断它们的时间消耗,我们都需要依赖时间复杂度这个工具。当然,索引在某些场景下也会降低性能。例如添加、删除元素时,更新索引消耗的时间就是新增的。但相对于整体的收益,这些消耗是微不足道的.
04 | 零拷贝:如何高效地传输文件?
磁盘是主机中最慢的硬件之一,常常是性能瓶颈,所以优化它能获得立竿见影的效果。
和https://blog.csdn.net/fdsafwagdagadg6576/article/details/107584821 内容一样.
05 | 协程:如何快速地实现高并发服务?
如何通过切换请求实现高并发?
imageimage
协程是如何实现高并发的?
image用户态的代码切换协程,与内核切换线程的原理是一样的.
协程的切换与此相同,只是把内核的工作转移到协程框架实现而已,下图是协程切换前的状态:
从协程 1 切换到协程 2 后的状态如下图所示:
image所以,协程的高性能,建立在切换必须由用户态代码完成之上,这要求协程生态是完整的,要尽量覆盖常见的组件.
小结
这一讲,我们从高并发的应用场景入手,分析了协程出现的背景和实现原理,以及它的应用范围。
你会发现,协程融合了多线程与异步化编程的优点,既保证了开发效率,也提升了运行效率.有限的硬件资源下,多线程通过微观上时间片的切换,实现了同时服务上百个用户的能力.
多线程的开发成本虽然低,但内存消耗大,切换次数过多,无法实现高并发。
异步编程方式通过非阻塞系统调用和多路复用,把原本属于内核的请求切换能力,放在用户态的代码中执行。这样,不仅减少了每个请求的内存消耗,也降低了切换请求的成本,最终实现了高并发。然而,异步编程违反了代码的内聚性,还需要业务代码关注并发细节,开发成本很高。协程参考内核通过 CPU 寄存器切换线程的方法,在用户态代码中实现了协程的切换,既降低了切换请求的成本,也使得协程中的业务代码不用关注自己何时被挂起,何时被执行。
相比异步编程中要维护一堆数据结构表示中间状态,协程直接用代码表示状态,大大提升了开发效率。在协程中调用的所有 API,都需要做非阻塞的协程化改造。
优秀的协程生态下,常用服务都有对应的协程 SDK,方便业务代码使用。
开发高并发服务时,与 IO 多路复用结合的协程框架可以与这些 SDK 配合,自动挂起、切换协程,进一步提升开发效率。
协程并不是完全与线程无关,首先线程可以帮助协程充分使用多核 CPU 的计算力,其次,遇到无法协程化、会导致内核切换的阻塞函数,或者计算太密集从而长时间占用 CPU 的任务,还是要放在独立的线程中执行,以防止它影响所有协程的执行
06 | 锁:如何根据业务场景选择合适的锁?
互斥锁与自旋锁:休眠还是“忙等待”?
阻塞是怎样进行的呢?对于 99% 的线程级互斥锁而言,阻塞都是由操作系统内核实现的.
image
但是,线程获取锁失败时,增加了两次上下文切换的成本:从运行中切换为休眠,以及锁释放时从休眠状态切换为运行中。上下文切换耗时在几十纳秒到几微秒之间,或许这段时间比锁住的代码段执行时间还长。而且,线程主动进入休眠是高并发服务无法容忍的行为,这让其他异步请求都无法执行。如果你能确定被锁住的代码执行时间很短,就应该用自旋锁取代互斥锁。自旋锁比互斥锁快得多,因为它通过 CPU 提供的 CAS 函数(全称 Compare And Swap),在用户态代码中完成加锁与解锁操作。
下面这段生产级的自旋锁:
while (true) {
//因为判断lock变量的值比CAS操作更快,所以先判断lock再调用CAS效率更高
if (lock == 0 && CAS(lock, 0, pid) == 1) return;
if (CPU_count > 1 ) { //如果是多核CPU,“忙等待”才有意义
for (n = 1; n < 2048; n <<= 1) {//pause的时间,应当越来越长
for (i = 0; i < n; i++) pause();//CPU专为自旋锁设计了pause指令
if (lock == 0 && CAS(lock, 0, pid)) return;//pause后再尝试获取锁
}
}
sched_yield();//单核CPU,或者长时间不能获取到锁,应主动休眠,让出CPU
}
允许并发持有的读写锁
如果你能够明确区分出读和写两种场景,可以选择读写锁
用队列把请求锁的线程排队,按照先来后到的顺序加锁即可,当然读线程仍然可以并发,只不过不能插队到写线程之前。
乐观锁:不使用锁也能同步
事实上,无论互斥锁、自旋锁还是读写锁,都属于悲观锁。
什么叫悲观锁呢?它认为同时修改资源的概率很高,很容易出现冲突,所以访问共享资源前,先加上锁,总体效率会更优。然而,如果并发产生冲突的概率很低,就不必使用悲观锁,而是使用乐观锁。
所谓“乐观”,就是假定冲突的概率很低,所以它采用的“加锁”方式是,先修改完共享资源,再验证这段时间内有没有发生冲突。如果没有其他线程在修改资源,那么操作完成。如果发现其他线程已经修改了这个资源,就放弃本次操作.
至于放弃后如何重试,则与业务场景相关,虽然重试的成本很高,但出现冲突的概率足够低的话,还是可以接受的。可见,乐观锁全程并没有加锁,所以它也叫无锁编程。
无锁编程中,验证是否发生了冲突是关键。
该怎么验证呢?这与具体的场景有关。比如说在线文档。Web 中的在线文档是怎么实现多人编辑的?用户 A 先在浏览器中编辑某个文档,之后用户 B 也打开了相同的页面开始编辑,可是,用户 B 最先编辑完成提交,这一过程用户 A 却不知道。当 A 提交他改完的内容时,A、B 之间的并行修改引发了冲突。
Web 服务是怎么解决这种冲突的呢?它并没有限制用户先拿到锁后才能编辑文档,这既因为冲突的概率非常低,也因为加解锁的代价很高。Web 中的方案是这样的:让用户先改着,但需要浏览器记录下修改前的文档版本号,这通过下载文档时,返回的 HTTP ETag 头部实现。
当用户提交修改时,浏览器在请求中通过 HTTP If-Match 头部携带原版本号,服务器将它与文档的当前版本号比较,一致后新的修改才能生效,否则提交失败.
乐观锁除了应用在 Web 分布式场景,在数据库等单机上也有广泛的应用。只是面向多线程时,最后的验证步骤是通过 CPU 提供的 CAS 操作完成的。
乐观锁虽然去除了锁操作,但是一旦发生冲突,重试的成本非常高。所以,只有在冲突概率非常低,且加锁成本较高时,才考虑使用乐观锁。
小结
这一讲我们介绍了高并发下同步资源时,如何根据应用场景选择合适的锁,来优化服务的性能。
互斥锁能够满足各类功能性要求,特别是被锁住的代码执行时间不可控时,它通过内核执行线程切换及时释放了资源,但它的性能消耗最大。需要注意的是,协程的互斥锁实现原理完全不同,它并不与内核打交道,虽然不能跨线程工作,但效率很高。
如果能够确定被锁住的代码取到锁后很快就能释放,应该使用更高效的自旋锁,它特别适合基于异步编程实现的高并发服务。
如果能区分出读写操作,读写锁就是第一选择,它允许多个读线程同时持有读锁,提高了并发性。读写锁是有倾向性的,读优先锁很高效,但容易让写线程饿死,而写优先锁会优先服务写线程,但对读线程亲和性差一些。还有一种公平读写锁,它通过把等待锁的线程排队,以略微牺牲性能的方式,保证了某种线程不会饿死,通用性更佳。
另外,读写锁既可以使用互斥锁实现,也可以使用自旋锁实现,我们应根据场景来选择合适的实现。
当并发访问共享资源,冲突概率非常低的时候,可以选择无锁编程。它在 Web 和数据库中有广泛的应用。然而,一旦冲突概率上升,就不适合使用它,因为它解决冲突的重试成本非常高。
总之,不管使用哪种锁,锁范围内的代码都应尽量的少,执行速度要快。在此之上,选择更合适的锁能够大幅提升高并发服务的性能!
二 系统网络层优化
07 | 性能好,效率高的一对多通讯该如何实现?
08 | 事件驱动:C10M是如何实现的? epoll
09 | 如何提升TCP三次握手的性能?
客户端的优化
image客户端在等待服务器回复的 ACK 报文。正常情况下,服务器会在几毫秒内返回 ACK,但如果客户端迟迟没有收到 ACK 会怎么样呢?客户端会重发 SYN,重试的次数由 tcp_syn_retries 参数控制,默认是 6 次:
net.ipv4.tcp_syn_retries = 6
第 1 次重试发生在 1 秒钟后,接着会以翻倍的方式在第 2、4、8、16、32 秒共做 6 次重试,最后一次重试会等待 64 秒,如果仍然没有返回 ACK,才会终止三次握手。所以,总耗时是 1+2+4+8+16+32+64=127 秒,超过 2 分钟。
服务器端的优化
imageimage
怎样调整 accept 队列的长度呢?listen 函数的 backlog 参数就可以设置 accept 队列的大小。
事实上,backlog 参数还受限于 Linux 系统级的队列长度上限,当然这个上限阈值也可以通过 somaxconn 参数修改。
10 | 如何提升TCP四次挥手的性能?
11 | 如何修改TCP缓冲区才能兼顾并发数量与传输速度?
滑动窗口是怎样影响传输速度的?
速的方式很简单,并行地批量发送报文,再批量确认报文即可。比如,发送一个 100MB 的文件,如果 MSS 值为 1KB,那么需要发送约 10 万个报文。发送方大可以同时发送这 10 万个报文,再等待它们的 ACK 确认。这样,发送速度瞬间就达到 100MB/10ms=10GB/s.
接收方把它的处理能力告诉发送方,使其限制发送速度即可,这就是滑动窗口的由来。
带宽时延积如何确定最大传输速度?
怎样计算出网络传输能力呢?带宽描述了网络传输能力,但它不能直接使用,因为它与窗口或者说缓冲区的计量单位不同。带宽是单位时间内的流量 ,它表达的是速度,比如你家里的宽带 100MB/s,而窗口和缓冲区的单位是字节。当网络速度乘以时间才能得到字节数,差的这个时间,这就是网络时延。
当最大带宽是 100MB/s、网络时延是 10ms 时,这意味着客户端到服务器间的网络一共可以存放 100MB/s * 0.01s = 1MB 的字节。这个 1MB 是带宽与时延的乘积,所以它就叫做带宽时延积(缩写为 BDP,Bandwidth Delay Product)。这 1MB 字节存在于飞行中的 TCP 报文,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了 1MB,就一定会让网络过载,最终导致丢包.
小结
实现高并发服务时,由于必须把大部分内存用在网络传输上,所以除了关注应用内存的使用,还必须关注 TCP 内核缓冲区的内存使用情况。
TCP 使用 ACK 确认报文实现了可靠性,又依赖滑动窗口既提升了发送速度也兼顾了接收方的处理能力。然而,默认的滑动窗口最大只能到 65KB,要想提升发送速度必须提升滑动窗口的上限,在 Linux 下是通过设置 tcp_window_scaling 为 1 做到的。
滑动窗口定义了飞行报文的最大字节数,当它超过带宽时延积时,就会发生丢包。而当它小于带宽时延积时,就无法让 TCP 的传输速度达到网络允许的最大值。因此,滑动窗口的设计,必须参考带宽时延积。
内核缓冲区决定了滑动窗口的上限,但我们不能通过 socket 的 SO_SNFBUF 等选项直接把缓冲区大小设置为带宽时延积,因为 TCP 不会一直维持在最高速上,过大的缓冲区会减少并发连接数。Linux 带来的缓冲区自动调节功能非常有效,我们应当把缓冲区的上限设置为带宽时延积。其中,发送缓冲区的调节功能是自动打开的,而接收缓冲区需要把 tcp_moderate_rcvbuf 设置为 1 来开启,其中调节的依据根据 tcp_mem 而定。
这样高效地配置内存后,既能够最大程度地保持并发性,也能让资源充裕时连接传输速度达到最大值。这一讲我们谈了内核缓冲区对传输速度的影响,下一讲我们再来看如何调节发送速度以匹配不同的网络能力。
12 | 如何调整TCP拥塞控制的性能?
慢启动阶段如何调整初始拥塞窗口?
出现网络拥塞时该怎么办?
第 1 种场景:
降低发送速度.慢启动
在规定时间内没有收到 ACK 报文,这说明报文丢失了,网络出现了严重的拥塞,必须先降低发送速度,再进入拥塞避免阶段。不同的拥塞控制算法降低速度的幅度并不相同,比如 CUBIC 算法会把拥塞窗口降为原先的 0.8 倍(也就是发送速度降到 0.8 倍).此时,我们知道了多大的窗口会导致拥塞,因此可以把慢启动阈值设为发生拥塞前的窗口大小.
第 2 种场景:
拥塞避免.
虽然还没有发生丢包,但发送方已经达到了曾经发生网络拥塞的速度(拥塞窗口达到了慢启动阈值),接下来发生拥塞的概率很高,所以进入拥塞避免阶段,此时拥塞窗口不能再以指数方式增长,而是要以线性方式增长。接下来,拥塞窗口会以每个 RTT 增加 1 个 MSS 的方式,代替慢启动阶段每收到 1 个 ACK 就增加 1 个 MSS 的方式。这里可能有同学会有疑问,在第 1 种场景发生前,慢启动阈值是多大呢?事实上,RFC5681 建议最初的慢启动阈值尽可能的大,这样才能在第 1、3 种场景里快速发现网络瓶颈.
第 3 种场景最为复杂:
快速重传
我们知道,TCP 传输的是字节流,而“流”是天然有序的。因此,当接收方收到不连续的报文时,就可能发生报文丢失或者延迟,等待发送方超时重发太花时间了,为了缩短重发时间,快速重传算法便应运而生。当连续收到 3 个重复 ACK 时,发送方便得到了网络发生拥塞的明确信号,通过重复 ACK 报文的序号,我们知道丢失了哪个报文,这样,不等待定时器的触发,立刻重发丢失的报文,可以让发送速度下降得慢一些,这就是快速重传算法。
第4种场景:快速恢复
慢启动、拥塞避免、快速重传、快速恢复,共同构成了拥塞控制算法。
小结
当 TCP 连接建立成功后,拥塞控制算法就会发生作用,首先进入慢启动阶段。决定连接此时网速的是初始拥塞窗口,Linux 上可以通过 route ip change 命令修改它。通常,在带宽时延积较大的网络中,应当调高初始拥塞窗口。
丢包以及重复的 ACK 都是明确的拥塞信号,此时,发送方就会调低拥塞窗口减速,同时修正慢启动阈值。这样,将来再次到达这个速度时,就会自动进入拥塞避免阶段,用线性速度代替慢启动阶段的指数速度提升窗口大小。
当然,重复 ACK 意味着发送方可以提前重发丢失报文,快速重传算法定义了这一行为。同时,为了使得重发报文的过程中,发送速度不至于出现断崖式下降,TCP 又定义了快速恢复算法,发送方在报文重新变得有序后,结束快速恢复进入拥塞避免阶段。
但以丢包作为网络拥塞的信号往往为时已晚,于是以 BBR 算法为代表的测量型拥塞控制算法应运而生。当飞行中报文数量不变,而网络时延升高时,就说明网络中的缓冲队列出现了积压,这是进行拥塞控制的最好时机。Linux 高版本支持 BBR 算法,你可以通过 tcp_congestion_control 配置更改拥塞控制算法。
13 | 实战:单机如何实现管理百万主机的心跳服务?
如何设计更快的宕机判断算法?
其实这个算法的根本问题在于,判断宕机的流程做了大量的重复工作。
image
image
image
**如何设计高并发架构? **
一颗 1GHZ 主频的 CPU,意味着一秒钟只有 10 亿个时钟周期可以工作,如果心跳服务每秒接收到 100 万心跳包,就要求它必须在 1000 个时钟周期内处理完一个心跳包。这无法做到,因为每一个汇编指令的执行需要多个时钟周期(参见CPI),一条高级语言的语句又由多条汇编指令构成,而中间件提供的反序列化等函数又需要很多条语句才能完成。另外,内核从网卡上读取报文,执行协议分析需要的时钟周期也要算到这 1000 个时钟周期里。
因此,选择只用一颗 CPU 为核心的单线程开发模式,一定会出现计算力不足,不能及时接收报文从而使得缓冲区溢出的问题,最终导致大量丢包。所以,我们必须选择多线程或者多进程开发模式。多进程之间干扰更小,但内存不是共享的,数据同步较为困难,因此案例中我们还是选择多线程开发模式。
使用多线程后我们需要解决 3 个问题。
第一是负载均衡
第二是多线程同步
image
第三要解决 CPU 亲和性问题
如何选择心跳包网络协议?
image三 应用层编解码优化
14 | 优化TLS/SSL性能该从何下手?
15 | 如何提升HTTP/1.1性能?
16 | HTTP/2是怎样提升性能的?
静态表、Huffman 编码、动态表共同完成了 HTTP/2 头部的编码,其中,前两者可以将体积压缩近一半,而后者可以将反复传输的头部压缩 95% 以上的体积!
image了理解 HTTP/2 的并发是怎样实现的,你需要了解 Stream、Message、Frame 这 3 个概念.
image小结
HTTP/2 的另一个优势是实现了 Stream 并发,这节约了 TCP 和 TLS 协议的握手时间,并减少了 TCP 的慢启动阶段对流量的影响。
同时,Stream 之间可以用 Weight 权重调节优先级,还可以直接设置 Stream 间的依赖关系,这样接收端就可以获得更优秀的体验。
HTTP/2 支持消息推送,从 HTTP/1.1 的拉模式到推模式,信息传输效率有了巨大的提升。
HTTP/2 推消息时,会使用 PUSH_PROMISE 帧传输头部,并用双号的 Stream 来传递包体,了解这一点对定位复杂的网络问题很有帮助。
HTTP/2 的最大问题来自于它下层的 TCP 协议。由于 TCP 是字符流协议,在前 1 字符未到达时,后接收到的字符只能存放在内核的缓冲区里,即使它们是并发的 Stream,应用层的 HTTP/2 协议也无法收到失序的报文,这就叫做队头阻塞问题。解决方案是放弃 TCP 协议,转而使用 UDP 协议作为传输层协议,这就是 HTTP/3 协议的由来。
17 | Protobuf是如何进一步提高编码效率的?
动态表可以让更多的 HTTP 头部编码为数字,在上一讲的例子中,动态表将 Host 头部减少了 96% 的体积,效果惊人。但动态表生效得有一个前提:必须在一个会话连接上反复传输完全相同的 HTTP 头部。如果消息字段在 1 个连接上只发送了 1 次,或者反复传输时字段总是略有变动,动态表就无能为力了。
有没有办法既使用静态表的预定义映射关系,又享受到动态表的灵活多变呢?其实只要把由 HTTP/2 框架实现的字段名映射关系,交由应用程序自行完成即可。而 Protobuf 就是这么做的。比如下面这段 39 字节的 JSON 消息,虽然一目了然,但字段名 name、id、sex 其实都是多余的,因为客户端与服务器的处理代码都清楚字段的含义
{"name":"John","id":1234,"sex":"MALE"}
Protobuf 将这 3 个字段名预分配了 3 个数字,定义在 proto 文件中:
message Person {
string name = 1;
uint32 id = 2;enum SexType {
MALE = 0;
FEMALE = 1;
}
SexType sex = 3;
}
接着,通过 protoc 程序便可以针对不同平台、编程语言,将它生成编解码类,最后通过类中自动生成的 SerializeToString 方法将消息序列化,编码后的信息仅有 11 个字节。其中,报文与字段的对应关系我放在下面这张图中。
image从图中可以看出,Protobuf 是按照字段名、值类型、字段值的顺序来编码的,由于编码极为紧凑,所以分析时必须基于二进制比特位进行。比如红色的 00001、00010、00011 等前 5 个比特位,就分别代表着 name、id、sex 字段.
图中字段值的编码方式我们后面再解释,这里想必大家会有疑问,如果只有 5 个比特位表示字段名的值,那不是限制消息最多只有 31 个(25 - 1)字段吗?当然不是,字段名的序号可以从 1 到 536870911(即 229 - 1),可是,多数消息不过只有几个字段,这意味着可以用很小的序号表示它们。因此,对于小于 16 的序号,Protobuf 仅有 5 个比特位表示,这样加上 3 位值类型,只需要 1 个字节表示字段名。对于大于 16 小于 2027 的序号,也只需要 2 个字节表示。
Protobuf 可以用 1 到 5 个字节来表示一个字段名,因此,每个字节的第 1 个比特位保留,它为 0 时表示这是字段名的最后一个字节。下表列出了几种典型序号的编码值(请把黑色的二进制位,从右至左排列,比如 2049 应为 000100000000001,即 2048+1).
image说完字段名,我们再来看字段值是如何编码的。
怎样高效地编码字段值?
Protobuf 对不同类型的值,采用 6 种不同的编码方式,如下表所示:
image字符串用 Length-delimited 方式编码,顾名思义,在值长度后顺序添加 ASCII 字节码即可。比如上文例子中的 John,对应的 ASCII 码如下表所示:
image这样,"John"需要 5 个字节进行编码,如下图所示(绿色表示长度,紫色表示 ASCII 码):
image这里需要注意,字符串长度的编码逻辑与字段名相同,当长度小于 128(27)时,1 个字节就可以表示长度。若长度从 128 到 16384(214),则需要 2 个字节,以此类推.
由于字符串编码时未做压缩,所以并不会节约空间,但胜在速度快。如果你的消息中含有大量字符串,那么使用 Huffman 等算法压缩后再编码效果更好。
我们再来看 id:1234 这个数字是如何编码的。其实 Protobuf 中所有数字的编码规则是一致的,字节中第 1 个比特位仅用于指示由哪些字节编码 1 个数字。例如图中的 1234,将由 14 个比特位 00010011010010 表示(1024+128+64+16+2,正好是 1234)。
由于消息中的大量数字都很小,这种编码方式可以带来很高的空间利用率!当然,如果你确定数字很大,这种编码方式不但不能节约空间,而且会导致原先 4 个字节的大整数需要用 5 个字节来表示时,你也可以使用 fixed32、fixed64 等类型定义数字.
Protobuf 还可以通过 enum 枚举类型压缩空间。回到第 1 幅图,sex: FEMALE 仅用 2 个字节就编码完成,正是枚举值 FEMALE 使用数字 1 表示所达到的效果.
image而且,由于 Protobuf 定义了每个字段的默认值,因此,当消息使用字段的默认值时,Protobuf 编码时会略过该字段。以 sex: MALE 为例,由于 MALE=0 是 sex 的默认值,因此在第 2 幅示例图中,这 2 个字节都省去了。
另外,当使用 repeated 语法将多个数字组成列表时,还可以通过打包功能提升编码效率。比如下图中,对 numbers 字段添加 101、102、103、104 这 4 个值后,如果不使用打包功能,共需要 8 个字节编码,其中每个数字前都需要添加字段名。而使用打包功能后,仅用 6 个字节就能完成编码,显然列表越庞大,节约的空间越多。
image在 Protobuf2 版本中,需要显式设置 [packed=True] 才能使用打包功能,而在 Protobuf3 版本中这是默认功能。
最后,从这里可以查看 Protobuf 的编解码性能测试报告,你能看到,在保持高空间利用率的前提下,Protobuf 仍然拥有飞快的速度.
小结
通过在 proto 文件中为每个字段预分配 1 个数字,编码时就省去了完整字段名占用的空间。而且,数字越小编码时用掉的空间也越小,实际网络中大量传输的是小数字,这带来了很高的空间利用率。Protobuf 的枚举类型也通过类似的原理,用数字代替字符串,可以节约许多空间。
对于字符串 Protobuf 没有做压缩,因此如果消息中的字符串比重很大时,建议你先压缩后再使用 Protobuf 编码。对于拥有默认值的字段,Protobuf 编码时会略过它。对于 repeated 列表,使用打包功能可以仅用 1 个字段前缀描述所有数值,它在列表较大时能带来可观的空间收益。
18 | 如何通过gRPC实现高效远程过程调用?
四 分布式优化
19 | 如何通过监控找到性能瓶颈?
单机:如何通过火焰图找到性能瓶颈?
最直接有效的方式,就是从代码层面直接寻找调用次数最频繁、耗时最长的函数,通常它就是性能瓶颈.
image
如果函数方块的长度,远大于调用栈中子函数方块的长度之和,那么这个函数就执行了比较耗费 CPU 的计算。
比如,下图中执行 TLS 握手的 ngx_ssl_handshake_handler 函数,自身并没有消耗 CPU 多少计算力 .
image
而更新 Nginx 时间的 ngx_time_update 函数就不同,它在做时间格式转换时消耗了许多 CPU,如下图所示:
image
因为 Linux 内核默认支持 perf 工具,你可以用 perf 生成函数的采样数据,再用 FlameGraph 脚本生成火焰图.
使用 perf 采样函数的调用频率
perf record -F 99 -p 进程PID -g --call-graph dwarf
再将二进制信息转换为 ASCII 格式的文件,方便 FlameGraph 处理
perf script > out.perf
需要汇聚函数调用栈,转化为 FlameGraph 生成火焰图的数据格式
FlameGraph/stackcollapse-perf.pl out.perf > out.folded
生成 SVG 格式的矢量图片
FlameGraph/flamegraph.pl out.folded > out.svg
上面的火焰图只能找到消耗 CPU 计算力最多的函数,因此它也叫做 On CPU 火焰图,由于 CPU 工作时会发热,所以色块都是暖色调 。
image小结
这一讲,我们介绍了单机上如何通过火焰图找到性能瓶颈函数,以及在分布式系统中,如何通过全链路监控找到性能瓶颈组件。
在 Linux 系统中,你可以用内核支持的 perf 工具,快速地生成火焰图。其他高级编程语言生态中,都有类似的 Profiler 工具,可生成火焰图。
火焰图中可以看到函数调用栈,它对你分析源码很有帮助。图中方块的长度表示函数的调用频率,当采样很密集时,你可以把它近似为函数的执行时长。父方块长度减去所有子方块长度的和,就表示了父函数自身代码对 CPU 计算力的消耗。因此,火焰图可以直观地找到调用次数最频繁且最耗时的函数。
除了上面介绍的 On CPU 火焰图,你也可以用同样的工具生成 Off CPU 火焰图,它用于找到频率引发进程休眠,进而降低了性能的瓶颈函数。
对于分布式系统,性能监控还有利于系统的扩容和缩容运维。搭建性能监控体系包括以下四个关键点:首先要把五花八门的日志用正则表达式提取为结构化的监控数据;其次用半结构化的列式数据库存放集群中的所有日志,便于后续的汇聚分析;第三,使用统一的请求 ID 将各组件串联在一起,并使用 MapReduce 算法对大量的监控数据做离线分析;最后,通过实时流计算框架,对监控数据做实时汇聚分析。
性能监控是一个很大的话题,这节课我只希望能从不同的角度带给你思考,课后还需要你进行更深入的探索。
20 | CAP理论:怎样舍弃一致性去换取性能?
小结
CAP 理论指出,可用性、分区容错性、一致性三者只能取其二,因此当分布式系统需要服务更多的用户时,只能舍弃一致性,换取可用性中的性能因子。当然,性能与一致性并不是简单的二选一,而是需要你根据网络时延、故障概率设计出一致性模型,在提供高性能的同时,保持时间、空间上可接受的最终一致性。
具体的设计方法,可以分为纵向上添加缓存,横向上添加副本进程两种做法。对于缓存的更新,write through 模式保持一致性更容易,但写请求的时延偏高,而一致性模型更复杂的 write back 模式时延则更低,适用于性能要求很高的场景。
提升系统并发性可以通过添加数据副本,并让工作在副本上的进程同时对用户提供服务。副本间的数据同步是由写请求触发的,其中包括同步、异步两种同步方式。异步方式的最终一致性要差一些,但写请求的处理时延更快。在宕机恢复、系统扩容时,采用快照加操作日志的方式,系统的性能会好很多。
21 | AKF立方体:怎样通过可扩展性来提高性能?
imageimage
image
image
image
image
小结
这一讲我们介绍了如何基于 AKF 立方体的 X、Y、Z 三个轴扩展系统提升性能。
X轴扩展系统时实施成本最低,只需要将程序复制到不同的服务器上运行,再用下游的负载均衡分配流量即可.X 轴只能应用在无状态进程上,故无法解决数据增长引入的性能瓶颈.
Y 轴扩展系统时实施成本最高,通常涉及到部分代码的重构,但它通过拆分功能,使系统中的组件分工更细,因此可以解决数据增长带来的性能压力,也可以提升系统的总体效率。比如关系数据库的读写分离、表字段的垂直拆分,或者引入缓存,都属于沿 Y 轴扩展系统。
Z 轴扩展系统时实施成本也比较高,但它基于用户信息拆分数据后,可以在解决数据增长问题的同时,基于地理位置就近提供服务,进而大幅度降低请求的时延,比如常见的 CDN 就是这么提升用户体验的。但 Z 轴扩展系统后,一旦发生路由规则的变动导致数据迁移时,运维成本就会比较高。
当然,X、Y、Z 轴的扩展并不是孤立的,我们可以同时应用这 3 个维度扩展系统。分布式系统非常复杂,AKF 给我们提供了一种自上而下的方法论,让我们能够针对不同场景下的性能瓶颈,以最低的成本提升性能。
22 | NWR算法:如何修改读写模型以提升性能?
小结
这一讲我们介绍了鸽巢原理,以及由此推导出的 NWR 算法,并以流行的 NoSQL 数据库 Cassandra 为例,介绍了 NWR 在分布式系统中的实践。
当鸽子的数量超过了鸽巢后,就要注定某一个鸽巢内一定含有两只以上的鸽子,同样的道理,只要读、写操作涉及的节点超过半数,就注定读写操作总包含一个含有正确数据的节点。NWR 算法将这一原理一般化为:只要读节点数 R + 写节点数 W > 存储节点数 N,特别是 W > N/2 时,就能使去中心的分布式系统获得强一致性。
支持上万节点的 Cassandra 数据库,就使用了 NWR 算法来保持一致性。当然,Cassandra 支持多种一致性模型,当你需要更强劲的性能时,你可以令 R + W < N,当业务变化导致需要增强系统的一致性时,你可以实时地修改 R、W。Cassandra 也支持跨数据中心部署,此时的一致性模型更为复杂,但仍然将 NWR 算法作为实现基础.
23 | 负载均衡:选择Nginx还是OpenResty?
负载均衡是如何扩展系统提升性能的?
imageimage
image
Nginx 在进程启动、处理请求时提供了许多钩子函数,允许第三方 C 模块将其代码放在这些钩子函数中执行。同时,Nginx 还允许 C 模块自行解析 nginx.conf 中出现的配置项.
image24 | 一致性哈希:如何高效地均衡负载?
小结
这一讲我们介绍了一致性哈希算法的工作原理。
传统哈希函数中,主机节点的变化会导致大量数据发生迁移。一致性哈希算法将 32 位哈希值构成环,并将它分段赋予各节点,这样,扩容、缩容动作就只影响相邻节点,大幅度减少了数据迁移量。一致性哈希算法虽然将数据的迁移量从 O(M) 降为 O(M/N),却也将映射函数的时间复杂度从 O(1) 提高到 O(logN),但由于节点数量 N 并不会很大,所以一致性哈希算法的性价比还是很高的。
当哈希值分布不均匀时,数据分布也不会均衡。在哈希环与真实节点间,添加虚拟节点层,可以通过新的哈希函数,分散不均匀的数据。每个真实节点含有的虚拟节点数越多,数据分布便会越均衡,但同时也会消耗更多的内存与计算力。
虚拟节点带来的最大优点是宕机时由所有节点共同分担流量缺口,这避免了可能产生的雪崩效应.同时,扩容的新节点也会分流所有节点的压力,这也提升了系统整体资源的利用率
25 | 过期缓存:如何防止缓存被流量打穿?
小结
这一讲我们系统地总结了缓存的工作原理,以及 Nginx 解决缓存穿透问题的方案。
当组件间的访问速度差距很大时,直接访问会降低整体性能,在二者之间添加更快的缓存是常用的解决方案。根据时间局部性原理,将请求结果放入缓存,会有很大概率被再次命中,而根据空间局部性原理,可以将相邻的内容预取至缓存中,这样既能通过批处理提升效率,也能降低后续请求的时延。
由于缓存容量小于原始数据集,因此需要将命中概率较低的数据及时淘汰出去。其中最常用的淘汰算法是 FIFO 与 LRU,它们执行的时间复杂度都是 O(1),效率很高。
由于缓存服务的性能远大于上游应用,一旦大流量穿透失效的缓存到达上游后,就可能压垮应用。Nginx 作为 HTTP 缓存使用时,可以打开合并回源功能,减轻上游压力。在上游应用宕机后,还可以使用过期缓存为用户提供降级服务
26 | 应用层多播:如何快速地分发内容?
image
小结
这一讲我们介绍了应用层的多播协议。
网络层的 IP 多播功能有限,对网络环境也有过多的要求,所以很难通过多播协议提升传输效率。基于 IP 单播协议(如 TCP 或者 UDP),在应用代码层面实现分布式节点间的接力转发,就可以实现应用层的多播功能。
在分布式集群的文件分发场景中,阿里开源的Dragonfly 蜻蜓可以将发布节点上的源文件,通过 HTTP 协议推送到集群中的每个节点上,其中每个节点在应用层都参与了多播流量分发的实现。当节点数到达千、万级时,蜻蜓仍然能保持较低的分发时延,避免发布节点被下行流量打爆。
在完全去中心化的分布式集群中,每个节点都没有准确的全局信息,此时可以使用 Gossip 流言协议,通过仅向有限的相邻节点发送消息,完成整个集群的数据同步,实现最终一致性。因此,Gossip 协议常用于大规模分布集群中的节点状态同步。
27 | 消息队列:如何基于异步消息提升性能?
image把单机中的 FIFO 队列放大到分布式系统中,就形成了独立的消息队列服务。此时,生产者、消费者的角色从线程变成了网络中的独立服务,生产者可以向消息队列发布多种消息,多个消费者也可以订阅同一种消息,如下图所示:
image总结一下的话,消息队列就具备了以下 7 个优点:
1 降低了系统的耦合性。比如上图中,组件 2 发布了一条用户注册成功消息,原本只有负责通知用户注册结果的组件 3 在处理,如果组件 4 需要立刻开启新用户的营销工作,只需要同时向消息队列订阅即可;再比如,组件 2、组件 3、组件 4 通讯时并不需要统一应用层协议或者 RPC 接口,所有参与方只需要与消息队列服务的 SDK 打交道。
2 可伸缩性很容易实现。比如,当组件 3 的性能不足时,添加订阅消息的新实例,就可以通过水平扩展提升消费能力。反之,也可以扩展组件 1,提升消息的生产能力。
3 天然实现“削峰填谷”功能。消息队列服务会将消息持久化存储在磁盘中,在高峰期来不及处理的消息,会在低谷期被消费者服务处理完。通常,消息队列会使用廉价、高容量的机械磁盘存放消息,可以轻松缓存住高峰期超载的全部请求。
4 提高了系统可用性。首先,持久化到磁盘中的消息,在宕机故障时比内存中的请求有更高的可用性;其次,消息队列可以隔离故障,比如,消费者服务宕机后,生产者服务短期内不会受到影响;再次,当总吞吐量超过性能上限时,还可以设置不同的消息优先级,通过服务降级保障系统的基本可用性。
5 消息队列的生产者天然具备异步功能,这降低了生产者的请求处理时延,提升了用户体验。
6 [第 21 课] 介绍过,基于 AKF Y 轴拆分功能可以降低数据规模,而且组件间分工更细也会带来更深入的性能优化。当消息队列作为通讯方式时,这种“事件驱动”的分布式系统很容易通过消息实现服务拆分,成本会低很多。
7 消息队列服务对于各种消息的发布、消费情况都有统计,因此,从消息中就能获得业务的实时运行状态,以极低的成本实现系统的监控。
正是因为这么多的优点,所以消息队列成为了多数分布式系统必备的基础设施。而且,消息队列自身也拥有很高的性能,比如 RabbitMQ 单机每秒可以处理 10 万条消息,而 Kafka 单机每秒甚至可以处理百万条消息。消息队列的性能为什么如此夸张呢?除了消息队列处理逻辑简单外,还有一个重要原因,就是消息的产生、消费在时间上是连续的,这让消息队列在以下优化点上能获得很高的收益:
- 首先,在网络通讯中,很容易通过批量处理提高网络效率。比如生产者快速发布消息时,Kafka 的客户端 SDK 会自动聚集完一批消息,再一次性发送给 Broker,这样网络报文的有效载荷比会很高。
- 其次,在数据写入磁盘的过程中,由于时序性特征,存放消息的文件仅以追加形式变更,这样多数情况下机械硬盘的磁头仅朝一个方向转动,这让磁盘写入速度可以轻松达到 100MB/s。
- 最后,由于消费者也是按照 FIFO 规则有序接收消息的,这样消息队列的缓存就可以通过批量预读等优化方式,大幅提高读操作的缓存命中率。
消息队列的服务质量是如何保证的?
为了提升整个分布式系统的性能,我们在处理消息时,还需要在生产端、消费端以及消息队列的监控上,做到以下 3 件事:
首先,虽然生产者会异步地发布消息,但毕竟需要接收到消息队列的确认,才构成完整的发布流程。网络传输是相对漫长、不可控的,所以在高性能场景中,生产者应基于多线程或者非阻塞 Socket 发布消息,以提高并发能力。
其次,当消费端性能不足需要扩容时,必须同步增加消息队列服务中的队列(在 Kafka 中叫做分区),才能允许新增的消费节点并行接收消息,提高消息的处理能力。否则,当多个消费者消费同一消息队列时,消息的有序性会导致多个消费节点串行处理消息,无法发挥出它们的全部性能,如下图所示:
image最后,如果通过监控发现消息的消费能力小于生产能力,那就必须及时扩容消费端,或者降低消息的发布速度,否则消息就会积压,最终导致系统不可用 。
小结
这一讲我们介绍了消息队列及其用法。
消息队列可以解耦分布式系统,其缓存的消息提供了削峰填谷功能,将消息持久化则提高了系统可用性,共享队列则为系统提供了可伸缩性,而且统计消息就可以监控整个系统,因此消息队列已成为当下分布式系统的必备基础设施。
虽然消息队列自身拥有优秀的性能,但若想提高使用效率,我们就需要确保在生产端实现网络传输上的并发,在消费端扩容时同步增加队列或者分区,并且需要持续监控系统,确保消息的生产能力小于消费能力,防止消息积压。
消息队列的 Qos 提供三种语义,其中 at most once 很少使用,而主流的 at least once 由消息持久化时的冗余,以及生产端、消息端使用消息的方式共同保障。Kafka 通过幂等性、事务消息这两个特性,在 at least once 的基础上提供了 exactly once 语义。
28 | MapReduce:如何通过集群实现离线计算?
分而治之:如何实现集群中的批量计算?
imageimage
image
image
image
小结
这一讲我们介绍了在集群中使用分治算法统计大规模数据的 MapReduce 模式。
当数据量很大,或者计算时间过长时,如果计算过程可以被分解为并发执行的子任务,就可以基于 MapReduce 思想,利用分布式集群的计算力完成任务。其中,用户可以预定义在节点中并发执行的 Map 函数,以及将 Map 输出的列表合并为最终结果的 Reduce 函数。
虽然 MapReduce 将并行计算抽象为统一的模型,但开发 Map、Reduce 函数的成本还是太高了,于是针对高频场景,许多 MapReduce 之上的框架提供了类 SQL 语言接口,通过 group by 的聚合、join 连接以及各种统计函数,我们就可以利用整个集群完成数据分析。
MapReduce 模式针对的是静态数据,也叫有边界数据,它更多用于业务的事前或者事后处理流程中,而做事中处理时必须面对实时、不断增长的无边界数据流,此时 MapReduce 就无能为力了。下一讲我们将介绍处理无边界数据的流式计算框架。
29 | 流式计算:如何通过集群实现实时计算?
流式计算是如何实现的?
image这种设计思想就是基于固定时间窗口的批处理解决方案
imageimage
如何通过窗口确定待计算的数据? image
为了避免乱序事件扰乱统计结果,我们可以使用水位线 Watermark 减少乱序概率.比如下图中,消息队列中的数字表示事件时间,其中事件 7 先于事件 3,5 到达了流式计算系统:
image如果设置了水位 4,窗口就不再以事件顺序严格划分,而是通过水位上的时间来划分窗口,这样事件 7 就会放在第 2 个窗口中处理:
image当然,并不是有了水位线,第 1 个窗口就会无限制的等下去。在经历一个时间段后,第 1 个窗口会认定窗口关闭(这未必准确),它会处理 3、1、3、2 这 4 个事件。基于业务规则,下一个水位被设置为 9:
image这样第 2 个窗口会处理 6、5、7 事件,而事件 9 就放在了第 3 个窗口中处理:
image以此类推。根据业务特性和经验值,找到最大乱序时间差,再基于此设置合适的水位线,就能减轻乱序事件的影响.
小结
这一讲我们介绍了流式计算的实现原理,以及常用的几种分片窗口。
对于无边界的实时数据流,我们可以在时间维度上将其切分到不同的窗口中,再将每个窗口内的数据从空间维度上分发到不同的节点并行计算,在窗口结束时汇总结果,这就实现了流式计算。Apache Flink、Spark、Storm 等开源产品都是这样的流式计算框架。
通过不同的窗口划分规则,可以实现不同的计算目的,包括以时间驱动的固定窗口、滑动窗口和计数窗口,以及以事件驱动的会话窗口。为了避免乱序事件的影响,还可以通过携带超时时间的 Watermark 水位,基于事件发生时间更精准地划分窗口.
30 | 如何权衡关系数据库与NoSQL数据库?
最后是关系数据库固有的可伸缩性问题,这是各类 NoSQL 数据库不断诞生的主要原因。
image沿 AKF Z 轴扩展数据库虽然能够降低数据规模,但分库分表后,单一值关系引申出的 ACID 事务不能基于高时延、会抖动的网络传输强行实现,否则会导致性能大幅下降,这样的可用性是分布式系统无法接受的。
imageNoSQL 数据库是如何解决上述问题的?
NoSQL 数据库只是放弃了与分布式环境相悖的 ACID 事务,提供了另一种聚合数据模型,从而拥有可伸缩性的非关系数据库。
NoSQL 数据库可以分为以下 4 类:
key/Value 的类型通常由应用层代码决定,当然,Redis 这样的 Key/Value 数据库还可以将 Value 定义为列表、哈希等复合结构。
文档型数据库,在 Key/Value 数据库中,由于没有预定义的值结构,所以只能针对 Key 执行查询,这大大限制了使用场景。文档型数据库将 Value 扩展为 XML、JSON(比如 MongoDB)等数据结构,于是允许使用者在文档型数据库的内部解析复合型的 Value 结构,再通过其中的单一值进行查询,这就兼具了部分关系数据库的功能.
列式数据库,比如[第 22 讲] 介绍过的 Cassandra。列式数据库基于 Key 来映射行,再通过列名进行二级映射,同时它基于列来安排存储的拓扑结构,这样当仅读写大量行中某个列时,操作的数据节点、磁盘非常集中,磁盘 IO、网络 IO 都会少很多。列式数据库的应用场景非常有针对性,比如博客文章标签的行数很多,但在做数据分析时往往只读取标签列,这就很适合使用列式数据库。再比如,通过倒排索引实现了全文检索的 ElasticSearch,就适合使用列式存储存放 Doc Values,这样做排序、聚合时非常高效。
图数据库,在社交关系、知识图谱等场景中,携带各种属性的边可以表示节点间的关系,由于节点的关系数量多,而且非常容易变化,所以关系数据库的实现成本很高,而图数据库既没有固定的数据模型,遍历关系的速度也非常快,很适合处理这类问题。当然,我们日常见到的主要是前 3 类 NoSQL 数据库 .
相对于关系数据库,NoSQL 在性能和易用性上都有明显的优点。
首先我们来看可用性及性能,这是 NoSQL 数据库快速发展的核心原因:
- NoSQL 数据库的可伸缩性都非常好。虽然许多文档型、列式数据库都提供了类 SQL 语言接口,但这只是为了降低用户的学习成本,它们对跨节点事务的支持极其有限。因此,这些 NoSQL 数据库可以放开手脚,基于 Key/Value 模型沿 AKF Z 轴将系统扩展到上万个节点。
- 在数据基于 Key 分片后,很容易通过[第 28 讲] 介绍过的 MapReduce 思想,提高系统的计算能力。比如,MongoDB 很自然的就在查询接口中,提供了MapReduce 函数。
- 通过冗余备份,NoSQL 可以提供优秀的容灾能力。比如,Redis、Cassandra 等数据库,都可以基于[第 22 讲] 介绍过的 NWR 算法,灵活地调整 CAP 权重。
- 如果每个 Key 中 Value 存放的复合数据已经能满足全部业务需求,那么 NoSQL 的单机查询速度也会优于关系数据库。
其次再来看易用性,这主要体现在我们可以低成本地变更 Value 结构。虽然 NoSQL 数据库支持复合型 Value 结构,但并不限定结构类型。比如,文档型数据库中,同一个表中的两行数据,其值可以是完全不同的 JSON 结构;同样的,列式数据库中两行数据也可以拥有不同的列数。因此,当数据结构改变时,只需要修改应用层操作数据的代码,并不需要像关系数据库那样同时修改表结构以及迁移数据。
那么,到底该如何选择关系数据库与 NoSQL 数据库呢?其实,沿着“单一值关系”这一线索,我们已经找到了各自适用的场景。
如果多个业务数据间互相关联,我们需要从多个不同的角度分析、计算,并保持住相关数据的一致性,那么关系数据库最为适合。一旦数据行数到了亿级别以上,就需要放弃单一值结构,将单行数据聚合为复合结构,放在可以自由伸缩的 NoSQL 数据库中。此时,我们无法寄希望于 NoSQL 数据库提供 ACID 事务,只能基于二段式提交等算法在应用层代码中自行实现事务。
小结
这一讲我们介绍了关系数据库与 NoSQL 数据库各自的特点及其适用场景。
关系数据库通过行、列交汇处的单一值,实现了多种数据间的关联。通过统一的 SQL 接口,用户可以在数据库中实现复杂的计算任务。为了维持关联数据间的一致性,关系数据库提供了拥有 ACID 特性的事务,提升了应用层的开发效率。
虽然单一值无法映射内存中的复合数据结构,但通过 ORM 框架,关系数据库可以将表映射为面向对象编程中的类,将每行数据映射为对象,继续降低开发成本。然而,关系数据库是为单机设计的,一旦将事务延伸到分布式系统中,执行成本就会高到影响基本的可用性。因此,关系数据库的可伸缩性是很差的。
NoSQL 数据库基于 Key/Value 数据模型,可以提供几乎无限的可伸缩性。同时,将 Value 值进一步设计为复合结构后,既可以增加查询方式的多样性,也可以通过 MapReduce 提升系统的计算能力。实际上,关系数据库与每一类 NoSQL 数据库都有明显的优缺点,我们可以从数据模型、访问方式、数据容量上观察它们,结合具体的应用场景权衡取舍
加餐4|百万并发下Nginx的优化之道
优化方法论
今天的分享重点会看这样两个问题:
- 第一,如何有效使用每个连接分配的内存,以此实现高并发。
- 第二,在高并发的同时,怎样提高 QPS。
当然,实现这两个目标,既可以从单机中的应用、框架、内核优化入手,也可以使用类似 F5 这样的硬件设备,或者通过 DNS 等方案实现分布式集群。
image而 Nginx 最大的限制是网络,所以将网卡升级到万兆,比如 10G 或者 40G 吞吐量就会有很大提升。作为静态资源、缓存服务时,磁盘也是重点关注对象,比如固态硬盘的 IOPS 或者 BPS,要比不超过 1 万转每秒的机械磁盘高出许多。
image这里我们重点看下 CPU,如果由操作系统切换进程实现并发,代价太大,毕竟每次都有 5 微秒左右的切换成本。Nginx 将其改到进程内部,由 epoll 切换 ngx_connection_t 连接的处理,成本会非常低。OpenResty 切换 Lua 协程,也是基于同样的方式。这样,CPU 的计算力会更多地用在业务处理上。
从整体上看,只有充分、高效地使用各类 IT 资源,才能减少 RTT 时延、提升并发连接。
image**请求的“一生” **
只有熟悉 Nginx 处理 HTTP 请求的流程,优化时才能做到有的放矢。
首先,我们要搞清楚 Nginx 的模块架构。Nginx 是一个极其开放的生态,它允许第三方编写的 C 模块与框架协作,共同处理 1 个 HTTP 请求。比如,所有的请求处理模块会构成一个链表,以 PipeAndFilter 这种架构依次处理请求。再比如,生成 HTTP 响应后,所有过滤模块也会依次加工。
**1. 请求到来 **
试想一下,当用户请求到来时,服务器到底会做哪些事呢?首先,操作系统内核会将完成三次握手的连接 socket,放入 1 个 ACCEPT 队列(如果打开了 reuseport,内核会选择某个 worker 进程对应的队列),某个 Nginx Worker 进程事件模块中的代码,需要调用 accept 函数取出 socket。
建立好连接并分配 ngx_connection_t 对象后,Nginx 会为它分配 1 个内存池,它的默认大小是 512 字节(可以由 connection_pool_size 指令修改),只有这个连接关闭的时候才会去释放。
接下来 Nginx 会为这个连接添加一个默认 60 秒(client_header_timeout 指令可以配置)的定时器,其中,需要将内核的 socket 读缓冲区里的 TCP 报文,拷贝到用户态内存中。所以,此时会将连接内存池扩展到 1KB(client_header_buffer_size 指令可以配置)来拷贝消息内容,如果在这段时间之内没有接收完请求,则返回失败并关闭连接。
image**2. 处理请求 **
当接收完 HTTP 请求行和 HEADER 后,就清楚了这是一个什么样的请求,此时会再分配另一个默认为 4KB(request_pool_size 指令可以修改,这里请你思考为什么这个请求内存池比连接内存池的初始字节数多了 8 倍?)的内存池。
Nginx 会通过协议状态机解析接收到的字符流,如果 1KB 内存还没有接收到完整的 HTTP 头部,就会再从请求内存池上分配出 32KB,继续接收字符流。其中,这 32KB 默认是分成 4 次分配,每次分配 8KB(可以通过 large_client_header_buffers 指令修改),这样可以避免为少量的请求浪费过大的内存。
image接下来,各类 HTTP 处理模块登场。当然,它们并不是简单构成 1 个链表,而是通过 11 个阶段构成了一个二维链表。其中,第 1 维长度是与 Web 业务场景相关的 11 个阶段,第 2 维的长度与每个阶段中注册的 HTTP 模块有关。
这 11 个阶段不用刻意死记,你只要掌握 3 个关键词,就能够轻松地把他们分解开。首先是 5 个阶段的预处理,包括 post_read,以及与 rewrite 重写 URL 相关的 3 个阶段,以及 URL 与 location 相匹配的 find_config 阶段。
image其次是访问控制,包括限流限速的 preaccess 阶段、控制 IP 访问范围的 access 阶段和做完访问控制后的 post_access 阶段。
最后则是内容处理,比如执行镜象分流的 precontent 阶段、生成响应的 content 阶段、记录处理结果的 log 阶段。
每个阶段中的 HTTP 模块,会在 configure 脚本执行时就构成链表,顺序地处理 HTTP 请求。其中,HTTP 框架允许某个模块跳过其后链接的本阶段模块,直接进入下一个阶段的第 1 个模块。
imagecontent 阶段会生成 HTTP 响应。当然,其他阶段也有可能生成 HTTP 响应返回给客户端,它们通常都是非 200 的错误响应。接下来,会由 HTTP 过滤模块加工这些响应的内容,并由 write_filter 过滤模块最终发送到网络中。
image3. 请求的反向代理
Nginx 由于性能高,常用来做分布式集群的负载均衡服务。由于 Nginx 下游通常是公网,网络带宽小、延迟大、抖动大,而上游的企业内网则带宽大、延迟小、非常稳定,因此 Nginx 需要区别对待这两端的网络,以求尽可能地减轻上游应用的负载。
比如,当你配置 proxy_request_buffering on 指令(默认就是打开的)后,Nginx 会先试图将完整的 HTTP BODY 接收完,当内存不够(默认是 16KB,你可以通过 client_body_buffer_size 指令修改)时还会保存到磁盘中。这样,在公网上漫长的接收 BODY 流程中,上游应用都不会有任何流量压力。
接收完请求后,会向上游应用建立连接。当然,Nginx 也会通过定时器来保护自己,比如建立连接的最长超时时间是 60 秒(可以通过 proxy_connect_timeout 指令修改)
当上游生成 HTTP 响应后,考虑到不同的网络特点,如果你打开了 proxy_buffering on(该功能也是默认打开的)功能,Nginx 会优先将内网传来的上游响应接收完毕(包括存储到磁盘上),这样就可以关闭与上游之间的 TCP 连接,减轻上游应用的并发压力。最后再通过缓慢的公网将响应发送给客户端。当然,针对下游客户端与上游应用,还可以通过 proxy_limit_rate 与 limit_rate 指令限制传输速度。如果设置 proxy_buffering off,Nginx 会从上游接收到一点响应,就立刻往下游发一些。
image4. 返回响应
当生成 HTTP 响应后,会由注册为 HTTP 响应的模块依次加工响应。同样,这些模块的顺序也是由 configure 脚本决定的。由于 HTTP 响应分为 HEADER(包括响应行和头部两部分)、BODY,所以每个过滤模块也可以决定是仅处理 HEADER,还是同时处理 HEADER 和 BODY。
image因此,OpenResty 中会提供有 header_filter_by_lua 和 body_filter_by_lua 这两个指令。
image**应用层优化 **
**1. 协议 **
应用层协议的优化可以带来非常大的收益。比如 HTTP/1 HEADER 的编码方式低效,REST 架构又放大了这一点,改为 HTTP/2 协议后就大有改善。Nginx 对 HTTP/2 有良好的支持,包括上游、下游,以及基于 HTTP/2 的 gRPC 协议。
image2. 压缩
对于无损压缩,信息熵越大,压缩效果就越好。对于文本文件的压缩来说,Google 的 Brotli 就比 Gzip 效果好,你可以通过https://github.com/google/ngx_brotli 模块,让 Nginx 支持 Brotli 压缩算法。
对于静态图片通常会采用有损压缩,这里不同压缩算法的效果差距更大。目前 Webp 的压缩效果要比 jpeg 好不少。对于音频、视频则可以基于关键帧,做动态增量压缩。当然,只要是在 Nginx 中做实时压缩,就会大幅降低性能。除了每次压缩对 CPU 的消耗外,也不能使用 sendfile 零拷贝技术,因为从磁盘中读出资源后,copy_filter 过滤模块必须将其拷贝到内存中做压缩,这增加了上下文切换的次数。更好的做法是提前在磁盘中压缩好,然后通过 add_header 等指令在响应头部中告诉客户端该如何解压。
3. 提高内存使用率
只在需要时分配恰当的内存,可以提高内存效率。所以下图中 Nginx 提供的这些内存相关的指令,需要我们根据业务场景谨慎配置。当然,Nginx 的内存池已经将内存碎片、小内存分配次数过多等问题解决了。必要时,通过 TcMalloc 可以进一步提升 Nginx 申请系统内存的效率。
同样,提升 CPU 缓存命中率,也可以提升内存的读取速度。基于 cpu cache line 来设置哈希表的桶大小,就可以提高多核 CPU 下的缓存命中率。
image4. 限速
作为负载均衡,Nginx 可以通过各类模块提供丰富的限速功能。比如 limit_conn 可以限制并发连接,而 limit_req 可以基于 leacky bucket 漏斗原理限速。对于向客户端发送 HTTP 响应,可以通过 limit_rate 指令限速,而对于 HTTP 上游应用,可以使用 proxy_limit_rate 限制发送响应的速度,对于 TCP 上游应用,则可以分别使用 proxy_upload_rate 和 proxy_download_rate 指令限制上行、下行速度
image**5. Worker 间负载均衡 **
当 Worker 进程通过 epoll_wait 的读事件获取新连接时,就由内核挑选 1 个 Worker 进程处理新连接。早期 Linux 内核的挑选算法很糟糕,特别是 1 个新连接建立完成时,内核会唤醒所有阻塞在 epoll_wait 函数上的 Worker 进程,然而,只有 1 个 Worker 进程,可以通过 accept 函数获取到新连接,其他进程获取失败后重新休眠,这就是曾经广为人知的“惊群”现象。同时,这也很容易造成 Worker 进程间负载不均衡,由于每个 Worker 进程绑定 1 个 CPU 核心,当部分 Worker 进程中的并发 TCP 连接过少时,意味着 CPU 的计算力被闲置了,所以这也降低了系统的吞吐量。
Nginx 早期解决这一问题,是通过应用层 accept_mutex 锁完成的,在 1.11.3 版本前它是默认开启的:accept_mutex on;
其中负载均衡功能,是在连接数达到 worker_connections 的八分之七后,进行次数限制实现的。
我们还可以通过 accept_mutex_delay 配置控制负载均衡的执行频率,它的默认值是 500 毫秒,也就是最多 500 毫秒后,并发连接数较少的 Worker 进程会尝试处理新连接:accept_mutex_delay 500ms;
当然,在 1.11.3 版本后,Nginx 默认关闭了 accept_mutex 锁,这是因为操作系统提供了 reuseport(Linux3.9 版本后才提供这一功能)这个更好的解决方案。
image图中,横轴中的 default 项开启了 accept_mutex 锁。我们可以看到,使用 reuseport 后,QPS 吞吐量有了 3 倍的提高,同时处理时延有明显的下降,特别是时延的波动(蓝色的标准差线)有大幅度的下降。
6. 超时
Nginx 通过红黑树高效地管理着定时器,这里既有面对 TCP 报文层面的配置指令,比如面对下游的 send_timeout 指令,也有面对 UDP 报文层面的配置指令,比如 proxy_responses,还有面对业务层面的配置指令,比如面对下游 HTTP 协议的 client_header_timeout。
image**7. 缓存 **
只要想提升性能必须要在缓存上下工夫。Nginx 对于七层负载均衡,提供各种 HTTP 缓存,比如 http_proxy 模块、uwsgi_proxy 模块、fastcgi_proxy 模块、scgi_proxy 模块等等。由于 Nginx 中可以通过变量来命名日志文件,因此 Nginx 很有可能会并行打开上百个文件,此时通过 open_file_cache,Nginx 可以将文件句柄、统计信息等写入缓存中,提升性能。
image8. 减少磁盘 IO
Nginx 虽然读写磁盘远没有数据库等服务要多,但由于它对性能的极致追求,仍然提供了许多优化策略。比如为方便统计和定位错误,每条 HTTP 请求的执行结果都会写入 access.log 日志文件。为了减少 access.log 日志对写磁盘造成的压力,Nginx 提供了批量写入、实时压缩后写入等功能,甚至你可以在另一个服务器上搭建 rsyslog 服务,然后配置 Nginx 通过 UDP 协议,将 access.log 日志文件从网络写入到 rsyslog 中,这完全移除了日志磁盘 IO。
image四 系统优化
最后,我们来看看针对操作系统内核的优化。
首先是为由内核实现的 OSI 网络层(IP 协议)、传输层(TCP 与 UDP 协议)修改影响并发性的配置。毕竟操作系统并不知道自己会作为高并发服务,所以很多配置都需要进一步调整。
image其次,优化 CPU 缓存的亲和性,对于 Numa 架构的服务器,如果 Nginx 只使用一半以下的 CPU 核心,那么就让 Worker 进程只绑定一颗 CPU 上的核心 。
image再次,调整默认的 TCP 网络选项,更快速地发现错误、重试、释放资源。
image还可以减少 TCP 报文的往返次数。比如 FastOpen 技术可以减少三次握手中 1 个 RTT 的时延,而增大初始拥塞窗口可以更快地达到带宽峰值 。
image还可以提高硬件资源的利用效率,比如当你在 listen 指令后加入 defer 选项后,就使用了 TCP_DEFER_ACCEPT 功能,这样 epoll_wait 并不会返回仅完成三次握手的连接,只有连接上接收到的 TCP 数据报文后,它才会返回 socket,这样 Worker 进程就将原本 2 次切换就降为 1 次了,虽然会牺牲一些即时性,但提高了 CPU 的效率。
Linux 为 TCP 内存提供了动态调整功能,这样高负载下我们更强调并发性,而低负载下则可以更强调高传输速度。
我们还可以将小报文合并后批量发送,通过减少 IP 与 TCP 头部的占比,提高网络效率。在 nginx.conf 文件中打开 tcp_nopush、tcp_nodelay 功能后,都可以实现这些目的.
image为了防止处理系统层网络栈的 CPU 过载,还可以通过多队列网卡,将负载分担到多个 CPU 中
image为了提高内存、带宽的利用率,我们必须更精确地计算出 BDP,也就是通过带宽与 ping 时延算出的带宽时延积,决定 socket 读写缓冲区(影响滑动窗口大小)。
imageNginx 上多使用小于 256KB 的小内存,而且我们通常会按照 CPU 核数开启 Worker 进程,这样一种场景下,TCMalloc 的性能要远高于 Linux 默认的 PTMalloc2 内存池。
image作为 Web 服务器,Nginx 必须重写 URL 以应对网址变化,或者应用的维护,这需要正则表达式的支持。做复杂的 URL 或者域名匹配时,也会用到正则表达式。优秀的正则表达式库,可以提供更好的执行性能 .
image
网友评论