@@ -65,29 +65,122 @@ func (rl *rateLimiter) allow(key string) bool {
6565
6666func RateLimit (rps , burst int ) gin.HandlerFunc {
6767 limiter := newRateLimiter (rps , burst )
68+ startBucketGC (limiter , 5 * time .Minute , 10 * time .Minute )
6869
70+ return func (c * gin.Context ) {
71+ key := c .ClientIP ()
72+ if ! limiter .allow (key ) {
73+ c .JSON (http .StatusTooManyRequests , gin.H {"error" : "rate limit exceeded" })
74+ c .Abort ()
75+ return
76+ }
77+ c .Next ()
78+ }
79+ }
80+
81+ // RateLimitWithIdempotency 在 RateLimit 基础上叠加幂等窗口,适用于"写多读少且天然幂等"的接口
82+ // (例如点赞)。
83+ //
84+ // - 单 IP 令牌桶限速:超过阈值返回 429。
85+ // - (IP, 资源 ID) 维度的去重窗口:同一 IP 在 dedupTTL 内对同一资源的请求被视作已处理,
86+ // 调用 onIdempotent 写出响应并中止后续处理,避免重复请求触发数据库写入与缓存失效。
87+ //
88+ // resourceParam 是 gin 路径参数名(如 "id")。当 IP 或资源 ID 缺失时跳过去重检查,
89+ // 由 handler 进行业务校验并返回业务错误,避免幂等逻辑掩盖参数问题。
90+ //
91+ // onIdempotent 必须自行写出响应并 Abort;推荐返回与正常成功路径形状一致的响应,
92+ // 使客户端无感知。
93+ func RateLimitWithIdempotency (
94+ rps , burst int ,
95+ dedupTTL time.Duration ,
96+ resourceParam string ,
97+ onIdempotent gin.HandlerFunc ,
98+ ) gin.HandlerFunc {
99+ limiter := newRateLimiter (rps , burst )
100+ dedup := newIdempotencyStore (dedupTTL )
101+
102+ gcInterval := max (dedupTTL , time .Minute )
103+ startBucketGC (limiter , gcInterval , 10 * time .Minute )
104+ startIdempotencyGC (dedup , gcInterval )
105+
106+ return func (c * gin.Context ) {
107+ ip := c .ClientIP ()
108+ if ! limiter .allow (ip ) {
109+ c .JSON (http .StatusTooManyRequests , gin.H {"error" : "rate limit exceeded" })
110+ c .Abort ()
111+ return
112+ }
113+
114+ resourceID := c .Param (resourceParam )
115+ if ip != "" && resourceID != "" && ! dedup .acquire (ip + "|" + resourceID , time .Now ()) {
116+ onIdempotent (c )
117+ c .Abort ()
118+ return
119+ }
120+
121+ c .Next ()
122+ }
123+ }
124+
125+ func startBucketGC (rl * rateLimiter , interval , idle time.Duration ) {
69126 go func () {
70- ticker := time .NewTicker (5 * time . Minute )
127+ ticker := time .NewTicker (interval )
71128 defer ticker .Stop ()
72129 for range ticker .C {
73- limiter .mu .Lock ()
74- cutoff := time .Now ().Add (- 10 * time . Minute )
75- for k , b := range limiter .buckets {
130+ rl .mu .Lock ()
131+ cutoff := time .Now ().Add (- idle )
132+ for k , b := range rl .buckets {
76133 if b .lastTime .Before (cutoff ) {
77- delete (limiter .buckets , k )
134+ delete (rl .buckets , k )
78135 }
79136 }
80- limiter .mu .Unlock ()
137+ rl .mu .Unlock ()
81138 }
82139 }()
140+ }
83141
84- return func (c * gin.Context ) {
85- key := c .ClientIP ()
86- if ! limiter .allow (key ) {
87- c .JSON (http .StatusTooManyRequests , gin.H {"error" : "rate limit exceeded" })
88- c .Abort ()
89- return
142+ // idempotencyStore 维护 (IP, 资源 ID) → 最近一次命中时间的映射,由后台 goroutine
143+ // 周期性回收过期条目,使内存占用与活跃来源数成正比而非历史累积。
144+ type idempotencyStore struct {
145+ mu sync.Mutex
146+ seen map [string ]time.Time
147+ ttl time.Duration
148+ }
149+
150+ func newIdempotencyStore (ttl time.Duration ) * idempotencyStore {
151+ return & idempotencyStore {
152+ seen : make (map [string ]time.Time ),
153+ ttl : ttl ,
154+ }
155+ }
156+
157+ // acquire 在窗口内未命中时记录并返回 true(允许放行);命中时返回 false(应作幂等处理)。
158+ func (s * idempotencyStore ) acquire (key string , now time.Time ) bool {
159+ s .mu .Lock ()
160+ defer s .mu .Unlock ()
161+ if t , ok := s .seen [key ]; ok && now .Sub (t ) < s .ttl {
162+ return false
163+ }
164+ s .seen [key ] = now
165+ return true
166+ }
167+
168+ func (s * idempotencyStore ) gc (now time.Time ) {
169+ s .mu .Lock ()
170+ defer s .mu .Unlock ()
171+ for k , t := range s .seen {
172+ if now .Sub (t ) >= s .ttl {
173+ delete (s .seen , k )
90174 }
91- c .Next ()
92175 }
93176}
177+
178+ func startIdempotencyGC (s * idempotencyStore , interval time.Duration ) {
179+ go func () {
180+ ticker := time .NewTicker (interval )
181+ defer ticker .Stop ()
182+ for range ticker .C {
183+ s .gc (time .Now ())
184+ }
185+ }()
186+ }
0 commit comments