流计算与服务网格

流计算(Stream Processing)和服务网格(Service Mesh)本分别属于大数据和在线服务两个不同的领域,放在一起比较并不常见,但从本质而言两者都是分布式的运行时系统(Runtime)或基础设施,并提供专用的编程 API 或 SDK 供用户在其上运行任意代码。若不论效率,流计算提供的海量数据流式处理功能改为使用服务网格同样可以实现,反之某些服务网格提供的在线服务通过一定改造也可以运行在流计算引擎之上。当然,实现中大概没有人会将它们的使用场景搞混,但随着实时数据服务与在线服务的边界逐渐模糊,在某些数据密集型系统里,流计算和以服务网络为代表的微服务的确都可以作为技术选型的选项,比如事件驱动(Event Driven)应用。

因此,本文将对比流计算与以服务网络为代表的微服务基础设施之间的异同,希望能帮助大家从不一样的角度来理解两种当前发展迅速的技术。由于笔者并不是微服务或服务网格专家,不能保证所有理解正确,如内容有纰漏之处还请读者不吝指教。

服务网格简述

因为本文读者大多数是大数据背景,对服务网格可能并不熟悉,所以先简单介绍下服务网格的基本概念。

在微服务之前的单体系统(Monolithic)时代,业务逻辑集中在少数的进程中,服务进程的可拓展性、可用性、容错性尤为重要,而进程间的依赖关系并不复杂,服务间调用的流量也较少,因此工程师的关注点主要在服务本身,流量调度通常用简单的 Nginx 则可满足。但随着微服务的流行,系统不同模块被拆分为多个独立的微服务,每个服务承载的业务逻辑简化,但迅速膨胀的服务数量以及依赖关系却成为管理的瓶颈。单体系统的模块间调用演变成基于网络的服务调用,复杂性从单个服务内部转移到了多个服务的治理上,此时各类微服务框架应运而生。

在微服务框架发展早期,SDK 或开发框架等应用层的解决方案毫无疑问是主流,其中最为著名的便是 Spring Cloud[11]。Spring Cloud 对微服务提供了非常完善的支持,同时延续了 Spring 家族一贯的强大生态。然而在近几年容器技术逐渐成熟的背景下,以 Kubernetes 为代表的容器编排和与之配套的以 Istio 为代表的服务网格异军突起,凭借更轻量级和适用面更广的优势获得了更多的关注。

图1.服务网格架构

在服务编排之上,服务网格将服务中原本的非业务功能抽取出来作为基础设施,这些功能包括服务发现、健康检查、流量路由、负载均衡、容错重试、认证授权、故障注入和可观察性等。服务网格分为两个组件: 数据面板(Data Plane)和控制面板(Control Plane)。数据面板由一组边车代理(Sidecar Proxy)组成,而边车代理通常为系统自动在服务器(通常是 Kubernetes 的 Pod)中注入的通信服务器,以类似网络安全里中间人攻击的方式进行流量劫持,在服务无感知的情况下接管其对外的通信[3];而控制面板则负责边车代理的配置和管理,以操纵流量。

流计算与服务网格的对比

如本文开头所说,流计算与服务网格均提供通用目的的分布式的基础设施和编程 API/SDK,因此两者在大体功能上难免有相近之处,但由于各自面向场景的不同,在实现上各有侧重点。下文将详细讨论这里些不同背后的权衡取舍,即为什么要这么设计。

分布式运行时环境

流计算的分布式 Runtime 通常是主从架构,由计算引擎的 Master 和 Worker 进程组成(比如 Flink 的 JobManager 和 TaskManager、Spark 的 Driver 和 Executor)。Master 负责 Worker 管理和计算的调度,Worker 负责提供计算资源和具体的用户代码执行。Runtime 首先要调度资源,然后才可以接受请求调度用户代码,这样的设计被称为两级调度(Two-Level Scheduling),即资源和计算任务是分开调度的。

两级调度的 Runtime 架构是比较重的,优点是用户代码可以非常轻,通常以 UDF 的方式存在,提供类似 FaaS (Function As a Service)的体验,并且方便多次调度复用资源(例如 Flink 重试策略的 Local Recovery);缺点显然是首次运行初始化的时间较长,因为资源准备和计算任务调度是分开两步的。

相比之下,服务网格的 Runtime 环境则复杂一点,职责分散在容器编排系统系统(Kubernetes)、容器 Runtime(Docker/containerd)、服务网格的控制面板(Istio/Consul 等)和数据面板(Envoy)四者上。Kubernetes 和容器 Runtime 负责底层的服务编排和运行,而服务网格负责上层的流量调度。与流计算不同,服务网格的服务调度是一级调度,服务本身绑定了资源,比如 Kubernetes 的 Pod。这样的设计很符合在线场景,因为用户代码以容器镜像的方式打包并通过仓库分发,只需要容器 Runtime 即可独立运行,并不需要一个中心化的 Master 来调度任务。然而,不同于流计算的 Worker 属于同个用户并且任务的优先级基本相同,在线服务可能千差万别,可能来自不同用户,有不同调用模式和不同优先级,因此需要更加灵活和强大的服务调用管理(服务调用体现为网络流量,因此又称为流量管理)。为此,服务网格在服务编排的基础上再提供一层流量管理,为每个服务部署一个 Sidecar Proxy 组成数据面板进行流量劫持,并提供统一的控制面板进行细粒度管理。

图2. XaaS 对比

服务网格的 Runtime 相对而言比较轻,因为服务本身已经是比较重的可执行程序,而且对业务无入侵是最重要的特性之一。类比流计算提供的 FaaS,服务网格毫无疑问是提供 PaaS(见图2)。通常情况下,一个已经容器化部署的微服务架构系统几乎不用业务改造就可以迁移到服务网格之上(Istio 等主流控制面板一般提供对业务透明的代理注入)。这样设计的优点是对用户使用模式没有假设或者前置条件,因此非常灵活;缺点是架构比较复杂,管理比较分散,例如用户代码的分发和运行依赖容器 Runtime 及其仓库,服务编排依赖 Kubernetes,流量管理依赖服务网格,而不像流计算那样一站式的解决方案。

流量管理

上文简单提到流计算的流量管理简单,而服务网格的流量管理复杂,本节将深入分析两者的不同。

根据 2019 年 KubeCon 上微软联合 Linkerd 、Consul 等厂商发布的服务网格接口(Service Mesh Interfaces)[6],当前服务网格的核心功能分为以下四点,均以流量(Traffic)为核心。

  • 流量访问控制(Traffic Access Control): 配置 Pod 间的访问权限以及根据用户身份进行流量的路由。
  • 流量规范(Traffic Spec): 配置服务间的基于流量特征的路由。不同的协议有专用的一套资源和路由,例如 HTTPRouteGroup 为 HTTP 协议专用的资源,提基于 HTTP Path/Header/Method 等特征的路由。
  • 流量拆分(Traffic Split): 按照百分比拆分流量到不同的服务。除了用于服务实例间的负载均衡,也可用于金丝雀发布和 A/B 测试的场景。
  • 流量度量(Traffic Metrics): 通用的流量统计,比如 HTTP 请求的错误率、响应的延迟等。

就这四个功能点而言,流计算中的流量控制或缺少某个功能,或实现了基本功能但特性上比较少,而当然这些设计都是基于使用场景权衡之后的结果。

  • 不支持访问控制: 流计算通常以作业集群方式隔离不同用户的作业,而且不同作业间不需要直接网络通信,因此不需要细粒度的访问控制。
  • 简单的流量规范: 相比与服务网格支持多种应用层协议,流计算通常使用更高效的 Socket 传输数据,因此并没有协议特定的路由规则,而是简单地按照数据本身的属性来路由,比如用户点击数据中按照用户 ID 来进行 KeyByPartitionBy。值得注意的是,流计算的路由规则是隐式的,用户只要指定作业计算逻辑,系统会自动规划如何路由,或者用大数据领域的词称为 Shuffle。之所以有这样的差别,主要是通常一个在线服务的不同实例都是完全并行的(Embarrassingly Parallel)[7] ,即计算逻辑不需要通过分治来解决,单实例即可完成,所以流量路由是可选的(出于负载均衡、会话亲和性等目的),而不像流计算一样是必须的(分治的划分子问题和合并结果)。
  • 流量拆分: 流计算假设同个任务的不同实例都是相同的,包括代码版本、资源等,因此一般没有流量拆分的需求。如果一个算子有多个下游,通常以 RoudRobin 的方式来均等拆分流量。虽然用户也可以实现动态配置的 Partitioner 来定制流量拆分,但应用场景十分有限。
  • 流量度量: 流计算 Runtime 通常会提供通用的流量度量,包括延迟、QPS 等等,但请求错误率通常不会提供。不像在线业务可以按照请求隔离错误,流计算中所有的错误都会导致作业计算不准确,所以一个任意的小错误也会导致整个作业异常,可能整个集群的 Worker 都要从某个快照开始重新计算。

数据存储架构

作为微服务架构的延伸,服务网格通常应用 Datebase Per Service 的数据库架构分散管理数据,即每个微服务有独立的数据库,并且只允许直接访问自己的数据库。不同服务的数据库可以放在相同或不同的数据管理系统实例上,只要在逻辑上是分开访问即可。这样的目的是给数据划分明确的边界,避免微服务间的耦合,不过也带来一定的性能损失。

这样的数据存储架构其实跟流计算的状态持久化十分相似。以 Flink 为例,在以 Runtime 集群为单位的全局状态存储后端(StateBackend)背后,Flink 的每个算子有独立的本地状态存储后端,不同算子间的状态完全隔离。在此基础上,Flink 再提供算子状态的数据分区(即 KeyedState),按照业务特征来隔离物理存储,类似 MySQL 的分库分表。如果并行度发生变化,Flink 可以统一地对数据流和状态进行重新的分区。

分布式事务

作为使用多个存储系统的分布式系统,流计算与服务网格均不得不面对分布式事务的难题。实现分布式事务通常有两种思路:一是把分布式事务看作横跨多个服务的一个大事务,典型的实现算法是 2PC(两阶段提交)和 Paxos(BTW,事实上 2PC 也可以被看作是 Paxos 算法容忍零错误时的特例[8]);二是把分布式事务看作一系列本地(子)事物并分步执行,这种方案被称为 SAGA 模式[9]。

2PC 引入协调者(Coordinator)来掌控所有事务的参与者(Participants)。事务开始进入 Pre-Commit 阶段,协调者节点向所有参与者节点询问是否可提交事务,参与者做提交准备(比如写 WAL)并根据结果决定是否同意。第二阶段为 Commit 阶段,协调者根据参与者反馈作出决策。若所有参与者均同意,则进入下个阶段;否则终止(Abort)事务,协调者通知参与者进行回滚操作。

2PC 实现简单且提供较强的事务保证,因此在业界应用广泛,然而也有一定的局限性:

  • 2PC 事务的提交和回滚依赖于参与者本地事务的提交和回滚,但是 NoSQL 或消息队列等存储系统不一定支持事务。
  • 2PC 事物的进度依赖于协调者单点,如果协调者出现故障,整个事务就会卡住,直到协调者恢复。虽然有 2PC 的改进版本可以通过 Peer 间通信等方式缓解该问题,但又会出现更多衍生问题。
  • 2PC 是阻塞的,性能有明显的木桶效应,总体性能取决于最慢的一个参与者。

相比之下,SAGA 模式以牺牲一定程度的原子性和隔离性为代价,降低了分布式事务的门槛。SAGA 原本的目的是避免长时间运行的大事务锁定数据库资源太久,导致事务冲突频繁甚至死锁,因此用多个子事务的方式来分步执行,降低每次加锁的范围。因为在 SAGA 事务未结束前,子事务便会提交,因此 SAGA 要求为每个子事务设计相应的补偿事物(Compensatmg Transaction),用于在 SAGA 事务终止时消除已提交的事务的影响。比如对于新建订单的事务,对应的补偿事务便是取消该订单。

从实现的架构而言,SAGA 可以分为中心化的编排(Orchestration)模式和去中心化的协同(Choreography)模式。

图3. 编排模式 SAGA

编排模式与 2PC 一样引入一个中心化的编排者(Orchestrator)来负责 SAGA 事务的决策。编排者与所有服务通信,依照 SAGA 事务的定义按序触发本地事务或异常恢复。

图4. 协同模式 SAGA

而协同模式下,SAGA 事务的决策被分散在各个服务上,每个服务通过消息队列直接与其他服务通信,在成功完成当前步骤的 SAGA 事务后,通过事件通知后序步骤的服务执行下一步,或在当前步骤失败后,通过事件通知前序服务的服务回滚前一步。因为服务间通过事件消息协同,协同模式的 SAGA 又被称为事件驱动(Event Driven)。如果对协同模式的 SAGA 进一步放宽约束,允许子事务并行执行,那么还可以细分出一种称为 Parallel Pipelines 的变体。

RedHat 的一篇博客[10]对上述几种分布式事务进行了很好的总结,借用其中两张图来概括选型上的基本考量。

图5. 分布式事务特性对比

图6. 分布式事务的一致性与水平拓展能力

对于流计算而言,长时间的大事务并不是问题:流计算 Source 的数据通常是不可变的(Append-Only,不支持更新),比如 Kafka/Pulsar 等消息队列或 CDC 数据流,所以不需要锁;而 Sink 的数据存储要么也是不可变的,要么是专门准备给流计算写入的,与其他业务完全隔离,所以很少出现与其他用户的事务冲突的问题。显然,在这样的背景下,提供较强一致性的 2PC 会是不二的选择。事实上,笔者所接触过的流计算引擎都使用 2PC 实现分布式事务,包括 Flink、Spark Structured Streaming、Kafka Streams。

而对微服务而言,由于无法假定服务的业务使用模式,比如对一致性要求如何、需要何种级别的事务隔离、数据库是否支持事务,所以一般把方案暴露给用户自由选择。常见支持的选项有 XA/TCC2PC 的实现和编排模式或协同模式的 SAGA。在早期微服务时代,Spring Cloud 等云原生基础设施或 Seate[12] 等中间件都提供分布式事务的支持,但现在进入服务网格崛起的后微服务时代,Istio 等头部项目却未对分布式事务有所规划,原因也很简单: 分布式事务与业务联系紧密,离不开应用层的支持,但服务网格对业务无入侵的特性让其也失去了在应用层做事的空间。

编程模型

本文开头有说到,流计算和服务网格有一些共性,但很难让人将它们想到一起,其中很大原因可以归咎于两者的编程模型非常不同。但试想如果流计算提供与 Web 开发框架类似的编程接口,每个函数注册好服务地址,接受请求并处理返回结果给调用者,是不是也一定程度上能达到微服务的效果呢?实际上,业界的确有这样的尝试,例如基于 Flink 的 Serverless 框架 StateFun[13]。

StateFun 在 Flink 之上提供更接近在线场景的面向消息的 API 函数,而且允许任意函数间通过注册的地址相互发消息。这意味着 StateFun 不再需要在编译期构建一个静态的 DAG(有向无环图),打破了流计算由系统控制数据流的传统。这样的编程模型可以更好地适应在线服务不同请求差异大和相互之间基本隔离的特性。有趣的是,若不考虑 Master 节点,StateFun 集群基本是微服务架构(见下图),与传统的微服务框架 Spring Cloud 有意外的相似之处。

图7. StateFun 架构

不过值得注意的是,StateFun 受限于底层 Flink Runtime 为吞吐量优化的异步网络传输,因此服务间的调用接口也只有异步的。相比之下,服务网格完全不对应用层有假设,使用同步的 HTTP/REST 服务、通过消息队列解耦的异步 Event Driven 服务或其他类型的服务都完全取决于用户。

容错机制

上文流量管理部分谈到流计算不提供错误率,因为通常所有的数据都属于一个业务单元(即作业),如果出现异常就会导致整个作业的计算结果不准确,所以结果通常只有正常和失败两种。相对地,在线服务的业务单元通常在请求级别,一个请求错误并不影响其他请求,所以能按比率进行统计。错误造成的不同后果导致流计算和服务网格有非常不同的容错机制。

流计算通过分布式事务定时进行 Checkpoint 快照,在默认情况下,如果某个节点计算出现异常(包括机器故障等系统原因或代码 bug 等业务原因),与之有依赖关系的上下游节点全部需要进行重启恢复,将状态回滚至最近一个成功的快照并进行重试。当回滚发生时,在内存中正被处理的数据会被丢弃掉,然后在作业重试后从快照中读取(比如 Spark)或者重新从数据源读取(比如 Flink)。

在服务网格中,容错通常是在请求级别的,不会涉及其他的请求和服务实例。对于一个提供 HTTP/REST 这样同步接口的服务,如果某个实例出现异常,通常系统会将流量自动切换到相同服务的其他实例,并自动进行请求的重试。对于 Event Driven 的服务,Kafka/Pulsar 等消息队列通常提供多次消费的持久化能力和负载均衡的消费模式,在某个实例异常时,其他实例可以自动接管其未处理完的事件。

顺带一提,StateFun 尽管面向 Event Driven 的微服务场景,但容错机制依然沿用了 Flink 的 Checkpoint 快照方式,导致某个服务的异常会引起全部服务的重启(官方称之为“回滚整个世界”)。初看下这是很明显的 overkilled,但因为 Flink 本身节点间的通信并没有使用外部的提供持久化能力的消息队列,要让上游节点重发事件不得不也回滚上游的服务,直到 Source 节点从外部重读消息,所以 StateFun 的做法估计也是不得已而为之。

总结

本文主要从分布式运行时环境、数据存储架构、分布式事务、编程模型和容错机制几个角度去对比流计算和服务网格两项不同领域的分布式基础设施。实际上,受限于篇幅和笔者时间精力,还有更多角度未纳入讨论,比如反压熔断、进出(南北)流量等,但也足以体现两者设计差异背后的考量

由于流计算面向的场景主要是外部依赖较少、业务类型比较确定的大数据计算,因此提供更“重”的基础设施(比如有统一数据存储和分布式事务的运行环境)和更新”轻”的编程 API;而服务网格面向的是差异性很大的在线服务,因此需要提供更”轻”更灵活的 API,比如无入侵的流量劫持,但同时也导致基础设施只能比较”轻”,无法做分布式事务这样比较深度的支持。

参考

  1. What’s a service mesh? And why do I need one?
  2. Service mesh data plane vs. control plane
  3. Istio Architecture
  4. [周志明.凤凰架构[M]北京:机械工业出版社,2021]
  5. Kubernetes Service Mesh: A Comparison of Istio, Linkerd, and Consul
  6. Service Mesh Interface Spec
  7. Embarrassingly parallel
  8. Consensus on Transaction Commit
  9. Pattern: Saga
  10. Distributed transaction patterns for microservices compared
  11. Spring Cloud
  12. Seata
  13. Flink Stateful Functions