redis学习

redis

存取数据时,是键值对的形式key-value

  • 比如存一个用户信息时,key = 1001,value 可以为 用户的信息,是用json数据存的
SQLNoSQL
结构化:(修改起来很麻烦)非结构化(健值,文档型,列表,Graph)
关系型的,需要维护无关联
sql查询非sql
ACID(原子性,一致性,隔离性,持久性)BASE
磁盘存储内存存储(速度快)
垂直水平

1、特征

  • 键值型:value支持多种不用的数据结构

  • 单线程,每个命令具有原子性

  • 低延迟,速度快(基于内存,io多路复用,良好的编码)

  • 支持数据持久化,定期存磁盘中

  • 支持主从集群,分片集群

  • 支持多语言客户端

2、数据结构类型

2.1、值的类型:

  • 基本类型:不能重复

    • string

    • hash

    • list

    • set

    • sortedSet

  • 特殊类型

    • GEO

    • Bitmap

    • Hyperlog

  • 还有些其他类型

2.2、redis的通用命令

  • keys 模糊查询

    • keys a* 查询以 a 开头的键
  • del 删除命令

    • del name 删除键为 name 的数据
  • exists 判断key是否存在

    • exists name 返回值为key 的数量
  • expire 设置有效期

    • expire 键 存活时间 expire name 20,存活20秒
  • ttl 查看key剩余的有效期

    • ttl name

2.3、string类型

2.3.1、存储格式
  • 字符串格式分为三种,底层是字节数组形式存储
    • string:普通字符串
    • int:整型类型 ,可以自增自减
    • float:浮点类型,可以自增自减
    • image-20240117152428673
2.3.2、string的内部实现
  • string底层采用的数据结构是:int和sds(简单字符串)
  • redis是C语言编写的,并没有使用C语言的字符串因为sds有以下优点
    • sds不仅可以保存文本数据,还可以保存二进制数据,不仅可以保存文本,还能存图片等等的二进制数据。
    • sds获取字符串长度的复杂度是O(1),因为在sds结构中有 len来记录长度,直接可以获取,所以为O(1)。
    • redis的sdsAPI是安全的,拼接字符串不会造成缓冲区溢出,因为sds会检查空间大小,不够时会自动扩容,所以不会造成缓冲区溢出的问题。
2.3.3、常见的命令
  • 基本操作

    • set :添加或者修改

    • get:获取值

    • exists:判断key是否存在

    • strlen:获取长度

  • 批量设置

    • mset:批量添加
    • mget:批量获取
  • 计数器

    • incr :自增+1

    • incrby:设置自增的步长

    • derc:自减-1

    • decby:设置自减的步长

    • incrbyfloat :必须设置指定增长的步长

2.3.4、层级存储
  • 项目名:业务名:类型:id ===》heima:user:1
  • 例如value是java对象
    • heima:user:1 -----> {“id”: 1,“name” : “jack”,“age” : 21 }
    • heima:user:1 -----> {“id”: 1,“name” : “小米手机”,“price” : 3999 }
  • image-20240117145245199
2.3.5、应用场景
  • 缓存对象

    • 直接缓存整个JSON,例如:SET user:1 {“name”:“zzj”,“age”:18},同时也采用了分层思想
    • 采用key进行分离为 user:ID 属性,采用 mset储存,用mget 获取各属性值,例如:MSET user:1:name xiaoming user:1:age 18 user:2:name xiaowang user:2:age:20
  • 常规计数

    • 以为redis处理命令是单线程的,所以执行命令的过程是原子性的。因此 String 数据类型适合计数场景,比如计算访问次数、点赞、转发、库存数量等等。

    • # 初始化文章的阅读量
      > SET aritcle:readcount:1001 0
      OK
      #阅读量+1
      > INCR aritcle:readcount:1001
      (integer) 1
      #阅读量+1
      > INCR aritcle:readcount:1001
      (integer) 2
      #阅读量+1
      > INCR aritcle:readcount:1001
      (integer) 3
      # 获取对应文章的阅读量
      > GET aritcle:readcount:1001
      "3"
      
  • 分布式锁

    • SET 命令有个 NX 参数可以实现「key不存在才插入」,可以用它来实现分布式锁:

2.4、hash类型

2.4.1、介绍
  • key和string没啥变化,主要是value是一个无序的字典,是松散的

  • hash结构将,String中的json变成键值类型

  • keyfieldvalue
    heima:user:1namejack
    age21
2.4.2、基本命令
  • HSET:添加操作,要指定三个参数,例如:HSET user:3 name xiaozeng
  • HGET:读取操作,例如:HGET user:3 age

  • HMSET:批量添加,例如:HMSET user:3 name xiaoli age 18 sex nan phone 123456789
  • HMGET:批量读取,例如:HMGET user:3 name age sex

  • HGETALL:获取一个hash中 key 中的所有field和value,例如:HGETALL user:3
  • HKEYS:获取一个hash中的所有field,例如:HKEYS user:3
  • HVALS:获取一个hash中的所有value,例如:HVALS user:3
  • HINCRBY:让一个hash中的一个key进行自增,例如:HINCRBY user:3 age 2
  • HSETNX:判断field是否存在,存在不添加,不存在添加,例如:

2.5、list类型

2.5.1、结构和特征
  • 类似一中双向链表,支持正向检索和反向检索

  • 特征:

    • 有序
    • 元素可重复
    • 插入和删除快
    • 查询速度一般
  • 常用于存一些有序的数据

2.5.2、常用命令

  • LPUSH:向列表 左侧 插入一个或多个元素 (头插法)
  • LPOP:移除并返回 左侧 的第一个元素

  • RPUSH:向列表 右侧 插入一个或多个元素 (尾插法)
  • RPOP:移除并返回 右侧 的第一个元素

  • LRANGE key start end:返回一段角标范围内的所有元素
  • BLPOP,BRPOP:当没有元素时,会等待
2.5.3、思考
  • list结构模拟栈
    • 入口和出口一边
      • lpush + lpop
  • list结构模拟队列
    • 入口和出口不一边
      • lpush + rpop

2.6、set类型

2.6.1、特征
  • 无序
  • 元素不可重复
  • 查找快
  • 支持交集、并集、差集、等功能
2.6.2、常见命令
  • SADD:向set中添加一个或多个元素
  • SREM:移除set中的指定元素
  • SCARD:返回set中元素的个数
  • SISMEMBER:判断一个元素是否存在于set中
  • SMEMBERS:获取set中的所有元素

  • SINTER:比较两个 key 之间的 交集
  • SDIFF:比较两个key之间的 差集
  • SUNION:比较两个key之间的 并集

2.7、SortSet

2.7.1、介绍和特征
  • Redis的SortedSet是一个可排序的set集合,与Java中的TreeSet有些类似,但底层数据结构却差别很大。SortedSet中的每一个元素都带有一个score属性,可以基于score属性对元素排序,底层的实现是一个跳表(SkipList)加 hash表。

  • 可排序

  • 元素不可重复

  • 查询速度快

  • 通常用于实现排行榜功能

2.7.2、常见的命令
  • ZADD key score member:添加一个或多个元素到sorted set ,如果已经存在则更新其score值
  • ZREM key member:删除sorted set中的一个指定元素
  • ZSCORE key member : 获取sorted set中的指定元素的score值
  • ZRANK key member:获取sorted set 中的指定元素的排名

  • ZCARD key:获取sorted set中的元素个数
  • ZCOUNT key min max:统计score值在给定范围内的所有元素的个数
  • ZINCRBY key increment member:让sorted set中的指定元素自增,步长为指定的increment值
  • ZRANGE key min max:按照score排序后,获取指定排名范围内的元素
  • ZRANGEBYSCORE key min max:按照score排序后,获取指定score范围内的元素
  • ZDIFFZINTERZUNION:求差集、交集、并集

注意:所有的排名默认都是升序,如果要降序则在命令的Z后面添加REV即可

3、redis客户端

3.1、jedis

3.1.1、小demo
  • 用idea创建一个java的maven项目

  • 引入依赖

    •     <dependency>
              <groupId>redis.clients</groupId>
              <artifactId>jedis</artifactId>
              <version>4.3.1</version>
          </dependency>
      
  • 建立链接

    • //建立链接
              JedisPool pool = new JedisPool("服务器地址", 6379);
              Jedis jedis = pool.getResource();
              //输入密码
              jedis.auth("0806");
              //选择库
              jedis.select(0);
      
  • 执行redis操作

    •     //进行操作
          jedis.set("name","lmg");
          jedis.set("age", String.valueOf(12));  //默认填字符串要转换
          System.out.println(jedis.get("name"));
          System.out.println(jedis.get("age"));
        
          Map<String,String> hash = new HashMap<>();
          hash.put("name", "John");
          hash.put("surname", "Smith");
          hash.put("company", "Redis");
          hash.put("age", "29");
          jedis.hset("book:1",hash);
          System.out.println(jedis.hgetAll("book:1"));
        
          //关闭jedis
          if (jedis !=null){
              jedis.close();
          }
      
3.1.2、建立线程安全的jedis
  • 建立一个工具类

    • public class JedisConnetionFactory {
          private static final JedisPool jedisPool;
      
          static {
              JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
              //最大链接数
              jedisPoolConfig.setMaxTotal(8);
              //最大空闲链接
              jedisPoolConfig.setMaxIdle(8);
              //最小空闲链接
              jedisPoolConfig.setMinIdle(8);
              //设置最长等待时间,5s
              jedisPoolConfig.setMaxWait(Duration.ofSeconds(5));
      
              jedisPool = new JedisPool(jedisPoolConfig,"服务器地址",6379,1000,"0806");
          }
      
          public static Jedis getJedis(){
              return jedisPool.getResource();
          }
      }
      
      
  • main函数中使用工具类创建jedis

    •     Jedis jedis = JedisConnetionFactory.getJedis();
          System.out.println(jedis.get("name"));
      jedis.close();
      

4、SpringDataRedis

4.1、初始化项目

  • 支持的功能

    • 提供了对不同Redis客户端的整合(Lettuce和Jedis)

    • 提供了RedisTemplate统一API来操作Redis

    • 支持Redis的发布订阅模型

    • 支持Redis哨兵和Redis集群

    • 支持基于Lettuce的响应式编程

    • 支持基于JDK、JSON、字符串、Spring对象的数据序列化及反序列化

    • 支持基于Redis的JDKCollection实现

    • image-20240118111403349

  • 1、引入依赖

    • Spring-redis依赖

      • <!--        redis依赖-->       
        	<dependency>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-data-redis</artifactId>
                </dependency>
        <!--        pool的依赖-->
                <dependency>
                    <groupId>org.apache.commons</groupId>
                    <artifactId>commons-pool2</artifactId>
                </dependency>
        
  • 2、配置redis的信息

    • spring:
        # redis 配置
        redis:
          # 地址
          host: .服务器地址
          # 端口,默认为6379
          port: 6379
          # 密码,密码用双引号括起来,血与泪的排查(重置服务器的代价)
          password: "0806"  
          # 连接超时时
          timeout: 5200
          lettuce:
            pool:
              # 连接池中的最小空闲连接
              min-idle: 0
              # 连接池中的最大空闲连接
              max-idle: 8
              # 连接池的最大数据库连接数
              max-active: 8
              # #连接池最大阻塞等待时间(使用负值表示没有限制)
              max-wait: -1
      

4.2、修改序列化操作

  • 未修改时

    • image-20240118150234216
    • image-20240118150202955
    • 键和值都是这样显示的,而我set的确实name,显然这样是不行的。要修改序列化操作
  • @Configuration
    public class RedisConfig {
    
        @Bean
        public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory connectionFactory){
            //创建RedisTemplate对象
            RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
            //创建链接工厂
            redisTemplate.setConnectionFactory(connectionFactory);
            //创建序列化工具,这个是value的
            GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
            //设置key 的序列化
            redisTemplate.setKeySerializer(RedisSerializer.string());
            //设置value的序列化
            redisTemplate.setValueSerializer(serializer);
            redisTemplate.setHashValueSerializer(serializer);
            //返回
            return redisTemplate;
        }
    }
    

4.3、StringRedisTemplate

为了节省内存空间,我们并不会使用JSON序列化器来处理value,而是统一使用String序列化器,要求只能存储String类型的key和value。当需要存储Java对象时,手动完成对象的序列化和反序列化。

  • json序列化器时,将user对象转json存入redis时,redis会默认存入一个user所在的地址,最后可以通过反射达到反序列化,这样会消耗redis空间
  • 我们可以用Spring默认提供了一个StringRedisTemplate类,它的key和value的序列化方式默认就是String方式。省去了我们自定义RedisTemplate的过程:
4.3.1、自己序列化
  • 写入redis序列化将对象为json

  • 读出数据时,将json转换为对象

  • User user = new User("xxl",20);
    //对象转json
    String jsonString = JSONObject.toJSONString(user);
    stringRedisTemplate.opsForValue().set("user:11",jsonString);
    
    //json转对象
    String jsonUser = stringRedisTemplate.opsForValue().get("user:11");
    User user1 = Objects.requireNonNull(JSONObject.parseObject(jsonUser)).toJavaObject(User.class);
    
    System.out.println(user1);
    

5、redis实战

5.1、短信登录
  • 验证验证码的接口

    • image-20240119201151462
    •     /**
           * 发送手机验证码,控制层
           */
          @PostMapping("code")
          public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
              return userService.sendCode(phone,session);
          }
      
      //业务逻辑
      @Override
          public Result sendCode(String phone, HttpSession session) {
              //校验手机号,用hutool中的工具
              boolean isValid = PhoneUtil.isMobile(phone);
              //不符合返回错误
              if (!isValid){
                  return Result.fail("手机号码格式错误");
              }
              //符合生成验证码
              String str = RandomUtil.randomNumbers(6);
              //保存用户到redis,加个前缀有助于区分,时间为两分钟
              String s = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
              if (s == null) {
                  stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,str,2, TimeUnit.MINUTES);
              }
              //发送验证码
              log.debug("发送验证码成功,验证码:{}",str);
              //返回ok
              return Result.ok("ok");
          }
      
  • 登录接口的实现

    • image-20240119201326820

    •     /**
           * 登录功能
           * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
           */
          @PostMapping("/login")
          public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
              return userService.login(loginForm,session);
          }
          @GetMapping("/me")
          public Result me(){
              UserDTO userDTO = UserHolder.getUser();
              return Result.ok(userDTO);
          }
      
      
          
         //业务层 
          @Override
          public Result login(LoginFormDTO loginForm, HttpSession session) {
              String phone = loginForm.getPhone();
              String code = loginForm.getCode();
              //1、验证手机号
              if (!PhoneUtil.isMobile(phone)){
                  return Result.fail("手机号码格式错误");
              }
              //2、验证验证码,在redis中验证
              String flag = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
              if (flag == null || !flag.equals(code)) {
                  return Result.fail("验证码错误");
              }
              //3、判断用户是否存在
              User user = this.query().eq("phone", phone).one();
              //4、不存在创建一个新用户
              if (user == null){
                  user = createUser(phone);
              }
              //5、保存用户数据到redis中,用hash存,token用uuid生成,
              String token = UUID.randomUUID().toString(true);
              //6、将user对象转化为hashmap存,因为在转map的方法中默认两个类型都是String所以需要转化
              UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
              //法1、高级
              Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
                      CopyOptions.create().setIgnoreNullValue(true)
                              .setFieldValueEditor((fileldName,fieldVaule) -> fieldVaule.toString()));
              //法2、笨方法
      //        HashMap<String,String> map = new HashMap<>();
      //        map.put("id",userDTO.getId().toString());
      //        map.put("nickName",userDTO.getNickName());
      //        map.put("icon",userDTO.getIcon());
      
              //7、value用来保存用户信息
              stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token,userMap);//键是uuid+token
              //设置有效期,长时间未操作,清除
              stringRedisTemplate.expire(LOGIN_USER_KEY + token,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);
              //返回token信息
              return Result.ok(token);
          }
      
          private User createUser(String phone) {
              User user = new User();
              user.setPhone(phone);
              user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
              this.save(user);
              return user;
          }
      
  • 登录校验,保证用户一直在登录状态

    • 使用拦截器,对请求进行拦截。

    • image-20240119201511681
    • @Configuration
      public class MvcConfig implements WebMvcConfigurer {
      
          @Resource
          private StringRedisTemplate stringRedisTemplate;
      
          //order为 0 的拦截所有地址,用于做token的更新和保存数据到ThreadLocal,并对redis的token有效期刷新
          //order为 1 的拦截要登录才能使用的功能,判断是否登录
          @Override
          public void addInterceptors(InterceptorRegistry registry) {
              registry.addInterceptor(new LoginInterceptor())
                      .excludePathPatterns(
                              "/shop/**",
                              "/voucher/**",
                              "/upload/**",
                              "/shop-type/**",
                              "/user/code",
                              "/blog/hot",
                              "/user/login"
                      ).order(1);
              registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
          }
      }
      
    • public class RefreshTokenInterceptor implements HandlerInterceptor {
      
          //无法通过注解进行构建,因为这个类是我们自己构建的,不是Spring构建的,可以通过构造器注入
          private StringRedisTemplate stringRedisTemplate;
      
          public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
              this.stringRedisTemplate = stringRedisTemplate;
          }
      
          @Override
          public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
              //1、根据前端的请求头获取token
              String token = request.getHeader("authorization");
              if (CharSequenceUtil.isBlank(token)){
                  return true;
              }
              //2、通过token获取用户信息
              Map<Object, Object> map = stringRedisTemplate.opsForHash()
                      .entries(LOGIN_USER_KEY + token);
              //3、判断用户是否存在
              if (map.isEmpty()){
                  return true;
              }
              //4、将map转换为UserDTO,hutool里面的工具
              UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
              //5、存在保存信息到ThreadLocal
              UserHolder.saveUser(userDTO);
              //6、刷新token的有效期,达到用户一直访问,一直有效
              stringRedisTemplate.expire(LOGIN_USER_KEY + token,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
              //放行
              return true;
          }
      
          @Override
          public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
              UserHolder.removeUser();
          }
      }
      
    • public class LoginInterceptor implements HandlerInterceptor {
      
          @Override
          public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
              //1、判断是否要拦截
              if (UserHolder.getUser() == null){
                  response.setStatus(401);
                  return false;
              }
              return true;
          }
      
          @Override
          public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
              UserHolder.removeUser();
          }
      }
      

6、redis缓存

6.1、什么是缓存

  • 缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码

6.2、为什么要使用缓存

  • 缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力
  • 实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术;
  • 但是缓存也会增加代码复杂度和运营的成本:

7、缓存更新策略

7.1、内存淘汰

  • 不用自己维护,利用Redis的内存淘汰机制,当内存不足时自动淘汰部分数据,下次查询时更新缓存。
  • 一致性差。

7.2、超时剔除

  • 给缓存数据添加,ttl时间,到期后自动删除缓存,下次查询时更新数据
  • 一致性一般,维护成本低

7.3、主动更新

  • 编业务逻辑,在修改数据库的同时,更新缓存
  • 一致性好维护成本高

主动更新策略的方法

  • Cache Aside Pattern----(胜出)
    • 由缓存的调用者,在更新数据库的同时更新缓存
  • Read/Write Through Pattern
    • 缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。
  • Write Behind Caching Pattern
    • 调用者只操作缓存,由其它线程异步的将缓存数据持久化到数据库,保证最终一致。

7.4、如何选择策略

  • 低一致性需求:使用内存淘汰机制,例如:店铺类型的查询缓存
  • 高一致性:主动更新,并以超时剔除作为兜底。
  • 最终Cache Aside Pattern----(胜出)

7.5、操作缓存和数据库需要考虑的问题

  • 操作缓存和数据库时有三个问题需要考虑:

  • 删除缓存还是更新缓存?

    • 更新缓存:每次更新数据库都更新缓存,无效写操作较多
    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存
  • 如何保证缓存与数据库的操作的同时成功或失败?

    • 单体系统,将缓存与数据库操作放在一个事务

    • 分布式系统,利用TCC等分布式事务方案

  • 先操作数据库还是缓存

    • 先删除缓存,再操作数据库

    • 先操作数据库,再删除缓存 (胜出)

7.6、最终策略

  • 低一致性需求:使用Redis自带的内存淘汰机制

  • 高一致性需求:主动更新,并以超时剔除作为兜底方案

    • 读操作:

      • 缓存命中则直接返回
      • 缓存未命中则查询数据库,并写入缓存,设定超时时间
    • 写操作:

      • 先写数据库,然后再删除缓存

      • 要确保数据库与缓存操作的原子性

8、缓存穿透

  • 缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

8.1、缓存空对象

image-20240120220414235

  • 优点:实现简单,维护方便

  • 缺点:

    • 额外的内存消耗

    • 可能造成短期的不一致

8.2、布隆过滤

image-20240120221756092

  • 优点:内存占用较少,没有多余key

  • 缺点:

    • 实现复杂

    • 存在误判可能

8.3、总结

  • 缓存穿透产生的原因是什么?
    • 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力
  • 缓存穿透的解决方案有哪些?
    • 缓存null值
    • 布隆过滤
    • 增强id的复杂度,避免被猜测id规律,雪花算法id等等
    • 做好数据的基础格式校验
    • 加强用户权限校验
    • 做好热点参数的限流

9、缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

  • image-20240121105225155

9.1、解决方案

  • 给不同的Key的TTL添加随机值
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

10、缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

10.1、互斥锁

  • image-20240121154833691

  • image-20240121223507373

  •   @Override
        public Result queryById(Long id) {
    
            //1、从rides中查询商铺
            String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
            //2、命中、返回信息
            if (CharSequenceUtil.isNotBlank(shopJson)){
                Shop shop = JSONObject.parseObject(shopJson, Shop.class);
                return Result.ok(shop);
            }
            //2.1、如果命中的是插入的空值,则返回错误
            if (shopJson != null){
                return Result.fail("店铺信息不能为空");
            }
            //3、未命中,获取互斥锁
            String keyLock = "cache:lock";
            Shop shop = null;
            try {
                boolean isLock = tryLock(keyLock);
                //4、获取失败,休眠一段时间,再去缓存中查数据
                if (!isLock){
                    Thread.sleep(50);
                    return queryById(id);
                }
                //5、获取成功,去数据库里查信息
                shop = getById(id);
                //6、数据库中不存在,返回错误
                if (shop == null){
                    //6.1、写入空值,来解决缓存穿透
                    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
                    return Result.fail("店铺不存在");
                }
                //7、存在,写入redis里,并设置过期时间30分钟
                String jsonString = JSONObject.toJSONString(shop);
                stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,jsonString,CACHE_SHOP_TTL,TimeUnit.MINUTES);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                //8、释放锁
                unLock(keyLock);
            }
    
            //9、返回信息
            return Result.ok(shop);
        }
    
    
    //获取锁
    private boolean tryLock(String key){
            Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
            return BooleanUtil.isTrue(flag);
        }
    
    //释放锁
        private void unLock(String key){
            stringRedisTemplate.delete(key);
        }
    

10.2、逻辑过期

  • image-20240121160137686

  • image-20240122102854705

  • //线程池来开始线程
        private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
    
    @Override
        public Result queryById(Long id) {
            //1、从rides中查询商铺
            String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
            //2、未命中、返回信息
            if (CharSequenceUtil.isBlank(shopJson)){
                return Result.fail("获取数据失败");
            }
            //3、命中,将json反序列化为对象
            RedisData redisData = JSONObject.parseObject(shopJson, RedisData.class);
            Object data = redisData.getData();
            Shop shop = JSONObject.parseObject(JSONObject.toJSONString(data), Shop.class);
            // 3.1、判断缓存是否过期,未过期返回数据
            if (redisData.getExpireTime().isAfter(LocalDateTime.now())){
                return Result.ok(shop);
            }
            //3.2、缓存过期,获取互斥锁
            String keyLock = LOCK_SHOP_KEY;
            boolean isLock = tryLock(keyLock);
            //4、获取锁失败直接返回数据
            if (!isLock){
                return Result.fail("获取数据失败");
            }
            //5、获取成功,再次判断缓存是否过期,防止在获取锁的过程中,之前有线程重建好了,双重判断
            if (redisData.getExpireTime().isAfter(LocalDateTime.now())){
                return Result.ok(shop);
            }
            //6、再次查询缓存过期后,开启新的线程重建缓存
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    this.saveShopToRedis(id, CACHE_SHOP_TTL);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    unLock(keyLock);
                }
            });
            //9、返回信息
            return Result.ok(shop);
        }
    
    //获取锁
    private boolean tryLock(String key){
            Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.MINUTES);
            return BooleanUtil.isTrue(flag);
        }
    
    //释放锁
        private void unLock(String key){
            stringRedisTemplate.delete(key);
        }
    
    
        private void saveShopToRedis(Long id,Long expireSeconds){
            //1、查询店铺
            Shop shop = getById(id);
    
            //2、封装逻辑过期时间
            RedisData redisData = new RedisData();
            redisData.setData(shop);
            redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
            //3、写入redis
            String jsonString = JSONObject.toJSONString(redisData);
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,jsonString);
        }
    

10.3、两种方法比较

  • 解决方案优点缺点
    互斥锁没有额外的内存消耗
    保证一致性
    实现简单
    线程需要等待,性能受影响
    可能有死锁风险
    逻辑过期线程无需等待,性能较好不保证一致性
    有额外的内存消耗
    实现复制

11、秒杀

11.1、全局ID

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

11.1.1、特性
  • 唯一性
  • 高可用
  • 高性能
  • 递增性
  • 安全性
11.1.2、结构
  • ID的组成部分:
  • 符号位:1bit,永远为0
  • 时间戳:31bit,以秒为单位,可以使用69年
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
11.1.3、代码
  •     @Resource
        private StringRedisTemplate stringRedisTemplate;
        public static final long BEGIN_TIMESTAMP = 1706108836L;
        public static final int COUNT_BITS = 32;
    
        public long nextIn(String keyPrefix){
            //1、生成时间戳、
            LocalDateTime now = LocalDateTime.now();
            long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
            long time = nowSecond - BEGIN_TIMESTAMP;
            //2、生成序列号
            String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
            Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
            //3、拼接并返回哦
            //时间戳左移32位进行拼接
            return time << COUNT_BITS | count;
        }
    

11.2、实现优惠券秒杀的下单功能

  • 下单时需要判断两点:

    • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
    • 库存是否充足,不足则无法下单
  • image-20240126173142365

  • 代码

    •  @Resource
          private ISeckillVoucherService seckillVoucherService;
          @Resource
          private RedisIdWorker redisIdWorker;
      
          @Override
          @Transactional
          public Result seckillVoucher(Long voucherId) {
              //查询秒杀券信息
              SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
              //判断秒杀是否开始
              //未开始,返回错误信息
              if (seckillVoucher == null || seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
                  return Result.fail("你怎么知道活动的?,秒杀未开始");
              }
              //判断秒杀是否结束
              if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
                  return Result.fail("秒杀结束啦");
              }
              //开始,判断库存是否充足
              // 不充足返回错误信息
              if (seckillVoucher.getStock() < 1){
                  return Result.fail("秒杀券卖光了");
              }
              //充足,库存减一
              boolean flag = seckillVoucherService.update()
                      .setSql("stock = stock - 1")
                      .eq("voucher_id", voucherId)
                      .update();//更新库存
              if (!flag){
                  return Result.fail("库存不足");
              }
              //创建订单
              VoucherOrder voucherOrder = new VoucherOrder();
              //订单id
              long orderId = redisIdWorker.nextId("order");
              voucherOrder.setId(orderId);
              //用户id
              Long userId = UserHolder.getUser().getId();
              voucherOrder.setUserId(userId);
              //代金券id
              voucherOrder.setVoucherId(voucherId);
              //插入订单
              boolean save = save(voucherOrder);
              if (!save){
                  return Result.fail("失败");
              }
              //返回订单id
              return Result.ok(orderId);
      
          }
      

11.3、超卖问题

  • 当200个线程去执行,购买优惠券,会出现超卖的问题

    • image-20240126195855442
11.3.1、悲观锁
  • 认为线程的安全问题一定会发生,因此在操作数据库之前先获取到锁,确保线程的串行执行。
11.3.2、乐观锁
  • 认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。
    • 如果没有修改则认为是安全的,自己才更新数据。
    • 如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常
①、版本号法
  • image-20240126201110442
②、CAS法
  • 用库存代替版本

  • image-20240126201155692

  • 代码

  • 在操作数据库,减库存的时候加乐观锁

    • //充足,库存减一
              boolean flag = seckillVoucherService.update()
                      .setSql("stock = stock - 1")
                      .eq("voucher_id", voucherId).gt("stock",0) 
                      .update();
                      //更新库存--->update tb_seckill_voucher set stock = stock - 1 where voucher_id = ? and stock = ?
                      //这个方案出现了,少买的情况,200人就20件成功了
                      //改进方案--->~~ where voucher_id = ? and stock > 0,解决问题
      
11.3.3、总结

超卖这样的线程安全问题,解决方案有哪些?

  • 悲观锁:添加同步锁,让线程串行执行
    • 优点:简单粗暴
    • 缺点:性能一般
  • 乐观锁:不加锁,在更新时判断是否有其它线程在修改
    • 优点:性能好
    • 缺点:存在成功率低的问题

12、一人一单的问题

  • 同一个优惠券,一个用户只能下一单

  • image-20240126214233383

12.1、初步代码

  •         //一人一单
            Long userId = UserHolder.getUser().getId();
            //根据用户id和优惠券id查询用户
            int count =query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            if (count >0){
                return Result.fail("不要重复下单");
            }
    
  • 测试–>同一个用户同时下200单 ----> 通过测试,同一个用户下了10单

    • image-20240126215735606
    • image-20240126215815443
  • 为什么会出现这个问题呢?

    • 多线程并发的操作,会出现多个线程在进行查询订单的时候,有些线程在查询到的数据都为0,都判断为第一次下单,随后都进行创建订单的操作

12.2、优化代码

  • 将判断优惠券充足后的整个逻辑提取出来,等拿到锁后才能进行操作,并对其进行事务管理,这里对用户id进行加悲观锁

    •  @Resource
          private ISeckillVoucherService seckillVoucherService;
          @Resource
          private RedisIdWorker redisIdWorker;
          @Override
          public Result seckillVoucher(Long voucherId) {
              //查询秒杀券信息
              SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
              //判断秒杀是否开始
              //未开始,返回错误信息
              if (seckillVoucher == null || seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())){
                  return Result.fail("你怎么知道活动的?,秒杀未开始");
              }
              //判断秒杀是否结束
              if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())){
                  return Result.fail("秒杀结束啦");
              }
              //开始,判断库存是否充足
              // 不充足返回错误信息
              if (seckillVoucher.getStock() < 1){
                  return Result.fail("秒杀券卖光了");
              }
      
              //加锁
              Long userId = UserHolder.getUser().getId();
              //为什么要用这个--intern()--呢,因为在userId转String的过程中,是采用new String的方式来转换的
              //会使每个userId的锁都是新的,我们要去String常量值里面找用户id,这样才能保证是同一把锁
              synchronized (userId.toString().intern()) {
                  //这里的createVoucherOrder这个方法的对象是VoucherOrderServiceImpl,并不是代理对象
      //            return createVoucherOrder(voucherId);
                  //要先获取代理对象在进行操作,这样就会给spring管理,可以进行事务管理,这样可以保证事务不会失效
                  IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
                  return proxy.createVoucherOrder(voucherId);
              }
          }
      
          @Transactional
          public Result createVoucherOrder(Long voucherId) {
              //一人一单
              Long userId = UserHolder.getUser().getId();
              //根据用户id和优惠券id查询用户
              int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
              if (count > 0) {
                  return Result.fail("不要重复下单");
              }
              //充足,库存减一
              boolean flag = seckillVoucherService.update()
                      .setSql("stock = stock - 1")
                      .eq("voucher_id", voucherId).gt("stock", 0)
                      .update();
              //更新库存--->update tb_seckill_voucher set stock = stock - 1 where voucher_id = ? and stock = ?
              //这个方案出现了,少买的情况,200人就20件成功了
              //改进方案--->~~ where voucher_id = ? and stock > 0,解决问题
              if (!flag) {
                  return Result.fail("秒杀券卖光了");
              }
              //创建订单
              VoucherOrder voucherOrder = new VoucherOrder();
              //订单id
              long orderId = redisIdWorker.nextId("order");
              voucherOrder.setId(orderId);
              //用户id
      
              voucherOrder.setUserId(userId);
              //代金券id
              voucherOrder.setVoucherId(voucherId);
              //插入订单
              boolean save = save(voucherOrder);
              if (!save) {
                  return Result.fail("秒杀券卖光了");
              }
              //返回订单id
              return Result.ok(orderId);
          }
      
    • 启动类上加注解

      • //暴露代理对象,可以获取到代理对象
        @EnableAspectJAutoProxy(exposeProxy = true)
        
    • 导入一个依赖

      •         <dependency>
                    <groupId>org.aspectj</groupId>
                    <artifactId>aspectjweaver</artifactId>
                </dependency>
        
  • 测试一下

    • image-20240126224357586

    • 0.5%*200 = 1

    • image-20240126224211695

    • image-20240126224251407

    • image-20240126224338101

    • 测试通过

12.3、集群状态下,锁的问题

  • 在单机项目下,我们做到了一人只能下一单

  • 但是呢,在集群能不能实现呢,现在我来测试一下

    • 先开两个服务,并debug形式执行
      • image-20240127103607733
  • 用postman提交两个请求,请求的header为同一用户的Authorization

    • image-20240127104629893

    • image-20240127104611652

    • 出现了两个服务拿到同一个用户id,都进入了锁的情况

    • image-20240127105209748

    • 又出现了并发安全问题,锁没锁住

    • image-20240127105359170

    • 这时候就要用分布式锁了

13、分布式锁

满足分布式系统或集群模式下多进程可见并且互斥的锁。

  • image-20240127141445957

13.1、MySQL,Redis,zookeeper实现

MySQLRediszookeeper
互斥利用MySQL本身的互斥锁机制利用setnx这样的互斥命令利用节点的唯一性和有序性实现互斥
高可用
高性能一般一般
安全性断开链接,自动释放锁利用锁超时时间,到期释放临时节点,断开连接自动释放

13.2、Redis分布式锁基础版

  • 需要实现的两个基本方法

  • 获取锁

    • 互斥,确保只有一个线程获取锁
    • image-20240127150639485
    • 但是呢这样不能保证,加锁和加超时时间是原子性的
    • 可以使用合并的命令,保证其原子性
      • image-20240127150802867
  • 释放锁

    • 手动释放和超时释放
  • 定义一个锁的接口,定义获取锁和释放锁

    • public interface ILock {
      
          /**
           *
           * @param timeoutSec 锁的超时时间,过期自动释放锁
           * @return 尝试获取锁,true成功,false失败
           */
          boolean tryLock(Long timeoutSec);
      
          /**
           * 释放锁
           */
          void unLock();
      
      }
      
      
  • 实现锁的接口

    • public class SimpleRedisLock implements ILock{
      
          @Resource
          private StringRedisTemplate stringRedisTemplate;
          private String name;
      
          public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
              this.stringRedisTemplate = stringRedisTemplate;
              this.name = name;
          }
          //锁前缀
          private static final String KEY_PREFIX = "lock:";
          @Override
          public boolean tryLock(Long timeoutSec) {
              //获取线程id
              long threadId = Thread.currentThread().getId();
              //获取锁
              Boolean setLock = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId+"", timeoutSec, TimeUnit.SECONDS);
              //避免拆箱时遇到null保空指针异常
              return Boolean.TRUE.equals(setLock);
          }
      
          @Override
          public void unLock() {
              //释放锁
              stringRedisTemplate.delete(KEY_PREFIX + name);
          }
      }
      
  • 业务层代码更改

    • //加锁
              Long userId = UserHolder.getUser().getId();
              //创建锁对象
              SimpleRedisLock simpleRedisLock = new SimpleRedisLock(stringRedisTemplate,"order" + userId);
              //设置超时时间
              boolean tryLock = simpleRedisLock.tryLock(1200L);
              if (!tryLock){
                  return Result.fail("不要重复下单,谢谢!");
              }
              try {
                  //这里的createVoucherOrder这个方法的对象是VoucherOrderServiceImpl,并不是代理对象
                  //return createVoucherOrder(voucherId);
                  //要先获取代理对象在进行操作,否则事务会失效
                  IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
                  return proxy.createVoucherOrder(voucherId);
              }finally {
                  simpleRedisLock.unLock();
              }
      
  • 有一种情况,会导致误删锁,还需要改进

    • 线程 1 堵塞了,锁超时释放了

    • 线程 2 理所当然的获取到了锁,然而,在线程 2 业务还没执行完的时候,线程 1 醒了,并把线程 2 的锁释放了。

    • 此时线程 3 来获取锁,成功了,这时就有两个线程在执行业务了,导致一人下两单

    • image-20240127171307205

13.3、Redis分布式锁改进版

需求:修改之前的分布式锁实现,满足:

  • 在获取锁时存入线程标示(可以用UUID表示),为啥不用线程号呢,因为不同的jvm里面的线程号可能相等。
  • 在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致
    • 如果一致则释放锁
    • 如果不一致则不释放锁
13.3.1、初步改进
  •     @Resource
        private StringRedisTemplate stringRedisTemplate;
        private String name;
    
        public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
            this.stringRedisTemplate = stringRedisTemplate;
            this.name = name;
        }
        //锁前缀
        private static final String KEY_PREFIX = "lock:";
        //uuid标识
        public static final String ID_PREFIX = UUID.fastUUID().toString(true);
        @Override
        public boolean tryLock(Long timeoutSec) {
            //获取线程标识
            String threadId = ID_PREFIX + Thread.currentThread().getId();
            //获取锁
            Boolean setLock = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
            //避免拆箱时遇到null保空指针异常
            return Boolean.TRUE.equals(setLock);
        }
    
        @Override
        public void unLock() {
            //获取线程标识
            String threadId = ID_PREFIX + Thread.currentThread().getId();
            //获取锁中的标识
            String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
            if (threadId.equals(id)){
                //释放锁
                stringRedisTemplate.delete(KEY_PREFIX + name);
            }
        }
    
13.3.2、lua脚本介绍
  • Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html

  • set name jack ----> redis.call('set','name','jack')
    get name -----> redis.call('get','name')
    
  • redis中运行脚本的命令

    • image-20240127211810660
  • 执行—redis.call(‘set’,‘name’,‘jack’)

    • image-20240127212317106
  • 如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:

    • image-20240127213558373
    • image-20240127214402171
13.3.3、lua脚本优化
  • 先安装个lua插件—emmylua

  • 在resources包下,创建文件–unLock.lua,代码如下

    • --- 比较线程标识与锁的标识是否一致
      if (redis.call('get',KEYS[1]) == ARGV[1]) then
          ---释放锁
          return redis.call('del',KEYS[1])
      end
      return 0
      
  • 然后去修改Java代码

    •     //预先加载lua脚本,可以不用每次释放锁都加载
          public static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
          static {
              UNLOCK_SCRIPT = new DefaultRedisScript<>();
              UNLOCK_SCRIPT.setLocation(new ClassPathResource("unLock.lua"));
              UNLOCK_SCRIPT.setResultType(Long.class);
          }
      
      
      @Override
          public void unLock() {
              //调用lua脚本
              stringRedisTemplate.execute(
                      UNLOCK_SCRIPT,
                      Collections.singletonList(KEY_PREFIX + name),
                      ID_PREFIX + Thread.currentThread().getId());
          }
      
  • 23
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值