直接用SETNX不能可靠防并发提交,因其未设过期、客户端崩溃导致锁不释放、判断与写入非原子;需用SET+EX原子指令、ZADD+Lua实现带过期的有序排队,并结合状态机(queued/processing/done)保障最终一致性。

为什么直接用 SETNX 不能可靠防并发提交
很多人一上来就用 SETNX 加个 key,成功就处理、失败就拒掉——看似简单,但漏掉三个关键问题:key 过期没设、客户端崩溃导致锁永远不释放、多个请求同时卡在「判断+写入」中间态。结果是部分请求绕过校验,或队列卡死。
真正要防的是「同一业务标识(比如订单号)的重复提交」,不是单纯抢锁。所以得用原子操作 + 可控生命周期 + 显式排队语义。
-
SETNX必须搭配EXPIRE,但两者非原子——改用SET key value EX seconds NX - 单靠锁只能互斥,无法排队;得用
LPUSH + BRPOP或ZADD + ZRANGE构建有序等待队列 - 业务层必须区分「排队中」「已处理」「已拒绝」三种状态,不能只靠 Redis key 存在与否判断
用 redis.ZAdd 实现带优先级和超时的分布式排队
Go 标准库不直接支持 ZSET 原子插入+过期,但 github.com/go-redis/redis/v8 的 ZAdd 可以配合 EXPIREAT 模拟 TTL,更稳妥的做法是用 Lua 脚本把「入队 + 设置过期」打包成一个原子操作。
示例场景:用户提交订单,用订单号 order:12345 作为唯一标识,按时间戳排序:
local key = KEYS[1]
local score = ARGV[1] -- 当前时间戳,用于排序
local member = ARGV[2] -- 请求ID或traceID
local ttl = tonumber(ARGV[3])
<p>redis.call('ZADD', key, score, member)
redis.call('EXPIREAT', key, ttl + tonumber(redis.call('TIME')[1]))
return redis.call('ZCARD', key)</p>
Go 调用时传入 ctx 和超时控制,避免阻塞:
- 用
ZCARD返回当前排队人数,前端可显示「前面还有 X 人」 - 用
ZRANGE key 0 0 WITHSCORES查自己是不是队首,再决定是否执行业务逻辑 - 处理完后必须调用
ZREM key member,否则队列会越积越多
如何避免「处理中」状态丢失导致重复执行
网络超时、服务重启、panic 都可能导致业务逻辑执行了一半就中断,Redis 里没清理状态,后续请求又进队列——结果同个订单被处理两次。
解决方法不是加更多锁,而是引入「状态机 + 最终一致性」:
- 入队时写入两个 key:
queue:order:12345(ZSET)和status:order:12345(String,值为queued) - 拿到队首后,先用
GETSET status:order:12345 processing尝试抢占——只有返回queued才真正开始处理 - 处理成功后设
status:order:12345 = done;失败则设为failed,并记录错误码 - 所有新请求进来,先查
status:order:12345,如果是done或failed直接返回,不再入队
这个 GETSET 是原子的,比先 GET 再 SET 安全得多,也避免了条件竞争。
Go 里怎么封装成可复用的 QueueClient
别把 Redis 操作散落在 handler 里。建议定义一个结构体,把队列名前缀、超时、重试策略都收拢起来:
type QueueClient struct {
client *redis.Client
prefix string // e.g. "submit:"
timeout time.Duration
}
<p>func (q *QueueClient) Enqueue(ctx context.Context, bizID string, traceID string) (int64, error) {
key := q.prefix + bizID
score := float64(time.Now().UnixNano() / 1e6)
ttl := time.Now().Add(q.timeout).Unix()</p><pre class="brush:php;toolbar:false;">script := redis.NewScript(zaddWithExpireLua)
return script.Run(ctx, q.client, []string{key}, score, traceID, ttl).Int64()}
func (q *QueueClient) IsHead(ctx context.Context, bizID string, traceID string) (bool, error) { key := q.prefix + bizID res, err := q.client.ZRangeWithScores(ctx, key, 0, 0).Result() if err != nil || len(res) == 0 { return false, err } return res[0].Member == traceID, nil }
注意点:
-
Enqueue返回的是当前队列长度,不是布尔值,方便做限流判断(比如 > 100 就直接拒) -
IsHead不该用ZRANK,因为可能有相同 score,ZRANGE更准确 - 所有方法都接收
ctx,便于上游统一控制超时和取消
最常被忽略的是:队列 key 的生命周期必须比业务处理时间长,但又不能无限长。建议 timeout 设为业务最大耗时 × 2,并配监控告警 ZSET size 异常增长。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











