用Golang如何实现限制用户1分钟内最多请求1000次?

admin 2026-06-17 04:39:18 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细讲解使用Golang实现用户请求频率限制的两种方案。单机环境下采用内存滑动窗口算法记录用户最近1分钟请求时间戳,多实例部署时使用RedisZSet配合Lua脚本实现分布式限流。关键点包括避免固定窗口的边界问题、Redis异常时的降级策略以及按用户/IP/API等多维度限流建议。 综合评分: 85 文章分类: 安全开发,WEB安全,解决方案,安全工具,技术标准


cover_image

用Golang 如何实现限制用户 1 分钟内最多请求 1000 次?

原创

go go

Go语言教程

2026年6月12日 15:23 陕西

在小说阅读器读本章

去阅读

接口突然冒出一堆 429,不一定是网关抽风。

我一般先看 access log,不急着翻业务代码。像这种:

2026-06-10 10:21:08 api=/v1/order/create uid=83621 status=200 cost=31ms
2026-06-10 10:21:09 api=/v1/order/create uid=83621 status=200 cost=28ms
2026-06-10 10:21:10 api=/v1/order/create uid=83621 status=429 cost=1ms

这个 uid 明显不是正常用户点按钮了,大概率是脚本、重试风暴,或者前端某个轮询写崩了。

需求也很直白:同一个用户,1 分钟内最多请求 1000 次

这东西别一上来就写成:

if count > 1000 {
    return 429
}

这种代码我见过不少,最后问题都卡在两个地方。

第一个,count 放哪? 第二个,这个“1 分钟”到底怎么算?

如果只是单机服务,内存里搞一个滑动窗口就够用。别搞太复杂,先把事情做准。

package limit

import (
 "sync"
 "time"
)

type UserLimiter struct {
 mu      sync.Mutex
 records map[string][]int64
 limit   int
 window  int64
}

func NewUserLimiter() *UserLimiter {
 return &UserLimiter{
  records: make(map[string][]int64),
  limit:   1000,
  window:  int64(time.Minute),
 }
}

func (l *UserLimiter) Allow(uid string) bool {
 now := time.Now().UnixNano()
 cut := now - l.window

 l.mu.Lock()
 defer l.mu.Unlock()

 old := l.records[uid]
 idx := 0

&nbsp;for&nbsp;idx <&nbsp;len(old) && old[idx] < cut {
&nbsp; idx++
&nbsp;}

&nbsp;if&nbsp;idx >&nbsp;0&nbsp;{
&nbsp; old = old[idx:]
&nbsp;}

&nbsp;if&nbsp;len(old) >= l.limit {
&nbsp; l.records[uid] = old
&nbsp;&nbsp;return&nbsp;false
&nbsp;}

&nbsp;old =&nbsp;append(old, now)
&nbsp;l.records[uid] = old
&nbsp;return&nbsp;true
}

这段代码干的事很土,但是靠谱。

每个用户保存最近一分钟的请求时间。请求来了,先把一分钟以前的时间戳扔掉。剩下的如果已经有 1000 个,就拒绝。没到 1000,就把当前请求塞进去。

用在 HTTP 里,大概这样:

func&nbsp;RateLimit(next http.Handler, limiter *limit.UserLimiter)&nbsp;http.Handler&nbsp;{
&nbsp;return&nbsp;http.HandlerFunc(func(w http.ResponseWriter, r *http.Request)&nbsp;{
&nbsp; uid := r.Header.Get("X-User-Id")
&nbsp;&nbsp;if&nbsp;uid ==&nbsp;""&nbsp;{
&nbsp; &nbsp;http.Error(w,&nbsp;"missing user", http.StatusUnauthorized)
&nbsp; &nbsp;return
&nbsp; }

&nbsp;&nbsp;if&nbsp;!limiter.Allow(uid) {
&nbsp; &nbsp;w.Header().Set("Retry-After",&nbsp;"60")
&nbsp; &nbsp;http.Error(w,&nbsp;"too many requests", http.StatusTooManyRequests)
&nbsp; &nbsp;return
&nbsp; }

&nbsp; next.ServeHTTP(w, r)
&nbsp;})
}

单机这么写没问题,压测、后台工具、小服务都能用。

但线上多实例就别这么玩了。

你服务部署了 4 台,每台都允许 1000 次,那用户实际一分钟能打 4000 次。这个坑很常见,尤其是服务刚拆出来的时候,单机逻辑看着没毛病,一扩容,限流直接稀碎。

这种我一般丢给 Redis 做。不是因为 Redis 高级,是因为它能让多台机器看到同一份计数。

这里别用普通 INCR + EXPIRE 固定窗口。它有边界问题。

比如用户在 10:00:59 打了 1000 次,又在 10:01:00 打了 1000 次。按自然分钟看都没超,但实际 1 秒内打了 2000 次。要是接口后面还连着数据库,这一下就挺难看。

更稳一点,用 Redis ZSet 做滑动窗口。

key 是用户维度:

rate:user:83621

score 存毫秒时间戳,value 加一点随机串,避免同一毫秒请求覆盖。

关键逻辑用 Lua 包起来,不然 ZREMRANGEBYSCOREZCARDZADD 分开执行,中间插进来并发请求,计数会飘。

var&nbsp;userRateScript = redis.NewScript(`
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]

redis.call("ZREMRANGEBYSCORE", key, 0, now - window)

local current = redis.call("ZCARD", key)
if current >= limit then
&nbsp; &nbsp; return 0
end

redis.call("ZADD", key, now, member)
redis.call("PEXPIRE", key, window + 5000)

return 1
`)

Go 这边封一层,不要把 Redis 细节散在 handler 里。限流这种东西,一旦散开,后面排查会很烦。

type&nbsp;RedisUserLimiter&nbsp;struct&nbsp;{
&nbsp;rdb &nbsp; &nbsp;*redis.Client
&nbsp;limit &nbsp;int
&nbsp;window time.Duration
}

func&nbsp;NewRedisUserLimiter(rdb *redis.Client)&nbsp;*RedisUserLimiter&nbsp;{
&nbsp;return&nbsp;&RedisUserLimiter{
&nbsp; rdb: &nbsp; &nbsp;rdb,
&nbsp; limit: &nbsp;1000,
&nbsp; window: time.Minute,
&nbsp;}
}

func&nbsp;(l *RedisUserLimiter)&nbsp;Allow(ctx context.Context, uid&nbsp;string)&nbsp;(bool, error)&nbsp;{
&nbsp;now := time.Now().UnixMilli()
&nbsp;key :=&nbsp;"rate:user:"&nbsp;+ uid

&nbsp;member := strconv.FormatInt(now,&nbsp;10) +&nbsp;":"&nbsp;+ strconv.Itoa(rand.Int())

&nbsp;ret, err := userRateScript.Run(
&nbsp; ctx,
&nbsp; l.rdb,
&nbsp; []string{key},
&nbsp; now,
&nbsp; l.window.Milliseconds(),
&nbsp; l.limit,
&nbsp; member,
&nbsp;).Int()

&nbsp;if&nbsp;err !=&nbsp;nil&nbsp;{
&nbsp;&nbsp;return&nbsp;false, err
&nbsp;}

&nbsp;return&nbsp;ret ==&nbsp;1,&nbsp;nil
}

handler 里就清爽很多:

func&nbsp;LimitByUser(limiter *RedisUserLimiter, next http.Handler)&nbsp;http.Handler&nbsp;{
&nbsp;return&nbsp;http.HandlerFunc(func(w http.ResponseWriter, r *http.Request)&nbsp;{
&nbsp; uid := r.Header.Get("X-User-Id")
&nbsp;&nbsp;if&nbsp;uid ==&nbsp;""&nbsp;{
&nbsp; &nbsp;http.Error(w,&nbsp;"missing user", http.StatusUnauthorized)
&nbsp; &nbsp;return
&nbsp; }

&nbsp; ok, err := limiter.Allow(r.Context(), uid)
&nbsp;&nbsp;if&nbsp;err !=&nbsp;nil&nbsp;{
&nbsp; &nbsp;// 这里我一般不会直接放行。
&nbsp; &nbsp;// Redis 都挂了还放流量,后面的 MySQL、ES、第三方接口可能先死。
&nbsp; &nbsp;http.Error(w,&nbsp;"rate limit unavailable", http.StatusServiceUnavailable)
&nbsp; &nbsp;return
&nbsp; }

&nbsp;&nbsp;if&nbsp;!ok {
&nbsp; &nbsp;w.Header().Set("Retry-After",&nbsp;"60")
&nbsp; &nbsp;http.Error(w,&nbsp;"too many requests", http.StatusTooManyRequests)
&nbsp; &nbsp;return
&nbsp; }

&nbsp; next.ServeHTTP(w, r)
&nbsp;})
}

这里有个小细节,Redis 异常时到底放行还是拒绝。

这个没有标准答案,看业务。

登录、下单、发券这种接口,我偏向拒绝一部分,至少别让后面的核心资源被打穿。普通内容浏览接口,可以短暂放行,最多就是用户多刷几次页面。

还有一个点,key 不能只按用户限。

真实线上一般会加几层:

rate:user:83621
rate:ip:112.23.xx.xx
rate:api:/v1/order/create:user:83621

只按用户限,挡不住未登录接口。只按 IP 限,又容易误伤公司出口、学校出口、网吧出口。按接口加一层,是为了保护那些特别贵的接口,比如导出、搜索、创建订单。

我比较嫌弃那种全站一个限流值的做法。查详情和提交支付,成本不是一回事,没必要混着限。

最后补一句清理问题。

上面的 ZSet 每次请求都会清掉过期数据,又设置了 PEXPIRE,正常不会无限涨。但如果某个 uid 一分钟打满 1000 次,这个 key 里也就是 1000 条左右。这个量 Redis 扛得住。

真要担心内存,就别把 member 写太长。别塞 JSON,别塞 user-agent,别顺手把 traceId 也放进去。限流只需要判断次数,不是做审计日志。

这个方案不花哨,但够用:单机用内存滑动窗口,多实例用 Redis ZSet + Lua。别在分钟边界上偷懒,很多线上毛刺就是这么来的。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:Go语言教程 go go《用Golang 如何实现限制用户 1 分钟内最多请求 1000 次?》

评论:0   参与:  0