MENU

用户提现场景下的多次打款问题

June 6, 2025 • PHP

引用自Grok:https://x.com/i/grok/share/yz2KLB2SZVpdwabuOHTa1oV8z

1. 使用 Redis 的 SET 命令(原子操作)

Redis 2.6.12 及以上版本支持 SET 命令的扩展选项,可以同时设置值和过期时间,具备原子性,避免 SETNX + EXPIRE 的问题。

优化后的代码:

<?php
namespace app\controller;

use support\Request;
use support\Redis;
use support\Response;

class WithdrawController
{
    public function withdraw(Request $request)
    {
        $userId = $request->session()->getId();
        $amount = $request->post('amount');
        $lockKey = 'withdraw_lock:' . $userId;
        $lockValue = uniqid(); // 唯一值,防止误删其他锁

        // 尝试获取锁(原子操作,设置 10 秒过期)
        $acquired = Redis::set($lockKey, $lockValue, ['NX', 'EX' => 10]);
        if (!$acquired) {
            return response()->json(['error' => '请勿重复提交'], 429);
        }

        try {
            // 业务逻辑:检查余额、生成订单、调用支付接口
            $result = app('withdrawService')->process($userId, $amount);
            return response()->json(['message' => '提现成功', 'data' => $result]);
        } catch (\Exception $e) {
            // 释放锁
            if (Redis::get($lockKey) === $lockValue) {
                Redis::del($lockKey);
            }
            return response()->json(['error' => '提现失败: ' . $e->getMessage()], 500);
        } finally {
            // 确保锁被当前请求释放
            if (Redis::get($lockKey) === $lockValue) {
                Redis::del($lockKey);
            }
        }
    }
}

关键点:

  • SET 命令的 NX(仅在键不存在时设置)和 EX(设置过期时间)选项保证原子性。
  • 使用 $lockValue(唯一值)确保只有锁的持有者能释放锁,防止误删其他请求的锁。
  • finally 块确保即使发生异常,锁也能被正确释放。

2. 结合 Lua 脚本(更严格的锁管理)

如果需要更高的可靠性,可以使用 Redis 的 Lua 脚本实现锁的获取和释放,保证释放锁时的原子性(避免在高并发场景下误删锁)。

Lua 脚本实现锁释放:

创建 Lua 脚本文件(release\_lock.lua):

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

后端代码:

<?php
namespace app\controller;

use support\Request;
use support\Redis;
use support\Response;

class WithdrawController
{
    public function withdraw(Request $request)
    {
        $userId = $request->session()->getId();
        $amount = $request->post('amount');
        $lockKey = 'withdraw_lock:' . $userId;
        $lockValue = uniqid();

        // 尝试获取锁(原子操作)
        $acquired = Redis::set($lockKey, $lockValue, ['NX', 'EX' => 10]);
        if (!$acquired) {
            return response()->json(['error' => '请勿重复提交'], 429);
        }

        try {
            // 业务逻辑
            $result = app('withdrawService')->process($userId, $amount);
            return response()->json(['message' => '提现成功', 'data' => $result]);
        } catch (\Exception $e) {
            return response()->json(['error' => '提现失败: ' . $e->getMessage()], 500);
        } finally {
            // 使用 Lua 脚本释放锁
            $luaScript = file_get_contents(__DIR__ . '/release_lock.lua');
            Redis::eval($luaScript, [$lockKey, $lockValue], 1);
        }
    }
}

优点:

  • Lua 脚本在 Redis 服务端执行,保证释放锁的原子性。
  • 只有当锁的值匹配 $lockValue 时才会删除,避免误删其他请求的锁。
  • 适合高并发场景,减少锁管理的复杂性。

3. 结合唯一请求令牌(进一步防重复)

为防止 Redis 锁失效(如 Redis 宕机)或绕过前端重复提交,结合唯一请求令牌机制:

代码示例:

<?php
namespace app\controller;

use support\Request;
use support\Redis;
use support\Response;

class WithdrawController
{
    public function createToken(Request $request)
    {
        $token = uniqid('withdraw_', true);
        Redis::setex('withdraw_token:' . $token, 300, $request->session()->getId());
        return response()->json(['token' => $token]);
    }

    public function withdraw(Request $request)
    {
        $userId = $request->session()->getId();
        $token = $request->post('withdraw_token');
        $amount = $request->post('amount');
        $lockKey = 'withdraw_lock:' . $userId;
        $lockValue = uniqid();

        // 验证令牌
        $tokenKey = 'withdraw_token:' . $token;
        if (!Redis::exists($tokenKey) || Redis::get($tokenKey) !== $userId) {
            return response()->json(['error' => '无效的请求令牌'], 400);
        }
        Redis::del($tokenKey); // 立即失效令牌

        // 尝试获取锁
        $acquired = Redis::set($lockKey, $lockValue, ['NX', 'EX' => 10]);
        if (!$acquired) {
            return response()->json(['error' => '请勿重复提交'], 429);
        }

        try {
            // 业务逻辑
            $result = app('withdrawService')->process($userId, $amount);
            return response()->json(['message' => '提现成功', 'data' => $result]);
        } catch (\Exception $e) {
            return response()->json(['error' => '提现失败: ' . $e->getMessage()], 500);
        } finally {
            // 使用 Lua 脚本释放锁
            $luaScript = file_get_contents(__DIR__ . '/release_lock.lua');
            Redis::eval($luaScript, [$lockKey, $lockValue], 1);
        }
    }
}

前端配合:

  • 页面加载时请求 /api/withdraw/token 获取令牌。
  • 提交提现时携带 withdraw\_token

4. 数据库事务作为最后防线

即使 Redis 锁和令牌机制可能被绕过,数据库的唯一约束可以作为最后一道防线,防止重复订单插入。

代码示例:

public function withdraw(Request $request)
{
    $userId = $request->session()->getId();
    $token = $request->post('withdraw_token');
    $amount = $request->post('amount');
    $lockKey = 'withdraw_lock:' . $userId;
    $lockValue = uniqid();

    // 验证令牌
    $tokenKey = 'withdraw_token:' . $token;
    if (!Redis::exists($tokenKey) || Redis::get($tokenKey) !== $userId) {
        return response()->json(['error' => '无效的请求令牌'], 400);
    }
    Redis::del($tokenKey);

    // 尝试获取锁
    $acquired = Redis::set($lockKey, $lockValue, ['NX', 'EX' => 10]);
    if (!$acquired) {
        return response()->json(['error' => '请勿重复提交'], 429);
    }

    // 数据库事务
    return Db::transaction(function () use ($userId, $amount, $lockKey, $lockValue) {
        // 检查短时间内是否有相同订单
        $exists = Db::table('withdraw_orders')
            ->where('user_id', $userId)
            ->where('amount', $amount)
            ->where('created_at', '>=', date('Y-m-d H:i:s', time() - 10))
            ->exists();

        if ($exists) {
            return response()->json(['error' => '请勿重复提交'], 429);
        }

        // 插入订单
        $orderId = uniqid('wd_' . $userId . '_', true);
        Db::table('withdraw_orders')->insert([
            'order_id' => $orderId,
            'user_id' => $userId,
            'amount' => $amount,
            'created_at' => date('Y-m-d H:i:s'),
        ]);

        // 业务逻辑
        $result = app('withdrawService')->process($userId, $amount);
        return response()->json(['message' => '提现成功', 'data' => $result]);
    })->finally(function () use ($lockKey, $lockValue) {
        // 释放锁
        $luaScript = file_get_contents(__DIR__ . '/release_lock.lua');
        Redis::eval($luaScript, [$lockKey, $lockValue], 1);
    });
}

数据库表结构:

CREATE TABLE withdraw_orders (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    order_id VARCHAR(50) NOT NULL,
    user_id BIGINT NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE INDEX idx_user_amount_time (user_id, amount, created_at)
);

最终推荐方案

综合以上优化,推荐以下组合:

  1. 前端

    • 按钮禁用 + 防抖,减少用户误操作。
  2. 后端

    • 使用 Redis::setNXEX 选项实现原子锁,取代 SETNX + EXPIRE
    • 使用 Lua 脚本确保锁释放的原子性。
    • 结合唯一请求令牌(withdraw\_token)进一步防止重复提交。
    • 数据库唯一约束作为最后防线,防止重复订单插入。

流程:

  1. 用户进入页面,获取 withdraw\_token
  2. 用户提交请求,前端禁用按钮并携带 withdraw\_token
  3. 后端:

    • 验证 withdraw\_token,立即失效。
    • 使用 SET NX EX 获取 Redis 锁,设置 10 秒过期。
    • 在数据库事务中检查订单唯一性,插入订单并处理业务逻辑。
    • 使用 Lua 脚本释放锁。

其他注意事项:

  • 锁过期时间:根据业务需求调整(如 10 秒),确保足够处理请求但不过长。
  • Redis 可用性:在 Redis 不可用时,考虑降级到数据库锁(如 SELECT FOR UPDATE)。
  • 监控与日志:记录所有请求和锁操作,便于排查问题。
  • 幂等性:确保支付接口支持幂等性,防止重复扣款。

通过这些优化,可以彻底解决 SETNX + EXPIRE 的非原子性问题,避免死锁,同时保证防重复提交的高效性和可靠性。