118 lines
5.5 KiB
Markdown
118 lines
5.5 KiB
Markdown
---
|
||
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、锁过期了业务还没执行完怎么办、几种限流算法的区别。
|