深度 | 字节跳动微服务架构体系演进
本文整理自字节跳动(火山引擎)基础架构/服务框架团队负责人成国柱在 QCon 2021 的分享,主要介绍了 2018-2021 年间,服务框架团队在 Golang 服务框架和 Service Mesh 上的技术实践和经验总结。
字节跳动微服务架构概述
快速迭代。研发和上线一定要快;
对多语言的支持要足够好。配合员工规模增速,要对多语言保持非常包容的态度;
运行时的稳定性。
Golang 微服务框架的演进
JIT:有些 json 库已经开始展现出 JIT 特征,但还停留在把常见代码片段通过聚合的方式组织起来,没有做到非常极致的 just in time 编译,因此需要推进一步;
前置筛选+SIMD:simdjson 在一些大包场景下的表现很不错,但在一些小包场景下的表现其实不太好,因此需要做前置筛选和 SIMD 优化;
asm2asm: C++ -> Go——“优化 Go 最好的方法就是不要用 Go”,技术团队发现用 C++ 写 json 常用函数,把它们编译成 x86 汇编,再通过内部 asm2asm 这个工具转成 Go 的汇编,可以获得非常大的性能提升;
-
lazy-load 解析器 :针对多 key 查找的场景,做了一个解析器。
字节跳动的 Service Mesh
在数据面,字节跳动的 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 没有太大差别。
总结与展望
关于火山引擎
火山引擎是字节跳动旗下的智能科技品牌,基于公司大数据、人工智能和基础服务等能力,为企业客户提供系统化全链路解决方案,助力企业务实地创新,实现业务持续快速的增长。