《Spring Boot 实战》18.1 需求与架构

把生产级图书借阅服务的功能性需求与非功能性需求翻译成一组可评审的架构决策记录(ADR):模块拆分、数据模型、同步与异步边界、缓存策略、多租户隔离方式与技术选型,逐条给出理由、代价与被否决方案,并附模块依赖图、需求追溯表、风险清单与容量预算粗算,作为后两节实现与上线的地基。

本节目标:把「生产级图书借阅服务」的功能性需求与非功能性需求翻译成一组可评审的架构决策记录(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 Boot4.1.1 / Framework 7.0.9全卷主线,Java 21 基线
WebTomcat(默认)11.0.24无特殊需求,不引入 Undertow
持久化Hibernate ORM7.4.5.Final@TenantId、@Version 直接可用
连接池HikariCP7.0.2默认且足够,见 11.1
迁移Flyway12.4.0需引入 spring-boot-starter-flyway
安全Spring Security7.1.1JWT 资源服务器,见 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   |------+
        +-----------+        +--------------+

用表格把依赖与调用方式写清楚,避免「谁都能调谁」:

模块依赖调用方式一致性
circulationcatalog, member同步接口强一致(同事务)
circulation—发布借阅事件最终一致(outbox)
reportingcirculation, catalog读投影最终一致
catalog—无—

circulation 是核心,它不反向依赖 reporting;统计需要数据时,由 reporting 订阅事件或读只读副本,绝不把统计逻辑塞回借还路径。

18.1.5 容量与预算粗算

架构评审需要一份量级估算,避免「拍脑袋上配置」。以下数字都是假设,用于推导资源,不是实测结果:

项假设推导
分馆数数十个单库承载,行级隔离
每馆读者数万会员表量级百万内
每馆藏书数十万册馆藏表量级千万内
峰值借还数百 TPS连接池按此设上界
年借阅流水千万行级loan 表需按时间分区
单次借还事务毫秒级主键 + 唯一约束命中

据此推导三条预算:

  1. 连接池:峰值 TPS × 单事务耗时 = 需要的并发连接数。若单事务毫秒级、峰值数百 TPS,池大小按几十配置即可,具体在 11.1 HikariCP 调优 里按实测收敛,不要照抄默认 10 或盲目放大。
  2. 存储:loan 按年千万行、每行数百字节估算,年增容量在 GB 量级,需要归档策略而非无限保留。
  3. 迁移窗口:千万行表的加列、加索引会锁表,必须用向前兼容写法(见 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 实现与联调 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计