PHP后端应使用Redis原子性SET命令(如SET submit_token:abc123 1 EX 30 NX)校验一次性token,返回OK则为新请求,否则返回425错误并终止执行;token须由PHP生成(bin2hex(random_bytes(16)))、随页面或API下发,严禁前端生成或长期存储。

PHP后端如何识别并拦截重复提交
纯前端防抖(比如按钮置灰、加loading)根本不可靠——用户可以绕过JS直接发请求,或者用Postman反复重放。真正的防抖必须由PHP后端验证请求是否“已处理过”。核心思路是:每次提交带唯一标识(如submit_token),PHP收到后先查这个token是否已存在且未过期,存在则直接返回错误,不执行业务逻辑。
注意:不能只依赖$_SESSION存token——分布式部署时session可能不共享;也不能只用数据库锁——高并发下性能差。稳妥做法是用Redis做原子性校验:
-
SET submit_token:abc123 1 EX 30 NX(30秒内唯一,NX保证仅首次成功) - 返回
1表示新请求,继续处理;返回null说明已被提交过 - 业务成功后无需主动删token——EX自动过期,避免清理遗漏
如何生成和下发submit_token给前端
token不能由前端生成(可伪造),必须由PHP生成并随页面/接口响应一起返回。常见两种场景:
表单页渲染时注入:
立即学习“PHP免费学习笔记(深入)”;
session_start();
$token = bin2hex(random_bytes(16));
$_SESSION['submit_token'] = $token;
// 同时写入Redis(供接口校验)
redis()->setex('submit_token:'.$token, 30, 1);
API接口预获取token(适合SPA):
// /api/get-submit-token echo json_encode(['token' => bin2hex(random_bytes(16))]);
关键点:
- token长度建议≥32字符(避免碰撞),用
random_bytes()而非mt_rand() - 不要把token塞进cookie或localStorage长期存——每次提交都应换新token
- 如果用
$_SESSION中转,需确保session未被劫持(启用session.cookie_httponly)
提交接口里怎么校验submit_token
接收POST/JSON数据后,第一步不是处理业务,而是校验token有效性。典型流程:
$token = $_POST['submit_token'] ?? '';
if (empty($token) || !is_string($token) || strlen($token) < 32) {
http_response_code(400);
exit('Invalid token');
}
// 原子性检查并标记已使用
$result = redis()->set('submit_token:'.$token, 1, ['nx', 'ex' => 30]);
if ($result !== 'OK') {
http_response_code(425);
exit('Request already submitted');
}
注意几个坑:
- 错误码别用
409 Conflict——语义不符,推荐425 Too Early(RFC 8470明确用于防重放) - Redis命令必须用
SET ... NX EX组合,分开GET+SET会竞态 - 如果业务逻辑抛异常,token已占用但没提交成功——没关系,30秒后自动释放,用户稍等重试即可
为什么不用数据库唯一索引做防重
有人想在订单表加UNIQUE(submit_token),靠数据库报错拦截。这看似简单,但实际问题很多:
- 插入失败时,PHP捕获的是
SQLSTATE[23000],需额外解析错误码,耦合度高 - 数据库写入比Redis SET慢一个数量级,高并发下成为瓶颈
- 如果业务需要先查库存再下单,唯一索引拦不住“查+插”之间的重复(幻读)
- 订单表字段加索引会影响主键写入性能,尤其分库分表后更难维护
Redis的原子性SET是目前最轻量、最可靠的方案,只要确保Redis可用(主从+哨兵足够),就不用为防抖单独设计复杂状态机。
真正麻烦的是token生命周期管理——比如用户开两个标签页,一个提交了,另一个的token还在有效期内,点提交就会被拒。这种情况得配合前端做页面级协调,比如用localStorage广播事件,但这已超出PHP能控制的范围。



















