16-业务系统边界与架构取舍
业务系统怎样划边界?模块、权限、审批与微服务取舍
把订单、审批和用户拆成三个服务,不会自动让系统更清楚。数据权限怎么贯穿查询,审批完成怎么与业务状态一致,拆分后事务怎么恢复,才是决定架构是否可维护的问题。
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 能阻止重复提交吗?
答:不能默认依赖它实现唯一性。业务应通过状态、提交轮次和唯一约束限制重复启动。
参考资料
- Martin Fowler:Monolith First
- Microservices.io:微服务架构模式
- Microservices.io:Database per Service
- Microservices.io:Saga
- Microservices.io:Transactional Outbox
- RuoYi-Vue 后台手册:数据权限
- MyBatis:参数与字符串替换
- Spring Security 方法安全
- MySQL UPDATE 语句
- Gateway Server WebFlux 4.3 官方文档
- Gateway 4.3 starter
- Spring Security:响应式安全
- Spring Security:JWT Resource Server
- Activiti 用户指南:引擎 API、BPMN 与 Spring 集成