一篇文章让你了解高并发下缓存和分布式锁的应该怎么用

缓存使用

为了系统性能提升,我们一般都会将部分数据放入缓存中,加速访问,而db承担数据落盘工作。

那些数据适合放入缓存

  1. 及时性,数据一致性要求不高的
  2. 访问量大而且更新频率不高的数据(读多,写少)

例如:电商应用,商品分类,商品列表等适合缓存并加一个失效时间(根据数据更新频率来定),后台如果发布一个商品,卖家需要5分钟后才能看到新的商品一般还是可以接受的。
image.png

User user = cache.load(id)//从缓存加载数据
if(Objects.isEntity(user)){//缓存里面没有数据
    user = mysql.load(id)//去数据库里面取数据
    cache.put(user);//保存到缓存里面去
    return user;
}else{
	//有缓存的情况下
    return user;
}

SpringBoot整合Redis

  1. 导入依赖
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-redis</artifactId>
		</dependency>
  1. 注入
    @Autowired
    private StringRedisTemplate redisTemplate;
  1. 简单使用
    public Map<Long, List<Catelog2Vo>> getCatalogJson(){
        //1,加入缓存逻辑
        String CatalogJson = redisTemplate.opsForValue().get("methods:getCatalogJson");
        if (StringUtils.isEmpty(CatalogJson)){
            Map<Long, List<Catelog2Vo>> catalogJsonFromDb = getCatalogJsonFromDb();
            redisTemplate.opsForValue().set("methods:getCatalogJson", JSONObject.toJSONString(catalogJsonFromDb));
            return catalogJsonFromDb;
        }
        Map<Long, List<Catelog2Vo>> map = JSON.parseObject(CatalogJson,new TypeReference<Map<Long, List<Catelog2Vo>>>(){});
        return map;
    }

缓存失效问题

缓存穿透

指查询一个不存在的数据,由于缓存无法命中,从而去查询数据库,但是数据库也没有记录,所以就不会将这个结果写入缓存,这将导致这个不存在的数据每次请求都要到缓存层去查询,从而失去了缓存的意义。

  1. 理由不存在的数据进行攻击,数据库瞬间压力增大,最总导致崩溃
  2. 可以给null结果进行缓存,并加入短暂的过期时间,这样就可以抵挡住先请求,减轻数据库压力。

image.png

缓存击穿

对于一些设置了过期时间的key,如果这些key看你会在某些时间点被高并发的访问,时一种非常“热点”的数据
如果这个key在大量请求同时进来前正好失效,那么所有对这个key的数据查询都落到db,我们成为缓存击穿

  1. 解决方法:加锁,大量并发下只让一个人去查,其他人等待,查到以后释放锁,其他人获取到锁,先查缓存,就会有数据,就不用去db查询了。

b5e51a71-9d26-4698-b5c5-389e11a75962.png

缓存雪崩

缓存雪崩是指在我们设置缓存时key采用了相同的过期时间,导致环境在某一时刻同时失效,请求全部转发到DB,DB瞬时间压力过大导致崩溃

  1. 解决方法:原有的失效时间基础上增加一个随机值,例如1-5分钟随机,这样每一个缓存过期时间重复率就会降低,就很难引起集体失效的事件。

image.png

总结

  1. 穿透:原因:高并发下一百万人同时去查询一个不存在的数据,那么redis中没有,就全部去查询mysql了,但是mysql里面也没有,那么一百万人同时查询就全部去查询db了,导致数据库崩溃
  2. 穿透:解决方法:第一个人去查询redis 的时候没有数据,再去查询mysql,但是这个时候mysql也没数据,那么就吧这个null存redis,并设置一个短暂的过期时间。
  3. 雪崩:原因:同一有大量的key过期,但是这个时候并发很大,一万个不同key同时过期,同时又有很多人查询这一万个key,导致全部都先去db里面查询然后在同步到redis,但是db一次扛不住一万次查询,导致宕机
  4. 雪崩:解决方法:在设置key过期时间的时候,将设置原有的过期时间+随机时间,这样每个key 的过期时间都不一样,就不会出现大面积的key到期,大量请求全部查询db,导致宕机。
  5. 穿透:原因:某个key在高并发下被很多人同时访问,这个时候key突然到过期时间了,导致这么多人同时并行请求接口,全部去查询db了,导致db宕机
  6. 穿透:解决方法:加锁,先让一个人去查询,然后把查询的结果保存到redis里面,然后在让其他人来访问。

并发锁

本地锁

image.png

  1. 使用synchronized把当前对象锁住,那么就只能一次性有一条线程可以进入。
  2. 优点:简单
  3. 缺点:在集群多台实例下失效,因为每个实例都有一个本地锁
@Override
public String getCatalogJson() {

    String CatalogJson = redisTemplate.opsForValue().get("methods:getCatalogJson");
    if (StringUtils.isEmpty(CatalogJson)) {
        System.out.println("没有命中缓存.............");
        String catalogJsonFromDb = getCatalogJsonFromDb();
        redisTemplate.opsForValue().set("methods:getCatalogJson", JSONObject.toJSONString(catalogJsonFromDb));
        return catalogJsonFromDb;
    }
    System.out.println("命中缓存");
    return CatalogJson;
}


public synchronized String getCatalogJsonFromDb() {
    synchronized (this) {
        String CatalogJson = redisTemplate.opsForValue().get("methods:getCatalogJson");
        if (!StringUtils.isEmpty(CatalogJson)) {
            System.out.println("直接返回");
            return CatalogJson;
        }
        String msg = db.list();
        System.out.println("查询数据库");
        return msg;
    }
}
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("查询数据库");
System.out.println("查询数据库");
System.out.println("查询数据库");
System.out.println("查询数据库");
System.out.println("查询数据库");
System.out.println("命中缓存");
System.out.println("命中缓存");
System.out.println("直接返回");
System.out.println("直接返回");
  1. 为什么我们添加了锁,还是会有很多线程同时去查询db呢?
  2. 因为这里代码写的有问题,锁的粒度很小,导致,数据还没有放到缓存中去,其他线程就进入到锁里面,所以才会出现多次查询数据库,导致锁失效。

image.png

  1. 锁的时序性问题,就是当前1线程和2线程同时请求进来,1线程强到锁了,去查询缓存没有,再去查询数据库,方法结束释放锁,在把结果放到缓存,但是这个时候,1线程释放锁后,数据还在存放缓存中的时候,2线程里面就拿到锁了,就去查询缓存,因为1线程的数据还没有成功放到缓存中,所以是没有的,然后2线程继续查询数据库查询完成方法结束,释放锁。
  2. 解决方法:增加锁的粒度,将结果放入缓存操作也放到锁里面。

image.png

  1. 将缓存存放操作放到锁里,这样就避免了锁的时序问题。
@Override
public String getCatalogJson() {
    String CatalogJson = redisTemplate.opsForValue().get("methods:getCatalogJson");
    if (StringUtils.isEmpty(CatalogJson)) {
        System.out.println("没有命中缓存.............");
        String catalogJsonFromDb = getCatalogJsonFromDb();
        return catalogJsonFromDb;
    }
    System.out.println("命中缓存");
    return CatalogJson;
}


public synchronized String getCatalogJsonFromDb() {
    synchronized (this) {
        String CatalogJson = redisTemplate.opsForValue().get("methods:getCatalogJson");
        if (!StringUtils.isEmpty(CatalogJson)) {
            System.out.println("直接返回");
            return CatalogJson;
        }
        String msg = db.list();
        System.out.println("查询数据库");
        //将存入缓存操作也放到锁里面
        redisTemplate.opsForValue().set("methods:getCatalogJson", JSONObject.toJSONString(msg));
        return msg;
    }
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("没有命中缓存.............");
System.out.println("查询数据库");
 System.out.println("直接返回");
 System.out.println("直接返回");
 System.out.println("直接返回");
 System.out.println("直接返回");
System.out.println("命中缓存");
System.out.println("命中缓存");
System.out.println("命中缓存");
System.out.println("命中缓存");

分布式锁

  1. 优点:多台实例集群下,保证每个机器都可以锁住
  2. 缺点:麻烦,需要中间件实现

使用redis实现分布式锁

public Map<Long, List<Catelog2Vo>> getCatalogJsonFromCache() {
    String uuid = UUID.randomUUID().toString();
    //上锁
    Boolean look = redisTemplate.opsForValue().setIfAbsent("look", uuid, 300, TimeUnit.SECONDS);
    Map<Long, List<Catelog2Vo>> categoryListFromDb=null;
    if (look) {//上锁成功
        //执行业务代码
        categoryListFromDb = getCategoryListFromDb();
        //解锁
        String lua = "if redis.call('get', KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
        redisTemplate.execute(new DefaultRedisScript<Long>(lua, Long.class), Arrays.asList("look"), uuid);
        return categoryListFromDb;
    } else {//没有抢到锁就自旋重试
        try {
            Thread.sleep(100);
            System.out.println("重试,自旋");
            categoryListFromDb=getCatalogJsonFromCache();
        } catch (Exception e) {

        }
    }
    return categoryListFromDb;
}

使用redisson框架实现

  1. 使用redisson可以实现更高级的功能
  2. redisson实现了JUC中的Look方法,使得我们使用redisson更简单
  3. redsson里面有看门狗机制,我们加锁后不需要指定过期时间,由看门狗来自动检测是否我们业务执行完成,原理:(加锁的时候会默认指定一个30s的过期时间,当执行到过期时间的三分之一的时候这个锁还没有被删掉,那么看门狗会给这个锁续期,直到这个业务执行完成,释放锁)
  1. 导包
<!--		分布式锁-->
		<!-- https://mvnrepository.com/artifact/org.redisson/redisson -->
		<dependency>
			<groupId>org.redisson</groupId>
			<artifactId>redisson</artifactId>
			<version>3.12.0</version>
		</dependency>
  1. 实例化bean
package com.atguigu.gulimall.product.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.cluster.ClusterConnectionManager;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedisSonConfig {

    @Bean
    public RedissonClient redissonClient(){
        Config config = new Config();
        config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(4);
        RedissonClient redissonClient = Redisson.create(config);
        return redissonClient;
    }
}
Lock可重入锁

@Autowired
private RedissonClient redissonClient;


@GetMapping("/hello")
    public String hello() {
        //获取一把锁,只需要锁的名字一样,那么就是同一把锁
        RLock lock = redissonClient.getLock("my-lock");
        //加锁
        lock.lock();//阻塞式等待,默认加的锁过期时间是30秒
        //自动续期,如果业务超长时间,运行期间会自动给锁续上新的30s,不用担心业务时间长,锁自动过期被删掉
        try {
            System.out.println("加锁成功...执行业务"+Thread.currentThread().getId());
            Thread.sleep(30000);
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            //解锁,加锁这个解锁代码没有执行,也不会出现死锁问题。
            lock.unlock();
            System.out.println("成功解锁"+Thread.currentThread().getId());
        }
        return "hello";
    }
RedWriteLock读写锁
  1. 读写锁指的是,可以同时读,但是不能编写边读
  2. 在写操作还没有释放锁的时候,读会一直阻塞状态
  3. 写操作完成后,读才会拿到锁进行读取操作
  1. 保证一读取到的是最新的数据,修改期间,写锁是一个互斥锁,读锁是一个共享锁。
  2. 写锁没释放就读必须等待
  3. 读+读,相当于无所,并发读,只会在redis中记录好,所以当前的读锁,他们会同时加锁成功。
  4. 写+读,等待写锁释放
  5. 写+写,阻塞方式
  6. 读+写,有读锁,写也需要等待。
@GetMapping("/write")
@ResponseBody
public String write() {
    //获取一把锁,只需要锁的名字一样,那么就是同一把锁
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock");
    //加锁
    RLock rLock = readWriteLock.writeLock();
    String s = UUID.randomUUID().toString();
    try {
        rLock.lock();
        Thread.sleep(30000);
        stringRedisTemplate.opsForValue().set("red-write-lock", s);
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        //解锁,加锁这个解锁代码没有执行,也不会出现死锁问题。
        rLock.unlock();
        System.out.println("成功解锁" + Thread.currentThread().getId());
    }
    return "成功";
}

@GetMapping("/read")
@ResponseBody
public String read() {
    //获取一把锁,只需要锁的名字一样,那么就是同一把锁
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("rw-lock");
    //加锁
    RLock rLock = readWriteLock.readLock();
    String s="";
    try {
        rLock.unlock();
        s = stringRedisTemplate.opsForValue().get("red-write-lock");
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        //解锁,加锁这个解锁代码没有执行,也不会出现死锁问题。
        rLock.unlock();
        System.out.println("成功解锁" + Thread.currentThread().getId());
    }
    return s;
}
Semaphore信号量
  1. 信号量类似于地下车库,车库位置固定那么多,停一个车少一个车位,开走一辆车多一个车位
@GetMapping("/park")
@ResponseBody
public String park() throws InterruptedException {
//获取信号量锁
RSemaphore park = redissonClient.getSemaphore("park");
//取一个信号量,信号量-1,如果没有信号量了就会一直在这里等待。
park.acquire();
return "ok";
}

@GetMapping("/go")
@ResponseBody
public String go() throws InterruptedException {
//获取信号量锁
RSemaphore park = redissonClient.getSemaphore("park");
//释放一个信号量,信号量+1
park.release();
return "ok";
}
CountDownLatch闭锁
  1. 闭锁类似于学校锁大门,当全部班级人走完后,才可以锁学校大门,如果其中有一个班没有走,那么就不能锁大门。
@GetMapping("/lockdoor")
@ResponseBody
public String lockdoor() throws InterruptedException {
RCountDownLatch door = redissonClient.getCountDownLatch("door");
door.trySetCount(5);//假设5个班,
door.await();//当班级没有走完,就会一直在这里阻塞住
return "全部班级走完,锁学校大门";
}

@GetMapping("/gogogo/{id}")
@ResponseBody
public String gogogo(@PathVariable("id") Long id) throws InterruptedException {
    RCountDownLatch door = redissonClient.getCountDownLatch("door");
    door.countDown();//走一个班就执行一次。
    return id+"班走了";
}

image.png

缓存一致性

当我们修改了某条数据,但是缓存里面没有修改,导致缓存不一致,有有两种解决问题

  1. 双写模式:当修改的时候写入数据库同时写入缓存

image.png

  1. 失效模式:当修改的时候写入数据库,删掉缓存,下次请求进来获取最新的数据写入缓存。

image.png
注意:明面上看着都没有问题,但是在高并发下都会有几率出现问题

解决方案

无论是双写模式还是失效模式,都会导致缓存不一致的问题,既多个实例同时更新会出事 怎么办?

  1. 如果是用户维度数据(订单数据,用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据上加上过期时间,每隔一段时间触发读的主动更新即可。
  2. 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅mysql的binlog方式
  3. 缓存数据+过期时间也足够解决大部分对于缓存的要求。
  4. 通过分布式(读写锁)保证并发读写,写写的时候按顺序排好队,读读无所谓,所以适合使用读写锁(业务不关心脏数据,运行临时脏数据可忽略);

总结

  1. 我们能放入缓存的数据根本就不应该是实时性,一致性要求超高的,所以缓存数据的适合加上过期时间,保证每天拿到当前最新数据即可。
  2. 我们不应该过度设计,增加系统的复杂性
  3. 遇到实时性,一致性要求高的数据,就应该查数据库,即使慢点。

canal原理

image.png

SpringCache

  1. 引入依赖
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
  1. 配置缓存中间件,type:选择缓存中间件,time-to-live:缓存过期时间
 spring:
  cache:
    type: redis #设置缓存中间件
    redis:
      time-to-live: 360000 #key获取时间
      use-key-prefix: true #是否开启前缀
      cache-null-values: true #是否缓存空值,解决缓存穿透问题
      key-prefix: CACHE_ #统一前缀

定义JSON存储

package com.atguigu.gulimall.product.config;

import com.alibaba.fastjson.support.spring.FastJsonRedisSerializer;
import com.alibaba.fastjson.support.spring.GenericFastJsonRedisSerializer;
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@EnableCaching
public class MyCacheConfig {

    @Bean
    RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
        config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericFastJsonRedisSerializer()));
        CacheProperties.Redis redisProperties = cacheProperties.getRedis();
        if (redisProperties.getTimeToLive() != null) {
            config = config.entryTtl(redisProperties.getTimeToLive());
        }

        if (redisProperties.getKeyPrefix() != null) {
            config = config.prefixKeysWith(redisProperties.getKeyPrefix());
        }

        if (!redisProperties.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }

        if (!redisProperties.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }
        return config;
    }
}

注解使用

Cacheable

获取结果并保存到缓存,下次请求来直接读取缓存返回

  1. category:缓存名称(分区)
@Cacheable({"category"})
@Override
public List<CategoryEntity> getLave1CateGorys() {
    System.out.println("getLave1CateGorys");
    // long timeMillis = System.currentTimeMillis();
    List<CategoryEntity> categoryEntityList = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
    //        System.out.println("耗费时间:"+(System.currentTimeMillis()-timeMillis));
    return categoryEntityList;
}

CacheEvict

触发将数据从缓存中删除

@CacheEvict(value = "category",key = "'getLave1CateGorys'")
@Transactional
@Override
public void updateDetail(CategoryEntity category) {
this.updateById(category);
if (!StringUtils.isEmpty(category.getName())) {
    categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
}
}
  1. 一次删除多个缓存
@Caching(evict = {
    @CacheEvict(value = "category",key = "'getCatalogJson'"),
    @CacheEvict(value = "category",key = "'getLave1CateGorys'")
})
@Transactional
@Override
public void updateDetail(CategoryEntity category) {
this.updateById(category);
if (!StringUtils.isEmpty(category.getName())) {
    categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
}
}
  1. 一次性删除多个缓存
@CacheEvict(value = "category",allEntries = true)
@Transactional
@Override
public void updateDetail(CategoryEntity category) {
this.updateById(category);
if (!StringUtils.isEmpty(category.getName())) {
    categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
}
}

CachePut

双写模式,吧当前返回的值,在缓存中在存一份。

@CacheEvict(value = "category",allEntries = true)
@CachePut
@Transactional
@Override
public void updateDetail(CategoryEntity category) {
this.updateById(category);
if (!StringUtils.isEmpty(category.getName())) {
    categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
}
}

注意:

  1. 存储同一个类型的数据,都可以指定成一个分区,分区名默认就是缓存的前缀。

image.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值