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

Laravel并发请求致余额无法及时更新的问题解决咨询

解决Laravel并发余额更新导致负数的问题

你遇到的是典型的竞态条件问题:并发请求时,第二个请求在第一个请求完成余额更新前就读取了旧余额,导致校验通过后最终余额超支。以下是几种可行的解决方案:


1. 使用数据库原子更新(最可靠)

直接通过数据库的原子操作完成余额扣除,避免先查询后更新的非原子步骤,数据库会自动保证操作的一致性,从根本上杜绝竞态。

修改控制器事务内的余额更新逻辑,用decrement结合条件判断:

DB::transaction(function () use ($user, $sum) {
    // 原子扣除余额,同时校验余额是否足够
    $affectedRows = DB::table('wallet_user')
        ->where('user_id', $user->id)
        ->where('wallet_id', 1) // USD钱包ID
        ->where('balance', '>=', $sum)
        ->decrement('balance', $sum);

    if ($affectedRows === 0) {
        throw new \RuntimeException('余额不足,操作失败');
    }

    // 后续创建交易记录的逻辑...
    $user->transactions()->create([
        // ... 你的字段
    ]);
});

同时可以把请求类里的SendMoneyBalance校验去掉,或者保留作为前端友好提示,核心校验交给数据库的原子操作。


2. 悲观锁(行级锁)

在查询用户钱包余额时,给对应的行加排他锁,确保同一时间只有一个请求能修改这条记录。

修改请求验证类的规则方法:

public function rules()
{
    // 查询时加排他锁,防止并发读取旧余额
    $balance_usd = auth()->user()->wallets()
        ->where('currency', 'USD')
        ->lockForUpdate()
        ->first()
        ->pivot->balance;

    return [
        'amount' => ['numeric', 'required', new NotZeroAmount(), new SendMoneyBalance($balance_usd)],
        'cardId' => ['required'],
        'ArrayHashId' => ['required'],
    ];
}

同时控制器事务内的查询也要加锁:

DB::transaction(function () use ($user, $request, $sum) {
    $balance = $user->wallets()
        ->where('currency', 'USD')
        ->lockForUpdate()
        ->first()
        ->pivot->balance;

    $user->wallets()->updateExistingPivot(1, ['balance' => $balance - $sum]);

    // 创建交易记录...
});

注意:锁会在事务结束后自动释放,这种方式适合并发量不是极高的场景,避免长时间锁等待影响性能。


3. 乐观锁

给钱包关联表(wallet_user)新增一个version字段(整数类型,默认0),每次更新时校验版本号,只有版本匹配才允许更新,不匹配则说明有并发修改,抛出异常让用户重试。

1. 新增字段

生成迁移文件添加version字段:

Schema::table('wallet_user', function (Blueprint $table) {
    $table->unsignedInteger('version')->default(0);
});

2. 修改更新逻辑

DB::transaction(function () use ($user, $sum) {
    $walletPivot = $user->wallets()
        ->where('currency', 'USD')
        ->first()
        ->pivot;

    // 更新时校验版本号,只有当前版本匹配才执行更新
    $updated = $user->wallets()->updateExistingPivot(1, [
        'balance' => $walletPivot->balance - $sum,
        'version' => $walletPivot->version + 1
    ], ['version' => $walletPivot->version]);

    if (!$updated) {
        throw new \RuntimeException('操作过于频繁,请稍后重试');
    }

    // 创建交易记录...
});

这种方式不需要长时间锁,适合高并发场景,但需要处理更新失败的情况,前端要给用户友好提示。


4. 请求限流(辅助防护)

用Laravel自带的限流中间件,限制用户短时间内对该接口的请求次数,从源头减少并发请求的概率。

在路由定义中添加限流中间件:

Route::post('/load-funds', [YourController::class, 'loadFunds'])
    ->middleware(['auth', 'throttle:2,1']); // 1分钟内最多2次请求

可以根据业务调整限流规则,比如针对用户ID做更精准的限流:

// 在App\Providers\RouteServiceProvider中自定义限流规则
RateLimiter::for('user-fund-load', function (Request $request) {
    return Limit::perMinute(1)->by($request->user()->id);
});

// 路由使用自定义限流
Route::post('/load-funds', [YourController::class, 'loadFunds'])
    ->middleware(['auth', 'throttle:user-fund-load']);

总结

  • 优先选择数据库原子更新,逻辑简单且最可靠;
  • 并发量高的场景可以用乐观锁;
  • 悲观锁适合并发量中等、对数据一致性要求极高的场景;
  • 限流作为辅助手段,减少恶意或误操作带来的并发请求。

内容的提问来源于stack exchange,提问作者Abylay Omar

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.10 01:10:12