当前位置: 首页>后端>正文

Java实现本地缓存

实现缓存
源码参考https://gitee.com/genj/cache

本文主要讲缓存中相关问题的理论以及实际项目中的部分实现。理论部分与语言无关,无知识要求。实现部分需要掌握Java并发基础知识。主要参考《并发编程实战 》、EhCache和网上一些资料。

缓存相关的问题: 缓存逻辑与业务代码分离、 缓存预热、缓存更新、缓存污染、缓存淘汰、缓存雪崩、缓存穿透、 缓存降级以及数据写盘。

1. 缓存逻辑与业务代码分离

部分开发人员,以及网上许多示例,都是将缓存的处理与业务代码混合在一起,业务逻辑代码复用性差。

可以使用装饰者模式将二者分离。

2. 缓存预热

在用户请求数据前先将数据加载到缓存系统中,用户查询事先被预热的缓存数据,以提高系统查询效率。

具体时机有系统启动加载和定时加载。

3. 缓存更新

在数据发生变化后即时将变化后的数据更新到缓存中。关于是先更新数据库还是先更新缓存,差别不大,可以根据具体业务逻辑选择。有数据实时强一致要求的可以先写库。

实现方式:

  1. 定时更新:使用定时任务将底层数据更新到缓存中,实现方式简单,适合需要缓存的数据量不大的情景。

  2. 写请求更新:在用户有写请求时先写数据库同时更新缓存,这适用于用户对缓存数据和数据库的数据有实时强一致的情况。

  3. 读请求更新:在用户有读请求时,先判断该请求数据的缓存是否存在过期,如果不存在或已过期,则进行底层数据库查询并将查询结果更新到缓存中,同时将查询结果返回给用户。

  4. 过期更新:定时将缓存中过期的数据更新为最新数据并更新缓存的过期时间。只更新过期部分。

4. 缓存污染

缓存污染是把不常用的数据读取到缓存中的现象。

解决方案:即时将数据从缓存中移除。

4. 缓存淘汰

在缓存数据过多时需要使用某种淘汰算法决定淘汰哪些数据。

实现方式有:

  1. FIFO(先进先出):判断被存储的时间,离目前最远的数据优先被淘汰。使用队列可以实现。

  2. LRU(最近最少使用):判断缓存最近被使用的时间,距离当前时间最远的数据优先被淘汰。可使用双向链表可以实现。只使用双向链表可以实现;双向链表+哈希表性能较好;使用内置的库最好。

  3. LFU(最不经常使用):在一段时间内,被使用次数最少的缓存先被淘汰。使用复杂点的双向链表,在原始双向链表的基础上,链表中的结点又是一个双向链表。实现较为复杂。

5. 缓存雪崩

同一时刻由于大量缓存失效,导致大量原本应该访问缓存的请求都去查询数据库,对数据库造成巨大压力,严重的话会导致数据库宕机,从而形成一系列连锁反应,使整个系统崩溃。

原因是大量缓存的过期时间相同。

解决方案:

  1. 请求加锁:对于并发量不是很多的应用,使用请求加锁排队的方案防止过多请求数据库。在高并发场景不推荐,因为串行化了请求。

  2. 失效更新:为每一个缓存数据都增加过期标记来记录缓存数据是否失效,如果缓存标记失效,则更新数据缓存。

  3. 设置随机失效时间:为不同的数据设置不同的缓存失效时间,防止在同一时刻有大量的数据失效。推荐。

6. 缓存穿透

由于缓存系统故障或者用户频繁查询系统中不存在的数据,而这时请求穿过缓存不断被发送到数据库,导致数据库过载,进而引发一连串并发问题。

解决方案:

  1. 缓存null:在缓存中记录一个短暂的(数据过期时间内)数据在系统中是否存在的状态,如果不存在,则直接返回null,不再查询数据库,从而避免缓存穿透到数据库上。

  2. 布隆过滤器:将所有可能存在的数据都映射到一个足够大的Bitmap中,在用户发起请求时首先经过布隆过滤器的拦截,一个一定不存在的数据会被这个布隆过滤器拦截,从而避免对底层存储系统带来查询上的压力。

业务代码中暂时没有想到优雅处理缓存系统故障(比如Redis宕机)的方式,使用的try-catch。架构上比较好处理,比如Redis哨兵模式。

7. 缓存降级

缓存降级指由于访问量剧增导致服务出现问题(一般是底层数据库,如宕机、响应时间慢或不响应)时,优先保障核心业务的运行,减少或关闭非核心业务对资源的使用。

分为:

  1. 读降级:在数据库服务负载过高或数据库系统故障时,可以只对cache进行读取并将结果返回给用户,在数据库服务正常后再去查询数据库,即将读请求从数据库降级为cache。这种方式适用于对数据实时性要求不高的场景,保障了在系统发生故障的情况下用户依然能够访问到数据,只是访问到的数据相对有延迟。

  2. 写降级:在写请求增大时,可以只进行Cache的更新,然后将数据异步更新到数据库中,保证最终一致性即可,即将写请求从数据库降级为写cache。

8. 数据写盘

可选操作。将数据存储在磁盘上,保障服务重启后内存数据能够重新从磁盘上加载,效率较低。

9. 在实际项目中的部分实现

主要参考《并发编程实战 》和网上一些资料。

package com.jerry.compute;

/**
 * 使用装饰者模式将缓存从业务代码中解耦
 * 用来表示耗时的数据处理操作
 */
public interface Computable<K, V>{
    V compute(K k) throws Exception;
}
package com.jerry;

import com.jerry.compute.Computable;

import java.util.Map;
import java.util.concurrent.*;

/**
 * 本地缓存的实现
 */
public class Cache<K, V> implements Computable<K, V> {
    /**
     * 使用装饰者模式将缓存从业务代码中解耦
     */
    public interface Computable<K, V>{
        V compute(K k) throws Exception;
    }

    /**
     * 表示一个耗时计算,实现了Computable接口,不具备缓存能力
     */
    public class ExpensiveFunction implements com.jerry.compute.Computable<String, Integer> {
        @Override
        public Integer compute(String s) throws Exception {
            TimeUnit.SECONDS.sleep(5);
            return Integer.valueOf(s);
        }
    }

    /**
     * final修饰:防止其他人错误修改,提高安全性。
     * ConcurrentHashMap:使用内置高校性能的Map。
     * 问题说明:
     * 1. 如果使用HashMap+synchronized或者是读写锁,性能没有ConcurrentHashMap好,可以避免扩容时可能的死循环,分段锁,使用更简单。
     * 2. 缓存Future,而不是具体值,是因为实际的计算任务(如调用第三方)都有延迟,转换缓存维度,可以避免并发时的部分重复计算。
     */
    private final Map<K, Future<V>> cache = new ConcurrentHashMap<>();

    //用于实现装饰者模式,分离缓存逻辑和业务逻辑
    private final Computable<K, V> c;

    public Cache(Computable<K, V> c) {
        this.c = c;
    }

    @Override
    public V compute(K k) throws Exception {
        //对业务比较熟悉,假设任务肯定能返回,就可以用while(true)
        while (true){
            //先从缓存中查找计算任务
            Future<V> f = cache.get(k);
            if(f == null){//没有进行相同计算任务
                FutureTask<V> task = new FutureTask<V>(()->{
                    return c.compute(k);
                });
                /*
                如果使用下例的代码,多线程下存在同时put()相同任务,执行相同任务的问题
                f = cache.put(k, task);
                f = task;
                task.run();
                 */
                //避免了多线程时,创建重复计算任务的可能
                f = cache.putIfAbsent(k, task);
                if(f == null){
                    f = task;
                    task.run();
                }
            }
            //捕获异常是为了针对不同异常进行不同的处理,比如人为取消、数据库异常
            try{
                //有相同的计算任务,阻塞式
                return f.get();
            } catch (CancellationException e){
                //对于手动取消Future,可以即时从缓存中移除,避免缓存污染
                cache.remove(k);
                throw e;
            } catch (InterruptedException e){
                cache.remove(k);
                throw e;
            }catch (ExecutionException e){
                //处理缓存污染
                cache.remove(k);
                //假设该异常可以进行重试,继续计算
                e.printStackTrace();
            }
        }
    }

    //用于定时清理过期缓存
    public final static ScheduledExecutorService executor = Executors.newScheduledThreadPool(8);

    //缓存过期
    public V compute(K k, long expire) throws Exception {
        if(expire > 0){
            executor.schedule(()->{
                expire(k);
            }, expire, TimeUnit.SECONDS);
        }
        return compute(k);
    }

    //随机缓存时间,预防缓存雪崩
    //最终的缓存方法
    public V computeRandomExpire(K k) throws Exception {
        long randomExpire = (long) (Math.random() * 10000);
        return compute(k, randomExpire);
    }

    //清理过期缓存
    public synchronized void expire(K key){
        Future<V> future = cache.get(key);
        if(future != null){
            //处理任务正在计算的情况
            if(!future.isDone()){
                System.out.println("任务被取消");
                future.cancel(true);
            }
            System.out.println("过期时间到");
            cache.remove(key);
        }
    }
}


https://www.xamrdz.com/backend/3zu1934981.html

相关文章: