【金仓数据库征文】从“能下单”到“不超卖”:Spring Boot + KingbaseES 事务订单接口实战

本文记录一次本地模拟实验。项目使用 Spring Boot 3.3.5、Spring JDBC 与 KingbaseES V9,围绕正常下单、事务回滚、请求幂等和并发扣库存逐项验证。文中的接口返回、SQL 查询和并发统计均来自同一套实际运行环境。

一次从"库存怎么少了一件"开始的复查

8 月 9 日上午,我准备给订单接口做一轮完整留证。数据库和表已经建好,单元测试也通过了,照理说接下来只需启动应用、依次运行脚本即可。实际过程没有这么顺。

第一次执行正常下单脚本时,请求返回了 201,订单也创建成功,但剩余库存是 97,而脚本期望的是 98。往前追查才发现,真实数据库集成测试结束后,商品 1001 的库存停在 99;正常下单又扣了 2 件,所以 97 并不是数据库算错,而是实验起点已经变化。如果只盯着"HTTP 201"就截图,这个偏差很容易被忽略。

这次复查改变了后面的做法:每种场景单独留证,开始前都恢复库存、版本号和交易数据;接口输出之后,还要回到 KingbaseES 查询订单、明细、库存流水。文章不把一次成功请求当成结论,而是尽量让结论能从数据库状态中重新算出来。

实验环境与边界

实验在本机完成,KingbaseES 监听 127.0.0.1:54321,使用独立数据库 spring_order_lab 和模式 order_lab。这样做是为了把征文实验与机器上已有的其他库隔离开。应用禁止自动初始化数据库,spring.sql.init.mode=never,所有建模和重置 SQL 都在 KStudio 中手动执行。

项目 实际配置
数据库 KingbaseES V009R001C010
数据库 / 模式 spring_order_lab / order_lab
应用框架 Spring Boot 3.3.5、Spring JDBC
编译目标 Java 17 字节码,本机使用 JDK 24.0.1 运行
JDBC 驱动 com.kingbase8.Driver
连接池 HikariCP,最大连接数 10
实验入口 HTTP 8080、PowerShell 7 脚本
数据访问约束 不使用 JPA、Hibernate、MyBatis、Redis 或消息队列

新建连接时,我先在 KStudio 中做连接测试,再用 current_database() 核对实际连接目标。这里曾经出现过一个容易混淆的情况:连接名称已经改成 spring_order_lab,实际数据库仍是默认的 test。连接名称只是客户端标签,不能代替数据库端的确认。

模式创建后,我同时查询当前数据库、当前用户、当前模式和 search_path。结果为 spring_order_labsystemorder_laborder_lab, public,之后才继续建表。

sql 复制代码
CREATE SCHEMA IF NOT EXISTS order_lab AUTHORIZATION system;
SET search_path TO order_lab, public;

SELECT current_database(), current_user, current_schema(),
       current_setting('search_path');

五张表不是为了"凑模型"

这个实验只保留一条订单链路:商品、库存、订单、订单明细和库存流水。模型不大,但每张表都对应一个需要复核的问题。

products 保存商品与价格;product_stock 保存可用库存和版本号;orders 通过 request_no 唯一约束承担幂等仲裁;order_items 固化下单时的数量和价格;inventory_logs 记录每一次成功扣减的 stock_beforestock_after。订单、明细和流水的 ID 来自三个显式序列,Java 代码通过 nextval 取号,不依赖 ORM 生成主键。

库存表额外设置了检查约束:

sql 复制代码
CREATE TABLE order_lab.product_stock (
    product_id      BIGINT PRIMARY KEY,
    available_stock INTEGER NOT NULL,
    version         INTEGER NOT NULL DEFAULT 0,
    updated_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CHECK (available_stock >= 0),
    CONSTRAINT fk_stock_product FOREIGN KEY (product_id)
        REFERENCES order_lab.products(id)
);

脚本执行后,information_schema.tables 返回 5 张业务表。这里的重点不只是"表存在",而是它们全部落在 order_lab,没有散落到 public 或其他实验模式。

初始化数据只放两个商品。1001 的库存为 100,用于正常、幂等和回滚;2001 的库存为 20,用于并发抢购。初始化脚本使用 ON CONFLICT,重复执行时把数据恢复到实验基线,而不是继续插入重复记录。

订单事务里真正需要一起成功的十步

事务入口放在 OrderTransactionService,方法使用:

java 复制代码
@Transactional(rollbackFor = Exception.class)
public CreatedOrder createOrderInTransaction(CreateOrderCommand command) {
    // 取订单ID、查商品、插入CREATING订单
    // 锁库存、条件扣减、写明细和流水、推进为CREATED
}

具体顺序是:先从序列取得订单 ID,读取商品价格,插入状态为 CREATING 的订单;随后锁定库存行,执行条件扣减;扣减成功后写订单明细和库存流水,最后把状态推进为 CREATED。任何一步抛出异常,整个方法回滚。

这里先写 CREATING 并不是为了让外部看到中间状态。它与后续扣库存处在同一个事务里,其他会话不会把未提交状态当作完整订单。它的作用之一,是让 request_no 唯一约束尽早参与仲裁。如果事务失败,CREATING 记录也会一起消失;最终核验中没有发现残留状态。

在运行场景脚本之前,我先执行了 18 项测试,其中 4 项连接真实 KingbaseES,覆盖正常下单、幂等、模拟异常和 50 请求并发场景。最终统计为 18 项通过、0 失败、0 错误、0 跳过。

应用启动后还单独检查了数据库信息接口。返回的数据库是 spring_order_lab,模式是 order_lab,版本为 KingbaseES V009R001C010。这一步看起来简单,却能防止应用使用了另一套 URL 或环境变量。

正常下单:接口成功还不够

重置基线后,使用请求号 REQ-NORMAL-0001 购买商品 1001 两件。接口返回 HTTP 201,订单状态为 CREATED,库存由 100 变为 98,version 从 0 变为 1。

随后在 KStudio 联查订单、明细、库存和流水。订单金额为 3999.00 × 2 = 7998.00;库存流水的 change_quantity=-2stock_before=100stock_after=98。这组记录能把接口中的"剩余98"还原为一次具体扣减,而不是仅依赖应用打印的 PASS。

sql 复制代码
SELECT o.request_no, o.status, o.quantity, o.total_amount,
       s.available_stock, s.version,
       l.change_quantity, l.stock_before, l.stock_after
FROM order_lab.orders o
JOIN order_lab.product_stock s ON s.product_id = o.product_id
JOIN order_lab.inventory_logs l ON l.order_id = o.id
WHERE o.request_no = 'REQ-NORMAL-0001';

幂等不是在Controller里加一个if

顺序重复请求的第一层处理,是按 requestNo 查询已有订单,命中后直接返回,不再扣库存。真正兜底的是数据库唯一约束:

sql 复制代码
request_no VARCHAR(64) NOT NULL UNIQUE

如果两个请求同时通过了应用层的"未查到"判断,最终仍只能有一个事务插入该请求号。应用捕获 DuplicateKeyException 后重新查询已存在订单,将其作为重复响应返回。也就是说,应用层预查减少无谓写入,数据库约束负责最后仲裁,两者职责不同。

本轮留证使用同一个 REQ-IDEMPOTENT-0001 连续调用 10 次。第 1 次返回 201,后 9 次返回 200 和 duplicate=true;库存只从 100 降到 99。数据库中订单、明细、流水各 1 条,version 也只增加 1。

这项结果验证的是顺序重试。生产环境若要覆盖"同一请求号完全并发"的极端竞态,捕获唯一键冲突后还应增加短暂重试或回查等待,避免获胜事务尚未提交时立即查询。这一点没有用现有实验结果去扩大结论。

在扣完库存之后主动失败

回滚实验启用单独的 lab profile。请求参数 simulateFailure=true 时,程序在条件扣减成功后、写明细和流水之前主动抛出异常。这个位置比"进入方法就抛异常"更有意义,因为数据库已经执行过更新语句,能检验更新是否真的随事务撤销。

java 复制代码
int affected = stockRepository.deductStock(productId, quantity);
if (affected == 0) {
    throw new InsufficientStockException(productId, quantity);
}
if (command.simulateFailure()) {
    throw new SimulatedFailureException();
}

实际请求返回 HTTP 500 和 SIMULATED_FAILURE。失败前后库存都为 100,version 都为 0。

数据库复核中,请求号对应的订单数和流水数都是 0,库存仍为 100。序列取出的值不要求回滚连续,这是数据库序列的正常特性;业务上需要确认的是订单、明细、库存和流水没有出现半套数据。

防超卖的核心是一条带条件的UPDATE

库存扣减没有采用"先普通查询、Java 判断、再无条件更新"的写法。关键 SQL 把库存判断放进更新条件:

sql 复制代码
UPDATE order_lab.product_stock
SET available_stock = available_stock - :quantity,
    version = version + 1,
    updated_at = CURRENT_TIMESTAMP
WHERE product_id = :productId
  AND available_stock >= :quantity;

影响行数为 1,说明本次扣减成功;影响行数为 0,则按库存不足处理。available_stock >= :quantity 与更新在同一条语句中完成,不给两个线程留下"都读到还有库存、然后都去扣"的空隙。

事务中还使用 SELECT ... FOR UPDATE 锁定商品库存行。它让 stock_before 的读取和随后的扣减处在稳定的行级顺序中,便于写出可信的 before/after 流水。条件 UPDATE 是不穿底的硬边界,CHECK (available_stock >= 0) 则是表结构上的最后一道防线。

本次并发脚本一次提交 50 个请求,PowerShell 并发上限为 20,应用连接池最大连接数为 10。商品 2001 的初始库存为 20,每个请求购买 1 件,并使用不同的 requestNo

终端统计为:20 个请求返回 201,30 个请求返回库存不足的 409,其他失败为 0,最终库存为 0。

数据库查询得到 20 张订单、20 条明细、20 条库存流水;available_stock=0version=20,负库存记录数为 0。库存流水中最小 stock_after 为 0,最大 stock_before 为 20,没有出现负值。

从订单 created_at 看,20 个成功订单的时间分布在 10:44:10.85673510:44:11.177144 之间,跨度约 0.32 秒。这个数只能说明成功订单落库时间比较集中,不能直接当作接口吞吐量或压测耗时;脚本还包含客户端调度、HTTP 和连接池等待,若做性能结论需要单独的压测工具和多轮统计。

最终应用层检查显示商品 2001 库存为 0、version 为 20,并能按请求号查到状态为 CREATED 的并发订单。

实验中真正返工的几处

这套项目最后的脚本并不是第一次就全部跑通。除了开头提到的库存基线,还有几处问题值得保留。

数据库进程最初以前台方式启动,关闭 PowerShell 后,KStudio 报 08006,SQL 看起来像语法错误,实际是 54321 已经没有进程监听。后来改为保持数据库窗口运行,并在每轮操作前先查连接状态。

Windows PowerShell 5.1 将无 BOM 的 UTF-8 中文脚本按错误编码解析,表现为字符串缺少终止符和 try 缺少 catch。换成 PowerShell 7 后,同一脚本不需要修改即可执行。

重置脚本也改过一次。最初依次 TRUNCATE order_itemsinventory_logsorders,KingbaseES 仍拒绝单独截断被外键引用的 orders。最终把三张表放进同一条语句:

sql 复制代码
TRUNCATE TABLE
    order_lab.order_items,
    order_lab.inventory_logs,
    order_lab.orders;

此外,spring-boot:run 在本机环境中启动子进程时出现主类找不到,但编译产物中的 class 文件实际存在。最后使用 mvn package -DskipTests 生成可执行 JAR,再通过 java -jar ... --spring.profiles.active=lab 启动。对文章来说,这些环境问题不属于核心方案;对复现实验的人来说,它们往往比业务代码更早出现。

结果汇总与适用范围

场景 实际结果 数据库复核
正常下单 HTTP 201,库存100→98 订单/明细/流水各1条,流水100→98
同号重试10次 1次201、9次200,库存只减1 订单/明细/流水各1条,version=1
扣减后模拟异常 HTTP 500 订单0、流水0、库存100、version=0
50请求抢20件 成功20、不足30、其他0 订单/明细/流水各20,库存0,负库存0
自动化测试 18项通过 失败0、错误0、跳过0

这个方案适合单库事务内的库存扣减。它没有覆盖跨库订单、分布式事务、热点商品拆分,也没有把一次本地并发实验包装成性能基准。FOR UPDATE 会把同一库存行上的写请求串行化,正确性清楚,但热点越集中,锁等待越明显。实际系统还需要结合超时、重试、监控、限流,以及业务对吞吐和一致性的取舍。

这次实验最有价值的不是终端最后出现了一个 PASS,而是每个 PASS 后面都有另一种检查方式:接口返回之后查数据库,异常之后查残留,并发统计之后再数订单、流水和负库存。订单接口从"能下单"走到"不超卖",靠的不是某一个注解或某一把锁,而是事务边界、条件更新、唯一约束、表级检查和复核方法共同起作用。

相关推荐
爱看报的猿15 天前
【金仓数据库征文】MySQL至金仓KES异构数据库平滑迁移与性能深度调优实战
数据库·数据仓库·mysql·金仓数据库征文
正在走向自律15 天前
【金仓数据库征文】从 MySQL 迁移到金仓数据库:哈工大智能造价项目的一次信创改造实践
数据库·mysql·性能优化·国产数据库·数据库迁移·信创适配·金仓数据库征文
Lethehong21 天前
【金仓数据库征文】AI 直连国产数据库——KES MCP Server 自然语言查库与 SQL 调优实战
数据库·人工智能·sql·金仓数据库征文