2024 年,所在公司的 PDF 报告定制需求不断增加,业务范围也逐步扩大,对用户数据安全和审批流程的要求随之提高。

原有流程基于飞书多维表格、审批流程和 HTML 转 PDF 工具搭建。随着业务深化,这套流程在权限控制、报告管理和系统集成上的能力逐渐难以满足需求,因此由我负责报告生成与审批系统的架构设计。

系统既需要支持当时的报告业务,也需要保留未来接入 LIMS 系统和移动端应用的可能性。在功能之外,一个需要尽早回答的问题是:当业务操作涉及多个服务时,应该为一致性付出多少实现和运维成本?

一、服务边界与事务边界是两回事

当时从领域建模和系统职责划分出发,将系统拆为授权、基础、框架、网关、存储和报告六类服务,服务间通过 Apache Dubbo 进行 RPC 通信。

回头看,这一划分同时包含业务职责与技术职责。网关、框架等技术模块的独立部署,并不能直接证明它们构成了 DDD 中的限界上下文;DDD 本身也不要求采用微服务。

未来需要接入 LIMS 和移动端,意味着系统需要清晰的接口和可演进的业务边界,但并不必然要求在第一阶段就把所有模块拆成独立进程。这是当时架构选择中值得继续复盘的一点。

微服务也不意味着每次跨服务调用都需要分布式事务。查询多个服务的数据,或者发送允许稍后完成的通知,与要求多个服务的写操作共同成功或失败,是不同的问题。

真正需要处理的是:一个业务操作修改了多个服务拥有的数据,而业务又要求这些修改保持一致。

以报告流程为例,报告状态更新、文件生成和通知发送可能发生在同一条业务链路中。但它们是否必须同时完成,需要由业务规则决定。这个例子用于说明一致性要求的区别,并不代表这些步骤在当时都被放进了同一个事务。

通知失败或许可以重试;报告未生成时,系统是否允许进入某个审批状态,则需要更明确的约束。不能仅凭“属于同一个流程”,就把全部操作纳入强一致事务。

二、评估 Seata,先理解它解决什么

在方案调研中,Seata 是一个重要参考。它提供 AT、TCC、Saga 和 XA 等事务模式,但这些模式对业务侵入、数据库支持和一致性语义的要求并不相同。

以 AT 模式为例,参与全局事务的服务会在本地事务中修改业务数据,并记录用于回滚的 undo log,随后提交本地事务。事务协调器根据全局事务的结果组织提交或回滚;需要回滚时,参与者利用 undo log 撤销已经提交的修改。

这套机制还涉及全局事务上下文传播、分支事务注册和全局锁,不能简化为“调用方抛出异常,再通知被调用方回滚”。它的隔离效果也不能直接视为与单数据库本地事务完全相同。

对于当时的系统,微服务已经拆分,但项目初期的使用量并不大。引入事务协调中间件,除了部署资源,还需要承担配置、监控、故障恢复和版本兼容的成本。

因此,当时没有直接引入 Seata。这个选择应建立在业务一致性要求与团队运维能力之上,而不是用厂商声誉判断产品是否合适,也不能把某次部署的内存占用当作产品固定的资源需求。

不引入协调器,并不会让一致性问题消失。系统仍需要明确哪些操作由本地事务保证,哪些失败允许补偿,以及哪些场景尚未获得跨资源原子提交的保证。

三、当时采用的三类处理方式

当时的实现主要采用了异常记录、共享数据库和动态多数据源三类方式。它们分别降低了不同方面的成本,但不能统称为三种等价的分布式事务方案。

异常记录:为人工恢复保留依据

对于当时判断可以接受暂时不一致、且能够事后修复的操作,主要通过日志保留执行结果和错误信息,再由人工处理异常。

这种方式适合低频、可核对、可恢复的业务,但日志本身并不保证一致性。“非核心功能”也不意味着失败没有损失,仍需要判断失败影响是否可接受。

要让人工恢复成为可靠机制,后续还需要明确如何发现失败、关联业务对象、识别已经完成的步骤,以及如何避免重复补偿。这些能力应单独验证,不能因为写入了错误日志,就默认已经形成恢复闭环。

共享数据库:减少跨库访问

授权与框架服务使用同一个数据库,减少了跨数据库访问和维护多个数据源的复杂度。

但共享数据库不等于共享事务。两个服务即使连接同一个数据库,只要通过各自的连接执行并提交本地事务,就仍然可能出现一个成功、另一个失败的情况。普通 Dubbo 调用不会自动传播调用方的数据库事务。

如果希望通过本地事务保证一组写操作共同完成,需要把这些操作收敛到同一个事务边界中,例如由同一个服务负责执行,并通过同一事务管理器、同一事务资源完成提交。

所以,共享数据库只是减少跨库复杂度的手段。它还可能增加服务之间的数据耦合;后续若要独立演进,仍需明确数据归属和访问边界。

动态多数据源:集中执行多库操作

对于需要访问多个数据库的核心业务,引入 Baomidou 的 Dynamic Datasource,让一个服务能够路由到不同数据源,执行相关操作。

这可以减少一部分跨服务调用,但数据源路由与事务协调仍是两回事。一个进程能够操作多个数据库,并不代表这些数据库可以原子提交。

如果采用多连接的本地事务组合,也需要检查具体实现的提交机制和失败边界。依次提交时,可能出现前一个数据库已经成功提交,后一个数据库提交失败的情况,不能仅靠统一捕获异常就把所有已提交修改撤销。

因此,动态多数据源可以作为收敛执行位置的工具,但不能直接当作分布式事务的替代品。涉及跨库核心写操作时,仍需要明确部分提交后的处理方式,或者引入能够满足业务要求的协调机制。

四、先把一致性要求写成业务规则

这次设计更值得提炼的经验,是先回答“哪些状态不能出现”,再选择事务方案。

不同操作可以有不同的一致性要求:

业务要求 优先考虑的处理方式 必须确认的边界
一组数据库写入必须共同成功或失败 收敛到本地事务;无法收敛时评估分布式协调 数据是否确实参与同一个事务,失败后能否回滚
允许暂时不一致,但必须最终完成 持久化待处理状态,结合重试和补偿 任务不能丢失,重复执行需要安全
低频且允许人工恢复 异常记录、核对和人工补偿 失败能被发现,责任人和恢复步骤明确

这张表是后续评估的依据,不代表当时已经完整实现了所有机制。

报告系统还涉及对象存储等非数据库资源。数据库事务回滚,并不会自动删除已经生成的文件。即使引入 Seata AT,也不能直接假定普通对象存储操作会被一并回滚。

如果某个流程需要协调数据库状态与文件生成,可以考虑将“待生成、生成成功、生成失败”等状态显式建模,再对失败步骤进行重试或补偿。若通过消息驱动后续处理,还要解决业务状态提交与消息发送之间的可靠性问题,例如评估事务型 Outbox。

这些都是需要结合具体业务验证的演进方向,不能用一个中间件名称替代完整的失败分析。

五、为演进留空间,不等于已经具备接入能力

当时注册中心与配置中心选择了 Nacos,也保留了后续评估 Seata 的方向。但采用 Nacos 本身,并不能证明系统已经具备接入 Seata 的条件。

真正的接入工作还包括事务边界梳理、数据源代理、事务上下文传播,以及对 SQL 支持、并发冲突和故障回滚的验证。涉及数据库之外的资源时,还需要单独设计处理方式。

更有价值的准备,是把业务状态和失败语义定义清楚:哪些步骤可以重试,哪些操作需要幂等,哪些状态需要补偿,以及失败后由谁发现和处理。

当跨服务原子性成为明确要求,或者人工恢复的频率、成本和风险已经无法接受时,再评估 Seata、TCC、Saga 等机制,选择才有具体依据。

六、架构取舍也需要验证失败路径

李智慧在《大型网站技术架构》中强调,大型网站的架构往往随着业务规模逐步演化。这一点也适用于业务初期的一致性设计:不必为假设中的规模提前引入所有基础设施,但必须知道当前方案能够保证什么。

这次实践的价值,是根据当时的业务量和资源约束控制复杂度;它的边界则是,共享数据库和动态多数据源降低了部分实现成本,却没有自动提供跨服务、跨数据库的原子性。

验证这类设计,需要主动检查失败路径:RPC 超时但远端已经提交、数据库依次提交时中途失败、文件生成成功但状态更新失败,以及重复重试是否产生副作用。

从现有需求出发,既要避免过度建设,也要避免把尚未验证的一致性能力当作既成事实。先定义业务必须维持的状态,再收敛事务边界;剩余的跨资源问题,通过可验证的补偿或协调机制处理。