请问CAS并发更新余额失败时,怎么加重试机制

Forsend

问题描述

CAS更新账户余额,并且记录流水变动,大致逻辑如下:

1、查询原有余额

原有余额 = select 余额 from 账户表 where uid = 1;

2、更新余额

更新数量 = update 账户表 set 余额=余额- 变化金额 where uid = 1 and 余额 = 原有余额

3、记录流水

insert into 流水表 values(uid, 原有余额, 变化金额, 现有余额)

当第二步的更新数量为0时,表示扣费失败,会抛异常并回滚

现在问题是,当同一个用户并发更新余额失败时,怎么加重试机制?
业务里同一个用户可以有多个附属账号同时操作扣费这个用户余额,所以是有这种场景的
非webman的话,可能是整个流程加个重试次数,每次失败sleep一下然后重试
现在用webman,这种问题有什么好的解决办法么?

481 5 1
5个回答

rbb

如果是webman,可以引入一下消息队列,让步骤2这里抛出异常时,丢到消息队列中,消费失败,也可以再重试消费

  • Forsend 2023-12-13

    业务是请求api接口扣费,扣费不管成不成功都要响应给用户端,应该不能放到队列里处理

  • rbb 2023-12-13

    那你写的时候,就在里面写一下重调函数逻辑(类似递归,加个次数限制),或者返回一个参数让客户端重试。

meows

你要么 uid for update 。
要不你就队列异步实现充值,推荐这种方式。(当然消息通知就不是及时的)
好处就是不会阻塞http worker 进程处理请求,

当然该lock还是要lock,不要用sleep()这个是秒级别,你可以选择用usleep() 这种控制微妙或者毫秒(重试)
webman redis 队列配置文件有个每个任务失败次数,你上锁失败就重新放回队列(说明出现并发情况)
如果达到webman 任务失败次数上限,大概率这个任务就是失败了。(需要人工干预)

  • Forsend 2023-12-13

    业务是请求api接口扣费,扣费不管成不成功都要响应给用户端,应该不能放到队列里处理

  • Forsend 2023-12-13

    其余框架的做法,可能就是简单加个usleep然后重试几次,尽量保证用户不出现扣费失败这种情况。但是webman不是不能用sleep函数么

  • meows 2023-12-13

    当然能用

  • meows 2023-12-13

    usleep() 时间短一点,worker 数量稍微大一点

charlescc

更新余额,我们是加业务锁的,同一时间 只能一个先完成

  • Forsend 2023-12-22

    同一时间肯定是要保证只有一个能扣费成功的。
    因为我这边业务场景,一个账号是允许多个附属账号同时登录操作扣费的,都是扣同一个账户的余额。所以想尽量保证这种情况下,用户都能操作成功,避免出现扣费失败让用户以为软件有问题

  • charlescc 2023-12-22

    下面回复了一个,搞了个伪代码, 你看符合需求不

TM

如果不能出现负余额只能加锁吧

  • 暂无评论
charlescc

方案有几种。 看你们附属账号多不多,还有扣费逻辑执行时间久不久。 简单来处理,就用缓存锁。 比如 laravel cache 的lock,当然也可以自己实现。
第一种:

<?php
use Illuminate\Support\Facades\Cache;

/**
 * 尝试获取锁并执行闭包,带重试机制
 *
 * @param string $name 锁的名称
 * @param int $seconds 锁持续时间
 * @param Closure $callback 获取锁后执行的闭包
 * @param int $timeout 获取锁的超时时间
 * @param int $retryTimes 重试次数
 * @param int $waitMilliseconds 重试等待时间(毫秒)
 * @return mixed
 */
 function runWithRetry(string $name, int $seconds, Closure $callback, int $timeout = 0, int $retryTimes = 3, int $waitMilliseconds = 100)
{
    for ($i = 0; $i < $retryTimes; $i++) {
        $result = Cache::lock($name, $seconds)->block($timeout, function () use ($callback) {
            return $callback();
        });

        if ($result !== null) {
            return $result;
        }

        // 等待一段时间后重试
        usleep($waitMilliseconds * 1000);
    }

    return false;
}

// 使用示例
$result = runWithRetry('account_lock_123', 10, function () {
    // 执行扣费操作
    // ...扣费逻辑...
    return '扣费成功';
}, 5, 5, 200); // 重试5次,每次等待200毫秒

if ($result) {
    echo $result; // 显示操作结果
} else {
    echo '由于高并发,扣费操作未能执行,请稍后重试。';
}
开始
 │
 ├─ 尝试获取锁 (等待最多 $timeout 秒)
 │    │
 │    ├─ 成功获取 → 执行闭包 → 返回成功
 │    │
 │    └─ 失败获取 → 等待 $waitMilliseconds 毫秒 → 重试
 │
 └─ 重试次数用尽 → 返回失败

第二种:第二种,就用 for update 锁住行记录,其他事物得等待释放才能执行, 但是这种需要等待锁超时或者死锁问题。还是需要重试机制,设置次数。

建议用第一种,简单,灵活,也可以减少对数据库的直接负载

  • 暂无评论
🔝