You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.26 15:25:02