Files

5.5 KiB
Raw Permalink Blame History

id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week, order
id title type domain stack tags level prerequisites est_hours related week order
backend.redis.lock-ratelimit 分布式锁和限流:SET NX、Lua、令牌释放、固定窗口与滑动窗口 lesson backend
redis
分布式锁
Lua
限流
滑动窗口
令牌桶
intermediate
backend.redis.data-structures
3
backend.redis.scenario.stock-reservation
backend.redis.interview
backend.gin.middleware-auth
3
14
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(lock.go、ratelimit.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
}

滑动窗口限流脚本:

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 里接成中间件(示意):

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 面试题:分布式锁怎么实现、为什么要用 Lua、锁过期了业务还没执行完怎么办、几种限流算法的区别。