Java 后端开发教程

Java SE → Spring Boot · 从 Java 基础、OOP、集合、泛型、Lambda/Stream 一路到 Spring Boot、MyBatis、My
← 返回教程馆
7阶段
43章节
43源文档
12 周计划配套生态

📊 我的学习进度 本地自动保存

加载中…
🧭 导览0/0
正文0/0
docs 1-80/0
docs 9-160/0
docs 17-240/0
docs 25-320/0
docs 33-400/0
sample0/0
💡 每章卡片右上角「✓ 标记已学」;教程间互链已自动映射到本页章节;代码示例请配合 Gitee 仓库 使用。

📖 系统化 Java 后端教程:从零基础到企业级开发 README.md

一份面向零基础读者的完整 Java 学习路径。 主线 JDK 21 + Spring Boot 4,覆盖语法 → 并发/JVM → Spring 全家桶 → 生产化部署的完整闭环; 全书以一个贯穿始终的电商下单 / 秒杀项目「mini-shop」为实战载体, 体现基础实现 → 分层重构 → 生产化改造的三阶段演进,并配套一份 Maven 多模块工程源码(sample/)。


一、读者起点与你能得到什么

需要你具备

  • 会开关机、会装软件、能用浏览器搜索问题。
  • 一台 8GB 以上内存、可用磁盘 20GB 以上的开发机(Windows / macOS / Linux 均可)。
  • 不需要任何编程经验——第 00、01 章就是为你准备的缓冲带。

读完你能做到

  1. 独立搭建 Java 开发环境,写出、跑通、调试第一个程序。
  2. 扎实掌握 Java SE:面向对象、集合、泛型、异常、IO、Lambda/Stream、反射与注解、单元测试。
  3. 理解并发编程与 JVM 基础:能解释超卖是怎么发生的、如何修复、OOM 报错该看哪里。
  4. 按企业标准完成后端开发全流程:Maven 多模块 → Spring Boot 分层 → MySQL/MyBatis-Plus 与事务 → RESTful 规范 → Redis 缓存 → RabbitMQ 异步 → JWT 鉴权 → 日志监控 → 单元测试 → Docker 部署。
  5. 把一个玩具项目一步步改造成能抗并发、可观测、可上线的服务——这正是企业里「从 0 到 1」和「从 1 到稳定」的真实工作内容。

二、技术栈与版本基线

[!WARNING] 一切版本号以 附录 90 · 技术栈版本基线 为唯一权威来源; 新建工程时版本以 sample/pom.xml 为准。软件版本持续演进,请学会自己核对。

类别 基线(2026-09) 说明
JDK 21 LTS(25 LTS 兼容) 企业存量最广的现代 LTS;第 13 章顺带讲虚拟线程
构建 Maven 3.9+ 多模块工程,版本收口到父 POM
框架 Spring Boot 4.1.x(Spring Framework 7) 3.5 的免费支持已于 2026-06 结束,新项目不建议再选
ORM MyBatis-Plus 3.5.17(boot4-starter) 国内主流;分页需配套 mybatis-plus-jsqlparser
数据库 MySQL 8.0 / 8.4 LTS 字符集统一 utf8mb4
缓存 Redis 8.x + Redisson 4.7.x 分布式锁、缓存三大问题的主战场
消息队列 RabbitMQ 4.3.x 可靠投递、死信与延迟消息
鉴权 JWT + Spring Security 7 Security 7 全新 Lambda DSL 写法
JSON Jackson 3 ⚠️ 包名已从 com.fasterxml 迁至 tools.jackson
测试 JUnit 6 + Mockito + Testcontainers 第 10 章入门、第 28 章进阶
可观测 SLF4J/Logback + Actuator + Prometheus 第 27 章
部署 Docker + Docker Compose 第 00 章起就用,第 29 章收尾

三、学习路线图

flowchart LR
    S0["阶段 0 起步<br/>环境 + 第一个程序"]
    S1["阶段 1 入门基础<br/>语法 / OOP / 集合 / 泛型 / IO"]
    S2["阶段 2 进阶提升<br/>并发 / JVM / 网络 / 类库"]
    S3["阶段 3 企业级<br/>Spring / DB / 缓存 / MQ / 安全"]
    S4["阶段 4 实战 mini-shop<br/>SE 版 → 并发版 → 生产版"]
    S0 --> S1 --> S2 --> S3 --> S4

建议节奏:

  • 零基础(00 → 第 10 章):按章节顺序读,每章做完动手练习再往下走,约 6–8 周。这是本书的主线读者。
  • 会其他语言想转 Java 后端:第 01–02 章速读,重点看 03–07;阶段二按需跳读;阶段三逐章精读,约 3–4 周。
  • 已会 Java SE、想补工程能力:直接从第 17 章开始,18/19/21/22/24/25 是核心,实战章 30–35 对照 sample/ 阅读,约 2–3 周。

四、总目录

阶段 0 · 起步

章节 标题 关键词
00 开发环境搭建 JDK 21、IntelliJ IDEA、Maven、Docker Compose、jshell

阶段 1 · 入门基础

章节 标题 关键词
01 基础语法 类型、运算符、流程控制、数组、BigDecimal 金额
02 面向对象基础 封装、继承、多态、接口、商品/订单建模
03 类设计进阶 equals/hashCode、枚举状态机、record、内部类
04 集合框架 List/Set/Map/Queue 选型、fail-fast、HashMap 扩容
05 泛型 类型参数、通配符 PECS、类型擦除、Repository 抽象
06 异常处理 异常体系、业务异常、try-with-resources
07 Lambda 与 Stream 函数式接口、Collectors、分组汇总、并行流陷阱
08 IO 与文件 字节/字符流、字符编码、NIO.2、JSON 落盘
09 反射与注解 自定义注解、反射解析、JDK 动态代理、框架地基
10 单元测试入门 JUnit 6、断言、参数化、Given-When-Then

阶段 2 · 进阶提升

章节 标题 关键词
11 并发基础 线程、JMM、可见性/原子性/有序性、复现超卖
12 锁与原子类 ReentrantLock、CAS、死锁定位、jstack
13 线程池与异步编排 线程池参数、ThreadLocal、CompletableFuture、虚拟线程
14 JVM 基础 内存区域、类加载、GC、OOM 排障四件套
15 网络编程 Socket、TCP/HTTP、BIO→NIO、HttpClient、Netty 概览
16 常用类库 java.time、Jackson、Hutool/Guava、摘要与 HMAC 签名

阶段 3 · 企业级开发

章节 标题 关键词
17 工程构建与依赖管理 坐标、依赖调解、多模块、生命周期、国内镜像
18 Spring Boot 与 IoC/DI 自动配置、Bean 生命周期、依赖注入、条件装配
19 AOP 与配置管理 切面、代理、@ConfigurationProperties、事件
20 分层架构与代码规范 Controller/Service/Manager/Mapper、DTO/VO、命名规约
21 数据库访问 建表规范、BaseMapper、条件构造器、分页、索引、乐观锁
22 事务 ACID、隔离级别、传播行为、@Transactional 失效场景
23 接口设计与规范 RESTful、统一响应、全局异常、参数校验、OpenAPI
24 缓存 Cache-Aside、穿透/击穿/雪崩、分布式锁
25 消息队列 交换机模型、可靠投递、死信、延迟消息、消费幂等
26 安全与鉴权 JWT、Spring Security 7、越权防护、常见 Web 漏洞
27 日志与监控 日志分级、MDC traceId、脱敏、Actuator、Prometheus
28 单元测试与集成测试 Mockito、切片测试、Testcontainers
29 部署与运维 Dockerfile、Compose、优雅停机、CI 流水线

阶段 4 · 实战项目 mini-shop

章节 标题 关键词
30 mini-shop 总览 需求拆解、领域模型、状态机、里程碑、验收标准
31 阶段一:纯 Java SE 内存版 全链路跑通、JSON 落盘、单元测试
32 阶段二:并发改造 超卖复现、五种锁方案压测对比、延迟关单
33 阶段三上:分层重构 Spring Boot 分层、MySQL、MyBatis-Plus、事务
34 阶段三下:生产化改造 缓存、分布式锁、MQ 异步、幂等、JWT
35 收尾:可观测与部署 traceId、指标、Docker Compose、CI

附录

章节 标题
90 技术栈版本基线(全篇唯一权威来源)
91 术语表与 API 速查
92 常见报错速查与延伸阅读

五、贯穿全书的实战项目:mini-shop

一个极简电商后端:浏览商品 → 加购物车 → 下单 → 扣库存 → 支付回调 → 超时关单。 链路不长,却足以覆盖企业级后端的全部核心技术点:

技术点 在 mini-shop 中的落点 章节
语法与 OOP Product/Order 建模、OrderStatus 枚举状态机、Money 值对象 01–03, 31
集合/泛型 商品索引 Map、购物车 LinkedHashMap、Repository<T, ID> 抽象 04–05, 31
异常 BizException / StockNotEnoughException 业务异常体系 06, 31
Stream 购物车金额汇总、订单项分组统计 07, 31
IO 订单快照落盘 data/orders.json 08, 31
并发 多线程扣库存复现超卖→五种方案修复→压测对比 11–13, 32
JVM 压测配 -Xmx、jstack 分析锁竞争 14, 32
类库 LocalDateTime 过期时间、订单号生成、支付回调 HMAC 验签 16, 34
分层与事务 下单主流程 @Transactional、Controller/Service/Manager/Mapper 20–22, 33
接口规范 统一 Result<T>、全局异常、参数校验、OpenAPI 文档 23, 33
缓存 商品详情缓存、库存预热、分布式锁防超卖 24, 34
消息队列 下单成功发消息、延迟消息关单、支付回调幂等 25, 34
安全 JWT 登录、订单归属校验防水平越权 26, 34
日志监控 MDC traceId 贯穿下单→MQ→回调、下单 QPS 指标 27, 35
测试 服务层单测 + Testcontainers 集成测试 10/28, 31/33
部署 多阶段 Dockerfile、Compose 一键起全家桶、CI 29, 35

源码位于 sample/,与第 30–35 章代码对照阅读,以 sample 为准(个别教学简化处章节内有标注)。


六、全书统一约定

每章结构(8 段式)

学习目标 → 核心概念 → 示例代码 → 常见坑 ⚠️ → 调试技巧 → 最佳实践 → 本章小结 → 动手练习

每章开头标明学习目标与前置知识;章末练习分三档:基础题(照着做)、进阶题(自己设计)、思考题(理解取舍)。做完基础题就算通过。

排版与代码规范

  • 术语首次出现写作「中文(English, 缩写)」,例如「控制反转(Inversion of Control, IoC)」。
  • 中英文之间加空格;类名、API、文件路径一律用反引号包起来。
  • 每个代码块首行注释标明文件路径,例如 // sample/mini-shop-se/src/main/java/com/example/minishop/model/Order.java。
  • 关键代码行附中文注释,注释解释为什么这么写,不复述语法。
  • 风险点统一前缀 ⚠️,提示用 > [!TIP],警告用 > [!WARNING]。
  • 同一概念全篇只在一章展开(「主章制」),其他章节一句带过并链接回主章。

关于新旧写法

Java 生态新老交替多(Date → java.time、javax.* → jakarta.*、Jackson 2 → Jackson 3、Security 5 → 7)。本书以新写法为主线,老写法只在易混处用 <details> 折叠块承载,便于你读懂存量代码。


七、本教程不覆盖

明确划清边界,避免范围蔓延:

  • 前端界面(HTML/Vue/React)—— 只讲后端接口,接口调试用 curl 与 OpenAPI 文档
  • 微服务全家桶(Spring Cloud、Nacos、Sentinel、Seata 深入)—— 单体优先,仅在第 22 章对分布式事务做概览
  • Kubernetes / 服务网格 —— Docker 单机编排(Compose)够用
  • 分库分表、大数据 —— 单库单表足够教学
  • 算法刷题 —— 只讲工程必需的数据结构与并发原语

八、常见问题

Q:零基础真的能学会吗? 能。前 10 章不依赖任何框架,只需要 JDK 和一个编辑器。每章的示例都可独立运行,练习分档,做完基础题即可继续。

Q:为什么用 Spring Boot 4 而不是网上教程更多的 3.x? Spring Boot 3.5 的免费支持已于 2026-06 结束。你今天开始学、明天就要写新项目,应该学当前活跃支持的版本(4.1,免费支持至 2027-07)。新老差异(jakarta.*、Jackson 3、Security 7)本书在相关章节都有明确标注。

Q:示例工程能直接编译吗? sample/ 按可编译的多模块 Maven 工程组织,工程已按附录 90 做过全量一致性核对与修复。依赖小版本随时间演进,若拉取失败或解析异常,请按附录 90「如何自己核对版本」处理。

Q:章节之间有依赖吗? 阶段 0–1 是线性依赖;阶段 2 内部 11→12→13→14 有序,15/16 可跳读;阶段 3 建议按序;实战章(30–35)建议最后读并对照 sample/ 源码。每章「前置知识」一栏都标注了依赖章节。


九、如何阅读源码与练习

D:/Documents/tencent/workbuddy/learning/java-backend/
├─ README.md            # 本文件:总目录与学习路线
├─ docs/                # 教程正文 39 个 Markdown
└─ sample/              # mini-shop Maven 多模块工程源码
   ├─ mini-shop-common/ # 通用:Result、ErrorCode、BizException
   ├─ mini-shop-se/     # 阶段一/二:纯 Java SE 实现(含并发压测)
   └─ mini-shop-boot/   # 阶段三:Spring Boot 分层 + 生产化

mini-shop-se 与 mini-shop-boot 是同一个业务的两代实现,刻意并存:

  • 第 31/32 章读 mini-shop-se:看业务如何用最朴素的 Java 跑通,又如何被并发问题逼疯;
  • 第 33–35 章读 mini-shop-boot:看同一业务如何被框架、分层与中间件重塑成生产级服务;
  • 章内多处给出「同一个方法在两代实现中的写法对照表」,这是全书最重要的学习材料。

祝你写得开心。🚀

📄 《系统化 Java 后端教程》全量 Review 报告 REVIEW-2026-09-29.md

审查日期:2026-09-29 | 审查范围:docs/ 全部 39 章 + 附录、sample/ 三个模块全部源码、Docker/CI 配置、双 README 索引 审查基准:JDK 21 + Spring Boot 4.1.x + MyBatis-Plus boot4-starter + Jackson 3(tools.jackson)+ 压测口径「200 线程抢 100 库存」+ 统一 8 段式体例

✅ 修复状态(2026-09-29 当日完成):P0 全部清零、P1 全部修复、P2 绝大部分修复。mvn test 四模块 BUILD SUCCESS(22 个测试全绿),mvn package 含 spring-boot repackage 成功——本工程首次通过完整编译与测试。详见文末「修复记录」。


一、总体结论

维度 评价
教学设计 优秀。章节回链、坑位显式声明、反面教材与正解对照、注释讲「为什么」——教学自觉远超平均水准
技术栈口径 高度自洽。Boot 4.1.x / Jackson 3 包名 / boot4-starter / 压测口径在全书执行一致,无一处 3.x 回退违例
工程可用性 不可交付。sample 当前状态:编不过、跑不通、测不绿——se 与 boot 各有必然编译失败,compose 部署下 MQ 链路必断
docs ↔ sample 一致性 中偏差。README 宣称「30–35 章代码清单与工程文件逐行对应」,实际存在十余处「同路径不同版本」漂移

问题总量约 200+ 项,其中 P0(照抄即失败/必然报错)约 15 项,P1(坏示范/读者对照工程会困惑)约 30 项,其余为体例与覆盖类 P2。

README 中「本教程编写环境未做过实际编译验证」的自述与发现完全吻合——建议交付前至少完整执行一次 mvn clean verify + docker compose up + 打通登录→下单→关单链路。


二、P0 清单(必须修复)

A. sample 编译/运行必然失败

# 位置 问题
1 mini-shop-se:InventoryService / OrderService / OopDemo import 不存在的 com.example.minishop.repository.ProductRepository——se 模块编译失败。02 章「面向接口编程」的教学主线等于把接口弄丢了,应补 ProductRepository extends Repository<Product, Long>
2 mini-shop-boot:OrderService:18、OrderVO:14 引用 com.example.minishop.model.OrderStatus,该类在 mini-shop-se,而 boot 的 pom 不依赖 se——编译失败。应下沉到 common 或 boot 内
3 mini-shop-boot:OrderController:38 PageResult 缺 import——编译失败
4 OrderMapper:15 × schema.sql SQL 含 AND deleted = 0,但 order 表无 deleted 列——「我的订单」接口运行时 Unknown column
5 OrderService:117 myOrders 的 total 用 list.size(),而 listByUser 带 LIMIT——分页总数永远错误
6 OrderService:83 + CreateOrderDTO 下单信任客户端传入的 userId,任何登录用户可用他人身份下单——与 detail() 的归属校验自相矛盾(教程 26 章自己强调的水平越权)
7 JwtAuthFilter:26-27 解析 JWT 后只写 UserContext,从不写 SecurityContextHolder——所有 authenticated() 端点对合法 token 也拒绝,鉴权链路整体不可用
8 docker-compose.yml × application.yml:20-21 RabbitMQ 以 guest/guest 跨容器连接,默认 guest 仅允许 localhost——AMQP 链路在标准部署下必被 ACCESS_REFUSED
9 OrderControllerTest:8-9 Boot 4 已移除 @MockBean 与旧 WebMvcTest 包(迁移至 org.springframework.boot.webmvc.test.autoconfigure + @MockitoBean)——测试编译失败
10 OrderServiceTest:58-66 打桩 stockManager.deduct 但执行路径先走 productMapper.selectById(mock 返回 null 先抛异常)——测试必然红

B. docs 中「照抄即失败/自相矛盾」的代码级错误

# 位置 问题
11 03 章 L257/L273 同一 Order 类中 getPayable() 定义了两次——直接编译错误
12 07 章 3 处输出数字全错(GMV 实算 13996.00 元、用户榜应为 519600、Top2 销量应为 2)+ Map<String,Long> 声明与 groupingBy(Order::getUserId) 键类型不符——编译错误 + 引用不存在的 Product::getCategory
13 11 章 L150-159 docs 版 OversellDemo 的 CountDownLatch 死锁(线程内先 await 后 countDown,计数永不归零)——宣称的输出跑不出来;sample 版已正确
14 14 章 L59 「JDK 21 默认并行收集器」事实错误(JDK 9+ 默认 G1,同句后文自相矛盾)
15 25 章 L113 vs L131 order.create.q 角色自相矛盾:既当正常业务队列消费、又当无消费者 TTL 队列——两设定不可能同时成立;且 L121 basicNack(tag,false,true) 正是本章自己坑清单里禁止的「无限重投」模式
16 28 章 L86 + 附录 91 @MockBean 已被 Boot 4 移除,应改 @MockitoBean——文档、sample、附录三方一致地用了已删除的 API
17 29 章 L62/L139 build: ./mini-shop-boot 把 boot 当构建上下文,但 Dockerfile 要 COPY 兄弟模块 mini-shop-common——照抄必失败(sample 正确用 context: .)
18 19 章 L102 vs L115 @Min(60) 订单超时下限 vs yml 示例 order-timeout-minutes: 15——照抄启动即校验失败

三、P1 重点问题(按模块)

sample/mini-shop-se + common

  • 快照恢复 id 冲突:重启恢复后发号器仍停在旧值,新订单覆盖历史订单(InMemoryOrderRepository 需 ensureIdAbove)
  • createOrder 多商品部分失败:第 N 项抛异常时前面已扣库存无回补,且类注释未声明此坑,学生会当正确范式抄走
  • pay 后不回写快照:支付 → 重启 → 订单又变回 CREATED,「重启可恢复」承诺未闭环
  • InMemoryProductRepository 价格索引同价互相覆盖(应为 ConcurrentSkipListMap<Long, List<Product>>)
  • AggregateDemo 的 pool.shutdown() 不在 finally——并发章「线程池必须关闭」的第一教学点自己示范错了
  • InventoryServiceTest 测试对象错位:全在测 Product.decreaseStock(),mustFind 一条没测
  • StreamDemo 「热销 Top2」统计的是订单项出现次数而非销量件数(键盘买 3 件显示 2)
  • 根 pom「统一版本收口」名不副实:dependencyManagement 只收了 3 项;exec-maven-plugin 未锁版本且教学命令缺 -am

sample/mini-shop-boot

  • closeIfCreated 读-判-写竞态:updateById 无条件更新(无 WHERE status='CREATED'、无 @Version)——与 markPaid 并发时可能出现「订单 PAID 但库存已回补」→ 超卖
  • Redis 扣减在 DB 事务内先行:事务回滚时已扣的段库存无补偿、无对账——L79 注释宣称「以 DB 为准重建」但代码不存在该机制
  • 缺 micrometer-registry-prometheus 依赖:yml 暴露 + Security 放行 prometheus 端点,但端点必然 404
  • MQ 可靠性只有注释没有落地:publisher-confirm 配了但无 ConfirmCallback;order.close.q 未配死信交换机,「nack 进死信人工兜底」实际直接丢弃;每单向同一队列发两条消息且通知消息无消费者
  • DataInitializer 无 profile 门禁:生产启动也写入演示账号(公开口令);RabbitConfig TTL 硬编码 15 分钟不复用配置且 closeIfCreated 不校验 pay_expire_at
  • ProductService 错误码语义错用:商品不存在抛 ORDER_NOT_FOUND
  • PayCallbackDTO 无任何校验注解:msgId=null 可击穿幂等防线(MySQL 唯一键对 NULL 不生效)
  • GlobalExceptionHandler 覆盖不足:坏 JSON / 404 路径 / 类型不匹配 / 撞唯一键全部落兜底 50000
  • logback FILE_INFO 用 LevelFilter:WARN 级日志不进任何文件,而业务拒绝证据全是 WARN
  • Dockerfile 目录权限:USER app 但 /app 未 chown,logs/dump 双双不可写;compose 中间件仅 service_started(Redis 未就绪即预热必失败)、healthcheck 用了镜像里不存在的 curl、未发布端口致 README 的本机流程连不上
  • 缺 .dockerignore;缺 enforcer 门禁插件;seckillEnabled/markPaid(orderNo)/listExpired() 等死配置死代码

docs 体例与覆盖

  • 30–35 章普遍缺「调试技巧/最佳实践」段(8 段式不完整,6 章均涉及);09/15 章缺独立「示例代码」段
  • > [!IMPORTANT] 共 10+ 处,超出体例约定的 TIP/WARNING 两类;旧写法未放 <details> 4 处(全书仅 16 章用对了)
  • 21 章表数口径三方矛盾(「四张表含 sku_stock」vs「三张」vs「5 张」——实际 5 张、无 sku_stock 幻影表)
  • 附录 90:JUnit 写 5.x 而 pom 实为 6.0.1;Hutool 正文用了 3 处 API 但附录没列、工程也没引依赖(21/24/27 章 StrUtil/RandomUtil/IdUtil 照抄必失败)
  • 附录 91 术语缺口:聚合根、本地消息表、看门狗、脏读/不可重复读/幻读、垂直越权、BOM、ACID 等
  • 20/22 章引用不存在的 OrderServiceImpl(实际是无接口的 OrderService 具体类);19 章引用 sample 中不存在的 aspect 包三个文件
  • 24 章 Lua 脚本缺 stock == false 判断(nil 比较直接 Lua 运行时错误——34 章 sample 版有,24 章这版没有)
  • 06→10 章叙事断层:06 章 createOrder 返回 null 占位,10 章测试直接断言返回值——按 docs 版代码必 NPE

交叉一致性(本报告新增校验)

  • ✅ 双 README 索引与实际文件完全对齐,章节编号 00–35 连续无跳号
  • ✅ 根 README「39 个 Markdown」与实际相符(36 章 + 3 附录 + docs/README)
  • ⚠️ README L168「30–35 章的代码清单与工程文件逐行对应」的承诺不成立——Order、Money、InMemoryProductRepository、OversellDemo、StockStrategies、RabbitConfig 路径、ShopProperties 等十余处为「同路径不同版本」,且无一标注「本章为简化版」
  • ⚠️ 根 README「测试 JUnit 5」与 pom junit-jupiter 6.0.1 不一致(与附录 90 同病)
  • ⚠️ 16 章 SignUtil 路径张冠李戴(写成 mini-shop-se,实际在 mini-shop-boot);14 章 OomLab、15 章 EchoServer/HttpClientDemo、16 章 OrderNoGenerator 等练习指向 sample 中不存在的文件

四、亮点(值得保持)

  1. 教学叙事:几乎每个类都有章节回链、⚠️ 坑位显式声明、「反面教材 + 正解」对照结构
  2. 事务/事件模式教科书级:rollbackFor=Exception + AFTER_COMMIT 桥 MQ,「订单入库才投递」纪律正确
  3. 缓存防护三件套完整:空值短 TTL / Redisson 单飞+双检 / TTL 抖动,Javadoc 讲清动机
  4. 扣减原子性双层设计:Lua check+DECRBY(-1/-2 语义清晰)+ DB WHERE stock >= n 条件 UPDATE
  5. 安全基本功扎实:BCrypt、统一话术防枚举、MessageDigest.isEqual 常量时间验签、JWT 密钥双校验、MDC finally 清理
  6. Money 值对象 / OrderStatus 状态机 / PageResult record:生产级习惯的教学范本
  7. 工程配套用心:幂等表复合唯一键、Dockerfile 多阶段+非 root、CI test 门禁+sha tag、版本收口意识(坐标与附录 90 严格同源)

五、修复路线建议

第一批(半天,消灭全部 P0)

  1. 补 ProductRepository 接口(se 编译)→ OrderStatus 下沉 common + 补 PageResult import(boot 编译)→ 修 deleted 幽灵列与分页 total
  2. JwtAuthFilter 写入 SecurityContext;下单改用 UserContext.require()
  3. 测试迁移 @MockitoBean + 新 WebMvcTest 包;修 OrderServiceTest 打桩顺序
  4. compose 建 RabbitMQ 自定义用户 + app 侧环境变量
  5. docs 侧修 25 章队列矛盾、28 章 @MockBean、29 章构建上下文、11 章死锁、03 章重复方法、07 章输出数字

第二批(1–2 天,P1)

  • closeIfCreated 改条件 UPDATE;Redis 扣减补偿;MQ confirm 回调 + 关单死信;DataInitializer 加 @Profile;错误码/异常处理器补齐;docs↔sample 版本漂移同步

第三批(随章节迭代,P2)

  • 30–35 章补齐 8 段式;附录 90 修 JUnit 版本 + 补 Hutool 决策(要么加依赖要么换 JDK 写法);附录 91 补术语;README「逐行对应」措辞降级为「以 sample 为准」

验收门禁:全部修完后执行 mvn clean verify 全绿 + docker compose up --build 起全家桶 + curl 走通 登录→下单→查单→关单 全链路——这会一次性暴露并消灭绝大多数遗留问题。


六、修复记录(2026-09-29 当日执行)

编译验证结果

  • mvn -B test:BUILD SUCCESS——common/se 13 个测试 + boot 9 个测试(含 Boot 4 @WebMvcTest 切片)全部通过
  • mvn -B package -DskipTests:BUILD SUCCESS——mini-shop.jar repackage 成功
  • 验证环境:JDK 25.0.4.1(release=21 编译)+ Maven 3.9.16

sample/mini-shop-common

  • 新增 common/model/OrderStatus.java(从 se 下沉,两代实现共用);ErrorCode 补 PRODUCT_NOT_FOUND(40402);Result.fail 拒绝 code=0;PageResult 紧凑构造器校验 size>0

sample/mini-shop-se

  • P0:补 ProductRepository 接口(extends Repository<Product, Long> + findByPriceRange)——编译链修复
  • createOrder 多商品部分失败按台账回补;InMemoryOrderRepository.ensureIdAbove + ShopCli.restore 推进发号器(id 覆盖修复);pay 后回写快照;下单成功才清空购物车;CLI 输入防御(EOF/非数字不崩)
  • InMemoryProductRepository 价格索引改 Map<Long, List<Product>>(同价共存)+ 实现 ProductRepository
  • OversellDemo 重写(ready/done 双闩、恢复中断位);AggregateDemo finally 关池 + awaitTermination + 线程名带序号;SeckillBench 计时口径修正(ready 后起表)+ 中断路径收工 + 跑法命令补 -am
  • StreamDemo Top2 改 summingInt(quantity)(销量件数);Money 用 Math.*Exact + ofYuan 异常翻译;RetryProcessor 参数个数匹配 + setAccessible 外移 + Error 分流;AopDemo 无接口抛 IAE + 改用 import;MiniContainer 循环依赖差异注释;OrderSnapshotStore 恢复以快照存值为准
  • 测试重写:OrderServiceTest 补部分失败回补/商品不存在/重复支付/CLOSED 断言(7 用例);InventoryServiceTest 补 mustFind 正常/异常(6 用例)

sample/mini-shop-boot

  • P0:OrderStatus 引用改 common(编译修复);OrderController 补 PageResult import;schema.sql 补 order.deleted 列;myOrders total 改 countByUser(分页修复);createOrder 改 UserContext.require()(越权修复);JwtAuthFilter 写入 SecurityContext(鉴权链路修复)
  • 事务与竞态:closeByOrderNo/markPaid 条件 UPDATE(与支付并发的关单超卖路径封死);insertOrderWithRetry 撞 uk 重试 3 次;Redis 段补偿台账(Deducted + restockRedisSegment,事务回滚时按段回补)
  • MQ:TTL 复用 shop.order-timeout-minutes;order.close.q 补死信出口 → order.close.parking.q 停车场(nack 有真实去处);桥改单消息 + msgId 同源 + ConfirmCallback;consumer/注释/配置口径统一
  • 安全:SecurityConfig 补 401 entry point(Result JSON);PayCallbackDTO 全字段校验(防 NULL 击穿幂等);回调失败回纯文本 FAIL;TraceIdFilter @Order(-200) + 入参净化
  • 配置/可观测:RabbitMQ 凭据环境变量化(guest 跨容器问题);JWT_SECRET/PAY_SECRET 无默认值 fail-fast;DataInitializer @Profile("!prod") + 并发注册容错;logback FILE_INFO 改 ThresholdFilter(WARN 不再丢失);补 micrometer-registry-prometheus
  • 杂项:GlobalExceptionHandler 补 6 类异常;UserService.login 单查询;ProductService 用 PRODUCT_NOT_FOUND;seckillEnabled/markPaid(orderNo) 死代码清理;page/size 夹取
  • 测试迁移 Boot 4:@WebMvcTest 新包 + @MockitoBean(spring-test)+ @AutoConfigureMockMvc(addFilters=false) + spring-boot-webmvc-test 依赖;OrderServiceTest 重写 7 用例(成功/不足/未登录/markPaid×2/关单×2)

构建与部署

  • 根 pom:enforcer 门禁(JDK 21+/Maven 3.9+)、exec-maven-plugin 锁 3.5.0、jackson 移出 dm(boot 交 BOM)、testcontainers 1.21.4(2.x 未发布 mysql 模块)
  • Dockerfile:logs/dump 目录 chown、装 curl、MaxRAMPercentage、COPY src 精确化、上下文警告注释
  • compose:RABBITMQ_DEFAULT_USER=minishop、三中间件 service_healthy、端口发布(3306/6379/5672)、restart、healthcheck 加 start_period;.dockerignore 新建;CI 加 concurrency/timeout;sample README 更新(环境变量必注入清单)

docs(两个并行修复,共 39 文件 + 双 README)

  • P0 级错误:03 章 getPayable 重复定义、07 章 3 处输出数字 + 编译错、11 章 OversellDemo 死锁版、14 章 GC 事实错误、25 章队列角色矛盾 + nack requeue、28 章 + 91 附录 @MockBean、29 章构建上下文、19 章 @Min 矛盾——全部修正
  • 体例:[!IMPORTANT] 全部清零(改 TIP/WARNING 按语义);旧写法 4 处包 <details>;术语首现补「中文(English, 缩写)」;09/15 章补「示例代码」段;30-35 章补齐「调试技巧/最佳实践」段(8 段式完整)
  • 一致性:全部代码示例与 sample 修复后对齐(OrderStatus 路径、DTO 去 userId、条件 UPDATE、Deducted 台账、单消息、OrderService 类名、deleted 列、JUnit 6 口径 + 10 章 Jupiter 说明、表数统一 5 张、Hutool 全部换 JDK 原生);附录 90 版本表与 pom 同步;附录 91 补 10 术语;附录 92 补 4 条 FAQ;根 README「逐行对应」降级为「以 sample 为准」

遗留(未修,均为低风险)

  • boot 模块 Testcontainers 依赖为 1.21.4(2.x 未发布 mysql 坐标),待官方 2.x 补齐后升级
  • pay 假端点仍返回写死的 {"status":"PAID"}(教程声明教学链路,真实链路走 34 章回调)
  • 28 章练习「给 myOrders 写 Testcontainers 集成测试」仍是练习题(依赖已备好)
  • 登录无失败锁定/限流(教学取舍,可在安全章练习中扩展)

📄 第 00 章 开发环境搭建:JDK 21、IDEA、Maven 与 Docker 00-environment-setup.md

阶段:阶段 0 · 起步 | 预计阅读 45 min | 代码落地模块:无(本章产出第一个可运行程序) 前置:无 | 相关章节:第 01 章 基础语法 | 第 17 章 Maven | 附录 90 版本基线 实战落点:本章装好的 Docker 中间件(MySQL/Redis/RabbitMQ)会被阶段三直接复用

学习目标

读完本章,你应该能够:

  • [ ] 安装并配置 JDK 21、IntelliJ IDEA、Maven,能解释三者各自的角色。
  • [ ] 用命令行和 IDEA 各跑通一个「Hello, Java」,并用 jshell 做即时实验。
  • [ ] 用 Docker Compose 一键启动 MySQL、Redis、RabbitMQ 三个中间件。
  • [ ] 配置好国内 Maven 镜像,避免「卡在下载依赖」上浪费一小时。
  • [ ] 知道环境类问题该查哪几个地方(java -version、JAVA_HOME、mvn -v)。

一、核心概念

1.1 三件套的关系

flowchart LR
    A["JDK 21<br/>编译器 + 运行时"] --> B["Maven<br/>依赖管理 + 构建"]
    A --> C["IDEA<br/>编辑器 / 调试器"]
    B --> C
    C --> D["你的代码<br/>*.java"]
    B --> E["产物<br/>*.jar / 可运行应用"]

一句话总结:JDK 负责把代码编译并运行起来,Maven 负责管理第三方依赖和构建流程,IDEA 是让你高效写代码的编辑器。

概念 是什么 常见误区
JDK Java 开发工具包:含编译器 javac、运行时 java、调试与排障工具 「JRE 和 JDK 一样」——JDK = JRE + 开发工具,学习/开发一律装 JDK
JVM Java 虚拟机,字节码的运行载体 「Java 程序直接跑在操作系统上」——中间隔着 JVM,这正是跨平台的基础
Maven 构建与依赖管理工具 「只是下载 jar 的」——它还统一了目录结构、构建生命周期与多模块组织(第 17 章展开)
IDEA 集成开发环境(Integrated Development Environment, IDE) 社区版(Community)足够本书全部内容;旗舰版额外支持的是框架付费增强
Docker 容器引擎 「装中间件必须装在本机」——用容器起 MySQL/Redis 更干净、可随时删除重来

1.2 为什么锁死 JDK 21

  • 21 是长期支持版(Long-Term Support, LTS),Oracle 官方支持到 2028 年,社区构建(Temurin)持续更久。
  • 企业存量项目正从 8/11/17 向 17/21 迁移,招聘主流即 17/21。
  • 21 起虚拟线程正式可用(第 13 章),本书部分示例依赖它。

[!TIP] 已装了 JDK 17 也不必卸载,本书代码在 17 上几乎都能编译(个别虚拟线程示例除外)。但不要用 JDK 8:record、var、文本块等语法全部不可用。


二、操作步骤

2.1 安装 JDK 21

  1. 下载 Eclipse Temurin 21(开源免费):https://adoptium.net,或 Oracle 官网 JDK 21。
  2. Windows 安装时勾选 「Add to PATH」 与 「Set JAVA_HOME」 两个选项;macOS 用 .pkg 安装包;Linux 可用 apt install temurin-21-jdk(需先加 Adoptium 源,见 ../linux-server/docs/20-jdk.md)。
  3. 打开新终端验证(改完环境变量必须重开终端):
java -version    # 应显示 21.x.x
javac -version   # 编译器版本,应同为 21
echo $JAVA_HOME  # 应指向 JDK 安装目录(Windows: echo %JAVA_HOME%)

若 java 能用而 javac 不行,说明装的是 JRE 或 PATH 里串进了旧版本——用 where java(Windows)/ which -a java(macOS/Linux)找出所有 java,把 JDK 21 的 bin 目录放到 PATH 最前面。

2.2 安装 IntelliJ IDEA

  1. 下载 Community Edition(免费):https://www.jetbrains.com/idea/download。
  2. 首次启动后进入 File → Settings → Build → Build Tools → Maven(macOS 在 Preferences):
    • Maven home path:选「Bundled (Maven 3)」即可,也可指向自己安装的 Maven;
    • User settings file:记住这个路径(默认 ~/.m2/settings.xml),下一步要改它。
  3. Settings → Editor → File Encodings:全部设为 UTF-8,避免中文注释乱码。

2.3 配置国内 Maven 镜像(强烈建议)

编辑 ~/.m2/settings.xml(Windows 是 C:\Users\<你>\.m2\settings.xml),加入阿里云镜像:

<!-- ~/.m2/settings.xml -->
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0">
  <mirrors>
    <mirror>
      <id>aliyunmaven</id>
      <mirrorOf>central</mirrorOf> <!-- 只代理中央仓库,不劫持其他仓库 -->
      <name>阿里云公共仓库</name>
      <url>https://maven.aliyun.com/repository/public</url>
    </mirror>
  </mirrors>
</settings>

[!WARNING] 不要把 <mirrorOf> 写成 *。这会让所有仓库(包括 Spring 自有仓库)都被强制走阿里云,某些依赖会 404。用 central 只代理中央仓库即可。

2.4 用 Docker Compose 启动中间件

先安装 Docker Desktop(Windows 需开启 WSL2)。然后随便找个目录建 docker-compose.yml(sample 工程根目录 sample/docker-compose.yml 已有现成副本,可直接复制):

# ~/java-env/docker-compose.yml —— 全书共用的中间件环境
services:
  mysql:
    image: mysql:8.0
    container_name: mini-mysql
    environment:
      MYSQL_ROOT_PASSWORD: root123        # 教学环境用弱密码,生产绝不允许
      MYSQL_DATABASE: mini_shop           # 顺带建好实战项目的库
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql         # 数据落在卷里,容器删了数据还在
    command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci

  redis:
    image: redis:8.0
    container_name: mini-redis
    ports:
      - "6379:6379"

  rabbitmq:
    image: rabbitmq:4.3-management        # -management 后缀 = 带网页管理台
    container_name: mini-rabbitmq
    ports:
      - "5672:5672"    # AMQP 协议端口,应用连这个
      - "15672:15672"  # 管理台端口,浏览器打开 guest/guest

volumes:
  mysql-data:

启动与验证:

docker compose up -d        # 后台启动全部三个服务
docker compose ps           # 三个容器应为 Up 状态
docker compose logs mysql   # 某个起不来时看日志
docker compose down         # 停止并删除容器(数据卷保留)

浏览器打开 http://localhost:15672(账号 guest/guest)能看到 RabbitMQ 管理台即成功。

[!TIP] 端口被占用是最常见的启动失败原因(比如本机已装过 MySQL 占了 3306)。把 ports 左边改成 "3307:3306" 之类换一个宿主机端口即可,应用侧连接串同步改。

2.5 第一个程序:命令行版

mkdir hello && cd hello
// hello/Hello.java —— 文件名必须与 public 类名完全一致
public class Hello {
    public static void main(String[] args) {  // 程序入口,JVM 从这里开始执行
        System.out.println("Hello, Java 21!");
    }
}
javac Hello.java   # 编译:生成 Hello.class(字节码)
java Hello         # 运行:注意不带 .class 后缀
# JDK 11+ 也可以一步到位:java Hello.java

2.6 第一个程序:IDEA 版

  1. File → New → Project,左侧选 Maven Archetype 不必纠结,直接选 Java,JDK 选 21,其余默认。
  2. 在 src/main/java 上右键 → New → Java Class,命名 Hello,粘贴上面的代码。
  3. 点 main 方法左侧绿色三角运行。下方的 Run 窗口就是你的输出台。
  4. 试试打断点:在 println 行号旁单击出现红点,再点甲虫图标(Debug)启动,程序会停在断点处——这是全书最重要的调试手段。

2.7 jshell:你的即时草稿纸

$ jshell
jshell> 1 + 1
$1 ==> 2
jshell> "abc".toUpperCase()
$2 ==> "ABC"
jshell> /exit

jshell 是 JDK 自带的交互式解释器,不用建类不用编译,输入表达式立即出结果。学语法阶段遇到任何拿不准的写法,先开 jshell 试,再写进工程。


三、常见坑 ⚠️

  • ⚠️ 装了新 JDK,命令行还是旧版本:PATH 里残留旧路径。where java / which -a java 排查,调整顺序后重开终端。
  • ⚠️ 文件名与类名不一致:public class Hello 必须放在 Hello.java 里,否则编译失败。
  • ⚠️ 中文乱码:Windows 终端默认 GBK。IDEA 里确认 File Encodings 全 UTF-8;命令行执行 chcp 65001 切到 UTF-8。
  • ⚠️ Maven 下载极慢或反复失败:没配镜像。回看 2.3 节;公司内网另说(可能需要配代理或私服)。
  • ⚠️ Docker 起不来:Windows 未开启 WSL2 或虚拟化未在 BIOS 打开;先 docker run hello-world 验证 Docker 本身可用。
  • ⚠️ 3306/6379 端口冲突:本机已装过同名中间件。换宿主机端口映射(见 2.4 节 TIP)。

四、调试技巧

症状 排查命令
java 版本不对 java -version、where java / which -a java
Maven 找不到 / 版本不对 mvn -v(它会同时显示其使用的 JDK 版本)
容器状态异常 docker compose ps、docker compose logs <服务名>
端口被谁占了 Windows:`netstat -ano
IDEA 认不到 JDK File → Project Structure → SDK 手动指定 JDK 21 安装目录

五、最佳实践

  • 开发环境全部容器化:本机不装 MySQL/Redis/RabbitMQ,docker compose down -v 即可重置一切。
  • 一台机器可以并存多个 JDK,用 JAVA_HOME 切换;IDEA 里还能按工程指定不同 JDK。
  • 代码目录不要含中文和空格(尤其 Windows),如 D:\code\java-backend。
  • 从第一天就用 Git:git init + 每完成一章提交一次,这是最朴素的进度备份。

六、本章小结

  • JDK 编译运行、Maven 管依赖构建、IDEA 编辑调试,三件套各司其职。
  • 基线锁定 JDK 21 LTS;Maven 配国内镜像;中间件用 Docker Compose 起。
  • jshell 是语法实验田;IDEA 断点调试是全书最常用的排障手段。
  • 环境问题三板斧:java -version、mvn -v、docker compose ps。

下一章我们正式开始写 Java:变量、类型、流程控制,以及企业代码里第一条军规——金额为什么不能用 double。


七、动手练习

基础题

  1. 完成 2.1–2.4 全部安装,截图或记录 java -version、mvn -v、docker compose ps 三个命令的输出。
  2. 在命令行与 IDEA 中各跑通一次 Hello,再用 jshell 计算 2026 * 365。

进阶题

  1. 故意把 docker-compose.yml 里 MySQL 端口改成 13306:3306 启动,然后用 mysql 客户端或 docker exec -it mini-mysql mysql -uroot -p 连接,验证能连上 mini_shop 库;改回 3306 再 docker compose up -d。
  2. 把 settings.xml 的 mirrorOf 改成 *,新建一个工程让它拉依赖,观察是否出现找不到依赖的报错,再改回 central。亲手踩一遍才知道为什么。

思考题

  1. 为什么 Java 选择「编译成字节码、由 JVM 运行」而不是像 C 一样直接编译成机器码?这个设计给 Java 带来了什么(提示:跨平台之外,再想想第 14 章的 GC 与 JIT)?

📄 第 01 章 基础语法:类型、运算符、流程控制与数组 01-java-basics.md

阶段:阶段 1 · 入门基础 | 预计阅读 60 min | 代码落地模块:sample/mini-shop-se(预备知识) 前置:第 00 章 环境搭建 | 相关章节:第 02 章 面向对象基础 | 第 16 章 常用类库 实战落点:mini-shop 里商品价格、库存数量的类型选择;控制台菜单的循环骨架

学习目标

读完本章,你应该能够:

  • [ ] 区分基本类型与引用类型,解释为什么 long 存金额、double 不行。
  • [ ] 独立写出带分支、循环、方法调用的控制台程序。
  • [ ] 使用数组并理解它「长度固定」的局限(为第 04 章集合铺垫)。
  • [ ] 会用 var 局部变量类型推断,也知道什么时候不该用它。
  • [ ] 养成第一个工程习惯:金额一律用 long(分)或 BigDecimal,禁用 double。

一、核心概念

1.1 变量与数据类型

Java 是静态强类型语言:每个变量在声明时确定类型,编译期就检查类型是否匹配。

Java 类型
├─ 基本类型(primitive,8 种):直接存值,存在栈上
│   整数:byte(1B) short(2B) int(4B) long(8B)
│   浮点:float(4B) double(8B)
│   字符:char(2B)   布尔:boolean
└─ 引用类型(reference):存的是对象的地址
    String、数组、以及你以后写的所有类
类型 字面量示例 用途 一句话规则
int 100 一般整数 默认整数类型,够用到 21 亿
long 100L 大整数、金额(分)、ID、时间戳 字面量加 L 后缀
double 1.5 科学计算 永远不要存钱,原因见 1.2
boolean true 开关、标志位 不能与整数互转(和 C 不同)
char 'A' 单个字符 单引号;字符串用双引号
String "hello" 文本 引用类型,不可变(immutable)

1.2 为什么金额不能用 double ⚠️

double 用二进制浮点存储,无法精确表示 0.1 这类十进制小数:

System.out.println(0.1 + 0.2);          // 0.30000000000000004
System.out.println(1.03 - 0.42);        // 0.6100000000000001

商品 0.1 元 + 0.2 元,收银台算出 0.30000000000000004 元——对账系统会直接爆炸。两条铁律:

  1. 整数分存储:金额以「分」为单位用 long(如 1999 表示 19.99 元),加减乘精确,性能最好;
  2. 需要小数运算(如折扣、分摊)时用 BigDecimal,并且用字符串构造(第 16 章展开)。

1.3 运算符与类型转换

int a = 7, b = 2;
a / b        // 3   整数除法直接截断!
a % b        // 1   取余
(double) a / b   // 3.5  先转 double 再除
a++          // 后置自增:先取值再 +1
++a          // 前置自增:先 +1 再取值(企业代码里建议统一用 a += 1,可读性更好)

类型转换两条路:

  • 自动提升(小 → 大):int → long → double 无损,直接赋值;
  • 强制转换(大 → 小):(int) 3.99 得 3(直接截断不四舍五入),溢出会绕回,必须清醒地写。
long big = 3_000_000_000L;   // 下划线分隔增强可读性
int overflow = (int) big;    // -1294967296 静默溢出!编译器不会提醒

1.4 流程控制

// if-else:业务分支的主力
if (stock <= 0) {
    System.out.println("已售罄");
} else if (stock < 10) {
    System.out.println("库存紧张");
} else {
    System.out.println("可购买");
}

// switch(JDK 14+ 箭头语法:不穿透、不用 break,推荐)
String level = switch (score / 10) {
    case 10, 9 -> "优秀";
    case 8 -> "良好";
    case 7, 6 -> "及格";
    default -> "不及格";
};

// for / while / do-while
for (int i = 0; i < 3; i++) { ... }        // 已知次数
for (String name : names) { ... }          // 增强 for:遍历数组/集合(后文常用)
while (input != 0) { ... }                 // 未知次数,先判断
do { ... } while (input != 0);             // 至少执行一次(菜单循环常用)

break 跳出本层循环,continue 跳过本轮。企业代码规范:避免在循环里堆三层以上 break/continue,复杂条件优先拆方法。

1.5 方法(函数)

// 修饰符 返回类型 方法名(参数列表) —— 方法名用小驼峰,动词开头
static long calculateTotal(long priceInCents, int quantity) {
    return priceInCents * quantity;   // long x int:int 自动提升为 long,结果不会溢出
}

要点:

  • 参数是值传递:基本类型拷贝值,引用类型拷贝地址(对象内容可被修改,但让参数指向新对象不影响调用方);
  • 方法不写 static 就属于对象——这要到第 02 章才展开,本章示例统一加 static 以便在 main 里直接调用;
  • 一个方法只做一件事,超过 30 行就该考虑拆分。

1.6 数组与 var

long[] prices = {1999, 4599, 8800};      // 长度固定,创建后不能变
prices[0]                                 // 下标从 0 开始
prices.length                             // 是属性不是方法
int[] cart = new int[10];                 // 默认值 0;引用类型数组默认 null

// 遍历
for (int i = 0; i < prices.length; i++) { ... }
for (long p : prices) { ... }             // 只读遍历首选

// var:局部变量类型推断(JDK 10+),右边类型一目了然时用
var names = new String[]{"A", "B"};
// 不该用 var 的场合:返回类型无业务含义,或想让读者一眼看到声明类型

数组的硬伤:长度固定、类型单一、没有增删方法。mini-shop 的购物车需要随时加商品——这就是第 04 章集合框架登场的理由。


二、示例代码

下面把本章知识拼成一个能跑的小程序:商品价目表 + 简易下单计算(mini-shop 的雏形)。

// sample/mini-shop-se/src/main/java/com/example/minishop/cli/BasicsDemo.java
package com.example.minishop.cli;

public class BasicsDemo {

    public static void main(String[] args) {
        // 金额一律用 long(单位:分),这是全书铁律
        long[] prices = {1999, 4599, 8800};        // 三件商品的价格
        String[] names = {"机械键盘", "显示器", "显卡"};
        int[] cart = new int[names.length];        // 购物车:每件商品买几件

        // 模拟下单:键盘 x2、显卡 x1
        cart[0] = 2;
        cart[2] = 1;

        long total = 0;
        for (int i = 0; i < names.length; i++) {
            if (cart[i] == 0) {
                continue;                          // 没买这件,跳过
            }
            long sub = (long) prices[i] * cart[i]; // 教学点:乘法先于赋值提升——若价格是 int,会先按 int 相乘溢出、赋给 long 已晚;此处 prices 已是 long[],强转是防御性习惯
            total += sub;
            System.out.printf("%s x%d = %d.%02d 元%n",
                    names[i], cart[i], sub / 100, sub % 100);
        }
        System.out.printf("合计:%d.%02d 元%n", total / 100, total % 100);

        // 满减规则:满 5000 分(50 元)减 500 分(5 元)
        long payable = (total >= 5000) ? total - 500 : total;  // 三目运算符适合简单二选一
        System.out.printf("应付:%d.%02d 元%n", payable / 100, payable % 100);
    }
}

输出:

机械键盘 x2 = 39.98 元
显卡 x1 = 88.00 元
合计:127.98 元
应付:122.98 元

[!TIP] printf 的 %d(整数)、%s(字符串)、%n(换行)够用全书;%02d 表示不足两位补零,打印金额很顺手。


三、常见坑 ⚠️

  • ⚠️ 用 double 存金额:全章最重要的坑,见 1.2。Code Review 里出现 double price 直接打回。
  • ⚠️ 整数除法截断:3 / 2 == 1。想要小数,先把其中一个转 double;想要金额精确,别用小数。
  • ⚠️ int 乘法溢出:price * quantity 两个 int 相乘结果还是 int,上亿数量级会静默溢出。先 (long) 提升再乘。
  • ⚠️ 数组下标越界:ArrayIndexOutOfBoundsException 是新手第一杀手。循环条件写 < length 而不是 <= length。
  • ⚠️ == 比较字符串:== 比的是地址。"a" == new String("a") 是 false。比内容用 str.equals("a"),且把常量放前面防空指针:"a".equals(str)。
  • ⚠️ switch 忘了 break(老语法):用 JDK 14+ 箭头语法可彻底避免。

四、调试技巧

  • IDEA 断点 + Evaluate Expression(调试中按 Alt+F8):随时算一个表达式的值,验证你对运算顺序的理解。
  • 数值不对时先打印中间变量:System.out.println("total=" + total),二分定位是哪一步开始错的。
  • 溢出类 bug 极隐蔽:结果「差一个数量级」或「突然变负数」,优先检查类型提升。

五、最佳实践

  • 变量名说人话:priceInCents 比 p 好;布尔量用 isVip、hasStock 这类形容词。
  • 方法不超过 30 行、嵌套不超过 3 层,超了就拆。
  • 魔法数字提成常量:static final long FREE_SHIPPING_THRESHOLD = 9900;(99 元包邮)。
  • 金额单位在命名里写清楚:priceInCents / priceYuan,单位歧义是财务 bug 的头号来源。

六、本章小结

  • 基本类型直接存值、引用类型存地址;String 不可变,比较用 equals。
  • 金额永远不用 double:long(分)或 BigDecimal。
  • 整数除法截断、大转小强转会截断/溢出,是两类高频线上事故。
  • 数组固定长度,增删场景交给第 04 章的集合。

下一章把「商品」「订单」这些现实概念变成 Java 的类——面向对象登场。


七、动手练习

基础题

  1. 把 BasicsDemo 跑起来,然后新增一件商品「鼠标」(价格自定,long 分),加入购物车买 3 件,验证合计正确。
  2. 写方法 static long applyDiscount(long totalInCents, int level):VIP 等级 1 打 95 折、2 打 9 折、其他不打折,在 main 中调用验证。

进阶题

  1. 用 switch 箭头语法实现:根据订单金额分档运费(<99 元收 10 元、99–299 元收 5 元、≥299 元免运费),集成进 BasicsDemo 的应付金额计算。
  2. 打印一个居中对齐的价目表(用 printf 的 %-16s 左对齐 / %16s 右对齐),列:商品名、单价、数量、小计。

思考题

  1. long 以分存储最大可表示约 9.2 亿亿元(Long.MAX_VALUE ≈ 9.2×10¹⁸ 分,合 9.2×10¹⁶ 元),看起来永远不会溢出。那为什么第 16 章还要讲 BigDecimal?提示:想一想「满 100 减 30.5」「按 33.33% 分摊佣金」这类需求,long 还方便吗?各自的最佳适用场景是什么?

📄 第 02 章 面向对象基础:类、封装、继承、多态与接口 02-oop-basics.md

阶段:阶段 1 · 入门基础 | 预计阅读 70 min | 代码落地模块:sample/mini-shop-se(model / repository) 前置:第 01 章 基础语法 | 相关章节:第 03 章 类设计进阶 | 第 18 章 IoC/DI 实战落点:Product、Order 领域类;ProductRepository 仓储接口——第 31 章直接沿用

学习目标

读完本章,你应该能够:

  • [ ] 用「属性私有 + 构造器 + getter」正确封装一个类。
  • [ ] 解释继承与组合的区别,并默认优先组合。
  • [ ] 写出接口 + 多个实现类,理解为什么面向接口编程。
  • [ ] 理解 static、final、this 三个高频关键字。
  • [ ] 看懂「面向对象建模」的思路:名词→类、动词→方法。

一、核心概念

1.1 类与对象

**类(Class)**是模板,**对象(Object)**是按模板造出的实例。面向对象建模的口诀:名词变类、形容词变属性、动词变方法。

// sample/mini-shop-se/src/main/java/com/example/minishop/model/Product.java
package com.example.minishop.model;

/** 商品。金额一律 long(分),单位在命名中写明。 */
public class Product {
    // 1. 属性全部 private:外部不能直接改,只能通过方法改(封装)
    private final Long id;            // final:创建后不可变,id 一旦分配不许改
    private String name;
    private long priceInCents;        // 单价,单位:分
    private int stock;                // 库存

    // 2. 构造器:new 的时候执行,负责把对象「装配完整」
    public Product(Long id, String name, long priceInCents, int stock) {
        this.id = id;                 // this. 用来区分「成员变量」和「同名的参数」
        this.name = name;
        this.priceInCents = priceInCents;
        this.stock = stock;
    }

    // 3. getter 暴露只读访问;没有 setter 的属性 = 外部只读
    public Long getId() { return id; }
    public String getName() { return name; }
    public long getPriceInCents() { return priceInCents; }
    public int getStock() { return stock; }

    // 4. 行为(动词):改库存必须走方法,方法内部可以做校验 —— 这就是封装的价值
    public void decreaseStock(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("扣减数量必须为正数: " + quantity);
        }
        if (stock < quantity) {
            throw new IllegalStateException("库存不足: 现有 " + stock + ", 需要 " + quantity);
        }
        this.stock -= quantity;
    }
}
Product p = new Product(1L, "机械键盘", 19900L, 100);  // 1L:long 字面量,199.00 元
p.decreaseStock(2);      // 库存 100 -> 98
p.setStock(-5);          // 编译失败:没有 setter,外部无法把库存改成负数

1.2 封装:为什么要私有 + 方法

如果 stock 是 public,任何代码都能写 p.stock = -999,bug 会散落在全工程。封装成方法后:

  1. 校验收口:所有改库存的路径都经过 decreaseStock 的合法性检查;
  2. 改动可控:将来把库存从内存字段换成查数据库,调用方一行不用改;
  3. 意图清晰:读代码的人只需要看方法签名,不需要理解内部字段。

[!TIP] 企业规范:字段一律 private,绝不 public;getter/setter 只对真正需要的字段生成——无脑全生成 setter 等于放弃了封装。

1.3 继承与多态

继承表达「is-a」关系。先记住三条规则:

  1. 子类构造器第一行会隐式或显式调用父类构造器(super(...));
  2. Java 单继承:一个类只能 extends 一个父类;多能力用接口组合;
  3. Object 是所有类的根父类(第 03 章讲它的 equals/toString 契约)。

下面这个 DigitalProduct 是教学虚构示例,不入 sample 工程:

// 教学虚构示例(sample 工程中没有这个文件)
package com.example.minishop.model;

/** 数字商品:无需物流、永远有货 —— is-a 关系成立才用继承 */
public class DigitalProduct extends Product {

    private String downloadUrl;

    public DigitalProduct(Long id, String name, long priceInCents, String downloadUrl) {
        super(id, name, priceInCents, Integer.MAX_VALUE);  // 必须第一行调用父类构造器
        this.downloadUrl = downloadUrl;
    }

    // 重写(Override):同名同参,运行时按实际类型执行 —— 这就是多态
    @Override
    public void decreaseStock(int quantity) {
        // 数字商品无限库存,父类的校验逻辑不适用,直接覆盖为空实现
    }
}

多态(Polymorphism):父类引用指向子类对象,调方法时执行实际类型的版本:

Product p = new DigitalProduct(2L, "电子书", 990L, "https://...");
p.decreaseStock(1);   // 执行的是 DigitalProduct 的版本,不会真的扣库存

⚠️ 两根红线:

  • private/static/final 方法不能被重写(static 方法属于类,谈不上多态);
  • 默认优先组合而非继承:「购物车 has-a 商品列表」就该组合(持有字段),别把购物车继承自某个 List。
// 组合:Cart 「拥有」一组 CartItem,而不是「是一种」List
public class Cart {
    private final List<CartItem> items = new ArrayList<>();  // 04 章详讲 List
    public void add(CartItem item) { items.add(item); }
}

1.4 接口:面向接口编程

**接口(Interface)**是一份能力契约:只声明「能做什么」,不关心「怎么做」。这是全书最重要的设计习惯。

// sample/mini-shop-se/src/main/java/com/example/minishop/repository/ProductRepository.java
package com.example.minishop.repository;

import com.example.minishop.common.repository.Repository;
import com.example.minishop.model.Product;

import java.util.List;

/**
 * 商品仓储契约(第 02 章「面向接口编程」定义)。
 * 调用方只依赖本接口,不感知背后是内存实现(SE 版)还是数据库实现(Boot 版)——
 * 第 31 章的 OrderService 之所以能被两代实现复用,靠的就是这一层抽象。
 */
public interface ProductRepository extends Repository<Product, Long> {

    /** 价格区间查询 [fromCents, toCents](第 04 章进阶练习) */
    List<Product> findByPriceRange(long fromCents, long toCents);
}

说明:sample 真实版并没有把 save/findById 直接写在本接口里,而是继承了通用仓储契约 Repository<T, ID>(save / findById / findAll / deleteById / count,定义在 mini-shop-common,第 05 章讲泛型时实现它)。findById 返回 Optional<T>——Optional 表达「可能查不到」,逼着调用方处理空场景(比返回 null 安全);本接口只追加商品特有的价格区间查询。

// sample/mini-shop-se/src/main/java/com/example/minishop/repository/InMemoryProductRepository.java(节选)
package com.example.minishop.repository;

import com.example.minishop.model.Product;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

/** 内存实现:阶段一用。阶段三换成 MySQL 实现,业务代码一行不用改(完整版见 sample,含按价格排序的辅助索引) */
public class InMemoryProductRepository implements ProductRepository {

    // ConcurrentHashMap:线程安全的 Map(04/11 章展开),key 是商品 id
    private final ConcurrentMap<Long, Product> store = new ConcurrentHashMap<>();

    @Override
    public Product save(Product product) {
        store.put(product.getId(), product);
        return product;
    }

    @Override
    public Optional<Product> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    // findAll / findByPriceRange / deleteById / count:完整版见 sample
}

调用方写法:

ProductRepository repo = new InMemoryProductRepository();  // 面向接口声明
repo.findById(1L).orElseThrow(() -> new RuntimeException("商品不存在"));

[!TIP] 为什么面向接口编程:阶段三我们会把存储换成 MySQL。如果业务代码里到处写死 InMemoryProductRepository,改造就是全工程大扫除;依赖接口时,只需换掉「new 出来的那个实现类」。这就是第 18 章 Spring IoC 容器自动完成的事——容器替你 new 实现、注入接口。本章先手工体会一遍。

1.5 static、final、this

关键字 含义 典型用途
static 属于类而不属于对象 工具方法、常量、计数器。静态方法里不能用 this
final 不可变/不可变引用 final 类不能被继承、final 方法不能被重写、final 变量只能赋值一次
this 当前对象引用 区分同名参数与字段;构造器间用 this(...) 复用
public class IdGenerator {
    private static final AtomicLong SEQ = new AtomicLong(1000);  // 常量命名全大写
    public static long next() { return SEQ.incrementAndGet(); }  // 工具方法用 static
}

注意:final 修饰引用类型时,引用不可变但对象内容可变——final List<String> list 依然可以 list.add("x"),只是不能再 list = other。


二、示例代码

把「商品 + 仓储 + 简单业务」串成一个可运行的主流程:

// sample/mini-shop-se/src/main/java/com/example/minishop/cli/OopDemo.java
package com.example.minishop.cli;

import com.example.minishop.model.Product;
import com.example.minishop.repository.InMemoryProductRepository;
import com.example.minishop.repository.ProductRepository;

public class OopDemo {

    public static void main(String[] args) {
        // 面向接口:左边永远是接口类型
        ProductRepository repo = new InMemoryProductRepository();

        repo.save(new Product(1L, "机械键盘", 19900L, 100));
        repo.save(new Product(2L, "显示器", 459900L, 30));

        // 查询商品并下单:Optional 强制你面对「找不到」的情况
        Product keyboard = repo.findById(1L)
                .orElseThrow(() -> new IllegalArgumentException("商品不存在"));

        int quantity = 2;
        keyboard.decreaseStock(quantity);                 // 封装:库存修改只此一个入口
        long payable = keyboard.getPriceInCents() * quantity;

        System.out.printf("下单成功:%s x%d,应付 %d.%02d 元%n",
                keyboard.getName(), quantity, payable / 100, payable % 100);
        System.out.printf("剩余库存:%d%n", keyboard.getStock());
    }
}

这个 20 行的 main,就是第 31 章 mini-shop 阶段一的核心链路(商品 → 扣库存 → 算金额)。第 31 章会把它扩展成完整的 ProductService / OrderService。


三、常见坑 ⚠️

  • ⚠️ 可变对象从 getter 泄漏:getItems() 直接返回内部 List,外部拿到就能乱加乱删。返回 List.copyOf(items) 或 Collections.unmodifiableList(items)。
  • ⚠️ 继承用来复用代码:为了「复用几个方法」就 extends,会造成脆弱的父子耦合。is-a 才继承,has-a 用组合。
  • ⚠️ 重写时漏 @Override:方法名拼错(equals 写成 equal)编译器不报错,多态悄悄失效。重写必加 @Override,拼错会直接编译失败。
  • ⚠️ 接口里写状态:接口只有常量(public static final)与抽象方法描述能力,别把业务字段塞进接口。
  • ⚠️ 静态方法里用 this:编译失败。静态上下文没有「当前对象」。

四、调试技巧

  • IDEA 中 Ctrl + Alt + H(Call Hierarchy) 查看一个方法被谁调用,重构封装前先摸清影响面。
  • Ctrl + T(IDEA)/ Ctrl + T 或右键 Go to Implementations:从接口跳到所有实现类——接口多了之后这是日常导航动作。
  • 调试时在 Variables 面板直接看对象字段;想盯某个字段,右键字段 → Add to Watches。

五、最佳实践

  • 一个 .java 文件只放一个 public 类,文件名 = 类名。
  • 领域类遵循「构造完整 + 属性私有 + 必要时 final」:能不可变的尽量不可变。
  • 接口命名不加 I 前缀(ProductRepository 而非 IProductRepository),实现类以 InMemoryXxx / XxxImpl 区分。
  • equals/hashCode/toString 先别手写——第 03 章的 record 和 IDE 生成会替你做好。

六、本章小结

  • 封装 = 私有字段 + 构造完整 + 行为方法校验收口。
  • 继承表达 is-a,组合表达 has-a,默认组合;多态让同一调用走不同实现。
  • 面向接口编程是企业代码的底座:调用方依赖契约,实现可替换——IoC 容器正是把这一点自动化。
  • Optional 返回「可能没有」,static/final/this 三个关键字要分清归属。

下一章打磨类的细节:equals 与 hashCode 的契约、枚举状态机、record 值对象——都是写好领域模型的利器。


七、动手练习

基础题

  1. 给 Product 增加「上下架」能力:私有字段 boolean onSale(默认 true),提供 offShelf() / onShelf(),且只有上架状态才能被下单(在 decreaseStock 前校验,抛 IllegalStateException)。
  2. 把 OopDemo 跑通,再故意下单一个不存在的商品 id,观察 Optional 抛出的异常。

进阶题

  1. 新增接口 OrderRepository(save、findById、count())与内存实现 InMemoryOrderRepository;再写一个极简 Order 类(id、productId、quantity、payableInCents、状态字符串先不管),在 OopDemo 下单成功后保存订单并打印订单数。
  2. 用组合重写第 01 章的 BasicsDemo:设计 Cart 类(内部持有 List<CartItem>,CartItem 含商品与数量),提供 add、remove、totalInCents() 三个方法,替换原来平铺的数组逻辑。体会「数据 + 行为聚到一个类里」带来的可读性差异。

思考题

  1. DigitalProduct 重写了 decreaseStock 为空实现。如果将来数字商品引入「限购 1 件」的需求,这种「继承 + 覆盖成空」的设计还合理吗?有没有更优雅的建模方式(提示:思考「库存策略」本身抽象成接口 + 组合)?

📄 第 03 章 类设计进阶:Object 契约、枚举、record 与内部类 03-oop-advanced.md

阶段:阶段 1 · 入门基础 | 预计阅读 60 min | 代码落地模块:sample/mini-shop-se(model)+ sample/mini-shop-common(OrderStatus) 前置:第 02 章 面向对象基础 | 相关章节:第 09 章 反射与注解 | 第 31 章 实战落点:OrderStatus 枚举状态机、OrderItem record、Money 值对象——mini-shop 领域模型的三块基石

学习目标

读完本章,你应该能够:

  • [ ] 正确实现 equals/hashCode,说清两者为什么必须成对出现。
  • [ ] 用枚举建模订单状态机,让非法状态转移在编译期或早期暴露。
  • [ ] 用 record 写不可变数据类,替代 90% 的手写数据传输对象(Data Transfer Object, DTO)。
  • [ ] 掌握静态嵌套类 / 匿名类的适用场景。
  • [ ] 理解「值对象」思想:Money 封装金额与货币单位,杜绝裸 long 满天飞。

一、核心概念

1.1 Object 的四大方法

所有类都隐式继承 Object,其中四个方法你需要主动关心:

方法 默认行为 什么时候重写
toString() com.example.Product@1b6d3586(类名@哈希) 几乎总是重写,日志可读性的基石
equals(Object) ==(比地址) 两个对象按内容相等时(如订单号相同即同一订单)
hashCode() 地址相关整数 重写 equals 就必须重写 hashCode
getClass() 返回运行时类型 一般不重写,反射常用(第 09 章)

equals 与 hashCode 的契约:a.equals(b) 为 true,则 a.hashCode() == b.hashCode() 必须成立。反例后果:把对象放进 HashMap/HashSet 后,只重写 equals 不重写 hashCode,两个「相等」的对象会落到不同的哈希桶——contains 查不到、remove 删不掉,且毫无报错。

// 手写equals的三板斧(IDEA 可自动生成,但要看得懂)
@Override
public boolean equals(Object o) {
    if (this == o) return true;                    // 1. 自己比自己:直接 true
    if (!(o instanceof Product p)) return false;   // 2. 类型不对:false(模式匹配写法)
    return Objects.equals(id, p.id);               // 3. 逐字段比(业务主键)
}

@Override
public int hashCode() {
    return Objects.hash(id);                       // 用与 equals 相同的字段
}

[!TIP] instanceof Product p 是 JDK 16+ 的模式匹配写法:判断 + 强转 + 赋值一步完成。

旧写法(看得懂就行)
if (!(o instanceof Product)) return false;
Product p = (Product) o;

1.2 枚举:有限集合的正确表达

订单状态、支付方式这类取值有限且固定的概念,用字符串或 int 常量都是灾难("PAYED" 拼错没人知道)。枚举把非法值挡在编译期。为何放在 mini-shop-common 而非 mini-shop-se:订单状态是 SE 版与 Boot 版两代实现共用的领域契约,故 sample 中它落在 common 模块:

// sample/mini-shop-common/src/main/java/com/example/minishop/common/model/OrderStatus.java(节选)
package com.example.minishop.common.model;

/** 订单状态机:待支付 → 已支付 → 已发货 → 已完成;任意状态可关单 */
public enum OrderStatus {
    CREATED("待支付"),
    PAID("已支付"),
    SHIPPED("已发货"),
    COMPLETED("已完成"),
    CLOSED("已关闭");

    private final String desc;

    // 枚举构造器天然 private,只能在列常量时调用
    OrderStatus(String desc) {
        this.desc = desc;
    }

    public String getDesc() { return desc; }

    /** 状态转移合法性集中在一个方法里,而不是散落在 N 个 if-else */
    public boolean canPay()    { return this == CREATED; }
    public boolean canShip()   { return this == PAID; }
    public boolean canClose()  { return this == CREATED || this == PAID; }

    /** 转移 + 校验一体:非法转移直接抛异常,状态机永不越轨 */
    public OrderStatus pay() {
        if (!canPay()) {
            throw new IllegalStateException("当前状态[" + desc + "]不允许支付");
        }
        return PAID;
    }
}
OrderStatus s = OrderStatus.CREATED;
s = s.pay();      // CREATED -> PAID
s.canShip();      // true:PAID 允许发货(完整版还提供 ship() 转移方法,见 sample)
// new OrderStatus("X")   // 编译失败!枚举实例集在编译期封闭

枚举的其他要点:

  • 枚举是天然单例且线程安全(JVM 保证每个常量只实例化一次);
  • 可声明抽象方法让每个常量各自实现(策略模式的轻量替代);
  • values() 返回全部常量、valueOf("PAID") 按名查找、name()/ordinal() 取名与序号(不要用序号做持久化值,插入新常量会整体错位,DB 存 name)。

1.3 record:一行写好不可变数据类

领域里有一大类对象纯粹是数据的载体(DTO、查询结果、组合键)。JDK 16+ 的 record 一行搞定:自动生成构造器、getter(name() 而非 getName())、equals/hashCode/toString,且字段全部 final 不可变。

// sample/mini-shop-se/src/main/java/com/example/minishop/model/OrderItem.java
package com.example.minishop.model;

/** 订单项:productId + 数量 + 成交快照价。record 自动实现 equals/hashCode/toString */
public record OrderItem(Long productId, String productName, int quantity, long unitPriceInCents) {

    // 紧凑构造器:在主构造器之前做参数校验,对象「要么完整合法,要么不诞生」
    public OrderItem {
        if (quantity <= 0) throw new IllegalArgumentException("数量必须为正: " + quantity);
        if (unitPriceInCents < 0) throw new IllegalArgumentException("价格不能为负");
    }

    /** record 可以定义自己的方法(不含可变状态) */
    public long subtotalInCents() {
        return unitPriceInCents * quantity;   // long x int:int 自动提升,无溢出风险
    }
}
var item = new OrderItem(1L, "机械键盘", 2, 19900L);
item.quantity();          // 2 —— 注意访问器没有 get 前缀
item.subtotalInCents();   // 39800
new OrderItem(1L, "键盘", 0, 19900L);  // IllegalArgumentException:校验在构造期拦截

record vs 普通类怎么选:不可变数据 → record;带业务行为、字段可变(如 Product 的库存)→ 普通类。JPA/MyBatis-Plus 实体因框架要求无参构造与可写属性,通常仍用普通类(第 21 章说明)。

1.4 值对象 Money:给裸 long 穿上衣服

第 01 章的「金额用 long 分」解决了精度,但 long 表达不了「这是钱、什么货币、怎么安全运算」。把「金额 + 货币 + 运算规则」封装成值对象(Value Object):

// sample/mini-shop-se/src/main/java/com/example/minishop/model/Money.java(节选,完整版见 sample)
package com.example.minishop.model;

import java.util.Objects;

/** 金额值对象:不可变 + 以分存储 + 自带业务运算。不可变对象天然线程安全(11 章回收伏笔) */
public final class Money {

    public static final Money ZERO = new Money(0L);

    private final long cents;   // final:一旦创建不可变

    private Money(long cents) {
        this.cents = cents;
    }

    /** 静态工厂比 public 构造器更有表达力:读代码像读句子 */
    public static Money ofYuan(String yuan) {
        return new Money(new java.math.BigDecimal(yuan)
                .movePointRight(2).longValueExact());   // exact:有小数尾差直接抛异常
    }

    public static Money ofCents(long cents) {
        return new Money(cents);
    }

    public Money add(Money other)      { return new Money(cents + other.cents); }
    public Money multiply(int qty)     { return new Money(cents * qty); }

    /** 余额不足时返回 null 是坏味道?不,返回「运算前提」的判断方法更直白 */
    public boolean isGreaterThanOrEqual(Money other) { return cents >= other.cents; }

    public long centsInLong() { return cents; }

    @Override
    public String toString() { return "¥" + cents / 100 + "." + String.format("%02d", cents % 100); }

    @Override
    public boolean equals(Object o) {
        return o instanceof Money m && cents == m.cents;   // 值相等即对象相等
    }

    @Override
    public int hashCode() { return Objects.hash(cents); }
}
Money price = Money.ofYuan("199.00");
Money total = price.multiply(2);          // ¥398.00
total.isGreaterThanOrEqual(Money.ofYuan("299.00"));  // true

[!TIP] BigDecimal 的完整规则(构造陷阱、舍入模式、除法必须指定精度)集中在第 16 章展开;本章只需要接受结论:业务代码里裸 long/BigDecimal 满天飞是坏味道,用 Money 这类值对象收口。

1.5 内部类与匿名类

public class Order {
    /** 静态嵌套类:仅被 Order 使用的辅助结构,如构建器 */
    public static class Builder { ... }
}

// 匿名类:一次性实现接口(第 07 章 Lambda 出现后大部分场景被取代)
Runnable r = new Runnable() {
    @Override
    public void run() { System.out.println("老式写法"); }
};

规则速记:

  • 静态嵌套类(static class):不持有外部实例,最常用——工具结构、Builder、DTO 挂在宿主类里;
  • 内部类(非 static):持有外部实例引用,能用但容易造成内存泄漏(外部对象被它拖着不放,13 章 ThreadLocal 一样有泄漏话题),少用;
  • 匿名类:实现接口/继承类的一次性写法,能被 Lambda 替代的都被替代了。

二、示例代码

用本章三件套(枚举状态机 + record + Money)搭出 mini-shop 的订单骨架(本章为教学简化版,完整版见 sample):

// sample/mini-shop-se/src/main/java/com/example/minishop/model/Order.java(教学简化版)
package com.example.minishop.model;

import com.example.minishop.common.model.OrderStatus;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/** 订单聚合根:状态机 + 订单项 + 金额,全部状态变更走方法 */
public class Order {

    private final Long id;
    private final Long userId;
    private final List<OrderItem> items;   // 订单项创建后不可变(record)
    private OrderStatus status;
    private long payableInCents;           // 领域内金额以 long(分)存储,对外运算走 Money 值对象

    public Order(Long id, Long userId, List<OrderItem> items) {
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("订单至少包含一个订单项");
        }
        this.id = id;
        this.userId = userId;
        this.items = List.copyOf(items);       // 防御性拷贝:外部之后改 List 不影响我
        this.status = OrderStatus.CREATED;     // 新订单起点固定
        // Stream 汇总金额(07 章展开语法):mapToLong + sum
        long cents = items.stream().mapToLong(OrderItem::subtotalInCents).sum();
        this.payableInCents = cents;
    }

    /** 以 Money 值对象对外暴露金额,运算与展示收口到一个类型 */
    public Money getPayable() { return Money.ofCents(payableInCents); }

    public void pay() {
        this.status = status.pay();            // 非法转移由枚举抛异常,订单类零重复校验
    }

    public void close() {
        if (!status.canClose()) {
            throw new IllegalStateException("当前状态[" + status.getDesc() + "]不允许关单");
        }
        this.status = OrderStatus.CLOSED;
    }

    /** 只读视图:外部可以看订单项,不能增删 */
    public List<OrderItem> getItems() { return Collections.unmodifiableList(items); }
    public OrderStatus getStatus() { return status; }
    public Long getId() { return id; }
    public Long getUserId() { return userId; }
}

[!WARNING] 示例里的 items.stream().mapToLong(OrderItem::subtotalInCents).sum() 用到了第 07 章的 Stream 语法。看不懂没关系,记住意图「把每个订单项小计加起来」即可,第 07 章会逐个拆解。


三、常见坑 ⚠️

  • ⚠️ 只重写 equals 不重写 hashCode:HashMap/HashSet 行为诡异且无报错。铁律:成对重写,字段集合一致。
  • ⚠️ equals 参数类型写成 equals(Product p):那是重载不是重写!参数必须是 Object,务必加 @Override 让编译器把关。
  • ⚠️ 枚举 ordinal 持久化:中间插入一个新状态,历史数据全部错位。数据库与接口一律存 name()。
  • ⚠️ record 当 JPA/MyBatis 实体:框架需要无参构造 + setter,record 没有。实体用普通类,传输对象用 record。
  • ⚠️ 构造器里调用可被重写的方法:子类重写该方法时,字段可能还没初始化完毕。构造器里只做赋值与私有/final 方法的调用。
  • ⚠️ getter 返回可变集合本体:返回 Collections.unmodifiableList / List.copyOf。

四、调试技巧

  • IDEA 自动生成:Alt + Insert → equals() and hashCode() / toString(),选业务主键字段即可。
  • 怀疑 equals 有问题:断点打在 equals 内部,用调试面板 Evaluate 手动调用 a.equals(b) 单步看。
  • IDEA 增强提示:Settings → Editor → Inspections 保持默认开启,会把「类重写了 equals 但没重写 hashCode」标黄。

五、最佳实践

  • 状态机字段用枚举 + 转移方法,消灭散落各处的 if (status == 2)。
  • DTO/接口出入参、聚合内部的子结构,优先 record:不可变自带线程安全与清晰语义。
  • 涉及钱的模块第一件事就是建 Money 值对象(或直接引 javax.money 生态),让「金额」在类型系统里可辨识。
  • 对外暴露集合一律只读视图;构造器对传入集合做防御性拷贝。

六、本章小结

  • equals/hashCode 是一对契约,成对重写、字段一致。
  • 枚举 = 类型安全的常量集 + 可挂行为的状态机;持久化存 name() 不存序号。
  • record 是不可变数据类的语法糖,紧凑构造器里做参数校验。
  • 值对象(Money)让领域概念在类型层面可见;不可变对象天然线程安全。
  • 嵌套类首选静态;匿名类大多已被 Lambda 取代。

下一章进入集合框架:List/Map/Set 到底怎么选,HashMap 扩容与 fail-fast 又是什么。


七、动手练习

基础题

  1. 给 Order 增加 ship()、complete() 方法(复用枚举转移校验),并写一个 main 走完 CREATED → PAID → SHIPPED → COMPLETED 全链路;再故意在 COMPLETED 状态调用 pay(),观察异常。
  2. 把 OrderItem 从普通使用改为带紧凑构造器校验(数量、价格为正),测试非法参数被拦截。

进阶题

  1. 给 Money 增加 subtract、discount(int percent)(9 折传 90,向上/向下取整说明你的选择)方法,并写三条注释解释取整方向对商家/用户谁有利。
  2. 为订单号设计一个 OrderNo 值对象(格式 YYYYMMDD-8位随机),提供 generate() 与 isValid(String);生成规则可先用 LocalDate.now()(16 章正式讲日期 API)。

思考题

  1. String 是不可变类,所以它能安全地作为 HashMap 的 key。如果用一个可变类(字段可被 setter 修改且参与 hashCode)做 key,然后修改其字段后再 get,会发生什么?动手试一下,并用本章的 equals/hashCode 契约解释现象。

📄 第 04 章 集合框架:List、Set、Map、Queue 与选型 04-collections.md

阶段:阶段 1 · 入门基础 | 预计阅读 80 min | 代码落地模块:sample/mini-shop-se 前置:第 02 章 面向对象基础 | 相关章节:第 05 章 泛型 | 第 11 章 并发基础 实战落点:商品索引 Map<Long, Product>、购物车 LinkedHashMap、订单流水 ArrayDeque——31 章直接使用

学习目标

读完本章,你应该能够:

  • [ ] 看到需求就能说出「用哪个集合」,并说出理由(一张选型表搞定)。
  • [ ] 理解 ArrayList 与 LinkedList 的真实性能差异(不是网上说的那样)。
  • [ ] 解释 HashMap 的底层结构(数组 + 链表/红黑树、扩容、负载因子)。
  • [ ] 避开快速失败(fail-fast)、Arrays.asList、遍历中删除三大经典坑。
  • [ ] 知道什么时候必须换并发容器(ConcurrentHashMap / CopyOnWriteArrayList)。

一、核心概念

1.1 集合体系地图

flowchart TB
    Iterable["Iterable 接口<br/>(可 for-each 遍历)"] --> Collection["Collection 接口"]
    Collection --> List["List 有序可重复"]
    Collection --> Set["Set 不可重复"]
    Collection --> Queue["Queue / Deque 队列"]
    List --> AL["ArrayList<br/>数组,查询快 ★默认选择"]
    List --> LL["LinkedList<br/>双向链表"]
    Set --> HS["HashSet<br/>哈希表,无序 ★默认选择"]
    Set --> LHS["LinkedHashSet<br/>保持插入顺序"]
    Set --> TS["TreeSet<br/>自动排序"]
    Queue --> AD["ArrayDeque<br/>栈/队列首选"]
    Queue --> PQ["PriorityQueue<br/>优先级队列"]
    Map["Map 接口(独立体系)"] --> HM["HashMap ★默认选择"]
    Map --> LHM["LinkedHashMap<br/>保持插入顺序"]
    Map --> TM["TreeMap<br/>按 key 排序"]
    Map --> CHM["ConcurrentHashMap<br/>线程安全"]

[!WARNING] 集合只能装引用类型。List<int> 不存在——基本类型要用包装类 List<Integer>,JDK 5+ 自动装箱/拆箱帮你转换。⚠️ 装箱有性能与 NPE 代价(见三、常见坑)。

1.2 选型速查表(背下来)

我要…… 用这个 理由
按顺序存一串东西、按下标访问 ArrayList 底层数组,随机访问 O(1)
根据 key 找 value(id → 对象) HashMap 哈希查找 O(1),最高频需求
去重 HashSet 借助 equals/hashCode 判重(第 03 章契约在此兑现)
记住插入顺序的 Map/Set LinkedHashMap / LinkedHashSet 额外维护一条链表
按 key 排序 / 范围查询 TreeMap / TreeSet 红黑树,O(log n)
栈 / 队列 ArrayDeque ⚠️ 别用 Stack( synchronized 且设计过时)
多线程读写 ConcurrentHashMap / CopyOnWriteArrayList 第 11 章展开

关于 ArrayList vs LinkedList:网上常说「LinkedList 增删快」,实际上 ArrayList 的中间插入虽然要移动元素,但 System.arraycopy 是内存批量拷贝,实测通常仍快于 LinkedList(它每次插入要 new 节点 + 改指针,CPU 缓存还不友好)。默认无脑 ArrayList,除非你要在头部频繁插入(用 ArrayDeque)。

1.3 ArrayList 的扩容机制

new ArrayList<>() 内部是一个空数组,第一次 add 时分配容量 10;装满后扩容为 1.5 倍(10 → 15 → 22 …),扩容要整组拷贝。

List<Product> list = new ArrayList<>(10_000);  // 已知大概规模时,指定初始容量

[!TIP] 存量 10 万商品逐个 add,不指定容量会发生约 24 次数组拷贝(10 → 15 → … → 106710,共搬运约 40 万个引用)。new ArrayList<>(100_000) 一次到位。构造时给容量是廉价优化,高频路径值得写。

1.4 HashMap 原理(面试与排障双高频)

结构:数组 + 链表 + 红黑树(JDK 8+)。

put(key, value) 的旅程:
1. key.hashCode() 经扰动函数混合 → & (table.length - 1) 算出数组下标
2. 该位置为空          → 直接放入
3. key 相同(equals)    → 覆盖 value
4. 哈希冲突(不同 key 同下标)→ 挂成链表
5. 链表长度 ≥ 8 且数组长度 ≥ 64 → 链表转红黑树,查询从 O(n) 降到 O(log n)
6. size 超过 容量 × 0.75(负载因子)→ 扩容为 2 倍并 rehash

三个推论,都值得记住:

  1. key 必须正确实现 equals/hashCode(第 03 章),否则查不到;
  2. new HashMap<>(expectedSize):传期望条目数(源码内部会按 0.75 换算容量),避免反复扩容;
  3. 遍历顺序不保证:需要插入序用 LinkedHashMap,需要排序用 TreeMap。

1.5 遍历与 fail-fast

Map<Long, Product> products = new HashMap<>();

// 遍历 entry:一次拿 key 和 value,标准写法
for (Map.Entry<Long, Product> e : products.entrySet()) {
    System.out.println(e.getKey() + " -> " + e.getValue().getName());
}
// 只要 value
for (Product p : products.values()) { ... }

// ⚠️ 遍历途中 remove/add 结构性修改 → ConcurrentModificationException(fail-fast)
// 正确姿势一:Iterator
Iterator<Product> it = products.values().iterator();
while (it.hasNext()) {
    if (it.next().getStock() == 0) it.remove();
}
// 正确姿势二(JDK 8+,首选):removeIf
products.values().removeIf(p -> p.getStock() == 0);

fail-fast 的意义:迭代器发现 modCount(结构修改计数)对不上就立刻抛异常,而不是让你在残缺数据上继续跑。它是「早点炸」的保护机制,不是并发容器(多线程请直接用 ConcurrentHashMap)。


二、示例代码

用四种集合各解决 mini-shop 的一个真实小需求:

// sample/mini-shop-se/src/main/java/com/example/minishop/cli/CollectionsDemo.java
package com.example.minishop.cli;

import com.example.minishop.model.Product;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class CollectionsDemo {

    public static void main(String[] args) {
        // 1) ArrayList:商品列表——顺序展示
        List<Product> shelf = new ArrayList<>();
        shelf.add(new Product(3L, "显卡", 880000L, 10));
        shelf.add(new Product(1L, "机械键盘", 19900L, 100));
        shelf.add(new Product(2L, "显示器", 459900L, 30));

        // 2) HashMap:id -> 商品 的索引。查询场景一律建索引,别在 List 里线性扫
        Map<Long, Product> index = new java.util.HashMap<>(32);
        for (Product p : shelf) {
            index.put(p.getId(), p);
        }
        System.out.println("id=2 -> " + index.get(2L).getName());

        // 3) LinkedHashMap:购物车——既按 key 去重,又记住「加入的先后」
        Map<Long, Integer> cart = new LinkedHashMap<>();
        cart.put(1L, 2);    // 键盘 x2
        cart.put(2L, 1);    // 显示器 x1
        cart.put(1L, 3);    // 又加一件键盘:覆盖为 3,位置保持在最前
        System.out.println("购物车顺序(按加入先后): " + cart.keySet());

        // 4) TreeMap:按价格排序的价目表(比较器按价格升序)
        //    ⚠️ 以价格为单值 key:同价商品会互相覆盖(本例三件商品价格恰好互不相同;
        //    sample 的 InMemoryProductRepository 用 Map<Long, List<Product>> 让同价共存)
        TreeMap<Long, String> priceTable = new TreeMap<>();
        for (Product p : shelf) {
            priceTable.put(p.getPriceInCents(), p.getName());
        }
        System.out.println("最便宜: " + priceTable.firstEntry());
        System.out.println("最贵:   " + priceTable.lastEntry());

        // 5) ArrayDeque:最近浏览记录——栈语义(后进先出)
        Deque<Long> history = new ArrayDeque<>();
        history.push(1L);
        history.push(3L);
        history.push(2L);
        System.out.println("最近浏览: " + history.pop());   // 2 —— 最后看的先出来
    }
}

输出:

id=2 -> 显示器
购物车顺序(按加入先后): [1, 2]
最便宜: 19900=机械键盘
最贵:   880000=显卡
最近浏览: 2

三、常见坑 ⚠️

  • ⚠️ Arrays.asList(...) 不是真 List:它长度固定,add/remove 直接抛 UnsupportedOperationException;且对基本类型数组会装出一个「只有一个元素(那个数组本身)」的 List。需要可变列表:new ArrayList<>(Arrays.asList(...))。
  • ⚠️ 遍历中结构性修改:见 1.5。用 removeIf 或 Iterator.remove。
  • ⚠️ 多线程用普通 HashMap:并发 put 可能丢数据甚至死循环(JDK 7 扩容环形链表的老bug,JDK 8 改了结构但依然线程不安全)。多线程一律 ConcurrentHashMap。
  • ⚠️ List<Integer> 与 remove(int):List<Integer> list 上调 list.remove(1) 删的是下标 1,不是元素 1!删元素请 list.remove(Integer.valueOf(1))。
  • ⚠️ 自动装箱 NPE:Map<Long, Integer> stock 里 get(id) 若不存在返回 null,拆箱 int s = map.get(id) 直接 NPE。用 getOrDefault(id, 0)。
  • ⚠️ subList 是视图:对原 List 的修改会反映到 subList(反之亦然),需要独立副本用 new ArrayList<>(list.subList(a, b))。
  • ⚠️ TreeSet/TreeMap 的元素没实现 Comparable:put 时抛 ClassCastException,要么实现接口要么构造时传 Comparator。

四、调试技巧

  • IDEA 调试面板自带集合视图:断点停在集合变量上,能看到每个元素;Map 可切 key 视图 / value 视图。
  • 「为什么 get 不到」三连:map.containsKey(k)、两个对象的 hashCode() 是否相等、equals() 是否为 true——三者依次定位问题在哪一环。
  • 怀疑线程安全问题时,先把局部变量打印逻辑加上时间戳;更系统的做法在第 11 章压测复现。

五、最佳实践

  • 声明用接口类型:List<Product> list = new ArrayList<>();,实现细节可以随时替换。
  • 查询先建索引:需要按 id 找对象时建 Map,而不是在 List 里 for 循环比对。
  • 已知规模就传初始容量;大集合常驻内存考虑预估上限。
  • 不可变场景用工厂:List.of("a", "b")、Map.of("k1", 1)(不可变、无 null、几元素内最省内存)。
  • 给集合元素实现简洁的 toString()(第 03 章),日志里才看得懂。

六、本章小结

  • 选型口诀:有序列表 ArrayList、键值索引 HashMap、去重 HashSet、要顺序 LinkedHashMap、要排序 TreeMap、栈队列 ArrayDeque、多线程 ConcurrentHashMap。
  • HashMap = 数组 + 链表/红黑树 + 扩容 2 倍 + 负载因子 0.75;key 的 equals/hashCode 必须正确。
  • fail-fast 是保护机制;遍历删除用 removeIf。
  • 集合装引用类型,警惕装箱 NPE 与 remove(int) 歧义。

下一章泛型:为什么 List<Product> 里的尖括号能保证你 get 出来的一定是 Product,以及怎么写自己的泛型接口。


七、动手练习

基础题

  1. 补全 CollectionsDemo:把购物车 cart(LinkedHashMap<Long, Integer>)遍历一遍,借助商品索引 index 计算购物车总金额(long 分),打印格式 合计:xxx.xx 元。
  2. 用 TreeMap 实现「查找价格在 200~500 元之间的所有商品」(subMap 方法,先统一单位为分再查询)。

进阶题

  1. 实现一个 RecentOrders 类:内部用 ArrayDeque 保留最近 100 笔订单(超限弹最旧的,Deque 大小判断 + pollLast),提供 record(Order o) 与 latest(int n)(返回最近 n 笔的从新到旧列表)。
  2. 把第 02 章的 InMemoryProductRepository 从 ConcurrentHashMap 改为同时维护一个 TreeMap<Long, Product>,新增 findByIdRange(long fromId, long toId) 方法(用 subMap),体会「同一份数据多个索引结构」的思路。

思考题

  1. ConcurrentHashMap 为什么禁止 null key 和 null value,而 HashMap 都允许?(提示:并发场景下 get 返回 null 时,你无法区分「不存在」和「值就是 null」,而 containsKey 与 get 两次调用之间状态可能已变。)这个设计决策对 API 使用方式有什么影响?

📄 第 05 章 泛型:类型参数、通配符与类型擦除 05-generics.md

阶段:阶段 1 · 入门基础 | 预计阅读 55 min | 代码落地模块:sample/mini-shop-se、sample/mini-shop-common 前置:第 02 章 面向对象基础 | 相关章节:第 04 章 集合 | 第 07 章 Stream 实战落点:Repository<T, ID> 泛型仓储接口、Result<T> 统一响应、PageResult<T> 分页——全书反复复用

学习目标

读完本章,你应该能够:

  • [ ] 读懂并写出泛型类、泛型接口、泛型方法。
  • [ ] 讲清泛型存在的意义:把 ClassCastException 从运行期消灭到编译期。
  • [ ] 正确使用通配符 ? extends / ? super,记住 PECS 口诀。
  • [ ] 理解类型擦除带来的三个限制,不再写出「不可能工作」的泛型代码。
  • [ ] 用泛型把第 02 章的仓储接口抽象成可复用的 Repository<T, ID>。

一、核心概念

1.1 为什么需要泛型

没有泛型的远古 Java,集合里装的都是 Object:

旧写法(看得懂就行)
// 老写法(JDK 4 及以前)
List list = new ArrayList();
list.add("键盘");
String name = (String) list.get(0);   // 必须强转
list.add(123);                        // 编译器不拦!Integer 混进来了
String oops = (String) list.get(1);   // 运行期 ClassCastException 💥

泛型(JDK 5+)把类型变成参数,编译期就锁定:

List<String> list = new ArrayList<>();
list.add("键盘");
String name = list.get(0);   // 无强转
list.add(123);               // 编译失败 ✅ 错误提前暴露

一句话:泛型 = 把运行期的类型错误提前到编译期,顺带省掉强转。

1.2 泛型类与类型参数命名

// T = Type,容器里装的主类型;常见约定:T 类型、E 元素、K 键、V 值、R 返回值
public class Box<T> {
    private T content;
    public void put(T t)      { this.content = t; }
    public T get()            { return content; }
}

1.3 泛型接口:抽象 Repository

第 02 章的 ProductRepository 只服务商品。用泛型抽成通用仓储契约:

// sample/mini-shop-common/src/main/java/com/example/minishop/common/repository/Repository.java
package com.example.minishop.common.repository;

import java.util.List;
import java.util.Optional;

/**
 * 通用仓储契约。
 * T = 实体类型(如 Product),ID = 主键类型(如 Long)。
 * 一次抽象,商品/订单/用户仓储全部复用同一套语义。
 */
public interface Repository<T, ID> {

    T save(T entity);

    Optional<T> findById(ID id);

    List<T> findAll();

    /** @return 是否真的删到了(不存在的 id 返回 false 而不是报错) */
    boolean deleteById(ID id);

    long count();
}
// sample/mini-shop-se/src/main/java/com/example/minishop/repository/InMemoryProductRepository.java(节选)
// 泛型落地:实体=Product,主键=Long(现版 implements ProductRepository,后者继承 Repository<Product, Long>)
public class InMemoryProductRepository implements ProductRepository {

    private final ConcurrentMap<Long, Product> store = new ConcurrentHashMap<>();

    @Override
    public Product save(Product product) { store.put(product.getId(), product); return product; }

    @Override
    public Optional<Product> findById(Long id) { return Optional.ofNullable(store.get(id)); }

    @Override
    public List<Product> findAll() {
        return store.values().stream()
                .sorted(Comparator.comparing(Product::getId))
                .toList();
    }

    @Override
    public boolean deleteById(Long id) { return store.remove(id) != null; }

    @Override
    public long count() { return store.size(); }

    // 另有按价格排序的辅助索引与 findByPriceRange(第 04 章练习落地),完整版见 sample
}

第 31 章 mini-shop 的 InMemoryOrderRepository 将以同样方式实现 Repository<Order, Long>——一份契约,两个实现,这就是泛型的复用价值。

1.4 泛型方法

类型参数写在返回类型之前,作用域仅限本方法:

public class RepoSupport {
    /** 从列表里按 id 查找:T 由调用处的参数自动推断 */
    public static <T, ID> Optional<T> findById(List<T> list, ID id, Function<T, ID> keyExtractor) {
        return list.stream()
                .filter(item -> keyExtractor.apply(item).equals(id))
                .findFirst();
    }
}

// 调用:T 被推断为 Product,ID 被推断为 Long——一个字母都不用写
Optional<Product> p = RepoSupport.findById(shelf, 2L, Product::getId);

static 方法不能用类的类型参数 T(那是属于对象的),所以要自己声明——这是初学者最常撞的墙。

1.5 通配符与 PECS

先看一个「看似没问题」的编译错误(金额口径与全书一致:long 分):

static long totalPriceInCents(List<Product> list) { ... }
List<DigitalProduct> digitals = ...;
totalPriceInCents(digitals);   // ❌ 编译失败!List<DigitalProduct> 不是 List<Product> 的子类型

泛型没有继承协变:List<DigitalProduct> 与 List<Product> 是两个独立类型。解决之道是通配符:

// extends:只读。任何 Product 子类的列表都接受
static long totalPriceInCents(List<? extends Product> list) {
    long sum = 0;
    for (Product p : list) {
        sum += p.getPriceInCents();   // 读:当 Product 用,OK
    }
    return sum;
    // list.add(...)    // ❌ 编译失败——编译器不知道确切子类型,禁止写入
}

// super:只写。任何「能装下 Product 及其父类」的列表都接受
static void fillDigitalProducts(List<? super DigitalProduct> sink) {
    sink.add(new DigitalProduct(...));   // 写:放进去一定是合法的
}

PECS 口诀(Producer Extends, Consumer Super):

  • 这个参数是给你生产数据(你从中读)→ 用 ? extends T;
  • 这个参数是消费数据(你往里写)→ 用 ? super T;
  • 又读又写 → 老老实实用精确类型 T。

JDK 自己就是这么用的:Collections.copy(List<? super T> dest, List<? extends T> src)。

1.6 类型擦除与它的三个限制

泛型只存在于编译期,编译后 List<String> 和 List<Integer> 都是 List——这叫类型擦除(Type Erasure),编译器在前后帮你插入强转与检查。由此带来三个硬限制:

// 1. 运行期拿不到泛型参数本身
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
a.getClass() == b.getClass();          // true!都是 java.util.ArrayList

// 2. 不能 new T()
class Repo<T> {
    T create() { return new T(); }     // ❌ 编译失败。T 擦除后 JVM 不知道它是什么
}

// 3. 不能 new 泛型数组、不能用基本类型做参数
List<int> nums;                        // ❌ 只能 List<Integer>
T[] arr = new T[10];                   // ❌

[!TIP] 需要在运行期保留类型信息(框架里很常见,比如第 09 章反序列化 JSON 列表)时,把类型作为参数传进来:deserialize(json, Product.class),方法签名 static <T> T deserialize(String s, Class<T> clazz)——Class<T> 就是「携带在手上」的类型令牌。


二、示例代码

写两个全书反复出现的泛型工具类型:统一响应 Result<T> 与分页 PageResult<T>。

// sample/mini-shop-common/src/main/java/com/example/minishop/common/api/Result.java(节选,完整版见 sample)
package com.example.minishop.common.api;

/** 统一 API 响应外壳:code=0 成功;非 0 为错误码。第 23 章 REST 规范的直接基石 */
public class Result<T> {

    private int code;
    private String message;
    private T data;

    private Result(int code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    // 静态工厂让调用侧语义清晰:Result.ok(data) / Result.fail(code, msg)
    public static <T> Result<T> ok(T data)              { return new Result<>(0, "ok", data); }
    public static <T> Result<T> fail(int code, String message) {
        return new Result<>(code, message, null);
    }

    public int getCode() { return code; }
    public String getMessage() { return message; }
    public T getData() { return data; }
}
// sample/mini-shop-common/src/main/java/com/example/minishop/common/api/PageResult.java(节选;of(...) 工厂是练习 3 的落地,完整版见 sample)
package com.example.minishop.common.api;

import java.util.List;

/** 分页结果:任何列表查询接口都能复用 */
public record PageResult<T>(List<T> records, long total, int page, int size) {

    public PageResult {
        records = List.copyOf(records);   // record 紧凑构造器里做防御性拷贝
    }

    /** 总页数:向上取整的惯用位运算写法,(total + size - 1) / size 等价 */
    public int totalPages() {
        return (int) ((total + size - 1) / size);
    }
}

使用效果:

Result<PageResult<Product>> resp =
        Result.ok(new PageResult<>(shelf, 120, 1, 20));
resp.getData().records().get(0).getName();   // 类型链完整,全程无强转

三、常见坑 ⚠️

  • ⚠️ 泛型与重载冲突:void f(List<String>) 和 void f(List<Integer>) 擦除后签名相同,编译失败。换方法名。
  • ⚠️ 以为 List<DigitalProduct> 是 List<Product> 的子类型:见 1.5,用 ? extends 解决。
  • ⚠️ new T() / new T[] / T.class:全部编译失败。用 Class<T> 令牌或工厂传入。
  • ⚠️ 裸类型(raw type):List list = new ArrayList(); 能编译但失去全部类型检查,还产生 unchecked 警告。禁用裸类型,IDEA 会标黄。
  • ⚠️ 边界里的装箱陷阱:List<Long> ids 与 List<Integer> 混用时,contains(1) 因类型不符恒为 false——注意字面量后缀 1L。

四、调试技巧

  • 泛型报错信息冗长难读时,从最内层的尖括号往外读:先定位哪个具体类型不匹配,再看外层容器。
  • IDEA Ctrl + Q(快速文档)悬停在方法上,会显示推断后的实际泛型参数,比盯源码快得多。
  • 运行期想确认真实类型:直接 obj.getClass()——泛型被擦除了,运行期只有原始类型。

五、最佳实践

  • 对外 API 与集合字段永远带泛型参数,配合第 04 章的「接口类型声明」双保险。
  • 通用语义抽象成泛型接口(Repository<T, ID>、Result<T>),实体-specific 逻辑放实现类。
  • 方法参数用 PECS 提升 API 弹性;不确定时先写精确类型,出现复用需求再放宽。
  • 泛型嵌套别超过两层(Map<String, List<Result<T>>> 已经在警告线上了),太深说明该拆 DTO 了。

六、本章小结

  • 泛型把类型错误从运行期挪到编译期,并消灭强转。
  • Repository<T, ID>、Result<T>、PageResult<T> 是全书复用三次以上的抽象。
  • PECS:读 extends、写 super、又读又写用精确类型。
  • 类型擦除带来三个限制(取不到参数、不能 new T、不能泛型数组),解法是传 Class<T> 令牌。

下一章异常处理:让 StockNotEnoughException 这类业务语义在异常体系里有一席之地,并且不再出现 catch (Exception e) {}。


七、动手练习

基础题

  1. 把 InMemoryOrderRepository 按 Repository<Order, Long> 接口实现一遍(内存结构用 ConcurrentHashMap<Long, Order>),在 main 里保存两笔订单并 count() 验证。
  2. 用 RepoSupport.findById 的思路,写一个泛型方法 <T> List<T> filter(List<T> list, Predicate<T> cond),并用它过滤出库存为 0 的商品。

进阶题

  1. 给 PageResult<T> 增加 static <T> PageResult<T> of(List<T> allRecords, int page, int size):内部完成内存分页(下标越界时返回空列表而不是抛异常),思考这个工厂方法为什么比构造器更友好。
  2. 设计泛型接口 Cache<K, V>(get、put、evict),先给出 ConcurrentHashMap 内存实现;第 24 章它会演化为 Redis 缓存接口——现在就体验「面向接口留下演化空间」。

思考题

  1. Result<T> 为什么把构造器设为 private 只留静态工厂?如果允许 new Result<>(-1, "x", data)(成功码 + 有数据)这种非法组合,会给调用方带来什么麻烦?由此体会「让非法状态不可表示」的设计原则与第 03 章枚举状态机的相通之处。

📄 第 06 章 异常处理:体系、自定义业务异常与 try-with-resources 06-exceptions.md

阶段:阶段 1 · 入门基础 | 预计阅读 55 min | 代码落地模块:sample/mini-shop-common(exception)+ sample/mini-shop-se(service) 前置:第 02 章 面向对象基础 | 相关章节:第 23 章 全局异常处理 实战落点:BizException / ErrorCode 体系——第 23 章直接演化为全局异常处理器,第 34 章的幂等与库存校验都靠它

学习目标

读完本章,你应该能够:

  • [ ] 画出 Java 异常类层次,说清 checked 与 unchecked 的分工。
  • [ ] 定义带错误码的业务异常体系,替代「到处返回 -1 / null」的丑陋写法。
  • [ ] 正确使用 try-catch-finally 与 try-with-resources,不再泄漏资源。
  • [ ] 遵守异常处理的伦理:要么处理,要么往上抛,绝不吞掉。

一、核心概念

1.1 异常类层次

flowchart TB
    Throwable["Throwable"] --> Error["Error<br/>JVM 级错误,不接(OOM/StackOverflow)"]
    Throwable --> Exception["Exception"]
    Exception --> Checked["受检异常 checked<br/>IOException / SQLException<br/>编译器强制处理"]
    Exception --> Runtime["RuntimeException<br/>非受检 unchecked"]
    Runtime --> ISE["IllegalStateException"]
    Runtime --> IAE["IllegalArgumentException"]
    Runtime --> NPE["NullPointerException"]
类别 何时用 典型代表 调用方必须
Error JVM 撑不住了 OutOfMemoryError、StackOverflowError 不捕获,直接让进程死(第 14 章讲怎么排查)
checked 可预期、必须被应对的外部失败 IOException、SQLException 要么 try-catch,要么方法签名 throws 声明
unchecked 编程错误或业务拒绝 NPE、IllegalArgumentException、你的 BizException 自由选择;通常在统一入口处理

业务后端的实用结论:业务规则不满足(库存不足、重复下单)→ 抛非受检的 BizException;只有「调用外部 I/O 必须面对失败」时才和 checked 异常打交道。

1.2 抛出与捕获的基本姿势

public void shipOrder(Order order) {
    if (!order.getStatus().canShip()) {
        // 异常消息要包含「当时的数据」:排障时它就是现场证词
        throw new IllegalStateException("状态[" + order.getStatus() + "]不允许发货, orderId=" + order.getId());
    }
    // ...
}
try {
    orderService.pay(orderId);
} catch (BizException e) {
    // 处理 = 有意义的动作:转错误响应 / 记录 + 告警 / 降级重试
    log.warn("支付被拒绝: {}", e.getMessage());
} finally {
    metrics.increment("order.pay.attempt");   // 无论成败都执行的收尾(如指标、解锁)
}

多重 catch 与合并 catch:

try {
    parseAndValidate(raw);
} catch (IllegalArgumentException | IllegalStateException e) {  // 多类型合并:同一处理逻辑
    throw new BizException(ErrorCode.PARAM_INVALID, e.getMessage());
} catch (Exception e) {          // 兜底放最后,范围由小到大
    log.error("未知错误", e);
    throw new BizException(ErrorCode.SYSTEM_ERROR);
}

1.3 自定义业务异常体系

企业后端的标配三件套:错误码枚举 + 业务异常 + 异常基类。

// sample/mini-shop-common/src/main/java/com/example/minishop/common/exception/ErrorCode.java(节选,完整版见 sample,含 PRODUCT_NOT_FOUND / PAY_CLOSED 等)
package com.example.minishop.common.exception;

/** 错误码枚举:code 直接暴露给前端/调用方,必须稳定、永不复用 */
public enum ErrorCode {
    PARAM_INVALID(40001, "参数不合法"),
    NOT_LOGIN(40101, "未登录或登录已过期"),
    ORDER_NOT_FOUND(40401, "订单不存在"),
    STOCK_NOT_ENOUGH(40901, "库存不足"),
    DUPLICATE_ORDER(40902, "请勿重复下单"),
    SYSTEM_ERROR(50000, "系统繁忙,请稍后再试");

    private final int code;
    private final String message;

    ErrorCode(int code, String message) {
        this.code = code;
        this.message = message;
    }
    public int getCode() { return code; }
    public String getMessage() { return message; }
}
// sample/mini-shop-common/src/main/java/com/example/minishop/common/exception/BizException.java
package com.example.minishop.common.exception;

/** 业务异常:规则拒绝(可预期、非程序 bug)。unchecked,业务代码放心抛 */
public class BizException extends RuntimeException {

    private final ErrorCode errorCode;

    public BizException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    /** 支持覆盖默认消息:带上现场数据(哪个商品、差多少库存) */
    public BizException(ErrorCode errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    /** 包装底层异常:必须携带 cause,排障时才有完整异常链(见 1.5) */
    public BizException(ErrorCode errorCode, String message, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() { return errorCode; }
}
// sample/mini-shop-common/src/main/java/com/example/minishop/common/exception/StockNotEnoughException.java
package com.example.minishop.common.exception;

/** 具名业务异常:catch 它 = 表达「我知道库存不足该怎么处理」 */
public class StockNotEnoughException extends BizException {

    private final Long productId;
    private final int required;
    private final int available;

    public StockNotEnoughException(Long productId, int required, int available) {
        super(ErrorCode.STOCK_NOT_ENOUGH,
                "商品" + productId + "库存不足: 需要" + required + ", 仅剩" + available);
        this.productId = productId;
        this.required = required;
        this.available = available;
    }

    public Long getProductId() { return productId; }
    public int getRequired() { return required; }
    public int getAvailable() { return available; }   // 调用方可据此决定降级策略
}

三层设计的分工:ErrorCode 定接口语义,BizException 做通用载体,StockNotEnoughException 这类具名子类承载调用方需要的数据(库存差多少),把「异常」从字符串升级为可编程对象。

1.4 try-with-resources:资源管理正确解

文件、连接等资源必须关闭。手写 finally 的关闭代码又长又容易错——try-with-resources(JDK 7+)让任何实现 AutoCloseable 的对象自动关闭:

旧写法(看得懂就行)
BufferedReader r = null;
try {
    r = new BufferedReader(new FileReader(path));
    return r.readLine();
} finally {
    if (r != null) try { r.close(); } catch (IOException ignore) {}  // 嵌套又吞异常
}
// 新写法:括号里声明的资源在 try 结束时自动逆序关闭
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    return reader.readLine();
}   // reader.close() 自动调用,异常也不会被 close 掩盖

[!TIP] 自定义资源类实现 AutoCloseable 即可享受同样待遇。第 08 章 IO、第 21 章数据库连接池、第 13 章线程池关闭,全是它的舞台。

1.5 异常链:不要弄丢根因

try {
    return json.parse(raw);
} catch (Exception e) {
    // ⚠️ 错误:throw new BizException(...)  —— 根因堆栈丢了
    // 正确:用接收 cause 的三参构造器,把原始异常一并带上
    throw new BizException(ErrorCode.SYSTEM_ERROR, "订单快照解析失败", e);
}

排障时日志里若只有你的包装异常而没有 Caused by:,基本等于现场被破坏。


二、示例代码

把异常体系接进 mini-shop 的下单链路(sample 现版,含多商品部分失败时的库存回补——catch 不只用来「展示错误」,还负责把已扣的库存还回去):

// sample/mini-shop-se/src/main/java/com/example/minishop/service/OrderService.java
package com.example.minishop.service;

import com.example.minishop.common.exception.BizException;
import com.example.minishop.common.exception.ErrorCode;
import com.example.minishop.common.exception.StockNotEnoughException;
import com.example.minishop.model.Order;
import com.example.minishop.model.OrderItem;
import com.example.minishop.model.Product;
import com.example.minishop.repository.InMemoryOrderRepository;
import com.example.minishop.repository.ProductRepository;

import java.util.ArrayList;
import java.util.List;

public class OrderService {

    private final ProductRepository productRepository;
    private final InMemoryOrderRepository orderRepository;

    public OrderService(ProductRepository productRepository, InMemoryOrderRepository orderRepository) {
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
    }

    /**
     * 下单:逐项校验库存并扣减,全部通过才落订单。用异常表达业务拒绝。
     * ⚠️ 多商品部分失败必须回补:第 N 项库存不足时,前面已扣的库存若不回补会永久丢失。
     */
    public Order createOrder(Long userId, List<OrderItem> items) {
        List<OrderItem> deducted = new ArrayList<>();
        try {
            for (OrderItem item : items) {
                Product product = productRepository.findById(item.productId())
                        .orElseThrow(() -> new BizException(ErrorCode.PARAM_INVALID,
                                "商品不存在: " + item.productId()));

                if (product.getStock() < item.quantity()) {
                    throw new StockNotEnoughException(product.getId(), item.quantity(), product.getStock());
                }
                product.decreaseStock(item.quantity());
                deducted.add(item);
            }
        } catch (RuntimeException e) {
            // 回补已扣减的项,再向上抛业务拒绝
            for (OrderItem item : deducted) {
                productRepository.findById(item.productId())
                        .ifPresent(p -> p.increaseStock(item.quantity()));
            }
            throw e;
        }

        Order order = new Order(orderRepository.nextId(), userId, items);
        return orderRepository.save(order);
    }

    // pay / close / mustFind 见 sample:支付与关单同样用异常表达非法状态转移
}

调用方分层处理:

try {
    orderService.createOrder(100L, items);
} catch (StockNotEnoughException e) {
    // 精细处理:知道差多少,可推荐「再等补货」或「改为预购」
    ui.toast(e.getMessage());
} catch (BizException e) {
    // 通用业务拒绝:直接展示 message 给用户
    ui.toast(e.getMessage());
} catch (Exception e) {
    // 兜底:未预期的程序错误——记录完整堆栈 + 通用话术,绝不能把堆栈甩给用户
    log.error("下单失败", e);
    ui.toast("系统繁忙,请稍后再试");
}

三、常见坑 ⚠️

  • ⚠️ 吞异常:catch (Exception e) {} 或只 e.printStackTrace()。前者让故障静默扩散,后者输出到 stdout 而非日志系统(第 27 章会讲为什么要用 log)。要么处理,要么抛出。
  • ⚠️ catch 了不该 catch 的:catch (Exception e) 包住整个方法,会把 NPE 等编程 bug 也「美化」成业务错误,掩埋真问题。catch 范围尽量精确,兜底 catch 只在统一入口。
  • ⚠️ 用异常做流程控制:正常路径也靠抛异常传递结果,性能差且难读。异常只表达「异常」。
  • ⚠️ finally 里 return:会吞掉 try/catch 中的异常与返回值,禁止。
  • ⚠️ 丢失 cause:包装异常必须携带原始异常(异常链),否则排障时根因断链。
  • ⚠️ 异常消息不带数据:"下单失败" 没有信息量;"订单1234库存不足: 需要5, 仅剩2" 才是现场证词。

四、调试技巧

  • IDEA 断点右键 → Exception Breakpoints(勾选 BizException 或 All):任何异常抛出瞬间自动停住,比等堆栈打印更快。
  • 读堆栈从第一个 Caused by: 开始找根因;顶层异常往往是包装,不是元凶。
  • 日志里 log.error("msg", e) 一定要把异常对象作为最后一个参数传进去,才能打出完整堆栈。

五、最佳实践

  • 业务拒绝 → BizException + ErrorCode;编程错误 → 让 NPE/ISE 原样炸出来。
  • 具名异常子类只建「调用方需要差异化处理」的那几个,别为每种场景都建类。
  • 统一出口处理:Web 层用 @RestControllerAdvice 全局兜底(第 23 章),业务代码只管抛。
  • 资源一律 try-with-resources;关闭逻辑绝不手写。
  • 错误码分配规划好段位(4xx 客户端问题、5xx 服务端问题),写进团队规范永不复用。

六、本章小结

  • 异常三分类:Error 不接、checked 应对外部失败、unchecked 表达编程错误与业务拒绝。
  • 企业标配:ErrorCode + BizException + 必要的具名子类;异常消息带现场数据。
  • try-with-resources 自动关闭资源;包装异常必须带 cause。
  • 伦理三连:不吞、不滥用、不拿异常当流程控制。

下一章 Lambda 与 Stream:把「过滤库存不足商品再汇总金额」这类集合加工,从三层 for 循环变成一行流水线。


七、动手练习

基础题

  1. 把第 02 章 Product.decreaseStock 里的 IllegalStateException 替换为 StockNotEnoughException,OrderService.createOrder 相应调整 catch 顺序,验证「库存不足」能被精细捕获。
  2. 为 ErrorCode 增加 PAY_CLOSED(40903, "订单已关闭"),写一个 closeOrder 场景:对已支付订单调用关单时抛出对应异常,调用方按 ErrorCode 精细处理。

进阶题

  1. 给 BizException 增加接收 Throwable cause 的构造器;模拟「JSON 解析失败导致下单中止」场景:catch 原始异常后包装抛出,打印日志确认 Caused by 链完整。
  2. 实现一个 RetryExecutor:static <T> T execute(Callable<T> task, int maxAttempts),捕获 IOException 重试至多 maxAttempts 次(间隔可先用 Thread.sleep(100)),最终失败抛出最后一次的异常(带 cause 链)。这是生产代码里重试骨架的雏形。

思考题

  1. checked 异常的初衷是「强制处理」,但 Spring/MyBatis 等现代框架几乎全部转向 unchecked(DataAccessException 体系)。你认为原因是什么?从「调用方是否真的能处理」「层间签名污染(一路 throws)」两个角度想一想,再对照 1.1 的表格给出你的选择标准。

📄 第 07 章 Lambda 与 Stream:函数式编程入门 07-lambda-and-stream.md

阶段:阶段 1 · 入门基础 | 预计阅读 75 min | 代码落地模块:sample/mini-shop-se 前置:第 05 章 泛型 | 相关章节:第 13 章 CompletableFuture | 第 24 章 缓存 实战落点:购物车金额汇总、订单分组统计、热销 Top N——第 31 章实战代码的日常写法

学习目标

读完本章,你应该能够:

  • [ ] 理解函数式接口与 Lambda 的关系,读懂 -> 与 :: 语法。
  • [ ] 用 Stream 流水线替代三层嵌套 for:过滤、映射、排序、归约。
  • [ ] 熟练使用 Collectors:toList、groupingBy、toMap、joining。
  • [ ] 知道流是一次性的、惰性的,终端操作才触发执行。
  • [ ] 识别并行流的适用边界,不再写「parallelStream + 共享可变状态」这种事故代码。

一、核心概念

1.1 函数式接口与 Lambda

函数式接口(Functional Interface)= 只有一个抽象方法的接口。Lambda 就是它的实例的简写:

旧写法(看得懂就行)
// 老写法:匿名类(第 03 章末尾见过)
Comparator<Product> byPrice = new Comparator<>() {
    @Override
    public int compare(Product a, Product b) {
        return Long.compare(a.getPriceInCents(), b.getPriceInCents());
    }
};
// Lambda:类型可推断时直接写参数与表达式
Comparator<Product> byPrice2 = (a, b) -> Long.compare(a.getPriceInCents(), b.getPriceInCents());

// 方法引用:Lambda 只是「转发到已有方法」时的再简化(可读性最优)
Comparator<Product> byPrice3 = Comparator.comparingLong(Product::getPriceInCents);

JDK 内置四大函数式接口,读懂它们就能读懂 90% 的 Lambda 代码:

接口 抽象方法 语义 示例
Predicate<T> boolean test(T) 判断 p -> p.getStock() > 0
Function<T, R> R apply(T) 转换 Product::getName
Consumer<T> void accept(T) 消费 System.out::println
Supplier<T> T get() 生产 ArrayList::new

方法引用四种形式:类::静态方法(Integer::parseInt)、对象::实例方法(System.out::println)、类::实例方法(Product::getName,第一个参数当接收者)、类::new(构造引用)。

1.2 Stream:声明式集合流水线

Stream 不是数据结构,是对数据源的一条加工流水线:

数据源 → 中间操作0..n(惰性,返回新流) → 终端操作 ×1(触发执行)
        filter / map / sorted /        forEach / collect / count /
        distinct / limit / flatMap     findFirst / anyMatch / reduce
// 需求:找出有货商品的名称,按价格升序,取前 3 个
List<String> names = shelf.stream()                        // 1. 数据源
        .filter(p -> p.getStock() > 0)                     // 2. 中间:过滤
        .sorted(Comparator.comparingLong(Product::getPriceInCents))  // 3. 中间:排序
        .map(Product::getName)                             // 4. 中间:映射为名称
        .limit(3)                                          // 5. 中间:截取
        .toList();                                         // 6. 终端:收集(JDK 16+)

对照三层 for + 临时变量 + if 的命令式版本,声明式版本读起来就是需求本身。

两条铁律:

  1. 流是一次性的:终端操作后流就关闭,再调用会抛 IllegalStateException;要复用请重新从数据源获取。
  2. 惰性求值:没有终端操作,中间操作一行都不执行(调试时发现「断点不进 filter」,先看有没有终端操作)。

1.3 原始类型流与数值计算

泛型流的元素是包装类型,数值聚合有专用流避免装箱:

long total = cartItems.stream()
        .mapToLong(OrderItem::subtotalInCents)   // Stream -> LongStream(元素变 long)
        .sum();

LongSummaryStatistics stat = shelf.stream()
        .mapToLong(Product::getPriceInCents)
        .summaryStatistics();                    // count / min / max / sum / average 一次拿全

1.4 Collectors:收集器的重头戏

// toList:收集为列表(最常用)
List<String> names = shelf.stream().map(Product::getName).toList();

// joining:字符串拼接
String csv = shelf.stream().map(Product::getName).collect(Collectors.joining(",", "[", "]"));

// groupingBy:分组 → Map<分组键, List<元素>>(Product 没有分类字段,这里按用户分组订单)
Map<Long, List<Order>> ordersByUser =
        orders.stream().collect(Collectors.groupingBy(Order::getUserId));

// groupingBy + 下游收集器:分组计数 / 分组求和(报表原型)
Map<String, Long> countByName = orders.stream()
        .flatMap(o -> o.getItems().stream())
        .collect(Collectors.groupingBy(OrderItem::productName, Collectors.counting()));
Map<Long, Long> amountByUser = orders.stream()
        .collect(Collectors.groupingBy(Order::getUserId,
                Collectors.summingLong(Order::getPayableInCents)));

// partitioningBy:按布尔分两组
Map<Boolean, List<Product>> split =
        shelf.stream().collect(Collectors.partitioningBy(p -> p.getStock() == 0));

// toMap:列表转索引(第 04 章「查询先建索引」的 Stream 写法)
Map<Long, Product> index = shelf.stream()
        .collect(Collectors.toMap(Product::getId, Function.identity()));

1.5 flatMap 与 Optional

// flatMap:流中流拍平。需求:所有订单的所有订单项
List<OrderItem> allItems = orders.stream()
        .flatMap(o -> o.getItems().stream())   // 每笔订单 -> 订单项流,拍平合并
        .toList();

// findFirst / findAny 返回 Optional:强迫你处理「没有找到」(第 02 章伏笔回收)
Product cheapest = shelf.stream()
        .min(Comparator.comparingLong(Product::getPriceInCents))
        .orElseThrow(() -> new BizException(ErrorCode.PARAM_INVALID, "货架空了"));

1.6 并行流:一把双刃剑 ⚠️

long sum = hugeList.parallelStream().mapToLong(...).sum();  // ForkJoinPool 并行计算

只在同时满足以下条件时才考虑:数据量大(十万级以上)、元素计算 CPU 密集且相互独立、无共享可变状态、数据源易切分(ArrayList 好,LinkedList 差)。

// ❌ 事故代码:parallelStream + 共享 List。并发 add 丢数据甚至抛异常
List<String> result = new ArrayList<>();
hugeList.parallelStream().forEach(x -> result.add(process(x)));   // ArrayList 非线程安全!

// ✅ 正确:收集交给 collect 终端操作,或干脆串行
List<String> result2 = hugeList.parallelStream().map(x -> process(x)).toList();

后端 Web 场景的真相:请求处理时间大多耗在数据库/网络 IO 上,CPU 并行收益小、坑多。默认串行流,没有压测数据不要开并行。


二、示例代码

用 Stream 重写 mini-shop 的四个真实统计需求:

// sample/mini-shop-se/src/main/java/com/example/minishop/cli/StreamDemo.java
package com.example.minishop.cli;

import com.example.minishop.model.Order;
import com.example.minishop.model.OrderItem;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class StreamDemo {

    public static void main(String[] args) {
        List<Order> orders = mockOrders();

        // 需求 1:商品交易总额(Gross Merchandise Volume, GMV)——订单项小计求和
        long gmv = orders.stream()
                .flatMap(o -> o.getItems().stream())
                .mapToLong(OrderItem::subtotalInCents)
                .sum();
        System.out.printf("GMV: %d.%02d 元%n", gmv / 100, gmv % 100);

        // 需求 2:每个用户的下单金额(groupingBy + summing)
        Map<Long, Long> amountByUser = orders.stream()
                .collect(Collectors.groupingBy(Order::getUserId,
                        Collectors.summingLong(Order::getPayableInCents)));
        System.out.println("用户消费榜: " + amountByUser);

        // 需求 3:热销商品 Top 2(先拍平订单项 -> 按商品分组求件数 -> 排序取前 2)
        List<Map.Entry<Long, Integer>> top2 = orders.stream()
                .flatMap(o -> o.getItems().stream())
                .collect(Collectors.groupingBy(OrderItem::productId,
                        Collectors.summingInt(OrderItem::quantity)))   // 销量 = 件数求和,不是出现次数
                .entrySet().stream()
                .sorted(Map.Entry.<Long, Integer>comparingByValue().reversed())
                .limit(2)
                .toList();
        System.out.println("热销 Top2 (商品id, 销量件数): " + top2);

        // 需求 4:一个句子里出现过的所有商品名(去重、排序、拼接)
        String catalog = orders.stream()
                .flatMap(o -> o.getItems().stream())
                .map(OrderItem::productName)
                .distinct()
                .sorted()
                .collect(Collectors.joining(" / "));
        System.out.println("本单涉及商品: " + catalog);
    }

    private static List<Order> mockOrders() {
        // 构造 3 笔演示订单;完整下单逻辑见第 31 章
        Order o1 = new Order(1L, 100L, List.of(
                new OrderItem(1L, "机械键盘", 2, 19900L),
                new OrderItem(2L, "显示器", 1, 459900L)));
        o1.pay();
        Order o2 = new Order(2L, 100L, List.of(new OrderItem(1L, "机械键盘", 1, 19900L)));
        Order o3 = new Order(3L, 200L, List.of(new OrderItem(3L, "显卡", 1, 880000L)));
        return List.of(o1, o2, o3);
    }
}

输出:

GMV: 13996.00 元
用户消费榜: {100=519600, 200=880000}
热销 Top2 (商品id, 销量件数): [1=3, 2=1]
本单涉及商品: 显卡 / 机械键盘 / 显示器

[!TIP] Order 构造后立刻 o1.pay() 是为了绕开演示数据的状态限制——真实代码里状态流转都在业务方法里(第 03 章状态机)。这提醒我们:测试数据构造也要走业务语义。


三、常见坑 ⚠️

  • ⚠️ toMap 遇到重复 key 抛 IllegalStateException:默认不合并。知道会重复就给第三个参数:toMap(k, v, (a, b) -> a)(保留先出现的)或 (a, b) -> b。同理 toMap 的 value 为 null 会直接 NPE。
  • ⚠️ groupingBy 的分组键为 null:抛 NPE。先 filter 掉键为 null 的元素。
  • ⚠️ 流复用:同一个 Stream 对象只能消费一次。Stream<Product> s = shelf.stream(); s.count(); s.count(); → IllegalStateException。
  • ⚠️ parallelStream + 共享可变集合:见 1.6,事故高发区。
  • ⚠️ 在 Stream 里改外部状态 / 做 IO:forEach 里写数据库、改成员变量,让流水线失去可读性还有副作用。副作用操作(落库、发消息)放在终端 collect 拿到结果之后。
  • ⚠️ sorted 的比较器抛 NPE:Comparator.comparing(Product::getName) 遇到 null 字段直接炸,需要 null 友好时用 Comparator.nullsFirst(...) 包装。

四、调试技巧

  • IDEA 对 Stream 提供调试专用按钮(调试流式调用时暂停在终端操作,点 Trace Current Stream Chain):能看到每一步中间结果的可视化表格——学习 Stream 的最强工具。
  • 想看中间结果又不想引入依赖:临时插 .peek(System.out::println)(⚠️ 仅调试用,peek 在某些优化路径下不保证执行,生产代码别依赖它)。
  • Lambda 里断点:直接点行号即可;this 与局部变量在调试面板的 Lambda 帧(lambda$main$0)里查看。

五、最佳实践

  • 一条流水线不超过 5~6 个操作,再长就拆方法(每个方法名=一个业务意图)。
  • 数值聚合用 mapToLong/mapToInt 专用流,避免装箱。
  • 「查索引」场景用 toMap(记得给合并函数),替代 for 循环里手动 map.put。
  • 集合遍历的纯计算交给 Stream;含 IO/事务的批处理老老实实写 for 循环,错误处理更直白。
  • 团队约定:parallelStream 需 Code Review 特批并附压测数据。

六、本章小结

  • 函数式接口是 Lambda 的载体;四大件 Predicate/Function/Consumer/Supplier 覆盖日常。
  • Stream = 数据源 + 惰性中间操作 + 唯一终端操作;一次性消费。
  • Collectors 三板斧:toList/groupingBy/toMap(toMap 记得带合并函数)。
  • 并行流默认不用;副作用(IO、改共享状态)绝不放进流水线。

下一章 IO 与 NIO.2:把订单快照写进文件、把配置读回来——顺便解决「乱码」这个祖传问题。


七、动手练习

基础题

  1. 补全 StreamDemo 的 mockOrders:再增加两笔订单,重新计算 GMV 与热销 Top2,手工验算一遍结果是否正确。
  2. 用 groupingBy + counting 统计每个商品被下单的次数,输出格式 商品名 x N 单。

进阶题

  1. 实现方法 static Map<Long, Long> userRanking(List<Order> orders, int topN):返回消费金额前 N 的「用户 id -> 金额」。注意 TreeMap 按 value 排序需要自建比较器或改用 Stream 排序后收集。
  2. 把第 04 章 CollectionsDemo 里「购物车金额计算」的 for 循环版本,重写为一行 Stream;再用 IDEA 的 Stream 调试器(Trace Current Stream Chain)逐步观察中间结果,截图对比两种写法。

思考题

  1. collect(Collectors.toList())(JDK 8~15)与 .toList()(JDK 16+)都会收集为列表,但前者返回的是可变 ArrayList,后者是不可变列表。这一差异会在什么场景下造成运行时异常?团队从 JDK 8 迁移到 21 时,你打算如何在 Code Review 里发现这类隐患?

📄 第 08 章 IO 与文件:字节/字符流、字符编码与 NIO.2 08-io-and-nio2.md

阶段:阶段 1 · 入门基础 | 预计阅读 60 min | 代码落地模块:sample/mini-shop-se(util/OrderSnapshotStore) 前置:第 06 章 异常处理 | 相关章节:第 15 章 网络编程 | 第 16 章 常用类库 实战落点:订单快照落盘 data/orders.json(31 章使用);「显式 UTF-8」写进肌肉记忆

学习目标

读完本章,你应该能够:

  • [ ] 区分字节流与字符流,知道「文本永远走 Reader/Writer + 显式编码」。
  • [ ] 解释乱码的成因(编解码字符集不一致),并给出三条预防措施。
  • [ ] 用 NIO.2 的 Path/Files 完成日常文件操作(读写、拷贝、遍历)。
  • [ ] 处理大文件的正确姿势(逐行读,不整读进内存)。
  • [ ] 实现订单快照的 JSON 落盘与回读,串联 IO + Jackson + try-with-resources。

一、核心概念

1.1 字节流与字符流

InputStream / OutputStream      字节流:8 位原始字节。图片、音视频、任意二进制
Reader / Writer                 字符流:16 位字符。文本专用,负责「字节 ↔ 字符」的编码转换

字符流是字节流的「翻译层」:读文件时把字节按字符集解码成字符,写文件时反向编码。翻译时用的字符集就是乱码的病灶。

1.2 字符编码:乱码问题的根源

  • UTF-8:变长编码(英文 1 字节、中文 3 字节),互联网事实标准,全书默认;
  • GBK:中文 Windows 记事本/控制台的祖传默认,老系统互操作时常见;
  • 乱码 = 用 A 编码写入,用 B 编码读取。字节本身没坏,是解读错了。
// ❌ 灾难写法:不指定编码,用「平台默认值」——Windows 上是 GBK,Linux 上是 UTF-8
new FileReader("orders.txt");          // 同一份代码,换个机器行为就变

// ✅ 唯一正确姿势:三处都显式 UTF-8
new InputStreamReader(in, StandardCharsets.UTF_8);
Files.newBufferedReader(path, StandardCharsets.UTF_8);
new String(bytes, StandardCharsets.UTF_8);

[!WARNING] JDK 18 起 file.encoding 默认已改为 UTF-8,但存量代码与老 JDK 依然存在平台差异。「显式 UTF-8」不依赖任何默认值,一条纪律消灭一类 bug。

1.3 NIO.2:Path 与 Files

java.io.File 设计古老(错误信息贫瘠、方法不抛异常、无符号链接支持)。JDK 7+ 的 NIO.2 全面替代:

Path path = Path.of("data", "orders.json");     // 跨平台路径,自动适配分隔符

Files.createDirectories(path.getParent());      // 目录不存在则创建(含父目录)
Files.exists(path);  Files.size(path);          // 存在性与大小
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
Files.delete(path);
Files.list(dir).forEach(System.out::println);   // 遍历目录(返回 Stream,记得关闭)

// 三个高频读写方法(内部已做好缓冲与关闭)
String json  = Files.readString(path, StandardCharsets.UTF_8);
Files.writeString(path, json, StandardCharsets.UTF_8);                       // 覆盖写
Files.writeString(path, line + System.lineSeparator(), StandardCharsets.UTF_8,
        StandardOpenOption.CREATE, StandardOpenOption.APPEND);               // 追加写

// 逐行读(大文件友好)
try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
    lines.filter(l -> l.contains("ERROR")).forEach(System.out::println);
}

需要真正的流式 IO(边读边处理、网络流)时才用 BufferedReader/BufferedWriter:

try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8);
     BufferedWriter writer = Files.newBufferedWriter(out, StandardCharsets.UTF_8)) {
    String line;
    while ((line = reader.readLine()) != null) {   // readLine 返回 null 即读到末尾
        writer.write(transform(line));
        writer.newLine();
    }
}   // 两个资源按声明逆序自动关闭(第 06 章 try-with-resources)

二、示例代码

mini-shop 阶段一的订单快照:下单后把订单写成 JSON 文件,重启程序可回读。这复刻了「持久化」的最朴素形态,也提前体验 Jackson(第 16 章正式讲解,第 33 章是 Web 层标配)。

// sample/mini-shop-se/src/main/java/com/example/minishop/util/OrderSnapshotStore.java
package com.example.minishop.util;

import com.example.minishop.common.model.OrderStatus;
import com.example.minishop.model.Order;
import com.example.minishop.model.OrderItem;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;

/**
 * 订单快照存取(第 08 章):JSON 落盘 + 启动恢复。
 * 教学要点:① 序列化专用快照 DTO 而非领域对象本身;② 临时文件 + 原子改名防半截文件;③ 单文件损坏不拖垮整体。
 */
public class OrderSnapshotStore {

    /**
     * 快照 DTO:只暴露需要持久化的字段。record 被 Jackson 3 原生支持,无需额外注解。
     * 直接序列化领域对象的问题是:领域类一旦重构(加字段/改结构),历史快照全部读不回来。
     */
    public record Snapshot(Long id, Long userId, String status,
                           long payableInCents, List<OrderItem> items) {
    }

    private final Path dataDir;

    public OrderSnapshotStore(String dir) {
        this.dataDir = Path.of(dir);
    }

    /** 保存单笔订单快照:data/orders/order-1001.json(原子写) */
    public void save(Order order) {
        try {
            Path dir = dataDir.resolve("orders");
            Files.createDirectories(dir);
            Path target = dir.resolve("order-" + order.getId() + ".json");
            Path tmp = dir.resolve("order-" + order.getId() + ".json.tmp");

            Snapshot snapshot = new Snapshot(order.getId(), order.getUserId(),
                    order.getStatus().name(), order.getPayableInCents(), order.getItems());
            Files.writeString(tmp, Json.MAPPER.writeValueAsString(snapshot), StandardCharsets.UTF_8);
            moveAtomically(tmp, target);
        } catch (IOException e) {
            throw new IllegalStateException("订单快照写入失败, orderId=" + order.getId(), e);
        }
    }

    /** 读取目录下全部快照:启动恢复场景。单文件损坏只跳过并告警,不抛出 */
    public List<Order> loadAll() {
        Path dir = dataDir.resolve("orders");
        if (!Files.exists(dir)) {
            return List.of();   // 目录不存在 = 没有历史订单,属正常空态
        }
        try (var files = Files.list(dir)) {
            return files
                    .filter(p -> p.getFileName().toString().endsWith(".json"))
                    .map(this::readOrder)
                    .filter(java.util.Objects::nonNull)
                    .toList();
        } catch (IOException e) {
            throw new IllegalStateException("订单快照目录读取失败: " + dir, e);
        }
    }

    private Order readOrder(Path file) {
        try {
            String json = Files.readString(file, StandardCharsets.UTF_8);
            Snapshot s = Json.MAPPER.readValue(json, Snapshot.class);
            Order order = new Order(s.id(), s.userId(), s.items());
            // 状态与金额都以快照存值为准:重算只作兜底,未来出现折扣时两者才不会分叉
            order.setStatus(OrderStatus.valueOf(s.status()));
            order.setPayableInCents(s.payableInCents());
            return order;
        } catch (IOException | RuntimeException e) {
            System.err.println("跳过损坏的快照文件: " + file + " (" + e.getMessage() + ")");
            return null;
        }
    }

    private static void moveAtomically(Path tmp, Path target) throws IOException {
        try {
            Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE);
        } catch (IOException atomicUnsupported) {
            // 个别文件系统不支持原子移动:退化为普通覆盖移动
            Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
        }
    }
}
// sample/mini-shop-se/src/main/java/com/example/minishop/util/Json.java
package com.example.minishop.util;

import tools.jackson.databind.json.JsonMapper;

/** Jackson 3 单例。⚠️ 包名是 tools.jackson(Boot 4 世代),不是 com.fasterxml.jackson */
public final class Json {
    public static final JsonMapper MAPPER = JsonMapper.builder().build();
    private Json() {}
}
// 使用:下单成功后立即落盘 + 程序重启恢复
store.save(order);
List<Order> restored = store.loadAll();
System.out.println("恢复了 " + restored.size() + " 笔历史订单");

三、常见坑 ⚠️

  • ⚠️ 不指定编码读中文文件:平台默认值在 Windows/Linux 不同,同一份代码两种命运。全文显式 StandardCharsets.UTF_8。
  • ⚠️ 大文件整读进内存:Files.readAllBytes 处理 2GB 日志直接 OOM。日志/大文件用 Files.lines 或 BufferedReader.readLine 逐行处理。
  • ⚠️ 忘记关闭流:自定义流对象务必 try-with-resources。Files.list 返回的 Stream 也要关(上面已示范)。
  • ⚠️ 路径硬编码分隔符:"data\\orders" 在 Linux 上失效。用 Path.of("data", "orders")。
  • ⚠️ Files.writeString 默认覆盖:追加写必须显式 StandardOpenOption.APPEND。
  • ⚠️ 写文件不是原子的:进程写到一半被杀会留下半截文件。关键数据先写临时文件再 Files.move(tmp, target, ATOMIC_MOVE)(第 35 章部署脚本会再遇到「原子性」思想)。

四、调试技巧

  • 乱码排查三步:确认文件真实编码(IDEA 右下角显示,可重新加载)、确认代码读取编码、确认输出终端编码——三处对齐即痊愈。
  • Files.exists 为 false 时打印 path.toAbsolutePath():相对路径的基准目录是进程启动目录,不是源码目录,这是新手最迷惑的点。
  • IDEA 运行配置里可设置 Working Directory,把相对路径固定下来。

五、最佳实践

  • 文本 IO 三件套:Path + Files + 显式 UTF-8。
  • 数据文件写「临时文件 + 原子改名」,读文件对单文件失败做隔离(跳过并告警,不让一颗老鼠药毒死全盘)。
  • JSON 序列化统一走工具类(如 Json.MAPPER),配置收口一处。
  • 敏感信息(密码、密钥)永不落明文文件——落盘前先想「这个文件被拿走会怎样」。

六、本章小结

  • 字节流管二进制,字符流管文本;乱码源于编解码字符集不一致,显式 UTF-8 是唯一纪律。
  • Path/Files 是文件操作现代写法;大文件逐行读,目录遍历的 Stream 记得关。
  • 订单快照示例串起 IO + Jackson + 异常链 + 防御性读取,是 mini-shop 阶段一的持久化雏形——第 33 章它将被 MySQL 取代,但「原子写、坏数据隔离」的思想原样迁移。

下一章反射与注解:理解 Spring、MyBatis 这些框架是怎样「看懂」你的代码的——打通后续所有框架章的地基。


七、动手练习

基础题

  1. 把 OrderSnapshotStore 跑通:下单两笔 → 落盘 → 新起进程 loadAll() 恢复,打印恢复的订单号与状态。
  2. 用 Files.lines 统计任意一个文本文件里包含「ERROR」的行数(自己造个 log.txt),并练习用 Files.writeString 的 APPEND 模式追加一行。

进阶题

  1. 给 save 改造为原子写:先写 order-1001.json.tmp,成功后 Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE);人为在 tmp 写入后抛异常,验证旧快照不被破坏。
  2. 实现简易「日志归档」:遍历 logs/ 目录,把 2026-01 开头的文件移动到 logs/archive/2026-01/(目录不存在则创建)。提示:Files.list + Files.move + 文件名前缀判断。

思考题

  1. 第 33 章会用 MySQL 替换 JSON 落盘。对比两种持久化:并发写入安全、按字段查询、事务回滚、运维备份四个维度,各有什么差距?由此预判「为什么订单系统最终都要数据库」。

📄 第 09 章 反射与注解:框架的地基 09-reflection-and-annotation.md

阶段:阶段 1 · 入门基础 | 预计阅读 65 min | 代码落地模块:sample/mini-shop-se(lab 包) 前置:第 03 章 类设计进阶 | 相关章节:第 18 章 IoC/DI | 第 19 章 AOP 实战落点:理解 @Autowired、@Mapper、@Transactional 从「黑魔法」变成「看得见的机制」;亲手写一个迷你控制反转(Inversion of Control, IoC)原型

学习目标

读完本章,你应该能够:

  • [ ] 用反射在运行期查看类的字段、方法、注解并动态调用。
  • [ ] 自定义注解并写代码解析它,理解「注解 + 反射」如何驱动框架。
  • [ ] 写一个 JDK 动态代理,明白「方法前后插逻辑」的代理模式原理。
  • [ ] 亲手实现 30 行的迷你 IoC 容器原型,破除对 Spring 的神秘感。
  • [ ] 知道反射的性能与封装代价,明白为什么业务代码不该滥用它。

一、核心概念

1.1 反射:运行期把类当数据看

正常代码在编译期决定调用谁;反射(Reflection)让程序在运行期才拿到类的结构并操作它。框架的本质需求正在于此:Spring 拿到的是你随手写的类(编译框架的人根本没见过),它必须运行期才能「发现」并装配它们。

// 三种获取 Class 对象的方式(同一份 Class 对象)
Class<Product> c1 = Product.class;                  // 1. 类字面量:编译期已知
Class<?> c2 = p.getClass();                         // 2. 对象方法
Class<?> c3 = Class.forName("com.example.minishop.model.Product");  // 3. 字符串:运行期才知

// 阅读类结构:类元数据就在指尖
c1.getSimpleName();                    // "Product"
Field[] fields = c1.getDeclaredFields();      // 全部字段(含 private)
Method[] methods = c1.getDeclaredMethods();   // 全部方法

// 动态操作:绕过编译期检查
Field stock = c1.getDeclaredField("stock");
stock.setAccessible(true);                    // 打开 private 的门(setAccessible)
stock.setInt(p, 5);                           // 等价 p.stock = 5,但编译器无从检查

Method m = c1.getMethod("getName");
String name = (String) m.invoke(p);           // 等价 p.getName(),返回值是 Object 需强转

Object newInstance = c1.getConstructor().newInstance();   // 等价 new Product()

1.2 注解:贴在代码上的元数据

注解(Annotation)本身不做任何事——它只是「贴纸」。真正让它生效的是读取贴纸并执行动作的代码(框架),而读取手段就是反射。

// sample/mini-shop-se/src/main/java/com/example/minishop/lab/Retry.java
package com.example.minishop.lab;

import java.lang.annotation.*;

/** 标记「这个方法失败后自动重试」—— 一个迷你框架的入口贴纸 */
@Retention(RetentionPolicy.RUNTIME)     // 运行期可读(反射需要);SOURCE 只留源码、CLASS 只进字节码
@Target(ElementType.METHOD)             // 只能贴在方法上
public @interface Retry {
    int times() default 3;              // 注解属性带默认值,语法像抽象方法
}

三个元注解(注解的注解)必备知识:

元注解 含义
@Retention(RUNTIME) 保留到运行期——反射可读的前提
@Target(METHOD) 允许贴的位置(类/方法/字段/参数…)
@Inherited / @Repeatable 可被子类继承 / 可重复贴(进阶用)

[!TIP] 你已经天天在用注解了:@Override 是 SOURCE 级(仅编译器检查);JUnit 的 @Test、Spring 的 @Service、MyBatis-Plus 的 @TableName 都是 RUNTIME 级——它们的生命周期策略直接对应用途。

1.3 解析注解:让贴纸生效

注解本身不做任何事——让 @Retry 真正生效的是解析器 RetryProcessor(完整实现见「二、示例代码」)。使用方视角:

// 使用方:业务代码只贴注解,零侵入
public class FlakyPayment {
    @Retry(times = 3)
    void callBankApi() {
        if (Math.random() < 0.7) throw new RuntimeException("银行接口超时");
        System.out.println("支付成功!");
    }
}

RetryProcessor.executeWithRetry(new FlakyPayment(), "callBankApi");

看清楚这个闭环:@Retry 提供配置 → RetryProcessor 用反射读取配置 → 反射调用目标方法。Spring 的 @Transactional(方法前后开事务)、@Scheduled(定时触发)、JUnit 的 @Test(发现并执行测试)全部是同一个套路的工程级放大。

1.4 JDK 动态代理:AOP 的引擎

「在方法前后插逻辑」(日志、耗时、事务)——面向切面编程(Aspect-Oriented Programming, AOP)的核心能力——用代理实现。JDK 内置的动态代理在运行期生成接口的实现类:给任意接口实现包一层「耗时日志」的 AopDemo 完整实现见「二、示例代码」。

两个要点:

  1. JDK 动态代理只能代理接口(生成的代理类 extends Proxy implements 你的接口);target 没实现任何接口时,生成的代理无法强转回具体类型。要代理类本身得用第三方字节码库(如 CGLIB,Spring 内置)——知道区别即可,不必深究;
  2. 代理对象与真实对象实现同一接口,调用方无感知——这就是第 19 章「切面」的全部秘密:你调用的其实从来都是代理。

1.5 迷你 IoC(Inversion of Control,控制反转):30 行理解 Spring 的心脏

注册接口 → 实现,按需创建并缓存(单例),递归解析构造器参数完成构造注入——MiniContainer 完整实现见「二、示例代码」。

// 用法:注册契约,容器自动把实现链装配起来
MiniContainer c = new MiniContainer();
c.register(ProductRepository.class, InMemoryProductRepository.class);
c.register(OrderService.class, OrderService.class);       // OrderService 构造器要 ProductRepository

OrderService service = c.get(OrderService.class);         // 容器替你 new 全链

对比第 18 章你会发现:Spring IoC 做的就是这件事的完整版(组件扫描代替手工 register、更完整的生命周期、循环依赖处理、AOP 整合)。先手工理解,再享受框架。


二、示例代码

lab 包的三个迷你框架,对应本章三个核心机制:注解解析、动态代理、IoC 容器。

// sample/mini-shop-se/src/main/java/com/example/minishop/lab/RetryProcessor.java
package com.example.minishop.lab;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

/** 扫描对象方法上的 @Retry,为它提供失败自动重试(第 09 章)—— 注解真正「生效」的地方 */
public class RetryProcessor {

    public static void executeWithRetry(Object target, String methodName, Object... args)
            throws Exception {
        Class<?> clazz = target.getClass();
        for (Method method : clazz.getDeclaredMethods()) {
            // 同名重载:按参数个数匹配(demo 简化,不比较类型)
            if (!method.getName().equals(methodName) || method.getParameterCount() != args.length) {
                continue;
            }
            Retry retry = method.getAnnotation(Retry.class);   // ① 反射读取贴纸
            int maxAttempts = (retry == null) ? 1 : retry.times();

            method.setAccessible(true);                        // 提到循环外:只做一次
            for (int attempt = 1; attempt <= maxAttempts; attempt++) {
                try {
                    method.invoke(target, args);               // ② 反射调用真实方法
                    return;
                } catch (InvocationTargetException e) {
                    Throwable cause = e.getCause();             // 反射包装了真实异常,取 cause
                    System.err.println("第 " + attempt + " 次失败: " + cause.getMessage());
                    if (attempt == maxAttempts) {
                        // Error 无法强转 Exception:原样抛出,不吞类型
                        throw cause instanceof Exception ex ? ex : cause;
                    }
                }
            }
        }
        throw new IllegalArgumentException("方法不存在: " + methodName
                + "(参数个数 " + args.length + ")");
    }
}
// sample/mini-shop-se/src/main/java/com/example/minishop/lab/AopDemo.java
package com.example.minishop.lab;

import com.example.minishop.common.repository.Repository;
import com.example.minishop.model.Product;
import com.example.minishop.repository.InMemoryProductRepository;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

/**
 * JDK 动态代理演示(第 09 章):给任意接口实现包一层「耗时日志」——AOP 的引擎原理。
 * ⚠️ JDK 动态代理只能代理接口:target 没实现任何接口时,生成的代理无法强转回具体类型(CCE)。
 * 要代理类本身得用 CGLIB/ByteBuddy 字节码子类化——Spring 对 @Component 类就是这么做的。
 */
public class AopDemo {

    @SuppressWarnings("unchecked")
    static <T> T withTimingLog(T target) {
        Class<?>[] interfaces = target.getClass().getInterfaces();
        if (interfaces.length == 0) {
            throw new IllegalArgumentException(
                    "JDK 动态代理只能代理接口," + target.getClass().getName() + " 没有实现任何接口");
        }
        InvocationHandler handler = (proxy, method, args) -> {
            long start = System.nanoTime();
            try {
                return method.invoke(target, args);      // 放行到真实对象
            } finally {
                long costMs = (System.nanoTime() - start) / 1_000_000;
                System.out.println("[耗时] " + method.getName() + " " + costMs + "ms");
            }
        };
        return (T) Proxy.newProxyInstance(
                target.getClass().getClassLoader(), interfaces, handler);
    }

    public static void main(String[] args) {
        Repository<Product, Long> raw = new InMemoryProductRepository();
        raw.save(new Product(1L, "键盘", 19900L, 5));

        // 接口不变,行为增强:调用方拿到的其实是代理对象
        Repository<Product, Long> proxied = withTimingLog(raw);
        proxied.findAll();
    }
}
// sample/mini-shop-se/src/main/java/com/example/minishop/lab/MiniContainer.java
package com.example.minishop.lab;

import java.lang.reflect.Constructor;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 迷你 IoC 原型(第 09 章):注册接口 -> 实现,按需创建并缓存(单例),支持构造注入。
 *
 * <p>与 Spring 的差异(教学时要心里有数):
 * <ul>
 *   <li>取第 0 个声明构造器——JDK 不保证 getDeclaredConstructors() 的顺序,
 *       多构造器的类行为不可预测(demo 假定实现类只有一个构造器);</li>
 *   <li>循环依赖会无限递归直到栈溢出(Spring 用「暴露早期引用」解决);</li>
 *   <li>递归发生在 computeIfAbsent 的映射函数内,同一契约自依赖会触发
 *       ConcurrentHashMap 的 IllegalStateException(递归更新检测)。</li>
 * </ul>
 */
public class MiniContainer {

    private final Map<Class<?>, Class<?>> registrations = new ConcurrentHashMap<>();
    private final Map<Class<?>, Object> singletons = new ConcurrentHashMap<>();

    /** 绑定契约与实现:等价于 Spring 的 @Bean / 组件扫描结果 */
    public <T> void register(Class<T> contract, Class<? extends T> impl) {
        registrations.put(contract, impl);
    }

    /** 获取实例:递归解析构造器参数 —— 依赖注入的雏形 */
    @SuppressWarnings("unchecked")
    public <T> T get(Class<T> contract) {
        return (T) singletons.computeIfAbsent(contract, k -> {
            try {
                Class<?> impl = registrations.getOrDefault(k, k);
                Constructor<?> ctor = impl.getDeclaredConstructors()[0];
                Class<?>[] paramTypes = ctor.getParameterTypes();
                Object[] params = new Object[paramTypes.length];
                for (int i = 0; i < paramTypes.length; i++) {
                    params[i] = get(paramTypes[i]);        // ← 递归:参数也是容器管理的 Bean
                }
                return ctor.newInstance(params);
            } catch (ReflectiveOperationException e) {
                throw new IllegalStateException("创建 Bean 失败: " + contract.getName(), e);
            }
        });
    }
}

三、常见坑 ⚠️

  • ⚠️ 反射绕过封装:setAccessible(true) 改 private 字段等于拆掉对象的安全带,仅限框架/测试工具,业务代码禁止。
  • ⚠️ getFields() 与 getDeclaredFields():前者只有 public(含继承),后者是本类全部(不含继承)。用错就「明明有字段却找不到」。
  • ⚠️ InvocationTargetException 不是真异常:反射调用抛出的异常被它包了一层,必须取 getCause() 才是真凶。
  • ⚠️ 注解忘了 RUNTIME:默认 RetentionPolicy.CLASS,运行期 getAnnotation 返回 null,还毫无报错。自定义注解三件套:@Retention(RUNTIME) + @Target + 属性默认值。
  • ⚠️ 反射性能:比直接调用慢一个数量级(JIT 优化后差距缩小但仍存在)。框架内部可接受,业务热路径手写反射要先测。
  • ⚠️ 注解继承的错觉:方法上的注解不会被子类重写方法继承(@Inherited 只对类上的注解生效)——Spring 事务方法不生效的经典坑源之一(第 22 章回收)。

四、调试技巧

  • 断点打在 getAnnotation(...) 返回处,看注解实例的属性值——验证「贴纸是否被读到」。
  • Class.getDeclaredMethods() 打印全部方法名,排查「为什么我的方法没被框架扫描到」(命名/可见性/参数不符)。
  • IDEA 中 Ctrl + Alt + B 从接口跳实现、调试面板看对象的实际类型(显示 $Proxy12 即代理类)——看到代理你就懂了 AOP。

五、最佳实践

  • 业务代码用注解表达配置,不手写反射;反射留给框架与基础设施层。
  • 反射代码集中在统一的工具类并封装异常(带 cause),调用方感知不到 ReflectiveOperationException。
  • 自定义注解命名成「动作或契约」:@Retry、@SeckillSafe,而不是 @MyAnnotation。
  • 理解但不要重复造轮子:本节的 MiniContainer 用于理解,生产一律用 Spring。

六、本章小结

  • 反射 = 运行期读写类结构;setAccessible 能开门但要守规矩;InvocationTargetException 记得拆包。
  • 注解只是元数据,RUNTIME 保留 + 反射解析才生效——这是所有框架注解的统一原理。
  • JDK 动态代理在运行期生成接口实现,是「方法前后插逻辑」的引擎;Spring AOP 与事务都建立在它之上。
  • 迷你 IoC 展示了「注册契约 → 递归构造注入 → 缓存单例」三步心脏手术;下一阶段把这台心脏换成 Spring 的。

进入阶段二之前最后一站:JUnit 5——从现在起,你写的每个类都值得配一个测试。


七、动手练习

基础题

  1. 用反射打印 Order 类的全部字段与方法签名(含注解信息);再用 getDeclaredField("status") + setAccessible 把一笔订单状态强行改成 COMPLETED,观察并思考这个能力为什么危险。
  2. 实现 @Audit(属性 value 默认 "default")注解 + 处理器:被标注的方法调用前打印 [审计] 方法名(value)。模仿 RetryProcessor 的套路。

进阶题

  1. 扩展 MiniContainer:支持构造器上标注 @Inject 选择注入哪个构造器(默认取第一个);故意制造循环依赖(A 要 B、B 要 A),观察 StackOverflowError,想想 Spring 是怎么用「三级缓存 + 提前暴露引用」解决的(提示:先造半成品)。
  2. 给 AopDemo 的 withTimingLog 增加「超过 100ms 打印 WARN」逻辑,再包装 OrderService.createOrder 跑一次。

思考题

  1. @Transactional 加在同类内部调用链的内层方法上(this.innerMethod())不会生效。结合本章动态代理原理解释:调用方拿到的是代理对象,而 this 指向的是谁?这个解释你在第 22 章会再次用到——现在先写下你的推理。

📄 第 10 章 单元测试入门:JUnit 5、断言与测试习惯 10-unit-test-basics.md

阶段:阶段 1 · 入门基础 | 预计阅读 50 min | 代码落地模块:sample/mini-shop-se(src/test) 前置:第 06 章 异常处理 | 相关章节:第 28 章 测试策略 | 第 32 章 并发压测 实战落点:InventoryServiceTest / OrderServiceTest——之后每章的示例都会带测试,32 章的压测也是在测试基建上长出来的

学习目标

读完本章,你应该能够:

  • [ ] 写出规范的 JUnit 5 测试类:@Test、@BeforeEach、断言、生命周期。
  • [ ] 用「Given-When-Then」结构组织测试,命名能自述失败原因。
  • [ ] 测试异常路径:断言「该抛的异常确实抛了」。
  • [ ] 使用参数化测试消除复制粘贴。
  • [ ] 理解为什么后端工程师必须会写测试(不是 QA 的事)。

一、核心概念

1.1 为什么单元测试是工程师的分水岭

  • 重构的底气:没有测试的重构是闭眼开车。本书 31→33 章两代重构,靠的就是先有测试兜底。
  • 调试提速一半:mvn test 秒级复现问题,不用启动整个应用点页面。
  • 活文档:测试用例就是「这个方法在什么输入下应该有什么行为」的 executable spec。

[!TIP] 单元测试(Unit Test)测的是一个类/一个方法,不依赖数据库、网络、Spring 容器——那些是集成测试(第 28 章)。判定标准:断网断库也能跑,毫秒级完成。

1.2 JUnit 5 最小知识集

测试代码放在 src/test/java(Maven 约定),依赖坐标 org.junit.jupiter:junit-jupiter(Boot 工程随 BOM 自带;sample/mini-shop-se 手工引入)。

[!TIP] 版本说明:本书写作时 JUnit 已发布 6.x(工程实际使用 6.0.1,见附录 90),但编程模型仍是 Jupiter——@Test、@BeforeEach、断言这些「JUnit 5 时代」的写法在 6.x 原样可用。正文统一用「JUnit 5」指代这套写法,版本号以附录 90 为准。

// sample/mini-shop-se/src/test/java/com/example/minishop/service/OrderServiceTest.java(节选)
package com.example.minishop.service;

import com.example.minishop.common.exception.StockNotEnoughException;
import com.example.minishop.common.model.OrderStatus;
import com.example.minishop.model.Order;
import com.example.minishop.model.OrderItem;
import com.example.minishop.model.Product;
import com.example.minishop.repository.InMemoryOrderRepository;
import com.example.minishop.repository.InMemoryProductRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

/** Given-When-Then 三段式(第 10 章):准备 → 动作 → 断言 */
class OrderServiceTest {

    private InMemoryProductRepository repo;
    private InMemoryOrderRepository orderRepo;
    private OrderService orderService;

    @BeforeEach                      // 每个测试方法执行前都跑一遍:测试间互不污染
    void setUp() {
        repo = new InMemoryProductRepository();
        repo.save(new Product(1L, "机械键盘", 19900L, 2));
        repo.save(new Product(2L, "显示器", 459900L, 5));
        orderRepo = new InMemoryOrderRepository();
        orderService = new OrderService(repo, orderRepo);   // 双参构造器:仓储依赖一次注入
    }

    @Test
    @DisplayName("库存充足时下单成功并扣减库存")   // 测试名说人话,失败时一眼看懂哪个业务坏了
    void shouldCreateOrderAndDeductStock_whenStockEnough() {
        // Given:准备数据(setUp 已就绪)
        List<OrderItem> items = List.of(new OrderItem(1L, "机械键盘", 2, 19900L));

        // When:执行被测动作
        Order order = orderService.createOrder(100L, items);

        // Then:断言结果。第一个参数是「失败时显示的消息」,帮你定位
        assertEquals(0, repo.findById(1L).orElseThrow().getStock(), "库存应被扣减到 0");
        assertEquals(OrderStatus.CREATED, order.getStatus());
        assertEquals(39800L, order.getPayableInCents());
        assertEquals(1, orderRepo.count());
    }

    @Test
    @DisplayName("库存不足时抛出 StockNotEnoughException 且不落订单")
    void shouldThrow_whenStockNotEnough() {
        List<OrderItem> items = List.of(new OrderItem(1L, "机械键盘", 5, 19900L));

        // assertThrows:断言异常类型 + 拿到异常对象做进一步断言
        StockNotEnoughException e = assertThrows(StockNotEnoughException.class,
                () -> orderService.createOrder(100L, items));
        assertEquals(2, e.getAvailable(), "异常应携带实际可用库存");
        assertEquals(0, orderRepo.count(), "失败订单不应保存");
    }

    // sample 完整版还包含:多商品部分失败时已扣库存必须回补、商品不存在抛
    // BizException(PARAM_INVALID)、重复支付被状态机拒绝、关单后库存回补与 CLOSED 断言
}

运行方式任选:IDEA 类旁绿色箭头、或命令行 mvn test。绿灯通过、红灯失败并展示每个断言的差异值。

1.3 常用断言速查

断言 用途
assertEquals(expected, actual) 相等(对象走 equals)
assertNotEquals / assertSame 不等 / 同一引用
assertTrue(cond, msg) / assertFalse 布尔条件
assertNull / assertNotNull 空判断
assertThrows(Class, exec) 断言抛异常,返回异常对象可续断言
assertAll("组名", () -> ..., () -> ...) 一组断言全部执行(不像默认那样遇错即停,便于一次看全)
assertTimeout(Duration, exec) 超时即失败

1.4 参数化测试:一份逻辑吃多种输入

@ParameterizedTest(name = "价格 {0} 分 数量 {1} -> 小计 {2} 分")
@CsvSource({
        "19900, 1, 19900",
        "19900, 3, 59700",
        "0,     5, 0"          // 边界:0 元商品
})
void shouldComputeSubtotal(long price, int qty, long expected) {
    assertEquals(expected, new OrderItem(1L, "x", qty, price).subtotalInCents());
}

边界值(0、负数、最大值)是 bug 高发区,参数化让「补一个用例」只需加一行 CSV。

1.5 生命周期与 F.I.R.S.T 原则

@BeforeAll   static void initAll() {}   // 整个类一次(必须 static)
@BeforeEach  void setUp() {}             // 每个测试前
@AfterEach   void tearDown() {}          // 每个测试后(清理临时文件等)
@AfterAll    static void done() {}       // 整个类一次

好测试的五条 F.I.R.S.T:Fast(毫秒级)、Independent(互不依赖、可乱序)、Repeatable(任何环境结果一致)、Self-validating(自动判对错,不靠眼看)、Timely(随功能一起写,不是事后补)。


二、示例代码

给 InventoryService(第 31 章正式实现的库存服务雏形)配一套完整测试,展示「测试驱动发现设计问题」:

// sample/mini-shop-se/src/test/java/com/example/minishop/service/InventoryServiceTest.java
package com.example.minishop.service;

import com.example.minishop.model.Product;
import com.example.minishop.repository.InMemoryProductRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

/**
 * InventoryService 的正常/异常路径 + Product 扣减边界(第 10 章:测试先覆盖自己的主题,
 * 顺带守住模型层的经典边界)。
 */
class InventoryServiceTest {

    private InMemoryProductRepository repo;
    private InventoryService inventoryService;

    @BeforeEach
    void setUp() {
        repo = new InMemoryProductRepository();
        repo.save(new Product(1L, "机械键盘", 19900L, 10));
        inventoryService = new InventoryService(repo);
    }

    @Test
    @DisplayName("mustFind:存在的商品原样返回")
    void shouldReturnProduct_whenExists() {
        Product p = inventoryService.mustFind(1L);
        assertEquals("机械键盘", p.getName());
        assertEquals(19900L, p.getPriceInCents());
    }

    @Test
    @DisplayName("mustFind:不存在的商品抛 IllegalArgumentException")
    void shouldThrow_whenNotFound() {
        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
                () -> inventoryService.mustFind(99L));
        assertTrue(e.getMessage().contains("99"), "异常信息应包含商品 id");
    }

    @Test
    @DisplayName("正常扣减:10 - 3 = 7")
    void shouldDeduct() {
        repo.findById(1L).orElseThrow().decreaseStock(3);
        assertEquals(7, repo.findById(1L).orElseThrow().getStock());
    }

    @Test
    @DisplayName("扣到刚好为 0 是合法边界")
    void shouldAllowExactDeduction() {
        assertDoesNotThrow(() -> repo.findById(1L).orElseThrow().decreaseStock(10));
        assertEquals(0, repo.findById(1L).orElseThrow().getStock());
    }

    @Test
    @DisplayName("扣 0 件与负数应被拒绝")
    void shouldRejectNonPositiveQuantity() {
        Product p = repo.findById(1L).orElseThrow();
        assertThrows(IllegalArgumentException.class, () -> p.decreaseStock(0));
        assertThrows(IllegalArgumentException.class, () -> p.decreaseStock(-1));
        assertThrows(IllegalArgumentException.class, () -> p.increaseStock(0));
    }
}

注意「扣 0 件」这条边界用例:0 恰好是最容易漏的临界值——第 02 章的 decreaseStock 用 quantity <= 0 一次挡住「0 件」与「负数」两类非法输入,这里就是回归验证。边界值测试的价值正在于此:它逼你在写用例时逐个确认自己想到了每个分支。


三、常见坑 ⚠️

  • ⚠️ 测试之间共享状态:static 字段缓存、单例残留,导致「单独跑绿、一起跑红」。@BeforeEach 重建全部夹具,绝不共享。
  • ⚠️ 测试依赖外部资源:连数据库、读网络。单元测试被拖慢且脆弱,外部依赖留给第 28 章的 Testcontainers。
  • ⚠️ 只测正常路径:异常、边界(0、负数、空集合、null)才是 bug 主产区。每个方法至少「一正一反一边界」。
  • ⚠️ 断言写错方向:assertEquals(expected, actual) 参数顺序反了,失败消息会误导排查方向。
  • ⚠️ 为覆盖率而测试:追 100% 覆盖率催生一堆 assertTrue(true)。覆盖率是参考,断言质量才是灵魂——没有断言的测试等于没有测试。
  • ⚠️ 测试里写业务逻辑:测试代码出现 for/if 组装复杂数据时,说明夹具该抽方法或该用参数化了。

四、调试技巧

  • 测试失败时优先看 IDEA 断言对比窗(左 expected / 右 actual 的 diff 视图)。
  • 想单独重跑某个用例:点方法旁的绿色箭头,别每次跑全类。
  • 「在我机器上是绿的」:先确认没改代码没提交;再怀疑静态状态与随机顺序,JUnit 5 可用 @TestMethodOrder 强制排序定位(但生产测试不应依赖顺序)。

五、最佳实践

  • 命名模板 shouldXxx_whenYyy:测试名即需求文档。
  • 一个测试方法只验证一个行为;断言可以多个但主题一致。
  • 先写测试再写实现(哪怕只提前五分钟),接口设计会被测试推得更合理。
  • 提交前 mvn test 全绿是纪律;CI 里测试不过禁止合入(第 29 章落成流水线门禁)。
  • 测试代码与业务代码同等质量对待:也要重构、也进 Code Review。

六、本章小结

  • JUnit 5 核心就四样:@Test、@BeforeEach、断言、assertThrows。
  • Given-When-Then + shouldXxx_whenYyy 命名,让测试自己会说话。
  • 单元测试不碰外部资源;边界与异常路径优先覆盖;断言质量 > 覆盖率数字。
  • 从本章起养成习惯:每个新类,先想「它的测试怎么写」。

阶段一到此收官。下一站实战章(第 31 章)会把 01–10 全部知识组装成 mini-shop 的 SE 版本——建议先跳读第 30 章的项目总览,也可以先继续阶段二、回头再做实战。


七、动手练习

基础题

  1. 给第 03 章 Money 写测试类:加法、乘法、ofYuan("0.1") 的精确性、非法输入(负数构造若被允许,想清楚为什么)。
  2. 给 OrderStatus 写状态机测试:用 @ParameterizedTest + @CsvSource 验证每个状态的 canPay/canShip/canClose 真值表。

进阶题

  1. 给第 08 章 OrderSnapshotStore 写测试:保存后 loadAll 能读回;故意在目录里塞一个损坏的 json 文件,断言 loadAll 跳过它而不抛异常(@TempDir 提供 JUnit 自管临时目录,测试结束自动清理)。
  2. 为 MiniContainer(第 09 章)写测试:单例性(两次 get 同一实例)、递归注入(OrderService 拿到 ProductRepository)、未注册且无默认构造时的异常。

思考题

  1. 「先写测试再写实现」与「先实现再补测试」产出的测试,风格差异在哪里?结合 F.I.R.S.T 的 Timely 一条,谈谈你打算在今后的项目中怎么安排测试时机。

📄 第 11 章 并发基础:线程、JMM 与 volatile/synchronized 11-concurrency-basics.md

阶段:阶段 2 · 进阶提升 | 预计阅读 75 min | 代码落地模块:sample/mini-shop-se/concurrent 前置:第 01 章 基础语法、第 03 章 类设计进阶 | 相关章节:第 12 章 锁与原子类 | 第 13 章 线程池 实战落点:多线程复现超卖——第 32 章压测的教学锚点;理解 ConcurrentHashMap(04 章)为何必要

学习目标

读完本章,你应该能够:

  • [ ] 创建并启动线程,理解线程与进程、并发与并行的区别。
  • [ ] 用可见性、原子性、有序性三要素解释并发 bug 的成因。
  • [ ] 解释为什么 i++ 在多线程下会丢更新,并用 synchronized 修复。
  • [ ] 正确使用 volatile(并知道它管不了什么)。
  • [ ] 亲手跑出「200 线程抢 100 库存卖出 137 件」的超卖现场。

一、核心概念

1.1 线程:最轻量的执行单元

一个进程(运行中的 JVM)至少有一条主线程;main 方法就跑在主线程上。再起线程就是多线程:

// 方式一:Runnable + Lambda(推荐:任务与线程解耦)
Thread t = new Thread(() -> System.out.println("hello from " + Thread.currentThread().getName()));
t.start();          // start() 才是新起线程;直接调 run() 只是普通方法调用!

// 方式二:线程池统一管理(生产标准姿势,第 13 章展开)
// ❌ 方式三:继承 Thread 并重写 run —— 占掉了唯一的继承名额, enterprise 代码禁用
概念 一句话
并发(Concurrency) 同一时间段交替执行(单核也能并发,本质是快速切换)
并行(Parallelism) 同一时刻同时执行(多核真并行)
上下文切换 CPU 从一条线程换到另一条,有微秒级开销——线程不是越多越快

后端为什么绕不开并发:Web 容器(Tomcat)就是「一个请求一条线程」的模型。你的 OrderService 从部署第一秒起就被并发调用,不写并发代码 ≠ 代码不在并发环境里跑。

1.2 JMM 三要素:并发 bug 的完整解释框架

Java 内存模型(Java Memory Model, JMM)定义了多线程读写共享变量的规则。所有并发问题都能落到三要素:

flowchart LR
    A["可见性<br/>Visibility"] --- B["原子性<br/>Atomicity"] --- C["有序性<br/>Ordering"]
    A1["改了,别的线程看不见"] --> A
    B1["一步操作被拆成多步,中间被打断"] --> B
    C1["编译器/CPU 重排指令,顺序与代码不符"] --> C
  • 可见性:每条线程有自己的工作内存(CPU 缓存的抽象),共享变量修改后未必立刻刷回主存。经典现象:循环 while (!flag) 永远退不出去。
  • 原子性:i++ 实际是「读 → 加 1 → 写回」三步,两条线程交叠执行就会丢更新(读到同一个旧值)。
  • 有序性:为优化性能,编译器与 CPU 可能重排指令;double-checked locking 单例不加 volatile 出半成品对象就是这个原因(进阶了解)。

1.3 synchronized:三合一的保底方案

synchronized 同时解决三要素:互斥(原子性)+ 内存屏障(可见性 + 有序性)。用法两种:

public class Counter {
    private int count;

    /** 修饰实例方法:锁的是 this —— 同一对象的所有 synchronized 方法互斥 */
    public synchronized void increment() { count++; }

    /** 同步块:锁粒度更细,锁对象自选(推荐,能显著缩小临界区) */
    public void increment2() {
        synchronized (this) {        // 也可以锁专门的锁对象 private final Object lock = new Object();
            count++;
        }
    }
}

public static synchronized void staticInc() { ... }   // 锁的是 Class 对象,全局一把

三个必知规则:

  1. 锁的是对象,不是代码。两个线程锁不同对象,互不阻塞;
  2. 可重入:同一线程可以重复拿同一把锁(方法间嵌套调用不死锁);
  3. 只保护「进入同一把锁」的线程。忘记加锁的路径照样绕过——所以校验+修改必须整体在临界区内。

1.4 volatile:只管可见性的轻量开关

private volatile boolean running = true;   // 状态标志位的标准写法

// 线程 A:running = false;
// 线程 B:while (running) { ... }   ← 没有 volatile 时,B 可能永远读不到 false(优化进缓存了)

volatile 保证:写立即对所有线程可见 + 禁止相关指令重排。但它不保证原子性——volatile int i; i++ 照样丢更新。经验法则:一写多读的开关用 volatile,多写计数用原子类(第 12 章),复杂状态直接 synchronized。

1.5 超卖现场:mini-shop 的并发病历

第 31 章的扣库存代码:

if (product.getStock() < item.quantity()) {   // ① 读
    throw new StockNotEnoughException(...);
}
product.decreaseStock(item.quantity());       // ② 改:stock -= qty(读→减→写)

两个线程同时跑到 ①:库存剩 1,两边的 stock < 1 都不成立(都读到旧值 1)→ 都通过校验 → 都执行 ② → 卖出 2 件,库存变成 -1。这就是超卖,而它不需要任何「玄学」,只是原子性被破坏。


二、示例代码

复现超卖 + 用 synchronized 修复,一组对照实验(第 32 章压测的雏形):

// sample/mini-shop-se/src/main/java/com/example/minishop/concurrent/OversellDemo.java
package com.example.minishop.concurrent;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

/** 复现超卖(第 11 章):200 线程抢 100 库存,无锁版会卖出 >100 件(线程数必须大于库存) */
public class OversellDemo {

    static class Stock {                      // 模拟「数据库里的库存行」
        private int remaining = 100;

        public boolean tryDeductUnsafe() {    // ① 读 ② 比较 ③ 写 —— 三步没有任何原子性保障
            if (remaining <= 0) return false; // 检查
            remaining--;                      // 扣减
            return true;
        }

        public synchronized boolean tryDeductSafe() {   // synchronized 把三步变成原子区
            if (remaining <= 0) return false;
            remaining--;
            return true;
        }

        public int remaining() { return remaining; }
    }

    public static void main(String[] args) throws InterruptedException {
        for (String mode : new String[]{"unsafe", "safe"}) {
            Stock stock = new Stock();
            AtomicInteger sold = new AtomicInteger();      // 成功计数必须用原子类!
            int threads = 200;                             // ⚠️ 线程数 > 库存数,抢购才有竞争
            var ready = new CountDownLatch(threads);      // 发令枪:全员就位后同时起跑
            var done = new CountDownLatch(threads);        // 收工枪:主线程等全部跑完再统计

            for (int i = 0; i < threads; i++) {
                new Thread(() -> {
                    try {
                        ready.countDown();                 // 先报到
                        ready.await();                     // 后等待——顺序不能反,反了就是死锁
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt(); // 恢复中断位,让上层能感知
                        return;
                    }
                    try {
                        boolean ok = mode.equals("safe")
                                ? stock.tryDeductSafe()
                                : stock.tryDeductUnsafe();
                        if (ok) sold.incrementAndGet();
                    } finally {
                        done.countDown();
                    }
                }).start();
            }
            done.await();                                  // 等全部线程收工,统计口径才准

            System.out.printf("[%s] 卖出 %d 件,剩余 %d(库存 100)%s%n",
                    mode, sold.get(), stock.remaining(),
                    sold.get() > 100 ? "  ← 超卖!" : "");
        }
    }
}

典型输出(unsafe 每次数字略有差异,但几乎必超卖):

[unsafe] 卖出 137 件,剩余 -37(库存 100)  ← 超卖!
[safe]   卖出 100 件,剩余 0(库存 100)

逐行解释超卖成因:remaining-- 在字节码层是「读字段 → 栈上减 1 → 写回字段」。200 个线程抢 100 件,竞态窗口内「两个线程读到同一个值,各自减 1,写回同一结果」——两次扣减只生效一次,于是 100+ 次「成功」把剩余值一路砸成负数。修复只需把「检查 + 扣减」圈进同一把锁。


三、常见坑 ⚠️

  • ⚠️ 调 run() 不调 start():run() 是普通方法调用,永远单线程。
  • ⚠️ 用 i++ 做并发计数:用 AtomicInteger / LongAdder(第 12 章)。
  • ⚠️ 以为 volatile 能保原子:一写多读专用;volatile int 配 ++ 是伪修复。
  • ⚠️ 锁对象被换:synchronized (lock) 里 lock = new Object(),持锁者与后来者锁的不是同一个对象,形同虚设。锁对象必须 final。
  • ⚠️ String/Integer 做锁对象:字符串常量池与包装类缓存会让毫不相干的代码共享同一把锁,出现「莫名其妙的互相阻塞」。锁私有 final Object。
  • ⚠️ 临界区里做慢 IO:锁内调数据库/HTTP,吞吐雪崩。锁内只做内存操作,IO 放外面。

四、调试技巧

  • 可见性问题难以稳定复现(加一行 println 可能就「修好」了——println 自带同步)。怀疑可见性时直接按规范加 volatile/锁,别赌。
  • jstack <pid>(第 14 章)看线程状态:BLOCKED (on object monitor) 说明在等 synchronized 锁。
  • IDEA 调试并发代码意义有限(断点本身改变时序),优先写可重复的压测程序(本章 OversellDemo 的模式)。

五、最佳实践

  • 共享可变状态是万恶之源。优先级:能不共享就不共享(无状态/不可变/ThreadLocal)→ 必须共享用并发容器/原子类 → 最后才是锁。
  • 锁粒度最小化、临界区最短化;一把锁管一个资源。
  • 写并发代码先写压测复现 bug,修完压测验证——「我觉得没问题」在并发世界毫无意义。
  • Web 后端的真实并发在「多个请求线程 + 数据库行」之间,内存锁只能保护单实例——分布式锁是第 24 章的事。

六、本章小结

  • Web 容器天生并发;并发 bug 都可归因到可见性/原子性/有序性三要素。
  • synchronized 三合一但粗粒度;volatile 只管可见性;计数用原子类。
  • 超卖 = 「检查 + 扣减」竞态,锁住整个临界区即可修复,代价是吞吐下降(32 章量化)。

下一章把锁玩明白:ReentrantLock 的超时与公平、CAS 与原子类、死锁的定位与预防。


七、动手练习

基础题

  1. 跑通 OversellDemo 五次,记录每次 unsafe 的卖出数;把线程数改成 10 再跑,观察超卖是否还稳定出现,思考原因(竞态窗口被踩中的概率)。
  2. 写一个可见性实验:子线程 while (!stop) 自旋,主线程 1 秒后 stop = true。分别用普通 boolean 与 volatile boolean 跑,观察差异。

进阶题

  1. 给 OversellDemo 增加第三种模式 lock-segment:把 100 库存拆成 10 个桶各 10 个,synchronized 锁单个桶,抢完自己桶再去共享池借。对比 safe 模式的耗时——这是第 24 章 Redis 分片库存的思想原型。
  2. 用两个线程演示死锁:线程 1 先锁 a 再锁 b,线程 2 先锁 b 再锁 a,各 sleep 100ms 制造交叠;用 jstack 找到 Found one Java-level deadlock 字样并读懂线程栈。

思考题

  1. synchronized 修复了正确性,但 100 线程排队扣库存,吞吐上限就是「串行」。结合第 30 章验收标准思考:电商大促场景(10 万 QPS 抢购)为什么内存锁完全不够用?从「单机内存 vs 共享存储」「实例多副本」两个角度预答,第 24 章见分晓。

📄 第 12 章 锁与原子类:ReentrantLock、CAS 与死锁定位 12-locks-and-atomic.md

阶段:阶段 2 · 进阶提升 | 预计阅读 70 min | 代码落地模块:sample/mini-shop-se/concurrent 前置:第 11 章 并发基础 | 相关章节:第 13 章 线程池 | 第 24 章 分布式锁 实战落点:库存扣减的五种方案之二三(ReentrantLock / 原子类),32 章压测的主角;分布式锁的前置知识

学习目标

读完本章,你应该能够:

  • [ ] 使用 ReentrantLock 并说清它相对 synchronized 的三个增强(超时/可中断/公平)。
  • [ ] 理解 CAS 原理与 ABA 问题,正确使用 AtomicInteger / AtomicReference。
  • [ ] 讲出 LongAdder 为什么在高并发计数上更快。
  • [ ] 识别死锁四条件,用 jstack 定位死锁,写出不会死锁的加锁顺序。

一、核心概念

1.1 ReentrantLock:synchronized 的增强版

private final ReentrantLock lock = new ReentrantLock();   // final:锁对象永不更换

public boolean tryDeduct() {
    lock.lock();                       // 加锁必须紧跟 try,unlock 放 finally
    try {
        // 临界区
        return doDeduct();
    } finally {
        lock.unlock();                 // 无论成败必解锁,否则永久死锁
    }
}
能力 synchronized ReentrantLock
基本互斥/可重入 ✅ ✅
尝试获取(不阻塞) ❌ tryLock() 立即返回成败
限时等待 ❌ tryLock(3, TimeUnit.SECONDS)
可中断等待 ❌(等锁时不响应中断) lockInterruptibly()
公平锁(先来先得) ❌ new ReentrantLock(true)
Condition 条件队列 单一 wait/notify 多个 Condition 精准唤醒

[!TIP] 选型默认 synchronized(JDK 6 后优化已足够好、语法简洁不怕忘解锁);只有需要表中后四行能力时才升级 ReentrantLock。「tryLock 带超时」是生产中最常用的增强——永远不要让线程无限期等锁。

1.2 CAS:无锁的正确性

比较并交换(Compare-And-Swap, CAS):硬件级原子指令——「当内存值等于期望值时,才把它改成新值,否则失败」。失败者自旋重试(重新读、重新算、重新比),全程不加锁。

AtomicInteger stock = new AtomicInteger(100);

// decrementAndGet 内部:自旋 CAS(old, old-1),成功返回新值
// 关键技巧:用「返回值判断」替代「先检查后修改」,三步变一步
public boolean tryDeduct() {
    while (true) {
        int current = stock.get();
        if (current <= 0) return false;             // 检查与修改合一,竞态窗口消失
        if (stock.compareAndSet(current, current - 1)) return true;
        // CAS 失败 = 别人先改了:循环重读,再战
    }
}

对比第 11 章:synchronized 让 99 个线程排队等 1 个;CAS 让 100 个线程同时冲、失败的下次再来。低冲突时 CAS 吞吐显著更高;冲突激烈时自旋空转烧 CPU,此时分层/分桶减少竞争面(32 章的分段方案)才是正解。

CAS 的三个注意事项:

  1. ABA 问题:值从 A→B→A,CAS 察觉不出中间变化。计数类场景无碍;链表类结构需 AtomicStampedReference(版本号化),了解即可;
  2. 自旋重试的循环体必须短且无副作用,否则退化为烧 CPU;
  3. AtomicReference<V> 可以对任意对象引用做 CAS,是实现无锁数据结构的砖块。

1.3 LongAdder:高并发计数的正解

AtomicInteger.incrementAndGet() 在极端竞争下所有线程反复 CAS 同一个值(高争用)。LongAdder 把一个数拆成 Cell 数组分散热点,sum() 时汇总——写性能高一个量级,代价是读到的是近似瞬时值。

private final LongAdder successCount = new LongAdder();
successCount.increment();          // 各线程写各自的 Cell
long total = successCount.sum();   // 统计时汇总(32 章压测脚手架用它统计成功/失败数)

经验:统计计数用 LongAdder,需要「读改写后再判断」的数值(如库存)用 AtomicInteger——库存的语义是「扣减必须成功或拒绝」,不能容忍近似值。

1.4 死锁:四条件与定位法

死锁发生的四个必要条件(破坏任意一个即可预防):

  1. 互斥:资源同一时刻只能被一个线程持有;
  2. 持有并等待:拿着 A 等 B;
  3. 不可剥夺:不能强行抢走;
  4. 循环等待:T1 等 T2 的,T2 等 T1 的。
// 经典死锁:加锁顺序不一致
// T1: synchronized(a) { synchronized(b) {...} }
// T2: synchronized(b) { synchronized(a) {...} }   ← 互相等到天荒地老

预防三板斧:全局统一加锁顺序(按 id 升序拿锁)、缩小锁范围(不嵌套两把锁)、tryLock 超时放弃(拿不到就退回重来)。

jstack <pid> | grep -A 20 "Found one Java-level deadlock"   # JVM 自动检测死锁并给出双方持有/等待的锁

二、示例代码

库存扣减方案 2/3:ReentrantLock 版与原子类版。sample 现版把五种方案统一到 StockStrategy 接口(tryDeduct / remaining / name)下,供 32 章压测逐一体测:

// sample/mini-shop-se/src/main/java/com/example/minishop/concurrent/StockStrategies.java(节选)
package com.example.minishop.concurrent;

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 库存扣减的五种并发方案(第 11/12/32 章)。
 * 压测入口:SeckillBench 对五种方案逐一测正确性与吞吐。
 */
public final class StockStrategies {

    /** 统一契约:尝试扣一件,成功返回 true */
    public interface StockStrategy {
        boolean tryDeduct();

        int remaining();

        String name();
    }

    /** 方案 3:ReentrantLock(正确;可加超时/可中断,吞吐与 synchronized 相当) */
    public static class LockStock implements StockStrategy {
        private final ReentrantLock lock = new ReentrantLock();
        private int remaining;

        public LockStock(int initial) { this.remaining = initial; }

        @Override
        public boolean tryDeduct() {
            lock.lock();
            try {
                if (remaining <= 0) return false;
                remaining--;
                return true;
            } finally {
                lock.unlock();
            }
        }

        @Override
        public int remaining() { return remaining; }

        @Override
        public String name() { return "3.reentrant-lock"; }
    }

    /** 方案 4:CAS 原子扣减(正确;检查+修改合一;高冲突下自旋空转烧 CPU) */
    public static class CasStock implements StockStrategy {
        private final AtomicInteger remaining;

        public CasStock(int initial) { this.remaining = new AtomicInteger(initial); }

        @Override
        public boolean tryDeduct() {
            while (true) {
                int current = remaining.get();
                if (current <= 0) return false;
                if (remaining.compareAndSet(current, current - 1)) return true;
            }
        }

        @Override
        public int remaining() { return remaining.get(); }

        @Override
        public String name() { return "4.cas-atomic"; }
    }

    // 另有 UnsafeStock / SyncStock / SegmentedStock 三种方案,完整版见 sample
    private StockStrategies() { }
}

工程里最常见的「限时拿锁」骨架见 TryLockDemo:

// sample/mini-shop-se/src/main/java/com/example/minishop/concurrent/TryLockDemo.java
package com.example.minishop.concurrent;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

/** tryLock 超时骨架(第 12 章):拿不到锁快速失败,保护线程资源 */
public class TryLockDemo {

    private final ReentrantLock lock = new ReentrantLock();

    public boolean doCriticalWork() throws InterruptedException {
        if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) {
            return false;   // 500ms 拿不到:让上层走「系统繁忙」降级,而不是无限排队
        }
        try {
            work();         // 临界区:只放内存操作
            return true;
        } finally {
            lock.unlock();
        }
    }

    private void work() { /* 临界区业务 */ }
}

三、常见坑 ⚠️

  • ⚠️ lock() 与 try 之间抛异常:lock.lock(); try {...} 顺序必须严格——lock 放 try 外、unlock 放 finally,否则锁泄漏。
  • ⚠️ 公平锁当默认:公平锁吞吐明显低于非公平。只在「排队公平性是业务需求」时才开(如抢购防饿死要另设计,不是靠公平锁)。
  • ⚠️ CAS 自旋里做重活:循环体内调数据库/发消息,冲突时重试成本爆炸。自旋体只做纯内存计算。
  • ⚠️ 把 LongAdder.sum() 当精确值用:它是近似汇总,不能拿来做「最后 1 件库存」的判断。
  • ⚠️ 嵌套多锁无全局顺序:两把以上锁交互时必须规定全序(如按资源 id 排序获取),并写进团队规范。
  • ⚠️ 在锁内调用外部回调:锁内触发 listener/回调,回调里再拿别的锁——死锁温床。

四、调试技巧

  • 死锁:jstack 输出末尾的 Found one Java-level deadlock 直接给出环;平时用 jstack | grep BLOCKED 观察等锁积压。
  • 判断 CAS 是否在空转:压测时 top -H -p <pid> 看线程 CPU,某线程接近 100% 且 jstack 显示它在自旋循环里,就是高争用信号(换分段方案)。
  • IDEA 并发调试技巧:给断点加条件(右键断点 → Condition,如 remaining == 1),只在关键时刻停下。

五、最佳实践

  • 决策链:不可变/无状态 → 并发容器 → 原子类(简单数值)→ ReentrantLock(需要超时/公平/条件)→ synchronized(其余默认)。
  • 所有锁等待必须带超时;lock() 无限等待只允许出现在初始化阶段。
  • 高争用先想「减少竞争面」(分段、分桶、本地聚合),再想「换更快的锁」。
  • 库存、余额这类「不可重复消费」的数值,最终都要落到数据库原子 UPDATE 兜底(第 33 章),内存方案只是性能前哨。

六、本章小结

  • ReentrantLock 的价值在超时/可中断/公平/多 Condition;lock-try-finally 是纪律。
  • CAS 把「检查+修改」合一实现无锁正确性;ABA 与自旋开销是两个代价。
  • 统计用 LongAdder,条件扣减用 AtomicInteger。
  • 死锁预防三板斧:统一顺序、缩小范围、超时放弃;jstack 一键定位。

下一章 JUC 工具与线程池:100 个任务怎么调度、CompletableFuture 怎么编排异步、虚拟线程什么时候用。


七、动手练习

基础题

  1. 把 StockStrategies.LockStock / CasStock 接进 OversellDemo(第 11 章练习 3 的思路),200 线程 × 100 库存各跑 5 次,记录卖出数均为 100、剩余均为 0 才算通过。
  2. 用 AtomicLong 实现一个线程安全的发号器 IdGenerator.next(),并写并发测试:100 线程各取 1000 个号,收集到 ConcurrentHashMap.newKeySet(),断言总数 100000 且无重复。

进阶题

  1. 实现两个账户转账的死锁安全版:transfer(Account from, Account to, long cents),要求锁定两个账户但不死锁(提示:按 account.getId() 升序加锁);故意写一个死锁版本对照 jstack 输出。
  2. 用 AtomicStampedReference 解决一个你自造的 ABA 场景(值回到原样但语义已变),打印「版本号拦截了错误的 CAS」。

思考题

  1. 库存分段(把 100 拆成 10×10)提升了吞吐,但引入新问题:某一段卖完后,持有对剩余段的请求该如何处理(借段/合并/直接失败)?画出你的方案流程图,第 24 章会看到 Redis 版的工业实现。

📄 第 13 章 JUC 工具、线程池与异步编排(含虚拟线程) 13-juc-threadpool-async.md

阶段:阶段 2 · 进阶提升 | 预计阅读 80 min | 代码落地模块:sample/mini-shop-se/concurrent 前置:第 11 章、第 12 章 | 相关章节:第 25 章 MQ | 第 32 章 实战落点:32 章压测的线程池脚手架;34 章「并行查商品+库存」的 CompletableFuture 原型;ThreadLocal 存登录用户

学习目标

读完本章,你应该能够:

  • [ ] 配置一个线程池并解释七个参数(尤其队列与拒绝策略)。
  • [ ] 用 CountDownLatch / Semaphore / CyclicBarrier 编排多线程协作。
  • [ ] 用 CompletableFuture 把串行调用改成并行编排,并正确处理异常。
  • [ ] 正确使用与清理 ThreadLocal,说出内存泄漏的成因。
  • [ ] 判断虚拟线程适合什么场景、不适合什么场景。

一、核心概念

1.1 线程的生命成本与线程池

new Thread() 每次都要:创建内核线程(约 1MB 栈内存)+ 上下文切换。高并发下「来一个请求开一个线程」会瞬间打爆内存与调度器。线程池 = 线程复用 + 数量管控,是生产代码处理并发的唯一正解(Web 容器内部就是线程池)。

1.2 ThreadPoolExecutor 七参数(必须能默写)

var seq = new java.util.concurrent.atomic.AtomicInteger();
ThreadPoolExecutor pool = new ThreadPoolExecutor(
        4,                                  // corePoolSize:常驻线程数
        8,                                  // maximumPoolSize:峰值线程数
        60, TimeUnit.SECONDS,               // 非核心线程空闲存活时间
        new ArrayBlockingQueue<>(200),      // workQueue:有界队列!
        r -> { Thread t = new Thread(r); t.setName("order-pool-" + seq.incrementAndGet()); return t; },
        // threadFactory:命名线程(排障刚需)。Guava 的 ThreadFactoryBuilder 是现成工具,纯 JDK 用手写 lambda
        new ThreadPoolExecutor.CallerRunsPolicy()   // rejection policy:拒绝策略
);

提交流程务必理解(面试与排障双高频):

提交任务
  → 核心线程未满?起核心线程执行
  → 满了 → 入队
  → 队列满 → 起非核心线程(直到 maximumPoolSize)
  → 线程也到顶 → 执行拒绝策略
拒绝策略 行为 适用
AbortPolicy(默认) 抛 RejectedExecutionException 快速失败,配合监控告警
CallerRunsPolicy 提交者自己跑(天然限流背压) 允许降低吞吐但不丢任务
DiscardPolicy / DiscardOldestPolicy 静默丢弃 / 丢最老的 ⚠️ 几乎总错误——丢任务无声无息

[!WARNING] 禁止使用 Executors.newFixedThreadPool / newCachedThreadPool:前者队列无界(任务堆积到 OOM),后者线程数无界(线程爆炸)。自己 new ThreadPoolExecutor,队列必须有界。

1.3 协作三件套

// CountDownLatch:倒数门闩 —— 「等 N 件事全部完成」
var latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) new Thread(() -> { work(); latch.countDown(); }).start();
latch.await();      // 阻塞直到计数归零(32 章压测「全部线程跑完再统计」)

// Semaphore:信号量 —— 「最多 N 个同时进」(限流)
var permits = new Semaphore(10);
permits.acquire();
try { callRemoteApi(); } finally { permits.release(); }

// CyclicBarrier:栅栏 —— 「一组线程互相等齐再一起走」(可重复)
var barrier = new CyclicBarrier(4, () -> System.out.println("全员到齐,进入下一阶段"));
barrier.await();    // 各线程到达后等齐

1.4 ThreadLocal:线程私有的口袋

// 全书最常见的用法:在一次请求内随处取「当前登录用户」,不用层层传参
private static final ThreadLocal<Long> CURRENT_USER = new ThreadLocal<>();

CURRENT_USER.set(userId);        // 入口(过滤器)设置
Long uid = CURRENT_USER.get();   // 任意深层代码读取
CURRENT_USER.remove();           // ⚠️ 出口必须清理!

内存泄漏成因:线程池的线程永远存活,ThreadLocalMap 的 key 是弱引用(可回收),但 value 是强引用——不 remove(),value(可能是大对象)就跟着线程活到天荒地老,还可能串到下一个请求(用户 A 的身份被用户 B 读到!)。铁律:try-finally 里 remove(Web 场景在过滤器 finally 中统一清,第 26 章)。

1.5 CompletableFuture:异步编排

回调地狱的解药,也是第 34 章「并行查商品+库存」的原型:

// 两个耗时查询并行跑,都完成后合并
CompletableFuture<Product> productFuture =
        CompletableFuture.supplyAsync(() -> productApi.load(id), pool);   // 指定自己的线程池!
CompletableFuture<Integer> stockFuture =
        CompletableFuture.supplyAsync(() -> stockApi.load(id), pool);

CompletableFuture<DetailVO> detail = productFuture
        .thenCombine(stockFuture, (p, stock) -> new DetailVO(p, stock))   // 合并两路结果
        .exceptionally(ex -> {                       // 异常兜底:任一路失败走到这
            log.error("查询失败", ex);
            return DetailVO.fallback();
        });
DetailVO vo = detail.join();     // 等待最终结果(带受检异常包装的是 get())

常用组合子速记:

方法 语义
supplyAsync / runAsync 起异步任务(有/无返回值)
thenApply / thenAccept / thenRun 成功后转换 / 消费 / 执行
thenCombine / thenCompose 合并两路 / 串联另一异步任务(避免嵌套)
allOf(...) / anyOf(...) 等全部 / 任一完成
exceptionally / handle 异常兜底 / 无论成败都处理

[!WARNING] 必须显式传线程池:不传时默认走 ForkJoinPool.commonPool()——全 JVM 共享,一个慢任务把全局并行度吃光。Web 应用里这条规则能救命。

1.6 虚拟线程(JDK 21 正式)

传统平台线程 1:1 映射内核线程,阻塞即占用昂贵的内核资源。虚拟线程由 JVM 调度:百万级并发也不怕,阻塞时自动让出底层载体线程。

// 适合:IO 密集、每任务一个线程的简单模型
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            Thread.sleep(1000);          // 阻塞不再昂贵
            return fetchRemote(i);
        });
    }
}   // try-with-resources 等待全部完成并关闭

边界要划清:虚拟线程加速的是「等待」,不是「计算」。CPU 密集任务该用固定大小平台池;给虚拟线程加 synchronized 包住的慢 IO 会「钉住」载体线程(JDK 24 已缓解,但习惯上 IO 内改用 ReentrantLock);也不需要池化虚拟线程(用完即弃才是设计意图)。Web 容器在 Boot 4 时代可一键切虚拟线程执行器处理请求。


二、示例代码

一个「并行聚合商品详情」的完整示例——34 章 Redis+DB 双查的原型:

// sample/mini-shop-se/src/main/java/com/example/minishop/concurrent/AggregateDemo.java
package com.example.minishop.concurrent;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.atomic.AtomicInteger;

/** 并行聚合(第 13 章):商品(100ms)+ 库存(50ms),串行 150ms → 并行 ~100ms */
public class AggregateDemo {

    record Detail(String name, int stock) { }

    public static void main(String[] args) throws Exception {
        // 有界池 + 命名线程(带序号,排查 dump 时能区分):生产标配
        AtomicInteger seq = new AtomicInteger();
        ExecutorService pool = new ThreadPoolExecutor(
                4, 8, 60, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(100),
                r -> {
                    Thread t = new Thread(r);
                    t.setName("detail-agg-" + seq.incrementAndGet());
                    return t;
                });

        try {                                   // ⚠️ 线程池必须在 finally 里关闭:否则非守护线程挂住 JVM
            long start = System.currentTimeMillis();
            CompletableFuture<Detail> detail =
                    CompletableFuture.supplyAsync(AggregateDemo::loadProduct, pool)
                            .thenCombine(
                                    CompletableFuture.supplyAsync(AggregateDemo::loadStock, pool),
                                    Detail::new)
                            .exceptionally(ex -> {
                                System.err.println("聚合失败降级: " + ex.getMessage());
                                return new Detail("兜底商品", 0);
                            });

            System.out.println(detail.get(2, TimeUnit.SECONDS));   // 带超时等待
            System.out.println("耗时: " + (System.currentTimeMillis() - start) + "ms");
        } finally {
            pool.shutdown();
            if (!pool.awaitTermination(3, TimeUnit.SECONDS)) {     // 等存量任务跑完,超时强停
                pool.shutdownNow();
            }
        }
    }

    static String loadProduct() { sleep(100); return "机械键盘"; }

    static int loadStock() { sleep(50); return 99; }

    static void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

输出:Detail[name=机械键盘, stock=99],耗时约 100ms——耗时取决于最慢一路,而非各路之和;线程池在 finally 里 shutdown() + awaitTermination(3s) 超时 shutdownNow(),优雅收尾。


三、常见坑 ⚠️

  • ⚠️ 无界队列:任务生产速度长期大于消费速度,队列静默膨胀到 OOM。有界队列 + 明确拒绝策略是底线。
  • ⚠️ 线程不命名:出问题时日志里全是 pool-1-thread-3,无法定位业务。ThreadFactory 统一命名。
  • ⚠️ shutdown() 后还提任务:抛 RejectedExecutionException;优雅关闭用 shutdown() + awaitTermination(第 29 章停机脚本)。
  • ⚠️ ThreadLocal 忘 remove:泄漏 + 用户串号双重事故,finally 清理写进过滤器模板。
  • ⚠️ CompletableFuture 不传池:默认池被打满,所有异步任务一起卡死。
  • ⚠️ join()/get() 不带超时:一路任务挂死,整条请求线程陪着挂。永远用 get(timeout, unit)。
  • ⚠️ 虚拟线程里放 CPU 密集任务:不会变快,只会让调度器白忙。

四、调试技巧

  • jstack 看线程池状态:大量 WAITING (parking) 的池线程 + 队列堆积,是消费不给力的信号。
  • 监控线程池两指标:活跃线程数 / 最大线程数、队列当前长度,暴露到 Actuator 指标(第 27 章)。
  • 怀疑 ThreadLocal 串号:在 set/get/remove 三处打日志带 System.identityHashCode(thread) 对照。

五、最佳实践

  • 线程池按业务域隔离(下单一个池、查询一个池),互不拖累;参数写进配置中心(第 19 章)。
  • IO 密集线程数经验公式:核数 × (1 + 等待时间/计算时间),拍脑袋不如压测(32 章)。
  • 异步编排的最外层必须有 exceptionally 兜底 + 超时等待。
  • Web 场景优先用容器/框架的执行器(Boot 4 支持虚拟线程开关),自定义池留给明确的批处理场景。

六、本章小结

  • 线程池七参数与提交流程是并发工程的心电图;有界队列 + 命名线程 + 拒绝策略三件套缺一不可。
  • CountDownLatch 等齐、Semaphore 限流、CyclicBarrier 反复栅栏。
  • ThreadLocal 用完必删,否则泄漏 + 串号。
  • CompletableFuture 三步走:supplyAsync 起任务(带池)→ combine/compose 编排 → exceptionally 兜底。
  • 虚拟线程救「等待」不救「计算」;同步块包 IO 是它的天敌。

下一章 JVM:这些线程、对象、异常在内存里长什么样?OOM 与 CPU 飙高该怎么查?


七、动手练习

基础题

  1. 把 AggregateDemo 的两个查询改成串行版(先商品后库存),记录耗时对比;再把线程池核心数改成 1,观察并行效果如何退化。
  2. 写三个线程分别用 ThreadLocal<String> 存名字并读取,主线程验证取不到(隔离性);然后故意不 remove,用注释说明会发生什么。

进阶题

  1. 用 Semaphore(3) 实现并发限流的下载器:20 个任务,最多 3 个同时执行,统计总耗时并与不限流版对比。
  2. 用 CompletableFuture.allOf 并行调用三个「接口」(sleep 模拟 80/120/50ms),汇总成 Map<String, String>,要求任一路失败不影响其他路(各自 exceptionally 降级),总耗时 ≤ 150ms。

思考题

  1. 拒绝策略选 CallerRunsPolicy 时,提交线程亲自执行任务,等于「生产者被拖慢」——这恰是限流背压。但如果提交线程是 Tomcat 请求线程,会发生什么连锁反应(提示:请求线程被占用 → 容器线程池耗尽 → 整站变慢)?你会如何为「下单接口」选拒绝策略?

📄 第 14 章 JVM 基础:内存区域、类加载、GC 与排障命令 14-jvm-basics.md

阶段:阶段 2 · 进阶提升 | 预计阅读 75 min | 代码落地模块:命令行实操(jps/jstat/jmap/jstack) 前置:第 11 章 并发基础 | 相关章节:第 27 章 监控 | 第 29 章 部署 实战落点:32 章压测配 -Xmx 观察 GC;35 章 Docker 内存限制的依据;线上 OOM/CPU 飙高的排障四件套

学习目标

读完本章,你应该能够:

  • [ ] 画出 JVM 运行时内存分区,说出对象在各区域的旅程。
  • [ ] 解释分代收集的原理(为什么大部分对象朝生夕死)。
  • [ ] 看懂 OutOfMemoryError 的四种常见形态与初步定位方向。
  • [ ] 熟练使用排障四件套:jps / jstat / jmap / jstack。
  • [ ] 为一个 Spring Boot 服务设置合理的堆参数(第 35 章 Docker 部署直接复用)。

一、核心概念

1.1 运行时内存分区

flowchart TB
    subgraph JVM["JVM 运行时数据区(线程共享)"]
        HEAP["堆 Heap<br/>对象实例的家 ★GC 主战场"]
        META["元空间 Metaspace<br/>类元数据(原永久代)"]
    end
    subgraph PerThread["线程私有"]
        STACK["虚拟机栈<br/>栈帧:局部变量表/操作数栈"]
        PC["程序计数器"]
        NAT["本地方法栈"]
    end
    HEAP --> YOUNG["新生代<br/>Eden + 两个 Survivor"]
    HEAP --> OLD["老年代<br/>长命对象"]
区域 放什么 什么时候炸
堆 new 出来的对象、数组 OutOfMemoryError: Java heap space(最常见)
虚拟机栈 方法调用栈帧 StackOverflowError(递归失控 / 栈深 -Xss 太小)
元空间 类元数据 Metaspace OOM(动态生成类失控,如大量反射/CGlib)
直接内存 NIO DirectByteBuffer Direct buffer memory OOM

对象的旅程:new 出生在 Eden → 第一次 Minor GC 幸存进入 Survivor(年龄+1)→ 年龄到 15(默认)晋升 老年代 → 最终被回收或活到进程结束。大对象(大数组)可直接进老年代。

1.2 垃圾判定与分代收集

GC 判定:从 GC Roots(栈帧局部变量、静态变量、JNI 引用等)出发做可达性分析——到不了的对象就是垃圾。引用计数法因循环引用问题不被 JVM 采用。

为什么分代:经验法则「大部分对象朝生夕死」(一个请求里的临时对象活不过一次 GC)。所以:

  • Minor GC:只清新生代,频率高、速度快(活对象少,复制即可);
  • Major/Full GC:涉及老年代,慢一个量级,会带来长暂停(Stop-The-World, STW)——接口抖动的常见元凶。

调优第一原则:让垃圾死在新生代。若频繁 Full GC,通常是内存泄漏或堆太小。

[!TIP] JDK 21 默认收集器就是 G1(JDK 9 起即为服务端默认)——把堆划成 Region、可预测停顿,显式写 -XX:+UseG1GC 只是自证配置。本书不展开收集器家族史,记住:先看 GC 日志确认瓶颈,再谈换收集器。

1.3 常用 JVM 参数(背下这一屏就够)

java -Xms1g -Xmx1g -Xss512k \
     -XX:+UseG1GC -XX:MaxMetaspaceSize=256m \
     -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/ \
     -jar app.jar

参数速记:-Xms = -Xmx(初始堆 = 最大堆,避免运行期抖动,生产标配相等值);-Xss 单线程栈大小(默认 1M;虚拟线程不受此限);-XX:+UseG1GC 收集器;-XX:+HeapDumpOnOutOfMemoryError OOM 时自动 dump(排障神器)。

[!WARNING] 容器时代(第 29 章 Docker):JVM 8u191+ 能感知容器内存限额,但仍建议显式 -Xmx,设为容器限额的 50%~75%,留余量给堆外(元空间、线程栈、直接内存、Netty 缓冲)。容器 OOM Kill 不留任何现场,比 Java OOM 难查得多。

1.4 排障四件套

命令 用途 常用姿势
jps -l 列出 Java 进程 pid 排障第一步,找到目标进程
jstat -gcutil <pid> 1000 每秒打印 GC 占比(各代使用率、GC 次数/耗时) 判断 GC 是否异常频繁
jmap -dump:live,format=b,file=a.hprof <pid> 导出堆快照 配合 MAT/JProfiler 分析泄漏对象
jstack <pid> 线程栈快照 死锁(12 章)、CPU 飙高定位、慢请求卡点

CPU 飙高标准流程(背下来,面试必考、值班必用):

top -p <pid>                # 1. 确认进程 CPU 高;按 H 显示线程,记下最耗 CPU 的线程号(如 12345)
printf '%x\n' 12345         # 2. 线程号转十六进制 → 3039
jstack <pid> | grep -A 20 "nid=0x3039"   # 3. 在栈里找到这个线程正在执行的代码
# 90% 的答案:某个自旋循环(12 章 CAS 高争用)、正则回溯、或频繁 Full GC 的 GC 线程

二、示例代码

一组「制造症状」的实验,把 OOM 与内存泄漏从名词变成手感。OomLab 是本章练习产物,sample 不提供——按下述骨架自己动手写(练习 1),再逐个模式做实验:

// 本章练习产物(sample 不提供),建议放到自己的 playground 工程
package com.example.minishop.lab;

import java.util.ArrayList;
import java.util.List;

/** OOM 现场制造器。跑法:java -Xmx32m com.example.minishop.lab.OomLab heap */
public class OomLab {

    public static void main(String[] args) {
        String mode = args.length > 0 ? args[0] : "heap";
        switch (mode) {
            case "heap" -> {                      // 堆 OOM:往 List 里堆大对象
                List<byte[]> holder = new ArrayList<>();
                while (true) {
                    holder.add(new byte[10_000_000]);   // 每轮 10MB,32m 堆很快见底
                }
            }
            case "stack" -> {                     // 栈溢出:无限递归
                recurse(0);
            }
            case "leak" -> {                      // 内存泄漏:示例用 main 内局部持有即足以撑爆堆
                List<byte[]> LEAK = new ArrayList<>();   // 产业级泄漏的常态:static 持有、永不清理
                int i = 0;
                while (true) {
                    LEAK.add(new byte[1_000_000]);       // 每秒 1MB:堆缓慢耗尽
                    System.out.println("第 " + i++ + " 轮, 已持有约 " + i + "MB");
                    try { Thread.sleep(1000); } catch (InterruptedException e) { return; }
                }
            }
            default -> System.out.println("用法: heap | stack | leak");
        }
    }

    static void recurse(int depth) {
        recurse(depth + 1);   // StackOverflowError: 打印的数字就是栈深
    }
}
# 三个实验的正确打开方式
java -Xmx32m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./ \
     -cp target/classes com.example.minishop.lab.OomLab heap
# 观察:Java heap space + 自动生成 *.hprof

java -Xss256k -cp target/classes com.example.minishop.lab.OomLab stack
# 观察:StackOverflowError 与栈深数字

# leak 模式跑 2 分钟,另开终端用 jstat 观察 Old 区缓慢涨满 → Full GC 频率上升
jps -l                          # 找到 pid
jstat -gcutil <pid> 2000        # 每 2 秒一次:O 列(老年代使用率)逼近 100,F 趋近 0 但 FGCT 持续增长

[!TIP] 「泄漏」与「堆太小」的区分:dump 后用 MAT 看支配树——某个对象持有几百 MB 且引用链指向静态字段 = 泄漏;分配不出去但没有异常持有者 = 堆太小或流量超预期。


三、常见坑 ⚠️

  • ⚠️ -Xms 与 -Xmx 不等:堆伸缩引发 GC 抖动与容器内存告警,生产固定相等。
  • ⚠️ 容器里不设 -Xmx:默认按宿主机内存算,直接被容器 OOM Kill(连 dump 都没有)。
  • ⚠️ 把 OutOfMemoryError catch 住继续跑:堆已碎,应用处于未定义状态。捕获后应立即记录并让进程退出(让重启机制接管)。
  • ⚠️ 静态集合当缓存用却无上限:本章 leak 实验的产业级版本。缓存必须有界(LRU/过期),第 24 章 Redis 登场后进一步外移。
  • ⚠️ 递归无终止条件或过深:JSON 深嵌套解析、树遍历都可爆栈;深层递归改显式栈或流式处理。
  • ⚠️ 线上直接 jmap -dump 大堆:会 STW 数秒。生产优先依赖 -XX:+HeapDumpOnOutOfMemoryError 的现场,确需手动 dump 先摘流量。

四、调试技巧

  • GC 是否健康看三个数:Minor GC 频率(秒级正常)、Full GC 频率(小时级以上正常)、单次 Full GC 耗时(百毫秒内理想)。jstat -gcutil 的 O 列锯齿状缓慢抬升 = 泄漏特征。
  • IDEA 里 Debug 观察对象:Memory 视图可看各类对象实例数,适合小实验;线上大堆用 MAT 的 Histogram 按类的 Retained Size 排序。
  • -Xlog:gc*(JDK 9+ 统一日志)输出 GC 日志到文件,是分析停顿的原始证据:-Xlog:gc*:file=gc.log:time,uptime,level,tags。

五、最佳实践

  • 生产 JVM 参数进配置模板(第 29 章 Dockerfile/启动脚本),禁止「每台机器手调一套」。
  • 每个服务标配三件:-Xms=-Xmx、HeapDumpOnOutOfMemoryError、GC 日志。
  • 内存问题按「现象 → 四件套取证 → dump 定位 → 修复 → 压测回归」闭环,不靠重启糊弄。
  • 升级依赖后盯一眼元空间:大量反射/动态代理框架(Spring AOP)会增加类数量。

六、本章小结

  • 堆(新生代 Eden/Survivor + 老年代)是 GC 主战场;栈管方法调用;元空间管类元数据。
  • 分代的依据是「朝生夕死」;Full GC 慢且 STW,调优首要是让垃圾死在新生代。
  • 四件套 jps/jstat/jmap/jstack + CPU 飙高三步法是值班硬技能。
  • 容器必须显式 -Xmx(限额 50%~75%)并开启 OOM 自动 dump。

下一章网络编程:请求是怎么从网卡走到你的 OrderService 的?BIO 为什么扛不住高并发?


七、动手练习

基础题

  1. 自己动手写 OomLab(提示:static List<byte[]> 持有 + heap/stack/leak 三种模式,sample 不提供),完成三个模式的实验,分别记录报错信息与 jstat 观察;把 -Xmx 调到 128m 重跑 heap 模式,对比撑的时间。
  2. 写一个「缓存有界化」实验:静态 HashMap 每秒放入 1 个 1MB 数组,用 LinkedHashMap 重写为 LRU(accessOrder=true,重写 removeEldestEntry 限 50 条),jstat 确认老年代稳定。

进阶题

  1. 用 -Xlog:gc*:file=gc.log 跑 leak 实验 3 分钟,从日志统计 Full GC 次数与平均停顿,写一段 100 字的「GC 健康报告」。
  2. 模拟「CPU 飙高」:写一个死循环做字符串拼接(别优化掉它,结果存入 volatile 变量),用本章三步法定位到线程与代码行,把全过程整理成值班笔记。

思考题

  1. 第 29 章会把应用打包进 Docker 并限制内存 1GB。请你预先设计 JVM 参数(-Xmx/-Xss/MaxMetaspaceSize),并说明每项依据;再想一个问题:为什么虚拟线程场景下 -Xss 不再是主要约束?提示:回看第 13 章虚拟线程的内存模型。

📄 第 15 章 网络编程:Socket、HTTP、BIO→NIO 与 Netty 概览 15-network-programming.md

阶段:阶段 2 · 进阶提升 | 预计阅读 65 min | 代码落地模块:无(本章为独立小实验,sample 不收录,建议自建 playground 目录练手) 前置:第 08 章 IO、第 11 章 并发基础 | 相关章节:第 23 章 REST 接口 | 第 25 章 RabbitMQ 实战落点:理解第 23 章「HTTP 请求 → Controller」之间发生了什么;34 章支付回调用到的 HTTP 客户端知识

学习目标

读完本章,你应该能够:

  • [ ] 说出一次 HTTP 请求的完整旅程(DNS → TCP → 请求报文 → 响应)。
  • [ ] 手写一个最小 TCP Echo 服务端/客户端,理解 Socket 抽象。
  • [ ] 解释阻塞 IO(Blocking IO, BIO)一连接一线程的瓶颈与 NIO 的解决思路(多路复用)。
  • [ ] 熟练使用 JDK HttpClient(同步/异步/超时),理解超时是网络调用的第一配置。
  • [ ] 知道 Netty 在生态中的位置(Redis/RabbitMQ/gRPC 客户端都是它写的),不要求掌握其 API。

一、核心概念

1.1 一次 HTTP 请求的旅程

sequenceDiagram
    participant C as 客户端
    participant D as DNS
    participant S as 服务端(Tomcat)
    C->>D: 1. 查 api.shop.com 的 IP
    D-->>C: 返回 1.2.3.4
    C->>S: 2. TCP 三次握手 (port 8080)
    C->>S: 3. 发送 HTTP 请求报文<br/>POST /orders + JSON
    S->>S: 4. 容器解析报文 → Servlet → Spring DispatcherServlet
    S-->>C: 5. HTTP 响应(状态码 + JSON)
    C->>S: 6. 连接复用(keep-alive)或关闭

TCP 三个事实,影响你写的每一行业务代码:

  1. 字节流无消息边界:两次发送可能被一次收到(粘包)或一次发送被拆成两次(拆包)——所以应用层协议(HTTP 的 Content-Length、MQ 的帧头)都自带定长/分隔符约定;
  2. 连接是有状态资源:keep-alive 复用连接省去每次握手,这就是连接池(第 21 章 HikariCP、第 24 章 Redis 客户端连接池)存在的理由;
  3. 网络随时会失败:超时、重置、对端无响应是常态而非异常——任何远程调用必须有超时(本章 1.4)。

1.2 Socket:网络 IO 的门把手

Socket 是操作系统对「一条 TCP 连接」的编程抽象:读写它 = 读写网络。最小 TCP Echo 服务端/客户端(BIO 模型,先跑起来再谈缺点)的完整实现见「二、示例代码」,用 telnet localhost 9000 或直接跑客户端即可验证。

1.3 BIO → NIO:为什么容器不用一连接一线程

**阻塞 IO(Blocking IO, BIO)**的账本:每个连接占一条线程(约 1MB 栈 + 调度开销)。1 万并发连接 = 1 万线程——线程切换打满 CPU,内存先炸。第 13 章的线程池只能缓解,不能根除:瓶颈是「线程数 ∝ 连接数」这个模型本身。

**非阻塞 IO(Non-blocking IO, NIO)**换模型:**少量线程 + 多路复用器(Selector)**管理海量连接——

BIO:  连接 A → 线程 1(干等)  连接 B → 线程 2(干等) ... 连接 N → 线程 N(干等)
NIO:  Selector(1~n 个线程)← 事件通知:A 可读 / B 可写 / C 已连接
      就绪了才分配线程处理,等待发生在操作系统里,不占应用线程

三组概念对齐:Channel(双向数据通道,替代流)+ Buffer(读写缓冲,flip 切换)+ Selector(就绪事件分发)。结论先行,API 不必背:你几乎不会手写 NIO,因为 Netty 已把它封装成易用的框架,而 Tomcat/Undertow(Spring Boot 内嵌容器)内部正是 NIO 模型——这解释了为什么 Boot 应用默认能扛住远超线程数的并发连接。

[!TIP] 「响应式/Reactor 框架」是 NIO 思想的更高层封装(少量事件循环线程 + 非阻塞回调)。学习主线仍是阻塞式 + 线程池,遇到 WebFlux/Vert.x 项目时再补——原理你已经有了。

1.4 HTTP 客户端:JDK HttpClient

后端服务互调(调用支付网关、查询物流)都用 HTTP 客户端。JDK 11+ 内置的 java.net.http.HttpClient 够用且零依赖,完整示例见「二、示例代码」。

三条纪律:

  1. HttpClient 实例全局复用(内部连接池),别在方法里 new;
  2. 两个超时都必须配:连接超时管「建连快不快」,请求超时管「对方处理快不快」;无超时的远程调用 = 把自己的线程押给别人的人品;
  3. 检查状态码:HTTP 200 只代表「送达」,业务结果还要看响应体里的 code(第 23 章统一响应)。

1.5 Netty 概览:知道它站在哪

你不会直接写 Netty,但它无处不在:Redis 客户端 Lettuce、RabbitMQ 的 AMQP 客户端、gRPC、Elasticsearch 传输层、网关(Spring Cloud Gateway)全是 Netty。它的定位一句话:异步事件驱动的网络应用框架——把 1.3 的 NIO 三件套封装成「事件回调 + Pipeline 处理器链」,并解决粘包拆包、内存池、零拷贝等工程细节。现在记住这个坐标即可,等你在第 24 章看到 Redisson 的连接数配置、第 25 章看到 AMQP 连接时,能对上号「底下是 Netty 在跑事件循环」。


二、示例代码

本章示例是独立小实验,sample 工程不收录——建议自建 playground 目录(例如 playground/src/main/java/lab/)练手。

实验一:最小 TCP Echo 服务端 + 客户端(BIO 模型:一连接一线程,先跑起来再谈缺点)

// playground/lab/EchoServer.java —— 本章独立小实验,sample 不收录
package lab;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

/** 最小 TCP Echo 服务端:BIO 模型(一连接一线程)。先跑起来再谈缺点 */
public class EchoServer {
    public static void main(String[] args) throws Exception {
        try (ServerSocket server = new ServerSocket(9000)) {
            System.out.println("Echo 服务端已启动 :9000");
            while (true) {
                Socket client = server.accept();                 // 阻塞:等新连接
                // ⚠️ 为每个连接单独起线程 = BIO 模型(1.3 详解其瓶颈)
                new Thread(() -> handle(client)).start();
            }
        }
    }

    static void handle(Socket client) {
        try (client;
             var in = new BufferedReader(new InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8));
             var out = new PrintWriter(client.getOutputStream(), true)) {
            String line;
            while ((line = in.readLine()) != null) {             // 阻塞读:没数据就干等
                if ("bye".equals(line)) break;
                out.println("echo: " + line);
            }
        } catch (Exception e) {
            System.err.println("连接异常: " + e.getMessage());
        }
    }
}
// playground/lab/EchoClient.java —— 同包客户端:连接 → 写一行 → 读回显
package lab;

try (Socket socket = new Socket("localhost", 9000);
     var out = new PrintWriter(socket.getOutputStream(), true);
     var in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) {
    out.println("hello");
    System.out.println(in.readLine());   // echo: hello
}

实验二:JDK HttpClient 三要素(连接超时、请求超时、状态码检查——缺一不可)

// playground/lab/HttpClientDemo.java —— 本章独立小实验,sample 不收录
package lab;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

/** HTTP 客户端三要素:连接超时、请求超时、状态码检查 —— 缺一不可 */
public class HttpClientDemo {

    static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(3))      // TCP 连接超时(复用实例!内部有连接池)
            .build();

    public static String postJson(String url, String json) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .timeout(Duration.ofSeconds(5))          // 请求整体超时:远程调用必配
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(json))
                .build();

        HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() != 200) {
            throw new IllegalStateException("远程调用失败: HTTP " + response.statusCode()
                    + " body=" + response.body());
        }
        return response.body();
    }

    public static void main(String[] args) throws Exception {
        // 异步版:第 13 章 CompletableFuture 语法直接复用
        HttpRequest req = HttpRequest.newBuilder(URI.create("https://httpbin.org/get")).build();
        CLIENT.sendAsync(req, HttpResponse.BodyHandlers.ofString())
                .thenApply(HttpResponse::body)
                .orTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
                .whenComplete((body, err) -> {
                    if (err != null) System.err.println("调用失败: " + err.getMessage());
                    else System.out.println(body.substring(0, 100) + "...");
                })
                .join();
    }
}

三、常见坑 ⚠️

  • ⚠️ 远程调用不设超时:对端假死 → 你的线程池被拖满 → 全服务雪崩。默认超时 + 熔断(进阶主题)是标配。
  • ⚠️ 每次调用 new HttpClient/new 连接:握手开销 + 端口耗尽(TIME_WAIT 堆积)。实例复用。
  • ⚠️ 忽略 statusCode:404/500 的响应体当正常数据解析,NPE 迷惑现场。
  • ⚠️ 服务端 read 死循环不判流结束:readLine() 返回 null 要退出;否则客户端断开后空转。
  • ⚠️ 用「加大线程数」解决 IO 慢:方向性错误——慢在远端,加线程只是堆更多等待者。先查对端、加超时、上熔断,最后才考虑扩容。
  • ⚠️ 明文传敏感数据:内网也要 TLS 或至少不传明文密码(hash 见第 26 章)。

四、调试技巧

  • curl -v 看完整请求/响应头,是接口调试第一工具(第 23 章天天用)。
  • 本机端口占用/连通性:lsof -i :9000(macOS/Linux)、netstat -ano | findstr 9000(Windows)、telnet host port 测连通。
  • 抓包进阶:tcpdump -i any port 9000 -A(Linux 服务器场景,../linux-server/docs/ 有专章)。
  • 连接池泄漏的典型症状:客户端报 Connection pool exhausted 或系统 TIME_WAIT 数万(ss -s 查看)。

五、最佳实践

  • 所有远程调用统一封装:超时、重试(幂等前提,第 25 章)、状态码检查、日志(带 traceId,第 27 章)四件套齐全。
  • HTTP 调用升级路径:JDK HttpClient → 企业统一 RPC/HTTP 客户端(带治理能力)。换客户端不换纪律。
  • 服务间调用失败要有「用户可感知的降级文案」,而不是 500 白屏。

六、本章小结

  • HTTP 旅程:DNS → TCP 握手 → 报文 → 容器解析 → 你的代码;TCP 无边界、连接是资源、网络必失败。
  • Socket 是连接的抽象;BIO 一连接一线程扛不住海量连接,NIO 用 Selector 换模型——Tomcat 内部就是 NIO。
  • JDK HttpClient:实例复用 + 双超时 + 状态码检查,三纪律。
  • Netty 是生态底座,知道位置即可。

下一章常用类库:时间处理、JSON 细节、工具类与摘要签名——都是业务代码每天在摸的轮子。


七、动手练习

基础题

  1. 跑通 Echo 服务端 + 客户端;再开 3 个终端同时 telnet,验证 BIO 模型多连接并行可用;把服务端改成「单线程不加 Thread」,验证第二个连接被阻塞——这就是 BIO 的痛点。
  2. 用 HttpClientDemo 请求 https://httpbin.org/delay/3,把请求超时设为 1 秒,观察 HttpTimeoutException;再去掉超时对比行为。

进阶题

  1. 给 Echo 服务端加「每连接限 3 条消息」:超过后回复 bye 并断开;客户端验证收到后正常退出。
  2. 用 HttpClient + CompletableFuture 并行调用 httpbin.org/delay/1 三次,总耗时接近 1 秒而非 3 秒(第 13 章编排的网络版)。

思考题

  1. 支付场景:你调用支付网关「创建支付单」,超时了——订单该置为失败还是「未知」?如果置为失败而实际网关已扣款,会怎样?由此推导:为什么「支付创建」必须设计成可重试 + 幂等(带唯一请求号)?这道题是第 25/34 章幂等设计的思想起点。

📄 第 16 章 常用类库:java.time、Jackson、工具类与摘要签名 16-common-libraries.md

阶段:阶段 2 · 进阶提升 | 预计阅读 70 min | 代码落地模块:sample/mini-shop-se/util 前置:第 01 章、第 05 章 泛型 | 相关章节:第 26 章 安全 | 第 34 章 生产化 实战落点:订单号生成、下单/过期时间、支付回调 HMAC 验签、金额 BigDecimal 完整规则——34 章直接复用

学习目标

读完本章,你应该能够:

  • [ ] 用 java.time 完成时间创建、运算、格式化、时区转换,彻底告别 Date。
  • [ ] 掌握 BigDecimal 三纪律(字符串构造、显式精度、比较用 compareTo)。
  • [ ] 用 Jackson 3 完成对象与 JSON 互转,避开常见注解坑。
  • [ ] 知道 Hutool / Guava 提供什么、什么时候该用、什么时候不该用。
  • [ ] 实现摘要与 HMAC 签名,理解支付回调验签的安全原理。

一、核心概念

1.1 java.time:时间 API 的正确打开方式

// 三大核心类:本地日期 / 本地时间 / 本地日期时间(无时区概念,业务系统首选)
LocalDate today = LocalDate.now();                    // 2026-09-29
LocalDateTime now = LocalDateTime.now();              // 2026-09-29T10:30:00.123
LocalDateTime expire = now.plusMinutes(15);           // 订单 15 分钟过期(mini-shop 超时关单)

// 判断过期:isBefore,不要转时间戳比较
boolean expired = expire.isBefore(LocalDateTime.now());

// Duration:两个时间点的时长
Duration between = Duration.between(now, expire);
between.toMinutes();                                  // 15

// 格式化:线程安全(SimpleDateFormat 不是!)
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
String text = now.format(fmt);                        // 2026-09-29 10:30:00
LocalDateTime parsed = LocalDateTime.parse("2026-09-29 10:30:00", fmt);

// 时间戳:Instant(UTC 时刻)—— 与 Date/数据库交互的桥梁
Instant instant = Instant.now();                      // UTC
long epochMilli = instant.toEpochMilli();
LocalDateTime local = LocalDateTime.ofInstant(instant, ZoneId.of("Asia/Shanghai"));

// 与库表、前端的衔接:数据库(21 章 DATETIME 列)、前端 ISO 格式
// Date -> LocalDateTime:新项目不该再写这行;存量项目迁移见下方折叠块
老代码互转(维护存量项目时再看)
// Date -> LocalDateTime
LocalDateTime ldt = date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
// LocalDateTime -> Date
Date d = Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());

三条军规:

  1. 存储与传输用 UTC 时间戳或 ISO-8601 字符串,展示层再转本地时区;数据库列统一 DATETIME + 应用层固定时区(第 21 章);
  2. 不要用 SimpleDateFormat(线程不安全,并发下格式化错乱),DateTimeFormatter 是它的替代且不可变线程安全;
  3. 跨时区业务从第一天就写明时区(ZoneId.of("Asia/Shanghai")),「默认时区」是国际化灾难的起点。

1.2 BigDecimal:金额运算的完整规则

第 01/03 章的「金额不用 double」在这里补完全部细节:

// 纪律一:必须用字符串(或 BigDecimal.valueOf)构造 —— double 构造天生带误差
BigDecimal a = new BigDecimal("0.1");          // 精确 0.1
BigDecimal bad = new BigDecimal(0.1);          // 0.1000000000000000055511151231257827...❌
BigDecimal good = BigDecimal.valueOf(0.1);     // 内部走 Double.toString,通常可接受

// 纪律二:除法必须显式精度与舍入模式 —— 否则除不尽直接抛 ArithmeticException
new BigDecimal("100").divide(new BigDecimal("3"));                    // ❌ 抛异常
new BigDecimal("100").divide(new BigDecimal("3"), 2, RoundingMode.HALF_UP);  // 33.33 ✅

// 纪律三:比较用 compareTo,不用 equals —— equals 连精度一起比
new BigDecimal("1.0").equals(new BigDecimal("1.00"));          // false(陷阱!)
new BigDecimal("1.0").compareTo(new BigDecimal("1.00")) == 0;  // 0 表示相等 ✅

舍入模式速记(业务最常用前三个):HALF_UP 四舍五入(金额展示)、DOWN 直接截断(分摊对商家有利/佣金)、UP 进位(对用户收费宁多勿少需谨慎,合规敏感)。选哪种是业务决策,代码里必须注释说明谁受益(第 03 章 Money.discount 的约定)。

1.3 Jackson 3:JSON 序列化

Boot 4 世代默认 Jackson 3,包名 tools.jackson(附录 90 重点):

// sample/mini-shop-se/src/main/java/com/example/minishop/util/Json.java(第 08 章已建,复习)
JsonMapper mapper = JsonMapper.builder().build();

String json = mapper.writeValueAsString(order);        // 对象 -> JSON
Order back = mapper.readValue(json, Order.class);      // JSON -> 对象

// 泛型集合反序列化:List<OrderItem> 不能写 OrderItem.class —— 用 TypeReference 携带完整泛型(第 05 章类型令牌回收)
// ⚠️ Jackson 3 的 TypeReference 在 tools.jackson.core.type 包:import tools.jackson.core.type.TypeReference;
List<OrderItem> items = mapper.readValue(itemsJson,
        new TypeReference<List<OrderItem>>() { });

高频注解(贴在 DTO/实体上):

注解 作用
@JsonProperty("user_id") 字段重命名(对接 snake_case 接口)
@JsonIgnore 序列化时跳过(密码、内部字段)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") 日期格式(LocalDateTime 默认 ISO 带 T)

[!WARNING] 迁移存量代码时最常见的编译错误:import com.fasterxml.jackson.annotation.* 找不到。Jackson 3 的注解包是 tools.jackson.databind.annotation 等;老教程/老博客的 Jackson 2 写法需逐一替换。MyBatis-Plus 的 JSON 字段处理器在 Boot 4 下用 Jackson3TypeHandler(第 21 章)。

1.4 工具类库:Hutool 与 Guava

库 定位 典型用法 提醒
Hutool 国产大而全工具箱 StrUtil.isBlank、IdUtil.fastSimpleUUID()、DateUtil 快糙猛适合小项目;大项目建议只取零散工具,核心逻辑少依赖
Guava 谷歌精品件 Lists/Maps 工厂、ImmutableList、RateLimiter、缓存 质量高;注意与项目依赖冲突排查(17 章)
commons-lang3 老牌基础件 StringUtils 存量项目极常见,认识即可
// 典型三连
String s = StrUtil.blankToDefault(input, "default");     // 空白兜底
String traceId = IdUtil.fastSimpleUUID();                 // 32 位随机串(27 章 traceId 原型)
ImmutableList<String> names = ImmutableList.of("a", "b"); // 不可变(第 03 章值对象思想的集合版)

[!TIP] 工具类原则:先查 JDK 与项目已有依赖,再引新库。Objects.equals、String.join、Files 能解决的事不要为此引依赖——依赖树每加一分,冲突与升级成本多一分(第 17 章)。

1.5 摘要与 HMAC:安全的第一课

// 摘要(Digest):单向「指纹」,验证数据是否被篡改
MessageDigest md5 = MessageDigest.getInstance("MD5");       // ❌ 已不安全,仅老系统兼容
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] digest = sha256.digest(data.getBytes(StandardCharsets.UTF_8));

// 密码存储:不能用裸摘要(可被彩虹表逆推)—— 用带盐慢哈希(BCrypt)
// 第 26 章登录注册的密码一律 BCrypt;本节摘要用于「数据完整性」而非密码

// HMAC:摘要 + 密钥 = 签名。支付回调验签的标准武器
public class HmacDemo {
    /** 支付网关回调验签:用约定密钥对报文重算签名比对(34 章 PayCallbackService 直接复用) */
    static boolean verify(String payload, String receivedSign, String secret) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        byte[] expected = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
        String computed = HexFormat.of().formatHex(expected);
        // ⚠️ 用常量时间比较,防止逐字节比对时序泄露(企业安全审计必查项)
        return MessageDigest.isEqual(
                computed.getBytes(StandardCharsets.UTF_8),
                receivedSign.getBytes(StandardCharsets.UTF_8));
    }
}

为什么验签必须 HMAC 而非裸摘要:攻击者可以改报文后重算裸摘要;HMAC 的密钥只有双方知道,改报文算不出合法签名。这就是「微信/支付宝回调都带 sign 字段」的原理。


二、示例代码

组装 mini-shop 的两个真实工具:订单号生成器(本章练习产物,sample 不提供,可放到自己的 playground)与回调验签器(sample 真实文件):

// 本章练习产物(sample 不提供),可独立编译的完整小类
package com.example.minishop.util;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicLong;

/** 订单号:日期前缀 + 当日序列。可读、可按日归档;真正分布式唯一见第 34 章的说明 */
public class OrderNoGenerator {

    private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyyMMdd");
    private final AtomicLong seq = new AtomicLong(0);
    private volatile String day = LocalDate.now().format(DAY);   // 跨日重置序列

    public String next() {
        String today = LocalDate.now().format(DAY);
        if (!today.equals(day)) {
            synchronized (this) {
                if (!today.equals(day)) {   // 双检:只重置一次
                    day = today;
                    seq.set(0);
                }
            }
        }
        return today + String.format("%08d", seq.incrementAndGet());   // 日期前缀 + 8 位序列,如 2026092900000001
    }
}
// sample/mini-shop-boot/src/main/java/com/example/minishop/util/SignUtil.java
package com.example.minishop.util;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;

/** HMAC-SHA256 签名/验签(第 16/34 章):支付回调的门禁 */
public final class SignUtil {

    public static String sign(String payload, String secret) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
        } catch (Exception e) {
            throw new IllegalStateException("签名失败", e);
        }
    }

    /** 常量时间比较:不给时序侧信道留窗口 */
    public static boolean verify(String payload, String receivedSign, String secret) {
        return MessageDigest.isEqual(
                sign(payload, secret).getBytes(StandardCharsets.UTF_8),
                receivedSign.getBytes(StandardCharsets.UTF_8));
    }

    private SignUtil() { }
}

三、常见坑 ⚠️

  • ⚠️ new BigDecimal(double):天生带误差。字符串构造或 valueOf。
  • ⚠️ BigDecimal.equals 比较:1.0 与 1.00 不等。业务比较一律 compareTo。
  • ⚠️ 除法不设精度:divide 除不尽抛 ArithmeticException,线上事故常客。
  • ⚠️ SimpleDateFormat 共享实例:并发格式化错乱。用 DateTimeFormatter。
  • ⚠️ Jackson 2/3 包名混用:Boot 4 项目里 import 老包直接编译失败(附录 90 速查表)。
  • ⚠️ MD5 存密码:等于明文。密码必须 BCrypt 等慢哈希(第 26 章),摘要只用于完整性。
  • ⚠️ 验签用 String.equals:时序侧信道。MessageDigest.isEqual 常量时间比较。

四、调试技巧

  • 时间 bug 用「固定时钟」测:Clock.fixed(Instant.parse("2026-09-29T10:00:00Z"), ZoneId.of("UTC")),把 Clock 注入业务类(第 19 章可测试性思想),别在代码里到处 LocalDateTime.now()。
  • JSON 序列化结果不对:先把对象 writeValueAsString 打出来肉眼对照,再查注解(命名/忽略/格式)。
  • 签名对不上:双方分别打印「参与签名的原始串」逐字符 diff——90% 是参数排序或编码差异。

五、最佳实践

  • 时间三统一:代码全 java.time、库表 DATETIME、接口 ISO 格式;业务类通过注入 Clock 获取当前时间以便测试。
  • 金额全部走 Money 值对象(第 03 章),BigDecimal 规则封装在其内部,业务代码不裸碰。
  • 工具方法集中 util 包并写明「为什么存在」,超过 30 行的工具类按主题拆分。
  • 签名密钥来自配置/密钥管理(第 19 章 @ConfigurationProperties),绝不硬编码。

六、本章小结

  • java.time 三件套 + 军规三连;DateTimeFormatter 线程安全,SimpleDateFormat 退役。
  • BigDecimal 三纪律:字符串构造、显式精度、compareTo 比较。
  • Jackson 3 新包名 tools.jackson;泛型反序列化用 TypeReference;高频注解三枚。
  • 工具库按需取用不贪多;摘要管完整性、HMAC 管真实性、BCrypt 管密码。

阶段二收官。从下一章开始进入企业级开发:先从「工程怎么组织」——Maven 多模块说起。


七、动手练习

基础题

  1. 写一个 ExpireChecker:输入下单时间,判断是否超过 15 分钟;用固定 Clock 写测试覆盖「正好 15 分钟」「差 1 秒」两个边界。
  2. 用 BigDecimal 实现账单分摊:总额 100.00 元分给 3 人(每人金额保留 2 位小数,总和必须精确等于 100.00)。提示:前 n-1 人截断,最后一人拿余数。

进阶题

  1. 给 OrderNoGenerator 写并发测试:100 线程各生成 100 个号,断言无重复;模拟跨日(反射改 day 字段)验证序列重置。
  2. 实现支付回调的「完整验签流程」:构造 payload(orderId=1&amount=3998&status=SUCCESS)+ 密钥签名 → 作为「网关」发给自己写的回调处理方法 → 验签通过才处理;再篡改 amount 重放,验证被拒。

思考题

  1. OrderNoGenerator 的序列在单实例内唯一,但部署两个实例后会出现重复。结合第 12 章 CAS 与第 24 章 Redis(INCR 原子自增)思考:分布式发号有哪些方案(数据库号段、Redis 自增、雪花算法 UUID)?各自优缺点是什么?先给结论,第 34 章生产化时对照你的答案。

📄 第 17 章 工程构建与依赖管理:Maven 多模块 17-maven-and-build.md

阶段:阶段 3 · 企业级开发 | 预计阅读 65 min | 代码落地模块:sample/(父 POM 全解) 前置:第 00 章(黑盒用过 Maven)、第 05 章 | 相关章节:第 29 章 CI | 附录 90 实战落点:sample/pom.xml 的版本收口设计;mini-shop 三模块的依赖关系

学习目标

读完本章,你应该能够:

  • [ ] 读懂 POM 的坐标体系与依赖范围,说清 compile/provided/test/runtime 的区别。
  • [ ] 掌握依赖调解规则(最短路径优先、先声明优先),会排查版本冲突。
  • [ ] 用 dependencyManagement 把版本收口到父 POM,子模块不写版本号。
  • [ ] 组织一个多模块工程并解释模块间依赖方向。
  • [ ] 会用 mvn dependency:tree 与国内镜像解决 90% 的「依赖问题」。

一、核心概念

1.1 坐标与仓库

Maven 世界里每个构件由三元组唯一定位:groupId:artifactId:version(加 packaging 与 classifier 扩展)。构建即「按坐标从仓库取依赖 + 按生命周期编译打包」:

本地仓库 ~/.m2/repository(缓存)
   ↑ 下载
远程仓库:中央仓库(Maven Central)/ 镜像(阿里云)/ 私服(企业 Nexus,进阶)

1.2 POM 与依赖范围

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>6.0.1</version>
    <scope>test</scope>          <!-- 只参与测试编译与运行,不进最终产物 -->
</dependency>
scope 编译 测试 运行 打进包 典型
compile(默认) ✅ ✅ ✅ ✅ 业务依赖
provided ✅ ✅ ❌ ❌ Lombok(编译期)、容器已提供的 API
runtime ❌ ✅ ✅ ✅ JDBC 驱动(代码只碰接口)
test ❌ ✅ ❌ ❌ JUnit、Mockito

1.3 传递依赖(Transitive Dependency)与依赖调解(Dependency Mediation)

你依赖 A,A 依赖 B——B 是传递依赖(transitive dependency),自动进入你的依赖树。当两条路径引来不同版本的 B 时,Maven 依赖调解(dependency mediation)规则:

  1. 最短路径优先:直接依赖 B:2.0 与 A→B:1.0 冲突时,取 B:2.0;
  2. 路径等长时,先声明者优先(POM 中靠上的赢)。

冲突的症状千奇百怪(NoSuchMethodError、ClassNotFoundException),根源都是「运行时加载的版本与编译时假设的不一致」。排查一招定乾坤:

mvn dependency:tree -Dincludes=com.google.protobuf     # 看 protobuf 从哪些路径被引入、各是什么版本

[!TIP] dependency:tree 全量输出太长,加 -Dincludes=组:构件 过滤;怀疑冲突时再配合 mvn dependency:analyze 找「声明了没用 / 用了没声明」的依赖。

1.4 版本收口:dependencyManagement

多模块工程最忌「每个模块各写一套版本」。标准做法:父 POM 的 <dependencyManagement> 声明版本,子模块引用时不写版本:

<!-- sample/pom.xml(节选):全工程版本的唯一权威来源,与附录 90 同源 -->
<properties>
    <junit.version>6.0.1</junit.version>
    <jackson.version>3.0.0</jackson.version>
</properties>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${junit.version}</version>
        </dependency>
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>mini-shop-common</artifactId>
            <version>${project.version}</version>   <!-- 内部模块也收口 -->
        </dependency>
    </dependencies>
</dependencyManagement>
<!-- 子模块:只写坐标不写版本,升级改一处全局生效 -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

另一种收口:导入 BOM(Bill of Materials)。Spring Boot 把上百个依赖的兼容版本做成了 BOM,继承 spring-boot-starter-parent 或导入 spring-boot-dependencies 后,绝大多数依赖免写版本——第 18 章实战。

1.5 生命周期与常用命令

Maven 三套生命周期(clean / default / site),default 主线的关键站:

validate → compile → test → package → verify → install → deploy
   校验      编译      测试    打包(jar)  集成校验   装入本地仓库  发布到远程

后面的阶段自动执行它之前的所有阶段——mvn package 必然先 compile 再 test(测试不过打包失败,这是 CI 门禁的基础,第 29 章)。

命令 用途
mvn clean test 清理 + 编译 + 单测(日常最高频)
mvn clean package -DskipTests 打包跳过测试(本地快速出包,CI 里禁用)
mvn clean install 打包并装入本地仓库(供其他本地工程依赖)
mvn test -pl mini-shop-se -am 只构建指定模块(-am 连带其依赖模块)
mvn dependency:tree 依赖树排障

二、示例代码:mini-shop 多模块全解

sample/
├─ pom.xml                    # 父 POM:packaging=pom,版本收口 + 插件管理
├─ mini-shop-common/          # 通用件:被下面两个模块依赖(只向下依赖,绝不向上)
├─ mini-shop-se/              # 依赖 common;零中间件
└─ mini-shop-boot/            # 依赖 common;Spring Boot 全家桶(33 章展开)
<!-- sample/mini-shop-boot/pom.xml(节选,完整版见工程):
     通过 BOM 导入 Spring Boot 全套版本管理,而不是继承 parent——
     多模块工程里父 POM 已被占用,导入 BOM 是标准替代方案 -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>4.1.1</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>com.example</groupId>
        <artifactId>mini-shop-common</artifactId>   <!-- 版本来自父 POM 收口 -->
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>   <!-- 版本来自 BOM,不写 -->
    </dependency>
</dependencies>

模块依赖方向铁律:common ← se/boot,单向。任何「se 依赖 boot」的环状依赖都是架构警报(第 20 章分层依赖同理:上层可依赖下层,反向禁止)。

[!WARNING] Boot 4 项目的 MyBatis-Plus 坐标必须用 mybatis-plus-spring-boot4-starter(3.5.15+ 才兼容),分页还要配 mybatis-plus-jsqlparser。版本与坐标对照表见附录 90「Starter 坐标陷阱」。


三、常见坑 ⚠️

  • ⚠️ 子模块私自写版本号:收口失效,冲突温床。Code Review 见版本号直接问「为什么绕过父 POM」。
  • ⚠️ -DskipTests 进了 CI:测试形同虚设。CI 只允许完整 mvn verify(第 29 章门禁)。
  • ⚠️ mirrorOf 写 *:劫持所有仓库(含 Spring/公司私服),某些依赖 404(第 00 章已踩过)。
  • ⚠️ 快照版本(SNAPSHOT)上生产:SNAPSHOT 每次构建可能变化,产物不可复现。生产只用 release 版。
  • ⚠️ 两个依赖不同版本的同名类:NoSuchMethodError。先 dependency:tree -Dincludes=...,再 dependencyManagement 强制收敛或 <exclusions> 排除。
  • ⚠️ 在 IDE 里改了 pom 不同步:IDEA 右上角点 Maven 刷新图标(或 Ctrl+Shift+O)重新导入。

四、调试技巧

  • mvn -X clean compile:debug 日志看插件执行与仓库解析细节,解决「为什么没下载/下载了哪个」。
  • mvn help:effective-pom:查看合并后生效的最终 POM(继承 + 管理全展开),理解配置来源。
  • 下载损坏(.lastUpdated 文件残留):删掉本地仓库对应目录重下,或 mvn -U 强制刷新。

五、最佳实践

  • 版本只出现在父 POM 的 <properties> + <dependencyManagement>(与附录 90 同源),全局 grep 版本号只应命中一处。
  • 模块划分按「复用与部署边界」:common/se/boot 各自可独立构建,模块间编译期即可发现问题。
  • 依赖引入前问三句:JDK 能不能做?项目里已有的能不能做?必须引的话选最轻的坐标。
  • 本地验证顺序:mvn clean test → mvn clean package → 才有资格谈部署。

六、本章小结

  • 坐标定位构件;scope 控制依赖参与阶段;传递依赖靠「最短路径 + 先声明」调解。
  • dependencyManagement 收口版本 + BOM 导入生态版本,是治理依赖树的两板斧。
  • 生命周期链式执行是 CI 门禁的基石;-pl -am 支撑多模块增量构建。
  • mini-shop 三模块单向依赖:common ← se/boot。

下一章进入 Spring 的心脏:IoC 容器——为什么企业代码不再自己 new 对象。


七、动手练习

基础题

  1. 对 sample 执行 mvn clean test,观察生命周期输出(各项 [INFO] --- maven-xxx-plugin 的执行顺序);再用 -pl mini-shop-common 只构建通用模块。
  2. 在 mini-shop-se 里故意把 Jackson 版本改成 99.99,跑构建观察报错;恢复后执行 mvn dependency:tree | grep jackson,确认实际解析版本与父 POM 一致。

进阶题

  1. 制造一次依赖冲突:在 mini-shop-se 同时引入两个(你找得到的)同组不同版本传递依赖的库,用 dependency:tree 找出 Maven 选了哪个版本;再用 dependencyManagement 强制指定你想要的版本,验证树被收敛。
  2. 给 sample 增加 mini-shop-tools 模块(只依赖 common),放一个字符串工具类;在 se 模块引用它,体会「新增模块只需三步:建目录、写 pom、父 POM 加 module」。

思考题

  1. 为什么「传递依赖自动可用」是双刃剑?设想 A 库升级后把它内部用的 B 从 1.0 换到 2.0(API 不兼容),你的代码会发生什么?由此解释为什么企业规范要求「直接使用的依赖必须显式声明,哪怕它是传递依赖」。

📄 第 18 章 Spring Boot 与 IoC/DI:自动配置、Bean 与依赖注入 18-spring-boot-and-ioc.md

阶段:阶段 3 · 企业级开发 | 预计阅读 85 min | 代码落地模块:sample/mini-shop-boot(启动与装配) 前置:第 09 章 反射与注解、第 17 章 Maven | 相关章节:第 19 章 AOP 实战落点:mini-shop-boot 的启动类、依赖注入改造——OrderService 从手工 new 到容器接管

学习目标

读完本章,你应该能够:

  • [ ] 创建 Spring Boot 4 工程并跑通第一个接口,理解「约定大于配置」。
  • [ ] 解释 IoC 与 DI:对象从哪来、依赖谁来给、为什么这样更好测。
  • [ ] 熟练使用组件扫描与三类注入姿势,遵守「构造器注入」军规。
  • [ ] 理解 Bean 作用域与生命周期,会用条件装配与环境隔离。
  • [ ] 能定位「Bean 找不到 / 循环依赖」两类高频启动失败。

一、核心概念

1.1 从手工 new 到容器接管

第 09 章的 MiniContainer 已经预演了 IoC(Inversion of Control,控制反转):对象的创建与装配权从业务代码反转给容器。Spring 的版本叫 IoC 容器,装配手段叫 DI(Dependency Injection,依赖注入)。

// 手工时代(31 章):组装链是业务代码的责任,写死在 main 里
ProductRepository repo = new InMemoryProductRepository();
OrderService service = new OrderService(repo, new InMemoryOrderRepository());

// Spring 时代:只声明「我需要什么」,容器负责创建与注入
@Service
public class OrderService {
    private final ProductRepository productRepository;   // 接口类型
    private final OrderRepository orderRepository;

    public OrderService(ProductRepository productRepository, OrderRepository orderRepository) {
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
    }
}

两大收益:

  1. 实现可替换:把内存实现换成 MySQL 实现,业务类零改动(第 02 章「面向接口」的容器化兑现);
  2. 可测试性:测试里注入一个假实现(Mock),不连数据库也能测 OrderService(第 28 章)。

1.2 Boot 工程:三件套起步

// sample/mini-shop-boot/src/main/java/com/example/minishop/MiniShopApplication.java
package com.example.minishop;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication   // = @Configuration + @ComponentScan + @EnableAutoConfiguration
@ConfigurationPropertiesScan   // 启用 @ConfigurationProperties 绑定类(第 19 章)
@EnableAsync                   // 允许 @Async 事件监听器(19 章练习方向)
public class MiniShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(MiniShopApplication.class, args);
    }
}

组合注解拆开看:

  • @ComponentScan:扫描本类所在包及子包,把带 @Component/@Service/@Repository/@Controller 的类注册为 Bean——启动类必须放在根包,否则扫不到;
  • @EnableAutoConfiguration:按 classpath 自动装配(检测到 Web 就配 Tomcat + MVC,检测到 MySQL 驱动就配数据源)——「约定大于配置」的核心。
老教程对照:javax 与 Boot 2

Boot 3 起命名空间全面 jakarta.*(jakarta.servlet、jakarta.validation)。网上大量教程写 javax.servlet.http.HttpServletRequest,在 Boot 4 下直接编译失败。看到 javax.* 一律脑内翻译为 jakarta.*。

1.3 Bean 注册的四种姿势

方式 写法 适用
组件扫描 类上 @Service / @Component / @Repository / @Controller 自己写的业务类(默认首选)
配置类方法 @Configuration 类里 @Bean 方法 第三方库的类(改不了源码,如 RestClient、RedissonClient)
自动配置 starter 内置 @AutoConfiguration 框架集成(引依赖即生效)
手工注册 ApplicationContext.registerBean(...) 极少数动态场景
// sample/mini-shop-boot/src/main/java/com/example/minishop/config/AppConfig.java(节选)
@Configuration
public class AppConfig {

    /** 第三方对象用 @Bean 方法注册:方法名默认就是 Bean 名称 */
    @Bean
    public Clock clock() {
        return Clock.system(ZoneId.of("Asia/Shanghai"));   // 业务类注入 Clock 取时间(16 章可测试性)
    }
}

1.4 注入姿势:构造器注入是唯一推荐

// ✅ 构造器注入(唯一推荐):字段 final 不可变、依赖显式、漏注入直接启动失败
@Service
public class OrderService {
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;

    public OrderService(ProductRepository productRepository, OrderRepository orderRepository) {
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
    }
}

// ❌ 字段注入:隐藏依赖、无法 final、测试要靠反射塞值——老教程大量使用,新代码禁用
// @Autowired private ProductRepository productRepository;

单构造器时 Spring 自动注入,连 @Autowired 都不用写。需要「多个实现选一个」时配合限定:

// OrderRepository 有两个实现:mysql(默认)与 memory(压测用)
public OrderService(ProductRepository repo,
                    @Qualifier("mysqlOrderRepository") OrderRepository orderRepository) { ... }

// 更优雅的按配置切换:@ConditionalOnProperty(见 1.6)

1.5 作用域与生命周期

作用域:默认 singleton(全容器一个实例,必须无状态或线程安全——第 11 章的伏笔在这里兑现:Service 里绝不放可变请求状态,要放用 ThreadLocal 或参数传递);prototype(每次注入新建);Web 场景另有 request/session。

生命周期关键回调:

@Component
public class CacheWarmer implements InitializingBean, DisposableBean {
    @Override
    public void afterPropertiesSet() {   // 属性注入完毕:预热缓存(34 章库存预热落点)
        // load hot products into cache
    }
    @Override
    public void destroy() {              // 容器关闭:释放资源
        // flush, close connections
    }
}

[!TIP] 优先用更轻的 @PostConstruct / @PreDestroy 注解(jakarta 包),不必实现接口。初始化回调里做「启动预热」是常见套路,但要控制耗时——拖慢启动的预热该异步化(第 13 章)。需要命令行参数感知或依赖多个 Bean 协作完成的启动任务(如种子数据 + 缓存预热),用 ApplicationRunner 更合适:容器就绪后回调 run(ApplicationArguments),sample 的种子数据初始化就是它(第 24/34 章 DataInitializer)。

1.6 条件装配与多环境

@Bean
@ConditionalOnProperty(name = "shop.stock-mode", havingValue = "redis")
public StockService redisStockService() { return new RedisStockService(...); }   // 配置开关决定装配

顺带一提 @ConditionalOnMissingBean:它的语义是「容器里没有该类型 Bean 时才装配」,只有在框架的 auto-configuration 类里才有保障(auto-configuration 永远晚于用户配置处理,天然让用户自定义优先);写在自己的普通 @Configuration 类里则依赖 Bean 注册顺序,结果不可预测——用户配置想覆盖默认值时,优先用 @ConditionalOnProperty 这类显式开关。

环境隔离:mini-shop-boot 实际只维护一份 application.yml,环境差异(数据库地址、密钥)全部通过环境变量注入(${MYSQL_HOST:localhost} 这类占位符),docker compose 启动时统一带入——不另设 dev/prod profile 文件,环境快照集中在部署侧(第 29 章)。需要更复杂的分环境覆盖时,才引入 application-{profile}.yml + --spring.profiles.active=prod。


二、示例代码:启动 Boot 版 mini-shop

// sample/mini-shop-boot/src/main/java/com/example/minishop/controller/ProductController.java
package com.example.minishop.controller;

import com.example.minishop.common.api.Result;
import com.example.minishop.service.ProductService;
import org.springframework.web.bind.annotation.*;

/** 第一个接口:依赖注入让 Controller 只管协议转换,业务在 Service(20 章展开分层) */
@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {   // 构造器注入
        this.productService = productService;
    }

    @GetMapping("/{id}")
    public Result<?> get(@PathVariable Long id) {
        return Result.ok(productService.getById(id));
    }
}

启动与验证:

docker compose up -d                       # 第 00 章的中间件
mvn -pl mini-shop-boot spring-boot:run     # 默认 8080

curl http://localhost:8080/api/products/1
# {"code":0,"message":"ok","data":{...}}

启动日志值得逐行读:Boot 会打印自动配置报告(--debug 参数可看正/负匹配),你能看到「哪些配置类生效了、为什么」——把自动配置从魔法变成清单。


三、常见坑 ⚠️

  • ⚠️ 启动类不在根包:组件扫描扫不到业务类,报 NoSuchBeanDefinitionException。启动类永远放最上层包。
  • ⚠️ 字段注入成瘾:测试困难 + 隐藏依赖。全工程 grep @Autowired 出现在字段上即整改。
  • ⚠️ Service 里存可变状态:单例 Bean 被所有请求共享,可变字段=串数据。状态进方法参数、数据库或 ThreadLocal(必须清理)。
  • ⚠️ 循环依赖:A 注 B、B 注 A。报 BeanCurrentlyInCreationException。正解是重构(抽出共同的 C,或改事件/接口解耦),@Lazy 只是掩盖问题的绷带。
  • ⚠️ @Component 扫到了不该管的类:工具类、常量类不要注册成 Bean;Bean 是「有依赖、有生命周期」的对象。
  • ⚠️ MyBatis-Plus Starter 用错大版本:Boot 4 配 mybatis-plus-spring-boot4-starter,用 3 版 starter 报 Mapper 找不到(附录 90)。

四、调试技巧

  • 启动失败先看第一行 Caused by,再看 Boot 的 APPLICATION FAILED TO START 报告——它通常直接给出动作建议(缺配置项、端口占用、Bean 缺失)。
  • --debug 启动看自动配置正负报告:Positive matches 生效、Negative matches 未生效及原因。
  • IDEA 的 Spring 插件:Ctrl+点击 Bean 接口直接跳到实现类;Bean 依赖图(Spring 面板)可视化装配关系。
  • 运行期 introspection:/actuator/beans(第 27 章开启)列出全部 Bean 与依赖来源。

五、最佳实践

  • 构造器注入 + 接口类型 + final 字段,三件套固化成肌肉记忆。
  • 第三方对象一律 @Configuration + @Bean 收口在 config 包,禁止散落 new。
  • 配置项集中 application.yml 并配 @ConfigurationProperties(第 19 章),禁止硬编码。
  • 单元测试不启动 Spring(第 28 章)——IoC 的终极收益就是「不启容器也能 new 出被测对象」。

六、本章小结

  • IoC 把创建与装配权交给容器;DI 的正解是构造器注入:不可变、显式、早失败。
  • @SpringBootApplication 三合一;启动类放根包;starter 自动配置按 classpath 生效。
  • Bean 默认单例,Service 必须无状态;生命周期回调做启动/清理动作。
  • 多实现用 @Qualifier/条件装配切换;多环境用 profile。
  • 两类高频故障:扫描不到(包位置)、循环依赖(该重构了)。

下一章 AOP:@Transactional、统一日志这些「方法前后插逻辑」是怎么无侵入实现的。


七、动手练习

基础题

  1. 跑通 mini-shop-boot 的 /api/products/1;把启动类临时移到 com.example 包外的子包,重启观察 NoSuchBeanDefinitionException,体会扫描规则。
  2. 给 OrderService 加一个 Clock 依赖(第 1.3 节的 Bean),在日志里打印「下单时间」;写一个不启动 Spring 的测试:直接 new OrderService(fakeRepo, fakeOrderRepo, Clock.fixed(...)),验证注入架构的可测试性。

进阶题

  1. 实现 OrderRepository 的内存版与 MySQL 版两个 Bean(内存版 @ConditionalOnProperty(name="shop.order-repo", havingValue="memory")),用配置切换并各自验证查询接口。
  2. 人为制造循环依赖(A 的构造器注入 B、B 注入 A),记录完整报错;然后用「抽接口 C」方式重构解环,写一段注释说明为什么 @Lazy 不是好答案。

思考题

  1. 第 09 章的 MiniContainer 没有「三级缓存」也工作正常,但 Spring 需要它解决带 AOP 代理的循环依赖(先暴露半成品引用,代理生成后再替换)。结合第 09 章动态代理知识推理:为什么「先 new 再生成代理」会带来顺序问题?这道题在第 19 章会有呼应。

📄 第 19 章 AOP、配置绑定与 Spring 常用机制 19-spring-aop-and-config.md

阶段:阶段 3 · 企业级开发 | 预计阅读 70 min | 代码落地模块:sample/mini-shop-boot(aspect/config 包) 前置:第 18 章 IoC/DI、第 09 章 动态代理 | 相关章节:第 22 章 事务 | 第 27 章 日志 实战落点:配置绑定与启动期校验、Spring 事件机制——33/34 章直接使用

学习目标

读完本章,你应该能够:

  • [ ] 解释 AOP 与动态代理的关系,看懂切面五要素(切点/通知/连接点…)。
  • [ ] 写一个统一耗时/日志切面,并知道它的两个失效场景。
  • [ ] 用 @ConfigurationProperties 做类型安全的配置绑定,配置校验齐全。
  • [ ] 用 Spring 事件做模块间解耦(如「下单成功 → 发通知」)。
  • [ ] 读懂 @Transactional 失效的一半原因(另一半在 22 章)。

一、核心概念

1.1 AOP:横切关注点的分离

日志、耗时统计、鉴权、事务……这些逻辑散落在每个方法里就是灾难。**AOP(Aspect-Oriented Programming,面向切面编程)**把它们抽成「切面」,声明式地织入目标方法——实现引擎就是第 09 章的动态代理:你调用的 Bean 是代理对象,通知逻辑跑在真实方法前后。

五要素一张表:

概念 一句话 本例
连接点(JoinPoint) 可被拦截的点(Spring 里=方法执行) OrderService.createOrder
切点(Pointcut) 表达式筛选哪些连接点 execution(* com.example..service..*.*(..))
通知(Advice) 织入的逻辑与时机 @Around 环绕计时
切面(Aspect) 切点 + 通知的组合 TimingAspect
织入(Weaving) 生成代理把两者接起来 Spring 启动时自动完成

1.2 环绕通知:最常用的切面

下面的 TimingAspect 是本章练习产物(第 35 章后可按此自行落地),sample 工程未收录——耗时统计在 sample 里由 OrderMetrics(Micrometer,第 27 章)承担,无需切面。

// 本章练习产物(sample 未收录;建议落点 mini-shop-boot 的 aspect 包)
package com.example.minishop.aspect;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

/** service 层统一耗时切面:一次编写,全层生效(27 章 traceId 日志的地基) */
@Aspect
@Component
public class TimingAspect {

    private static final Logger log = LoggerFactory.getLogger(TimingAspect.class);

    @Around("execution(* com.example.minishop.service..*(..))")   // service 包及子包全部方法
    public Object time(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.nanoTime();
        try {
            return pjp.proceed();                    // 放行到真实方法
        } finally {
            long ms = (System.nanoTime() - start) / 1_000_000;
            if (ms > 200) {
                // 慢调用单独告警级别:比混在 info 里醒目(27 章日志分级)
                log.warn("[耗时] {}#{} {}ms", pjp.getSignature().getDeclaringType().getSimpleName(),
                        pjp.getSignature().getName(), ms);
            } else {
                log.info("[耗时] {}#{} {}ms", pjp.getSignature().getDeclaringType().getSimpleName(),
                        pjp.getSignature().getName(), ms);
            }
        }
    }
}

其他通知类型速记:@Before(前置)、@AfterReturning(正常返回后)、@AfterThrowing(异常后)、@After(finally 语义)。能写 @Around 的场景都推荐 @Around(控制权最完整),其余用于只读观察。

1.3 切面失效的两个场景(必考)

  1. 同类内部调用:this.otherMethod() 绕过代理直接跑真实方法——第 09 章思考题的答案。切面、事务、缓存注解同理失效。解法:拆类、自注入代理、或 AopContext(不推荐)。
  2. 方法不是 public / 被 final:Spring AOP 默认只拦 public;final 方法无法被代理覆盖。

1.4 配置绑定 @ConfigurationProperties

散落的 @Value("${xxx}") 难以管理与校验,批量配置用绑定类:

// sample/mini-shop-boot/src/main/java/com/example/minishop/config/ShopProperties.java
package com.example.minishop.config;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

import java.time.Duration;

/** shop.* 前缀配置的类型安全绑定(第 19 章):类型安全 + 启动期校验 */
@Validated
@ConfigurationProperties(prefix = "shop")
public record ShopProperties(
        @Min(1) int orderTimeoutMinutes,       // shop.order-timeout-minutes,校验 ≥1(分钟数,15 = 一刻钟)
        @Min(1) @Max(64) int stockSegments,    // shop.stock-segments,库存分段数
        Jwt jwt                                // 嵌套绑定 shop.jwt.*
) {
    /** 嵌套绑定 shop.jwt.*:secret 与 ttl 都必须显式配置,缺一个启动期就报错 */
    public record Jwt(String secret, Duration ttl) {
        public Jwt {
            if (secret == null || secret.length() < 32) {
                throw new IllegalArgumentException("shop.jwt.secret 必须至少 32 字符");
            }
            if (ttl == null || ttl.isNegative() || ttl.isZero()) {
                throw new IllegalArgumentException("shop.jwt.ttl 必须为正时长,如 2h");
            }
        }
    }
}
# sample/mini-shop-boot/src/main/resources/application.yml(节选)
shop:
  order-timeout-minutes: 15
  stock-segments: 10
  jwt:
    secret: ${JWT_SECRET}          # 生产必须环境变量注入,至少 32 字符(无默认值:忘了配启动即失败)
    ttl: 2h
// 启用绑定(任选其一):启动类 @EnableConfigurationProperties(ShopProperties.class),或配置类 @ConfigurationPropertiesScan
// 使用处正常构造器注入 ShopProperties 即可

收益链:类型安全(启动期绑定失败即报错)→ 校验注解兜住非法值 → 测试直接 new 一个记录类注入 → 配置项在 IDE 里可跳转。@Value 只留给零散的单值。

1.5 事件机制:模块间解耦

「下单成功后要发通知、记积分、清购物车」——直接在 createOrder 里调三个服务,耦合爆炸。Spring 事件把「发布」与「订阅」拆开:

// 事件:纯数据载体(record)
public record OrderCreatedEvent(Long orderId, Long userId, long payableInCents) { }

// 发布方:只管喊一嗓子,不知道谁在听
@Service
public class OrderService {
    private final ApplicationEventPublisher publisher;
    // ...
    public Order createOrder(...) {
        Order order = /* 下单 */;
        publisher.publishEvent(new OrderCreatedEvent(order.getId(), userId, payable));
        return order;
    }
}

// 订阅方:各自独立演进(34 章的 MQ 异步化是本机制的「跨进程版」)
@Component
public class OrderNotifyListener {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)   // 主事务提交后才执行
    public void on(OrderCreatedEvent event) {
        // 发通知 / 记积分:失败不影响订单主流程
    }
}

[!TIP] @TransactionalEventListener(AFTER_COMMIT) 是关键细节:普通 @EventListener 在事务提交前执行,若监听器又去查库可能读到未提交状态。事件的默认语义是同步的(发布者线程内执行),要异步加 @Async(需先开启 @EnableAsync 并配独立线程池——第 13 章纪律)。


二、示例代码:自定义注解 + 切面(防重提交)

把第 09 章「注解 + 反射解析」升级为「注解 + AOP」的工程版——mini-shop 防重提交开关。⚠️ 下面两个类是本章练习产物(第 35 章后可自行落地),sample 工程未收录——生产版的防重提交在第 34 章走的是 msgId 去重表方案(无注解切面),本节的价值是掌握「注解 + 切面」这套无侵入机制本身。

// 本章练习产物(sample 未收录;建议落点 mini-shop-boot 的 aspect 包)
package com.example.minishop.aspect;

import java.lang.annotation.*;

/** 标记需要防重复提交的接口(34 章实际落地的是 msgId 去重表方案,无注解切面) */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Idempotent {
    int windowMillis() default 1000;   // 防重窗口
}
// 本章练习产物(sample 未收录)
package com.example.minishop.aspect;

import com.example.minishop.common.exception.BizException;
import com.example.minishop.common.exception.ErrorCode;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Aspect
@Component
public class IdempotentAspect {

    /** ⚠️ 教学内存版:单实例有效。多实例/分布式场景必须换 Redis(34 章),原理不变 */
    private final Map<String, Long> lastSeen = new ConcurrentHashMap<>();

    @Around("@annotation(idempotent)")   // 切点:凡标注了 @Idempotent 的方法,注解实例自动绑定
    public Object guard(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
        String key = pjp.getSignature().toShortString();   // 生产应拼入用户id+参数摘要
        long now = System.currentTimeMillis();
        Long last = lastSeen.get(key);
        if (last != null && now - last < idempotent.windowMillis()) {
            throw new BizException(ErrorCode.IDEMPOTENT_REPLAY);
        }
        lastSeen.put(key, now);
        return pjp.proceed();
    }
}

对比第 09 章手工反射版:注解声明不变,解析与织入交给 AOP——业务方法上贴 @Idempotent 即生效,零侵入。


三、常见坑 ⚠️

  • ⚠️ 同类调用失效:见 1.3。症状「切面/事务/缓存注解时灵时不灵」,先查是不是 this.xxx() 调用。
  • ⚠️ 切点表达式过宽:execution(* com.example..*.*(..)) 把 Controller、工具类全拦,性能与日志噪音双杀。切面瞄准层(service)或注解。
  • ⚠️ 切面里吞异常:@Around 不重抛异常,上层以为成功。proceed() 的异常必须原样抛出(或包装后抛)。
  • ⚠️ @ConfigurationProperties 忘了启用:绑定类不被注册,注入报找不到 Bean。启动类加 @ConfigurationPropertiesScan。
  • ⚠️ 配置错误到运行期才炸:没用 @Validated 校验,端口/超时写成负数跑半天才发现。绑定类必须校验。
  • ⚠️ 事件监听器做重活:默认同步执行,监听器慢 = 发布者慢。重活标注 @Async 或干脆走 MQ(34 章)。

四、调试技巧

  • 切面没生效:启动日志搜 TimingAspect(确认 Bean 建了)→ 查调用是否 this. 内部调用 → 查方法可见性。
  • IDEA 断点打在 pjp.proceed(),调试面板看 pjp.getSignature() 与参数;Step Into 进的是代理类(CglibAopProxy 字样)说明织入成功。
  • 配置不生效:/actuator/configprops(第 27 章开启)直接看每个绑定类的最终生效值。

五、最佳实践

  • 切面只做「横切技术关注点」(日志/耗时/鉴权/限流),业务规则写在业务代码里——切面里的业务分支是维护噩梦。
  • 配置绑定类按业务域拆分(Shop/Pay/Mq),全项目搜配置项只看两类地方:yml 与绑定类。
  • 事件用于「同进程内的松耦合通知」;跨服务、需要可靠投递的一律 MQ(25 章)。
  • 每个切面写一条集成测试(拦截 + 不拦截两类目标方法)。

六、本章小结

  • AOP = 动态代理的工程化:切点选目标、通知放逻辑,横切关注点与业务正交分离。
  • 失效两大坑:同类内部调用、非 public/final 方法。
  • @ConfigurationProperties + 校验 = 类型安全配置;@Value 只配零散值。
  • 事件解耦同进程通知,AFTER_COMMIT 保时序正确,重活异步化。

下一章正式进入数据层:建表规范、MyBatis-Plus 与 MySQL 的索引与慢查询。


七、动手练习

基础题

  1. 把 TimingAspect 跑起来,压一压下单接口,找一条 WARN 慢日志;然后把阈值改成 100ms 重启验证。
  2. 给 ShopProperties 增加 shop.stock-warn-threshold(校验 ≥1),在 application.yml 配置并在启动日志打印生效值。

进阶题

  1. 实现一个 @Audit 注解 + 切面(第 09 章练习 2 的 AOP 版):记录操作人(从 ThreadLocal 取,没有就记 anonymous)、方法名、参数摘要,输出结构化日志。
  2. 用事件机制实现「订单关闭 → 回补库存」:OrderService.close() 发布 OrderClosedEvent,新监听器负责库存回补;写测试验证「主流程失败时监听器不执行」(AFTER_COMMIT 语义)。

思考题

  1. IdempotentAspect 的内存 Map 有四个硬伤:多实例失效、不持久化、get 与 put 非原子(两个请求同时通过 last == null 检查,防重窗口形同虚设)、无清理导致膨胀。逐条给出 Redis 方案(SET key value NX EX ttl 的原子性恰好根治第三条)如何解决——这道题是第 24/34 章防重提交的完整预告。

📄 第 20 章 分层架构与代码规范 20-layering-and-code-style.md

阶段:阶段 3 · 企业级开发 | 预计阅读 55 min | 代码落地模块:sample/mini-shop-boot(目录与命名规约) 前置:第 18 章、第 19 章 | 相关章节:第 21 章 数据库访问 | 第 33 章 实战落点:mini-shop-boot 的包结构与 DTO/VO 规约,33–35 章逐层填充

学习目标

读完本章,你应该能够:

  • [ ] 画出经典分层(Controller/Service/Manager/Mapper)并说清各层职责与禁令。
  • [ ] 区分 Entity/DTO/VO 三类对象,解释「为什么不能把实体直接返回给前端」。
  • [ ] 遵守团队级命名与包组织规约。
  • [ ] 解释「分层不是越多越好」:Manager 层什么时候该出现。

一、核心概念

1.1 分层架构总图

flowchart TB
    C["Controller 接入层<br/>协议转换 / 参数校验 / 组装响应"]
    S["Service 业务层<br/>业务规则 / 事务边界"]
    M["Manager 通用业务层<br/>跨域编排 / 第三方封装 / 缓存+DB 组合"]
    D["Mapper 数据层<br/>表级 CRUD(MyBatis-Plus)"]
    DB[("MySQL / Redis")]
    C --> S --> M --> D --> DB
    S -.允许直连简单场景.-> D

1.2 各层职责与禁令

层 职责 禁令
Controller 参数校验、调用 Service、包统一响应、异常翻译(23 章) ❌ 不写业务判断、不碰 Mapper
Service 业务规则、状态机流转、事务边界(22 章) ❌ 不解析 HTTP 细节、不做 SQL 调优式组合查询
Manager(可选) 组合多个 Mapper/外部服务;封装 Redis+DB 的复合逻辑;第三方 SDK 适配 ❌ 不承载核心业务规则(那是 Service 的)
Mapper 单表 CRUD、SQL 映射 ❌ 不出现业务语义(Mapper 不叫 closeExpiredOrders,那属于 Service 编排)
Entity/Domain 表映射对象(Mapper 层)、领域对象(Service 层) ❌ 不跨层直传

两条判断题帮你定职责:

  • 这段逻辑换一种协议(HTTP → MQ 消费)还成立吗?成立 → Service/Manager;不成立 → Controller。
  • 这段逻辑换一种存储还成立吗?成立 → Service;不成立(如「先查缓存再查库」)→ Manager(34 章的 StockManager 就是活例)。

1.3 三类对象:Entity / DTO / VO

对象 生命周期边界 例子
Entity Mapper ↔ DB OrderEntity(与 order 表一一对应)
DTO(Data Transfer Object) 外部 → 内部(入参) CreateOrderDTO(商品列表、数量)
VO(View Object) 内部 → 外部(出参) OrderVO(订单号、状态描述、金额元字符串)

为什么不能拿 Entity 直接进出接口:

  1. 安全:实体含 passwordHash、deleted、version 等内部字段,序列化即泄漏;
  2. 耦合:表改一列,接口契约跟着变,前端全崩;
  3. 语义:前端要「元」后端存「分」、要状态文案不要状态码——转换层就是翻译层。
// 入参 DTO:只含客户端该填的字段 + 校验注解(23 章展开校验细节)
// ⚠️ 故意没有 userId:下单人身份从 JWT 令牌取(UserContext.require),
// 客户端传什么都不算数——否则任何人都能替别人下单(26 章水平越权)
public record CreateOrderDTO(
        @NotEmpty @Valid List<Item> items) {
    public record Item(@NotNull Long productId, @Min(1) @Max(99) int quantity) { }
}

// 出参 VO:金额转元字符串、状态给文案
public record OrderVO(String orderNo, String statusDesc, String payableYuan, String createdAt) {
    public static OrderVO from(OrderEntity e) {          // 转换器与 VO 同文件,就近维护
        return new OrderVO(e.getOrderNo(),
                OrderStatus.valueOf(e.getStatus()).getDesc(),
                Money.ofCents(e.getPayableCents()).toString(),
                e.getCreatedAt().format(FMT));
    }
}

字段少时手写 converter 即可;字段多(10+)考虑 MapStruct(编译期生成,零反射开销),不要用 BeanUtils 浅拷贝——字段改名编译器不报错,是隐形炸弹。

1.4 包组织:按层还是按功能?

按层分包(本书采用,中小项目友好)     按功能分包(大项目/微服务常用)
com.example.minishop                  com.example.minishop
├─ controller/                        ├─ order/
├─ service/                           │  ├─ controller/ service/ mapper/
├─ manager/                           ├─ product/
├─ mapper/                            └─ payment/
├─ domain/ (entity/dto/vo/enums)
├─ infra/ (redis/mq/security)
└─ config/

两种都对,关键是团队统一。本书用按层分包(与经典教程一致,单体阶段最直观),并预留 34 章「infra 包收敛中间件代码」的演化路径。

1.5 命名规约(团队规范模板)

对象 规约 例
查接口 GET /api/orders/{id}、列表 GET /api/orders?page=&size= 资源名复数
写接口 POST /api/orders(创建)、POST /api/orders/{id}/pay(动作用动词子路径) RPC 风格动作显式化
类 Controller/Service/Manager/Mapper/Entity 后缀齐全(ServiceImpl 仅在有接口时出现) OrderService、StockManager
方法 查 get/list/count、写 create/update/delete/close/pay(动词开头) listPaidOrders
数据库 表/字段 snake_case;表名不用复数;前缀统一与否(如 t_)由团队约定 order_item.unit_price_cents
布尔 is_/has_/can_ 前缀 isVip

二、示例代码:mini-shop-boot 的分层骨架

// sample/mini-shop-boot/src/main/java/com/example/minishop/controller/OrderController.java
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) { this.orderService = orderService; }

    /** 创建订单:只做「校验入参 → 调服务 → 包响应」,三行以内最好 */
    @PostMapping
    public Result<OrderVO> create(@RequestBody @Valid CreateOrderDTO dto) {
        return Result.ok(OrderVO.from(orderService.createOrder(dto)));
    }
}
// sample/mini-shop-boot/src/main/java/com/example/minishop/service/impl/OrderService.java
@Service
public class OrderService {                      // sample 是具体类、无接口:单实现且稳定,不为接口而接口

    private final StockManager stockManager;     // Manager:缓存+DB 的复合扣减(34 章展开)
    private final OrderMapper orderMapper;
    private final ApplicationEventPublisher publisher;

    // 构造器注入(18 章军规)……

    @Transactional(rollbackFor = Exception.class)  // 事务边界:22 章详解 rollbackFor
    public OrderVO createOrder(CreateOrderDTO dto) {
        // 1. 领域校验与状态机流转(业务规则在此,不在 Controller)
        // 2. stockManager.deduct(...) 扣库存
        // 3. orderMapper.insert(...) 落单(下单人从 UserContext 取,防水平越权)
        // 4. publisher.publishEvent(new OrderCreatedEvent(...))
        return OrderVO.from(order);
    }
}

33 章会把这层骨架填成完整可跑的代码;本章要带走的是形状:每层一个职责、对象不跨层裸奔、依赖只准向下。


三、常见坑 ⚠️

  • ⚠️ Controller 长出业务:if (stock < qty) 出现在 Controller 就是分层失守的开始。Controller 超过 50 行基本该拆。
  • ⚠️ Service 互相调用成环:A 调 B、B 调 A。抽公共逻辑下沉到 Manager 或用事件解耦(19 章)。
  • ⚠️ Entity 满天飞:findByName(String name) 返回 Entity 直接到前端。出接口必转 VO。
  • ⚠️ DTO 与 Entity 字段「顺便同步」:靠 BeanUtils 拷贝,字段改名静默丢数据。显式转换 + 单测覆盖。
  • ⚠️ 一个方法三层透传:Controller→Service→Manager→Mapper 全是 return xxx.get(id)。无业务逻辑的透传层该合并。
  • ⚠️ Mapper 写业务 SQL 大杂烩:为每个页面定制一条「超级 SQL」,换需求就重写。复杂读走 Manager 组合或专门的查询服务。

四、调试技巧

  • 用 IDEA 依赖结构定位层:Ctrl + Alt + H 看方法调用链,跨层直调(Controller→Mapper)一眼现形。
  • ArchUnit(进阶)可把分层规则写成测试:noClasses().that().resideInAPackage("..controller..").should().dependOnClassesThat().resideInAPackage("..mapper..")——CI 里自动守规。
  • 接口字段对不上:先确认「哪一层的对象被序列化了」,八成是 Entity 漏转 VO。

五、最佳实践

  • 新模块先定包结构与命名规约再写代码;规范的意义是让任何人接手任何文件都面熟。
  • 层间一律接口依赖?现实取舍:Mapper 用接口(MP 自带),Service/Manager 可先类后接口——不要为接口而接口,单实现且稳定的类先用类(sample 的 OrderService 就是具体类,Mockito 打桩具体类毫无压力,见 28 章)。
  • VO 转换器与 VO 同文件、DTO 校验与 DTO 同文件,就近原则降低跳转成本。
  • 每层包内按「类名一致性」组织:OrderController/OrderService/OrderMapper/OrderEntity,改一个业务只动一组同前缀文件。

六、本章小结

  • 分层 = 按变化频率切分:协议变(Controller 层)、业务变(Service 层)、存储变(Mapper 层)互不牵连。
  • Manager 层收编「跨域编排 + 存储组合」,防 Service 膨胀与 Mapper 越权。
  • Entity/DTO/VO 三类对象各守边界;Entity 出接口是安全事故预备队。
  • 规约的价值在一致:命名、包结构、转换器位置,全团队一套。

下一章数据访问:建表规范、MyBatis-Plus 实战与索引优化。


七、动手练习

基础题

  1. 为 mini-shop-boot 建立第 1.4 节的包结构骨架(空类即可),把第 18/19 章已有的类挪进去并保证启动成功。
  2. 为 OrderVO.from 写单测:断言金额分转元、状态码转文案两个转换点。

进阶题

  1. 设计 ProductVO(含价格元格式、是否售罄布尔)与 ProductEntity 的转换器,并故意在 Entity 增加 internalRemark 字段验证「VO 不泄漏内部字段」。
  2. 写一个 ArchUnit 测试:Controller 包禁止依赖 Mapper 包、Entity 禁止出现在 controller 包的类型签名中。跑起来把现有违规揪出来修掉。

思考题

  1. 「按层分包」在团队扩张到 20 人、模块边界模糊时会暴露什么问题(改一个功能要横跨 6 个包、发布不能按业务独立)?对比「按功能分包 + 模块化」的思路给出你的迁移预案——这道题是「单体 → 微服务」演化的第一课。

📄 第 21 章 数据库访问:MySQL、连接池与 MyBatis-Plus 21-mybatis-plus-and-mysql.md

阶段:阶段 3 · 企业级开发 | 预计阅读 90 min | 代码落地模块:sample/mini-shop-boot(mapper/db) 前置:第 17 章、第 18 章 | 相关章节:第 22 章 事务 | 第 24 章 缓存 实战落点:product/order/order_item/idempotent_record/app_user 五张表 + 全套 Mapper,33 章直接使用;乐观锁兜底超卖(库存分段的 Redis 方案在 24/34 章,本章先落 DB 层)

学习目标

读完本章,你应该能够:

  • [ ] 按 Mini 商店 schema 建出规范的表(主键、时间戳、utf8mb4、软删)。
  • [ ] 配置 MyBatis-Plus(Boot 4 专用 starter + 分页插件),用 BaseMapper 与 LambdaQueryWrapper 完成常用 CRUD。
  • [ ] 理解连接池(HikariCP)的参数含义,知道为什么不能每次请求建连接。
  • [ ] 会看 EXPLAIN,能给高频查询建正确索引,识别索引失效写法。
  • [ ] 用乐观锁(@Version)兜底并发更新,理解它与第 32 章五方案的关系。

一、核心概念

1.1 建表规范(团队规范模板)

-- sample/mini-shop-boot/src/main/resources/db/schema.sql(节选)
CREATE TABLE IF NOT EXISTS product (
    id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name         VARCHAR(128)  NOT NULL COMMENT '商品名',
    price_cents  BIGINT        NOT NULL COMMENT '单价,分',
    stock        INT           NOT NULL DEFAULT 0 COMMENT '库存',
    version      INT           NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
    deleted      TINYINT       NOT NULL DEFAULT 0 COMMENT '逻辑删除 0否1是',
    created_at   DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at   DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    KEY idx_name (name)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '商品表';

六条铁律:

  1. 主键 BIGINT UNSIGNED AUTO_INCREMENT:自增做聚簇索引顺序写性能好;别用 UUID 做主键(随机插入导致页分裂,进阶话题);
  2. 金额 BIGINT 存分(或 DECIMAL(12,2)),绝不用 FLOAT/DOUBLE——第 01 章铁律延伸到存储层;
  3. utf8mb4:完整 Unicode(含 emoji);utf8 在 MySQL 里是残血的 utf8mb3;
  4. created_at/updated_at 全表标配,交给数据库默认值维护,应用层不操心;
  5. 软删 deleted:订单类数据法律上不能物理删;查询统一带 deleted=0(MP 的 @TableLogic 自动拼);
  6. 每列写 COMMENT:三个月后没人记得 status=2 是什么——第 03 章枚举教训在库表的重演。

1.2 连接池:为什么必须池化

建一条 MySQL 连接要 TCP 握手 + 认证 + 会话初始化,毫秒级开销;连接对象在服务端还占资源(默认 max_connections=151)。池化 = 复用连接 + 上限管控。Spring Boot 默认自带 HikariCP(业界最快),关键参数:

# sample/mini-shop-boot/src/main/resources/application.yml(节选)
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mini_shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root123
    hikari:
      maximum-pool-size: 10        # 经验公式:核数*2 + 磁盘数;不是越大越好!
      minimum-idle: 5
      connection-timeout: 3000     # 池借不出连接的等待上限
      max-lifetime: 1800000        # 连接寿命(小于 MySQL wait_timeout)

[!WARNING] 池大小不是越大越好:连接数超过数据库处理能力只会增加上下文切换与锁排队。200 并发请求配 200 连接,数据库先倒下。小池(10~20)+ 快查询才是常态;池打满说明有慢 SQL,先治 SQL。

1.3 MyBatis-Plus:CRUD 加速器

Boot 4 依赖(坐标陷阱见附录 90):

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-spring-boot4-starter</artifactId>
    <version>3.5.17</version>
</dependency>
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-jsqlparser</artifactId>   <!-- 分页插件 3.5.9 起拆出,必引 -->
    <version>3.5.17</version>
</dependency>
// sample/mini-shop-boot/src/main/java/com/example/minishop/domain/entity/ProductEntity.java
package com.example.minishop.domain.entity;

import com.baomidou.mybatisplus.annotation.*;
import java.time.LocalDateTime;

/** 与 product 表一一对应(第 20 章:实体不出 service 层) */
@TableName("product")
public class ProductEntity {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String name;
    private Long priceCents;         // 驼峰自动映射 price_cents(默认开启 map-underscore-to-camel-case)
    private Integer stock;
    @Version                          // 乐观锁:更新时自动带 version 条件并 +1
    private Integer version;
    @TableLogic                       // 逻辑删除:查询自动拼 deleted=0
    private Integer deleted;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    // getter/setter 省略(MP 实体需要无参构造 + 可写属性,故不用 record——第 03 章的取舍)
}
// sample/mini-shop-boot/src/main/java/com/example/minishop/mapper/ProductMapper.java
package com.example.minishop.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;

/** 单表 CRUD 全部白送:insert/selectById/updateById/deleteById/selectPage... */
@Mapper
public interface ProductMapper extends BaseMapper<ProductEntity> { }
// 条件构造器与分页(Service 里使用)
LambdaQueryWrapper<ProductEntity> qw = new LambdaQueryWrapper<ProductEntity>()
        .like(ProductEntity::getName, kw)                       // name LIKE '%kw%'(方法引用防列名拼错)
        .ge(ProductEntity::getPriceCents, minPrice)
        .orderByDesc(ProductEntity::getCreatedAt);

Page<ProductEntity> page = productMapper.selectPage(new Page<>(pageNo, 20), qw);
long total = page.getTotal();
List<ProductEntity> records = page.getRecords();
// sample/mini-shop-boot/src/main/java/com/example/minishop/config/AppConfig.java(节选)
// MP 的拦截器与 @MapperScan 统一收口在 AppConfig(不单设 MybatisPlusConfig)
@Configuration
@MapperScan("com.example.minishop.mapper")
public class AppConfig {

    /** 分页插件:不配则 selectPage 不追加 LIMIT(整表查询伪装分页,事故预备队) */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());   // @Version 生效
        return interceptor;
    }
}

1.4 乐观锁:并发更新的 DB 兜底

ProductEntity p = productMapper.selectById(1L);
p.setStock(p.getStock() - 1);
int rows = productMapper.updateById(p);
// 生成 SQL: UPDATE product SET stock=?, version=version+1 WHERE id=? AND version=?(旧值)
// rows == 0 → 版本已被别人改过 → 重读重试(或直接失败)
if (rows == 0) throw new BizException(ErrorCode.SYSTEM_ERROR, "商品信息已被修改,请重试");

对照第 32 章:内存五种方案保护的是「单实例内存数据」;乐观锁保护的是「数据库行」——无论几个实例并发写,都逃不出 version 比对。扣库存的更强写法是条件原子 UPDATE(34 章主角):

UPDATE product SET stock = stock - 1 WHERE id = ? AND stock >= 1   -- 影响行数=0 即库存不足

1.5 索引与慢查询速成

索引 = 排好序的查找结构(B+ 树)。给 WHERE/ORDER BY/JOIN 列建索引,代价是写入变慢 + 空间。EXPLAIN 三眼看:

EXPLAIN SELECT * FROM order_item WHERE order_id = 1001;
-- type: ref(走索引,好);ALL(全表扫,坏)
-- key:  idx_order_id(实际用的索引)
-- rows: 预估扫描行数

索引失效五连(Review 必查):

写法 问题
WHERE name LIKE '%键盘' 前缀通配符无法走索引('键盘%' 可以)
WHERE DATE(created_at) = '2026-09-29' 对列包函数失效;改范围 created_at >= '2026-09-29' AND < '2026-09-30'
WHERE phone = 13800138000(列 varchar 传数字) 隐式类型转换失效
最左前缀破坏:联合索引 (a,b,c) 只查 b 失效;联合索引按「最左连续列」生效
SELECT * 大宽表回表 慢且浪费;只查需要的列(覆盖索引场景)

二、示例代码:Service 层完整用法

// sample/mini-shop-boot/src/main/java/com/example/minishop/service/impl/ProductService.java(节选)
@Service
public class ProductService {                     // 具体类、无接口(20 章:不为接口而接口)

    private final ProductMapper productMapper;

    public ProductService(ProductMapper productMapper, ProductCacheManager cacheManager) { ... }

    /** 分页查商品(24 章会加缓存层) */
    public PageResult<ProductVO> list(int page, int size, String keyword) {
        var qw = Wrappers.<ProductEntity>lambdaQuery()
                .like(keyword != null && !keyword.isBlank(),       // JDK 原生判空:条件为真才拼 LIKE
                        ProductEntity::getName, keyword)
                .orderByDesc(ProductEntity::getId);

        Page<ProductEntity> result = productMapper.selectPage(Page.of(page, Math.min(size, 100)), qw);
        List<ProductVO> vos = result.getRecords().stream().map(ProductVO::from).toList();
        return new PageResult<>(vos, result.getTotal(), page, size);
    }
}

条件原子扣减在 sample 里是 Mapper 上的注解 SQL(StockManager 的 DB 兜底路径调它):

// sample/mini-shop-boot/src/main/java/com/example/minishop/mapper/ProductMapper.java(节选)
@Mapper
public interface ProductMapper extends BaseMapper<ProductEntity> {

    /** 条件原子扣库存:WHERE stock >= 数量,影响行数=1 成功 / 0 库存不足(33 章下单主链路) */
    @Update("UPDATE product SET stock = stock - #{quantity}, version = version + 1 " +
            "WHERE id = #{productId} AND stock >= #{quantity} AND deleted = 0")
    int deductStock(@Param("productId") Long productId, @Param("quantity") int quantity);
}

三、常见坑 ⚠️

  • ⚠️ 不配分页插件:selectPage 不带 LIMIT,内存分页伪装,大表 OOM。
  • ⚠️ updateById 传 null 字段:MP 默认不更新 null 字段(field-strategy 默认 NOT_NULL)——「为什么字段没清空」的经典疑问。清空用 LambdaUpdateWrapper.set(column, null)。
  • ⚠️ 逻辑删除后查不到却以为删了:@TableLogic 让 select 自动过滤,物理数据还在(这正是目的);需要物理删用自定义 SQL。
  • ⚠️ LIKE '%xx%' 当搜索:全表扫。真实搜索上 ES(本书范围外,附录 92 有指引)。
  • ⚠️ 大事务里做慢查询:@Transactional 方法里调外部 HTTP,连接一直占着——连接池耗尽常见元凶(22 章细讲)。
  • ⚠️ SELECT * + 巨型 JSON 字段:列表页拉大字段。列表/详情拆查询或用 select(...) 指定列。

四、调试技巧

  • 开 MP 的 SQL 日志(dev 环境):mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl,prod 必须关(日志洪水)。
  • 慢 SQL 定位:MySQL 开 slow_query_log,long_query_time=1;或看 APM(27 章)。
  • EXPLAIN 前先 ANALYZE TABLE(统计信息过期会导致预估失真);改索引用 ALTER TABLE ... ADD INDEX 并在低峰执行。

五、最佳实践

  • 所有写并发敏感的行,走「条件原子 UPDATE」或乐观锁,二选一必须落一个(33 章)。
  • Mapper 只做表级操作;跨表编排上移 Service/Manager(20 章规约的数据库侧落实)。
  • 每张表建索引前写下「这条索引服务哪条查询」,删不掉的查询才配拥有索引。
  • 时间字段统一 LocalDateTime + DATETIME;全局时区固定(16 章三统一)。

六、本章小结

  • 建表六铁律:自增大主键、金额存分、utf8mb4、双时间戳、软删、列注释。
  • HikariCP 小池快查;池打满先查慢 SQL 而不是加连接。
  • MP 三板斧:BaseMapper 白送 CRUD、LambdaWrapper 防拼错列、分页插件必须显式配置。
  • 并发写数据库的两把锁:条件原子 UPDATE(强推)与 @Version 乐观锁(兜底)。
  • EXPLAIN 三眼法 + 失效五连,是慢查询的第一道体检。

下一章事务:@Transactional 到底做了什么、什么时候会悄悄失效。


七、动手练习

基础题

  1. 按 1.1 规范执行 schema.sql 建出全部五张表:product/order/order_item/idempotent_record/app_user(画出 ER 图标注外键关系——生产可不用物理外键,用逻辑关联 + 应用层保证。库存不单设 sku_stock 表:教学聚焦核心链路,分段库存走 Redis,见 24/34 章)。
  2. 跑通分页接口:GET /api/products?page=1&size=2&keyword=键盘;关掉分页插件配置再跑,用 SQL 日志对比「有没有 LIMIT」。

进阶题

  1. 给 order 表造 10 万条测试数据(存储过程或程序循环插入),用 EXPLAIN 对比「有/无 idx_user_id 索引」时按用户查订单的 type 与 rows。
  2. 写一个并发测试:ExecutorService 起 20 线程对同一商品各扣 1 件库存(初始 10 件),分别用「先查再减」与「条件原子 UPDATE」两版实现,验证后者永远不超卖而前者会。

思考题

  1. 自增主键在「分库分表」后失效(各库各自自增会撞号)。结合第 16 章思考题的发号方案(号段/Redis/雪花),给你心中的 mini-shop 选一个并说明理由——这是第 34 章订单号设计的预演。

📄 第 22 章 事务:隔离级别、传播行为与失效场景 22-transactions.md

阶段:阶段 3 · 企业级开发 | 预计阅读 80 min | 代码落地模块:sample/mini-shop-boot(service 事务边界) 前置:第 19 章 AOP、第 21 章 | 相关章节:第 34 章 MQ 异步化 实战落点:下单主流程事务 + 「事务内发消息」的正确姿势——33/34 章的稳定基石

学习目标

读完本章,你应该能够:

  • [ ] 用 ACID 四特性检查一段事务代码的设计。
  • [ ] 说清四种隔离级别各挡住什么(脏读/不可重复读/幻读),知道 MySQL 默认 RR。
  • [ ] 掌握 @Transactional 七个失效场景中的高频四个,会排查会修。
  • [ ] 按场景选择传播行为(REQUIRED/REQUIRES_NEW/NOT_SUPPORTED)。
  • [ ] 划出正确的事务边界:事务里只做 DB 操作,慢 IO 一律移出。

一、核心概念

1.1 ACID 与「扣库存 + 落单」为什么必须是一个事务

mini-shop 下单要写两处:product.stock 减 1、order 插一行。没有事务的两种惨案:

  • 第一步成功、第二步失败 → 库存扣了、订单没了(少发货,资损);
  • 反过来 → 订单有了、库存没扣(超卖)。

事务(Transaction)保证这组操作要么全部成功(持久性 Durability + 原子性 Atomicity),并发交错时像独占执行(隔离性 Isolation),前后约束始终合法(一致性 Consistency)。

1.2 隔离级别:并发读的三类脏数据

图例:✅ = 能防住该读异常;❌ = 该读异常会发生。

隔离级别 脏读 不可重复读 幻读 说明
READ UNCOMMITTED ❌ 会发生 ❌ ❌ 读到别人未提交数据,几乎无人用
READ COMMITTED ✅ 防 ❌ 会 ❌ 每次读最新已提交(PG/Oracle 默认)
REPEATABLE READ ✅ ✅ 防 ✅*(InnoDB 基本防) MySQL 默认;同一事务内多次读一致
SERIALIZABLE ✅ ✅ ✅ 全串行化,吞吐最低

实用结论:MySQL 默认 RR 已满足绝大多数业务;能靠「乐观锁/条件更新 + 唯一键」解决的并发问题,不要上 SERIALIZABLE。你需要在应用里做的不是调级别,而是把事务边界划对。

1.3 @Transactional 的正确姿势

@Transactional(rollbackFor = Exception.class)   // ⚠️ rollbackFor 必写!
public OrderEntity createOrder(CreateOrderDTO dto) {
    stockManager.deduct(...);        // 条件 UPDATE 扣库存
    OrderEntity order = build(dto);
    orderMapper.insert(order);       // 落单
    orderItemMapper.batchInsert(items);
    return order;
}

三个要点:

  1. rollbackFor = Exception.class 必写:默认只回滚 RuntimeException/Error;抛受检异常(如自定义 IOException 包装前)默认不回滚——教科书级事故;
  2. 事务方法必须 public 且从代理外部调用(19 章失效原理);
  3. 事务边界 = 业务原子单元。大事务是万恶之源:锁持有时间长、undo log 膨胀、连接被占——「事务里只做 DB 快操作」。

1.4 传播行为:事务套事务怎么办

传播行为 语义 典型场景
REQUIRED(默认) 有事务就加入,没有就新建 绝大多数业务方法
REQUIRES_NEW 挂起当前事务,开独立新事务 操作日志、审计流水——主事务回滚日志也要留
NESTED 在当前事务内建保存点,回滚只退到保存点 批量处理中单条失败不影响整体(部分回滚);依赖 JDBC 保存点,非全部数据源支持
NOT_SUPPORTED 挂起事务,无事务执行 事务内的慢查询/批处理
MANDATORY / NEVER / SUPPORTS 强制要求有 / 强制没有 / 有就加入 防御性声明

mini-shop 实例:支付回调里「订单状态更新」与「支付流水记录」——流水表无论如何都要留痕(教学示意:sample 的回调主流程 PayCallbackService 见 34 章,验签 + 幂等 + 条件 UPDATE 落库):

// 教学示意(sample 未收录流水表部分)
@Transactional(rollbackFor = Exception.class)
public void handlePayCallback(PayCallbackDTO cb) {
    payRecordService.record(cb);      // 内部 @Transactional(propagation = REQUIRES_NEW) 独立提交
    orderService.markPaidByOrderId(cb.orderId());
}

1.5 失效场景 Top4(附排查)

# 场景 原因 解法
1 同类内部调用 this.methodB() 绕过代理,注解形同虚设(19 章) 拆类 / 自注入 / 编排上移
2 方法非 public CGLIB 无法拦截 提为 public;或迁移到接口方法
3 rollbackFor 缺省 + 受检异常 默认只滚非受检 统一写 rollbackFor = Exception.class
4 事务内被 try-catch 吞掉 异常没抛出代理层,事务以为一切正常 要回滚就 throw,或 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()

另有隐蔽款:多线程内执行(事务绑定 ThreadLocal 连接,子线程的 SQL 不在事务里)、存储引擎不支持(MyISAM)、传播行为配错。记忆锚点:事务是代理 + ThreadLocal 实现的,凡是绕开这两者都会失效。

1.6 事务里不能做什么

@Transactional
public void createOrder(...) {
    // ❌ 调 HTTP/发邮件(秒级慢 IO):连接占用 + 锁等待放大
    // ❌ Thread.sleep / 重计算
    // ✅ 只做:INSERT/UPDATE/SELECT(快)
    // ✅ 要发消息:收集「事务提交后要做的事」,提交后再发(见下)
}

「事务提交后再发 MQ」的两种正确实现:

  1. @TransactionalEventListener(phase = AFTER_COMMIT) 事件监听(19 章)——同进程首选;
  2. 事务消息/本地消息表(34 章)——需要跨进程可靠投递时。

二、示例代码:下单事务的完整形态

// sample/mini-shop-boot/src/main/java/com/example/minishop/service/impl/OrderService.java(节选)
@Service
public class OrderService {                       // 具体类、无接口(20 章取舍)

    private final StockManager stockManager;
    private final OrderMapper orderMapper;
    private final OrderItemMapper orderItemMapper;
    private final ApplicationEventPublisher publisher;
    private final ShopProperties props;

    // 构造器注入……

    @Transactional(rollbackFor = Exception.class)
    public OrderVO createOrder(CreateOrderDTO dto) {
        long userId = UserContext.require();      // 下单人从 JWT 取(26 章防水平越权)
        List<StockManager.Deducted> ledger = new ArrayList<>();   // Redis 段补偿台账
        try {
            // 1. 扣库存(Redis 段热路径 + DB 条件 UPDATE 兜底;结果记入台账)
            for (CreateOrderDTO.Item item : dto.items()) {
                StockManager.Deducted d = stockManager.deduct(item.productId(), item.quantity());
                if (!d.success()) {
                    throw new StockNotEnoughException(item.productId(), item.quantity(), 0);
                }
                ledger.add(d);
            }

            // 2. 落订单与订单项(同一事务:要么都成要么都不成)
            OrderEntity order = insertOrderWithRetry(userId, items, payable);

            // 3. 事务提交后才能做的副作用:监听器在 AFTER_COMMIT 发 MQ(19/25 章)
            publisher.publishEvent(new OrderCreatedEvent(order.getId(), order.getOrderNo(),
                    userId, order.getPayableCents()));
            return OrderVO.from(order);
        } catch (RuntimeException e) {
            compensateRedis(ledger, productIds, dto);   // Redis 不在事务里:按台账手动回补(34 章)
            throw e;
        }
    }
}

注意两点:事件发布放在事务方法内但监听器标 AFTER_COMMIT——发布本身只是登记,副作用延后到提交成功,兼顾「代码聚合」与「时序正确」;而 Redis 扣减不在数据库事务里——事务回滚只救得了 DB,Redis 段必须按 Deducted 台账逐段回补(34 章的补偿逻辑)。


三、常见坑 ⚠️

  • ⚠️ rollbackFor 忘写:受检异常不回滚,半截数据入库。全团队统一模板。
  • ⚠️ 事务方法被同类调用:this.saveXxx() 注解失效(Top1 失效王)。
  • ⚠️ 事务里调外部接口:连接被霸占、锁等到天荒地老,连接池雪崩。
  • ⚠️ catch 后继续走:想回滚就抛出去;吞掉等于放弃原子性。
  • ⚠️ 以为加了注解就有事务:注解生效条件(public、代理调用、引擎支持)一个都不能少;怀疑失效用日志验证——连接上有没有 begin/commit。
  • ⚠️ 跨库事务想当然:一个方法写 MySQL 又写 Redis,Redis 不参与 MySQL 事务——Redis 扣减失败时 MySQL 不会自动回滚(这正是 34 章「本地消息表 + 幂等」要解的问题)。

四、调试技巧

  • 开 MP SQL 日志(dev),观察同一事务的多条 SQL 是否共享同一连接(日志线程名相同 + commit 出现一次)。
  • 怀疑失效:在方法内 TransactionSynchronizationManager.isActualTransactionActive() 打日志,false 即没进事务。
  • 死锁(MySQL 1213):SHOW ENGINE INNODB STATUS 看 LATEST DETECTED DEADLOCK 双方持锁;常见于两个事务以不同顺序更新两行——统一更新顺序即解。

五、最佳实践

  • 事务边界与服务方法对齐(20 章分层约定的事务侧注解);Controller/Manager 默认不开事务。
  • 事务方法控制在 100ms 量级:只放 DB 快操作;副作用走 AFTER_COMMIT 事件或消息表。
  • 团队模板固定:@Transactional(rollbackFor = Exception.class),Review 见裸注解直接打回。
  • 库存/余额类用「条件原子 UPDATE」先保证单行正确,事务保证多行一致,两层防线都要有。

六、本章小结

  • ACID 检查法:这组操作分开执行会不会出事故?会就需要事务。
  • 隔离级别记住 MySQL 默认 RR 与三类读异常;并发正确性优先靠条件更新/唯一键。
  • 传播行为三大主力:REQUIRED 默认、REQUIRES_NEW 留痕、NOT_SUPPORTED 摘事务。
  • 失效 Top4:同类调用、非 public、rollbackFor 缺省、异常被吞——锚点是「代理 + ThreadLocal」。
  • 事务里只做快 DB 操作;副作用 AFTER_COMMIT 或消息表(34 章)。

下一章接口设计:把订单服务暴露成规范的 RESTful API,统一响应、全局异常、参数校验与文档一步到位。


七、动手练习

基础题

  1. 给 OrderService.createOrder 写两条事务测试:订单项落库失败时断言「库存未被扣减」(回滚生效);再故意在方法内 catch 掉异常验证「半截数据」事故,体会 rollbackFor + 抛出的意义。
  2. 写一个 REQUIRES_NEW 场景:PayRecordService.record 独立事务记录流水,主流程故意抛异常回滚,验证流水仍在。

进阶题

  1. 复现失效场景 1:同类里 methodA 调 this.methodB()(B 有事务注解),日志确认 B 没进事务;用「自注入自身代理」与「拆类」两种方式修复并对比优缺点。
  2. 用 TransactionTemplate 编程式事务重写 createOrder,对比声明式版本的可读性与灵活性(编程式适合「事务范围由运行期条件决定」的场景)。

思考题

  1. 「订单支付成功」要同时:更新订单状态、给用户加积分、通知库存释放。积分服务挂了怎么办?三个动作显然无法塞进一个数据库事务(跨服务)。结合本章 AFTER_COMMIT 事件与第 30 章思考题 4,草拟你的「最终一致性」方案(重试 + 幂等 + 对账),34 章会给出完整实现。

📄 第 23 章 接口设计与规范:RESTful、统一响应、校验与 OpenAPI 23-rest-api-design.md

阶段:阶段 3 · 企业级开发 | 预计阅读 70 min | 代码落地模块:sample/mini-shop-boot(controller/api 包) 前置:第 20 章 分层、第 06 章 异常 | 相关章节:第 26 章 鉴权 实战落点:mini-shop 全套 API 规范:统一 Result<T>、全局异常处理器、参数校验、springdoc 文档

学习目标

读完本章,你应该能够:

  • [ ] 按 REST 语义设计资源化接口,动作用显式子路径表达。
  • [ ] 落地统一响应 Result<T> + 全局异常处理器 @RestControllerAdvice。
  • [ ] 用 jakarta-validation 注解做入参校验,校验失败自动转标准错误响应。
  • [ ] 集成 springdoc-openapi 生成可调试文档。
  • [ ] 遵守接口安全与版本化的基本纪律。

一、核心概念

1.1 RESTful 语义速成

REST 不是信仰是习惯:名词做资源、HTTP 方法做动作、状态码表达结果。

操作 设计 说明
查列表(分页) GET /api/orders?page=1&size=20&status=PAID 过滤条件进查询串
查详情 GET /api/orders/{id}
创建 POST /api/orders 幂等性:POST 不幂等,重复点击要防重(19 章)
全量/部分更新 PUT/PATCH /api/orders/{id} 教学链路少用
状态类动作 POST /api/orders/{id}/pay、/close 动作用动词子路径——比硬凹 PUT 语义诚实
删除 DELETE /api/orders/{id} 软删(21 章)

状态码使用守则:200 成功、400 参数错、401 未登录、403 无权限、404 资源不存在、409 冲突(重复下单)、429 限流、500 服务端错。业务错误码放在响应体 Result.code(网关/监控只看 HTTP 码,业务语义看 body)。

1.2 统一响应与错误码

第 05 章的 Result<T> 在这里接上前端契约:

// 成功
{ "code": 0, "message": "ok", "data": { "orderNo": "2026092900000001" } }
// 业务失败(HTTP 仍返回 200 或 4xx,团队约定一致即可;本书:业务拒绝 → 200 + 非零 code;系统错误 → 500)
{ "code": 40901, "message": "商品1库存不足: 需要5, 仅剩2", "data": null }

[!TIP] 「业务失败 HTTP 用 200 还是 4xx」是团队约定问题,没有绝对正确。本书约定:参数/鉴权类错误用 4xx(网关与监控可归类),业务规则拒绝用 200 + 错误码(前端统一按 code 分支)。写进接口文档,全队遵守。

1.3 全局异常处理器:业务代码只管抛

// sample/mini-shop-boot/src/main/java/com/example/minishop/controller/advice/GlobalExceptionHandler.java(节选)
package com.example.minishop.controller.advice;

import com.example.minishop.common.api.Result;
import com.example.minishop.common.exception.BizException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

/** 全局异常出口(第 06 章「统一出口处理」的落地):业务代码抛、这里翻译 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    /** 业务异常:可预期,WARN 级别记消息即可,不打堆栈 */
    @ExceptionHandler(BizException.class)
    public Result<Void> biz(BizException e) {
        log.warn("业务拒绝: code={}, msg={}", e.getErrorCode().getCode(), e.getMessage());
        return Result.fail(e.getErrorCode().getCode(), e.getMessage());
    }

    /** @RequestBody 校验失败:400 */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<Void> invalidBody(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
                .map(f -> f.getField() + " " + f.getDefaultMessage())
                .findFirst().orElse("参数不合法");
        return Result.fail(40001, msg);
    }

    /** 兜底:未预期异常。ERROR 级 + 完整堆栈 + 通用话术(绝不把堆栈给前端) */
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<Void> unknown(Exception e) {
        log.error("系统异常", e);
        return Result.fail(50000, "系统繁忙,请稍后再试");
    }
}

sample 的完整版还接住了更多「客户端可预期错误」:请求体不是合法 JSON(HttpMessageNotReadableException)、路径/查询参数类型不匹配、缺必填参数、参数级 @Validated 校验失败(ConstraintViolationException,即 @RequestParam/@PathVariable 上的注解校验,与 body 校验殊途同归,都翻译成 40001)——一律 400;路径不存在(NoResourceFoundException)→ 404;撞唯一键(DuplicateKeyException)→ 409。原则:客户端错误不该落进 50000 兜底吓人。

从此 Controller 里没有任何 try-catch:业务拒绝抛 BizException,系统错误自然冒泡——异常路径全部收口在这一处,日志格式统一、响应结构统一。

1.4 参数校验:注解声明式

// sample/mini-shop-boot/src/main/java/com/example/minishop/domain/dto/CreateOrderDTO.java
// ⚠️ 故意没有 userId:下单人身份从 JWT 令牌取(UserContext.require),客户端传什么都不算数
public record CreateOrderDTO(
        @NotEmpty(message = "商品列表不能为空") @Valid List<Item> items) {

    public record Item(
            @NotNull(message = "商品id不能为空") Long productId,
            @Min(value = 1, message = "数量至少为1")
            @Max(value = 99, message = "单商品最多购买99件") int quantity) { }
}

启用方式:方法参数标 @Valid(body 校验)或 Controller 类标 @Validated(校验 @PathVariable/@RequestParam)。校验失败抛 MethodArgumentNotValidException,被 1.3 的处理器接住——校验与响应格式解耦。

自定义校验(复杂规则)写一个注解 + ConstraintValidator(第 09 章注解知识的实战):如「订单项数量和不超过 999」。

1.5 OpenAPI 文档:接口即文档

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>3.0.0</version>   <!-- springdoc 不在 Boot BOM 内,需自声明版本(附录 90) -->
</dependency>
@Tag(name = "订单")
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @Operation(summary = "创建订单", description = "防重窗口 1s,库存不足返回 40901")
    @PostMapping
    public Result<OrderVO> create(@RequestBody @Valid CreateOrderDTO dto) { ... }
}

启动后打开 http://localhost:8080/swagger-ui.html:接口列表、参数说明、在线调试一应俱全。文档随代码更新——这就是比 wiki 文档强的根本原因。


二、示例代码:mini-shop 完整 API 面

方法 路径 说明 错误码
GET /api/products/{id} 商品详情 40402 商品不存在
GET /api/products 商品分页(keyword/page/size) 40001
POST /api/orders 下单(下单人取自 JWT) 40901 库存不足 / 40402 商品不存在
POST /api/orders/{orderNo}/pay 支付(模拟) 40903 状态不允许
POST /api/pay/callback 支付回调(网关调用,验签) 40904 幂等拒绝
POST /api/orders/{orderNo}/close 关单(仅本人的 CREATED 可关) 40301 归属越权 / 40903
GET /api/orders/{orderNo} 订单详情(校验归属) 40401 订单不存在 / 40301 查别人订单
GET /api/orders 我的订单(分页,登录态) 40101

curl 联调三连:

curl -X POST http://localhost:8080/api/orders -H 'Content-Type: application/json' \
     -d '{"items":[{"productId":1,"quantity":2}]}'   # 下单人从 JWT 取,body 不含 userId
curl http://localhost:8080/api/orders/202609290000000001
curl -X POST http://localhost:8080/api/orders/202609290000000001/pay

三、常见坑 ⚠️

  • ⚠️ Controller 手写 try-catch:异常处理散落、响应格式漂移。统一交给 @RestControllerAdvice。
  • ⚠️ 校验注解忘标 @Valid:注解全是摆设,null 直通到 Service。DTO 入参必查 @Valid 是否在位。
  • ⚠️ 错误码随意编:前端按 code 分支会全崩。错误码枚举 + 文档(附录 90 的同类纪律:唯一权威来源)。
  • ⚠️ 接口无版本:路径带 /api/v1/ 或用 Header 版本化;裸接口改契约即灾难。本书教学用 /api,真实项目建议 /api/v1。
  • ⚠️ 敏感操作无幂等保护:支付回调、下单这类接口重复调用产生双倍效果——19 章防重切面 + 25 章消费幂等双保险。
  • ⚠️ 把内部异常消息透给前端:e.getMessage() 只允许给业务异常;系统异常一律通用话术(堆栈进日志,不进响应)。

四、调试技巧

  • curl -v 看状态码与响应头;配合 swagger-ui 在线调试最直观。
  • 校验不生效三连:注解在不在、@Valid 在不在、对象是不是被 @RequestBody 包着。
  • 接口 500 但日志无异常:查是否被全局处理器以外的过滤器吃掉(26 章 Security 过滤器在 DispatcherServlet 之前)。

五、最佳实践

  • 接口设计四件套一次配齐:统一响应、全局异常、参数校验、OpenAPI——新工程第一天的基建。
  • 幂等设计先于联调:下单防重、回调幂等,写在接口文档的「调用约定」里。
  • 分页参数统一 page/size(size 上限 100 防拖库),响应统一 PageResult<T>(第 05 章)。
  • 对外字段命名统一 camelCase + 时间 ISO 格式(16 章三统一)。

六、本章小结

  • REST 三件事:资源名词化、方法表语义、动作用子路径;状态码与业务码各司其职。
  • Result<T> + @RestControllerAdvice + 校验注解 = 接口三支柱,Controller 回归薄。
  • OpenAPI 文档随代码生成,接口联调不靠口口相传。
  • 幂等与版本化是接口的「设计时责任」,不是上线后补丁。

下一章缓存:给商品详情加 Redis,正面解决穿透/击穿/雪崩与分布式锁——mini-shop 生产化改造的前哨。


七、动手练习

基础题

  1. 给 mini-shop 补齐 1.5 表中所有接口的 Controller(复用已有 Service),swagger-ui 里逐个联调;故意传 quantity: 0 验证校验响应。
  2. 给 GET /api/products 加 size 上限保护(>100 返回 40001),写出你选择的校验位置(DTO 注解 / Controller 判断)并说明理由。

进阶题

  1. 实现自定义校验 @OrderItemsLimit(max=999):校验 items 的 quantity 总和;在 DTO 上启用并用边界数据测试。
  2. 把 GlobalExceptionHandler 再扩展一类异常:HttpRequestMethodNotSupportedException(405,方法用错;类型不匹配/缺参 sample 已内置,可对照源码看写法),保持响应结构一致。

思考题

  1. 「回调接口」的调用方是支付网关而不是浏览器:它读不懂你的 Result<T>,只认 HTTP 状态码与约定报文。这类「机器对机器」接口的响应契约应该怎么设计(直接 HTTP 200 + SUCCESS 字样?失败返回什么?)?写一小节接口文档草案,34 章实现时对照。

📄 第 24 章 缓存:Redis、三大缓存问题与分布式锁 24-redis-cache.md

阶段:阶段 3 · 企业级开发 | 预计阅读 85 min | 代码落地模块:sample/mini-shop-boot(infra/redis) 前置:第 18 章、第 21 章、第 12 章 CAS/锁 | 相关章节:第 34 章 实战落点:商品详情缓存、库存预热、Redisson 分布式锁防超卖——34 章的承重墙

学习目标

读完本章,你应该能够:

  • [ ] 用 Spring Data Redis 完成基本读写,理解序列化配置。
  • [ ] 落地 Cache-Aside 模式,解释为什么是「先更新库再删缓存」。
  • [ ] 分别给出穿透、击穿、雪崩的成因与工程解法。
  • [ ] 用 Redisson 实现分布式锁,理解看门狗与锁误删问题。
  • [ ] 用 Redis 原子操作做分布式库存扣减(32 章分段方案的生产版)。

一、核心概念

1.1 Redis 在后端架构里的位置

flowchart LR
    A["请求"] --> B{"缓存命中?"}
    B -- 命中 --> C["直接返回<br/>~1ms"]
    B -- 未命中 --> D["查 MySQL<br/>~10-50ms"]
    D --> E["写回缓存 + TTL"]
    E --> C

Redis 是内存 KV 数据库:单线程命令执行(无锁竞争)、每秒 10 万级 QPS、微秒级延迟。常用结构五件套:String(缓存/计数/锁)、Hash(对象)、List(队列)、Set(去重)、ZSet(排行榜)。

1.2 Cache-Aside:缓存的默认读写模式

// 读:先缓存,未命中查库并回填
public ProductVO getById(Long id) {
    String key = "product:" + id;
    String cached = redis.opsForValue().get(key);
    if (cached != null) return deserialize(cached);        // 命中:1 次网络往返
    ProductVO vo = loadFromDb(id);                          // 未命中:回源
    redis.opsForValue().set(key, serialize(vo), Duration.ofMinutes(10));   // TTL 必配!
    return vo;
}

// 写:先更新数据库,再删除缓存(不是更新缓存!)
public void update(ProductVO vo) {
    productMapper.updateById(toEntity(vo));
    redis.delete("product:" + vo.id());                    // 删缓存,让下次读自然回源
}

为什么「更新库后删缓存」而不是更新缓存:

  1. 写多读少的数据更新缓存白费(可能没人读);
  2. 并发更新时「更新缓存」会产生覆盖竞态,「删缓存」语义幂等;
  3. 懒加载(下次读回源)天然保证最终一致。

为什么「先库后删」:先删缓存后更新库的窗口里,并发读会把旧值重新填进缓存且无 TTL 前的脏读期更长。先库后删的不一致窗口只剩「删失败」——用 TTL 兜底 + 延迟双删/订阅 binlog(进阶)消化。

1.3 三大缓存问题

问题 场景 解法
穿透:查不存在的数据,缓存永远没有 恶意刷不存在的 id,请求全打 DB ① 缓存空值(短 TTL 如 60s);② 入口参数校验/布隆过滤器(进阶)
击穿:热点 key 过期瞬间,海量并发同时回源 秒杀商品详情过期 ① 互斥重建(单飞:分布式锁/本地锁,回源一次大家等);② 逻辑过期(值永不过期,内含过期标记异步刷新)
雪崩:大批 key 同时过期或 Redis 整体不可用 凌晨批量预热的缓存同一刻失效 ① TTL 加随机抖动;② 多级缓存(本地 Caffeine + Redis);③ 限流降级兜底

「单飞回源」骨架(击穿的互斥解):

public ProductVO getByIdWithBreakdownGuard(Long id) {
    String key = "product:" + id;
    String cached = redis.opsForValue().get(key);
    if (cached != null) return deserialize(cached);

    RLock lock = redisson.getLock("lock:product:" + id);      // 每个 key 一把锁,锁粒度=数据单元
    if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
        // 没抢到锁:说明有人在重建,稍等重试或返回旧值/降级
        return getByIdWithBreakdownGuard(id);   // 教学简化;生产用短暂 sleep 或读二级缓存
    }
    try {
        // 双检:拿到锁前可能别人已建好
        cached = redis.opsForValue().get(key);
        if (cached != null) return deserialize(cached);
        ProductVO vo = loadFromDb(id);
        redis.opsForValue().set(key, serialize(vo), Duration.ofMinutes(10).plusSeconds((long) (Math.random() * 60)));   // TTL 抖动用 JDK 原生随机
        return vo;
    } finally {
        lock.unlock();
    }
}

1.4 Redisson 分布式锁

第 11/12 章的锁只能护住一个 JVM。多实例部署后,「防超卖」「防重复回调」需要跨进程的锁——Redis 分布式锁:

RLock lock = redisson.getLock("lock:pay:callback:" + orderNo);
if (lock.tryLock(3, TimeUnit.SECONDS)) {        // 最多等 3s
    try {
        doIdempotentPay(orderNo);
    } finally {
        lock.unlock();                          // 只解锁自己持有的(Redisson 校验线程标识)
    }
} else {
    throw new BizException(ErrorCode.IDEMPOTENT_REPLAY);   // 快速失败
}

三件事必须懂:

  1. 看门狗:不显式传 leaseTime 时,Redisson 默认 30s 锁 + 后台每 10s 续期——业务没跑完锁不会先失效;显式传了 leaseTime 则没有续期,业务超时会解锁失效(两难:短了业务被闯入,长了宕机锁久占。默认看门狗是稳妥起点);
  2. 锁误删:手写 SET key val EX 10 NX 版本里,A 业务超时锁过期、B 拿到新锁、A 完事 DEL key——删掉了 B 的锁。必须「值存唯一标识 + Lua 校验后删」。Redisson 已内置该逻辑,不要手写 SETNX 锁;
  3. 分布式锁是优化不是保底:Redis 主从切换的极端瞬间锁可能失效。资金级正确性最终靠 DB 约束(条件 UPDATE/唯一键)兜底——缓存/锁加速,数据库立法。

1.5 Redis 原子扣库存:32 章分段方案的工业版

// Lua 脚本:检查+扣减原子化(等价第 12 章 CAS 的「检查与修改合一」)
private static final String DEDUCT_LUA = """
        local stock = redis.call('GET', KEYS[1])
        if stock == false then return -2 end
        if tonumber(stock) < tonumber(ARGV[1]) then return -1 end
        return redis.call('DECRBY', KEYS[1], ARGV[1])
        """;

Long result = redis.execute(new DefaultRedisScript<>(DEDUCT_LUA, Long.class),
        List.of("stock:seg:" + segId), String.valueOf(quantity));
// result == -2 → 段不存在(未预热):走 DB 兜底
// result == -1 → 本段不足(走借段/降级);>=0 → 扣减成功

⚠️ if stock == false 这行不能省:key 不存在时 GET 返回 false(不是 nil 数字),tonumber(false) 直接让 Lua 运行时报错——段未预热的商品一下单脚本就炸。sample 的 StockManager 完整落地了这套「Redis 段 + DB 兜底」逻辑(含 Redis 故障降级,34 章)。

Redis 单线程执行保证脚本原子;库存预热进 Redis(启动时由 DataInitializer 的 ApplicationRunner 从 DB 装载,18 章已讲过它)后,抢购流量根本不碰 MySQL。落库异步化(MQ 消费批量写)是 34 章的最后一环。


二、示例代码:商品缓存管理器

// sample/mini-shop-boot/src/main/java/com/example/minishop/manager/ProductCacheManager.java(节选)
package com.example.minishop.manager;

import com.example.minishop.domain.entity.ProductEntity;
import com.example.minishop.mapper.ProductMapper;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;

/** Manager 层(20 章):封装「缓存 + DB」复合读逻辑,Service 只调一个方法 */
@Component
public class ProductCacheManager {

    private static final String NULL_VALUE = "__NULL__";      // 空值占位:与合法 JSON 区分开

    private final StringRedisTemplate redis;
    private final RedissonClient redisson;
    private final ProductMapper productMapper;
    private final tools.jackson.databind.json.JsonMapper json;   // Boot 4 = Jackson 3(tools.jackson)

    // 构造器注入……

    /** 读:缓存 → 单飞回源。返回 null 表示商品不存在(空值缓存期内) */
    public ProductEntity getProduct(Long id) {
        String key = "product:" + id;
        String cached = redis.opsForValue().get(key);
        if (cached != null) {
            return NULL_VALUE.equals(cached) ? null : read(cached);   // 空值缓存:防穿透(60s 自纠)
        }

        RLock lock = redisson.getLock("lock:rebuild:" + key);   // 防击穿:同 key 单飞重建
        lock.lock();
        try {
            cached = redis.opsForValue().get(key);              // 双检
            if (cached != null) return NULL_VALUE.equals(cached) ? null : read(cached);

            ProductEntity entity = productMapper.selectById(id);   // 回源
            String value = entity == null ? NULL_VALUE : json.writeValueAsString(entity);
            // 防雪崩:TTL 基础值 + 随机抖动;空值短 TTL 快速自纠
            Duration ttl = entity == null ? Duration.ofSeconds(60)
                    : Duration.ofMinutes(10).plusSeconds((long) (Math.random() * 60));
            redis.opsForValue().set(key, value, ttl);
            return entity;
        } finally {
            lock.unlock();
        }
    }

    /** 写路径的缓存失效:先更新库,再删缓存(1.2 节纪律) */
    public void evict(Long id) {
        redis.delete("product:" + id);
    }

    private ProductEntity read(String jsonStr) {
        return json.readValue(jsonStr, ProductEntity.class);
    }
}

三合一了:空值缓存防穿透 + 单飞双检防击穿 + TTL 抖动防雪崩——这三个问题从来不是三段独立代码,而是同一个读路径上的三处防御工事。


三、常见坑 ⚠️

  • ⚠️ 缓存不设 TTL:数据更新后缓存永不失效(除非记得删)。一切缓存必带 TTL。
  • ⚠️ 先删缓存后更新库:脏读窗口拉长。顺序固定:先库后删。
  • ⚠️ 手写 SETNX 锁:锁误删 + 无法续期。直接用 Redisson。
  • ⚠️ 把分布式锁当正确性保底:极端故障下锁会失效;资金正确性靠 DB 唯一键/条件更新兜底。
  • ⚠️ 大 value 塞 Redis:几 MB 的 JSON 拖慢所有命令(单线程阻塞)。大对象拆字段或压缩。
  • ⚠️ keys 命令:全量遍历阻塞单线程 Redis,生产禁用;用 SCAN 游标遍历。
  • ⚠️ 缓存与 DB 的「双写一致性」想一步到位:Cache-Aside 只保证最终一致。强一致诉求根本不该上缓存,直接读库。

四、调试技巧

  • redis-cli 直连排障:GET/TTL/EXISTS key、--scan --pattern 'product:*'、MONITOR(慎用,看实时命令)。
  • 「缓存没生效」三连:写路径有没有删缓存?读路径 key 拼接是否一致?序列化前后是否同构(对象 → JSON 字符串,取出来要反序列化)。
  • Redisson 锁卡死:看 TTL lock:xxx 是否在续期(看门狗活着);线程 dump 找 tryLock 的等待栈。

五、最佳实践

  • 缓存 key 统一规约 业务:实体:id(product:1001),管理台里一眼可读。
  • 缓存代码收敛在 Manager 层(20 章),Service 不感知 Redis 存在——换本地缓存/多级缓存只动 Manager。
  • 每条缓存策略回答三问:谁失效(TTL)、谁更新(先库后删)、谁兜底(回源 + 限流)。
  • 上线前压一遍「DB 挂了缓存还在」「缓存挂了 DB 扛不扛得住」两个故障剧本。

六、本章小结

  • Cache-Aside 双纪律:读「缓存→回源→回填+TTL」,写「先库后删」。
  • 三大问题一表流:穿透挡空值、击穿靠单飞、雪崩散 TTL;它们共享同一条读路径。
  • Redisson 锁的正确姿势:tryLock 带超时 + finally 解锁 + 默认看门狗;手写 SETNX 是事故。
  • Redis Lua 扣库存 = 分段 CAS 的跨进程版;数据库约束仍是最后防线。

下一章消息队列:RabbitMQ 的可靠投递、延迟关单与消费幂等——把「下单成功之后」的活全部异步化。


七、动手练习

基础题

  1. 给 GET /api/products/{id} 接上 ProductCacheManager,用 redis-cli TTL product:1 验证 TTL 抖动;连续两次请求观察第二次耗时骤降。
  2. 造一个穿透实验:狂刷不存在的产品 id,观察「空值缓存」生效后 DB 查询停止;改掉空值缓存代码对比日志。

进阶题

  1. 库存预热 sample 已内置(DataInitializer 启动时调 StockManager.warmup,按商品拆 10 段写入 stock:seg:*):读懂它之后,写一个 GET /api/debug/stock/{productId} 观察段分布(生产记得加权限)。
  2. 用 JMeter/ab 或第 32 章思路自写 HTTP 压测,对「无缓存直查 DB」与「有缓存」两个版本对比 QPS 与 P99,记录数据。

思考题

  1. 「先更新库再删缓存」在「删缓存失败」时仍会脏读一段时间。给出至少两种补救思路(TTL 兜底之外):延迟双删的时机怎么定?订阅 binlog(Canal 类)解决什么、引入什么新复杂度?写一段方案对比,标注你会在 mini-shop 选哪个、为什么。

📄 第 25 章 消息队列:RabbitMQ、可靠投递与消费幂等 25-rabbitmq.md

阶段:阶段 3 · 企业级开发 | 预计阅读 85 min | 代码落地模块:sample/mini-shop-boot(infra/mq) 前置:第 18 章、第 22 章 事务 | 相关章节:第 34 章 实战落点:延迟关单(替代 32 章 DelayQueue)、支付回调幂等——34 章的全套异步骨架

学习目标

读完本章,你应该能够:

  • [ ] 解释为什么要 MQ:削峰、解耦、异步三大动机与代价(一致性延迟、运维复杂度)。
  • [ ] 理解交换机/队列/绑定模型,选对 direct/topic 场景。
  • [ ] 落地可靠投递三件套:生产者 confirm、持久化、消费者手动 ACK。
  • [ ] 用死信队列实现延迟关单,替代第 32 章内存版。
  • [ ] 设计消费幂等:唯一键 + 去重表,重复投递不产生二次效果。

一、核心概念

1.1 为什么引入 MQ

flowchart LR
    subgraph 同步调用["没有 MQ:下单接口串行干 3 件事"]
        A1["扣库存落单"] --> A2["发短信 200ms"] --> A3["记积分 150ms"] --> A4["返回<br/>总耗时 500ms+"]
    end
    subgraph 异步["有 MQ:只做核心动作"]
        B1["扣库存落单 50ms"] --> B2["返回"]
        B1 -.发消息 1ms.-> M(("MQ"))
        M --> C2["短信消费者"]
        M --> C3["积分消费者"]
    end

三大动机:异步(主链路只留核心动作)、削峰(流量洪峰先进队列排队消化)、解耦(下游增减不改编译上游)。代价也要认清:一致性从「强」变「最终」(消息没到之前下游不知道)、多一个要运维的中间件、排障链路变长(消息在哪一段丢了?)。核心动作(扣库存)不异步,外围动作(通知/积分)才异步。

1.2 模型:交换机 → 队列 → 消费者

Producer ──发消息(带 routing key)──> Exchange ──按绑定规则路由──> Queue ──> Consumer

direct:routing key 精确匹配      → 点对点任务(订单关单)
topic :通配符匹配(order.* / #)  → 分类订阅
fanout:广播到所有绑定队列         → 事件广播

本书用 direct(够用且直观),topic 认识即可。

1.3 可靠投递:消息不丢的三道防线

消息丢失只可能发生在三段,每段一道防线:

① 生产者 → Broker:Confirm 机制(异步确认)
② Broker 内部:交换机/队列/消息全部持久化
③ Broker → 消费者:手动 ACK,处理成功才确认
// sample/mini-shop-boot/src/main/java/com/example/minishop/config/RabbitConfig.java(节选)
@Configuration
public class RabbitConfig {

    public static final String ORDER_EXCHANGE = "order.create.x";
    public static final String ORDER_KEY = "order.create";
    public static final String ORDER_QUEUE = "order.create.q";

    public static final String DLX = "order.dlx";
    public static final String CLOSE_KEY = "order.close";
    public static final String CLOSE_QUEUE = "order.close.q";

    /** 关单队列的死信出口:消费失败(nack 不重投)的消息进停车场,人工兜底 */
    public static final String CLOSE_DLX = "order.close.dlx";
    public static final String PARKING_QUEUE = "order.close.parking.q";

    /**
     * 主队列:带 TTL + 死信路由——到期消息自动转入关单队列(延迟关单装置)。
     * ⚠️ TTL 与 shop.order-timeout-minutes 同源:写死 15 分钟的话,
     * 配置改成 30 分钟后 MQ 仍会在 15 分钟关单,支付窗口未过就被提前关掉。
     */
    @Bean
    public Queue orderQueue(ShopProperties props) {
        return QueueBuilder.durable(ORDER_QUEUE)                // ② 队列持久化
                .withArgument("x-dead-letter-exchange", DLX)
                .withArgument("x-dead-letter-routing-key", CLOSE_KEY)
                .withArgument("x-message-ttl", props.orderTimeoutMinutes() * 60_000L)
                .build();
    }

    @Bean
    public DirectExchange orderExchange() { return new DirectExchange(ORDER_EXCHANGE); }

    @Bean
    public Binding orderBinding(Queue orderQueue) {
        return BindingBuilder.bind(orderQueue).to(orderExchange()).with(ORDER_KEY);
    }

    @Bean
    public DirectExchange orderDlx() { return new DirectExchange(DLX); }

    /**
     * 关单队列:消费失败的消息死信到停车场队列。
     * 不配 x-dead-letter-exchange 的话,basicNack 的消息会被直接丢弃——
     * 「人工兜底」就成了空话。
     */
    @Bean
    public Queue orderCloseQueue() {
        return QueueBuilder.durable(CLOSE_QUEUE)
                .withArgument("x-dead-letter-exchange", CLOSE_DLX)
                .withArgument("x-dead-letter-routing-key", CLOSE_KEY)
                .build();
    }

    @Bean
    public Binding closeBinding() {
        return BindingBuilder.bind(orderCloseQueue()).to(orderDlx()).with(CLOSE_KEY);
    }

    @Bean
    public DirectExchange closeDlx() { return new DirectExchange(CLOSE_DLX); }

    @Bean
    public Queue parkingQueue() { return QueueBuilder.durable(PARKING_QUEUE).build(); }

    @Bean
    public Binding parkingBinding() {
        return BindingBuilder.bind(parkingQueue()).to(closeDlx()).with(CLOSE_KEY);
    }
}
# sample/mini-shop-boot/src/main/resources/application.yml(节选):① 生产者确认
spring:
  rabbitmq:
    host: ${RABBITMQ_HOST:localhost}
    # ⚠️ guest 只允许 localhost 连接:跨容器/跨机必须自定义用户(compose 已创建 minishop)
    username: ${RABBITMQ_USER:minishop}
    password: ${RABBITMQ_PASSWORD:minishop123}
    publisher-confirm-type: correlated   # 异步 confirm 回执
    publisher-returns: true
    listener:
      simple:
        acknowledge-mode: manual         # ③ 消费者手动 ACK

⚠️ 手动 ACK 场景不要再配 listener 的 retry(Spring 的容器级重试):retry 是容器替你自动重投,而手动 ACK 的重试策略是你自己在消费代码里决定的(nack 不重投 + 死信进停车场)。两条路径混用会让消息被容器悄悄重投,绕开你的死信设计——所以 sample 的 listener 配置里没有 retry 块。

// 生产者 ConfirmCallback:sample 里注册在 OrderEventToMqBridge 的构造器(见第二节),
// broker 未确认时记 ERROR 人工介入;生产可配合「消息表」补偿重发(本地消息表思想)
rabbit.setConfirmCallback((correlation, ack, cause) -> {
    if (!ack) {
        log.error("消息未被 broker 确认: {}, cause={}", correlation, cause);
    }
});
// 消费者(sample 的 OrderCloseConsumer 节选):⚠️ 监听的是 order.close.q——
// order.create.q 是纯 TTL 延迟装置,没有任何消费者;TTL 到期的死信才到达关单队列
@RabbitListener(queues = "order.close.q", ackMode = "MANUAL")
public void onClose(Message message, Channel channel) throws Exception {
    long tag = message.getMessageProperties().getDeliveryTag();
    try {
        Map<?, ?> payload = json.readValue(new String(message.getBody(), StandardCharsets.UTF_8), Map.class);
        boolean closed = orderService.closeIfCreated(String.valueOf(payload.get("orderNo")));
        // closeIfCreated 是条件 UPDATE:非 CREATED 自动跳过(幂等,第一层防线)
        channel.basicAck(tag, false);                    // 成功:确认
    } catch (Exception e) {
        log.error("关单消费失败, 转停车场队列人工处理", e);
        channel.basicNack(tag, false, false);            // 失败:不重投,死信进 order.close.parking.q
    }
}

1.4 延迟关单:死信队列版

RabbitMQ 原生不支持延迟队列,标准姿势是 TTL + 死信(DLX):消息进了带 TTL 的队列不消费,到期后自动转发到死信交换机,绑定到「关单队列」的消费者收到的就是「到期订单」。

下单成功 ──> order.create.x ──> order.create.q(TTL = order-timeout-minutes,无消费者)
                    │ 配置的支付窗口(默认 15 分钟)后 TTL 到期
                    ▼
             order.dlx(死信交换机)
                    ▼
             order.close.q ──> 关单消费者:条件 UPDATE → CREATED 才关单回补库存
                    │ 消费异常 nack(不重投)
                    ▼
             order.close.parking.q(停车场:人工兜底)

对照第 32 章 DelayQueue 三大缺陷逐条被解决:持久化(重启不丢)、集群共享(多实例不重不漏)、消费速率可控(削峰)。

[!TIP] TTL + DLX 的队头阻塞问题(前面消息 TTL 更长会挡住后面的)在「统一延迟时长」场景不存在——订单统一按 order-timeout-minutes 延迟,完美匹配。需要任意延迟时长时用延迟插件 rabbitmq_delayed_message_exchange(进阶了解)。

1.5 消费幂等:重复投递的必然与解法

MQ 的可靠性承诺是「至少一次」(at-least-once):网络抖动、ACK 丢失都会导致重复投递。幂等设计不是可选项。

三层防线(由弱到强):

  1. 业务状态机天然幂等:关单前查状态,非 CREATED 直接跳过——32 章「不删任务,让任务可跳过」的兑现;
  2. 唯一键兜底:支付流水表 UNIQUE KEY(order_no),重复插入直接被数据库拒绝(DuplicateKeyException 当成功处理);
  3. 去重表通用方案:消费前 INSERT idempotent_record(msg_id) 抢占唯一键,插入成功才处理业务,冲突即说明消费过。
// 幂等消费骨架(支付回调,34 章直接复用)
@Transactional(rollbackFor = Exception.class)
public void handleCallback(PayCallbackDTO cb) {
    try {
        idempotentMapper.insert(new IdempotentRecord(cb.msgId(), "PAY_CALLBACK"));   // 唯一键抢占
    } catch (DuplicateKeyException e) {
        log.warn("重复回调,幂等跳过: msgId={}", cb.msgId());
        return;                                                                       // 已处理过,静默成功
    }
    // ...验证签 → 更新订单状态 → 记流水(唯一键第三层兜底)
}

二、示例代码:下单发消息全链路

// sample/mini-shop-boot/src/main/java/com/example/minishop/infra/mq/OrderEventToMqBridge.java(节选)
// 生产:事务提交后才发(22 章 AFTER_COMMIT 事件桥接,保证「订单已入库」才发消息)
@Component
public class OrderEventToMqBridge {

    private final RabbitTemplate rabbit;
    private final JsonMapper json;   // Boot 4 = Jackson 3(tools.jackson)

    public OrderEventToMqBridge(RabbitTemplate rabbit, JsonMapper json) {
        this.rabbit = rabbit;
        this.json = json;
        // ① 可靠投递第一防线:broker 未确认时记 ERROR 人工介入
        rabbit.setConfirmCallback((correlationData, ack, cause) -> {
            if (!ack) {
                log.error("消息未被 broker 确认, correlationData={}, cause={}", correlationData, cause);
            }
        });
    }

    /** 每个订单只发一条消息:延迟关单任务(TTL 到期死信进关单队列) */
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void on(OrderCreatedEvent event) {
        String msgId = UUID.randomUUID().toString();   // 报文内 msgId 与消息属性 setMessageId 同源
        String payload = json.writeValueAsString(Map.of(
                "orderId", event.orderId(), "orderNo", event.orderNo(),
                "userId", event.userId(), "payableCents", event.payableCents(),
                "msgId", msgId));
        rabbit.convertAndSend(RabbitConfig.ORDER_EXCHANGE, RabbitConfig.ORDER_KEY, payload, m -> {
            m.getMessageProperties().setMessageId(msgId);           // 消费幂等的凭据
            String traceId = MDC.get("traceId");
            if (traceId != null) m.getMessageProperties().setHeader("X-Trace-Id", traceId);   // 27 章跨 MQ 透传
            return m;
        });
    }
}

时序一句话:下单事务提交 → 事件触发 → 发一条「延迟关单任务」消息(进 TTL 队列,到期死信进关单队列)→ 关单消费者幂等处理。sample 的拓扑里主队列就是纯 TTL 延迟装置,一单只投一条消息;「下单成功通知用户」这类下游扩展应另起独立的 topic 交换机(生产扩展方向),不要往延迟队列里塞第二种消息。34 章把这条链完整跑通并接上压测。


三、常见坑 ⚠️

  • ⚠️ 消息体放复杂对象:序列化兼容性噩梦。消息体只放 id + 轻量字段,细节由消费者自查库。
  • ⚠️ 无 msgId:无法幂等。每条消息必须有全局唯一 id(UUID/雪花,16 章思考题回收)。
  • ⚠️ 消费者先干活后判断幂等:抢占去重记录必须是第一步,否则并发重复消息同时过闸。
  • ⚠️ NACK 无限重投: poison message(必失败消息)把队列打爆。重试上限 + 死信人工处理。
  • ⚠️ 在消费者里做超大事务:消费慢 → 消息积压 → 队列爆炸。消费逻辑与 22 章事务纪律同源:只做快操作。
  • ⚠️ 队列/交换机在代码里随手声明:启动自动声明(RabbitConfig 统一)+ 环境隔离(命名带环境前缀),避免「测试环境声明,生产环境不存在」。

四、调试技巧

  • 管理台 localhost:15672:Queues 页看消息数(积压告警第一现场)、Consumers 是否在线、消息速率曲线。
  • 消息没到:按链路查——ConfirmCallback 有没有失败回调?Exchange 是否存在(不存在消息静默丢弃,配 publisher-returns + ReturnCallback 兜底)?routing key 与绑定是否对上?
  • 本地重放:管理台队列页 Get Message 直接查看/重发消息,排障神器。

五、最佳实践

  • 消息契约(字段、msgId、版本号)写进文档并评审;消息结构变更优先「新增字段」不动旧字段。
  • 幂等三件套默认配置在所有消费者:msgId 去重表 + 业务唯一键 + 状态机校验。
  • 消费者监控三指标:积压量、消费耗时、失败率(27 章接 Actuator 指标)。
  • 「发消息」永远发生在事务提交之后;需要「事务和消息原子」的强场景用本地消息表(22/34 章)。

六、本章小结

  • MQ 三动机(异步/削峰/解耦)与三代价(最终一致/运维/排障)。
  • 可靠投递三防线:confirm、持久化、手动 ACK;丢失排查按三段走。
  • TTL + DLX = 延迟关单,正好补掉 DelayQueue 的三大缺陷。
  • 幂等是 MQ 时代的生存技能:msgId 去重表 + 业务唯一键 + 状态机校验三层防线。

下一章安全与鉴权:JWT 登录、Spring Security 7 与越权防护,给 mini-shop 装上门禁。


七、动手练习

基础题

  1. 在管理台手工声明 test.q 并发一条消息,写消费者打印后 ACK;再写一个必失败消费者(nack 不重投 + 给队列配死信交换机),观察消息进入死信/停车场队列的全过程。
  2. 跑通 TTL + DLX 链路:往 order.create.q 塞一条 TTL 10s 的消息,10 秒后在 order.close.q 的消费者里收到——把「死信头字段」(x-death)打印出来看路由轨迹。

进阶题

  1. 实现 idempotent_record 表 + 幂等消费者骨架,写测试:同一 msgId 的消息投递两次,业务处理(如插入订单)只发生一次。
  2. 给关单消费者加「状态机校验」:非 CREATED 订单直接 ACK 跳过;模拟「先支付后关单消息到达」的乱序场景验证不误关。

思考题

  1. 「本地消息表」方案:业务与消息记录同库同事务落库,后台任务扫表补偿发送。对比本章「AFTER_COMMIT 直接发」:多出什么保障、多出什么成本?什么时候必须升级到消息表?(提示:发消息本身失败、Broker 长时间不可用、跨库数据源。)

📄 第 26 章 安全与鉴权:JWT、Spring Security 7 与常见 Web 漏洞 26-security-and-auth.md

阶段:阶段 3 · 企业级开发 | 预计阅读 75 min | 代码落地模块:sample/mini-shop-boot(infra/security) 前置:第 16 章 摘要与 HMAC、第 23 章 接口规范 | 相关章节:第 34 章 实战落点:JWT 登录、过滤器解析用户进 ThreadLocal、订单归属校验防水平越权

学习目标

读完本章,你应该能够:

  • [ ] 解释认证(你是谁)与授权(你能干什么)的分工。
  • [ ] 用 jjwt 签发与校验 JWT,说清它「无状态」的代价(无法主动吊销)。
  • [ ] 用 Spring Security 7(Lambda DSL)保护接口,放行公开路径。
  • [ ] 把用户身份放进 ThreadLocal 并在请求结束时清理(第 13 章纪律兑现)。
  • [ ] 识别并防御水平/垂直越权、SQL 注入、XSS、CSRF。

一、核心概念

1.1 认证与授权

  • 认证(Authentication):你是谁 → 登录(用户名密码 → 令牌)。
  • 授权(Authorization):你能干什么 → 接口权限、数据归属。

Web 会话的两种流派:

方案 原理 优点 代价
Session + Cookie 服务端存会话,Cookie 带 sessionId 可随时踢人下线 服务端有状态,集群要共享会话
JWT(JSON Web Token) 服务端签发自包含令牌,请求携带,签名验证 无状态、跨服务友好 签发后无法主动作废(只能等过期/黑名单)

mini-shop 选 JWT(前后端分离 + 无状态横向扩容的典型选择),并接受其代价:敏感操作(改密码)配合「令牌版本号」或短有效期 + 刷新令牌(本书教学简化为 2 小时短令牌)。

1.2 JWT 结构与签发校验

header.payload.signature
eyJhbGciOi...  .  eyJzdWIiOiIxMDAi...  .  SflKxwRJSMeK...
       ↑ Base64URL 编码(不是加密!内容可被任何人解码查看)
       ↑ signature = HMAC(header+payload, 密钥) —— 防篡改(第 16 章 HMAC 思想)

[!WARNING] JWT 是签名不是加密:payload 里的 userId、角色人人可见。敏感数据绝不放进 JWT。

// sample/mini-shop-boot/src/main/java/com/example/minishop/infra/security/JwtService.java
package com.example.minishop.infra.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
import io.jsonwebtoken.security.Keys;
import java.time.Duration;
import java.util.Date;

/** JWT 签发与校验(密钥来自配置,绝不硬编码——19 章配置纪律) */
public class JwtService {

    private final SecretKey key;
    private final Duration ttl;

    public JwtService(String secret, Duration ttl) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes(java.nio.charset.StandardCharsets.UTF_8));
        this.ttl = ttl;
    }

    public String issue(Long userId, String username) {
        Date now = new Date();
        return Jwts.builder()
                .subject(String.valueOf(userId))              // sub:标准声明放主键
                .claim("name", username)
                .issuedAt(now)
                .expiration(new Date(now.getTime() + ttl.toMillis()))
                .signWith(key)
                .compact();
    }

    /** 校验签名与有效期;非法/过期抛 JwtException,由调用方转 401 */
    public Claims parse(String token) {
        return Jwts.parser().verifyWith(key).build()
                .parseSignedClaims(token).getPayload();
    }
}

1.3 Spring Security 7:Lambda DSL 配置

// sample/mini-shop-boot/src/main/java/com/example/minishop/infra/security/SecurityConfig.java
package com.example.minishop.infra.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService) throws Exception {
        http
            .csrf(csrf -> csrf.disable())                       // JWT 走 Header,无 Cookie 会话,CSRF 免疫;见 1.5
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**", "/swagger-ui/**", "/v3/api-docs/**",
                        "/actuator/health/**", "/actuator/prometheus").permitAll()
                .requestMatchers("/api/pay/callback").permitAll()   // 网关回调:走 HMAC 验签而非登录态(16/34 章)
                .anyRequest().authenticated())                  // 其余全部要登录
            .exceptionHandling(ex -> ex
                // 未带令牌/令牌无效:401 + Result JSON(默认会回 403 空体,契约不统一)
                .authenticationEntryPoint((req, res, e) -> {
                    res.setStatus(401);
                    res.setContentType(MediaType.APPLICATION_JSON_VALUE);
                    res.setCharacterEncoding("UTF-8");
                    res.getWriter().write("{\"code\":40101,\"message\":\"未登录或登录已过期\"}");
                }))
            .addFilterBefore(new JwtAuthFilter(jwtService),
                    UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

[!WARNING] Security 7(Boot 4 配套)全面 Lambda DSL:老教程的 WebSecurityConfigurerAdapter、antMatchers() 已被删除/更名(requestMatchers)。网上抄来的 Boot 2 Security 代码在 Boot 4 下必然编译失败,以本节风格为准。

// sample/mini-shop-boot/src/main/java/com/example/minishop/infra/security/JwtAuthFilter.java(节选)
/** 每请求一次:解析令牌 → UserContext + SecurityContext → finally 清理(第 13 章纪律) */
public class JwtAuthFilter extends OncePerRequestFilter {

    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain)
            throws ServletException, IOException {
        String header = req.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            try {
                Claims claims = jwtService.parse(header.substring(7));
                Long userId = Long.parseLong(claims.getSubject());

                UserContext.set(userId);                      // ThreadLocal:业务层随手取当前用户
                // ⚠️ 必须两处都写:SecurityContextHolder 给 Spring Security 的授权检查用——
                // 只写 UserContext 时,anyRequest().authenticated() 会对合法令牌也返回 401
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(userId, null,
                                List.of(new SimpleGrantedAuthority("ROLE_USER")));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            } catch (Exception e) {
                // 令牌无效/过期:不在此处应答,清干净继续走链路,
                // 由 SecurityConfig 的 authenticationEntryPoint 统一回 401
                UserContext.clear();
                SecurityContextHolder.clearContext();
            }
        }
        try {
            chain.doFilter(req, resp);
        } finally {
            UserContext.clear();                              // 线程复用前必须清理:防串号
            SecurityContextHolder.clearContext();
        }
    }
}

1.4 越权:后端事故的头号来源

  • 水平越权:用户 A 拿用户 B 的数据。GET /api/orders/1002(1002 是别人的订单)——必须校验资源归属:
// sample 的 OrderService#detail:按订单号查,归属以令牌为准
public OrderVO detail(String orderNo) {
    long userId = UserContext.require();                       // 从 ThreadLocal 取(空则抛 40101)
    OrderEntity order = findByNo(orderNo);
    if (!order.getUserId().equals(userId)) {
        throw new BizException(ErrorCode.FORBIDDEN);           // 40301:不暴露「订单存在但不是你的」
    }
    return OrderVO.from(order);
}
  • 垂直越权:普通用户调管理员接口。本书单角色(登录即用户),真实系统用 Security 的 hasRole("ADMIN") 或注解 @PreAuthorize。

1.5 常见 Web 漏洞与防御速查

漏洞 原理 防御
SQL 注入 拼接 SQL 改变语义(' OR '1'='1) 只走预编译参数(MyBatis #{};${} 仅用于白名单列名/排序字段);21 章 setSql 拼接数字处要严格校验
XSS 恶意脚本随内容入库,他人浏览时执行 输入过滤 + 输出转义;富文本用白名单净化库
CSRF 借用户浏览器 Cookie 冒发请求 JWT 放 Header(不进 Cookie)天然免疫;若用 Cookie 会话则开 CSRF Token
敏感信息泄露 日志/响应/异常吐密码、身份证 日志脱敏(27 章)、异常统一话术(23 章)、VO 白名单字段(20 章)
暴力破解 登录接口被撞库 登录失败次数限制 + 验证码(教学链路从简,生产必配)

密码存储铁律:BCrypt 慢哈希(自带盐,同密码每次哈希不同),绝不明文、绝不 MD5:

// 依赖 spring-security-crypto(Security 全家桶里最轻的一块)
PasswordEncoder encoder = new BCryptPasswordEncoder();
String hash = encoder.encode(rawPassword);                  // 注册时
boolean ok = encoder.matches(rawPassword, hashFromDb);      // 登录时(常量时间比较)

二、示例代码:登录与订单归属校验

// sample/mini-shop-boot/src/main/java/com/example/minishop/controller/AuthController.java
@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final UserService userService;
    private final JwtService jwtService;

    @PostMapping("/login")
    public Result<String> login(@RequestBody @Valid LoginDTO dto) {
        // 单次查询完成「找用户 + 验密码」(BCrypt matches,失败统一话术抛 NOT_LOGIN)
        UserEntity user = userService.login(dto.username(), dto.password());
        return Result.ok(jwtService.issue(user.getId(), user.getUsername()));   // 前端存起来,后续带 Authorization: Bearer xxx
    }
}
# 联调三连
curl -X POST localhost:8080/api/auth/login -H 'Content-Type: application/json' \
     -d '{"username":"alice","password":"Passw0rd!"}'
curl localhost:8080/api/orders -H 'Authorization: Bearer <上一步的token>'
curl localhost:8080/api/orders/202609290000000002 -H 'Authorization: Bearer <alice的token>'   # 别人的订单 → 40301

三、常见坑 ⚠️

  • ⚠️ JWT 密钥硬编码/进 Git:密钥进配置中心/环境变量(19 章),泄露 = 任何人可伪造任意用户。
  • ⚠️ payload 放敏感信息:JWT 可被任何人解码。只放 userId 与非敏感展示字段。
  • ⚠️ 只在前端隐藏按钮当权限:前端藏的是入口,后端校验才是门禁——每个接口独立鉴权,不信任任何上游。
  • ⚠️ 查详情不校验归属:水平越权(安全审计必查项)。一切「带 id 的资源读取」默认要验归属。
  • ⚠️ ${} 拼接用户输入进 SQL:注入。排序/列名白名单映射。
  • ⚠️ 自定义过滤器只写 ThreadLocal 不写 SecurityContext:anyRequest().authenticated() 的判定看的是 SecurityContextHolder——业务层虽然能从 UserContext 取到人,Security 却认为未认证,拿着合法令牌也吃 401。两处都要写(本工程 JwtAuthFilter 已落地)。
  • ⚠️ ThreadLocal 忘清理:线程复用串号(13 章),过滤器 finally 统一 remove。
  • ⚠️ 回调接口图省事关验签:/api/pay/callback permitAll 的前提是HMAC 验签(16 章),两头缺一不可。

四、调试技巧

  • 401 排查三连:请求头 Authorization 格式(Bearer 带空格)、令牌是否过期、密钥两端是否一致。
  • jwt.io 粘贴令牌肉眼查 payload(记住:人人可见)。
  • Security 日志:logging.level.org.springframework.security=DEBUG 看每个请求过过滤器的判定过程。

五、最佳实践

  • 安全清单写进 CI/Review 模板:归属校验、参数化 SQL、脱敏、密钥来源、回调验签。
  • 令牌短有效期(2h)+ 关键操作二次校验(支付再验密码/短信,教学从略)。
  • 公开路径白名单最小化:permitAll 每多一行,多一次安全评审。
  • 安全不是一章内容而是一组习惯:本节的每条「防御」都对应一条 Review 检查项。

六、本章小结

  • 认证管身份、授权管权限;JWT 无状态好用但不可吊销,敏感操作配合短时效。
  • Security 7 Lambda DSL + 自定义 JWT 过滤器 + ThreadLocal 用户上下文(finally 清理)。
  • 越权防御:资源归属校验(水平)、角色控制(垂直),后端独立校验不信任前端。
  • 四大漏洞防御各有抓手:参数化 SQL、输出转义、Header 令牌、统一脱敏。

下一章日志与监控:让每一笔订单的问题都能靠 traceId 十秒定位。


七、动手练习

基础题

  1. 实现登录接口与 JwtAuthFilter:登录拿令牌 → 访问受保护接口 → 篡改令牌最后一位重放,观察 401。
  2. 给「查订单详情」加归属校验,用两个用户互相访问对方订单,验证 40301。

进阶题

  1. 实现 UserContext(ThreadLocal 封装:set/get/require 三方法,require 为空抛 40101),改造 OrderService 全部方法不再从参数收 userId。
  2. 写一个「密码强度」自定义校验注解 @StrongPassword(长度 ≥8 且含字母数字),挂在注册 DTO 上,边界数据全测一遍。

思考题

  1. JWT 无法主动吊销:用户改密码后旧令牌仍然有效 2 小时。给出至少两种缓解方案(短时效+刷新令牌、Redis 黑名单、令牌版本号对比)并分析各自成本。这个「无状态的代价」与第 24 章「缓存的最终一致」在工程哲学上有什么共同点?

📄 第 27 章 日志与监控:SLF4J/Logback、MDC traceId 与 Actuator 27-logging-and-monitoring.md

阶段:阶段 3 · 企业级开发 | 预计阅读 70 min | 代码落地模块:sample/mini-shop-boot(infra/observe) 前置:第 19 章、第 25 章 MQ | 相关章节:第 29 章 部署 | 第 35 章 实战落点:traceId 贯穿下单→MQ→回调、Logback 分级分文件、Actuator + Prometheus 指标

学习目标

读完本章,你应该能够:

  • [ ] 遵守日志五要素纪律:分级、占位符、上下文、脱敏、不打印敏感信息。
  • [ ] 配置 Logback 按级别分文件 + 滚动归档。
  • [ ] 用 MDC 实现 traceId 贯穿一次请求的全部日志,并跨 MQ 传递。
  • [ ] 暴露 Actuator 健康检查与自定义业务指标(下单 QPS/失败率)。
  • [ ] 用日志与指标回答「线上出了什么事、影响多大、根因在哪」。

一、核心概念

1.1 日志五纪律

// ① 分级:ERROR 需要人介入 / WARN 可自动恢复但值得看 / INFO 关键路径 / DEBUG 细节(生产关闭)
log.error("下单失败, orderId={}, reason={}", id, e.getMessage(), e);   // 异常对象作最后一个参数 → 打印堆栈
log.warn("库存不足拒绝, productId={}", pid);                            // 可预期业务拒绝:WARN 不带堆栈

// ② 占位符 {},不字符串拼接(级别未开时拼接白做)
log.debug("详细参数: {}", dto);

// ③ 关键路径必打:入口/出口/外部调用/状态变更(带业务主键)
log.info("订单创建成功, orderId={}, userId={}, amount={}", orderId, userId, amount);

// ④ 禁止敏感信息:密码、手机号全文、令牌。脱敏工具统一处理
log.info("用户登录, phone={}", Desensitize.mobile(phone));              // 138****8000

// ⑤ 用 SLF4J 门面 + Logback 实现;private static final Logger log 声明在类首

错误姿势三连(Review 必打回):e.printStackTrace()(进 stdout 不进日志系统)、log.info("xx" + obj)(拼接)、吞异常不打日志(06 章)。

1.2 Logback:分级分文件 + 滚动

<!-- sample/mini-shop-boot/src/main/resources/logback-spring.xml(节选,完整文件见 sample) -->
<configuration>
    <property name="PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] %logger{36} - %msg%n"/>

    <!-- CONSOLE appender 此处省略(完整文件见 sample):开发期看 stdout,生产日志进文件 -->
    <appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/app-info.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>logs/app-info.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>14</maxHistory>          <!-- 保留 14 天,磁盘预算自己算 -->
            <totalSizeCap>5GB</totalSizeCap>
        </rollingPolicy>
        <encoder><pattern>${PATTERN}</pattern><charset>UTF-8</charset></encoder>
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>INFO</level><onMatch>ACCEPT</onMatch><onMismatch>DENY</onMismatch>
        </filter>
    </appender>

    <appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/app-error.log</file>
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter"><level>ERROR</level></filter>
        <encoder><pattern>${PATTERN}</pattern><charset>UTF-8</charset></encoder>
        <!-- 滚动策略同上,略 -->
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE_INFO"/>
        <appender-ref ref="FILE_ERROR"/>
    </root>
</configuration>

注意 PATTERN 里的 %X{traceId:-}——从 MDC 取 traceId,没有则空。这是链路排查的核心机关,见 1.3。

1.3 MDC traceId:一次请求一条线

MDC(Mapped Diagnostic Context)是日志框架的线程级上下文(内部就是 ThreadLocal,第 13 章纪律同样适用:用完必须清)。做法:

// sample/mini-shop-boot/src/main/java/com/example/minishop/infra/observe/TraceIdFilter.java
/** 入口生成 traceId → MDC → 响应头回传;finally 清理 */
@Component
@Order(-200)   // 必须排在 Spring Security(-100)之前:鉴权日志里才有 traceId(26 章过滤器顺序意识)
public class TraceIdFilter extends OncePerRequestFilter {

    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain)
            throws ServletException, IOException {
        String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))
                .filter(id -> id.matches("[0-9a-zA-Z\\-]{8,32}"))   // 客户端可注入任意头:长度与字符集收口
                .orElseGet(() -> UUID.randomUUID().toString().replace("-", "").substring(0, 16));   // JDK 原生,无工具库
        MDC.put("traceId", traceId);
        resp.setHeader("X-Trace-Id", traceId);
        try {
            chain.doFilter(req, resp);
        } finally {
            MDC.clear();                                        // 线程复用前清理
        }
    }
}

从此每条日志自动带 traceId:grep "2026092900000001" app-info.log 一把捞出这单的完整日志。跨 MQ 传递(异步链路不断线):

// 发送方:traceId 塞进消息头
rabbit.convertAndSend(exchange, key, payload, m -> {
    m.getMessageProperties().setHeader("X-Trace-Id", MDC.get("traceId"));
    return m;
});

// 消费方:取出来放回 MDC(@RabbitListener 方法第一行;finally 清理)
MDC.put("traceId", (String) message.getMessageProperties().getHeader("X-Trace-Id"));

HTTP 客户端出站同理:调下游服务时把 X-Trace-Id 带上(第 15 章统一封装的又一职责)——traceId 一路传,日志一路跟,微服务时代的排查基础。

1.4 Actuator:健康检查与指标

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus   # 生产最小暴露面:不要 include: *
  endpoint:
    health:
      show-details: when-authorized
  metrics:
    tags:
      application: mini-shop       # 指标打应用标签,多服务聚合时区分
端点 用途
/actuator/health 存活探测(K8s/负载均衡健康检查挂这里;DB/Redis 异常会反映在 detail)
/actuator/metrics/http.server.requests 内置 HTTP 指标(QPS、耗时分布)
/actuator/prometheus Prometheus 抓取格式文本(配合 micrometer-registry-prometheus 依赖)

自定义业务指标(Micrometer):

// 下单计数与耗时:给「业务健康度」一个数字
private final Counter orderCreated;
private final Timer orderTimer;

public OrderMetrics(MeterRegistry registry) {
    this.orderCreated = Counter.builder("shop.order.created")
            .description("下单成功数").register(registry);
    this.orderTimer = Timer.builder("shop.order.create.time")
            .description("下单耗时").register(registry);
}

// 业务方法里埋点
orderTimer.recordCallable(() -> doCreate(dto));
orderCreated.increment();

[!TIP] 指标(聚合数字,看趋势与告警)、日志(离散事件,看细节与根因)、链路(跨服务调用拓扑,进阶)是可观测三支柱。本书落地前两个,第三支柱(OpenTelemetry/SkyWalking)在附录 92 留延伸阅读。


二、示例代码:一次下单的完整观测

# 1) 压一批下单请求后,按 traceId 追一笔
grep "5f3a9c" logs/app-info.log
2026-09-29 22:15:01.101 [http-nio-8080-exec-3] INFO  [5f3a9c] c.e.m.s.OrderService - 订单创建成功, orderNo=202609290000000001
2026-09-29 22:15:01.102 [http-nio-8080-exec-3] INFO  [5f3a9c] c.e.m.s.OrderService - 订单延迟关单任务已投递, orderNo=202609290000000001, msgId=...

# 2) MQ 消费侧同一 traceId(消息头透传,关单队列)
2026-09-29 22:30:01.290 [org.springframework.amqp...] INFO [5f3a9c] c.e.m.i.m.OrderCloseConsumer - 延迟关单处理完成, orderNo=202609290000000001, closed=false

# 3) 指标侧
curl localhost:8080/actuator/metrics/shop.order.created
# {"name":"shop.order.created","measurements":[{"statistic":"COUNT","value":1523}]}

排障故事线:告警群看到 shop.order.created 增速掉零 → /actuator/health 发现 DB 连接异常 → error.log 找最近 traceId → 定位到某条慢 SQL。日志给根因、指标给影响面,缺一不可。


三、常见坑 ⚠️

  • ⚠️ 日志没有业务主键:"下单成功" 三个月后的你恨死这句话。可检索字段(orderId/userId)必须出现。
  • ⚠️ MDC 只 put 不 clear:线程池复用,下个请求顶着上个请求的 traceId。finally 清理。
  • ⚠️ 异步线程丢上下文:@Async/新开线程里 MDC 为空。提交任务前手动拷贝 MDC(装饰 Runnable 或用框架的 TaskDecorator)。
  • ⚠️ ERROR 轰炸:把可预期业务拒绝打成 ERROR,值班群全是噪音,真故障被淹没。ERROR = 需要人介入。
  • ⚠️ Actuator 全量暴露:include: * 把 env/heapdump 挂公网 = 泄密。最小暴露 + 管理端口隔离。
  • ⚠️ 日志不打盘/打满盘:maxHistory/totalSizeCap 必配;logs/ 进 .gitignore。

四、调试技巧

  • 本地复现线上问题:把该 traceId 的日志按时间排序后通读,99% 的故事都能讲通。
  • logger 端点(dev 开)运行时调级别:POST /actuator/logger/com.example.minishop body {"configuredLevel":"DEBUG"},免重启开 debug。
  • 指标突发异常先看「部署/配置变更时间线」,八成是变更引入(黄金法则:先回滚再排查)。

五、最佳实践

  • 日志是给「三个月后的陌生人」看的:主键 + 动作 + 关键数值,人话。
  • traceId 从网关/前端一路透传(HTTP Header → MDC → MQ Header → 下游 Header),全链路一条线。
  • 指标先行:每个核心业务动作配一个 Counter/Timer,上线第一天就有基线数据。
  • 告警分级:ERROR 日志速率、订单成功率、MQ 积压三件套是最小告警组合。

六、本章小结

  • 五纪律:分级、占位符、上下文、脱敏、异常对象进日志。
  • Logback 分级分文件 + 滚动归档;ERROR 单独成文件是值班效率的关键。
  • MDC traceId + 透传(HTTP/MQ)= 全链路一条线;ThreadLocal 用完即清。
  • Actuator health 做探活、Micrometer 自定义业务指标做趋势;日志管根因、指标管影响面。

下一章测试策略:把前面散落的测试习惯升级成企业级测试体系(Mock/切片/Testcontainers)。


七、动手练习

基础题

  1. 接入 TraceIdFilter 与 logback-spring.xml,压一次下单,用 traceId 串出「入口→Service→MQ」三段日志。
  2. 给 OrderService 的日志补业务主键与脱敏(写一个 Desensitize.mobile 工具),Review 你此前所有日志语句。

进阶题

  1. 实现 MQ 侧 traceId 透传:发送方塞 Header、消费者取出放回 MDC 并 finally 清理;跨进程验证同一 traceId。
  2. 暴露 shop.order.created 计数与 shop.order.create.time 计时,压 100 单后在 /actuator/prometheus 输出里找到这两个指标并解读数值。

思考题

  1. 「ERROR 日志 = 告警」的直译做法(grep -c ERROR 超阈值就报警)在「一个上游故障触发下游 1 万条 ERROR」时会告警风暴。你会如何设计「聚合降噪」(按 traceId/错误指纹聚合计数、首条告警后续静默窗口)?这道题是把日志纪律升级为值班体系的第一步。

📄 第 28 章 测试策略:Mock、切片测试与 Testcontainers 28-testing-strategy.md

阶段:阶段 3 · 企业级开发 | 预计阅读 70 min | 代码落地模块:sample/mini-shop-boot(src/test) 前置:第 10 章 JUnit、第 18 章 IoC | 相关章节:第 35 章 实战落点:OrderService 单测 + 带真实 MySQL/Redis 的集成测试,35 章 CI 门禁跑它们

学习目标

读完本章,你应该能够:

  • [ ] 搭建测试金字塔:单元测试为主、切片测试适中、端到端少量。
  • [ ] 用 Mockito 打桩与验证,隔离外部依赖测 Service。
  • [ ] 用 @WebMvcTest 做 Web 层切片(MyBatis-Plus 生态对应 @MybatisPlusTest,思路同切片)做窄范围集成测试。
  • [ ] 用 Testcontainers 在测试里起真容器,消灭「测试环境数据不一致」。
  • [ ] 守住测试与生产的隔离纪律(独立库、无副作用、可重复)。

一、核心概念

1.1 测试金字塔

        ▲  端到端 E2E(少量:冒烟主链路,慢、脆)
       ▲▲  集成/切片(适量:Mapper 真库、Controller 层装配)
      ▲▲▲  单元测试(大量:Service/领域逻辑,毫秒级、无容器)

第 10 章说过单元测试的判据(断网断库毫秒级)。本章补两个问题:依赖数据库的代码怎么单测(Mock 换掉依赖)与**「换掉之后还信吗」**(集成测试补真实验证)。

1.2 Mockito:隔离依赖的三板斧

// sample/mini-shop-boot/src/test/java/com/example/minishop/service/OrderServiceTest.java(节选)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock StockManager stockManager;            // 假库存管理器:不碰 Redis/DB
    @Mock ProductMapper productMapper;          // 假 Mapper:不碰数据库
    @Mock OrderMapper orderMapper;
    @Mock OrderItemMapper orderItemMapper;
    @Mock ApplicationEventPublisher publisher;
    @Mock OrderMetrics metrics;

    private OrderService orderService;

    // sample 的 OrderService 构造器有 8 个参数(含 ShopProperties/Clock),
    // @InjectMocks 不好使,改为 @BeforeEach 手工构造——依赖全显式,重排构造参数时编译器兜底
    @BeforeEach
    void setUp() {
        var props = new ShopProperties(15, 10,
                new ShopProperties.Jwt("0123456789abcdef0123456789abcdef", Duration.ofHours(2)));
        orderService = new OrderService(stockManager, orderMapper, orderItemMapper,
                productMapper, publisher, metrics, Clock.systemUTC(), props);
        UserContext.set(100L);                  // 模拟已登录:createOrder 从令牌取下单人
    }

    @AfterEach
    void tearDown() {
        UserContext.clear();                    // ThreadLocal 测试也要清理(第 13 章纪律)
    }

    @Test
    @DisplayName("库存不足时抛异常且不落单")
    void shouldThrow_whenStockNotEnough() {
        // 打桩返回 Deducted 台账(34 章):success=false 表示扣减失败
        given(stockManager.deduct(1L, 2)).willReturn(StockManager.Deducted.FAIL);

        assertThatThrownBy(() -> orderService.createOrder(dto()))
                .isInstanceOf(StockNotEnoughException.class);   // When + Then

        then(orderMapper).should(never()).insert(any());       // 验证交互:绝不该落单
    }

    @Test
    @DisplayName("下单成功发布事件")
    void shouldPublishEvent_whenCreated() {
        given(stockManager.deduct(1L, 2)).willReturn(new StockManager.Deducted(true, 3));

        orderService.createOrder(dto());

        then(publisher).should().publishEvent(any(OrderCreatedEvent.class));
    }
}

Mockito 心法三句:

  1. 打桩(given/when):定义「依赖被这么调时返回什么」;
  2. 验证(verify/then.should):断言「与依赖的交互发生了没有、几次、什么参数」;
  3. 克制:只 Mock 你拥有的接口(Mapper/外部客户端),别 Mock 值对象与被测对象自己——到处是 Mock 的测试测不出任何真东西。

[!TIP] 遇到 Json.write(...) 这类静态调用无法常规打桩时:优先重构为可注入的组件(依赖注入的又一收益);确要 mock 静态方法,Mockito 5 起内联引擎已是默认,直接用 Mockito.mockStatic(...)(try-with-resources 包裹,用完即关)。

1.3 切片测试:只装一部分 Spring

@SpringBootTest 启动全容器慢且重。Spring 提供按层切片:

// Web 层切片(sample 的 OrderControllerTest 节选):
// 只装 Controller + 全局异常 + 校验,Mock 掉 Service
// ⚠️ Boot 4 两个迁移点:@WebMvcTest 移到 spring-boot-webmvc-test(新包名
// org.springframework.boot.webmvc.test.autoconfigure);@MockBean 已移除,改用 @MockitoBean
@WebMvcTest(OrderController.class)
@AutoConfigureMockMvc(addFilters = false)   // 绕过 Security 过滤器链,专注测 Controller 契约
class OrderControllerTest {

    @Autowired MockMvc mockMvc;
    @MockitoBean OrderService orderService;   // org.springframework.test.context.bean.override.mockito

    @Test
    void shouldReturn400_whenQuantityZero() throws Exception {
        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"items\":[{\"productId\":1,\"quantity\":0}]}"))   // body 无 userId(26 章)
                .andExpect(status().isBadRequest())                     // 校验拦截(23 章链路)
                .andExpect(jsonPath("$.code").value(40001));
    }

    @Test
    void shouldReturnBizError_whenStockNotEnough() throws Exception {
        given(orderService.createOrder(any())).willThrow(new StockNotEnoughException(1L, 2, 0));
        mockMvc.perform(post("/api/orders").contentType(MediaType.APPLICATION_JSON)
                        .content("{\"items\":[{\"productId\":1,\"quantity\":2}]}"))
                .andExpect(jsonPath("$.code").value(40901));            // 全局异常处理器生效
    }
}

切片测试的定位:验证装配与协议链路(路由、校验、异常映射、序列化),不验证业务逻辑(那是单测的事)。

1.4 Testcontainers:真中间件的集成测试

Mapper 的 SQL 写对没有,Mock 测不出来——必须打真库。Testcontainers 在测试期用 Docker 起一次性容器,跑完即焚:

// sample/mini-shop-boot/src/test/java/com/example/minishop/mapper/OrderMapperIT.java(节选)
@SpringBootTest
@Testcontainers
class OrderMapperIT {

    @Container
    @ServiceConnection                                   // Boot 3.1+:自动把容器连接信息注入数据源
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");

    @Autowired OrderMapper orderMapper;

    @Test
    void shouldInsertAndQuery() {
        OrderEntity order = sampleOrder();
        orderMapper.insert(order);
        assertThat(orderMapper.selectById(order.getId()).getOrderNo()).isEqualTo(order.getOrderNo());
    }
}

前置:本机 Docker(第 00 章已装)。命名约定 *IT(Integration Test)+ maven-failsafe 在 verify 阶段执行,与单测(surefire/test)分离——CI 里单测每次都跑、集成测试跑在有 Docker 的作业里(29 章)。

纪律三条:

  1. 测试库一次性:容器即起即焚,永不共享状态,测试天然可重复(F.I.R.S.T 的 R);
  2. 不许连生产/共享开发库:测试数据自造自灭;
  3. 数量克制:集成测试留给「SQL 正确性」「事务行为」这类 Mock 验不了的地方,别把金字塔倒过来。

二、示例代码:mini-shop 的测试矩阵

被测对象 层 方式 关键断言
OrderService 业务规则 单测 Mockito 打桩 Mapper/StockManager(UserContext.set 模拟登录) 异常路径不落单、事件发布、Redis 段回补
Money/状态机 单测 纯 JUnit(第 03/10 章) 边界与非法转移
OrderController 协议链路 切片 @WebMvcTest + @MockitoBean 校验 400、业务码 40901
OrderMapper SQL 集成 Testcontainers MySQL 插入查询一致、分页 SQL 带 LIMIT
关单消费者 集成 Testcontainers RabbitMQ(进阶) 状态机校验跳过非 CREATED

工程落地(mini-shop-boot/pom.xml 追加):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>   <!-- JUnit 6 + Mockito + AssertJ + MockMvc -->
    <scope>test</scope>
</dependency>
<dependency>
    <!-- Boot 4 起 WebMvc 切片独立成 spring-boot-webmvc-test(@WebMvcTest 新家) -->
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-webmvc-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>
    <scope>test</scope>
</dependency>

三、常见坑 ⚠️

  • ⚠️ @SpringBootTest 当默认:全部测试起全容器,套件跑 20 分钟。默认单测/切片,全容器留给端到端冒烟。
  • ⚠️ Mock 返回值与真实不符:桩返回 null 导致 NPE,还以为是业务 bug。打桩必须贴真实行为。
  • ⚠️ 验证过度:verify 了「每个依赖都被调用、参数逐字段相等」——实现一重构测试全崩。验证输出与关键交互,不验证实现细节。
  • ⚠️ 测试共享可变状态:static 缓存、顺序依赖(10 章老坑在切片测试里复发率最高)。
  • ⚠️ Testcontainers 拉镜像慢:CI 配镜像缓存/国内镜像源(兄弟教程 linux-server 有镜像加速专题,没读过可跳过)。
  • ⚠️ 测试里断言弱:assertThat(result).isNotNull() 等于没测。断言具体值与状态变化。

四、调试技巧

  • MockMvc 失败先看 .andDo(print()) 的请求/响应全量输出,多数是 JSON 路径写错。
  • 容器起不来:Docker Desktop 是否运行、端口/镜像拉取失败看容器日志(Testcontainers 日志里有完整 docker 输出)。
  • 单测想临时看 SQL:@SpringBootTest + MP SQL 日志(dev 配置),或直接去集成测试看。

五、最佳实践

  • 新增 Service 方法的第一件事:写它的单测骨架(红→绿→重构)。
  • 每修一个 bug,先写一条复现测试(防回归),再修——这是「测试资产」的滚雪球方式。
  • 测试命名 shouldXxx_whenYyy + @DisplayName 中文描述,报告可读。
  • CI 分层:PR 必跑单测 + 切片;集成测试跑在带 Docker 的作业;E2E 冒烟只在部署后(29 章)。

六、本章小结

  • 金字塔结构决定投入分布:单测量大管逻辑、切片管装配、集成管 SQL/事务、E2E 只冒烟。
  • Mockito 三板斧:打桩、验证、克制;@Mock + 构造器手工装配(或 @InjectMocks)让 Service 测试零容器。
  • @WebMvcTest 验证协议链路四件套(路由/校验/异常/序列化)。
  • Testcontainers 给「真中间件、假环境」的标准答案:一次性容器 + @ServiceConnection。
  • 测试纪律 = 生产纪律的预演:隔离、可重复、失败即阻断。

下一章部署与运维:把 mini-shop-boot 打成镜像,一条命令拉起全家桶,接上 CI。


七、动手练习

基础题

  1. 给 OrderService.createOrder 写两条 Mockito 单测(成功发布事件 / 库存不足不落单),运行毫秒级完成验证;再补一条「未登录(UserContext 为空)直接拒绝」。
  2. 写 OrderControllerTest 切片测试覆盖「校验失败 400」「业务拒绝 40901」「成功 200」三态。

进阶题

  1. 用 Testcontainers 写 ProductMapperIT:验证分页查询生成 LIMIT(观察 SQL 日志或断言返回条数 ≤ size)、逻辑删除后查询不到。
  2. 给「关单消费者」写集成测试:真 RabbitMQ 容器 + 投递一条非 CREATED 状态的关单消息,断言不回补库存且消息被 ACK。

思考题

  1. 「集成测试用真中间件(Testcontainers)」与「单测用 Mock」边界在哪里?列出三个「必须真件」与三个「必须 Mock」的场景,并回答:如果团队只能保留一种测试(单测或集成),你保哪种、为什么?(提示:想清楚哪一种更能拦住生产事故。)

📄 第 29 章 部署与运维:Docker 打包、Compose 编排与 CI 29-deployment-and-ci.md

阶段:阶段 3 · 企业级开发 | 预计阅读 70 min | 代码落地模块:sample/mini-shop-boot(Dockerfile/compose/CI) 前置:第 14 章 JVM 参数、第 17 章 Maven、第 27 章 监控 | 相关章节:第 35 章 实战落点:多阶段 Dockerfile、compose 全家桶、GitHub Actions 流水线——35 章收官验收的载体

学习目标

读完本章,你应该能够:

  • [ ] 写多阶段 Dockerfile:编译环境与运行环境分离,镜像瘦身。
  • [ ] 用 Compose 编排 app + MySQL + Redis + RabbitMQ,一条命令拉起全环境。
  • [ ] 落地优雅停机与 JVM 容器参数(14 章承诺的兑现)。
  • [ ] 搭一条 GitHub Actions 流水线:测试门禁 → 构建镜像 → 部署脚本。
  • [ ] 掌握「上线三板斧」:健康检查、滚动重启、快速回滚。

一、核心概念

1.1 镜像:应用的标准交付物

「在我机器上是好的」的终结者:把 JVM + 应用 + 依赖打成不可变镜像,任何环境运行一致。

# sample/mini-shop-boot/Dockerfile —— 多阶段构建
# ⚠️ 构建上下文必须是 sample/ 根目录(Dockerfile 要 COPY mini-shop-common 兄弟模块):
#    docker build -f mini-shop-boot/Dockerfile .
# 阶段一:构建(含 Maven 与全部依赖,最终镜像不带它)
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml ./
COPY mini-shop-common/pom.xml mini-shop-common/
COPY mini-shop-boot/pom.xml mini-shop-boot/
RUN mvn -B -pl mini-shop-common,mini-shop-boot dependency:go-offline   # 依赖层缓存:pom 不变则不重新下载
COPY mini-shop-common/src mini-shop-common/src        # 只 COPY src 目录,别 COPY . .(.dockerignore 之外的杂物全会进去)
COPY mini-shop-boot/src mini-shop-boot/src
RUN mvn -B clean package -DskipTests -pl mini-shop-boot -am

# 阶段二:运行(只有 JRE 与 jar,体积小、攻击面小)
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN groupadd -r app && useradd -r -g app app \
    && mkdir -p logs dump && chown -R app:app /app    # logs/dump 目录必须先建好归属:运行用户写不进 = 日志静默丢失
# healthcheck 用(基础镜像不带 curl)
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*
COPY --from=build /app/mini-shop-boot/target/mini-shop.jar app.jar
USER app
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:MaxMetaspaceSize=256m -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/dump/"
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

要点:

  1. 多阶段:builder 的 Maven/源码不进最终镜像(1GB+ → 300MB 级);
  2. 依赖层前置:dependency:go-offline 单独一层,源码改动不触发依赖重下,CI 提速显著;
  3. JVM 参数显式(14 章):容器内存感知的 MaxRAMPercentage + OOM dump + G1——堆随容器限额伸缩,不必每个环境手调 -Xmx;
  4. 非 root + 目录权限:logs/dump 目录要提前建好并 chown,否则应用用户没有写权限,日志与 OOM dump 静默丢失;
  5. 构建上下文:Dockerfile 在 mini-shop-boot/ 下但要 COPY 兄弟模块 mini-shop-common,所以构建命令必须以 sample/ 根为上下文(compose 里对应 context: . + dockerfile: mini-shop-boot/Dockerfile);配合根目录的 .dockerignore(排除 target/、.git/ 等)避免垃圾进镜像层。

1.2 Compose:一键全家桶

# sample/docker-compose.yml(第 00 章中间件版 + 应用,35 章直接复用)
services:
  app:
    build:
      context: .                          # 构建上下文 = sample 根(Dockerfile 要 COPY 兄弟模块)
      dockerfile: mini-shop-boot/Dockerfile
    ports: ["8080:8080"]
    environment:
      MYSQL_HOST: mysql
      MYSQL_USER: root
      MYSQL_PASSWORD: root123
      REDIS_HOST: redis
      RABBITMQ_HOST: rabbitmq
      RABBITMQ_USER: minishop             # ⚠️ guest 只允许 localhost,跨容器必被拒
      RABBITMQ_PASSWORD: minishop123
      JWT_SECRET: local-dev-secret-0123456789abcdef0123456789abcdef
      PAY_SECRET: local-dev-pay-secret
    depends_on:                           # 三个中间件都等 healthy:预热/建连不再抢跑
      mysql:    { condition: service_healthy }
      redis:    { condition: service_healthy }
      rabbitmq: { condition: service_healthy }
    healthcheck:
      test: ["CMD-SHELL", "curl -sf http://localhost:8080/actuator/health || exit 1"]
      interval: 15s
      timeout: 3s
      retries: 5
      start_period: 40s
    restart: unless-stopped

  mysql:
    image: mysql:8.0
    ports: ["3306:3306"]                  # 发布到宿主机:本机 mvn spring-boot:run 流程可直连
    environment: { MYSQL_ROOT_PASSWORD: root123, MYSQL_DATABASE: mini_shop }
    volumes:
      - mysql-data:/var/lib/mysql
      - ./mini-shop-boot/src/main/resources/db/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h localhost -p$$MYSQL_ROOT_PASSWORD"]
      interval: 5s
      retries: 10
    restart: unless-stopped

  redis:
    image: redis:8.0
    ports: ["6379:6379"]
    healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 5s, retries: 10 }
    restart: unless-stopped

  rabbitmq:
    image: rabbitmq:4.3-management
    ports: ["5672:5672", "15672:15672"]   # 管理台 minishop/minishop123
    environment: { RABBITMQ_DEFAULT_USER: minishop, RABBITMQ_DEFAULT_PASS: minishop123 }
    healthcheck: { test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"], interval: 10s, retries: 10 }
    restart: unless-stopped

volumes:
  mysql-data:

[!WARNING] 容器互访用服务名(mysql/redis),不是 localhost——每个容器有独立网络命名空间。配置走环境变量注入(sample 直接用 MYSQL_HOST/RABBITMQ_USER 等自定义占位符,见 18 章 application.yml),一份镜像跑遍 dev/prod。三个中间件端口同时发布到宿主机,是为了「compose 起中间件 + IDE 跑应用」的本机开发流程。

docker compose up -d --build     # 构建并拉起
docker compose ps                # healthcheck 状态 healthy 才算就绪
docker compose logs -f app       # 跟日志
docker compose down && docker compose up -d --build app   # 改代码后的快速重启(中间件不动)

1.3 优雅停机:不丢在途请求

docker compose down 默认先 SIGTERM 等 10s 再 SIGKILL。Spring 侧配合:

server:
  shutdown: graceful            # 收到停机信号:停止接新请求,等在途请求完成
spring:
  lifecycle:
    timeout-per-shutdown-phase: 20s

配套要点:负载均衡先把实例摘流量 → 优雅停机排空 → 退出。MQ 消费者停机时未 ACK 的消息会重投(25 章幂等在此兜底——可靠性的环闭环了)。

1.4 CI 流水线(GitHub Actions)

# sample/.github/workflows/ci.yml
name: ci
on:
  push:
    branches: [main]
  pull_request:

concurrency:                        # 同一 PR 新推送取消旧跑,省额度
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15             # 卡死的 job 别占着 runner 烧钱
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21', cache: maven }
      - name: 单元测试 + 切片测试(门禁:不过禁止合并)
        run: mvn -B clean test
  build:
    needs: test
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - name: 构建镜像(tag 用 commit sha,不用 latest)
        run: docker build -t mini-shop:${{ github.sha }} -f mini-shop-boot/Dockerfile .
      # 推送镜像仓库 + 部署脚本(教学环境略,思路:打版本 tag → 服务器 pull → compose up)

流水线哲学:每个 PR 至少过「编译 + 测试 + 镜像可构建」三关;部署是流水线的自然延伸(构建产物有唯一 tag——用 commit sha,不用 latest)。


二、示例代码:上线三板斧

# ① 部署(首次)
docker compose up -d --build

# ② 验证(健康检查 + 冒烟)
curl -f http://localhost:8080/actuator/health          # {"status":"UP"}
curl -f http://localhost:8080/api/products/1 >/dev/null && echo "冒烟通过"

# ③ 发布新版本(滚动 + 可回滚)
docker compose up -d --no-deps --build app             # 只重建应用,中间件不动
# 回滚:镜像 tag 是版本化的,指回旧 tag 即可(教学 compose 场景:git revert + rebuild)

运维检查单(部署后 5 分钟内):

  1. /actuator/health UP,且 DB/Redis/RabbitMQ 分项健康;
  2. 日志无启动 ERROR(27 章 error.log);
  3. 冒烟接口通过(下单 → 查询);
  4. 指标基线正常(QPS/耗时无突刺,27 章);
  5. 告警群静默 10 分钟后收工。

三、常见坑 ⚠️

  • ⚠️ 构建上下文搞错:Dockerfile 在 mini-shop-boot/ 下就顺手 docker build ./mini-shop-boot——Dockerfile 里 COPY 兄弟模块直接失败。多模块工程的上下文必须是仓库(sample)根:docker build -f mini-shop-boot/Dockerfile .。
  • ⚠️ COPY . . 把杂物全打进去:构建缓存、.git、IDE 配置全进镜像层。只 COPY 需要的 src 目录 + 根目录 .dockerignore 兜底。
  • ⚠️ 镜像里打 latest:回滚无从谈起。tag 用 git sha / 语义化版本。
  • ⚠️ 容器内存无限制 / JVM 不设 -Xmx:OOM Kill 无现场(14 章)。限额 + 显式堆参数成对出现。
  • ⚠️ depends_on 以为能保证就绪:它只保证启动顺序,不保证服务可用。用 condition: service_healthy(应用侧再配重试)。
  • ⚠️ 数据不落卷:docker compose down -v 顺手把数据库清了。数据目录必须挂 volume。
  • ⚠️ 密码硬编码在 compose/镜像:环境变量注入 + .env(进 .gitignore)或密钥管理,生产用 secret 机制。
  • ⚠️ docker compose down 与 stop 混淆:down 删容器(卷保留但端口/容器没了),stop 只停。排障时看清状态。
  • ⚠️ CI 跳过测试图快:门禁的意义就是每次都跑;提速靠缓存与分层测试(28 章),不靠跳过。

四、调试技巧

  • 容器起不来:docker compose logs app --tail 100 看启动日志;docker compose ps 看重启循环(Restarting)。
  • 容器内探查:docker compose exec app sh(JRE 镜像有 sh 无 bash);docker stats 看内存/CPU 实况。
  • 端口不通三连:容器内服务起了吗(logs)→ 端口映射对吗(ps 的 PORTS 列)→ 防火墙/云安全组放行了吗。
  • 镜像过大排查:docker history mini-shop:tag 看每层体积,多半是没拆多阶段或带了构建缓存。

五、最佳实践

  • 交付物唯一:镜像。所有环境(dev/test/prod)跑同一个镜像,只变配置(环境变量/profile)。
  • 启动即自检:应用启动时校验关键依赖连通性(连不上快速失败),别让半死应用混进流量。
  • 一切变更走流水线:手工 docker exec 改文件 = 制造不可复现环境。
  • 部署文档化成脚本:deploy.sh 五行胜过 wiki 五页,脚本进仓库随代码演进。

六、本章小结

  • 多阶段 Dockerfile = 瘦身 + 依赖层缓存 + 非 root + 显式 JVM 参数。
  • Compose 编排全环境:服务名互访、healthcheck 定就绪、数据落卷、配置环境变量注入。
  • 优雅停机三件套:graceful + 摘流量 + 幂等兜底;停机不丢请求是 MQ 幂等的回报。
  • CI 门禁三关(测试/构建/镜像)+ 版本化 tag + 上线检查单,就是「生产化」的最低标准。

第 30–35 章实战收官:把 17–29 章的能力全部装配回 mini-shop,交付一个真正「能上线」的服务。


七、动手练习

基础题

  1. 给 mini-shop-boot 构建镜像并 compose up,跑通上线检查单五条;记录镜像体积,再注释掉多阶段构建对比一次。
  2. 制造故障演练:docker compose stop redis,观察 /actuator/health 变 DOWN 与应用行为(读接口报错/降级),恢复后验证自愈。

进阶题

  1. 把 1.4 的 CI 跑起来(fork 到自己 GitHub 或用本地 act runner):故意提交一个测试失败的改动,验证门禁拦截。
  2. 写 deploy.sh:拉最新代码 → 构建镜像(tag=git sha)→ compose 滚动重建 app → 健康检查失败自动回滚到上一个 tag。脚本 ≤ 40 行。

思考题

  1. 「应用 + 中间件全部塞一个 compose」适合开发与教学,生产却常常「应用进 K8s、中间件用云服务」。列出拆开后的三个收益与三个新问题(配置管理、服务发现、多机网络),对照附录 92 的延伸阅读规划你的下一阶段学习路径。

📄 第 30 章 mini-shop 总览:需求、领域模型与三阶段里程碑 30-minishop-overview.md

阶段:阶段 4 · 实战项目 | 预计阅读 40 min | 代码落地模块:sample/(全局视角) 前置:第 03 章 类设计进阶(领域模型基础)| 相关章节:第 31 章 | 第 33 章 实战落点:本章是 31–35 章的「作战地图」,先读它再写代码

学习目标

读完本章,你应该能够:

  • [ ] 说清 mini-shop 的业务链路与领域模型,不看代码也能给别人讲明白。
  • [ ] 理解「订单状态机」在整个系统中的核心地位。
  • [ ] 知道三阶段(SE 版 → 并发版 → 生产版)各自引入什么、解决什么问题。
  • [ ] 学会动手前先写「验收标准」的工作方式——这也是企业项目的立项动作。

一、业务链路:一条主线走到底

mini-shop 是一个极简电商后端,只有一条主链路:

flowchart LR
    A["浏览商品"] --> B["加入购物车"] --> C["下单<br/>校验+扣库存"] --> D["待支付<br/>15 分钟倒计时"]
    D -->|支付成功| E["已支付<br/>后续可发货"]
    D -->|超时/主动取消| F["已关闭<br/>回补库存"]
    E --> G["支付回调<br/>验签+幂等"]

六个用例,覆盖企业后端 80% 的核心技术难题:

用例 技术难题 对应章节
浏览商品 热点读、缓存穿透/击穿 24
加购物车 会话与登录态 26
下单扣库存 并发安全(超卖)、事务原子性 11–13、32、33
支付回调 可靠性、幂等、验签 16、25、34
超时关单 延迟任务、消息队列死信 13、25、32、34
查订单 分页查询、越权防护 21、26

[!TIP] 链路刻意做短。真实电商还有优惠券、购物车合并、库存分层、物流、售后……每加一个概念,教学主线就模糊一分。学会这条链路的全部原理,那些扩展只是「同样的招式换个业务」。

二、领域模型

2.1 核心对象

对象 类型 关键字段 说明
Product 领域类(可变) id、name、priceInCents、stock 有库存行为,第 02/03 章建立
OrderItem record(不可变) productId、productName、quantity、unitPriceInCents 记录成交快照价,商品改价不影响历史订单
Order 聚合根(Aggregate Root) id、userId、items、status、payableInCents 状态流转的唯一入口;外部只能通过它操作订单项
OrderStatus 枚举状态机 五状态 + 转移校验 第 03 章建立,现位于 mini-shop-common(SE 版与 Boot 版共用的领域契约),贯穿三代实现
Money 值对象(Value Object) cents + 运算 金额语义收口:无身份、不可变、相等性看值
User 极简 id、username、passwordHash 只为演示鉴权(26 章)

两个 DDD 术语一句话版本:聚合根是「一组必须保持一致的对象的总入口」——改订单项必须经过 Order,不许绕过去单独改;值对象是「只看值不看身份的对象」——Money(100) 与另一个 Money(100) 就是同一个钱,没有「哪一张」的区别。

2.2 订单状态机

stateDiagram-v2
    [*] --> CREATED: 下单成功
    CREATED --> PAID: 支付回调(幂等校验)
    CREATED --> CLOSED: 超时 15 分钟 / 用户取消
    PAID --> SHIPPED: 发货(教学链路不实现)
    SHIPPED --> COMPLETED: 确认收货(教学链路不实现)
    PAID --> CLOSED: 关单(全额退款,教学链路不实现)
    COMPLETED --> [*]
    CLOSED --> [*]

[!WARNING] 状态机是本项目的法律:任何代码不得绕过转移校验直接改状态(比如直接 order.setStatus(PAID))。三代实现里这条红线始终不变——第 34 章你会看到,支付回调的幂等防护正是建立在「非法转移会抛异常」之上。

三、三代实现:演进地图

维度 阶段一(31 章) 阶段二(32 章) 阶段三(33–35 章)
模块 mini-shop-se(基础包) mini-shop-se/concurrent mini-shop-boot
形态 main + Scanner 控制台 控制台 + 多线程压测 Spring Boot Web 服务
存储 ConcurrentHashMap 内存 + JSON 快照 同左 MySQL + Redis
依赖 仅 JUnit + Jackson + 自写压测脚手架 Boot 4 / MyBatis-Plus / Redisson / RabbitMQ / JWT
核心课题 用纯 Java 把业务跑通 用纯 Java 把并发做对 用工程手段把系统做稳、做快、可运维
验收 命令行完成一次下单,单测绿 200 线程抢 100 库存,恰好卖 100 compose up 后压测:无超卖、回调不重复、超时单自动关

为什么不直接教 Spring Boot 版?因为框架会把「库存为什么超卖」「事务为什么回滚」这些因果包进黑盒。先用 100 行纯 Java 复现超卖,你才会在第 24/32 章真正理解每种方案在解决什么。工具永远在变,原理才是资产。

四、工程结构与阅读顺序

sample/
├─ mini-shop-common/                  # 通用:Result、ErrorCode、异常体系、Repository 契约
├─ mini-shop-se/
│  ├─ src/main/java/com/example/minishop/
│  │  ├─ model/                       # Product、Order、OrderItem、OrderStatus、Money
│  │  ├─ repository/                  # InMemory*Repository(31 章)
│  │  ├─ service/                     # OrderService、InventoryService(31 章)
│  │  ├─ util/                        # Json、OrderSnapshotStore(08 章)
│  │  ├─ cli/                         # ShopCli + 各章 Demo(01–07 章)
│  │  ├─ lab/                         # 反射/代理/迷你IoC 实验(09 章)
│  │  └─ concurrent/                  # 五种锁方案 + 压测 + 延迟关单(32 章)
│  └─ src/test/java/                  # 单元测试(10/31/32 章)
└─ mini-shop-boot/                    # 33 章起逐层展开(controller/service/manager/mapper/infra)

建议阅读顺序:30(本章)→ 31 → 32 → 11–16(回头补原理)→ 17–29 → 33–35。已读完阶段一/二的读者按 30→31→32→33→34→35 直进即可,每章开头都标注了「本节用到:第 X 章的 Y」。

五、全局验收标准(三阶段共用)

写代码前先定验收,这是企业项目立项的基本动作:

  1. 功能:下单、支付、关单、查单全链路可用;订单状态只能按状态机流转。
  2. 正确性:任意并发压力下,销量 ≤ 库存(无超卖)、金额账目平衡(下单扣减 = 关单回补)。
  3. 幂等:同一笔回调重复投递 N 次,订单只被支付一次。
  4. 可观测:任何一笔订单出错,能靠 traceId 在日志里还原完整链路。
  5. 可部署:docker compose up -d 一条命令拉起全部依赖与应用。

每一章结尾的「动手练习」都在为这五条添砖;第 35 章收官时逐条打钩。


六、常见坑 ⚠️

  • ⚠️ 跳过阶段一直接上 Boot:SE 版看着「土」,但超卖、锁、状态机这些教训都在纯 Java 里最便宜。直接从 Boot 开始的读者,遇到 @Transactional 失效或库存对不上时没有排查直觉,最后还得回头补——按顺序走反而是捷径。
  • ⚠️ 把「演进」当「重写」:31→32→33 章换的是存储与并发手段,领域模型(Order/OrderItem/OrderStatus/Money)与业务规则原样平移。每代都推翻重设计的人,做的是三个项目而不是一个项目的三次演进。
  • ⚠️ 验收标准后补:先写代码再凑验收,等于考试后改考卷。第五节的五条标准在写第一行代码前就该定稿。
  • ⚠️ 状态机纪律松懈:三代实现里最容易破线的诱惑是「就这一次直接 setStatus」——每破一次,幂等与并发防线就多一个暗洞(34 章支付回调依赖非法转移抛异常)。

七、调试技巧

  • 超卖类问题先缩小口径:固定线程数与库存数(如 200 抢 100),把随机性挤出去,现象才可复现。
  • 演进对照法:同一 bug 在 SE 版(32 章)与 Boot 版(33/34 章)各复现一次,对比两边行为差异,能快速定位是「框架黑盒」还是「自己的逻辑」问题。
  • 状态不对劲时先查状态机:打印当前状态 + 触发的转移,非法转移抛的 IllegalStateException(03 章)通常就是第一现场。

八、最佳实践

  • 动手前先复述验收标准;说不清「怎么算完成」的任务不该开工。
  • 领域模型是三代共享资产,放 mini-shop-common(如 OrderStatus);技术实现各自演进——「稳定的核心下沉,易变的外围快跑」。
  • 每完成一代实现,回到第五节五条验收自测一遍,把「感觉能跑」换成「逐条打钩」。

九、本章小结

  • 一条主链路(浏览→购物车→下单→支付→关单)覆盖缓存、并发、事务、MQ、幂等、鉴权六大主题。
  • 状态机是系统法律;订单项记快照价;金额走值对象。
  • 三代实现层层递进:跑通 → 做对 → 做稳;先原理后框架是本项目的教学策略。
  • 验收标准先行,练习为验收添砖。

下一章正式开工:用前十章的纯 Java 功底,把这条链路完整跑起来。


十、动手练习

基础题

  1. 画出 mini-shop 的实体关系草图(Product、Order、OrderItem、User),标出一对多关系。
  2. 对照 2.2 的状态机,列出「哪些转移需要触发库存动作」(提示:关单回补;再想想支付失败要不要回补?)

进阶题

  1. 假如产品经理要求「下单后 15 分钟未支付自动关闭」,列出你能想到的至少三种实现思路(提示:定时扫描、JDK 延迟队列、MQ 死信/延迟消息),并写出各自优缺点——第 32/34 章会逐一落地其中两种。
  2. 为「防重复下单」设计一个方案草案:同一用户对同一组商品 1 秒内重复点击下单按钮,如何只生成一笔订单?(先想思路,第 25/34 章的幂等方案会回收这道题。)

思考题

  1. OrderItem 为什么要冗余 productName 和 unitPriceInCents,而不是只存 productId 运行时去查?这个「快照」思想,还能用在系统的哪些地方(提示:地址快照、优惠券规则快照)?

📄 第 31 章 阶段一实现:纯 Java SE 的内存版商城 31-minishop-java-se.md

阶段:阶段 4 · 实战项目 | 预计阅读 90 min | 代码落地模块:sample/mini-shop-se(基础包) 前置:第 30 章 总览、第 06 章 异常、第 08 章 IO、第 10 章 测试 实战落点:本章产出的 model/repository/service/cli 就是三代实现的共同骨架

学习目标

读完本章,你应该能够:

  • [ ] 用纯 Java SE 跑通「浏览 → 购物车 → 下单 → 支付 → 关单」全链路。
  • [ ] 理解分层雏形:模型 / 仓储 / 服务 / 界面(CLI)各司其职。
  • [ ] 把订单快照落盘并支持重启恢复,理解「持久化最小形态」。
  • [ ] 为核心业务配上单元测试,作为阶段二/三重构的安全网。

本节用到:第 01 章金额铁律、第 02 章接口建模、第 03 章枚举状态机与 record、第 04 章集合选型、第 05 章仓储抽象、第 06 章业务异常、第 07 章 Stream 汇总、第 08 章 JSON 落盘、第 09 章容器思想(第 33 章 Spring 接管)。


一、架构:四层雏形

flowchart TB
    CLI["cli 层<br/>ShopCli(Scanner 菜单)"] --> SVC["service 层<br/>OrderService(业务规则)"]
    SVC --> REPO["repository 层<br/>InMemory*Repository(存取)"]
    REPO --> MEM[("内存<br/>ConcurrentHashMap")]
    SVC -.落盘/恢复.-> SNAP["util 层<br/>OrderSnapshotStore"]
    SNAP --> DISK[("data/orders/*.json")]
    MODEL["model 层<br/>Product/Order/OrderItem/Money<br/>(OrderStatus 在 mini-shop-common,三代共用)"] -.被所有层依赖.-> SVC

四层职责一句话:cli 只管交互、service 只管规则、repository 只管存取、model 只管定义。这个形状与第 33 章 Boot 版的 controller/service/mapper/entity 一一对应——到时候你只是「给每层换工具」,不需要重学结构。

二、逐层实现

2.1 model 层:领域对象(第 02/03 章成果的组装)

// sample/mini-shop-common/src/main/java/com/example/minishop/common/model/OrderStatus.java(节选,完整版见工程)
// 放在 common 而非 mini-shop-se:订单状态是两代实现(SE 版 / Boot 版)共用的领域契约
public enum OrderStatus {
    CREATED("待支付"), PAID("已支付"), SHIPPED("已发货"), COMPLETED("已完成"), CLOSED("已关闭");
    // canPay/canShip/canClose 转移校验 + pay()/close() 转移方法,见第 03 章
}
// sample/mini-shop-se/src/main/java/com/example/minishop/model/Order.java(节选)
public class Order {
    private Long id;                       // 由仓储统一发号(非 final,set 仅供仓储/反序列化)
    private final Long userId;
    private final List<OrderItem> items;   // List.copyOf 防御性拷贝
    private OrderStatus status;
    private long payableInCents;           // 下单时的快照总价

    public Order(Long id, Long userId, List<OrderItem> items) {
        if (items == null || items.isEmpty()) {
            throw new IllegalArgumentException("订单至少包含一个订单项");
        }
        this.id = id;
        this.userId = userId;
        this.items = List.copyOf(items);
        this.status = OrderStatus.CREATED;
        this.payableInCents = items.stream().mapToLong(OrderItem::subtotalInCents).sum();
    }

    public void pay()   { this.status = status.pay(); }     // 非法转移由枚举抛异常
    public void close() { this.status = status.close(); }
}

2.2 repository 层:内存存取(第 05 章 Repository<T, ID> 的实现)

// sample/mini-shop-se/src/main/java/com/example/minishop/repository/InMemoryOrderRepository.java(节选)
public class InMemoryOrderRepository implements Repository<Order, Long> {

    private final ConcurrentMap<Long, Order> store = new ConcurrentHashMap<>();
    private final AtomicLong idGenerator = new AtomicLong(1000);

    /** 发号器:仓储统一管 id,避免业务层自己生成冲突 */
    public Long nextId() { return idGenerator.incrementAndGet(); }

    @Override
    public Order save(Order order) {
        if (order.getId() == null) order.setId(nextId());
        store.put(order.getId(), order);
        return order;
    }

    @Override
    public Optional<Order> findById(Long id) { return Optional.ofNullable(store.get(id)); }
}

[!TIP] 这里已经能看出并发伏笔:ConcurrentHashMap 只保证单个 key 操作线程安全;「查库存 → 比较 → 扣减」这三步组合起来仍然不是原子的——这就是第 32 章超卖实验的伏笔。

2.3 service 层:业务规则

// sample/mini-shop-se/src/main/java/com/example/minishop/service/OrderService.java(节选)
public class OrderService {

    private final ProductRepository productRepository;
    private final InMemoryOrderRepository orderRepository;

    public OrderService(ProductRepository productRepository, InMemoryOrderRepository orderRepository) {
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
    }

    /** 下单:逐项校验库存并扣减,全部通过才落订单 */
    public Order createOrder(Long userId, List<OrderItem> items) {
        for (OrderItem item : items) {
            Product product = productRepository.findById(item.productId())
                    .orElseThrow(() -> new BizException(ErrorCode.PARAM_INVALID,
                            "商品不存在: " + item.productId()));
            if (product.getStock() < item.quantity()) {
                throw new StockNotEnoughException(product.getId(), item.quantity(), product.getStock());
            }
            product.decreaseStock(item.quantity());
        }
        Order order = new Order(orderRepository.nextId(), userId, items);
        return orderRepository.save(order);
    }

    /** 关单(超时未支付):库存回补是关单的隐藏职责 */
    public Order close(Long orderId) {
        Order order = mustFind(orderId);
        order.close();
        for (OrderItem item : order.getItems()) {
            productRepository.findById(item.productId()).ifPresent(p -> p.increaseStock(item.quantity()));
        }
        return order;
    }
}

注意三条设计决策:

  1. 校验与扣减写在一起、同步完成——把「查库存」和「扣库存」拆成两个对外方法,就会有人只查不扣。规则内聚在 service,是防 bug 的第一道墙;
  2. 关单必须回补库存——状态变更连带的数据动作要在同一处做完,漏掉就是「钱货两空」事故;
  3. 依赖具体类的取舍——InMemoryOrderRepository 是具体类而非接口(构造器参数直接收它),因为本章只有一个实现、且它的 nextId() 发号能力就是 service 需要的契约。若预见到多实现,应回到第 05 章 Repository<T, ID> 接口 + 自定义发号接口的组合——不为接口而接口,但多实现预期出现时就该抽(33 章 Boot 版换成 Mapper 时你会看到接口边界重新出现)。

2.4 cli 层 + 持久化

// sample/mini-shop-se/src/main/java/com/example/minishop/cli/ShopCli.java(节选,完整版见工程)
public class ShopCli {
    public static void main(String[] args) {
        var productRepo = new InMemoryProductRepository();
        var orderRepo = new InMemoryOrderRepository();
        var orderService = new OrderService(productRepo, orderRepo);
        var snapshots = new OrderSnapshotStore("data");

        seed(productRepo);                 // 内置三件演示商品
        restore(snapshots, orderRepo);     // 从 data/orders/*.json 恢复历史订单
        Map<Long, Integer> cart = new LinkedHashMap<>();   // 第 04 章:键去重 + 插入顺序
        // Scanner 菜单循环:1 加入购物车 2 下单结算 3 支付 4 退出(完整代码见工程)
    }
}
// 下单成功后立即落盘(快照 DTO + 原子写,完整实现见第 08 章)
snapshots.save(order);

运行:

mvn -pl mini-shop-se -am compile exec:java -Dexec.mainClass=com.example.minishop.cli.ShopCli
# -am 必带:ShopCli 依赖 mini-shop-common,只构建 se 模块会因找不到依赖失败

体验流程:1(键盘 x2)→ 2 下单 → 3 支付 → Ctrl+C 杀进程 → 重新运行,历史订单被恢复——你已经完成了一个「重启不丢数据」的程序,尽管它还没有数据库。

三、三代对照:同一个 createOrder

这是全书最重要的对照表,先记住阶段一的样子:

关注点 阶段一(本章) 阶段二(32 章) 阶段三(33 章)
库存校验 if (stock < qty) 内存判断 + 五种并发方案护住竞态 DB 乐观锁 version 兜底
扣减原子性 stock -= qty(单线程安全) AtomicInteger/分段锁 UPDATE ... SET stock=stock-? WHERE stock>=?
多步原子性 无(单线程天然原子) 无(仍单事务语义) @Transactional 数据库事务
落单 内存 Map + JSON 快照 同左 MySQL order 表
失败回滚 不需要(扣减失败即抛异常停止) 同左 事务回滚 + 补偿(22 章)

四、常见坑 ⚠️

  • ⚠️ service 里写交互逻辑:createOrder 里出现 Scanner/println,这层就废了——它必须能被测试静默调用。IO 永远在 cli/controller 层。
  • ⚠️ 下单校验绕过 service 直接改仓储:任何绕过 decreaseStock 校验的写路径都是定时炸弹,仓储层不做业务判断、只管存取。
  • ⚠️ 快照里存领域对象:直接序列化 Order,将来领域类一改历史数据全废。用专用快照 DTO(第 08 章已示范)。
  • ⚠️ 订单项不存价格快照:只存 productId,商品改价后历史订单金额全错(第 30 章思考题)。
  • ⚠️ id 由业务层生成:nextId 放仓储统一发号;将来换 MySQL 自增/雪花算法时只改一处。

五、调试技巧

  • CLI 菜单卡住/行为怪异:先看是不是 Scanner 的 nextInt() 没吃掉换行符(经典坑:nextInt 后紧跟 nextLine 读到空串),nextLine() + Integer.parseInt 一把梭更稳。
  • 快照恢复失败:检查 data/orders/*.json 是否手改过、DTO 字段名与序列化输出是否一致(Jackson 反序列化静默丢弃未知字段,字段拼错不报错、值变 null——加 @JsonIgnoreProperties 严格模式或在测试里跑一次 round-trip)。
  • 下单行为不符合预期时,从 OrderServiceTest 里挑最接近的用例改输入复现——单测环境里打断点比 CLI 交互快得多。
  • exec:java 报「找不到或无法加载主类」:确认 -am 带了、mainClass 全限定名拼写、以及先 compile(exec:java 不触发编译)。

六、最佳实践

  • 先写测试再重构:OrderServiceTest 的四条用例(下单扣减/库存不足/支付/关单回补)是 32/33 章动手术的安全网。
  • 演示数据(seed)与业务代码分离,别把 new Product(...) 撒在业务方法里。
  • 快照目录 data/ 进 .gitignore,仓库只留代码不留运行时数据。

七、本章小结

  • 四层雏形(cli/service/repository/model)与第 33 章 Boot 分层一一对应。
  • 业务规则内聚 service:校验+扣减原子、关单必回补。
  • 快照落盘 = 「持久化最小形态」,DTO 隔离领域演进。
  • 单测先行的安全网已就位,接下来进入并发世界把它压出裂缝。

下一章:200 个线程同时抢 100 件库存,本章的代码会发生什么?


八、动手练习

基础题

  1. 跑通 ShopCli:完成一次「加购 → 下单 → 支付」并重启程序验证快照恢复;再对同一订单重复支付,观察状态机异常。
  2. 给 ShopCli 增加「查看我的订单」菜单项:按用户 id 过滤订单并打印状态与金额(Stream 过滤 + 格式化输出)。

进阶题

  1. 实现「15 分钟超时关单」的内存版:下单时把订单放进 DelayQueue(元素带到期时间),后台线程 take() 到期订单并调用 close()。提示:DelayQueue 元素需实现 Delayed 接口;用 ScheduledExecutorService 起消费线程(第 13 章知识)。
  2. 给 OrderService.createOrder 增加防重:同一用户在 1 秒内重复提交同样商品组合时抛 DUPLICATE_ORDER。用 Map<Long, Long>(userId -> 上次下单时间戳)实现,思考它的并发问题(第 32/34 章会给出正式方案)。

思考题

  1. OrderSnapshotStore 是「写时全量落盘、读时全量恢复」。当订单涨到 100 万笔,这个方案的启动恢复时间与磁盘占用会怎样?列出你想到的优化方向(增量写、压缩、分片、直接上数据库),并预测为什么企业最终都选了数据库——带着答案进入第 33 章。

📄 第 32 章 阶段二改造:并发扣库存与超时关单 32-minishop-concurrent.md

阶段:阶段 4 · 实战项目 | 预计阅读 90 min | 代码落地模块:sample/mini-shop-se/concurrent 前置:第 11–13 章、第 31 章、第 14 章 JVM 实战落点:为第 33 章「DB 层怎么扣库存」和第 34 章「Redis/MQ 方案」提供性能与正确性参照

学习目标

读完本章,你应该能够:

  • [ ] 用压测基准量化五种库存扣减方案的正确性与吞吐差异。
  • [ ] 解释「正确但慢」与「快但高危」之间的工程权衡。
  • [ ] 用 DelayQueue 实现内存版超时关单,并说清它为什么必须被 MQ 替换。
  • [ ] 在压测中观察 GC 与锁竞争,把第 14 章的四件套用起来。

本节用到:第 11 章 JMM 与 synchronized、第 12 章 ReentrantLock/CAS/分段思想、第 13 章 CountDownLatch/LongAdder、第 14 章 jstack 与 GC 日志。


一、压测设计:先定口径再跑数字

第 30 章验收标准说「并发下无超卖」。翻译成可执行口径:

正确性:200 线程抢 100 库存
  → 成功数 ≤ 100(无超卖)
  → 剩余 + 成功 = 100(账目平衡,无丢更新)
性能:三轮取平均耗时,比较相对吞吐

[!WARNING] 线程数必须大于库存数才有竞争意义——100 线程抢 100 件,人人都该成功,测不出任何问题。压测口径写清楚、可复现,是性能工作的第一纪律。

压测入口(源码 SeckillBench):

mvn -pl mini-shop-se -am compile exec:java -Dexec.mainClass=com.example.minishop.concurrent.SeckillBench

核心骨架:CountDownLatch 双门闩(全员就位 → 放行 → 全员结束)+ LongAdder 统计成功数:

// sample/mini-shop-se/src/main/java/com/example/minishop/concurrent/SeckillBench.java(节选)
static void bench(String label, Factory factory) throws InterruptedException {
    StockStrategies.StockStrategy stock = factory.create();
    LongAdder success = new LongAdder();          // 统计计数:LongAdder(第 12 章)
    CountDownLatch ready = new CountDownLatch(THREADS);
    CountDownLatch done = new CountDownLatch(THREADS);

    for (int i = 0; i < THREADS; i++) {
        new Thread(() -> {
            ready.countDown();
            try { ready.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            if (stock.tryDeduct()) success.increment();
            done.countDown();
        }, "bench-" + label).start();
    }

    long start = System.nanoTime();
    done.await();
    long costMs = (System.nanoTime() - start) / 1_000_000;

    int sold = (int) success.sum();
    String verdict = (stock.remaining() + sold == STOCK)
            ? (sold > STOCK ? "  ← 超卖!" : "")
            : "  ← 数据不一致(账目不平)";
    System.out.printf("%-18s 卖出 %-4d 剩余 %-4d 耗时 %3dms%s%n",
            label, sold, stock.remaining(), costMs, verdict);
}

二、五种方案逐一体测

五种方案全部实现在 StockStrategies,同一契约 tryDeduct(),压测可互换:

方案 核心代码 正确性 吞吐特征
1. unsafe if(remaining<=0) return false; remaining--; ❌ 超卖或账目不平 看似最快,数字全错
2. synchronized 方法级互斥 ✅ 临界区串行,200 线程排队
3. ReentrantLock lock/try-finally unlock ✅ 与 2 相当;换来超时/可中断能力
4. CAS compareAndSet(current, current-1) 自旋 ✅ 低冲突快;200 冲 100 时自旋烧 CPU
5. 分段 CAS 100 件拆 10 段各自 CAS,空段借段 ✅ 冲突面缩小 10 倍,正确且最快

典型一轮输出(机器不同数字不同,相对关系稳定):

---- 第 1 轮 ----
1.unsafe           卖出 137  剩余 -37  耗时  45ms  ← 超卖!
2.synchronized     卖出 100  剩余 0    耗时 312ms
3.reentrant-lock   卖出 100  剩余 0    耗时 298ms
4.cas-atomic       卖出 100  剩余 0    耗时 187ms
5.segmented-cas    卖出 100  剩余 0    耗时  96ms

三个必须带走的结论:

  1. unsafe 的「快」是假象:它少做了排队,也少做了正确性。数字对不上的系统,性能没有意义;
  2. CAS 不是万能快:方案 4 快于锁是因为临界区极短(一行减法)。把临界区换成「查库+扣减」,自旋重试的成本立刻反超锁——锁 vs CAS 取决于临界区长度与冲突率;
  3. 分段是空间换时间的通用解:冲突不可消除就缩小冲突面。第 24 章 Redis 分片库存、第 21 章的索引设计,本质都是这一招。

方案 5 的关键片段

// sample/mini-shop-se/src/main/java/com/example/minishop/concurrent/StockStrategies.java(节选)
public boolean tryDeduct() {
    int start = Math.floorMod(Thread.currentThread().hashCode(), segmentCount);
    if (casDeduct(segments[start])) return true;      // 先打自己段(粘性路由)
    for (int i = 1; i < segmentCount; i++) {          // 空了顺时针借段
        if (casDeduct(segments[(start + i) % segmentCount])) return true;
    }
    return false;
}

⚠️ 借段策略的教学简化说明:真实抢购要考虑「段的冷热均衡」与「借段失败的公平性」,工业实现(Redis 分片)会用请求号哈希 + 后台再平衡。教学目标是理解「冲突面」这个变量。

三、压测现场观察:GC 与锁竞争

跑第 2/3 方案时另开终端(第 14 章四件套实战):

jps -l                                   # 找到 bench 进程
jstat -gcutil <pid> 500                  # 每 500ms 看一眼:200 条线程的栈与对象分配是否触发 GC
jstack <pid> | grep -c BLOCKED           # 锁方案下应有大量 BLOCKED 线程排队
jstack <pid> | grep -A 3 "bench-4.cas"   # CAS 方案下线程多在 RUNNABLE(自旋也是 runnable)

解读:

  • 锁方案大量 BLOCKED (on object monitor)——这正是串行化的可视化;
  • CAS 方案线程 RUNNABLE 但 CPU 100%(top -H 验证)——自旋空转的真面目;
  • 200 线程各建栈帧约 200MB 内存,若 -Xmx 给 32m 会看到 GC 频繁——把 -Xmx 调到 256m 对比,感受第 14 章「容器参数」的实际意义。

四、超时关单:DelayQueue 版与它的天花板

// sample/mini-shop-se/src/main/java/com/example/minishop/concurrent/DelayCloseDemo.java(节选)
record OrderDelayTask(Long orderId, long expireAtMillis) implements Delayed {
    @Override
    public long getDelay(TimeUnit unit) {   // 剩余多久到期(<=0 可被 take 取出)
        return unit.convert(expireAtMillis - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
    }
    @Override
    public int compareTo(Delayed other) {   // 按到期时间排序
        return Long.compare(getDelay(TimeUnit.MILLISECONDS), other.getDelay(TimeUnit.MILLISECONDS));
    }
}

DelayQueue<OrderDelayTask> queue = new DelayQueue<>();
queue.put(OrderDelayTask.of(1001L, 15 * 60 * 1000));   // 下单后 15 分钟
// 后台线程:task = queue.take() 阻塞到最近任务到期 → closeOrder(task.orderId())

take() 永久阻塞等待最近到期任务,天然按时间排序——优雅。但三大缺陷决定它只能活在教学里:

  1. 重启即丢:内存数据没有持久化,发布一次全部超时单消失;
  2. 不能水平扩展:两个实例各跑各的队列,同一订单可能被关两次;
  3. 无削峰能力:1 万笔订单同时到期,关单请求同时砸向数据库。

[!TIP] 正解是第 25/34 章的 RabbitMQ 死信/延迟消息:消息持久化、集群共享、消费速率可控。内存方案的价值是让你先理解「延迟任务」的语义,再看 MQ 如何逐条补齐缺陷。

支付成功后如何「取消」已排队的关单任务?DelayQueue 不支持高效按元素删除。生产做法是关单前幂等校验:关单消费者取出任务后先查订单状态,PAID 就直接跳过——「不删任务,让任务变得可跳过」(34 章落地)。

五、三代对照:库存扣减的完整演进

维度 阶段一(31 章) 阶段二(本章) 阶段三(33/34 章)
正确性手段 单线程天然安全 五方案 + 压测验证 DB 原子 UPDATE + 乐观锁;Redis 分片 + 分布式锁
性能手段 无 分段缩冲突 缓存预热 + 异步化削峰
超时关单 无 DelayQueue(内存) RabbitMQ 延迟消息 + 幂等消费
验证手段 单元测试 并发压测 + jstack/jstat 压测脚本 + Actuator 指标 + 告警

六、常见坑 ⚠️

  • ⚠️ 压测口径不写死:线程数、库存数、轮次、预热次数必须成文,否则两次结果没有可比性。
  • ⚠️ 只测吞吐不验账目:卖出 + 剩余 = 100 是正确性的硬校验,任何方案过不了这条直接出局。
  • ⚠️ 拿本机压测数字推断生产:本机无网络跳数、无 DB 往返。相对关系可用,绝对值作废。
  • ⚠️ DelayQueue 当生产方案:见第四节三大缺陷。
  • ⚠️ 关单不回补库存:超时关单只改状态不还库存,等于「少卖」——账目校验同样会抓住它。

七、最佳实践

  • 并发改造前先跑基线:把单线程版的吞吐与正确性记录在案,任何「优化」都要与基线同口径对比——没有基线的性能调优是玄学。
  • 正确性与性能分开验证:正确性用「账目平衡」硬校验(一行断言),性能用多轮取平均;混在一起看容易漏掉「快但错」。
  • 压测脚手架本身要有单测:SeckillBench 的统计口径(成功数、剩余数、 verdict 判定)错了,后面所有结论全歪。
  • 方案选型写 ADR(Architecture Decision Record)三行:为什么选分段、放弃了什么(内存开销与借段复杂度)、什么条件下重新评估——33/34 章换存储时直接引用。

八、本章小结

  • 压测三件套:口径先行、正确性硬校验、相对吞吐对比。
  • 五方案结论:unsafe 出局、锁正确但串行、CAS 快但怕长临界区、分段缩冲突面最优——正确性是底线,性能在架构里。
  • 压测现场用 jstack/jstat 把「慢」归因到锁排队或自旋空转。
  • DelayQueue 版关单教学完毕即退役,MQ 延迟消息在 34 章接班。

阶段二全部完成。若你按 30 章的路线图跳读过阶段三的前置章节,现在正是回头补 17–29 章(Maven、Boot、数据访问、缓存、MQ、安全、部署)的时候——下一章 33 章起,我们用它们给 mini-shop 换上 Spring Boot 的心脏。


九、动手练习

基础题

  1. 跑通 SeckillBench 三轮,把每种方案的「卖出/剩余/耗时」记录成表格;人为把 SyncStock.tryDeduct 的临界区加一句 Thread.sleep(1),再压一次,观察吞吐崩塌——体会临界区长度的影响。
  2. 跑通 DelayCloseDemo,把三笔订单的超时改成 15 分钟并压缩为 3/6/9 秒,验证按到期顺序关单。

进阶题

  1. 给 SeckillBench 增加「预热轮」(每方案先空跑 1 轮不计入统计)与「每线程多次尝试」模式(每个线程循环抢到失败为止),对比单发与持续压测下方案 4 与 5 的差距变化。
  2. 用第 14 章参数(-Xmx64m -Xlog:gc*)跑方案 2 的压测,从 GC 日志统计压测期间的 Minor GC 次数;改用 -Xmx512m 复测,写 3 行结论。

思考题

  1. 方案 5 借段逻辑在「9 段全空、1 段剩 1 件」时,最多要 CAS 尝试 10 次。如果段数扩大到 1000、库存分布极不均匀,借段扫描会成为新瓶颈。请设计一个改进(提示:维护「非空段」的并发集合做路由;或按库存比例加权选段),并说明它牺牲了什么。

📄 第 33 章 阶段三上:Spring Boot 分层重构 + MySQL 落地 33-minishop-layering.md

阶段:阶段 4 · 实战项目 | 预计阅读 90 min | 代码落地模块:sample/mini-shop-boot 前置:第 17–23 章、第 31 章 | 相关章节:第 34 章 实战落点:把 SE 版业务原样搬进 Spring Boot 分层 + MySQL——业务不变,地基全换

学习目标

读完本章,你应该能够:

  • [ ] 完成 SE → Boot 的整体迁移路线:POM → 启动类 → 表与实体 → 分层 → 接口。
  • [ ] 理解「重构不改业务」的边界:哪些代码原样搬、哪些彻底重写。
  • [ ] 跑通 Boot 版下单全链路(HTTP 接口 + MySQL 落库 + 事务保障)。
  • [ ] 对照两代实现,说清框架到底替你做了什么。

本节用到:17 章 Maven 多模块、18 章 IoC、20 章分层、21 章 MyBatis-Plus、22 章事务、23 章接口规范。


一、重构策略:换心脏,不换业务

31 章验证过的业务规则一条都不改:库存不足拒绝、关单回补、状态机流转、金额分存储。变的是实现载体:

关注点 SE 版(31 章) Boot 版(本章) 谁替你干了
对象组装 main 里手工 new 容器构造器注入 Spring IoC(18 章)
存取 ConcurrentHashMap + JSON 快照 MySQL + MyBatis-Plus Mapper/连接池(21 章)
多步原子性 单线程天然 @Transactional AOP 代理(19/22 章)
对外入口 Scanner 菜单 RESTful + OpenAPI Spring MVC(23 章)
错误出口 cli 层 catch 打印 @RestControllerAdvice 统一翻译 全局异常(23 章)

[!WARNING] 重构的第一纪律:先让旧实现的测试在新实现上全部通过。OrderServiceTest(31 章)的业务断言(扣减/回补/状态)在 Boot 版一一对应——这就是第 10 章「测试是安全网」的兑现。

二、工程搭建(第 17/18 章)

// sample/mini-shop-boot/src/main/java/com/example/minishop/MiniShopApplication.java
@SpringBootApplication          // 组件扫描 + 自动配置;启动类必须在根包
@ConfigurationPropertiesScan    // 启用 shop.* 配置绑定(19 章)
@EnableAsync
public class MiniShopApplication {
    public static void main(String[] args) { SpringApplication.run(MiniShopApplication.class, args); }
}

依赖坐标的三个 Boot 4 专属注意点(附录 90):mybatis-plus-spring-boot4-starter + mybatis-plus-jsqlparser(分页)+ Jackson 3(tools.jackson 包名)。

三、表与实体(第 21 章)

-- sample/mini-shop-boot/src/main/resources/db/schema.sql(节选,完整 5 张表见工程)
CREATE TABLE IF NOT EXISTS product (
    id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name         VARCHAR(128)  NOT NULL COMMENT '商品名',
    price_cents  BIGINT        NOT NULL COMMENT '单价,分',
    stock        INT           NOT NULL DEFAULT 0 COMMENT '库存',
    version      INT           NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
    deleted      TINYINT       NOT NULL DEFAULT 0 COMMENT '逻辑删除 0否1是',
    created_at   DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at   DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT '商品表';

实体与 Mapper 的标准形态:

// sample/mini-shop-boot/src/main/java/com/example/minishop/mapper/ProductMapper.java
@Mapper
public interface ProductMapper extends BaseMapper<ProductEntity> {

    /** 条件原子扣库存:检查 + 扣减同一条 UPDATE,并发安全(32 章结论的 DB 版) */
    @Update("UPDATE product SET stock = stock - #{quantity}, version = version + 1 " +
            "WHERE id = #{productId} AND stock >= #{quantity} AND deleted = 0")
    int deductStock(@Param("productId") Long productId, @Param("quantity") int quantity);
}

对照 SE 版 Product.decreaseStock():「先查再改」变成了「一条 UPDATE 自带条件」——32 章压测证明过的正确性方案,在数据库里重演。

四、下单主流程(20 章分层 + 22 章事务)

// sample/mini-shop-boot/src/main/java/com/example/minishop/service/impl/OrderService.java(节选)
@Service
public class OrderService {

    @Transactional(rollbackFor = Exception.class)                    // 22 章:rollbackFor 必写
    public OrderVO createOrder(CreateOrderDTO dto) {
        long userId = UserContext.require();                         // 下单人从 JWT 取(26 章防水平越权)
        List<StockManager.Deducted> ledger = new ArrayList<>();      // Redis 段补偿台账(34 章)
        for (CreateOrderDTO.Item item : dto.items()) {
            ProductEntity p = mustFindProduct(item.productId());     // 商品不存在 → 40402
            StockManager.Deducted d = stockManager.deduct(p.getId(), item.quantity());
            if (!d.success()) {                                      // 扣减失败 → 抛异常 → 事务回滚
                throw new StockNotEnoughException(p.getId(), item.quantity(), p.getStock());
            }
            ledger.add(d);
            // ... 组装 OrderItemEntity(快照名/价,30 章)
        }

        OrderEntity order = insertOrderWithRetry(userId, items, payable);   // 撞订单号 uk 自动换号重试
        // orderNo = 日期 + 12 位随机;status = OrderStatus.CREATED.name()(03 章状态机起点)
        // payExpireAt = now + props.orderTimeoutMinutes()

        publisher.publishEvent(new OrderCreatedEvent(...));          // AFTER_COMMIT 再发 MQ(25 章)
        return OrderVO.from(order);
    }
}

与 SE 版逐行对照(第 31 章 OrderService.createOrder):

SE 版 Boot 版 变化说明
productRepository.findById(...).orElseThrow(...) mustFindProduct(...)(Mapper) 存储从内存换 SQL,语义不变
product.decreaseStock(qty)(内存字段) stockManager.deduct(...) 返回 Deducted 台账(DB 条件 UPDATE 兜底) 并发正确性从线程模型搬到 SQL;台账为 34 章补偿留凭据
new Order(...) + orderRepository.save orderMapper.insert(order) 持久化交给数据库
返回 Order 对象给 cli 返回 OrderVO 给 Controller 输出契约化(20 章)
无事务概念 @Transactional 全链原子 多表写的一致性

五、接口层与统一规范(第 23 章)

// sample/mini-shop-boot/src/main/java/com/example/minishop/controller/OrderController.java(节选)
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @PostMapping
    public Result<OrderVO> create(@RequestBody @Valid CreateOrderDTO dto) {   // 校验注解(23 章)
        return Result.ok(orderService.createOrder(dto));
    }

    @GetMapping("/{orderNo}")
    public Result<OrderVO> detail(@PathVariable String orderNo) {
        return Result.ok(orderService.detail(orderNo));   // 内部做归属校验(26 章)
    }
}

异常路径零 try-catch:StockNotEnoughException → 全局处理器 → {"code":40901,...}。联调脚本:

mvn -pl mini-shop-boot spring-boot:run    # 需先 docker compose up -d(00 章)

curl -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
     -d '{"items":[{"productId":1,"quantity":2}]}'   # 需带 Authorization 头(26 章登录令牌);body 无 userId
# {"code":0,"message":"ok","data":{"orderNo":"20260929...","statusDesc":"待支付","payableYuan":"3998.00"}}

同时验证 MySQL:SELECT * FROM \order` ORDER BY id DESC LIMIT 1; 看到订单与订单项、product.stock` 已扣减。

六、常见坑 ⚠️

  • ⚠️ 迁移时把业务规则也「顺手优化」:重构铁律是一次只动一个维度(存储/框架/结构)。规则变更与架构变更必须分开提交。
  • ⚠️ 忘记 Redis 扣减与 DB 事务的关系:Redis 扣减不在 MySQL 事务里——事务回滚时 Redis 段不会自动回补。本章用「DB 兜底扣减 + Redis 段可由 DB 重建」消化,34 章给出完整对账策略。
  • ⚠️ 实体直出接口:OrderEntity 带着 status 英文码与 payableCents 裸数就返回了。必转 VO(20 章)。
  • ⚠️ order 表名是关键字:SQL 里要写 `order`(反引号),注解 @TableName("\order`")`。
  • ⚠️ 先写代码后建表:schema.sql 进工程随版本管理,docker-entrypoint-initdb.d 自动执行(29 章 compose)。

七、调试技巧

  • 启动失败按链路排查:先看 APPLICATION FAILED TO START 报告(缺配置/连不上中间件),再开 --debug 看自动配置负匹配——Boot 4 下 MP 的 starter 坐标配错会报「Mapper 找不到」,先核对附录 90。
  • 下单 500 但日志只有一行 DuplicateKeyException:是订单号撞 uk——sample 的 insertOrderWithRetry 已自动换号重试 3 次,仍频繁撞说明随机段熵不够,检查 generateOrderNo。
  • SQL 不符预期:dev 开 MP SQL 日志(21 章),对照 @Update 注解与实际参数;order 表查询记得反引号。
  • 事务没回滚:按 22 章 Top4 排查(同类调用/非 public/异常被吞/rollbackFor),最直观的验证是 isActualTransactionActive() 打日志。

八、最佳实践

  • 迁移按「表与实体 → Mapper → Service → Controller」四步走,每步跑一次旧测试,绿了再进下一步——问题暴露在最小范围内。
  • 领域契约(OrderStatus 等枚举、Result/ErrorCode)放 mini-shop-common,两代实现共用;实现层各自演进(30 章原则在工程结构上的落地)。
  • 生成数据(DataInitializer 种子)挂 @Profile("!prod"),演示账号永不进生产。
  • Mapper 只写表级 SQL:closeByOrderNo/markPaid 这类条件 UPDATE 是「单表原子操作」可以进 Mapper,「先查再判再写」的编排一律在 Service。

九、本章小结

  • 迁移路线五步:POM/启动 → 表与实体 → Mapper → Service 事务 → Controller/规范。
  • 「重构不改业务」靠旧测试兜底;对照表让你看清框架代劳的每件事。
  • 并发正确性在 DB 层落地:条件原子 UPDATE 是 32 章结论的最终形态。
  • 至此 mini-shop 是一个功能完整的 Web 服务——但还没有缓存、异步与鉴权,下一章补齐生产化的四梁八柱。

十、动手练习

基础题

  1. 跑通 Boot 版全链路:下单 → 查详情 → 手动关单(POST /api/orders/{no}/close),每步用 SQL 验证数据变化(订单状态、库存回补)。
  2. 故意把 @Transactional 从 createOrder 上去掉,制造「扣库存成功、落单失败」(在 insert 前抛异常),验证出现「库存被扣但没订单」的事故;恢复注解后重跑验证回滚。

进阶题

  1. sample 的 myOrders 已是真分页:listByUser 在 SQL 里 LIMIT offset,size,total 由 countByUser 单独 COUNT(不当页行数凑数)。练习:给 listByUser 的查询跑 EXPLAIN,验证 idx_user_status 被命中;再把 size 传 0 与 1000 验证 Service 层的夹取逻辑。
  2. 把 31 章的 OrderServiceTest 四条断言在 Boot 版重写为 Mockito 单测(28 章打法),体验「安全网跨代迁移」。

思考题

  1. stockManager.deduct 目前「Redis 成功即返回,失败落 DB」。如果 Redis 扣成功后、事务提交前应用崩溃,Redis 与 DB 会差一件库存。列出你能想到的三种对账思路(定时比对 DB↔Redis、关单时以 DB 为准重置、扣减时写「扣减流水」),第 34 章会采纳其一。

📄 第 34 章 阶段三下:缓存、MQ 异步、幂等与鉴权的生产化改造 34-minishop-production.md

阶段:阶段 4 · 实战项目 | 预计阅读 90 min | 代码落地模块:sample/mini-shop-boot(manager/infra) 前置:第 24–26 章、第 33 章 | 相关章节:第 35 章 实战落点:mini-shop 生产化四件套一次装配:缓存读路径、Redis 扣减热路径、延迟关单、回调幂等 + JWT

学习目标

读完本章,你应该能够:

  • [ ] 把商品详情接入 Cache-Aside(穿透/击穿/雪崩三防御齐全)。
  • [ ] 把扣库存切到 Redis 分段热路径 + DB 兜底,理解对账思路。
  • [ ] 用 RabbitMQ TTL+DLX 实现延迟关单,替代 32 章 DelayQueue。
  • [ ] 落地支付回调三层幂等(验签 → msgId 去重 → 状态机校验)。
  • [ ] 打通 JWT 登录与订单归属校验的完整安全链路。

本节用到:24 章缓存与分布式锁、25 章可靠投递与幂等、26 章安全、16 章 HMAC、27 章 traceId 透传。


一、生产化改造总图

flowchart TB
    U["用户(带JWT)"] --> GW["Controller<br/>校验/鉴权"]
    GW --> SVC["OrderService<br/>事务边界"]
    SVC --> SM["StockManager<br/>Redis分段扣减(Lua) → DB兜底"]
    SM --> R[("Redis 段库存<br/>预热自 DB")]
    SM --> DB[("MySQL<br/>条件UPDATE")]
    SVC -- AFTER_COMMIT --> MQ[("RabbitMQ<br/>order.create.q TTL=order-timeout-minutes")]
    MQ -- 死信 --> CLOSE["关单消费者<br/>状态机校验→关单→回补"]
    GW -.详情读.-> PCM["ProductCacheManager<br/>Cache-Aside"]
    PAY["支付网关"] --> CB["PayCallbackController<br/>验签→幂等→markPaid"]

四条改造主线,每条都是前面章节的直接落地。

二、读路径:商品详情缓存(第 24 章)

// sample/mini-shop-boot/src/main/java/com/example/minishop/manager/ProductCacheManager.java(节选)
public ProductEntity getProduct(Long id) {
    String key = "product:" + id;
    String cached = redis.opsForValue().get(key);
    if (cached != null) return NULL_VALUE.equals(cached) ? null : read(cached);   // 空值缓存防穿透

    RLock lock = redisson.getLock("lock:rebuild:" + key);      // 防击穿:同 key 单飞重建
    lock.lock();
    try {
        cached = redis.opsForValue().get(key);                 // 双检
        if (cached != null) return NULL_VALUE.equals(cached) ? null : read(cached);
        ProductEntity entity = productMapper.selectById(id);   // 回源
        String value = entity == null ? NULL_VALUE : json.writeValueAsString(entity);
        Duration ttl = entity == null ? Duration.ofSeconds(60)
                : Duration.ofMinutes(10).plusSeconds((long) (Math.random() * 60));   // 防雪崩:TTL 抖动
        redis.opsForValue().set(key, value, ttl);
        return entity;
    } finally { lock.unlock(); }
}

Service 一行接入:cacheManager.getProduct(id)——Service 不感知 Redis 存在(20 章 Manager 层职责)。

三、写路径:Redis 分段扣减 + DB 兜底(第 24/32 章)

// sample/mini-shop-boot/src/main/java/com/example/minishop/manager/StockManager.java(节选)
private static final String DEDUCT_LUA = """
        local stock = redis.call('GET', KEYS[1])
        if stock == false then return -2 end                 -- 段不存在:未预热/已淘汰 → DB 兜底
        if tonumber(stock) < tonumber(ARGV[1]) then return -1 end
        return redis.call('DECRBY', KEYS[1], ARGV[1])        -- 检查+扣减原子(32 章 CAS 思想)
        """;

/** 扣减结果台账:success 时记录「扣的是哪一段」——事务回滚时按它回补 Redis */
public record Deducted(boolean success, int segment) { ... }

public Deducted deduct(Long productId, int quantity) {
    int start = ...;                                          // 粘性选段(32 章分段方案)
    for (int i = 0; i < segments; i++) {                      // 空段顺时针借
        Long r;
        try {
            r = redis.execute(script, List.of(segKey(productId, (start + i) % segments)),
                    String.valueOf(quantity));
        } catch (Exception redisDown) {
            log.warn("Redis 不可用,降级 DB 扣减", redisDown);
            break;                                            // 整个 Redis 都不可用,别再试下一段
        }
        if (r != null && r >= 0) return new Deducted(true, /*段号*/ seg);
        if (r != null && r == -2) break;
    }
    return productMapper.deductStock(productId, quantity) == 1   // DB 条件 UPDATE 兜底(33 章)
            ? Deducted.FROM_DB : Deducted.FAIL;
}

Redis 段补偿台账(33 章思考题的答案落地):Redis 扣减不在 MySQL 事务里——下单事务回滚时,数据库部分交给事务自己回滚,但已扣掉的 Redis 段必须手动加回去,否则热路径库存凭空蒸发。OrderService.createOrder 的做法:逐项扣减时把 Deducted(segment=段号) 记入台账,catch 到异常时按台账调 restockRedisSegment(productId, segment, quantity) 逐段回补;走 DB 兜底的扣减(segment = -1)不需要补偿——数据库事务自己会回滚。补偿也失败时记 ERROR,等段 TTL 到期由预热以 DB 为准重建。

预热(启动时从 DB 装载,18 章 ApplicationRunner):

// sample/mini-shop-boot/src/main/java/com/example/minishop/config/DataInitializer.java(节选)
productMapper.insert(p);
stockManager.warmup(p.getId(), p.getStock());   // 拆 N 段写 Redis,抢购流量不碰 MySQL

一致性策略(33 章思考题的答案):Redis 段是「性能前哨」,DB 是「唯一事实」。两者允许瞬时误差,三条对账规则闭环:

  1. 关单回补两边都补(Redis 尽力、DB 必达);
  2. 段丢失(-2)或异常时降级 DB,宁可慢不可错;
  3. 上线对账任务(延伸):定时比对 SUM(Redis 段) 与 DB stock,差异告警。

四、异步链路:AFTER_COMMIT 发消息 + 延迟关单(第 25 章)

// sample/mini-shop-boot/src/main/java/com/example/minishop/infra/mq/OrderEventToMqBridge.java(节选)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)   // 22 章:提交后才发
public void on(OrderCreatedEvent event) {
    // 每个订单只发一条「延迟关单任务」消息(报文内 msgId 与消息属性 setMessageId 同源):
    // 进 order.create.q(纯 TTL 队列、无消费者),到期死信进关单队列。
    // 下游通知(短信/积分)若需要,应另起独立交换机——别往延迟队列塞第二种消息。
    send(event, UUID.randomUUID().toString());
}
// sample/mini-shop-boot/src/main/java/com/example/minishop/infra/mq/OrderCloseConsumer.java(节选)
@RabbitListener(queues = RabbitConfig.CLOSE_QUEUE, ackMode = "MANUAL")
public void onClose(Message message, Channel channel) throws Exception {
    ...
    boolean closed = orderService.closeIfCreated(orderNo);   // 状态机校验:非 CREATED 跳过(幂等)
    channel.basicAck(tag, false);
}

三代关单对照(全书关单演进线收官):

版本 机制 缺陷 状态
32 章 DelayQueue 内存队列 重启丢、不能扩展、无削峰 教学退役
34 章 RabbitMQ TTL + 死信 需要统一延迟时长(本项目统一 order-timeout-minutes) ✅ 现役
延伸 延迟插件/定时扫描补偿 附录 92 延伸阅读 —

五、支付回调:三层幂等(第 16/25/26 章)

// sample/mini-shop-boot/src/main/java/com/example/minishop/service/impl/PayCallbackService.java(节选)
public String handle(PayCallbackDTO cb) {
    // ① 验签:permitAll 接口的门禁(HMAC-SHA256,16 章)
    String raw = "orderId=%d&payableCents=%d&status=%s&msgId=%s".formatted(...);
    if (!SignUtil.verify(raw, cb.sign(), paySecret)) throw new BizException(40001, "签名校验失败");

    // ② 幂等:msgId 唯一键抢占(25 章)——重复投递在这里挡下
    try {
        idempotentMapper.insert(record(cb.msgId()));
    } catch (DuplicateKeyException e) {
        return "SUCCESS";                       // 已处理过,静默成功
    }

    // ③ 状态机:条件 UPDATE(`UPDATE ... SET status='PAID' WHERE id=? AND status='CREATED'`),
    //    影响行数 0 = 已不是 CREATED:已关单抛 PAY_CLOSED(03 章状态机),重复回调幂等跳过
    orderService.markPaidByOrderId(cb.orderId());
    return "SUCCESS";                           // 网关约定报文,不是 Result<T>(23 章思考题的答案)
}

本地验证(自造网关):

SECRET=local-dev-pay-secret
PAYLOAD='orderId=1&payableCents=3998&status=SUCCESS&msgId=m-001'
SIGN=$(python3 - "$PAYLOAD" "$SECRET" <<'EOF'
import hmac,hashlib,sys
print(hmac.new(sys.argv[2].encode(), sys.argv[1].encode(), hashlib.sha256).hexdigest())
EOF
)
curl -X POST localhost:8080/api/pay/callback -H 'Content-Type: application/json' \
     -d "{\"msgId\":\"m-001\",\"orderId\":1,\"payableCents\":3998,\"status\":\"SUCCESS\",\"sign\":\"$SIGN\"}"
# 再重放一次:日志出现「重复回调幂等跳过」,订单状态不变

六、安全链路:JWT 登录与归属校验(第 26 章)

登录 POST /api/auth/login(白名单)→ 颁发 JWT
    ↓ 后续请求 Authorization: Bearer xxx
JwtAuthFilter 解析 → UserContext.set(userId) + SecurityContextHolder 写入认证
    → finally 两处都清(13 章纪律 + 26 章:只写前者会被 anyRequest().authenticated() 拒 401)
    ↓
OrderService.detail:UserContext.require() 与订单 userId 比对 → 40301 防水平越权

密钥从环境变量注入(29 章 compose:JWT_SECRET),ShopProperties 启动期校验长度 ≥32(19 章)。

七、常见坑 ⚠️

  • ⚠️ 缓存与 DB 双写乱序:更新接口必须「先库后删」(24 章),先删缓存会被并发读灌回旧值。
  • ⚠️ MQ 消息无 msgId:幂等表空转。发消息端强制 setMessageId(25 章)。
  • ⚠️ 回调验签串拼接不一致:签名串的「参数顺序与格式」必须与网关文档逐字符一致(16 章排障技巧)。
  • ⚠️ 把 Redis 当唯一事实:段库存可重建,DB 才是立法者。任何「Redis 成功 DB 失败」都要有对账或回补路径。
  • ⚠️ JWT 密钥弱:ShopProperties 构造校验挡掉 <32 字符密钥——启动失败好过上线被伪造。

八、调试技巧

  • 缓存不生效:redis-cli GET product:1 看有没有值、TTL 看是不是已过期;空值缓存的占位是 __NULL__(不是字符串 "null"),别被值骗了。
  • 库存对不上:先分清差在哪一侧——redis-cli MGET stock:seg:1:0 ... :9 求和与 SELECT stock FROM product 比对;Redis 少了查补偿日志(Redis 段回补失败),DB 少了查关单回补路径。
  • 延迟关单没触发:管理台看 order.create.q 是否有消息堆积、x-message-ttl 参数是否等于配置分钟数(RabbitConfig 用的是 props.orderTimeoutMinutes() * 60_000L)、死信绑定是否齐全;再查停车场队列有没有消费失败的消息。
  • 回调一直 40001:打印验签原始串与网关文档逐字符对比(空格、参数顺序、大小写);PAY_SECRET 两端是否一致。
  • JWT 全 401:请求头 Bearer 带空格了吗;JWT_SECRET 是否两端一致;JwtAuthFilter 是否两处上下文都写了(26 章坑清单)。

九、最佳实践

  • 热路径失败必须有兜底:Redis 降级 DB 的 try/catch 不是「防御式编程」,是明确的双层架构——宁可慢、不能错。
  • 一切跨系统边界(MQ、回调、缓存)的消息/请求都带唯一 id 并落幂等记录;「至少一次」是世界的默认值。
  • 事务回滚的补偿路径与主路径同等重要:台账(Deducted)记录要在扣减成功的那一刻写入,补偿逻辑要有对应单测(回滚→逐段回补)。
  • 生产化改造按「一条主线一个 PR」推进:缓存、MQ、幂等、鉴权各自独立可回滚,别一把梭。

十、本章小结

  • 读路径三防御 + 写路径两兜底(Redis 热 / DB 冷)+ 回滚补偿台账(Deducted 段回补)+ 异步链路一桥(AFTER_COMMIT 单条延迟关单消息)+ 回调三幂等 + 安全一链(JWT + SecurityContext),构成生产化完整拼图。
  • 关单三代演进收官:DelayQueue → TTL+DLX;「不删任务,让任务可跳过」是幂等式关单的精髓。
  • 一致性分层:缓存最终一致、Redis/DB 秒级对账、MQ 至少一次 + 幂等消费。
  • mini-shop 现在能扛并发、能防重放、能验身份——只差可观测与部署的最后一公里。

十一、动手练习

基础题

  1. 跑通缓存:两次请求 /api/products/1,第二次观察耗时骤降;redis-cli TTL product:1 验证抖动;更新商品后再读,验证「先库后删」生效。
  2. 跑通回调:按第五节脚本签发合法回调 → 状态变 PAID;重放同 msgId → 幂等跳过;篡改 sign → 40001。

进阶题

  1. 压测对比:用第 32 章思路写 200 线程 HTTP 压测脚本打 /api/orders(库存 100),验证「成功 ≤ 100 且无超卖」;分别测「只有 DB 兜底」与「Redis 热路径」两档,记录 QPS 差异。
  2. 实现关单补偿:定时任务(@Scheduled 每 5 分钟)扫 listExpired() 已过期未支付订单调用 closeIfCreated,与 MQ 关单形成双保险;思考两者重复执行时为什么不会出错(幂等)。

思考题

  1. 若把「库存段」从 10 段调到 100 段,扣减吞吐与内存/管理成本各如何变化?结合 32 章思考题的「非空段路由」,给出你认为 10 万 QPS 场景的段数与借段策略,写一段 200 字的设计说明。

📄 第 35 章 收尾:链路日志、指标与 Docker Compose 部署 35-minishop-ops.md

阶段:阶段 4 · 实战项目 | 预计阅读 60 min | 代码落地模块:sample/mini-shop-boot(observe/Dockerfile)+ sample/docker-compose.yml 前置:第 27–29 章、第 34 章 实战落点:全书验收——第 30 章五条验收标准逐条打钩,交付一个「能上线」的 mini-shop

学习目标

读完本章,你应该能够:

  • [ ] 跑通 traceId 贯穿「下单 → MQ → 关单」的全链路日志。
  • [ ] 暴露并解读下单成功率、耗时与 MQ 积压三类指标。
  • [ ] 用 docker compose up -d 一条命令拉起全家桶并完成上线检查单。
  • [ ] 逐条核验第 30 章验收标准,写出项目复盘。

一、可观测收尾(第 27 章)

1.1 全链路 traceId

链路已在前两章埋好,本章串起来验证:

HTTP 入口  TraceIdFilter:生成/沿用 X-Trace-Id → MDC          (27 章)
    ↓ 每条日志自动带 [%X{traceId}]
MQ 投递   OrderEventToMqBridge:消息头塞 X-Trace-Id           (25 章)
    ↓
MQ 消费   OrderCloseConsumer:取出放回 MDC → finally 清理     (25/27 章)

验证脚本:

curl -si -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
     -H 'X-Trace-Id: my-trace-001' \
     -d '{"items":[{"productId":1,"quantity":1}]}' | grep -i x-trace-id
# X-Trace-Id: my-trace-001

grep "my-trace-001" logs/app-info.log
# 22:15:01.101 INFO [my-trace-001] 订单创建成功, orderNo=...
# (等待死信或手动触发关单后)
# 22:30:01.220 INFO [my-trace-001] 延迟关单处理完成, orderNo=..., closed=true

一条 grep 还原一笔订单的完整生命周期——第 30 章验收标准第 4 条达成。

1.2 业务指标(第 27 章)

OrderMetrics 已埋三个指标,/actuator/prometheus 输出示例:

shop_order_created_total 1523.0
shop_order_failed_total 17.0
shop_order_create_time_seconds_count 1523.0
shop_order_create_time_seconds_sum 118.5      # 平均 77ms/单

值班解读口诀:失败率突增看 ERROR 日志根因、耗时突增看慢 SQL 与锁等待、两者都没问题看流量构成。Prometheus 抓取配置属于部署侧延伸(附录 92)。

二、部署收官(第 29 章)

cd sample
docker compose up -d --build          # app + mysql + redis + rabbitmq 一条命令
docker compose ps                     # 等 app 健康 healthy

compose 的关键接线(29 章逐条讲过,这里是验收):

配置 作用
schema.sql 挂载到 docker-entrypoint-initdb.d 首次启动自动建表
depends_on: condition: service_healthy ×3 MySQL/Redis/RabbitMQ 都就绪才起应用
环境变量注入 MYSQL_HOST/RABBITMQ_USER/JWT_SECRET/... 一份镜像多环境(RabbitMQ 自定义用户跨容器必备)
应用 healthcheck + server.shutdown: graceful 就绪探测 + 优雅停机

上线检查单(30 秒版):

curl -sf localhost:8080/actuator/health                       # 1. 健康
curl -sf -X POST localhost:8080/api/auth/login -H 'Content-Type: application/json' \
     -d '{"username":"alice","password":"Passw0rd!"}'          # 2. 登录冒烟
curl -sf -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
     -H 'Authorization: Bearer <token>' \
     -d '{"items":[{"productId":1,"quantity":1}]}'              # 3. 下单冒烟
docker compose logs app --tail 50 | grep -c ERROR              # 4. 无启动错误

三、全书验收:五条标准逐条打钩

# 验收标准(第 30 章) 达成方式 验证
1 全链路功能可用,状态机不越轨 33 章分层实现 + 枚举状态机 本章冒烟三连
2 并发无超卖、账目平衡 Redis 分段 Lua + DB 条件 UPDATE(32/34 章) 200 线程压测:成功=库存、成功+剩余=100
3 回调幂等 验签 + msgId 去重表 + 状态机(25/34 章) 同 msgId 重放,订单只支付一次
4 traceId 全链路可追 MDC + MQ Header 透传(27 章) 一条 grep 串出下单→关单
5 一条命令可部署 Dockerfile + compose + 健康检查(29 章) docker compose up -d --build

五条全绿,这个项目就有了「生产化」的骨架——你也走完了从 main 方法到可上线服务的全程。

四、复盘:mini-shop 教会你的十件事

  1. 金额用 long/BigDecimal,绝不用 double(01 章)——一切账目事故的源头免疫;
  2. 面向接口让存储从内存换到 MySQL 只改装配不改业务(02/05/18 章);
  3. 状态机是法律:非法转移抛异常,幂等与关单的底气全来自它(03/25 章);
  4. 超卖的根因是原子性:从内存锁到 DB 条件 UPDATE 到 Redis Lua,是同一个原理的三次落地(11/21/24 章);
  5. 事务只包快操作,副作用 AFTER_COMMIT(22 章);
  6. 缓存三防御共享同一条读路径:空值、单飞、TTL 抖动(24 章);
  7. MQ 至少一次 ⇒ 幂等不是选项(25 章);
  8. 安全在后端:归属校验、验签、参数化 SQL,前端隐藏不算防线(26 章);
  9. 日志带主键 + traceId 透传,故障排查从玄学变成 grep(27 章);
  10. 镜像与环境分离、配置环境变量注入,交付物唯一(29 章)。

这十条就是「具备参与企业级后端开发能力」的骨架。接下来的成长靠真实项目:把每一条在团队代码库里找到对应物,你就正式入行了。

五、延伸方向(附录 92 有完整路线)

  • 深入并发:AQS 源码、无锁数据结构、限流算法(令牌桶/漏桶);
  • 深入存储:InnoDB 索引原理、分库分表、binlog 对账;
  • 深入中间件:Redis 持久化与集群、RabbitMQ 集群与镜像队列;
  • 微服务与云原生:Spring Cloud、K8s、服务网格——等你所在团队真的需要时再学,本教程的单体能力是它们的地基。

六、常见坑 ⚠️

  • ⚠️ 验收只测 happy path:五条标准里三条是故障路径(超卖/重放/宕机)。验收 = 压测 + 故障注入。
  • ⚠️ 指标没基线就上线:第一天的 QPS/耗时分布就是回归参照物,上线即埋点。
  • ⚠️ compose 起来就当验收完成:ps 全绿只是开始,检查单走完才算交付。

七、调试技巧

  • traceId 断链排查:先确认入口响应头有没有回传(curl -si 看 X-Trace-Id),再分链路段查——HTTP 段缺失查 TraceIdFilter 的 @Order(-200) 是否生效,MQ 段缺失查消息头 X-Trace-Id 与消费者的 MDC 恢复代码。
  • 指标页 404:/actuator/prometheus 需要 micrometer-registry-prometheus 依赖(27 章坑),依赖在而端点 404 再查 exposure 配置。
  • compose 起不来按层剥:docker compose ps 看哪个 unhealthy → logs 看该服务日志;MySQL 首次启动失败先查挂载的 schema.sql 语法,RabbitMQ 拒连先查用户名(guest 跨容器必被拒)。
  • 压测数字与 32 章对不上很正常:环境(容器/本机、网络跳数)不同只比相对关系;先固定口径再比较。

八、最佳实践

  • 上线检查单脚本化:把第二节四步检查写成 smoke.sh 进仓库,CI 部署后自动执行——「手动检查」在第三次发布后必然被跳过。
  • 基线数据进文档:压测结果、指标均值随手记进工程 docs/,回归时有对照物。
  • 收官即复盘:35 章十件事逐条写出「对应章节 + sample 落点」,这份清单就是你的面试讲稿。
  • 交付物再检查一遍可复现性:新机器 clone → docker compose up -d --build → 检查单全绿,能做到才算真正交付。

九、本章小结

  • 可观测收尾:一条 grep 串全链路;三个业务指标管趋势。
  • 部署收尾:一条命令全家桶 + 四步检查单。
  • 五条验收标准全部闭环——从第 01 章的 long 金额到今天的幂等回调,每个知识点都在 mini-shop 里找到了位置。

恭喜完成全书。附录(90–92)是你的随身工具箱:版本速查、术语表、报错急救——遇到问题先翻它们。


十、动手练习

基础题

  1. 完整跑一遍「上线检查单」(第二节四步),把输出贴进你的学习笔记;对 my-trace-001 完成一次全链路 grep 演示。
  2. 记录压测数据:200 线程抢 100 库存的「成功/剩余/失败率/平均耗时」,与 32 章 SE 版结论对照,写 100 字异同分析。

进阶题

  1. 实现一个 5 分钟对账任务:汇总 Redis 段库存与 DB stock 比对,差异超过 5 件打 ERROR 日志(先人工介入)。
  2. 给项目补一份 POSTMAN.md(或 OpenAPI 导出):收录登录→下单→支付回调→关单的完整请求集合,作为你的「接口回归清单」。

思考题

  1. 如果产品要求「订单支持 30 分钟与 24 小时两种支付时限」,TTL+DLX 方案会遇到队头阻塞(25 章提示)。给出你的改造方案(延迟插件/多级 TTL 队列/定时扫描),说明选型理由——这是你离开教程后第一个真实的架构决策。

📄 技术栈版本基线 90-appendix-tech-stack-versions.md


title: 90 技术栈版本基线(全篇唯一权威来源) updated: 2026-09 applies-to: JDK 21 + Spring Boot 4.1.x + MySQL 8 + Redis + RabbitMQ

[!WARNING] 本文件是全篇版本信息的唯一权威来源。所有章节的「本章版本基准」都引用这里。 如果发现某章写着的版本与本表不一致,以本表为准并修正该章。

所有版本号截至 2026 年 9 月。 软件版本持续变化,请务必学会自己核对(见文末「如何自己核对版本」)。

[!WARNING] 本教程编写环境已按本表对 sample/ 做过全量一致性核对与修复。依赖小版本随时间演进(表中是 2026-09 快照),拉取失败或解析异常时,按文末「如何自己核对版本」处理;大版本与 Starter 坐标不要动。


一、语言与运行时

项目 基线 说明
JDK 21 LTS(教程基线) Oracle Premier 至 2028-09;Eclipse Temurin 社区构建免费
JDK(最新 LTS) 25(2025-09 GA) 本书代码全部兼容 25;虚拟线程等新特性在第 13 章讲解
JDK(17) 仅存量维护 Oracle Premier 已于 2026-09 到期,新项目不要再选
最新非 LTS 27(2026-09-15 发布) 半年生命周期,教学不追
发行版 Eclipse Temurin 21 / Oracle JDK 21 均可 国内也可用阿里 Dragonwell、毕昇 JDK
JAVA_HOME 必须配置 Maven 与多数工具依赖它,见第 00 章

[!NOTE] LTS(Long-Term Support,长期支持版)每两年一个:17 → 21 → 25。企业选型看支持周期与存量生态,教学选 21 是因为它存量最广、招聘匹配度最高。


二、框架与核心依赖

组件 基线 获取方式 关键提醒
Spring Boot 4.1.x(当前稳定 4.1.1) Maven Central / 阿里云镜像 OSS 免费支持至 2027-07-31
Spring Boot 4.0.x 仅存量 同上 OSS 支持至 2026-12-31
Spring Boot 3.5.x ⚠️ 已停止免费支持(2026-06-30) 同上 商业支持到 2032;存量项目常见,新项目不要选
Spring Framework 7.0.x(随 Boot 4) 传递依赖 基线仍是 Java 17+
Spring Security 7.x(随 Boot 4) 传递依赖 配置风格为 Lambda DSL + SecurityFilterChain,与 5.x 教程完全不同
Jackson 3.0.0(Boot 4 世代) boot 模块随 BOM;se 模块自声明 ${jackson.version} ⚠️ 包名从 com.fasterxml.jackson 迁至 tools.jackson;老教程的 import 会编译失败
MyBatis-Plus 3.5.17 Maven Central Spring Boot 4 必须用 mybatis-plus-spring-boot4-starter(3.5.15 起才兼容 Boot 4)
MyBatis-Plus 分页 mybatis-plus-jsqlparser 3.5.17 Maven Central ⚠️ 3.5.9 起分页拦截器从核心包拆出,必须单独引入且版本与 starter 一致
Redisson 4.7.x Maven Central Boot 4.1 配 redisson-spring-data-41;starter 会自动带对
JWT jjwt 0.13.x(io.jsonwebtoken) Maven Central 也可用 com.auth0:java-jwt;本书用 jjwt
参数校验 jakarta-validation(随 Boot) 传递依赖 包名 jakarta.validation,不是 javax.validation
Lombok 最新稳定(随 Boot BOM) Maven Central 可选;sample/ 为降低环境差异不使用 Lombok
MapStruct 1.6.x Maven Central 可选;对象转换也可手写 converter
Hutool — Maven Central 常用工具库(第 16 章概览);本书示例代码不引入该依赖,统一用 JDK 原生写法

Starter 坐标陷阱(背下来)

你的 Spring Boot MyBatis-Plus 正确坐标
2.x mybatis-plus-boot-starter
3.x mybatis-plus-spring-boot3-starter
4.x mybatis-plus-spring-boot4-starter

用错坐标的典型症状:启动报 No qualifying bean of type 'XxxMapper' 或 Invalid value type for attribute 'factoryBeanObjectType'——自动配置根本没生效。


三、数据库与中间件

组件 基线 关键提醒
MySQL 8.0.x(生态最成熟)或 8.4 LTS 认证插件 caching_sha2_password,驱动用 com.mysql:mysql-connector-j;字符集统一 utf8mb4
Redis 8.x 8.0 起 License 改为 AGPLv3,商用注意;BSD 替代品 Valkey,客户端用法一致
RabbitMQ 4.3.x 依赖 Erlang,版本必须匹配(官方源会自动处理);管理台端口 15672
连接池 HikariCP(Boot 默认) 不要额外引入 Druid 才能跑通;想学连接池原理见第 21 章

[!TIP] 本书所有中间件都用 Docker Compose 启动(第 00 章给出 docker-compose.yml,第 29 章复用)。 想在 Linux 服务器上手工安装的读者,可参考本工作区 ../linux-server/docs/ 对应章节。


四、测试与工具链

组件 基线 说明
Maven 3.9+(父 POM enforcer 强制门禁) sample/ 用多模块 + 父 POM 收口版本
JUnit 6.x(工程为 6.0.1,org.junit.jupiter) 第 10 章入门、第 28 章进阶;JDK 17+
Mockito 随 Boot BOM 服务层单测;5.x 起内联引擎默认,mockStatic 可直接用
Testcontainers 最新稳定 集成测试起真容器;需要本机 Docker
spring-boot-webmvc-test 随 Boot 4.1 Boot 4 起 @WebMvcTest 独立成该依赖(新包名,见 28 章)
springdoc-openapi 3.0.0(适配 Boot 4,不在 Boot BOM 内需自声明) 接口文档 /swagger-ui.html;引入坐标 springdoc-openapi-starter-webmvc-ui
maven-enforcer-plugin 3.5.0(父 POM 锁定) 构建门禁:JDK 21+ / Maven 3.9+,不满足直接失败
exec-maven-plugin 3.5.0(父 POM 锁定) exec:java 跑 CLI/压测入口(31/32 章),锁版本保证可复现
IDE IntelliJ IDEA(Community 够用) 第 00 章
Docker Docker Engine + Compose v2 docker compose(带空格),不是 docker-compose

五、版本兼容速查(常见踩坑对照)

症状 根因 解决
import com.fasterxml.jackson... 编译失败 Boot 4 用 Jackson 3 改 import tools.jackson...;MyBatis-Plus JSON 字段用 Jackson3TypeHandler
javax.validation / javax.servlet 不存在 Boot 3 起全面 jakarta.* 改 jakarta.validation / jakarta.servlet
Mapper 注入失败 Starter 坐标与 Boot 大版本不匹配 按上表换 mybatis-plus-spring-boot4-starter
分页不生效(查全表) 缺 mybatis-plus-jsqlparser 引入并保持版本一致
Redisson 自动装配行为诡异 redisson-spring-data-XX 与 Spring Data 版本不匹配 用 redisson-spring-boot-starter 4.6+,勿手工混搭旧模块
Security 配置抄老教程报错 5.x 的 WebSecurityConfigurerAdapter 已删除 用 7.x Lambda DSL,见第 26 章
LocalDateTime 序列化成数组/带 T JSON 库与格式配置问题 Jackson 3 默认支持 JSR-310,需要统一格式时全局配置 spring.jackson

六、如何自己核对版本

  1. Maven 依赖:在 https://central.sonatype.com 或 https://mvnrepository.com 搜索坐标,看 Latest 与发布日期。
  2. Spring Boot 支持期:https://endoflife.date/spring-boot,一张表看清每条线 OSS 支持截止日。
  3. JDK 路线图:https://www.oracle.com/java/technologies/java-se-support-roadmap.html。
  4. 让 Maven 告诉你:工程里执行 mvn dependency:tree -Dincludes=组名: artifactId,看实际解析到的版本。
  5. Spring Boot 版本管理:工程继承 spring-boot-starter-parent 后,绝大多数依赖不需要写版本号,由 BOM 统一管理——这正是第 17 章要讲的「依赖收口」。

[!TIP] 也有例外:MyBatis-Plus、Redisson、jjwt、springdoc 不在 Boot BOM 管理范围内,需要在 dependencyManagement 里自己声明版本(sample/ 父 POM 已统一收口)。


维护约定

  • 每个正文章节不写死小版本号,需要时以「随 Boot 4 BOM」或「见附录 90」表述。
  • 豁免:正文章节展示 sample/pom.xml 真实内容时(如 17/21/23 章的依赖代码块),版本号以 sample 为准照抄——代码块是「工程快照」而非「版本声明」。
  • 升级基线时只改本文件 + sample/pom.xml,正文不动。
  • 本文件头部 updated 字段随版本核对更新。

📄 术语表与 API 速查 91-appendix-glossary.md


title: 91 术语表与 API 速查 updated: 2026-09 applies-to: JDK 21 + Spring Boot 4.1.x

一、术语表(按拼音/字母序)

术语 英文 / 缩写 一句话解释 主章
原子性 Atomicity 事务/操作不可分割:要么全做要么全不做 11/22
可见性 Visibility 一个线程的写对其他线程立即可见 11
有序性 Ordering 指令不因重排而与代码语义相悖 11
Java 内存模型 JMM 定义多线程读写共享变量的规则 11
比较并交换 CAS(Compare-And-Swap) 硬件原子指令:期望值匹配才更新,失败自旋重试 12
死锁 Deadlock 互相持锁等待,四条件破坏其一即可预防 12
线程池 Thread Pool 线程复用 + 数量管控;有界队列是底线 13
ThreadLocal — 线程私有变量;用完必须 remove 防泄漏与串号 13
虚拟线程 Virtual Thread(JDK 21) JVM 调度的轻量线程,救「等待」不救「计算」 13
停止世界 STW(Stop-The-World) GC 期间应用线程全部暂停 14
堆/栈/元空间 Heap/Stack/Metaspace 对象的家 / 方法调用帧 / 类元数据 14
阻塞 IO BIO 一连接一线程,连接海量时线程爆炸 15
非阻塞 IO NIO(含 Selector 多路复用) 少量线程管理海量连接 15
BOM Bill of Materials 版本清单导入:Boot 全家桶坐标免写版本号 17
传递依赖 Transitive Dependency 依赖的依赖自动进树;版本冲突靠「最短路径+先声明」调解 17
依赖注入 DI(Dependency Injection) 容器把依赖「递给」对象,正解是构造器注入 18
控制反转 IoC(Inversion of Control) 对象的创建与装配权从业务代码反转给容器 18
面向切面编程 AOP 日志/事务等横切逻辑声明式织入(代理实现) 19
统一响应 Result<T> code/message/data 三段式接口外壳 05/23
数据传输对象 DTO / VO 入参对象 / 出参对象;Entity 绝不直出接口 20
乐观锁 Optimistic Lock version 比对或条件 UPDATE,冲突时失败重试 21
逻辑删除 Soft Delete deleted 标记删除,查询自动过滤(@TableLogic);物理数据还在 21
ACID Atomicity/Consistency/Isolation/Durability 事务四特性:原子/一致/隔离/持久 22
脏读/不可重复读/幻读 Dirty Read / Non-Repeatable Read / Phantom Read 三类并发读异常,隔离级别逐级防住 22
事务隔离级别 Isolation Level 读未提交/读已提交/可重复读(MySQL 默认)/串行化 22
传播行为 Propagation REQUIRED / REQUIRES_NEW / NOT_SUPPORTED 等 22
REST Representational State Transfer 资源名词 + HTTP 方法语义 + 状态码 23
缓存穿透/击穿/雪崩 — 查不存在 / 热点过期 / 批量过期,解法见主章 24
Cache-Aside — 读:缓存→回源回填;写:先库后删 24
分布式锁 Distributed Lock 跨进程互斥;用 Redisson,勿手写 SETNX 24
看门狗 Watchdog Redisson 锁默认 30s + 后台每 10s 续期;显式传 leaseTime 则不续期 24
至少一次投递 At-Least-Once MQ 可能重复投递 ⇒ 消费必须幂等 25
死信队列 DLX(Dead Letter Exchange) 失败/过期消息的去处;TTL+DLX 实现延迟 25
队头阻塞 Head-of-Line Blocking TTL 队列里前面的长延迟消息挡住后面的短延迟消息(统一延迟时长可免疫) 25
幂等 Idempotency 重复执行不产生二次效果:去重表+唯一键+状态机 25
本地消息表 Outbox / Local Message Table 业务与消息记录同库同事务落库,后台扫表补偿发送 22/25/34
JWT JSON Web Token 自包含签名令牌;签名非加密,无法主动吊销 26
水平越权 — 访问同级别其他用户的数据;必须校验资源归属 26
垂直越权 — 普通用户调管理员接口;角色控制(hasRole/@PreAuthorize) 26
MDC Mapped Diagnostic Context 日志线程级上下文,承载 traceId 27
可观测三支柱 Metrics/Logging/Tracing 指标看趋势、日志看根因、链路看拓扑 27
测试金字塔 Test Pyramid 单测量大、切片适中、E2E 冒烟 10/28
打桩/验证 Stub/Verify Mock 返回预设值 / 断言交互发生与否 28
一次性容器 Testcontainers 测试期起真中间件,跑完即焚 28
多阶段构建 Multi-stage Build 编译与运行镜像分离,交付物瘦身 29
优雅停机 Graceful Shutdown 停止接新请求 + 排空在途请求 29
快照价 — 下单时固化商品名与单价,改价不影响历史订单 30
状态机 State Machine 有限状态 + 合法转移表;枚举实现 03/30
聚合根 Aggregate Root 一组必须保持一致的对象的总入口,外部只能经它操作(如 Order) 03/30
值对象 Value Object 不可变 + 按值相等(如 Money) 03
类型擦除 Type Erasure 泛型只在编译期存在;解法是传 Class<T> 令牌 05
fail-fast — 集合遍历中结构性修改立即抛异常的保护机制 04
类型令牌 Class<T> 运行期携带泛型信息的参数 05/16

二、高频 API 速查

字符串与集合

String.isBlank() / strip() / join(",", list) / formatted("%d", x)
list.removeIf(p -> cond)          // 遍历删除唯一推荐
map.getOrDefault(k, 0)            // 防装箱 NPE
List.of(a, b) / Map.of(k1, v1)    // 不可变工厂
Collections.unmodifiableList(x)   // 只读视图

Stream 骨架

list.stream().filter(cond).map(fn).sorted(cmp).limit(n).toList()
list.stream().mapToLong(fn).sum() / .summaryStatistics()
Collectors.groupingBy(key, Collectors.counting())
Collectors.toMap(k, v, (a, b) -> a)      // 合并函数防重复 key 异常

并发

new ThreadPoolExecutor(core, max, 60, SECONDS, new ArrayBlockingQueue<>(n), factory, policy)
latch.countDown() / latch.await()
atomicInteger.compareAndSet(expect, update)
redisson.getLock(key).tryLock(3, TimeUnit.SECONDS)
CompletableFuture.supplyAsync(fn, pool).thenCombine(other, merge).exceptionally(fallback)

排障四件套

jps -l                                   # 找 pid
jstat -gcutil <pid> 1000                 # GC 状况
jmap -dump:live,format=b,file=a.hprof <pid>   # 堆快照
jstack <pid>                             # 线程栈(死锁/CPU 定位)

Spring 常用注解

注解 用途 主章
@SpringBootApplication 启动三合一 18
@Service/@Component/@Mapper Bean 注册 18/21
@Transactional(rollbackFor = Exception.class) 事务(模板抄全) 22
@ConfigurationProperties(prefix="x") 配置绑定 19
@RestControllerAdvice + @ExceptionHandler 全局异常 23
@Valid / @NotNull / @Min / @Max 参数校验 23
@TransactionalEventListener(AFTER_COMMIT) 事务后事件 19/22/25
@RabbitListener(queues="q", ackMode="MANUAL") MQ 消费 25
@WebMvcTest / @MockitoBean / @SpringBootTest 测试切片(Boot 4 起 @MockBean 已移除) 28

MyBatis-Plus 骨架

LambdaQueryWrapper<T> qw = Wrappers.<T>lambdaQuery()
        .like(cond, T::getField, kw)
        .orderByDesc(T::getId);
mapper.selectPage(Page.of(page, size), qw)
mapper.update(null, Wrappers.<T>lambdaUpdate().setSql("stock = stock - 1").eq(T::getId, id).ge(T::getStock, 1))

📄 常见报错速查与延伸阅读 92-appendix-errors-and-faq.md


title: 92 常见报错速查与延伸阅读 updated: 2026-09 applies-to: JDK 21 + Spring Boot 4.1.x + MySQL 8 + Redis + RabbitMQ

用法:先按「症状」定位行,读「根因」,再按「解决」动手。排错方法论:现象 → 范围 → 证据 → 假设 → 验证(一次只改一个变量)。

一、编译期

症状 根因 解决
cannot find symbol: class ServletException 缺 jakarta servlet 依赖(Boot web 自带,检查 starter) 确认引了 spring-boot-starter-web;包名用 jakarta.* 不是 javax.*
package com.fasterxml.jackson does not exist Jackson 2 老教程 import Boot 4 用 Jackson 3:databind 在 tools.jackson;附录 90 速查
incompatible types: List<Integer> cannot be converted to List<Long> 泛型不变性 改类型或 ? extends 通配符(第 05 章)
method does not override or implement a method 重写签名不一致(参数类型/访问级别) 检查参数是否 Object(equals 场景),加 @Override 让编译器把关(第 03 章)
unreported exception XXX; must be caught checked 异常未处理 try-catch 或 throws(第 06 章)
Lombok 注解不生效 注解处理器未启用 本书不使用 Lombok;若自用需装 IDE 插件并开启 annotation processing

二、启动失败(Spring)

症状 根因 解决
NoSuchBeanDefinitionException: XxxMapper MyBatis-Plus starter 与 Boot 大版本不匹配;或 Mapper 不在扫描包 用 mybatis-plus-spring-boot4-starter(附录 90);@MapperScan 覆盖 mapper 包(第 18/21 章)
Invalid value type for attribute 'factoryBeanObjectType' 同上:老 starter 在 Boot 4 下自动配置失效 同上
APPLICATION FAILED TO START: Failed to configure a DataSource 没配数据库连接 检查 spring.datasource.url/username/password 与中间件容器状态(第 21 章)
BeanCurrentlyInCreationException 循环依赖 重构解环(抽公共类/事件),@Lazy 只作急救(第 18 章)
Port 8080 was already in use 端口占用 `netstat -ano
Connection refused: localhost:3306/6379/5672 中间件没起/容器内连了 localhost docker compose ps;容器互访用服务名(第 29 章)
Jwt secret must be at least 32 chars JWT 密钥过短 环境变量注入 32+ 字符密钥(第 26 章)
RabbitMQ PRECONDITION_FAILED - inequivalent arg 'x-message-ttl' 队列参数与已存在队列不一致 管理台删除旧队列重建,或换队列名(第 25 章)

三、运行期:接口与业务

症状 根因 解决
接口 404 路径/方法不匹配,或类没被扫描 对照 swagger-ui;确认 Controller 在扫描包内(第 23 章)
接口 401 JWT 缺失/过期/格式错 Authorization: Bearer <token>(有空格);重新登录(第 26 章)
接口 403(40301) 归属校验拦截或权限不足 用资源所有者账号测试;确认校验逻辑(第 26 章)
MethodArgumentNotValidException 返回 500 而非 400 全局处理器未生效 @RestControllerAdvice 在扫描包内;切片测试验证(第 23/28 章)
@Transactional 没回滚 四大失效之一:同类调用/非 public/缺 rollbackFor/异常被吞 对照第 22 章失效 Top4 逐条排查
并发扣减出现超卖 直接改库存字段,未走条件 UPDATE/乐观锁 UPDATE ... WHERE stock >= ?;@Version 兜底(第 21/32 章)
缓存读到的永远是旧值 更新接口先删缓存后更库,或写路径没删缓存 固定「先库后删」;检查 key 拼接一致(第 24 章)
消息消费了但业务没生效 无 msgId / 幂等表抢占失败被吞 每条消息必带唯一 msgId;检查幂等插入是否第一步(第 25 章)
消息无限重投 毒消息(必失败)+ 重回队列 重试上限 + 死信兜底;NACK 不 requeue(第 25 章)
分页返回全部数据 没配 PaginationInnerInterceptor 配分页插件 + mybatis-plus-jsqlparser(第 21 章)

四、运行期:JVM 与性能

症状 根因 解决
OutOfMemoryError: Java heap space 堆太小或泄漏 dump 分析(MAT);检查静态集合无界缓存;调 -Xmx(第 14 章)
StackOverflowError 递归失控/栈过小 修终止条件;-Xss 谨慎调大(第 14 章)
容器被 OOM Kill 无任何日志 堆外内存 + 容器限额冲突 显式 -Xmx(容器限额 50%~75%)+ HeapDump 参数(第 14/29 章)
CPU 100% 自旋空转/正则回溯/频繁 Full GC 三步法定位:top -H → 线程号转 hex → jstack(第 14 章)
接口偶发 200ms+ 抖动 Full GC/锁竞争/连接池等待 GC 日志 + jstack BLOCKED 统计 + Hikari 指标(第 14/21 章)
Connection is not available, request timed out 连接池耗尽 找事务里的慢 IO;池大小不是越大越好(第 21/22 章)
Timeout on blocking read 远程调用无超时 连接超时 + 请求超时必配(第 15 章)

五、构建与部署

症状 根因 解决
Maven 依赖下载失败/极慢 没配镜像或 mirrorOf=* 阿里云镜像 + mirrorOf=central(第 00 章)
NoSuchMethodError 运行期 依赖版本冲突 dependency:tree -Dincludes= 定位,dependencyManagement 收敛(第 17 章)
镜像构建慢 未分层缓存依赖 dependency:go-offline 单独一层(第 29 章)
容器 Restarting 循环 应用启动崩溃 docker compose logs app --tail 100 看根因(第 29 章)
数据库数据总被清空 compose down -v 删了卷 数据挂 volume;区分 down 与 down -v(第 29 章)
CI 测试本地过线上挂 时区/编码/环境差异 测试用固定 Clock(16 章);文件 IO 显式 UTF-8(08 章)

六、学习路线 FAQ

Q1:学完本书能达到什么水平? 独立完成「从 0 到 1 + 生产化」的中型后端模块;能读懂企业项目的分层与中间件代码;面试高频考点(集合/并发/JVM/事务/缓存/MQ)有实战支撑。

Q2:下一步学什么?

  • 巩固层(1–3 个月):把 mini-shop 扩展(秒杀限流、订单导出、对账任务),每个扩展逼你复习 2–3 章;
  • 深入层(3–6 个月):按需选一条——《Java 并发编程实战》(并发)、《深入理解 Java 虚拟机》第 3 版(JVM)、《MySQL 实战 45 讲》(数据库)、《DDD 实战》(领域建模);
  • 平台层(按团队需要):Spring Cloud Alibaba / Kubernetes / Redis 集群与分片——等你所在项目真的用到,带着问题学效率十倍。

Q3:书里的工程没编译过怎么办? 按附录 90「如何自己核对版本」更新 sample/pom.xml 版本;启动失败按本章二节逐条排查。排障本身就是最好的练习——报错信息按本章表格归类记录,一个月后你会有一份私人急救手册。

Q4:为什么不做微服务版? 单体 + 模块化(20 章按功能分包预案)能承载日均百万级订单;微服务解决的是「组织规模」问题不是「技术难度」问题。先把单体写对,再谈拆分。

Q5:为什么生产严禁 SNAPSHOT 版本? SNAPSHOT 每次构建可能被静默更新成不同产物,「今天打包、明天重打结果不一样」,出了问题无法复现现场。生产只允许 release 版本;sample 的父 POM 是 1.0.0-SNAPSHOT 属教学工程自用,别照抄到公司项目(第 17 章常见坑)。

Q6:为什么 updateById 后字段没被清空? MyBatis-Plus 默认更新策略是 NOT_NULL——实体里的 null 字段不会进 UPDATE 语句,这是防误清空的保护,但也让「想把字段置空」的人一头雾水。要显式清空用 LambdaUpdateWrapper.set(column, null)(第 21 章常见坑)。

Q7:日志里 traceId 偶尔「串」到了别的请求? MDC 底层是 ThreadLocal,线程池复用线程时上一个请求的 traceId 会残留——只 put 不 clear 的过滤器就是事故源。规矩:入口过滤器 finally { MDC.clear() },MQ 消费者同理(第 27 章常见坑)。

Q8:为什么我的测试套件要跑 20 分钟? 八成是 @SpringBootTest 当默认——每个测试类都起全容器。分层:业务逻辑用 Mockito 单测(毫秒级)、Web 契约用 @WebMvcTest 切片、只有真中间件验证才用全容器 + Testcontainers(第 28 章测试金字塔)。

七、延伸阅读清单

主题 资源 时机
Java 语言精读 《On Java 8》(Bruce Eckel)/ JDK 官方教程 阶段一后回读
并发深入 《Java 并发编程实战》、AQS 源码 第 13 章后
JVM 深入 《深入理解 Java 虚拟机》第 3 版(周志明) 第 14 章后
MySQL 《高性能 MySQL》、InnoDB 官方文档 第 21 章后
Redis 《Redis 深度历险》、Redisson 文档 第 24 章后
消息队列 RabbitMQ 官方 Tutorials(六篇全读) 第 25 章后
安全 OWASP Top 10 中文项目 第 26 章后
架构 《数据密集型应用系统设计》(DDIA) 全书完成后,进阶必读
可观测 OpenTelemetry 官方文档 第 27 章后
同工作区 ../linux-server/docs/(兄弟教程:服务器与中间件部署,没读过可跳过) 按需

📖 Java 后端教程 · 章节目录 README.md

本目录存放《系统化 Java 后端教程:从零基础到企业级开发》的正文分章文件。 总目录、学习路线与技术栈基线见上级目录的 ../README.md。


阅读路线

路线一:零基础直通(推荐第一次完整读)

00 环境 → 01–03 语法与 OOP → 04–08 集合/泛型/异常/Stream/IO
      ↓
09 反射注解 → 10 测试入门 → 30–31(mini-shop SE 版,阶段一闭环)
      ↓
11–14 并发与 JVM → 32(并发改造) → 15–16 网络/类库
      ↓
17–29 企业级逐章精读 → 33–35(生产化闭环) → 90–92 附录随时查

路线二:会其他语言 / 已会 Java SE

你的情况 从这里开始
会 C/Python,没写过 Java 01 速读到 03,04 起精读
会 Java SE,想补并发与 JVM 11 直通 16
会 Java SE,直接想做后端 17 起逐章精读,SE 部分当工具书查
只想看项目怎么演进 30 → 31 → 32 → 33 → 34 → 35,遇到不认识的回查知识点章

路线三:出问题了(救火)

症状 先去这章
编译不过 / 找不到符号 92
Spring 启动失败 / Bean 报错 18、92
接口报 500 想看日志 27
数据库慢 / 索引疑问 21
@Transactional 没生效 22
缓存与数据库不一致 24
消息丢了 / 重复消费 25
CPU 飙高 / OOM 14
不知道版本选哪个 90

阶段 0 · 起步

文件 标题 关键词
00-environment-setup.md 开发环境搭建 JDK 21、IDEA、Maven、Docker Compose、jshell

阶段 1 · 入门基础

文件 标题 关键词
01-java-basics.md 基础语法 类型、运算符、流程控制、数组、BigDecimal
02-oop-basics.md 面向对象基础 封装、继承、多态、接口
03-oop-advanced.md 类设计进阶 equals/hashCode、枚举状态机、record
04-collections.md 集合框架 List/Set/Map/Queue、fail-fast、HashMap 扩容
05-generics.md 泛型 类型参数、PECS、类型擦除
06-exceptions.md 异常处理 异常体系、业务异常、try-with-resources
07-lambda-and-stream.md Lambda 与 Stream 函数式接口、Collectors、并行流陷阱
08-io-and-nio2.md IO 与文件 字节/字符流、编码、NIO.2
09-reflection-and-annotation.md 反射与注解 自定义注解、动态代理、框架地基
10-unit-test-basics.md 单元测试入门 JUnit 6、断言、参数化

阶段 2 · 进阶提升

文件 标题 关键词
11-concurrency-basics.md 并发基础 线程、JMM、volatile、synchronized、超卖复现
12-locks-and-atomic.md 锁与原子类 ReentrantLock、CAS、死锁定位
13-juc-threadpool-async.md 线程池与异步编排 线程池参数、ThreadLocal、CompletableFuture
14-jvm-basics.md JVM 基础 内存区域、类加载、GC、排障四件套
15-network-programming.md 网络编程 Socket、HTTP、BIO→NIO、HttpClient
16-common-libraries.md 常用类库 java.time、Jackson、Hutool、摘要签名

阶段 3 · 企业级开发

文件 标题 关键词
17-maven-and-build.md 工程构建与依赖管理 坐标、依赖调解、多模块、生命周期
18-spring-boot-and-ioc.md Spring Boot 与 IoC/DI 自动配置、Bean 生命周期、依赖注入
19-spring-aop-and-config.md AOP 与配置管理 切面、代理、配置绑定、事件
20-layering-and-code-style.md 分层架构与代码规范 分层职责、DTO/VO、命名规约
21-mybatis-plus-and-mysql.md 数据库访问 建表、BaseMapper、分页、索引、乐观锁
22-transactions.md 事务 隔离级别、传播行为、失效场景
23-rest-api-design.md 接口设计与规范 RESTful、统一响应、参数校验、OpenAPI
24-redis-cache.md 缓存 Cache-Aside、穿透/击穿/雪崩、分布式锁
25-rabbitmq.md 消息队列 交换机、可靠投递、死信、延迟消息、幂等
26-security-and-auth.md 安全与鉴权 JWT、Spring Security 7、越权防护
27-logging-and-monitoring.md 日志与监控 日志分级、MDC traceId、Actuator、Prometheus
28-testing-strategy.md 单元测试与集成测试 Mockito、切片测试、Testcontainers
29-deployment-and-ci.md 部署与运维 Dockerfile、Compose、优雅停机、CI

阶段 4 · 实战项目 mini-shop

文件 标题 关键词
30-minishop-overview.md mini-shop 总览 需求、领域模型、状态机、里程碑
31-minishop-java-se.md 阶段一:纯 Java SE 内存版 全链路、JSON 落盘、单测
32-minishop-concurrent.md 阶段二:并发改造 超卖复现、五方案压测、延迟关单
33-minishop-layering.md 阶段三上:分层重构 Spring Boot 分层、MySQL、事务
34-minishop-production.md 阶段三下:生产化改造 缓存、分布式锁、MQ、幂等、JWT
35-minishop-ops.md 收尾:可观测与部署 traceId、指标、Docker Compose、CI

附录

文件 标题
90-appendix-tech-stack-versions.md 技术栈版本基线(全篇唯一权威来源)
91-appendix-glossary.md 术语表与 API 速查
92-appendix-errors-and-faq.md 常见报错速查与延伸阅读

每章结构(8 段式)

学习目标 → 核心概念 → 示例代码 → 常见坑 ⚠️ → 调试技巧 → 最佳实践 → 本章小结 → 动手练习

排版与代码规范

  • 术语首次出现写作「中文(English, 缩写)」,例如「控制反转(Inversion of Control, IoC)」。
  • 中英文之间加空格;类名、API、文件路径一律用反引号包起来。
  • 每个代码块首行注释标明文件路径,例如 // sample/mini-shop-boot/src/main/java/.../OrderService.java。
  • 关键代码行附中文注释,注释解释为什么这么写,不复述语法。
  • 风险点统一前缀 ⚠️,提示用 > [!TIP],警告用 > [!WARNING]。
  • 旧写法 / 迁移对照用 <details> 折叠块承载,不占主线篇幅。

版本基线

一切版本号以 附录 90 与工程内 sample/pom.xml 为准,本目录正文不锁死小版本。

📖 mini-shop 示例工程 README.md

《系统化 Java 后端教程》贯穿实战项目的 Maven 多模块源码,与第 30–35 章代码对照阅读。

模块地图

模块 对应章节 内容
mini-shop-common 05/06/20 起 Result、PageResult、Repository 契约、OrderStatus 状态机、ErrorCode + 业务异常体系
mini-shop-se 31/32 阶段一/二:纯 Java SE 实现(cli/ShopCli 入口、concurrent/ 并发压测)
mini-shop-boot 33/34/35 阶段三:Spring Boot 分层 + MySQL + Redis + RabbitMQ 生产化版本

首次构建检查清单

  1. JDK:java -version 确认 21+(版本基线见 ../docs/90-appendix-tech-stack-versions.md)。
  2. Maven:3.9+;国内网络先按第 00 章配置阿里云镜像(~/.m2/settings.xml)。
  3. 版本号:本工程各依赖版本集中在根 pom.xml 的 <properties>;若依赖拉取失败,去 Maven Central 核对该坐标的最新版本(正文不锁小版本,这里也是示例值)。
  4. 中间件:mini-shop-se 完全不需要 Docker;mini-shop-boot 需要先 docker compose up -d(本目录的 docker-compose.yml,含 MySQL/Redis/RabbitMQ)。

常用命令

# 全量构建 + 测试(阶段一/二零中间件,必须全绿)
mvn clean test

# 只构建某个模块
mvn clean test -pl mini-shop-se -am

# 运行阶段一控制台商城(第 31 章)
mvn -pl mini-shop-se -am compile exec:java -Dexec.mainClass=com.example.minishop.cli.ShopCli

# 运行并发压测(第 32 章)
mvn -pl mini-shop-se -am compile exec:java -Dexec.mainClass=com.example.minishop.concurrent.SeckillBench

# 启动阶段三服务(第 33 章起,需先 docker compose up -d)
# ⚠️ 必须注入 JWT_SECRET / PAY_SECRET(无默认值,忘配启动期直接失败——故意的)
JWT_SECRET=dev-only-secret-0123456789abcdef0123456789abcdef \
PAY_SECRET=dev-only-pay-secret \
RABBITMQ_USER=minishop RABBITMQ_PASSWORD=minishop123 \
mvn -pl mini-shop-boot spring-boot:run

部署口径(与 docker-compose.yml 一致)

  • RabbitMQ 自定义用户 minishop/minishop123(guest 只允许 localhost,跨容器必被拒);
  • MySQL/Redis/RabbitMQ 端口已发布到宿主机,本机 spring-boot:run 可直连;
  • 生产启动记得 -Dspring.profiles.active=prod:DataInitializer 的演示数据(含 alice/bob 公开口令)在 prod 下不装配。

[!WARNING] 依赖版本持续演进:启动失败先查三处——① 版本与附录 90 是否一致;② Starter 坐标与 Spring Boot 大版本是否匹配;③ 中间件容器是否健康(docker compose ps 看 healthy)。详见附录 90 顶部说明。

📁 配套文件目录

java-backend/
  README.md
  REVIEW-2026-09-29.md
  docs/
    00-environment-setup.md
    01-java-basics.md
    02-oop-basics.md
    03-oop-advanced.md
    04-collections.md
    05-generics.md
    06-exceptions.md
    07-lambda-and-stream.md
    08-io-and-nio2.md
    09-reflection-and-annotation.md
    10-unit-test-basics.md
    11-concurrency-basics.md
    12-locks-and-atomic.md
    13-juc-threadpool-async.md
    14-jvm-basics.md
    15-network-programming.md
    16-common-libraries.md
    17-maven-and-build.md
    18-spring-boot-and-ioc.md
    19-spring-aop-and-config.md
    20-layering-and-code-style.md
    21-mybatis-plus-and-mysql.md
    22-transactions.md
    23-rest-api-design.md
    24-redis-cache.md
    25-rabbitmq.md
    26-security-and-auth.md
    27-logging-and-monitoring.md
    28-testing-strategy.md
    29-deployment-and-ci.md
    30-minishop-overview.md
    31-minishop-java-se.md
    32-minishop-concurrent.md
    33-minishop-layering.md
    34-minishop-production.md
    35-minishop-ops.md
    90-appendix-tech-stack-versions.md
    91-appendix-glossary.md
    92-appendix-errors-and-faq.md
    README.md
  sample/
    .dockerignore
    .gitignore
    README.md
    docker-compose.yml
    pom.xml
    .github/
    mini-shop-boot/
      Dockerfile
      pom.xml
    mini-shop-common/
      pom.xml
    mini-shop-se/
      pom.xml

完整源码 Gitee 仓库浏览 →