直接用setnx防重会失效,因其仅保证单次写入原子性,无法保障“校验→执行→清理”整条链路原子性,高并发下仍可能重复下单;必须用lua脚本在redis端原子封装判断、设值与过期逻辑。

为什么直接用 SETNX 在 Echo 中防重会失效
因为 SETNX 只保证单次写入原子性,但不解决「校验 → 业务执行 → 清理」这一整条链路的原子性。在高并发下,两个请求几乎同时通过 SETNX 校验(比如都返回 true),接着都进入下单逻辑,最终造成重复创建订单。
更隐蔽的问题是:如果业务执行失败(如数据库异常、网络超时),而你又在 defer 里删 Redis key,那这个 key 就永远残留,后续合法请求全被拦截;反之,若提前删 key,又可能被并发请求钻空子。
- 必须把「判断是否存在 + 设置过期 + 业务执行成功后清理」这三步收束到一次 Redis 操作里
- 推荐用 Lua 脚本封装,由 Redis 服务端原子执行,避免客户端侧竞态
- 不要依赖 Go 层的
if-else+ 多次Do()调用,那是伪幂等
Lua 脚本怎么写才真正防重
核心逻辑是:尝试设置一个带过期时间的 key,仅当它不存在时才成功,并返回是否“首次设置”。不能只靠 SET key val EX 60 NX 后再查返回值——因为 Go 客户端拿到 true 后仍要走业务逻辑,中间无锁保护。
下面这段 Lua 是生产验证过的最小可行脚本:
if redis.call("exists", KEYS[1]) == 1 then
return 0
else
redis.call("setex", KEYS[1], tonumber(ARGV[1]), "1")
return 1
end
调用时传入 KEYS[1] = "idempotent:" + reqId、ARGV[1] = "30"(单位秒)。它返回 1 表示首次请求,0 表示已存在。关键点:
- 整个判断+写入在 Redis 单线程内完成,无竞态
- 不用
GET再SET,避免两次网络往返和中间状态暴露 - 过期时间必须显式设为比接口最大耗时长 1–2 倍(例如接口 SLA 是 2s,这里设 5s)
如何在 Echo 中注册防重中间件并绑定请求 ID
防重不是对所有接口生效,而是针对写操作(如 POST /order、PUT /pay)。你需要从请求中提取稳定、唯一、不可伪造的标识,而不是简单用 c.Request().URL.String()。
推荐组合方式:userId + interfacePath + md5(body)。其中 body 需提前读取并还原(注意不要破坏 c.Request().Body 流):
- 用
ioutil.ReadAll(c.Request().Body)读原始字节,再用bytes.NewReader()重新塞回去 - 若接口接受表单,用
c.FormValue()拼接字段;若为 JSON,建议统一解析为map[string]interface{}后排序序列化 - 避免用
c.RealIP()或userAgent作为主键——它们易变且不具业务唯一性
中间件注册示例:
func IdempotentMiddleware(redisClient *redis.Client) echo.MiddlewareFunc {
script := redis.NewScript(luaScript)
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
reqID := generateReqID(c) // 实现见上文
result, err := script.Run(redisClient, []string{"idempotent:" + reqID}, "30").Int()
if err != nil {
return c.JSON(500, map[string]string{"error": "防重服务异常"})
}
if result == 0 {
return c.JSON(409, map[string]string{"error": "请勿重复提交"})
}
return next(c)
}
}
}
Redis 连接与错误降级怎么处理才不拖垮接口
防重中间件一旦因 Redis 不可用而 panic 或长时间阻塞,整个接口就挂了。必须做两层兜底:
- 连接池配置要合理:
MaxIdle≥ 10,MaxActive≥ 50,IdleTimeout设为 60s,避免短连接风暴 - 加超时控制:用
context.WithTimeout包裹 Lua 脚本执行,例如ctx, _ := context.WithTimeout(c.Request().Context(), 100*time.Millisecond) - 降级策略:当 Redis 调用失败(超时/连接拒绝/集群不可达),日志告警,但**允许请求继续执行**——此时退化为「尽力防重」,靠下游唯一索引兜底,而不是直接 5xx
这点特别容易被忽略:防重是增强型保障,不是业务正确性的唯一防线。数据库唯一索引、状态机校验这些最终一致性手段,一个都不能少。











