php使用乐观锁加事务扣除余额为何只成功扣了1次?

public function userbuy()
{
        $user = $this->getUser(); //从数据库获取购买商品的用户信息
        $oldMoney = $user['balance']; //得到用户旧余额
        $orderOffer = $this->getOrderMoney($orderId); //获取次订单价格
        if($oldMoney < $orderOffer['price']) $this->error('账户余额不足');

        //乐观锁方案
        $newMoney = $oldMoney - $orderOffer['price']; //计算出新余额
        $newUser = SmsUser::where(['id' => $user['id'],'balance' => $oldMoney])->find();
        if(!$newUser) $this->error('用户不存在');
        //开启数据库事务
        Db::transaction(function () use($newUser,$orderId,$newMoney){
            $newUser->balance = $newMoney;
            $result = $newUser->save();
            if(!$result) $this->error('保存余额失败');

             //创建订单 code
             //扣除库存 code
             //创建用户余额变动记录 code    
            Db::commit(); //提交事务         
        })
}

我用上面这种组织代码,我测试同时创建请求5个订单,同时买5个商品,于是出现一个问题,就扣了用户1个订单的钱。比如单价,1块。买5个也就是花5块钱,总余额买之前是100.最后扣了5块钱,总余额就变成95元,现在是99元。明显不对。

于是我又开始折腾,改了代码,是这样。

public function userbuy()
{
        $user = $this->getUser(); //从数据库获取购买商品的用户信息
        $oldMoney = $user['balance']; //得到用户旧余额
        $orderOffer = $this->getOrderMoney($orderId); //获取次订单价格
        if($oldMoney < $orderOffer['price']) $this->error('账户余额不足');

        //乐观锁方案
        $newMoney = $oldMoney - $orderOffer['price']; //计算出新余额
        $newUser = SmsUser::where(['id' => $user['id'],'balance' => $oldMoney])->find();
        if(!$newUser) $this->error('用户不存在');
            $newUser->balance = $newMoney;
            $result = $newUser->save();
            if(!$result) $this->error('保存余额失败');
        //开启数据库事务
        Db::transaction(function () use(){
             //创建订单 code
             //扣除库存 code
             //创建用户余额变动记录 code    
            Db::commit(); //提交事务         
        })
}

就是可以正常扣5单的钱了,现在还搞不清什么原因,谁能告诉我什么原因吗?

代码我用的thinkphp6,用的。

把扣除余额的代码,拎出来,就可以了。但是这种又引发了另一个问题,数据一致性不能统一,如果后面的代码,创建订单,扣库存,===一系列操作有一个发生异常,他这个余额就不能回滚了。

阅读 2.9k
4 个回答

先说结论:你的使用方式不对。

这里主要提一个点,就是事务四大特性之一的「隔离性」。

只是你前一段代码中,实际执行的过程。

flowchart TD
    A[开始] --> B[查询用户余额]
    B --> C[计算订单价格]
    C --> D{判断用户余额大于订单金额}
    D --> |余额充足| E[使用 find 查询用户信息]
    D --> |余额不足| END[结束]
    E --> F[开启事务]
    subgraph 事务
    F --> G[修改用户余额并保存]
    G --> |保存成功| H[提交事务]
    G --> |保存失败| I[回滚事务]
    end
    H --> END
    I --> END

存在的问题

事务范围错误:,当我们在使用事务,事务所涉及的数据,都应该放到事务中去,让 MySQL 把这一部分数据隔离起来,把这些查询当成一个主体。

乐观锁使用错误,你说你想使用乐观锁,实际上并没有使用到,这是为什么?因为 find 查询的存在, find 查询实际上是会执行一个 select 语句把对应的数据查询出来,这时候拿到了主键 ID,然后你在下面调用 save 时,再把新金额写入。

乐观锁的预期 SQL 应该是这样的

update sms_user set balance = 99 where balance = 100 and id = 1;

即只有再 balance 为 100 时,才更新 balance 为 99,而实际上,你这里的 SQL 最终结果应该是这样的:

# 获取用户的信息
select * from sms_user where id = 1 and balance = 100; # find 语句
update sms_user set balance = 99  where id = 1;# update 语句

实际上不应该这样去使用。

Db::transaction 启动的事务不需要手动的调用 Db::commit(),因为 transaction 内部会自动调用 commit,不需要你手动 commit, transaction 默认你的操作都是可以 commit 的,如果你需要 rollback ,那你只需要在代码中抛出 (throw) 一个异常即可,transaction 内部会自动调用 rollback ,并且继续把异常抛出来。

看完第一个问题,再来看第二个问题。

为什么第二种方式又可以了呢?因为「隔离」了。

当然,其实如果你只是在第一个的基础上,把 $newUser = SmsUser::where(['id' => $user['id'],'balance' => $oldMoney])->find(); 这一句放到事务里也就可以了,但是在大量请求的时候,这种用法或许会出问题。当然,你这里的“乐观锁”这个使用本身就是错误的。

先声明我对 thinkphp6 不熟悉,仅从业务逻辑来分析。

原因分析:

以楼主的第二段代码做分析(理论上这段仍然是错误的)

public function userbuy()
{
        $user = $this->getUser(); //从数据库获取购买商品的用户信息
        $oldMoney = $user['balance']; //得到用户旧余额
        $orderOffer = $this->getOrderMoney($orderId); //获取次订单价格
        if($oldMoney < $orderOffer['price']) $this->error('账户余额不足');
        //【1】5个订单可能并发执行,获得相同的 $oldMoney

        //乐观锁方案
        $newMoney = $oldMoney - $orderOffer['price']; //计算出新余额
        //【2】接【1】5个并发订单获得相同$newMoney

        $newUser = SmsUser::where(['id' => $user['id'],'balance' => $oldMoney])->find();
        //【3】接【2】5个并发订单获得相同$newUser

        if(!$newUser) $this->error('用户不存在');
        $newUser->balance = $newMoney;
        $result = $newUser->save();
        //【4】接【3】5个并发订单都执行成功,但相当于执行了一次更新
        if(!$result) $this->error('保存余额失败');
        //开启数据库事务
        Db::transaction(function () use(){
             //创建订单 code
             //扣除库存 code
             //创建用户余额变动记录 code    
            Db::commit(); //提交事务         
        })
}

乐观锁其实用的不是很对。

建议方案

public function userbuy()
{
        $user = $this->getUser(); //从数据库获取购买商品的用户信息
        $orderOffer = $this->getOrderMoney($orderId); //获取次订单价格
        $oldMoney = $user['balance']; //得到用户旧余额
        if($oldMoney < $orderOffer['price']) $this->error('账户余额不足');
        $newUser = SmsUser::where(['id' => $user['id'],'balance' => $oldMoney])->find();
        if(!$newUser) $this->error('用户不存在');

        //开启数据库事务
        Db::transaction(function () use(){

            // 乐观锁方案,伪代码,需要楼主自己替换
            $price = $orderOffer['price'];
            update 账户表 set balance=balance-$price where id=$user['id'] and balance>=price
            // update返回结果(成功修改条数),判断是继续执行,还是回滚
            // 甚至如果下面的创建订单等操作可以保证成功的话,可以不用事务

             //创建订单 code
             //扣除库存 code
             //创建用户余额变动记录 code    
            Db::commit(); //提交事务         
        })
}

另:建议楼主搜索下“mysql的四种事务隔离级别”,这个对理解事务提交很重要。

楼主对乐观锁的理解有点偏差..
要用锁之前一定要开启事务否则锁相当于没锁差不多的效果。
锁是针对某个事务加锁的,为了防止事务脏读取或者防止其他事务更新当前事务所用到的加锁数据。

//开启事务
Db::startTrans();

//乐观锁:只有state为0的时候才更新,否则更新失败
$res =Db::name('order')->where(['order_id'=>1,'state'=>0])->update(['state'=>1]);
//更新失败则回滚事务
if(!$res) { Db::rollback(); return false; }

//悲观锁:对数据库某条数据加锁,确保读取到的一定是最新数据而不是脏数据。
//若有其他事务在读取该数据并加了锁,会一直等待到其他事务结束后再读取。
//会阻塞进程,一般少用。
$account = Db::name('user_account')->where('user_id',9527)->lock(true)->find();
if($account['money'] < $order['amount']){
  Db::rollback();
  return false;
}

Db::commit();

晕,讨论的这么激烈,但是这个事情和锁有这么重要的关系吗。核心问题是楼主在用秒杀的思路来设计普通结算好不好。

我测试同时创建请求5个订单,同时买5个商品

亲,你能告诉我这个测试的目的吗?5个订单买5个商品没毛病,但是结算是一次啊,你告诉我一下不写代码的情况下用户怎么同时完成5次结算?所以你的第一个版本唯一的问题是应该将本次结算的所有订单金额合并后再执行。
至于你修改的第二个版本根本没有意义,纯粹就是为了让你的错误测试逻辑跑出你希望的结果而已,早就脱离业务了。

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题