Redis

介绍

Redis是一种NOSQL型数据库,即一种非关系型数据库

我们常见的Mysql是一种SQL型数据库,是一种关系型数据库

SQL型数据库

结构化 Structured

  • 下面这张图,我们创建这张表之初就对表的字段做了指定与规定,比如下面这张表只有三个字段,每个字段我们都加上了不同的约束,并且规定了字段的类型和长度,就使得这张表具有很强的结构体系,后续插入修改的数据都必须遵守表的结构

image-20220307174510020

关联的(Relational)

  • 比如一个表中的某个字段被其他表中的字段所关联(外键) 这样表中的数据就会自动维护,当删除某个字段时,就会提示无法删除的情况,除此之外,关联的优点还有可以节省存储空间,不需要记录数据的全部信息,只需要记录一个数据的主键即可.

    image-20220307175758165

    SQL查询

    • 优点:语法固定

    • 缺点:需要去学习大量的语法

      image-20220307180551365

    事务

    • 满足ACID(原子性, 一致性, 隔离性, 持久性) 对关系性要求较高的业务采用SQL

    存储方式

    • 磁盘

    扩展性

    • 垂直(即一主多从 数据存储的大小没有改变 只是增强了数据的存储效率(读写分离))

    使用场景

    • 数据结构稳定
    • 相关业务对数据安全性,一致性较高.(ACID)

NOSQL

非结构化

image-20220307174959275

  • NoSQL有三种数据存储格式 分别为key-value型, Document型, 还有Graph型 没有SQL那种有很强的结构性 比如key-value型的存储格式中 不需要指定数据的类型, 只要是NoSQL支持的数据格式 都可以填入. 对于Document型中 数据的字段数量也可以不同可以任意增添数据字段 不需要上一条数据有四个字段 这次就可以只有三种

无关系的

image-20220307175732140

  • 一般通过json(Document)的形式存储,缺点就是这种的数据格式不能自己维护,需要程序员自己通过业务逻辑维护 ,并且可能会出现数据的冗余,比如同一个商品多个用户下单,多个用户的信息的存储中都有相同的订单数据,这需要程序员自己根据不同的业务逻辑来维护.

非SQL

  • 优点: 不需要去学习大量的语法

  • 缺点: 不同的NoSQL语句的格式用法可能不同

    image-20220307180601578

    事务

    • BASE(基本一致或者无事务 无法完全满足ACID) 业务对安全性要求较低的可以采用NoSQL

    存储方式

    • 内存

    扩展性

    • 水平(即多主多从 数据存储的大小增加 也增强了数据的存储效率(读写分离))

    使用场景

    • 数据结构不稳定
    • 对一致性,安全性要求不高
    • 对性能要求高

    认识Redis

    Redis诞生与2009年全程时Remote Dictionary Server(远程词典服务器),是一个基于内存的键值型NoSQL数据库.

    特征

  • 键值型(key-value)型, value支持多种不同的数据类型,功能丰富

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

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

  • 支持数据持久化(持久化到磁盘)

  • 支持主从集群,分片集群(将数据分部分存储到多个master中 增加数据存储的上限)

  • 支持多语言客户端


Redis常见命令

Redis数据结构介绍

Redis是一个key-value的数据库,key一般是String类型,不过value的类型多种多样

image-20220307191247016

基本类型: String, Hash, List, Set. SortedSet

特殊类型: GEO(主要用于存储地理位置信息), BitMap, HyperLog(用来做基数统计的算法), Stream(主要用于消息队列)

Redis通用命令

通用指令是部分数据类型的,都可以使用的指令,常见的有 用法详情可以官网查看:

  • keys:查看符合模板的所有key,不建议在生产设备上使用(因为Redis是单线程的 使用keys *命令查看会占用很大资源 导致线程堵塞)
  • DEL: 删除可以删除一个或多个key (del key1 key2)
  • EXISTS: 判断key是否存在
  • EXPIRE: 给一个key设置有效期,有效期到时该key会被自动删除
  • TTL: 查看一个key剩余有效期

String类型

String类型,也就是字符串类型,是Redis中最简单的存储类型.

其value是字符串,不过根据字符串的格式不同,又可以分为3类

  • String:普通字符串
  • int: 整数类型,可以做自增,自减操作
  • float: 浮点类型,可以做自增,自减操作

不管是哪种格式,底层都是字节数组形式存储,只不过是编码方式不同,字符串类型的最大的空间不能超过512m(可以存储图片 只需要将图片转为字节数组的格式存储 但这样会耗很大空间 一般不这样用)

img/image-20220308233257985.png

String类型的常见指令

image-20220308233444257

SETEX用法比较特殊: set name jack ex 20(存活时间 单位秒)

key的结构

Redis的key允许有多个单词形成层级结构,多个单词之间用**':'**隔开,格式如下:

image-20220308235659340

这个格式并非固定,也可以根据自己的需求来删除或添加词条

例如 一个项目下有user和product两种不同类型的数据,我们可以这样定义key:

  • user相关的key: project:user:1
  • product相关的key: project:producet:1

这样的格式实际上是以层级结构的形式存储的**(主要是为了区分不同项目中相同的数据 用户的id可以为1 商品的id也可以为1)**

image-20220309000908161

如果value是一个java对象,例如要给User对象,则可以将对象序列化为JSON字符串后存储

image-20220308235948333

set project:user:1 '{"id":1,"name":"ding"}'

image-20220309000302102


Hash类型

Hash类型,也叫散列,其value是一个无序字典,类似于java中的HashMap结构.

String结构是将对象序列化为JSON字符串后存储,当需要修改对象某个字段时很不方便:

image-20220309191057489

Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD: 相对于String类型较为灵活

image-20220309191148786

Hash类型的常见命令:

image-20220309191557981


List类型

Redis中的List类型于Java中的LinkedList类似,可以看作是一个双向链表结构.既可以支持正向检索也可以支持反向检索.

特征也与LinkedList类似

  • 有序
  • 元素可以重复
  • 插入和删除块
  • 查询速度一般

常用来存储一个有序数据,例如:朋友圈点赞列表,批量列表等.

List的常见命令

image-20220309194126739


Set类型

Redis的Set结构与Java的中HashSet类似,可以看做是一个value为null的HashMap.因为也是一个Hash表,因此具备与HashSet类似的特征

  • 无序
  • 元素不可重复
  • 查找块
  • 支持交集 并集 差集等功能

Set类型的常见命令

image-20220309195325392


SortedSet类型(有序集合)

Redis的SortedSet是一个可排序的set集合,与Java中的TreeSet有些类似,但底层数据结构却差别很大.SortedSet中的每一个元素都带有一个score属性,可以基于score属性对元素排序,底层的实现是一个跳表(SkipList)加hash表.SortedSet具备下列特性:

  • 可排序
  • 元素不重复
  • 查询速度快

因为SortedSet的可排序特性,经常被用来实现排行榜这样的功能

SortedSet类型的常见命令

image-20220309201610780

Redis的Java客户端

image-20220309203536114

Jedis

  • 创建Maven工程

  • 引入Jedis依赖

    <!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
    <dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>4.1.1</version>
    </dependency>
  • 建立连接

    private Jedis jedis;

    @BeforeEach
    void setUp(){
    //建立连接
    jedis = new Jedis("81.68.186.20",6379);
    //设置密码
    jedis.auth("ffdd2021@");
    //选择库
    jedis.select(0);
    }
    • 存取数据

      @Test
      void testString(){
      //存入数据
      String name = jedis.set("name", "12");
      System.out.println(name);
      //获取数据
      String name1 = jedis.get("name");
      System.out.println(name1);
      }
      @Test
      void testHash(){
      //插入hash数据
      long name = jedis.hset("user:1", "name", "123");
      System.out.println(name);
      //取出
      String name1 = jedis.hget("user:1", "name");
      System.out.println(name1);
      }
    • 关闭连接

      @AfterEach
      void tearDown(){
      //关闭连接
      if(jedis!=null){
      jedis.close();
      }
      }

      Jedis中数据的存储命令是与命令行一致的


Jedis连接池

Jedis本身是线程不安全的,并且频繁的创建和销毁连接会有性能损耗,因此我们推荐大家使用Jedis连接池代替Jedis直连的方式.

类似SQL型的数据库连接池 不会直接关闭连接而是归还到连接池

public class JedisConnectionFactory {
private static JedisPool jedisPool = null;

static{
//配置连接池
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
//连接总数
jedisPoolConfig.setMaxTotal(10);
//最大连接数量
jedisPoolConfig.setMaxIdle(10);
//最小连接数量
jedisPoolConfig.setMinIdle(3);
//最大等待时间
jedisPoolConfig.setMaxWait(Duration.ofMillis(10000));
//创建连接池对象
jedisPool = new JedisPool(jedisPoolConfig,"81.68.186.20",6379,1000,"ffdd2021@");

}
//创建一个获取连接的方法
public static Jedis getJedis(){
return jedisPool.getResource();
}
}

SpringDataRedis

SpringData是Spring中数据操作的模板,包含对各种数据库的集成,其中对Redis的集成模板就叫做SpringDataRedis

  • 提供了对不同Redis客户端的整合(Lettuce和Jedis)
  • 提供了RedisTemplate统一API来操作Redis
  • 支持Redis的发布订阅模型
  • 支持Redis哨兵和Redis集群
  • 支持基于Lettuce的响应式编程
  • 支持JDK,JSON,字符串,Spring对象的数据序列化及反序列化(方便地将上述数据格式 转化为字节数组 更方便地去存储在Redis中)
  • 支持基于Redis的JDKCollection实现

SpringDataRedis中提供了RedisTemplate工具类,其中封装了各种对Redis的操作.并且将不同数据类型的操作API封装到了不同的类型中:

RedisTemplate工具类

image-20220309234553190

RedisTemplate使用

  • 导入依赖

    • SpringDataRedis依赖

      <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>
    • 连接池依赖(Jedis和Lettuce都是依赖于commons-pool2实现)

      <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-pool2</artifactId>
      <version>2.4.3</version>
      </dependency>
  • 配置文件

    spring:
    redis:
    port: 6379
    host: 81.68.186.20
    password: ffdd2021@
    # 选择数据库 database: 1
    # pool SpringDataRedis默认使用的客户端是Lettuce 可以导入其他依赖来使用其他连接池
    # 必须要配置了连接池 连接池才能生效
    lettuce:
    pool:
    enabled: true
    max-active: 8
    max-idle: 8
    min-idle: 0
    max-wait: 100ms
  • 注入RedisTemplate工具类

    @Autowired
    private RedisTemplate<String,String> redisTemplate;
  • 实例

    @SpringBootTest
    class Redis01RedisTemplateApplicationTests {

    @Autowired
    private RedisTemplate<String,String> redisTemplate;
    @Test
    void contextLoads() {
    redisTemplate.opsForValue().set("name","丁杨维");

    String name = redisTemplate.opsForValue().get("name");
    System.out.println(name);
    }

    }

    image-20220310000959983

SpringDataRedis的序列化方式

RedisTemplate可以接收任意Object作为值写入Redis,只不过吸入人前会把Object序列化为字节形式,默认是采用的JDK序列化,得到的结果是这样的:(及使用RedisTemplate时没有指定泛型)

image-20220310185259667

缺点:

  • 可读性差
  • 内存占用较大

解决方法

  • 如果key-value都是String类型 直接指定泛型为String即可 但如果value或者key为Object时就需要自己配置了.

    image-20220310190944166

    Redis提供了设置key-value序列化方式的接口

  • 首先导入Jackson的依赖

    <dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>最新版</version>
    </dependency>
  • 配置类 RedisConfig

    @Configuration
    public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
    //创建RedisTemplate对象
    RedisTemplate<String,Object> template = new RedisTemplate<>();
    //设置连接工厂
    template.setConnectionFactory(redisConnectionFactory);
    //创建JSON序列化工具 注意这里使用的时Jackson需要导入Jackson的相关依赖才行
    GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
    //设置key的序列化
    template.setKeySerializer(genericJackson2JsonRedisSerializer);
    template.setHashKeySerializer(genericJackson2JsonRedisSerializer);
    //设置value的序列化
    template.setValueSerializer(genericJackson2JsonRedisSerializer);
    template.setHashValueSerializer(genericJackson2JsonRedisSerializer);
    //返回
    return template;

    }
    }
  • 实体类 User

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class User {
    private String name;

    private Integer age;
    }
  • 测试类

    @SpringBootTest
    class Redis01RedisTemplateApplicationTests {

    @Autowired
    private RedisTemplate<String,Object> redisTemplate;
    @Test
    void contextLoads() {
    User user = new User("丁杨维", 19);
    redisTemplate.opsForValue().set("user1",user);
    User user1 = (User) redisTemplate.opsForValue().get("user1");
    System.out.println("user1="+user1);
    }

    }
  • 结果

    image-20220310191828636

    image-20220310191838617

    通过配置使用了Jackson序列化对象对JSON字符串格式存储,在取出时也可以将JSON字符串反系列化为对象.


    JSON序列化的问题

    如上图 我们会发现使用JSON序列化存储value时,会默认带上全类名名称 这样会造成额外的内存开销. (为了在反序列化时知道对象的类型,JSON序列化器会将类的class类型写入json结果中,存入Redis. )而如果想使用JSON序列化器序列化对象,这一步又是必不可少的.

    解决方案:

    • 为了节省内存空间, 我们并不会使用JSON序列化器来处理value, 而是统一使用String序列化器, 要求只能存储String类型的Key和value. 当需要存储Java对象时, 手动完成对象的序列化和反序列化.
    • Spring默认提供了一个StringRedisTemplate类,他的key和value的序列化方式默认就是String方式.省去了我们自定义RedisTemplate的过程.

    StringRedisTemplate类

    • Jackson的ObjectMapper提供了将Java对象序列化为Json字符串和反序列化为Java对象的API

      @SpringBootTest
      class Redis01RedisTemplateApplicationTests {

      @Autowired
      private StringRedisTemplate stringRedisTemplate;

      private static final ObjectMapper mapper = new ObjectMapper();

      @Test
      public void doTest() throws JsonProcessingException {
      User user = new User("彭芳姐", 10);

      //ObjectMapper提供了将对象转化为Json串的API
      //手动序列化
      String userString = mapper.writeValueAsString(user);

      //写入数据
      stringRedisTemplate.opsForValue().set("user2",userString);
      //获取数据
      String userJson = stringRedisTemplate.opsForValue().get("user2");

      //手动反序列化
      User user2 = mapper.readValue(userJson, User.class);

      System.out.println(user2);
      }
      }
    • 结果

      image-20220310194423995

      image-20220310194441788

      RedisTemplate对Hash的操作

      @Test
      public void doTest2(){
      stringRedisTemplate.opsForHash().put("key","hashKey","value");
      stringRedisTemplate.opsForHash().put("key","hashKey1","value1");

      Map<Object, Object> key = stringRedisTemplate.opsForHash().entries("key");

      System.out.println(key);
      }

      操作习惯上更偏向于java的HashMap之类的.

      stringRedisTemplate.opsForHash().entries(String key)会获得hash类型中的某个key的全部key-value信息.

      剩余操作或其他类型操作可以参考官方文档.RedisAPI文档


      项目实战

      image-20220310200358089


      Session共享问题:

      多台TomCat并不共享session存储空间,当请求切换到不同的tomcat服务时,导致数据丢失的问题

      image-20220310213458462

      session的代替方案应该满足:

      • 数据共享
      • 内存存储
      • key-value结构

      以上的要求Redis都满足, 可以使用Redis来代替Session存储,但也不是简单的存储.

      基于Redis实现共享Session登陆

      Redis要实现共享Session登陆, 需要满足key唯一性.

      image-20220310214224767

      • 如果使用用户登陆存储一定的用户信息在Redis中时,一般使用token作为key,而value使用Hash类型.
      • 学习Hash类型的时候我们学过 Hash类型的存储可以将对象的字段分别存储,可以针对单个字段进行crud,不需要每次改的是否传全部信息进行更改.

      image-20220309191148786

      使用token存储用户的非敏感信息,传到前端,前端利用浏览器的缓存存储

      注意:

      • 使用token存储到Redis时一定要设置过期时间,因为会耗费内存空间(如果存储的token太多 有没有及时清楚 可能会造成堆栈溢出)
      • 使用 RedisTemplate.expire可以指定指定key-value的存活时间
      • 在登陆拦截器中,验证了token后需要刷新token存活时间,及再执行一遍第二步.
      • 常量等可以专门编写一个类来记录 static final constant防止自己写出错.
      • 对于一些不需要登陆拦截的Controller当用户访问时,也是需要刷新token的,设置一个全局刷新的token的拦截器,将其优先级设置为最高,所有请求都由他拦截,后续的登录拦截只需要从,全局拦截器中的存储到ThreadLocal的用户信息取到验证即可.

全局请求拦截器

作用:

  • 用于拦截所有请求,获取如果有token就获取token中的数据,并且刷新token,并且将数据存入ThreadLocal中,如果没有就直接放行.(后续可能会被登陆拦截器拦截 检查是否有用户信息, 如果没有就返回false)
  • 全局请求拦截器的主要作用就是为了防止用户登陆后,访问一些不需要登陆拦截器的接口token无法刷新的情况.

缓存

缓存就是数据交换的缓存区(称作Cache), 是存储数据的临时地方,一般读写性能较高

image-20220311151533133

缓存的作用

  • 降低后端负载
  • 提高读写效率,降低响应时间.

缓存的成本

  • 数据一致性成本
  • 代码维护成本
  • 运维成本

添加Redis缓存

image-20220311152211102

缓存更新策略

image-20220311153346059

对于经常修改的数据,可以采用主动更新策略,在修改数据库的同时,更新缓存,这样的数据一致性较好,相应的维护成本较高. 对于不经常修改的数据,采用默认策略即可.

主动更新策略

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

操作缓存和数据库时的三个问题考虑(For Cache Aside Pattern)

  1. 删除缓存还是更新缓存 ?

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

    • 单体系统, 将缓存与数据库操作防止一个事务
    • 分布式系统,利用TTC等分布式事务方案
  3. 先操作缓存还是先操作数据库 ?

    • 对于先操作缓存,可能会出现在删除缓存后将要操作数据库时, 另一个线程在查询缓存, 此时缓存中已无数据, 缓存未命中查询数据库写入缓存的情况,然后才去更新我们的数据库 造成读到脏数据的线程安全问题.
    • 对于先操作数据库,也可能出现(刚好一个缓存过期)一个线程想要来查询缓存,缓存未命中,查询数据库的情况,此时数据库还没有更新,在要进行吸入缓存之前数据库才更新,这就造成读取到脏数据的情况.
    • 但是对于先操作数据库的情况,实际上写入缓存的时间是非常快的,是远远快于操作数据库的情况,所以基本上不会出现在写入缓存之前,还能操作数据库的情况,所以一般采用先操作数据库的情况. 相比于第一种安全性更高.

    image-20220311160417480

缓存更新的最佳实践方案:

  1. 低一致性需求: 使用Redis自带的淘汰机制
  2. 高一致性需求: 主动更新,并以超时作为兜底
    • 读操作:
      • 缓存命中则直接返回
      • 缓存 未命中则查询数据库,并写入缓存,设定超时时间
    • 写操作:
      • 先写入数据库,再删除缓存
      • 要确保数据的于缓存操作的原子性

缓存未命中

当CPU在缓存中找到有用的数据时,称为命中。当缓存中没有CPU需要的数据时(这称为未命中)


缓存穿透

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

一些不坏好意的人可能会利用这里点来多线程发送多次请求导致数据库崩坏. 不存在的数据在数据库中查询对于数据库来说是会遍历全部数据然后返回NULL这对数据库的压力很大.

image-20220311161920235

常见的解决方案:

  • 缓存空对象*

    • 优点: 实现简单,维护方便
    • 缺点:
      • 额外的内存消耗(一般设置过期时间,且过期时间较短)
      • 可能造成短期的不一致(此时新增一条该key对应的数据,但是在TTL结束前,查询到的都是NULL 造成短期不一致)
  • 布隆过滤

    image-20220311162648729

    • 优点: 内存占用较少, 没有多余key
    • 缺点:
      • 实现复杂
      • 存在误判

除此之外缓存穿透的解决方案还有:

  • 增强id的复杂度,避免被猜测id规律
  • 做好数据的基础格式校验
  • 加强用户权限校验
  • 做好热点参数的限流(也可以减小数据库压力)

缓存雪崩

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

image-20220311163954070

解决方案:

  • 给不同的key的TTL添加随机值(防止大量的缓存Key同时失效)
  • 利用Redis集群提高服务的可用性(防止单机宕机的情况)
  • 给缓存业务添加降级限流策略(待学)
  • 给业务添加多级缓存

缓存击穿

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

image-20220311165255394

常见解决方案:

  • 互斥锁

    • image-20220311165723072
    • 加锁,性能低.
  • 逻辑过期

    • 设置逻辑过期时间 而非TTL
    • 获取互斥锁 开启新线程,去查询数据库重建缓存数据.

    image-20220311170401649

两种方案的比较:

image-20220311170545184

互斥锁

互斥锁实现原理是通过Redis中的String类型的SETNX(插入一个不存在的数据)方法.这样别人就无法修改了.

image-20220311172926169

RedisTemplate中的api是 opsForValue.setIfAbsent()

逻辑过期

使用逻辑过期 需要给数据设置一个逻辑过期时间, 一般会创建一个RedisData类 将逻辑过期时间和业务数据封装在一起,避免了在原有的业务基础上修改代码(指在实体类上新增逻辑过期字段).

@Data
public class RedisData {
private LocalDateTime expireTime;
//业务数据
private Object data;
}

LocalDateTime.After(LocalDateTime time),如果时间过期那么返回

封装Redis工具

image-20220311180308032

全局ID生成器

当用户抢购时,就会生成订单数据保存到订单表中,而订单表如果使用数据库自增ID就存在一些问题:

  • id的规律性太明显
  • 受单表数据量的限制

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

  • 唯一性
  • 高可用
  • 递增性
  • 安全性
  • 高性能

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其他的信息.

image-20220311210845236ID的组成部分:

  • 符号位: 1bit, 永远为0
  • 时间戳: 31bit,以秒为单位
  • 序列号: 32bit,秒内的计数器, 支持每秒产生2^32个不同的ID

全局唯一ID生成策略:

  • UUID
  • Redis自增*
  • snowflake算法(雪花算法)*
  • 数据库自增

Redis自增策略:

  • 每天一个key, 方便统计订单量
  • ID构造是 时间戳 + 计数器

优惠券秒杀下单功能

image-20220312123558073

超卖问题(线程安全问题)

image-20220312131903889

解决方案: 加锁

  • 悲观锁
  • 乐观锁

悲观锁

认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行.

  • 例如Synchronized, Lock都属于悲观锁
  • 优点: 简单粗暴
  • 缺点: 性能一般

乐观锁

认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其他线程对数据进行了修改

  • 如果没有修改则认为是安全的, 自己才更新数据
  • 如果已经被其他线程修改说明发生了线程安全问题,此时可以重试或异常.
  • 优点: 性能好
  • 缺点存在成功率低的问题

乐观锁的关键是判断之前查询到的数据是否被修改过,常见的方式有两种:

  • 版本号法

    设置一个用于悲观锁修改是判断的字段(版本号),每次修改后都会被修改

    image-20220312132808463

  • CAS法

    利用本身的数据作为判断依据(不新增任何字段 用自身数据代替版本)

    image-20220312133142933

一人一单

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

image-20220312135825445

集群下的一人一单问题

JVM中有一个锁监视器 单机情况下可以实现锁的监视,但是在集群模式下 多个的JVM有不同的锁监视器, 即集群模式下的悲观锁失效了(它只能保证单个JVM下的线程安全问题)

image-20220312142218951

分布式锁

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

image-20220312142754987

  • 多线程可见*
  • 互斥*
  • 高可用*
  • 高性能(指获得锁的性能)*
  • 安全性*

image-20220312143720266

实现分布式锁(此处的是实现都是基于一人一单的情况下)

实现分布式锁需要实现两个基本方法

  • 获取锁
  • 释放锁

基于Redis的分布式锁

利用SETNX的互斥机制 和DEL的删除机制

为了防止Redis异常宕机的情况下锁的安全性问题(需要设置锁的过期时间)

  • 获取锁:

    • 互斥: 确保只能有一个线程获取锁

      # 添加锁, NX是互斥 EX是设置超时时间
      set lock thread1 NX EX 10
  • 释放锁:

    • 手动释放

    • 超时释放: 获取锁时添加一个超时时间

      # 释放锁, 删除即可
      DEL key

image-20220312145523204

@AllArgsConstructor
@NoArgsConstructor
@Data
public class SimpleRedisLock implements ILock{
private StringRedisTemplate stringRedisTemplate;
/**
* 锁的前缀
*/
private static final String KEY_PREFIX = "lock:";
private String name;

@Override
public boolean tryLock(long timeoutSec) {
//获取线程表示
long threadId = Thread.currentThread().getId();
//获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}

@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}

简单的分布式锁的线程安全问题

业务堵塞导致锁的时间到期释放, 第二个线程此时获取到锁,在执行业务中,一号线程业务堵塞完毕此时释放锁 就导致线程二的锁被释放了.

image-20220312152342991

改进后

通过新增UUID+ThreadID作为锁标识 在释放锁之前判断锁标识是否一致(同一个线程的锁标识一致,这样就不会出现业务堵塞导致释放了其他线程的锁的情况)

image-20220312152522311

image-20220312152710464

改进Redis的分布式锁

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

  1. 在获取锁时存入线程表示(可用UUID表示)

  2. 在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致

    • 如果不一致则不释放锁

    • 如果一致则释放锁

    @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);
    }
    //否则就不释放
    }

改良后的Redis分布式锁 依旧存在如下问题就是在 删除锁的时候发生了堵塞 导致超时释放锁 此时二号线程进入获得锁执行业务,但是刚好一号线程堵塞完毕就将二号线程的锁释放了.

image-20220312173903547

Redis的Lua脚本

释放锁业务的Lua脚本

-- 比较线程标示与锁中的标示是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0

Java中调用Lua脚本

RedisTemplate可以直接调用Lua脚本

使用静态代码块初始化lua脚本

private static DefaultRedisScript<Long> UNLOCK_SCRIPT = null;
static{
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}

将原来java中的两行代码,变为了一行代码, 就不会出现上述的情况

改良后的unlock方法

@Override
public void unlock() {
//调用lua脚本
stringRedisTemplate.execute(UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX+name),
ID_PREFIX+Thread.currentThread().getId());

}

基于Redis的分布式锁优化

基于SETNX实现的分布式锁存在下面的问题:

image-20220312184555611

Redission官方文档

Redission是一个在Redis的基础上实现的java驻内存数据网络(In-Memory Data Grid). 它不仅提供了一系列的分布式java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现

image-20220312200824948