RPC函数的原子化处理

1 min read
Zekari
分布式系统RPC系统设计原子性

一个函数应该只做一件事。这个原则在本地代码里容易遵守,在 RPC 调用里却经常被打破。

原因很简单:每次 RPC 调用都有成本。网络延迟、序列化开销、连接建立,这些都让开发者倾向于把多个操作塞进一个远程函数里。调用一次总比调用三次快。

但这种优化带来了新的问题。

💡 Click the maximize icon to view in fullscreen

当失败发生时

假设这个函数在第三步失败了。邮件服务挂了,或者超时了,或者只是网络抖动。函数会返回什么?

如果返回错误,客户端会以为整个操作失败了。但实际上前两步已经成功了:用户信息已经更新,数据库已经改变。客户端可能会重试,导致重复更新。或者放弃,但用户会看到不一致的状态。

如果返回成功,客户端会以为一切都好。但实际上邮件没发出去,用户没收到通知。后续可能有依赖这个通知的逻辑,现在都会出问题。

这就是非原子化的代价:失败变得模糊不清。你不知道哪些步骤成功了,哪些失败了,应该重试什么,应该补偿什么。

原子性的真正含义

原子性不是说操作不可分割。它是说操作的结果只有两种:要么全部成功,要么全部失败。不存在中间状态。

对于本地事务,数据库帮你保证原子性。如果事务中任何一步失败,整个事务回滚,就像什么都没发生过。

但 RPC 调用没有这种保证。一个远程函数可能执行了一半就失败了。它可能修改了数据库,但没能发送消息。它可能扣了钱,但没能发货。

你需要自己保证原子性。

分布式事务试图在多个服务间保证原子性。Two-Phase Commit、Saga 模式、TCC,这些都是解决方案。

但它们都很复杂。2PC 需要协调者和锁,Saga 需要补偿逻辑,TCC 需要预留资源。而且它们都不能完全避免失败。

这就是为什么很多系统选择接受最终一致性,而不是追求强一致性。承认分布式系统中的原子性很难,设计能容忍不一致的系统,通过对账和修复来达到最终正确。

但这不意味着单个 RPC 函数就应该做多件事。恰恰相反,只有当每个函数职责单一,你才能更清楚地理解系统的状态。

拆分的策略

把一个复杂的远程函数拆成多个简单的,听起来容易,做起来需要权衡。

按业务边界拆分:更新用户信息是一件事,发送通知是另一件事。它们不应该在同一个函数里。前者是核心操作,必须成功。后者是附加功能,可以异步处理,可以失败重试。

按失败域拆分:修改数据库和调用外部服务是不同的失败域。数据库操作通常快速可靠,外部服务可能慢、可能不稳定。把它们混在一起,失败会传染。一个不稳定的服务会拖慢整个函数。

幂等性拆分:有些操作天然幂等,比如设置用户状态。有些操作不幂等,比如增加计数器。把幂等和非幂等操作混在一起,重试会变得危险。拆开它们,你可以安全地重试幂等部分。

拆分的代价是更多的网络调用。但这个代价值得,因为你得到了清晰的语义。每个函数的职责单一,失败的边界明确,重试的策略清楚。

编排在客户端

当你把一个复杂操作拆成多个简单函数,就需要有地方来编排它们。这个地方通常是客户端。

客户端调用 updateProfile(userId, newProfile),成功后调用 sendNotification(userId, event),最后调用 logAudit(userId, action)。每一步都是原子的,失败了可以单独处理。

但这意味着客户端需要处理更多逻辑:它需要知道调用的顺序,需要处理每一步的失败,需要决定哪些步骤可以重试,哪些需要人工介入。

这是对的。客户端最了解业务流程,最清楚哪些操作是必须的,哪些是可选的。把编排逻辑放在客户端,让每个服务专注于自己的职责。

有时候你会想在服务端做编排。创建一个"高层函数",内部调用多个"底层函数",把复杂度隐藏起来。

这在某些场景下有意义,特别是当多个客户端都需要同样的编排逻辑时。但要小心,这很容易滑向服务端做所有事情。

一个好的标准是:如果编排逻辑是领域知识的一部分,应该在服务端。如果只是为了方便客户端,可能在客户端更好。

记住,服务端编排意味着服务间调用。一个服务调用另一个服务,失败会传递,延迟会累加。这和把所有逻辑塞进一个函数没有本质区别。

批量操作的困境

原子化会遇到一个实际问题:批量操作

如果你需要更新一千个用户,调用一千次 updateProfile 显然不现实。你需要一个批量接口 batchUpdateProfiles(updates)

但批量操作天然不是原子的。一千个更新中,有些可能成功,有些可能失败。返回值会变成一个列表,标记每个操作的状态。

这没有违反原子性原则。相反,它让原子性的边界更清楚:每个单独的更新是原子的,整个批次不是。客户端知道哪些成功了,可以重试失败的那些。

关键是不要在批量操作里混入其他逻辑。batchUpdateProfiles 应该只更新用户信息,不应该发邮件、不应该记日志、不应该调用其他服务。保持单一职责,即使在批量场景下。

代价与收益

原子化让系统更容易理解。每个函数做一件事,失败的语义清晰,重试的策略简单。

代价是更多的网络调用。更多的延迟,更多的序列化,更多的连接开销。在性能敏感的场景下,这可能无法接受。

但性能问题通常有其他解决方案:缓存、批量、异步、队列。而复杂度问题很难解决。当一个函数做太多事情,它就变成了黑盒。你不知道它的状态,不知道失败意味着什么,不知道如何修复。

在大多数情况下,清晰比快速更重要。因为清晰的系统可以优化,混乱的系统只能重写。

最后

原子化不是教条。有时候你需要在一个函数里做多件事,有时候性能确实比简单重要。

但默认应该是原子化。让每个远程函数有清晰的职责、明确的边界、可预测的行为。当你需要做多件事情,在客户端编排,或者清楚地标记哪些是核心逻辑,哪些是附加功能。

分布式系统已经够复杂了。不要让函数的职责也变得模糊。

Related Posts

Articles you might also find interesting

RPC函数

2 min read

关于远程过程调用的本质思考:当你试图让远方看起来像眼前

分布式系统RPC
Read More

监听 Redis 连接事件 - 让不可见的脆弱变得可见

4 min read

连接看起来应该是透明的。但当它断开时,你才意识到透明不等于可靠。监听不是多余,而是对脆弱性的承认。

系统设计Redis
Read More

资源不会消失,只会泄露

2 min read

在积分系统中,用户的钱不会凭空消失,但会因为两个时间窗口而泄露:并发请求之间的竞争,和回调永不到达的沉默。

系统设计并发控制
Read More

幂等性检查

1 min read

在不确定的系统中,幂等性检查是对重复的容忍,是对稳定性的追求,也是对失败的预期与接纳

系统设计分布式系统
Read More

管理后台需要两次设计

3 min read

第一次设计回答"发生了什么",第二次设计回答"我能做什么"。在第一次就试图解决所有问题,结果是功能很多但都不够深入。

系统设计API 设计
Read More

告警分级与响应时间

2 min read

不是所有问题都需要立即响应。RPC失败会在凌晨3点叫醒人。安全事件每15分钟检查一次。支付成功只记录,不告警。系统的响应时间应该匹配问题的紧急程度。

系统设计监控
Read More

文档标准是成本计算的前提

3 min read

API文档不只是写给开发者看的。它定义了系统的边界、成本结构和可维护性。统一的文档标准让隐性成本变得可见。

API文档
Read More

BullMQ 队列

3 min read

队列不是技术选型,而是对时间的承认,对顺序的尊重,对不确定性的应对

系统设计异步处理
Read More

BullMQ Worker

2 min read

Worker 的本质是对时间的重新分配,是对主线的解放,也是对专注的追求

系统设计异步处理
Read More

配置不会自动同步

2 min read

视频生成任务永远pending,代码完美部署,队列正确配置。问题不在代码,在于配置的独立性被低估。静默失败比错误更危险。

部署配置管理
Read More

CRUD 操作

2 min read

四个字母背后,是数据的生命周期,是权限的边界,也是系统设计的基础逻辑

系统设计软件工程
Read More

数据库参数国际化:从 13 个迁移学到的设计原则

3 min read

数据不该懂语言。当数据库参数嵌入中文标签时,系统的边界就被语言限制了。这篇文章从 13 个参数对齐迁移中提炼出设计原则——国际化不是功能,是系统设计的底层约束。

数据库国际化
Read More

Stripe Webhook中的防御性编程

2 min read

三个Bug揭示的真相:假设是代码中最危险的东西。API返回类型、环境配置、变量作用域——每个看似合理的假设都可能导致客户损失。

Web开发系统设计
Read More

双重验证:Stripe生产模式的防御性切换

7 min read

从测试到生产不是更换API keys,而是建立一套双重验证系统。每一步都在两个环境中验证,确保真实支付不会因假设而失败。

系统设计Stripe
Read More

端到端 Postback 模拟测试

2 min read

真实的测试不是模拟完美的流程,而是重现真实世界的混乱。Postback 测试的价值在于发现系统在不确定性中的表现。

测试API
Read More

错误隔离

3 min read

失败是必然的。真正的问题不是失败本身,而是失败如何蔓延。错误隔离不是为了消除失败,而是为了控制失败的范围。

系统设计可靠性工程
Read More

在运行的系统上生长新功能

3 min read

扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。

系统设计架构
Read More

实现幂等性处理,忽略已处理的任务

3 min read

在代码层面识别和忽略已处理的任务,不是简单的布尔检查,而是对时序、并发和状态的深刻理解

系统设计并发控制
Read More

单例模式管理 Redis 连接

5 min read

连接不是技术细节,而是系统与外部世界的第一次握手,是可靠性的起点

系统设计后端架构
Read More

缺失值的级联效应

3 min read

一个NULL值如何在调用链中传播,最终导致错误的错误消息。理解防御层的设计,在失败传播前拦截。

系统设计防御性编程
Read More

监控观察期法

3 min read

部署不是结束,而是验证的开始。修复代码只是假设,监控数据才是证明。48小时观察期:让错误主动暴露,让数据证明修复。

系统设计监控
Read More

Props Drilling

3 min read

数据在组件树中层层传递,每一层都不需要它,却必须经手。这不是技术债,是架构对真实需求的误解。

React组件设计
Read More

队列生产者实例的工厂函数

4 min read

工厂函数不是设计模式的炫技,而是对重复的拒绝,对集中管理的追求,对变化的准备

系统设计设计模式
Read More

队列、可靠性与系统边界

1 min read

探讨消息队列系统如何通过时间换空间,用异步换解耦,以及可靠性背后的权衡

架构可靠性
Read More

使用Secret Token验证回调请求的合法性

2 min read

在开放的网络中,信任不能被假设。Secret Token 是对身份的确认,对伪装的识别,也是对安全边界的坚守

Web 安全系统设计
Read More