前言

我们在 从零手写 cache 框架(一)实现固定大小的缓存 中已经初步实现了我们的 cache。

本节,让我们来一起学习一下如何实现类似 redis 中的 expire 过期功能。

过期是一个非常有用的特性,比如我希望登录信息放到 redis 中,30min 之后失效;或者单日的累计信息放在 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 接口。

@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); }

expire 实现原理

其实过期的实思路也比较简单:我们可以开启一个定时任务,比如 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 过期功能,算是基本实现了。

当然,还有很多优化的地方。

比如为了后续添加各种监听器方便,我对所有需要刷新的地方调整为使用字节码+注解的方式,而不是在每一个需要的方法中添加刷新方法。

下一节,我们将共同学习下如何实现各种监听器。

对你有帮助的话,欢迎点赞评论收藏关注一波走起~

你的鼓励,是我最大的动力~

原文地址

Cache Travel-09-从零手写 cache 之 redis expire 过期实现原理

从零开始手写缓存框架(二)redis expire 过期原理及实现的更多相关文章

  1. 手写MVVM框架 之vue双向数据绑定原理剖析

    <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title> ...

  2. 手写DAO框架(二)-开发前的最后准备

    -------前篇:手写DAO框架(一)-从“1”开始 --------- 前言:前篇主要介绍了写此框架的动机,把主要功能点大致介绍了一下.此篇文章主要介绍开发前最后的一些准备.主要包括一些基础知识点 ...

  3. 手写MQ框架(二)-服务端实现

    一.起航 书接上文->手写MQ框架(一)-准备启程 本着从无到有,从有到优的原则,所以计划先通过web实现功能,然后再优化改写为socket的形式. 1.关于技术选型 web框架使用了之前写的g ...

  4. (二)springMvc原理和手写springMvc框架

    我们从两个方面了解springmvc执行原理,首先我们去熟悉springmvc执行的过程,然后知道原理后通过手写springmvc去深入了解代码中执行过程. (一)SpringMVC流程图 (二)Sp ...

  5. 手写MVC框架(二)-代码实现和使用示例

    --------上一篇:手写MVC框架(一)-再出发----- 背景 书接上文,之前整理了实现MVC框架需要写哪些东西.这周粗看了一下,感觉也没多少工作量,所以就计划一天时间来完成.周末的时间,哪会那 ...

  6. 手写SpringMVC框架(二)-------结构开发设计

    续接前文, 手写SpringMVC框架(一)项目搭建 本节我们来开始手写SpringMVC框架的第二阶段:结构开发设计. 新建一个空的springmvc.properties, 里面写我们要扫描的包名 ...

  7. 手写DAO框架(一)-从“1”开始

    背景: 很久(4年)之前写了一个DAO框架-zxdata(https://github.com/shuimutong/zxdata),这是我写的第一个框架.因为没有使用文档,我现在如果要用的话,得从头 ...

  8. 手写MQ框架(三)-客户端实现

    一.背景 书接手写MQ框架(二)-服务端实现  ,前面介绍了服务端的实现.但是具体使用框架过程中,用户肯定是以客户端的形式跟服务端打交道的.客户端的好坏直接影响了框架使用的便利性. 虽然框架目前是通过 ...

  9. 手写SpringMVC框架(三)-------具体方法的实现

    续接前文 手写SpringMVC框架(二)结构开发设计 本节我们来开始具体方法的代码实现. doLoadConfig()方法的开发 思路:我们需要将contextConfigLocation路径读取过 ...

  10. 手写 jQuery 框架

    1.测试页面; <!DOCTYPE html> <html lang="en"> <head> <meta charset="U ...

随机推荐

  1. CentOS下PHP7安装mysqlnd模块

    单独安装mysqlnd驱动 如果是centos下的yum安装方式,那么可以参考后续操作. 因为mysqlnd是mysql原生的驱动,如果已经安装了php-mysql,则需要先卸载,否则会遇到冲突. 先 ...

  2. 给Hexo博客文章加密

    有的时候博客内容会有变动,首发博客是最新的,其他博客地址可能会未同步,认准https://blog.zysicyj.top 首发博客地址 原文地址 这是个啥 首先, 这是 Hexo 生态圈中 最好的 ...

  3. [转帖]Web性能优化工具WebPageTest(三)——本地部署(Windows 7版本)

    http://www.zlprogram.com/Show/30/30117.shtml 这次先能够使用PC端的浏览器测试,首先需要下载官方的发布版本"WebPageTest 3.0&quo ...

  4. [转帖]手把手教你在QEMU上运行RISC-V Linux

    https://kernel.0voice.com/forum.php?mod=viewthread&tid=3080   嵌入式Linux内核 发布于 2023-3-15 14:44:37  ...

  5. [转帖]shell 实现行转列、列转行的几种方法

    目录 shell 实现行转列.列转行的几种方法 awk 行转列 xargs 行转列 tr 列转行 参考资料 shell 实现行转列.列转行的几种方法 awk 行转列 以空格为分隔符 awk -F &q ...

  6. echarts饼状图自定义legend的样式付费

    先看效果图 代码 <!DOCTYPE html> <html> <head> <meta charset="utf-8"> < ...

  7. 【分享一个工具】根据 /metrics 路径下的文本信息,自动生成包含所有 metrics 的 grafana 报表

    作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢! cnblogs博客 zhihu Github 公众号:一本正经的瞎扯 在做某个服务对应的 grafana 监控报表的时候发现,一 ...

  8. 语义检索系统:基于无监督预训练语义索引召回:SimCSE、Diffcse

    基于无监督预训练语义索引召回:SimCSE.Diffcse 语义索引(可通俗理解为向量索引)技术是搜索引擎.推荐系统.广告系统在召回阶段的核心技术之一.语义索引模型的目标是:给定输入文本,模型可以从海 ...

  9. 数据挖掘[一]---汽车车交易价格预测(测评指标;EDA)

    题目出自阿里天池赛题链接:零基础入门数据挖掘 - 二手车交易价格预测-天池大赛-阿里云天池 相关文章: 特征工程详解及实战项目[参考] 数据挖掘---汽车车交易价格预测[一](测评指标:EDA) 数据 ...

  10. [zookeeper] 集群搭建及启动后查询服务器状态异常解决

    一.集群搭建 1.每台服务器上部署zookeeper 1.将zookeeper压缩包解压到指定位置,在zookeeper解压后目录下创建数据目录zkData 2.在zkData下创建myid文件,内容 ...