PHP 接口幂等性设计:防重提交的 4 种实现方案

zero
zero 见习用户
发布于 2026-09-23 19:55 ·4 浏览 ·0 回复

学完这篇,你能给自己的 PHP 接口加上一套「重复提交怎么点都只生效一次」的防护,知道四种方案各自的适用场景和坑在哪。

第一步:先定义清楚"什么算重复"

幂等的判定维度通常不是"IP"或"内容相同",而是一次业务请求的唯一标识。做法很简单:前端每次渲染表单/每次点击生成一个 `request_id`(UUID 或时间戳+随机串),放进隐藏字段或请求头:

<input type="hidden" name="request_id" value="<?= uuid() ?>">

后端所有方案都围绕这个 `request_id` 或业务主键展开。注意:同一个 `request_id` 重复到达 = 重复请求,第一次的返回值要缓存下来返还给后面几次。

第二步:方案一 —— 数据库唯一索引(兜底必做)

成本最低、最可靠的一层。建表时把去重维度做成唯一键:

ALTER TABLE `orders` ADD UNIQUE KEY `uk_user_req` (`user_id`,`request_id`);

插入时直接捕获重复键错误码,不写额外查询:

try {
    $pdo->prepare("INSERT INTO orders(user_id, request_id, amount) VALUES(?,?,?)")
        ->execute([$uid, $requestId, $amount]);
} catch (PDOException $e) {
    if ($e->errorInfo[1] === 1062) {   // MySQL 重复键
        return $this->returnOk($requestId); // 返回首次结果
    }
    throw $e;
}

注意:`request_id` 要来自客户端而非服务端自己生成,否则每次请求都是"新请求",唯一索引形同虚设。

第三步:方案二 —— Token 令牌(挡误触最有效)

思路是"令牌一次性消费"。表单渲染时生成 token 存入 Redis,TTL 给 600 秒:

$redis->setex("idem:token:$token", 600, 1);

提交时用 `DEL` 原子消费,返回 1 才放行:

if (!$redis->del("idem:token:$token")) {
    exit('请勿重复提交');
}

`DEL` 本身是原子操作,天然抗并发,不需要先 `GET` 再 `DEL`。

注意:CSRF token 不等于幂等 token。CSRF token 只防跨站伪造,同一个值可以反复提交成功;要做幂等必须用"消费即失效"的一次性 token。系统里那套"提交失败自动拉新 token 重发一次"的机制属于 CSRF 重试,别把它当成防重手段。

第四步:方案三 —— 状态机 + 乐观锁(业务型接口首选)

订单、悬赏采纳这类有明确状态流转的场景,把幂等判断压进 SQL 的 `WHERE`:

$stmt = $pdo->prepare(
    "UPDATE orders SET status='paid', version=version+1
     WHERE id=? AND status='unpaid' AND version=?"
);
$stmt->execute([$orderId, $version]);

if ($stmt->rowCount() === 0) {
    return $this->returnOk($orderId); // 已被处理过,直接返回
}

一次 SQL 同时完成了"校验+更新",不需要加锁也不会重复扣款。同理,悬赏采纳可以写成 `WHERE id=? AND status='open' AND user_id!=?`,天然挡住重复采纳和自己的回复。

第五步:方案四 —— Redis 分布式锁(管并发,不管重复)

$lockKey = "lock:pay:{$orderId}";
$ok = $redis->set($lockKey, 1, ['nx', 'ex' => 10]);
if (!$ok) {
    exit('正在处理中,请稍候');
}
try {
    // 业务逻辑
} finally {
    $redis->del($lockKey);
}

参数要点:`nx` 保证只有一个请求抢到,`ex` 是自动过期时间,必须大于业务最长耗时

注意:分布式锁只解决"同一瞬间多个请求并行进入",锁一过期第二次请求照样能进来。所以它只能作为辅助层,必须配合方案一或方案三使用。

小结

  • 定标识:客户端生成 `request_id`,一切幂等判断围绕它。
  • 唯一索引兜底:`user_id + request_id` 建唯一键,捕获 1062 错误返回首次结果,这层不能省。
  • 一次性 Token:Redis `DEL` 原子消费,专门挡用户手抖连点。
  • 状态机 + 乐观锁:`UPDATE ... WHERE status='旧状态'`,用 `rowCount()` 判断是否已处理。
  • 分布式锁:只防并发,不防重复,`ex` 到期时间要留够余量。
  • 前端按钮置灰、禁用只是体验优化,绝不能当作防重手段。
  • 实际落地按「Token → 状态机 → 唯一索引」三层叠加,任何一层漏掉都还有下一层接着。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-573.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~