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

业务场景分析

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

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

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 业务A后进行修改,修改余额为20

step1

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

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

解决方案

悲观锁

使用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判断是否成功

结语

  • 解决方案有很多,这只是其中一种解决方案
  • 使用Redis悲观锁的方案会降低吞吐量
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,我可以为您提供一个Java实现,示例代码如下: 1. 采用分布式实现: ```java public void deductMoney(String userId, BigDecimal amount) { String lockKey = "deduct_money_" + userId; RLock lock = redisson.getLock(lockKey); try { // 尝试获取,等待时间为10秒,的过期时间为5秒 if (lock.tryLock(10, 5, TimeUnit.SECONDS)) { BigDecimal balance = getBalance(userId); if (balance.compareTo(amount) >= 0) { updateBalance(userId, balance.subtract(amount)); // 成功,记录日志 log.info("User {} deduct money {} success", userId, amount); } else { // 余额不足,失败,记录日志 log.error("User {} deduct money {} failed, insufficient balance", userId, amount); } } else { // 获取失败,记录日志 log.error("User {} deduct money {} failed, get lock failed", userId, amount); } } catch (InterruptedException e) { // 异常处理,记录日志 log.error("User {} deduct money {} failed, unexpected error occurred: {}", userId, amount, e.getMessage()); } finally { lock.unlock(); } } ``` 2. 使用乐观实现: ```java public void deductMoney(String userId, BigDecimal amount) { BigDecimal balance = getBalance(userId); if (balance.compareTo(amount) >= 0) { int rows = updateBalanceWithVersion(userId, balance.subtract(amount), balance.getVersion()); if (rows > 0) { // 成功,记录日志 log.info("User {} deduct money {} success", userId, amount); } else { // 更新失败,记录日志 log.error("User {} deduct money {} failed, update failed", userId, amount); } } else { // 余额不足,失败,记录日志 log.error("User {} deduct money {} failed, insufficient balance", userId, amount); } } ``` 3. 采用事务控制实现: ```java @Transactional(rollbackFor = Exception.class) public void deductMoney(String userId, BigDecimal amount) { BigDecimal balance = getBalance(userId); if (balance.compareTo(amount) >= 0) { updateBalance(userId, balance.subtract(amount)); // 成功,记录日志 log.info("User {} deduct money {} success", userId, amount); } else { // 余额不足,失败,记录日志 log.error("User {} deduct money {} failed, insufficient balance", userId, amount); throw new RuntimeException("Insufficient balance"); } } ``` 4. 采用消息队列实现: ```java public void deductMoney(String userId, BigDecimal amount) { String message = userId + ":" + amount.toString(); rabbitTemplate.convertAndSend("deduct_money_queue", message); } @RabbitListener(queues = "deduct_money_queue") public void processMessage(String message) { String[] arr = message.split(":"); String userId = arr[0]; BigDecimal amount = new BigDecimal(arr[1]); BigDecimal balance = getBalance(userId); if (balance.compareTo(amount) >= 0) { updateBalance(userId, balance.subtract(amount)); // 成功,记录日志 log.info("User {} deduct money {} success", userId, amount); } else { // 余额不足,失败,记录日志 log.error("User {} deduct money {} failed, insufficient balance", userId, amount); } } ``` 以上是四种常用的解决并发场景下数据一致性问题的Java实现方式,您可以根据自己的实际情况选择适合自己的方式。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值