令牌桶按用户维度限流

那基于用户id、ip、apikey请求速率的限流(更贴近生产的需求), 阁下又该如何应对?

那这个问题就从全局速率变成了按照用户维度(group by userid)来做限流,那么

  • 早先的全局的rateLimiter就要变成 userid:rateLimiter的键值对, select count( * ) from table ---> select userid, count(*) from table group by userid

  • 使用缓存组件来存储维度键值对: 缓存的剔除机制来清理不再访问的键值对 (30min过期,10min周期清理内存)。

复制代码

|---|------------------------------------------------------------------------------------------|
| | var userLimiters = cache.New(time.Minute*30, 10) // 10 items per minute |
| | func limiterForUser(userID string) *rate.Limiter { |
| | if v, found := userLimiters.Get(userID); found { |
| | return v.(*rate.Limiter) |
| | } |
| | |
| | l := rate.NewLimiter(rate.Every(time.Minute/60), 10) |
| | userLimiters.Set(userID, l, cache.DefaultExpiration) |
| | return l |
| | } |
| | |
| | // 更细化的限流: 针对同一用户的请求次数限速, 增加了细粒度的用户维度,需要维护 用户与对应限速器的映射关系 |
| | func userRatelimitMiddleware(c *gin.Context) { |
| | userID := c.GetString("userID") // 从每个请求context的key中取得信息, 这个key对于req context是排他性的 |
| | if userID == "" { |
| | c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) |
| | return |
| | } |
| | if userID == "" { |
| | userID = c.GetString("x-api-key") |
| | } |
| | |
| | if userID == "" { |
| | userID = c.ClientIP() |
| | } |
| | limiter := limiterForUser(userID) // 通过userid维度找到对应的限速器 |
| | if !limiter.Allow() { |
| | c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Too many requests"}) |
| | return |
| | } |
| | c.Next() |
| | } |

2. redis 作为限流器第三方存储

这个思路也是极其常见的行为: redis可以成为用户令牌桶的全局中心存储: 当多个负载层需要读写用户限流器时,与redis交互。

本次通过golang的实战,深入理解基于redis的令牌桶限流器的算法实现。

① 请求到达负载层,被负载层识别为userid=junio

② 负载层请求redis获取该用户的token bucket的当前状态:
hget userbucket:junio tokens last_time

③ 基于当前时间nowlast_time,计算流逝的时间,再根据rate计算这一阶段下发了多少tokens:delta=(now-last_time) * r/1000,加上redis原始记录的token,就是本次请求时bucket中能用的tokens, 注意:令牌数量最多不能超过cap

④ 如果tokens>=1, 表示桶中有令牌,可放行请求,tokens数量减1

⑤ 最后将本次处理完后的 tokens和last_time=now写入原用户令牌桶
hset userbucket:junio tokens 20 last_time 990

使用redis 中的hashmap存储用户的tokenbucket状态,应用存在读取redis- 计算- 回写redis过程,使用redis lua的脚本执行三个动作,以保证线程安全。

为什么lua脚本能保证线程安全呢?

主要得益于 Redis 的单线程架构和原子性执行机制: 加载并执行lua脚本时所有的redis操作作为一个整体完成; 整个脚本执行期间没有其他命令可以插入。

复制代码

|---|---------------------------------------------------------------------------------------|
| | // 读取- 计算 - 重新赋值都在一个 lua 脚本里面 |
| | ``var redisScript = ``` |
| | local key = KEYS[1] |
| | local capacity = tonumber(ARGV[1]) |
| | local rate = tonumber(ARGV[2]) |
| | local now = tonumber(ARGV[3]) |
| | local tokens = tonumber(redis.call('hget', key, 'tokens') or '-1') |
| | local last_time = tonumber(redis.call('hget', key, 'last_time') or '-1') |
| | |
| | if tokens == -1 or last_time == -1 then |
| | tokens = capacity |
| | last_time = now |
| | else |
| | local elapsed = now - last_time |
| | if elapsed < 0 |
| | then elapsed = 0 |
| | end |
| | local delta = elapsed * rate / 1000 |
| | tokens = tokens + delta |
| | if tokens > capacity then |
| | tokens = capacity |
| | end |
| | last_time = now |
| | end |
| | local allow = 0 |
| | if tokens >= 1 then |
| | allow = 1 |
| | tokens= tokens - 1 |
| | else |
| | allow = 0 |
| | end |
| | |
| | redis.call('hset', key, 'tokens', tokens) |
| | redis.call('hset', key, 'last_time', last_time) |
| | redis.call('PEXPIRE', key, math.max(1000, 2 * math.ceil((capacity / rate) * 5000))) |
| | return allow |
| | ````` |

注意

  • 上面还使用的redis expire机制: redis expire不是滑动过期,但是每次被请求触发执行的时候就重新设置TTL, 表现为"滑动过期"。
  • 除了hset/hget ,还有hmget可用,另外这些操作还有配套的TTL指令,eg:hset key EXAT 1740470400 FIELDS 2 field1 "Hello" field2 "World"

golang应用层的写法如下:

复制代码

|---|------------------------------------------------------------------------|
| | func (r *RedisLimiter) Allow(c *gin.Context, userid string) bool { |
| | key := r.keyprefix + userid // 定位这个用户的token bucket |
| | now := time.Now().UnixMilli() |
| | // Check if the key exists in Redis |
| | rCmd := r.redis.Eval(redisScript, []string{key}, r.cap, r.rate, now) |
| | res, err := rCmd.Result() |
| | if err != nil { |
| | log.Printf("get from redis failure. ", err) |
| | return false |
| | } |
| | if allow, ok := res.(int64); ok { // 注意:lua返回的0,1 值对应golang的int64 |
| | log.Printf("%v %v \n", allow, res) |
| | return allow == 1 |
| | } else { |
| | log.Printf("get from redis failure. ", err) |
| | return false |
| | } |
| | } |


至此限流第二弹结束步讲述了

① 实现根据特定业务维度的限流: 从全局限流器转换成针对业务维度的键值对限流器;

② redis作为限流计数器的外置存储,令牌桶算法在redis上实现原理:核心是使用hashmap存储当前请求用户的令牌桶状态(current_tokens, last_time), 落地时注意使用lua脚本避免竞态条件。

相关推荐
0思必得039 分钟前
[Web自动化] Selenium处理动态网页
前端·爬虫·python·selenium·自动化
东东5161 小时前
智能社区管理系统的设计与实现ssm+vue
前端·javascript·vue.js·毕业设计·毕设
catino1 小时前
图片、文件的预览
前端·javascript
layman05283 小时前
webpack5 css-loader:从基础到原理
前端·css·webpack
半桔3 小时前
【前端小站】CSS 样式美学:从基础语法到界面精筑的实战宝典
前端·css·html
AI老李3 小时前
PostCSS完全指南:功能/配置/插件/SourceMap/AST/插件开发/自定义语法
前端·javascript·postcss
_OP_CHEN3 小时前
【前端开发之CSS】(一)初识 CSS:网页化妆术的终极指南,新手也能轻松拿捏页面美化!
前端·css·html·网页开发·样式表·界面美化
啊哈一半醒3 小时前
CSS 主流布局
前端·css·css布局·标准流 浮动 定位·flex grid 响应式布局
PHP武器库3 小时前
ULUI:不止于按钮和菜单,一个专注于“业务组件”的纯 CSS 框架
前端·css
电商API_180079052473 小时前
第三方淘宝商品详情 API 全维度调用指南:从技术对接到生产落地
java·大数据·前端·数据库·人工智能·网络爬虫