我开发的第一套缓存中间件是cachex。cachex支持自定义存储后端,实现了哨兵机制,自带lru存储,支持系统故障时使用过期缓存数据。
cachex的主要问题是不支持批量处理。这也导致在上家公司,cachex未能上阵。我在上家三年时间一直不能开发出支持批量处理的缓存中间件,我也写了三年业务耦合的缓存处理代码。
这次的restcache是fastrest组件库的一个组件。在写restcache之前,我创建了gox项目,实现了优化版的lru,和一套完整的支持批量处理的哨兵机制,然后再实现restcache就简单多了。解决一个复杂问题的一个有效方法,就是模块拆分。
一套完整的缓存,应该包括三个模块:存储、查询、中间件。三个名字是我取的,不准确。
- 存储是存储缓存数据的部分,也可以称为后端,一般是redis客户端,或者进程LRU存储。存储逻辑都是透明处理业务数据,也就是存储逻辑是通用的,不用对每个业务逻辑写一套存储代码。
- 查询是存储查不到目标数据时,提供新缓存数据的部分,一般是从MySQL等持久化数据库或者服务接口查询。很遗憾,查询逻辑是耦合业务数据的,需要对每个缓存业务写一套定制的查询代码。
- 中间件是响应用户查询,调用存储和查询的胶水逻辑,也就是实现了典型的缓存流程。这个也是通用的。
cachex和restcache实现的就是缓存的中间件,并定义了存储和查询的接口。restcache的用户需要自己实现存储和查询。restcache已经附带了一套lrucache存储,可以直接用。但因为用户的redis库版本会不同,restcache没有实现redis存储。
典型的缓存流程如下:
- 先查存储。如果找到,返回结果,结束;如果没找到,可能是因为没存或者已经过期,继续下一步。
- 调用查询,查到新缓存数据,保存到存储,返回结果,结束;如果没查到,返回notfound,结束。
- 下次查存储,查到上一步保存的新缓存数据。糟糕的情况是notfound,会重复第一、二步。
应该绝大多数缓存都是这个流程。这个流程的主要麻烦有两个:
- 缓存失效风暴,即大面积缓存失效。缓存本意是避免执行大量重复的低效的查询,但大面积缓存实效会降低缓存的意义,严重的会导致后面的服务失去服务能力。
- 批量查询。实际场景中,很多列表的查询。批量处理,会让流程中的每个环节变复杂。
解决方案为:
- 对于缓存实效风暴,cachex和restcache都用上了哨兵机制。我实现的叫SentinelGroup,Go团队实现了一套singleflight。SentinelGroup不见得写得比singleflight好,但强在支持批量处理。两者核心逻辑都是一致,多个过程需要同时取得相同的结果,只放行一个去做生产者,其它作为消费者等待生产者分享结果。
- 对于批量查询,这是cachex无法实现,但restcache实现了的特性。重复下:解决一个复杂问题的一个有效方法,就是模块拆分。一个复杂模块,由几个小模块简单地构成,每个小模块都封装了一部分复杂逻辑。然后小模块又可以继续拆分为几个更小的模块。cachex的失败,在于没划分清楚缓存和哨兵机制的边界,没能定义出清晰简单的批量存储接口,导致单个模块过于复杂。解决了实现的复杂性,简单来说,缓存的批量处理,就是要从不同的来源查询数据,再把不同来源的数据组装起来。第一步需要从存储和查询中拿到数据,第二步需要从哨兵机制的生产者和消费者拿到数据。因为这两步的边界足够清楚,也就降低了整体的复杂度。
restcache刚刚开发出来,虽然我写了很多单元测试,但经过项目的考验才能成为一个成熟稳定的组件。