中间件本身不能防止重复请求,必须结合请求指纹+redis全局状态才能生效;单机缓存(如sync.map)无法跨实例共享,集群下必然漏判,且中间件无天然上下文关联能力,无法识别语义重复。

中间件本身不能防止重复请求,必须结合请求指纹 + 全局去重状态(如 Redis)才能生效;单纯在中间件里加 sync.Map 或本地缓存只对单机有效,且极易漏判。
为什么不能只用中间件拦截重复请求
中间件是每个请求必经的流水线环节,但它不天然知道“这个请求是否重复”——它没有上下文关联能力,也无法跨实例共享状态。常见误区包括:
- 在中间件里直接用
c.Request().Body计算 MD5:Body 只能读一次,后续 handler 会读不到,导致绑定失败 - 用
sync.Map存请求 ID(如X-Request-ID):单机有效,但集群部署时,同一请求打到不同实例就完全失效 - 在中间件里调用
c.Bind()提前解析参数再做比对:破坏了 Echo 的生命周期设计,Bind()应由业务 handler 控制时机 - 把防重逻辑塞进
e.Pre():此时路由未匹配,拿不到 path 参数或 query,无法构造唯一指纹
正确构造请求指纹的三个必要字段
重复请求 ≠ 完全相同的 HTTP 报文,而是语义等价的操作。比如两次 POST /api/v1/orders 提交相同用户、相同商品、相同金额,应视为重复。指纹必须包含:
-
c.Request().Method+c.Request().URL.Path(固定接口维度) - 关键业务参数:从
c.Param("id")、c.QueryParam("token")或已解析的 JSON body 中提取(需提前读取并缓存 Body) - 客户端标识:优先用
c.Get("user_id")(JWT 解析后注入), fallback 到getRealIP(c.Request())(注意X-Forwarded-For)
示例指纹 key:dup:POST:/api/v1/payments:user_123:order_456,避免只用 raw body hash——压缩、空格、字段顺序差异都会导致误判。
Redis + Lua 原子去重的最小可行实现
必须用 Redis 的原子性保证“检查是否存在 → 不存在则写入 → 返回结果”三步不被并发打断。不能拆成 GET + SETNX 两步调用。
Echo框架 5.1.0 版本源码包下载,适合关注 RealIP 行为变化、StartConfig.Listener、NewDefaultFS 和观测性中间件入口的开发团队。
- Lua 脚本要带过期时间(如
EXPIRE),否则 key 永久残留;建议 TTL 设为业务操作最大耗时 × 2(如支付接口设 30s) - Key 过期时间不能依赖客户端设置,必须在 Lua 里用
redis.call("SET", key, "1", "EX", ttl)一次性完成 - 返回值约定:
1表示首次请求放行,0表示重复请求拦截 - 连接 Redis 时务必配置
ReadTimeout和WriteTimeout ≤ 50ms,防止单点故障拖垮整个 API
示例 Lua 脚本(保存为 dedupe.lua):
if redis.call("EXISTS", KEYS[1]) == 1 then
return 0
else
redis.call("SET", KEYS[1], "1", "EX", tonumber(ARGV[1]))
return 1
end
Go 中调用:client.Eval(ctx, dedupeScript, []string{fingerprint}, ttlSeconds).Int64()
如何安全地提前读取并复用 Request.Body
Echo 默认不缓存 body,而防重需要读一次 body 提取参数,业务 handler 还要再读一次——必须手动缓存。
- 在中间件开头调用
body, err := io.ReadAll(c.Request().Body),然后用io.NopCloser(bytes.NewBuffer(body))重置Body - 把解析好的结构体存到
c.Set("parsed_body", parsed),后续 handler 用c.Get("parsed_body")获取,避免重复解析 - 不要在中间件里调用
c.Bind():它会直接修改c.Request().Body,且错误处理逻辑与业务 handler 冲突 - 若 body 超过 1MB,直接拒绝(防 DoS),不进去重逻辑
真正难的不是写中间件,而是让指纹足够业务语义化、Redis 调用足够快、Body 复用足够稳——这三处任一出错,防重就变成摆设。










