PHP 接口幂等性设计:防重提交的 4 种实现方案
学完这篇,你能给自己的 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 → 状态机 → 唯一索引」三层叠加,任何一层漏掉都还有下一层接着。
转载请注明出处,版权归原作者所有。
见习用户





