消息队列为了提高性能使用了哪些细节?

1 采用异步设计

假设使用同步实现,伪装代码如下:

Transfer(accountFrom, accountTo, amount) {
  // 先从 accountFrom 的账户中减去相应的钱数
  Add(accountFrom, -1 * amount)
  // 再把减去的钱数加到 accountTo 的账户中
  Add(accountTo, amount)
  return OK
}

  上面的伪代码首先从 accountFrom 的账户中减去相应的钱数,再把减去的钱数加到 accountTo 的账户中,这种同步实现是一种很自然方式,简单直接。那么性能表现如何呢?假设微服务 Add 的平均响应时延是 50ms,那么可以计算出我们实现的微服务 Transfer 的平均响应时延大约等于执行 2 次 Add 的时延,也就是 100ms。那随着调用Transfer 服务的请求越来越多,会出现什么情况呢?

  在这种实现中,每处理一个请求需要耗时 100ms,并在这 100ms 过程中是需要独占一个线程的:每个线程每秒钟最多可以处理 10 个请求。每台计算机上的线程资源并不是无限的,假设我们使用的服务器同时打开的线程数量上限是 10,000,可以计算出这台服务器每秒钟可以处理的请求上限是: 10,000(个线程)* 10(次请求每秒) =100,000 次每秒。如果请求速度超过这个值,那么请求就不能被马上处理,只能阻塞或者排队,这时候 Transfer 服务的响应时延由 100ms 延长到了:排队的等待时延 + 处理时延 (100ms)。也就是说,在大量请求的情况下,我们的微服务的平均响应时延变长了。

  这是不是已经到了这台服务器所能承受的极限了呢?其实远远没有,如果我们监测一下服务器的各项指标,会发现无论是 CPU、内存,还是网卡流量或者是磁盘的 IO 都空闲的很,那我们 Transfer 服务中的那 10,000 个线程在干什么呢?对,绝大部分线程都在等待 Add 服务返回结果。

采用异步处理

TransferAsync(accountFrom, accountTo, amount, OnComplete()) {
  // 异步从 accountFrom 的账户中减去相应的钱数,然后调用 OnDebit 方法。
  AddAsync(accountFrom, -1 * amount, OnDebit(accountTo, amount, OnAllDone(OnComplete())))
}
// 扣减账户 accountFrom 完成后调用
OnDebit(accountTo, amount, OnAllDone(OnComplete())) {
  //  再异步把减去的钱数加到 accountTo 的账户中,然后执行 OnAllDone 方法
  AddAsync(accountTo, amount, OnAllDone(OnComplete()))
}
// 转入账户 accountTo 完成后调用
OnAllDone(OnComplete()) {
  OnComplete()
}

由于没有了线程的数量的限制,总体吞吐量上限会大大超过同步实现,并且在服务器 CPU、网络带宽资源达到极限之前,响应时延不会随着请求数量增加而显著升高,可以一直保持约 100ms 的平均响应时延。

2 序列化与反序列化数据的压缩优化

比如我们要序列化一个 User 对象,它包含 3 个属性,姓名 zhangsan,年龄:23,婚姻状况:已婚。

User:
  name: "zhangsan"
  age: 23
  married: true

使用 JSON 序列化后:

{"name":"zhangsan","age":"23","married":"true"}

实现高性能的序列化,对于同样的 User 对象,我们可以把它序列化成这样:

03   | 08 7a 68 61 6e 67 73 61 6e | 17 | 01
User |    z  h  a  n  g  s  a  n  | 23 | true

03 表示这是一个 User 类型的对象。可以约定按照 name、age、married 这个固定顺序来序列化这三个属性。按照顺序,第一个字段是 name,不存字段名,直接存字段值“zhangsan”就可以了,由于名字的长度不固定,用第一个字节 08 表示这个名字的长度是 8 个字节,后面的 8 个字节就是 zhangsan。第二个字段是年龄,直接用一个字节表示就可以了,23 的 16 进制是 17 。最后一个字段是婚姻状态,用一个字节来表示,01 表示已婚,00 表示未婚,这里面保存一个 01。

可以看到,同样的一个 User 对象,JSON 序列化后需要 47 个字节,这里只要 12 个字节就够了。

3 双工收发协议

4 使用批量消息提升服务端处理能力

5 利用 PageCache 加速消息读写

  PageCache 是现代操作系统都具有的一项基本特性。通俗地说,PageCache 就是操作系统在内存中给磁盘上的文件建立的缓存。无论我们使用什么语言编写的程序,在调用系统的 API 读写文件的时候,并不会直接去读写磁盘上的文件,应用程序实际操作的都是 PageCache,也就是文件在内存中缓存的副本。应用程序在写入文件的时候,操作系统会先把数据写入到内存中的 PageCache,然后再一批一批地写到磁盘上。读取文件的时候,也是从 PageCache 中来读取数据,这时候会出现两种可能情况。

  一种是 PageCache 中有数据,那就直接读取,这样就节省了从磁盘上读取数据的时间;另一种情况是,PageCache 中没有数据,这时候操作系统会引发一个缺页中断,应用程序的读取线程会被阻塞,操作系统把数据从文件中复制到 PageCache 中,然后应用程序再从 PageCache 中继续把数据读出来,这时会真正读一次磁盘上的文件,这个读的过程就会比较慢。用户的应用程序在使用完某块 PageCache 后,操作系统并不会立刻就清除这个 PageCache,而是尽可能地利用空闲的物理内存保存这些 PageCache,除非系统内存不够用,操作系统才会清理掉一部分 PageCache。清理的策略一般是 LRU 或它的变种算法,这个算法我们不展开讲,它保留 PageCache 的逻辑是:优先保留最近一段时间最常使用的那些 PageCache。

  例如Kafka 在读写消息文件的时候,充分利用了 PageCache 的特性。一般来说,消息刚刚写入到服务端就会被消费,按照 LRU 的“优先清除最近最少使用的页”这种策略,读取的时候,对于这种刚刚写入的 PageCache,命中的几率会非常高。也就是说,大部分情况下,消费读消息都会命中 PageCache,带来的好处有两个:一个是读取的速度会非常快,另外一个是,给写入消息让出磁盘的 IO 资源,间接也提升了写入的性能。 

posted @ 2020-12-15 21:30  hongxinerke  阅读(250)  评论(0编辑  收藏  举报