使用singleflight解决缓存击穿(python版本)

2,871 阅读5分钟

缓存击穿

在使用缓存的时候,我们往往会先从缓存中获取数据,如果获取不到,就去数据源加载数据,然后写入缓存

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机制。