编程 双击 Submit 插了两行:一次性 token、幂等键、CSRF 各挡哪种重复

2026-11-02 00:04:38

双击 Submit 插了两行:一次性 token、幂等键、CSRF 各挡哪种重复

「防重复提交」这个词把几件不同的事装在一起,混着用就会漏。先分清三者各自挡什么:

  • CSRF token:per-session 稳定值,同一个 _csrf 连续提交两次都会通过。它挡伪造来源,不挡重复。
  • Idempotency-Key:header 驱动,浏览器发裸表单不会带这个 header,得客户端代码补。
  • 一次性提交 token:每次渲染都生成,提交一次就作废,才真正对应「这份表单最多写一次」。

只有 CSRF 保护的创建表单,双击照样插两行。UNIQUE 约束能挡一部分,但发评论、扣款、发邀请这类写操作没有天然唯一键。而 autumn-web 的 One-Time Submit Tokens 把这件事做成了服务端默认行为,不需要客户端 JS。

一次性提交 token 的三个动作:Mint / Scan / Consume

  • Mint:每次渲染表单生成新随机 token,放进 request extensions。
  • Scan:对 POST/PUT/PATCH/DELETE 扫表单体里的 _submit_token 字段;没有该字段的请求原样放行,所以只有嵌了字段的表单被保护。
  • Consume:在共享幂等存储里查 token。

消费阶段对应三种结果:

  1. 首次使用:取 in-flight 锁,跑 handler,把 2xx/3xx 响应记在 token 下,释放锁。
  2. 重放 token:原样返回第一次的响应,带 x-submit-token-replayed 头,handler 不再执行。
  3. 并发重复:第二个请求抢锁失败,返回 409 Conflict,而不是再跑一次写操作。

token 是每次渲染的随机 UUID,全局唯一,不需要按 method/path/principal 做作用域。存储默认继承 [idempotency].backend:开发用内存,生产用 Redis。横向扩容时这点很关键——双击被负载均衡打到另一个副本,共享 Redis 下 token 仍被视为已消费,回放第一次响应,全集群只跑一次。

默认值:enabled=true、field_name="_submit_token"、ttl_secs=600(已消费 token 的响应回放窗口 10 分钟)、in_flight_ttl_secs=86400、exempt_paths=[]。生产环境显式写 [security.submit_token].backend="memory" 会在启动时快速失败:内存存储是 per-process 的,保护不了多副本。留空继承 Redis,或显式写 "redis"。

用 go-redis 实现「消费一次 + in-flight 回放」:

package submit

import (
"context"
"errors"
"strconv"
"time"

"github.com/redis/go-redis/v9"
)

var ErrInFlight = errors.New("duplicate submit in flight")

type Guard struct {
rdb         *redis.Client
replayTTL   time.Duration // 已消费 token 的响应回放窗口,默认 10m
inFlightTTL time.Duration // in-flight 锁的安全过期,默认 24h
}

type Result struct {
Status   int
Body     []byte
Replayed bool
}

// Consume 对同一个 token 至多执行一次 h,后续调用回放第一次的响应。
func (g *Guard) Consume(ctx context.Context, token string,
h func(context.Context) (int, []byte, error)) (Result, error) {

respKey := "submit:resp:" + token
lockKey := "submit:lock:" + token

// 已经跑完的直接回放
if status, body, ok := g.load(ctx, respKey); ok {
return Result{Status: status, Body: body, Replayed: true}, nil
}

// 抢 in-flight 锁:单条 SET ... EX ... NX,不是 SETNX + EXPIRE
owner := strconv.FormatInt(time.Now().UnixNano(), 36)
ok, err := g.rdb.SetNX(ctx, lockKey, owner, g.inFlightTTL).Result()
if err != nil {
return Result{}, err
}
if !ok {
// 可能是上一个持锁者刚写完响应还没释放,再查一次回放
if status, body, ok := g.load(ctx, respKey); ok {
return Result{Status: status, Body: body, Replayed: true}, nil
}
return Result{}, ErrInFlight // 交给上层转 409
}
defer g.unlock(ctx, lockKey, owner)

status, body, err := h(ctx)
if err != nil || status >= 500 {
return Result{}, err // 失败不写回放,token 仍可重试
}
g.save(ctx, respKey, status, body)
return Result{Status: status, Body: body}, nil
}

func (g *Guard) load(ctx context.Context, key string) (int, []byte, bool) {
m, err := g.rdb.HGetAll(ctx, key).Result()
if err != nil || len(m) == 0 {
return 0, nil, false
}
status, _ := strconv.Atoi(m["status"])
return status, []byte(m["body"]), true
}

func (g *Guard) save(ctx context.Context, key string, status int, body []byte) {
pipe := g.rdb.TxPipeline()
pipe.HSet(ctx, key, "status", status, "body", body)
pipe.Expire(ctx, key, g.replayTTL)
_, _ = pipe.Exec(ctx)
}

var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
`)

func (g *Guard) unlock(ctx context.Context, key, owner string) {
_ = unlockScript.Run(ctx, g.rdb, []string{key}, owner).Err()
}

如果不需要回放响应,只要「第二次直接拒」,判据落在 DEL 的返回值上,不用先 GET:

// DEL 返回被删除的键数量,1 表示这次才轮到它,0 表示 token 不存在或已用过
func (g *Guard) CheckOnce(ctx context.Context, token string) (bool, error) {
n, err := g.rdb.Del(ctx, "submit:token:"+token).Result()
if err != nil {
return false, err
}
return n == 1, nil
}

客户端侧:自动生成 idempotency key

use-safe-submit 是框架无关的 helper,客户端用 crypto.randomUUID() 生成 key:FormData 场景追加 idempotency-key 隐藏字段,fetch + JSON 场景临时包住 fetch 设 Idempotency-Key 头。服务端 wrapper 自动从头、form data、JSON body 三处取键。

生产推荐 Redis(Upstash)store,默认 TTL 24 小时。跨标签页安全靠服务端强制:多个标签的重复提交复用同一个 key 会被拒。验收行为是双击只有 1 次服务端调用,TTL 内刷新重提交同样 body 返回 409,两个标签同意图第二个 409,502/503 重试则重试后成功——失败请求不占位,这点和上面 Go 版本「5xx 不写回放」一致。

六种落地实现里的顺序问题

前后端防重复提交的 6 种落地实现 把常见做法列了一遍。前端三招是按钮禁用(disabled + 「提交中…」,finally 里恢复)、防抖 debounce、axios 拦截器用 pendingRequests Map 按 url + JSON.stringify(data) 去重。UX 好,但 F12 改 JS、Postman 重放都能绕过。

Token 机制:进表单页时服务端生成 UUID 存 Session/Redis,前端隐藏域带上,后端校验通过后立即删除。缺点是依赖 Session,分布式要共享存储或改 Redis。

AOP + Redis(分布式推荐):自定义 @NoRepeatSubmit(lockTime=5),切面里 setIfAbsent(key,"1",lockTime,SECONDS),key 形如 repeat:submit: + userId + : + uri + : + md5(params)。这里有个明确的坑:SETNX 之后再 EXPIRE 是两条命令,不是原子操作,高并发或进程中途崩溃会留下永不过期的键。应改成 SET key value EX seconds NX,或 SETNX + Lua。删除锁同理,用 Lua 校验后删,避免误删别人的锁。

key 里带 md5(params) 属于指纹模式,误杀来自业务语义而非并发:同一用户短时间内发两条内容相同的评论、下两笔金额相同的订单,会被判成重复。锁过期时间 5~10 秒够用,但要清楚这段时间内它是「内容级」而不是「请求级」去重。

拦截器 + Redis 增强版同理,preHandle 里 hasKey 判定再 set 5s,注册即全局生效。重复提交不要返 500,应提示「请勿重复提交」。总结表里,前端禁用按钮=辅助,Session Token=推荐,AOP+Redis=强烈推荐,唯一索引=辅助(无法阻止请求进入)。

token 两阶段:用 DEL 的返回值判,不要 select + delete

由表单重复提交引发的幂等性思考 指出,客户端解决多次提交后接口层问题依旧:其他系统调用、网络延迟、系统补偿时一样会重复。

token 两阶段:进页面之前申请 token 存 Redis 并设生效时间;提交时执行删除,返回 0 表示 token 不存在为非法请求,返回 1 是第一次请求。原文明确写了:「redis 要用删除操作来判断 token,删除成功代表校验通过,如果用 select+delete 来校验 token,存在并发问题,不建议使用」。先 GET 再 DEL 之间有一个窗口,两个并发请求都能读到 token 存在,然后都往下走。DEL 的返回值本身就是原子判据。

唯一索引防脏数据:订单号唯一索引,重复请求插入失败就不会再走实际支付,像一种另类锁。但它的位置在链路末端——请求已经进入 handler、已经过了校验和部分业务逻辑,才在 INSERT 时冲突;而且它要求业务本身有天然唯一键。它是兜底,不是入口防重。

悲观锁 select ... for update;乐观锁 UPDATE ... WHERE id=? and version=?,版本不符执行失败。条件字段必须是主键或唯一索引,否则可能锁表。分布式锁以订单号做 key,已存在返回重复操作,支付完成删 key。

重复提交的来源比按钮多

面试题整理 列了来源:双击按钮、点刷新、浏览器后退重复操作、历史记录重复提交、浏览器重复 HTTP 请求、nginx 重发、分布式 RPC try 重发。

对应方案各有边界:禁用按钮挡不住 F5 和前进后退;Cache-Control: no-cache 下提交页点刷新仍会重提;PRG 设计模式(POST 后用 3xx 重定向到结果页)让刷新/前进后退变成 GET,不产生数据变更,但挡不住多次点提交按钮和恶意绕过;数据库唯一标识;session token;直接加锁(纯后端,只能限制固定时间内操作一次,一般 10s 左右)。

session token 有个容易写错的细节:隐藏域必须存 uuid 并与 session 值比对,否则开两个窗口共用 session 时,窗口 1 按 F5 仍能重复提交。抽象地看,session token 就是「锁」——取到 token 等于获取锁,用完锁住直到重新获取 token 释放;锁必须有失效时间,否则累积脏数据。

判据本身也会写反

  • 返回 429 是错的判据。429 Too Many Requests 表示频率超限,通常配 Retry-After 告诉客户端「稍后可以重试」。用 429 回「这是重复提交,别再来」,等于让客户端退避后重试,意图相反。重复提交应该用 409 Conflict(请求与资源当前状态冲突),或者直接回放第一次的 2xx 响应。
  • SETNX + EXPIRE 不是原子,用 SET key value EX n NX。
  • SELECT + DELETE 校验 token 有并发窗口,用 DEL 的返回值。
  • 指纹模式(userId + uri + md5(params))会误杀语义上合法的相同请求。
  • 唯一索引不能当入口防重,它只在写入时生效,且要求存在天然唯一键。

《Go Web 编程》4.4 里的做法是最小版本:表单加带唯一值的隐藏字段,验证时先检查该唯一值是否已递交过;Go 示例用 md5(时间戳) 生成 token 输出到模板隐藏域,POST 时取 token 校验。Ajax 模式则在提交后禁用递交按钮。

前端这一侧的完整形态大致是这样,按钮禁用只管 UX,真正的判据在服务端:


提交

document.querySelector('form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.currentTarget;
const btn = form.querySelector('button');
btn.disabled = true;
btn.textContent = '提交中…';
try {
const res = await fetch(form.action, {
method: 'POST',
headers: { 'Idempotency-Key': crypto.randomUUID() },
body: new FormData(form),
});
if (res.status === 409) {
// 另一个请求正在处理同一个 token,不要重试写操作
}
} finally {
btn.disabled = false;
btn.textContent = '提交';
}
});

最后一句来自掘金的总结,也是这几份材料共同的落点:防重不等于幂等,复杂业务还需设计幂等接口。

复制全文 生成海报 重复提交 表单防重 幂等 Redis Go HTTP

推荐文章

程序员茄子在线接单