PHP 并发扣款,保证数据一致性(悲观锁和乐观锁)

:elephant:业务场景分析

用户购买商品的逻辑中,需要对用户钱包的余额进行查询和扣款

异常:如果同一用户并发执行多个业务进行”查询+扣款”的业务中有一定概率出现数据不一致

Tips:如果没有做限制单一接口请求频率,用户使用并发请求的手段也有概率出现数据不一致

扣款场景

Step1: 从数据库查询用户钱包余额

SELECT balance FROM user_wallet WHERE uid = $uid;
+---------+
| balance |
+---------+
| 100     |
+---------+
1 row in set (0.02 sec)

Step2: 业务逻辑

Tips: 文章分享处理同一用户并发扣款一致性,检查库存啥的逻辑略过

1. 查询商品价格,比如70元
2. 商品价格对比余额是否足够,足够时进行扣款提交订单逻辑

if(goodsPrice <= userBalance) {
    $newUserBalance = userBalance - goodsPrice;  
}else {
    throw new UserWalletException(['msg' => '用户余额不足']);
}

Step3: 将数据库的余额进行修改

UPDATE user_wallet SET balance=$newUserBalance WHERE uid = $uid

在没有并发的情况下,这个流程没有任何问题,原有余额100,购买70元的商品,剩余30元

异常场景

Step1: 用户并发购买业务A和业务B(不同实例/服务),一定概率并行查询余额是100

step1

Step2: 业务A和业务B分别扣款逻辑处理,业务A商品70结果余额30,业务B商品80结果余额20

step1

Step3:

1 业务A先进行修改,修改余额为30

step1

2 业务B后进行修改,修改余额为20

step1

此时异常出现了,原余额100元,业务A和业务B的商品价格总和150元(70+80)都购买成功且余额还剩20元。

异常点:业务A和业务B并行查询余额为100

解决方案

悲观锁:lock:

使用Redis悲观锁,例如抢到一个KEY才能继续操作,否则禁止操作

封装了一个开箱即用的RedisLock

<?php

use Ar414\RedisLock;

$redis = new \Redis();
$redis->connect('127.0.0.1','6379');

$lockTimeOut = 5;
$redisLock = new RedisLock($redis,$lockTimeOut);

$lockKey    = 'lock:user:wallet:uid:1001';
$lockExpire = $redisLock->getLock($lockKey);

if($lockExpire) {
    try {
        //select user wallet balance for uid
        $userBalance = 100;
        //select goods price for goods_id
        $goodsPrice = 80;

        if($userBalance >= $goodsPrice) {
            $newUserBalance = $userBalance - $goodsPrice;
            //TODO set user balance in db
        }else {
            throw new Exception('user balance insufficient');
        }
        $redisLock->releaseLock($lockKey,$lockExpire);
    } catch (\Throwable $throwable) {
        $redisLock->releaseLock($lockKey,$lockExpire);
        throw new Exception('Busy network');
    }
}

乐观锁

使用CAS(Compare And Set)

在set写回的时候,加上初始状态的条件compare,只有初始状态不变的时候才允许set写回成功,保证数据一致性的方法

将:

UPDATE user_wallet SET balance=$newUserBalance WHERE uid = $uid

改为:

UPDATE user_wallet SET balance=$newUserBalance WHERE uid = $uid AND balance = $oldUserBalance

这样的话并发操作时只有一个是执行成功的,根据affect rows是否为1判断是否成功

结语

ar414

  • 解决方案有很多,这只是其中一种解决方案
  • 使用Redis悲观锁的方案会降低吞吐量
本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 6个月前 自动加精
AR414
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
讨论数量: 14

我一般这样写

        $key = 'cancel_order:' . $order->id;
        if (!Cache::add($key, 1, 10)) {
            return ['code' => 305, 'message' => '操作频繁'];
        }


        DB::beginTransaction();

        try {   
            ...
            DB::commit();
        } catch (\Exception $exception) {
            DB::rollBack();       
        } finally {
            Cache::forget($key);
        }
4个月前 评论
LW_aravel 4个月前
goodgood 2周前
codejam

mysql 中间件可以解决这个问题吧

6个月前 评论

修改余额之前 把用户余额查询出来; 修改余额带上修改之前的余额作为条件

6个月前 评论
AR414 (楼主) 6个月前

操作sql语句的时候使用原子操作 直接在sql上增减,不要在程序里面计算,会好一些,但也不是根本办法

6个月前 评论

Redis 悲观锁没获取到锁,该咋处理,好像程序没有阻塞,只能返回4XX了

6个月前 评论
AR414 (楼主) 6个月前

将余额字段改为非负,当修改为负数时,会失败,一旦失败我们就可以处理了。

6个月前 评论
giao哥 6个月前

@AR414 ,RedisLock 那里解锁 releaseLock(),是不是不够严谨,可能存在解锁掉其他锁的情况

5个月前 评论
AR414 (楼主) 5个月前
be-be (作者) 5个月前

AND balance = $oldUserBalance 改成 AND balance >= $oldUserBalance 是不是好一点啊?不然,我中间充值了咋办?

5个月前 评论
AR414 (楼主) 5个月前
bynow1994 2个月前
AR414 (楼主) 15小时前

想请教一下:虽然业务A、B在不同的服务器,但是不能直接用排它锁嘛(for update)...排它锁应该是 mysql 层面的而非 php 层面的吧...

5个月前 评论
AR414 (楼主) 15小时前

现在几乎不会使用悲观锁了,都是乐观锁实现

4个月前 评论
LW_aravel 4个月前

我一般这样写

        $key = 'cancel_order:' . $order->id;
        if (!Cache::add($key, 1, 10)) {
            return ['code' => 305, 'message' => '操作频繁'];
        }


        DB::beginTransaction();

        try {   
            ...
            DB::commit();
        } catch (\Exception $exception) {
            DB::rollBack();       
        } finally {
            Cache::forget($key);
        }
4个月前 评论
LW_aravel 4个月前
goodgood 2周前

为什么不用FOR UPDATE?

3个月前 评论

用Lua写redis锁吧

3个月前 评论
AR414 (楼主) 15小时前

这个靠谱点,lua 写个等待锁

2个月前 评论

谢谢分享

2周前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!