type
status
date
slug
summary
tags
category
icon
password
过去十年来,分布式系统取得了巨大进步,但我们编程的方式却几乎没有根本性的改进。虽然我们有时可以抽象掉分布式特性(如Spark、Redis等),但开发人员仍然在并发性、容错性和版本控制等挑战面前挣扎。
有很多人(和初创公司)正在解决这个问题。但几乎所有人都专注于开发工具,帮助分析用经典(顺序)编程语言编写的分布式系统。像Jepsen和Antithesis这样的工具已经提升了验证正确性和容错性的技术水平,但工具无法与那些能原生展现基本概念的编程模型相匹敌。我们已经在Rust中看到了这一点,它提供的内存安全保证远比带有AddressSanitizer的C++更丰富。
如果你在网上搜索,会发现大量用于编写分布式代码的框架。在这篇博文中,我将论证它们只是对三种固定的底层范式提供权宜之计和语法糖:外部分布式(external-distribution)、静态位置(static-location)和任意位置(arbitrary-location)。我们仍然缺少一个适合分布式系统的原生编程模型。我们将探讨这些范式,然后思考真正的分布式编程模型还缺少什么。
外部分布式架构是绝大多数"分布式"系统的样子。在这种模型中,软件被编写为运行在具有顺序语义的状态管理系统上的顺序逻辑:
- 无状态服务配合分布式数据库(Aurora DSQL, Cockroach)
- 使用gossip协议传播CRDT状态的服务(Ditto, ElectricSQL, Redis Enterprise)¹¹。这可能令人惊讶。CRDT常被宣传为分布式系统的万能解决方案,但另一种观点是它们只是加速了分布式事务。运行在CRDT上的软件仍然是顺序的。
- 工作流和步骤函数
这些架构易于编写软件,因为底层的分布式特性对开发者完全隐藏²²。至少理论上是这样。可序列化通常不是默认选项(快照隔离才是),所以并发问题有时仍会暴露给开发者!虽然这种架构会形成一个分布式系统,但我们并没有一个分布式编程模型。
开发者几乎不需要考虑容错性或并发问题(除了确保为CRDT选择正确的一致性级别)。因此,开发者选择这种方式是很明显的,因为它在干净的顺序语义下隐藏了分布式的混乱。但这样做有明显的代价:性能和可扩展性。
序列化所有内容本质上相当于模拟一个非分布式系统,但使用的是昂贵的协调协议。数据库成为系统中的单点故障;你要么希望us-east-1区域不会宕机,要么切换到像Cockroach这样的多写入系统,但后者也有自己的性能影响。许多应用程序的规模足够小,可以容忍这种情况,但你不会想用这种方式实现一个计数器。
静态位置架构是编写分布式代码的经典方式。你组合几个单元——每个单元都被编写为**本地(单机)**代码,通过异步网络调用与其他机器通信:
- 使用API调用进行通信的服务,可能使用异步/等待机制(gRPC, REST)
- Actors(Akka, Ray, Orleans)
- Services polling and pushing to a shared pub/sub 服务轮询并推送到共享的发布/订阅系统(Kafka)
这些架构给我们提供了完全的、低级别的控制。我们编写大量的顺序的、单机软件,并加入网络调用。这对性能和容错性很有利,因为我们可以控制什么在哪里以及何时运行。
但是联网单元之间的边界是僵化且不透明的。开发者必须对如何拆分应用程序做出单向决策。这些决策对正确性有广泛影响;重试和消息排序由发送方控制,接收方对此一无所知。此外,语言和工具对单元如何组合的洞察有限。跳转到定义通常不可用,跨服务的序列化不匹配很容易潜入系统。
最重要的是,这种分布式系统的方法从根本上消除了语义上的共同定位和模块化。在顺序代码中,一个接一个发生的事情在文本上是一个接一个放置的,函数调用封装了完整的算法。但使用静态位置架构,开发者被迫在机器边界上而非语义边界上对代码进行模块化。在这些架构中,根本没有办法将分布式算法封装为单一、统一的语义单元。
尽管静态位置架构为开发者提供了对系统最低级别的控制,但在实践中,如果没有分布式系统专业知识,很难稳健地实现它们。实现和执行之间存在根本性的不匹配:静态位置软件被编写为单机代码,但系统的正确性需要对整个机器集群进行推理。构建此类系统的团队常常生活在对并发错误和故障的恐惧中,导致大量关键到无法触碰的遗留代码。
任意位置架构是大多数“现代”分布式系统方法的基础。这些架构通过让我们像在单台机器上运行代码一样编写代码,从而简化了分布式系统,但在运行时,软件会动态地在多台机器上执行。
- Distributed SQL Engines
- MapReduce Frameworks (Hadoop, Spark)
- Stream Processing (Flink, Spark Streaming, Storm)
- Durable Execution (Temporal, DBOS, Azure Durable Functions)
这些架构优雅地解决了协同定位问题,因为在语言/API 中没有明确的网络边界来拆分代码。然而,这种简化带来了显著的代价:控制。通过让运行时决定代码如何分布,我们失去了对应用程序如何扩展、故障域的位置以及何时通过网络发送数据的控制能力。
就像外部分发模型一样,任意位置架构通常也伴随有性能开销。持久化执行系统通常会在每一步之间将其状态快照存储到持久化存储中,除非在某些优化的情况下,当步骤是纯粹的、确定性的函数时。流处理系统可能会动态地持久化数据,并且可以自由地在步骤之间引入异步性。SQL 用户则完全依赖于查询优化器,最好的情况是他们只能对分发决策提供“提示”。
我们通常需要对单个逻辑放置位置进行低级别控制,以确保性能和正确性。以实现两阶段提交协议为例,该协议为领导者和工作者定义了明确的、不对称的角色,其中领导者广播提案,工作者则确认这些提案。为了正确实现这样的协议,我们需要明确地将特定的逻辑分配给这些角色,因为仲裁必须在单个领导者上确定,并且每个工作者必须原子性地决定接受或拒绝一个提案。在没有引入不必要的网络和协调开销的情况下,根本不可能在任意位置架构中实现这样的协议。
我们可以从这些系统中学到什么?
尽管我们讨论过的编程模型各自都有一些局限性,但它们也展示了一个适用于分布式系统的原生编程模型应当支持的理想特性。那么,我们可以从每个模型中学到什么呢?
我将跳过外部分发模型,因为正如我们所讨论的,它并不是真正意义上的分布式。对于那些可以容忍该模型的性能和语义限制的应用程序,这是一个不错的选择。但对于一个通用的分布式编程模型,我们不能让开发者看不见网络和并发的细节。
静态位置模型似乎是一个合适的起点,因为它至少能够表达我们可能想要实现的所有类型的分布式系统,即使该编程模型在推理分布式系统的分发上并未提供太多帮助。我们错过了任意位置模型所提供的两个特性:
- 将跨多台机器的逻辑写在一起,放在一个函数中
- 揭示分布式行为的语义信息,如消息重排序、重试和跨网络边界的序列化格式
这些点各自都有一个“对立面”,是我们不希望放弃的东西:
- 对逻辑在机器上的放置的显式控制,能够执行本地的原子计算
- 对故障容错保证和网络语义的丰富选择,而不让语言将我们锁定在全局协调和恢复协议中
是时候提出一个原生的编程模型了——如果你愿意的话,就像“分布式系统的Rust”,它能够解决所有这些问题。