在电商系统的核心交易链路中,用户提交订单的同时必须扣减对应商品库存。这两个操作如果分开执行,一旦订单表写入成功但库存更新因网络或异常中断,就会产生“有单无库存”的脏数据;反之则可能库存已扣而订单丢失,引发超卖投诉。Laravel基于PDO封装的数据库事务,可以让这两步操作在同一个原子上下文中完成,要么全部生效,要么全部回滚。

事务基础:使用DB::transaction包裹核心逻辑
Laravel最简便的方式是调用DB::transaction闭包,框架会在闭包内自动开启事务,执行成功则提交,抛出任何异常则回滚。这种方式屏蔽了手动begin、commit、rollback的样板代码,适合绝大多数订单创建场景。在闭包中应先查询库存,判断余量,再写订单,最后更新库存,保证逻辑顺序严格一致。
下面的示例演示了在单个事务中完成订单与库存处理。注意库存判断必须使用大于等于当前需求量的条件,且更新时再次依赖数据库层面的行约束,防止并发下的竞态。若中途任意一步抛出异常,Laravel会自动还原所有已执行的写操作,前端可捕获提示用户重试。
use IlluminateSupportFacadesDB;
use AppModelsOrder;
use AppModelsProduct;
function createOrderWithStock($userId, $productId, $qty) {
return DB::transaction(function () use ($userId, $productId, $qty) {
$product = Product::find($productId);
if ($product->stock < $qty) {
throw new Exception('库存不足');
}
$order = Order::create([
'user_id' => $userId,
'product_id' => $productId,
'qty' => $qty,
'status' => 'pending'
]);
$product->decrement('stock', $qty);
return $order;
});
}
上述代码虽然使用了事务,但在高并发下仍可能出现问题:两个请求同时读到库存为10,都通过判断,然后各自扣减,最终结果可能变为8而非预期的0。这是因为普通查询没有加锁,读写之间发生了幻读与不可重复读。因此仅用DB::transaction并不足以应对电商秒杀类场景,需要引入锁机制。
悲观锁实战:withLockForUpdate避免超卖
要解决并发超卖,必须在事务内对库存记录加悲观行锁,使其他事务阻塞等待直至当前事务结束。Laravel的Eloquent提供了withLockForUpdate方法,它会在生成的SQL后附加FOR UPDATE子句。被锁定的行在事务提交前不允许其他事务修改或加锁读取,从而将库存校验与扣减变成串行的临界区。
改写后的逻辑中,我们先以锁方式取出商品,此时其他并发请求在查询同一商品时会挂起,待本事务提交后才继续。这样第二次读到的一定是已扣减后的最新值,从根本上消除了超卖。下方代码展示了带锁的事务写法,并加入了库存不足时的明确异常,便于业务层区分处理。
use IlluminateSupportFacadesDB;
use AppModelsOrder;
use AppModelsProduct;
function safeCreateOrder($userId, $productId, $qty) {
return DB::transaction(function () use ($userId, $productId, $qty) {
$product = Product::where('id', $productId)
->withLockForUpdate()
->first();
if (!$product || $product->stock < $qty) {
throw new Exception('库存不足或商品不存在');
}
$order = Order::create([
'user_id' => $userId,
'product_id' => $productId,
'qty' => $qty,
'status' => 'paid'
]);
$product->decrement('stock', $qty);
return $order;
});
}
悲观锁虽安全,但会增加数据库行锁持有时间,在流量极高时可能造成连接堆积。此时可结合Redis分布式锁做前置限流,或采用乐观锁(如带版本号更新)降低数据库压力。不过对于中小规模电商,withLockForUpdate配合事务已能提供足够强的一致性保障,且代码维护成本最低。
异常回滚与重试:构建健壮的下单流程
事务并非万能,死锁、唯一索引冲突、网络闪断都可能导致提交失败。Laravel事务在捕获异常后会回滚,但调用方需要区分是可重试错误还是业务错误。例如库存不足不应重试,而死锁异常可短暂等待后重试。框架的transaction方法支持第二个参数设置自动重试次数,内部会对特定异常进行有限次重新执行。
我们可以在业务服务中封装带重试的订单创建,并针对QueryException中的死锁信息做判断。同时利用订单号唯一索引,即使重试产生重复写入也会被数据库拒绝,避免脏单。以下示例展示了如何安全重试并记录失败原因,保障最终用户要么下单成功,要么明确收到库存不足提示。
use IlluminateSupportFacadesDB;
use IlluminateDatabaseQueryException;
function createOrderWithRetry($userId, $productId, $qty) {
try {
return DB::transaction(function () use ($userId, $productId, $qty) {
$product = Product::where('id', $productId)
->withLockForUpdate()
->first();
if ($product->stock < $qty) {
throw new Exception('STOCK_NOT_ENOUGH');
}
$order = Order::create([
'order_no' => uniqid('ORD'),
'user_id' => $userId,
'product_id' => $productId,
'qty' => $qty
]);
$product->decrement('stock', $qty);
return $order;
}, 3);
} catch (QueryException $e) {
if (strpos($e->getMessage(), 'Deadlock') !== false) {
throw new Exception('系统繁忙请稍后重试');
}
throw $e;
} catch (Exception $e) {
if ($e->getMessage() === 'STOCK_NOT_ENOUGH') {
throw new Exception('库存不足');
}
throw $e;
}
}
此外,在队列异步处理订单后续状态(如支付回调)时,也应将库存最终确认放在独立事务中,并和订单状态机联动。若用户超时未支付,需通过补偿事务将库存加回,此时同样要使用锁或唯一流水号防止重复返还。只有把事务边界、锁粒度与业务状态严格对齐,Laravel电商系统才能在复杂场景下始终保持订单与库存的绝对一致。