引用自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)
);
最终推荐方案
综合以上优化,推荐以下组合:
前端:
- 按钮禁用 + 防抖,减少用户误操作。
后端:
- 使用 Redis::set 的 NX 和 EX 选项实现原子锁,取代 SETNX + EXPIRE。
- 使用 Lua 脚本确保锁释放的原子性。
- 结合唯一请求令牌(withdraw\_token)进一步防止重复提交。
- 数据库唯一约束作为最后防线,防止重复订单插入。
流程:
- 用户进入页面,获取 withdraw\_token。
- 用户提交请求,前端禁用按钮并携带 withdraw\_token。
后端:
- 验证 withdraw\_token,立即失效。
- 使用 SET NX EX 获取 Redis 锁,设置 10 秒过期。
- 在数据库事务中检查订单唯一性,插入订单并处理业务逻辑。
- 使用 Lua 脚本释放锁。
其他注意事项:
- 锁过期时间:根据业务需求调整(如 10 秒),确保足够处理请求但不过长。
- Redis 可用性:在 Redis 不可用时,考虑降级到数据库锁(如 SELECT FOR UPDATE)。
- 监控与日志:记录所有请求和锁操作,便于排查问题。
- 幂等性:确保支付接口支持幂等性,防止重复扣款。
通过这些优化,可以彻底解决 SETNX + EXPIRE 的非原子性问题,避免死锁,同时保证防重复提交的高效性和可靠性。