先跑通闭环,再决定要不要拆服务
单进程能减少部署成本,却不会自动带来可靠性。这篇从任务不变量、事务与租约、通知故障窗口一路推演到恢复和扩展,说明一个闭环怎样在进程退出、重复消息和外部结果未知时仍然可解释。
先带走这三个判断
90 秒 · 架构取舍
- 短查询可以同步完成;需要查询和恢复的长任务,先建立持久闭环,再讨论部署数量。
- 状态、通知与输入快照各司其职。通知只提醒检查任务,不能代替业务事实。
- 拆分由容量、故障隔离或发布需求推动。先用故障实验验证当前边界,别把接口预留当作分布式已经可用。
文章目录
模块化单体、同步请求与提前拆服务
选择部署形态时,我会比较真实工作特征,而不是把同步、异步和微服务排成成熟度等级。一个短查询没有必要建立完整调度链;一个长任务也不适合仅靠浏览器请求持续存在来维持执行。
| 方案 | 适合的工作 | 必须承担的代价 |
|---|---|---|
| 同步请求内完成 | 耗时短、边界简单、失败容易重新确认,且完成时间在请求超时预算内。 | 长调用占用请求资源;响应丢失时仍可能结果未知;需要继续执行的工作不能依赖连接存活。 |
| 模块化单体中的持久任务 | 业务仍在收敛,工作需要查询与恢复,当前单副本和资源预算可以接受。 | 持久状态、恢复与幂等仍需认真实现;角色共享故障域,独立扩缩容粒度较粗。 |
| 独立服务与跨进程消息 | 确有独立容量、故障隔离、发布节奏或团队所有权需求。 | 增加网络与交付契约、跨服务观测、版本兼容和运维协调;不能靠服务拆分代替状态正确性。 |
模块化单体的价值是先让领域职责和事务边界稳定,再决定哪些资源需要独立调配。同一进程里的模块也应通过明确接口合作,而不是互相修改对方状态表,或者依赖某个共享对象记住业务进度。否则未来搬进不同服务时,会同时遭遇职责重划与通信重建。
单进程的限制要接受并测量。执行器的内存、阻塞行为或异常可能影响接入与协调;整个进程退出时,所有后端角色一起暂停。可以先用有界并发、超时和资源预算控制范围,但这些措施不能创造独立故障域。若业务要求在某类执行器故障时接入仍持续可用,就需要认真评估更强的隔离。
什么时候值得拆开
扩展触发条件应该能被观察。持续增加执行并发后,API 延迟是否明显恶化;最老待处理任务和待发布事件是否持续变老;过期租约是否来自合理中断,还是预算与调用期限不匹配。先排除扫描、限流、慢查询和资源失控,再判断是否必须独立部署。具体阈值由业务可接受的等待与中断时间决定,本文不提供虚构的通用吞吐数字。
先定义不变量,再谈部署形态
做一个需要调用模型或外部系统的应用,第一次演示往往不难:收到请求,调用接口,把结果展示出来。困难出现在请求超时、执行到一半退出、用户重复点击之后。此时系统必须回答:这次工作是否已经被接受,使用了哪些输入,执行到了哪里,还能不能继续。部署几个服务不能替代这些答案。
我会先约定几条不变量。它们描述系统无论遇到什么故障都不能破坏的事实,也直接决定后续的数据模型和验证方式。
- 受理有据:只有事务已经提交,系统才把一次运行视为正式受理;客户端没有收到响应,不代表服务端没有受理。
- 输入有据:正式结果关联本次运行的输入快照和配置版本,不在重启后悄悄改用最新数据。
- 执行有据:执行者需要领取资格;完成、取消或失效的任务不能被一条旧通知重新激活。
- 结果有据:同一逻辑任务的正式结果不能被重复投递或过期执行者覆盖;调用经过与最终结果分别记录。
- 失败有据:失败、结果未知和等待重试必须能区分,不能全部折叠成一个看似成功的完成状态。
最小闭环由这些约束展开:受理运行,准备输入,形成可执行任务,领取并执行,保存结果,推进后续阶段,再让使用者查询。每一步既要有前进路径,也要有中断后可判断的状态。通知发送成功只是其中一个动作;结果已落库才可能成为业务完成的依据。
执行完成还要与结果的业务含义分开:评估正常完成,可以给出较低的质量指标;材料不足,也可能无法评价。闭环应保存这些差异,而不是把过程都涂成绿色。
状态、通知和输入快照为什么分别存在
这三类记录看起来都在描述一次工作,却回答不同的问题。混用它们,常常导致恢复时没有依据,或者表面恢复成功,实际输入已经变化。
| 记录 | 回答什么 | 不能代替什么 |
|---|---|---|
| 持久化状态 | 工作是否受理、何时可执行、谁正在执行、结果是否正式提交。 | 不能只靠一段内存日志保存,也不能由消息到达次数推导。 |
| 任务通知 | 某项持久化工作可能已经可以处理,值得执行者现在检查。 | 不能证明任务仍然有效,更不能证明已经完成。 |
| 输入快照 | 本次运行实际使用的材料、配置和版本是什么。 | 不能拿当前数据源或当前配置补写过去的运行依据。 |
通知可以携带定位任务的信息,但执行者收到后仍然回库核对。通知排队期间,任务可能已经完成、被暂停、被取消,或者被另一个执行者领取。把通知当作执行命令直接照做,就会绕过这些变化。消息体越完整,也不意味着它越有资格成为事实源。
快照解决的是时间变化。运行受理时先固定所选配置与版本;外部数据在本次准备阶段读取,准备完成后形成这次运行独占的输入。执行阶段持续引用这份输入,而不是每次重新读取外部最新内容。对于 AI 应用,还应保留实际使用的提示、上下文组织规则和评估配置,才能判断差异来自哪里。
快照也有边界。分页读取时,外部数据源可能正在更新。若数据源提供修订号或一致性读取能力,应利用它;否则只能说明“固定了本次实际采集到的内容”,不能声称采集过程天然对应外部系统的一个原子时刻。固定输入也不保证模型逐字复现,它只是让运行差异有可比较的基础。
一个进程中的三个逻辑角色
起步时,我倾向于把后端装配在一个进程里,同时明确接入、协调和执行三个逻辑角色。数据库独立保存业务事实。这里的单进程是后端运行方式,不是把数据库、浏览器和所有外部系统塞进一个程序。
API 负责受理。它完成协议转换、权限与参数检查,处理重复提交,再通过应用服务保存运行和待发送事件。一个耗时任务可以返回受理结果,让使用者随后查询;请求处理器不必一直等待整条外部调用链结束。受理响应与任务结果因此拥有不同的时间边界。
Coordinator 负责推进。它判断前置条件是否成立、任务是否到可执行时间、是否需要准备下一阶段,并检查没有继续推进的工作。它也负责把暂停、恢复、取消等控制意图转成持久状态变化。协调逻辑不能仅靠某个回调曾经触发过来判断流程进度。
Worker 负责执行。它读取任务、领取执行权、调用对应适配器并提交执行结果。不同工作可以拥有独立的并发预算,但共享同一套领取与完成约束。领域规则不需要知道通知来自哪种消息供应商,外部系统细节也集中在适配器中。
本地消息通道连接这些角色,Outbox 转发与恢复扫描给它发送提醒。通道需要有界容量、受控等待和明确的启动、停止顺序;后台执行循环异常退出,也应让运行监督机制感知。一个还在回答健康检查、实际已经不再处理任务的进程,很难通过增加服务数量得到改善。
领取、租约、事务和外部调用的时间边界
收到提醒不等于获得执行权。领取应该是一个原子决定:任务可执行、所属运行允许继续、还没有正式完成,并且不存在有效的其他领取,或者旧租约已满足回收条件。检查与更新不能分成两个互不约束的动作,否则两个执行者都可能先读到“可执行”,然后同时开始。
一次领取通常记录执行者、到期时间,以及能区分本次领取的版本或令牌。后续写回要携带这份资格。下面的版本、令牌与条件写回,是通用设计原则;具体字段和协议要由实现明确,应用时需要明确字段含义、过期规则与写回条件。
| 阶段 | 事务内做什么 | 事务外做什么 |
|---|---|---|
| 准备执行 | 条件领取任务,保存领取资格与本次调用的必要记录,随后提交。 | 读取或组装本次调用需要的固定输入。 |
| 执行调用 | 不跨着调用一直占用行锁或数据库事务。 | 受超时与并发预算约束地调用外部系统,收集响应或中断证据。 |
| 提交结果 | 重新核验领取资格,原子保存结果、任务状态及后续事件。 | 正式提交后,再通过通知推进后续工作。 |
数据库事务与领取租约的作用不同。事务保护一小段状态变更的原子性;租约描述执行资格在较长过程中的有效期。PostgreSQL 的锁通常保留到事务结束,跨外部调用保持事务会把网络等待变成锁等待和连接占用。这里应采用短事务。参考:PostgreSQL Explicit Locking
租约到期也不会自动停止旧进程,更不会撤销已经发送的请求。旧执行者可能在暂停后继续运行。因此,新执行者接手后,完成写回应以领取版本、执行者或令牌作条件;过期写回被拒绝,不能覆盖新结果。这种约束能保护本地状态,却不能单独约束外部系统已经发生的副作用。
租约要覆盖合法调用期限与提交余量,续租应受监督,不能把卡死执行永久伪装成正常工作。重试的“何时再执行”与租约的“谁有执行资格”分别记录;到期判断也要使用一致的时间基准。
Outbox 的提交、发送和标记三个窗口
如果先提交任务,再调用消息发送,进程可能在两步之间退出;如果先发消息,再提交任务,消费者可能收到一项最终回滚的工作。把顺序调换,无法让数据库提交与通知通道接收自然成为一个原子动作。
Transactional Outbox 的做法是把业务变更和待发送事件存入同一数据库事务,提交后再由转发器发送。AWS 的官方说明也指出,转发仍可能产生重复消息,消费端需要幂等处理。参考:AWS Transactional outbox pattern
把这段过程拆开,才能看清它究竟保证了什么。以下故障窗口是依据提交、发送、标记三个动作构造的推演,不是一次真实生产故障记录。
| 退出位置 | 留下的事实 | 后续动作 |
|---|---|---|
| 业务事务提交前 | 任务变更与 Outbox 一起回滚,没有正式受理的新工作。 | 客户端可以按原提交意图查询或重试,不应有对应下游工作。 |
| 提交后、发送前 | 任务与待发送事件都已持久化。 | 转发器再次读取事件并发送。 |
| 通道接收后、发布标记提交前 | 通道可能已经接收,数据库却仍显示未发布或转发中。 | 再次发送可能重复,消费者必须按持久状态处理。 |
| 发布标记提交后、任务处理前 | 事件已经交给通道,任务仍未完成;本地进程退出还会丢失内存通知。 | 任务恢复扫描重新唤醒,不能只依赖未发布事件扫描。 |
转发器也采用短事务:先领取事件并提交,再发送,最后记录发布结果。发送前退出会留下待回收的领取;失败则记录下一次可发送时间。长期停滞的转发状态应被恢复和告警发现。
发布标记证明通道接受了发送,不证明业务完成。本地通道的内存提醒仍可能随进程退出丢失,因此需要未发布事件补发和未完成任务恢复。通知顺序也不能代替阶段顺序:迟到提醒仍要检查当前状态与前置条件。
至少一次、幂等与外部结果不确定
允许重复提醒,是为发送结果未知、进程退出和消费者中断留出恢复机会。我们希望工作最终得到处理,也承认同一提醒可能出现多次。对内存通道不能直接套用持久 Broker 的交付保证;这里的持续推进来自持久任务、通知重投和恢复扫描的组合。
幂等要明确重复的对象:重复受理指向原运行,重复通知指向原任务,授权的业务重试才可能产生新的调用记录。不能每次收到消息都新建任务,也不能把基础设施重投都算成新的业务尝试。
消费者可以识别已处理事件,但去重记录必须与其对应的状态变化保持一致。先标记消息已处理,随后业务事务失败,会把工作永久挡在恢复之外。对正在执行、已经完成、已经取消的任务,也需要各自明确的处理结果。正式结果的唯一性和条件更新,是防止两个执行者各自完成后互相覆盖的另一层保护。
外部调用多了一层无法由本地事务覆盖的不确定性:请求可能没发出去,也可能已经完成,只是响应丢了。数据库里没有成功结果,不代表对方没有执行。对模型调用而言,再次生成还可能得到不同输出;对有副作用的操作,重复调用则可能产生第二次实际变更。
若外部接口支持幂等,同一逻辑调用的重试应保留相同的请求意图标识与参数;新意图使用新标识。参数相同不等于意图相同。AWS 对这个区别有专门讨论。参考:Making retries safe with idempotent APIs
实际处理还要看对方是否允许查询已执行结果、幂等记录保存多久、重复请求返回什么,以及晚到请求是否仍受保护。能查询时先核实,能安全重试时沿用原意图;两者都不具备时,就保留结果不确定的证据,按业务策略转为待核实或明确终止。让流程停在可解释的状态,比用一次新调用悄悄替换未知结果更可靠。
恢复流程怎样继续推进
恢复器不应该把所有非完成任务重新执行一遍。它先依据持久状态分类,再决定允许的下一步。一个可检查的恢复过程可以按下面的顺序展开。
- 检查运行意图。暂停、取消和已结束的运行不能仅因存在旧通知就重新启动;输入快照尚未准备完成时,也不能提前进入执行。
- 分类停滞原因。未发布事件需要补发,转发中记录需要回收,已到执行时间的任务需要唤醒,仍在有效租约内的工作需要等待。
- 条件接手过期任务。新的执行资格通过原子领取取得;旧执行者的写回失效。不能仅修改一个状态字段,然后假设并发冲突已经消失。
- 检查调用证据。没有外部执行风险的阶段可以按检查点继续;外部结果未知时,先进入查询、核实或停止策略。
- 提交前进事实。结果、阶段推进及后续待发送事件在边界清楚的事务中保存,让下一轮恢复看见新的状态。
延迟工作依赖任务记录中的可执行时间,而不是把某条内存消息延迟保留很久。扫描可以在启动时、收到提醒时和周期到期时触发;提醒减少等待,周期扫描为提醒丢失兜底。它查询持久状态,不把内存里积累了多少次唤醒当作还有多少项业务工作。
恢复也需要批量上限、退避和运行间公平性,毒性任务不能占住整个扫描循环;扫描异常应被监督。继续推进以数据库和必要外部依赖可用为前提,还要满足业务重试上限与外部结果处理规则。
一条含中断的完整构造时间线
下面是一条构造任务时间线,用来检查中断后还剩哪些事实,以及下一步是否有合法依据。假设某个外部接口支持按请求意图查询结果,并对相同意图的重复调用提供幂等保护。没有这两项能力时,第八步必须改为核实或终止,而不能照搬。
| 顺序 | 发生的动作 | 留下的依据与下一步 |
|---|---|---|
| 一:受理 | API 固定选定配置,保存运行与初始待发送事件,一起提交。 | 正式受理可查询;输入内容等待本次准备阶段读取。 |
| 二:发送 | 转发器领取事件,通道接收提醒;发布标记提交前,后端进程退出。 | 数据库留有转发中记录;内存提醒可能丢失。 |
| 三:重新启动 | 恢复器回收过期转发领取,转发器再次发送原提醒。 | 协调处理允许重复,按当前运行状态决定是否建立准备任务。 |
| 四:准备 | 准备执行者分批读取材料,保存检查点,最终封存本次输入快照。 | 快照完整后才建立后续执行工作;中断后按来源修订与既定策略续读或重新准备,封存后不再更换输入。 |
| 五:执行领取 | 执行者取得领取资格,保存调用记录与稳定的外部请求意图。 | 短事务提交后,开始外部调用。 |
| 六:响应丢失 | 外部系统已经处理;响应未返回,本地进程再次退出。 | 调用没有正式结果,结果是否发生处于未知状态。 |
| 七:租约到期 | 扫描发现执行资格过期,新的执行者条件接手。 | 旧领取失效;新执行者先检查此前调用证据。 |
| 八:核实 | 新执行者按原请求意图查询,对方返回先前已完成的结果。 | 取得原调用结果,不把新的生成当作原结果。 |
| 九:迟到写回 | 旧执行者恢复运行,试图用旧资格提交迟到响应。 | 条件写回拒绝旧资格,不能覆盖新执行者的正式提交。 |
| 十:正式完成 | 有效执行者保存核实结果、任务完成状态与后续事件。 | 三者按既定事务边界提交,随后推进评价或汇总。 |
| 十一:汇总 | 后续任务读取正式结果,保存报告及运行终态。 | 报告关联固定输入与实际调用证据;缺失或未知项按规则表达。 |
| 十二:查询 | 使用者重新打开页面或重试原受理请求。 | 查询持久记录得到同一运行,不依赖此前浏览器是否收到通知。 |
用扩展触发条件与故障实验收尾
决定拆分后,需要单独验证新通知通道的接收确认、重复和丢失行为、背压、停止顺序及重启恢复,也要验证多执行者同时领取时的结果唯一性。保留接口让这次改变有清楚的落点;它不意味着更换一个配置就获得已验证的分布式能力。
验证可以从隔离环境的一条固定任务开始。保存输入与配置,记录初始状态,在明确边界注入故障,再检查数据库事实、外部调用记录和使用者可见结果。下面列的是可执行实验设计,尚未执行的项应保留为待验证。
| 注入方式 | 应当观察的结果 | 失败信号 |
|---|---|---|
| 业务事务提交前终止进程,随后重试受理。 | 未提交工作不存在;按原意图重新受理后仅有一项正式运行。 | 存在没有运行事实的下游工作,或生成重复运行。 |
| 提交后、发布前终止,再启动。 | 已受理工作可查,待发送事件最终发出,任务继续。 | 请求已成功受理,工作却永久停滞。 |
| 发送后、发布标记前终止,并重复投递。 | 提醒可以重复,正式任务和结果保持唯一。 | 多出业务尝试、重复结果或重复副作用。 |
| 发布标记后清空本地在途提醒,再启动扫描。 | 未完成任务通过状态扫描重新获得提醒。 | 转发器认为已发布,恢复器却再也看不到工作。 |
| 暂停旧执行者至租约失效,让新执行者领取,再恢复旧者。 | 旧资格写回被拒绝,正式结果来自有效提交。 | 迟到结果覆盖新结果,或两份结果都成为正式结果。 |
| 外部系统处理后切断响应连接。 | 依能力查询、幂等重试或记录未知,调用次数与处理策略一致。 | 未知被当成未执行,无依据再次产生副作用。 |
| 准备输入中途退出,同时修改外部数据源。 | 按已定义的快照与检查点策略继续;输入来源和边界可解释。 | 同次运行静默混用输入,或执行阶段读取最新配置。 |
| 持续填满通知通道并注入扫描异常。 | 背压受控、事件保留,异常可见;依赖恢复后能继续推进。 | 内存无限增长,事件消失,或处理停止仍持续报告正常。 |
每次实验还应检查:任务是否最终到达允许的终态,正式结果是否唯一,输入关联是否一致,恢复等待是否满足事先设定的预算。外部结果无法判定时,允许的终态可以是明确失败或待核实,不应为了“跑通”强行改成成功。把这些证据留下,下一次容量变化或部署拆分才有可以比较的基线。
由工程实践中的判断整理,使用通用场景说明方法;示意架构、案例和数值不代表某个系统的生产验收或效果数据。
可靠性来自持久状态与恢复依据;拆服务要由测量到的瓶颈推动。
读完,留一句在书桌上
仅保存在当前浏览器。