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

admin 2026-10-24 06:18:33 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

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

 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。别在分钟边界上偷懒,很多线上毛刺就是这么来的。


用Golang如何实现限制用户1分钟内最多请求1000次?" 网络安全文章

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

本文详细讲解使用Golang实现用户请求频率限制的两种方案。单机环境下采用内存滑动窗口算法记录用户最近1分钟请求时间戳,多实例部署时使用RedisZSet配合Lua脚本实现分布式限流。关键点包括避免固定窗口的边界问题、Redis异常时的降级策略以及按用户/IP/API等多维度限流建议。
一文梳理三大欧盟标准组织全系列网络安全标准|CRA、RED、工控、消费IoT全覆盖" 网络安全文章

一文梳理三大欧盟标准组织全系列网络安全标准|CRA、RED、工控、消费IoT全覆盖"

本文系统梳理欧盟三大标准化组织CEN、CENELEC和ETSI在网络安全领域的职责分工与协作机制。CEN主导通用IT安全框架和评估标准如EN17927SESIP和EN40000系列;CENELEC专注工控电气设备安全如ENIEC62443;ETSI负责无线通信和消费物联网标准如EN303645。文章详解各组织核心标准适用范围及CRA法案合规要求,并提供按产品类型匹配对应标准的实操指南,帮助出口企业规避高额罚款并提升认证效率。
AI漏洞挖掘实测:漏洞数量与成本谁更优?" 网络安全文章

AI漏洞挖掘实测:漏洞数量与成本谁更优?"

本文实测比较Strix和Shannon两款AI漏洞挖掘工具在10个漏洞demo上的表现。Strix支持灰白盒扫描,白盒发现7个漏洞费用较高,灰盒可检测全部10个漏洞但成本更高;Shannon仅支持灰盒扫描,费用最高但也能检测全部漏洞。测试显示工具已具备基本漏洞发现能力,但成本过高(demo花费3-5美元),建议尝试国内模型降低成本。
AI生成网站正在“Tailwind化”" 网络安全文章

AI生成网站正在“Tailwind化”"

文章探讨了当前AI生成网站普遍采用TailwindCSS模板的现象。技术博主BrianDouglas观察发现,许多AI辅助编程的产品网站在视觉风格上高度相似,并以HackerNews上的多个项目为例进行了说明。文章分析认为,这主要是因为TailwindCSS的实用性和灵活性使其成为AI模型生成网页时的默认选择,从而导致了`模板化审美`的问题。作者指出,这种现象可能反映出产品创建者对产品本身缺乏真正的热情和投入。
评论:0   参与:  0