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

缓存击穿(Cache Breakdown)

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

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

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

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

java 复制代码
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 实现的示例,展示了如何使用异步方式不断刷新缓存的过期时间。

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)

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

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

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

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

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

java 复制代码
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)解决缓存穿透。

java 复制代码
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)

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

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

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

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

相关推荐
Y38153266213 小时前
SERP API + Redis 缓存层:4 种方案对比与选型
数据库·redis·缓存
宇宙第一小趴菜14 小时前
五、Oracle vs MySQL 架构深度对比笔记
mysql·oracle·架构
海兰15 小时前
【高速缓存】RedisVL 高级查询(全文搜索、混合搜索和 多向量搜索)
数据库·人工智能·redis·缓存
肥胖小羊16 小时前
解决企业微信 AccessToken 刷新并发冲突的分布式锁机制实践
分布式·企业微信
流星白龙1 天前
【Redis】4.基本全局命令
数据库·redis·缓存
流星白龙1 天前
【Redis】3.Redis安装与命令行客户端
数据库·redis·缓存
BullSmall1 天前
PostgreSQL 14 pg_dumpall 完整备份指南
数据库·postgresql·oracle
我不想名字重复2 天前
redis缓存和数据库数据保持一致
数据库·redis·缓存
时空无限2 天前
vllm 大模型启动缓存相关环境变量 export
linux·缓存·vllm
宇宙第一小趴菜2 天前
三、Oracle 核心原理
数据库·oracle