Go 基于 Redis + Lua 实现分布式限流器

2023-11-06

Go 基于 Redis + Lua 实现分布式限流器

限流算法在分布式系统设计中有广泛的应用,特别是在系统的处理能力有限的时候,通过一种有效的手段阻止限制范围外的请求继续对系统造成压力,避免系统被压垮,值得开发工程师们去思考。

实际生活中,限流器算法通常作为限制用户行为的一种方式之一。比如最近我在某东抢 PS5,开始购买的一瞬间就没了,肯定是有些用户使用了脚本去抢(黑产!),导致我们用手的人很难抢到。那么限流器就可以限制一下这些通过脚本去抢购的用户,强烈建议某东优化!

1. 简单计数限流器

首先要介绍的是一种稍微简单的策略,使用一个计数器,限制用户在指定时间内某个行为最多能发生 N 次。由于现在的系统大多都是使用分布式部署的,所以这个计数器我们使用 Redis 来代替。但是由于存在高并发的问题,如何保障计数器的原子性,也是我们需要讨论的问题。

我们先定义一下限流接口:

// 指定用户uid的某个行为action在特定时间内period能够最多发生maxCount次,其中period单位为秒
func isAllowed(uid string, action string, period, maxCount int) bool {
// 基于redis的简单counter算法
// ...
}

// 下单接口
func CreateOrder() {
	canCreateOrder := isAllowed("berryjam", "createOrder", 5, 50) // 调用限流接口,5秒内最多只能下单50次
	if canCreateOrder {
		// 处理下单逻辑
		// ...
		fmt.Println("下单成功")
	} else { // 返回请求或者抛出异常、panic
		panic("下单次数超限")
	}
}

解决方案

我们需要维护一个时间滑动窗口,结合 redis 的 zset 数据结构,就可以轻松地通过 score(我们用的时间戳) 来找出指定的时间窗口。每次可以先删除掉时间窗口范围外的值,剩下的值就是这段时间内的值。zset 的 value 为了保证唯一性,我们使用纳秒级时间戳作为 value。

如图 1 所示:

image-20210811173335334

图 1 滑动窗口示意图

为了减少算法的空间复杂度,只需要保留时间窗口内的行为记录,另外如果用户是不活跃用户,滑动时间窗口内的行为记录也是空的,那么这个zset就可以直接删除,进一步减少占用的空间。

接着通过统计滑动时间窗口内的元素数量与阀值maxCount进行比较,便可知道当前行为是否允许。golang实现代码如下:

package main

import (
	"github.com/go-redis/redis"
	"fmt"
	"time"
)

var redisdb *redis.Client

var counterLuaScript = `
	-- 记录行为
	redis.pcall("zadd", KEYS[1], ARGV[1], ARGV[1]); -- value 和 score 都使用纳秒时间戳,即ARGV[1]
	redis.pcall("zremrangebyscore", KEYS[1], 0, ARGV[2]); -- 移除时间窗口之前的行为记录,剩下的都是时间窗口内的
	local count = redis.pcall("zcard", KEYS[1]); -- 获取窗口内的行为数量
	redis.pcall("expire", KEYS[1], ARGV[3]); -- 设置 zset 过期时间,避免冷用户持续占用内存
	return count -- 返回窗口内行为数量`

var evalSha string

func init() {
	initRedisClient()
}

func initRedisClient() {
	redisdb = redis.NewClient(&redis.Options{
		Addr:     "localhost:6379",
		Password: "",
		DB:       0,
	})

	var err error
	evalSha, err = redisdb.ScriptLoad(counterLuaScript).Result()
	if err != nil {
		panic(err)
	}
}

// period单位为秒
func isAllowed(uid string, action string, period, maxCount int) bool {
	key := fmt.Sprintf("%v_%v", uid, action)
	now := time.Now().UnixNano()
	beforeTime := now - int64(period*1000000000)
	res, err := redisdb.EvalSha(evalSha, []string{key}, now, beforeTime, period).Result()
	if err != nil {
		panic(err)
	}
	if res.(int64) > int64(maxCount) {
		return false
	}
	return true
}

func CreateOrder() {
	canCreateOrder := isAllowed("berryjam", "createOrder", 5, 10)
	if canCreateOrder {
		// 处理下单逻辑
		// ...
		fmt.Println("下单成功")
	} else { // 返回请求或者抛出异常、panic
		panic("下单次数超限")
	}
}

func main() {
	for i := 0; i < 100; i++ {
		CreateOrder()
	}
}

isAllowed 接口使用了 lua 脚本来保证 redis 执行的原子性,防止高并发场景下发生无法预料的错误。

整体思路:每当一个行为到来时,都会维护一次时间窗口;将时间窗口外的记录全部清除掉,只保留窗口内的记录。最后获取窗口内记录的 count,与允许的最大值比较。

至此,一个基于 Redis 的限流器已经完成,但是这种方案有一个比较明显的缺点,当时间窗口内的记录特别大时,就需要消耗巨大的内存来维护这个时间窗口。比如 限定 120s 内操作不得超过 1000 万次,就会消耗巨大的资源去维护计数器。

那么就引出下一种优化策略,漏斗限流。

2. 漏斗限流

漏斗限流是另一种常见的限流方法,这个算法灵感来源自漏斗(funnel)结构。

如图 2 所示:

image-20210811175345641

图 2 漏斗算法

漏斗的容量有限,并且漏嘴的流率固定。如果漏斗停止灌水,那么若干时间后漏斗会变空。如果把漏嘴堵住,一直往里灌水,漏斗会变满直到溢出再也装不进去。如果漏嘴放开,等流走一部分水后,又可以往里面灌水。另外如果漏嘴流水速率大于灌水速率,那么漏斗永远不会满。如果漏嘴流水速率小于灌水速率,那么一旦漏斗满了,就需要暂停灌水等漏斗流出足够的空间后才能继续往里灌水。

因此,漏斗的剩余空间可以表示当前可以进行的行为数量,漏嘴流速表示系统能够处理该行为的频率。下面是用golang实现的一个完整示例代码:

package main

import (
	"time"
	"fmt"
	"github.com/go-redis/redis"
)

const (
	FAILED = iota
	SUCC
)

var redisdb *redis.Client

var initFunnelScript = `
	-- 分别初始化漏斗结构的4个字段capacity、left_quota、leaking_rate、leaking_time
	-- capacity:漏斗容量
	-- left_quota:漏斗剩余空间
	-- leaking_rate:漏嘴流水速率
	-- leaking_time:上一次漏水时间
	local key
	for i,j in ipairs(ARGV) 
	do if i%2 == 0
		then
			redis.pcall('hsetnx', KEYS[1], key, j)
		else
			key = j
		end
	end`

var initFunnelSha string

var makeSpaceScript = `
	local leaking_time = tonumber(redis.pcall('hget', KEYS[1], 'leaking_time'))
	local leaking_rate = tonumber(redis.pcall('hget', KEYS[1], 'leaking_rate'))
	local left_quota = tonumber(redis.pcall('hget', KEYS[1], 'left_quota'))
	local capacity = tonumber(redis.pcall('hget', KEYS[1], 'capacity'))
	local now = tonumber(ARGV[1])
	local delta_time = now - leaking_time -- 距离上一次漏水过去了多久
	local delta_quota = leaking_rate * delta_time -- 又可以腾出不少空间了
	
	redis.pcall('hset', KEYS[1], 'leaking_time', now) -- 记录漏水时间
	if delta_quota + left_quota >= capacity then -- 剩余空间不得高于容量
		redis.pcall('hset', KEYS[1], 'left_quota', capacity) 
	else 
		redis.pcall('hset', KEYS[1], 'left_quota', delta_quota + left_quota) -- 增加剩余空间
	end
`
var makeSpaceSha string

var wateringScript = `
	local left_quota = tonumber(redis.pcall('hget', KEYS[1], 'left_quota'))
	local quota = tonumber(ARGV[1])
	if left_quota >= quota then -- 判断剩余空间是否足够
		redis.pcall('hset', KEYS[1], 'left_quota', left_quota-quota) 
		return 1
	else
		return 0
	end
`

var wateringSha string

func init() {
	initRedisClient()
}

func initRedisClient() {
	redisdb = redis.NewClient(&redis.Options{
		Addr:     "localhost:6379",
		Password: "",
		DB:       0,
	})

	var err error
	initFunnelSha, err = redisdb.ScriptLoad(initFunnelScript).Result()
	if err != nil {
		panic(err)
	}

	makeSpaceSha, err = redisdb.ScriptLoad(makeSpaceScript).Result()
	if err != nil {
		panic(err)
	}

	wateringSha, err = redisdb.ScriptLoad(wateringScript).Result()
	if err != nil {
		panic(err)
	}
}

func MakeSpace(key string) {
	now := time.Now().Unix()
	redisdb.EvalSha(makeSpaceSha, []string{key}, now).Result()
}

// quota为每次处理请求所需要的资源配额
func Watering(key string, quota float64) bool {
	MakeSpace(key)
	res, err := redisdb.EvalSha(wateringSha, []string{key}, quota).Result()
	if err != nil {
		panic(err)
	}
	return res.(int64) == SUCC
}

func IsActionAllowed(uid, action string, capacity float64, leakingRate float64) bool {
	key := fmt.Sprintf("%v_%v", uid, action)
	redisdb.EvalSha(initFunnelSha, []string{key}, "capacity", capacity, "left_quota", capacity, "leaking_rate", leakingRate, "leaking_time", time.Now().Unix())
	return Watering(key, 1)
}

func main() {
	for i := 0; i < 20; i++ {
		fmt.Printf("%+v\n", IsActionAllowed("berryjam", "reply", 15, 0.5))
	}
}

整体思路:

每次先初始化该动作的 redis 结构,如果 hsetnx 已经存在,则操作不会生效。

然后根据初始化的 QPS(leakingRate),和leaking_time(上一次漏水的时间)可以计算出现在还剩下多少空间。

最后如果空间允许的情况下,对空间减少 n 个值。

Redis4.0本身提供了一个限流redis模块,叫redis-cell[3]。该模块也使用了漏斗算法,并提供了原子的限流指令,限流问题会变得更加简单。

叮~

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

Go 基于 Redis + Lua 实现分布式限流器 的相关文章

  • BUAA_C程序括号匹配检查

    问题描述 编写一程序检查C源程序文件中 等括号是否匹配 并输出第一个检测到的不匹配的括号及所对应括号所在的行号 程序中只有一个括号不匹配 注意 1 除了括号可能不匹配外 输入的C源程序无其它语法错误 2 字符常量 字符串常量及注释中括号不应
  • 基于近半年Twitter与Github趋势分析_12大分类500+ChatGPT最新开源GitHub存储库(涵盖ChatGPT开发全框架、全编程语言及教程)——每周更新

    目录 前言 令人惊叹的开源ChatGPT资源 Awesome lists 提示工程 聊天机器人 浏览器扩展及插件 CLIs命令行界面标准应用程序 Reimplementations重实现模型 教程 NLP自然语言处理 Langchain U
  • Vue-自定义指令

    Vue 自定义指令 1 什么是自定义指令 vue 官方提供了 v text v for v model v if 等常用的指令 除此之外vue 还允许开发者自定义指令 2 自定义指令的分类 私有自定义指令 在每个vue 组件中 可以在dir
  • Safari开发者工具

    Safari开发者工具 1 开发者功能 2 开发者功能可以干什么 2 1 捕获模拟器的请求 1 开发者功能 Safari gt 首选项 gt 高级 gt 开启 在菜单栏中显示 开发 菜单 2 开发者功能可以干什么 2 1 捕获模拟器的请求
  • Android 自定义View :虚线矩形

    预览效果 涉及参数 斜线起点坐标 斜线可以忽略 斜线终点坐标 斜线可以忽略 矩形左上角坐标 矩形右下角坐标 其中 前两个参数用于绘制预览效果中矩形上方的斜线 如果不需要可以移除 本案例涉及视频外一个点指向视频内某块区域 因此参数略微复杂 除
  • CMD 命令换行

    CMD 命令换行 在执行较长的 cmd 命令或制作 cmd 命令脚本时 为了方便编写和阅读 有时需要在命令中加入适当的换行 基于不同的命令 有两种换行方式 普通命令 在要换行的地方输入 然后回车 再继续命令的输入 控制命令 如 if for
  • 如何使实时数据采集处理系统保持数据的高速传输

    如何使实时数据采集处理系统保持数据的高速传输 1引言 当前 越来越多的设计应用领域要求具有高精度的A D转换和实时处理功能 在实时数据采集处理系统设计中 一般需要考虑数据采集以及对采集数据的处理 而对于大数据量的实时数据采集处理系统来说 保
  • Linux服务器Java输出文件中文乱码

    使用下面语句查看编码 String encoding System getProperty file encoding 结果输出 ANSI X3 4 1968 从而导致中文乱码通过 locale 查看服务器系统编码 需要修改 1在tomca
  • 【论文阅读笔记】BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding

    BERT的出现使我们终于可以在一个大数据集上训练号一个深的神经网络 应用在很多NLP应用上面 BERT Pre training of Deep Bidirectional Transformers for Language Underst
  • css实现容器高度 适应 屏幕高度

    元素的高度默认是auto 被内容自动撑开 100 使得html的height与屏幕的高度相等 50 使得html的height等于屏幕的一半 若想让一个 div 的高度与屏幕高度自适应 始终充满屏幕 需要从html层开始层层添加height
  • 文本生成评估指标:ROUGE、BLEU详谈

    目录 1 自动摘要与机器翻译 1 自动摘要和机器翻译的定义和目标 2 自动摘要和机器翻译领域的挑战 2 ROUGE Recall Oriented Understudy for Gisting Evaluation 1 ROUGE 的目的和
  • AI虚拟点读机--详细注释解析恩培作品7

    感谢恩培大佬对项目进行了完整的实现 并将代码进行开源 供大家交流学习 一 项目简介 本项目最终达到的效果为手势控制虚拟点读机 如下所示 项目用python实现 调用opencv等库 使用SVM对字体进行分类 由以下步骤组成 1 使用Open
  • cd命令、pwd命令和环境变量PWD、OLDPWD的关联

    1 cd命令 cd命令这里不多介绍 cd 命令是返回上次所在的目录 2 PWD和OLDPWD环境变量 dai ubuntu env PWD home dai OLDPWD dai ubuntu 3 关联 1 当你输入 cd 命令返回上次的目
  • R语言之匹配篇

    2019独角兽企业重金招聘Python工程师标准 gt gt gt match match函数的声明如下 match x table nomatch NA integer incomparables NULL x 向量 要匹配的值 tabl
  • 深入MTK平台bootloader启动之【 Pre-loader -> Lk】分析笔记

    1 bootloader到kernel启动总逻辑流程图 ARM架构中 EL0 EL1是必须实现 EL2 EL3是选配 ELx跟层级对应关系 EL0 app EL1 Linux kernel lk EL2 hypervisor 虚拟化 EL3
  • Codeforces Round #589 (Div. 2)【数学 + 构造】

    A题 Distinct Digits 因为数的大小最长也就是5位 所以直接暴力求解即可 复杂度O 5 N include
  • C\C++ standard lib

    link
  • vue.js 解决空格报错!!!

    当我们初入vue js的时候 使用cli脚手架快速创建项目的时候 如果语法格式错误 这里主要指的是 空格多少引起的问题 找到 webpack base config js文件注释掉下面的东西 var path require path va

随机推荐

  • LeetCode 82. 删除排序链表中的重复元素 II

    题目链接 82 删除排序链表中的重复元素 II 设置虚拟头结点dummy不用考虑边界情况 p指针指向的是上一个没有重复的元素的位置 初始位置是dummy q从p gt next开始 一直走到第一个与q gt next不同元素的位置 删除中间
  • 经典目标检测算法—背景差分法、帧差法和三帧差法

    一 实验目的与要求 1 熟悉经典目标检测算法的原理 2 使用MATLAB语言编程实现背景差分法 帧差法和三帧差法 3 比较背景差分法 帧差法和三帧差法的特点 并了解该算法的应用条件 二 实验环境 Windows matlab 三 实验内容和
  • phpspreadsheet excel导入导出

    单个sheet页Excel2003版最大行数是65536行 Excel2007开始的版本最大行数是1048576行 Excel2003的最大列数是256列 2007以上版本是16384列 xlswriter xlswriter PHP 高性
  • Bean的四种注入方式

    1 set方法注入 2 构造器注入 3 静态工厂注入 4 实例工厂注入 我使用下面两个类来进行注入的演示 这两个类分别是User和Car类 Car类 public class Car 只包含基本数据类型的属性 private int spe
  • 内存管理篇 (一):Go语言之逃逸

    本篇做为Go语言内存管理的第一篇文章 会从下面几个方向来讲述逃逸 1 什么是逃逸 2 为什么需要逃逸 3 逃逸是怎么实现的 一 什么是逃逸 在开始讲逃逸之前 我们先看一下 下面的两个例子 例子1 stack go的fun 返回的就是一个in
  • 转载:浅谈批处理获取管理员运行权限的几种方法

    很多用了Win10版本系统的人都会发现 Windows对程序的运行权限是控制得更加严格了 即使你将UAC控制放至最低 如果没有特别赋予外来程序管理员运行权限的话 很多程序都会运行出错 包括很多用于系统维护的批处理程序由于运行权限不够都会导致
  • linux系统查看命令

    系统 uname a 查看内核 操作系统 CPU信息 head n 1 etc issue 查看操作系统版本 cat proc cpuinfo 查看CPU信息 hostname 查看计算机名 lspci tv 列出所有PCI设备 lsusb
  • Java弱引用(WeakReference)的理解与使用

    在Java里 当一个对象被创建时 它被放在内存堆里 当GC运行的时候 如果发现没有任何引用指向该对象 该对象就会被回收以腾出内存空间 或者换句话说 一个对象被回收 必须满足两个条件 1 没有任何引用指向它 2 GC被运行 Java对于简单的
  • Nacos Client2.2.9源码启动问题

    Nacos Client2 2 9源码启动问题 1 开启服务端 源码启动 推荐使用稳定版本作为 服务端 我是用了最新的2 2 1的nacos版本处理了一些问题 现在启动成功 nacos首页 http 192 168 3 111 8848 n
  • 分布式微电网能源交易算法matlab源代码孤岛微电网之间的能源交易问题,提出了一种分布式算法

    分布式微电网能源交易算法matlab源代码 代码按照高水平文章复现 保证正确 孤岛微电网之间的能源交易问题 提出了一种分布式算法 这个问题由几个通过任意拓扑交换能量流的岛屿微网格组成 提出了一种基于次梯度的开销最小化算法 该算法在实际迭代次
  • flutter报错[!] Android toolchain - develop for Android devices (Android SDK version 29.0.3) X Andr

    Flutter官网 问题 出现以下报错 说许可未知 解决方法 1 选择tools gt SDK Manager gt 2 SDK Platforms tab gt Android 9 0 Pie 3 安装 4 选择29 0 3下载重启And
  • 时序预测

    时序预测 MATLAB实现Hamilton滤波AR时间序列预测 目录 时序预测 MATLAB实现Hamilton滤波AR时间序列预测 预测效果 基本介绍 程序设计 参考资料 预测效果 基本介绍 预测在很大程度上取决于适合周期的模型和所采用的
  • flutter 生命周期

    生命周期似乎已经成为前端框架的标配了 然后在flutter中依然有生命周期这个概念 flutter是一个组件加载到卸载的整个周期 不同的生命周期内可以做的事情都是不一样 相信使用过react vue的小伙伴应该都清楚 在更新组件的时候在相应
  • 暗影精灵跑深度学习,环境安装:ubuntu16.04+GTX1050TI+cuda10.1+cudnn+tensorflow1.13

    最近在用暗影精灵跑深度学习 基于tensorflow 随着数据量增多 CPU已经明显太慢 效率太低 所以把系统环境重新安装了一遍 搭建GPU环境 机器平台 I7 1050TI UBUNTU16 04 1 安装驱动 参考之前的一篇博文 暗影精
  • 分表和联合索引

    系统已经在线上运行了一段时间了 虽然有些小bug 但也都能快速的定位并解决 昨天讨论了二期的需求 看起来还有很长的路要走 1 分表 当某个表的记录数大于某个值 比如 一百万 时 mysql查询的效率会下降 通常这时的办法是水平分表 把记录根
  • 学习笔记:数据分析之上海一卡通乘客刷卡数据分析

    一 数据集简介 本文用到的数据集是以上海一卡通乘客刷卡数据为背景 利用Python对数据进行预处理 以Tableau对数据进行可视化 数据集共包含15772842个样本 每个样本包含7个属性 每个属性之间均已逗号分隔 属性 定义 刷卡人 用
  • IDEA项目配置中出现 in module XXX File is included in 4 contexts的解决方法

    问题 in module XXX File is included in 4 contexts 问题翻译 你的配置文件应该在同一个Application context下 解决办法 如下 最后点击 上面有个 笔 的形状的按钮 勾选全部添加
  • vscode下载和安装教程和配置中文插件(超详细)

    目录 前言必读 一 下载步骤 二 安装步骤 vscode安装设置完成 三 安装中文插件 额外的 四 设置vue高亮代码 额外的 前言必读 读者手册 必读 云边的快乐猫的博客 CSDN博客 前言 vscode主要是用于前端的编程工具 其他编程
  • 常用jdbc连接url

    常用JDBC连接数据库方法总结如下 一 JDBC连接DB2 Class forName Com ibm db2 jdbc net DB2Driver String url jdbc db2 dburl port DBname cn Driv
  • Go 基于 Redis + Lua 实现分布式限流器

    Go 基于 Redis Lua 实现分布式限流器 限流算法在分布式系统设计中有广泛的应用 特别是在系统的处理能力有限的时候 通过一种有效的手段阻止限制范围外的请求继续对系统造成压力 避免系统被压垮 值得开发工程师们去思考 实际生活中 限流器