GitHub仓库:github.com/2530622506/…
01|(前端转全栈)前端人第一次打开 Spring Boot 项目,应该先看哪里?
02|(前端转全栈)从 pnpm dev 到 Spring Boot 启动:后端服务到底怎么跑起来?
03|(前端转全栈)Axios 请求进了后端之后:Controller、Request、Response 是怎么接住它的?
04|(前端转全栈)前端状态为什么不够用?从页面数据到 MySQL 持久化
05|(前端转全栈)不手写一堆 SQL,后端怎么操作数据库?MyBatis-Plus 入门
06|(前端转全栈)登录后端到底在做什么?JWT、Spring Security 和权限链路
07|(前端转全栈)为什么后端也要缓存?从前端缓存思维理解 Redis
08|(前端转全栈)一个商品详情接口背后的完整链路:HTTP、Redis、MySQL 与 JSON
09|(前端转全栈)商品为什么不能随便上下架?后端状态机思维入门
本篇继续写给“已经会前端、正在转后端”的你。前面第 9 篇讲了商品 SPU 的创建、上架、下架和状态机,本篇进入 SKU、价格、库存和并发安全。本文出现的 Vue / TypeScript / Axios 片段全部是“前端侧示意代码”,只用于类比理解;真实工程依据当前仓库
backend/代码。
1. 这篇解决什么问题
前端同学做电商页面时,经常会接触“商品详情”“规格选择”“库存不足”“加入购物车”“立即购买”这些功能。页面上你可能会写:选择颜色、选择容量、展示价格、剩余库存为 0 时禁用按钮、提交时防重复点击。站在前端视角,这些逻辑已经很完整。但站在后端视角,最关键的问题不是“按钮是否禁用”,而是“数据库里的库存是否永远不会被错误扣成负数”“两个人同时操作时是否只允许一个成功”“价格和库存被别人改过以后,旧页面提交的数据会不会覆盖新数据”。
本篇要解决 7 个核心问题:
- 什么是 SPU,什么是 SKU,它们在本项目中分别落到哪张表;
- 为什么金额字段使用
BigDecimal,不能像前端一样随便用number理解; - SKU 创建时,哪些字段可以由前端提交,哪些字段必须由后端维护;
- 为什么已上架商品不能随便新增 SKU;
- 什么是乐观锁
version,为什么价格和库存调整都需要expectedVersion; - 为什么库存调整必须用单条 SQL 原子更新,不能先查库存再更新;
available_stock、locked_stock、version三个字段如何为后续订单和支付做准备。
读完本篇,你应该能独立解释这些现象:创建 SKU 时 lockedStock 为什么固定为 0;salePrice=12.345 为什么会被参数校验拒绝;两个管理员拿着同一个旧版本同时改价格,为什么只能一个成功;后台把库存从 2 增加 3 后版本为什么从 0 变成 1;继续扣减 6 个库存为什么返回 INSUFFICIENT_STOCK;订单创建时为什么不能只先查库存再扣库存。
2. 用前端知识类比
先看一个常见的前端侧示意代码。商品详情页有规格列表,用户选择一个 SKU 后,页面展示对应价格和库存:
// 前端侧示意代码:商品详情页选择 SKU
interface SkuViewModel {
id: number
skuCode: string
specText: string
salePrice: string
availableStock: number
lockedStock: number
version: number
}
function canBuy(sku: SkuViewModel, quantity: number) {
return sku.availableStock >= quantity
}
如果 availableStock 小于购买数量,前端可以禁用“立即购买”按钮:
// 前端侧示意代码:禁用按钮只是体验优化
const disabled = selectedSku.availableStock <= 0 || submitting
这个逻辑对用户体验很重要,但它不能保证库存安全。原因有两个。第一,前端数据是旧快照。用户打开页面时看到库存 1,但另一个用户可能已经先下单了。第二,前端请求可以被绕过。用户可以手动发请求,甚至发送一个页面上本来不允许的购买数量。
所以后端必须在数据库层做最终判断。真正可靠的库存扣减不是:先查 available_stock,在 Java 里判断够不够,再执行 update;而是把“库存足够”和“扣减库存”放到同一条 SQL 里完成。这样数据库会保证同一行更新的原子性,多个并发请求不会同时把同一个库存卖出去。
flowchart LR
A[前端展示库存\n旧快照] --> B[用户点击购买]
B --> C[后端收到请求]
C --> D{数据库单条 SQL 判断库存是否足够}
D -- 足够 --> E[扣减 available_stock\n增加 locked_stock]
D -- 不足 --> F[返回库存不足]
E --> G[返回成功]
你可以把 version 类比成前端状态快照的版本号。前端读到 SKU 时拿到 version=0,提交改价或改库存时也带上 expectedVersion=0。如果数据库当前版本仍然是 0,说明这期间没人改过,更新可以成功;如果数据库已经变成 1,说明别人先改了,后端拒绝你的旧提交,让你刷新后重试。这和前端处理表单冲突很像:你打开编辑页后,同事已经修改保存了,你的旧表单不能无脑覆盖同事的新数据。
3. 后端核心概念讲解
3.1 SPU 和 SKU
SPU 可以理解为“商品大类”,SKU 可以理解为“可售规格”。例如“iPhone 15”是一个商品,黑色 128G、蓝色 256G 是不同 SKU;“一本书”可能只有一个 SKU;“一件衣服”可能按颜色和尺码拆成多个 SKU。
当前项目中:
| 概念 | 表 | 主要字段 | 作用 |
|---|---|---|---|
| SPU 商品 | mall_product | id、category_id、title、status_code | 管理商品生命周期和公开可见性 |
| SKU 规格 | mall_product_sku | product_id、sku_code、spec_text、sale_price、available_stock、locked_stock、version | 管理价格、规格、库存和并发 |
第 9 篇讲的上架条件里,SkuDbService.requireSaleableSku 要求商品至少有一个 sale_price > 0 且 available_stock > 0 的 SKU。也就是说,SPU 负责“这个商品能不能展示”,SKU 负责“这个规格能不能卖”。
classDiagram
class mall_product {
id
category_id
title
status_code
created_by
}
class mall_product_sku {
id
product_id
sku_code
spec_text
sale_price
available_stock
locked_stock
version
}
mall_product "1" --> "0..n" mall_product_sku : 一个商品有多个 SKU
3.2 金额为什么用 BigDecimal
前端里 number 是 JavaScript 的双精度浮点数,写页面展示时很方便,但它不适合直接表达严肃金额。后端金额字段使用 Java 的 BigDecimal,数据库字段使用 DECIMAL(12,2),目的是保证十进制金额精确。比如 0.1 + 0.2 在 JavaScript 里会出现浮点精度问题,金额系统不能接受这种误差。
当前项目的 SkuCreateRequest.salePrice 和 SkuPriceUpdateRequest.salePrice 都是 BigDecimal,并且有校验:价格不能为空,必须大于 0,最多 10 位整数和 2 位小数。SkuFacade.normalizePrice 还会用 setScale(2, RoundingMode.UNNECESSARY) 要求价格必须已经是两位小数以内,不能偷偷传 12.345。
前端侧示意代码可以把金额当字符串处理,提交给后端:
// 前端侧示意代码:金额输入最好按字符串处理并限制两位小数
interface SkuCreatePayload {
skuCode: string
specText: string
salePrice: string
initialStock: number
}
3.3 可售库存、锁定库存和版本号
available_stock 表示还可以被新订单占用的库存。locked_stock 表示已经被待支付订单占用、但还没有最终成交的库存。version 是乐观锁版本号,用于识别并发修改。
为什么要有锁定库存?假设用户提交订单后还没支付,如果后端直接把库存永久扣掉,用户不支付时就要恢复;如果完全不扣,其他用户又可能继续下单造成超卖。所以常见做法是:创建订单时把可售库存转成锁定库存;支付成功时确认售出,只减少锁定库存;取消或超时关单时释放锁定库存,把它加回可售库存。
stateDiagram-v2
[*] --> Available: 商品创建 SKU\navailable_stock 初始值
Available --> Locked: 创建订单\nlockStock
Locked --> Sold: 支付成功\nconfirmLockedStock
Locked --> Available: 取消或超时关单\nreleaseLockedStock
本篇重点讲后台 SKU 管理中的价格和库存调整,订单里的锁定库存会在第 12、13 篇继续展开。但你现在要先建立一个观念:库存不是一个简单数字,它会随着订单状态在“可售、锁定、售出”之间移动。
3.4 乐观锁是什么
乐观锁的核心假设是:大多数时候不会发生冲突,所以不提前锁住数据;等真正更新时再检查版本。如果版本没变,就更新并把版本加 1;如果版本变了,就说明别人先更新过,本次更新失败。
它和前端编辑表单的版本冲突很像。你打开 SKU 编辑弹窗时读到:价格 100,库存 2,版本 0。另一个管理员也打开同一个 SKU,并先把价格改成 88.50,数据库版本变成 1。你还拿着版本 0 去提交价格 77.00,后端看到 WHERE version = 0 匹配不到当前行,就拒绝并返回 INVENTORY_VERSION_CONFLICT。
sequenceDiagram
participant A as 管理员 A 页面
participant B as 管理员 B 页面
participant API as 后端
participant DB as mall_product_sku
A->>API: 读取 SKU,version=0
B->>API: 读取 SKU,version=0
A->>API: PATCH price expectedVersion=0
API->>DB: UPDATE ... WHERE version=0
DB-->>API: 影响 1 行,version 变 1
API-->>A: 成功
B->>API: PATCH price expectedVersion=0
API->>DB: UPDATE ... WHERE version=0
DB-->>API: 影响 0 行
API-->>B: INVENTORY_VERSION_CONFLICT
乐观锁不是前端防重复点击。防重复点击只能减少同一个浏览器发重复请求,乐观锁能处理多个浏览器、多个管理员、多个服务实例同时更新同一行数据的冲突。
4. 在本项目中对应哪些文件
本篇主要基于这些真实文件:
| 文件 | 作用 |
|---|---|
backend/service/src/main/java/com/example/fullstackmall/service/inventory/SkuAdminController.java | 管理员 SKU 创建、查询、改价、调库存入口 |
backend/service/src/main/java/com/example/fullstackmall/service/inventory/SkuController.java | 公开查询已上架商品 SKU 入口 |
backend/contract/src/main/java/com/example/fullstackmall/contract/inventory/ISkuFacade.java | SKU 用例契约 |
backend/service/src/main/java/com/example/fullstackmall/service/inventory/SkuFacade.java | SKU 业务编排:商品状态、金额规范化、乐观锁和库存冲突解释 |
backend/service/src/main/java/com/example/fullstackmall/service/inventory/service/SkuDbService.java | SKU 数据库服务,封装查询、版本更新、库存锁定等方法 |
backend/service/src/main/java/com/example/fullstackmall/service/inventory/mapper/SkuMapper.java | 并发敏感的单条原子 SQL |
backend/service/src/main/java/com/example/fullstackmall/service/inventory/entity/SkuEntity.java | SKU Entity,包含 @Version 乐观锁字段 |
backend/contract/src/main/java/com/example/fullstackmall/contract/inventory/SkuCreateRequest.java | 创建 SKU 请求校验 |
backend/contract/src/main/java/com/example/fullstackmall/contract/inventory/SkuPriceUpdateRequest.java | 改价请求,包含 expectedVersion |
backend/contract/src/main/java/com/example/fullstackmall/contract/inventory/SkuStockAdjustRequest.java | 调库存请求,包含 delta 和 expectedVersion |
backend/contract/src/main/java/com/example/fullstackmall/contract/inventory/SkuResponse.java | SKU 响应字段 |
backend/service/src/main/java/com/example/fullstackmall/service/config/MybatisPlusConfig.java | 注册 MyBatis-Plus 乐观锁插件和分页插件 |
sql/01_schema.sql | SKU 表结构和约束 |
sql/02_seed.sql | 演示 SKU 数据 |
backend/service/src/test/java/com/example/fullstackmall/service/inventory/SkuControllerTest.java | 验证 HTTP 行为、参数校验、权限、版本冲突、库存不足 |
backend/service/src/test/java/com/example/fullstackmall/service/inventory/SkuConcurrencyTest.java | 验证同版本并发库存调整只能成功一次 |
backend/service/src/test/java/com/example/fullstackmall/service/inventory/SkuMapperTest.java | 验证 Mapper 读取和 MyBatis-Plus 乐观锁 |
5. SKU 管理请求链路图
管理员创建 SKU 链路:
sequenceDiagram
participant UI as 后台 SKU 表单\n前端侧示意
participant Sec as Spring Security
participant C as SkuAdminController
participant F as SkuFacade
participant P as ProductDbService
participant S as SkuDbService
participant DB as MySQL mall_product_sku
UI->>Sec: POST /api/admin/products/{productId}/skus
Sec->>Sec: 校验 ADMIN 权限
Sec->>C: createSku
C->>C: @Valid 校验请求体
C->>F: createSku(productId, request)
F->>P: requireById(productId)
F->>F: 拒绝 ON_SALE 商品新增 SKU
F->>S: existsBySkuCode(trim 后编码)
F->>F: normalizePrice + 初始化库存字段
F->>DB: INSERT mall_product_sku
DB-->>F: id / version=0
F-->>C: SkuResponse
C-->>UI: 201 + ApiResponse
管理员改价和调库存链路:
flowchart TD
A[管理员读取 SKU\n拿到 version] --> B[提交改价或调库存\n带 expectedVersion]
B --> C[SkuAdminController]
C --> D[SkuFacade]
D --> E{参数校验和金额规范化}
E --> F[SkuDbService]
F --> G[SkuMapper 单条 UPDATE]
G --> H{影响行数是否为 1}
H -- 是 --> I[重新读取 SKU 并返回最新 version]
H -- 否 --> J{版本是否已变化}
J -- 是 --> K[INVENTORY_VERSION_CONFLICT]
J -- 否 --> L[库存不足或其他冲突]
公开查询 SKU 链路也很简单,但它有一个关键校验:
sequenceDiagram
participant User as 匿名用户
participant C as SkuController
participant F as SkuFacade
participant P as ProductDbService
participant S as SkuDbService
User->>C: GET /api/products/{productId}/skus
C->>F: queryPublishedSkus(productId)
F->>P: requirePublishedById(productId)
alt 商品是 ON_SALE
F->>S: listByProductId(productId)
S-->>F: SKU 列表
F-->>C: List
else 商品未上架或不存在
P-->>F: PRODUCT_NOT_FOUND
F-->>C: 404
end
6. 逐段读源码
6.1 管理端入口:SkuAdminController
SkuAdminController 的类路径是:
@RestController
@RequestMapping("/api/admin")
@Tag(name = "管理员 SKU 管理")
public class SkuAdminController {
它提供 4 个管理接口:
@PostMapping("/products/{productId}/skus")
public ResponseEntity<ApiResponse<SkuResponse>> createSku(...)
@GetMapping("/products/{productId}/skus")
public ApiResponse<List<SkuResponse>> querySkus(...)
@PatchMapping("/skus/{skuId}/price")
public ApiResponse<SkuResponse> updatePrice(...)
@PatchMapping("/skus/{skuId}/stock")
public ApiResponse<SkuResponse> adjustStock(...)
路径设计表达了资源关系:创建和查询 SKU 时,路径里有 productId,因为 SKU 属于某个商品;改价和调库存时,路径里直接用 skuId,因为操作的是具体 SKU。创建成功返回 201 Created,改价和调库存返回普通成功响应。
Controller 层没有写数据库逻辑,也没有自己判断库存够不够。它只做 HTTP 入口工作:绑定路径参数、绑定请求体、触发 @Valid、调用 skuFacade、包装 ApiResponse 和 traceId。这和前面章节讲过的 Controller 职责保持一致。
6.2 公开入口:SkuController
公开 SKU 查询在另一个 Controller:
@RestController
@RequestMapping("/api/products")
@Tag(name = "公开 SKU")
public class SkuController {
@GetMapping("/{productId}/skus")
public ApiResponse<List<SkuResponse>> queryPublishedSkus(...)
}
公开路径是 /api/products/{productId}/skus。它允许匿名用户查看已上架商品的 SKU,但不能查看草稿商品或下架商品的 SKU。真正的限制在 SkuFacade.queryPublishedSkus:
@Override
public List<SkuResponse> queryPublishedSkus(Long productId) {
productDbService.requirePublishedById(productId);
return toResponses(skuDbService.listByProductId(productId));
}
第一行先确认商品公开可见,第二行才查询 SKU。也就是说,SKU 是否公开不由前端传参决定,而由商品状态决定。这个设计和商品详情保持一致:公开端只能看到 ON_SALE 商品相关数据。
6.3 创建 SKU 请求:SkuCreateRequest
创建 SKU 请求字段如下:
public class SkuCreateRequest {
@NotBlank(message = "SKU 编码不能为空")
@Size(max = 64, message = "SKU 编码不能超过 64 个字符")
private String skuCode;
@NotBlank(message = "规格描述不能为空")
@Size(max = 200, message = "规格描述不能超过 200 个字符")
private String specText;
@NotNull(message = "销售价不能为空")
@DecimalMin(value = "0.01", message = "销售价必须大于 0")
@Digits(integer = 10, fraction = 2, message = "销售价最多 10 位整数和 2 位小数")
private BigDecimal salePrice;
@NotNull(message = "初始库存不能为空")
@Min(value = 0, message = "初始库存不能小于 0")
private Integer initialStock;
}
这里有几个边界很重要。productId 不在请求体里,而来自 URL。这样可以避免 body 里的 productId 和路径里的 productId 不一致。lockedStock 不允许前端传,创建时后端固定为 0。version 不允许前端传,创建时后端固定为 0。创建时间和更新时间也不允许前端传,由服务端维护。
前端侧示意代码:
// 前端侧示意代码:创建 SKU 只提交后端允许的字段
interface SkuCreatePayload {
skuCode: string
specText: string
salePrice: string
initialStock: number
}
async function createSku(productId: number, payload: SkuCreatePayload) {
return request.post(`/api/admin/products/${productId}/skus`, payload)
}
如果你把 lockedStock、version、createdAt 一起传过去,当前后端也不会使用这些字段。后端契约越明确,越不容易让前端误以为自己可以决定服务端内部状态。
6.4 创建 SKU 业务:SkuFacade.createSku
核心代码如下:
@Override
public SkuResponse createSku(Long productId, SkuCreateRequest request) {
ProductEntity product = productDbService.requireById(productId);
ProductStatus status = ProductStatus.valueOf(product.getStatusCode());
if (status == ProductStatus.ON_SALE) {
throw new BusinessException(
ApiCode.PRODUCT_SKU_NOT_EDITABLE,
ApiCode.PRODUCT_SKU_NOT_EDITABLE.defaultMessage()
);
}
String skuCode = request.getSkuCode().trim();
if (skuDbService.existsBySkuCode(skuCode)) {
throw skuCodeAlreadyExists();
}
LocalDateTime now = LocalDateTime.now();
SkuEntity sku = new SkuEntity();
sku.setProductId(productId);
sku.setSkuCode(skuCode);
sku.setSpecText(request.getSpecText().trim());
sku.setSalePrice(normalizePrice(request.getSalePrice()));
sku.setAvailableStock(request.getInitialStock());
sku.setLockedStock(0);
sku.setVersion(0);
sku.setCreatedAt(now);
sku.setUpdatedAt(now);
try {
skuDbService.save(sku);
} catch (DuplicateKeyException exception) {
throw skuCodeAlreadyExists();
}
return toResponse(sku);
}
第一步先查商品是否存在。SKU 必须挂在真实商品下面。第二步判断商品状态,如果商品已经 ON_SALE,就拒绝新增 SKU,返回 PRODUCT_SKU_NOT_EDITABLE。为什么?因为已上架商品面向用户公开,随便新增规格会影响价格、库存和用户购买路径。当前项目把 SKU 维护限制在未上架阶段,降低线上可见数据被随意修改的风险。
第三步 trim skuCode 并先查是否重复。sku_code 是全局唯一业务编码,数据库里还有唯一索引兜底。注意代码注释说“数据库唯一索引负责兜住并发创建,不能只依赖写入前查询”。这是很重要的后端思维:先查重复只能提升错误提示友好度,不能保证并发安全。两个请求可能同时查到“不存在”,然后同时插入;最终必须靠数据库唯一约束让一个成功、一个失败。
第四步初始化 SKU。salePrice 经过 normalizePrice,availableStock 来自初始库存,lockedStock 固定为 0,version 固定为 0。也就是说,创建 SKU 时库存全在可售池,还没有订单占用。
6.5 金额规范化:normalizePrice
SkuFacade.normalizePrice 代码如下:
private BigDecimal normalizePrice(BigDecimal price) {
try {
BigDecimal normalized = price.setScale(2, RoundingMode.UNNECESSARY);
if (normalized.compareTo(BigDecimal.ZERO) <= 0) {
throw invalidPrice();
}
return normalized;
} catch (ArithmeticException exception) {
throw invalidPrice();
}
}
setScale(2, RoundingMode.UNNECESSARY) 的意思是:要求这个金额不需要四舍五入就能表达为两位小数。如果传 12.34,可以;如果传 12.345,需要舍入才能变成两位小数,于是抛异常并转成 VALIDATION_ERROR。这比悄悄四舍五入更安全,因为金额不应该在用户不知道的情况下被后端改掉。
前端侧也应该限制输入两位小数,但后端仍然要校验。前端限制是体验,后端校验是可信边界。
6.6 改价请求:SkuPriceUpdateRequest
改价请求包含新价格和期望版本:
public class SkuPriceUpdateRequest {
@NotNull(message = "销售价不能为空")
@DecimalMin(value = "0.01", message = "销售价必须大于 0")
@Digits(integer = 10, fraction = 2, message = "销售价最多 10 位整数和 2 位小数")
private BigDecimal salePrice;
@NotNull(message = "期望版本不能为空")
@Min(value = 0, message = "期望版本不能小于 0")
private Integer expectedVersion;
}
为什么改价要带 expectedVersion?因为价格是敏感字段,旧页面不能覆盖新价格。如果管理员 A 和管理员 B 同时打开 SKU 编辑页,都看到版本 0。A 先保存,版本变 1;B 再保存时仍带版本 0,后端必须拒绝。否则 B 的旧表单会覆盖 A 刚刚保存的新价格。
6.7 改价 SQL:updatePriceByVersion
Facade 调用:
int updated = skuDbService.updatePriceByVersion(skuId, price, request.getExpectedVersion());
if (updated == 0) {
explainVersionUpdateFailure(skuId, request.getExpectedVersion());
}
return toResponse(skuDbService.requireById(skuId));
底层 Mapper 是单条 SQL:
@Update("""
UPDATE mall_product_sku
SET sale_price = #{salePrice},
version = version + 1,
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{skuId}
AND version = #{expectedVersion}
""")
int updatePriceByVersion(...);
关键在 WHERE version = #{expectedVersion}。如果当前版本不等于前端提交的期望版本,数据库影响行数就是 0。后端根据影响行数判断冲突,而不是假设 update 一定成功。
SkuControllerTest.shouldUpdatePriceWithVersionAndRejectStaleVersion 验证了这个行为:第一次用 expectedVersion=0 改价成功,版本变 1;第二次仍用旧版本 0 改价,返回 INVENTORY_VERSION_CONFLICT,数据库价格保持第一次更新后的 88.50。
6.8 调库存请求:SkuStockAdjustRequest
调库存请求包含 delta 和 expectedVersion:
public class SkuStockAdjustRequest {
@NotNull(message = "库存变化量不能为空")
private Integer delta;
@NotNull(message = "期望版本不能为空")
@Min(value = 0, message = "期望版本不能小于 0")
private Integer expectedVersion;
@AssertTrue(message = "库存变化量不能为 0")
public boolean isDeltaNonZero() {
return delta == null || delta != 0;
}
}
delta 为正表示增加库存,为负表示减少库存。比如 delta=3 表示入库 3 件,delta=-2 表示扣减 2 件。delta=0 没有业务意义,会被 @AssertTrue 拒绝。
前端侧示意代码:
// 前端侧示意代码:库存调整要带上上次读取到的 version
async function adjustStock(sku: SkuViewModel, delta: number) {
return request.patch(`/api/admin/skus/${sku.id}/stock`, {
delta,
expectedVersion: sku.version,
})
}
这里再次强调:sku.version 是前端上次读取到的快照版本,不代表数据库当前一定还是这个版本。后端会用它做乐观锁判断。
6.9 调库存 SQL:adjustAvailableStockByVersion
Facade 调用:
int updated = skuDbService.adjustAvailableStockByVersion(
skuId,
request.getDelta(),
request.getExpectedVersion()
);
if (updated == 0) {
explainStockUpdateFailure(skuId, request.getDelta(), request.getExpectedVersion());
}
return toResponse(skuDbService.requireById(skuId));
Mapper SQL:
@Update("""
UPDATE mall_product_sku
SET available_stock = available_stock + #{delta},
version = version + 1,
updated_at = CURRENT_TIMESTAMP(3)
WHERE id = #{skuId}
AND version = #{expectedVersion}
AND available_stock + #{delta} >= 0
""")
int adjustAvailableStockByVersion(...);
这条 SQL 同时做了 3 件事:第一,检查 SKU ID;第二,检查版本是否匹配;第三,检查调整后的可售库存不能小于 0。只有全部满足,才会更新 available_stock、递增 version、刷新 updated_at。
为什么不能先查后改?假设库存是 1,有两个请求同时扣 1。如果两个请求都先查,都会看到库存足够;然后都执行 update,库存可能被扣成 -1,或者发生覆盖。单条 SQL 把判断和写入合在一起,由数据库保证并发下只有满足条件的更新成功。
sequenceDiagram
participant R1 as 请求 1
participant R2 as 请求 2
participant DB as MySQL 行锁 + UPDATE 条件
R1->>DB: UPDATE stock = stock - 1\nWHERE version=0 AND stock-1>=0
R2->>DB: UPDATE stock = stock - 1\nWHERE version=0 AND stock-1>=0
DB-->>R1: 影响 1 行\nstock=0 version=1
DB-->>R2: 影响 0 行\nversion 不匹配或库存不足
SkuConcurrencyTest.shouldAllowOnlyOneUpdateForTheSameExpectedVersion 就是并发证明。它创建一个库存为 1、版本为 0 的 SKU,然后用两个线程同时调用 adjustAvailableStockByVersion(skuId, -1, 0)。最后断言两个请求影响行数之和等于 1,库存为 0,版本为 1,库存没有变成负数。
6.10 冲突解释:版本冲突还是库存不足
当 SQL 影响行数为 0 时,可能有不同原因:SKU 不存在、版本已变化、库存不足。SkuFacade.explainStockUpdateFailure 会再读取当前 SKU,判断具体原因:
private void explainStockUpdateFailure(Long skuId, Integer delta, Integer expectedVersion) {
SkuEntity current = skuDbService.requireById(skuId);
if (!current.getVersion().equals(expectedVersion)) {
throw versionConflict();
}
long remaining = (long) current.getAvailableStock() + delta;
if (remaining < 0) {
throw new BusinessException(
ApiCode.INSUFFICIENT_STOCK,
ApiCode.INSUFFICIENT_STOCK.defaultMessage()
);
}
throw versionConflict();
}
这段代码的价值是给前端更清晰的错误 code。如果版本不一致,前端应该提示“数据已变化,请刷新后重试”;如果库存不足,前端应该提示“库存不足”。虽然这一步是二次读取,可能仍然受到并发影响,但它只用于解释失败原因,真正的安全性已经由前面的单条 SQL 保证。
前端侧示意处理:
// 前端侧示意代码:根据业务 code 给出不同提示
if (error.code === 'INVENTORY_VERSION_CONFLICT') {
toast('库存数据已被其他请求修改,请刷新后重试')
}
if (error.code === 'INSUFFICIENT_STOCK') {
toast('可售库存不足')
}
6.11 Entity 和 MyBatis-Plus 乐观锁插件
SkuEntity 中有:
@TableName("mall_product_sku")
public class SkuEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("sale_price")
private BigDecimal salePrice;
@TableField("available_stock")
private Integer availableStock;
@TableField("locked_stock")
private Integer lockedStock;
@Version
private Integer version;
}
@Version 是 MyBatis-Plus 乐观锁注解。项目在 MybatisPlusConfig 中注册了 OptimisticLockerInnerInterceptor:
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
SkuMapperTest.shouldApplyOptimisticLockerWhenUsingUpdateById 证明了使用 MyBatis-Plus updateById 时,旧版本更新会失败。与此同时,本项目对库存和价格这种并发敏感操作还写了明确的 @Update SQL。你可以这样理解:MyBatis-Plus 乐观锁插件是通用保护,而手写 SQL 是对关键库存语义的精确表达,尤其是 available_stock + delta >= 0 这种业务条件必须写进 SQL。
6.12 订单相关的库存方法预告
SkuMapper 里还有 3 个方法,本篇先建立概念:
int lockStock(Long skuId, Integer quantity);
int releaseLockedStock(Long skuId, Integer quantity);
int confirmLockedStock(Long skuId, Integer quantity);
对应 SQL 语义是:
| 方法 | 发生场景 | 可售库存 | 锁定库存 |
|---|---|---|---|
lockStock | 创建订单 | 减少 | 增加 |
releaseLockedStock | 取消或超时关单 | 增加 | 减少 |
confirmLockedStock | 支付成功 | 不变 | 减少 |
这就是电商库存常见的三段式。创建订单时先锁住,避免别人买走;取消订单时释放;支付成功时确认售出。第 12、13 篇会把它和订单事务、支付回调、幂等结合起来。现在你只要记住:库存不是页面上的一个数字,而是订单状态流转中的资源账户。
7. 本地运行 / curl 验证
7.1 登录管理员
curl -sS -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"Admin123456"}'
复制响应中的 accessToken:
export ADMIN_TOKEN='<复制 accessToken>'
7.2 查询公开 SKU
1 号商品是已上架商品,可以公开查询 SKU:
curl -i http://localhost:8080/api/products/1/skus \
-H 'X-Trace-Id: sku-public-001'
你应该看到 IPHONE15-BLACK-128G,价格 4599.00,可售库存 10,锁定库存 0,版本 0。
2 号商品种子数据是草稿,如果还没有被你在第 9 篇上架,公开查询应该 404:
curl -i http://localhost:8080/api/products/2/skus
这证明公开 SKU 查询会先校验商品是否 ON_SALE。
7.3 创建一个草稿商品的 SKU
先准备一个草稿商品 ID。可以使用第 9 篇创建商品返回的 ID,或者新建一个草稿商品。然后:
curl -i -X POST http://localhost:8080/api/admin/products/$PRODUCT_ID/skus \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-H 'X-Trace-Id: sku-create-001' \
-d '{
"skuCode":"LEARN-BACKEND-001",
"specText":"教程套装 / 标准版",
"salePrice":99.00,
"initialStock":10
}'
关注返回:HTTP 201;availableStock=10;lockedStock=0;version=0。如果返回 SKU_CODE_ALREADY_EXISTS,换一个唯一 skuCode。如果返回 PRODUCT_SKU_NOT_EDITABLE,说明这个商品已经上架,当前项目不允许上架商品新增 SKU。
7.4 验证金额小数位校验
curl -i -X POST http://localhost:8080/api/admin/products/$PRODUCT_ID/skus \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"skuCode":"BAD-PRICE-001",
"specText":"错误价格",
"salePrice":12.345,
"initialStock":1
}'
预期返回 VALIDATION_ERROR。原因是金额最多 2 位小数,后端不会偷偷把 12.345 四舍五入成 12.35。
7.5 按版本修改价格
假设刚创建的 SKU ID 是 SKU_ID,当前版本是 0:
curl -i -X PATCH http://localhost:8080/api/admin/skus/$SKU_ID/price \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"salePrice":88.50,"expectedVersion":0}'
成功后响应里的 version 应该变成 1。再次用旧版本 0 修改:
curl -i -X PATCH http://localhost:8080/api/admin/skus/$SKU_ID/price \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"salePrice":77.00,"expectedVersion":0}'
预期返回 INVENTORY_VERSION_CONFLICT。前端拿到这个 code 后应该提示用户刷新,而不是继续重试旧请求。
7.6 调整库存并验证库存不足
如果当前版本是 1,可售库存是 10:
curl -i -X PATCH http://localhost:8080/api/admin/skus/$SKU_ID/stock \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"delta":3,"expectedVersion":1}'
成功后可售库存变 13,版本变 2。然后尝试扣减超过可售库存:
curl -i -X PATCH http://localhost:8080/api/admin/skus/$SKU_ID/stock \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"delta":-999,"expectedVersion":2}'
预期返回 INSUFFICIENT_STOCK,数据库库存不会被扣成负数。
7.7 用 SQL 观察字段变化
可以进入 MySQL 查看:
SELECT id, product_id, sku_code, sale_price, available_stock, locked_stock, version
FROM mall_product_sku
WHERE id = 1;
你要关注:价格更新后 version 递增;后台库存调整成功后 available_stock 变化且 version 递增;库存不足时没有更新;订单相关的锁定库存后续才会改变 locked_stock。
8. 常见错误
8.1 把 SKU 当成商品本身
商品 SPU 管生命周期和公开可见性,SKU 管规格、价格和库存。不要把所有字段都塞进商品表。多规格商品必须拆 SKU,否则价格和库存无法精确到规格。
8.2 用 JavaScript number 思维理解后端金额
前端展示可以用 number 或字符串,但后端金额必须精确。当前项目使用 BigDecimal 和 DECIMAL(12,2),并限制最多两位小数。
8.3 允许前端传 lockedStock 和 version
lockedStock 和 version 是后端维护字段。创建 SKU 时前端只传初始可售库存,锁定库存和版本由后端初始化。让前端决定这些字段会破坏库存账户。
8.4 已上架商品随便新增 SKU
已上架商品面向用户公开,新增 SKU 会影响购买路径。当前项目拒绝 ON_SALE 商品新增 SKU,返回 PRODUCT_SKU_NOT_EDITABLE。
8.5 只做前端防重复点击
防重复点击不能解决多用户、多浏览器、多服务实例并发。库存和价格更新必须在后端使用乐观锁或原子 SQL。
8.6 先查库存再更新
先查再更新在并发下不安全。正确方式是单条 SQL:UPDATE ... WHERE available_stock + delta >= 0,让数据库把判断和写入放在同一个原子操作里。
8.7 忽略 update 影响行数
并发敏感 update 必须检查影响行数。影响 1 行才表示成功,影响 0 行可能是版本冲突、库存不足或记录不存在。
8.8 前端拿旧 version 继续重试
INVENTORY_VERSION_CONFLICT 不是让前端原样重试,而是提醒用户刷新数据。原样重试仍然会冲突。
8.9 库存不足时仍然本地减少显示
前端可以做乐观 UI,但后端返回 INSUFFICIENT_STOCK 时必须回滚本地展示,重新拉取真实库存。
8.10 忘记读测试
SkuControllerTest、SkuConcurrencyTest、SkuMapperTest 已经把很多业务规则写成测试。读测试比只读实现更容易建立规则边界。
8.11 把后台库存调整和用户下单扣库存混在一起
本篇讲的 adjustStock 是管理员后台维护库存,它表达的是运营或仓库视角:今天补货了,所以 delta=+10;盘点发现少了 2 件,所以 delta=-2。它需要 expectedVersion,因为管理员页面可能拿着旧数据提交,后端要阻止旧页面覆盖新数据。
订单创建时的库存锁定不是后台调库存。订单场景表达的是用户购买视角:用户买 1 件,系统要把 1 件可售库存转成 1 件锁定库存。它不依赖前端传来的 expectedVersion,而是依赖 SQL 条件 available_stock >= quantity。因为用户下单时真正关心的是此刻还有没有足够库存,而不是用户页面打开时的版本是否仍然一致。
这两个场景容易混淆,但后端语义不同:
| 场景 | 接口 / 方法 | 主要输入 | 核心保护 | 成功后的库存变化 |
|---|---|---|---|---|
| 管理员调库存 | PATCH /api/admin/skus/{skuId}/stock | delta、expectedVersion | 版本一致,调整后不为负 | available_stock += delta |
| 创建订单锁库存 | SkuMapper.lockStock | quantity | 当前可售库存足够 | available_stock -= quantity,locked_stock += quantity |
| 取消订单释放库存 | SkuMapper.releaseLockedStock | quantity | 当前锁定库存足够 | available_stock += quantity,locked_stock -= quantity |
| 支付成功确认售出 | SkuMapper.confirmLockedStock | quantity | 当前锁定库存足够 | locked_stock -= quantity |
前端类比一下:后台调库存像管理员手动编辑库存表单,订单锁库存像用户点击购买产生的业务流程。两个动作都改库存,但一个是运营修正,一个是交易占用。不要因为它们都涉及库存,就把它们写成同一个“改库存接口”。后端接口设计要表达业务动作,而不只是表达字段变化。
flowchart TD
A[库存相关变化] --> B{变化来源是什么}
B -->|管理员维护| C[adjustStock:delta + expectedVersion]
B -->|用户创建订单| D[lockStock:quantity + available_stock 条件]
B -->|订单取消或超时| E[releaseLockedStock:locked_stock 条件]
B -->|支付成功| F[confirmLockedStock:确认售出]
C --> G[后台库存审计语义]
D --> H[交易库存占用语义]
E --> H
F --> H
8.12 排查 SKU 和库存问题的顺序
库存问题往往比普通查询问题更难排查,因为它可能同时涉及前端旧数据、管理员操作、并发请求、SQL 条件、订单状态和测试数据。建议你按固定顺序查,不要一上来就怀疑数据库坏了。
第一步看接口路径。公开 SKU 是 /api/products/{productId}/skus,管理端 SKU 是 /api/admin/products/{productId}/skus 或 /api/admin/skus/{skuId}/...。如果路径错了,权限和业务语义都会错。公开接口查不到草稿商品是正常规则,不是 SKU 丢了。
第二步看 HTTP 状态和业务 code。401 说明没登录,403 说明不是管理员,VALIDATION_ERROR 说明请求体字段不合法,PRODUCT_SKU_NOT_EDITABLE 说明商品已上架不能新增 SKU,INVENTORY_VERSION_CONFLICT 说明你拿的是旧版本,INSUFFICIENT_STOCK 说明库存不够。不要只看 message 文案,要看稳定 code。
第三步看请求体。创建 SKU 时检查 skuCode、specText、salePrice、initialStock;改价时检查 salePrice 和 expectedVersion;调库存时检查 delta 和 expectedVersion。尤其是 expectedVersion,它必须来自上一次查询响应,不应该在前端写死为 0。
第四步看数据库当前行。用 SQL 查 sale_price、available_stock、locked_stock、version。如果响应说版本冲突,而数据库 version 已经不是你提交的 expectedVersion,这就是正常保护。若响应说库存不足,就计算 available_stock + delta 是否小于 0。
第五步看测试是否已经定义规则。SkuControllerTest 能告诉你接口应该返回什么 code;SkuConcurrencyTest 能告诉你并发下只允许一个同版本库存调整成功;SkuMapperTest 能告诉你 MyBatis-Plus 乐观锁插件对 updateById 的保护。测试是排查时的规则地图。
8.13 前端页面应该如何配合乐观锁
后端已经做了乐观锁,不代表前端可以完全不管版本。更好的前后端协作方式是:前端查询 SKU 列表时保存每一行的 version;打开编辑弹窗时展示当前版本的数据;提交改价或调库存时带上这个版本;成功后用后端返回的新 SkuResponse 替换本地旧行;如果返回 INVENTORY_VERSION_CONFLICT,不要在旧数据上继续提交,而是重新拉取 SKU 列表。
前端侧示意代码可以这样写:
// 前端侧示意代码:成功后用后端返回的新行替换旧行
async function savePrice(row: SkuViewModel, salePrice: string) {
const res = await request.patch(`/api/admin/skus/${row.id}/price`, {
salePrice,
expectedVersion: row.version,
})
replaceSkuRow(res.data)
}
async function onSkuError(code: string) {
if (code === 'INVENTORY_VERSION_CONFLICT') {
toast('数据已变化,正在刷新')
await reloadSkuList()
}
}
这里的关键不是代码写法,而是思维方式:前端不要试图自己推导下一个 version,也不要觉得“我刚刚加了 3,所以本地库存直接加 3 就一定正确”。以后端返回的新响应为准,才能避免本地状态和服务端事实越走越远。
8.14 为什么本章暂时不引入 Redis 扣库存
你可能听过“高并发库存要放 Redis”,于是会疑惑:为什么本项目这一章主要讲 MySQL 条件更新?原因是学习顺序要从最可靠、最容易验证的机制开始。MySQL 是最终事实来源,available_stock 和 locked_stock 都落在数据库里;单条 UPDATE ... WHERE 能直接证明库存不会小于 0,也能被测试稳定复现。
Redis 预扣库存适合更高并发、更复杂的秒杀场景,但它会额外引入缓存和数据库一致性、异步回写、失败补偿、消息队列、库存回滚等问题。对于前端转后端的第一阶段,先读懂数据库原子更新,比过早套上 Redis 扣库存更重要。等你理解了“库存不变量必须被可靠保护”,再学习 Redis 才不会把缓存误当成真实库存。
9. 本章小练习
练习 1:画出 SPU 和 SKU 关系
画出 mall_product 和 mall_product_sku 的一对多关系,并标出哪些字段属于商品生命周期,哪些字段属于库存和规格。
练习 2:解释为什么价格不能用 double
结合 SkuCreateRequest.salePrice、SkuResponse.salePrice、sql/01_schema.sql 中的 DECIMAL(12,2),说明为什么后端金额使用 BigDecimal。
练习 3:用 curl 验证旧版本改价失败
创建 SKU 后先用 expectedVersion=0 改价成功,再继续用 expectedVersion=0 改价。记录第二次响应的业务 code,并解释 SQL 为什么影响 0 行。
练习 4:用 curl 验证库存不足
先把某个 SKU 可售库存调整到一个小值,再用负数 delta 扣超过库存。说明为什么 available_stock + delta >= 0 能阻止负库存。
练习 5:阅读并发测试
阅读 SkuConcurrencyTest.shouldAllowOnlyOneUpdateForTheSameExpectedVersion,说明两个线程为什么最终只能有一个更新成功。
练习 6:写前端侧示意错误处理
写一段错误处理代码,分别处理 INVENTORY_VERSION_CONFLICT、INSUFFICIENT_STOCK、SKU_CODE_ALREADY_EXISTS。要求注释说明这些 code 都来自后端业务规则。
// 前端侧示意代码:根据后端业务 code 显示提示
function handleSkuError(code: string) {
const messageMap: Record<string, string> = {
INVENTORY_VERSION_CONFLICT: '数据已被别人修改,请刷新后重试',
INSUFFICIENT_STOCK: '可售库存不足',
SKU_CODE_ALREADY_EXISTS: 'SKU 编码已存在',
}
return messageMap[code] ?? '操作失败,请稍后重试'
}
练习 7:解释 lockedStock 的意义
不用看订单代码,先用自己的话解释:为什么创建订单时不能直接把库存“卖掉”,而要先从 available_stock 转到 locked_stock?
10. 再深入一点:库存并发的本质是“把判断靠近数据”
前端转后端时,很容易把后端想成“接收请求、写 if、返回 JSON”。但库存并发问题会逼你升级思维:有些判断不能只放在 Java 内存里,而要尽量靠近数据本身。库存够不够,最终要看数据库当前行;版本有没有变化,最终也要看数据库当前行。把判断放进 WHERE 条件,就是把业务不变量交给数据库在更新瞬间检查。
这不是说所有业务都要写进 SQL。状态机、权限、DTO 组装更适合放在 Java 业务层;但“当前库存不能小于 0”“当前版本必须等于期望版本”这种和某一行数据强相关、并发敏感的规则,非常适合进入 SQL 条件。否则你在 Java 里查到的只是过去某一瞬间的库存,到了 update 时可能已经变了。
你可以把它类比成前端的响应式状态。如果多个异步请求都会修改同一个状态,最后一次返回不一定代表最新意图,所以你会用请求序号、取消旧请求、状态机或缓存库来管理一致性。后端面对的是更严肃的数据一致性问题,它不能只靠“我刚才查过了”来证明现在仍然安全。
本项目当前使用的是相对容易理解的方案:版本号 + 单条 SQL 条件更新。真实高并发电商还可能引入数据库事务、行锁、Redis 预扣、消息队列、库存中心、分库分表等更复杂机制。但这些高级方案的底层目标仍然一样:保证库存不会被错误出售,保证失败能被准确解释,保证用户和管理员不会用旧数据覆盖新数据。
11. 下一章预告:购物车模块
本篇讲了 SKU、价格、库存和并发更新。下一篇会进入购物车模块。购物车对前端同学很熟悉:页面上有商品行、数量加减、勾选状态、删除按钮、合计金额。但后端购物车不是简单数组,它是用户维度的持久化数据,涉及当前登录用户、SKU 是否可售、同用户同 SKU 是否重复、数量范围、勾选状态、列表组装和价格快照展示。
下一章我们会基于 CartController、CartFacade、CartItemEntity、购物车请求 DTO 和测试类,学习“前端状态管理里的购物车”如何变成“后端数据库里的购物车”。你会看到:前端可以在 Pinia / Vuex 里临时维护购物车数量,但最终购物车数据要和用户 ID 绑定并落到 MySQL;前端可以禁用不可售 SKU 的按钮,但后端必须再次校验商品和 SKU 是否仍然可售。
12. 本篇总结
本篇你需要带走 10 个结论:
- SPU 是商品主体,SKU 是具体可售规格,本项目分别对应
mall_product和mall_product_sku; - SKU 承担价格、规格、可售库存、锁定库存和并发版本号;
- 金额字段后端使用
BigDecimal,数据库使用DECIMAL(12,2),避免浮点精度问题; - 创建 SKU 时前端只能提交编码、规格、价格、初始库存,
lockedStock、version、时间字段由后端维护; - 已上架商品不能新增 SKU,当前项目用
PRODUCT_SKU_NOT_EDITABLE拒绝; expectedVersion是前端上次读取到的版本,用于乐观锁防止旧页面覆盖新数据;- 改价 SQL 通过
WHERE version = expectedVersion保证版本一致才更新; - 调库存 SQL 通过
WHERE version = expectedVersion AND available_stock + delta >= 0同时保证版本一致和库存不为负; - 并发敏感 update 必须检查影响行数,影响 0 行不是成功;
lockStock、releaseLockedStock、confirmLockedStock为后续订单和支付链路准备了库存流转模型。
如果你能不用看答案,自己解释“为什么两个线程同时用 version=0 扣 1 个库存,最终只能成功一个”,并能说清楚“前端禁用按钮为什么不能替代 SQL 条件更新”,说明你已经跨过后端库存并发的第一道门槛。下一篇我们会把 SKU 和用户行为结合起来,进入购物车模块。