后端接口如何实现防重复提交?

在平时工作中,我们经常会碰到一些应用场景,需要我们去做防重复提交或防重复点击处理,比如像投票、抽奖等等。正常情况下,前后端的都要去做防重复点击处理。今天我们则重点介绍一下服务器端如何实现对接口的防重复提交。

比较常见的一种方案是,我们首先对请求生成一个唯一的标识,然后针对该标识去做防重复提交判断。

废话不多说,直接上代码:

// 示例代码,仅供参考
$request_signature = sha1(
    $request->method() .
    '|' . $request->server('SERVER_NAME') .
    '|' . $request->path() .
    '|' . $request->ip() .
    '|' . $authInfo['user_id']
);
$redis = app('redis')->connection('default');
if ($redis->get($request_signature)) {
    throw new Exception('请勿重复提交。');
}
$expire_time = 10; // 10 秒内只能发起一次请求
$redis->set($request_signature, 1, 'EX', $expire_time);
echo "请求成功" . PHP_EOL;

上面这种情况可能是大家比较常见的吧,但是这里面有一个很严重的问题,正常情况下,我们的接口运行良好,也不存在重复提交的问题。但是当遇到并发请求的时候,我们就会发现,这种方法根本起不到防止重复提交的作用。

下面这张截图是我在本地模拟并发请求的测试结果,可以看到,我们在 8 秒内发起了 100 次请求,结果居然有 3 次请求成功:

接口防重复提交并发测试结果

那如何解决这种情况呢?大家可能还记得我们前面在写互斥锁的时候用到了 Redis 的 setnx 命令。下面我们给出改进版的方案:

// 示例代码,仅供参考
$request_signature = sha1(
    $request->method() .
    '|' . $request->server('SERVER_NAME') .
    '|' . $request->path() .
    '|' . $request->ip() .
    '|' . $authInfo['user_id']
);
$redis = app('redis')->connection('default');
if ($redis->setnx($request_signature, 1) !== 1) {
    throw new Exception('请勿重复提交。');
}
$expire_time = 10; // 10 秒内只能发起一次请求
$redis->expire($request_signature, $expire_time);
echo "请求成功" . PHP_EOL;

同样,我们在本地模拟下并发请求测试,结果如下:

接口防重复提交并发测试结果

可以看到,同样是在 8 秒内发起了 100 次请求,结果只有 1 次请求成功。

写到这里,我们就已经能够实现后端接口的防重复提交了。

我们知道 setnx 之所以能成功做到在高并发下限制接口的重复提交,完全是基于 Redis 本身的原子性操作。想要在 Redis 中实现原子性操作,还有一种办法,那就是利用 Lua 脚本。

下面我再贴出另外一种实现方案:

// 示例代码,仅供参考
$request_signature = sha1(
    $request->method() .
    '|' . $request->server('SERVER_NAME') .
    '|' . $request->path() .
    '|' . $request->ip() .
    '|' . $authInfo['user_id']
);
$lua_script = <<<LUA
local cmd = redis.call
local req_uuid = KEYS[1]
local expire = ARGV[1]

if (cmd('EXISTS', req_uuid) == 1) then
    return cmd('INCR', req_uuid)
else
    cmd('SETEX', req_uuid, expire, 1)
    return 1
end
LUA;
$expire_time = 10; // 10 秒内只能发起一次请求
$req_count = app('redis')->eval($lua_script, 1, $request_signature, $expire_time);
if ($req_count > 1) {
    throw new Exception('请勿重复提交。');
} else {
    echo "请求成功-->{$req_count}" . PHP_EOL;
}

同样,我们拿到并发下进行测试,结果如下:

接口防重复提交并发测试结果

效果同 setnx 一样,同样在 8 秒内发起了 100 次请求,只有 1 次请求成功。但是,这种方案还有另外一个好处,那就是我不仅能做到接口的防重复提交,还能做到指定时间窗口内接口访问频率的限制。关于接口限流,我们这里就不展开说了,后面我会单独写一篇文章来专门介绍一些常见的接口限流的方案。

好了,到这里关于后端接口防重复提交的办法我们就介绍完了。如果您有什么其他更好的方案,欢迎评论区留言,大家相互学习~

写在最后

如果本文对您有所帮助或者有所启发,请帮忙扫描下方二维码或微信搜索 「自在牛马」 关注一下我的公众号,您的支持是我最大的写作动力。感谢~

拒绝白嫖,转载请注明出处。

自在牛马

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 2
/**
 * 锁使用方式:
 *      RedisUtil::lock('my_lock', 10, 5); // 参数:锁的名称、锁超时时间、获取锁等待时间
 *      var_dump('执行需要锁保护的代码块:' . datetime());
 *      sleep(3); // 模拟程序执行3秒
 *
 *      RedisUtil::release('my_lock'); // 参数:之前获取锁时使用的锁的名称
 *      var_dump('释放锁:' . datetime());
 */

    // 添加锁
    public static function lock(string $lockKey, int $lockTimeout, int $acquireTimeout = 5): void
    {
        $acquireStartTime = microtime(true);

        while (true) {
            $lockAcquired = redis()->set($lockKey, Context::get('request_id'), ['NX', 'EX' => $lockTimeout]);

            // 抢到锁
            if ($lockAcquired) {
                break;
            }

            // 等待锁时间
            if ((microtime(true) - $acquireStartTime) > $acquireTimeout) {
                throw new BusinessException('程序异常,请稍后再试'); // 获取Redis锁失败
            }

            usleep(1000 * 200); // 间隔200ms重试
        }
    }

    // 释放锁
    public static function release(string $lockKey): bool
    {
        /**
         * eval($script, $args = [], $numKeys = 0):方法第三个参数表示第二个参数中key值的数量。本脚本对应参数为1,即第二个参数中只有第一个为KEYS值
         * KEYS[1]:传入的key参数,要操作的键名。本脚本对应参数为 $lockKey
         * ARGV[1]:传入的key在redis对应的键值。本脚本对应参数为 Context::get('request_id')
         */
        $script = <<<LUA
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
LUA;
        return (bool)redis()->eval($script, [$lockKey, Context::get('request_id')], 1);
    }
2个月前 评论

Redis Lua 维护都异常复杂, 不宜这样搭桥实现, 不为跳坑而要埋坑. 如果我没猜错的话, 可以结合mysql来实现.

2个月前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!