Part 1: Python异步缓存框架设计(Part 1): 缓存设计概览
Part 1 列出了缓存框架设计的一些常见思路及挑战。本文主则要介绍Cacheme的设计思路及功能。
功能规划
Cacheme在设计上有以下规划:
- 完全asyncio
- 完整的type hints
- 支持多种存储源
- 基于TinyLFU策略的高效本地内存缓存
- 支持多级缓存
- 缓存统计API
- 基于Node进行缓存管理
- 缓存击穿保护
在Cacheme0.2.0中这些规划都已经完成,同时还产生了2个额外项目:
- 使用Rust编写TinyLFU: cacheme-utils
- Benchmark不同存储源的基本性能: cacheme-benchmark
特性介绍
Cache Node
Cacheme使用Node来定义缓存,你可以把它想象成Django的Model之类的,每个Node都对应一类具有实际业务逻辑的数据,比如UserNode就用来缓存用户的基本信息。同时每一个Node的缓存配置都是独立的,你可以UserNode使用Redis缓存,而ShopNode则使用本地缓存。使用预先定义的Node有个很大的好处是方便管理,将所有nodes放在单独的package/module下,别人很容易就能知道现在有哪些数据缓存了,以及缓存的方式。下面来对比一下Cacheme与其他框架的缓存定义及基本API:
cacheme:
@dataclass
class UserInfoNode(cacheme.Node):
user_id: int
def key(self) -> str:
return f"user:{self.user_id}:info"
async def load(self) -> Dict:
...
class Meta(cacheme.Node.Meta):
version = "v1"
caches = [cacheme.Cache(storage="my-redis", ttl=None)]
serializer = MsgPackSerializer()
await cacheme.get(UserInfoNode(user_id=1))
aiocache:
cahce = Cache(Cache.MEMCACHED, namespace="test", serializer=PickleSerializer())
await cache.set('key', 'value')
await cache.get('key')
jcache:
CachingProvider cachingProvider = Caching.getCachingProvider();
CacheManager cacheManager = cachingProvider.getCacheManager();
MutableConfiguration<String, String> config
= new MutableConfiguration<>();
Cache<String, String> cache = cacheManager
.createCache("simpleCache", config);
cache.put("key1", "value1");
cache.put("key2", "value2");
cacheManager.close();
gocache:
redisStore := redis_store.NewRedis(redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
}))
cacheManager := cache.New[string](redisStore)
marshal := marshaler.New(cacheManager)
err := marshal.Set("my-key", "my-value", store.WithExpiration(15*time.Second))
value, err := marshal.Get(ctx, "my-key")
可以明显的看出Cacheme相比其他框架在定义部分结构化程度和可读性更高。同时Cacheme的API也全部基于Node,不再需要手工写key string。这里dataclass并不是必须的,你也可以自己写__init__方法。同时cacheme的API里也没有set方法,因为缓存不存在时会自动通过load函数从数据源获取数据。
另一方面,Cacheme的API使用了范型,因此会根据load函数自动判断类型。例子中的cacheme.get(UserInfoNode(user_id=1))返回类型就是Dict。而aiocache明显无法推断类型。
接着再对比一下使用装饰器的情况,由于Go不存在装饰器就跳过了:
cacheme:
@cacheme.Memoize(UserNode)
async def get_user_info(user_id: int) -> Dict:
return {}
@get_user_info.to_node
def _(user_id: int) -> UserNode:
return UserNode(user_id=user_id)
aiocache:
@cached(ttl=10, cache=Cache.REDIS, key_builder=lambda *args, **kw: "key", serializer=PickleSerializer())
async def cached_call():
print("Sleeping for three seconds zzzz.....")
await asyncio.sleep(3)
return Result("content", 200)
spring:
@Cacheable(value="books", key="#isbn.rawNumber")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)
个人认为aiocache的lambda比较难看。虽然旧版本的Cacheme也通过在装饰器里写lambda来自定义key,但看上去比这个强点。另外使用lambda还有个问题就是无法推断类型。因此为了确保类型正确,Cacheme采用了一种稍微有点复杂的方法,使用单独的函数来进行输入参数到Node的转换。同样由于使用了范型,这个转换函数的输入参数应当与被装饰函数相同。如果你启用了mypy并且两边不一样的话mypy应该会报错。
这里忽略了Node定义部分,如果加上定义就会比其他两种长很多。下一个版本会给Cacheme加上动态build Node功能,类似这样:
@cacheme.Memoize(build_node(name="", caches=[], serializer=xxx))
async def get_user_info(user_id: int) -> Dict:
return {}
@get_user_info.to_node
def _(user_id: int) -> DynamicNode:
return DynamicNode(key=f"{user_id}")
TinyLFU
TinyLFU是最先应用在caffeine中的一种缓存管理策略。在原论文中其实提出了两类策略,缓存准入策略TinyLFU和基于TinyLFU的缓存驱逐策略W-TinyLFU。Cacheme使用的是W-TinyLFU,而ristretto则使用的TinyLFU。这里统称为TinyLFU因为原理上并没有大区别。具体这里不做分析,很多文章都有介绍,也可以去看论文。
Cacheme的TinyLFU最开始是用Python实现的,后来发现基本上都是各种位运算不需要Python自身的特性,而且API也很简单。于是改成用Rust重写,差不多能有20x的性能提升。写的时候参考了这些项目以及论文:
caffeine/cache/sketch/CountMinSketch.java
开发过程中发现这些项目或多或少都与原始论文有些差别,比如ristretto的这个issue:
cmSketch not benefitting from four rows
另外原论文中Reset的时候所有counters都应当减半, 包括insertion counter:
Every time we add an item to the approximation sketch, we increment a counter. Once this counter reaches the sample size (W), we divide it and all other counters in the approximation sketch by 2.
但实际上ristretto是直接把insertion counter清0了: reset()
至于TinyLFU的benchmark,这些项目和论文里都有数据。Cacheme的Readme里也有相关数据。
缓存击穿保护
Cacheme的get和get_all API都带有缓存击穿保护。原理其实很简单,Cacheme有一个全局的locker dict,这个dict的key是node,value是locker。locker包含缓存值和asyncio event。每次get前先把node放入这个dict中,get完成后再删掉。并发的请求如果发现dict里已经有当前node了就直接await locker的event等待完成。这种方式不仅保护了数据源,对缓存源也起到了保护作用。因为对同一个node的并发请求会直接从locker的缓存值获取数据,不用再查询一次缓存源。同时这些并发请求也都算作cache hit,能够提高命中率。get_all API包含多个node,会对每个node都单独进行lock,比如现在有3个并发请求: get_all([1,2,3]), get_all([2,3,4]), get(4). 那么1,2,3,4这4个node都只会请求缓存源1次。2,3的lock被请求1和请求2共享,而4的lock则被请求2和请求3共享。
Go的singleflight也能起到类似的作用,比如groupcache里的这个groupcache.go#L165:
// loadGroup ensures that each key is only fetched once
// (either locally or remotely), regardless of the number of
// concurrent callers.
loadGroup flightGroup
Cacheme-Go也是用了singleflight来避免thundering herd。
Benchmark
最开始重写Cacheme的时候本来只打算放几张图作为benchmarks,但后来无意中看到了django-asv这个项目,感觉挺有趣。因此也开了一个repo做了个类似的页面。由于测试用的是pytest,搜了搜发现github-action-benchmark这个项目正好支持pytest-benchmark,而且配置起来看上去也比asv简单些,就选择了pytest-benchmark。
Benchmark的目的并不是评估绝对性能,因为除了本地内存缓存外,其他种类的storages都涉及到io。而且由于使用的是github action,实际运行机器的情况也有所不同。这些Benchmarks最重要的意义在于让用户对不同存储源,不同存储数据下Cacheme的基本性能有个具体的概念。理论上在实际使用中应该有更好的性能。
Local Storage的Benchmark可以近似认为是真正的性能Benchmark,只是需要扣除掉asyncio本身的额外开销。很明显Python的性能相比Go/Java还是有挺大差距的。
自定义
Cacheme里的广泛使用了Protocol。比如get/get_all方法接收的都是protocol。同时storage/serializer这些也是protocol。因此开发者能很方便的根据需要写自己的node/storage/serializer。