手写 Redis 系列

java从零手写实现redis(一)如何实现固定大小的缓存?

java从零手写实现redis(三)redis expire 过期原理

java从零手写实现redis(三)内存数据如何重启不丢失?

java从零手写实现redis(四)添加监听器

java从零手写实现redis(五)过期策略的另一种实现思路

java从零手写实现redis(六)AOF 持久化原理详解及实现

java从零手写实现redis(七)LRU 缓存淘汰策略详解

java从零开始手写redis(八)朴素 LRU 淘汰算法性能优化

java从零开始手写redis(九)LRU 缓存淘汰算法如何避免缓存污染

java从零开始手写redis(十)缓存淘汰算法 LFU 最少使用频次

java从零开始手写redis(十一)缓存淘汰算法 COLOK 算法

java从零开始手写redis(十二)过期策略如何实现随机 keys 淘汰

java从零开始手写redis(十三)redis渐进式rehash详解

java从零开始手写redis(十四)JDK HashMap 源码解析

java从零开始手写redis(十四)JDK ConcurrentHashMap 源码解析

java从零开始手写redis(十五)实现自己的 HashMap

java从零开始手写redis(十六)实现渐进式 rehash map

前言

java从零手写实现redis(一)如何实现固定大小的缓存?

java从零手写实现redis(三)redis expire 过期原理

java从零手写实现redis(三)内存数据如何重启不丢失?

java从零手写实现redis(四)添加监听器

前面实现了 redis 的几个基本特性,其中在 expire 过期原理时,提到了另外一种实现方式。

这里将其记录下来,可以拓展一下自己的思路。

以前的实现方式

核心思路

原来的实现方式见:

java从零手写实现redis(三)redis expire 过期原理

https://mp.weixin.qq.com/s/BWfBc98oLqhAPLN2Hgkwow

不足

以前的设计非常简单,符合最基本的思路,就是将过期的信息放在一个 map 中,然后去遍历清空。

为了避免单次操作时间过长,类似 redis,单次操作 100 个元素之后,直接返回。

不过定时任务之心时,其实存在两个不足:

(1)keys 的选择不够随机,可能会导致每次循环 100 个结束时,真正需要过期的没有被遍历到。

不过 map 的随机比较蠢,就是将 map 的 keys 全部转为集合,然后通过 random 返回。

转的过程就是一个时间复杂度为 O(n) 的遍历,所以一开始没有去实现。

还有一种方式,就是用空间换区时间,存储的时候,同时存储在 list 中,然后随机返回处理,这个后续优化。

(2)keys 的遍历可能大部分都是无效的。

我们每次都是根据 keys 从前往后遍历,但是没有关心对应的过期时间,所以导致很多无效遍历。

本文主要提供一种以过期时间为维度的实现方式,仅供参考,因为这种方式也存在缺陷。

基于时间的遍历

思路

我们每次 put 放入过期元素时,根据过期时间对元素进行排序,相同的过期时间的 Keys 放在一起。

优点:定时遍历的时候,如果时间不到当前时间,就可以直接返回了,大大降低无效遍历。

缺点:考虑到惰性删除问题,还是需要存储以删除信息作为 key 的 map 关系,这样内存基本翻倍。

基本属性定义

我们这里使用 TreeMap 帮助我们进行过期时间的排序,这个集合后续有时间可以详细讲解了,我大概看了下 jdk1.8 的源码,主要是通过红黑树实现的。

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;

}

放入元素时

每次存入新元素时,同时放入 sortMap 和 expireMap。

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

定时任务的执行

定义

我们定义一个定时任务,100ms 执行一次。

/**
 * 线程执行类
 * @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(), 100, 100, TimeUnit.MILLISECONDS);
}

执行任务

实现源码如下:

/**
 * 定时执行任务
 * @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;
            }
        }
    }
}

这里直接遍历 sortMap,对应的 key 就是过期时间,然后和当前时间对比即可。

删除的时候,需要删除 expireMap/sortMap/cache。

惰性删除刷新

惰性删除刷新时,就会用到 expireMap。

因为有时候刷新的 key 就一个,如果没有 expireMap 映射关系,可能要把 sortMap 全部遍历一遍才能找到对应的过期时间。

就是一个时间复杂度与空间复杂度衡量的问题。

@Override
public void refreshExpire(Collection<K> keyList) {
    if(CollectionUtil.isEmpty(keyList)) {
        return;
    }
    // 这样维护两套的代价太大,后续优化,暂时不用。
    // 判断大小,小的作为外循环
    final int expireSize = expireMap.size();
    if(expireSize <= keyList.size()) {
        // 一般过期的数量都是较少的
        for(Map.Entry<K,Long> entry : expireMap.entrySet()) {
            K key = entry.getKey();
            // 这里直接执行过期处理,不再判断是否存在于集合中。
            // 因为基于集合的判断,时间复杂度为 O(n)
            this.removeExpireKey(key);
        }
    } else {
        for(K key : keyList) {
            this.removeExpireKey(key);
        }
    }
}

/**
 * 移除过期信息
 * @param key key
 * @since 0.0.10
 */
private void removeExpireKey(final K key) {
    Long expireTime = expireMap.get(key);
    if(expireTime != null) {
        final long currentTime = System.currentTimeMillis();
        if(currentTime >= expireTime) {
            expireMap.remove(key);
            List<K> expireKeys = sortMap.get(expireTime);
            expireKeys.remove(key);
            sortMap.put(expireTime, expireKeys);
        }
    }
}

小结

实现过期的方法有很多种,目前我们提供的两种方案,都各有优缺点,我相信会有更加优秀的方式。

程序 = 数据结构 + 算法

redis 之所以性能这么优异,其实和其中的数据结构与算法用的合理是分不开的,优秀的框架值得反复学习和思考。

文中主要讲述了思路,实现部分因为篇幅限制,没有全部贴出来。

开源地址:https://github.com/houbb/cache

觉得本文对你有帮助的话,欢迎点赞评论收藏关注一波~

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