Redis缓存经典问题

1.本文章所有内容仅代表个人观点,如存在理解偏差,请指正。

2.本文章所有图片均手工绘制,可能存在误区。

一、Redis概念

       Redis是后端架构中使用频率较高的中间件。想要了解Redis,就需要知道为什么要使用Redis以及Redis是什么

       解答这两个问题,我们只需要知道三点:

       首先,Redis可以理解为一台数据库,但它不是用于持久化数据的存储,而是临时数据的存储,常作为缓存层或高速数据缓冲区。

下图为Redis缓存决策流程:

      其次,Redis基于内存,内存的处理效率远胜于磁盘,并且Redis优化了数据结构(String、List、Set、ZSet、Hash),大大提升了数据处理效率。所以Redis效率极高,轻松处理10万级别QPS(响应/请求),适合应用于高并发服务,性能远远超过基于磁盘的传统数据库。

       最后,Redis为防止数据丢失,提供了RDB和AOF持久化机制,数据更加安全;支持集群部署,确保服务高可用与横向扩展;提供原子操作,可使用事务或Lua脚本保证复杂操作的原子性。

二、Redis经典问题

       Redis既然这么优秀,那么它有什么缺点嘛,答案显而易见,任何中间件都有优缺点。

       首先来了解一个概念,为了减轻数据库的压力,我们常选择对数据库中的高访问量的数据进行提前缓存,这个过程称为缓存预热。

       了解完这个概念,就可以继续了解使用Redis过程中会遇到的经典的四个问题。

1.缓存穿透

        当客户端请求数据库中不存在的数据时,导致Redis缓存无法命中,直接穿透到数据库,当存在大量这样的请求,数据库压力倍增,不仅影响其他业务,严重情况会导致数据库宕机。

2.缓存雪崩

       Redis为了防止数据冗余,通常会设置过期时间,当为大量数据设置相同的过期时间,大量数据将会同一时间过期,恰好此时出现大量请求(若干key),会导致请求全部转发到DB,DB瞬时压力过重雪崩。

3.缓存击穿

       当存在一个热点key(例如某一个id),一段时间内出现此热点数据的大量请求,又恰逢此key的缓存已经过期,导致大量的请求涌入到数据库,从而导致数据库压力剧增。

4.数据一致性

       数据库中的数据缓存完成后,数据库中的数据发生了修改,也需要对缓存中的数据进行修改,但是Redis和数据库之间通信存在网络和一致性选择问题。

三、解决方案

       那么如何解决这些问题?

1.解决缓存击穿

       出现缓存击穿的原因:1.Redis无缓存 2.数据库无数据

解决方案1:bitmap(快速解决)

       bitmap可以理解为一个二进制数组,以数组偏移量表示为数据的id,从而在相应的偏移量位置设置0(数据无缓存)和1(数据有缓存),并且需要对不存在的数据进行单独处理,只能解决固定id的大量访问。

解决方案2:Bloom过滤器(优选)

①Bloom过滤器概念

       Bloom过滤器底层是基于bitmap二进制数组,通过若干个Hash算法对数据进行加工,存储到bitmap中若干个位置中,通过判断位置上的所有的数字为1(数据存在)。 优点:占用内存空间小,将记录的标识以二进制的形式进行保存,极大的减小的内存占比;基于Redis存储,查找速度快。 缺点:因为Hash算法存在Hash冲突,数据存在一定的误判率,但是可以确定数据一定不存在;删除困难,类似数据容易误删,所以不能删除,解决以上问题通常使用重建Bloom过滤器或者当空间不足时进行扩容,都是基于重建的原理实现。

②Bloom初始化

1.@PostConstruct等待RedissonClient客户端对象注入到启动类中。
2.启动类中实现 Command LineRunner 接口,自动执行run()方法。
3.在方法中创建BloomFilter对象,指定过滤器的名字,判断过滤器是否存在,不存在则初始化过滤器参数(预计数据数量,误判率),redisson会根据输入的数据数量计算需要使用的bitmap数组的大小和hash算法的数量。
4.使用Bloom过滤器方法。

@Component
public class BloomFilterInitializer implements CommandLineRunner {
    
    @Autowired
    private RedissonClient redissonClient;
    // 1. 使用@PostConstruct确保RedissonClient先注入
    @PostConstruct
    public void init() {
        // 前置检查
    }
    // 2. 实现CommandLineRunner接口,在Spring完全启动后执行
    @Override
    public void run(String... args) throws Exception {
        // 3. 获取或创建布隆过滤器
        RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("userBloomFilter");
        // 4. 初始化过滤器
        if (!bloomFilter.isExists()) {
            boolean initialized = bloomFilter.tryInit(
                1000000L,      // 预计数据数量
                0.001          // 误判率
            );
            if (initialized) {
                // 5. Redisson内部会自动计算bitmap数组大小(根据预期数量和误判率)
                // 6. 缓存预热(可选)
                // bloomFilter.add("Key");
            }
        }
        // 7. 后续使用
        // bloomFilter.contains("someKey");
        // bloomFilter.add("newKey");
    }
}

注意:为什么将@PostConstruct和CommandLineRunner接口配合使用

       @PostConstruct标记的方法会在Bean的依赖注入完成之后执行,但此时容器可能并未完全启动,redis连接可能尚未建立成功,所以需要配合CommmandLineRunner使用,他会确保容器完全启动之后才会去执行run方法。

③Bloom过滤器方法
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter(name);//初始化bloom过滤器,但并未真正的建立BloomFilter.tryInit(预计数据数量,误判率):初始化bloom过滤器参数
bloomFilter.isExists();        //检测bloom过滤器是否存在
bloomFilter.add(albumId);      //加入bloom过滤器
bloomFilter.contains(albumId); //查找bloom过滤器
bloomFilter.delete();          //删除bloom过滤器
bloomFilter.rename(newname);   //重命名bloom过滤器

2.解决缓存雪崩

       大量缓存同一时间过期。

解决方案1:随机时间

       原有失效时间的基础上增加一个随机值(1-3分钟),这样每一个缓存的过期时间的重复率就会降低,几乎不会再度产生雪崩问题。

public class SolutionRandomTime {
    
    private static final Random RANDOM = new Random();

    public static int getRandomExpireTime(int baseSeconds, int randomRange) {
        // 生成1到randomRange秒的随机值
        int randomValue = RANDOM.nextInt(randomRange) + 1;
        return baseSeconds + randomValue;
    }
    
    public void setCacheWithRandomExpire(String key, Object value) {
        int baseExpire = 3600;   // 基础过期时间
        int randomRange = 180;   // 随机范围:1-180秒
        int actualExpire = getRandomExpireTime(baseExpire, randomRange);
        
        // 设置缓存
        redisTemplate.opsForValue().set(key, value, actualExpire, TimeUnit.SECONDS);
        
    }
}

解决方案2:集群部署

       如果Redis单节点宕机,可以采用集群部署方式防止雪崩。

3.解决缓存击穿

       大量热点Key访问+缓存过期,核心是并发回源打穿数据库

解决方案1:MySQL分布式锁

核心流程:
1.缓存命中 → 直接返回
2.缓存未命中 → 抢锁表行锁 → 抢到锁查数据表 → 刷新缓存 → 返回
3.缓存未命中 → 抢锁失败 → 重试查缓存 → 返回

问题:锁施放时间不好掌控,增加了MySQL抢锁压力,会产生其他问题。

解决方案2:Zookeeper(仅了解)

      博主并未使用过Zookeeper解决问题,仅了解理论。

      利用Zookeeper的临时顺序节点Watcher机制实现分布式锁。

ZNode结构设计
/locks
  ├── product_detail_1001
  │     ├── lock-0000000001  [临时顺序节点]
  │     └── lock-0000000002
  └── product_detail_1002
        └── lock-0000000001
# 也是通过锁解决问题,需要维护Zookeeper集群

解决方案3:Redis分布式锁(经典)

      Redis存在多种分布式锁,例如set nx 和 Redission 框架,底层原理一致,使用框架更为方便。

①实现步骤:

1.引入Redisson依赖。
2.配置配置文件。
3.配置配置类,主机地址、端口、密码、超时时间、前缀,注册客户端对象到ioc。
4.调用客户端对象getlock()、lock()、trylock()和unlock()等方法。

@Service
public class CacheService {
    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    /* 使用Redisson锁解决缓存击穿 */
    public Object getDataWithLock(String key) {
        // 1. 先查缓存
        Object value = redisTemplate.opsForValue().get(key);
        if (value != null) {
            return value;
        }
        
        // 2. 缓存不存在,准备获取分布式锁
        String lockKey = "lock:" + key;
        RLock lock = redissonClient.getLock(lockKey);
        boolean lockAcquired = false;
        
        try {
            // 3. 尝试获取锁(最多等待100ms,锁租期30秒)
            lockAcquired = lock.tryLock(100, 30000, TimeUnit.MILLISECONDS);
            
            if (lockAcquired) {
                // 4. 双重检查(Double Check)
                value = redisTemplate.opsForValue().get(key);
                if (value != null) {
                    return value;
                }
                
                // 5. 查询数据库
                value = queryFromDatabase(key);
                
                // 6. 写入Redis,设置随机过期时间防止雪崩
                int expireTime = 3600 + new Random().nextInt(300); 
                redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS);
                return value;
            } else {
                // 7. 获取锁失败,等待后重试
                Thread.sleep(50);
                return getDataWithLock(key); // 递归重试
            }
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取数据失败", e);
        } finally {
            // 8. 释放锁
            if (lockAcquired && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    
    private Object queryFromDatabase(String key) {
        // 数据库查询
        return "data_from_db_" + key;
    }
}
②为什么使用Redission框架不使用原生的 set nx 命令

      使用分布式锁的目的是为了:多线程可见、保证排他性、避免死锁、锁服务高可用。 redis常用两种方式实现分布式锁,但是原生分布式锁:redis的 set ex nx 命令,存在一部分缺点:

1.加锁解锁代码手动实现,人力成本高。

2.锁不可重入。

3.锁到期后不会自动续期。

redisson框架

1.编码简单,可以直接调用客户端方法,进行加锁解锁

2.底层采用Hash(Key:锁的名称;Filed:线程的唯一标识:UUID+线程ID;Value:锁的计数器)作为锁的存储,支持锁的重入。

3.自动续期基于看门狗机制(定时任务)完成。

③Redission加锁底层原理(仅了解)
Ⅰ 加锁底层原理

      lock() 加锁方法,底层调用同名lock方法,首先获取当前线程id,并且调用尝试加锁的方法tryAcquire,将等待时间、锁过期时间、线程id传入加锁tryAcquire方法,如果ttl返回null,则代表加锁成功; tryAcquire() 会调用tryAcquireAsync,tryAcquireAsync方法会根据是否传入了锁的持有时间判断如何设置tryLockInnerAsync方法的参数,没有则使用默认的30秒,其中使用lua脚本保证加锁的原子性,只有锁名称不存在或者属于当前线程的锁,才允许创建或者重入。 如果不为空,则开启异步任务,订阅释放锁的消息,redisson使用发布订阅机制实现锁的等待,当锁被释放的时候,持有锁的客户端会释放一条消息,阻塞锁的客户端会收到通知并且尝试重新获取锁。 设值合理的的超时时间,确保长时间获取不到锁,触发超时异常;并且interruptibly属性为false,表示锁在等待的过程中不会被中断,lockInterruptibly()可中断,默认的interruptibly为true。 紧接着,我们会进入一个死循环,继续尝试获取锁,成功则退出,失败则对锁的剩余过期时间进行一个判断,如果大于0,则进入等待状态,直到过期时间到或者被订阅消息唤醒,如果剩余过期时间小于0,则判断是否支持等待时中断,调用方法检测当前线程订阅的监听器的计数器,直到其归零也就是线程调用countdown归零,自己则会被唤醒。 无论如何最后取消订阅。

  • 支持中断(acquire(): 线程在等待计数器归零的过程中,如果收到中断信号,会 立即停止等待,抛出 InterruptedException 并退出,后续代码不再执行(除非捕获异常处理)。 这相当于 “线程在等待时可以被‘打扰’,收到信号就停止等待”。

  • 不支持中断(acquireUninterruptibly(): 线程在等待过程中,即使收到中断信号,也会 坚持等待到计数器归零,不会提前退出,也不抛异常。但中断信号会被记录(中断状态为 true),等待结束后线程可以感知到。

Ⅱ 看门狗机制

       看门狗的默认时间可以通过客户端对象中config.setLockWatchdogTimeout()来自定义时长。 仅当超时时间为-1时,才触发看门狗机制,调用scheduleExpirationRenewal(threadId);方法,将线程id传入,指定续期方法renewExpiration,在这个方法中会开启一个定时任务renewExpirationAsync,定时任务每10秒进行一次续期,这个定时任务首先会通过lua脚本判断当前线程是否持有锁,如果持有锁则续期,返回1,自旋执行续期任务,没有持有锁代表锁已经被释放,返回0,取消定时任务。 ​ 我们通常不使用看门狗机制,如果线程在执行的过程中发生了阻塞、卡死,那么看门狗会一直续期,导致锁无法释放,发生死锁。

Ⅲ 解锁

       调用unlock方法后,底层同样使用lua脚本释放锁,首先通过hexists锁名称和唯一标识判断锁是否存在,存在则使用hincrby对value值减一,判断value值,如果大于0则代表锁还存在重入,pexpire重制剩余过期时间,否则则代表锁已经不存在,del删除锁,并且使用publish发布 通知其他线程竞争锁。

4.解决数据一致性

       缓存和数据库之间 需要保证数据的一致性。

解决方案1:延迟双删

1.删除缓存。
2.更新数据库。
3.睡眠一段时间。
4.再次删除缓存。 加了个睡眠时间,主要是为了确保请求 A (更新)在睡眠的时候,请求 B (查询)能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 B 睡眠完,再删除缓存。 但这个睡眠时间不好掌握,一旦睡眠完成,第二个线程的缓存还没有写入,那么就白睡了,出现数据不一致。

 @Transactional
    public void delayDoubleDelete(Long userId, User userData) {
        String cacheKey = "user:" + userId;
        // 步骤1:先删除缓存
        redisTemplate.delete(cacheKey);
        // 步骤2:更新数据库
        userRepository.updateUser(userId, userData);
        // 步骤3:休眠一段时间(等待其他线程完成读取)
        try {
            Thread.sleep(500); // 休眠500ms,不好掌握,需要根据业务严格测试
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("延迟双删被中断", e);
        }
        // 步骤4:再次删除缓存
        redisTemplate.delete(cacheKey);
        logDoubleDelete(userId);
    }
    
    public User getUser(Long userId) {
        String cacheKey = "user:" + userId;
        // 1. 先查缓存
        User cachedUser = (User) redisTemplate.opsForValue().get(cacheKey);
        if (cachedUser != null) {
            return cachedUser;
        }
        // 2. 缓存不存在,查询数据库
        User dbUser = userRepository.findById(userId)
                .orElseThrow(() -> new RuntimeException("用户不存在"));
        // 3. 写入缓存,存在线程竞争
        redisTemplate.opsForValue().set(cacheKey, dbUser, 1, TimeUnit.HOURS);
        return dbUser;
    }

解决方案2:分布式读写锁

        缺点:除 '读读' 外不允许并发,依赖于Redission 框架 和 Redis 分布式锁机制,性能低

  //1.创建读写锁对象 锁名称前缀:数据标识
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("myLock:" + id);
    RLock lock = readWriteLock.readLock();//RLock lock = readWriteLock.writeLock();
    //2.获取读锁成功 执行读操作  读锁持有期间,不允许并发发写,允许并发读
    try {
        lock.lock(5, TimeUnit.SECONDS);
        log.info(Thread.currentThread().getName() + "执行读数据操作");
        return id;
    } finally {
        //3.释放读锁
        //lock.unlock();
    }

解决方案3:Canal(稳定)

     「先更新数据库,再删缓存」策略 Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。 我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存。

Cancal创建流程:

1.验证MySQL是否开启BinLog确保Value为 ON 。
2.在MySQL新增用户用于监听Binlog日志,在Canal容器中要使用该用户。
3.查询MySQL主节点状态;完成重置。
4.采用Docker等方式创建Canal服务端,促使canal重新跟MySQL建立连接。
5.客户端引入相关依赖canal-spring-boot-starter。
6.配著启动类,排除数据库连接。
7.配置配置文件。
8.配置监听器。

理解Cancal,其实只需要理解Mysql的主从同步机制:

  1. 主人记录变化:主人(Master)将所有数据变更写入二进制日志(binlog)文件。

  2. 学徒请求同步:学徒(Slave)启动同步后,创建IO线程向主人请求binlog内容。

  3. 主人派发日志:主人接到请求,创建Log Dump线程读取并发送binlog给学徒。

  4. 学徒接收日志:学徒的IO线程将收到的binlog内容保存到中继日志(relay log)。

  5. 学徒执行操作:学徒的SQL线程读取relay log,执行其中的SQL操作,实现数据同步。

然后理解代码:


/**
 * 用户表监听器
 * 其实Canal就是通过监听器去检测数据库发生增删改操作,然后做出反应
 */
@Component
@CanalTable(value = "user")
@Slf4j
public class UserCanalListener implements EntryHandler<Map<String, String>> {
    
    @Autowired
    private CacheService cacheService;
    
    @Override
    public void insert(Map<String, String> data) {
        log.info("监听到用户表INSERT操作: {}", data);
        // 新增用户时,删除相关的缓存
        String userId = data.get("id");
        if (userId != null) {
            // 删除用户详情缓存
            cacheService.deleteCacheAsync("user:detail:" + userId);
            // 删除用户列表缓存
            cacheService.deleteCacheAsync("user:list:*");
        }
    }
    
    @Override
    public void update(Map<String, String> before, Map<String, String> after) {
        log.info("监听到用户表UPDATE操作: before={}, after={}", before, after);
        // 更新用户时,删除相关的缓存
        String userId = after.get("id");
        if (userId != null) {
            // 删除用户详情缓存
            cacheService.deleteCacheAsync("user:detail:" + userId);
            // 如果用户名或状态发生变化,删除相关缓存
            if (!before.get("username").equals(after.get("username")) ||
                !before.get("status").equals(after.get("status"))) {
                cacheService.deleteCacheAsync("user:list:*");
            }
        }
    }
    
    @Override
    public void delete(Map<String, String> data) {
        log.info("监听到用户表DELETE操作: {}", data);
        // 删除用户时,删除相关的缓存
        String userId = data.get("id");
        if (userId != null) {
            // 删除用户详情缓存
            cacheService.deleteCacheAsync("user:detail:" + userId);
            // 删除用户列表缓存
            cacheService.deleteCacheAsync("user:list:*");
        }
    }
}

本文章结束

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值