Python异步缓存框架设计(Part 1): 缓存设计概览

441 阅读7分钟

年前到现在有些空余时间,于是在写了两年Go之后又重新看起了Python。虽然以前也用的是Python3.5,但并没有用到多少Python3的新特性。这次把asyncio看完后就决定把以前写的缓存框架Cacheme使用asyncio重写一下。同时这两年也做了些Go相关的缓存工作,有一些新的想法也想融入到新版本Cacheme当中。现在Cacheme0.2.0已经发布: Cacheme。写篇文章记录一下设计这个框架时的一些想法。

什么是缓存

首先来看一下缓存的定义。Wikipedia是这样定义cache的:

In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation or a copy of data stored elsewhere.

简单来说缓存的目的就是把未来可能要访问的数据提前存储下来,提高系统的整体性能。缓存的使用范围非常广泛,从计算机硬件系统到各种应用程序都在大量使用缓存。而这里主要关注的是各类编程语言对应的缓存框架以及他们的特性。

常见缓存框架类别

缓存框架总体来说可以分为3大类:

第一类是存储直接集成在框架中。这种类型的框架一般都是使用本地内存存储,性能非常高。比如caffeine和ristretto,benchmark数据显示能达到千万级别的ops/sec。本地内存通常都是使用语言自身的hashmap直接存储对象,免去了序列化以及io的消耗。另外有些框架比如Ehcache和GroupCache自带分布式能力,但在分布式情况下则无法避免序列化及反序列化操作。常见的框架如下(只列举Java/Go/Python的,因为Java在这一方面比较完善而且有一些非常著名的框架,而Python/Go则是我比较熟悉):

Java: Guava Cache/Caffeine/Ehcache

Go: Ristretto/Groupcache/Bigcache

Python: functools.cache

第二类则是作为client调用各类Key-Value数据库来存储/读取数据。使用独立数据库存储数据的好处是缓存变为分布式,不会像本地缓存存在数据不一致的问题。各种Key-Value数据库的client都可以归为这一类型。由于各种Key-Value数据库很多,同时传统的relational database也可以用作Key-Value,这里就不详细举例。

第三类框架则同时支持多种Key-Value数据库, 这一类框架通常会直接使用第一类及第二类框架作为driver,将存储层与API层分离。让用户可以自由选择存储方式,甚至组合多种存储方式(多级缓存):

Java: JCache/Jetcache

Go: Gocache

Python: Aiocache/Django Cache

缓存框架设计面临的挑战

1. 缓存失效

There are only two hard things in computer science: cache invalidation and naming things.

缓存失效的问题一直以来都没有通用的解决方法,因为invalidation很多时候都是与业务本身绑定的,比如有些业务可以接受一定的延迟,有些业务需要将一系列关联的缓存同时失效。大部分缓存框架一般也不会对这一块进行特殊处理,而是只提供基本的API来让开发者自己处理。但也有些特殊的情况,比如针对Django ORM的django-cachalotjohnny-cache这些。由于Django ORM的广泛使用以及Python的高度灵活性才会有这类框架产生,换成Go想做类似的东西就很难了。

关于缓存失效有兴趣的可以看看Hacker News上的这个讨论:

Cache made consistent: Meta’s cache invalidation solution

以及上文作者blog上的相关文章:

Cache Invalidation

When and How to Invalidate Cache

2. thundering herd(缓存击穿)

Thundering herd是指大量请求获取同一数据时,由于数据不在缓存中导致所有请求都从数据源读取数据,给数据源瞬间造成极大负担。

Thundering herd通常的处理方法就是加锁,对同一个key的并发请求通过锁来确保只有1个会从数据源读取数据。这里我们可以分成两种情况讨论, 单机下的thundering herd和分布式的thundering herd。假设有N台服务器,每台服务器最多支持M并发,那么最坏情况下会出现M*N的总并发量。单机往往通过goroutine/aynsio/threads来实现请求的并发处理,在这种情况下使用语言本身的锁机制就可以避免这个问题。比如Go的singleflight以及Python的Synchronization Primitives等。而分布式情况下则只能依靠另外的系统来处理,比如redis的pub/sub或者通过Message Queue之类的。一般来说M都是远大于N的,所以最重要的通常是解决单机并发的问题把M变成1。在服务器特别多的情况下才需要考虑怎么减少N。

3. 缓存雪崩

这个其实不应该算缓存框架本身的挑战,只是国内各种文章上经常说到所以也放在这里。高并发下限流的各种方法都可以用在这儿。

4. 缓存管理

缓存管理可以分为以下几个方面:

  • 缓存监控

缓存监控一般依赖框架本身的缓存统计API,通过API将相关metrics上报到prometheus等平台上方便监控。一般来说如果有多个cache store的情况下,每个cache store的数据也应该分开统计。缓存监控最核心的指标是Hit Ratio,直接反应了缓存的利用率。如果Hit Ratio过低可能要评估一下缓存策略设置是否恰当。

  • 缓存策略

缓存策略可以分为三方面,第一个是针对本地内存缓存的管理策略。管理策略通常又分为准入策略和淘汰策略:

  • 准入策略:新数据是否允许存入缓存
  • 淘汰策略:缓存满了后如何淘汰旧的缓存

一般来说我们讨论策略时最多的是讨论淘汰策略,准入策略直接不允许数据进入缓存有些情况下过于激进。常见的淘汰策略是lru和lfu。lru通过简单的链表结构就可以做到,而lfu则需要一些统计学数据结构来进行高效处理。汰策略是否合适通过Hit Ratio就可以判断。像Caffeine就使用了自创的TinyLfu策略,在实际使用中有着非常好的Hit Ratio。

第二个是缓存TTL设置。这个一般根据缓存的实际情况进行调整。TTL时间较长可能会占用大量存储空间,而时间太短则有可能影响系统性能。

第三个则是存储源设置。缓存存储有很多选择,既可以用高效的本地内存存储,也可以用Redis这样的分布式内存数据库或者是传统的关系型数据库。不同类型的缓存数据应当根据实际情况用不同的方式缓存。当然在并发量不高的情况下,使用关系型数据库或者持久化的Key-Value store是性价比最高的选择。

  • 多存储源缓存及多级缓存

实际使用缓存时,最有效的方式是根据不同的使用场景选择合适的存储源。而不是全部扔本地缓存或是全部Redis。这一类需求就产生了上文所说的第三类框架。这一类框架还有个好处是可以串联多个缓存,同时使用本地缓存和分布式缓存应对短时间大量并发的情况。当然使用多级缓存时不可避免的会遇到数据同步的问题。这时也需要根据实际情况选择是设置较短的ttl就能接受还是要通过另外的系统来同步不同服务器间的数据。

# Python异步缓存框架设计(Part 2): Cacheme介绍