基于Go+Redis实现3种限流算法

 更新时间:2026年11月04日 08:54:09   作者:我不会起名字322  
秒杀活动接口被打爆,本文用Go+Redis实现三种经典限流算法:固定窗口、滑动窗口、令牌桶,详细对比优缺点、适用场景和组合用法,教你如何优雅拒绝超量请求,保护系统不雪崩,需要的朋友可以参考下

一、现象:秒杀开始 1 秒,接口被打爆

活动开始瞬间,网关日志出现大量 502/超时。Redis 没挂、数据库没挂——是接口的 QPS 超过了它能承受的上限(假设单实例只能扛 2000 QPS),请求堆积把线程池耗光,雪崩开始。

限流的目标不是"拒绝用户",而是在入口就把超出的流量优雅地丢掉(返回 429 或降级数据),保护系统活到流量峰值过去。3 种经典算法各有坑,本篇用 Go+Redis 全部实现一遍。

二、算法 1:固定窗口(最简单,有突刺)

// AllowFixedWindow 每 window 秒允许 limit 次
func AllowFixedWindow(ctx context.Context, rdb *redis.Client, key string, limit int64, window time.Duration) (bool, error) {
	// INCR + EXPIRE 的经典组合;key 带时间窗口戳天然滚动
	winKey := fmt.Sprintf("rl:%s:%d", key, time.Now().Unix()/int64(window.Seconds()))
	pipe := rdb.Pipeline()
	incr := pipe.Incr(ctx, winKey)
	pipe.Expire(ctx, winKey, window+time.Second) // 多留 1 秒防边界误差
	if _, err := pipe.Exec(ctx); err != nil {
		return false, err
	}
	return incr.Val() <= limit, nil
}

坑在哪:窗口边界突刺。限制 100 QPS,第 1 秒末尾来了 100 个请求,第 2 秒开头又来 100 个——窗口各自都没超限,但 2 秒内实际打了 200 QPS,服务照样被冲垮。

三、算法 2:滑动窗口(用 ZSet 精确计数)

思路:把每个请求的时间戳存进 ZSet(member=唯一 id,score=毫秒时间戳),每次检查"当前时间往前滑一个窗口"内的数量。

// AllowSlidingWindow 滑动窗口:窗口内请求精确计数
func AllowSlidingWindow(ctx context.Context, rdb *redis.Client, key string, limit int64, window time.Duration) (bool, error) {
	now := time.Now().UnixMilli()
	winStart := now - window.Milliseconds()
	zkey := "rl:sw:" + key

	// 用 WATCH 保证"清理旧记录 + 计数 + 写入新记录"原子(第 13 篇的套路)
	var allowed bool
	_, err := rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
		pipe.ZRemRangeByScore(ctx, zkey, "0", fmt.Sprintf("%d", winStart)) // 清掉窗口外的
		cnt := pipe.ZCard(ctx, zkey)
		if _, err := pipe.Exec(ctx); err != nil { // 占位:go-redis 会在函数返回后统一 EXEC
			return err
		}
		allowed = cnt.Val() < limit
		if allowed {
			pipe.ZAdd(ctx, zkey, redis.Z{Score: float64(now), Member: fmt.Sprintf("%d", now)})
			pipe.Expire(ctx, zkey, window+time.Second)
		}
		return nil
	})
	if err != nil {
		// 并发冲突时 WATCH 事务失败,直接按拒绝处理(限流宁可错杀)
		return false, nil
	}
	return allowed, nil
}

代价:每个请求一条 ZAdd + 一条 ZRemRangeByScore + 一次事务,写放大明显。QPS 高(万级)时不划算——这时用算法 3。

四、算法 3:令牌桶(平滑限流,预支能力)

令牌桶:桶容量 burst,每秒补充 rate 个令牌;请求拿走一个令牌才放行,桶空则拒绝。平滑(无突刺)且能容忍短时突发。

用 Redis Hash 存 {tokens, last_refill_time},扣减逻辑用 WATCH/MULTI 保证并发下不多发:

type TokenBucket struct {
	rdb   *redis.Client
	burst int64
	rate  float64 // 每秒补充的令牌数
}

func (b *TokenBucket) Allow(ctx context.Context, key string) (bool, error) {
	hkey := "rl:tb:" + key
	var allowed bool
	_, err := b.rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
		hm := pipe.HGetAll(ctx, hkey)
		if _, err := pipe.Exec(ctx); err != nil {
			return err
		}
		vals := hm.Val()
		tokens := b.burst
		last := time.Now().UnixMilli()
		if t, ok := vals["tokens"]; ok {
			fmt.Sscanf(t, "%d", &tokens)
			fmt.Sscanf(vals["ts"], "%d", &last)
		}
		// 按流逝时间补充令牌,上限 burst
		refill := float64(time.Now().UnixMilli()-last) / 1000 * b.rate
		tokens = int64(math.Min(float64(tokens)+refill, float64(b.burst)))

		if tokens > 0 {
			allowed = true
			tokens--
		}
		pipe.HSet(ctx, hkey, "tokens", tokens, "ts", time.Now().UnixMilli())
		pipe.Expire(ctx, hkey, time.Hour)
		return nil
	})
	if err != nil {
		return false, nil
	}
	return allowed, nil
}

五、3 种算法对比与选型(收藏点 1)

维度固定窗口滑动窗口令牌桶
实现复杂度2 条命令ZSet + 事务Hash + 事务
突刺问题窗口边界双倍流量无无(突发被 burst 平滑限制)
写放大低高(每请求 3+ 写)中
适合场景粗粒度保护(如 1 分钟 1000 次)精度要求高的计数平滑 QPS + 容忍突发(API 网关)

组合用法是生产常态:外层固定窗口挡风暴(便宜),内层令牌桶平滑(精确)。

六、中间件封装(收藏点 2)

func RateLimitMiddleware(rdb *redis.Client, tb *TokenBucket) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// 按用户/IP 维度限流:key 取登录用户 id 或 X-Forwarded-For
			key := r.Header.Get("X-Forwarded-For")
			ok, err := tb.Allow(r.Context(), key)
			if err != nil || !ok {
				w.Header().Set("Retry-After", "1")
				http.Error(w, `{"code":429,"msg":"请求过于频繁"}`, http.StatusTooManyRequests)
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

配套约定:返回 429 要带 Retry-After;限流维度选"谁"要想清楚——按 IP 会误伤 NAT 后的公司出口,按用户 id 会被小号绕过。

七、命令清单与压测建议

命令用于算法
INCR + EXPIRE固定窗口
ZADD/ZCARD/ZREMRANGEBYSCORE/ZREVRANGE滑动窗口
HGETALL/HSET + WATCH/MULTI令牌桶
EXPIRE三种都要:计数器 key 必须带 TTL,否则内存泄漏

压测建议:用第 5 篇的压测器改造,验证"100 QPS 限制下压 500 QPS,1 秒吞吐 ≈100,拒绝率 ≈80%";边界测试用固定窗口故意在窗口边界打双倍流量,验证滑动窗口/令牌桶确实无突刺。

八、总结

  1. 固定窗口最简单但有边界突刺;滑动窗口精确但写放大;令牌桶平滑且容忍突发;
  2. 生产组合:外层固定窗口挡风暴 + 内层令牌桶平滑;
  3. 计数器 key 必带 TTL,否则限流器自己成为内存泄漏源;
  4. 事务冲突时宁可错杀(拒绝),不可错放。

到此这篇关于基于Go+Redis实现3种限流算法的文章就介绍到这了,更多相关Go Redis限流算法内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • Go 语言指针赋值的具体使用

    Go 语言指针赋值的具体使用

    Go语言中指针用于存储变量地址,可提升性能但需谨慎使用,需注意nil检查、避免循环问题及返回局部变量地址的陷阱,下面就来介绍一下指针赋值,感兴趣的可以了解一下
    2025-08-08
  • GoFrame框架gset使用对比PHP Java Redis优势

    GoFrame框架gset使用对比PHP Java Redis优势

    这篇文章主要为大家介绍了GoFrame框架gset对比PHP Java Redis的使用优势详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-06-06
  • Go1.25通过Headers实现CSRF防护

    Go1.25通过Headers实现CSRF防护

    Go 1.25 引入的利用现代浏览器的 Headers 机制,大幅简化了 CSRF 防护的实现,相比传统的 Token 方案,它无需修改表单模板,只需在应用层统一配置即可,下面就来详细的了解一下
    2026-06-06
  • 如何使用Go语言获取当天、昨天、明天、某天0点时间戳以及格式化时间

    如何使用Go语言获取当天、昨天、明天、某天0点时间戳以及格式化时间

    这篇文章主要给大家介绍了关于如何使用Go语言获取当天、昨天、明天、某天0点时间戳以及格式化时间的相关资料,格式化时间戳是将时间戳转换为特定的日期和时间格式,文中通过代码示例介绍的非常详细,需要的朋友可以参考下
    2023-10-10
  • 详解golang中bufio包的实现原理

    详解golang中bufio包的实现原理

    这篇文章主要介绍了详解golang中bufio包的实现原理,通过分析golang中bufio包的源码,来了解为什么bufio能够提高文件读写的效率和速度
    2018-01-01
  • Go语言读取txt文档的操作方法

    Go语言读取txt文档的操作方法

    Go语言提供了很多文件操作的支持,在不同场景下,有对应的处理方式,本节我们来介绍一下Go语言读取txt文档的相关知识,感兴趣的朋友跟随小编一起看看吧
    2022-01-01
  • Go语言运行环境安装详细教程

    Go语言运行环境安装详细教程

    这篇文章主要介绍了Go语言运行环境安装详细教程,本文讲解了Linux、MAC OS、Windows下的安装方法,同时讲解了验证是否安装成功、第三方工具安装运行环境的方法,需要的朋友可以参考下
    2014-10-10
  • GoFrame代码优化gconv类型转换避免重复定义map

    GoFrame代码优化gconv类型转换避免重复定义map

    这篇文章主要为大家介绍了GoFrame代码优化gconv类型转换避免重复定义map示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-06-06
  • Go函数的使用示例教程

    Go函数的使用示例教程

    这篇文章主要介绍了Go函数的使用示例,本文通过实例代码给大家介绍的非常详细,感兴趣的朋友跟随小编一起看看吧
    2024-07-07
  • golang 中获取字符串个数的方法

    golang 中获取字符串个数的方法

    这篇文章主要介绍了golang 中获取字符串个数 ,本文给大家介绍的非常详细,具有一定的参考借鉴价值,需要的朋友可以参考下
    2019-08-08

最新评论