Hotdry.

Article

百万行 Haskell 生产工程实践:Mercury 金融科技公司的类型化运维哲学

解析 Mercury 如何在百万行级 Haskell 代码库中实现生产级可靠性,涉及类型系统运维化、工作流引擎集成与可观测性设计。

2026-05-03systems

金融科技公司 Mercury 维护着一个约两百万行的 Haskell 代码库,服务超过三十万家企业,2025 年处理了 2480 亿美元的交易量,年化收入达 6.5 亿美元。这个数字本身已经足够令人好奇:当一家金融基础设施公司选择 Haskell 作为核心语言,并让其快速成长的工程团队(其中大多数人在入职前从未写过一行 Haskell)在这个规模的代码库上工作时,支撑这种选择的究竟是怎样的工程哲学?本文深入解析 Mercury 的生产工程实践,从可靠性理念、类型系统边界设计到可观测性基础设施,为大规模 Haskell 生产部署提供可落地的参考框架。

可靠性哲学:吸收变化而非仅防止失败

传统可靠性工程往往聚焦于预防失败:列举所有可能出错的情况,添加检查,写针对每个坏情况的测试,追踪漏洞。这种方法固然必要,但 Mercury 的稳定性工程师团队提出了一个关键洞见:当团队完全围绕预防失败进行组织时,会产生一种特定的盲视 —— 他们变得非常擅长列举东西是如何坏的,却非常不擅长理解东西为什么通常能正常工作。

Mercury 采纳的思路来自弹性工程(resilience engineering):一个系统能够可靠运行,是因为它能够吸收变化 —— 它能优雅降级,它的运维人员能够理解并调整它,架构让正确的事情变得容易、错误的事情变得困难。可靠性不仅仅是 absence of failure,更是 presence of adaptive capacity,是一个系统在现实继续其令人遗憾的拒绝静止的习惯时保持运作的能力。

这个理念直接影响了技术决策。当一个公司以每年两倍的速度增长时,一半的同事将永远有不到一年的经验。一年后,一半的同事仍然有不到一年的经验。对于非常成功的公司,这种状态永远不会停止。因此,Mercury 提出的核心操作问题是:新加入团队的工程师能否阅读这个模块并理解它做什么?如果数据库变慢,这个服务是降级还是崩溃并波及邻居?如果有人误用接口,是编译器告诉他们,还是我们在值班电话响起时才发现问题?如果没有这些问题的答案,一场未来的事故正在悄然酝酿。

这正是 Mercury 将类型系统视为运维辅助工具而非仅仅是正确性证明的原因。类型系统的价值不仅仅在于它排除某些类别的错误(尽管它确实做到了),更在于它以某种形式编码了机构知识,这种形式在最初编写它的人离开后仍然存在。在一个快速成长的公司里,人会离开,人会转岗,人会休休假或产假,人会加入,而流失意味着人们知道的事情会随他们离开,除非你把它们写下来。理想情况下,你以编译器可以读取的形式写下来,因为编译器比大多数维基页面更有纪律。

纯度是边界而非属性

关于 Haskell 最根本的误解是认为纯度是语言本身的某种属性。事实是,在表面之下,Haskell 并不是一台神奇的机器,能够在纯函数的同时执行副作用。在 bytestringtextvector 的每个 “纯” 函数背后,都存在着可变分配、缓冲区写入、不安全强制转换以及其他行为的愉快小地狱。ST monad 内部也是原地变异和副作用。使其可接受的是这些副作用被封装起来,边界无法被违反。

runST :: (forall s. ST s a) -> a

runST 的 rank-2 类型(类型 s 被限定在括号内,无法逃逸)确保了在计算内部创建的可变引用无法逃逸,因为它们被标记为类型 s。内部可能发生各种指令式的胡闹;外部来看,函数是纯的。世界在边界之外看不到任何变异,只有结果。

这是一个更广泛的设计原则:在允许任意危险操作的范围内,只要范围的出口类型足够狭窄以至于危险无法泄漏。数据库层内部使用连接池、重试逻辑和可变状态。缓存使用并发的可变映射。HTTP 客户端可能有熔断器、连接池和大量记账操作。这些都不是问题,只要接口足够紧密以防止误用,只要边界保持。

在生产中,目标往往不是完全避免变异,因为这对于大多数真实系统来说不是一个严肃的命题。目标变异被包含,使包含变得可读,并验证它保持包含状态。通常正确的问题不是 “这纯粹吗?” 而是 “变异在哪里?有多少代码被允许知道它?”

对于一个三个月前学会 Haskell 的新工程师来说,“纯度是你试图维护的边界” 比 “Haskell 是纯的” 有用得多。一个告诉他们在设计模块时该做什么;另一个大部分时间只是坐在那里显得很深奥。

这种边界导向的纯度观奠定了一个更普遍的模式的基础,这个模式在 Haskell 生产工程的各个地方反复出现:危险的事情在它们被栅栏围起来、小心暴露且难以误用时是可以容忍的。这对变异是对的,对重试、事务、状态机、分布式工作流和类型级工具都是如此。

让正确的事情变得容易

大型代码库中有一个模式:正确性取决于以特定顺序执行操作,或者包含一个与主要工作没有明显联系的特定步骤。

“在每笔交易后记得刷新审计日志。”“在调用此端点之前务必检查功能标志。”“确保在数据库事务内部而不是之后将通知加入队列。”

这些是运维咒语。它们存在于维基页面、入职文档、半遗忘的设计评审和高级工程师的记忆中,而后者现在已经转到三个团队之外且预约到周四。在一个积极招聘的公司里,咒语的半衰期短得惊人。当工程师离开时,咒语消失。当截止日期临近时,它们是第一个被跳过的东西。当新工程师加入时,他们通常没有办法知道咒语的存在。

Haskell 给了你将这些问题编码到类型中的工具,这样它们就不会被遗忘。考虑一下简化版的真实模式:需要确保某些副作用(发送通知、发布事件)与数据库写入事务性地发生。不是之前,不是之后,也不是在单独的事务中。一起,或者根本不一起。

天真的方法是告诉人们使用正确的函数:

-- 请使用这个,不是那个
writeWithEvents :: Transaction -> [Event] -> IO ()

-- 不要直接使用这个(但我们无法阻止你)
writeTransaction :: Transaction -> IO ()
publishEvents :: [Event] -> IO ()

这是入门级工程。它在起作用之前有效,而 “起作用之前” 往往在周五下午到来,写维基页面的人正在度假,其他每个人都在实时发现维基页面是承重的。

更好的方法是重构类型,使得提交工作的唯一方式是通过包含事件发布的路径:

data Transact a -- 不透明;无法直接运行
record :: Transaction -> Transact ()
emit :: Event -> Transact ()

-- 执行 Transact 的唯一方式:原子提交和发布
commit :: Transact a -> IO a

现在咒语是房间里唯一的门。你无法忘记它,因为没有其他事情可做。类型系统并没有对你的事件证明什么特别深刻的东西。它做了更实际的事情:它使正确的运维程序成为阻力最小的路径。

这个区别在生产中很重要。在有很多地方,我们不需要一个定理。我们需要一个设计,使得一个普通的忙碌工程师在试图做其他十二件完全合理的事情时不小心做错事情变得困难。编译器在这里不仅仅是检查逻辑;它在保留机构知识并将其转化为硬边的接口。当一个新工程师加入并问 “我如何写入交易?”,类型系统回答他们。当高级工程师离开时,答案仍然存在。机构知识得以幸存,不是因为有人出色地记录了它,尽管有文档是愉快的,而是因为有人以编译器强制执行的形式编码了它。

持久执行:Temporal 工作流引擎

上述模式 —— 重构类型使得正确的运维程序成为唯一的程序 —— 在单个事务中运作良好。不幸的是,金融系统从未被要求停留在单个事务中。

它们充满了跨多个步骤、多个服务和多个失败模式的流程。发送支付,等待合作伙伴确认它,更新分类账,通知客户,处理取消,处理超时,处理合作伙伴说 “是” 但你的工作线程在记录答案之前就死了的情况,处理合作伙伴什么都没说因为网络暂时进入了一个更高的存在平面并拒绝告诉你它的消息。如果任何一步失败,你需要知道你在哪里,已经发生了什么,仍需发生什么。你需要状态、重试、超时、幂等性。你需要所有这些在进程崩溃和部署中继续工作。很快,“只是一些业务逻辑” 就开始积累大量一次性重复的常见运维问题。

Mercury 此前用数据库支持的状态机协调这些流程,由 cron 作业和后台工作线程驱动,重试逻辑和超时处理散布在代码库中。它能工作。它还需要那种通常与拆除未爆炸弹药相关的警惕性。它脆弱,难以推理,是我们运维事故的超比例来源。

Temporal 是 Mercury 的持久执行框架,采用它是他们做出的更好的基础设施决策之一。你将工作流写为普通的顺序代码,平台记录每一步到事件历史中。如果一个工作线程在工作流中途崩溃,另一个工作线程重放确定性的前缀来重建状态,然后从它停止的地方继续。重试、超时、取消和错误处理由平台提供,而不是每个团队糟糕地重新实现。

从某种重要意义上说,Temporal 工作流是其事件历史上的纯函数。Temporal 工作流有一个确定性要求 —— 重放的工作流必须产生与原始相同的命令序列 —— 这正是 Haskell 对纯代码施加的约束:相同的输入,相同的输出。副作用被隔离到 ** 活动(activities)** 中,这是工作流的 IO 等价物。工作流编排;活动执行。

Mercury 构建并开源了 hs-temporal-sdk,这是他们的 Haskell SDK for Temporal,它包装了官方 Core SDK(Rust,通过 FFI)并提供了用于定义工作流、活动和工作线程的 Haskell 本地 API。

可观测性设计:函数记录与中间件模式

如果可靠性是关于适应能力,可观测性是购买它的方式之一。运维人员无法理解他们看不到的东西。团队无法适应其内部不透明的系统。可观测性不是你在最后撒上的装饰品。它是软件设计表面的一部分。

这在 Haskell 中非常重要,因为 Haskell 没有猴子补丁。你无法在运行时进入一个库并将其 HTTP 客户端替换为一个记录计时的客户端,或者将其数据库调用换成发出 OpenTelemetry spans 的客户端。

最常使用的解决方案是函数记录。不是暴露一个充满具体函数的模块,而是暴露一个字段是函数的记录。调用者然后可以单独包装、记录、模拟或替换任何函数,而无需触及其余部分:

-- 具体模块不给你任何杠杆:
sendRequest :: Request -> IO Response

-- 函数记录给你全部:
data HttpClient = HttpClient
  { sendRequest :: Request -> IO Response
  , getManager  :: IO Manager
  }

通过记录,你可以用计时检测包装 sendRequest 并返回一个新的 HttpClient。你可以注入故障进行测试。你可以交换实现为模拟。你可以添加重试、跟踪、重写、租户特定行为,或任何其他生产在本季度为我们发现的横切关注点。所有这些都在运行时发生,无需触及库的源代码。

WAI 通过 type Middleware = Application -> Application 正确做到了这一点:行为可组合转换在广泛的各种系统中非常有用,而系统很少足够仁慈地提前呈现所有横切需求。

这种模式还有一个值得更多关注的不错的代数属性。中间件和拦截器类型几乎总是支持 SemigroupMonoid 实例。WAI 的 MiddlewareApplication -> Application,一个自同态,自同态在组合下形成一个 monoid,以 id 作为单位元。一个拦截器钩子记录,其中每个字段本身是一个自同态(或类似形状的 continuation-passing 函数),免费获得一个逐字段的 Semigroup 实例:a <> b 独立组合每个字段,mempty 是每个字段都是单位元(未改变地传递调用)的记录。

这将组合从工程问题变成了几乎不是问题。你不需要编写定制管道来组合你的跟踪拦截器、超时拦截器、任务队列重写拦截器。你 mconcat 它们:

appTemporalInterceptors =
  mconcat
    [ retargetingInterceptor
    , otelInterceptor
    , sentryInterceptor
    , sqlApplicationNameInterceptor
    , loggingContextInterceptor
    , statementTimeoutInterceptor
    , teamNameInterceptor
    , clientExceptionInterceptor
    , workflowTypeNameInterceptor
    ]

每个拦截器在它自己的模块中独立定义,由只需要考虑一个关注点的人构建。单个拦截器从 mempty 构建,覆盖一个或多个字段 —— 其他一切都通过。组合就是 (<>)。没有隐藏的接线。除了同意钩子的形状,没有协调税。顺序在列表中明确。新的横切关注点通过附加一个元素添加;现有拦截器永远不会被触及。

persistent 库是典型的正面例子。它的 SqlBackend 类型是一个函数记录:connPrepareconnInsertSqlconnBeginconnCommitconnRollback 等等。当为 persistent 实现 OpenTelemetry 检测时,可以通过包装相关字段在每个数据库操作周围添加跟踪 span。不需要 fork。几乎没有源代码更改。几行代码,我们就有了数据库层的完整可见性。

类型编码的取舍

将不变量编码到类型中是强大的。它也是昂贵的。不是在运行时,而是在认知开销、它引入的 rigidity 以及以后改变事物的难度上。需求会转移。如果你在一家不这样做的公司工作,我想知道你的秘诀,还有你的股票代码。

每个你推入类型系统的不变量是对每个未来工程师接触该代码的约束。如果违反约束会导致数据丢失、财务错误、监管麻烦或一个可怜的人的传呼机响起,那么成本是合理的。如果约束是 “我们目前碰巧以这种方式做事”,或者 “我读了这篇关于依赖类型的文章,我必须将它应用于我的授权逻辑”,你可能只是让你的代码库更难改变,而没有运维收益。下一个人遇到它将要么花一周重构类型,或者更可能,找到一个比你试图防止的更糟糕的绕过方式。

在两个极端之间:一边是你编码一切。你的类型是你领域的忠实模型。非法的状态无法表示。重构需要数周,因为改变一条业务规则意味着将类型变化穿过五十个模块。新工程师盯着类型签名,想知道他们做了什么值得这件事,然后安静地开始与治疗师讨论他们的职业选择。你建了一座大教堂。大教堂很美。它们也很贵,寒冷,而且不特别以管道翻新速度著称。

另一边是你编码 nothing。你的类型是 StringIO (),在最糟糕的情况下是 Dynamic。代码很容易改变,因为没有契约要违反。系统能工作,因为构建它的人仍然在场并记得字符串意味着什么。当他们离开时,它停止工作,没有人知道为什么。你搭了一个帐篷。帐篷灵活,便携,在某些天气条件下,是了解天空的直接方式。

甜点在对岸。几条有用的启发式:

编码防止无声损坏的不变量。 如果违规会产生错误数据而没有任何即时错误(事务提交没有其事件,支付处理没有审计日志,看起来合理但语义上不可能的状态转换),将其放入类型中。无声失败的反馈循环太长,无法依靠人为勤奋。

对失败时大声报告的不变量使用运行时检查。 如果违规会产生即时、明显的错误(500 响应、失败的断言、JSON 边界处的类型不匹配),运行时检查和好的错误消息可能就足够了。你会在生产之前或之后很快抓住它。

抵制将整个领域建模到类型中的冲动。 你的领域很乱。它有边缘情况、祖父条款、相互矛盾的规则以及三个特定客户群的特殊行为,可以追溯到 2018 年,没有人完全理解。类型系统想要 crispness。你的业务不提供它。也永远不会。

记住类型是为团队做的,而不仅仅是为编译器。 编译器是众多工具之一。测试、文档、代码审查、示例、 playbook:这些结合提供深度防御。目标不是赢得与类型检查器的争论。目标是构建一个团队可以运营、扩展和维护的系统,包括今年学会 Haskell 的人类。

实际参数与阈值

基于 Mercury 的实践,以下是生产级 Haskell 部署的关键参数与监控阈值:

类型编码优先级矩阵:第一优先级是事务与事件必须一起提交(防止数据不一致);第二优先级是支付失败建模为领域类型而非 HTTP 状态码(确保跨上下文一致处理);第三优先级是可空性边界(避免空指针在金融逻辑中扩散)。其他业务逻辑变化优先使用运行时验证。

Temporal 工作流超时配置:活动超时设置为 30 秒(与合作伙伴 API SLO 对齐),工作流执行超时默认 24 小时(覆盖最长业务审批流程),重试策略为指数退避(初始 1 秒,最大 5 分钟,最多重试 3 次)。

可观测性采样率:高流量端点 Trace 采样率 10%,错误请求采样率 100%,延迟超过 500 毫秒的请求自动提升至 100% 采样。OpenTelemetry 必须在所有 IO 操作的关键路径上集成。

新工程师上手周期:Mercury 的内部培训计划在六到八周内将无 Haskell 经验的工程师提升为可生产代码贡献者。关键里程碑包括:第一周完成类型基础与纯度边界概念;第二周掌握持久层与事务模式;第三周通过审查第一笔生产 PR;第四周开始独立模块开发。

依赖更新频率:核心库(如 persistentwai)每季度审查一次更新;安全补丁在 48 小时内评估;生态系统库(维护者较少的)采用 “fork 当作最后手段” 策略,优先通过 Internal 模块扩展。

结论

Mercury 的两百万行 Haskell 代码库不是一个理论实验,而是一个在极高风险环境中持续运行的生产系统。他们的经验表明:类型系统的真正价值在于将机构知识编码为编译器可读的约束;纯度是可以通过接口维护的边界而非语言属性;让正确的事情变得困难是比任何测试套件更有效的 bug 预防;可观测性必须在设计时考虑而非事后添加。

这种方法的回报在数月而非数年后显现。当一个会在动态类型代码库中花费数周的重构在 Haskell 中只需数小时(因为编译器将变化线程化到每个调用点并准确告诉你遗漏了什么),当新工程师仅通过阅读模块的类型签名就能理解其契约,当生产事故因为不可能的状态真的不可能而没有发生时 —— 这些时刻构成了 Haskell 在生产中的独特价值主张。

如果你正在考虑将 Haskell 用于生产系统,这提供了一个现实的图景:不是银弹,不是道德圣战,而是一套真正强大的工具,即使由具有广泛 Haskell 专业知识范围的团队使用。

资料来源:本文核心事实与观点来自 Mercury 工程师 Ian Duncan 在 Haskell 官方博客发表的文章《A Couple Million Lines of Haskell: Production Engineering at Mercury》(2026 年 3 月 30 日),该公司公开了 hs-temporal-sdk(GitHub: MercuryTechnologies/hs-temporal-sdk)与 OpenTelemetry 集成的生产实践。

systems