You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Knex.js库存超卖问题求助:购物车并发添加时库存校验失效

购物车超卖问题排查与解决

问题背景

我正在为学校社团搭建网店,数据库和Knex.js经验不足。现在遇到多人同时加购时,购物车商品数量可能超出库存的问题,票务类商品超卖后果严重,怀疑事务理解有偏差,求解决方法和学习指引。

现有事务代码

private async inventoryToCartTransaction(
    cart: sql.Cart,
    inventoryId: UUID,
    quantity: number = 1,
  ) {
    return this.knex.transaction(async (trx) => {
      const inventory = await trx<sql.ProductInventory>(TABLE.PRODUCT_INVENTORY)
        .where({ id: inventoryId }).first();
      if (!inventory) throw new Error(`Inventory with id ${inventoryId} not found`);
      if (inventory.quantity < quantity) throw new Error('Not enough inventory');
      const product = await trx<sql.Product>(TABLE.PRODUCT)
        .where({ id: inventory.product_id }).first();
      if (!product) throw new Error(`Product with id ${inventory.product_id} not found`);
      const cartItem = await trx<sql.CartItem>(TABLE.CART_ITEM)
        .where({ cart_id: cart.id, product_inventory_id: inventory.id })
        .first();
      const userInventoryItem = await trx<sql.UserInventoryItem>('user_inventory_item').where({
        student_id: cart.student_id,
        product_inventory_id: inventory.id,
      }).first();
      if (cartItem) {
        if ((userInventoryItem ? userInventoryItem.quantity : 0) + cartItem.quantity + quantity > product.max_per_user) throw new Error('You already have the maximum amount of this product.');
        await trx<sql.CartItem>(TABLE.CART_ITEM).where({ id: cartItem.id }).update({
          quantity: cartItem.quantity + quantity,
        });
      } else {
        if ((userInventoryItem ? userInventoryItem.quantity : 0) + quantity > product.max_per_user) throw new Error('You already have the maximum amount of this product.');
        await trx<sql.CartItem>(TABLE.CART_ITEM).insert({
          cart_id: cart.id,
          product_inventory_id: inventory.id,
          quantity,
        });
      }
      await trx<sql.ProductInventory>(TABLE.PRODUCT_INVENTORY).where({ id: inventory.id }).update({
        quantity: inventory.quantity - 1,
      });
      await trx<sql.Cart>(TABLE.CART).where({ id: cart.id }).update({
        total_price: cart.total_price + product.price,
        total_quantity: cart.total_quantity + quantity,
      });
    });
  }

数据库结构

export interface Product {
  id: UUID,
  name: string,
  description: string,
  SKU: string,
  price: number,
  image_url: string,
  max_per_user: number,
  category_id: UUID,
  created_at: Date,
  updated_at: Date,
  deleted_at?: Date,
}

export interface ProductCategory {
  id: UUID,
  name: string,
  description: string,
  created_at: Date,
  updated_at: Date,
  deleted_at?: Date,
}

export interface ProductInventory {
  id: UUID,
  quantity: number,
  variant: string,
  discount_id?: UUID,
  product_id: UUID,
  created_at: Date,
  updated_at: Date,
  deleted_at?: Date,
}

export interface Cart {
  id: UUID,
  student_id: UUID,
  total_price: number,
  total_quantity: number,
  created_at: Date,
  updated_at: Date,
  expires_at: Date,
}

export interface CartItem {
  id: UUID,
  cart_id: UUID,
  product_inventory_id: UUID,
  quantity: number,
  created_at: Date,
  updated_at: Date,
}

export interface UserInventory {
  id: UUID,
  student_id: UUID,
  created_at: Date,
  updated_at: Date,
  deleted_at?: Date,
}

export interface UserInventoryItem {
  id: UUID,
  student_id: UUID,
  user_inventory_id: UUID,
  product_inventory_id: UUID,
  quantity: number,
  created_at: Date,
  updated_at: Date,
  deleted_at?: Date,
}

问题根源与解决方法

核心问题

当前代码存在竞态条件:事务中先查询库存数量,再执行更新操作。当多个请求同时触发时,它们可能读取到相同的库存剩余值,之后都执行减库存逻辑,最终导致库存负数(超卖)。另外代码存在数值错误:更新库存时写死减1,而非传入的quantity参数值。

解决步骤

  1. 用行级锁阻断竞态
    查询库存时添加行级锁,确保同一时间只有一个事务能修改该库存记录,避免并发读取旧值:

    const inventory = await trx<sql.ProductInventory>(TABLE.PRODUCT_INVENTORY)
      .where({ id: inventoryId })
      .forUpdate() // 添加行级排他锁
      .first();
    

    其他事务会等待当前事务提交后,才能读取并修改该库存记录。

  2. 修复库存更新数值错误
    将库存更新逻辑中的quantity: inventory.quantity - 1改为quantity: inventory.quantity - quantity,确保减去用户实际请求的加购数量。

  3. 合并校验与更新(原子化操作)
    直接在数据库层面完成库存校验与更新,无需先查询再判断,进一步降低竞态风险:

    const updateResult = await trx<sql.ProductInventory>(TABLE.PRODUCT_INVENTORY)
      .where({ id: inventoryId })
      .where('quantity', '>=', quantity)
      .update({
        quantity: trx.raw('quantity - ?', [quantity])
      });
    if (updateResult === 0) {
      throw new Error('Not enough inventory');
    }
    
  4. 优化用户限购逻辑
    用户限购校验同样存在竞态风险,可对用户已购记录加锁后再判断:

    const [userInventoryItem, cartItem] = await Promise.all([
      trx<sql.UserInventoryItem>('user_inventory_item')
        .where({ student_id: cart.student_id, product_inventory_id: inventory.id })
        .forUpdate()
        .first(),
      trx<sql.CartItem>(TABLE.CART_ITEM)
        .where({ cart_id: cart.id, product_inventory_id: inventory.id })
        .forUpdate()
        .first()
    ]);
    // 后续执行限购数量判断
    

学习指引

  • 先理解数据库事务的隔离级别,重点掌握默认的READ COMMITTED和REPEATABLE READ的区别,以及不同级别下的并发问题。
  • 熟悉Knex事务的使用,包括trx对象的传递方式,以及for update、for share等锁机制的适用场景。
  • 掌握悲观锁和乐观锁两种并发控制方案,理解各自的优缺点和适用场景。

内容的提问来源于stack exchange,提问作者Oliver Levay

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.11 05:20:32