Files

118 lines
5.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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、锁过期了业务还没执行完怎么办、几种限流算法的区别。