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参数值。
解决步骤
用行级锁阻断竞态
查询库存时添加行级锁,确保同一时间只有一个事务能修改该库存记录,避免并发读取旧值:const inventory = await trx<sql.ProductInventory>(TABLE.PRODUCT_INVENTORY) .where({ id: inventoryId }) .forUpdate() // 添加行级排他锁 .first();其他事务会等待当前事务提交后,才能读取并修改该库存记录。
修复库存更新数值错误
将库存更新逻辑中的quantity: inventory.quantity - 1改为quantity: inventory.quantity - quantity,确保减去用户实际请求的加购数量。合并校验与更新(原子化操作)
直接在数据库层面完成库存校验与更新,无需先查询再判断,进一步降低竞态风险: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'); }优化用户限购逻辑
用户限购校验同样存在竞态风险,可对用户已购记录加锁后再判断: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
相关产品推荐
相关产品推荐

