字节跳动 架构概述

在字节跳动, 微服务架构 的特征可以被归纳为 4 点,如下图所示:

首先是 规模大、增长快 。近三年来,字节跳动的微服务数量和规模迎来快速发展。2018 年,我们的在线微服务数大约是 7000-8000,到今年五月份,这一数字已经突破 5 万。伴随快速增长,服务框架团队也遇到了非常多挑战。

其次是 全面容器化、PaaS 化 。字节跳动的在线微服务,超过 90% 都运行在容器里。所有上线都通过 PaaS 化平台进行,这意味着线上不会存在物理机部署这种模式。这种做法既有一些挑战:增加调度复杂性;也带来了一些便利性:有利于新功能的推广。

第三,字节跳动的技术体系 以 Golang 语言为主 。根据最新的调查统计,公司里有超过 55% 的服务是采用 Golang 的,排名第二的语言是前端的 NodeJS,之后是 Python、JAVA、C++,Rust 也有一些使用。

最后是 Service Mesh 在字节跳动目前已经是全面落地状态 ,这块的详细内容会在本文第三节展开。

基于以上 4 个特点,当前字节跳动微服务架构遇到的主要挑战还是围绕研发效率、运行效率和稳定性。其中研发效率和稳定性是几乎所有互联网公司都会遇到的:多语言、易用性、性能、成本……在这些问题中,字节跳动服务框架团队和火山引擎云原生团队最关注的是以下三个:

  • 快速迭代。研发和上线一定要快;
  • 对多语言的支持要足够好。配合员工规模增速,要对多语言保持非常包容的态度;
  • 运行时的稳定性。
Golang 微服务框架的演进

2014 年,Golang 被引入字节跳动,以快速解决长连接推送业务所面临的高并发问题。到 2016 年,技术团队基于 Golang 推出了一个名为 Kite 的框架,同时对开源项目 Gin 做了一层很薄的封装,推出了 Ginex。这两个原始框架的推出,极大推动了 Golang 在公司内部的应用。

这种情况一直持续到 2019 年年中。在 Kite 和 Ginex 发布之初,由于很多功能版本过低,包括 Thrift 当时只有 v0.9.2,它们其实存在很多问题,再加上 Golang 迎来数轮大版本迭代,Kite 甚至连 golang context 参数都没有。综上种种原因,Kite 已经满足不了内部使用需求。

在 2019 年中,服务框架团队正式启动了 Kite 这个字节自有 RPC 框架的重构。这是一个自下而上的整体升级重构,围绕 性能和可扩展性 的诉求展开设计。2020 年 10 月,团队完成了 KiteX 发布,仅仅两个月后,KiteX 就已经接入超过 1000 个服务。

类似的设计思路和底层模块也被应用在字节跳动自研 Golang HTTP 框架 Hertz 上,该项目在 2021 年春晚当天承载的服务峰值 QPS 超过 1000w(未统计物理机部署服务),线上无一例异常反馈。

RPC 框架 KiteX

KiteX 是字节跳动研发的下一代高性能、强可扩展的 Golang RPC 框架。除了具备丰富的服务治理特性,它还集成了自研的网络库 Netpoll,支持多消息协议(Thrift/Protobuf)和多交互方式(Ping-Pong/Oneway/ Streaming),提供自研的、更加灵活可扩展的代码生成器。

下图是 KiteX 的架构图,左侧是它的一些核心特点。

Kite 在发布之初使用的是原生的库,所以存在两个最主要的问题:一是无法直接感知对端连接状态;二是原生 net 网络库在面对长连接时,容易产生 goroutine 爆炸的问题。

这在 HTTP 场景下其实很常见,开发人员经常会遇到一个 HTTP 服务(包括使用 Gin 这种框架的时候)的内存出现持续增长。为什么?因为实际场景中客户端经常忘记关连接。

Netpoll 可以通过一个 React 模型去解决连接爆炸的问题,同时也带来一些 zero-copy buffer 的使用。

关于多协议,字节跳动的大多数服务采用的都是 Thrift 协议。而这里的多协议是应对一些业务提出的支持其他协议的需求。例如后端服务和端上的 Protobuf 打通。通过多协议支持实现了它和 Thrift 的解耦。现在 KiteX 同时支持 PB 和 Thrift 协议,也支持灵活的自定义协议扩展

高性能是所有做框架的开发者共同追求的目标,这里不再赘述。最后, KiteX 是面向开源社区设计的 。如上图所示,KiteX Core 是可以直接开源的部分,右侧的 KiteX Byted 涉及字节跳动内部工具,这块在对外时可以被替换成一些开源的日志库和方案。

截至今年 5 月,公司内已经有超过 20000 个服务正在使用 KiteX,同时它也支持了 streaming/泛化调用等需求。在开源上,技术团队也提供了一些监控和日志可插拔的 feature。

在性能上,相比当前社区比较认可的 gRPC 框架,kitex/thrift 实现了 2.5 倍吞吐量的提升,kitex/protobuf 也有近 2 倍的提升。在 TP99 延迟上,KiteX 也有不错的表现。

HTTP 框架 Hertz

2020 年 10 月,字节跳动内部推出 Hertz,作为代替 Ginex(Gin 封装版)的 Golang HTTP 首选框架。

Hertz 的设计思路和 KiteX 大致相同。它借鉴了开源项目 Gin、FastHTTP 的优势,引入了 Netpoll、内存池、零拷贝等技术,因此有着较高的性能。同等配置下,Hertz 的极限 QPS 是 Ginex 的两倍,平均延时则只有 Ginex 的二分之一。

下图是技术团队基于最新版本制作的 Benchmark。在小包和中包场景下,Hertz 相比 FastHTTP 有 30% 到 50% 以上的 QPS 提升;在大包场景,双方差距不大,Hertz 在平均时延上占优。

json 库 Sonic

2021 年春节之前,技术团队对线上容器数量排名前 50 的服务进行了性能瓶颈分析。通过调研,团队发现 json 在整体上占了 9.5% 的资源,在某些服务上的资源占用甚至达到 40%!

既然 json 性能这么差,为什么不换成 Protobuf?

在当时,技术团队确实将部分服务切成了 Protobuf,但考虑到 json 的易用性、受欢迎程度,以及更换协议导致的迁移成本和迁移风险,他们也开始尝试进行一些优化。他们调研了所有可获取的 Golang json 库,并在业务场景下都做了比较详细的对比和分析,发现了一些问题并做了优化:

  • JIT:有些 json 库已经开始展现出 JIT 特征,但还停留在把常见代码片段通过聚合的方式组织起来,没有做到非常极致的 just in time 编译,因此需要推进一步;
  • 前置筛选+SIMD:simdjson 在一些大包场景下的表现很不错,但在一些小包场景下的表现其实不太好,因此需要做前置筛选和 SIMD 优化;
  • asm2asm: C++ -> Go——“优化 Go 最好的方法就是不要用 Go”,技术团队发现用 C++ 写 json 常用函数,把它们编译成 x86 汇编,再通过内部 asm2asm 这个工具转成 Go 的汇编,可以获得非常大的性能提升;
  • lazy-load 解析器:针对多 key 查找的场景,做了一个解析器。

基于上述优化,在一个 110KB 的真实业务请求场景下,相比其他 json 库,Sonic 在每个场景下的表现都有成倍的提升。目前 Sonic 已经开源,但经过内部服务测试,它还处于比较初级的阶段,有一些稳定性问题,后续会有相关团队持续跟进。

字节跳动的 Service Mesh

首先是几个数字。从 2018 年 6 月至今,字节跳动在三年间共上线了约 30000 个服务,Service Mesh 管理的容器数已经超过 300 万个。所有业务场景,以及 ToB 和边缘计算场景,现在都处于 Mesh 全覆盖的状态。

下图是公司内部 Service Mesh 的架构示意图。除数据面和控制面外,它还有一个运维控制面(Operation Plane),且具备两个突出特点:

  • 在数据面,字节跳动的 Service Mesh 实现了中间件的能力 sidecar 化,形成一个标准模式,下图中的通用 sidecar 即标准技术方案;
  • 该 Service Mesh 的运维控制面可以发布多种不同类型的资源。但凡需要集中发布的资源,例如 Mesh 的 sidecar,例如 WebAssembly 的资源、动态库,都可以通过运维控制面进行发布。
字节跳动内部 Service Mesh 的主要特征

当前字节跳动 Service Mesh 的特点可以用 4 个关键词概括:

全功能 。除了前文提到的 RPC 框架、HTTP 框架,字节跳动的 Service Mesh 已经对中间件、MySQL、MongoDB、Redis、RocketMQ 等提供全面支持,在安全能力、服务治理能力,包括流量复制、mock、容灾等方面,它均可提供完整功能。

多场景 。字节跳动的 Service Mesh 适用于内网环境、边缘,也可被用于把两个 IDC 串联起来。IDC 串联为什么要通过 Mesh?因为跨 IDC 的网络是不稳定的,考虑到高昂的成本和严格的服务访问控制,需要通过 Mesh 提供较强的边缘管控能力。

稳定性 。这是一个比较常见的话题,此处不做展开。

高性能 。字节跳动 Service Mesh 的性能优化基于技术团队的理念:如果目标是做一个真正 zero copy 的 proxy,而我们做不到,那么它的原因是什么? 网络和内核、基础库、组件架构、编译 ——如果阻碍来自内核,就去改内核的 API,例如降低 sendfile 的开销。围绕这一理念,技术团队通过采用 Facebook 的 hashmap ,带来了 1%-2% 的性能提升;通过重写抽象层,实现 35%-50% 的吞吐量提升;通过全静态编译,无需修改任何代码,就获得了 2% 左右的性能提升……

下图展示了技术团队在性能优化方面的主要思路和措施:

性能:基于共享内存的 IPC

如前文所述,技术团队想实现的是真正 zero copy 的 proxy。那么拷贝发生在哪里?

上图是一个最原始的状态,mesh proxy 带来了两层 copy,一次是把业务进程 copy 到 Unix Domain Socket 的 Buffer 里,另一次是读取出去。此外它还多了一个进程,而多一个进程,就意味着增加了调度开销,同时也会产生一些复制成本。

针对这个问题,技术团队采用的做法是 把它只写进内核一次 。通过一个共享内存的 IPC ,业务进程把准备发送的数据写进去后,mesh proxy 只需根据 meta header 就可以决定任何调度策略和治理策略,并通过调用下游发送函数把数据发出去。

但现在 TCP Socket 是不能直接把数据发出去的,怎么办?请负责内核的团队写一个 API。这样,整个 overhead 就可以降到最低值。

上图是采用共享内存 IPC 的性能测试结果。从 2020 年初开始,字节跳动内部就已经开始走上这种 数据走共享内存、控制信号走 Unix Domain Socket 的路,同时做好控制协议。确实这条路也是可以走下去的,当前已经有 500+ 服务在线上进行灰度,整体稳定性不错。未来技术团队也计划把这种优化方式扩展到所有涉及 同机通讯 的场景。

另外,定义一个通用的协议,可以实现这种能力的复用。随着容器与容器之间的通信变得越来越频繁,这种基于共享内存的通讯的重要性也会日益凸显出来,字节跳动内部 Service Mesh 的通用 sidecar 也是基于相同的思考。

可观测性:Service Mesh 之痛

基础架构部门经常会被问到两个问题:

  • Q1:上下游延迟不一致——为什么我在客户端看到的延迟是 100 毫秒,而 Server 端处理只花了 50 毫秒,中间这 50 毫秒去哪里了?
  • Q2:请求超时——接了 Mesh(不接 Mesh 其实也一样),我的请求为什么就超时了?

如果深入地去思考这个问题,可以发现,它的根本原因在于一次请求的时间可以分为业务可见的部分和业务不可见的部分。

比如开发人员做一次加密耗时 1 毫秒,但调用一个服务做一次加密可能需要 10 毫秒。这 9 毫秒到底去哪里了?很多开发人员会认为这是环境的问题,或是基础架构的问题。这个问题一直存在,而且相当普遍。

那么, 延迟 来自哪里?

  • 计算 。比如序列化,上游其实不会关心请求的序列化和反序列化,以及从用户态拷贝到内核态,这些都可以被归类到计算里,而这些延时业务侧是感知不到的(此处需要区分感知与可观测,通过监控可以看到这部分耗时,但一般业务不理解这儿的计算耗时,认为处理时间仅仅是自己的业务逻辑);
  • 调度 。比如要异步发送一个东西,数据写入后,上游并不知道会什么时候发。如果机器负载非常高,可能需要等待 100 毫秒,甚至任务会在容器里被 CPU throttle 掉,这时从上游的角度看就是请求超时;
  • 网络 。出现超时后,网络永远是第一个排查项:是不是挂了?是不是变慢了?

分析清楚这三个问题后,那么应当如何解决呢?服务框架团队的做法是推进 LLT(Low Level Tracing)。即把上游看不见的地方都做可视化处理:如果计算耗时看不到,就把计算时间表现出来;如果调度情况无感知,就把调度的用时算出来;如果网络情况不清楚,就把网络的用时也算出来。完成内核网络及调度事件收集后,这些时间最终都会汇入 OpenTracing 集中展示。

如上图所示,当上游开始调用 RPC Client write() 时,事实上就已经进入业务不可见范围。

字节跳动内部有一套基于 eBPF 做的 tracing 体系,它能根据提供的 log ID 展开追踪,把请求什么时候进入内核、什么时候从网卡发出等时间戳记录下来。有了这些信息,技术团队就能通过 OpenTracing 看到请求什么时候从本机发出、什么时候对端收到、什么时候开始进入 Mesh 处理、什么时候开始进入业务层处理……整个链路变得十分清晰。

上述方案的 CPU overhead 约为 8%- 10%,整体开销稍重,所以目前是按需开启状态。

字节内部 Mesh:通用 sidecar 的出现

Service Mesh 的出现源于技术社区希望用它解决多语言、运维、迭代等问题,这种概念可以被泛化, 即是否可以为解决多元问题和运维问题同样打造一套标准技术方案 。毫无疑问,所有 中间件 都在这个范畴内,无论是 MySQL、 Redis Client,还是 API Gateway、登录组件、风控组件,它们都属于这一领域。

同时,Service Mesh 背后的能力也可以被复用:既然可以把 Mesh 的 sidecar 分发到线上任意一个容器,并完成安全的热升级,那么同样的方法也可以被用于中间件的 sidecar。

字节跳动的 Service Mesh 提供通用 sidecar,把诸如 API Gateway sidecar、风控 sidecar、登录及 Session sidecar 等以 Mesh 的方式分发到容器里。这样做并不会对业务侧造成什么影响,但后果是它们也需要应对性能、可观测性、稳定性等问题。由于 Service Mesh 在字节跳动已经大规模落地,这些问题的解决方案其实和 mesh proxy 没有太大差别。

总结与展望

谈到微服务,由于缺乏直观感受,很多人往往只关注服务的功能层面,而忽略底层的运维属性。但其实现在为什么社区还在不断讨论微服务,因为它部署在公有云、私有云、混合云等充满不确定性的环境下,解决的是由此带来的各种复杂性问题。

但换个角度看,如果开发人员都使用同一种语言,不做跨机器和跨进程的通讯。当所有服务都部署在同一台宿主机上,类似跨网络通讯、请求超时等问题会大大减少,通过共享内存或是无序列化等手段,整体性能似乎可以获得最极致的提升。

这是不是破坏了微服务的概念?其实不然。这样的部署对于业务来说是透明的,只要宿主机规格足够,开发人员可以通过调度按服务域进行聚合,框架层和流量调度层感知情况后,很自然地就会把远程通讯切换成本地通讯,使得性能得到大幅优化、延时降低。如果机器出现宿主机级别的故障,所有服务确实会一起挂掉,但如果只是某一个模块出现故障,则可以把它轻松 fallback 到其他服务上。

这也是字节跳动内部正在进行的探索,为解决由微服务数量不断增长、复杂性不断提高导致的非业务消耗比业务消耗还要高的问题。

在微服务领域,除了内部探索,字节跳动也在尝试通过服务框架团队和火山引擎云原生团队做技术输出,通过 ToB 品牌火山引擎为更多企业提供微服务和 Service Mesh 方面的解决方案。