这个工程把 HTTP、数据库事务、幂等与对象授权放在同一条可执行流程里。它使用 Java 21、Spring Boot 3.5.16、JDBC 与内存 H2,方便本机直接测试;不是已经部署的业务系统。数据库产品的锁语义、真正消息转发和持久恢复需要另外验收。

运行与检查

下载 reservation-lab.zip,在解压目录运行:

mvn test
mvn package
java -jar target/reservation-lab-1.0.0.jar

需要 JDK 21 与 Maven 3.6.3 或更高版本,首次构建需下载依赖。默认监听 127.0.0.1:8080;H2 数据随进程结束消失。源码的 src/main/resources/schema.sql 建表并放入 book-1 的 5 份库存。

演示用户 alice 与 bob 使用密码 local-demo-only,它们只用于本机学习。服务保留 CSRF,写请求需要先取 token。压缩包内 smoke.ps1 会先获得 token 和会话,再创建预约、重复提交并读取结果:

pwsh -File ./smoke.ps1

没有 PowerShell 7 时,可根据脚本用自己的 HTTP 客户端执行相同请求。不要把演示账号和内存数据库当作公网认证与持久化方案。

数据流与幂等竞争

Controller 从经过验证的 principal 取得 owner,把请求交给容器管理的事务 Service。一次成功事务插入预约、条件扣减库存、写 outbox 三步一起提交。

规则实现位置
数量合法、SKU 和请求键有界Bean Validation 与请求键校验
同一用户同键只创建一次reservation 的复合唯一约束
库存不能扣成负数带 stock >= quantity 的 UPDATE 和 CHECK
同键换参数不能冒充重试原预约与新 SKU、quantity 比较
不读取他人预约SELECT 同时限定 id 与 owner_name
业务事件随预约原子落库同一事务写 outbox

先查询是否已有记录可以减少常见重试成本,但不是并发保证。两个首次请求同时到达时,唯一约束决定谁能插入。失败者的事务退出后,再在事务外读取获胜记录,核对参数并返回它;不能在已失败的事务里继续执行查询。

首次创建返回 201 和 Location,相同参数重放返回 200 与同一 ID,参数冲突或库存不足返回 409。不同用户可以使用相同键,因为键的作用域包含身份。若把所有用户共用一个全局键空间,会引入相互干扰与信息泄露风险。

事务保护到哪里为止

库存不足时,先插入的预约会随事务回滚,不能留下空预约或 outbox。事件只写进数据库,还没有发布到 RabbitMQ 或 Kafka;即使未来加转发器,仍需处理重复发送与消费幂等。

H2 集成测试能检查本工程的事务路径和约束调用,不代表已经验证 PostgreSQL 的执行计划与 MVCC。迁移时要固定目标数据库版本,运行事务双会话实验,用真实驱动重做并发和回滚测试,再设计持久数据迁移。

测试与失败案例

测试覆盖:未登录返回 401、缺 CSRF 返回 403、合法创建与重试、同键换参数冲突、库存不足全部回滚、未知商品返回 404、他人读取返回 404,以及并发同键只产生一条预约、一笔扣减和一条 outbox。两个不同请求各预约 4 份、库存只有 5 份时,也只有一个成功。检查最终行数和库存,避免接口看似成功但数据已经重复。

故意去掉 @Transactional,再跑回滚用例,会暴露“库存不足仍留下预约”的问题。故意去掉 owner 条件,再跑跨用户读取用例,会暴露对象级越权。改回后所有测试应恢复通过。

扩展练习与完成标准

将 H2 替换为 PostgreSQL、用 Flyway 管理迁移、增加真实数据库集成测试。给 outbox 转发器加入稳定事件 ID、认领与失败恢复,重启消费者验证重复投递不会重复业务更新。增加取消预约时,先写允许的状态转换,避免重复取消导致库存加回多次。

达到工程进阶还需要交付与观测中的指标、追踪、受控压测和隔离恢复记录;这个小工程不声称已经验证这些生产能力。

面试自查

  • 为什么需要唯一约束,而不是只在 Java 中先查后插?冲突后为什么离开原事务再查询?
  • 库存扣减成功但 outbox 插入失败时,最终状态应怎样?
  • 如果客户端在提交后断开,凭什么识别重试而不是第二次预约?

参考资料