文章总结: 本文详细讲解使用Golang实现用户请求频率限制的两种方案。单机环境下采用内存滑动窗口算法记录用户最近1分钟请求时间戳,多实例部署时使用RedisZSet配合Lua脚本实现分布式限流。关键点包括避免固定窗口的边界问题、Redis异常时的降级策略以及按用户/IP/API等多维度限流建议。 综合评分: 85 文章分类: 安全开发,WEB安全,解决方案,安全工具,技术标准
用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
for idx < len(old) && old[idx] < cut {
idx++
}
if idx > 0 {
old = old[idx:]
}
if len(old) >= l.limit {
l.records[uid] = old
return false
}
old = append(old, now)
l.records[uid] = old
return true
}
这段代码干的事很土,但是靠谱。
每个用户保存最近一分钟的请求时间。请求来了,先把一分钟以前的时间戳扔掉。剩下的如果已经有 1000 个,就拒绝。没到 1000,就把当前请求塞进去。
用在 HTTP 里,大概这样:
func RateLimit(next http.Handler, limiter *limit.UserLimiter) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid := r.Header.Get("X-User-Id")
if uid == "" {
http.Error(w, "missing user", http.StatusUnauthorized)
return
}
if !limiter.Allow(uid) {
w.Header().Set("Retry-After", "60")
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
单机这么写没问题,压测、后台工具、小服务都能用。
但线上多实例就别这么玩了。
你服务部署了 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 包起来,不然 ZREMRANGEBYSCORE、ZCARD、ZADD 分开执行,中间插进来并发请求,计数会飘。
var 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
return 0
end
redis.call("ZADD", key, now, member)
redis.call("PEXPIRE", key, window + 5000)
return 1
`)
Go 这边封一层,不要把 Redis 细节散在 handler 里。限流这种东西,一旦散开,后面排查会很烦。
type RedisUserLimiter struct {
rdb *redis.Client
limit int
window time.Duration
}
func NewRedisUserLimiter(rdb *redis.Client) *RedisUserLimiter {
return &RedisUserLimiter{
rdb: rdb,
limit: 1000,
window: time.Minute,
}
}
func (l *RedisUserLimiter) Allow(ctx context.Context, uid string) (bool, error) {
now := time.Now().UnixMilli()
key := "rate:user:" + uid
member := strconv.FormatInt(now, 10) + ":" + strconv.Itoa(rand.Int())
ret, err := userRateScript.Run(
ctx,
l.rdb,
[]string{key},
now,
l.window.Milliseconds(),
l.limit,
member,
).Int()
if err != nil {
return false, err
}
return ret == 1, nil
}
handler 里就清爽很多:
func LimitByUser(limiter *RedisUserLimiter, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid := r.Header.Get("X-User-Id")
if uid == "" {
http.Error(w, "missing user", http.StatusUnauthorized)
return
}
ok, err := limiter.Allow(r.Context(), uid)
if err != nil {
// 这里我一般不会直接放行。
// Redis 都挂了还放流量,后面的 MySQL、ES、第三方接口可能先死。
http.Error(w, "rate limit unavailable", http.StatusServiceUnavailable)
return
}
if !ok {
w.Header().Set("Retry-After", "60")
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
这里有个小细节,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 次?》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论