redis+token实现接口幂等性:客户端先获取一次性token并存入redis(设10分钟过期),请求时携带token,拦截器用setnx+ex原子写入校验,存在则执行业务并删除token,否则返回409;需配合数据库唯一约束兜底,确保重复请求结果一致。

Revel 中用 Redis + Token 实现幂等拦截
Revel 本身不提供开箱即用的幂等中间件,但它的 Interceptor 机制足够灵活,能让你在请求进入 Action 前完成 token 校验。核心思路是:客户端先调用 /api/v1/idempotent/token 获取一个一次性 token,再把该 token 放入请求 header(如 X-Idempotency-Key),后端拦截器查 Redis,存在则放行并立即 DEL,不存在则返回 409 Conflict。
常见错误现象包括:token 过期时间设太短(比如 30s),导致用户操作稍慢就失败;或没做 DEL + EXPIRE 的原子性保障,Redis 网络抖动时 token 残留,后续合法请求被误拒。
实操建议:
- 用
SET idempotent:{key} 1 NX EX 600命令写入 token,避免竞态——NX保证只在 key 不存在时设置,EX 600是 10 分钟过期,覆盖绝大多数业务操作窗口 - 拦截器里用
redis.Conn.Do("GETDEL", key)(若 Redis 版本 ≥ 6.2)或拆成GET+DEL两步(需用WATCH/MULTI/EXEC包裹,否则有并发风险) - 不要把 token 存在 session 或本地内存——Revel 多实例部署时会失效
- 校验失败统一返回
revel.ERROR(409, "idempotent token invalid or expired"),前端据此提示“操作已在处理中”,而不是重试
为什么不能只靠 Revel 的 Filter 链做幂等
Revel 的 Filter 是同步执行、串行链式调用,所有 filter 跑完才进 controller。但幂等校验必须「前置且快速失败」:如果 token 已失效,就不该让请求继续走完日志、鉴权、参数绑定等 filter。否则不仅浪费资源,还可能因重复触发下游副作用(比如扣库存逻辑已执行一半)。
所以必须把幂等拦截器放在 filter 链最前面,且内部不能有阻塞调用。实测发现,若用 redis.Dial 每次新建连接,QPS 上不去;应使用连接池(如 github.com/gomodule/redigo/redis 的 redis.Pool),并设置 MaxIdle=20、IdleTimeout=240s。
容易踩的坑:
- 拦截器里忘了调用
c.Result = nil,导致 Revel 后续仍尝试执行 controller,出现双写 - 对 GET 请求也强制校验 token——幂等只针对写操作(POST/PUT/DELETE),GET 本身语义就是幂等的,加 token 反而增加前端负担
- 没区分测试环境:本地开发时 Redis 不可用,应允许跳过校验(通过配置项
app.idempotent.enabled = false控制)
结合 Revel 的 Action 参数自动提取业务键
Token 方案适合通用场景,但支付、下单这类强业务语义接口,更推荐用「业务唯一键 + 数据库唯一约束」兜底。Revel 的 c.Params 和 c.Params.Get("order_id") 可直接提取关键字段,拼出幂等键,比如 pay:order_123456:payment_789。
这样做的好处是:即使 Redis 宕机,只要数据库唯一索引还在,插入重复记录就会报 ERROR 1062 (23000): Duplicate entry,服务端捕获后返回 409,结果依然一致。
注意事项:
- 不要用
c.Params.Get("timestamp")或c.Params.Get("nonce")构造键——它们每次请求都变,失去幂等意义 - POST body 是 JSON 时,需提前用
c.Params.Bind解析到 struct,再取字段;别在 controller 里才解析,否则拦截器拿不到 - 唯一索引要建在组合字段上,例如
UNIQUE KEY `uk_order_pay` (`order_id`, `payment_no`),单字段索引无法防住跨订单重复
重试场景下如何保证响应体一致
第一次请求成功后,第二次重复请求不能返回空或新数据,而要返回和首次完全相同的响应体(含 timestamp、流水号等)。Revel 没有内置响应缓存,得自己落地。
简单做法:在 token 校验通过后、执行业务逻辑前,先查 Redis 是否已有该 token 对应的缓存结果(key 为 idempotent:result:{token}),有则直接 return c.RenderJSON(...);没有则执行业务逻辑,并在成功后把结果 SET 进 Redis,过期时间与 token 一致。
关键细节:
- 缓存值必须是完整 HTTP 响应体 JSON 字符串,不是结构体指针——避免后续修改影响缓存内容
- 不要缓存 error 响应(如余额不足),因为状态可能已变化,下次重试可能是成功的
- 若业务逻辑耗时长(>5s),建议用异步方式写缓存,避免阻塞主线程——Revel 的 goroutine 生命周期由框架管理,别用裸
go func(),改用revel.Bg提交后台任务
真正难的不是加一层拦截,而是所有写接口都要明确回答:这个操作被调用两次会发生什么?答案不唯一,就得立刻补上幂等键设计。Token 只是入口守门员,数据库唯一约束才是终审法官。











