企业应用架构23:综合模式选择与架构评审
一份合同实收 1000 分,客户申请退款 300 分。请求可能重复,退款提交后进程可能退出,结算方收到通知并入账后也可能来不及确认。架构评审需要回答这些具体窗口会留下什么数据,而不是检查设计图中是否出现足够多的模式名称。
本篇将“租户范围内的部分退款”作为独立变化,使用两个 SQLite 文件分别表示租赁与结算持久状态。实验验证单库事务、并发重复请求、持久待发记录及接收方去重;本地函数调用承担传递工作,没有消息中间件、公网、真实支付或新页面。对应能力保留 NOT_RUN,综合章节的发布不改变前章证据边界。
业务契约先于模式选择
退款金额必须为正,累计退款不能超过实收;同一个租户内,同一个退款键重复提交相同金额应返回已有结果,改成不同金额必须拒绝;不同租户可以使用相同合同编号。合法退款在租赁库提交后,最终应在结算库形成同额入账,重复投递不能增加结算金额。
样例以 A、B 两个租户,各自拥有合同 1,实收均为 1000 分。主体 alice 只属于 A,bob 只属于 B。此映射沿用教学边界,不代替真实认证。所有金额固定 CNY 整数分,不涉及部分履约、支付通道费用、税务冲销或跨币种退款。
这项变化无需先引入完整事件溯源。业务只要求当前累计退款、退款请求去重和可靠推进结算,保留 contracts、refunds 与 outbox 即可。将事件流作为唯一事实来源会增加版本演化和重放语义,当前需求没有提供足以承担这些成本的理由。
也无需因为存在两个持久参与方就立刻设计任意补偿流程。实验中的结算账本只记录已经接受的退款结果,没有真实外部资金移动;发生真实退款后能否撤销属于业务与支付通道契约,不能从数据库回滚能力推出。若进一步引入银行退款,必须新增外部状态查询与人工处理路径。
最小提交边界
refund 先校验主体租户关系,再开启 BEGIN IMMEDIATE。事务内读取已有退款键;命中相同金额则结束,不再修改合同。新请求执行带上限条件的 UPDATE,要求受影响行数恰为 1,然后写 refunds 和 outbox 并提交。退款记录和待发记录因此与合同累计值在同一事务中出现。
上限检查放在实际 UPDATE 条件中,避免先在应用层比较、再在另一个写入窗口修改。SQLite 在本实验中通过写事务串行化这段竞争;这不是 PostgreSQL 行锁或分布式锁的验证。两个线程各自建立独立连接,以屏障同时开始重试,数据库最终只保留原来的 300 分。
金额非法、跨租户和同键不同载荷都是业务拒绝,不应进入成功重试。实验尝试 alice 给 B 退款、在已有 300 分后再退 800 分、把 r1 改为 301 分,三者均被拒绝;随后查询 B 的累计值为零,A 的退款记录数仍为一。错误返回与无副作用必须同时验证。
下图只表示两个本地数据库之间的持久边界。传递步骤是本地测试程序,不表示经过 broker 或 HTTP 网络。
flowchart LR
C[退款请求] --> A[授权与金额检查]
A --> T[租赁事务]
T --> R[(合同累计退款)]
T --> K[(退款键与金额)]
T --> O[(Outbox)]
O --> D[本地投递程序]
D --> I[(结算Inbox)]
I --> L[(结算账本)]
两个退出窗口需要分别演练
第一个故障点位于租赁事务提交之后、投递之前。子进程提交 300 分退款和 outbox 后以退出码 74 结束。父进程确认退出码,再通过新的连接执行并发重试。Outbox 保留下来,退款本身不会因请求方退出而消失,也不会因重试重复计入。
第二个故障点位于结算事务提交之后、租赁库确认之前。接收方把退款键写入 inbox,并把账本增加 300 分,提交后退出码为 75。租赁库仍把 outbox 记为未发送,恢复时再次投递同一条消息。接收方唯一键冲突使插入行数为零,因此跳过第二次账本增加。
接收方提交和发送方确认不在一个数据库事务里。这正是重复投递窗口存在的原因。实验不把这个窗口隐藏成一次函数成功调用,而是实际退出子进程,重新运行投递逻辑,再查看两个数据库。最终退款累计为 300、结算累计为 300、待发数为零。
下图回答确认缺失为何不等于业务失败。接收方已经提交,发送方却仍需重试;丢失的是确认结果,不是已经完成的账本修改。
sequenceDiagram
participant S as 租赁Outbox
participant W as 投递进程
participant R as 结算事务
W->>S: 读取未发送退款
W->>R: 插入Inbox并增加账本
R-->>W: COMMIT
W--xW: exit 75
W->>S: 重启后再次读取
W->>R: 重复退款键
R-->>W: 已处理,不增加账本
W->>S: 标记已发送
这个结果支持受限的重复处理安全性。它不支持全局 exactly-once:程序确实重复执行了投递,只有受幂等边界保护的账本副作用没有重复。若接收方在提交前另行调用外部支付服务,当前 Inbox 无法把两个资源变成一个原子事务,需要继续处理新的不确定窗口。
删除关键边界,测试应该失败
综合评审不能只展示所有测试通过,还应确认关键保护被破坏时测试会报警。实验提供 --mutant-no-idempotency,跳过已有退款键的提前返回。两个并发重试依次修改合同,最终累计值变成 900 分,正常契约要求的 300 分断言失败,进程退出码为 1。
变体仍然有退款金额上限,也仍然使用数据库事务。因此它清楚说明:局部原子性和不超实收不能代替业务幂等。900 小于 1000,数据库不会因为“这很可能是重复请求”自动拒绝。只有租户、请求键和载荷一致性共同表达了重复语义。
这个反例也说明测试不能只检查最后状态是否合法。若测试仅断言累计退款不超过 1000,错误实现会通过;若只检查 refunds 表中一条记录,INSERT OR REPLACE 也可能掩盖重复累计。关键可观察量应直接对应用户损失,即金额是否只增加一次。
实验同时比较一个没有收益的包装层。–with-facade 把重试先送入 RefundFacade.execute,该方法只转发参数;默认路径直接调用 refund。两种路径运行相同故障、恢复和金额断言,结果相同,默认实现因此移除这一层运行时调用。包装类仅保留在实验对照分支中,不承担生产设计角色。
质量情景如何进入评审
“可靠”“安全”“可扩展”都需要进一步写成可执行情景。本例的可靠性刺激是提交后退出,响应是重启后继续处理,度量是最终待发数零且两个账本均为 300;隔离刺激是 alice 声明 B,响应是拒绝,度量是 B 金额零;一致性刺激是同键不同金额,响应是拒绝,度量是退款记录仍一条。
性能目标没有经过测量,因此不填写吞吐量或百分位延迟。线程屏障用于控制竞争开始点,不能代表真实高并发负载。安全验证只覆盖固定主体映射和 SQL 条件,不能推断系统已经抵御令牌盗用、数据库管理员误操作或操作系统入侵。
ADR 的主决策是保留应用服务、事务、租户范围幂等键与 Outbox/Inbox,暂不采用事件溯源、通用规则引擎或多层转发包装。替代方案之一是同步调用结算后才返回,但它仍然需要处理调用方超时与接收方已提交的不确定性;另一方案是跨资源事务,当前实验未部署对应设施,也没有证明其运维成本合理。
评审还必须检查数据保留与恢复假设。幂等记录若过早清理,延迟重试会重新产生退款;Outbox 如果在备份恢复时丢失,单看合同金额无法判断消息是否需要重发。实验没有覆盖幂等过期、备份还原和跨机灾难恢复,不能因为本地进程重启成功就将这些故障划为已解决。
累计证据不能由最后一篇代签
系列中的领域规则、SQL 映射、HTTP、页面、消息运行时和引擎各有自己的验证面。综合退款实验没有调用前章页面,也没有经过前章消息通道,因此它不能为那些场景补签。完整交付需要逐章核对运行命令、退出码、非空原始日志和未覆盖项,并在全部源码固定后重新运行累计入口。
目录中存在 RUN.md 只表示有一份报告。应打开报告引用的原始文件,确认二值断言来自当次执行;截图证明展示结果,SQL 证明数据库交互,浏览器请求证明真实页面操作,它们不能互相替换。任何缺失的证据都应保留未完成状态,而不是用“系列已收尾”消除。
本章独立实验的报告会保留累计回归、页面和真实消息运行时的核对边界。前置批次并行准备不等于依赖门槛已经通过,只有最后集成验证才能推进整体状态。这让读者能够区分一个可复现的教学实验与完整生产交付。
运行入口
1 | |
1 | |
正常命令应以零退出并输出 recovery-and-no-invalid-effects=PASS refunded=300 ledger=300 pending=0 B=0;变体应以一退出并出现 observed-refunded=900 与 duplicate-refund。带 --with-facade 的对照也必须通过完整场景,才能支持删除无收益调用层的判断。
源码入口 lab.py,refund、deliver 与 main 分别组织提交、恢复和故障演练。证据目录 examples/enterprise-application-architecture/evidence/23/ 保存每个命令的 stdout、stderr、退出码、环境和源码摘要。业务契约、ADR、质量情景与累计状态核对入口分别见同编号 models 与 RUN 文档。
参考资料
实验下载:独立实验与原始证据包
前篇:企业应用架构22:Strangler 与 Branch by Abstraction
- Transactional Outbox,本地事务与后续转发的分工。
- Idempotent Consumer,重复处理记录与业务修改的事务边界。
- SQLite Transactions,本地事务行为;具体构建版本见环境记录。

