本文为稀土技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!


手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

大家好,又见面了。

在上一篇文章《# 手写本地缓存实战1——各个击破,按需应对实践运用场景》中,咱们领会了实践项目中一些零星的缓存场景的完结办法,并对缓存完结中的LRU筛选战略TTL过期整理机制完结方案进行了讨论。作为《深化了解缓存原理与实战规划》系列专栏的第四篇文章,咱们将在上一篇的基础之上进行升华,一同考虑怎么构建一个完整且通用的本地缓存结构,并在过程中领会缓存完结的关键点与架构规划的思路。

有的小伙伴可能会有疑问,现在有许多老练的开源库,比方JAVA项目的Guava cacheCaffeine CacheSpring Cache等(这些在咱们的系列文章中,后面都会逐一介绍),它们都供给了相对完善、开箱即用的本地缓存才能,为什么这儿还要去自己手写本地缓存呢?这不是重复造轮子吗?

是也?非也!在编码的进阶之路上,“会用”永久都只是让自己停留在入门等级。正所谓知其然更要知其所以然,经过一同讨论手写缓存的完结与规划关键点,来切身的领会蕴藏在缓存架构中的规划哲学。只有真实的把握其原理,才能在运用中更好的去发挥其最大价值。

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

缓存结构定调

在一个项目体系中需求缓存数据的场景会非常多,而且需求缓存的数据类型也不尽相同。假如每个运用到缓存的地方,咱们都单独的去完结一套缓存,那开发小伙伴们的工作量又要上升了,且后续各事务逻辑独立的缓存部分代码的保护也是一个可预见的头疼问题。

作为应对之法,咱们的本地缓存有必要往一个更高层级进行演进,使得项目中不同的缓存场景都能够通用 —— 也即将其笼统封装为一个通用的本地缓存结构。已然定位为事务通用的本地缓存结构,那至少从标准或许才能层面,具有一些结构该有的样子:

  • 泛型化规划,不同事务维度能够通用

  • 标准化接口,满意大部分场景的运用诉求

  • 轻量级集成,对事务逻辑不要有太强侵入性

  • 多战略可选,允许挑选不同完结战略甚至是缓存存储机制,打破众口难调的困局

下面,咱们以上述几个点要求作为起点,一同来勾勒一个契合上述诉求的本地缓存结构的容貌。

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

缓存结构完结

缓存容器接口规划

在前一篇文章中,咱们有介绍过项目中常见的缓存运用场景。依据提及的几种详细应用场景,咱们能够归纳出事务对本地缓存的API接口层的一些共性诉求。如下表所示:

接口称号 意义阐明
get 依据key查询对应的值
put 将对应的记载添加到缓存中
remove 将指定的缓存记载删去
containsKey 判断缓存中是否有指定的值
clear 清空缓存
getAll 传入多个key,然后批量查询各个key对应的值,批量返回,提升调用方的运用功率
putAll 一次性批量将多个键值对添加到缓存中,提升调用方的运用功率
putIfAbsent 假如不存在的情况下则添加到缓存中,假如存在则不做操作
putIfPresent 假如key已存在的情况下则去更新key对应的值,假如不存在则不做操作

为了满意一些场景对数据过期的支撑,还需求供给或许重载一些接口用于设定过期时刻

接口称号 意义阐明
expireAfter 用于指定某个记载的过期时刻长度
put 重载办法,添加过期时刻的参数设定
putAll 重载办法,添加过期时刻的参数设定

依据上述供给的各个API办法,咱们能够确定缓存的详细接口类界说:

/**
 * 缓存容器接口
 *
 * @author 架构悟道
 * @since 2022/10/15
 */
public interface ICache<K, V> {
    V get(K key);
    void put(K key, V value);
    void put(K key, V value, int timeIntvl, TimeUnit timeUnit);
    V remove(K key);
    boolean containsKey(K key);
    void clear();
    boolean containsValue(V value);
    Map<K, V> getAll(Set<K> keys);
    void putAll(Map<K, V> map);
    void putAll(Map<K, V> map, int timeIntvl, TimeUnit timeUnit);
    boolean putIfAbsent(K key, V value);
    boolean putIfPresent(K key, V value);
    void expireAfter(K key, int timeIntvl, TimeUnit timeUnit);
}

此外,为了便利结构层面临缓存数据的办理与保护,咱们也能够界说一套一致的办理API接口:

接口称号 意义阐明
removeIfExpired 假如给定的key过期则直接删去
clearAllExpiredCaches 清除当前容器中现已过期的一切缓存记载

同样地,咱们能够依据上述接口阐明,敲定接口界说如下:

public interface ICacheClear<K> {
    void removeIfExpired(K key);
    void clearAllExpiredCaches();
}

至此,咱们已完结了缓存的操作与办理保护接口的界说,下面咱们看下怎么对缓存进行保护办理。

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

缓存办理才能构建

在一个项目中,咱们会涉及到多种不同事务维度的数据缓存,而不同事务缓存对应的数据存管要求也各不相同。

比方关于一个公司行政办理体系而言,其涉及到如下数据的缓存:

  • 部分信息

部分信息量比较少,且部分组织架构相对固定,所以需求全量存储,数据不允许过期

  • 职工信息

职工信息总体体量也不大,但是职工信息可能会变更,如职工可能会修改签名、头像或许更换部分等。这些操作对实时性的要求并不高,所以需求设置每条记载缓存30分钟,超时则从缓存中删去,后续运用到之后重新查询DB并写入缓存中。

从上面的示例场景中,能够提炼出缓存结构需求重视到的两个办理才能诉求:

  1. 需求支撑保管多个缓存容器,别离存储不同的数据,比方部分信息和职工信息,需求存储在两个独立的缓存容器中,需求支撑获取各自独立的缓存容器进行操作。

  2. 需求支撑挑选多种不同才能的缓存容器,比方常规的容器、支撑数据过期的缓存容器等。

  3. 需求能够支撑对缓存容器的办理,以及缓存基础保护才能的支撑,比方毁掉缓存容器、比方整理容器内的过期数据。

依据上述诉求,咱们敲定办理接口类如下:

接口称号 意义阐明
createCache 创立一个新的缓存容器
getCache 获取指定的缓存容器
destoryCache 毁掉指定的缓存容器
destoryAllCache 毁掉一切的缓存容器
getAllCacheNames 获取一切的缓存容器称号

对应地,能够完结接口类的界说:

public interface ICacheManager {
    <K, V> ICache<K, V> getCache(String key, Class<K> keyType, Class<V> valueType);
    void createCache(String key, CacheType cacheType);
    void destoryCache(String key);
    void destoryAllCache();
    Set<String> getAllCacheNames();
}

在上一节关于缓存容器的接口划定描述中,咱们敲定了两大类的接口,一类是供给给事务调用的,另一类是给结构办理运用的。为了简化完结,咱们的缓存容器能够一同完结这两类接口,对应UML图如下:

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

为了能让事务自行挑选运用的容器类型,能够经过专门的容器工厂来创立,依据传入的缓存容器类型,创立对应的缓存容器实例:

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

这样,在CacheManager办理层面,咱们能够很轻松的完结创立缓存容器或许获取缓存容器的接口完结:

@Override
public void createCache(String key, CacheType cacheType) {
    ICache cache = CacheFactory.createCache(cacheType);
    caches.put(key, cache);
}
@Override
public <K, V> ICache<K, V> getCache(String cacheCollectionKey, Class<K> keyType, Class<V>valueType) {
    try {
        return (ICache<K, V>) caches.get(cacheCollectionKey);
    } catch (Exception e) {
        throw new RuntimeException("failed to get cache", e);
    }
}

过期整理

作为缓存,经常会需求设定一个缓存有效期,这个有效期能够依据Entry维度进行完结,而且需求支撑到期后自动删去此条数据。在前一篇文章《本地缓存完结的时分需求考虑什么——按需应对实践运用场景》中咱们有详细讨论过几种不同的过期数据整理机制,这儿咱们直接套用结论,选用慵懒删去与定期整理结合的战略来完结。

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

咱们对实践缓存数据值套个外壳,用于存储一些办理类的特点,比方过期时刻等。然后咱们的容器类完结ICacheClear接口,并在对外供给的事务操作接口中进行慵懒删去的完结逻辑。

比方关于默认的缓存容器而言,其ICacheClear的完结逻辑可能如下:

@Override
public synchronized void removeIfExpired(K key) {
    Optional.ofNullable(data.get(key)).map(CacheItem::hasExpired).ifPresent(expired -> {
        if (expired) {
            data.remove(key);
        }
    });
}
@Override
public synchronized void clearAllExpiredCaches() {
    List<K> expiredKeys = data.entrySet().stream()
            .filter(cacheItemEntry -> cacheItemEntry.getValue().hasExpired())
            .map(Map.Entry::getKey)
            .collect(Collectors.toList());
    for (K key : expiredKeys) {
        data.remove(key);
    }
}

这样呢,按照慵懒删去的战略,在各个事务接口中,需求先调用removeIfExpired办法移除已过期的数据:

@Override
public Optional<V> get(K key) {
    removeIfExpired(key);
    return Optional.ofNullable(data.get(key)).map(CacheItem::getValue);
}

而在结构办理层面,作为兜底,需求供给守时机制,来整理各个容器中的过期数据:

public class CacheManager implements ICacheManager {
    private Map<String, ICache> caches = new ConcurrentHashMap<>();
    private List<ICacheHandler> handlers = Collections.synchronizedList(new ArrayList<>());
    public CacheManager() {
        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("start clean expired data timely");
                handlers.forEach(ICacheHandler::clearAllExpiredCaches);
            }
        }, 60000L, 1000L * 60 * 60 * 24);
    }
    // 省掉其它办法
}

这样呢,对缓存的数据过期才能的支撑便完结了。

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

构建不同才能的缓存容器

作为缓存结构,势必需求面临不同的事务各不相同的诉求。在结构搭建层面,咱们整体结构的规划完结遵循着里式替换的准则,且凭借泛型进行构建。这样,咱们就能够完结给定的接口类,供给不同的缓存容器来满意事务的场景需求。

比方咱们需求供给两种类型的容器:

  • 一般的键值对容器

  • 支撑设定最大容量且运用LRU战略筛选的键值对容器

能够直接创立两个不同的容器类,然后别离完结接口办法即可。对应UML示意如下:

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

最后,需求将咱们创立的不同的容器类型在CacheType中注册下,这样调用便利能够经过指定不同的CacheType来挑选运用不同的缓存容器。

@AllArgsConstructor
@Getter
public enum CacheType {
    DEFAULT(DefaultCache.class),
    LRU(LruCache.class);
    private Class<? extends ICache> classType;
}

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

缓存结构运用初体会

至此呢,咱们的本地缓存结构就算是搭建完结了。在事务中有需求运用缓存的场景直接运用CacheManager中的createCache办法创立出对应缓存容器,然后调用缓存容器的接口进行缓存的操作即可。

咱们来调用一下,看看运用体会与功能怎么。比方咱们现在需求为用户信息创立一个独立的缓存,然后往里面写入一个用户记载并设定1s后过期:

public static void main(String[] args) {
    manager.createCache("userData", CacheType.LRU);
    ICache<String, User> userDataCache = manager.getCache("userData", String.class, User.class);
    userDataCache.put("user1", new User("user1"));
    userDataCache.expireAfter("user1", 1, TimeUnit.SECONDS);
    userDataCache.get("user1").ifPresent(value -> System.out.println("找到用户:" + value));
    try {
        Thread.sleep(2000L);
    } catch (Exception e) {
    }
    boolean present = userDataCache.get("user1").isPresent();
    if (!present) {
        System.out.println("用户不存在");
    }
}

履行之后,输出成果为:

找到用户:User(userName=user1)
用户不存在

能够发现,完全契合咱们的预期,且过期数据整理机也已收效。同样地,假如需求为其它数据创立独立的缓存存储,也参阅上面的逻辑,创立自己独立的缓存容器即可。

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

扩展讨论

分布式场景下本地缓存漂移现象应对战略

在本系列的开篇文章《聊一聊作为高并发体系柱石之一的缓存,会用很简略,用好才是技术活》中,咱们有说到过一个本地缓存在分布式场景下存在的一个缓存漂移问题:

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

处理缓存漂移问题,一个简略的方案便是凭借集中式缓存来处理(比方Redis)。但是在一些简略的小型分布式节点中,不太值得引进太多额外公共组件服务的时分,也能够考虑对本地缓存进行增强,供给一些同步更新各节点缓存的机制。

下面介绍两个两个完结思路。

  • 组网播送

在一些小型组网中,当某一个节点履行缓存更新操作的时分,都一同播送一个事件告诉给其他节点,各个节点都进行节点自身缓存数据的更新。

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

  • 守时轮询式

一般的体系中,都会有个数据库节点(比方MySQL),咱们能够凭借数据库作为一个中间辅佐,每次更新之后,都将缓存的更新信息写入一个独立的表中,然后各个缓存节点都守时从DB中拉取增量更新的记载,然后更新到本地缓存中。

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

值得注意的是,上面这些思路仅适用于写操作不是很频频、而且对实时一致性要求不是特别严苛的场景 —— 当然,在实践项目中,真实这么搞的情况比较少。因为本地缓存规划存在的初衷便是用来应对单进程内的缓存独立缓存运用,而这种涉及到多节点之间缓存数据一致确保的场景,本就不是本地缓存的擅长领域。所以在分布式场景下,往往都会直接挑选运用集中式缓存。

当然啦,上面咱们说到的两种本地缓存同步的机制,都是相对简略的一种完结。一些比较干流的本地缓存结构,也有供给一些集群化数据同步的机制,比方Ehcache就供给了高达5种不同的集群化战略,以达到各个本地缓存节点数据保持一致的效果:

  • RMI组播办法

  • JMS音讯办法

  • Cache Server模式

  • JGroup办法

  • Terracotta办法

后续文章中咱们会一同讨论下Ehcache的相关内容,这儿先卖个关子,到时分咱们细聊。

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

小结回忆

好啦,关于手写本地通用缓存结构的内容,咱们就聊这么多。经过本篇内容,咱们完结了对前面文章中提过的一些缓存规划理论准则的实践,并一步步的论述了缓存的规划与完结关键点,更展现了怎么让一个缓存模块从简略的能用变为好用、通用。

当然,本篇内容首要是为了经过手写缓存的模式,来让大家更切身的领会缓存完结中的关键点与架构规划思路,并能在后续的运用中更正确的去运用。在实践项目中,除非一些特别定制诉求需求手动完结缓存机制外,咱们倒也不用自己费时费神地去手写缓存结构,直接选用现有的开源方案即可。比方JAVA类的项目,现在有许多开源库(比方Guava cacheCaffeine CacheSpring Cache等)都供给了相对完善、开箱即用的本地缓存才能,能够直接运用,在后面的系列文章中,咱们将逐一分析。

那么,关于缓存模块的规划与完结,你是否也曾手动编写过呢?你是怎么处理这些问题的呢?你关于这些问题你是否有更好的了解与应对战略呢?欢迎谈论区一同交流下,等待和各位小伙伴们一同商讨、一同生长。

弥补阐明1

本文属于《深化了解缓存原理与实战规划》系列专栏的内容之一。该专栏环绕缓存这个庞大命题进行打开论述,全方位、体系性地深度分析各种缓存完结战略与原理、以及缓存的各种用法、各种问题应对战略,并一同讨论下缓存规划的哲学。

假如有爱好,也欢迎重视此专栏。

弥补阐明2

  • 关于本文中涉及的演示代码的完整示例,我现已整理并提交到github中,假如您有需求,能够自取:github.com/veezean/Jav…

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架

我是悟道,聊技术、又不只是聊技术~

假如觉得有用,请点赞 + 重视让我感受到您的支撑。也能够重视下我的公众号【架构悟道】,获取更及时的更新。

等待与你一同讨论,一同生长为更好的自己。

手写本地缓存实战2—— 打造正规军,构建通用本地缓存框架