cover_image

JetCache 缓存开源组件设计精要

张隆 阿里技术 2023年04月11日 00:31

图片


这是阿里技术2023年的第29篇文章

( 本文阅读时间:10分钟 )


本文将为大家介绍JetCache缓存开源组件的前世今生,并剖析了JetCache的工作原理及设计优势。



01



JetCache的前世今生

图片

1.1 诞生-阿里彩票JetCache的伊甸园

图片

  • 2013年,JetCache诞生于 [ 阿里彩票 ],作者是 [ huangli ] 凭借得天独厚的Tair支持和丰富的Spring生态注解支持,赢得了大家的喜爱。

  • 2015年,随着SpringBoot的大热和集团内PandoraBoot的彻底铺开,JetCache以Starter的形式实现了扩展,优化了配置项,在架构设计和性能上更上一层楼。

  • 2015年同年,JetCache开源至Github,作为alibaba的开源缓存框架,其易用性和设计先进性吸引了大批国内外用户,截止当前在github上累计3.7k star,870 fork。

  • 2018年JetCache最大版本更新,对整体的设计进行了调整,修改了默认的序列化方式,集成支持了SpringData,RedisLettuce,Redisson等更加高效以及功能更加灵活且高级的三方SDK。

1.2 整合-开源界大放异彩

图片

  • JetCache原生支持的远程缓存是Tair,但是Tair在集团外并不可用。JetCache为了拥抱开源,实现了时下主流的GuavaCache, CaffeineCache,  Redis,MemCache基本覆盖了国内的主流缓存中间件。

  • 在功能性方面,JetCache满足了用户一行注解解决Method缓存的刚需,同时也能通过叠加注解的方式非常高效的处理缓存穿透,缓存击穿,缓存雪崩,缓存失效等经典分布式缓存的问题,这让用户充分体验到了缓存框架的效率优势和设计先进性。

  • 在扩展性方面,JetCache满足了用户一行注解解决Method缓存的刚需,也提供了优秀的扩展能力。想要实现一个新的Cache类型,只需要实现AbstractEmbeddedCache或者AbstractExternalCache就可以以非常低廉的成本实现一个新的缓存框架。

1.3 挑战-SpringCache江湖地位


  • 在2015年最火的框架是SpringBoot,SpringBoot提供了非常丰富的组件支持以及模块化的组件管理,其中就包括基于JSR-107--JCacheAPI实现的SpringCache框架。

  • SpringCache框架很好的实现了JCacheAPI,在当时占据了非常有力的位置,几乎所有的SpringBoot初创项目,都选择了使用SpringCache来作为他们的第一个缓存框架。但随着软件工程的规模越来越大,分布式场景的经典问题也接踵而至,显然SpringCache在应对分布式环境的经典问题时显得太过于稚嫩。 

    • 对于分布式场景,缓存穿透,缓存击穿,缓存雪崩 等经典问题,缺少足够成熟的方案。

    • 高级特性上,如 分布式锁,多级缓存滑动窗口,缓存序列化,异步API支持等实际工作场景经常会需要用到的核心能力,要么没有,要么不够用。

    • 对于扩展性上,设计的不够开放和正交,很难低成本的完成一些高级功能的扩展。

  • JetCache在这方面做的就不错,并且在迁移缓存方面基本上可以做到换注解平替,所以一旦工程规模达到一定量级,很多架构师会选择从SpringCache的方式切换到JetCache上。

支持项

SpringCache

JetCache

JSR-107

支持

支持

本地缓存

支持

支持

远程缓存

支持

支持

注解缓存

支持

支持

对象缓存

——

支持

分布式锁

——

支持

缓存穿透

简单

灵活方案支持

缓存击穿

简单

灵活方案支持

缓存雪崩

——

灵活方案支持

多级缓存

简单

灵活方案支持

扩展性

支持

支持

监控

——

支持

高级API(异步,原始特性)

——

支持



02



JetCache是如何工作的


完整的组件串联文档:

https://app.heptabase.com/w/db02907915c401c6e33ddcc47e4d67a589047a846be16f30de1644501d939787

图片

2.1 JSR-107--缓存JCache标准抽象实现

Java在2012的JSR-107协议中新增了关于缓存的抽象设计标准--JCache。

图片

2.2 丰富注解-无侵入抽象设计

图片

2.3 启动器和配置-Bean方式

图片

@Configuration@EnableMethodCache(basePackages = "com.taobao.film.tfmind")@EnableCreateCacheAnnotationpublic class JetCacheConfig {    @Beanpublic GlobalCacheConfig config(  @Qualifier("ldbTairManager") TairManager tairLdbManager,  @Qualifier("mdbTairManager") TairManager tairMdbManager,  @Qualifier("rdb3CacheCompose") Rdb3CacheCompose rdb3CacheCompose) {  Map<String, CacheBuilder> localBuilders = new HashMap<>();  Map<String, CacheBuilder> remoteBuilders = new HashMap<>();

// 本地缓存 CaffeineCache EmbeddedCacheBuilder<?> localBuilder = CaffeineCacheBuilder .createCaffeineCacheBuilder() .keyConvertor(FastjsonKeyConvertor.INSTANCE); localBuilders.put(CacheConsts.DEFAULT_AREA, localBuilder);

// 远程缓存 LDB TairCacheBuilder<?> ldbCacheBuilder = TairCacheBuilder.createTairCacheBuilder() .keyConvertor(FastjsonKeyConvertor.INSTANCE) .valueEncoder(KryoValueEncoder.INSTANCE) .valueDecoder(KryoValueDecoder.INSTANCE) .tairManager(tairLdbManager) .namespace(SysConstants.NEW_TAIR_AREA) .cacheNullValue(true); remoteBuilders.put(CacheConsts.DEFAULT_AREA, ldbCacheBuilder);
// 远程缓存 MDB TairCacheBuilder<?> ldbCacheBuilder = TairCacheBuilder.createTairCacheBuilder() .keyConvertor(FastjsonKeyConvertor.INSTANCE) .valueEncoder(KryoValueEncoder.INSTANCE) .valueDecoder(KryoValueDecoder.INSTANCE) .tairManager(tairLdbManager) .namespace(SysConstants.NEW_TAIR_AREA) .cacheNullValue(true); remoteBuilders.put("MDB", mdbCacheBuilder);
// 远程缓存 RDB TairRdb3CacheBuilder<?> rdb3CacheBuilder = TairRdb3CacheBuilder.createRedisCacheBuilder() .keyConvertor(FastjsonKeyConvertor.INSTANCE) .valueEncoder(KryoValueEncoder.INSTANCE) .valueDecoder(KryoValueDecoder.INSTANCE) .jedisPool(rdb3CacheCompose.getJedisPool()) .cacheNullValue(true); remoteBuilders.put("RDB3", rdb3CacheBuilder);
// 构建全局缓存配置 GlobalCacheConfig globalCacheConfig = new GlobalCacheConfig(); globalCacheConfig.setConfigProvider(springConfigProvider()); globalCacheConfig.setLocalCacheBuilders(localBuilders); globalCacheConfig.setRemoteCacheBuilders(remoteBuilders); globalCacheConfig.setStatIntervalMinutes(5); globalCacheConfig.setAreaInCacheName(false);
return globalCacheConfig;}}

2.4 注解模式-AOP-缓存

基于AOP的方法级缓存,最常用最直观的CacheAside模式。

  public interface UserService {      @Cached(expire = 3600, cacheType = CacheType.REMOTE)      User getUserById(long userId);  }

2.5 注解模式-Cache-API 缓存

基于CacheAPI的缓存形式,复杂场景下最灵活的模式。

  @CreateCache(expire = 3600, cacheType = CacheType.REMOTE)  private Cache<Long, UserDO> userCache;

2.6 高级API模式-手动创建CacheAPI

  @Autowired  private CacheManager cacheManager;  private Cache<String, UserDO> userCache;    public UserDO getTestCacheValue() {    if(userCache == null) {      QuickConfig qc = QuickConfig.newBuilder("userCache")          .expire(Duration.ofSeconds(100))          .cacheType(CacheType.BOTH)          .syncLocal(true) // invalidate local cache in all jvm process after update          .build();      userCache = cacheManager.getOrCreateCache(qc);    }    return userCache.get("TestCacheKey")  }

2.7 Cache基础缓存操作

  // 数据存储  void put(K key, V value); // 数据录入  void putAll(Map<? extends K,? extends V> map); // 批量数据录入  boolean putIfAbsent(K key, V value); // 卫语句的数据存储    // 数据读取  V get(K key); // 数据读取  Map<K,V> getAll(Set<? extends K> keys); // 批量数据读取    // 数据删除  void remove(K key);  void removeAll(Set<? extends K> keys);    // 异步高级缓存API  V computeIfAbsent(K key, Function<K, V> loader);  V computeIfAbsent(K key, Function<K, V> loader, boolean cacheNullWhenLoaderReturnNull);  V computeIfAbsent(K key, Function<K, V> loader, boolean cacheNullWhenLoaderReturnNull, long expire, TimeUnit timeUnit);    // 分布式锁  AutoReleaseLock tryLock(K key, long expire, TimeUnit timeUnit);  boolean tryLockAndRun(K key, long expire, TimeUnit timeUnit, Runnable action);    // 原始缓存API (一般不用)  CacheGetResult<V> GET(K key);  MultiGetResult<K, V> GET_ALL(Set<? extends K> keys);  CacheResult PUT(K key, V value);  CacheResult PUT(K key, V value, long expireAfterWrite, TimeUnit timeUnit);  CacheResult PUT_ALL(Map<? extends K, ? extends V> map);  CacheResult PUT_ALL(Map<? extends K, ? extends V> map, long expireAfterWrite, TimeUnit timeUnit);  CacheResult REMOVE(K key);  CacheResult REMOVE_ALL(Set<? extends K> keys);  CacheResult PUT_IF_ABSENT(K key, V value, long expireAfterWrite, TimeUnit timeUnit);

2.8 分布式-缓存穿透

图片

  • 分布式场景下的热点数据通常都保存在缓存当中,以减少数据库的压力,提升服务的性能。

  • 缓存击穿是指,攻击者利用随机访问的方式短时间大量的访问不存在的数据,由于数据不存在,所以缓存中查不到,请求越过缓存层直达数据库,造成数据库的压力激增。

  • 通常的解法有:[空值缓存] 及 [布隆过滤器]

  • JetCache使用了较为轻量级的 [空值缓存] 方式,来解决这个问题。

    @Cached(cacheNullValue=true)@CreateCache(cacheNullValue=true)

// AbstractCache.class
static <K, V> V computeIfAbsentImpl(K key, Function<K, V> loader, boolean cacheNullWhenLoaderReturnNull, long expireAfterWrite, TimeUnit timeUnit, Cache<K, V> cache) { ....... Consumer<V> cacheUpdater = (loadedValue) -> { if(needUpdate(loadedValue, cacheNullWhenLoaderReturnNull, newLoader)) { if (timeUnit != null) { cache.PUT(key, loadedValue, expireAfterWrite, timeUnit).waitForResult(); } else { cache.PUT(key, loadedValue).waitForResult(); } } }; ...... }

2.9 分布式-缓存击穿

图片

  • CacheAside模式的缓存由于本身有淘汰策略,在数据失效后,缓存组件会直接访问数据库尝试重建缓存。

  • 在大规模分布式热点的情况下,一旦热点数据失效,会有大量的请求同时尝试重建缓存,这不但会导致资源浪费,更加危险的是会造成数据库瞬时极大的压力。

  • JetCache通过注解@CachePenetrationProtect实现了JVM内存锁级的击穿保护,使并发重建的请求限制到可控范围。( 如果数据利用率高还可以使用@CacheRefresh的方式来实现基于分布式锁的缓存重建能力 )

// AbstractCache.class
static <K, V> V computeIfAbsentImpl(K key, Function<K, V> loader, boolean cacheNullWhenLoaderReturnNull, long expireAfterWrite, TimeUnit timeUnit, Cache<K, V> cache) { .... if (cache.config().isCachePenetrationProtect()) { loadedValue = synchronizedLoad(cache.config(), abstractCache, key, newLoader, cacheUpdater); } else { loadedValue = newLoader.apply(key); cacheUpdater.accept(loadedValue); } ....}

2.10 分布式-缓存雪崩

图片

  • 缓存雪崩与缓存击穿类似,但是情况更为危机后果更为严重,有可能导致整个集群服务瘫痪。

  • 当大量热点缓存同时失效的时候,大量的缓存重建请求会直达数据库,造成服务节点瘫痪形成服务雪崩。

  • 缓存雪崩的处理方式较为复杂,但简单来说: 

    • 可以建立多级缓存,通过设置不同的过期时间,形成重叠数据滑动窗口。

    • 通过服务主动维护异步任务的形式,维护一块永固缓存,防止热点失效。

  • JetCache 可以通过多级缓存来避免这种情况。

  • JetCache 还提供了@CacheRefreshCacheLoader的方式,使服务有能力创建内建的时间块任务,来达到维护分布式环境下永固缓存的目的。

// RefreshCache.class
public void run() { try { if (config.getRefreshPolicy() == null || (loader == null && !hasLoader())) { cancel(); return; } long now = System.currentTimeMillis(); long stopRefreshAfterLastAccessMillis = config.getRefreshPolicy().getStopRefreshAfterLastAccessMillis(); if (stopRefreshAfterLastAccessMillis > 0) { if (lastAccessTime + stopRefreshAfterLastAccessMillis < now) { logger.debug("cancel refresh: {}", key); cancel(); return; } } logger.debug("refresh key: {}", key); Cache concreteCache = concreteCache(); if (concreteCache instanceof AbstractExternalCache) { externalLoad(concreteCache, now); } else { load(); } } catch (Throwable e) { logger.error("refresh error: key=" + key, e); }}

2.11 分布式-缓存失效/更新

  • 缓存数据也需要维护,尤其是缓存和实际数据不一致的情况下。

  • 例如用户数据,就非常需要缓存失效和缓存更新的能力,及时的在用户做了数据操作之后更新公共缓存的数据。

  • JetCache通过@CacheInvalid@CacheUpdate提供了这种能力,极大程度的避免了缓存数据不一致的情况,同时也增强了缓存操作的灵活性。

public interface UserService {    @Cached(name="userCache.", key="#userId", expire = 3600)    User getUserById(long userId);
@CacheUpdate(name="userCache.", key="#user.userId", value="#user") void updateUser(User user);
@CacheInvalidate(name="userCache.", key="#userId") void deleteUser(long userId);}


03



JetCache框架设计剖析优势有哪些?

3.1 支持多种KV序列化方式

图片

  • CacheKey Convertor :用来进行缓存Key的加工处理 

    • 环境隔离: CacheKey在影演使用最广泛方式,抽象实现环境前缀Convertor就可以当前环境进行缓存前缀的拼接,从而达到数据隔离的目的。

    • 长短缓存: 长短缓存通常使用对象缓存作为Key,为了容灾短缓存和长缓存通常使用了不同的缓存Key。通过实现长短缓存Convertor可以实现相同对象,可以控制长、短缓存的Key使用对象中的不同属性构造,从而达到短缓存提升性能,长缓存降级的目的。

  • ValueEncode、ValueDecode:用来提升缓存性能的绝佳方式

    • 高性能序列化:选择JavaSerialize、kyro、Kyro5的序列化方式可以极大程度的提升我们系统对性能的要求,很适合应对高并发环境的大流量压力。

    • 兼容性序列化:选择JSON(FastJson、FastJson2、Jackson)的方式,可以为缓存提供良好的兼容性。在架构设计的初期,完全可以采用这种方式来实现平稳迭代。

    • 加密序列化:当我们使用外部数据库的时候,我们可以自己实现ValueEncode和ValueDecode来保障我们数据的安全。

3.2 支持多种本地,远程缓存

图片

3.3 多级缓存-乐高积木

图片

  • 长短缓存:通过多级缓存加上KeyConvertor可以快速构建成本最低效率最高的长短缓存组件。

  • 用户缓存:互动用户数据很多,配合用户路由,可以结合 LocalCache + LDB 的方式既保证数据的可靠性,又能将性能从10ms -> 1ms 级。

  • 自定义多级“缓存”:由于JetCache缓存的实现相当方便,我们甚至可以实现 Mysql,Opensearch 的Cache实现,并且把它组转到多级缓存之中,形成一种结构稳固的数据读写组件。

3.4 高级特性-加载器

图片

// AOP 缓存 Example@Cached(expireTime= 5 * 60)public Long loadOrderSumFromDatabase(String orderType);
@CreateCache(expireTime= 5 * 60)private Cache<String, Order> orderSumCache;
// 每分钟拉取订单总数,形成持久缓存@PostConstructpublic void init(){ RefreshPolicy policy = RefreshPolicy.newPolicy(1, TimeUnit.MINUTES) .stopRefreshAfterLastAccess(30, TimeUnit.MINUTES); orderSumCache.config().setLoader(this::loadOrderSumFromDatabase); orderSumCache.config().setRefreshPolicy(policy);}

3.5 高级特性-监听器

图片

官方实现-数据报告

// 数据报告Monitor的代码实现public class DefaultCacheMonitor implements CacheMonitor {    public synchronized void afterOperation(CacheEvent event) {        if (event instanceof CacheGetEvent) {            CacheGetEvent e = (CacheGetEvent) event;            afterGet(e.getMillis(), e.getKey(), e.getResult());        } else if (event instanceof CachePutEvent) {            CachePutEvent e = (CachePutEvent) event;            afterPut(e.getMillis(), e.getKey(), e.getValue(), e.getResult());        } else if (event instanceof CacheRemoveEvent) {            CacheRemoveEvent e = (CacheRemoveEvent) event;            afterRemove(e.getMillis(), e.getKey(), e.getResult());        } else if (event instanceof CacheLoadEvent) {            CacheLoadEvent e = (CacheLoadEvent) event;            afterLoad(e.getMillis(), e.getKey(), e.getLoadedValue(), e.isSuccess());        } else if (event instanceof CacheGetAllEvent) {            CacheGetAllEvent e = (CacheGetAllEvent) event;            afterGetAll(e.getMillis(), e.getKeys(), e.getResult());        } else if (event instanceof CacheLoadAllEvent) {            CacheLoadAllEvent e = (CacheLoadAllEvent) event;            afterLoadAll(e.getMillis(), e.getKeys(), e.getLoadedValue(), e.isSuccess());        } else if (event instanceof CachePutAllEvent) {            CachePutAllEvent e = (CachePutAllEvent) event;            afterPutAll(e.getMillis(), e.getMap(), e.getResult());        } else if (event instanceof CacheRemoveAllEvent) {            CacheRemoveAllEvent e = (CacheRemoveAllEvent) event;            afterRemoveAll(e.getMillis(), e.getKeys(), e.getResult());        }    }}
// 数据报告Monitor 的注册public void addMonitors(CacheManager cacheManager, Cache cache, QuickConfig quickConfig) {    if (metricsManager == null) {        return;    }    DefaultCacheMonitor monitor = new DefaultCacheMonitor(quickConfig.getName());    cache.config().getMonitors().add(monitor);}

效果:

图片



04



影演之路:影演如何发展了JetCache

Jetcache在开源界如此火,离不开它遵循了JSR107标准,遵从于原则的设计和对原则的扩充使得它在学习效率上非常高效,代码结构上也非常优秀,并且它也在开放性和扩展性下足了功夫,真正实现了架构上的 ”正交“。


在电影演出BU内部,由于要应对业务的复杂性,所以需要针对Jetcache做一些比较定制化的扩展,其中有关于核心底层tair的支持,也有关于分布式场景管理的诉求,更有对业务瓶颈挑战的通用设计。


通过这些新的场景设计,我们极大的丰富了Jetcache的应用场景以及让它重新再集团中间件的环境之下,长出了新的分支,非常好的支撑的业务发展。

4.1 通用高并发三级缓存熔断组件

图片

4.2 缓存后置写(Cache Write-Back)

图片

缓存后置写是一种 Cache Write-Back 模式的实现:

1)缓存后置写由JetCache的Monitor来实现活跃事件的监控以及记录,每当有事件产生,后置写监控器就会被触发。

2)将需要缓存后置写的Cache实例通过Config.Monitor的方式添加好默认后置写监控器。

3)活跃Event 将会被不同的 缓存后置写实现捕获,并会将CacheKey缓存在一个唯一分布式队列中,等待调度。

4)我们通过了 ScheduleX 实现了分布式调度器,每分钟都会进行触发(当然每个后置写实现可能会有不同的触发频率)


目前影演使用缓存后置写实现了非常多的实用应用,包括: 

  • 影演评分数据准实时合并入库,同步至淘票票,大麦三方业务库。( 准实时并发写方案,数据同步方案)

  • 线上、预发缓存准实时同步。 (环境数据一致性)

  • 数据变更对比,趋势数据记录。 ( 数据对账,数据趋势图 )

  • 本地缓存广播器。( 本地缓存一致性,避免数据波动)

4.3 本地缓存广播器(LocalCache Distribute)

图片

4.4 稀疏列表缓存实现(MultiListCache)

图片

图片



05



面向未来:JetCache还有哪些不足

图片

图片

图片

图片
欢迎留言一起参与讨论~

微信扫一扫
关注该公众号

继续滑动看下一个
阿里技术
向上滑动看下一个