反微服务架构(A Macro Services Framework)

作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢!


反微服务架构(A Macro Services Framework)

本文提出了一种新的设想和概念:反微服务架构(A Macro Services Framework)。

与当前流行的微服务架构不同,在考虑后端面临的新挑战和新的硬件发展水平后,尝试提出一种新的架构模式来应对新挑战并适配当前的硬件发展水平,这种架构模式与微服务的理念有部分背离, 为此以"反"开头,并搞笑地针对微服务的Micro替换为Macro。

本文将以这样的逻辑顺序为读者解释"反微服务架构(A Macro Services Framework)"这一概念:

  • 引子:神人马斯克对推特做了什么改造

  • 背景部分

    • 当前的服务器硬件水平

    • 当前的服务器开发面临的挑战

  • 案例部分

    • 一个大资料服务为何变得如此之"大"

    • 对于视频信息提取的系统,遇到了什么挑战和问题

  • 设计细节部分

    • 反微服务架构适合什么样的业务场景

    • 反微服务架构的特点是什么,各个部分如何实现

从一件旧闻谈起

在埃隆·马斯克接手 Twitter 后,他对平台的架构进行了大幅调整,其中一个关键举措是将原本的微服务架构整合为一个大型的单体应用。

背景:

Twitter 原先采用微服务架构,将不同功能模块分散在多个服务中运行。这种架构虽然在扩展性和模块化方面具有优势,但也带来了复杂性和维护成本的增加。

马斯克的调整:

在接手 Twitter 后,马斯克对平台的架构进行了重组,特别是对 feeds 服务进行了整合。他将原本分散的服务合并为一个大型的单体应用,以简化系统结构,降低维护成本,并提高性能和稳定性。

具体措施:

代码精简: 马斯克主导的团队对代码进行了大规模的精简和优化。例如,For You 服务与排名系统的代码行数从 70 万行减少到 7 万行,精简比例高达 90%,计算占用量降低了 50%,根据请求得分计算的帖子吞吐量增长了 80%。 (36Kr)

架构重构: 团队重构了技术栈内的 API 中间件层,通过删除超过 10 万行代码和数千个未实际使用的内部端点、清理未采用的客户端服务等方式完成了架构简化。精简后的元数据获取延迟降低了 50%,全局 API 超时错误减少了 90%。 (36Kr)

效果:

这些调整使得 Twitter 的系统架构更加简洁高效,降低了运营成本,并提升了用户体验。然而,单体应用也可能带来一些挑战,如在扩展性和模块化方面的限制,需要在实际运营中持续关注和优化。

通过这些措施,马斯克成功地将 Twitter 的 feeds 服务整合为一个大型的单体应用,实现了系统的简化和性能的提升。

那么,问题来了,什么是单体架构?为什么单体架构导致了计算占用量和延迟下降那么多?

现在的服务器硬件到什么水平了

通常,服务器开发工程师只关注服务器硬件的"四大金刚",即 CPU,内存,网卡和磁盘。随着 AI 的兴起,服务器增加了 GPU 和 FPGA 等异构计算的设备,但相比其他领域的计算设备(例如移动设备),服务器依然一点也不复杂。

下面简单的罗列服务器的较新的硬件发展水平:

  • CPU

    • 英特尔® 至强® 6780E 处理器 144 核

    • AMD EPYC 9004 系列: 最高可达128核256线程,基于Zen 5架构,专为数据中心和高性能计算设计。

    • Ampere Altra系列:Ampere Computing推出的Altra和Altra Max处理器,采用ARM架构,提供高达128个核心,专为云原生工作负载设计,具有高性能和低功耗的特点。

    • 2024 年11月27日,在英特尔新质生产力技术生态大会上,腾讯云宣布发布基于英特尔⾄强6SRF-AP处理器的云服务器实例S9。该实例将采用全新腾讯云星星海服务器底座,最大实例规格达到576vCPU,整机计算性能相对上一代S8云服务器实例提升150%以上;借助腾讯云全新自研智能网卡,S9能够提供2*200G超高带宽,网络性能提升100%。

    • 总结:单颗 cpu 的核数超过 100,开启超线程后逻辑核达到 200 以上

  • 内存

    • 三星和美光等公司已经推出了容量达到1.5TB的DDR5 RDIMM

    • 高端1U服务器可能配备多达16个内存插槽

    • 单服务器的内存总量可达到:24TB

  • 网络

    • 网卡

      • 400Gbps:400GbE(400千兆以太网)网卡已在数据中心中广泛部署,提供非常高的带宽适合云服务提供商、流媒体、大数据分析等需求。

      • 200Gbps和100Gbps网卡:100GbE和200GbE网卡在企业和中型数据中心中也较常见,适用于高流量的应用。

      • 400Gbps 网卡让 1 秒传输一个 50 GB 的视频成为可能

      • 一个标准的 1U 服务器,至少支持 3 个 PCI-E 接口。单服务器带宽有望达到 1200 Gbps

    • IDC 级别的新技术

      • 巨帧(Jumbo Frames):以太网帧长度从 1500 字节扩展到 9000 字节,每帧的数据利用率从 94% 提升到 99%

      • 谷歌的 Aquila(天鹰座) 数据中心架构

        • see: 《谷歌发布重磅论文,推全新低延迟数据中心架构

        • Aquila在原型网络中实现了IP流量往返时间(RTT)低于40微秒,远程内存访问(RMA)执行时间低于10微秒,显著降低了尾部延迟。

        • 本机访问ssd的延迟是 1~10 微秒 ------ 也就是说以后通过网络从另一台服务器上获取数据的延迟,与本机访问ssd在同一个延迟级别。

    • 网络编程领域的新技术

      • 多队列网卡驱动:使用多核管理网卡中断

      • DPDK => 用户态协议栈:数据直接从网卡到用户态的buffer,绕开了内核协议栈

      • RDMA: 让访问网络数据像访问本地内存

  • 存储

    • SSD 的延迟(10微秒)比 HDD 的延迟(10毫秒) 低了三个数量级

    • SSD 的容量和成本即将追上机械硬盘

  • 异构计算

    • GPU

      • 截至2024年11月,NVIDIA推出的最强单卡GPU是H100 Tensor Core GPU,其在不同精度下的理论峰值性能如下:

        • FP64(双精度浮点):34 TFLOPS

        • FP32(单精度浮点):67 TFLOPS

        • TF32(Tensor Float 32):989 TFLOPS

        • FP16(半精度浮点):1,979 TFLOPS

        • FP8(8位浮点):3,958 TFLOPS

    • 其他异构硬件

      • DPU / XPU

      • FPGA

链接:一个充分利用单机算力的优秀案例

来自腾讯的 CKV+ 是一个基于 redis 协议的 cache + 存储一体的 nosql 服务。通过 DPDK + 用户态协议栈等技术,该团队做到了在 100 核的服务器上支撑 1000 万/s 的请求量,真正实现了业界提出的 C10M 问题。

后台服务器开发面临什么挑战

业务越来越复杂

服务器开发中,一方面要依据功能来把整个服务体系划分成不同的微服务,以便"分而治之"地解决复杂度问题;另一方面,从硬件成本考虑,又要把不同类型的服务组合部署来充分利用算力。

过往的经验是把服务按照负载模式的不同分别开发和部署,以此达到节约成本提升利用率的目的。

例如:

  • 快慢分离:把延迟忍耐度低的服务和延迟忍耐度高的服务分别部署在不同的群集(让快的一直快,不要被慢的拖慢)

  • 轻重分离:把重度依赖某种计算资源的服务和轻度依赖的服务分开部署

也有按照计算密集型,IO 密集型,事务密集型......等模式来划分的。

新的问题是:一方面,随着业务越来越复杂,服务器程序变得既要xx又要xx还要xx,难以按照单一的维度来划分;另一方面,就算服务一开始很简单很干净,但是随着业务的快速发展,在缺乏服务治理的足够投入的情况下,简单的服务随着需求堆得越来越多,最终也变得很复杂。

数据的类型越来越复杂

后台服务器开发常常让人首先先入为主的想到的是 HTTP 服务或者 RPC 服务,这些服务传输的内容都是文本(html, json)或二进制序列化(protobuf或thrift)的内容,内容小且通讯频繁,其数据是结构化或者半结构化的。

到了 5G 时代 / AI 时代,后台服务处理的数据类型越来越复杂,且容量越来越大,典型的例如图片、音频、视频等富媒体内容。可以预见,随着物联网的兴起,各种设备会产生更多非人类感官可以感受的数据,并送入后台服务器进行复杂的运算。

现在面临的问题是:单条数据的体积增大,对"四大金刚"(CPU/内存/磁盘/网络)的需求同时增加了;其次是数据处理复杂度的增加,且处理的链条也变得更长

单个应用难以充分利用现代化的服务器硬件

充分利用硬件的方法在云时代最典型的手段是"超卖"(或称为超售, Over-Provisioning, Over-Commitment):看起来每个业务都分配了 N 个核,其实是多个容器共享 N 个核。

超卖后如何服务好业务,就要看k8s中调度器的水平了:

  • 按照所消耗资源类型不同来组合:可以把 CPU 消耗大但是内存消耗小,与 CPU 消耗小但是内存消耗大的容器部署在同一个物理节点上。

  • 按照活跃周期的不同来组合:把白天繁忙晚上低负载的容器,与晚上繁忙白天空闲的容器混合部署。

  • 把瞬间消耗资源很高的容器,迁移到低负载的节点上去。

超卖对于云服务商是很好的节约成本手段,但是对于业务的瞬时算力支持是滞后的,可能业务持续一段时间因为算力不足发生延迟增大甚至超时后,才会被调度器迁移到别的有足够资源的物理节点上。使用超卖充分利用硬件其实是牺牲业务的服务质量来换取的。反言之,如果不超卖,找到一个完全利用起单机所有资源的应用非常困难。

业务的规模也是一个考量点:小业务可能很难一个进程就消耗上百个核,且为了容灾考虑,需要把业务分布在多个业务节点上。对于大业务而言,如果仅仅用了充分利用单机的资源,则可能把多种业务功能整合在一个进程上,增加了开发复杂度,复杂的功能容易造成稳定性问题,且发布重启的成本太高,不利于敏捷开发快速迭代。硬件成本上节省的收益抵不过软件质量的下降带来的损失。

现在的困难是:如果通过超卖来完全利用某个维度的资源(通常是 CPU),则资源争抢导致了业务服务质量出现问题;如果通过组合不同资源需求的应用,则调度器很难聪明地把各种服务器资源都利用起来;如果直接把业务写成一个"大应用"来尽可能占满单机资源,则分工、发布、故障隔离等方面又会变得困难。

  • 为什么把应用直接部署到更多核的容器上不是个好主意

假设这样的一个场景,有一个未充分优化的 golang 服务器程序,以前部署在 10 核容器上,部署了 10 个核,总计消耗 100 个核。

在不考虑容灾的情况下,能否分配一个 100 核的容器给这个服务器程序?

根据我以前测试服务性能的经验,一个运行在同样核心数的容器内的程序,其性能只有运行在同样物理核上的性能的 94%。理论上来说,大容器相比小容器会节省掉一些容器运行时的损耗。

应用不直接部署到大容器的根本原因是并发与锁的矛盾,如果并发中没有充分处理好锁,则越多的核,加锁的成本就越高。

golang似乎做并发很容易,且协程之间如果没有关联性的话,代码中无需加锁是不是就能总是充分利用多核?问题出在 gc 上:如果未优化内存分配,则运行一段时间后必然需要以 STW(Stop The World) 的方式来对对象池进行扫描。越多的核,其 STW 的影响越严重。因此,如上所举的例子:一个golang的服务器程序,部署在 1 个 100 核的容器上,可能性能反而不如部署在 10 个 10 核的容器上。把 STW 看成锁,部署10个容器相当于 把这把锁分成 10 个独立的锁。

  • 小知识:Amdahl's Law(阿姆达尔定律)

Amdahl's Law(阿姆达尔定律),它用于分析程序的并发加速性能。这个公式是:

含义:

• S(n): 加速比,即使用 n 个处理器时的性能提升倍数。

• P: 程序中可以并行化的部分所占比例(0 ≤ P ≤ 1)。

• 1-P: 程序中无法并行化的部分所占比例。

• n: 并行计算中使用的处理器数量。

解释:

• 当 P = 1(程序完全可以并行化)时,加速比可以达到 n 倍。

• 当 P < 1(程序有部分无法并行化)时,加速比受限于串行部分。即使 n 无限增大,性能的提升也会受限于串行部分 1-P。

• 当 n 趋于无穷 时,加速比的极限为:

举例:

如果程序的并行部分占比 P = 0.9,串行部分为 1 - P = 0.1,即使处理器数量无限增加,最大加速比为:

因此,阿姆达尔定律强调了程序中串行部分对并行性能的限制。优化程序时,减少串行部分的比例 1-P 是提升并行性能的关键。

团队协同与经济性

当应用越来越大,越来越复杂,则需要更大的团队,更丰富的团队角色来协同开发。一个后台服务,可能需要:

  • 全栈工程师写页面渲染和开发api网关(整个后端的多个服务功能)

  • 后台工程师实现业务逻辑,调用相关服务或公共云服务组件

  • 算法工程师提供用于推理的服务,通过深度学习模型来提供各种 AI 能力

  • 大数据工程师负责点击流的接入,进行ETL/流式处理/离线计算,并可能会把计算结果热更新回在线业务系统

  • SRE 提供运维用的bash或者python脚本,通过运维操作保障基础设施稳定

从团队构成方面看:

  • 全栈工程师可能喜欢 nodejs / php

  • 后端可能喜欢 golang / Java

  • 算法工程师可能主要使用 python

  • 而很多云服务组件可能使用 c/c++

在多种团队角色 + 多种开发语言的环境中:

  • 容易快速做功能的语言性能不高(例如python),且在工程规模扩大后,维护代码变得困难("脚本一时爽,维护火葬场")

  • 容易做网络 IO 和并发的编程语言不适合计算密集型应用(例如golang)

  • 贴近底层的可以高度优化的语言开发起来不经济(例如c/c++/rust)

    • 开发周期长

    • 稳定周期长

    • 不好招人,且昂贵

团队角色和编程语言的混杂,这就导致很多时候我们很容易做出"融合怪"来简单粗暴地弥补某个语言原本不擅长的领域:

  • 为了加速 python 用 c/c++ 写 module

  • 为了加速 golang/Java 用 c/c++ 写 cgo/jni

  • 为了便于业务扩展,c/c++的程序中调用 golang/python 实现的 c 插件

融合怪的存在一方面对开发工程师提出了更高的要求,一方面在原本擅长某个领域的编程语言上引入了新的问题:

  • golang/Java 中因为 cgo/jni 而发生内存泄露或者coredump

    • golang中为了避免cgo影响调度器的响应,开了很多物理线程来执行cgo
  • c/c++ 的应用因为引入python等运行时导致缓慢和gc问题

  • 代码难维护

因此:

  • 需要考虑多种团队角色、多种编程语言如何更好的整合

  • 避免"融合怪"的模式来跨编程语言解决原来那个语言不擅长的问题

  • 用擅长解决某类问题的编程语言来解决它擅长的那个领域的问题,用最经济的方法完成复杂应用的整合

延迟问题

云服务厂商非常期望他的客户尽可能的使用微服务架构来构建整个应用。这背后其实有着另一个不为人知的秘密:

微服务架构很容易通过"钞能力"(aka $uper Power)来解决短期的服务质量问题。

只要客户愿意掏更多钱购买云服务,短期的超时问题算力不足问题往往能够立竿见影的看到效果。

遗憾的是,微服务架构中的延迟问题,无法用"钞能力"来解决。 随着业务的发展,服务之间的调用路径越来越长,延迟也在不断增加。增加算力只能缓解高峰期过载情况下的超时请求数量,而对于一些延迟敏感的应用却无能力为例。

例如:

  • 云游戏等业务对时延极其敏感:

    • 谷歌的云游戏产品 Stadia,从用户点击鼠标到最终看见由这次点击鼠标所引起的游戏画面变更这一链路中实测得到的延迟是 108 毫秒。
  • 大模型,ai 方面的应用,必须把延迟降低到足够小:

    • 训练出一个效果显著的模型很难,在线上通过这个模型为海量用户提供服务也很难------不但需要很多计算资源来做推理,还要保障推理在用户能够忍受的时延内返回结果。
  • 搜索/feeds/广告推荐/商品推荐等系统,需要在海量数据中召回大量数据,并完成粗排/精排等操作,最后在用户接受的时延内返回结果

    • 特别对于广告系统、商品系统而言,增加时延等于损失收入
  • 小知识:云游戏的端到端延迟

在云游戏中,从用户点击鼠标到最终看到服务器返回的画面变化的延迟称为 端到端延迟,通常包括以下几个关键部分:

  1. 输入采集延迟:从用户点击鼠标或按键到设备捕获输入的时间。

  2. 网络上传延迟:用户设备将输入事件发送到云服务器的网络传输时间。

  3. 服务器处理延迟:云服务器接收输入、处理游戏逻辑、生成新的画面并编码为视频流所需的时间。

  4. 网络下载延迟:从云服务器将视频流传输回用户设备的时间。

  5. 解码和显示延迟:用户设备解码视频流并在屏幕上显示的时间。

一般端到端延迟的范围

理想情况下 ,端到端延迟通常在 50-100毫秒 之间,可以提供接近本地游戏的体验。

现实条件下 ,延迟可能受到用户网络条件、地理位置与云服务器的距离、设备性能等影响,可能增加到 150-200毫秒,甚至更高。

Stadia 的端到端延迟

根据谷歌在 Stadia 上的技术文档和第三方测试:

• 在优秀的网络条件下(如高速光纤,Ping 延迟低于20ms),Stadia 的端到端延迟可以保持在 50-80毫秒

• 如果网络条件一般,延迟可能上升到 100-150毫秒,但谷歌通过自适应码率和其他优化技术尽量减少用户感知的卡顿。

案例

这一节将把实际业务场景的一些典型案例拿出来分析。

一个大资料服务的实现

目前的团队中有一种元数据服务,为其他业务提供基础资料信息。其资料存储在 mongoDB 中,然后提供使用rpc 协议的微服务来对外提供元数据读写能力。一开始是直接读写mongoDB,随着业务量增加,对mongoDB 的访问压力增大,于是对 mongoDB 进行分片。业务量进一步扩展后,微服务上增加了localcache,以降低对 mongoDB 的压力。当业务的请求量继续扩大,请求到微服务上的量达到了 600万/s。这个服务最终在规格为 8 核的容器上部署了 2 万个实例,共计 16 万核。每容器是 4 GB 内存,共计 80TB 内存。

虽然 600 万/s 的请求量很吓人,但是按照部署的核数计算,平均每个核只达到 37.5/s 的负载量。这实在是太少了!

这个大服务的性能不佳,犯了以下错误:

  • local cache 方面:

    • 总计 80TB 的内存占用,如果在单节点上开更多内存做cache,预计cache命中率会好很多,且节约内存。

    • 调用此服务的上游,并未使用一致性hash算法,导致 local cache 的命中率并不高。key1请求A 容器的时候,可能发生cache miss,下次访问 B 容器,可能再次cache miss。如果使用一致性hash算法来路由,把确定的key的请求始终转发到确定的某个容器上,那么 localcache 的命中率会提升很多。并且,命中率低导致后端的 mongoDB 也需要很大的规格,来应付cache miss后走到存储层的请求。

    • localcache 因为节点太多+上游未使用有状态的路由,导致数据重复地分布在各个进程的内存中,重复存储数据导致白白浪费了内存;由于cache中的(所有不重复的)数据量,占整个 mongodb 数据的比例并未明显提升,因此在缓解后端存储压力上也并未取到很好的效果。

  • 容器规格方面

    • 一共 2 万个小实例,实在太多了。应该以占满一个 NUMA 为单位,尽可能使用大实例。

      • 例如,100 核以上的服务器,单个 NUMA 约 25 个核。应该以 25 核为一个容器,这样实例数就可以减少为 6400 个。
    • 服务在一开始规模很小的时候,以典型的微服务的模式来提供服务。随着业务量的增加,只是简单的通过扩容操作来应对请求量上升------最终导致了部署太多实例,且花费了太大的成本。

    • 上文中的"为什么把应用直接部署到更多核的容器上不是个好主意"在这里也体现了:未充分优化的程序或许在 8 核规格的容器上达到最佳资源利用率,因为更多的核会由于 golang 的 STW 特性导致性能根本上不去。

  • 负载均衡方面

    • 调用者使用的负载均衡算法太简单,没有综合接口延迟来调整调用量(延迟低的节点认为算力强,应该转发更多的请求到延迟低的节点上)。从而,已经部署了很多实例后,部署上带来了"规格绑架"的困境:例如,A实例为 10 个核,B实例为 20 个核,上游调用者如何才能知道应该给 B 实例转发更多的请求?上游简单的使用 round robin 算法,导致永远只能使用同一规格的容器进行部署。

视频内容分析平台

团队中有一套视频处理系统,用于从视频中提取元数据/图像帧/音频帧等信息,并把这些信息用于人工审核和机器视觉等平台。

这套系统有这样一些处理细节:

  • 整个处理程序使用golang开发,视频的图像帧提取等使用 ffmpeg 命令行程序。每个视频都会由golang进程创建独立的 ffmpeg 进程来进行处理。

  • 为了避免频繁的磁盘 IO ,使用了memfs,把内存虚拟化为磁盘,用于保存视频和中间文件。

  • 当需要提取某个视频的信息时,先要从对象存储把这个视频下载到计算容器中。这里存在一定的重复下载的情况:假设 A 业务和 B 业务都需要处理同一个视频,则可能两次计算会分布在不同的计算容器上。

  • 有一套机器视觉的深度模型服务群集,用于做视觉方面的推理计算。服务的接口设计为在 RPC 协议中接收 JPG 格式的二进制数据,推理完成后向调用者返回结构化的业务数据。(通常是人像识别、场景识别、违规内容识别等)

这一平台也花费了 10 万核以上的资源。

大多数人可能对于 10 万核没什么概念 ------ 看看阿里云上的报价,单个 vCPU 的价格是 0.000010 美元/s,折算到每月是 26.3 美元/月。十万核每月花费达到 263 万美元。

从成本的角度出发,有这样一些明显的优化点:

  • 既然视频可能会重复下载,那么能不能同一个视频的计算都放在一个计算节点上?那么就不会重复下载了。

    • 进一步地:类似 hadoop 的迁移计算而不是迁移数据的理念,如何实现一些基础设施来让迁移计算变得容易?
  • 每个视频都需要创建 ffmpeg 的进程来处理视频。如果 ffmpeg 处理视频的时候是一个常驻的服务程序,则创建进程的开销可以避免。

  • 把图片的内容作为 rpc 协议的请求体,发给另一个机器学习的服务来处理,这是明显有问题的:

    • rpc 服务框架通常是为小字节的且通讯频繁的内容来提供服务的,数百kb 的包内容对导致rpc 框架因为拷贝数据而拖慢处理流程

    • 如果机器学习的服务能够和视频处理的服务部署在同一个物理节点上,那么网络通讯的开销就可以完全省略掉。

微服务合并

业界有多个案例,通过合并微服务来节约资源和提升性能。

由此可见,把过"微"的微服务进行合并,可以提升性能、减少延迟、节约资源。

反微服务架构(A Macro Services Framework)

如何解决上述服务架构中出现的问题?如何适应新的服务器硬件?本节介绍反微服务架构的实现细节。

目标和场景

反微服务架构到底想干什么?到底在哪些场景里要"反"微服务?

反微服务架构的目标是什么

  • 适应现代化的硬件 ,通过充分利用单机的算力来为大型后端应用节约成本、降低延迟

  • 适应 AI 时代对复杂计算的需求,让异构计算、多种编程语言实现的模块可以高效整合在一起工作

  • 适应丰富的数据类型,除普通文本、结构化业务数据外,支持图片、视频、音频等富媒体类型,并能够更加经济高效地完成处理。

  • 适应延迟敏感型应用,通过在单机集成更多功能,将原来的多个微服务由跨网络通讯修改为进程间通讯,从而达到降低延迟的目的。

  • 推动改造现有的功能性命令行软件的协作模式,在利用多核且减少拷贝的前提下,可以像 unix 管道一样快速组合起来完成复杂功能。

例如,我们知道 ffmpeg 命令行可以处理视频,我们知道 yolov3 可以做物体识别。

那么,这两个工具有没有可能像 unix 管道一样简单的整合起来:

wget "http://xxx/1.mp4" | ffmpeg -i - -vf "select=not(mod(n,30))" -vsync vfr frame_%03d.jpg | yolov3 > objects.txt

在未来,各种富媒体处理工具、CV(计算机视觉)工具、AI 工具,能不能像 unix 的命令行管道一样,非常简单就可以组合起来,并且以极高性能的模式运行以充分利用好硬件资源?

反微服务架构不是什么

  • 反微服务架构不是简单的单体应用,此架构一方面像微服务一样把复杂应用拆成很多小的独立运行的单元来降低开发复杂度,一方面又尽可能的部署在同一个多核的服务器上来提高资源利用率。

  • 反微服务架构不是要替代微服务架构,反微服务架构是微服务架构的一种特殊场景和补充,适用于微服务架构的 kubernates 等基础设施,仍然适用于反微服务架构。

反微服务架构适合哪些场景

  • 大型应用:已经运行与数万核的群集上,服务众多,请求量大。

  • 资源消耗大的应用:音视频处理,CV,机器学习,大模型,视频生成,云游戏等。

  • 延迟敏感型应用:广告推荐,商品推荐,搜索,云游戏等。

反微服务架构不适合哪些场景

  • 小应用:原本就消耗资源不多,做改造带来的收益不大

  • 没有成本压力的应用:对于高速发展的业务,如果利润完全可以覆盖计算成本,则做更精细化的改造不划算

  • 已经可以满负载运行的应用:例如大数据处理平台,用于训练的机器学习平台------ 这些平台的基础组件已经比较成熟,且可以充分的利用单机算力,那么也不值得做反微服务架构的改造。

案例:以反微服务架构模式实现的计算系统

假定我们以上述的架构图实现了一个计算系统,那么这个系统有这样一些特点:

  • 系统的部署单位是 SET

    • 每个 SET 尽可能的占满一台大型服务器的所有资源(或者是占满一个完整NUMA的大型容器)

    • 可以在多个服务器上部署多个 SET,因此整个系统仍然是分布式的,与微服务并不矛盾。

    • 每个 SET 内有多个服务进程,每个进程的功能类似于"微服务",只不过跨网络的通讯变成了进程间通讯。

  • 基本的运行单位是插件

    • 与微服务的划分方式类似:每个插件尽可能的功能单一、代码简短、容易理解、容易开发(稳定周期短,交接成本低,丢了不可惜)

    • 每个插件只完成简单的功能,然后组合起来完成复杂的功能

  • 调度进程来组织和编排插件

    • 类似于"容器编排",此架构中存在一个调度进程来组织各个插件

    • 调度进程根据事先设定的规则,为插件分配 CPU,并把插件进程绑定到具体的核上。调度进程尽可能地避免 CPU 争抢。

    • 因为每个插件的功能单一,完全能够做到对插件的热升级。可以做到完全无损地升级任意插件,而且对业务没有任何影响。

  • 使用共享内存来通讯

    • 共享内存是进程间通讯效率最高的方式

      • 相比于微服务的跨网络通讯,基于共享内存的通讯 ------ 不需要对数据序列化和反序列化,省去了网络通讯的开销,不会失败,不会超时
    • 调度进程分配一定容量的共享内存来实现一个 RingBufferQueue

    • 共享内存RingBufferQueue可以简单做到生产者消费者之间完全无锁

    • RingBufferQueue 中存在 eventfd,可以通过操作系统提供的高性能通讯方式来实现队满/队空的事件处理。

    • RingBufferQueue 还能作为一个无锁的无碎片的高性能的内存分配器:

      • 为请求/结果数据分配内存相当于在 RingBufferQueue 中入队

      • 消费请求/结果数据,相当于在 RingBufferQueue 中出队

  • 通过 UDP 或者多路 TCP 来解决处理管线中的错误通知

    • 如果某个步骤出错,出错节点可以直接发送错误信息给调用者,而不必在真个处理管线中层层返回直至到达用户。

核心概念

反微服务架构综合了如下概念:

  • 插件

    • 实现单个业务功能的单元作为插件出现

    • 插件的输入来自一个共享内存的 RingBufferQueue

    • 插件的输出存放到另一个共享内存的 RingBufferQueue

    • 插件会绑定到具体的 CPU 核

    • 插件可以在两次请求处理之间无缝升级(而不是停止整个单机的服务进行重启)

    • 插件之间独立工作,是无锁的

    • 插件可以以串行执行的方式处理业务请求,追求极致的单核性能(减轻管理并发的心智负担)

    • 不同的业务插件可以用不同的开发语言来实现:这样容易把大型的多角色的团队整合起来,并且避免产生难以开发和维护的多种语言混合开发的"融合怪"

    • 与单体应用不同:插件的模式独立开发独立运行,对于开发、发布、运行、故障隔离等都能很好的应用。如果只是简单的把很多功能开发在一个应用里,虽然性能提高了,但是发布和故障隔离都变得困难了。

    • 如果处理某个请求的过程中,插件进程崩溃了,那么拉起插件进程就可以继续处理。所有请求都在共享内存之中,不会因为插件崩溃而丢失请求。

  • 调度进程

    • 类似于微服务体系中的 k8s 这样的基础设施,调度进程负责反微服务架构中的插件的编排

    • 调度进程负责插件进程的启动和 graceful shutdown

    • 调度进程负责分配单物理机或者单容器内的 CPU 核

    • 调度进程把插件进程绑定到具体的 CPU 核上,规避插件之间的相互影响

  • 两级调度

    • 两级调度指调度进程对插件进程的调度,和操作系统对插件进程的调度

    • 对于大型业务,可以成为业务专家对进程的组织比操作系统的进程调度算法更加贴近实际

    • 对于计算量大的插件进程,总是使用独立的核来绑定独享;对于无法跑满单核的插件进程,再交给操作系统来调度

    • 与微服务架构不同:通过两级调度来尽可能充分使用单机资源,而避免"超卖"的模式来占满资源

    • 插件进程在请求队列为空的事件触发后,可以通过系统调用主动放弃自己的 CPU 时间片,让操作系统接管调度

  • 共享内存通讯

    • 相比于经典的 unix pipe,共享内存更加简单高效。特别当处理的数据类型是视频或者图片等富媒体数据时,使用共享内存能够尽可能地减少拷贝和系统调用。

    • 共享内存实现的 RingBufferQueue 实现了进程间的无锁处理,减少了加锁带来的开销。

    • 共享内存 RingBufferQueue 可以作为一种上下游之间的内存分配器,有利于减少拷贝

    • 以往的 RPC 通讯中的数据可以使用 FlatBuffer 一类的技术,内存结构等于序列化结构,可以规避掉结构化数据的拷贝开销。

    • 共享内存的头部有队列的长度、队头、队尾等信息,每个队列中的 item 是变长的。

    • 队列使用 eventfd 一类的机制来解决跨进程的事件通知

  • 无锁:并非分配很多物理线程来跑满 CPU,而是通过业务专家的规划来做到无锁

  • 绑核:对于计算密集型的应用,绑核且避免操作系统将进程挂起,可以提升计算密集型程序的性能,且其 CPU CACHE 的命中率也会提升。

  • PER CORE 架构

    • 程序的开发和运行回到如何关注单核性能,程序的开发难度降低,代码复杂度降低,程序的性能提升更加容易
  • 管道-过滤器架构模式

    • 与UNIX经典的管道-过滤器架构模式类似,一系列的插件及其共享内存组成了数据/业务逻辑处理的管道

    • 与posix体系中命令行程序的管道符不同,基于共享内存的模式性能更高拷贝更少,特别对于音视频等富媒体内容的处理更加显现出优势

  • Hadoop 的思想:迁移计算而不要迁移数据

    • 如同案例一节中讲到的,因为某个机器视觉的处理服务的输入是图片,所以通过 RPC 请求把图片内容发送到了服务上。对于富媒体或者海量的数据而言,应该学习Hadoop的理念,通过反微服务架构来把计算单元做成插件,让迁移计算变得容易,让数据的迁移变少来节约资源和减少延迟。
  • 非请求响应模式

    • 通过 UDP 通讯(或者多路 TCP 技术),可以在整个处理流程中,在任意一个节点回复结果给调用者
  • SET 化部署

    • 与微服务大量使用数量众多的小容器不同,反微服务架构的基本规格是单个物理机,或者单个 NUMA。

    • 一个 SET 能够包含一个大型应用的所有功能,或者某一类 SET 完成一个大型应用的高资源消耗的部分功能。

  • 数据并行和任务并行

    • 多核编程中,通常按照数据并行或者任务并行的模式来使用多核。反微服务架构兼顾了数据并行和任务并行 ------ 调度进程的每一条处理管线相当于任务并行,并且可以启动多条处理管线来做到数据并行。

小知识:per core 架构

"Per core" 架构是指处理器设计中将每一个核心(core)作为一个独立的处理单元来看待的架构理念。现代的多核处理器(multi-core processors)由多个核心组成,每个核心可以独立执行任务。以下是对"per core"架构的一些关键点解释:

1. 核心(Core)概念

• 核心是处理器中的一个独立运算单元,具有自己的算术逻辑单元(ALU)、寄存器和控制单元,可以独立执行指令。

• 多核架构的处理器可以在单一芯片中包含多个核心,使其能够同时处理多线程或多任务,提高整体性能。

2. "Per Core" 的含义

• 在"per core"架构中,处理器的性能优化或资源分配是以每个核心为单位进行的。

• 这通常涉及到每个核心独立拥有某些硬件资源,比如:

L1缓存:每个核心独立的一级缓存(存储经常使用的数据)。

指令流水线:每个核心独立的指令执行流水线。

• 某些资源(如L3缓存)可能是多个核心共享的。

3. 应用场景与优点

高并发性:每个核心可以独立处理任务,适合多线程程序和并行计算。

可靠性:如果一个核心出现问题,其他核心仍然可以正常工作。

灵活性:某些系统可以根据每个核心的负载情况动态调整资源分配。

功耗优化:某些"per core"架构支持根据每个核心的工作负载动态调整频率和电压(动态电压与频率调整,DVFS)。

4. 实际应用

现代CPU设计:Intel 和 AMD 的多核处理器通常采用"per core"架构。例如,AMD Ryzen 系列处理器的每个核心都有自己的 L1 和 L2 缓存。

高性能计算:服务器和数据中心的 CPU 设计也广泛使用 per core 架构来最大化吞吐量和效率。

总结来说,"per core"架构专注于优化每个核心的性能和独立性,从而支持更高效的并行任务处理和资源管理,是现代处理器设计的重要基础。

小知识:管道-过滤器架构模式

Unix 中的管道-过滤器架构模式 是一种强大的编程模式,专注于模块化设计和数据流的处理。它基于两个核心概念:管道(Pipe)和过滤器(Filter)

核心思想

  1. 过滤器(Filter):

• 过滤器是独立的程序,它接收输入数据、处理数据后生成输出。

• 过滤器的功能通常是单一的,比如排序、查找、替换等。

• 每个过滤器通过标准输入(stdin)读取数据,并将结果输出到标准输出(stdout)。

  1. 管道(Pipe):

• 管道是连接多个过滤器的机制,用于将一个程序的输出直接作为下一个程序的输入。

• 在 Unix 中,用管道符号(|)来实现,比如:command1 | command2。

• 数据在管道中流动,避免了中间临时文件的生成,提高效率。

主要特点

模块化: 每个过滤器可以独立运行并完成特定的功能。

组合性: 通过管道符将多个过滤器组合,形成更复杂的功能。

无状态性: 大多数过滤器是无状态的,只处理当前输入,无需关心数据的历史或未来。

灵活性: 可以动态组合不同的过滤器,适应多种任务需求。

示例

假设有一个包含文本行的文件 data.txt,目标是找到包含 "error" 的行,按字母顺序排序并去掉重复项:

cat data.txt | grep "error" | sort | uniq

• cat data.txt:读取文件内容。

• grep "error":过滤出包含 "error" 的行。

• sort:对结果按字母顺序排序。

• uniq:去掉重复的行。

优势

  1. 简单高效: 管道允许程序并行工作,处理效率高。

  2. 可扩展性: 新增功能只需编写新过滤器并加入管道。

  3. 代码复用: 过滤器是独立的,可以在不同场景重复使用。

适用场景

• 数据处理任务:如文本过滤、日志分析、统计计算。

• 自动化脚本:通过组合常见的 Unix 工具快速完成复杂操作。

• 大规模数据流处理:流式处理数据而无需全部加载到内存中。

Unix 的管道-过滤器架构模式因其简单而优雅,奠定了许多现代软件设计的基础,例如微服务架构和流处理系统。

小知识:FlatBuffer 数据序列化组件

FlatBuffers 是一种高效的跨平台序列化库,主要用于性能要求高的场景,如游戏开发、嵌入式系统、移动设备等。由 Google 开发,它的设计目标是提供快速的访问速度和低内存占用。

FlatBuffers 的核心特点:

  1. 零拷贝解析(Zero-Copy Parsing)

• FlatBuffers 允许直接访问序列化的数据,而无需解码或拷贝,从而显著提高性能。

  1. 紧凑的二进制格式

• 数据被压缩到最小体积以减少存储和传输开销,同时保证访问效率。

  1. 跨平台支持

• 支持多种语言(如 C++, Java, Python, Go, Rust 等)和多种平台(Windows、Linux、iOS、Android 等)。

  1. 支持向前和向后兼容

• FlatBuffers 可以方便地支持数据模式的版本管理,添加或删除字段不会破坏现有数据。

  1. 内存效率高

• 因为不需要解码和重组数据,内存使用非常高效。

使用场景:

  1. 游戏开发

• 游戏中实时性要求高,FlatBuffers 提供快速的数据加载和访问。

  1. 嵌入式系统

• 嵌入式设备资源有限,FlatBuffers 的低内存占用非常适合。

  1. 网络通信

• 在客户端和服务端之间传输高效紧凑的数据。

  1. 移动应用

• 在移动设备上解析大型数据(如 JSON)时效率较低,而 FlatBuffers 可以显著提升性能。

基本工作流程:

  1. 定义数据模式(Schema 文件)

• 通过 .fbs 文件定义数据结构。

protobuf 复制代码
table Monster {

    name: string;

    mana: int = 150;

}

root_type Monster;
  1. 生成代码

• 使用 flatc 编译器将 .fbs 文件生成目标语言的代码。

  1. 序列化与反序列化

• 使用生成的代码创建和解析数据。

  1. 数据访问

• 通过 FlatBuffers API 直接访问二进制格式的数据。

优点和缺点:

优点:

• 极高的性能和低内存占用。

• 易于扩展和维护。

• 多语言和跨平台支持。

缺点:

• 与 JSON 和 XML 相比,FlatBuffers 可读性较差。

• 初期学习和设置可能需要更多时间。

• 对于简单的应用场景,可能显得复杂。

FlatBuffers 的优势特别适合需要高性能序列化和反序列化的场景。

缺陷及其解决方案

反微服务架构有这样一些典型缺陷:

  • 如何精准的为插件分配核数

    • 假如一个应用是 10 个业务处理阶段,一共实现了 10 个对应的插件,那么每个插件应该分配多少核呢?

    • 整个业务处理管线中,可能因为某个环节未分配足够的资源而导致整个处理流程变慢

  • 进程间通讯的共享内存究竟应该设置多大

    • 如果共享内存过小,生产者就总会阻塞,而消费者就总会饥饿

    • 如果共享内存过大,相当于白白浪费内存

这两个问题的解决办法是 可观测性 + 动态运营

1 系统要上报立体化的监控数据,全方位监测这些问题的频率和影响,最最终能够分析出根因;

2 调度进程根据以上的数据,综合业务规律、专家经验和调度算法,在运行期动态调整。

典型的分析和处理手段有这些:

  • 经常发生队列满,则需要扩充CPU资源

  • 经常发生队列空,则适当减少CPU资源

  • 统计长期的高峰期的队列数据量,可以得到关于队列长度的最佳值

总之,并非反微服务架构比微服务架构更好,而是:综合服务质量和硬件成本,大型应用通过使用反微服务架构提升计算能效。即:总硬件成本除以总服务用户数的比率,随着用户规模的增长,这个比率在微服务架构上会越来越低,而在反微服务架构上会越来越高。