NEE's Blog

函数式程序员对系统的误解

February 10, 2026

本文翻译自 What Functional Programmers Get Wrong About Systems,原载于 Hacker News。

引言

静态类型、代数数据类型、让非法状态无法表示——函数式编程传统为程序推理开发了非凡的工具。我花了十多年时间专业编写 Haskell,我深信这些工具的价值。

但这些工具的高效性也带来了一种特殊的弱点。我们有时会将程序推理误认为是系统推理。这不是同一活动,让你擅长其中之一的直觉不会自动迁移到另一个。

这不是函数式编程独有的问题。每个编程社区都把”程序”作为主要研究对象。但 FP 从业者处于独特位置:我们用于局部正确性的工具足够强大,以至于会产生对系统级属性的不合理自信。类型检查器对它检查的内容是诚实的。问题始于我们忘记其管辖范围结束时。

你的单体应用是一个分布式系统

在讨论类型之前,我想建立一个反复争论的观点:每个生产系统都是分布式系统,包括你的单体应用。

如果你有一个多服务器的 Web 应用,你就有分布式系统。如果你有后台任务 workers,你就有分布式系统。如果你有 cron 任务,你就有分布式系统。如果你与 Stripe 通信、通过 SendGrid 发送邮件、在 Redis 中排队、写入 Postgres 副本,那么(我很遗憾地通知你)你正在运营一个分布式系统。”单体”一词描述的是你的部署产物,而不是你的运行时拓扑。

这很重要,因为生产中有趣的正确性问题几乎总是系统性的而非局部的。它们存在于运行不同版本代码的组件之间的交互中,或在对数据库状态的不同假设中,或在对已在别处部分成功的操作的重试中。这些问题不是任何单程序分析能捕获的,无论你的类型系统多么复杂。

正确性的单元是部署集合

核心论断是:生产中正确性的单元不是程序,而是部署集合

当 Haskell 编译器告诉你程序类型正确时,它验证了单个产物的属性。一个二进制文件、一个版本、类型和逻辑的一个连贯快照。这确实有价值。但在生产中,该产物只是一个集合的成员。在任何时刻,你的系统可能运行着:

  • 当前部署,服务新请求
  • 上一个部署,仍在排空连接
  • 落后一、二或三个部署的后台任务 workers
  • schema 已迁移的数据库
  • Kafka 主题或作业队列中的序列化数据,由不再存在的代码版本写入
  • 符合它们的 schema 而非你的 schema 的第三方 webhook 处理器

正确性是整个集合同时具有的属性。类型检查器验证了一个元素。它没有告诉你元素之间的交互。而交互正是 bug 存在的地方。

多个版本总是同时运行

编程语言文化将程序视为单一事物。你编写它、编译它、部署它。旧版本停止存在,新版本取而代之。类型系统在这个模型上操作。模块系统在这个模型上操作。你对”代码”的心智模型在这个模型上操作。

在生产中,这是一个礼貌的虚构。

在任何非平凡的部署中,你的代码的多个版本同时运行。滚动部署意味着在某个窗口(秒、分钟、有时小时)内,旧版本和新版本都活跃,服务相同用户,彼此快乐地不知情。蓝绿部署意味着两者都存在,流量可能路由到任一。金丝雀部署意味着两者现在同时服务真实用户。

考虑一个 sum type:

data PaymentStatus
  = Pending
  | Completed
  | Failed

你发布一个添加构造函数的新版本:

data PaymentStatus
  = Pending
  | Completed
  | Failed
  | Refunded

在接下来的几分钟内,旧 workers 将收到包含 Refunded 的消息或数据库行,它们不知道该怎么做。如果你序列化为 JSON,旧代码会看到无法识别的字符串并抛出解析错误。类型检查器没有警告你,因为类型检查器一次只看到一个版本。

这就是为什么 Protocol Buffers 在线上使用数字字段标签而非字段名,为什么 Avro 在反序列化时需要写入者和读取者的 schema。这些不是怪癖,它们是对生产者和消费者将处于不同版本这一基本现实的工程响应。

迁移棘轮

如果多个版本并发运行是正常情况,代码和数据之间的关系才是真正危险的。

你可以相对容易地回滚代码:重新部署旧产物。你不能轻易回滚 ALTER TABLE ADD COLUMN,绝对不能回滚 DROP COLUMN。数据层在棘轮上向前移动。代码层似乎双向移动,但回滚创建了一个组合(旧代码、新 schema),该组合从未作为仓库中的提交存在。没有人编译它。没有人测试它。没有类型检查器见过它。

我是”始终向前滚动”的派系成员,这种不对称性正是原因。回滚给你安全网的感觉,但它产生的状态是你从未验证过甚至可能从未考虑过的。最好通过修复向前推进问题,而不是退回到未经测试的配置。

expand-and-contract 模式(将新列添加为可空、部署写入两者的代码、回填、部署从新列读取的代码、删除旧列)至少需要四次部署才能完成感觉像是一个更改的事情。这不仅是一个配方,它是一种只占据经过有意构建和测试状态的纪律。

消息队列是版本时间胶囊

数据库至少允许你就地迁移数据。消息队列更有耐心。

并非所有队列都以相同方式耐心。RabbitMQ 和 Sidekiq 通常在秒或分钟内处理消息(有时比你在监控面板之间切换还快);版本窗口很窄,大约是滚动部署的持续时间。如果你的消费者落后一个部署十分钟,这就是你的兼容性义务的范围。这些系统是宽容的,因为消息不会逗留。

Kafka 是不同的动物。具有 30 天保留策略的 Kafka 主题包含来自 30 天部署的消息。如果你每天部署,那就是你的序列化格式的 30 个版本,共存于同一主题。今天启动的消费者需要能够反序列化所有版本。如果你将 Kafka 用作具有无限保留的事件存储(一些团队这样做),你可能有来自几年前的消息,由你仓库的任何分支中都不再存在的代码写入,由不再在公司工作的工程师编写。消息持久存在。它们非常有耐心。

Event Sourcing:版本问题作为一种生活方式

如果消息队列是版本时间胶囊,event sourcing 系统已经将版本问题提升为一等原则。

Event sourcing 的承诺吸引着与吸引人们到函数式编程相同的本能:你的应用状态不是数据库中的可变事物,它是有序不可变事件序列的 left fold 的结果。事件是事实。它们发生了。你可以通过投影函数重放它们来派生任何数据视图。状态是历史的纯函数。

这是一个美丽的想法,它有一个可怕的推论:你写过的每个事件必须永远可被你当前的代码解释

在传统系统中,你可以就地迁移数据。ALTER TABLE,回填,继续。旧表示消失了。在 event-sourced 系统中,旧表示就是重点。事件日志按定义是仅追加的。你不能重写 2019 年的 PaymentInitiated 事件以匹配你的 2026 schema,因为那会撒谎说发生了什么。日志的不可变性是整个价值主张。这意味着你的系统曾经使用的每个事件 schema 的每个版本都是代码库义务的永久部分,无论是否有人记得编写它。

标准响应是upcasting:在读取时将旧事件转换当前 schema。当你的投影遇到 v1 PaymentInitiated 事件时,它通过 upcaster 运行它,产生你当前代码可以处理的东西。这有效,它也是义务的安静积累,只会增长。每个新事件 schema 版本需要新的 upcaster。Upcasters 组合(v1→v2→v3)但链条只会变长。五年后,你的投影管道可能花更多时间 upcasting 旧事件而不是处理新事件。过去变得更重。现在必须承载它。

语义漂移:类型没变,但意义变了

我在 event sourcing 上下文中提到过这一点,但它值得自己的处理,因为它是没有工具捕获的版本问题版本。

到目前为止我描述的所有内容(结构 schema 演化、sum type 更改、序列化兼容性)至少是可检测的。添加或删除了字段。出现了构造函数。类型改变了。Schema 比较工具可以看到这些。

更阴险的问题是当类型保持相同但意义改变时。

考虑交易记录上名为 amount 的字段,类型为 Int。在版本 1 中,它代表美分。在版本 2 中,有人决定它应该代表美元。Schema 没有改变。类型没有改变。没有 diff 工具、没有 schema registry、没有 linter 会标记这一点。但每个跨越版本边界的消费者都会静默产生错误答案,错误 100 倍,这是让会计师非常不满意、让审计员极其愤怒的那种错误。

这不是一个人为的例子。语义漂移以更微妙的形式不断发生:布尔字段的意义从”用户选择加入”转变为”用户没有选择退出”(相同的值,不同的默认假设)。状态枚举,其中 Completed 开始意味着”支付捕获”,但随着业务流程演变逐渐意味着”支付授权”。时间戳一直是 UTC,直到一个服务开始写入本地时间。(总有一个服务。)Schema 在版本之间相同。数据静默地、灾难性地不兼容。

没有类型系统捕获这一点,我不认为任何类型系统能够在完全一般性中捕获这一点;它需要编码每个字段的预期语义,而不仅仅是其表示。Liquid Haskell 可以表示细化类型,如 {v : Amount | v > 0},这将捕获值的符号但不是单位。Idris 或 Agda 中的依赖类型原则上可以跟踪单位通过计算,但仅当有人编写证明义务时,并且仅在单个版本内。

确实有帮助的是将语义更改视为与结构更改一样严肃。记录数据契约的语义。使用编码单位的新类型(Cents vs Dollars,而不是 Int)。当你改变字段的解释时,将其视为迁移,即使 schema 没有改变,因为在版本边界,它迁移。

Parse, don’t validate:跨越版本

Alexis King 的”Parse, Don’t Validate”是最近 FP 论述中最具影响力的想法之一,这是理所当然的。不是在运行时检查数据属性并希望检查在使用前执行,而是将非结构化输入解析为证明不变量成立的结构化类型。将检查推到边界一次,让类型向前保证保证。

这是正确的。它也是不完整的,因为它只考虑一个边界:单个版本中程序的边缘。

在生产中,数据不断跨越更难的边界:版本之间的边界。由部署 N 序列化的消息由部署 N+1 反序列化。由具有三个构造函数的代码编写的数据库行由具有四个构造函数的代码读取。由今天的 schema 形成的 GraphQL 响应由运行上个月代码的移动客户端消费。在这些边界,你正在解析根据别人的类型结构化的数据;可能不再存在于代码库中的类型。

“parse, don’t validate”的纪律应该扩展到这个边界。在某些领域,它已经这样做了。

Schema registry 是应用于版本边界的”parse, don’t validate”。在 Confluent 模型中,Kafka 主题上的每条消息都标记有 schema ID。生产者在写入之前注册其 schema。消费者获取自己的 schema 和写入者的 schema,反序列化器使用两者来解析数据:用默认值解析缺失字段,跳过未知字段,在不兼容的更改上大声失败。你不是反序列化然后检查数据是否看起来正确。你通过一对 schema 解析,解析本身保证兼容性。

GraphQL 更进一步。Schema API 契约,它可以通过设计内省。像 Apollo GraphOS 这样的工具针对你的 schema 运行操作检查:在你部署更改之前,它们将其与从生产流量收集的真实客户端查询进行比较,并准确告诉你哪些客户端会中断。GraphQL Hive,一个开源 schema registry,为联合 schema 执行组合检查,并且可以执行有条件的重大更改检测,只有在该字段实际出现在收集的操作中时才将字段移除标记为重大。

实践中的意义

这一切研究在凌晨 3 点你的部署失败、PagerDuty 尽最大努力毁掉你的夜晚时不会帮助你。我知道。但它指向我们应该如何构建系统的转变,无论语言如何。

为集合设计,而不是快照。 在为域类型建模时,不仅要问”这个类型正确吗?”还要问”这个类型可以演化吗?”添加构造函数会破坏旧消费者的反序列化吗?删除字段会导致上一个部署崩溃吗?在每个版本边界应用”parse, don’t validate”,而不仅仅是在单个程序的边缘。

使边界显式和机器可检查。 注册你的 schema。在 CI 中差异你的 GraphQL 类型。Lint 你的 SQL 迁移。每个边界产物(API schema、消息格式、数据库 schema、工作流定义)应该版本化并作为部署管道的一部分检查兼容性。

投资于”不纯外壳”。 处理重试、超时、连接管理、断路、优雅关闭和错误恢复的系统部分是你的系统与现实相遇的地方。在结构良好的 FP 应用程序中,这个逻辑存在于外部”不纯”层,通常不如纯核心获得设计关注。但它是确定你的系统是否优雅地处理版本转换或倒下的代码。它值得我们带到域建模的同样严格性。

构建部署时兼容性检查。 单个工具存在:Atlas 用于迁移安全,Buf 用于 Protobuf 兼容性,Apollo GraphOS 或 GraphQL Hive 用于 schema 检查,Temporal 用于工作流版本控制。缺少的是编排;管道中的单个步骤,查询运行的内容,检查每个边界的兼容性,并给你是或否。这今天可以用现成的部件实现。

结语

FP 社区花了数十年构建工具,以非凡的精度对程序进行推理。这项工作非常有价值、迷人,每天都让我成为更快乐的程序员。每个语言社区都会受益于像我们一样认真对待局部正确性。我的建议不是放弃它或最小化其价值,而是将我们的目光提升到许多最难问题实际存在的水平,并诚实地注意到这些问题中有多少看起来相同,无论你用什么语言编写程序。

缺少的是综合。没有人构建一个统一的工具,接受你的类型定义、序列化格式、迁移历史和部署拓扑,并告诉你给定的部署序列是否安全。公平地说,考虑到问题的广度,我不认为你真的可以。

但我们不必等待理论或一些闪亮的初创公司为我们修复它。”Parse, don’t validate”给了我们正确的直觉;我们只需要在每个版本边界应用它。检查 schema 兼容性、diff API 契约和 lint 数据库迁移的工具今天存在。缺少的部分是将它们连接到你的部署系统,这样你可以回答真正重要的问题:”鉴于现在正在运行的一切,部署这个安全吗?”

程序不是正确性的单元。部署集合是。类型检查器的管辖范围结束于单个产物的边界,生产是产物的集合,每个都是不同年代,每个都对编译它的类型忠实,每个都可能与其邻居不一致。推理该集合的工具比你想象的更近。它们只是你没有触及的工具。

comments powered by Disqus