--- id: backend.redis.lock-ratelimit title: 分布式锁和限流:SET NX、Lua、令牌释放、固定窗口与滑动窗口 type: lesson domain: backend stack: [redis] tags: [分布式锁, Lua, 限流, 滑动窗口, 令牌桶] level: intermediate prerequisites: [backend.redis.data-structures] est_hours: 3 related: [backend.redis.scenario.stock-reservation, backend.redis.interview, backend.gin.middleware-auth] week: [3, 14] order: 3 --- # 分布式锁和限流:SET NX、Lua、令牌释放、固定窗口与滑动窗口 ## 解释说明 **分布式锁**:多个服务实例之间互斥,例如“同一时间只有一个实例执行定时任务”“同一个订单只处理一次回调”。 - 加锁:`SET lock:key <随机令牌> NX PX 30000`。`NX` 保证只有一个人能设置成功,`PX` 设置过期时间防止持有者崩溃后锁永远不释放。**加锁和设置过期必须是一条命令**(不要 `SETNX` 再 `EXPIRE`)。 - 解锁:用 Lua 脚本“GET 比较令牌 → 相等才 DEL”,保证只删自己的锁。直接 `DEL` 可能删掉别人的锁:自己执行超时、锁已过期被别人拿到后,再 `DEL` 就把别人的锁删了。 - 过期时间怎么定:大于业务最长执行时间;执行时间不确定时由后台协程定期续期(Redisson 的“看门狗”就是这个思路)。 - 局限:Redis 主从切换时锁可能丢失。对正确性要求极高的场景,用数据库唯一约束 / 乐观锁兜底,或者在资源侧校验“防护令牌”(递增的版本号)。 **限流算法** | 算法 | 思路 | 特点 | | --- | --- | --- | | 固定窗口 | `INCR rl:user:1:202610231530`,首次设置 60 秒过期,超过阈值拒绝 | 最简单;窗口交界处可能瞬间放过 2 倍流量 | | 滑动窗口 | ZSET 存每次请求时间,删掉窗口外的再计数 | 精确;每次请求占一个元素,内存随请求量增长 | | 令牌桶 | 按固定速率放令牌,请求取令牌,允许一定突发 | 平滑且允许突发;实现稍复杂 | | 漏桶 | 请求进队列,按固定速率处理 | 输出最平滑,不允许突发 | 单实例服务用进程内限流就够(Go 的 `golang.org/x/time/rate` 是令牌桶);多实例要共享计数时才用 Redis。 **为什么用 Lua**:多条命令(判断 + 修改)需要原子执行。Redis 执行 Lua 脚本期间不会执行其他命令,所以脚本要短小,不能有耗时循环。 ## 案例 - 支付回调可能重复到达:处理前 `TryLock("lock:pay-callback:" + orderID, 30s)`,拿不到锁直接返回“处理中”;真正的幂等仍由数据库状态和唯一约束保证。 - 登录接口防暴力破解:同一 IP 每分钟最多 10 次、同一账号每小时最多 20 次,用滑动窗口。 - 调用大模型 API:按租户限流,避免某个租户把额度用光。 ## 代码示例 完整代码和测试在 [examples/stock-lock-ratelimit](examples/stock-lock-ratelimit/)(`lock.go`、`ratelimit.go`),下面是核心片段: ```go // 只有令牌一致才删除,防止误删别人的锁 var unlockScript = redis.NewScript(` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) end return 0`) func TryLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, error) { token := randomToken() ok, err := rdb.SetNX(ctx, key, token, ttl).Result() // SET key token NX PX ttl if err != nil { return nil, err } if !ok { return nil, ErrLockNotAcquired } return &Lock{rdb: rdb, key: key, token: token}, nil } ``` 滑动窗口限流脚本: ```lua local key, now, window, limit, member = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), ARGV[4] redis.call("ZREMRANGEBYSCORE", key, 0, now - window) -- 删掉窗口外的请求 if redis.call("ZCARD", key) >= limit then return 0 -- 超限 end redis.call("ZADD", key, now, member) redis.call("PEXPIRE", key, window) return 1 ``` 在 Gin 里接成中间件(示意): ```go func RateLimit(sw *stocklock.SlidingWindow) gin.HandlerFunc { return func(c *gin.Context) { ok, err := sw.Allow(c.Request.Context(), "rl:ip:"+c.ClientIP(), time.Now()) if err != nil { // Redis 故障时放行还是拒绝,要按业务决定(这里选择放行并记录日志) log.Printf("限流检查失败:%v", err) c.Next() return } if !ok { c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"code": 42900, "message": "请求太频繁,请稍后再试"}) return } c.Next() } } ``` 测试里验证了:锁不能被重复获取;锁过期后旧持有者解锁不会删掉新持有者的锁;滑动窗口限 3 次时第 4、5 次被拒,窗口滑过后放行。 ## 易错点 - **`SETNX` 和 `EXPIRE` 分两条命令**:中间崩溃就留下永不过期的锁。 - **解锁直接 `DEL`**:可能删掉别人的锁。 - **锁的过期时间小于业务执行时间**:两个实例同时执行。要么设足够长,要么续期,并且业务本身做幂等。 - **把分布式锁当成唯一的正确性保证**:网络分区、GC 停顿、主从切换都可能导致锁失效,关键数据要有数据库层面的约束兜底。 - **限流 key 没有过期时间**:key 越来越多。 - **按 `X-Forwarded-For` 限流却没配置可信代理**:客户端可以伪造 IP 绕过(Gin 用 `SetTrustedProxies` 配置)。 ## 面试常问 见 [Redis 面试题](interview.md):分布式锁怎么实现、为什么要用 Lua、锁过期了业务还没执行完怎么办、几种限流算法的区别。