redis map 过期时间_从零手写缓存框架(二)redis expire过期原理及实现

前言

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

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

48093a9c3d3aad183265cdb7800e1629.png

image

过期是一个非常有用的特性,比如我希望登录信息放到 redis 中,30min 之后失效;或者单日的累计信息放在 redis 中,在每天的凌晨自动清空。

代码实现

接口

我们首先来定义一下接口。

主要有两个:一个是多久之后过期,一个是在什么时候过期。

public interface ICache extends Map {    /**     * 设置过期时间     * (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 expire(final K key, final long timeInMills);    /**     * 在指定的时间过期     * @param key key     * @param timeInMills 时间戳     * @return this     * @since 0.0.3     */    ICache expireAt(final K key, final long timeInMills);}

代码实现

为了便于处理,我们将多久之后过期,进行计算。将两个问题变成同一个问题,在什么时候过期的问题。

核心的代码,主要还是看 cacheExpire 接口。

@Overridepublic ICache expire(K key, long timeInMills) {    long expireTime = System.currentTimeMillis() + timeInMills;    return this.expireAt(key, expireTime);}@Overridepublic ICache expireAt(K key, long timeInMills) {    this.cacheExpire.expire(key, timeInMills);    return this;}

缓存过期

这里为了便于后期拓展,对于过期的处理定义为接口,便于后期灵活替换。

接口

其中 expire(final K key, final long expireAt); 就是我们方法中调用的地方。

refershExpire 属于惰性删除,需要进行刷新时才考虑,我们后面讲解。

public interface ICacheExpire {    /**     * 指定过期信息     * @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 keyList);}

expire 实现原理

其实过期的实思路也比较简单:我们可以开启一个定时任务,比如 1 秒钟做一次轮训,将过期的信息清空。

过期信息的存储

/** * 过期 map * * 空间换时间 * @since 0.0.3 */private final Map expireMap = new HashMap<>();@Overridepublic 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 cache;/** * 线程执行类 * @since 0.0.3 */private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadScheduledExecutor();public CacheExpire(ICache cache) {    this.cache = cache;    this.init();}/** * 初始化任务 * @since 0.0.3 */private void init() {    EXECUTOR_SERVICE.scheduleAtFixedRate(new ExpireThread(), 100, 100, TimeUnit.MILLISECONDS);}

这里定义了一个单线程,用于执行清空任务。

bd368076bc248d192598ff617edb2ad9.png

image

清空任务

这个非常简单,遍历过期数据,判断对应的时间,如果已经到期了,则执行清空操作。

为了避免单次执行时间过长,最多只处理 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 entry : expireMap.entrySet()) {            if(count >= LIMIT) {                return;            }            expireKey(entry);            count++;        }    }}/** * 执行过期操作 * @param entry 明细 * @since 0.0.3 */private void expireKey(Map.Entry 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 implements ICacheExpire {    /**     * 单次清空的数量限制     * @since 0.0.3     */    private static final int LIMIT = 100;    /**     * 排序缓存存储     *     * 使用按照时间排序的缓存处理。     * @since 0.0.3     */    private final Map> sortMap = new TreeMap<>(new Comparator() {        @Override        public int compare(Long o1, Long o2) {            return (int) (o1-o2);        }    });    /**     * 过期 map     *     * 空间换时间     * @since 0.0.3     */    private final Map expireMap = new HashMap<>();    /**     * 缓存实现     * @since 0.0.3     */    private final ICache cache;    /**     * 线程执行类     * @since 0.0.3     */    private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadScheduledExecutor();    public CacheExpireSort(ICache 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> entry : sortMap.entrySet()) {                final Long expireAt = entry.getKey();                List expireKeys = entry.getValue();                // 判断队列是否为空                if(CollectionUtil.isEmpty(expireKeys)) {                    sortMap.remove(expireAt);                    continue;                }                if(count >= LIMIT) {                    return;                }                // 删除的逻辑处理                long currentTime = System.currentTimeMillis();                if(currentTime >= expireAt) {                    Iterator 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 keys = sortMap.get(expireAt);        if(keys == null) {            keys = new ArrayList<>();        }        keys.add(key);        // 设置对应的信息        sortMap.put(expireAt, keys);        expireMap.put(key, expireAt);    }}

看起来是切实可行的,这样可以降低轮询的压力。

这里其实使用空间换取时间,觉得后面可以做一下改进,这种方法性能应该还是不错的。

不过我并没有采用这个方案,主要是考虑到惰性删除的问题,这样会麻烦一些,后续考虑持续改善下这个方案。

惰性删除

出现的原因

类似于 redis,我们采用定时删除的方案,就有一个问题:可能数据清理的不及时。

那当我们查询时,可能获取到到是脏数据。

于是就有一些人就想了,当我们关心某些数据时,才对数据做对应的删除判断操作,这样压力会小很多。

算是一种折中方案。

425596b06d6a545a80ad86c1f9b12165.png

image

需要惰性删除的方法

一般就是各种查询方法,比如我们获取 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);

@Overridepublic void refreshExpire(Collection keyList) {    if(CollectionUtil.isEmpty(keyList)) {        return;    }    // 判断大小,小的作为外循环。一般都是过期的 keys 比较小。    if(keyList.size() <= expireMap.size()) {        for(K key : keyList) {            expireKey(key);        }    } else {        for(Map.Entry entry : expireMap.entrySet()) {            this.expireKey(entry);        }    }}

测试

上面的代码写完之后,我们就可以验证一下了。

ICache cache = CacheBs.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 过期功能,算是基本实现了。

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

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

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

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

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

607294e8e21cbaa2245ea4e62e99671f.png

深入学习

原文地址

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值