PHP商城多端并发下单重复发货问题:如何实现账户级购买锁?
解决积分商城并发购买重复发放道具问题
问题根源
你的代码中,积分校验、道具发放、积分扣减是分步执行的,在并发场景下,两个请求会同时通过$out->uCash < $out->totalPrice的校验,随后都执行insertPaidItem发放道具,之后才进行积分扣减,最终导致用户获得两份道具但积分仅扣减一次(或扣减逻辑异常)。
解决方案
要解决这个问题,必须保证用户的购买操作是原子性的,同时在并发时对用户账户加锁,同一时间只允许一个购买请求执行。以下是具体实现方案:
1. 数据库层面:行级锁+事务保证原子性
在查询用户积分时,使用SELECT ... FOR UPDATE锁定用户账户行,确保其他请求无法同时读取或修改该账户;同时将所有修改操作包裹在事务中,保证要么全部执行成功,要么全部回滚。
2. 代码修改示例
public function buyItem(){ // 开启数据库事务 DB::beginTransaction(); try{ /*1- Get All Data Necessary*/ /*Get & Verify Input*/ $data = $this->verifyInput(); /*Check If item exists in database And Get Info*/ $item = $this->getItemFromDb($data->id); /*Check if Item is on Sale With this type of currency */ $this->ifOnSale($item,$data->type); // 关键修改:查询用户积分时使用行级锁,防止并发读取 $buyer = $this->getPointsWithLock(); /*load prices */ $out = $this->getPriceAll($data,$item,$buyer); /*Start Checks*/ /*check if player has enough cash*/ if ($out->uCash < $out->totalPrice){ throw new Exception(_LC["er_notenoughcash"]); } /*start now adding point to player*/ $this->insertPaidItem($out); /*update Cash from account*/ $this->getCashFromAccount($out); /*Add log to data*/ $this->addLogToItem($out); $this->insertPaidItemLog($out,$item); // 提交事务 DB::commit(); return array( "alertContent"=>["title"=>self::strtoupper(_LC['shopsuccess']),"text"=>_LC['shopdoneitem'],"type"=>"success"], "newPoint"=>$this->getPoints()->point, "clickItem"=>"#cs-nav-close" ); }catch (Exception $e){ // 回滚事务 DB::rollBack(); return array( "alertContent"=>["title"=>self::strtoupper(_LC['error']),"text"=>$e->getMessage(),"type"=>"alert"], "newPoint"=>$this->getPoints()->point, "clickItem"=>"#cs-nav-close" ); } } // 新增方法:带行级锁查询用户积分 private function getPointsWithLock(){ // 替换为你的用户积分表和用户ID字段 return DB::table('user_points') ->where('user_id', $this->userId) ->lockForUpdate() ->first(); }
3. 多服务器部署场景:增加分布式锁
如果应用是多服务器部署,仅数据库行级锁可能存在延迟,可以增加Redis分布式锁,针对用户ID加锁,彻底避免跨服务器的并发请求:
public function buyItem(){ $lockKey = "buy_lock:user_{$this->userId}"; $lockTimeout = 10; // 锁超时时间,需大于购买操作的最大耗时 // 获取Redis锁,同一用户同一时间仅一个请求可进入 if(!Redis::set($lockKey, 1, 'EX', $lockTimeout, 'NX')){ throw new Exception(_LC["er_busy"]); // 提示用户"操作中,请稍后再试" } DB::beginTransaction(); try{ // ... 原有逻辑(已加行级锁和事务) DB::commit(); Redis::del($lockKey); // 释放锁 return array( "alertContent"=>["title"=>self::strtoupper(_LC['shopsuccess']),"text"=>_LC['shopdoneitem'],"type"=>"success"], "newPoint"=>$this->getPoints()->point, "clickItem"=>"#cs-nav-close" ); }catch (Exception $e){ DB::rollBack(); Redis::del($lockKey); // 异常时必须释放锁 return array( "alertContent"=>["title"=>self::strtoupper(_LC['error']),"text"=>$e->getMessage(),"type"=>"alert"], "newPoint"=>$this->getPoints()->point, "clickItem"=>"#cs-nav-close" ); } }
额外优化建议
- 前端增加防重复提交:点击购买按钮后立即禁用,直到请求返回结果,从源头减少并发请求。
- 优化积分扣减逻辑:在
getCashFromAccount中使用UPDATE user_points SET point = point - ? WHERE user_id = ? AND point >= ?,即使有漏网的并发请求,数据库层面也会因条件不满足而拒绝扣减。
内容的提问来源于stack exchange,提问作者Hatrick Legacy
相关产品推荐
相关产品推荐

