首页 > 其他分享 >高并发下的分布式缓存 | 缓存系统稳定性设计

高并发下的分布式缓存 | 缓存系统稳定性设计

时间:2024-08-07 08:56:14浏览次数:10  
标签:缓存 String 数据库 并发 key data public 分布式

缓存击穿(Cache Breakdown)

缓存击穿是指一个热点数据在缓存中失效后,可能同一时刻会有很多对该热点数据的请求,这些请求都无法在缓存中找到该数据,因此都会访问数据库,导致数据库压力骤增。

解决缓存击穿的主流方案有两种:

  • 互斥锁
  • 异步刷新热点缓存
互斥锁

在缓存失效时,使用互斥锁(或分布式锁)控制对数据库的访问,避免大量请求同时涌向数据库。

public class CacheService {
    private static final ReentrantLock lock = new ReentrantLock();

    // 缓存接口(模拟)
    private Cache cache = new Cache();
    // 数据库接口(模拟)
    private Database database = new Database();

    public Data getData(String key) {
        Data data = cache.get(key);
        if (data == null) {
            // 加锁防止缓存击穿
            lock.lock();
            try {
                data = cache.get(key);
                if (data == null) {
                    data = database.getData(key);
                    cache.put(key, data);
                }
            } finally {
                lock.unlock();
            }
        }
        return data;
    }
}

这里解释下为什么需要判断if (data == null))判空两次,这是一个经典的双重检查锁定(Double-Checked Locking)模式。

  • 第一次判空:在代码的最开始,系统从缓存中尝试获取 data。如果缓存中存在这个数据,就直接返回,避免了不必要的锁操作,从而提升性能。如果缓存中没有这个数据(data == null),则继续执行,准备从数据库中获取数据。

  • 第二次判空:防止多线程情况下的重复数据库查询,确保只有一个线程去数据库加载数据,其余线程可以直接使用缓存中的数据。假设两个线程 A 和 B,A 线程先获取锁,读取数据并将其放入缓存中。此时,如果不进行第二次判空,B 线程也会在获取到锁之后从数据库中再次读取数据,这会造成不必要的数据库访问(既浪费资源又影响性能)。但如果在获取锁后再次检查 data,就能发现 A 线程已经从数据库获取了数据并将其放入缓存中,这样 B 线程就可以直接使用缓存中的数据,而无需再访问数据库。

可以看出,通过两次判空,可以确保只有一个线程从数据库加载数据并更新缓存,其他线程可以直接使用已经加载的数据,从而提高系统的性能和一致性。

我们一起来看一下上图中的缓存击穿防护流程,我们用不同的颜色来表示不同的请求,这些请求是获取同一条数据。

  • 绿色请求先到达,发现缓存中没有数据,就去DB查询
  • 粉色请求到达,请求相同数据,发现已有请求正在处理,等待绿色请求返回
  • 绿色请求返回获取到的数据并在cache中保存一份,粉色请求直接返回缓存中的数据。
  • 后续请求(如蓝色请求)可以直接从缓存中获取数据
异步刷新热点缓存

当缓存中的数据即将过期时,我们可以使用一个异步线程在缓存过期之前重新加载数据,并更新缓存的过期时间。这种方式可以保证在缓存数据失效之前,已经有新的数据加载到缓存中,从而避免大量请求同时访问数据库(即缓存击穿)。

下面是一个使用 Java 实现的示例,展示了如何使用异步方式不断刷新缓存的过期时间。

public class CacheService {
    private final Map<String, Data> cache = new HashMap<>();
    private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
    private final Database database = new Database();
    private final long CACHE_REFRESH_INTERVAL = 30; // 每 30 秒刷新一次

    public CacheService() {
        // 启动异步任务定期刷新缓存
        scheduler.scheduleAtFixedRate(this::refreshCache, 0, CACHE_REFRESH_INTERVAL, TimeUnit.SECONDS);
    }

    // 获取缓存数据
    public Data getData(String key) {
        return cache.get(key);
    }

    // 异步刷新缓存数据
    private void refreshCache() {
        for (String key : cache.keySet()) {
            Data data = database.getData(key);
            cache.put(key, data);
        }
    }

    // 关闭资源
    public void shutdown() {
        scheduler.shutdown();
    }
}

class Data {
    // 模拟数据类
}

class Database {
    // 模拟数据库查询
    public Data getData(String key) {
        // 从数据库获取数据
        return new Data();
    }
}

// 使用示例
public class Main {
    public static void main(String[] args) throws InterruptedException {
        CacheService cacheService = new CacheService();
        
        // 模拟访问缓存
        System.out.println(cacheService.getData("key1"));

        // 让主线程休眠,观察异步刷新效果
        Thread.sleep(120000); // 2 分钟
        cacheService.shutdown();
    }
}

代码中:

ScheduledExecutorService: 用于定期执行缓存刷新任务。在构造函数中启动了一个线程池,定期调用 refreshCache 方法。

refreshCache 方法: 遍历缓存中的所有键,使用 CompletableFuture 异步地从数据库获取数据并更新缓存。

注意这里异步刷新缓存的时候,是从源数据库中重新获取数据更新缓存,这尽可能的保证缓存数据新鲜度。

缓存穿透(Cache Penetration)

缓存穿透是指查询一个数据库中不存在的数据,由于一般情况下缓存层不会存储这些不存在的数据,因此每次请求都会落到数据库上。这样系统就有可能会被恶意的请求搞垮。

解决缓存穿透的主流方案有两种:

  • 缓存空值
  • 布隆过滤器
缓存空值

将查询结果为空的数据也缓存起来,同时设置一个较短的过期时间以避免缓存空间被空缓存占满影响正常缓存的命中率。

下面的代码展示了如何通过缓存空值解决缓存穿透:

public class CacheService {
    private RedisCache redisCache = new RedisCache();
    private Database database = new Database();
    private static final long EXPIRATION_TIME = 6; // 缓存过期时间为6秒

    public Data getData(String key) {
        String cachedData = redisCache.get(key);
        if (cachedData != null) {
            return cachedData.equals("null") ? null : new Data(cachedData);
        }

        Data data = database.getData(key);
        redisCache.set(key, data == null ? "null" : data.toString(), EXPIRATION_TIME);
        return data;
    }
}

class RedisCache {
    private Jedis jedis = new Jedis("localhost");

    public String get(String key) {
        return jedis.get(key);
    }

    public void set(String key, String value, long expirationTime) {
        jedis.setex(key, (int) TimeUnit.SECONDS.toSeconds(expirationTime), value);
    }
}

// 模拟数据类
class Data {
    private String value;

    public Data(String value) {
        this.value = value;
    }

    @Override
    public String toString() {
        return value;
    }
}

// 模拟数据库查询
class Database {
    public Data getData(String key) {
        // 从数据库获取数据
        return null; // 模拟数据库返回null
    }
}

这种方法简单有效,但如果应用程序频繁查询大量不存在的键,则可能会消耗大量缓存资源。

布隆过滤器

另一种解决方案是使用布隆过滤器,这是一种节省空间的概率数据结构,用于测试元素是否属于某个集合。通过使用布隆过滤器,系统可以快速识别不存在的数据,布隆过滤器有一个特点:布隆过滤器判断不存在则一定不存在,布隆过滤器判断存在,也可能不存在。

系统引入布隆过滤器后,当有数据添加到数据库中时,同时将该数据的key也添加到布隆过滤器中。在获取一条数据时,应用程序首先检查key是否存在于布隆过滤器中。如果key不存在于布隆过滤器中,则它也不会存在于缓存或数据库中,应用程序可以直接返回空值。如果key存在于布隆过滤器中,则应用程序继续从缓存或存储中读取该数据。

很多中间件或框架提供了布隆过滤器的实现,下面是使用 Redis 提供的布隆过滤器模块(RedisBloom)解决缓存穿透。

public class CacheService {
    private RedisCache redisCache = new RedisCache();
    private Database database = new Database();
    private BloomFilter bloomFilter = new BloomFilter();
    private static final long EXPIRATION_TIME = 60; // 缓存过期时间为60秒

    public Data getData(String key) {
        //不存在直接返回了,避免请求数据库
        if (!bloomFilter.mightContain(key)) {
            return null; // 数据库中不存在该数据
        }

        String cachedData = redisCache.get(key);
        if (cachedData != null) {
            return cachedData.equals("null") ? null : new Data(cachedData);
        }

        Data data = database.getData(key);
        if (data != null) {
            bloomFilter.add(key); // 数据存在时添加到布隆过滤器中
            redisCache.set(key, data, EXPIRATION_TIME);
        }
        
        return data;
    }
}

class RedisCache {
    private Jedis jedis = new Jedis("localhost");

    public String get(String key) {
        return jedis.get(key);
    }

    public void set(String key, String value, long expirationTime) {
        jedis.setex(key, (int) TimeUnit.SECONDS.toSeconds(expirationTime), value);
    }
}

class BloomFilter {
    private Client bloomClient = new Client("localhost", 6379);

    public boolean mightContain(String key) {
        return bloomClient.exists("bloom_filter", key);
    }

    public void add(String key) {
        bloomClient.add("bloom_filter", key);
    }
}

// 模拟数据类
class Data {
    private String value;

    public Data(String value) {
        this.value = value;
    }

    @Override
    public String toString() {
        return value;
    }
}

// 模拟数据库查询
class Database {
    public Data getData(String key) {
        // 从数据库获取数据
        return null; // 模拟数据库返回null
    }
}

缓存雪崩(Cache Avalanche)

缓存雪崩是指在某个时间点,缓存中大量数据同时失效,或者缓存节点不可用,导致大量请求直接访问数据库,给数据库带来巨大压力,甚至导致数据库崩溃。

解决缓存雪崩的主流方案有两种:

  • 设置不同的缓存过期时间:避免大量缓存同时失效。
  • 使用分布式缓存,采用高可用部署。
  • 请求限流:对请求进行限流,避免瞬时流量过大。

实际中,一般这几种方案同时使用。

标签:缓存,String,数据库,并发,key,data,public,分布式
From: https://blog.csdn.net/weixin_42627385/article/details/140971647

相关文章

  • 高并发下的分布式缓存 | Cache-Aside缓存模式
    Cache-aside模式的缓存操作Cache-aside模式,也叫旁路缓存模式,是一种常见的缓存使用方式。在这个模式下,应用程序可能同时需要同缓存和数据库进行数据交互,而缓存和数据库之间是没有直接联系的。这意味着,应用程序代码要负责处理数据的获取和存储,一些应用程序使用“Read-Thr......
  • 部署伪分布式 Hadoop集群
    部署伪分布式Hadoop集群一、JDK安装配置1.1下载JDK1.2上传解压1.3java环境配置二、伪分布式Hadoop安装配置2.1Hadoop下载2.2上传解压2.3Hadoop文件目录介绍2.4Hadoop配置2.4.1修改core-site.xml配置文件2.4.2修改hdfs-site.xml配置文件2.4.3修改ha......
  • 基于simulink的分布式发电系统自动重合闸的建模与仿真分析
    1.课题概述      在配电系统中,80%-90%的故障都是瞬时故障。发生故障时,线路被保护迅速断开,随即重合闸。当分布式电源接入配电网后,线路发生故障后重合闸,此时分布式电源没有跳离线路,这将产生两种潜在威胁,即非同期重合闸和故障点电弧重燃。      非同期重合闸:当线路......
  • 新手小白的Hadoop分布式和集群简述
    Hadoop分布式简介:ApacheHadoop是一个开源的分布式计算框架,它允许用户在节点组成的集群中处理和分析大数据。Hadoop是“Hadoop之父”DougCutting的著作,最初是在Nutch搜索引擎项目中开发的,用于解决网页爬虫的存储和搜索问题。Hadoop的核心由以下几个部分组成:HDFS(Hadoop......
  • 分布式存储MinIO Console
    MinIO是什么?一种对象存储解决方案,Minio提供与亚马逊云科技S3兼容的API,并支持所有核心S3功能,所以也可以看做是S3的开源版本;它允许用户通过简单的API接口进行数据的存储和检索,同时提供高度可扩展性和强大的数据保护机制。MinIo主要是在微服务系统中使用,非常适合于存储......
  • 编程深水区之并发②:JS的单线程事件循环机制
    如果某天有人问你,Node.js是单线程还是多线程,你如何回答?一、单线程并发原理我们以处理Web请求为例,来看看Node在处理并发请求时,究竟发生了什么。Node启动Web服务器后,创建主线程(只有一个)。当有一个阻塞请求过来时,主线程不会发生阻塞,而是继续处理其它代码或请求。如果阻塞......
  • 编程深水区之并发①:什么是并发编程
    并发编程是一种让程序能够执行多个任务的编程技术,多个任务的执行时间有重合,如交替执行、同时执行等。相对于传统的从上到下依次同步执行代码,我们也称并发编程为异步编程。目前,常见的并发模型主要有两种,一是多线程模型,二是单线程事件循环模型。一、多线程模型1、进程和线......
  • 编程深水区之并发④:Web多线程
    Node的灵感来源于Chrome,更是移植了V8引擎。在Node中能够实现的多线程,在Web环境中自然也可以。一、浏览器是多进程和多线程的复杂应用在本系列的第二章节,有提到现代浏览器是一个多进程和多线程的复杂应用。浏览器主进程统管全局,每个Tab页都会创建一个渲染子进程,同时还有G......
  • 分布式主键 详解
    文章目录雪花算法结合分库分表的问题问题出现原因分析解决思路分布式主键要考虑的问题主键生成策略雪花算法详解时间戳位问题工作进程位问题序列号位问题根据雪花算法扩展基因分片法雪花算法结合分库分表的问题问题出现使用ShardingSphere框架自带的雪花算法生成......
  • 用户上下文打通+本地缓存Guava
    文章目录......