HTTP性能修炼之道与企业实践 | 字节青训营笔记

96 阅读20分钟

image.png

针对网络库的优化

针对原生的网络库进行了一定的优化,从而提升框架的性能:

1、go net是BIO,用户管理Buffer

2、netpoll是NIO,网络库管理Buffer image.png

1、go net

image.png 1、希望有一个地方可以存下全部的Header,http头部它是不知道长度的,那我们需要存下全部header才能够进行一个解析。header有大有小,这个就是一个非常麻烦的点。

2、希望能够减少调用的次数,因为系统调用涉及到内核态和用户态的一个切换。这部分的开销还是非常大的。像这个reader和writer,其实它就是底层就是一个系统调用了,尤其是这个writer。

 

3、希望能够复用内存,提高一下内存的使用率。内存的分配和这些对于客户来说的开销都是非常大的。虽然它优化了很多,但还是有一定开销的。

 

4、希望能够多次读,这部分主要体现在一个对header的处理上,那对于一个超大的header,比如我可能第一次把它读不完,我并不知道header是不完整的,那所以说我们就只有发现解析失败了,我们才知道当前的这个header是不足够的。那下一次呢我们希望还能够从头进行一个解析。

image.png

采用零拷贝的概念,绑定缓冲区:

针对此,我们在go net提供接口上面绑定1个buffer,就是说对每一个连接都绑定1个buffer,我们发现大部分的包其实都是在4K以下的,所以我们可以绑定一个大小为4K左右的一个缓冲区,这样的话对于连接的压力也不是很大。

对于刚刚提出的网络需求需要再设计一下我们封装到connection上面的这个接口。

1. Peek 接口

  • 功能:在读取数据时,不移动读取指针。这意味着你可以查看下一部分数据,但不会影响下一次读取操作的起始位置。下次再读的时候还能够在这里进行读。

  • 这个接口方法返回下 n 个字节的数据,但不改变读取指针的位置。

2. Discard 接口

  • 功能:移动读取指针,丢弃已经查看过的数据。这有助于在处理数据时跳过不需要的部分。

  • 这个接口方法会移动读取指针,丢弃下 n 个字节的数据,并返回实际丢弃的字节数和可能的错误。

3. Release 接口

  • 功能:回收内存,使得下一次请求可以复用之前的空间。这对于减少内存分配和提高性能非常重要。需要回收这样的一块内存,希望在下一次请求能够复用之前的空间。

  • 这个接口方法用于释放资源,以便下次使用时可以复用内存。

整体设计

  • 这些接口方法可以组合在一起,形成一个完整的 Reader 接口,用于处理网络连接中的数据读取操作。

  • 在实际实现中,你可能需要考虑并发安全性,确保在多线程环境下这些方法的正确执行。

  • 可以使用 bufio 包或其他缓冲机制来辅助实现这些接口,以提高数据读取的效率。

这种设计可以有效地优化网络连接中的数据读取操作,减少不必要的内存分配和系统调用,提高整体性能。

1、netpoll

image.png

关于netpoll这一部分,同样我们也有同样的诉求,首先是存下全部header,第二个呢是拷贝出完整的body,netpoll是由网络库去管理的buffer。buffer是为了避免锁竞争,采用链表方式,实现无锁化。

 

链表方式带来一个问题就是跨节点,比如我们这张示意图,我们header其实它是被分配到了两个节点上,那如果我要是想要完整的解析这个header的话,我是需要将这两个header编成一块完整的内存,拷贝到一块完整的内存里,然后再进行一个解析。那同样body也是需要一个完整的body了,所以我们这个body也需要再分配一块内存,然后拷贝进去

image.png

这是关于netpoll网络库中缓冲区管理的优化方案。以下是详细的分析和解释:

1. 现有问题

  • 跨节点数据处理

    • netpoll中,使用链表方式管理缓冲区(buffer)以避免锁竞争。然而,这种方式会导致数据(如headerbody)可能被分配到不同的节点上。
    • 为了完整地解析headerbody,需要将分散在不同节点的数据拷贝到一块完整的内存中,这增加了数据拷贝的开销。

2. 优化方案

  • 减少数据拷贝

    • 直接分配大节点:在底层分配一个足够大的节点,其大小根据历史请求中最大的请求来确定。这样,headerbody可以直接在底层拼接好。
    • 减少拷贝操作:框架层直接使用底层返回的切片,避免了将数据从多个节点拷贝到一块完整内存的操作,从而减少了数据拷贝的开销。

3. 内存使用率问题

  • 问题描述

    • 当出现一个大请求时,缓冲区(buffer)会被分配一个较大的内存空间。这可能导致后续的小请求也会分配到同样大的内存空间,造成内存浪费。
  • 解决方案

    • 限制最大缓冲区大小:为了避免这种情况,需要限制最大的缓冲区大小。这样可以防止一个大请求导致整个缓冲区变得过大,从而提高内存的使用率。

总结

  • 通过在底层直接分配足够大的节点来减少数据拷贝操作,提高了数据处理的效率。

  • 通过限制最大缓冲区大小,避免了大请求导致的内存浪费问题,提高了内存的使用率。

这种优化方案能够有效地解决netpoll网络库中缓冲区管理的一些关键问题,提升整体性能。

image.png

1. 接口定义

type Conn interface {
    net.Conn
    Reader
    Writer
}
  • 这个接口定义了一个名为Conn的接口类型。
  • 它嵌入了三个其他接口:net.ConnReaderWriter

2. 接口嵌入的目的

  • 兼容标准库生态

    • 通过嵌入net.Conn接口,这个自定义的Conn接口可以兼容标准库中的net.Conn接口。这意味着任何实现了标准库net.Conn接口的类型也实现了这个自定义的Conn接口。
    • 这有助于在使用这个网络库时,能够无缝地与标准库中的其他网络相关功能集成。
  • 自定义功能

    • 嵌入ReaderWriter接口,表明这个Conn接口还具有特定的读取和写入功能。
    • 根据描述,这些功能可能包括能够多次读取和复用缓冲区空间等优化操作。

3. 可能的实现和使用场景

  • 在具体实现这个接口时,需要确保实现类型同时满足net.ConnReaderWriter接口的所有方法。
  • 使用场景可能包括网络服务器或客户端的实现,其中需要高效地处理网络连接、多次读取数据以及复用缓冲区空间以提高性能。

image.png

1. go net

  • 流式友好

    • 原理go netread接口由用户态调用。如果用户不调用read,数据会留在 TCP 缓冲区中,不会导致用户态内存爆满。
    • 优势:这种设计使得go net在处理流式数据时非常友好,能够有效避免用户态内存的过度占用。
  • 小包性能高

    • 原理go net在连接上直接绑定了一块内存(通常约为 4K)。对于小于 4K 的小包,无需额外的缓冲区申请和回收操作。
    • 优势:这种设计使得go net在处理小包数据时性能较高,减少了内存管理的开销。

2. netpoll

  • 中大包性能高

    • 原理netpoll由网络库管理缓冲区,并且其接口可以直接发送二维切片。当一个节点的缓冲区不够时,可以挂接另一个节点,并且可以通过一次系统调用发送出去。
    • 优势:这种设计使得netpoll在处理中大包数据时性能较高,减少了系统调用的次数,提高了数据发送的效率。
  • 时延低

    • 原理:由于netpoll能够高效地管理和发送数据,减少了系统调用和数据拷贝的次数,从而降低了时延。
    • 优势:这种设计使得netpoll在对时延要求较高的场景下表现出色。

根据具体的应用场景和数据类型,可以选择更适合的网络库来优化性能。

针对协议的优化

Headers解析

image.png 主要讨论了针对协议的优化,特别是在解析 Headers 方面。以下是详细的解释:

1. Headers 解析的优化

  • 问题背景

    • 在处理网络协议时,需要解析 Headers。Headers 通常以\r\n作为行边界。传统的方法是先找到\n,然后再检查前一个字符是否是\r
  • 现有代码

    • 展示了一段 Go 语言代码,用于查找字节切片b中字符c的索引位置。
func index(b []byte, c byte) int {
    for i := 0; i < len(b); i++ {
        if b[i] == c {
            return i
        }
    }
    return -1
}
  • 这段代码是一个简单的线性搜索函数,用于在字节切片b中查找字符c的位置。

2. 优化思路

  • SIMD (Single Instruction, Multiple Data)

    • SIMD 是一种并行处理技术,允许一条指令处理多个数据元素。在解析 Headers 的场景中,可以利用 SIMD 来加速字符查找过程。
    • 例如,可以使用 CPU 的 SIMD 指令集(如 SSE、AVX 等)来同时比较多个字节,从而减少循环次数,提高查找效率。

3. 总结

  • 目前的代码采用了简单的线性搜索来查找字符,这种方法在处理大量数据时可能效率较低。
  • 可以考虑使用 SIMD 技术来优化字符查找过程,从而提高 Headers 解析的速度。

image.png

1. 代码部分

switch s.Key[0] | 0x20 {
case 'h':
    if utils.CaseInsensitiveCompare(s.Key, bytestr.StrHost) {
        h.SetHostBytes(s.Value)
    }
    continue
...
  • 这段代码看起来是在处理 HTTP 头(Headers)。它检查了一个键(s.Key)的首字母是否为h(不区分大小写),如果是,则进一步检查键是否为Host,若是,则设置主机字节(SetHostBytes)。

2. 针对协议相关的 Headers 快速解析

  • 通过 Header key 首字母快速筛除掉完全不可能的 key

    • 这种方法通过检查 Header 键的首字母来快速排除不可能的键。例如,如果一个请求的 Header 键的首字母不是h,那么它不可能是Host键。
  • 解析对应 value 到独立字段

    • 这意味着将 Header 中的值解析到对应的独立字段中,以便于后续处理。例如,将Host键对应的值解析到一个特定的字段中。
  • 使用 byte slice 管理对应 header 存储,方便复用

    • 使用字节切片(byte slice)来管理 Header 的存储,可以提高内存使用效率和复用性。

3. 请求体中同样处理的 Key

  • 提到了一些在请求体中同样需要处理的键(除了host):

    • User - Agent

    • Content - Type

    • Content - Length

    • Connection

    • Transfer - Encoding

这些键在处理 HTTP 请求时通常非常重要,需要进行特定的解析和处理。

总结

  • 图片中的内容主要围绕如何优化 HTTP 头(Headers)的解析过程,包括通过首字母快速筛选键、解析对应的值到独立字段,以及使用字节切片来管理存储。这些优化措施可以提高协议处理的效率

image.png

1. “取”

  • 核心内容

    • 核心字段快速解析:在解析 Headers 时,对核心字段进行快速解析。这可能涉及到对特定关键字段(如常见的 HTTP 头部字段)进行优先处理,以提高解析效率。
    • 使用 byte slice 存储:采用字节切片(byte slice)来存储解析后的结果。这种方式有助于优化内存使用,特别是在处理大量数据时。
    • 额外存储到成员变量中:高频的header会将解析后的结果存储到成员变量中,方便后续使用。

2. “舍”

  • 核心内容

    • 普通 header 性能较低:对于普通的头部信息,其处理性能较低。这可能是因为这些普通头部信息在处理时没有采用优化策略,导致处理速度较慢。
    • 没有 map 结构:在处理普通头部信息时,没有采用映射(map)结构。映射结构通常可以提高查找和处理的效率,没有这种结构可能会导致性能下降。

总结

  • 这张图片主要对比了在解析 Headers 时,对核心字段和普通字段的不同处理策略。核心字段采用了快速解析、字节切片存储和成员变量存储等优化策略,而普通字段则可能因为缺乏优化措施(如没有使用 map 结构)而导致性能较低。
  • 这种优化策略在网络协议处理中非常常见,特别是在处理 HTTP 请求时,可以显著提高处理效率。

Header key规范化

image.png 将类似于 “aaa - bbb” 的字符串转换为 “Aaa - Bbb” 的形式。以下是详细的解释:

1. 问题背景

  • 在处理网络协议中的 Header key 时,有时需要将字符串规范化,例如将小写字母转换为大写字母。传统的方法可能是使用加减法来转换字符的 ASCII 码,但这种方法效率较低。

2. 优化方法:表映射

  • 基本思路

    • 创建两个映射表:toupperTabletolowerTable
    • 这些表将字符的 ASCII 码值作为索引,直接查找并返回转换后的字符。
    • 这种方法的时间复杂度为 O (1),因为查找操作是常数时间。
  • 映射表的构建

    • 图片中展示了toupperTabletolowerTable的具体内容。这些表是通过 Go 语言中的字符串常量定义的。

    • 例如,tolowerTable的定义如下:

const tolowerTable = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f!"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"
  • 这个字符串常量实际上是一个 ASCII 码表,其中每个字符的位置对应其 ASCII 码值。

3. 转换过程

  • 当需要将一个字符转换为大写或小写时,只需查找该字符在映射表中的位置,并返回对应位置的字符。
  • 例如,要将字符 'a' 转换为 'A',可以查找toupperTable中 'a' 对应的位置,然后返回该位置的字符。

4. 效率对比

  • 这种表映射方法比传统的加减法转换方法效率更高。因为加减法方法需要进行算术运算,而表映射方法只需要一次查找操作。

总结

  • 通过创建和使用映射表,可以高效地将 Header key 中的字符进行规范化处理,提高协议处理的效率。

image.png

热点资源池化

image.png

image.png

image.png

1. 热点资源池化的基本概念

  • 请求处理流程

    • 从前面的图片可以看到,请求(Request)进入系统后,会从 “RequestContext 池” 中获取一个请求上下文(Request Context)来处理请求。处理完成后,系统会生成一个响应(Response)返回给用户。
    • 这种设计通过复用请求上下文,避免了频繁创建和销毁资源,从而提高了系统的处理效率。

2. 改进方法(“取”)

  • 减少了内存分配

    • 通过资源池化,系统不需要为每个请求都分配新的内存资源。而是从池中获取已经分配好的资源,减少了内存分配的开销。
  • 提高了内存复用

    • 资源池中的资源可以被多个请求复用,提高了内存的利用率,避免了资源的浪费。
  • 降低了 GC 压力

    • 由于减少了内存分配和销毁的频率,垃圾回收(GC)的压力也相应降低。这有助于提高系统的整体性能。
  • 性能提升

    • 综合以上几点,系统的性能得到了显著提升,能够更高效地处理大量请求。

3. 取舍(“舍”)

  • 额外的 Reset 逻辑

    • 为了确保资源池中的资源能够被正确复用,需要额外的逻辑来重置(Reset)资源的状态。这增加了系统的复杂性。
  • 请求内有效

    • 资源池中的资源通常是在请求内有效的,这意味着资源的生命周期与请求的生命周期紧密相关。这可能会限制资源的使用方式。
  • 问题定位难度增加

    • 由于资源池的存在,当出现问题时,定位问题的难度会增加。因为资源可能被多个请求共享,很难确定是哪个请求导致了问题。

总结

  • 热点资源池化是一种有效的优化策略,通过复用资源来提高系统的性能。
  • 然而,这种方法也带来了一些额外的复杂性和问题,需要在设计和实现时进行权衡。通过合理的设计和管理,可以最大化其优点,同时最小化其缺点。

企业实践

字节的 HTTP 框架 Hertz 1w+服务、3kW+ qpS

  • 追求性能
  • 追求容易使用、容易上手
  • 搭建内部生态、打通内部
  • 文档健全
  • 社区活跃

image.png

问题

image.png

1. 为什么HTTP框架要分层设计?

分层设计有哪些优势与劣势。

原因
  • 模块化和职责分离:HTTP框架处理的功能众多,包括请求处理、响应生成、资源管理、安全验证等。分层设计可以将这些功能模块分开,每个层专注于特定的任务,便于开发、维护和扩展。
    • 可复用性:分层后,每一层可以在不同的应用场景或项目中被复用。例如,底层的数据处理层可以在多个不同的上层业务逻辑应用中使用。
优势
  • 易维护:当出现问题时,可以快速定位到特定的层进行排查和修复,而不需要在整个代码库中搜索。
  • 可扩展性:可以方便地在某一层添加新功能或修改现有功能,而不会对其他层造成太大影响。
  • 灵活性:不同的层可以独立替换或升级,例如更换底层的数据库系统,而不影响上层的业务逻辑。 #### 劣势 - 性能开销:多层之间的调用和数据传递可能会带来一定的性能损耗,尤其是在对性能要求极高的场景下。
  • 复杂度增加:分层设计本身需要良好的架构设计,如果设计不当,可能会导致层与层之间的关系混乱,反而增加了系统的复杂性。
2. 现有开源社区HTTP框架有哪些优势与不足。
优势
  • 成熟稳定:许多开源HTTP框架(如Spring Boot、Django等)经过了大量项目的实践检验,在稳定性方面有保障。
  • 社区支持:拥有庞大的开源社区,能够得到及时的技术支持,并且有大量的文档、教程和示例代码可供参考。
  • 功能丰富:通常集成了诸如路由、缓存、数据库连接、安全验证等多种功能,减少了开发者的工作量。 #### 不足
  • 定制性限制:对于某些特殊的业务需求,开源框架可能无法很好地满足,需要进行大量的定制化工作,甚至可能需要修改框架的核心代码。
  • 性能瓶颈:在高并发场景下,一些开源框架可能会暴露出性能问题,需要进行性能优化或者寻找更适合的框架。
  • 版本兼容性:随着框架版本的更新,可能会出现与现有项目不兼容的情况,需要花费时间和精力进行升级和适配。
3. 中间件还有没有其他实现方式?可以用伪代码说明。 中间件有多种实现方式,以下是一种基于函数式编程的中间件实现方式(伪代码示例):
python
 代码解读
复制代码

# 定义中间件函数类型

Middleware = Callable[[Callable[[], Any]], Callable[[], Any]]

def middleware1(next_func):
    def wrapper():
        print("Middleware 1: Before")
        result = next_func()
        print("Middleware 1: After")
        return result
    return wrapper

def middleware2(next_func):
    def wrapper():
        print("Middleware 2: Before")
        result = next_func()
        print("Middleware 2: After")
        return result
    return wrapper

# 定义目标函数
def target_function():
    print("Target function executed")
    return "Result"

# 应用中间件
wrapped_function = middleware2(middleware1(target_function))
wrapped_function()

在这个示例中,中间件通过函数嵌套的方式实现,每个中间件可以在目标函数执行前后添加自定义的逻辑。

4. 完成基于前缀路由树的注册与查找功能?可以用伪代码说明。 以下是基于前缀路由树(Trie树)的路由注册与查找的伪代码:
python
 代码解读
复制代码

class TrieNode:
    def __init__(self):
        self.children = {}
        self.handler = None

class Router:
    def __init__(self):
        self.root = TrieNode()

    def register(self, route, handler):
        current = self.root
        for char in route:
            if char not in current.children:
                current.children[char] = TrieNode()
            current = current.children[char]
        current.handler = handler

    def lookup(self, route):
        current = self.root
        for char in route:
            if char not in current.children:
                return None
            current = current.children[char]
        return current.handler

在上述伪代码中: - TrieNode 表示路由树的节点,包含子节点字典和对应的处理函数。 - Router 类用于管理路由树,包括注册路由(register 方法)和查找路由(lookup 方法)。

5. 路由还有没有其他的实现方式?
基于正则表达式的路由
  • 原理:使用正则表达式来匹配请求的URL路径,根据匹配结果找到对应的处理函数。
  • 示例:在Python的Flask框架中,可以使用 @app.route 装饰器并传入正则表达式来定义路由,例如 @app.route(r'/user/<regex("[a - z]+"):username>') 来匹配以 /user/ 开头,后面跟着一个或多个小写字母的路径。
基于哈希表的路由
  • 原理:将请求的路径作为哈希表的键,对应的处理函数作为值。当请求到来时,通过计算路径的哈希值来快速查找处理函数。
  • 示例
lua
 代码解读
复制代码
route_table = {}

def register_route(path, handler):
    route_table[path] = handler

def lookup_route(path):
    return route_table.get(path)

这种方式在路径数量较少且固定的情况下,查找速度非常快。