PostgreSQL+Knex采购系统并发下单超卖问题如何解决?
问题根因
当前出现超卖是典型的读写竞态条件问题:两个并发事务先后读取到相同的库存值,都通过了库存充足校验,随后分别执行库存扣减操作,最终导致库存被扣为负数。
你之前的方案不生效的原因如下:
- 仅使用普通事务+默认隔离级别时,普通SELECT是快照读,不会对数据行加锁,两个事务可以同时读取到相同的旧库存值,自然都会通过校验。
- 你尝试使用的
read uncommitted隔离级别本身允许脏读,完全不适合库存校验场景,反而会引入更多数据问题。 - Serializable隔离级别是通过序列化冲突检测来保证一致性,并发修改同一条数据时会直接触发回滚,所以会出现大量非必要报错,而且你把支付这种耗时极长的第三方调用放在事务内,会拉长事务持有锁的时间,进一步放大冲突概率。
适配业务的解决方案
推荐使用行级悲观锁方案,只会锁定当前操作的商品行,不会锁表,不同商品的下单请求完全互不影响,既可以避免超卖,也不会出现非稀缺商品下单报错的问题,适配你的业务需求。
具体修改步骤
1. 查询商品时加行级排他锁
在查询tb_product的语句末尾添加.forUpdate(),Knex会自动为查询到的商品行加上排他锁,持有锁的事务未提交前,其他事务查询同一件商品时会进入等待状态,保证同一时间只有一个事务可以操作该商品的库存。
修改后的查询代码如下:
let prod = await trx('tb_product') .select('tb_product.id', 'tb_product.title', 'tb_product.price', 'tb_product.price_total', 'tb_product.quantity', 'tb_product.id_salesman', 'tb_product.width', 'tb_product.height', 'tb_product_discount.value', 'tb_product_discount.is_deleted') .leftJoin('tb_product_discount', 'tb_product_discount.id', 'tb_product.id_discount') .where({ 'tb_product.id': pBought.id_product, 'tb_product.is_active': true, 'tb_product.is_deleted': false }) .forUpdate(); // 新增行级锁逻辑
2. 把支付逻辑移出数据库事务
第三方支付调用耗时通常在数百毫秒到数秒不等,放在事务内会导致事务长时间持有锁,阻塞其他同商品的下单请求,严重降低并发性能。调整整体流程如下:
- 开启短事务:完成商品校验、库存扣减、生成待支付状态的预订单,快速提交事务释放锁
- 事务外调用支付接口
- 支付成功:更新订单状态为已支付
- 支付失败:回补商品库存,更新订单状态为支付失败
调整后的核心流程代码示例:
let order; let products = []; let sales = []; let order_total = 0; // 第一步:短事务完成库存扣减和预订单生成 try { const trx = await knex.transaction({ isolationLevel: 'read committed' }); // 使用默认的读提交隔离级别即可 for (const pBought of data.products) { // 此处用上面加了forUpdate的商品查询逻辑 // 原有商品校验、价格计算逻辑保持不变 // ... products.push({ id_product: prod.id, price: aux, discount: prod.value, quantity: pBought.quantity, available_quantity: prod.quantity, tax: process.env.PERCENTAGE_TAX }); sales.push(/* 原有sales构造逻辑 */) } // 插入待支付状态的预订单 order = await trx('tb_order').insert({ order_total, id_client: client.client.id_client, id_address: address.address.id, portions: data.portions, method: data.method, status: 0 // 0=待支付 }).returning('*'); order = order[0]; // 库存扣减、插入订单商品关联表逻辑保持不变 // ... await trx.commit(); } catch (error) { if(trx) await trx.rollback(); Message.warning(error); return { success: false, message: error.message } } // 第二步:事务外调用支付 try { const wasPaid = await Payment.execute(sales, { portions: data.portions, method: data.method, card_hash: data.card_hash, document: data.document, name: data.name, total: parseInt((order_total * 100)).toFixed(0) }, client, address, order.id); if (!wasPaid.success) throw new Error(wasPaid.message); // 支付成功,更新订单状态 await knex('tb_order').update({status: 1}).where({id: order.id}); return { success: true, order }; } catch (error) { // 支付失败,回补库存 for (const prod of products) { await knex('tb_product').increment('quantity', prod.quantity).where({ 'id': prod.id_product }); } await knex('tb_order').update({status: 2}).where({id: order.id}); Message.warning(error); return { success: false, message: error.message } }
额外优化建议
如果你的业务存在一次下单购买多件商品的场景,建议统一按商品ID从小到大的顺序加锁,避免不同事务加锁顺序不一致导致的死锁问题。
内容的提问来源于stack exchange,提问作者Sávio Miranda
相关产品推荐
相关产品推荐

