dalaoyang技术手记

先跑通闭环,再决定要不要拆服务

单进程能减少部署成本,却不会自动带来可靠性。这篇从任务不变量、事务与租约、通知故障窗口一路推演到恢复和扩展,说明一个闭环怎样在进程退出、重复消息和外部结果未知时仍然可解释。

先带走这三个判断

90 秒 · 架构取舍

  1. 短查询可以同步完成;需要查询和恢复的长任务,先建立持久闭环,再讨论部署数量。
  2. 状态、通知与输入快照各司其职。通知只提醒检查任务,不能代替业务事实。
  3. 拆分由容量、故障隔离或发布需求推动。先用故障实验验证当前边界,别把接口预留当作分布式已经可用。
直接看方案对比
文章目录

模块化单体、同步请求与提前拆服务

选择部署形态时,我会比较真实工作特征,而不是把同步、异步和微服务排成成熟度等级。一个短查询没有必要建立完整调度链;一个长任务也不适合仅靠浏览器请求持续存在来维持执行。

方案适合的工作必须承担的代价
同步请求内完成耗时短、边界简单、失败容易重新确认,且完成时间在请求超时预算内。长调用占用请求资源;响应丢失时仍可能结果未知;需要继续执行的工作不能依赖连接存活。
模块化单体中的持久任务业务仍在收敛,工作需要查询与恢复,当前单副本和资源预算可以接受。持久状态、恢复与幂等仍需认真实现;角色共享故障域,独立扩缩容粒度较粗。
独立服务与跨进程消息确有独立容量、故障隔离、发布节奏或团队所有权需求。增加网络与交付契约、跨服务观测、版本兼容和运维协调;不能靠服务拆分代替状态正确性。

模块化单体的价值是先让领域职责和事务边界稳定,再决定哪些资源需要独立调配。同一进程里的模块也应通过明确接口合作,而不是互相修改对方状态表,或者依赖某个共享对象记住业务进度。否则未来搬进不同服务时,会同时遭遇职责重划与通信重建。

单进程的限制要接受并测量。执行器的内存、阻塞行为或异常可能影响接入与协调;整个进程退出时,所有后端角色一起暂停。可以先用有界并发、超时和资源预算控制范围,但这些措施不能创造独立故障域。若业务要求在某类执行器故障时接入仍持续可用,就需要认真评估更强的隔离。

什么时候值得拆开

扩展触发条件应该能被观察。持续增加执行并发后,API 延迟是否明显恶化;最老待处理任务和待发布事件是否持续变老;过期租约是否来自合理中断,还是预算与调用期限不匹配。先排除扫描、限流、慢查询和资源失控,再判断是否必须独立部署。具体阈值由业务可接受的等待与中断时间决定,本文不提供虚构的通用吞吐数字。

先定义不变量,再谈部署形态

做一个需要调用模型或外部系统的应用,第一次演示往往不难:收到请求,调用接口,把结果展示出来。困难出现在请求超时、执行到一半退出、用户重复点击之后。此时系统必须回答:这次工作是否已经被接受,使用了哪些输入,执行到了哪里,还能不能继续。部署几个服务不能替代这些答案。

我会先约定几条不变量。它们描述系统无论遇到什么故障都不能破坏的事实,也直接决定后续的数据模型和验证方式。

  • 受理有据:只有事务已经提交,系统才把一次运行视为正式受理;客户端没有收到响应,不代表服务端没有受理。
  • 输入有据:正式结果关联本次运行的输入快照和配置版本,不在重启后悄悄改用最新数据。
  • 执行有据:执行者需要领取资格;完成、取消或失效的任务不能被一条旧通知重新激活。
  • 结果有据:同一逻辑任务的正式结果不能被重复投递或过期执行者覆盖;调用经过与最终结果分别记录。
  • 失败有据:失败、结果未知和等待重试必须能区分,不能全部折叠成一个看似成功的完成状态。

最小闭环由这些约束展开:受理运行,准备输入,形成可执行任务,领取并执行,保存结果,推进后续阶段,再让使用者查询。每一步既要有前进路径,也要有中断后可判断的状态。通知发送成功只是其中一个动作;结果已落库才可能成为业务完成的依据。

执行完成还要与结果的业务含义分开:评估正常完成,可以给出较低的质量指标;材料不足,也可能无法评价。闭环应保存这些差异,而不是把过程都涂成绿色。

状态、通知和输入快照为什么分别存在

这三类记录看起来都在描述一次工作,却回答不同的问题。混用它们,常常导致恢复时没有依据,或者表面恢复成功,实际输入已经变化。

记录回答什么不能代替什么
持久化状态工作是否受理、何时可执行、谁正在执行、结果是否正式提交。不能只靠一段内存日志保存,也不能由消息到达次数推导。
任务通知某项持久化工作可能已经可以处理,值得执行者现在检查。不能证明任务仍然有效,更不能证明已经完成。
输入快照本次运行实际使用的材料、配置和版本是什么。不能拿当前数据源或当前配置补写过去的运行依据。

通知可以携带定位任务的信息,但执行者收到后仍然回库核对。通知排队期间,任务可能已经完成、被暂停、被取消,或者被另一个执行者领取。把通知当作执行命令直接照做,就会绕过这些变化。消息体越完整,也不意味着它越有资格成为事实源。

快照解决的是时间变化。运行受理时先固定所选配置与版本;外部数据在本次准备阶段读取,准备完成后形成这次运行独占的输入。执行阶段持续引用这份输入,而不是每次重新读取外部最新内容。对于 AI 应用,还应保留实际使用的提示、上下文组织规则和评估配置,才能判断差异来自哪里。

快照也有边界。分页读取时,外部数据源可能正在更新。若数据源提供修订号或一致性读取能力,应利用它;否则只能说明“固定了本次实际采集到的内容”,不能声称采集过程天然对应外部系统的一个原子时刻。固定输入也不保证模型逐字复现,它只是让运行差异有可比较的基础。

一个进程中的三个逻辑角色

单进程中的职责、数据库状态与本地通知
通用示意:一个部署单元,多种逻辑职责。放大图表

起步时,我倾向于把后端装配在一个进程里,同时明确接入、协调和执行三个逻辑角色。数据库独立保存业务事实。这里的单进程是后端运行方式,不是把数据库、浏览器和所有外部系统塞进一个程序。

API 负责受理。它完成协议转换、权限与参数检查,处理重复提交,再通过应用服务保存运行和待发送事件。一个耗时任务可以返回受理结果,让使用者随后查询;请求处理器不必一直等待整条外部调用链结束。受理响应与任务结果因此拥有不同的时间边界。

Coordinator 负责推进。它判断前置条件是否成立、任务是否到可执行时间、是否需要准备下一阶段,并检查没有继续推进的工作。它也负责把暂停、恢复、取消等控制意图转成持久状态变化。协调逻辑不能仅靠某个回调曾经触发过来判断流程进度。

Worker 负责执行。它读取任务、领取执行权、调用对应适配器并提交执行结果。不同工作可以拥有独立的并发预算,但共享同一套领取与完成约束。领域规则不需要知道通知来自哪种消息供应商,外部系统细节也集中在适配器中。

本地消息通道连接这些角色,Outbox 转发与恢复扫描给它发送提醒。通道需要有界容量、受控等待和明确的启动、停止顺序;后台执行循环异常退出,也应让运行监督机制感知。一个还在回答健康检查、实际已经不再处理任务的进程,很难通过增加服务数量得到改善。

领取、租约、事务和外部调用的时间边界

收到提醒不等于获得执行权。领取应该是一个原子决定:任务可执行、所属运行允许继续、还没有正式完成,并且不存在有效的其他领取,或者旧租约已满足回收条件。检查与更新不能分成两个互不约束的动作,否则两个执行者都可能先读到“可执行”,然后同时开始。

一次领取通常记录执行者、到期时间,以及能区分本次领取的版本或令牌。后续写回要携带这份资格。下面的版本、令牌与条件写回,是通用设计原则;具体字段和协议要由实现明确,应用时需要明确字段含义、过期规则与写回条件。

阶段事务内做什么事务外做什么
准备执行条件领取任务,保存领取资格与本次调用的必要记录,随后提交。读取或组装本次调用需要的固定输入。
执行调用不跨着调用一直占用行锁或数据库事务。受超时与并发预算约束地调用外部系统,收集响应或中断证据。
提交结果重新核验领取资格,原子保存结果、任务状态及后续事件。正式提交后,再通过通知推进后续工作。

数据库事务与领取租约的作用不同。事务保护一小段状态变更的原子性;租约描述执行资格在较长过程中的有效期。PostgreSQL 的锁通常保留到事务结束,跨外部调用保持事务会把网络等待变成锁等待和连接占用。这里应采用短事务。参考:PostgreSQL Explicit Locking

租约到期也不会自动停止旧进程,更不会撤销已经发送的请求。旧执行者可能在暂停后继续运行。因此,新执行者接手后,完成写回应以领取版本、执行者或令牌作条件;过期写回被拒绝,不能覆盖新结果。这种约束能保护本地状态,却不能单独约束外部系统已经发生的副作用。

租约要覆盖合法调用期限与提交余量,续租应受监督,不能把卡死执行永久伪装成正常工作。重试的“何时再执行”与租约的“谁有执行资格”分别记录;到期判断也要使用一致的时间基准。

Outbox 的提交、发送和标记三个窗口

如果先提交任务,再调用消息发送,进程可能在两步之间退出;如果先发消息,再提交任务,消费者可能收到一项最终回滚的工作。把顺序调换,无法让数据库提交与通知通道接收自然成为一个原子动作。

Transactional Outbox 的做法是把业务变更和待发送事件存入同一数据库事务,提交后再由转发器发送。AWS 的官方说明也指出,转发仍可能产生重复消息,消费端需要幂等处理。参考:AWS Transactional outbox pattern

Outbox 的提交、发送与标记故障窗口
通知转发和业务完成拥有不同的提交边界。放大图表

把这段过程拆开,才能看清它究竟保证了什么。以下故障窗口是依据提交、发送、标记三个动作构造的推演,不是一次真实生产故障记录。

退出位置留下的事实后续动作
业务事务提交前任务变更与 Outbox 一起回滚,没有正式受理的新工作。客户端可以按原提交意图查询或重试,不应有对应下游工作。
提交后、发送前任务与待发送事件都已持久化。转发器再次读取事件并发送。
通道接收后、发布标记提交前通道可能已经接收,数据库却仍显示未发布或转发中。再次发送可能重复,消费者必须按持久状态处理。
发布标记提交后、任务处理前事件已经交给通道,任务仍未完成;本地进程退出还会丢失内存通知。任务恢复扫描重新唤醒,不能只依赖未发布事件扫描。

转发器也采用短事务:先领取事件并提交,再发送,最后记录发布结果。发送前退出会留下待回收的领取;失败则记录下一次可发送时间。长期停滞的转发状态应被恢复和告警发现。

发布标记证明通道接受了发送,不证明业务完成。本地通道的内存提醒仍可能随进程退出丢失,因此需要未发布事件补发和未完成任务恢复。通知顺序也不能代替阶段顺序:迟到提醒仍要检查当前状态与前置条件。

至少一次、幂等与外部结果不确定

允许重复提醒,是为发送结果未知、进程退出和消费者中断留出恢复机会。我们希望工作最终得到处理,也承认同一提醒可能出现多次。对内存通道不能直接套用持久 Broker 的交付保证;这里的持续推进来自持久任务、通知重投和恢复扫描的组合。

幂等要明确重复的对象:重复受理指向原运行,重复通知指向原任务,授权的业务重试才可能产生新的调用记录。不能每次收到消息都新建任务,也不能把基础设施重投都算成新的业务尝试。

消费者可以识别已处理事件,但去重记录必须与其对应的状态变化保持一致。先标记消息已处理,随后业务事务失败,会把工作永久挡在恢复之外。对正在执行、已经完成、已经取消的任务,也需要各自明确的处理结果。正式结果的唯一性和条件更新,是防止两个执行者各自完成后互相覆盖的另一层保护。

外部调用多了一层无法由本地事务覆盖的不确定性:请求可能没发出去,也可能已经完成,只是响应丢了。数据库里没有成功结果,不代表对方没有执行。对模型调用而言,再次生成还可能得到不同输出;对有副作用的操作,重复调用则可能产生第二次实际变更。

若外部接口支持幂等,同一逻辑调用的重试应保留相同的请求意图标识与参数;新意图使用新标识。参数相同不等于意图相同。AWS 对这个区别有专门讨论。参考:Making retries safe with idempotent APIs

实际处理还要看对方是否允许查询已执行结果、幂等记录保存多久、重复请求返回什么,以及晚到请求是否仍受保护。能查询时先核实,能安全重试时沿用原意图;两者都不具备时,就保留结果不确定的证据,按业务策略转为待核实或明确终止。让流程停在可解释的状态,比用一次新调用悄悄替换未知结果更可靠。

恢复流程怎样继续推进

恢复器不应该把所有非完成任务重新执行一遍。它先依据持久状态分类,再决定允许的下一步。一个可检查的恢复过程可以按下面的顺序展开。

  1. 检查运行意图。暂停、取消和已结束的运行不能仅因存在旧通知就重新启动;输入快照尚未准备完成时,也不能提前进入执行。
  2. 分类停滞原因。未发布事件需要补发,转发中记录需要回收,已到执行时间的任务需要唤醒,仍在有效租约内的工作需要等待。
  3. 条件接手过期任务。新的执行资格通过原子领取取得;旧执行者的写回失效。不能仅修改一个状态字段,然后假设并发冲突已经消失。
  4. 检查调用证据。没有外部执行风险的阶段可以按检查点继续;外部结果未知时,先进入查询、核实或停止策略。
  5. 提交前进事实。结果、阶段推进及后续待发送事件在边界清楚的事务中保存,让下一轮恢复看见新的状态。

延迟工作依赖任务记录中的可执行时间,而不是把某条内存消息延迟保留很久。扫描可以在启动时、收到提醒时和周期到期时触发;提醒减少等待,周期扫描为提醒丢失兜底。它查询持久状态,不把内存里积累了多少次唤醒当作还有多少项业务工作。

恢复也需要批量上限、退避和运行间公平性,毒性任务不能占住整个扫描循环;扫描异常应被监督。继续推进以数据库和必要外部依赖可用为前提,还要满足业务重试上限与外部结果处理规则。

一条含中断的完整构造时间线

下面是一条构造任务时间线,用来检查中断后还剩哪些事实,以及下一步是否有合法依据。假设某个外部接口支持按请求意图查询结果,并对相同意图的重复调用提供幂等保护。没有这两项能力时,第八步必须改为核实或终止,而不能照搬。

顺序发生的动作留下的依据与下一步
一:受理API 固定选定配置,保存运行与初始待发送事件,一起提交。正式受理可查询;输入内容等待本次准备阶段读取。
二:发送转发器领取事件,通道接收提醒;发布标记提交前,后端进程退出。数据库留有转发中记录;内存提醒可能丢失。
三:重新启动恢复器回收过期转发领取,转发器再次发送原提醒。协调处理允许重复,按当前运行状态决定是否建立准备任务。
四:准备准备执行者分批读取材料,保存检查点,最终封存本次输入快照。快照完整后才建立后续执行工作;中断后按来源修订与既定策略续读或重新准备,封存后不再更换输入。
五:执行领取执行者取得领取资格,保存调用记录与稳定的外部请求意图。短事务提交后,开始外部调用。
六:响应丢失外部系统已经处理;响应未返回,本地进程再次退出。调用没有正式结果,结果是否发生处于未知状态。
七:租约到期扫描发现执行资格过期,新的执行者条件接手。旧领取失效;新执行者先检查此前调用证据。
八:核实新执行者按原请求意图查询,对方返回先前已完成的结果。取得原调用结果,不把新的生成当作原结果。
九:迟到写回旧执行者恢复运行,试图用旧资格提交迟到响应。条件写回拒绝旧资格,不能覆盖新执行者的正式提交。
十:正式完成有效执行者保存核实结果、任务完成状态与后续事件。三者按既定事务边界提交,随后推进评价或汇总。
十一:汇总后续任务读取正式结果,保存报告及运行终态。报告关联固定输入与实际调用证据;缺失或未知项按规则表达。
十二:查询使用者重新打开页面或重试原受理请求。查询持久记录得到同一运行,不依赖此前浏览器是否收到通知。

用扩展触发条件与故障实验收尾

决定拆分后,需要单独验证新通知通道的接收确认、重复和丢失行为、背压、停止顺序及重启恢复,也要验证多执行者同时领取时的结果唯一性。保留接口让这次改变有清楚的落点;它不意味着更换一个配置就获得已验证的分布式能力。

验证可以从隔离环境的一条固定任务开始。保存输入与配置,记录初始状态,在明确边界注入故障,再检查数据库事实、外部调用记录和使用者可见结果。下面列的是可执行实验设计,尚未执行的项应保留为待验证。

注入方式应当观察的结果失败信号
业务事务提交前终止进程,随后重试受理。未提交工作不存在;按原意图重新受理后仅有一项正式运行。存在没有运行事实的下游工作,或生成重复运行。
提交后、发布前终止,再启动。已受理工作可查,待发送事件最终发出,任务继续。请求已成功受理,工作却永久停滞。
发送后、发布标记前终止,并重复投递。提醒可以重复,正式任务和结果保持唯一。多出业务尝试、重复结果或重复副作用。
发布标记后清空本地在途提醒,再启动扫描。未完成任务通过状态扫描重新获得提醒。转发器认为已发布,恢复器却再也看不到工作。
暂停旧执行者至租约失效,让新执行者领取,再恢复旧者。旧资格写回被拒绝,正式结果来自有效提交。迟到结果覆盖新结果,或两份结果都成为正式结果。
外部系统处理后切断响应连接。依能力查询、幂等重试或记录未知,调用次数与处理策略一致。未知被当成未执行,无依据再次产生副作用。
准备输入中途退出,同时修改外部数据源。按已定义的快照与检查点策略继续;输入来源和边界可解释。同次运行静默混用输入,或执行阶段读取最新配置。
持续填满通知通道并注入扫描异常。背压受控、事件保留,异常可见;依赖恢复后能继续推进。内存无限增长,事件消失,或处理停止仍持续报告正常。

每次实验还应检查:任务是否最终到达允许的终态,正式结果是否唯一,输入关联是否一致,恢复等待是否满足事先设定的预算。外部结果无法判定时,允许的终态可以是明确失败或待核实,不应为了“跑通”强行改成成功。把这些证据留下,下一次容量变化或部署拆分才有可以比较的基线。

由工程实践中的判断整理,使用通用场景说明方法;示意架构、案例和数值不代表某个系统的生产验收或效果数据。

可靠性来自持久状态与恢复依据;拆服务要由测量到的瓶颈推动。

读完,留一句在书桌上

回书桌看看

仅保存在当前浏览器。

图表

窄屏可在图内横向滑动查看细节。