16-业务系统边界与架构取舍

O泡李华 4

业务系统怎样划边界?模块、权限、审批与微服务取舍

把订单、审批和用户拆成三个服务,不会自动让系统更清楚。数据权限怎么贯穿查询,审批完成怎么与业务状态一致,拆分后事务怎么恢复,才是决定架构是否可维护的问题。

1. 先确定业务能力与拆分理由

先建立边界,再讨论进程数量与通信方式。

单体和微服务的差别不只是部署包数量

模块化单体可以在一个应用内划分清晰的接口、数据访问和职责。微服务进一步把部分边界变成独立部署和网络交互,获得发布与资源控制的独立性,同时承担网络失败和数据一致性成本。

问题 模块化单体 独立微服务
模块调用 进程内调用 网络协议与失败处理
原子操作 同一数据库事务较直接 需要明确跨服务一致性方案
发布 共同发布,可通过模块组织降低耦合 可以独立发布,但接口必须兼容
伸缩 通常整体扩容 可以针对服务扩容
排查 调用栈与本地日志较集中 需要跨服务追踪和关联

独立部署也不是天然的故障隔离。两个服务共享一个数据库、共享同一个连接池容量,或者调用链必须全部成功,仍可能一起受影响。

什么情况值得考虑拆分?

某个模块需要独立扩容,且负载特征明显不同;某个业务团队需要独立发布,当前协调成本已影响交付;某个功能需要独立的可靠性或访问边界;某类任务的资源使用经常影响在线接口。这些都是可以具体描述的动机。

例如商品搜索流量远高于后台商品维护,搜索索引也可以接受短暂延迟,那么查询服务可能具有独立部署价值。相反,五个小模块同一团队开发、同一节奏发布,却为了“架构先进”拆成十个服务,可能只增加维护工作。

没有固定的代码行数、用户量或表数量能够自动决定拆分。应比较当前问题的成本与拆分后的长期成本。

边界应该按业务能力划分

按 Controller、Service、Mapper 分成不同服务,容易把每次业务操作都变成连续网络调用。这些是代码分层,不是业务边界。

订单服务应拥有订单生命周期,库存服务应拥有可售数量及预占规则,支付服务应拥有支付操作和交易确认。边界之间通过明确契约交换信息,避免订单服务直接修改支付服务内部表。

可以用三个问题检查边界:这个规则由谁负责解释?数据最终由谁写入?修改它时,哪些模块总是一起变化?如果两个模块几乎每次都同时修改和发布,拆开后可能只是形成一个分布式单体。

共享基础设施并非一律禁止,但业务数据所有权必须明确。若多个服务随意跨库更新对方表,接口隔离和独立发布的价值就会被削弱。

原来的单库事务会怎样变化?

单体系统可能在一个事务中创建订单、扣减库存并更新支付状态。拆分后,一个服务提交成功,另一个服务失败,不能靠订单服务自己的 @Transactional 回滚远端已经提交的数据。

以库存预占为例,可以设计以下状态:

阶段 订单 库存预占 处理方向
发起请求 PENDING 尚未确认 等待结果或按操作号查询
预占成功 WAITING_PAYMENT RESERVED 允许进入支付
确定库存不足 REJECTED 未预占 结束订单
支付成功 PAID 确认扣减 幂等确认库存
支付超时且最终关闭 CLOSED 释放预占 幂等释放与补偿

“库存调用超时”不等于“没有预占”,应记录待确认。释放预占也不是简单执行一次加库存,要根据预占操作的状态和唯一标识确保不会重复释放。

Saga 等模式通过一系列本地事务与补偿协调业务,但补偿不等同于数据库回滚:已发送的短信无法让用户忘记,已发出的实物也无法通过反向 SQL 立即回到仓库。Saga 模式

对于必须立即满足的强约束,可能需要保留在同一事务边界内,或者选择符合要求的协调方式。不能在拆分后才发现业务不接受最终一致。

查询和运维成本也要纳入决策

单库可以直接联表查询,拆分后跨服务报表可能需要批量聚合、数据同步或专门的查询模型。逐行远程调用补齐字段,很容易产生大量请求,并放大尾部延迟。

运维侧至少需要清楚每个服务的部署版本、配置版本、依赖关系、请求链路、错误率及容量。没有这些能力,故障可能表现为“订单报错”,原因却隐藏在第四层依赖。

可以先拆出边界清晰、变更频率高或资源特征特殊的模块,保留迁移期间的兼容入口。迁移读写流量要有明确权威来源,不能长期让新旧服务同时写同一业务数据而没有冲突策略。

2. 权限必须进入对象访问条件

隐藏按钮不能替代读写条件中的数据范围。

操作权限与数据权限要同时满足

操作权限判断能否执行“查看报销单”或“删除报销单”;数据权限判断能操作哪些报销单。可以把授权条件表达为:身份有效,拥有操作权限,目标落在允许的数据范围内,业务状态允许执行。

例如主管拥有本部门查看权限,员工拥有本人查看权限。两个角色都可以进入同一个详情接口,但返回范围不同。

多租户系统通常还需要租户边界。拥有某部门权限,不能让用户跨租户访问同名部门。租户 ID 必须从可信身份和合法租户选择中确定,不能任意使用请求中的值。

用一组 SQL 看清条件的关系

下面的 MySQL 示例使用独立演示数据库。ID、金额和范围均为示例数据,预期结果根据 SQL 条件推导。

CREATE DATABASE IF NOT EXISTS blog_auth_demo;
USE blog_auth_demo;

CREATE TABLE expense (
  id BIGINT PRIMARY KEY,
  tenant_id BIGINT NOT NULL,
  owner_id BIGINT NOT NULL,
  dept_id BIGINT NOT NULL,
  amount_fen BIGINT NOT NULL,
  status VARCHAR(20) NOT NULL,
  version BIGINT NOT NULL DEFAULT 0,
  KEY idx_tenant_owner (tenant_id, owner_id, id),
  KEY idx_tenant_dept (tenant_id, dept_id, id)
) ENGINE=InnoDB;

INSERT INTO expense(id, tenant_id, owner_id, dept_id, amount_fen, status) VALUES
(1, 3, 7, 10, 10000, 'DRAFT'),
(2, 3, 8, 20, 20000, 'DRAFT'),
(3, 4, 7, 10, 30000, 'DRAFT');

-- 可信身份:租户 3、用户 7,拥有部门 10 的查看范围。
SELECT id, amount_fen
FROM expense
WHERE tenant_id = 3
  AND (owner_id = 7 OR dept_id = 10)
ORDER BY id;

-- 详情也使用相同范围,不能只写 WHERE id = 2。
SELECT id, amount_fen
FROM expense
WHERE id = 2
  AND tenant_id = 3
  AND (owner_id = 7 OR dept_id = 10);

列表预期只返回 ID 为 1 的记录,详情查询 ID 2 预期没有结果。ID 3 虽然用户和部门相同,但租户不同,不能被读到。

括号很关键。错误地写成 tenant_id = 3 AND owner_id = 7 OR dept_id = 10,按运算符优先级就会变成“本人且租户匹配,或者部门匹配”,让其他租户的部门记录也进入结果。

示例只表达一个已经被授予的部门范围。实际系统不能因为用户属于部门 10,就自动授予整个部门查看权限;角色与组织关系需要分别判断。

修改数据时,权限应该进入写入条件

先查权限,再执行不带限制的更新,中间可能发生归属、状态或版本变化。对重要写入,可以将授权范围、业务状态和版本一起放入条件更新。

例如当前用户只允许修改自己的草稿:

UPDATE expense
SET amount_fen = 12000,
    version = version + 1
WHERE id = 1
  AND tenant_id = 3
  AND owner_id = 7
  AND status = 'DRAFT'
  AND version = 0;

第一次执行预期影响一行,再执行相同 SQL 预期影响零行,因为版本已经变化。程序必须检查影响行数。

零行可能意味着目标不存在、权限不符、状态不允许或并发冲突。对外是否统一返回“不可操作”应按业务与信息暴露需求决定;不能为了区分原因,给未授权调用者查询并返回其他用户的完整记录。

这条 SQL 默认其他操作权限已经校验。它不是完整登录系统,也不包括金额上限等业务校验。

哪些入口最容易漏掉?

入口 容易遗漏的地方
详情 只按主键查询
修改、删除 列表有范围,写入 SQL 没范围
导出 使用另一套不带范围的 Mapper
批量操作 只验证第一个 ID,或只看任意一条成功
统计、分页总数 数据列表有限制,总数或金额聚合无限制
附件下载 主单有权限,文件 ID 可直接访问
异步任务 使用请求中的用户 ID,缺少执行时授权规则

数据范围必须在分页和聚合之前生效。先查十条再在 Java 中删除无权限记录,可能导致页面数量、总数和连续翻页都不正确,也可能把敏感数据加载进了不该接触的处理链路。

批量操作可以约定全部成功或明确的逐项结果。如果要求全部成功,应在事务中确认全部目标都满足条件,失败就回滚;不能悄悄只更新部分记录,再返回“全部成功”。

为什么不能直接相信 X-User-Id?

客户端可以自行发送 X-User-Id: 1。如果后端把它当成管理员身份,任何能触达接口的用户都可能伪造身份。

一个可信方案必须说明身份是谁生成的、谁验证、哪些路径允许到达后端,以及怎样防止调用者自行覆盖。常见选择包括:下游独立验证原始访问令牌;网关签发短时内部身份凭证,下游验证签名、发行方、受众和有效期;结合双向 TLS 与严格网络策略验证服务来源。

即便使用网关注入普通 Header,也必须先删除同名外部 Header,并确保后端只接收经过认证的可信入口。只删除 Header 而没有落实后端访问限制,仍然不能建立信任。

下游验证了令牌,也要验证它适用于当前服务,不能只检查签名正确。身份传递同时涉及权限范围,不能把“有一个合法令牌”当成访问全部资源的许可。

路径重写会怎样影响权限?

外部路径 /api/orders/42 被改写成 /orders/42。网关规则使用外部路径,下游规则使用内部路径,如果两边配置不一致,就可能出现“入口放行,下游拒绝”,甚至错误匹配到宽松规则。

检查路由时应同时记录原始路径、匹配路由、改写后路径和目标服务。管理接口、健康接口和业务接口应明确各自暴露范围,不能为方便调试给整个路径树配置匿名访问。

跨域预检与业务授权也不同。允许浏览器发送跨域请求,不等于允许用户读取业务数据。CORS 不能替代认证或资源权限。

3. 审批引擎和业务表怎样协作

流程任务有自己的生命周期,业务状态也必须定义提交与恢复规则。

先区分定义、实例和任务

流程定义是部署的模型,例如报销审批流程。流程实例是某一次运行,例如报销单 42 的第一次提交。任务是流程运行到某个用户节点时产生的工作项。

同一个定义可以启动很多实例,同一个实例可能先后甚至并行产生多个任务。因此业务 ID、流程定义 ID、流程实例 ID 和任务 ID 不能互相替代。

可以保存以下关联信息:

字段 示例 作用
businessId 42 业务单据
submissionNo 1 第几次提交
businessKey expense:42:1 便于流程与业务关联
processInstanceId 引擎生成 定位这一轮运行
processDefinitionId 带版本的定义标识 记录实际使用的模型

businessKey 是关联标识,不能默认引擎会按它禁止重复启动。业务需要通过唯一约束和状态校验限制重复提交。

业务表与流程表怎样一起提交?

提交报销时,需要完成业务校验、更新业务状态、启动流程并保存关联。如果业务写入先提交,流程启动随后失败,页面就会出现“审批中但没有流程”。反过来也可能产生没有业务关联的孤立流程。

当业务 Mapper 与 Activiti Spring 集成共用数据源和适当的 Spring 事务管理器,并且引擎调用确实参与同一事务时,可以在一个本地事务内完成这些写入。仅在方法上加 @Transactional,却使用独立构建的引擎、另一数据源或另一个事务管理器,不能自动得到这个保证。Activiti Spring 事务集成

正确边界应满足:任一步异常向外传播,业务状态、流程启动和关联记录都回滚;事务提交后,三者一起可见。自调用、吞异常和异步线程也可能破坏这个边界。

如果业务和引擎位于不同服务或数据库,应明确采用“提交中”等中间状态、可靠任务与补偿,而不是宣称仍是单库原子事务。

完成任务前必须检查什么?

任务 ID 只是定位参数,不是权限凭证。后端应验证当前可信用户是否为受理人或合法候选人,任务是否仍然活动,属于哪张业务单据,以及业务状态是否允许这次操作。

候选组任务还涉及认领与并发,不能因为用户拥有“审批”菜单,就允许完成任何任务。候选身份、租户、组织范围和业务规则都要参与判断。

完成任务与更新业务状态应处于设计好的事务边界。并发审批、重复点击和撤回竞争可能触发乐观锁或状态冲突,应返回明确结果,而不是盲目重试一个已失效任务。

为什么不该用流程变量保存整个业务对象?

流程变量适合保存路由所需信息,例如金额区间、审批决定和审批人。完整报销明细、附件和复杂实体应由业务表维护,并通过业务 ID 关联。

序列化实体可能引入类版本兼容、敏感数据重复存储和对象过大等问题。审批过程中允许修改哪些字段,也需要定义;对路由有影响的金额应使用提交快照,避免审批时查询到已被改动的金额。

流程定义升级通常不会自动迁移已经运行的实例。保存实际定义版本,才能解释旧申请为什么仍走旧流程,并制定明确的迁移策略。

4. 部署与回滚继续遵守边界

组件健康、版本兼容和数据迁移影响最终可用性。

可复现需要固定哪些东西?

固定应用镜像、数据库版本、Redis 版本和配置修订。正式发布最好记录镜像 digest,避免同一个标签被覆盖后难以定位实际产物。

应用镜像应由已完成构建的产物产生,运行阶段不再联网下载 Maven 依赖。配置从环境或外部文件注入,秘密不写进镜像层。

数据目录使用持久卷,但持久卷不是备份。它可以让容器替换后保留数据,无法防止误删、磁盘损坏或错误业务写入。

健康检查、启动顺序和恢复如何分工?

depends_on 的健康条件可以避免应用过早启动,但不能保证数据库之后永远可用,也不能在下游失效时自动完成业务恢复。Compose 启动顺序。

应用需要连接超时、有限重试与降级。健康状态变成 unhealthy 也不自动意味着 restart policy 会重启它;重启策略和探针行为需要分别理解。

应用健康可以分存活与就绪。存活关注进程是否还能工作,就绪关注能否承接关键请求。若模型供应商临时失败就让所有实例退出,反而会扩大故障。

部署检查怎样做到可追踪?

先检查配置可解析和镜像变量已填写,再启动并观察状态、日志和业务接口。检查命令示例为:

docker compose config --quiet
docker compose ps
docker compose logs --tail=100 app

命令不会替代业务验证。确认版本、登录、数据库迁移、缓存访问和关键接口,记录发布编号。展示配置时避免输出展开后的秘密;优先使用 --quiet 做结构检查。

更新数据库镜像之前先查看兼容与升级说明,备份并验证恢复。应用回滚不应顺带删除数据卷。多个副本、高可用和跨主机编排超出这个最小 Compose 示例的范围。

数据库采用扩展再收缩

假设原字段是 status,需要迁移到更明确的 status_code。先添加可空字段,保持旧应用可运行:

ALTER TABLE orders ADD COLUMN status_code VARCHAR(32) NULL;

这只是结构示例。实际 MySQL 版本、表规模、DDL 算法和锁影响需要在接近生产环境中验证,不能从一句 ADD COLUMN 推断完全无阻塞。

随后发布兼容版本,按规则写入新字段并保留旧字段兼容;再分批回填历史数据,检查转换规则和并发写入。确认全部读写路径迁移后,才考虑收缩旧字段。

不要在同一次发布里同时删除旧列、更新所有实例并假设不会失败。回滚窗口需要旧结构和旧语义仍可用。

发布过程保留观察与停止点

先在少量实例或限定流量验证,观察错误率、关键业务、数据库负载和后台任务,再扩大范围。发布指标按新旧版本区分,避免整体平均掩盖新版本故障。

如果失败,先暂停扩大发布,确认故障来自配置、代码、结构还是数据。配置错误可能用修复配置恢复;应用逻辑错误可以回到已知产物;不兼容数据变化需要更谨慎处理。

发布记录保存旧新镜像 digest、迁移版本、配置修订和业务检查结果。回滚使用已验证产物,不临时从不明确分支重新构建。

5. 设计判断与适用边界

先采用清楚的模块接口,不允许不同模块随意修改彼此内部状态。需要同库原子提交的事实尽量在明确事务边界内处理;拆成远程服务后重新设计事件、补偿和状态查询,不保留一个已经不存在的本地事务假设。

业务权限与服务认证分开。网关证明调用来自某个客户端,不直接证明该用户可以读取某个订单。工作流变量不作为完整业务数据库,流程完成之前再次核查任务归属和业务状态。架构选择用需求和恢复成本解释。

Martin Fowler 讨论先建立单体再逐步拆分的取舍。这是架构判断的参考观点,不能替代具体团队、业务边界与运维条件。 Martin Fowler:Monolith First。

6. 问答

问:项目规模小,是否应该直接使用微服务?

答:看独立部署和团队协作的需求。如果收益不明确,先做好模块化单体,通常能降低开发和运行成本。

问:服务拆开后,Spring 事务能一起回滚吗?

答:本地事务不能自动回滚其他服务已经提交的操作,需要设计跨服务一致性和补偿,或重新调整事务边界。

问:UUID 不容易猜,能替代数据权限吗?

答:不能。标识可能通过日志、分享链接或其他接口泄露。是否允许访问必须独立判断。

问:列表已经按本人过滤,详情还要检查吗?

答:要检查。详情可以被直接请求,每一种读取和写入路径都需要相应范围条件。

问:流程实例结束,业务就一定审批通过了吗?

答:不一定。流程可能通过、拒绝、取消或异常终止。业务必须明确记录结果,不能只看实例是否还存在。

问:businessKey 能阻止重复提交吗?

答:不能默认依赖它实现唯一性。业务应通过状态、提交轮次和唯一约束限制重复启动。

参考资料