Redis setex+DB唯一索引是幂等核心方案:先用setex原子写入request_id(5分钟过期),失败则拦截;再通过INSERT ON DUPLICATE KEY或唯一索引兜底,确保状态变更不重复。

用 redis.setex() 加唯一请求 ID 最常用
用户发一次请求,后端就生成一个全局唯一的 request_id(比如用 uniqid('', true) 或 openssl_random_pseudo_bytes()),然后尝试往 Redis 写入 request_id,设置过期时间(比如 5 分钟)。如果写入成功,说明是首次请求,继续执行业务;如果写入失败(redis.setex() 返回 false 或抛异常),说明该请求已存在,直接返回重复提交提示。
注意点:
-
setex必须是原子操作,PHP 的Redis::setex()或Predis\Client::setex()都满足,别手写exists + set两步,会竞态 - 过期时间不能太短(比如 1 秒),否则网络延迟或重试可能误判;也不能太长(比如 24 小时),占 Redis 内存且影响后续排查
-
request_id要由客户端传入(如 headerX-Request-ID)或服务端生成后透传给前端,不能每次请求都重生成
数据库唯一索引兜底,不是可选而是必须
Redis 可能宕机、超时、网络抖动,单靠它做幂等不保险。所有涉及状态变更的核心表(比如订单、支付、库存扣减),必须在关键字段上建唯一索引——例如 order_table(request_id) 或 payment_log(trace_id)。
实际写法示例:
立即学习“PHP免费学习笔记(深入)”;
INSERT INTO `order` (`user_id`, `goods_id`, `request_id`, `created_at`) VALUES (123, 456, 'req_abc789', NOW()) ON DUPLICATE KEY UPDATE status = status;
这样即使 Redis 失效,DB 层也能拦住重复插入。但要注意:
- 唯一索引字段不能是自增主键或时间戳这类必然不同的值
- 如果业务逻辑需要更新已有记录(比如补全字段),用
INSERT ... ON DUPLICATE KEY UPDATE;如果纯拦截,用INSERT IGNORE更轻量 - MySQL 8.0+ 支持函数索引,可用
MD5(request_id)缩小索引体积,但 PHP 端要同步计算,增加一致性风险
前端防抖 + 后端校验组合才真正可靠
只靠后端拦不住所有重复:用户狂点提交按钮、浏览器刷新、F5 重发、代理重试……这些都会带相同参数打进来。所以前端必须配合。
常见做法:
- 按钮点击后立即置灰 + 显示“提交中”,禁用期间不响应新点击
- 用
localStorage记录最近一次提交的request_id和时间戳,3 秒内相同 URL 不发新请求 - 关键接口加
fetch()的signal控制,避免用户切换页面后请求还在后台跑
但前端永远不可信,所以它的作用只是降低后端压力和改善体验,不能替代后端幂等逻辑。
幂等性不是开关,是按接口粒度设计的
同一个系统里,不同接口的幂等实现方式可能完全不同。比如:
- 创建订单:依赖
request_id+ Redis + DB 唯一索引 - 修改订单状态:用状态机 + 条件更新(
UPDATE order SET status = 'paid' WHERE id = 123 AND status = 'unpaid') - 支付回调:必须校验第三方签名 + 自己的
out_trade_no唯一索引,且回调需支持多次重入
最容易被忽略的是:没有统一的幂等上下文。比如 request_id 在日志、MQ 消息、DB 字段、Redis key 里格式不一致,或者漏传到异步任务里,导致补偿逻辑失效。这事一旦出问题,往往得人工对账。











