一份面向零基础读者的完整 Java 学习路径。 主线 JDK 21 + Spring Boot 4,覆盖语法 → 并发/JVM → Spring 全家桶 → 生产化部署的完整闭环; 全书以一个贯穿始终的电商下单 / 秒杀项目「mini-shop」为实战载体, 体现基础实现 → 分层重构 → 生产化改造的三阶段演进,并配套一份 Maven 多模块工程源码(
sample/)。
[!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
建议节奏:
sample/ 阅读,约 2–3 周。| 章节 | 标题 | 关键词 |
|---|---|---|
| 00 | 开发环境搭建 | JDK 21、IntelliJ IDEA、Maven、Docker Compose、jshell |
| 章节 | 标题 | 关键词 |
|---|---|---|
| 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 |
| 章节 | 标题 | 关键词 |
|---|---|---|
| 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 签名 |
| 章节 | 标题 | 关键词 |
|---|---|---|
| 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 流水线 |
| 章节 | 标题 | 关键词 |
|---|---|---|
| 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 中的落点 | 章节 |
|---|---|---|
| 语法与 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 为准(个别教学简化处章节内有标注)。
学习目标 → 核心概念 → 示例代码 → 常见坑 ⚠️ → 调试技巧 → 最佳实践 → 本章小结 → 动手练习
每章开头标明学习目标与前置知识;章末练习分三档:基础题(照着做)、进阶题(自己设计)、思考题(理解取舍)。做完基础题就算通过。
// 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> 折叠块承载,便于你读懂存量代码。
明确划清边界,避免范围蔓延:
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 是同一个业务的两代实现,刻意并存:
mini-shop-se:看业务如何用最朴素的 Java 跑通,又如何被并发问题逼疯;mini-shop-boot:看同一业务如何被框架、分层与中间件重塑成生产级服务;祝你写得开心。🚀
审查日期: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 + 打通登录→下单→关单链路。
| # | 位置 | 问题 |
|---|---|---|
| 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 先抛异常)——测试必然红 |
| # | 位置 | 问题 |
|---|---|---|
| 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——照抄启动即校验失败 |
InMemoryOrderRepository 需 ensureIdAbove)InMemoryProductRepository 价格索引同价互相覆盖(应为 ConcurrentSkipListMap<Long, List<Product>>)AggregateDemo 的 pool.shutdown() 不在 finally——并发章「线程池必须关闭」的第一教学点自己示范错了InventoryServiceTest 测试对象错位:全在测 Product.decreaseStock(),mustFind 一条没测StreamDemo 「热销 Top2」统计的是订单项出现次数而非销量件数(键盘买 3 件显示 2)exec-maven-plugin 未锁版本且教学命令缺 -amcloseIfCreated 读-判-写竞态:updateById 无条件更新(无 WHERE status='CREATED'、无 @Version)——与 markPaid 并发时可能出现「订单 PAID 但库存已回补」→ 超卖micrometer-registry-prometheus 依赖:yml 暴露 + Security 放行 prometheus 端点,但端点必然 404order.close.q 未配死信交换机,「nack 进死信人工兜底」实际直接丢弃;每单向同一队列发两条消息且通知消息无消费者DataInitializer 无 profile 门禁:生产启动也写入演示账号(公开口令);RabbitConfig TTL 硬编码 15 分钟不复用配置且 closeIfCreated 不校验 pay_expire_atProductService 错误码语义错用:商品不存在抛 ORDER_NOT_FOUNDPayCallbackDTO 无任何校验注解:msgId=null 可击穿幂等防线(MySQL 唯一键对 NULL 不生效)GlobalExceptionHandler 覆盖不足:坏 JSON / 404 路径 / 类型不匹配 / 撞唯一键全部落兜底 50000FILE_INFO 用 LevelFilter:WARN 级日志不进任何文件,而业务拒绝证据全是 WARNUSER app 但 /app 未 chown,logs/dump 双双不可写;compose 中间件仅 service_started(Redis 未就绪即预热必失败)、healthcheck 用了镜像里不存在的 curl、未发布端口致 README 的本机流程连不上.dockerignore;缺 enforcer 门禁插件;seckillEnabled/markPaid(orderNo)/listExpired() 等死配置死代码> [!IMPORTANT] 共 10+ 处,超出体例约定的 TIP/WARNING 两类;旧写法未放 <details> 4 处(全书仅 16 章用对了)OrderServiceImpl(实际是无接口的 OrderService 具体类);19 章引用 sample 中不存在的 aspect 包三个文件stock == false 判断(nil 比较直接 Lua 运行时错误——34 章 sample 版有,24 章这版没有)createOrder 返回 null 占位,10 章测试直接断言返回值——按 docs 版代码必 NPEjunit-jupiter 6.0.1 不一致(与附录 90 同病)SignUtil 路径张冠李戴(写成 mini-shop-se,实际在 mini-shop-boot);14 章 OomLab、15 章 EchoServer/HttpClientDemo、16 章 OrderNoGenerator 等练习指向 sample 中不存在的文件rollbackFor=Exception + AFTER_COMMIT 桥 MQ,「订单入库才投递」纪律正确WHERE stock >= n 条件 UPDATEMessageDigest.isEqual 常量时间验签、JWT 密钥双校验、MDC finally 清理第一批(半天,消灭全部 P0)
ProductRepository 接口(se 编译)→ OrderStatus 下沉 common + 补 PageResult import(boot 编译)→ 修 deleted 幽灵列与分页 totalJwtAuthFilter 写入 SecurityContext;下单改用 UserContext.require()@MockitoBean + 新 WebMvcTest 包;修 OrderServiceTest 打桩顺序第二批(1–2 天,P1)
第三批(随章节迭代,P2)
验收门禁:全部修完后执行 mvn clean verify 全绿 + docker compose up --build 起全家桶 + curl 走通 登录→下单→查单→关单 全链路——这会一次性暴露并消灭绝大多数遗留问题。
mvn -B test:BUILD SUCCESS——common/se 13 个测试 + boot 9 个测试(含 Boot 4 @WebMvcTest 切片)全部通过mvn -B package -DskipTests:BUILD SUCCESS——mini-shop.jar repackage 成功release=21 编译)+ Maven 3.9.16common/model/OrderStatus.java(从 se 下沉,两代实现共用);ErrorCode 补 PRODUCT_NOT_FOUND(40402);Result.fail 拒绝 code=0;PageResult 紧凑构造器校验 size>0ProductRepository 接口(extends Repository<Product, Long> + findByPriceRange)——编译链修复createOrder 多商品部分失败按台账回补;InMemoryOrderRepository.ensureIdAbove + ShopCli.restore 推进发号器(id 覆盖修复);pay 后回写快照;下单成功才清空购物车;CLI 输入防御(EOF/非数字不崩)InMemoryProductRepository 价格索引改 Map<Long, List<Product>>(同价共存)+ 实现 ProductRepositoryOversellDemo 重写(ready/done 双闩、恢复中断位);AggregateDemo finally 关池 + awaitTermination + 线程名带序号;SeckillBench 计时口径修正(ready 后起表)+ 中断路径收工 + 跑法命令补 -amStreamDemo Top2 改 summingInt(quantity)(销量件数);Money 用 Math.*Exact + ofYuan 异常翻译;RetryProcessor 参数个数匹配 + setAccessible 外移 + Error 分流;AopDemo 无接口抛 IAE + 改用 import;MiniContainer 循环依赖差异注释;OrderSnapshotStore 恢复以快照存值为准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,事务回滚时按段回补)shop.order-timeout-minutes;order.close.q 补死信出口 → order.close.parking.q 停车场(nack 有真实去处);桥改单消息 + msgId 同源 + ConfirmCallback;consumer/注释/配置口径统一micrometer-registry-prometheusseckillEnabled/markPaid(orderNo) 死代码清理;page/size 夹取@WebMvcTest 新包 + @MockitoBean(spring-test)+ @AutoConfigureMockMvc(addFilters=false) + spring-boot-webmvc-test 依赖;OrderServiceTest 重写 7 用例(成功/不足/未登录/markPaid×2/关单×2).dockerignore 新建;CI 加 concurrency/timeout;sample README 更新(环境变量必注入清单)[!IMPORTANT] 全部清零(改 TIP/WARNING 按语义);旧写法 4 处包 <details>;术语首现补「中文(English, 缩写)」;09/15 章补「示例代码」段;30-35 章补齐「调试技巧/最佳实践」段(8 段式完整)pay 假端点仍返回写死的 {"status":"PAID"}(教程声明教学链路,真实链路走 34 章回调)阶段:阶段 0 · 起步 | 预计阅读 45 min | 代码落地模块:无(本章产出第一个可运行程序) 前置:无 | 相关章节:第 01 章 基础语法 | 第 17 章 Maven | 附录 90 版本基线 实战落点:本章装好的 Docker 中间件(MySQL/Redis/RabbitMQ)会被阶段三直接复用
读完本章,你应该能够:
jshell 做即时实验。java -version、JAVA_HOME、mvn -v)。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 更干净、可随时删除重来 |
[!TIP] 已装了 JDK 17 也不必卸载,本书代码在 17 上几乎都能编译(个别虚拟线程示例除外)。但不要用 JDK 8:
record、var、文本块等语法全部不可用。
.pkg 安装包;Linux 可用 apt install temurin-21-jdk(需先加 Adoptium 源,见 ../linux-server/docs/20-jdk.md)。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 最前面。
File → Settings → Build → Build Tools → Maven(macOS 在 Preferences):
~/.m2/settings.xml),下一步要改它。Settings → Editor → File Encodings:全部设为 UTF-8,避免中文注释乱码。编辑 ~/.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只代理中央仓库即可。
先安装 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"之类换一个宿主机端口即可,应用侧连接串同步改。
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
File → New → Project,左侧选 Maven Archetype 不必纠结,直接选 Java,JDK 选 21,其余默认。src/main/java 上右键 → New → Java Class,命名 Hello,粘贴上面的代码。main 方法左侧绿色三角运行。下方的 Run 窗口就是你的输出台。println 行号旁单击出现红点,再点甲虫图标(Debug)启动,程序会停在断点处——这是全书最重要的调试手段。$ jshell
jshell> 1 + 1
$1 ==> 2
jshell> "abc".toUpperCase()
$2 ==> "ABC"
jshell> /exit
jshell 是 JDK 自带的交互式解释器,不用建类不用编译,输入表达式立即出结果。学语法阶段遇到任何拿不准的写法,先开 jshell 试,再写进工程。
where java / which -a java 排查,调整顺序后重开终端。public class Hello 必须放在 Hello.java 里,否则编译失败。chcp 65001 切到 UTF-8。docker run hello-world 验证 Docker 本身可用。| 症状 | 排查命令 |
|---|---|
| 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 安装目录 |
docker compose down -v 即可重置一切。JAVA_HOME 切换;IDEA 里还能按工程指定不同 JDK。D:\code\java-backend。git init + 每完成一章提交一次,这是最朴素的进度备份。jshell 是语法实验田;IDEA 断点调试是全书最常用的排障手段。java -version、mvn -v、docker compose ps。下一章我们正式开始写 Java:变量、类型、流程控制,以及企业代码里第一条军规——金额为什么不能用 double。
基础题
java -version、mvn -v、docker compose ps 三个命令的输出。Hello,再用 jshell 计算 2026 * 365。进阶题
docker-compose.yml 里 MySQL 端口改成 13306:3306 启动,然后用 mysql 客户端或 docker exec -it mini-mysql mysql -uroot -p 连接,验证能连上 mini_shop 库;改回 3306 再 docker compose up -d。settings.xml 的 mirrorOf 改成 *,新建一个工程让它拉依赖,观察是否出现找不到依赖的报错,再改回 central。亲手踩一遍才知道为什么。思考题
阶段:阶段 1 · 入门基础 | 预计阅读 60 min | 代码落地模块:
sample/mini-shop-se(预备知识) 前置:第 00 章 环境搭建 | 相关章节:第 02 章 面向对象基础 | 第 16 章 常用类库 实战落点:mini-shop 里商品价格、库存数量的类型选择;控制台菜单的循环骨架
读完本章,你应该能够:
long 存金额、double 不行。var 局部变量类型推断,也知道什么时候不该用它。long(分)或 BigDecimal,禁用 double。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) |
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 元——对账系统会直接爆炸。两条铁律:
long(如 1999 表示 19.99 元),加减乘精确,性能最好;BigDecimal,并且用字符串构造(第 16 章展开)。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 静默溢出!编译器不会提醒
// 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,复杂条件优先拆方法。
// 修饰符 返回类型 方法名(参数列表) —— 方法名用小驼峰,动词开头
static long calculateTotal(long priceInCents, int quantity) {
return priceInCents * quantity; // long x int:int 自动提升为 long,结果不会溢出
}
要点:
static 就属于对象——这要到第 02 章才展开,本章示例统一加 static 以便在 main 里直接调用;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 price 直接打回。3 / 2 == 1。想要小数,先把其中一个转 double;想要金额精确,别用小数。price * quantity 两个 int 相乘结果还是 int,上亿数量级会静默溢出。先 (long) 提升再乘。ArrayIndexOutOfBoundsException 是新手第一杀手。循环条件写 < length 而不是 <= length。== 比较字符串:== 比的是地址。"a" == new String("a") 是 false。比内容用 str.equals("a"),且把常量放前面防空指针:"a".equals(str)。Alt+F8):随时算一个表达式的值,验证你对运算顺序的理解。System.out.println("total=" + total),二分定位是哪一步开始错的。priceInCents 比 p 好;布尔量用 isVip、hasStock 这类形容词。static final long FREE_SHIPPING_THRESHOLD = 9900;(99 元包邮)。priceInCents / priceYuan,单位歧义是财务 bug 的头号来源。String 不可变,比较用 equals。double:long(分)或 BigDecimal。下一章把「商品」「订单」这些现实概念变成 Java 的类——面向对象登场。
基础题
BasicsDemo 跑起来,然后新增一件商品「鼠标」(价格自定,long 分),加入购物车买 3 件,验证合计正确。static long applyDiscount(long totalInCents, int level):VIP 等级 1 打 95 折、2 打 9 折、其他不打折,在 main 中调用验证。进阶题
switch 箭头语法实现:根据订单金额分档运费(<99 元收 10 元、99–299 元收 5 元、≥299 元免运费),集成进 BasicsDemo 的应付金额计算。printf 的 %-16s 左对齐 / %16s 右对齐),列:商品名、单价、数量、小计。思考题
long 以分存储最大可表示约 9.2 亿亿元(Long.MAX_VALUE ≈ 9.2×10¹⁸ 分,合 9.2×10¹⁶ 元),看起来永远不会溢出。那为什么第 16 章还要讲 BigDecimal?提示:想一想「满 100 减 30.5」「按 33.33% 分摊佣金」这类需求,long 还方便吗?各自的最佳适用场景是什么?阶段:阶段 1 · 入门基础 | 预计阅读 70 min | 代码落地模块:
sample/mini-shop-se(model / repository) 前置:第 01 章 基础语法 | 相关章节:第 03 章 类设计进阶 | 第 18 章 IoC/DI 实战落点:Product、Order领域类;ProductRepository仓储接口——第 31 章直接沿用
读完本章,你应该能够:
static、final、this 三个高频关键字。**类(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,外部无法把库存改成负数
如果 stock 是 public,任何代码都能写 p.stock = -999,bug 会散落在全工程。封装成方法后:
decreaseStock 的合法性检查;[!TIP] 企业规范:字段一律
private,绝不public;getter/setter只对真正需要的字段生成——无脑全生成 setter 等于放弃了封装。
继承表达「is-a」关系。先记住三条规则:
super(...));extends 一个父类;多能力用接口组合;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 方法属于类,谈不上多态);// 组合:Cart 「拥有」一组 CartItem,而不是「是一种」List
public class Cart {
private final List<CartItem> items = new ArrayList<>(); // 04 章详讲 List
public void add(CartItem item) { items.add(item); }
}
**接口(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 实现、注入接口。本章先手工体会一遍。
| 关键字 | 含义 | 典型用途 |
|---|---|---|
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。
getItems() 直接返回内部 List,外部拿到就能乱加乱删。返回 List.copyOf(items) 或 Collections.unmodifiableList(items)。extends,会造成脆弱的父子耦合。is-a 才继承,has-a 用组合。@Override:方法名拼错(equals 写成 equal)编译器不报错,多态悄悄失效。重写必加 @Override,拼错会直接编译失败。public static final)与抽象方法描述能力,别把业务字段塞进接口。this:编译失败。静态上下文没有「当前对象」。Ctrl + Alt + H(Call Hierarchy) 查看一个方法被谁调用,重构封装前先摸清影响面。Ctrl + T(IDEA)/ Ctrl + T 或右键 Go to Implementations:从接口跳到所有实现类——接口多了之后这是日常导航动作。Add to Watches。.java 文件只放一个 public 类,文件名 = 类名。I 前缀(ProductRepository 而非 IProductRepository),实现类以 InMemoryXxx / XxxImpl 区分。equals/hashCode/toString 先别手写——第 03 章的 record 和 IDE 生成会替你做好。Optional 返回「可能没有」,static/final/this 三个关键字要分清归属。下一章打磨类的细节:equals 与 hashCode 的契约、枚举状态机、record 值对象——都是写好领域模型的利器。
基础题
Product 增加「上下架」能力:私有字段 boolean onSale(默认 true),提供 offShelf() / onShelf(),且只有上架状态才能被下单(在 decreaseStock 前校验,抛 IllegalStateException)。OopDemo 跑通,再故意下单一个不存在的商品 id,观察 Optional 抛出的异常。进阶题
OrderRepository(save、findById、count())与内存实现 InMemoryOrderRepository;再写一个极简 Order 类(id、productId、quantity、payableInCents、状态字符串先不管),在 OopDemo 下单成功后保存订单并打印订单数。BasicsDemo:设计 Cart 类(内部持有 List<CartItem>,CartItem 含商品与数量),提供 add、remove、totalInCents() 三个方法,替换原来平铺的数组逻辑。体会「数据 + 行为聚到一个类里」带来的可读性差异。思考题
DigitalProduct 重写了 decreaseStock 为空实现。如果将来数字商品引入「限购 1 件」的需求,这种「继承 + 覆盖成空」的设计还合理吗?有没有更优雅的建模方式(提示:思考「库存策略」本身抽象成接口 + 组合)?阶段:阶段 1 · 入门基础 | 预计阅读 60 min | 代码落地模块:
sample/mini-shop-se(model)+sample/mini-shop-common(OrderStatus) 前置:第 02 章 面向对象基础 | 相关章节:第 09 章 反射与注解 | 第 31 章 实战落点:OrderStatus枚举状态机、OrderItemrecord、Money值对象——mini-shop 领域模型的三块基石
读完本章,你应该能够:
equals/hashCode,说清两者为什么必须成对出现。record 写不可变数据类,替代 90% 的手写数据传输对象(Data Transfer Object, DTO)。Money 封装金额与货币单位,杜绝裸 long 满天飞。所有类都隐式继承 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;
订单状态、支付方式这类取值有限且固定的概念,用字符串或 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") // 编译失败!枚举实例集在编译期封闭
枚举的其他要点:
values() 返回全部常量、valueOf("PAID") 按名查找、name()/ordinal() 取名与序号(不要用序号做持久化值,插入新常量会整体错位,DB 存 name)。领域里有一大类对象纯粹是数据的载体(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 章说明)。
第 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这类值对象收口。
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 挂在宿主类里;用本章三件套(枚举状态机 + 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 让编译器把关。name()。Collections.unmodifiableList / List.copyOf。Alt + Insert → equals() and hashCode() / toString(),选业务主键字段即可。equals 有问题:断点打在 equals 内部,用调试面板 Evaluate 手动调用 a.equals(b) 单步看。Settings → Editor → Inspections 保持默认开启,会把「类重写了 equals 但没重写 hashCode」标黄。if (status == 2)。record:不可变自带线程安全与清晰语义。Money 值对象(或直接引 javax.money 生态),让「金额」在类型系统里可辨识。equals/hashCode 是一对契约,成对重写、字段一致。name() 不存序号。record 是不可变数据类的语法糖,紧凑构造器里做参数校验。Money)让领域概念在类型层面可见;不可变对象天然线程安全。下一章进入集合框架:List/Map/Set 到底怎么选,HashMap 扩容与 fail-fast 又是什么。
基础题
Order 增加 ship()、complete() 方法(复用枚举转移校验),并写一个 main 走完 CREATED → PAID → SHIPPED → COMPLETED 全链路;再故意在 COMPLETED 状态调用 pay(),观察异常。OrderItem 从普通使用改为带紧凑构造器校验(数量、价格为正),测试非法参数被拦截。进阶题
Money 增加 subtract、discount(int percent)(9 折传 90,向上/向下取整说明你的选择)方法,并写三条注释解释取整方向对商家/用户谁有利。OrderNo 值对象(格式 YYYYMMDD-8位随机),提供 generate() 与 isValid(String);生成规则可先用 LocalDate.now()(16 章正式讲日期 API)。思考题
String 是不可变类,所以它能安全地作为 HashMap 的 key。如果用一个可变类(字段可被 setter 修改且参与 hashCode)做 key,然后修改其字段后再 get,会发生什么?动手试一下,并用本章的 equals/hashCode 契约解释现象。阶段:阶段 1 · 入门基础 | 预计阅读 80 min | 代码落地模块:
sample/mini-shop-se前置:第 02 章 面向对象基础 | 相关章节:第 05 章 泛型 | 第 11 章 并发基础 实战落点:商品索引Map<Long, Product>、购物车LinkedHashMap、订单流水ArrayDeque——31 章直接使用
读完本章,你应该能够:
ArrayList 与 LinkedList 的真实性能差异(不是网上说的那样)。HashMap 的底层结构(数组 + 链表/红黑树、扩容、负载因子)。Arrays.asList、遍历中删除三大经典坑。ConcurrentHashMap / CopyOnWriteArrayList)。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 代价(见三、常见坑)。
| 我要…… | 用这个 | 理由 |
|---|---|---|
| 按顺序存一串东西、按下标访问 | 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)。
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)一次到位。构造时给容量是廉价优化,高频路径值得写。
结构:数组 + 链表 + 红黑树(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
三个推论,都值得记住:
equals/hashCode(第 03 章),否则查不到;new HashMap<>(expectedSize):传期望条目数(源码内部会按 0.75 换算容量),避免反复扩容;LinkedHashMap,需要排序用 TreeMap。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(...))。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))。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。Map 可切 key 视图 / value 视图。map.containsKey(k)、两个对象的 hashCode() 是否相等、equals() 是否为 true——三者依次定位问题在哪一环。List<Product> list = new ArrayList<>();,实现细节可以随时替换。Map,而不是在 List 里 for 循环比对。List.of("a", "b")、Map.of("k1", 1)(不可变、无 null、几元素内最省内存)。toString()(第 03 章),日志里才看得懂。HashMap = 数组 + 链表/红黑树 + 扩容 2 倍 + 负载因子 0.75;key 的 equals/hashCode 必须正确。removeIf。remove(int) 歧义。下一章泛型:为什么 List<Product> 里的尖括号能保证你 get 出来的一定是 Product,以及怎么写自己的泛型接口。
基础题
CollectionsDemo:把购物车 cart(LinkedHashMap<Long, Integer>)遍历一遍,借助商品索引 index 计算购物车总金额(long 分),打印格式 合计:xxx.xx 元。TreeMap 实现「查找价格在 200~500 元之间的所有商品」(subMap 方法,先统一单位为分再查询)。进阶题
RecentOrders 类:内部用 ArrayDeque 保留最近 100 笔订单(超限弹最旧的,Deque 大小判断 + pollLast),提供 record(Order o) 与 latest(int n)(返回最近 n 笔的从新到旧列表)。InMemoryProductRepository 从 ConcurrentHashMap 改为同时维护一个 TreeMap<Long, Product>,新增 findByIdRange(long fromId, long toId) 方法(用 subMap),体会「同一份数据多个索引结构」的思路。思考题
ConcurrentHashMap 为什么禁止 null key 和 null value,而 HashMap 都允许?(提示:并发场景下 get 返回 null 时,你无法区分「不存在」和「值就是 null」,而 containsKey 与 get 两次调用之间状态可能已变。)这个设计决策对 API 使用方式有什么影响?阶段:阶段 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 口诀。Repository<T, ID>。没有泛型的远古 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); // 编译失败 ✅ 错误提前暴露
一句话:泛型 = 把运行期的类型错误提前到编译期,顺带省掉强转。
// 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; }
}
第 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>——一份契约,两个实现,这就是泛型的复用价值。
类型参数写在返回类型之前,作用域仅限本方法:
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(那是属于对象的),所以要自己声明——这是初学者最常撞的墙。
先看一个「看似没问题」的编译错误(金额口径与全书一致: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)。
泛型只存在于编译期,编译后 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> 令牌或工厂传入。List list = new ArrayList(); 能编译但失去全部类型检查,还产生 unchecked 警告。禁用裸类型,IDEA 会标黄。List<Long> ids 与 List<Integer> 混用时,contains(1) 因类型不符恒为 false——注意字面量后缀 1L。Ctrl + Q(快速文档)悬停在方法上,会显示推断后的实际泛型参数,比盯源码快得多。obj.getClass()——泛型被擦除了,运行期只有原始类型。Repository<T, ID>、Result<T>),实体-specific 逻辑放实现类。Map<String, List<Result<T>>> 已经在警告线上了),太深说明该拆 DTO 了。Repository<T, ID>、Result<T>、PageResult<T> 是全书复用三次以上的抽象。Class<T> 令牌。下一章异常处理:让 StockNotEnoughException 这类业务语义在异常体系里有一席之地,并且不再出现 catch (Exception e) {}。
基础题
InMemoryOrderRepository 按 Repository<Order, Long> 接口实现一遍(内存结构用 ConcurrentHashMap<Long, Order>),在 main 里保存两笔订单并 count() 验证。RepoSupport.findById 的思路,写一个泛型方法 <T> List<T> filter(List<T> list, Predicate<T> cond),并用它过滤出库存为 0 的商品。进阶题
PageResult<T> 增加 static <T> PageResult<T> of(List<T> allRecords, int page, int size):内部完成内存分页(下标越界时返回空列表而不是抛异常),思考这个工厂方法为什么比构造器更友好。Cache<K, V>(get、put、evict),先给出 ConcurrentHashMap 内存实现;第 24 章它会演化为 Redis 缓存接口——现在就体验「面向接口留下演化空间」。思考题
Result<T> 为什么把构造器设为 private 只留静态工厂?如果允许 new Result<>(-1, "x", data)(成功码 + 有数据)这种非法组合,会给调用方带来什么麻烦?由此体会「让非法状态不可表示」的设计原则与第 03 章枚举状态机的相通之处。阶段:阶段 1 · 入门基础 | 预计阅读 55 min | 代码落地模块:
sample/mini-shop-common(exception)+sample/mini-shop-se(service) 前置:第 02 章 面向对象基础 | 相关章节:第 23 章 全局异常处理 实战落点:BizException/ErrorCode体系——第 23 章直接演化为全局异常处理器,第 34 章的幂等与库存校验都靠它
读完本章,你应该能够:
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 异常打交道。
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);
}
企业后端的标配三件套:错误码枚举 + 业务异常 + 异常基类。
// 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 这类具名子类承载调用方需要的数据(库存差多少),把「异常」从字符串升级为可编程对象。
文件、连接等资源必须关闭。手写 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 章线程池关闭,全是它的舞台。
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 (Exception e) 包住整个方法,会把 NPE 等编程 bug 也「美化」成业务错误,掩埋真问题。catch 范围尽量精确,兜底 catch 只在统一入口。"下单失败" 没有信息量;"订单1234库存不足: 需要5, 仅剩2" 才是现场证词。BizException 或 All):任何异常抛出瞬间自动停住,比等堆栈打印更快。Caused by: 开始找根因;顶层异常往往是包装,不是元凶。log.error("msg", e) 一定要把异常对象作为最后一个参数传进去,才能打出完整堆栈。BizException + ErrorCode;编程错误 → 让 NPE/ISE 原样炸出来。@RestControllerAdvice 全局兜底(第 23 章),业务代码只管抛。ErrorCode + BizException + 必要的具名子类;异常消息带现场数据。下一章 Lambda 与 Stream:把「过滤库存不足商品再汇总金额」这类集合加工,从三层 for 循环变成一行流水线。
基础题
Product.decreaseStock 里的 IllegalStateException 替换为 StockNotEnoughException,OrderService.createOrder 相应调整 catch 顺序,验证「库存不足」能被精细捕获。ErrorCode 增加 PAY_CLOSED(40903, "订单已关闭"),写一个 closeOrder 场景:对已支付订单调用关单时抛出对应异常,调用方按 ErrorCode 精细处理。进阶题
BizException 增加接收 Throwable cause 的构造器;模拟「JSON 解析失败导致下单中止」场景:catch 原始异常后包装抛出,打印日志确认 Caused by 链完整。RetryExecutor:static <T> T execute(Callable<T> task, int maxAttempts),捕获 IOException 重试至多 maxAttempts 次(间隔可先用 Thread.sleep(100)),最终失败抛出最后一次的异常(带 cause 链)。这是生产代码里重试骨架的雏形。思考题
DataAccessException 体系)。你认为原因是什么?从「调用方是否真的能处理」「层间签名污染(一路 throws)」两个角度想一想,再对照 1.1 的表格给出你的选择标准。阶段:阶段 1 · 入门基础 | 预计阅读 75 min | 代码落地模块:
sample/mini-shop-se前置:第 05 章 泛型 | 相关章节:第 13 章 CompletableFuture | 第 24 章 缓存 实战落点:购物车金额汇总、订单分组统计、热销 Top N——第 31 章实战代码的日常写法
读完本章,你应该能够:
-> 与 :: 语法。Collectors:toList、groupingBy、toMap、joining。函数式接口(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(构造引用)。
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 的命令式版本,声明式版本读起来就是需求本身。
两条铁律:
IllegalStateException;要复用请重新从数据源获取。泛型流的元素是包装类型,数值聚合有专用流避免装箱:
long total = cartItems.stream()
.mapToLong(OrderItem::subtotalInCents) // Stream -> LongStream(元素变 long)
.sum();
LongSummaryStatistics stat = shelf.stream()
.mapToLong(Product::getPriceInCents)
.summaryStatistics(); // count / min / max / sum / average 一次拿全
// 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()));
// 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, "货架空了"));
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<Product> s = shelf.stream(); s.count(); s.count(); → IllegalStateException。parallelStream + 共享可变集合:见 1.6,事故高发区。forEach 里写数据库、改成员变量,让流水线失去可读性还有副作用。副作用操作(落库、发消息)放在终端 collect 拿到结果之后。sorted 的比较器抛 NPE:Comparator.comparing(Product::getName) 遇到 null 字段直接炸,需要 null 友好时用 Comparator.nullsFirst(...) 包装。Trace Current Stream Chain):能看到每一步中间结果的可视化表格——学习 Stream 的最强工具。.peek(System.out::println)(⚠️ 仅调试用,peek 在某些优化路径下不保证执行,生产代码别依赖它)。this 与局部变量在调试面板的 Lambda 帧(lambda$main$0)里查看。mapToLong/mapToInt 专用流,避免装箱。toMap(记得给合并函数),替代 for 循环里手动 map.put。Predicate/Function/Consumer/Supplier 覆盖日常。Collectors 三板斧:toList/groupingBy/toMap(toMap 记得带合并函数)。下一章 IO 与 NIO.2:把订单快照写进文件、把配置读回来——顺便解决「乱码」这个祖传问题。
基础题
StreamDemo 的 mockOrders:再增加两笔订单,重新计算 GMV 与热销 Top2,手工验算一遍结果是否正确。groupingBy + counting 统计每个商品被下单的次数,输出格式 商品名 x N 单。进阶题
static Map<Long, Long> userRanking(List<Order> orders, int topN):返回消费金额前 N 的「用户 id -> 金额」。注意 TreeMap 按 value 排序需要自建比较器或改用 Stream 排序后收集。CollectionsDemo 里「购物车金额计算」的 for 循环版本,重写为一行 Stream;再用 IDEA 的 Stream 调试器(Trace Current Stream Chain)逐步观察中间结果,截图对比两种写法。思考题
collect(Collectors.toList())(JDK 8~15)与 .toList()(JDK 16+)都会收集为列表,但前者返回的是可变 ArrayList,后者是不可变列表。这一差异会在什么场景下造成运行时异常?团队从 JDK 8 迁移到 21 时,你打算如何在 Code Review 里发现这类隐患?阶段:阶段 1 · 入门基础 | 预计阅读 60 min | 代码落地模块:
sample/mini-shop-se(util/OrderSnapshotStore) 前置:第 06 章 异常处理 | 相关章节:第 15 章 网络编程 | 第 16 章 常用类库 实战落点:订单快照落盘data/orders.json(31 章使用);「显式 UTF-8」写进肌肉记忆
读完本章,你应该能够:
Path/Files 完成日常文件操作(读写、拷贝、遍历)。InputStream / OutputStream 字节流:8 位原始字节。图片、音视频、任意二进制
Reader / Writer 字符流:16 位字符。文本专用,负责「字节 ↔ 字符」的编码转换
字符流是字节流的「翻译层」:读文件时把字节按字符集解码成字符,写文件时反向编码。翻译时用的字符集就是乱码的病灶。
// ❌ 灾难写法:不指定编码,用「平台默认值」——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。
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() + " 笔历史订单");
StandardCharsets.UTF_8。Files.readAllBytes 处理 2GB 日志直接 OOM。日志/大文件用 Files.lines 或 BufferedReader.readLine 逐行处理。Files.list 返回的 Stream 也要关(上面已示范)。"data\\orders" 在 Linux 上失效。用 Path.of("data", "orders")。Files.writeString 默认覆盖:追加写必须显式 StandardOpenOption.APPEND。Files.move(tmp, target, ATOMIC_MOVE)(第 35 章部署脚本会再遇到「原子性」思想)。Files.exists 为 false 时打印 path.toAbsolutePath():相对路径的基准目录是进程启动目录,不是源码目录,这是新手最迷惑的点。Path + Files + 显式 UTF-8。Json.MAPPER),配置收口一处。Path/Files 是文件操作现代写法;大文件逐行读,目录遍历的 Stream 记得关。下一章反射与注解:理解 Spring、MyBatis 这些框架是怎样「看懂」你的代码的——打通后续所有框架章的地基。
基础题
OrderSnapshotStore 跑通:下单两笔 → 落盘 → 新起进程 loadAll() 恢复,打印恢复的订单号与状态。Files.lines 统计任意一个文本文件里包含「ERROR」的行数(自己造个 log.txt),并练习用 Files.writeString 的 APPEND 模式追加一行。进阶题
save 改造为原子写:先写 order-1001.json.tmp,成功后 Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE);人为在 tmp 写入后抛异常,验证旧快照不被破坏。logs/ 目录,把 2026-01 开头的文件移动到 logs/archive/2026-01/(目录不存在则创建)。提示:Files.list + Files.move + 文件名前缀判断。思考题
阶段:阶段 1 · 入门基础 | 预计阅读 65 min | 代码落地模块:
sample/mini-shop-se(lab 包) 前置:第 03 章 类设计进阶 | 相关章节:第 18 章 IoC/DI | 第 19 章 AOP 实战落点:理解@Autowired、@Mapper、@Transactional从「黑魔法」变成「看得见的机制」;亲手写一个迷你控制反转(Inversion of Control, IoC)原型
读完本章,你应该能够:
正常代码在编译期决定调用谁;反射(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()
注解(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级——它们的生命周期策略直接对应用途。
注解本身不做任何事——让 @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(发现并执行测试)全部是同一个套路的工程级放大。
「在方法前后插逻辑」(日志、耗时、事务)——面向切面编程(Aspect-Oriented Programming, AOP)的核心能力——用代理实现。JDK 内置的动态代理在运行期生成接口的实现类:给任意接口实现包一层「耗时日志」的 AopDemo 完整实现见「二、示例代码」。
两个要点:
extends Proxy implements 你的接口);target 没实现任何接口时,生成的代理无法强转回具体类型。要代理类本身得用第三方字节码库(如 CGLIB,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 + 属性默认值。@Inherited 只对类上的注解生效)——Spring 事务方法不生效的经典坑源之一(第 22 章回收)。getAnnotation(...) 返回处,看注解实例的属性值——验证「贴纸是否被读到」。Class.getDeclaredMethods() 打印全部方法名,排查「为什么我的方法没被框架扫描到」(命名/可见性/参数不符)。Ctrl + Alt + B 从接口跳实现、调试面板看对象的实际类型(显示 $Proxy12 即代理类)——看到代理你就懂了 AOP。ReflectiveOperationException。@Retry、@SeckillSafe,而不是 @MyAnnotation。MiniContainer 用于理解,生产一律用 Spring。setAccessible 能开门但要守规矩;InvocationTargetException 记得拆包。RUNTIME 保留 + 反射解析才生效——这是所有框架注解的统一原理。进入阶段二之前最后一站:JUnit 5——从现在起,你写的每个类都值得配一个测试。
基础题
Order 类的全部字段与方法签名(含注解信息);再用 getDeclaredField("status") + setAccessible 把一笔订单状态强行改成 COMPLETED,观察并思考这个能力为什么危险。@Audit(属性 value 默认 "default")注解 + 处理器:被标注的方法调用前打印 [审计] 方法名(value)。模仿 RetryProcessor 的套路。进阶题
MiniContainer:支持构造器上标注 @Inject 选择注入哪个构造器(默认取第一个);故意制造循环依赖(A 要 B、B 要 A),观察 StackOverflowError,想想 Spring 是怎么用「三级缓存 + 提前暴露引用」解决的(提示:先造半成品)。AopDemo 的 withTimingLog 增加「超过 100ms 打印 WARN」逻辑,再包装 OrderService.createOrder 跑一次。思考题
@Transactional 加在同类内部调用链的内层方法上(this.innerMethod())不会生效。结合本章动态代理原理解释:调用方拿到的是代理对象,而 this 指向的是谁?这个解释你在第 22 章会再次用到——现在先写下你的推理。阶段:阶段 1 · 入门基础 | 预计阅读 50 min | 代码落地模块:
sample/mini-shop-se(src/test) 前置:第 06 章 异常处理 | 相关章节:第 28 章 测试策略 | 第 32 章 并发压测 实战落点:InventoryServiceTest/OrderServiceTest——之后每章的示例都会带测试,32 章的压测也是在测试基建上长出来的
读完本章,你应该能够:
@Test、@BeforeEach、断言、生命周期。mvn test 秒级复现问题,不用启动整个应用点页面。[!TIP] 单元测试(Unit Test)测的是一个类/一个方法,不依赖数据库、网络、Spring 容器——那些是集成测试(第 28 章)。判定标准:断网断库也能跑,毫秒级完成。
测试代码放在 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。绿灯通过、红灯失败并展示每个断言的差异值。
| 断言 | 用途 |
|---|---|
assertEquals(expected, actual) |
相等(对象走 equals) |
assertNotEquals / assertSame |
不等 / 同一引用 |
assertTrue(cond, msg) / assertFalse |
布尔条件 |
assertNull / assertNotNull |
空判断 |
assertThrows(Class, exec) |
断言抛异常,返回异常对象可续断言 |
assertAll("组名", () -> ..., () -> ...) |
一组断言全部执行(不像默认那样遇错即停,便于一次看全) |
assertTimeout(Duration, exec) |
超时即失败 |
@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。
@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 件」与「负数」两类非法输入,这里就是回归验证。边界值测试的价值正在于此:它逼你在写用例时逐个确认自己想到了每个分支。
@BeforeEach 重建全部夹具,绝不共享。assertEquals(expected, actual) 参数顺序反了,失败消息会误导排查方向。assertTrue(true)。覆盖率是参考,断言质量才是灵魂——没有断言的测试等于没有测试。@TestMethodOrder 强制排序定位(但生产测试不应依赖顺序)。shouldXxx_whenYyy:测试名即需求文档。mvn test 全绿是纪律;CI 里测试不过禁止合入(第 29 章落成流水线门禁)。@Test、@BeforeEach、断言、assertThrows。shouldXxx_whenYyy 命名,让测试自己会说话。阶段一到此收官。下一站实战章(第 31 章)会把 01–10 全部知识组装成 mini-shop 的 SE 版本——建议先跳读第 30 章的项目总览,也可以先继续阶段二、回头再做实战。
基础题
Money 写测试类:加法、乘法、ofYuan("0.1") 的精确性、非法输入(负数构造若被允许,想清楚为什么)。OrderStatus 写状态机测试:用 @ParameterizedTest + @CsvSource 验证每个状态的 canPay/canShip/canClose 真值表。进阶题
OrderSnapshotStore 写测试:保存后 loadAll 能读回;故意在目录里塞一个损坏的 json 文件,断言 loadAll 跳过它而不抛异常(@TempDir 提供 JUnit 自管临时目录,测试结束自动清理)。MiniContainer(第 09 章)写测试:单例性(两次 get 同一实例)、递归注入(OrderService 拿到 ProductRepository)、未注册且无默认构造时的异常。思考题
阶段:阶段 2 · 进阶提升 | 预计阅读 75 min | 代码落地模块:
sample/mini-shop-se/concurrent前置:第 01 章 基础语法、第 03 章 类设计进阶 | 相关章节:第 12 章 锁与原子类 | 第 13 章 线程池 实战落点:多线程复现超卖——第 32 章压测的教学锚点;理解ConcurrentHashMap(04 章)为何必要
读完本章,你应该能够:
i++ 在多线程下会丢更新,并用 synchronized 修复。volatile(并知道它管不了什么)。一个进程(运行中的 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 从部署第一秒起就被并发调用,不写并发代码 ≠ 代码不在并发环境里跑。
Java 内存模型(Java Memory Model, JMM)定义了多线程读写共享变量的规则。所有并发问题都能落到三要素:
flowchart LR
A["可见性<br/>Visibility"] --- B["原子性<br/>Atomicity"] --- C["有序性<br/>Ordering"]
A1["改了,别的线程看不见"] --> A
B1["一步操作被拆成多步,中间被打断"] --> B
C1["编译器/CPU 重排指令,顺序与代码不符"] --> C
while (!flag) 永远退不出去。i++ 实际是「读 → 加 1 → 写回」三步,两条线程交叠执行就会丢更新(读到同一个旧值)。double-checked locking 单例不加 volatile 出半成品对象就是这个原因(进阶了解)。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 对象,全局一把
三个必知规则:
private volatile boolean running = true; // 状态标志位的标准写法
// 线程 A:running = false;
// 线程 B:while (running) { ... } ← 没有 volatile 时,B 可能永远读不到 false(优化进缓存了)
volatile 保证:写立即对所有线程可见 + 禁止相关指令重排。但它不保证原子性——volatile int i; i++ 照样丢更新。经验法则:一写多读的开关用 volatile,多写计数用原子类(第 12 章),复杂状态直接 synchronized。
第 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。println 可能就「修好」了——println 自带同步)。怀疑可见性时直接按规范加 volatile/锁,别赌。jstack <pid>(第 14 章)看线程状态:BLOCKED (on object monitor) 说明在等 synchronized 锁。synchronized 三合一但粗粒度;volatile 只管可见性;计数用原子类。下一章把锁玩明白:ReentrantLock 的超时与公平、CAS 与原子类、死锁的定位与预防。
基础题
OversellDemo 五次,记录每次 unsafe 的卖出数;把线程数改成 10 再跑,观察超卖是否还稳定出现,思考原因(竞态窗口被踩中的概率)。while (!stop) 自旋,主线程 1 秒后 stop = true。分别用普通 boolean 与 volatile boolean 跑,观察差异。进阶题
OversellDemo 增加第三种模式 lock-segment:把 100 库存拆成 10 个桶各 10 个,synchronized 锁单个桶,抢完自己桶再去共享池借。对比 safe 模式的耗时——这是第 24 章 Redis 分片库存的思想原型。jstack 找到 Found one Java-level deadlock 字样并读懂线程栈。思考题
synchronized 修复了正确性,但 100 线程排队扣库存,吞吐上限就是「串行」。结合第 30 章验收标准思考:电商大促场景(10 万 QPS 抢购)为什么内存锁完全不够用?从「单机内存 vs 共享存储」「实例多副本」两个角度预答,第 24 章见分晓。阶段:阶段 2 · 进阶提升 | 预计阅读 70 min | 代码落地模块:
sample/mini-shop-se/concurrent前置:第 11 章 并发基础 | 相关章节:第 13 章 线程池 | 第 24 章 分布式锁 实战落点:库存扣减的五种方案之二三(ReentrantLock / 原子类),32 章压测的主角;分布式锁的前置知识
读完本章,你应该能够:
ReentrantLock 并说清它相对 synchronized 的三个增强(超时/可中断/公平)。AtomicInteger / AtomicReference。LongAdder 为什么在高并发计数上更快。jstack 定位死锁,写出不会死锁的加锁顺序。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 带超时」是生产中最常用的增强——永远不要让线程无限期等锁。
比较并交换(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 的三个注意事项:
AtomicStampedReference(版本号化),了解即可;AtomicReference<V> 可以对任意对象引用做 CAS,是实现无锁数据结构的砖块。AtomicInteger.incrementAndGet() 在极端竞争下所有线程反复 CAS 同一个值(高争用)。LongAdder 把一个数拆成 Cell 数组分散热点,sum() 时汇总——写性能高一个量级,代价是读到的是近似瞬时值。
private final LongAdder successCount = new LongAdder();
successCount.increment(); // 各线程写各自的 Cell
long total = successCount.sum(); // 统计时汇总(32 章压测脚手架用它统计成功/失败数)
经验:统计计数用 LongAdder,需要「读改写后再判断」的数值(如库存)用 AtomicInteger——库存的语义是「扣减必须成功或拒绝」,不能容忍近似值。
死锁发生的四个必要条件(破坏任意一个即可预防):
// 经典死锁:加锁顺序不一致
// 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,否则锁泄漏。LongAdder.sum() 当精确值用:它是近似汇总,不能拿来做「最后 1 件库存」的判断。jstack 输出末尾的 Found one Java-level deadlock 直接给出环;平时用 jstack | grep BLOCKED 观察等锁积压。top -H -p <pid> 看线程 CPU,某线程接近 100% 且 jstack 显示它在自旋循环里,就是高争用信号(换分段方案)。remaining == 1),只在关键时刻停下。lock() 无限等待只允许出现在初始化阶段。ReentrantLock 的价值在超时/可中断/公平/多 Condition;lock-try-finally 是纪律。LongAdder,条件扣减用 AtomicInteger。jstack 一键定位。下一章 JUC 工具与线程池:100 个任务怎么调度、CompletableFuture 怎么编排异步、虚拟线程什么时候用。
基础题
StockStrategies.LockStock / CasStock 接进 OversellDemo(第 11 章练习 3 的思路),200 线程 × 100 库存各跑 5 次,记录卖出数均为 100、剩余均为 0 才算通过。AtomicLong 实现一个线程安全的发号器 IdGenerator.next(),并写并发测试:100 线程各取 1000 个号,收集到 ConcurrentHashMap.newKeySet(),断言总数 100000 且无重复。进阶题
transfer(Account from, Account to, long cents),要求锁定两个账户但不死锁(提示:按 account.getId() 升序加锁);故意写一个死锁版本对照 jstack 输出。AtomicStampedReference 解决一个你自造的 ABA 场景(值回到原样但语义已变),打印「版本号拦截了错误的 CAS」。思考题
阶段:阶段 2 · 进阶提升 | 预计阅读 80 min | 代码落地模块:
sample/mini-shop-se/concurrent前置:第 11 章、第 12 章 | 相关章节:第 25 章 MQ | 第 32 章 实战落点:32 章压测的线程池脚手架;34 章「并行查商品+库存」的CompletableFuture原型;ThreadLocal存登录用户
读完本章,你应该能够:
CountDownLatch / Semaphore / CyclicBarrier 编排多线程协作。CompletableFuture 把串行调用改成并行编排,并正确处理异常。ThreadLocal,说出内存泄漏的成因。new Thread() 每次都要:创建内核线程(约 1MB 栈内存)+ 上下文切换。高并发下「来一个请求开一个线程」会瞬间打爆内存与调度器。线程池 = 线程复用 + 数量管控,是生产代码处理并发的唯一正解(Web 容器内部就是线程池)。
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,队列必须有界。
// 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(); // 各线程到达后等齐
// 全书最常见的用法:在一次请求内随处取「当前登录用户」,不用层层传参
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 章)。
回调地狱的解药,也是第 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: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(),优雅收尾。
pool-1-thread-3,无法定位业务。ThreadFactory 统一命名。shutdown() 后还提任务:抛 RejectedExecutionException;优雅关闭用 shutdown() + awaitTermination(第 29 章停机脚本)。join()/get() 不带超时:一路任务挂死,整条请求线程陪着挂。永远用 get(timeout, unit)。jstack 看线程池状态:大量 WAITING (parking) 的池线程 + 队列堆积,是消费不给力的信号。System.identityHashCode(thread) 对照。核数 × (1 + 等待时间/计算时间),拍脑袋不如压测(32 章)。exceptionally 兜底 + 超时等待。CountDownLatch 等齐、Semaphore 限流、CyclicBarrier 反复栅栏。ThreadLocal 用完必删,否则泄漏 + 串号。CompletableFuture 三步走:supplyAsync 起任务(带池)→ combine/compose 编排 → exceptionally 兜底。下一章 JVM:这些线程、对象、异常在内存里长什么样?OOM 与 CPU 飙高该怎么查?
基础题
AggregateDemo 的两个查询改成串行版(先商品后库存),记录耗时对比;再把线程池核心数改成 1,观察并行效果如何退化。ThreadLocal<String> 存名字并读取,主线程验证取不到(隔离性);然后故意不 remove,用注释说明会发生什么。进阶题
Semaphore(3) 实现并发限流的下载器:20 个任务,最多 3 个同时执行,统计总耗时并与不限流版对比。CompletableFuture.allOf 并行调用三个「接口」(sleep 模拟 80/120/50ms),汇总成 Map<String, String>,要求任一路失败不影响其他路(各自 exceptionally 降级),总耗时 ≤ 150ms。思考题
CallerRunsPolicy 时,提交线程亲自执行任务,等于「生产者被拖慢」——这恰是限流背压。但如果提交线程是 Tomcat 请求线程,会发生什么连锁反应(提示:请求线程被占用 → 容器线程池耗尽 → 整站变慢)?你会如何为「下单接口」选拒绝策略?阶段:阶段 2 · 进阶提升 | 预计阅读 75 min | 代码落地模块:命令行实操(
jps/jstat/jmap/jstack) 前置:第 11 章 并发基础 | 相关章节:第 27 章 监控 | 第 29 章 部署 实战落点:32 章压测配-Xmx观察 GC;35 章 Docker 内存限制的依据;线上 OOM/CPU 飙高的排障四件套
读完本章,你应该能够:
OutOfMemoryError 的四种常见形态与初步定位方向。jps / jstat / jmap / jstack。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(默认)晋升 老年代 → 最终被回收或活到进程结束。大对象(大数组)可直接进老年代。
GC 判定:从 GC Roots(栈帧局部变量、静态变量、JNI 引用等)出发做可达性分析——到不了的对象就是垃圾。引用计数法因循环引用问题不被 JVM 采用。
为什么分代:经验法则「大部分对象朝生夕死」(一个请求里的临时对象活不过一次 GC)。所以:
调优第一原则:让垃圾死在新生代。若频繁 Full GC,通常是内存泄漏或堆太小。
[!TIP] JDK 21 默认收集器就是 G1(JDK 9 起即为服务端默认)——把堆划成 Region、可预测停顿,显式写
-XX:+UseG1GC只是自证配置。本书不展开收集器家族史,记住:先看 GC 日志确认瓶颈,再谈换收集器。
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 难查得多。
| 命令 | 用途 | 常用姿势 |
|---|---|---|
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 住继续跑:堆已碎,应用处于未定义状态。捕获后应立即记录并让进程退出(让重启机制接管)。jmap -dump 大堆:会 STW 数秒。生产优先依赖 -XX:+HeapDumpOnOutOfMemoryError 的现场,确需手动 dump 先摘流量。jstat -gcutil 的 O 列锯齿状缓慢抬升 = 泄漏特征。-Xlog:gc*(JDK 9+ 统一日志)输出 GC 日志到文件,是分析停顿的原始证据:-Xlog:gc*:file=gc.log:time,uptime,level,tags。-Xms=-Xmx、HeapDumpOnOutOfMemoryError、GC 日志。jps/jstat/jmap/jstack + CPU 飙高三步法是值班硬技能。-Xmx(限额 50%~75%)并开启 OOM 自动 dump。下一章网络编程:请求是怎么从网卡走到你的 OrderService 的?BIO 为什么扛不住高并发?
基础题
OomLab(提示:static List<byte[]> 持有 + heap/stack/leak 三种模式,sample 不提供),完成三个模式的实验,分别记录报错信息与 jstat 观察;把 -Xmx 调到 128m 重跑 heap 模式,对比撑的时间。HashMap 每秒放入 1 个 1MB 数组,用 LinkedHashMap 重写为 LRU(accessOrder=true,重写 removeEldestEntry 限 50 条),jstat 确认老年代稳定。进阶题
-Xlog:gc*:file=gc.log 跑 leak 实验 3 分钟,从日志统计 Full GC 次数与平均停顿,写一段 100 字的「GC 健康报告」。思考题
-Xmx/-Xss/MaxMetaspaceSize),并说明每项依据;再想一个问题:为什么虚拟线程场景下 -Xss 不再是主要约束?提示:回看第 13 章虚拟线程的内存模型。阶段:阶段 2 · 进阶提升 | 预计阅读 65 min | 代码落地模块:无(本章为独立小实验,sample 不收录,建议自建 playground 目录练手) 前置:第 08 章 IO、第 11 章 并发基础 | 相关章节:第 23 章 REST 接口 | 第 25 章 RabbitMQ 实战落点:理解第 23 章「HTTP 请求 → Controller」之间发生了什么;34 章支付回调用到的 HTTP 客户端知识
读完本章,你应该能够:
HttpClient(同步/异步/超时),理解超时是网络调用的第一配置。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 三个事实,影响你写的每一行业务代码:
keep-alive 复用连接省去每次握手,这就是连接池(第 21 章 HikariCP、第 24 章 Redis 客户端连接池)存在的理由;Socket 是操作系统对「一条 TCP 连接」的编程抽象:读写它 = 读写网络。最小 TCP Echo 服务端/客户端(BIO 模型,先跑起来再谈缺点)的完整实现见「二、示例代码」,用 telnet localhost 9000 或直接跑客户端即可验证。
**阻塞 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 项目时再补——原理你已经有了。
后端服务互调(调用支付网关、查询物流)都用 HTTP 客户端。JDK 11+ 内置的 java.net.http.HttpClient 够用且零依赖,完整示例见「二、示例代码」。
三条纪律:
HttpClient 实例全局复用(内部连接池),别在方法里 new;你不会直接写 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();
}
}
statusCode:404/500 的响应体当正常数据解析,NPE 迷惑现场。read 死循环不判流结束:readLine() 返回 null 要退出;否则客户端断开后空转。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 查看)。HttpClient:实例复用 + 双超时 + 状态码检查,三纪律。下一章常用类库:时间处理、JSON 细节、工具类与摘要签名——都是业务代码每天在摸的轮子。
基础题
HttpClientDemo 请求 https://httpbin.org/delay/3,把请求超时设为 1 秒,观察 HttpTimeoutException;再去掉超时对比行为。进阶题
bye 并断开;客户端验证收到后正常退出。HttpClient + CompletableFuture 并行调用 httpbin.org/delay/1 三次,总耗时接近 1 秒而非 3 秒(第 13 章编排的网络版)。思考题
阶段:阶段 2 · 进阶提升 | 预计阅读 70 min | 代码落地模块:
sample/mini-shop-se/util前置:第 01 章、第 05 章 泛型 | 相关章节:第 26 章 安全 | 第 34 章 生产化 实战落点:订单号生成、下单/过期时间、支付回调 HMAC 验签、金额 BigDecimal 完整规则——34 章直接复用
读完本章,你应该能够:
java.time 完成时间创建、运算、格式化、时区转换,彻底告别 Date。BigDecimal 三纪律(字符串构造、显式精度、比较用 compareTo)。// 三大核心类:本地日期 / 本地时间 / 本地日期时间(无时区概念,业务系统首选)
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());
三条军规:
DATETIME + 应用层固定时区(第 21 章);SimpleDateFormat(线程不安全,并发下格式化错乱),DateTimeFormatter 是它的替代且不可变线程安全;ZoneId.of("Asia/Shanghai")),「默认时区」是国际化灾难的起点。第 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 的约定)。
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 章)。
| 库 | 定位 | 典型用法 | 提醒 |
|---|---|---|---|
| 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 章)。
// 摘要(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。String.equals:时序侧信道。MessageDigest.isEqual 常量时间比较。Clock.fixed(Instant.parse("2026-09-29T10:00:00Z"), ZoneId.of("UTC")),把 Clock 注入业务类(第 19 章可测试性思想),别在代码里到处 LocalDateTime.now()。writeValueAsString 打出来肉眼对照,再查注解(命名/忽略/格式)。java.time、库表 DATETIME、接口 ISO 格式;业务类通过注入 Clock 获取当前时间以便测试。Money 值对象(第 03 章),BigDecimal 规则封装在其内部,业务代码不裸碰。util 包并写明「为什么存在」,超过 30 行的工具类按主题拆分。@ConfigurationProperties),绝不硬编码。java.time 三件套 + 军规三连;DateTimeFormatter 线程安全,SimpleDateFormat 退役。BigDecimal 三纪律:字符串构造、显式精度、compareTo 比较。tools.jackson;泛型反序列化用 TypeReference;高频注解三枚。阶段二收官。从下一章开始进入企业级开发:先从「工程怎么组织」——Maven 多模块说起。
基础题
ExpireChecker:输入下单时间,判断是否超过 15 分钟;用固定 Clock 写测试覆盖「正好 15 分钟」「差 1 秒」两个边界。BigDecimal 实现账单分摊:总额 100.00 元分给 3 人(每人金额保留 2 位小数,总和必须精确等于 100.00)。提示:前 n-1 人截断,最后一人拿余数。进阶题
OrderNoGenerator 写并发测试:100 线程各生成 100 个号,断言无重复;模拟跨日(反射改 day 字段)验证序列重置。orderId=1&amount=3998&status=SUCCESS)+ 密钥签名 → 作为「网关」发给自己写的回调处理方法 → 验签通过才处理;再篡改 amount 重放,验证被拒。思考题
OrderNoGenerator 的序列在单实例内唯一,但部署两个实例后会出现重复。结合第 12 章 CAS 与第 24 章 Redis(INCR 原子自增)思考:分布式发号有哪些方案(数据库号段、Redis 自增、雪花算法 UUID)?各自优缺点是什么?先给结论,第 34 章生产化时对照你的答案。阶段:阶段 3 · 企业级开发 | 预计阅读 65 min | 代码落地模块:
sample/(父 POM 全解) 前置:第 00 章(黑盒用过 Maven)、第 05 章 | 相关章节:第 29 章 CI | 附录 90 实战落点:sample/pom.xml的版本收口设计;mini-shop 三模块的依赖关系
读完本章,你应该能够:
compile/provided/test/runtime 的区别。dependencyManagement 把版本收口到父 POM,子模块不写版本号。mvn dependency:tree 与国内镜像解决 90% 的「依赖问题」。Maven 世界里每个构件由三元组唯一定位:groupId:artifactId:version(加 packaging 与 classifier 扩展)。构建即「按坐标从仓库取依赖 + 按生命周期编译打包」:
本地仓库 ~/.m2/repository(缓存)
↑ 下载
远程仓库:中央仓库(Maven Central)/ 镜像(阿里云)/ 私服(企业 Nexus,进阶)
<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 |
你依赖 A,A 依赖 B——B 是传递依赖(transitive dependency),自动进入你的依赖树。当两条路径引来不同版本的 B 时,Maven 依赖调解(dependency mediation)规则:
冲突的症状千奇百怪(NoSuchMethodError、ClassNotFoundException),根源都是「运行时加载的版本与编译时假设的不一致」。排查一招定乾坤:
mvn dependency:tree -Dincludes=com.google.protobuf # 看 protobuf 从哪些路径被引入、各是什么版本
[!TIP]
dependency:tree全量输出太长,加-Dincludes=组:构件过滤;怀疑冲突时再配合mvn dependency:analyze找「声明了没用 / 用了没声明」的依赖。
多模块工程最忌「每个模块各写一套版本」。标准做法:父 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 章实战。
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 |
依赖树排障 |
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 坐标陷阱」。
-DskipTests 进了 CI:测试形同虚设。CI 只允许完整 mvn verify(第 29 章门禁)。mirrorOf 写 *:劫持所有仓库(含 Spring/公司私服),某些依赖 404(第 00 章已踩过)。NoSuchMethodError。先 dependency:tree -Dincludes=...,再 dependencyManagement 强制收敛或 <exclusions> 排除。Ctrl+Shift+O)重新导入。mvn -X clean compile:debug 日志看插件执行与仓库解析细节,解决「为什么没下载/下载了哪个」。mvn help:effective-pom:查看合并后生效的最终 POM(继承 + 管理全展开),理解配置来源。.lastUpdated 文件残留):删掉本地仓库对应目录重下,或 mvn -U 强制刷新。<properties> + <dependencyManagement>(与附录 90 同源),全局 grep 版本号只应命中一处。mvn clean test → mvn clean package → 才有资格谈部署。dependencyManagement 收口版本 + BOM 导入生态版本,是治理依赖树的两板斧。-pl -am 支撑多模块增量构建。common ← se/boot。下一章进入 Spring 的心脏:IoC 容器——为什么企业代码不再自己 new 对象。
基础题
sample 执行 mvn clean test,观察生命周期输出(各项 [INFO] --- maven-xxx-plugin 的执行顺序);再用 -pl mini-shop-common 只构建通用模块。mini-shop-se 里故意把 Jackson 版本改成 99.99,跑构建观察报错;恢复后执行 mvn dependency:tree | grep jackson,确认实际解析版本与父 POM 一致。进阶题
mini-shop-se 同时引入两个(你找得到的)同组不同版本传递依赖的库,用 dependency:tree 找出 Maven 选了哪个版本;再用 dependencyManagement 强制指定你想要的版本,验证树被收敛。sample 增加 mini-shop-tools 模块(只依赖 common),放一个字符串工具类;在 se 模块引用它,体会「新增模块只需三步:建目录、写 pom、父 POM 加 module」。思考题
阶段:阶段 3 · 企业级开发 | 预计阅读 85 min | 代码落地模块:
sample/mini-shop-boot(启动与装配) 前置:第 09 章 反射与注解、第 17 章 Maven | 相关章节:第 19 章 AOP 实战落点:mini-shop-boot 的启动类、依赖注入改造——OrderService从手工 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;
}
}
两大收益:
OrderService(第 28 章)。// 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 驱动就配数据源)——「约定大于配置」的核心。Boot 3 起命名空间全面 jakarta.*(jakarta.servlet、jakarta.validation)。网上大量教程写 javax.servlet.http.HttpServletRequest,在 Boot 4 下直接编译失败。看到 javax.* 一律脑内翻译为 jakarta.*。
| 方式 | 写法 | 适用 |
|---|---|---|
| 组件扫描 | 类上 @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 章可测试性)
}
}
// ✅ 构造器注入(唯一推荐):字段 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)
作用域:默认 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)。
@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。
// 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。启动类永远放最上层包。@Autowired 出现在字段上即整改。BeanCurrentlyInCreationException。正解是重构(抽出共同的 C,或改事件/接口解耦),@Lazy 只是掩盖问题的绷带。@Component 扫到了不该管的类:工具类、常量类不要注册成 Bean;Bean 是「有依赖、有生命周期」的对象。mybatis-plus-spring-boot4-starter,用 3 版 starter 报 Mapper 找不到(附录 90)。APPLICATION FAILED TO START 报告——它通常直接给出动作建议(缺配置项、端口占用、Bean 缺失)。--debug 启动看自动配置正负报告:Positive matches 生效、Negative matches 未生效及原因。Ctrl+点击 Bean 接口直接跳到实现类;Bean 依赖图(Spring 面板)可视化装配关系。/actuator/beans(第 27 章开启)列出全部 Bean 与依赖来源。@Configuration + @Bean 收口在 config 包,禁止散落 new。application.yml 并配 @ConfigurationProperties(第 19 章),禁止硬编码。@SpringBootApplication 三合一;启动类放根包;starter 自动配置按 classpath 生效。@Qualifier/条件装配切换;多环境用 profile。下一章 AOP:@Transactional、统一日志这些「方法前后插逻辑」是怎么无侵入实现的。
基础题
/api/products/1;把启动类临时移到 com.example 包外的子包,重启观察 NoSuchBeanDefinitionException,体会扫描规则。OrderService 加一个 Clock 依赖(第 1.3 节的 Bean),在日志里打印「下单时间」;写一个不启动 Spring 的测试:直接 new OrderService(fakeRepo, fakeOrderRepo, Clock.fixed(...)),验证注入架构的可测试性。进阶题
OrderRepository 的内存版与 MySQL 版两个 Bean(内存版 @ConditionalOnProperty(name="shop.order-repo", havingValue="memory")),用配置切换并各自验证查询接口。@Lazy 不是好答案。思考题
MiniContainer 没有「三级缓存」也工作正常,但 Spring 需要它解决带 AOP 代理的循环依赖(先暴露半成品引用,代理生成后再替换)。结合第 09 章动态代理知识推理:为什么「先 new 再生成代理」会带来顺序问题?这道题在第 19 章会有呼应。阶段:阶段 3 · 企业级开发 | 预计阅读 70 min | 代码落地模块:
sample/mini-shop-boot(aspect/config 包) 前置:第 18 章 IoC/DI、第 09 章 动态代理 | 相关章节:第 22 章 事务 | 第 27 章 日志 实战落点:配置绑定与启动期校验、Spring 事件机制——33/34 章直接使用
读完本章,你应该能够:
@ConfigurationProperties 做类型安全的配置绑定,配置校验齐全。@Transactional 失效的一半原因(另一半在 22 章)。日志、耗时统计、鉴权、事务……这些逻辑散落在每个方法里就是灾难。**AOP(Aspect-Oriented Programming,面向切面编程)**把它们抽成「切面」,声明式地织入目标方法——实现引擎就是第 09 章的动态代理:你调用的 Bean 是代理对象,通知逻辑跑在真实方法前后。
五要素一张表:
| 概念 | 一句话 | 本例 |
|---|---|---|
| 连接点(JoinPoint) | 可被拦截的点(Spring 里=方法执行) | OrderService.createOrder |
| 切点(Pointcut) | 表达式筛选哪些连接点 | execution(* com.example..service..*.*(..)) |
| 通知(Advice) | 织入的逻辑与时机 | @Around 环绕计时 |
| 切面(Aspect) | 切点 + 通知的组合 | TimingAspect |
| 织入(Weaving) | 生成代理把两者接起来 | Spring 启动时自动完成 |
下面的
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(控制权最完整),其余用于只读观察。
this.otherMethod() 绕过代理直接跑真实方法——第 09 章思考题的答案。切面、事务、缓存注解同理失效。解法:拆类、自注入代理、或 AopContext(不推荐)。散落的 @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 只留给零散的单值。
「下单成功后要发通知、记积分、清购物车」——直接在 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 即生效,零侵入。
this.xxx() 调用。execution(* com.example..*.*(..)) 把 Controller、工具类全拦,性能与日志噪音双杀。切面瞄准层(service)或注解。@Around 不重抛异常,上层以为成功。proceed() 的异常必须原样抛出(或包装后抛)。@ConfigurationProperties 忘了启用:绑定类不被注册,注入报找不到 Bean。启动类加 @ConfigurationPropertiesScan。@Validated 校验,端口/超时写成负数跑半天才发现。绑定类必须校验。@Async 或干脆走 MQ(34 章)。TimingAspect(确认 Bean 建了)→ 查调用是否 this. 内部调用 → 查方法可见性。pjp.proceed(),调试面板看 pjp.getSignature() 与参数;Step Into 进的是代理类(CglibAopProxy 字样)说明织入成功。/actuator/configprops(第 27 章开启)直接看每个绑定类的最终生效值。@ConfigurationProperties + 校验 = 类型安全配置;@Value 只配零散值。AFTER_COMMIT 保时序正确,重活异步化。下一章正式进入数据层:建表规范、MyBatis-Plus 与 MySQL 的索引与慢查询。
基础题
TimingAspect 跑起来,压一压下单接口,找一条 WARN 慢日志;然后把阈值改成 100ms 重启验证。ShopProperties 增加 shop.stock-warn-threshold(校验 ≥1),在 application.yml 配置并在启动日志打印生效值。进阶题
@Audit 注解 + 切面(第 09 章练习 2 的 AOP 版):记录操作人(从 ThreadLocal 取,没有就记 anonymous)、方法名、参数摘要,输出结构化日志。OrderService.close() 发布 OrderClosedEvent,新监听器负责库存回补;写测试验证「主流程失败时监听器不执行」(AFTER_COMMIT 语义)。思考题
IdempotentAspect 的内存 Map 有四个硬伤:多实例失效、不持久化、get 与 put 非原子(两个请求同时通过 last == null 检查,防重窗口形同虚设)、无清理导致膨胀。逐条给出 Redis 方案(SET key value NX EX ttl 的原子性恰好根治第三条)如何解决——这道题是第 24/34 章防重提交的完整预告。阶段:阶段 3 · 企业级开发 | 预计阅读 55 min | 代码落地模块:
sample/mini-shop-boot(目录与命名规约) 前置:第 18 章、第 19 章 | 相关章节:第 21 章 数据库访问 | 第 33 章 实战落点:mini-shop-boot 的包结构与 DTO/VO 规约,33–35 章逐层填充
读完本章,你应该能够:
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
| 层 | 职责 | 禁令 |
|---|---|---|
| Controller | 参数校验、调用 Service、包统一响应、异常翻译(23 章) | ❌ 不写业务判断、不碰 Mapper |
| Service | 业务规则、状态机流转、事务边界(22 章) | ❌ 不解析 HTTP 细节、不做 SQL 调优式组合查询 |
| Manager(可选) | 组合多个 Mapper/外部服务;封装 Redis+DB 的复合逻辑;第三方 SDK 适配 | ❌ 不承载核心业务规则(那是 Service 的) |
| Mapper | 单表 CRUD、SQL 映射 | ❌ 不出现业务语义(Mapper 不叫 closeExpiredOrders,那属于 Service 编排) |
| Entity/Domain | 表映射对象(Mapper 层)、领域对象(Service 层) | ❌ 不跨层直传 |
两条判断题帮你定职责:
StockManager 就是活例)。| 对象 | 生命周期边界 | 例子 |
|---|---|---|
| Entity | Mapper ↔ DB | OrderEntity(与 order 表一一对应) |
| DTO(Data Transfer Object) | 外部 → 内部(入参) | CreateOrderDTO(商品列表、数量) |
| VO(View Object) | 内部 → 外部(出参) | OrderVO(订单号、状态描述、金额元字符串) |
为什么不能拿 Entity 直接进出接口:
passwordHash、deleted、version 等内部字段,序列化即泄漏;// 入参 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 浅拷贝——字段改名编译器不报错,是隐形炸弹。
按层分包(本书采用,中小项目友好) 按功能分包(大项目/微服务常用)
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 包收敛中间件代码」的演化路径。
| 对象 | 规约 | 例 |
|---|---|---|
| 查接口 | 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 |
// 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 章会把这层骨架填成完整可跑的代码;本章要带走的是形状:每层一个职责、对象不跨层裸奔、依赖只准向下。
if (stock < qty) 出现在 Controller 就是分层失守的开始。Controller 超过 50 行基本该拆。findByName(String name) 返回 Entity 直接到前端。出接口必转 VO。return xxx.get(id)。无业务逻辑的透传层该合并。Ctrl + Alt + H 看方法调用链,跨层直调(Controller→Mapper)一眼现形。noClasses().that().resideInAPackage("..controller..").should().dependOnClassesThat().resideInAPackage("..mapper..")——CI 里自动守规。OrderService 就是具体类,Mockito 打桩具体类毫无压力,见 28 章)。OrderController/OrderService/OrderMapper/OrderEntity,改一个业务只动一组同前缀文件。下一章数据访问:建表规范、MyBatis-Plus 实战与索引优化。
基础题
OrderVO.from 写单测:断言金额分转元、状态码转文案两个转换点。进阶题
ProductVO(含价格元格式、是否售罄布尔)与 ProductEntity 的转换器,并故意在 Entity 增加 internalRemark 字段验证「VO 不泄漏内部字段」。思考题
阶段:阶段 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 层)
读完本章,你应该能够:
EXPLAIN,能给高频查询建正确索引,识别索引失效写法。@Version)兜底并发更新,理解它与第 32 章五方案的关系。-- 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 '商品表';
六条铁律:
BIGINT UNSIGNED AUTO_INCREMENT:自增做聚簇索引顺序写性能好;别用 UUID 做主键(随机插入导致页分裂,进阶话题);BIGINT 存分(或 DECIMAL(12,2)),绝不用 FLOAT/DOUBLE——第 01 章铁律延伸到存储层;utf8mb4:完整 Unicode(含 emoji);utf8 在 MySQL 里是残血的 utf8mb3;deleted:订单类数据法律上不能物理删;查询统一带 deleted=0(MP 的 @TableLogic 自动拼);status=2 是什么——第 03 章枚举教训在库表的重演。建一条 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。
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;
}
}
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 即库存不足
索引 = 排好序的查找结构(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 * 大宽表回表 |
慢且浪费;只查需要的列(覆盖索引场景) |
// 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(...) 指定列。mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl,prod 必须关(日志洪水)。slow_query_log,long_query_time=1;或看 APM(27 章)。EXPLAIN 前先 ANALYZE TABLE(统计信息过期会导致预估失真);改索引用 ALTER TABLE ... ADD INDEX 并在低峰执行。LocalDateTime + DATETIME;全局时区固定(16 章三统一)。@Version 乐观锁(兜底)。下一章事务:@Transactional 到底做了什么、什么时候会悄悄失效。
基础题
schema.sql 建出全部五张表:product/order/order_item/idempotent_record/app_user(画出 ER 图标注外键关系——生产可不用物理外键,用逻辑关联 + 应用层保证。库存不单设 sku_stock 表:教学聚焦核心链路,分段库存走 Redis,见 24/34 章)。GET /api/products?page=1&size=2&keyword=键盘;关掉分页插件配置再跑,用 SQL 日志对比「有没有 LIMIT」。进阶题
order 表造 10 万条测试数据(存储过程或程序循环插入),用 EXPLAIN 对比「有/无 idx_user_id 索引」时按用户查订单的 type 与 rows。ExecutorService 起 20 线程对同一商品各扣 1 件库存(初始 10 件),分别用「先查再减」与「条件原子 UPDATE」两版实现,验证后者永远不超卖而前者会。思考题
阶段:阶段 3 · 企业级开发 | 预计阅读 80 min | 代码落地模块:
sample/mini-shop-boot(service 事务边界) 前置:第 19 章 AOP、第 21 章 | 相关章节:第 34 章 MQ 异步化 实战落点:下单主流程事务 + 「事务内发消息」的正确姿势——33/34 章的稳定基石
读完本章,你应该能够:
@Transactional 七个失效场景中的高频四个,会排查会修。mini-shop 下单要写两处:product.stock 减 1、order 插一行。没有事务的两种惨案:
事务(Transaction)保证这组操作要么全部成功(持久性 Durability + 原子性 Atomicity),并发交错时像独占执行(隔离性 Isolation),前后约束始终合法(一致性 Consistency)。
图例:✅ = 能防住该读异常;❌ = 该读异常会发生。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ UNCOMMITTED | ❌ 会发生 | ❌ | ❌ | 读到别人未提交数据,几乎无人用 |
| READ COMMITTED | ✅ 防 | ❌ 会 | ❌ | 每次读最新已提交(PG/Oracle 默认) |
| REPEATABLE READ | ✅ | ✅ 防 | ✅*(InnoDB 基本防) | MySQL 默认;同一事务内多次读一致 |
| SERIALIZABLE | ✅ | ✅ | ✅ | 全串行化,吞吐最低 |
实用结论:MySQL 默认 RR 已满足绝大多数业务;能靠「乐观锁/条件更新 + 唯一键」解决的并发问题,不要上 SERIALIZABLE。你需要在应用里做的不是调级别,而是把事务边界划对。
@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;
}
三个要点:
rollbackFor = Exception.class 必写:默认只回滚 RuntimeException/Error;抛受检异常(如自定义 IOException 包装前)默认不回滚——教科书级事故;| 传播行为 | 语义 | 典型场景 |
|---|---|---|
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 | 同类内部调用 | this.methodB() 绕过代理,注解形同虚设(19 章) |
拆类 / 自注入 / 编排上移 |
| 2 | 方法非 public | CGLIB 无法拦截 | 提为 public;或迁移到接口方法 |
| 3 | rollbackFor 缺省 + 受检异常 | 默认只滚非受检 | 统一写 rollbackFor = Exception.class |
| 4 | 事务内被 try-catch 吞掉 | 异常没抛出代理层,事务以为一切正常 | 要回滚就 throw,或 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
另有隐蔽款:多线程内执行(事务绑定 ThreadLocal 连接,子线程的 SQL 不在事务里)、存储引擎不支持(MyISAM)、传播行为配错。记忆锚点:事务是代理 + ThreadLocal 实现的,凡是绕开这两者都会失效。
@Transactional
public void createOrder(...) {
// ❌ 调 HTTP/发邮件(秒级慢 IO):连接占用 + 锁等待放大
// ❌ Thread.sleep / 重计算
// ✅ 只做:INSERT/UPDATE/SELECT(快)
// ✅ 要发消息:收集「事务提交后要做的事」,提交后再发(见下)
}
「事务提交后再发 MQ」的两种正确实现:
@TransactionalEventListener(phase = AFTER_COMMIT) 事件监听(19 章)——同进程首选;// 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 章的补偿逻辑)。
this.saveXxx() 注解失效(Top1 失效王)。begin/commit。commit 出现一次)。TransactionSynchronizationManager.isActualTransactionActive() 打日志,false 即没进事务。SHOW ENGINE INNODB STATUS 看 LATEST DETECTED DEADLOCK 双方持锁;常见于两个事务以不同顺序更新两行——统一更新顺序即解。@Transactional(rollbackFor = Exception.class),Review 见裸注解直接打回。下一章接口设计:把订单服务暴露成规范的 RESTful API,统一响应、全局异常、参数校验与文档一步到位。
基础题
OrderService.createOrder 写两条事务测试:订单项落库失败时断言「库存未被扣减」(回滚生效);再故意在方法内 catch 掉异常验证「半截数据」事故,体会 rollbackFor + 抛出的意义。REQUIRES_NEW 场景:PayRecordService.record 独立事务记录流水,主流程故意抛异常回滚,验证流水仍在。进阶题
methodA 调 this.methodB()(B 有事务注解),日志确认 B 没进事务;用「自注入自身代理」与「拆类」两种方式修复并对比优缺点。TransactionTemplate 编程式事务重写 createOrder,对比声明式版本的可读性与灵活性(编程式适合「事务范围由运行期条件决定」的场景)。思考题
阶段:阶段 3 · 企业级开发 | 预计阅读 70 min | 代码落地模块:
sample/mini-shop-boot(controller/api 包) 前置:第 20 章 分层、第 06 章 异常 | 相关章节:第 26 章 鉴权 实战落点:mini-shop 全套 API 规范:统一Result<T>、全局异常处理器、参数校验、springdoc 文档
读完本章,你应该能够:
Result<T> + 全局异常处理器 @RestControllerAdvice。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)。
第 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 分支)。写进接口文档,全队遵守。
// 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,系统错误自然冒泡——异常路径全部收口在这一处,日志格式统一、响应结构统一。
// 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」。
<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 文档强的根本原因。
| 方法 | 路径 | 说明 | 错误码 |
|---|---|---|---|
| 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
@RestControllerAdvice。@Valid:注解全是摆设,null 直通到 Service。DTO 入参必查 @Valid 是否在位。/api/v1/ 或用 Header 版本化;裸接口改契约即灾难。本书教学用 /api,真实项目建议 /api/v1。e.getMessage() 只允许给业务异常;系统异常一律通用话术(堆栈进日志,不进响应)。curl -v 看状态码与响应头;配合 swagger-ui 在线调试最直观。@Valid 在不在、对象是不是被 @RequestBody 包着。page/size(size 上限 100 防拖库),响应统一 PageResult<T>(第 05 章)。Result<T> + @RestControllerAdvice + 校验注解 = 接口三支柱,Controller 回归薄。下一章缓存:给商品详情加 Redis,正面解决穿透/击穿/雪崩与分布式锁——mini-shop 生产化改造的前哨。
基础题
quantity: 0 验证校验响应。GET /api/products 加 size 上限保护(>100 返回 40001),写出你选择的校验位置(DTO 注解 / Controller 判断)并说明理由。进阶题
@OrderItemsLimit(max=999):校验 items 的 quantity 总和;在 DTO 上启用并用边界数据测试。GlobalExceptionHandler 再扩展一类异常:HttpRequestMethodNotSupportedException(405,方法用错;类型不匹配/缺参 sample 已内置,可对照源码看写法),保持响应结构一致。思考题
Result<T>,只认 HTTP 状态码与约定报文。这类「机器对机器」接口的响应契约应该怎么设计(直接 HTTP 200 + SUCCESS 字样?失败返回什么?)?写一小节接口文档草案,34 章实现时对照。阶段:阶段 3 · 企业级开发 | 预计阅读 85 min | 代码落地模块:
sample/mini-shop-boot(infra/redis) 前置:第 18 章、第 21 章、第 12 章 CAS/锁 | 相关章节:第 34 章 实战落点:商品详情缓存、库存预热、Redisson 分布式锁防超卖——34 章的承重墙
读完本章,你应该能够:
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(排行榜)。
// 读:先缓存,未命中查库并回填
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()); // 删缓存,让下次读自然回源
}
为什么「更新库后删缓存」而不是更新缓存:
为什么「先库后删」:先删缓存后更新库的窗口里,并发读会把旧值重新填进缓存且无 TTL 前的脏读期更长。先库后删的不一致窗口只剩「删失败」——用 TTL 兜底 + 延迟双删/订阅 binlog(进阶)消化。
| 问题 | 场景 | 解法 |
|---|---|---|
| 穿透:查不存在的数据,缓存永远没有 | 恶意刷不存在的 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();
}
}
第 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); // 快速失败
}
三件事必须懂:
SET key val EX 10 NX 版本里,A 业务超时锁过期、B 拿到新锁、A 完事 DEL key——删掉了 B 的锁。必须「值存唯一标识 + Lua 校验后删」。Redisson 已内置该逻辑,不要手写 SETNX 锁;// 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 抖动防雪崩——这三个问题从来不是三段独立代码,而是同一个读路径上的三处防御工事。
SCAN 游标遍历。redis-cli 直连排障:GET/TTL/EXISTS key、--scan --pattern 'product:*'、MONITOR(慎用,看实时命令)。TTL lock:xxx 是否在续期(看门狗活着);线程 dump 找 tryLock 的等待栈。业务:实体:id(product:1001),管理台里一眼可读。下一章消息队列:RabbitMQ 的可靠投递、延迟关单与消费幂等——把「下单成功之后」的活全部异步化。
基础题
GET /api/products/{id} 接上 ProductCacheManager,用 redis-cli TTL product:1 验证 TTL 抖动;连续两次请求观察第二次耗时骤降。进阶题
DataInitializer 启动时调 StockManager.warmup,按商品拆 10 段写入 stock:seg:*):读懂它之后,写一个 GET /api/debug/stock/{productId} 观察段分布(生产记得加权限)。思考题
阶段:阶段 3 · 企业级开发 | 预计阅读 85 min | 代码落地模块:
sample/mini-shop-boot(infra/mq) 前置:第 18 章、第 22 章 事务 | 相关章节:第 34 章 实战落点:延迟关单(替代 32 章 DelayQueue)、支付回调幂等——34 章的全套异步骨架
读完本章,你应该能够:
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
三大动机:异步(主链路只留核心动作)、削峰(流量洪峰先进队列排队消化)、解耦(下游增减不改编译上游)。代价也要认清:一致性从「强」变「最终」(消息没到之前下游不知道)、多一个要运维的中间件、排障链路变长(消息在哪一段丢了?)。核心动作(扣库存)不异步,外围动作(通知/积分)才异步。
Producer ──发消息(带 routing key)──> Exchange ──按绑定规则路由──> Queue ──> Consumer
direct:routing key 精确匹配 → 点对点任务(订单关单)
topic :通配符匹配(order.* / #) → 分类订阅
fanout:广播到所有绑定队列 → 事件广播
本书用 direct(够用且直观),topic 认识即可。
消息丢失只可能发生在三段,每段一道防线:
① 生产者 → 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
}
}
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(进阶了解)。
MQ 的可靠性承诺是「至少一次」(at-least-once):网络抖动、ACK 丢失都会导致重复投递。幂等设计不是可选项。
三层防线(由弱到强):
CREATED 直接跳过——32 章「不删任务,让任务可跳过」的兑现;UNIQUE KEY(order_no),重复插入直接被数据库拒绝(DuplicateKeyException 当成功处理);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 章把这条链完整跑通并接上压测。
localhost:15672:Queues 页看消息数(积压告警第一现场)、Consumers 是否在线、消息速率曲线。publisher-returns + ReturnCallback 兜底)?routing key 与绑定是否对上?Get Message 直接查看/重发消息,排障神器。下一章安全与鉴权:JWT 登录、Spring Security 7 与越权防护,给 mini-shop 装上门禁。
基础题
test.q 并发一条消息,写消费者打印后 ACK;再写一个必失败消费者(nack 不重投 + 给队列配死信交换机),观察消息进入死信/停车场队列的全过程。order.create.q 塞一条 TTL 10s 的消息,10 秒后在 order.close.q 的消费者里收到——把「死信头字段」(x-death)打印出来看路由轨迹。进阶题
idempotent_record 表 + 幂等消费者骨架,写测试:同一 msgId 的消息投递两次,业务处理(如插入订单)只发生一次。思考题
阶段:阶段 3 · 企业级开发 | 预计阅读 75 min | 代码落地模块:
sample/mini-shop-boot(infra/security) 前置:第 16 章 摘要与 HMAC、第 23 章 接口规范 | 相关章节:第 34 章 实战落点:JWT 登录、过滤器解析用户进 ThreadLocal、订单归属校验防水平越权
读完本章,你应该能够:
Web 会话的两种流派:
| 方案 | 原理 | 优点 | 代价 |
|---|---|---|---|
| Session + Cookie | 服务端存会话,Cookie 带 sessionId | 可随时踢人下线 | 服务端有状态,集群要共享会话 |
| JWT(JSON Web Token) | 服务端签发自包含令牌,请求携带,签名验证 | 无状态、跨服务友好 | 签发后无法主动作废(只能等过期/黑名单) |
mini-shop 选 JWT(前后端分离 + 无状态横向扩容的典型选择),并接受其代价:敏感操作(改密码)配合「令牌版本号」或短有效期 + 刷新令牌(本书教学简化为 2 小时短令牌)。
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();
}
}
// 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();
}
}
}
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);
}
hasRole("ADMIN") 或注解 @PreAuthorize。| 漏洞 | 原理 | 防御 |
|---|---|---|
| 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
${} 拼接用户输入进 SQL:注入。排序/列名白名单映射。anyRequest().authenticated() 的判定看的是 SecurityContextHolder——业务层虽然能从 UserContext 取到人,Security 却认为未认证,拿着合法令牌也吃 401。两处都要写(本工程 JwtAuthFilter 已落地)。/api/pay/callback permitAll 的前提是HMAC 验签(16 章),两头缺一不可。Authorization 格式(Bearer 带空格)、令牌是否过期、密钥两端是否一致。logging.level.org.springframework.security=DEBUG 看每个请求过过滤器的判定过程。permitAll 每多一行,多一次安全评审。下一章日志与监控:让每一笔订单的问题都能靠 traceId 十秒定位。
基础题
JwtAuthFilter:登录拿令牌 → 访问受保护接口 → 篡改令牌最后一位重放,观察 401。进阶题
UserContext(ThreadLocal 封装:set/get/require 三方法,require 为空抛 40101),改造 OrderService 全部方法不再从参数收 userId。@StrongPassword(长度 ≥8 且含字母数字),挂在注册 DTO 上,边界数据全测一遍。思考题
阶段:阶段 3 · 企业级开发 | 预计阅读 70 min | 代码落地模块:
sample/mini-shop-boot(infra/observe) 前置:第 19 章、第 25 章 MQ | 相关章节:第 29 章 部署 | 第 35 章 实战落点:traceId 贯穿下单→MQ→回调、Logback 分级分文件、Actuator + Prometheus 指标
读完本章,你应该能够:
// ① 分级: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 章)。
<!-- 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。
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 一路传,日志一路跟,微服务时代的排查基础。
<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)必须出现。@Async/新开线程里 MDC 为空。提交任务前手动拷贝 MDC(装饰 Runnable 或用框架的 TaskDecorator)。include: * 把 env/heapdump 挂公网 = 泄密。最小暴露 + 管理端口隔离。logs/ 进 .gitignore。logger 端点(dev 开)运行时调级别:POST /actuator/logger/com.example.minishop body {"configuredLevel":"DEBUG"},免重启开 debug。下一章测试策略:把前面散落的测试习惯升级成企业级测试体系(Mock/切片/Testcontainers)。
基础题
TraceIdFilter 与 logback-spring.xml,压一次下单,用 traceId 串出「入口→Service→MQ」三段日志。OrderService 的日志补业务主键与脱敏(写一个 Desensitize.mobile 工具),Review 你此前所有日志语句。进阶题
shop.order.created 计数与 shop.order.create.time 计时,压 100 单后在 /actuator/prometheus 输出里找到这两个指标并解读数值。思考题
阶段:阶段 3 · 企业级开发 | 预计阅读 70 min | 代码落地模块:
sample/mini-shop-boot(src/test) 前置:第 10 章 JUnit、第 18 章 IoC | 相关章节:第 35 章 实战落点:OrderService单测 + 带真实 MySQL/Redis 的集成测试,35 章 CI 门禁跑它们
读完本章,你应该能够:
@WebMvcTest 做 Web 层切片(MyBatis-Plus 生态对应 @MybatisPlusTest,思路同切片)做窄范围集成测试。 ▲ 端到端 E2E(少量:冒烟主链路,慢、脆)
▲▲ 集成/切片(适量:Mapper 真库、Controller 层装配)
▲▲▲ 单元测试(大量:Service/领域逻辑,毫秒级、无容器)
第 10 章说过单元测试的判据(断网断库毫秒级)。本章补两个问题:依赖数据库的代码怎么单测(Mock 换掉依赖)与**「换掉之后还信吗」**(集成测试补真实验证)。
// 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 心法三句:
[!TIP] 遇到
Json.write(...)这类静态调用无法常规打桩时:优先重构为可注入的组件(依赖注入的又一收益);确要 mock 静态方法,Mockito 5 起内联引擎已是默认,直接用Mockito.mockStatic(...)(try-with-resources 包裹,用完即关)。
@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)); // 全局异常处理器生效
}
}
切片测试的定位:验证装配与协议链路(路由、校验、异常映射、序列化),不验证业务逻辑(那是单测的事)。
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 章)。
纪律三条:
| 被测对象 | 层 | 方式 | 关键断言 |
|---|---|---|---|
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 分钟。默认单测/切片,全容器留给端到端冒烟。null 导致 NPE,还以为是业务 bug。打桩必须贴真实行为。verify 了「每个依赖都被调用、参数逐字段相等」——实现一重构测试全崩。验证输出与关键交互,不验证实现细节。assertThat(result).isNotNull() 等于没测。断言具体值与状态变化。.andDo(print()) 的请求/响应全量输出,多数是 JSON 路径写错。@SpringBootTest + MP SQL 日志(dev 配置),或直接去集成测试看。shouldXxx_whenYyy + @DisplayName 中文描述,报告可读。@Mock + 构造器手工装配(或 @InjectMocks)让 Service 测试零容器。@WebMvcTest 验证协议链路四件套(路由/校验/异常/序列化)。@ServiceConnection。下一章部署与运维:把 mini-shop-boot 打成镜像,一条命令拉起全家桶,接上 CI。
基础题
OrderService.createOrder 写两条 Mockito 单测(成功发布事件 / 库存不足不落单),运行毫秒级完成验证;再补一条「未登录(UserContext 为空)直接拒绝」。OrderControllerTest 切片测试覆盖「校验失败 400」「业务拒绝 40901」「成功 200」三态。进阶题
ProductMapperIT:验证分页查询生成 LIMIT(观察 SQL 日志或断言返回条数 ≤ size)、逻辑删除后查询不到。思考题
阶段:阶段 3 · 企业级开发 | 预计阅读 70 min | 代码落地模块:
sample/mini-shop-boot(Dockerfile/compose/CI) 前置:第 14 章 JVM 参数、第 17 章 Maven、第 27 章 监控 | 相关章节:第 35 章 实战落点:多阶段 Dockerfile、compose 全家桶、GitHub Actions 流水线——35 章收官验收的载体
读完本章,你应该能够:
「在我机器上是好的」的终结者:把 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"]
要点:
dependency:go-offline 单独一层,源码改动不触发依赖重下,CI 提速显著;MaxRAMPercentage + OOM dump + G1——堆随容器限额伸缩,不必每个环境手调 -Xmx;logs/dump 目录要提前建好并 chown,否则应用用户没有写权限,日志与 OOM dump 静默丢失;mini-shop-boot/ 下但要 COPY 兄弟模块 mini-shop-common,所以构建命令必须以 sample/ 根为上下文(compose 里对应 context: . + dockerfile: mini-shop-boot/Dockerfile);配合根目录的 .dockerignore(排除 target/、.git/ 等)避免垃圾进镜像层。# 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 # 改代码后的快速重启(中间件不动)
docker compose down 默认先 SIGTERM 等 10s 再 SIGKILL。Spring 侧配合:
server:
shutdown: graceful # 收到停机信号:停止接新请求,等在途请求完成
spring:
lifecycle:
timeout-per-shutdown-phase: 20s
配套要点:负载均衡先把实例摘流量 → 优雅停机排空 → 退出。MQ 消费者停机时未 ACK 的消息会重投(25 章幂等在此兜底——可靠性的环闭环了)。
# 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 分钟内):
/actuator/health UP,且 DB/Redis/RabbitMQ 分项健康;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 / 语义化版本。-Xmx:OOM Kill 无现场(14 章)。限额 + 显式堆参数成对出现。depends_on 以为能保证就绪:它只保证启动顺序,不保证服务可用。用 condition: service_healthy(应用侧再配重试)。docker compose down -v 顺手把数据库清了。数据目录必须挂 volume。.env(进 .gitignore)或密钥管理,生产用 secret 机制。docker compose down 与 stop 混淆:down 删容器(卷保留但端口/容器没了),stop 只停。排障时看清状态。docker compose logs app --tail 100 看启动日志;docker compose ps 看重启循环(Restarting)。docker compose exec app sh(JRE 镜像有 sh 无 bash);docker stats 看内存/CPU 实况。docker history mini-shop:tag 看每层体积,多半是没拆多阶段或带了构建缓存。docker exec 改文件 = 制造不可复现环境。deploy.sh 五行胜过 wiki 五页,脚本进仓库随代码演进。graceful + 摘流量 + 幂等兜底;停机不丢请求是 MQ 幂等的回报。第 30–35 章实战收官:把 17–29 章的能力全部装配回 mini-shop,交付一个真正「能上线」的服务。
基础题
docker compose stop redis,观察 /actuator/health 变 DOWN 与应用行为(读接口报错/降级),恢复后验证自愈。进阶题
deploy.sh:拉最新代码 → 构建镜像(tag=git sha)→ compose 滚动重建 app → 健康检查失败自动回滚到上一个 tag。脚本 ≤ 40 行。思考题
阶段:阶段 4 · 实战项目 | 预计阅读 40 min | 代码落地模块:
sample/(全局视角) 前置:第 03 章 类设计进阶(领域模型基础)| 相关章节:第 31 章 | 第 33 章 实战落点:本章是 31–35 章的「作战地图」,先读它再写代码
读完本章,你应该能够:
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] 链路刻意做短。真实电商还有优惠券、购物车合并、库存分层、物流、售后……每加一个概念,教学主线就模糊一分。学会这条链路的全部原理,那些扩展只是「同样的招式换个业务」。
| 对象 | 类型 | 关键字段 | 说明 |
|---|---|---|---|
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) 就是同一个钱,没有「哪一张」的区别。
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」。
写代码前先定验收,这是企业项目立项的基本动作:
docker compose up -d 一条命令拉起全部依赖与应用。每一章结尾的「动手练习」都在为这五条添砖;第 35 章收官时逐条打钩。
@Transactional 失效或库存对不上时没有排查直觉,最后还得回头补——按顺序走反而是捷径。IllegalStateException(03 章)通常就是第一现场。mini-shop-common(如 OrderStatus);技术实现各自演进——「稳定的核心下沉,易变的外围快跑」。下一章正式开工:用前十章的纯 Java 功底,把这条链路完整跑起来。
基础题
进阶题
思考题
OrderItem 为什么要冗余 productName 和 unitPriceInCents,而不是只存 productId 运行时去查?这个「快照」思想,还能用在系统的哪些地方(提示:地址快照、优惠券规则快照)?阶段:阶段 4 · 实战项目 | 预计阅读 90 min | 代码落地模块:
sample/mini-shop-se(基础包) 前置:第 30 章 总览、第 06 章 异常、第 08 章 IO、第 10 章 测试 实战落点:本章产出的model/repository/service/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 一一对应——到时候你只是「给每层换工具」,不需要重学结构。
// 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(); }
}
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 章超卖实验的伏笔。
// 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;
}
}
注意三条设计决策:
InMemoryOrderRepository 是具体类而非接口(构造器参数直接收它),因为本章只有一个实现、且它的 nextId() 发号能力就是 service 需要的契约。若预见到多实现,应回到第 05 章 Repository<T, ID> 接口 + 自定义发号接口的组合——不为接口而接口,但多实现预期出现时就该抽(33 章 Boot 版换成 Mapper 时你会看到接口边界重新出现)。// 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 章) |
createOrder 里出现 Scanner/println,这层就废了——它必须能被测试静默调用。IO 永远在 cli/controller 层。decreaseStock 校验的写路径都是定时炸弹,仓储层不做业务判断、只管存取。Order,将来领域类一改历史数据全废。用专用快照 DTO(第 08 章已示范)。productId,商品改价后历史订单金额全错(第 30 章思考题)。nextId 放仓储统一发号;将来换 MySQL 自增/雪花算法时只改一处。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 章动手术的安全网。new Product(...) 撒在业务方法里。data/ 进 .gitignore,仓库只留代码不留运行时数据。下一章:200 个线程同时抢 100 件库存,本章的代码会发生什么?
基础题
ShopCli:完成一次「加购 → 下单 → 支付」并重启程序验证快照恢复;再对同一订单重复支付,观察状态机异常。ShopCli 增加「查看我的订单」菜单项:按用户 id 过滤订单并打印状态与金额(Stream 过滤 + 格式化输出)。进阶题
DelayQueue(元素带到期时间),后台线程 take() 到期订单并调用 close()。提示:DelayQueue 元素需实现 Delayed 接口;用 ScheduledExecutorService 起消费线程(第 13 章知识)。OrderService.createOrder 增加防重:同一用户在 1 秒内重复提交同样商品组合时抛 DUPLICATE_ORDER。用 Map<Long, Long>(userId -> 上次下单时间戳)实现,思考它的并发问题(第 32/34 章会给出正式方案)。思考题
OrderSnapshotStore 是「写时全量落盘、读时全量恢复」。当订单涨到 100 万笔,这个方案的启动恢复时间与磁盘占用会怎样?列出你想到的优化方向(增量写、压缩、分片、直接上数据库),并预测为什么企业最终都选了数据库——带着答案进入第 33 章。阶段:阶段 4 · 实战项目 | 预计阅读 90 min | 代码落地模块:
sample/mini-shop-se/concurrent前置:第 11–13 章、第 31 章、第 14 章 JVM 实战落点:为第 33 章「DB 层怎么扣库存」和第 34 章「Redis/MQ 方案」提供性能与正确性参照
读完本章,你应该能够:
DelayQueue 实现内存版超时关单,并说清它为什么必须被 MQ 替换。本节用到:第 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
三个必须带走的结论:
// 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 分片)会用请求号哈希 + 后台再平衡。教学目标是理解「冲突面」这个变量。
跑第 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)——这正是串行化的可视化;RUNNABLE 但 CPU 100%(top -H 验证)——自旋空转的真面目;-Xmx 给 32m 会看到 GC 频繁——把 -Xmx 调到 256m 对比,感受第 14 章「容器参数」的实际意义。// 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() 永久阻塞等待最近到期任务,天然按时间排序——优雅。但三大缺陷决定它只能活在教学里:
[!TIP] 正解是第 25/34 章的 RabbitMQ 死信/延迟消息:消息持久化、集群共享、消费速率可控。内存方案的价值是让你先理解「延迟任务」的语义,再看 MQ 如何逐条补齐缺陷。
支付成功后如何「取消」已排队的关单任务?DelayQueue 不支持高效按元素删除。生产做法是关单前幂等校验:关单消费者取出任务后先查订单状态,PAID 就直接跳过——「不删任务,让任务变得可跳过」(34 章落地)。
| 维度 | 阶段一(31 章) | 阶段二(本章) | 阶段三(33/34 章) |
|---|---|---|---|
| 正确性手段 | 单线程天然安全 | 五方案 + 压测验证 | DB 原子 UPDATE + 乐观锁;Redis 分片 + 分布式锁 |
| 性能手段 | 无 | 分段缩冲突 | 缓存预热 + 异步化削峰 |
| 超时关单 | 无 | DelayQueue(内存) | RabbitMQ 延迟消息 + 幂等消费 |
| 验证手段 | 单元测试 | 并发压测 + jstack/jstat | 压测脚本 + Actuator 指标 + 告警 |
卖出 + 剩余 = 100 是正确性的硬校验,任何方案过不了这条直接出局。SeckillBench 的统计口径(成功数、剩余数、 verdict 判定)错了,后面所有结论全歪。jstack/jstat 把「慢」归因到锁排队或自旋空转。阶段二全部完成。若你按 30 章的路线图跳读过阶段三的前置章节,现在正是回头补 17–29 章(Maven、Boot、数据访问、缓存、MQ、安全、部署)的时候——下一章 33 章起,我们用它们给 mini-shop 换上 Spring Boot 的心脏。
基础题
SeckillBench 三轮,把每种方案的「卖出/剩余/耗时」记录成表格;人为把 SyncStock.tryDeduct 的临界区加一句 Thread.sleep(1),再压一次,观察吞吐崩塌——体会临界区长度的影响。DelayCloseDemo,把三笔订单的超时改成 15 分钟并压缩为 3/6/9 秒,验证按到期顺序关单。进阶题
SeckillBench 增加「预热轮」(每方案先空跑 1 轮不计入统计)与「每线程多次尝试」模式(每个线程循环抢到失败为止),对比单发与持续压测下方案 4 与 5 的差距变化。-Xmx64m -Xlog:gc*)跑方案 2 的压测,从 GC 日志统计压测期间的 Minor GC 次数;改用 -Xmx512m 复测,写 3 行结论。思考题
阶段:阶段 4 · 实战项目 | 预计阅读 90 min | 代码落地模块:
sample/mini-shop-boot前置:第 17–23 章、第 31 章 | 相关章节:第 34 章 实战落点:把 SE 版业务原样搬进 Spring Boot 分层 + 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 章「测试是安全网」的兑现。
// 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 包名)。
-- 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 章压测证明过的正确性方案,在数据库里重演。
// 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 全链原子 |
多表写的一致性 |
// 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` 已扣减。
OrderEntity 带着 status 英文码与 payableCents 裸数就返回了。必转 VO(20 章)。order 表名是关键字:SQL 里要写 `order`(反引号),注解 @TableName("\order`")`。docker-entrypoint-initdb.d 自动执行(29 章 compose)。APPLICATION FAILED TO START 报告(缺配置/连不上中间件),再开 --debug 看自动配置负匹配——Boot 4 下 MP 的 starter 坐标配错会报「Mapper 找不到」,先核对附录 90。DuplicateKeyException:是订单号撞 uk——sample 的 insertOrderWithRetry 已自动换号重试 3 次,仍频繁撞说明随机段熵不够,检查 generateOrderNo。@Update 注解与实际参数;order 表查询记得反引号。isActualTransactionActive() 打日志。mini-shop-common,两代实现共用;实现层各自演进(30 章原则在工程结构上的落地)。@Profile("!prod"),演示账号永不进生产。closeByOrderNo/markPaid 这类条件 UPDATE 是「单表原子操作」可以进 Mapper,「先查再判再写」的编排一律在 Service。基础题
POST /api/orders/{no}/close),每步用 SQL 验证数据变化(订单状态、库存回补)。@Transactional 从 createOrder 上去掉,制造「扣库存成功、落单失败」(在 insert 前抛异常),验证出现「库存被扣但没订单」的事故;恢复注解后重跑验证回滚。进阶题
myOrders 已是真分页:listByUser 在 SQL 里 LIMIT offset,size,total 由 countByUser 单独 COUNT(不当页行数凑数)。练习:给 listByUser 的查询跑 EXPLAIN,验证 idx_user_status 被命中;再把 size 传 0 与 1000 验证 Service 层的夹取逻辑。OrderServiceTest 四条断言在 Boot 版重写为 Mockito 单测(28 章打法),体验「安全网跨代迁移」。思考题
stockManager.deduct 目前「Redis 成功即返回,失败落 DB」。如果 Redis 扣成功后、事务提交前应用崩溃,Redis 与 DB 会差一件库存。列出你能想到的三种对账思路(定时比对 DB↔Redis、关单时以 DB 为准重置、扣减时写「扣减流水」),第 34 章会采纳其一。阶段:阶段 4 · 实战项目 | 预计阅读 90 min | 代码落地模块:
sample/mini-shop-boot(manager/infra) 前置:第 24–26 章、第 33 章 | 相关章节:第 35 章 实战落点:mini-shop 生产化四件套一次装配:缓存读路径、Redis 扣减热路径、延迟关单、回调幂等 + 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"]
四条改造主线,每条都是前面章节的直接落地。
// 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 层职责)。
// 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 是「唯一事实」。两者允许瞬时误差,三条对账规则闭环:
-2)或异常时降级 DB,宁可慢不可错;SUM(Redis 段) 与 DB stock,差异告警。// 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 延伸阅读 | — |
// 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\"}"
# 再重放一次:日志出现「重复回调幂等跳过」,订单状态不变
登录 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 章)。
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)、死信绑定是否齐全;再查停车场队列有没有消费失败的消息。PAY_SECRET 两端是否一致。Bearer 带空格了吗;JWT_SECRET 是否两端一致;JwtAuthFilter 是否两处上下文都写了(26 章坑清单)。基础题
/api/products/1,第二次观察耗时骤降;redis-cli TTL product:1 验证抖动;更新商品后再读,验证「先库后删」生效。进阶题
/api/orders(库存 100),验证「成功 ≤ 100 且无超卖」;分别测「只有 DB 兜底」与「Redis 热路径」两档,记录 QPS 差异。@Scheduled 每 5 分钟)扫 listExpired() 已过期未支付订单调用 closeIfCreated,与 MQ 关单形成双保险;思考两者重复执行时为什么不会出错(幂等)。思考题
阶段:阶段 4 · 实战项目 | 预计阅读 60 min | 代码落地模块:
sample/mini-shop-boot(observe/Dockerfile)+sample/docker-compose.yml前置:第 27–29 章、第 34 章 实战落点:全书验收——第 30 章五条验收标准逐条打钩,交付一个「能上线」的 mini-shop
读完本章,你应该能够:
docker compose up -d 一条命令拉起全家桶并完成上线检查单。链路已在前两章埋好,本章串起来验证:
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 条达成。
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)。
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 方法到可上线服务的全程。
这十条就是「具备参与企业级后端开发能力」的骨架。接下来的成长靠真实项目:把每一条在团队代码库里找到对应物,你就正式入行了。
ps 全绿只是开始,检查单走完才算交付。curl -si 看 X-Trace-Id),再分链路段查——HTTP 段缺失查 TraceIdFilter 的 @Order(-200) 是否生效,MQ 段缺失查消息头 X-Trace-Id 与消费者的 MDC 恢复代码。/actuator/prometheus 需要 micrometer-registry-prometheus 依赖(27 章坑),依赖在而端点 404 再查 exposure 配置。docker compose ps 看哪个 unhealthy → logs 看该服务日志;MySQL 首次启动失败先查挂载的 schema.sql 语法,RabbitMQ 拒连先查用户名(guest 跨容器必被拒)。smoke.sh 进仓库,CI 部署后自动执行——「手动检查」在第三次发布后必然被跳过。docs/,回归时有对照物。docker compose up -d --build → 检查单全绿,能做到才算真正交付。long 金额到今天的幂等回调,每个知识点都在 mini-shop 里找到了位置。恭喜完成全书。附录(90–92)是你的随身工具箱:版本速查、术语表、报错急救——遇到问题先翻它们。
基础题
my-trace-001 完成一次全链路 grep 演示。进阶题
POSTMAN.md(或 OpenAPI 导出):收录登录→下单→支付回调→关单的完整请求集合,作为你的「接口回归清单」。思考题
[!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 原生写法 |
| 你的 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 |
mvn dependency:tree -Dincludes=组名: artifactId,看实际解析到的版本。spring-boot-starter-parent 后,绝大多数依赖不需要写版本号,由 BOM 统一管理——这正是第 17 章要讲的「依赖收口」。[!TIP] 也有例外:MyBatis-Plus、Redisson、jjwt、springdoc 不在 Boot BOM 管理范围内,需要在
dependencyManagement里自己声明版本(sample/父 POM 已统一收口)。
sample/pom.xml 真实内容时(如 17/21/23 章的依赖代码块),版本号以 sample 为准照抄——代码块是「工程快照」而非「版本声明」。sample/pom.xml,正文不动。updated 字段随版本核对更新。| 术语 | 英文 / 缩写 | 一句话解释 | 主章 |
|---|---|---|---|
| 原子性 | 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 |
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) // 只读视图
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 定位)
| 注解 | 用途 | 主章 |
|---|---|---|
@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 |
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))
用法:先按「症状」定位行,读「根因」,再按「解决」动手。排错方法论:现象 → 范围 → 证据 → 假设 → 验证(一次只改一个变量)。
| 症状 | 根因 | 解决 |
|---|---|---|
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 |
| 症状 | 根因 | 解决 |
|---|---|---|
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 章) |
| 症状 | 根因 | 解决 |
|---|---|---|
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 章) |
Q1:学完本书能达到什么水平? 独立完成「从 0 到 1 + 生产化」的中型后端模块;能读懂企业项目的分层与中间件代码;面试高频考点(集合/并发/JVM/事务/缓存/MQ)有实战支撑。
Q2:下一步学什么?
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。
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 附录随时查
| 你的情况 | 从这里开始 |
|---|---|
| 会 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 |
| 文件 | 标题 | 关键词 |
|---|---|---|
| 00-environment-setup.md | 开发环境搭建 | JDK 21、IDEA、Maven、Docker Compose、jshell |
| 文件 | 标题 | 关键词 |
|---|---|---|
| 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、断言、参数化 |
| 文件 | 标题 | 关键词 |
|---|---|---|
| 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、摘要签名 |
| 文件 | 标题 | 关键词 |
|---|---|---|
| 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 |
| 文件 | 标题 | 关键词 |
|---|---|---|
| 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 | 常见报错速查与延伸阅读 |
学习目标 → 核心概念 → 示例代码 → 常见坑 ⚠️ → 调试技巧 → 最佳实践 → 本章小结 → 动手练习
// sample/mini-shop-boot/src/main/java/.../OrderService.java。> [!TIP],警告用 > [!WARNING]。<details> 折叠块承载,不占主线篇幅。一切版本号以 附录 90 与工程内 sample/pom.xml 为准,本目录正文不锁死小版本。
《系统化 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 生产化版本 |
java -version 确认 21+(版本基线见 ../docs/90-appendix-tech-stack-versions.md)。~/.m2/settings.xml)。pom.xml 的 <properties>;若依赖拉取失败,去 Maven Central 核对该坐标的最新版本(正文不锁小版本,这里也是示例值)。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
minishop/minishop123(guest 只允许 localhost,跨容器必被拒);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 仓库浏览 →