本节目标:把「生产级图书借阅服务」的功能性需求与非功能性需求翻译成一组可评审的架构决策记录(ADR),并给出模块依赖图与容量预算,为后两节的实现与上线立好地基。
适用版本:Spring Boot 4.1.x(Java 21)
18.1 需求与架构
前 17 章把同一个「图书借阅服务」从单体一路做到可观测、可调优。本节不再引入新框架,而是把前面所有决策收敛成一次可交付的架构。真实项目里的事故很少来自「某个注解写错」,多半来自需求没被翻译成约束:没人写清并发量级,连接池就按默认值上了生产;没人写清一致性要求,「缓存双写」的雷要三个月后才爆。
所以这一节的核心动作只有一个:把「需求」转成「决策」,并把每个决策的理由、代价、被否决的方案记下来。格式借用轻量版 ADR(Architecture Decision Record)。
18.1.1 功能性需求
需求来自产品线,但要落成可测试的规则。下表是本次交付的范围(相比前面章节的单体版本,新增了续借、逾期规则与对账):
| 编号 | 需求 | 关键规则 | 前序章节 |
|---|---|---|---|
| F1 | 借书 | 同一会员同时借阅不超过上限;同一册书只能被一人持有 | 5.3 幂等与并发控制 |
| F2 | 还书 | 重复还书返回同一结果,不报错 | 5.3 幂等与并发控制 |
| F3 | 续借 | 仅在未逾期且未被他人在等待队列预订时允许,续借次数有上限 | 12.2 乐观锁与悲观锁 |
| F4 | 逾期计算 | 按「应还日」与当前时间差计算罚金,规则随分馆可配 | 13.1 定时任务选型 |
| F5 | 查询 | 按会员、书目、状态分页检索,馆员与读者可见范围不同 | 5.2 分页、过滤与排序 |
| F6 | 对账 | 每日生成「借出—归还—在馆」三态流水,供馆员核对 | 13.2 Spring Batch |
需求里最容易漏的是边界规则。F3 的「未被他人在等待队列预订」这种条件如果不在需求阶段写清,实现时就会被忽略,等到线上出现「热门书被无限续借」的投诉才补——那时改的是核心借还逻辑,回归成本极高。
18.1.2 非功能性需求
非功能性需求不是「锦上添花」,它们直接决定架构形状。逐条量化,宁可给区间也不留空:
| 编号 | 维度 | 目标 | 对架构的影响 |
|---|---|---|---|
| N1 | 并发量级 | 峰值借还操作约数百 TPS,读查询高一个数量级 | 连接池、缓存、读写分离 |
| N2 | 可用性 | 核心借还接口月度可用性目标 99.9% | 多实例、无状态、健康探针 |
| N3 | 一致性 | 借还账务强一致;书目检索可最终一致 | 同步边界 vs 异步边界 |
| N4 | 合规与审计 | 借阅流水保留并可追溯,敏感字段脱敏 | 审计日志、字段级权限 |
| N5 | 多租户 | 分馆之间数据互不可见,可独立配置规则 | 租户隔离模型 |
| N6 | 可运维 | 可灰度、可回滚、可观测 | 探针、指标、迁移策略 |
N3 是本节最关键的约束:它把系统劈成两半。借还账务(F1/F2/F3)要求「扣减在借数、写入借阅记录、更新书状态」三步原子,必须走同一个数据库事务;而检索与统计(F5/F6)能容忍秒级延迟,可以走缓存与异步投影。把这条线画错,要么账务出现脏数据,要么检索被事务锁拖死。
18.1.3 架构决策记录
下面每条 ADR 都用「决策 / 理由 / 被否决方案 / 代价」四段式。这才是架构文档该有的样子——不是「我们用了 Redis」,而是「我们为什么用、不用什么、为此付出了什么」。
ADR-01:模块拆分——按业务能力切分,不做技术分层拆分。
沿用 1.1 何时该拆模块 的判据:有独立的业务生命周期、独立的变更频率、独立的负责人时才拆。本次切成四个模块:
catalog:书目与馆藏(书、册、可借状态)circulation:借、还、续借、逾期(核心账务)member:读者与权限reporting:对账与统计(只读投影)
被否决的方案是「按 controller/service/dao 切成三层模块」。理由:三层模块之间是全连接的网状依赖,任何一层改动都会牵动所有模块重新编译,等于把单体拆成了三个更小但互相缠死的单体。
ADR-02:数据模型——借阅记录是事件流,不是可变状态。
loan 表保存每一次借还动作的完整记录,而不是只存「当前是否借出」的布尔值。理由是 F6 对账需要历史流水,N4 合规需要可追溯。当前状态通过最新记录推导,并冗余一份到 book_copy.status 供高频查询。
被否决的方案是「一张表存当前状态 + 一张审计表异步记录」。理由是审计表与主表可能不一致,而对账场景恰恰要求两者严格对齐;用同一事务写主记录与状态字段,能保证不漂移。代价是 loan 表持续增长,需要按时间分区(见 ADR-06)。
ADR-03:同步 / 异步边界——账务同步,通知与投影异步。
借书动作在一个本地事务内完成三件事(校验上限、写 loan、更新 book_copy),不做跨服务的分布式事务。见 12.1 事务边界
的结论:能用一个本地事务解决的,绝不上分布式事务。
异步部分只承担两类工作:借阅成功事件(通知、积分、统计)和检索投影(更新搜索索引)。它们通过事务性发件箱(outbox)投递,保证「账务提交则事件必达」。见 10.3 可靠投递 。
被否决的方案是「借书成功后直接在业务方法里发 MQ 消息」。理由是消息发送与事务提交之间存在窗口,进程崩溃会导致「借阅成功但事件丢失」,对账时对不上。
一次借书的同步路径固定为三步,全部在同一个事务内:
POST /api/loans
-> 校验:会员在借数 < 上限?该册书状态可借?
-> 写 loan 记录(status=ACTIVE)
-> 更新 book_copy.status=LOANED,在借数 +1
-> 同事务写 outbox 事件(LoanCreated)
COMMIT 之后:
-> 投递器读取 outbox,发 MQ,标记已投递
-> 订阅方更新检索索引、发通知、记积分
关键点在最后两行:事件与账务在同一事务写入,投递是提交后的事。这样即使投递器崩溃,事件也留在 outbox 表里,重启后继续投递,不会丢。代价是 outbox 表需要监控积压。
ADR-04:缓存策略——只缓存可容忍陈旧的读,账务读不缓存。
书目详情、分馆规则配置这类「改得少、读得多、短暂陈旧可接受」的数据进缓存;借阅余额与在借数不缓存,因为它们是账务判断的依据,缓存陈旧会导致超额借阅。缓存 key 必须带租户维度,见 8.3 缓存一致性 与 9.3 多租户 。
被否决的方案是「借阅余额也缓存、用短 TTL 兜底」。理由是 TTL 窗口内的超借是真实发生的资损,而余额查询本身走主键索引足够快,不值得为它引入一致性风险。
ADR-05:多租户隔离——共享库 + tenant_id 行级隔离起步。
分馆是租户。按 9.3 多租户
的取舍表,从「共享库 + 租户字段」起步,用 Hibernate 的 @TenantId 自动追加租户条件。理由:分馆数量多、单馆数据量不大,独立库会让连接池与迁移成倍复杂。
被否决的方案是「每租户独立 schema」。理由是当前没有单个大客户提出强合规要求,提前上 schema 隔离只增加运维成本。升级路径要留好:所有表都带 tenant_id 且进主键或索引前缀,将来真需要独立库时,按租户导出即可,不必重构模型。
ADR-06:技术选型——沿用 4.1.1 主线,迁移工具选 Flyway。
| 组件 | 选型 | 版本口径 | 理由 |
|---|---|---|---|
| 运行时 | Spring Boot | 4.1.1 / Framework 7.0.9 | 全卷主线,Java 21 基线 |
| Web | Tomcat(默认) | 11.0.24 | 无特殊需求,不引入 Undertow |
| 持久化 | Hibernate ORM | 7.4.5.Final | @TenantId、@Version 直接可用 |
| 连接池 | HikariCP | 7.0.2 | 默认且足够,见 11.1 |
| 迁移 | Flyway | 12.4.0 | 需引入 spring-boot-starter-flyway |
| 安全 | Spring Security | 7.1.1 | JWT 资源服务器,见 9.2 |
注意 4.0 的模块化变更:Flyway 不再随 spring-boot-starter-data-jpa 附带,必须显式引入 spring-boot-starter-flyway,否则迁移脚本不会执行。这一点在升级时最容易被漏掉。
ADR-07:接口契约——先定契约再写实现,版本从第一天就规划。
借还接口一旦上线就有外部消费方(App、馆员端、第三方分馆系统),字段语义不能随手改。用 7.2 契约优先 的做法:OpenAPI 契约先评审、再生成骨架,见 7.1 OpenAPI 与 springdoc 。破坏性变更(删字段、改语义、收紧校验)必须升版本,见 7.3 API 版本化 。
被否决的方案是「先写实现、注解反推文档」。理由是反推出来的契约缺语义(哪些字段可空、错误码怎么变),消费方只能靠试。
ADR-08:可观测性——指标与日志在实现层就埋,不留到上线补。
16.1 Actuator 与指标
讲过的 RED 指标与业务指标,必须在写业务代码时一并埋点,否则上线后「借出成功率」这种关键指标根本没数据。关键路径上的日志必须带 tenantId 与 traceId,见 16.2 链路追踪
。
被否决的方案是「先上线、有需要再加监控」。理由是故障排查依赖历史数据,等出事才加埋点,第一现场的数据永远拿不回来。
18.1.4 模块与依赖图
依赖方向必须单向,reporting 依赖别人,别人不依赖 reporting:
+-----------+ +--------------+
| member |<-------| circulation |<-----+
+-----------+ +--------------+ |
^ ^ |
| | |
+-----------+ +--------------+ |
| catalog |<-------| reporting |------+
+-----------+ +--------------+
用表格把依赖与调用方式写清楚,避免「谁都能调谁」:
| 模块 | 依赖 | 调用方式 | 一致性 |
|---|---|---|---|
| circulation | catalog, member | 同步接口 | 强一致(同事务) |
| circulation | — | 发布借阅事件 | 最终一致(outbox) |
| reporting | circulation, catalog | 读投影 | 最终一致 |
| catalog | — | 无 | — |
circulation 是核心,它不反向依赖 reporting;统计需要数据时,由 reporting 订阅事件或读只读副本,绝不把统计逻辑塞回借还路径。
18.1.5 容量与预算粗算
架构评审需要一份量级估算,避免「拍脑袋上配置」。以下数字都是假设,用于推导资源,不是实测结果:
| 项 | 假设 | 推导 |
|---|---|---|
| 分馆数 | 数十个 | 单库承载,行级隔离 |
| 每馆读者 | 数万 | 会员表量级百万内 |
| 每馆藏书 | 数十万册 | 馆藏表量级千万内 |
| 峰值借还 | 数百 TPS | 连接池按此设上界 |
| 年借阅流水 | 千万行级 | loan 表需按时间分区 |
| 单次借还事务 | 毫秒级 | 主键 + 唯一约束命中 |
据此推导三条预算:
- 连接池:峰值 TPS × 单事务耗时 = 需要的并发连接数。若单事务毫秒级、峰值数百 TPS,池大小按几十配置即可,具体在 11.1 HikariCP 调优 里按实测收敛,不要照抄默认 10 或盲目放大。
- 存储:
loan按年千万行、每行数百字节估算,年增容量在 GB 量级,需要归档策略而非无限保留。 - 迁移窗口:千万行表的加列、加索引会锁表,必须用向前兼容写法(见 18.3),并安排在低峰。
分片边界先不引入。按 11.3 分片边界 的判据,当前量级单库 + 索引完全够用,分片是「单库写压力到瓶颈」才做的动作,提前分片会换来跨分片查询与分布式事务的复杂度。
18.1.6 需求到决策的追溯
评审时最常被问的一句是「这条需求由谁兜」。把需求和决策对上,缺口一眼可见:
| 需求 | 承接决策 | 落点 |
|---|---|---|
| F1 借书上限 | ADR-03 事务、ADR-02 模型 | circulation 同步事务 + 唯一约束 |
| F2 幂等还书 | ADR-02 状态机 | loan.status 吸收态 |
| F3 续借条件 | ADR-02 模型 | 续借计数 + 预订检查 |
| F4 逾期罚金 | ADR-03 异步 | 定时任务 + 规则配置 |
| F5 分范围查询 | ADR-05 租户 | @TenantId + 字段级权限 |
| F6 对账 | ADR-03 异步 | reporting 投影 + 批处理 |
| N1 并发 | ADR-01/04/06 | 模块隔离 + 缓存 + 连接池 |
| N2 可用性 | ADR-06 选型 | 无状态多实例 + 探针 |
| N3 一致性 | ADR-03 边界 | 账务同步、投影异步 |
| N4 合规 | ADR-02 模型 | 事件流保留 + 审计 |
| N5 多租户 | ADR-05 隔离 | 行级隔离 + 升级路径 |
| N6 可运维 | ADR-07/08 | 契约 + 埋点 + 灰度 |
如果某条需求在这一栏找不到承接决策,说明它还悬在空中——这类「没人认领的需求」正是上线后暴露问题的来源。
18.1.7 风险与开放问题
架构文档还要敢于写下「我还不确定什么」。本次交付前未闭环的点:
| 风险 | 影响 | 缓解 |
|---|---|---|
loan 表增长快 | 查询变慢、归档复杂 | 按时间分区,定期归档冷数据 |
| 逾期罚金规则频繁变 | 硬编码会反复改代码 | 规则配置化,随分馆下发 |
| outbox 表积压 | 投影延迟、对账滞后 | 监控投递延迟,积压告警 |
| 租户升级到独立库 | 迁移窗口长 | 表结构预留 tenant_id 前缀 |
| 缓存与账务的边界 | 误缓存导致超借 | 评审时逐字段确认「可陈旧」 |
把这些写下来不是示弱,而是让评审聚焦在真正不确定的地方。一个「什么都已确定」的架构文档,通常意味着风险被藏起来了。
18.1.8 常见坑
- 把非功能需求留空。 不写并发量级与可用性目标,评审就没有判据,最后变成「谁声音大听谁的」。
- 只记「选了什么」,不记「否了什么」。 半年后新人会重新提一遍被否决的方案,因为没人知道当初为什么不用。
- 一致性要求一刀切。 全系统都上强一致会拖死性能,全都最终一致会让账务出脏数据,必须按业务分。
- 提前分片。 量级没到就分片,换来的是跨分片查询与分布式事务,成本远超收益。
- 架构图只画模块不画依赖方向。 没有方向的依赖图,等于默许双向调用,模块隔离名存实亡。
- 把风险藏起来。 文档里写「一切已确定」,评审就无从下手;敢于列开放问题,才是对交付负责。
小结
- 架构的输入是非功能性需求,尤其是一致性要求——它决定了同步与异步的边界线画在哪。
- 每条决策都要写清理由、代价与被否决方案,否则半年后没人知道当初为什么这么选。
- 账务走同步本地事务,通知与投影走异步 outbox;缓存只覆盖可容忍陈旧的读,账务读不缓存。
- 多租户从「共享库 +
tenant_id」起步,但把升级到独立库的路径预留好。 - 容量估算是量级推导,用于定配置上界,不是性能承诺;分片等到单库真到瓶颈再做。
- 接口契约与可观测性属于「第一天的决策」,不是上线前的补课项。
需求与决策定下来,18.2 会把它们落成代码:分层骨架、跨模块契约、并发与异步的具体写法,以及一套可复现的本地联调流程。
阅读导航:上一节:17.3 JVM 调优 · 下一节:18.2 实现与联调 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。