10|(前端转全栈)库存扣减为什么最容易出事故?SKU、并发与原子更新

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 个核心问题:

  1. 什么是 SPU,什么是 SKU,它们在本项目中分别落到哪张表;
  2. 为什么金额字段使用 BigDecimal,不能像前端一样随便用 number 理解;
  3. SKU 创建时,哪些字段可以由前端提交,哪些字段必须由后端维护;
  4. 为什么已上架商品不能随便新增 SKU;
  5. 什么是乐观锁 version,为什么价格和库存调整都需要 expectedVersion
  6. 为什么库存调整必须用单条 SQL 原子更新,不能先查库存再更新;
  7. available_stocklocked_stockversion 三个字段如何为后续订单和支付做准备。

读完本篇,你应该能独立解释这些现象:创建 SKU 时 lockedStock 为什么固定为 0;salePrice=12.345 为什么会被参数校验拒绝;两个管理员拿着同一个旧版本同时改价格,为什么只能一个成功;后台把库存从 2 增加 3 后版本为什么从 0 变成 1;继续扣减 6 个库存为什么返回 INSUFFICIENT_STOCK;订单创建时为什么不能只先查库存再扣库存。

2. 用前端知识类比

先看一个常见的前端侧示意代码。商品详情页有规格列表,用户选择一个 SKU 后,页面展示对应价格和库存:

ts 复制代码
// 前端侧示意代码:商品详情页选择 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 小于购买数量,前端可以禁用"立即购买"按钮:

ts 复制代码
// 前端侧示意代码:禁用按钮只是体验优化
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 idcategory_idtitlestatus_code 管理商品生命周期和公开可见性
SKU 规格 mall_product_sku product_idsku_codespec_textsale_priceavailable_stocklocked_stockversion 管理价格、规格、库存和并发

第 9 篇讲的上架条件里,SkuDbService.requireSaleableSku 要求商品至少有一个 sale_price > 0available_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 &#34;1&#34; --> &#34;0..n&#34; mall_product_sku : 一个商品有多个 SKU

3.2 金额为什么用 BigDecimal

前端里 number 是 JavaScript 的双精度浮点数,写页面展示时很方便,但它不适合直接表达严肃金额。后端金额字段使用 Java 的 BigDecimal,数据库字段使用 DECIMAL(12,2),目的是保证十进制金额精确。比如 0.1 + 0.2 在 JavaScript 里会出现浮点精度问题,金额系统不能接受这种误差。

当前项目的 SkuCreateRequest.salePriceSkuPriceUpdateRequest.salePrice 都是 BigDecimal,并且有校验:价格不能为空,必须大于 0,最多 10 位整数和 2 位小数。SkuFacade.normalizePrice 还会用 setScale(2, RoundingMode.UNNECESSARY) 要求价格必须已经是两位小数以内,不能偷偷传 12.345。

前端侧示意代码可以把金额当字符串处理,提交给后端:

ts 复制代码
// 前端侧示意代码:金额输入最好按字符串处理并限制两位小数
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 调库存请求,包含 deltaexpectedVersion
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 的类路径是:

java 复制代码
@RestController
@RequestMapping("/api/admin")
@Tag(name = "管理员 SKU 管理")
public class SkuAdminController {

它提供 4 个管理接口:

java 复制代码
@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、包装 ApiResponsetraceId。这和前面章节讲过的 Controller 职责保持一致。

6.2 公开入口:SkuController

公开 SKU 查询在另一个 Controller:

java 复制代码
@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

java 复制代码
@Override
public List<SkuResponse> queryPublishedSkus(Long productId) {
    productDbService.requirePublishedById(productId);
    return toResponses(skuDbService.listByProductId(productId));
}

第一行先确认商品公开可见,第二行才查询 SKU。也就是说,SKU 是否公开不由前端传参决定,而由商品状态决定。这个设计和商品详情保持一致:公开端只能看到 ON_SALE 商品相关数据。

6.3 创建 SKU 请求:SkuCreateRequest

创建 SKU 请求字段如下:

java 复制代码
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。创建时间和更新时间也不允许前端传,由服务端维护。

前端侧示意代码:

ts 复制代码
// 前端侧示意代码:创建 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)
}

如果你把 lockedStockversioncreatedAt 一起传过去,当前后端也不会使用这些字段。后端契约越明确,越不容易让前端误以为自己可以决定服务端内部状态。

6.4 创建 SKU 业务:SkuFacade.createSku

核心代码如下:

java 复制代码
@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 经过 normalizePriceavailableStock 来自初始库存,lockedStock 固定为 0,version 固定为 0。也就是说,创建 SKU 时库存全在可售池,还没有订单占用。

6.5 金额规范化:normalizePrice

SkuFacade.normalizePrice 代码如下:

java 复制代码
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

改价请求包含新价格和期望版本:

java 复制代码
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 调用:

java 复制代码
int updated = skuDbService.updatePriceByVersion(skuId, price, request.getExpectedVersion());
if (updated == 0) {
    explainVersionUpdateFailure(skuId, request.getExpectedVersion());
}
return toResponse(skuDbService.requireById(skuId));

底层 Mapper 是单条 SQL:

java 复制代码
@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

调库存请求包含 deltaexpectedVersion

java 复制代码
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 拒绝。

前端侧示意代码:

ts 复制代码
// 前端侧示意代码:库存调整要带上上次读取到的 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 调用:

java 复制代码
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:

java 复制代码
@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,判断具体原因:

java 复制代码
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 保证。

前端侧示意处理:

ts 复制代码
// 前端侧示意代码:根据业务 code 给出不同提示
if (error.code === 'INVENTORY_VERSION_CONFLICT') {
  toast('库存数据已被其他请求修改,请刷新后重试')
}
if (error.code === 'INSUFFICIENT_STOCK') {
  toast('可售库存不足')
}

6.11 Entity 和 MyBatis-Plus 乐观锁插件

SkuEntity 中有:

java 复制代码
@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

java 复制代码
@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 个方法,本篇先建立概念:

java 复制代码
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 登录管理员

bash 复制代码
curl -sS -X POST http://localhost:8080/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"Admin123456"}'

复制响应中的 accessToken

bash 复制代码
export ADMIN_TOKEN='<复制 accessToken>'

7.2 查询公开 SKU

1 号商品是已上架商品,可以公开查询 SKU:

bash 复制代码
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:

bash 复制代码
curl -i http://localhost:8080/api/products/2/skus

这证明公开 SKU 查询会先校验商品是否 ON_SALE

7.3 创建一个草稿商品的 SKU

先准备一个草稿商品 ID。可以使用第 9 篇创建商品返回的 ID,或者新建一个草稿商品。然后:

bash 复制代码
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 201availableStock=10lockedStock=0version=0。如果返回 SKU_CODE_ALREADY_EXISTS,换一个唯一 skuCode。如果返回 PRODUCT_SKU_NOT_EDITABLE,说明这个商品已经上架,当前项目不允许上架商品新增 SKU。

7.4 验证金额小数位校验

bash 复制代码
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:

bash 复制代码
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 修改:

bash 复制代码
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:

bash 复制代码
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。然后尝试扣减超过可售库存:

bash 复制代码
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 查看:

sql 复制代码
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 或字符串,但后端金额必须精确。当前项目使用 BigDecimalDECIMAL(12,2),并限制最多两位小数。

8.3 允许前端传 lockedStock 和 version

lockedStockversion 是后端维护字段。创建 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 忘记读测试

SkuControllerTestSkuConcurrencyTestSkuMapperTest 已经把很多业务规则写成测试。读测试比只读实现更容易建立规则边界。

8.11 把后台库存调整和用户下单扣库存混在一起

本篇讲的 adjustStock 是管理员后台维护库存,它表达的是运营或仓库视角:今天补货了,所以 delta=+10;盘点发现少了 2 件,所以 delta=-2。它需要 expectedVersion,因为管理员页面可能拿着旧数据提交,后端要阻止旧页面覆盖新数据。

订单创建时的库存锁定不是后台调库存。订单场景表达的是用户购买视角:用户买 1 件,系统要把 1 件可售库存转成 1 件锁定库存。它不依赖前端传来的 expectedVersion,而是依赖 SQL 条件 available_stock >= quantity。因为用户下单时真正关心的是此刻还有没有足够库存,而不是用户页面打开时的版本是否仍然一致。

这两个场景容易混淆,但后端语义不同:

场景 接口 / 方法 主要输入 核心保护 成功后的库存变化
管理员调库存 PATCH /api/admin/skus/{skuId}/stock deltaexpectedVersion 版本一致,调整后不为负 available_stock += delta
创建订单锁库存 SkuMapper.lockStock quantity 当前可售库存足够 available_stock -= quantitylocked_stock += quantity
取消订单释放库存 SkuMapper.releaseLockedStock quantity 当前锁定库存足够 available_stock += quantitylocked_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 时检查 skuCodespecTextsalePriceinitialStock;改价时检查 salePriceexpectedVersion;调库存时检查 deltaexpectedVersion。尤其是 expectedVersion,它必须来自上一次查询响应,不应该在前端写死为 0。

第四步看数据库当前行。用 SQL 查 sale_priceavailable_stocklocked_stockversion。如果响应说版本冲突,而数据库 version 已经不是你提交的 expectedVersion,这就是正常保护。若响应说库存不足,就计算 available_stock + delta 是否小于 0。

第五步看测试是否已经定义规则。SkuControllerTest 能告诉你接口应该返回什么 code;SkuConcurrencyTest 能告诉你并发下只允许一个同版本库存调整成功;SkuMapperTest 能告诉你 MyBatis-Plus 乐观锁插件对 updateById 的保护。测试是排查时的规则地图。

8.13 前端页面应该如何配合乐观锁

后端已经做了乐观锁,不代表前端可以完全不管版本。更好的前后端协作方式是:前端查询 SKU 列表时保存每一行的 version;打开编辑弹窗时展示当前版本的数据;提交改价或调库存时带上这个版本;成功后用后端返回的新 SkuResponse 替换本地旧行;如果返回 INVENTORY_VERSION_CONFLICT,不要在旧数据上继续提交,而是重新拉取 SKU 列表。

前端侧示意代码可以这样写:

ts 复制代码
// 前端侧示意代码:成功后用后端返回的新行替换旧行
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_stocklocked_stock 都落在数据库里;单条 UPDATE ... WHERE 能直接证明库存不会小于 0,也能被测试稳定复现。

Redis 预扣库存适合更高并发、更复杂的秒杀场景,但它会额外引入缓存和数据库一致性、异步回写、失败补偿、消息队列、库存回滚等问题。对于前端转后端的第一阶段,先读懂数据库原子更新,比过早套上 Redis 扣库存更重要。等你理解了"库存不变量必须被可靠保护",再学习 Redis 才不会把缓存误当成真实库存。

9. 本章小练习

练习 1:画出 SPU 和 SKU 关系

画出 mall_productmall_product_sku 的一对多关系,并标出哪些字段属于商品生命周期,哪些字段属于库存和规格。

练习 2:解释为什么价格不能用 double

结合 SkuCreateRequest.salePriceSkuResponse.salePricesql/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_CONFLICTINSUFFICIENT_STOCKSKU_CODE_ALREADY_EXISTS。要求注释说明这些 code 都来自后端业务规则。

ts 复制代码
// 前端侧示意代码:根据后端业务 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 是否重复、数量范围、勾选状态、列表组装和价格快照展示。

下一章我们会基于 CartControllerCartFacadeCartItemEntity、购物车请求 DTO 和测试类,学习"前端状态管理里的购物车"如何变成"后端数据库里的购物车"。你会看到:前端可以在 Pinia / Vuex 里临时维护购物车数量,但最终购物车数据要和用户 ID 绑定并落到 MySQL;前端可以禁用不可售 SKU 的按钮,但后端必须再次校验商品和 SKU 是否仍然可售。

12. 本篇总结

本篇你需要带走 10 个结论:

  1. SPU 是商品主体,SKU 是具体可售规格,本项目分别对应 mall_productmall_product_sku
  2. SKU 承担价格、规格、可售库存、锁定库存和并发版本号;
  3. 金额字段后端使用 BigDecimal,数据库使用 DECIMAL(12,2),避免浮点精度问题;
  4. 创建 SKU 时前端只能提交编码、规格、价格、初始库存,lockedStockversion、时间字段由后端维护;
  5. 已上架商品不能新增 SKU,当前项目用 PRODUCT_SKU_NOT_EDITABLE 拒绝;
  6. expectedVersion 是前端上次读取到的版本,用于乐观锁防止旧页面覆盖新数据;
  7. 改价 SQL 通过 WHERE version = expectedVersion 保证版本一致才更新;
  8. 调库存 SQL 通过 WHERE version = expectedVersion AND available_stock + delta >= 0 同时保证版本一致和库存不为负;
  9. 并发敏感 update 必须检查影响行数,影响 0 行不是成功;
  10. lockStockreleaseLockedStockconfirmLockedStock 为后续订单和支付链路准备了库存流转模型。

如果你能不用看答案,自己解释"为什么两个线程同时用 version=0 扣 1 个库存,最终只能成功一个",并能说清楚"前端禁用按钮为什么不能替代 SQL 条件更新",说明你已经跨过后端库存并发的第一道门槛。下一篇我们会把 SKU 和用户行为结合起来,进入购物车模块。

相关推荐
cdcdhj1 小时前
vue3中的watchEffect()监听,什么时候监听,什么时候清理,什么时候停止
前端·javascript·vue.js
smallYoung1 小时前
学习笔记-python基础(day12 正则表达式)
后端
huabuyu1 小时前
几百 MB 的文件为什么等几分钟才能打开?文件分片下载与渐进预览原理
前端·javascript
虚惊一场1 小时前
麻将桌上的并发控制:扫码进房、乐观版本与零和结算
前端·javascript
Zane19941 小时前
volatile / synchronized / final:三大特性(原子性/可见性/有序性)到底谁保证了什么?
java·后端
巴勒个啦1 小时前
从需求到上线:记录一次完全由 AI 辅助完成的小产品全流程
java·前端
无人生还1 小时前
从 Vue3 到 React · 快速上手系列第 6 篇:状态管理 useState 与 useReducer
前端·vue.js·react.js
网易云信1 小时前
销售为什么是企业 AI 落地的"最佳突破口"?
人工智能·后端·agent
hunterandroid1 小时前
Room 并发写入与事务一致性:从数据竞争到可靠落地
前端