专业编程基础技术教程

网站首页 > 基础教程 正文

Redis 生产环境中的缓存穿透、击穿、雪崩的解决办法

ccvgpt 2024-09-11 02:33:58 基础教程 13 ℃

一、缓存穿透

1.1 说明

缓存穿透说的是查询不存在的数据时,由于缓存中也没有该数据,因此无法直接从缓存中获取,而是需要向数据库或其他数据源发送查询请求。这种情况在高并发访问且缓存重建业务较复杂的情况下,会对系统性能和稳定性产生负面影响。

例如,假设有一个缓存键为 "cache:user:{id}",其中 {id} 是用户 ID。当一个请求发送到该缓存键时,如果缓存中没有该键,则需要进行数据库查询。如果此时有大量并发请求访问该键,并且数据库中也查询不到该数据,那么每个请求都会向数据库发送查询请求,增加了数据库的负载并可能导致系统崩溃。

Redis 生产环境中的缓存穿透、击穿、雪崩的解决办法

1.2 解决办法

  1. 互斥锁:当缓存中没有命中时,只会有一个线程访问后端数据库或数据源,并读取数据存入缓存。其他线程需要等待该线程执行完毕后再次尝试读取缓存。这种方法可以避免大量并发请求直接落到后端,减少了并发压力,并保证了系统的稳定性。但是,会增加单个请求的响应时间。

互斥锁是一种解决缓存穿透的方法,它的思想是当缓存中没有命中时,只会有一个线程访问后端数据库或数据源,并读取数据存入缓存。其他线程需要等待该线程执行完毕后再次尝试读取缓存。

  1. 逻辑过期:可以减少缓存的更新次数,避免在没有必要的情况下过多地读取后端数据源。例如,对于一个经常访问但数据本身没有频繁更新的缓存键,可以设置较长的过期时间。而对于一个经常更新但数据本身不常被访问的缓存键,可以设置较短的过期时间。这种方法可以避免缓存数据过时,但可能会在某些极端情况下出现缓存为空的情况。
  2. 空数据缓存:对于查询到不存在的空数据的请求,可以在缓存中添加一个空数据项,并设置过期时间。这样,在短期内重复访问该缓存键的请求可以直接从缓存中获取到空数据,而不需要再次查询数据库。这种方法可以避免重复查找空数据给数据库带来过多压力,但会占用一定的缓存空间。
  3. 布隆过滤器:布隆过滤器是一种空间效率极高的概率型数据结构,它用来检测一个元素是不是在一个集合里。它可能会产生错误正例(false positive), 也就是可能会判断元素在集合里,但实际上并不在。但它不会产生错误反例(false negative),也就是说,如果它判断元素不在集合里,那就肯定不在。

例如,在用户信息查询的场景中,可以考虑在缓存中添加一个空数据项,如 "cache:user:{id}:empty",当查询到不存在的用户时,可以将该空数据项存入缓存,并设置过期时间。这样,在短期内重复访问该用户信息时,可以直接从缓存中获取到空数据项,而不需要再次查询数据库。

1.3 代码实现案例

1.3.1 采用互斥锁处理

具体实现步骤如下:

  1. 引入互斥锁

首先需要引入一个互斥锁类,用于实现线程间的互斥访问。可以使用Redis的SETNX命令来实现互斥锁,当缓存键不存在时,SETNX命令可以返回1,否则返回0。

  1. 加锁

在查询用户信息之前,先通过SETNX命令尝试获取互斥锁。如果获取成功,则执行查询操作;如果获取失败,则说明已经有其他线程在查询数据,当前线程需要等待其他线程执行完毕后再尝试获取锁。

  1. 解锁

当查询操作执行完毕后,需要释放互斥锁,以便其他线程可以获取锁并执行查询操作。可以使用Redis的DEL命令来删除缓存键,从而释放锁。

import redis.clients.jedis.Jedis;  
  
public class UserInfoService {  
    private Jedis jedis;  
    private String USER_INFO_KEY = "cache:user:{id}";  
    private String LOCK_KEY = "lock:user:{id}";  
    private int LOCK_EXPIRE = 10;  // 10秒  
  
    public UserInfoService(Jedis jedis) {  
        this.jedis = jedis;  
    }  
  
    public UserInfo getUserInfo(String id) {  
        String lockKey = LOCK_KEY.replace("{id}", id);  
        if (jedis.setnx(lockKey, "1")) {  // 获取互斥锁成功  
            // 执行查询操作  
            UserInfo userInfo = queryUserInfoFromDb(id);  
            if (userInfo != null) {  // 如果查询结果不为空,则存入缓存并释放锁  
                jedis.set(USER_INFO_KEY.replace("{id}", id), userInfo.toString(), "ex", LOCK_EXPIRE);  
                jedis.del(lockKey);  // 释放锁  
                return userInfo;  
            }  
        } else {  // 获取互斥锁失败,等待其他线程执行完毕后再重试  
            try {  
                Thread.sleep(100);  // 等待时间设置为100毫秒,可以根据实际情况调整等待时间  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
            return getUserInfo(id);  // 递归调用自身以等待其他线程执行完毕  
        }  
        return null;  // 如果查询结果为空,则返回null并释放锁(由于没有实际缓存数据,因此无需等待缓存过期时间)  
    }  
  
    // 模拟从数据库查询用户信息的操作,这里只是返回一个示例的用户信息对象,实际应用中需要根据业务逻辑实现该方法  
    private UserInfo queryUserInfoFromDb(String id) {  
        // 从数据库中查询用户信息,这里只是返回一个示例的用户信息对象,实际应用中需要根据业务逻辑实现该方法  
        return new UserInfo(id, "Tom", 18);  
    }  
}

1.3.2 采用布隆过滤器

缓存穿透是指查询一个不存在的数据,由于缓存中没有数据,因此每次请求都会直接访问数据库或后端服务,导致系统负载增加并可能导致系统崩溃。为了解决这个问题,可以使用布隆过滤器(Bloom Filter)来实现。

布隆过滤器是一种空间效率极高的概率型数据结构,它用来检测一个元素是不是在一个集合里。它可能会产生错误正例(false positive), 也就是可能会判断元素在集合里,但实际上并不在。但它不会产生错误反例(false negative),也就是说,如果它判断元素不在集合里,那就肯定不在。

在解决缓存穿透的问题上,布隆过滤器可以用来过滤不可能存在的数据,减少数据库或后端服务的访问次数。具体实现步骤如下:

  1. 初始化布隆过滤器:根据需要设置布隆过滤器的大小和误差率,然后初始化布隆过滤器。
  2. 将可能存在的数据加入布隆过滤器:将数据库或后端服务中可能存在的数据加入布隆过滤器中。
  3. 查询数据时进行过滤:在进行数据查询时,先通过布隆过滤器进行过滤,如果数据不可能存在,则直接返回空结果,避免访问数据库或后端服务。
  4. 定期更新布隆过滤器:由于布隆过滤器存在误差率,因此需要定期更新布隆过滤器,以保证其准确性。

通过使用布隆过滤器,可以减少数据库或后端服务的访问次数,减轻系统负载,同时避免缓存穿透对系统造成过大的影响。需要注意的是,由于布隆过滤器存在误差率,因此可能会产生错误正例,即判断数据在集合里,但实际上并不在。因此,在使用布隆过滤器时需要权衡误差率和系统性能之间的平衡。

以下是一个简单的Java代码示例,使用布隆过滤器来防止缓存穿透:

import java.util.BitSet;  
  
public class BloomFilter {  
    private static final int DEFAULT_SIZE = 2 << 24;  
    private static final double DEFAULT_FALSE_POSITIVE_RATE = 0.001;  
    private BitSet bits = new BitSet(DEFAULT_SIZE);  
    private SimpleHash[] func = new SimpleHash[4];  
  
    public BloomFilter() {  
        for (int i = 0; i < func.length; i++) {  
            func[i] = new SimpleHash();  
        }  
    }  
  
    public void add(String key) {  
        for (SimpleHash f : func) {  
            bits.set(f.hash(key), true);  
        }  
    }  
  
    public boolean contains(String key) {  
        if (key == null) {  
            return false;  
        }  
        boolean ret = true;  
        for (SimpleHash f : func) {  
            ret = ret && bits.get(f.hash(key));  
        }  
        return ret;  
    }  
  
    public static class SimpleHash {  
        private static final int[] seed = new int[]{3, 13, 46, 71};  
  
        public int hash(String value) {  
            int result = 0;  
            int len = value.length();  
            for (int i = 0; i < len; i++) {  
                result = seed[i % seed.length] * result + value.charAt(i);  
            }  
            return result & DEFAULT_SIZE - 1;  
        }  
    }  
}

使用方法:

首先,创建一个BloomFilter对象:

BloomFilter bf = new BloomFilter();

然后,在添加元素时,使用add()方法:

bf.add("key");

最后,在查询元素是否存在时,使用contains()方法:

if (bf.contains("key")) {  
    // key存在,执行操作;否则,执行其他操作。  
} else {  
    // key不存在,执行其他操作。  
}

二、缓冲击穿

2.1 说明

Redis缓存击穿是指缓存中过期键对应的值在缓存过期后,在极短时间内被高并发地访问,导致缓存被穿透,直接访问数据库或后端服务,给系统带来巨大的负载压力。

例如,假设有一个缓存键为"cache:user:{id}",其中{id}是用户ID。当一个请求发送到该缓存键时,如果缓存中没有命中该键,则需要查询数据库或后端服务获取数据,并将其存入缓存。如果此时有大量并发请求访问该键,并且数据库或后端服务查询时间较长,那么每个请求都会查询数据库或后端服务,导致系统负载增加并可能导致系统崩溃。

2.2 解决办法

解决Redis缓存击穿的方法有两种:互斥锁和逻辑过期。

互斥锁的实现思路是在第一个线程到达时获取互斥锁,后面的线程在尝试获取互斥锁时会被阻塞,直到第一个线程缓存重建成功并释放互斥锁后,后面的线程才能再次尝试获取锁并查询缓存。这种方法可以避免大量并发请求直接落到数据库或后端服务上,减少了并发压力,保证了系统的稳定性。但是,互斥锁可能会增加单个请求的响应时间,因为只有一个线程能够查询缓存值,其他线程需要等待。

逻辑过期的实现思路是在缓存中添加一个过期时间较长的空数据项,当缓存过期后,这个空数据项可以起到占位符的作用,避免缓存击穿。这种方法可以减少缓存的更新次数,避免在没有必要的情况下过多地读取后端数据源。但是,逻辑过期可能会在某些极端情况下出现缓存为空的情况,如果此时恰巧有大量请求同时访问缓存,则可能导致缓存击穿。

例如,在用户信息查询的场景中,可以使用互斥锁来解决缓存击穿问题。具体实现步骤如下:

  1. 在查询用户信息之前,先通过Redis的SETNX命令尝试获取互斥锁。如果获取成功,则执行查询操作;如果获取失败,则说明已经有其他线程在查询数据,当前线程需要等待其他线程执行完毕后再尝试获取锁。
  2. 当第一个线程查询到用户信息后,将其存入缓存并设置过期时间。此时后面的线程在尝试获取锁时会被阻塞,直到第一个线程释放锁后才能再次尝试获取锁并查询缓存。这样可以避免大量并发请求直接落到数据库或后端服务上,减少了并发压力,保证了系统的稳定性。
  3. 当缓存过期后,后面的线程在尝试获取锁时会被阻塞,直到第一个线程释放锁后才能再次尝试获取锁并查询缓存。为了避免缓存击穿,可以在缓存中添加一个过期时间较长的空数据项作为占位符。这样可以减少缓存的更新次数,避免在没有必要的情况下过多地读取后端数据源。

2.3 相关代码

import redis.clients.jedis.Jedis;  
  
public class UserInfoService {  
    private Jedis jedis;  
    private String USER_INFO_KEY = "cache:user:{id}";  
    private String LOCK_KEY = "lock:user:{id}";  
    private int LOCK_EXPIRE = 10;  // 10秒  
  
    public UserInfoService(Jedis jedis) {  
        this.jedis = jedis;  
    }  
  
    public UserInfo getUserInfo(String id) {  
        String lockKey = LOCK_KEY.replace("{id}", id);  
        if (jedis.setnx(lockKey, "1")) {  // 获取互斥锁成功  
            // 执行查询操作  
            UserInfo userInfo = queryUserInfoFromDb(id);  
            if (userInfo != null) {  // 如果查询结果不为空,则存入缓存并释放锁  
                jedis.set(USER_INFO_KEY.replace("{id}", id), userInfo.toString(), "ex", LOCK_EXPIRE);  
                jedis.del(lockKey);  // 释放锁  
                return userInfo;  
            } else {  // 如果查询结果为空,则释放锁,等待其他线程再次尝试获取互斥锁并查询缓存  
                jedis.del(lockKey);  // 释放锁  
                return null;  
            }  
        } else {  // 获取互斥锁失败,等待其他线程执行完毕后再重试  
            try {  
                Thread.sleep(100);  // 等待时间设置为100毫秒,可以根据实际情况调整等待时间  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
            return getUserInfo(id);  // 递归调用自身以等待其他线程执行完毕  
        }  
    }  
  
    // 模拟从数据库查询用户信息的操作,这里只是返回一个示例的用户信息对象,实际应用中需要根据业务逻辑实现该方法  
    private UserInfo queryUserInfoFromDb(String id) {  
        // 从数据库中查询用户信息,这里只是返回一个示例的用户信息对象,实际应用中需要根据业务逻辑实现该方法  
        return new UserInfo(id, "Tom", 18);  
    }  
}

实际上,采用互斥锁解决缓存击穿和缓存穿透的代码并不完全一样。这两种问题的解决方法存在一定的差异。

缓存击穿是指缓存中过期键对应的值在缓存过期后,在极短时间内被高并发地访问,导致缓存被穿透,直接访问数据库或后端服务,给系统带来巨大的负载压力。而缓存穿透是指缓存中没有键对应的值,即空数据,由于缓存中没有数据,因此每次请求都会直接访问数据库或后端服务,导致系统负载增加并可能导致系统崩溃。

对于缓存击穿,采用互斥锁的解决方法是在第一个线程到达时获取互斥锁,后面的线程在尝试获取互斥锁时会被阻塞,直到第一个线程缓存重建成功并释放互斥锁后,后面的线程才能再次尝试获取锁并查询缓存。这样可以避免大量并发请求直接落到数据库或后端服务上,减少了并发压力,保证了系统的稳定性。

而缓存穿透的解决方法是采用布隆过滤器(Bloom Filter)或其他数据结构来过滤掉不可能存在的数据,以减少数据库或后端服务的访问次数。另外,也可以采用互斥锁和空对象缓存的方式来处理缓存穿透问题。其中,互斥锁可以保证在缓存过期后,只有一个线程可以访问数据库或后端服务,而空对象缓存可以在缓存中存储一个空对象,以避免缓存被穿透时直接返回空结果。

综上所述,虽然采用互斥锁可以解决缓存击穿和缓存穿透问题,但两种问题的解决方法存在一定的差异。

三、缓存雪崩

3.1说明

Redis缓存雪崩是指由于缓存服务器集群中大量缓存服务器同时发生宕机或故障,导致缓存失效,大量请求到达数据库服务器,给数据库服务器造成压力冲击,甚至导致数据库服务器崩溃。

例如,在一个电商网站中,用户可以浏览商品详情页、搜索商品、加入购物车等操作。如果该网站的缓存服务器集群中大量缓存服务器同时发生宕机或故障,缓存失效,大量用户请求将直接到达数据库服务器。由于数据库服务器的处理能力有限,大量请求可能会导致数据库服务器崩溃,进而导致整个网站的服务中断。

3.2 解决办法

为了解决Redis缓存雪崩问题,可以采取以下措施:

  1. 缓存失效时间均匀分布:通过随机化缓存失效时间,避免大量缓存同时失效。例如,在原有的失效时间基础上增加一个随机值,或者将失效时间分散到不同的时间点。
  2. 缓存备份策略:在缓存服务器宕机时,可以通过备份缓存数据来保证缓存的可用性。可以使用Redis的持久化功能,将缓存数据保存到磁盘中,当缓存服务器重启时,可以自动加载备份数据。
  3. 缓存降级:当缓存服务器集群出现故障时,可以采取缓存降级策略,将部分缓存数据降级为较简单的数据类型或直接从数据库中读取数据,以减轻缓存服务器的负载压力。
  4. 数据库压力分流:在缓存服务器集群和数据库服务器之间引入负载均衡器,将请求分流到多个数据库服务器上,以减轻单个数据库服务器的负载压力。
  5. 主备缓存:使用主备缓存架构,当主缓存服务器宕机时,可以自动切换到备用缓存服务器,以保证缓存服务的可用性。例如Redis的主从复制或RedLock算法等。

以上是解决Redis缓存雪崩问题的一些常见方法,可以根据实际情况选择适合的方案。

3.3 如何实现

当Redis缓存雪崩发生时,可以采用缓存降级的方法减轻数据库服务器的负载压力。下面是一个缓存降级的例子:

假设有一个电商网站,用户可以浏览商品详情页、搜索商品、加入购物车等操作。该网站使用Redis作为缓存服务器,但当大量缓存服务器同时发生宕机或故障时,缓存失效,大量请求将直接到达数据库服务器。

为了解决这个问题,可以采用缓存降级策略。具体步骤如下:

  1. 配置主备缓存:使用Redis的主备复制或RedLock算法,配置主缓存服务器和备用缓存服务器。当主缓存服务器宕机时,可以自动切换到备用缓存服务器。
  2. 缓存降级处理:当主缓存服务器宕机时,备用缓存服务器可以接管请求,但可能会出现部分数据丢失的情况。此时,可以使用缓存降级处理策略,将部分缓存数据降级为较简单的数据类型或直接从数据库中读取数据。
  3. 降级数据源选择:在进行缓存降级处理时,需要选择合适的数据源。可以选择以下几种方式:

a. 从数据库中读取数据:当主缓存服务器宕机时,可以直接从数据库中读取数据。这种方式可以保证数据的完整性和一致性,但会对数据库造成一定的负载压力。

b. 使用简单的数据类型:对于一些对数据一致性要求不高的场景,可以将缓存数据降级为较简单的数据类型,如商品类别的ID和名称对应关系,而不是完整的商品对象。这样可以减少对数据库的访问次数,减轻数据库的负载压力。

c. 使用缓存的过期时间设置:对于一些需要缓存的数据,可以设置较短的过期时间,以减少缓存雪崩时对数据库的冲击。同时,可以通过调整过期时间的分布,避免大量缓存同时失效。
4. 监控和告警:对于采用缓存降级处理的系统,需要密切监控系统的运行状态,及时发现和处理问题。同时,可以设置告警机制,当系统出现异常情况时,及时通知管理员进行干预和处理。

综上所述,采用缓存降级策略可以减轻数据库服务器的负载压力,避免Redis缓存雪崩对系统造成过大的影响。在实际应用中,需要根据具体的业务场景和需求选择合适的缓存降级策略。

Tags:

最近发表
标签列表