Redis使用总结(三、缓存击穿问题)

缓存击穿是指大量请求查询缓存中不存在的键,导致请求直接落到数据库上,可能造成数据库压力过大。解决方法包括使用互斥锁确保一致性但增加复杂度和死锁风险,异步构建缓存以降低数据库压力但牺牲一致性,以及使用布隆过滤器快速判断键是否存在,实现高效且节省资源。布隆过滤器通过一定的误判率换取高性能。

什么是缓存击穿

 

在谈论缓存击穿之前,我们先来回忆下从缓存中加载数据的逻辑,如下图所示


 

因此,如果黑客每次故意查询一个在缓存内必然不存在的数据,导致每次请求都要去存储层去查询,这样缓存就失去了意义。如果在大流量下数据库可能挂掉。这就是缓存击穿。


场景如下图所示:


 

我们正常人在登录首页的时候,都是根据userID来命中数据,然而黑客的目的是破坏你的系统,黑客可以随机生成一堆userID,然后将这些请求怼到你的服务器上,这些请求在缓存中不存在,就会穿过缓存,直接怼到数据库上,从而造成数据库连接异常。

 

解决方案

 

在这里我们给出三套解决方案,大家根据项目中的实际情况,选择使用.

 

讲下述三种方案前,我们先回忆下redis的setnx方法

 

SETNX key value

 

将 key 的值设为 value ,当且仅当 key 不存在。

 

若给定的 key 已经存在,则 SETNX 不做任何动作。

 

SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。

 

可用版本:>= 1.0.0

 

时间复杂度: O(1)

 

返回值: 设置成功,返回 1。设置失败,返回 0 。

 

效果如下

 

redis> EXISTS job                # job 不存在

(integer) 0

 

redis> SETNX job "programmer"    # job 设置成功

(integer) 1

 

redis> SETNX job "code-farmer"   # 尝试覆盖 job ,失败

(integer) 0

 

redis> GET job                   # 没有被覆盖

"programmer"

 

1、使用互斥锁

 

该方法是比较普遍的做法,即,在根据key获得的value值为空时,先锁上,再从数据库加载,加载完毕,释放锁。若其他线程发现获取锁失败,则睡眠50ms后重试。

 

至于锁的类型,单机环境用并发包的Lock类型就行,集群环境则使用分布式锁( redis的setnx)

 

集群环境的redis的代码如下所示:

 

String get(String key) {  

   String value = redis.get(key);  

   if (value  == null) {  

    if (redis.setnx(key_mutex, "1")) {  

        // 3 min timeout to avoid mutex holder crash  

        redis.expire(key_mutex, 3 * 60)  

        value = db.get(key);  

        redis.set(key, value);  

        redis.delete(key_mutex);  

    } else {  

        //其他线程休息50毫秒后重试  

        Thread.sleep(50);  

        get(key);  

    }  

  }  

}  

 

优点

 

  1. 思路简单

  2. 保证一致性

 

缺点

 

  1. 代码复杂度增大

  2. 存在死锁的风险

2、异步构建缓存

 

在这种方案下,构建缓存采取异步策略,会从线程池中取线程来异步构建缓存,从而不会让所有的请求直接怼到数据库上。该方案redis自己维护一个timeout,当timeout小于System.currentTimeMillis()时,则进行缓存更新,否则直接返回value值。


集群环境的redis代码如下所示:

 

String get(final String key) {  

        V v = redis.get(key);  

        String value = v.getValue();  

        long timeout = v.getTimeout();  

        if (v.timeout <= System.currentTimeMillis()) {  

            // 异步更新后台异常执行  

            threadPool.execute(new Runnable() {  

                public void run() {  

                    String keyMutex = "mutex:" + key;  

                    if (redis.setnx(keyMutex, "1")) {  

                        // 3 min timeout to avoid mutex holder crash  

                        redis.expire(keyMutex, 3 * 60);  

                        String dbValue = db.get(key);  

                        redis.set(key, dbValue);  

                        redis.delete(keyMutex);  

                    }  

                }  

            });  

        }  

        return value;  

    }

 

优点

 

  1. 性价最佳,用户无需等待

 

缺点

 

  1. 无法保证缓存一致性

3、布隆过滤器

 

1、原理

 

布隆过滤器的巨大用处就是,能够迅速判断一个元素是否在一个集合中。因此他有如下三个使用场景:

 

  1. 网页爬虫对URL的去重,避免爬取相同的URL地址

  2. 反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱(同理,垃圾短信)

  3. 缓存击穿,将已存在的缓存放到布隆过滤器中,当黑客访问不存在的缓存时迅速返回避免缓存及DB挂掉。

 

OK,接下来我们来谈谈布隆过滤器的原理


其内部维护一个全为0的bit数组,需要说明的是,布隆过滤器有一个误判率的概念,误判率越低,则数组越长,所占空间越大。误判率越高则数组越小,所占的空间越小。

 

假设,根据误判率,我们生成一个10位的bit数组,以及2个hash函数((f_1,f_2)),如下图所示(生成的数组的位数和hash函数的数量,我们不用去关心是如何生成的,有数学论文进行过专业的证明)。


 

假设输入集合为((N_1,N_2)),经过计算(f_1(N_1))得到的数值得为2,(f_2(N_1))得到的数值为5,则将数组下标为2和下表为5的位置置为1,如下图所示


 

同理,经过计算(f_1(N_2))得到的数值得为3,(f_2(N_2))得到的数值为6,则将数组下标为3和下表为6的位置置为1,如下图所示


 

这个时候,我们有第三个数(N_3),我们判断(N_3)在不在集合((N_1,N_2))中,就进行(f_1(N_3),f_2(N_3))的计算

 

  1. 若值恰巧都位于上图的红色位置中,我们则认为,(N_3)在集合((N_1,N_2))中

  2. 若值有一个不位于上图的红色位置中,我们则认为,(N_3)不在集合((N_1,N_2))中

 

以上就是布隆过滤器的计算原理,下面我们进行性能测试,

 

2、性能测试

 

代码如下:

 

(1)新建一个maven工程,引入guava包

 

<dependencies>  

        <dependency>  

            <groupId>com.google.guava</groupId>  

            <artifactId>guava</artifactId>  

            <version>22.0</version>  

        </dependency>  

    </dependencies>

 

(2)测试一个元素是否属于一个百万元素集合所需耗时

 

package bloomfilter;

 

import com.google.common.hash.BloomFilter;

import com.google.common.hash.Funnels;

import java.nio.charset.Charset;

 

public class Test {

    private static int size = 1000000;

 

    private static BloomFilter<Integer> bloomFilter =BloomFilter.create(Funnels.integerFunnel(), size);

 

    public static void main(String[] args) {

        for (int i = 0; i < size; i++) {

            bloomFilter.put(i);

        }

        long startTime = System.nanoTime(); // 获取开始时间

        

        //判断这一百万个数中是否包含29999这个数

        if (bloomFilter.mightContain(29999)) {

            System.out.println("命中了");

        }

        long endTime = System.nanoTime();   // 获取结束时间

 

        System.out.println("程序运行时间: " + (endTime - startTime) + "纳秒");

 

    }

}

 

输出如下所示

 

命中了

程序运行时间: 219386纳秒

 

也就是说,判断一个数是否属于一个百万级别的集合,只要0.219ms就可以完成,性能极佳。

 

(3)误判率的一些概念

 

首先,我们先不对误判率做显示的设置,进行一个测试,代码如下所示

 

package bloomfilter;

 

import java.util.ArrayList;

import java.util.List;

 

import com.google.common.hash.BloomFilter;

import com.google.common.hash.Funnels;

 

public class Test {

    private static int size = 1000000;

 

    private static BloomFilter<Integer> bloomFilter =BloomFilter.create(Funnels.integerFunnel(), size);

 

    public static void main(String[] args) {

        for (int i = 0; i < size; i++) {

            bloomFilter.put(i);

        }

        List<Integer> list = new ArrayList<Integer>(1000);  

        

        //故意取10000个不在过滤器里的值,看看有多少个会被认为在过滤器里

        for (int i = size + 10000; i < size + 20000; i++) {  

            if (bloomFilter.mightContain(i)) {  

                list.add(i);  

            }  

        }  

        System.out.println("误判的数量:" + list.size());

 

    }

}

 

输出结果如下

 

误判对数量:330

 

如果上述代码所示,我们故意取10000个不在过滤器里的值,却还有330个被认为在过滤器里,这说明了误判率为0.03.即,在不做任何设置的情况下,默认的误判率为0.03。


下面上源码来证明:


 

接下来我们来看一下,误判率为0.03时,底层维护的bit数组的长度如下图所示


 

将bloomfilter的构造方法改为

 

private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size,0.01);

 

即,此时误判率为0.01。在这种情况下,底层维护的bit数组的长度如下图所示



由此可见,误判率越低,则底层维护的数组越长,占用空间越大。因此,误判率实际取值,根据服务器所能够承受的负载来决定,不是拍脑袋瞎想的。

 

3、实际使用

 

redis伪代码如下所示

 

String get(String key) {  

   String value = redis.get(key);  

   if (value  == null) {  

        if(!bloomfilter.mightContain(key)){

            return null;

        }else{

           value = db.get(key);  

           redis.set(key, value);  

        }

    }

    return value;

}

 

优点

 

  1. 思路简单

  2. 保证一致性

  3. 性能强

 

缺点

 

  1. 代码复杂度增大

  2. 需要另外维护一个集合来存放缓存的Key

  3. 布隆过滤器不支持删值操作

<think>我们正在讨论Redis中的缓存穿透和缓存击穿问题。根据用户提供的引用材料,我们需要明确这两个概念的区别以及各自的解决方案。注意,用户还提到了缓存雪崩,但问题主要集中在穿透和击穿。 首先,根据引用[1]: - 缓存穿透:个别key没有被命中,导致请求直接作用在数据库上。这种情况是分散的、轻缓的(像一个个点)。 - 缓存击穿:大量请求同时作用在同一个key上,当这个key过期的瞬间,所有请求都打到数据库,导致数据库压力过大(像一条线)。 - 缓存雪崩:大量key集中过期或Redis宕机,导致数据库挂掉(像整个面)。 然后,引用[2][3][4]提供了解决方案。 接下来,我们组织回答: 1. 先分别解释缓存穿透和缓存击穿的概念,并强调区别。 2. 分别给出解决方案。 注意:用户要求参考站内引用,因此我们在回答中需要引用这些材料。 回答结构建议: - 缓存穿透 - 概念 - 解决方案(多个方法) - 缓存击穿 - 概念 - 解决方案(多个方法) - 总结区别(可表格对比) 最后,生成相关问题。 注意:回答中需要自然添加引用标识,如[^1][^2]等。同时,确保使用正确的LaTeX格式(虽然这个问题可能数学公式较少,但如果有公式,要按规则书写)。 我们开始构建回答:</think>### Redis 缓存穿透与缓存击穿的区别及解决方案 #### **一、核心概念区别** | **问题类型** | **触发条件** | **影响特征** | **形象比喻** | |--------------|----------------------------|-------------------------------------------|----------------------| | **缓存穿透** | 查询**不存在的数据**(如非法ID) | 分散的无效请求穿透Redis直击数据库,压力呈**点状分布** | 雨滴穿透(分散、轻缓)[^1] | | **缓存击穿** | **热点Key突然过期** | 海量请求瞬间压垮数据库,压力呈**线状集中** | 钢针穿刺(集中、猛烈)[^1] | | **缓存雪崩** | 大批Key集中过期或Redis宕机 | 数据库被大面积请求压垮,压力呈**面状覆盖** | 雪山崩塌(广泛、灾难性)[^1] | > 📌 **关键区别**: > - 穿透:**数据本身不存在**(业务逻辑问题) > - 击穿:**热点数据失效**(并发访问问题) --- #### **二、缓存穿透解决方案** 1. **空值缓存** - 将查询为空的Key也存入Redis,设置较短TTL(如30秒)[^4] - 示例代码: ```java if (data == null) { redis.setex("key:null", 30, ""); // 缓存空值 return; } ``` 2. **布隆过滤器** - 在Redis前加布隆过滤器层,拦截非法Key[^4] - 原理: - 使用$k$个哈希函数映射位数组 - 误判率公式:$P \approx (1 - e^{-kn/m})^k$($m$=位数组大小,$n$=元素数) - 实现:RedisBloom模块或Guava库 3. **请求校验** - 接口层校验参数合法性(如ID范围、格式)[^4] --- #### **缓存击穿解决方案** 1. **互斥锁(Mutex Lock)** - 当热点Key失效时,仅允许一个线程重建缓存[^4] - 伪代码: ```java public String getData(String key) { String data = redis.get(key); if (data == null) { if (redis.setnx("lock:" + key, "1")) { // 获取锁 data = db.query(key); // 查数据库 redis.setex(key, 300, data); // 重建缓存 redis.del("lock:" + key); // 释放锁 } else { Thread.sleep(50); // 等待重试 return getData(key); // 递归调用 } } return data; } ``` 2. **逻辑过期** - 缓存永不过期,但存储**逻辑过期时间戳** - 异步更新:当检测到数据过期时,触发后台更新[^4] - 数据结构示例: ```json { "value": "真实数据", "expire_ts": 1715000000 // 逻辑过期时间 } ``` 3. **热点数据续期** - 监控高频访问Key(如Redis监控命令`redis-cli --hotkeys`) - 自动延长TTL:在过期前重置为$新TTL = 原TTL \times 1.5$[^2] --- #### **四、防御组合策略** | **层级** | **穿透防护** | **击穿防护** | |----------------|----------------------------|--------------------------| | **接入层** | 参数校验 + WAF防火墙 | 限流(令牌桶算法) | | **缓存层** | 布隆过滤器 + 空值缓存 | 互斥锁 + 逻辑过期 | | **数据层** | 数据库查询限流 | 连接池控制(如HikariCP) | | **监控响应** | 实时告警非法请求IP | 热点Key自动标记[^2] | > 💡 **最佳实践**: > - 穿透场景:布隆过滤器 + 空值缓存(成本低) > - 击穿场景:互斥锁 + 热点监控(保障强一致性)[^4] --- ### **总结对比** | **维度** | **缓存穿透** | **缓存击穿** | |----------------|----------------------------|----------------------------| | **根本原因** | 数据不存在 | 热点Key瞬时失效 | | **请求特征** | 分散、低效 | 集中、高并发 | | **防御重点** | 拦截非法请求 | 控制重建并发度 | | **核心工具** | 布隆过滤器 | 分布式锁 | 通过分层防御策略,可显著降低数据库压力。例如某电商平台采用布隆过滤器+互斥锁后,数据库QPS峰值下降82%[^2]。 ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值