分布式:api接口扣除库存如何满足幂等性,以及防止超卖

技术分享 · Fecmall · 于 1年前 发布 · 2739 次阅读

如果采用了分布式,底层都通过api调取,那么就会存在网络问题,超时问题,因此,可能会有多次请求到库存节点,为了满足一致性,需要每个api满足幂等性,也就是 f(x) == f(f(x)),也就是多次执行的结果和一次执行的结果是一样的

对于产品库存,也就是:多次调用扣除库存的api,库存只会扣除一次, 对于库存补偿,多次调用库存补偿方法,库存只会补偿一次。

当库存api满足幂等性,我们才可能采用分布式,通过api扣除库存,返还库存。

扣除库存的幂等性

库存api节点:

库存表: Product (product_id 为 唯一索引)

product_id
qty

库存历史表: ProductHistory (order_item_id 为唯一索引)

product_id
order_item_id
qty
updated_at
ver  default 0
// 扣除成功
const STOCK_DEDUCT_SUCCESS 	= 1;
// 库存不足
const STOCK_OUT 			= 2;
// 库存已经被扣除
const STOCK_HAVE_DEDUCTED 	= 3;
const UNKNOW_ERROR 			= 4;

// 回滚部分状态
// 回滚成功
const ROLLBACK_SUCCESS 		= 1;
// 回滚库存历史表找不到数据
const ROLLBACK_NO_HISTORY 	= 2;
// 回滚库存,已经被回滚了,不需要再次回滚
const ROLLBACK_HISTORY_HAS_DONE = 3;
// 回滚库存表失败
const ROLLBACK_STOCK_FAIL 	= 4;

const ROLLBACK_UNKNOW_ERROR 	= 10;
/**
 * 扣除库存的函数
 * @property $product_id 产品id
 * @property $sale_qty 扣除库存的个数(正数)
 * @property $order_item_id 订单产品表的id(这个是订单产品表的主键id,因此是唯一的)
 */
public function deductProductQty($product_id,$sale_qty,$order_item_id){
	$allowDeduct = 0;
	// 查找是否存在历史表
	$productHistory = ProductHistory::find([
		'product_id' => $product_id,
		'order_item_id' => $order_item_id,
		'qty' => $sale_qty,
	])->asArray()->one();
	
	if (empty($productHistory)) {
		// 如果在库存历史表中不存在,则查询一下库存是否满足,如果不满足,直接退出
		$product_One = Product::find(
			['and', ['product_id' => $product_id], ['>=', 'qty', $sale_qty]]
		)->asArray()->one();
		if(empty($product_One)){
			return $this->resultData(self::STOCK_OUT);
		}
	}
	$innerTransaction = Yii::$app->db->beginTransaction();
    try {
		if (!empty($productHistory)) {
			// 如果存在,则更新历史表
			$updateCounts = $this->updateHistiryQty($product_id, $order_item_id, $sale_qty);
			if ($updateCounts) {
				$allowDeduct = 1;
			} else {
				throw new \Exception(self::STOCK_HAVE_DEDUCTED);
			}
		} else {
			// 添加库存历史表信息
			$ProductHistory = new ProductHistory;
			$ProductHistory->product_id = $product_id;
			$ProductHistory->order_item_id = $order_item_id;
			$ProductHistory->qty = $qty;
			$ProductHistory->ver = 1;
			$ProductHistory->updated_at = time();
			$ProductHistory->save();
			$allowDeduct = 1;
		}
		// 允许扣库存
		if ($allowDeduct) {
			$updateColumns = Product::updateAllCounters(
				['qty' => $sale_qty],
				// 条件中加入 ['>=', 'qty', $sale_qty] 防止超卖。
				['and', ['product_id' => $product_id], ['>=', 'qty', $sale_qty]]
			);
			if(!$updateColumns) {
				throw new \Exception(self::STOCK_OUT);
			}
		}
		$innerTransaction->commit();
	} catch (Exception $e) {
		$innerTransaction->rollBack();
		return $this->resultData($e->getMessage());
	}
	return $this->resultData(self::STOCK_DEDUCT_SUCCESS);
}


/**
 * 返还库存的函数
 * @property $product_id 产品id
 * @property $sale_qty 扣除库存的个数(正数)
 * @property $order_item_id 订单产品表的id(这个是订单产品表的主键id,因此是唯一的)
 */
public function rollBackProductQty($order_item_id, $sale_qty){
	// 查看在库存历史表中是否存在记录
	$productHistoryOne = ProductHistory::find([
		'order_item_id' => $order_item_id,
		'qty' => $sale_qty,
	])->asArray()->one();
	if(empty($productHistoryOne)){
		// 返回,无历史记录
		return $this->resultRollbackData(self::ROLLBACK_NO_HISTORY);
	}
	$innerTransaction = Yii::$app->db->beginTransaction();
    try {
		$product_id = $productHistoryOne['product_id'];
		$order_item_id = $productHistoryOne['order_item_id'];
		// 将库存历史表回滚
		$updateColumns = $this->rollBackHistiryQty($order_item_id, $sale_qty);
		if(empty($updateColumns)){
			// 返回,回滚库存历史表失败
			throw new \Exception(self::self::ROLLBACK_HISTORY_HAS_DONE);
		}
		// 回滚库存表
		$updateQtyColumns = Product::updateAllCounters(
			['qty' => new \yii\db\Expression('qty + '.$sale_qty)],
			// 条件中加入 ['>=', 'qty', $sale_qty] 防止超卖。
			['product_id' => $product_id]
		);
		if(!$updateQtyColumns) {
			// 返回,回滚产品库存失败
			throw new \Exception(self::ROLLBACK_STOCK_FAIL);
		}
		// 回滚成功
		$innerTransaction->commit();
	} catch (Exception $e) {
		$innerTransaction->rollBack();
		return $this->resultRollbackData($e->getMessage());
	}
	return $this->resultRollbackData(self::ROLLBACK_SUCCESS);
}




public function resultRollbackData($message){
	if ($message == self::ROLLBACK_SUCCESS){
		return [true, self::ROLLBACK_SUCCESS,'库存回滚成功'];
	}else if ($message == self::ROLLBACK_NO_HISTORY){
		return [false, self::ROLLBACK_NO_HISTORY,'回滚库存,在库存历史表找不到数据'];
	}else if ($message == self::ROLLBACK_HISTORY_HAS_DONE){
		return [true, self::ROLLBACK_HISTORY_HAS_DONE,'库存已经回滚,不需要再次回滚'];
	}else if ($message == self::ROLLBACK_STOCK_FAIL){
		return [false, self::ROLLBACK_STOCK_FAIL,'回滚库存失败'];
	}else {
		return [false, self::ROLLBACK_UNKNOW_ERROR,'未知错误'];
	}

}


// return [库存扣除状态,库存扣除执行状态,详细信息]
public function resultData($message){
	if ($message == self::STOCK_DEDUCT_SUCCESS){
		return [true, self::STOCK_DEDUCT_SUCCESS,'库存扣除成功'];
	} else if ($message == self::STOCK_OUT){
		return [false, self::STOCK_OUT,'库存不足'];
	} else if ($message == self::STOCK_HAVE_DEDUCTED){
		return [true, self::STOCK_HAVE_DEDUCTED,'库存已经被扣除过了'];
	
	}else {
		return [false, self::UNKNOW_ERROR,'未知错误'];
	}

}


// 'qty' => new \yii\db\Expression('qty - '.$sale_qty),
public function updateHistiryQty($product_id, $order_item_id, $sale_qty){
	$updateCounts = ProductHistory::updateAll(
		[
			'ver' => 1,
			'updated_at' => time()
		],
		[
			'product_id' => $product_id, 
			'order_item_id' => $order_item_id,
			'ver' => 0,
			'qty' => $sale_qty,
		]
	);
}

// 回滚库存历史表信息。
public function rollBackHistiryQty($order_item_id, $sale_qty){
	$updateCounts = ProductHistory::updateAllCounters(
		[
			'ver' => 0,
			'updated_at' => time()
		],
		[
			'order_item_id' => $order_item_id,
			'ver' => 1,
			'qty' => $sale_qty,
		]
	);
}

总结:

1.调用api接口扣除库存,满足满足幂等性,即使多次调用api,库存仍旧只会扣除一次

2.回滚操作也就是函数 rollBackProductQty,也要满足幂等性,执行多次,库存的补偿只能一次

3.在下单过程,很调用很多类似库存这样的api节点,假设A,B,C,D四个api,C失败后,那么B,A都要回滚, 回滚可以调用相应的回滚函数。

4.在回滚的过程中可能宕机,因此,我们还需要有一个后台脚本,进行做检查,对于完成一半的分布式事务,调用回滚函数进行补偿,来满足最终一致性。另外,还要看这种补偿机制,是否会对业务有影响。

5.回滚补偿函数,可能会一直失败,譬如在产品库存补偿的时候,产品下架了,导致补偿一直失败, 因此,对于补偿脚本,应该要有一个记录,补偿次数超过最大次数,则不再运行补偿脚本,人工介入,查看是什么原因导致补偿脚本一直失败

共收到 1 条回复
Fecmall#11年前 0 个赞

1.先检查库存是否充足

1.1在库存历史表中检查是否存在数据,where条件:`product_id`, `order_item_id`
1.2如果不存在,则去库存表,查看库存是否满足,如果不满足,直接返回,库存不足

2在库存历史表中:

2.1如果不存在行,则插入数据,ver = 1

2.2如果存在行,则进行更新,这里要保证只能更新一次,因此需要 update set ver = 1 where ver = 0, 如果更新返回的行数为0,则说明已经更新过了,因此,按照幂等性要求,直接返回,不能更新库存,以免造成多次扣除库存

3.库存表:进行库存的扣除,如果返回行数为0,则说明库存不足(或者该产品不存在),因此返回库存不足,回滚事务

4.提交事务成功返回。

添加回复 (需要登录)
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册
Your Site Analytics