Java 使用 FutureTask 解决缓存击穿(缓存踩踏)问题

Java Cache 面试 大约 2870 字

缓存击穿

也叫缓存踩踏。

当缓存中无数据时,所有请求都在数据库访问,到时候数据库压力倍增。

自旋锁方案存在的问题

前一篇文章使用了自旋锁来防止缓存击穿问题,但因为自旋和线程休眠存在空轮询CPU飙升和惊群效应等问题。

说明

对于分布式应用来说,比如部署了10个服务,则至多有10个请求会到数据库层请求。如果在意这次10请求,非要只请求一次数据库,可以在负载均衡上设置URL HASH策略,相同的URL都负载到同一台服务上。

FutureTask 解决思路

  1. 从缓存中获取key对应的value,若存在直接返回。
  2. 若缓存中不存在,则从FutureTask缓存中获取是否有任务正在从数据库请求资源,若存在直接调用get方法阻塞获取。
  3. FutureTask缓存中不存在,则创建请求数据库的任务,并调用putIfAbsent原子操作放入FutureTask缓存,确保只有一个任务会被执行,若放入成功,则开始执行请求数据库任务。
  4. 最终都阻塞等待请求数据库任务的操作完成。

FutureTask 代码示例

public class FutureTaskDemo {

    private static final Map<String, String> CACHE_NORMAL = new ConcurrentHashMap<>();
    private static final Map<String, FutureTask<String>> CACHE_FUTURE_TASK = new ConcurrentHashMap<>();

    public String getByFutureTask(String key) {
        String value = CACHE_NORMAL.get(key);
        if (value == null) {
            System.out.println("step 1#缓存中无数据");
            FutureTask<String> futureTask = CACHE_FUTURE_TASK.get(key);
            if (futureTask == null) {
                FutureTask<String> ft = new FutureTask<>(() -> {
                    System.out.println("step 1.1#请求资源并缓存");
                    String v = getFromDB(key);
                    CACHE_NORMAL.putIfAbsent(key, v);
                    return v;
                });
                futureTask = CACHE_FUTURE_TASK.putIfAbsent(key, ft);
                if (futureTask == null) {
                    System.out.println("step 1.2#无缓存任务运行, 开始执行");
                    futureTask = ft;
                    futureTask.run();
                }
            } else {
                System.out.println("step 1.3#任务已经在缓存中,再运行了");
            }
            try {
                String s = futureTask.get();
                System.out.println("step 1.4#从 future task get#" + s);
                return s;
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            } finally {
                CACHE_FUTURE_TASK.remove(key, futureTask);
            }
        } else {
            System.out.println("step 2#缓存中有数据,直接返回");
            return value;
        }

    }

    protected String getFromDB(String key) {
        System.out.println("get from db#" + LocalDateTime.now() + ", thread id#" + Thread.currentThread().getId());
        return LocalDateTime.now() + " " + key;
    }

    public static void main(String[] args) {
        FutureTaskDemo futureTaskDemo = new FutureTaskDemo();
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                futureTaskDemo.getByFutureTask("test");
            }).start();
        }
    }

}

尾部延迟

使用FutureTask可以很好的保证只有一个线程去请求资源获取数据,但有可能这个请求的非常耗时,导致很多线程都阻塞在get()方法处,用户的此次请求耗时明显高于平时的平均耗时。虽然可以使用get(long timeout, TimeUnit unit)方法让线程再超过指定等待时间后抛出超时异常来防止超长耗时问题,但还是会影响用户体验。

之后的文章将介绍预先重计算和概率性预先重计算方式解决尾部延迟带来的影响用户体验的问题。

参考

缓存踩踏:Facebook 史上最严重的宕机事件分析

阅读 954 · 发布于 2021-03-23

————        END        ————

扫描下方二维码关注公众号和小程序↓↓↓

扫描二维码关注我
昵称:
随便看看 换一批