likes
comments
collection
share

redis的incr+expire的坑

作者站长头像
站长
· 阅读数 18

背景

redis的incr+expire的坑 用户需要进行ocr识别,为了防止接口被刷,这里面做了一个限制(每分钟调用次数不能超过xxx次)。 经过调研后,决定使用redis的incr和expire来实现这个功能

说明:以下代码使用golang实现

第一版代码

// 执行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调用(伪代码)
	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) {
		elog.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() {
		elog.Warn("checkMinute: user FrequentlyCalled", zap.Int64("uid", uid), zap.String("minuteCount", minuteCount))
		return false, nil
	}
	return true, nil
}

详解

这一版代码我先不说存在哪些问题,大家可以先自行YY下

redis的incr+expire的坑 说明:

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

总结

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

第二版代码

吃过第一版代码的亏后,我们决定将incr+expire放在lua脚本中执行。废话不多,直接上代码

// 执行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调用(伪代码)
	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 (b *baiduOcrSvc) 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
}

总结

经过一番折腾后,看样子是解决了最棘手的问题。给你们留一个问题,第二版代码你们觉得还存在哪些问题呢?欢迎在评论区留言

写作不易,烦请点个赞喽

转载自:https://juejin.cn/post/7079756129433370638
评论
请登录