咱们在 从零手写 cache 框架(一)实现固定大小的缓存 中已经初步实现了咱们的 cache。java
本节,让咱们来一块儿学习一下如何实现相似 redis 中的 expire 过时功能。git
过时是一个很是有用的特性,好比我但愿登陆信息放到 redis 中,30min 以后失效;或者单日的累计信息放在 redis 中,在天天的凌晨自动清空。github
咱们首先来定义一下接口。redis
主要有两个:一个是多久以后过时,一个是在何时过时。缓存
public interface ICache<K, V> extends Map<K, V> { /** * 设置过时时间 * (1)若是 key 不存在,则什么都不作。 * (2)暂时不提供新建 key 指定过时时间的方式,会破坏原来的方法。 * * 会作什么: * 相似于 redis * (1)惰性删除。 * 在执行下面的方法时,若是过时则进行删除。 * {@link ICache#get(Object)} 获取 * {@link ICache#values()} 获取全部值 * {@link ICache#entrySet()} 获取全部明细 * * 【数据的不一致性】 * 调用其余方法,可能获得的不是使用者的预期结果,由于此时的 expire 信息可能没有被及时更新。 * 好比 * {@link ICache#isEmpty()} 是否为空 * {@link ICache#size()} 当前大小 * 同时会致使以 size() 做为过时条件的问题。 * * 解决方案:考虑添加 refresh 等方法,暂时不作一致性的考虑。 * 对于实际的使用,咱们更关心 K/V 的信息。 * * (2)定时删除 * 启动一个定时任务。每次随机选择指定大小的 key 进行是否过时判断。 * 相似于 redis,为了简化,能够考虑设定超时时间,频率与超时时间成反比。 * * 其余拓展性考虑: * 后期考虑提供原子性操做,保证事务性。暂时不作考虑。 * 此处默认使用 TTL 做为比较的基准,暂时不想支持 LastAccessTime 的淘汰策略。会增长复杂度。 * 若是增长 lastAccessTime 过时,本方法能够不作修改。 * * @param key key * @param timeInMills 毫秒时间以后过时 * @return this * @since 0.0.3 */ ICache<K, V> expire(final K key, final long timeInMills); /** * 在指定的时间过时 * @param key key * @param timeInMills 时间戳 * @return this * @since 0.0.3 */ ICache<K, V> expireAt(final K key, final long timeInMills); }
为了便于处理,咱们将多久以后过时,进行计算。将两个问题变成同一个问题,在何时过时的问题。框架
核心的代码,主要仍是看 cacheExpire 接口。ide
@Override public ICache<K, V> expire(K key, long timeInMills) { long expireTime = System.currentTimeMillis() + timeInMills; return this.expireAt(key, expireTime); } @Override public ICache<K, V> expireAt(K key, long timeInMills) { this.cacheExpire.expire(key, timeInMills); return this; }
这里为了便于后期拓展,对于过时的处理定义为接口,便于后期灵活替换。性能
其中 expire(final K key, final long expireAt);
就是咱们方法中调用的地方。学习
refershExpire 属于惰性删除,须要进行刷新时才考虑,咱们后面讲解。测试
public interface ICacheExpire<K,V> { /** * 指定过时信息 * @param key key * @param expireAt 何时过时 * @since 0.0.3 */ void expire(final K key, final long expireAt); /** * 惰性删除中须要处理的 keys * @param keyList keys * @since 0.0.3 */ void refreshExpire(final Collection<K> keyList); }
其实过时的实思路也比较简单:咱们能够开启一个定时任务,好比 1 秒钟作一次轮训,将过时的信息清空。
/** * 过时 map * * 空间换时间 * @since 0.0.3 */ private final Map<K, Long> expireMap = new HashMap<>(); @Override public void expire(K key, long expireAt) { expireMap.put(key, expireAt); }
咱们定义一个 map,key 是对应的要过时的信息,value 存储的是过时时间。
咱们固定 100ms 清理一次,每次最多清理 100 个。
/** * 单次清空的数量限制 * @since 0.0.3 */ private static final int LIMIT = 100; /** * 缓存实现 * @since 0.0.3 */ private final ICache<K,V> cache; /** * 线程执行类 * @since 0.0.3 */ private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadScheduledExecutor(); public CacheExpire(ICache<K, V> cache) { this.cache = cache; this.init(); } /** * 初始化任务 * @since 0.0.3 */ private void init() { EXECUTOR_SERVICE.scheduleAtFixedRate(new ExpireThread(), 100, 100, TimeUnit.MILLISECONDS); }
这里定义了一个单线程,用于执行清空任务。
这个很是简单,遍历过时数据,判断对应的时间,若是已经到期了,则执行清空操做。
为了不单次执行时间过长,最多只处理 100 条。
/** * 定时执行任务 * @since 0.0.3 */ private class ExpireThread implements Runnable { @Override public void run() { //1.判断是否为空 if(MapUtil.isEmpty(expireMap)) { return; } //2. 获取 key 进行处理 int count = 0; for(Map.Entry<K, Long> entry : expireMap.entrySet()) { if(count >= LIMIT) { return; } expireKey(entry); count++; } } } /** * 执行过时操做 * @param entry 明细 * @since 0.0.3 */ private void expireKey(Map.Entry<K, Long> entry) { final K key = entry.getKey(); final Long expireAt = entry.getValue(); // 删除的逻辑处理 long currentTime = System.currentTimeMillis(); if(currentTime >= expireAt) { expireMap.remove(key); // 再移除缓存,后续能够经过惰性删除作补偿 cache.remove(key); } }
若是过时的应用场景很少,那么常常轮训的意义实际不大。
好比咱们的任务 99% 都是在凌晨清空数据,白天不管怎么轮询,纯粹是浪费资源。
那有没有什么方法,能够快速的判断有没有须要处理的过时元素呢?
答案是有的,那就是排序的 MAP。
咱们换一种思路,让过时的时间作 key,相同时间的须要过时的信息放在一个列表中,做为 value。
而后对过时时间排序,轮询的时候就能够快速判断出是否有过时的信息了。
public class CacheExpireSort<K,V> implements ICacheExpire<K,V> { /** * 单次清空的数量限制 * @since 0.0.3 */ private static final int LIMIT = 100; /** * 排序缓存存储 * * 使用按照时间排序的缓存处理。 * @since 0.0.3 */ private final Map<Long, List<K>> sortMap = new TreeMap<>(new Comparator<Long>() { @Override public int compare(Long o1, Long o2) { return (int) (o1-o2); } }); /** * 过时 map * * 空间换时间 * @since 0.0.3 */ private final Map<K, Long> expireMap = new HashMap<>(); /** * 缓存实现 * @since 0.0.3 */ private final ICache<K,V> cache; /** * 线程执行类 * @since 0.0.3 */ private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadScheduledExecutor(); public CacheExpireSort(ICache<K, V> cache) { this.cache = cache; this.init(); } /** * 初始化任务 * @since 0.0.3 */ private void init() { EXECUTOR_SERVICE.scheduleAtFixedRate(new ExpireThread(), 1, 1, TimeUnit.SECONDS); } /** * 定时执行任务 * @since 0.0.3 */ private class ExpireThread implements Runnable { @Override public void run() { //1.判断是否为空 if(MapUtil.isEmpty(sortMap)) { return; } //2. 获取 key 进行处理 int count = 0; for(Map.Entry<Long, List<K>> entry : sortMap.entrySet()) { final Long expireAt = entry.getKey(); List<K> expireKeys = entry.getValue(); // 判断队列是否为空 if(CollectionUtil.isEmpty(expireKeys)) { sortMap.remove(expireAt); continue; } if(count >= LIMIT) { return; } // 删除的逻辑处理 long currentTime = System.currentTimeMillis(); if(currentTime >= expireAt) { Iterator<K> iterator = expireKeys.iterator(); while (iterator.hasNext()) { K key = iterator.next(); // 先移除自己 iterator.remove(); expireMap.remove(key); // 再移除缓存,后续能够经过惰性删除作补偿 cache.remove(key); count++; } } else { // 直接跳过,没有过时的信息 return; } } } } @Override public void expire(K key, long expireAt) { List<K> keys = sortMap.get(expireAt); if(keys == null) { keys = new ArrayList<>(); } keys.add(key); // 设置对应的信息 sortMap.put(expireAt, keys); expireMap.put(key, expireAt); } }
看起来是切实可行的,这样能够下降轮询的压力。
这里其实使用空间换取时间,以为后面能够作一下改进,这种方法性能应该仍是不错的。
不过我并无采用这个方案,主要是考虑到惰性删除的问题,这样会麻烦一些,后续考虑持续改善下这个方案。
相似于 redis,咱们采用定时删除的方案,就有一个问题:可能数据清理的不及时。
那当咱们查询时,可能获取到到是脏数据。
因而就有一些人就想了,当咱们关心某些数据时,才对数据作对应的删除判断操做,这样压力会小不少。
算是一种折中方案。
通常就是各类查询方法,好比咱们获取 key 对应的值时
@Override @SuppressWarnings("unchecked") public V get(Object key) { //1. 刷新全部过时信息 K genericKey = (K) key; this.cacheExpire.refreshExpire(Collections.singletonList(genericKey)); return map.get(key); }
咱们在获取以前,先作一次数据的刷新。
实现原理也很是简单,就是一个循环,而后做删除便可。
这里加了一个小的优化:选择数量少的做为外循环。
循环集合的时间复杂度是 O(n), map.get() 的时间复杂度是 O(1);
@Override public void refreshExpire(Collection<K> keyList) { if(CollectionUtil.isEmpty(keyList)) { return; } // 判断大小,小的做为外循环。通常都是过时的 keys 比较小。 if(keyList.size() <= expireMap.size()) { for(K key : keyList) { expireKey(key); } } else { for(Map.Entry<K, Long> entry : expireMap.entrySet()) { this.expireKey(entry); } } }
上面的代码写完以后,咱们就能够验证一下了。
ICache<String, String> cache = CacheBs.<String,String>newInstance() .size(3) .build(); cache.put("1", "1"); cache.put("2", "2"); cache.expire("1", 10); Assert.assertEquals(2, cache.size()); TimeUnit.MILLISECONDS.sleep(50); Assert.assertEquals(1, cache.size()); System.out.println(cache.keySet());
结果也符合咱们的预期。
到这里,一个相似于 redis 的 expire 过时功能,算是基本实现了。
固然,还有不少优化的地方。
好比为了后续添加各类监听器方便,我对全部须要刷新的地方调整为使用字节码+注解的方式,而不是在每个须要的方法中添加刷新方法。
下一节,咱们将共同窗习下如何实现各类监听器。
对你有帮助的话,欢迎点赞评论收藏关注一波走起~
你的鼓励,是我最大的动力~