68. redis计数与限流中incr+expire的坑以及解决办法(Lua+TTL)

这篇具有很好参考价值的文章主要介绍了68. redis计数与限流中incr+expire的坑以及解决办法(Lua+TTL)。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

一、简介

在日常工作中,经常会遇到对某种操作进行频次控制或者统计次数的需求,此时常用的做法是采用redisincr来递增,记录访问次数, 以及 expire 来设置失效时间。本文将以一个实际的例子来说明incr存在的一个"坑",以及给出解决方案。

如:
26.redis实现日限流、周限流(含黑名单、白名单)
27.Go实现一月(30天)内不发送重复内容的站内信给用户

有这么一个场景,用户需要进行ocr识别,为了防止接口被刷,这里面做了一个限制(每分钟调用次数不能超过xx次)。 经过调研后,决定使用redisincrexpire来实现这个功能
68. redis计数与限流中incr+expire的坑以及解决办法(Lua+TTL),go,redis,lua,数据库

二、代码演进

第一版代码(存在bug隐患)

// 执行ocr调用
func (o *ocrSvc)doOcr(ctx context.Context,uid int)(interface,err){
	// 如果调用次数超过了指定限制,就直接拒绝此次请求
	ok,err := o.checkMinute(uid)
	if err != nil {
		return nil,err
	}
	if !ok {
		return nil,errors.News("frequently called")
	}
	// 执行第三方ocr调用(伪代码:模拟一个rpc接口)
	ocrRes,err := doOcrByThird()
	if err != nil {
		return nil,err
	}
	// 调用成功则执行 incr操作
	if err := o.redis.Incr(ctx,buildUserOcrCountKey(uid));err!=nil{
	   return nil,err
	}
	return ocrRes,nil
}
 
// 校验每分钟调用次数是否超过限制
func (o *ocrSvc)checkMinute (ctx context.Context,uid int) (bool, error) {
	minuteCount, err := o.redis.Get(ctx, buildUserOcrCountKey(uid))
	if err != nil && !errors.Is(err, eredis.Nil) {
		log.Error("checkMinute: redis.Get failed", zap.Error(err))
		return false, constx.ErrServer
	}
	if errors.Is(err, eredis.Nil) {
		// 过期了,或者没有该用户的调用次数记录(设置初始值为0,过期时间为1分钟)
		o.redis.Set(ctx, buildUserOcrCountKey(uid),0,time.Minute)
		return true, nil
	}
	// 已经超过每分钟的调用次数
	if cast.ToInt(minuteCount) >= config.UserOcrMinuteCount() {
		log.Warn("checkMinute: user FrequentlyCalled", zap.Int64("uid", uid), zap.String("minuteCount", minuteCount))
		return false, nil
	}
	return true, nil
}

详解
这一版代码存在什么问题呢?问题出在了 开始判断没有超出限制,然后执行第三方rpc接口调用也成功了,接下来直接进行计数加一(incr)操作有问题,如下图所示

68. redis计数与限流中incr+expire的坑以及解决办法(Lua+TTL),go,redis,lua,数据库

说明:

  1. 假设当前用户在进行ocr识别时,未超过调用次数。但是在redis中的ttl还剩1秒钟
  2. 然后调用第三方ocr进行识别,加入耗时超过了1
  3. 识别成功后,调用次数+1。这里就很有可能出问题,比如:在incr的时候刚好该key1s前过期了,那么redis是怎么做的呢,它会将该key的值设置为1ttl设置为-1ttl设置为-1ttl设置为-1重要的事情说三遍),-1表示没有过期时间
  4. 这时候bug就出现了,用户的调用次数一直在涨,并且也不会过期,达到临界值时用户的请求就会被拒掉,相当于该用户之后都不能访问这个接口了,并且这种key变多后,由于没有过期时间,还会一直占用redis的内存。

总结
以上代码说明了一个问题,也就是increxpire必须具备原子性。而我们第一版代码显然在边界条件下是不满足要求的,极有可能造成bug,影响用户体验,强烈不推荐使用,接下来一步一步引入修正后的代码

第二版代码(几乎无隐患)

从对第一版代码的分析可知,是由于查询次数还没有达到限制后,又进行了一些rpc调用,或者处理了一些其他业务逻辑,这个时间内,可能key过期了,然后我们直接使用incr进行计数加一,导致了永不过期的key产生。那么我们是不是可以在incr前先保证key还没有过期就行呢?答案是可以的,代码如下:

// 执行ocr调用
func (o *ocrSvc)doOcr(ctx context.Context,uid int)(interface,err){
	// 如果调用次数超过了指定限制,就直接拒绝此次请求
	ok,err := o.checkMinute(uid)
	if err != nil {
		return nil,err
	}
	if !ok {
		return nil,errors.News("frequently called")
	}
	// 执行第三方ocr调用(伪代码:模拟一个rpc接口)
	ocrRes,err := doOcrByThird()
	if err != nil {
		return nil,err
	}
	
	// 调用成功则执行 incr操作
	exists, err := o.redis.Exists(ctx, buildUserOcrCountKey(uid)).Result()
	if err != nil {
		log.Error("doOcr: redis.Exists failed", zap.Error(err))
		return nil, err
	}
	if exists == 1 { // key存在,计数加1
		if err := o.redis.Incr(ctx,buildUserOcrCountKey(uid));err!=nil{
	    return nil,err
	}
	} else { // key不存在,设置key与过期时间
		if err := o.redis.Set(ctx,buildUserOcrCountKey(uid),1,expireTime);err!=nil{
		   return nil,err
		}
	}
	return ocrRes,nil
}
 
// 校验每分钟调用次数是否超过限制
func (o *ocrSvc)checkMinute (ctx context.Context,uid int) (bool, error) {
	minuteCount, err := o.redis.Get(ctx, buildUserOcrCountKey(uid))
	if err != nil && !errors.Is(err, eredis.Nil) {
		log.Error("checkMinute: redis.Get failed", zap.Error(err))
		return false, constx.ErrServer
	}
	if errors.Is(err, eredis.Nil) {
		// 过期了,或者没有该用户的调用次数记录(设置初始值为0,过期时间为1分钟)
		o.redis.Set(ctx, buildUserOcrCountKey(uid),0,time.Minute)
		return true, nil
	}
	// 已经超过每分钟的调用次数
	if cast.ToInt(minuteCount) >= config.UserOcrMinuteCount() {
		log.Warn("checkMinute: user FrequentlyCalled", zap.Int64("uid", uid), zap.String("minuteCount", minuteCount))
		return false, nil
	}
	return true, nil
}

与第一版的差异主要在于如下代码:

  • 在需要incr操作前,我们先查看key是否存在(且没有过期)
    • 确保存在后,立即incr(这两步间隔几乎可以忽略,所以几乎可以避免第一版中的问题)
    • 如果不存在则设置key并设置过期时间。

注:redis中的incr命令是不会改变key的过期时间的

// 调用成功则执行 incr操作
	exists, err := o.redis.Exists(ctx, buildUserOcrCountKey(uid)).Result()
	if err != nil {
		log.Error("doOcr: redis.Exists failed", zap.Error(err))
		return nil, err
	}
	if exists == 1 { // key存在,计数加1
		if err := o.redis.Incr(ctx,buildUserOcrCountKey(uid));err!=nil{
	    return nil,err
	}
	} else { // key不存在,设置key与过期时间
		if err := o.redis.Set(ctx,buildUserOcrCountKey(uid),1,expireTime);err!=nil{
		   return nil,err
		}
	}

还有一种方式是查看key的过期时间,使用ttl,这样即使在极端情况下通过incr设置出了没有过期时间的key,也会在第二次访问的时候通过Set设置过期时间了。

注:ttl命令返回值是键的剩余时间(单位是秒)。当键不存在时,ttl命令会返回-2。没有为键设置过期时间(即永久存在,这是建立一个键后的默认情况)返回-1。

// 调用成功则执行 incr操作
	cnt, err := o.redis.Ttl(ctx, buildUserOcrCountKey(uid)).Result()
	if err != nil {
		log.Error("doOcr: redis.Ttl failed", zap.Error(err))
		return nil, err
	}
	if cnt >= 1 { // key存在,且还没有过期
		if err := o.redis.Incr(ctx,buildUserOcrCountKey(uid));err!=nil{
	    return nil,err
	}
	} else { // key马上过期0,key没有过期时间-1, key不存在-2
		if err := o.redis.Set(ctx,buildUserOcrCountKey(uid),1,expireTime);err!=nil{
		   return nil,err
		}
	}

第三版代码(完美无瑕)

第二版代码中的两种方式其实已经可以在工作中使用了,但如果追求完美无瑕的话,ttl版本的代码在极端情况下还是有点瑕疵,比如极端情况下,key过期时间还有1s过期,然后我们用incr去累加,但是网络延迟了,导致命令到达redis服务器的时候,key已经过期了,尽管第二次访问会用set重置key并设置过期时间,但是万一该用户再也不来访问了呢?这时候这个key就会永远占据着内存了。

incr+expire放在lua脚本中执行保证原子性是最完美的。废话不多说了,直接上代码文章来源地址https://www.toymoban.com/news/detail-811885.html

// 执行ocr调用
func (o *ocrSvc)doOcr(ctx context.Context,uid int)(interface,err){
	// 如果调用次数超过了指定限制,就直接拒绝此次请求
	ok,err := o.checkMinute(uid)
	if err != nil {
		return nil,err
	}
	if !ok {
		return nil,errors.News("frequently called")
	}
	// 执行第三方ocr调用((伪代码:模拟一个rpc接口))
	ocrRes,err := doOcrByThird()
	if err != nil {
		return nil,err
	}
	// 调用成功则执行 incr操作
	if err := o.incrCount(ctx,buildUserOcrCountKey(uid));err!=nil{
	   return nil,err
	}
	return ocrRes,nil
}
 
func (o *ocrSvc) incrCount(ctx context.Context, uid int64) error {
   /*
   此段lua脚本的作用:
     第一步,先执行incr操作
     local current = redis.call('incr',KEYS[1])
     第二步,看下该key的ttl
     local t = redis.call('ttl',KEYS[1]); 
     第三步,如果ttl为-1(永不过期)
     if t == -1 then
         则重新设置过期时间为 「一分钟」
		 redis.call('expire',KEYS[1],ARGV[1])
	 end;
   */
	script := redis.NewScript(
		`local current = redis.call('incr',KEYS[1]);
		 local t = redis.call('ttl',KEYS[1]); 
		 if t == -1 then
		 	redis.call('expire',KEYS[1],ARGV[1])
		 end;
		 return current
	`)
	var (
		expireTime = 60 // 60 秒
	)
	_, err := script.Run(ctx, b.redis.Client(), []string{buildUserOcrCountKey(uid)}, expireTime).Result()
	if err != nil {
		return err
	}
	return nil
}
 
// 校验每分钟调用次数是否超过
func (o *ocrSvc)checkMinute (ctx context.Context,uid int) (bool, error) {
	minuteCount, err := o.redis.Get(ctx, buildUserOcrCountKey(uid))
	if err != nil && !errors.Is(err, eredis.Nil) {
		elog.Error("checkMinute: redis.Get failed", zap.Error(err))
		return false, constx.ErrServer
	}
	if errors.Is(err, eredis.Nil) {
	    // 第二版代码中在check时不进行初始化操作
		// 过期了,或者没有该用户的调用次数记录(设置初始值为0,过期时间为1分钟)
		// o.redis.Set(ctx, buildUserOcrCountKey(uid),0,time.Minute)
		return true, nil
	}
	// 已经超过每分钟的调用次数
	if cast.ToInt(minuteCount) >= config.UserOcrMinuteCount() {
		elog.Warn("checkMinute: user FrequentlyCalled", zap.Int64("uid", uid), zap.String("minuteCount", minuteCount))
		return false, nil
	}
	return true, nil
}

到了这里,关于68. redis计数与限流中incr+expire的坑以及解决办法(Lua+TTL)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • SpringCloud-Alibaba之Sentinel熔断与限流

    一、下载安装运行 http://localhost:8080进行访问 登录账号和密码均为sentinel 二、创建工程,并注册到nacos服务中心 依赖spring-cloud-starter-alibaba-nacos-discovery,spring-cloud-starter-alibaba-sentinel sentine-datasource-nacos (持久化) 配置文件 启动类 业务类 三、启动sentinel java -jar sentinel-dashboard-1.7.0

    2024年02月16日
    浏览(32)
  • 微服务韧性工程:利用Sentinel实施有效服务容错与限流降级

            目录 一、雪崩效应 二、Sentinel 服务容错         2.1 Sentinel容错思路         2.2 内部异常兼容         2.3 外部流量控制 三、Sentinel 项目搭建 四、Sentinel 工作原理         服务容错是微服务设计中一项重要原则和技术手段,主要目标是在服务出现故障、网络波

    2024年03月15日
    浏览(73)
  • 熔断降级与限流在开源SpringBoot/SpringCloud微服务框架的最佳实践

    前期内容导读: Java开源RSA/AES/SHA1/PGP/SM2/SM3/SM4加密算法介绍 Java开源AES/SM4/3DES对称加密算法介绍及其实现 Java开源AES/SM4/3DES对称加密算法的验证说明 Java开源RSA/SM2非对称加密算法对比介绍 Java开源RSA非对称加密算法实现 Java开源SM2非对称加密算法实现 Java开源接口微服务代码框架

    2024年02月12日
    浏览(39)
  • SpringCloud(17~21章):Alibaba入门简介、Nacos服务注册和配置中心、Sentinel实现熔断与限流、Seata处理分布式事务

    Spring Cloud Netflix项目进入维护模式 https://spring.io/blog/2018/12/12/spring-cloud-greenwich-rc1-available-now 说明 Spring Cloud Netflix Projects Entering Maintenance Mode 什么是维护模式 将模块置于维护模式,意味着 Spring Cloud 团队将不会再向模块添加新功能。我们将修复 block 级别的 bug 以及安全问题,我

    2024年01月19日
    浏览(48)
  • go限流、计数器固定窗口算法/计数器滑动窗口算法

    问题1:后端接口只能支撑每10秒1w个请求,要怎么来保护它呢? 问题2:发短信的接口,不超过100次/时,1000次/24小时,要怎么实现? 所谓固定窗口,就是只设置了一个时间段,给这个时间段加上一个计数器。 常见的就是统计每秒钟的请求量。 这里就是一个QPS计数器。 在这一

    2024年04月26日
    浏览(30)
  • springboot 自定义注解 ,实现接口限流(计数器限流)【强行喂饭版】

    思路:通过AOP拦截注解标记的方法,在Redis中维护一个计数器来记录接口访问的频率, 并根据限流策略来判断是否允许继续处理请求。 另一篇:springboot 自定义注解 ,aop切面@Around; 为接口实现日志插入【强行喂饭版】 不多说,直接上代码: 一:创建限流类型 二:创建注解

    2024年02月15日
    浏览(52)
  • java进行系统的限流实现--Guava RateLimiter、简单计数、滑窗计数、信号量、令牌桶

    本文主要介绍了几种限流方法:Guava RateLimiter、简单计数、滑窗计数、信号量、令牌桶,漏桶算法和nginx限流等等 1、引入guava集成的工具 pom.xml 文件 demo代码实现 2.令牌桶算法 3、滑窗计数器 4、信号量

    2024年02月09日
    浏览(29)
  • go-zero 是如何实现计数器限流的?

    原文链接: 如何实现计数器限流? 上一篇文章 go-zero 是如何做路由管理的? 介绍了路由管理,这篇文章来说说限流,主要介绍计数器限流算法,具体的代码实现,我们还是来分析微服务框架 go-zero 的源码。 在微服务架构中,一个服务可能需要频繁地与其他服务交互,而过多

    2024年02月13日
    浏览(29)
  • 限流:计数器、漏桶、令牌桶 三大算法的原理与实战(史上最全)

    限流是面试中的常见的面试题(尤其是大厂面试、高P面试) 注:本文以 PDF 持续更新,最新尼恩 架构笔记、面试题 的PDF文件,请到文末《技术自由圈》公号获取 为什么要限流 简单来说: 限流在很多场景中用来限制并发和请求量,比如说秒杀抢购,保护自身系统和下游系统

    2023年04月17日
    浏览(28)
  • 68、Redis:缓存雪崩、缓存穿透、缓存击穿

    缓存雪崩是指缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生 给每一个缓存数据增加相应的缓存标记,记录缓存是否失效,如果缓存标记失效

    2024年02月16日
    浏览(28)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包