缓存击穿
在使用缓存的时候,我们往往会先从缓存中获取数据,如果获取不到,就去数据源加载数据,然后写入缓存
data = cache.get(key) #step1
if data is None:
data = load_data() #step2
cache.set(key,data) #step3
return data
这基本上也是后端开发再熟悉不过的一种模式,但这种模式,在高并发的场景下,很有可能会产生缓存击穿的问题。举个例子,在系统运行的高峰期,某个热点key过期了,大量的并发请求在step1阶段获取不到数据,就会进入到step2阶段,这时候就会有大量的请求直接打到数据库上,可能直接就把数据库给打死了。
解决方案
缓存击穿产生主要原因,就是缓存中获取不到数据时,大量并发请求同时去数据库加载数据,造成数据库压力太大。分析一下这个问题,我们不难知道,假如同时有1000个并发请求,其实加载数据、更新缓存的操作,做1次就好了,剩下的999次请求做的完全是重复操作。
加锁
那怎么避免加载数据、更新缓存的重复操作呢?首先想到的一个办法就是加锁:获取到锁的请求,去加载数据,没有获取到锁的请求,就先等待。
加锁方案1:(错误方案)
data = cache.get(key) #step1
if data is None:
lock.lock() #阻塞
data = load_data() #step2
cache.set(key,data) #step3
lock.unlock()
return data
这种加锁方案是不行的。这种方案下,虽热避免了并发地加载数据,保证数据库不会被打死,但依然没有解决重复操作的问题。而且这种方案下,大量请求串行化操作,会加大系统延迟。
加锁方案2:try-lock模式
data = cache.get(key) #step1
if data is None:
locked = lock.try_lock() #非阻塞
if locked:
data = load_data() #step2
cache.set(key,data) #step3
lock.unlock()
else:
time.sleep(1)
data = cache.get(key)
return data
这种方案,基本上已经能够满足我们的需求,只有获取到锁的线程会去加载数据,其他线程会休眠一段时间后,再次尝试从缓存中获取数据。但这里还是有两个细节需要注意,一是线程休眠是时间,太短的话可能新的数据还没加载到缓存,太长的话会影响性能。二是锁的粒度,锁的粒度如果太大,比如获取某条订单数据的时候,如果所有的订单都用同一把锁,还是会影响系统的性能,比较合理地方式应该是为每个订单创建一把锁。这里可以考虑使用redis实现分布式锁,用订单号作为分布式锁的key。
singleflight
singleflight是groupcache项目下的一个库,感兴趣的同学可以研究一下groupcache的源码。简单来说,singleflight 能够使多个并发请求所触发的回源操作里,只有第一个回源被执行,其余请求阻塞等待第一个被执行的那个回源操作完成后,直接取其结果,以此保证同一时刻只有一个回源操作在执行,以达到防止击穿的效果。
groupcache是memcache作者用go语言开发的一个缓存系统,我参考其中源码,实现了一个singlecache的Python版本,下面直接上代码:
from threading import Condition, Lock
class Caller(object):
def __init__(self):
self._cond = Condition()
self.val = None
self.err = None
self.done = False
def result(self):
with self._cond: # 阻塞等待执行结果
while not self.done:
self._cond.wait()
if self.err:
raise self.err
return self.val
def notify(self):
with self._cond:
self.done = True
self._cond.notify_all()#通知所有阻塞线程,执行已经完成
class SingleFlight(object):
def __init__(self):
self.map = {}
self.lock = Lock()
def do(self, key, fn, *args, **kwargs):
self.lock.acquire()
if key in self.map:
#已经存在对该key的请求,则新线程不会重复处理key的请求所以释放锁,然后阻塞等待请求得到的结果
caller = self.map[key]
self.lock.release()
return caller.result()
caller = Caller()
self.map[key] = caller
self.lock.release()
# 执行真正的请求函数,得到对该 key 请求的结果
try:
caller.val = fn(*args, **kwargs)
except Exception as e:
caller.err = e
finally:
caller.notify()#通知该key下的阻塞线程,执行已经完成,可以获取结果了
# 执行已经完成,删除对应的key,下次再有同样的key,还会再次执行
self.lock.acquire()
del self.map[key]
self.lock.release()
#返回执行结果
return caller.result()
让我们来简单模型一些并发操作,看看使用SingleFlight的执行效果:
if __name__ == '__main__':
executor = ThreadPoolExecutor(max_workers=20)
single_flight = SingleFlight()
def long_task(delay=1):
print("run long task")
time.sleep(delay)
return random.uniform(1, 100)
def run_in_single_flight():
return single_flight.do('long_task', long_task, delay=0.1)
tasks = []
for i in range(10):
task = executor.submit(run_in_single_flight)
tasks.append(task)
for task in as_completed(tasks):
data = task.result()
print(data)
执行后的输出结果如下:可以看到,我们启动了10个线程并发的执行long_task,每个线程都拿到了执行结果,但long_task实际上只执行了一次。
run long task
61.1450551376543
61.1450551376543
61.1450551376543
61.1450551376543
61.1450551376543
61.1450551376543
61.1450551376543
61.1450551376543
61.1450551376543
61.1450551376543
回到缓存击穿的问题上,使用SingleFlight解决缓存击穿的代码也很简单,其实关键就在于SingleFlight能够保证同一时刻只有一个load_data_and_set_cache操作在执行,执行完毕会立刻通知其他阻塞的线程,而且通过设置不同的key,能有效控制阻塞的粒度。
single_flight = SingleFlight()
def load_data_and_set_cache(key):
data = load_data() #step2
cache.set(key,data) #step3
def request_handler():
data = cache.get(key) #step1
if data is None:
data = single_flight.do(load_data_and_set_cache,key)
return data
总结
singleflight可以看做是一种并发控制机制,这种机制可以有不同的实现,单价版的、分布式版的,其实大部分情况下,单机版的就能很大程度上控制并发访问,也就够用了。这种控制机制,不只是在缓存击穿问题上,在微服务架构中,为了避免给下游服务造成太大的压力,也可以考虑使用singleflight机制。