书接上文,缓存界的另外量大问题是缓存击穿和缓存雪崩,这两种都会引起大量的流量泄露到持久层,导致我们的服务宕机并且无限重启可以说不得不防。
一、缓存击穿
其实缓存击穿和缓存穿透是有点类似,只不过是缓存穿透查询的是非法的数据而缓存击穿是查询的合法的数据,那你这你可能就要问了,合法的数据了为啥还没在缓存里为啥不走缓存呢,其实缓存击穿的场景是在于同时大量的请求都去查询同一个东西,例如同时并发查询多次一个没有在缓存里面的商品,这个时候就会导致所有的请求都发现缓存里面没有因为是同时的嘛,那么他们就都会去缓存里面去查找这个东西,那么这些流量就都进入持久层了,然后你的持久层理所当然的就去世了,紧接服务宕机并开始重启。
干掉缓存击穿
最有可能出现这种问题的是那些热点数据,加入一个热点数据是有过期时间的,那么也就是说当你这个热点数据过期的一瞬间就会有大量的请求泄露到持久层里面去,那么对与这种数据的处理直接设置不过期就好了
其次也有可能是有一个数据突然从非热点数据变成了热点数据,这个时候上面的策略对他也是不起效的,对与这种场景我们可以使用分布式的锁,锁住对应的查询条件,进入临界区的要干这几件事情第一查询缓存里面有没有这个数据,如果没有则到持久层查询并把查询到的数据回写到缓存里面,这样后面再进入临界区的请求就能走缓存了可以说是相当的奥利给。关于分布式锁Redisson那也是相当的奥利给。
二、缓存雪崩
上面的缓存击穿是说一个热点key失效了,那么缓存血本是一堆key失效了,造成这种问题的原因是多种多样的,例如你的某台redis服务器去世了,你key的过期时间都是相同的导致同一时间大量的key过期了。
干掉缓存雪崩
如果是redis服务器不稳定那没什么好说的主从,集群都可以一般线上不会出先这个问题,但是从写代码的角度来说集群环境中可以将数据进行分片,将同样的数据分布到多台机器上;当集群中的一台或几台机器宕机,也依然能保障redis是可用的。
对于同一时间大量的key过期则需要是key的过期时间在一定的范围内过期散列开来,并且对于那些热点数据的需要提前预热,但是如果真的不巧,就算是随机过期时间也都搞到一起去过期了,那么上那么就需要加锁排队去数据库中操作,好一点的做法是加入高并发限流算法。
高并发限流之计数器算法
计数器算法是限流算法里最简单也是最容易实现的一种算法,在一开 始的时候,我们可以设置一个计数器counter,每当一个请求过来的时候counter就减1,如果counter减到0了那就等下一个周期再放行,每个周期都重置counter。比如我们规定,对于查询物品接口来说,我们1分钟的访问次数不能超过1000个。现在2000个请求进来了,那只能有1000个请求放行,剩下的等下个周期放行。
高并发限流之漏桶算法
就像是一个水桶,上面放水下面漏水,如果放水的速度太大了则水就会溢出来,所以我们只需要把请求放到一个有界的队列里面去,然后我们从这个有界队列里面FIFO请求去执行就可以了,原理如下图:
上代码
import time
from collections import deque
from concurrent.futures import ThreadPoolExecutor
def _get_executor(max_workers=1):
executor = ThreadPoolExecutor(max_workers=max_workers) #桶的大小
return executor
class LeakBucket:
def __init__(self, bucket_limit = 5):
self.bucket = deque(maxlen=bucket_limit)
def flow(self, request): #放入桶的操作
return self.bucket.append(request)
def leak(self): #从桶里面取出
return self.bucket.popleft() if len(self.bucket) else None
def flow_to_bucket(bucket:LeakBucket):
i = 0
while True:
bucket.flow(f'request {i}')
i += 1
time.sleep(0.2)
def leak_from_bucket(bucket:LeakBucket):
while True:
print(f'{bucket.leak()} is executor')
time.sleep(0.5)
if __name__ == '__main__':
bucket = LeakBucket()
leak_executor = _get_executor()
flow_executor = _get_executor()
leak_executor.submit(flow_to_bucket, bucket)
flow_executor.submit(leak_from_bucket, bucket)
从执行如上可以看出有些request并没有被执行
令牌桶算法
对于很多应用场景来说,除了要求能够限制数据的平均传输速率外,还要求允许某种程度的突发传输。这时候漏桶算法可能就不合适了,令牌桶算法更为适合。如图所示,令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
上代码
import time
import uuid
from collections import deque
from concurrent.futures import ThreadPoolExecutor
def _get_executor(max_workers=1):
executor = ThreadPoolExecutor(max_workers=max_workers)
return executor
class TokenBucket:
def __init__(self, bucket_limit = 5):
self.bucket = deque(maxlen=bucket_limit)
for i in range(bucket_limit):
self.add_token()
def add_token(self):
return self.bucket.append(f'token:{str(uuid.uuid1())}')
def get_token(self):
return self.bucket.popleft() if len(self.bucket) else None
def add_token_to_bucket(bucket:TokenBucket):
while True:
bucket.add_token()
time.sleep(0.5)
def get_token_to_executor(bucket:TokenBucket, thread_id = 0):
request_time = 0
while True:
token = bucket.get_token()
if token:
print(f'{thread_id}:{request_time} get {token} to exec')
request_time += 1
time.sleep(0.2)
if __name__ == '__main__':
bucket = TokenBucket()
add_token_executor = _get_executor()
get_token_exec_executor = _get_executor(3)
add_token_executor.submit(add_token_to_bucket, bucket)
get_token_exec_executor.submit(get_token_to_executor, bucket, 0)
get_token_exec_executor.submit(get_token_to_executor, bucket, 1)
get_token_exec_executor.submit(get_token_to_executor, bucket, 2)
下面是测试效果。
漏桶 VS 令牌桶
漏桶是把请求放到桶里,而令牌桶是把令牌放到桶里。 令牌桶算法生成令牌的速度是恒定的,而请求去拿令牌是没有速度限制的。这意味,面对瞬时大流量,该算法可以在短时间内请求拿到大量令牌,而且拿令牌的过程并不是消耗很大的事情。
无论是对于令牌桶拿不到令牌被拒绝,还是漏桶的水满了溢出,都是为了保证大部分流量的正常使用,而牺牲掉了少部分流量,这是合理的,如果因为极少部分流量需要保证的话,那么就可能导致系统达到极限而挂掉,得不偿失。
在分布式环境下,你就需要在一个集中的地方来维护计数和队列等等这些桶了。这时候就需要用到诸如redis或zookeeper来对上面对应的变量和队列进行修改了。