IO密集服务的设计指北

发布时间 2023-12-20 19:46:35作者: YarBor

IO密集服务的设计指北


异步编程

异步是指在程序执行过程中,某个操作的执行不会阻塞其他操作的进行。在异步编程中,一个操作的执行通常会在后台进行,而程序可以继续执行其他操作,不需要等待该操作的完成。这种方式提高了程序的效率和响应性。

异步设计如何提升系统性能?

服务大体分为两类 计算密集型 IO密集型
以消息队列中间件提供的服务,其属于IO密集型系统

而如今IO密集型系统的性能瓶颈,在于磁盘IO和网络IO,

在IO密集型系统中使用同步会有以下问题

  1. 阻塞:在同步操作中,当一个操作执行时,程序会被阻塞,无法执行其他操作。这种阻塞会导致程序的响应性下降,特别是当操作需要花费较长时间时,整个程序会被阻塞的时间更长。
  2. 顺序执行:同步操作按照顺序依次执行,每个操作必须等待前一个操作完成后才能执行。这种顺序执行方式限制了并行性和并发性,无法充分利用多核处理器和系统资源。
  3. 等待时间:当一个操作需要等待外部资源的返回或其他操作的完成时,同步操作会浪费大量的时间在等待上。这种等待时间会导致程序的整体性能降低。

举个例子

假设每个请求需要耗费100毫秒的时间,并且在这100毫秒的过程中需要独占一个线程。
但是,计算机上的线程资源并非无限的。假设服务器设定的线程上限是10,000个,那么该服务器每秒最多能处理的请求数量为:10,000(个线程)× 10(每秒请求数)= 100,000次/秒。如果请求速度超过这个限制,那么请求就无法立即处理,只能被阻塞或排队。这种情况下,Transfer服务的响应延迟将由原本的100毫秒增加到:排队等待延迟 + 处理延迟(100毫秒)。换句话说,在大量请求的情况下,服务的平均响应延迟会增加。这样看起来系统忙于工作,无法接受新的链接,但从全局来看 服务器的各项指标,如CPU、内存、网络流量和磁盘IO等,都是空闲的 这意味着Transfer服务中的10,000个线程大部分时间都在等待Add服务的返回结果。

上述问题 可以通过异步操作 向操作系统注册行为,并设定回调函数 以充分发挥磁盘和带宽的限制。这样可以充分利用系统资源,减少等待时间,并提高程序的并行性和并发性,从而提升整体性能。

异步编程后的程序性能虽好 但也有问题

  1. 相比于同步实现,异步实现的复杂度要大很多,代码的可读性和可维护性都会显著的下降。虽然使用一些异步编程框架会在一定程度上简化异步开发,但是并不能解决异步模型高复杂度的问题。
  2. 或者必须长时间等待资源的地方,才考虑使用异步模型。

使用异步编程模型,虽然并不能加快程序本身的速度,但可以减少或者避免线程等待,只用很少的线程就可以达到超高的吞吐能力。

内存管理

现代语言的内存自动回收机制使得我们在编码变得简单,不用像C系一样自己管理内存
但同时 自动管理回收内存同样有相应的弊端

  1. 内存分配开销:在高并发情况下,频繁地进行内存分配会引起额外的开销。Java 的垃圾回收器通常使用分代回收策略,其中包括年轻代和老年代。当并发请求频繁地创建对象时,会导致频繁的年轻代垃圾回收,增加内存分配的开销。
  2. 内存占用和分配:Java 的垃圾回收器通常会为了提高回收效率而分配较大的堆空间。这意味着即使并发请求只需要较少的内存,也需要分配较大的堆空间,导致内存占用较高。在高并发情况下,大量的内存分配和回收操作可能会对性能产生一定的影响。
  3. 垃圾回收压力:高并发场景下,大量的对象创建和销毁会导致垃圾回收的压力增加。如果垃圾回收器无法及时回收垃圾对象,堆内存的使用量会增加,可能导致内存溢出或触发频繁的全局垃圾回收,从而影响应用的性能稳定性。
  4. 堆内存分配:Go 使用了更加高效的堆内存分配器,但在高并发场景下,大量的并发请求可能会导致堆内存分配的竞争。当多个 goroutine 同时请求内存时,会增加内存分配的开销,并可能导致内存分配的延迟。

垃圾回收完成后,还需要进行内存碎片整理,将不连续的空闲内存移动到一起,以便空出足够的连续内存空间供后续使用。
虽然自动内存管理机制有效地解决了内存泄漏问题,带来的代价是执行垃圾回收时会暂停进程,如果内存的申请频繁,暂停的时间过长,程序看起来就像“卡死了”一样。

高并发下的内存管理

对于开发者来说,垃圾回收是不可控的,而且是无法避免的。但是,我们还是可以通过一些方法来降低垃圾回收的频率,减少进程暂停的时长。

我们知道,只有使用过被丢弃的对象才是垃圾回收的目标,所以,我们需要想办法在处理大量请求的同时,尽量少的产生这种一次性对象。

对于上述问题

  1. 通过优化代码中的业务逻辑,尽量减少创建一次性对象的情况,尤其是占用大量内存的对象,可以显著减少垃圾回收的频率和开销。一个常见的优化策略是在处理请求时,将接收到的 Request 对象在整个业务流程中传递,而不是在每个步骤中创建类似的新对象。
  2. 对于需要频繁使用且占用大量内存的一次性对象,可以考虑实现对象池。通过在对象池中维护一组可重用的对象,可以避免频繁地创建和销毁对象,从而减轻垃圾回收的压力。在处理请求时,可以从对象池中获取对象并在使用完毕后放回对象池,实现对象的反复重用。

缓存策略

现代的消息队列系统使用磁盘文件来储存消息。这是因为磁盘具有持久性存储的特点,即使服务器断电,数据也不会丢失。大多数用于生产系统的服务器通常由多块磁盘组成磁盘阵列,这样即使其中一块磁盘发生故障,数据也可以从其他磁盘中进行恢复。另一个使用磁盘的原因是其成本相对较低,这使得我们可以以较低的成本存储大量的消息。因此,不仅仅是消息队列,几乎所有存储系统的数据都需要保存在磁盘上。

磁盘它有一个致命的问题,就是读写速度很慢。它有多慢呢?

一般来说,SSD(固态硬盘)每秒钟可以读写几千次。如果说我们的程序在处理业务请求的时候直接来读写磁盘,假设处理每次请求需要读写3~5次,即使每次请求的数据量不大,你的程序最多每秒也就能处理1000次左右的请求。

而内存的随机读写速度是磁盘的10万倍!所以,使用内存作为缓存来加速应用程序的访问速度,是几乎所有高性能系统都会采用的方法。

缓存的思想很简单,就是把低速存储的数据,复制一份副本放到高速的存储中,用来加速数据的访问。

保持缓存数据新鲜

在缓存中 保持缓存数据新鲜 成了主要问题

  1. 设置合适的缓存过期时间:在将数据存入缓存时,为其设置一个适当的过期时间。过期时间应根据数据的更新频率和重要性来确定。较频繁更新的数据可以设置较短的过期时间,以确保数据的新鲜性。

  2. 主动刷新缓存:可以在缓存数据过期之前,主动触发刷新操作,从数据源获取最新数据并更新缓存。可以通过定时任务、异步消息等方式来触发刷新操作,保持缓存数据的及时性。

  3. 采用缓存失效策略:当数据发生更新或变化时,及时使缓存失效,下次请求时会重新获取最新的数据并更新缓存。可以使用发布订阅模式、回调机制等方式,感知数据变化并使缓存失效。

  4. 结合事件驱动机制:当数据发生变化时,可以使用事件驱动机制通知相关缓存节点进行更新。这样可以避免不必要的缓存更新,只针对需要更新的数据进行操作,提高系统的效率。

  5. 使用缓存更新策略:针对不同的数据类型和业务需求,可以选择合适的缓存更新策略。例如,可以使用最近最少使用(LRU)策略,缓存数据的访问频率较高的部分进行更新,以保持数据的新鲜性。

在操作系统中 类似的PageCache便是缓存的设计

提高缓存命中

当使用缓存时 缓存的命中与否是提高运行效率的关键
当缓存命中率极低时 缓存反而会成为性能的绊脚石

命中率最高的置换策略通常是根据业务逻辑进行定制化的。例如,如果你知道某些数据已被删除并且不会再被访问,优先置换这些数据是合理的。另外,如果你的系统是一个有会话的系统,并且你知道哪些用户在线,哪些用户已离线,那么优先置换已离线用户的数据,尽量保留在线用户的数据也是一个很好的策略。

另一种选择是使用通用的置换算法。其中最经典且实用的算法是LRU(最近最少使用)算法。该算法的思想是,最近被访问的数据在将来被访问的概率较高,而长时间没有被访问的数据在未来被访问的概率较低。因此,LRU算法会优先置换最近最少使用的数据。

高性能IO

提高IO性能 是消息队列系统优化的主要途径

批处理

一种可能的是 kafka 中使用的 批处理 (打包)

在Kafka中,Producer可以通过两种方式实现异步批量发送:

  • 批量发送:Producer可以将多个消息进行批量打包后发送给Broker。可以通过设置batch.size参数来控制每个批次中消息的数量,也可以通过设置linger.ms参数来控制发送之前等待积累更多消息的时间。当批次中的消息数量达到一定阈值或等待时间超过指定时间时,批次会被发送。
  • 后台线程发送:Producer可以使用一个后台线程来处理消息发送。在发送消息时,Producer将消息添加到一个缓冲区中,后台线程会周期性地将缓冲区中的消息批量发送给Broker。可以通过设置buffer.memory参数来控制缓冲区的大小。

Broker在接收到批消息后,会进行相应的处理:

  • 写入日志:Broker会将接收到的批消息写入磁盘中的日志文件中,该过程称为写入日志(write to log)。写入日志是Kafka的持久化机制,确保消息的可靠性。
  • 批量处理:Broker会对批消息进行批量处理(batch processing)。这意味着Broker会一次性处理多个消息,而不是逐条处理。这样可以提高处理效率和吞吐量。

消费端以批为单位传递消息

  • 消费者在从Broker拉取消息时,可以一次拉取多个消息形成消息批次,然后以批的形式进行处理。这种方式可以减少与Broker之间的网络通信开销,提高消费的效率。

消费者可以通过设置fetch.min.bytes参数来控制每次拉取的最小消息字节数,以及通过设置fetch.max.wait.ms参数来控制拉取等待的最大时间。当满足最小字节数或等待时间达到最大值时,消费者会将已拉取的消息批次传递给应用程序进行处理。

通过批量传递消息,消费者可以减少网络延迟和资源消耗,并提高消息的处理效率。

顺序读写磁盘

与随机读写相比,顺序读写磁盘通常能够获得更好的性能和吞吐量。
通过顺序读写,可以最大的发挥磁盘的IO性能,通过在内存中保存 文件数据的缓存,进行一次寻址,顺序读写。

一些可能的优化方式

  • 文件对齐:确保文件的起始位置和读写操作的大小都与磁盘的块大小对齐。磁盘通常以固定大小的块进行读写操作,对齐文件和读写操作可以最大程度地利用磁盘的读写性能。
  • 对于大规模数据存储,可以考虑优化磁盘的布局方式。例如,将相关的数据块或文件放置在相邻的磁盘区域,以便顺序读写时可以减少磁头的移动。这需要根据具体的磁盘硬件和存储需求进行综合考虑和调整。

ZeroCopy技术

ZeroCopy技术是一种优化数据传输的技术,旨在减少数据在不同内存区域之间的复制次数。传统的数据传输过程中,数据需要从一个内存区域复制到另一个内存区域,例如从文件系统的PageCache复制到Socket缓冲区。而ZeroCopy技术通过避免这些数据复制过程,直接在内核空间中操作数据,以提高数据传输的效率。

在使用ZeroCopy技术时,数据传输的过程:

  • 应用程序将数据发送请求提交给操作系统。
  • 操作系统通过文件描述符或内存映射等方式访问数据源,如PageCache。
  • 操作系统使用DMA(Direct Memory Access)引擎,直接将数据从数据源复制到网络设备的缓冲区,跳过了应用程序的内存空间。
  • 网络设备将数据通过网络发送出去。

效率提升

  1. 减少数据复制:ZeroCopy技术避免了数据在内存中的不必要复制,减少了CPU和内存的开销,提高了数据传输的效率。
  2. 节省内存带宽:传统的数据复制会占用内存带宽,而ZeroCopy技术通过直接操作内核空间中的数据,减少了对内存带宽的消耗,提高了系统的整体性能。
  3. 提高网络吞吐量:通过减少数据复制和内存带宽的消耗,ZeroCopy技术可以提高网络传输的吞吐量,使数据在网络中的传输更加高效。

数据压缩

这里的压缩不是指在对性能敏感的服务器中进行数据的解压或压缩
我们可以在对性能不那么敏感的Client端进行对数据的处理

在Server中仅仅通过元数据或其他数据进行压缩后数据的转发

当在服务器端进行压缩后,仅将压缩数据和元数据转发给不太敏感于性能的客户端时,这种方法可以被称为“服务器端压缩传输”。

服务器端压缩传输的过程

  • 压缩数据:服务器端使用适当的压缩算法对要传输的数据进行压缩。这可以减小数据的大小,从而降低传输所需的带宽和时间。压缩算法的选择应该根据数据特性和压缩率的要求进行。
  • 生成元数据:服务器端生成元数据,包含有关压缩数据的信息。元数据可能包括压缩算法的类型、压缩前后数据的大小、数据校验和等。这些元数据提供给客户端以便进行解压缩和处理。
  • 转发压缩数据和元数据:服务器将压缩后的数据和元数据发送给客户端。在传输过程中,服务器不需要对数据进行解压缩或处理,只需将压缩数据和元数据转发给客户端。
  • 客户端解压缩和处理:客户端接收到压缩数据和元数据后,首先根据元数据中的信息选择相应的解压缩算法。然后,使用选定的解压缩算法对压缩数据进行解压缩,恢复原始数据。最后,客户端可以对原始数据进行进一步的处理或使用。

通过压缩 进行带宽的减负