go高并发之路——缓存击穿

缓存击穿,Redis中的某个热点key不存在或者过期,但是此时有大量的用户访问该key。比如xxx直播间优惠券抢购、xxx商品活动,这时候大量用户会在某个时间点一同访问该热点事件。但是可能由于某种原因,redis的这个热点key没有设置,或者过期了,那么这时候大量高并发对于该key的请求就得不到redis的响应,那么就会将请求直接打在DB服务器上,造成DB突刺,CPU和内存瞬间被打满,最终导致服务崩溃。

本人所负责的业务就存在这样的场景,以直播间邀请榜单为例,顾名思义就是会查询该直播间实时的邀请人数,统计前30名邀请人数最多的用户展示在直播间里面,通过榜单去刺激C端用户的分享参与热情。下面一起分析下这个场景遇到的问题和解决方案。

问题1:
统计邀请榜单需要加载实时的,即我邀请一个人进来,假设在前30名,那我不得上榜吗?那问题来了,这种数据我是不是得实时去查数据库呢?

解决方案:这种业务,我们一般会设置一个短时间的缓存,比如30秒左右。也就是在缓存失效后,即30秒去查一次数据库,不然数据库肯定是顶不住的。

问题2:
我们常规的设置缓存的代码逻辑可能是下面这种。(代码片段错误处理等细节请自行处理,这是一段精简版的代码,主要介绍Redis的处理逻辑)

	//step1:读缓存,存在则返回结果
	ctx := context.Background()

	rdb := redis.NewClient(&redis.Options{
		Addr:     "localhost:6379",
		Password: "123456",
		DB:       0,
	})

	redisKey := "xxx_xxx_xxx" //邀请榜单数据的key

	res, err := rdb.Get(ctx, redisKey).Result()
	if err == nil {
		return res
	}

	//step2:不存在缓存,读DB
	//此处省略,查DB的数据,结果为res

	//step3:设置缓存,并返回结果
	args := redis.SetArgs{
		TTL:  time.Second * 30,
		Mode: "EX",
	}
	_, _ = rdb.SetArgs(ctx, redisKey, res, args).Result()
	
	return res

这种代码逻辑在并发量小的情况下是没有任何问题的,事实上我平时写一些业务,基本上就把它当成一个“公式”来用,用的非常多。然而,在一些高并发的场景下,这种逻辑就会出现问题。试想一下这个场景:假如某个大直播(用户量巨大)是在晚上8点开播,那么8点一到,那个瞬间就会有大量的C端用户进入直播间,去调用后端的接口,假如此时接口的Redis缓存已经过期或者不存在,那么这一刻就会有大量的请求落到DB上,可想而知这一刻DB的压力是多么巨大(这谁顶得住啊)。这就是一个典型的缓存击穿的业务场景。
那么我们需要怎么做,才能让我们的服务抵抗住瞬时的请求洪峰呢?

解决方案:
解决缓存击穿的常见方法有几种:
1、设置该key永不过期,那么就不会存在缓存失效、过期等问题。但这种方法很明显不适合我这种场景,因为我上面提到过,我这个key值存的是邀请榜单的数据,是动态更新的,在直播中,这个榜单的数据是会变化的,所以只能设30秒的缓存时间。该方案行不通。

2、人工干预该key,比如写一个脚本去定时读DB数据,然后更新这个key,然后业务侧(对接前端的接口)只能通过读该key的缓存去获取结果数据,而不能直接读DB。这样也能解决问题,但是貌似维护成本有点高,而且业务侧不能读DB也很不灵活,你想下如果每个热点key都这样去设置维护,那估计会很烦吧。该方案也行不通。

3、使用互斥锁,即在缓存失效的时候,只有一个请求可以获取到互斥锁,然后去查DB,最后重建缓存。这种方案就能很好地解决缓存击穿这个问题,也是我在工作中用来应对缓存击穿问题的最常用的方案。下面是精简版代码:

	//step1:读缓存,存在则返回结果
	ctx := context.Background()

	rdb := redis.NewClient(&redis.Options{
		Addr:     "localhost:6379",
		Password: "123456",
		DB:       0,
	})

	redisKey := "xxx_xxx_xxx" //邀请榜单数据的key

	res, err := rdb.Get(ctx, redisKey).Result()
	if err == nil {
		return res
	}
	
	//step2:不存在缓存,加互斥锁,读缓存
	lockKey := "yyy_yyy_yyy" //互斥锁的key

	argsLock := redis.SetArgs{
		TTL:  time.Second * 3,
		Mode: "NX", //不存在时才执行
	}

	_, err = rdb.SetArgs(ctx, lockKey, "1", argsLock).Result()
	if err != nil { //获取互斥锁失败
		for i := 0; i < 3; i++ { //重复三次去读缓存值
			res, errRetry := rdb.Get(ctx, redisKey).Result()
			if errRetry == nil { //重试读缓存成功,则返回结果
				return res 
			}
			time.Sleep(10 * time.Millisecond) //这里睡眠时间根据业务来定,取的是另一个线程从读数据库到设置缓存成功的大概时间区间
		}
		return nil //如果循环三次,都读不到缓存,则返回空结果
	}

	//step3:获取互斥锁成功,则表明当前的线程/协程拥有查DB的权力
	//此处省略,查DB的数据,结果为res

	//step4:设置缓存,删除互斥锁,并返回结果
	args := redis.SetArgs{
		TTL:  time.Second * 30,
		Mode: "EX",
	}
	_, _ = rdb.SetArgs(ctx, redisKey, res, args).Result()
	
	rdb.Del(ctx, lockKey) //删除互斥锁
	
	return res

以上就是个人在线上的一些项目面对缓存击穿问题,所做的一些处理方案了。当然这个方案也不是完美的,例如当获取到互斥锁的当前线程/协程,出现异常,导致设置缓存失败,那么其他线程/协程就重试3次可能都获取不到正常结果,最后返回了一个空结果给前端。感兴趣的朋友可以想想这个方案还有什么问题,然后能怎么优化,欢迎指出

posted @ 2024-05-05 20:11  snail_lie  阅读(43)  评论(0编辑  收藏  举报