Redis

1 篇文章 0 订阅

Redis

一、NoSQL概述

1.NoSQL数据库概述

​ Redis数据库是NoSQL的一种,解决性能问题的。NoSQL(Not Only SQL),意即”不仅仅是SQL“,泛指非关系型数据库。NoSQL不依赖业务逻辑方式存储,而以简单的key-value模式存储,因此大大增加了数据库的扩展能力,以下为NoSQL的特点:

  • 不遵循SQL标准
  • 不支持ACID
  • 远超SQL的性能
2.NoSQL使用场景

  • 对数据高并发的读写
  • 海量数据的读写
  • 对数据高可扩展性的
3.NoSQL不适用场景

  • 需要事务支持,即不支持事务
  • 基于sql的结构化查询存储,处理复杂的关系,需要即查询,即不支持复杂查询

二、Redis数据库

1.Redis安装

2.Redis键(Key)

  • keys *:查看当前库中所有的key
    在这里插入图片描述

  • exists key:判断某个key是否存在
    在这里插入图片描述

  • set key :添加key
    在这里插入图片描述

  • type key:查看key的类型
    在这里插入图片描述

  • del key:删除指定的key
    在这里插入图片描述

  • unlink key:根据value选择非阻塞删除,仅将key从keyspace元数据中删除,真正的删除会在后续异步操作

  • expire key 10:为给定的key设置过期时间,10是时间单位是秒
    在这里插入图片描述

  • ttl key:查看这个key的过期时间,-1代表永不过期,-2代表已过期,大于0则意味着还剩这么长时间
    在这里插入图片描述

  • select:切换数据库,select+数字,代表切换到哪个数据库,默认16个库(0-15)
    在这里插入图片描述

  • dbsize:查询这个数据库中有多少key
    在这里插入图片描述

  • flushdb:清理当前库所有的数据

  • flushall:通杀所有库的所有数据

3.Redis常用数据类型

3.1 字符串(String)

​ String是Redis中最基本的类型,一个key对应一个value,一个Redis中字符串value最多可以是512M

​ String是二进制安全的,意味着Redis中的String可以包含任何数据,比如jpg图片或者序列化对象。

3.1.1 指令操作

set KEY VALUE:添加String类型的key和value

​ 【注意】:当set已有的key时,后面的value值会覆盖掉之前的。
在这里插入图片描述

get KEY:查询对应的键值
在这里插入图片描述

append KEY VALUE:给已有key的value值后追加,返回值为追加后value的长度
在这里插入图片描述

strlen KEY:查询Value的长度
在这里插入图片描述

setnx KEY VALUE:只有当key不存在的时候才可以添加成功,如果key存在就不会成功
在这里插入图片描述

incr KEY:将key中存储的数字值增1,只能对数字操作,如果为空,新增值为1
在这里插入图片描述

decr KEY:将key中存储的数字值减1,只能对数字操作,如果为空,新增值为-1
在这里插入图片描述

incrby/decrby KEY 增长/减少值:对某个key设置一次增长和减少的值
在这里插入图片描述

getrange KEY x y:获取key中value下标(x-y)的字符
在这里插入图片描述

setrange KEY x VALUE:设置key中的value,在下标x之后覆盖原本的value
在这里插入图片描述

setex KEY SECOND VALUE:在设置key-value的时候就可以设置过期时间
在这里插入图片描述

getset KEY VALUE:设置新值并且获取原先的值
在这里插入图片描述

3.1.2 原子性

​ 原子操作是指不会被线程调度机制打断的操作,这种操作一旦开始就一直运行到结束,中间不会有任何context switch(切换到另外一个线程)

  • Redis是单线程的,在单线程中,能够在单条指令中完成的操作都可以认为是”原子操作“,因为中断只能发生于指令之间。
  • 在多线程中,不能被其他线程打断的操作就叫原子操作

mset KEY1 VALUE1 KEY2 VALU2:同时设置一个或多个key-value对

mget KEY1 KEY2 KEY3:通过获取多个key的value
在这里插入图片描述

msetnx KEY1 VALUE1 KEY2 VALUE2:同时设置一个或多个key-calue对,如果有key存在则插入失败,由于Redis的原子性,一个插入失败其他都会失败
在这里插入图片描述

3.2 List集合

​ 单键多值,Redis列表是简单的字符串列表,按照插入顺序排列,可以添加一个元素到列表的头部或尾部。

​ 它的底层实际是个双向链表,对两端的操作性能很高,通过索引下标的操作中间的节点性能会较差。

3.2.1 指令操作

lpush/rpush KEY VALUE1 VALUE2 VALUE3:从左边/右边插入一个或多个值

【从左边放】:由于底层是一个双向链表,使用的是头插法,如下:
在这里插入图片描述

lrange KEY INDEX1 INDEX2:从左边开始遍历这个key的value,从下标为index1遍历到index2的位置

【注意】:INDEX2为-1时,即遍历完

【遍历顺序】:上图可以看到,底层插入使用的是头插法,所以最左边插入的是最后一个元素,指令运行如下:
在这里插入图片描述

lpop/rpop KEY COUNT:从左边/右边取出这个key中的value,数量为count
在这里插入图片描述

rpushlpush KEY1 KEY2:从key1的右边/左边取一个元素添加在key2的左边
在这里插入图片描述

lindex KEY INDEX:从左到右按照下标索引取值
在这里插入图片描述

llen KEY :查询key中value的个数
在这里插入图片描述

3.2.2 数据结构

​ List的数据结构为快速链表quickList。

​ 首先在列表元素较少的情况下会使用一块连续的内存空间,这个结构是ziplist,即压缩列表,它将所有的元素挨着一起存储,分配的是一块连续的内存。

​ 当数据量较大的时候才会改成quickLsit,因为普通链表需要的附加指针空间太大,比较浪费空间,所以数据量大的时候将zipList连接在一起

​ 也可以简单的说,List的底层数据结构就是【数组】+【链表】组成的一个链式数据结构。
在这里插入图片描述

3.3 Set集合

​ Set和List对外提供的功能比较类似,Set可以自动排重,当需要存储一个列表数据,又不希望出现重复的数据,那么就可以选用Set集合

​ Set是String类型的无序集合,它底层其实是一个value为null的hash表,所以增加

删除、查找的复杂度都是O(1)

3.3.1 指令操作

sadd KEY VALUE1 VALUE2 VALUE3:给key中添加一个或多个value
在这里插入图片描述

smembers KEY:取出key中所有的value
在这里插入图片描述

sismember KEY VALUE:判断key中是否存在value
在这里插入图片描述

scard KEY:返回key中value的个数
在这里插入图片描述

srem KEY VALUE1 VALUE2:删除key中的"value"值,可以为多个
在这里插入图片描述

3.3.2 数据结构

​ Set数据结构是dict字典,字典是用哈希表实现的。

3.4 哈希(Hash)

​ Redis hash是一个键值对集合,是一个string类型的field和value的映射表,hash特别适合存对象,类型Java中的Map<String,Object>,
在这里插入图片描述

3.4.1 指令操作

hset KEY FIELD VALUE:向key中添加value,value中的数据格式以键值对形式存放
在这里插入图片描述

hget KEY FIELD :从key中取出field对应的value
在这里插入图片描述

hmset KEY FIELD1 VALUE1 FIELD2 VALUE2:批量加入数据
在这里插入图片描述

hexists KEY FIELD:判断key中的field是否存在
在这里插入图片描述

hkeys KEY:列出key中所有的field
在这里插入图片描述

hvals KEY:列出key中所有的value
在这里插入图片描述

3.4.1 数据结构

​ Hash类型对应的数据结构是两种:ziplist(压缩列表),hashtable(哈希表)。当field-value长度较短且个数较少时,使用ziplist,否则使用hashtable。

3.5 ZSet有序集合

​ Redis有序集合zset和普通集合set非常相似,是一个没有重复元素的字符串集合。

​ 不同之处是有序集合的每个成员都关联了一个评分,这个评分被用来按照从最低分到最高分的方式排序集合中的成员。集合的成员是唯一的,但是评分是可以重复的

​ 因为元素是有序的,所以可以很快的根据评分或者次序来获取一个范围的元素。

3.5.1 指令操作

zadd KEY1 SCORE1 VALUE1 SCORE2 VALUE2:向key中添加分数和其对应的值
在这里插入图片描述

zrange KEY INDEX1 INDEX2:取出key中下标index1到index2的值
在这里插入图片描述

zrange KEY INDEX1 INDEX2 withscores:取出key中下标index1到index2的值和其对应的分数
在这里插入图片描述

zrangebyscore KEY SCORE1 SCORE2:取出key中分数score1到score2的value
在这里插入图片描述

zrem KEY VALUE:删除key中的vlaue
在这里插入图片描述

zcount KEY SCORE1 SCORE2:统计key中分数在score1到score2之间的个数
在这里插入图片描述

3.5.2 数据结构

​ ZSet是Redis提供的一个非常特别的数据结构,一方面它等价于Java的数据结构Map<String,Double>,可以给每个元素value赋予一个权重score,另一方面它又类似于TreeSet,内部的元素会按照权重score来排序,可以得到每个元素的名次,还可以通过score的范围来获取元素的列表。

​ zset底层使用了两个数据结构:

  • hash,hash的作用就是关联元素value和权重score,保障元素value的唯一性,可以通过元素value找到对应的score
  • 跳跃表,跳跃表的目的在于给元素value排序,根据score的范围获取元素列表
3.6 跳跃表
3.6.1 简介

​ 有序集合在生活中比较常见,例如根据成绩对学生排名,根据得分对玩家排名。对于有序集合底层的实现,可以用数组、平衡树、链表等。数据不便元素的插入、删除;平衡树或红黑树虽然效率高但结构比较复杂;链表查询遍历效率较低。Redis采用的是跳跃表,跳跃表效率堪比红黑树,实现远比红黑树简单。

3.6.2 实例

​ 对比有序集合和跳跃表,从链表中查询出51。

有序链表
在这里插入图片描述

​ 要查询值为51的元素,需要从第一个元素开始依次查找、比较才能找到,共需要6次比较。

跳跃表
在这里插入图片描述

3.7 Bitmaps

​ Bitmaps本身不是数据类型,实际上它就是一个字符串,但是它可以对字符串的位进行操作。

​ Bitmaps单独提供了一套命令,所以在Redis中使用Bitmaps和使用字符串的方法不太相同,可以把Bitmaps想象成一个以位为单位的数组,数组的每个单元只能存储0和1,数组的下标在Bitmaps中叫做偏移量。
在这里插入图片描述

3.7.1 指令操作

setbit KEY OFFSET VALUE:设置Bitmaps中某个偏移量的值

3.8 HyperLogLog
4.Redis6配置文件详解

4.1 redis支持的类型

​ redis中只支持字节类型,不支持其他类型,下面是其配置文件中的单位转换:
在这里插入图片描述

4.2 网络配置相关
4.2.1 bind

​ 默认情况bind=127.0.0.1只接受本机的访问请求,不写的情况下,无限制接受任何ip地址的访问。
在这里插入图片描述

4.2.2 protected-mode

​ 默认情况下protected-mode为yes,是开启本机保护模式,这样外部就不能访问redis,现在设置为no。
在这里插入图片描述

4.2.3 端口号

​ redis默认的端口号为6379
在这里插入图片描述

4.2.4 tcp-backlog

​ 设置tcp的backlog,backlog其实是一个连接队列,backlog队列总和=未完成三次握手队列+已经完成三次握手队列。

​ 在高并发环境下需要一个高backlog值来避免慢客户端连接的问题。
在这里插入图片描述

4.2.4 timeout

​ 超时时间,默认为0,不设置超时时间,这个可以改,以秒为单位。
在这里插入图片描述

4.2.5 daemonize

​ 设置是否允许redis后台启动,默认为no,可以设置为yes。

5.发布和订阅

5.1 什么是发布和订阅?

​ Redis发布订阅(pub/sub)是一种消息通信模式:发送者(pub)发送消息,订阅者(sub)接收消息。

5.2 发布和订阅

客户端可以订阅频道
在这里插入图片描述

当这个频道发布消息之后,消息就会发送给订阅的客户端
在这里插入图片描述

5.3 发布订阅命令行实现
  • 打开一个客户端订阅 channel1

    SUBSCRIBE channel1
    在这里插入图片描述

  • 打开另外一个客户端,给channel1发布消息 hello

    publish channel1 hello
    在这里插入图片描述

  • 打开第一个客户端可以看到发送的消息
    在这里插入图片描述

三、Redis操作

1.Jedis操作Redis6

​ Jedis是使用Java语言来操作redis数据库的,和JDBC类似。

1.1 Jedis所需依赖
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.2.0</version>
</dependency>
1.2 测试是否连接成功
public static void main(String[] args) {
    Jedis jedis=new Jedis("127.0.0.1",6379);
    System.out.println(jedis.ping());
}
1.3 对key操作
//对key进行操作
@Test
public void test01(){
    Jedis jedis=new Jedis("127.0.0.1",6379);

    //插入key-value值
    jedis.set("id","1");
    jedis.set("name","Alice");
    jedis.set("age","18");

    //得到所有的key,即 keys *
    Set<String> keys = jedis.keys("*");
    //获取key的总数量
    System.out.println(keys.size());
    //遍历keys,得到所有的key
    for (String key : keys) {
        System.out.println(key);
    }

    //根据key获取value
    String id = jedis.get("id");
    String name = jedis.get("name");
    String age = jedis.get("age");
    System.out.println("id:"+id+",name:"+name+",age:"+age);

    //设置过期时间
    jedis.expire("name",60*60*24);
    //查看过期时间
    Long expireTime = jedis.ttl("name");
    System.out.println(expireTime);

}
1.4 对String操作
//对String进行操作,批量操作key
@Test
public void test02(){
    Jedis jedis=new Jedis("127.0.0.1",6379);

    //设置多个key-value
    jedis.mset("k1","v1","k2","v2","k3","v3");
    //取多个key的value
    List<String> values = jedis.mget("k1", "k2", "k3");
    //遍历输出values
    for (String value : values) {
        System.out.println(value);
    }
}
1.5 对List操作
//对List进行操作
@Test
public void test03(){
    Jedis jedis=new Jedis("127.0.0.1",6379);

    //添加值,从右边添加
    jedis.lpush("name","lucy","jack","mary","alice");
    //取值,从右边取,批量取,按照下标取
    List<String> names = jedis.lrange("name", 0, -1);
    //遍历names
    for (String name : names) {
        System.out.println(name);
    }
}
1.6 对set操作
//对set进行操作
@Test
public void test04(){
    Jedis jedis=new Jedis("127.0.0.1",6379);

    //添加
    jedis.sadd("name","jack","alice","mary");
    //取出key的value
    Set<String> names = jedis.smembers("name");
    //遍历names,输出为:jack,alice,mary
    for (String name : names) {
        System.out.println(name);
    }

    //删除name中的key
    jedis.srem("name","jack");
    //取出删除之后的name中的元素
    Set<String> names2 = jedis.smembers("name");
    //遍历names2,输出为:alice,mary
    for (String name2 : names2) {
        System.out.println(name2);
    }
}
1.7 对Hash操作
//对Hash进行操作
@Test
public void test05(){
    Jedis jedis=new Jedis("127.0.0.1",6379);

    //单个添加
    jedis.hset("user1","username","jack");
    //取值
    String username = jedis.hget("user1", "username");

    //多个添加
    Map map=new HashMap();
    map.put("id",1);
    map.put("name","mary");
    map.put("sex","nan");
    jedis.hmset("user2",map);
    //多个取出
    List<String> user2 = jedis.hmget("user2", "id", "name", "sex");
    for (String s : user2) {
        System.out.println(s);
    }
}
1.8 对zset操作
//对zset进行操作
@Test
public void test06(){
    Jedis jedis=new Jedis("127.0.0.1",6379);

    //添加
    jedis.zadd("shannxi",100,"xian");
    jedis.zadd("shannxi", 90,"xianyang");
    jedis.zadd("shannxi",80,"yulin");

    //获取全部
    Set<String> shannxi = jedis.zrange("shannxi", 0, -1);
    for (String s : shannxi) {
        System.out.println(s);
    }
}
2. Jedis实例-手机验证码

需求

  1. 输入手机号,点击发送随机生成6位数字码,2分钟有效
  2. 输入验证码,点击验证,返回成功或失败
  3. 每个手机号每天只能获取3次验证码

实现思路

  1. 生成随机6位数可以使用Random
  2. 将6位数放进redis中,同时设置过期时间为2分钟
  3. 从redis中获取的验证码和输入的验证码进行比对,可以判断验证码是否一致
  4. 每个手机每天只能发送3次验证码,使用redis中的incr,每次发送完验证码之后+1,大于2时候提交不能发送
public class PhoneCode {
    public static void main(String[] args) {

        //模拟前端传来的手机号
        String phoneCode = "110";

        //1.获取6位数的数字验证码
        String code = PhoneCode.getCode(phoneCode);
        //如果code为null则说明超过了三次,此手机号今天不能再获取验证码
        if (code == null) {
            System.out.println("此手机号今天超过了获取验证码次数!");
        }

        //模拟前端传入用户输入验证码与redis中的验证码是否一致并且是否过期
        //2.验证code是否合法
        System.out.println(PhoneCode.verify(code) ? "合法" : "不合法");
    }


    /**
     * 模拟前端传来手机号需要返回验证码code
     * 思路:
     * 1.获取6位数字码,获取到验证码之后判断是否此手机获取验证码是否超过三次
     * 2.如果符合获取验证码要求,那么将生成的验证码放入redis中设置过期时间,设置此手机获取次数
     *
     * @return
     */
    public static String getCode(String phoneCode) {
        Jedis jedis = new Jedis("127.0.0.1", 6379);

        //获取6位数字验证码
        Random random = new Random();
        //创建StringBuilder来进行字符串拼接,线程安全
        StringBuilder code = new StringBuilder("");
        for (int i = 0; i < 6; i++) {
            //参数为10代表只能随机取10以下的数字
            int rd = random.nextInt(10);
            code.append(rd);
        }

        //判断此手机是否获取过验证码
        //并且获取验证码是否超过3次,如果超过3次那么就不会返回验证码code
        if (jedis.get(phoneCode) != null) {
            if (Integer.parseInt(jedis.get(phoneCode)) >= 3)
                return null;
        }

        //运行到这块,那么将生成的验证码放入redis中设置过期时间,设置此手机获取次数
        //如果phoneCode的不为null,那就说明获取过验证码,自增1
        if (jedis.get(phoneCode) != null) {
            //自增1
            jedis.incr(phoneCode);
        } else {
            //如果运行到这,则说明没有获取过验证码,获取验证码次数设置为1
            jedis.setex(phoneCode, 60*60*24,"1");//设置监控此手机的获取验证码次数
        }
        jedis.set("code", code.toString());//将code放入redis
        jedis.expire("code", 60 * 2);//设置过期时间
        //说明没有获取过验证码或者获取验证码不超过三次,返回验证码code
        return code.toString();
    }


    /**
     * 模拟前端输入验证码code,后端进行判断
     * 在getCode中已经判断过此手机号是否获取超过3次
     * 在这里只需要判断验证码是否存在或者过期
     * @param code
     * @return
     */
    public static boolean verify(String code) {
        Jedis jedis = new Jedis("127.0.0.1", 6379);

        //判断redis中key为code是否为空
        if (jedis.get("code") == null) {
            return false;
        }

        //判断redis中key为code的value是否过期
        if (jedis.ttl("code") < 0) {
            return false;
        }

        //判断redis中key为code的value是否与用户所传入的一致
        if (!jedis.get("code").equals(code)){
            return false;
        }

        return true;
    }
}
3.SpringBoot整合redis

3.1 加入依赖
<!--redis-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--spring继承redis所需commons-pool2-->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.6.0</version>
</dependency>
3.2 application.properties配置文件
#redis 服务器地址
spring.redis.host=127.0.0.1
#redis 端口号
spring.redis.port=6379
#redis 数据库索引(默认为0),表示使用的哪个库,默认使用0号库
spring.redis.database=0
#连接超时时间(毫秒)
spring.redis.timeout=18000000
#连接池最大连接数(使用负值表示没有限制)
spring.redis.lettuce.pool.max-active=20
#最大阻塞等待时间(使用负值表示没有限制)
spring.redis.lettuce.pool.max-wait=-1
#连接池中的最大空闲连接
spring.redis.lettuce.pool.max-idle=5
#连接池中的最小空闲连接
spring.redis.lettuce.pool.min-idle=0
3.3 redis配置文件
@Configuration
@EnableCaching  //表示开启缓存
public class RedisConfig extends CachingConfigurerSupport {

    //用于计算两个“时间”间隔,也就是多久后持久化?
    private Duration timeToLive=Duration.ZERO;
    public void setTimeToLive(Duration timeToLive){
        this.timeToLive = timeToLive;
    }

    /**
     * 保存Json格式到Redis的配置
     */
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        RedisSerializer<String> redisSerializer = new StringRedisSerializer();
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);

        //解决查询缓存转换异常的问题
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);

        // 配置序列化(解决乱码的问题)将jdk序列化,改为jackson序列化
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(timeToLive)
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
                .disableCachingNullValues();

        RedisCacheManager cacheManager = RedisCacheManager.builder(factory)
                .cacheDefaults(config)
                .build();
        return cacheManager;
    }

    /**
     * 自定义redisTemplate,springboot使用redisTemplate对redis进行操作
     * @param factory
     * @return
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<String, Object>();
        template.setConnectionFactory(factory);
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        // key采用String的序列化方式
        template.setKeySerializer(stringRedisSerializer);
        // hash的key也采用String的序列化方式
        template.setHashKeySerializer(stringRedisSerializer);
        // value序列化方式采用jackson
        template.setValueSerializer(jackson2JsonRedisSerializer);
        // hash的value序列化方式采用jackson
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();
        System.out.println("使用自定义redisTemplate配置");
        return template;

    }
}
3.4 测试
@Autowired
private RedisTemplate template;

@RequestMapping("/test")
public String test(){
    //添加数据
    template.opsForValue().set("k1","v2");
    //从redis中获取值
    String k1 = (String)template.opsForValue().get("k1");
    return k1;
}

四、redis事务和锁

1.事务的定义

​ Redis事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。

​ Redis事务的主要作用就是串联多个命令防止别的命令插队。

2.Multi、Exec、discard

​ 从输入Multi命令开始,输入的命令都会依次进入命令队列中,但不会执行直到输入Exec后,Redis会将之前的命令队列中的命令依次执行

组队过程中可以通过discard来放弃组队

【注意】:

  • 组队阶段的命令不会执行,会等待按照顺序执行,类似mysql中的开启事务
  • 组队阶段的命令会放入队列中,先进先出,先放入先执行
  • 执行阶段会按照顺序进行执行命令,类似mysql中的提交事务
  • 当discard之后,会取消组队阶段期间的命令排序,放弃组队,类似mysql的回滚
    在这里插入图片描述

multi和exec配合演示
在这里插入图片描述

multi和diacard配合演示
在这里插入图片描述

3.事务的错误处理

​ Redis中事务错误有两种情况,第一种是在组队阶段错误,第二个是在执行阶段错误。

组队阶段错误

如果在组队阶段错误,那么所有的命令都会失败
在这里插入图片描述

执行阶段错误

如果在执行阶段错误,那么只有错误的那一个命令会失败,其他的命令都会成功
在这里插入图片描述

4.事务冲突的问题
4.1 例子

【场景】:在双11,一个购物账号被三个人登录,账号中总金额为10000元,一个请求给金额减8000元,一个请求给金额减5000元,最后一个请求给金额减1000元,该什么处理?

4.2 悲观锁

​ 悲观锁,顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改数据,因为每次拿到数据的时候都会上锁,这样别人想拿这个数据就会block直到拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁、表锁等,读锁、写锁等,都是在操作之前先上锁。

​ 悲观锁效率较低。

4.3 乐观锁

​ 乐观锁,顾名思义,就是很乐观,每次去拿数据的时候都会认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有更新这个数据,可以使用版本号。乐观锁适用于多读的应用类型,这样可以提高吞吐量,Redis就是使用这种机制实现事务。

4.4 演示乐观锁

WATCH key [key…]

​ 在执行multi之前,先执行watch key1[key2],可以监视一个(或多个)key,如果事务执行之前这个key被其他命令所改动,那么事务将被打断。

接下来用两个终端来模仿两个人同时使用一个账号买东西的场景

用户1
在这里插入图片描述

用户2
在这里插入图片描述

4.5 事务三特性
  • 单独的隔离操作:事务中的所有命令都会序列化、按顺序的执行。事务在执行过程中,不会被客户端发来的其他命令所打断。
  • 没有隔离级别的概念:队列中的命令没有提交之前都不会实质性的被执行。
  • 不保证原子性:事务中如果在执行阶段有一个命令没有执行成功,其他命令仍是可以执行的,不会回滚。
5.事务秒杀案例
5.1 秒杀操作代码

​ 只演示秒杀方法执行过程:

/***
 * 秒杀方法
 * @param userId 用户id,用来记录秒杀成功的用户
 * @param productId 产品id,用来实现秒杀之后产品数量-1
 * @return 返回某一个用户是否秒杀成功
 */
/*
   注意:
    1.在redis中库存使用String数据类型,key-value
    2.在redis中秒杀成功的用户清单使用Set,key-value1,value2,value3...
        并且Set集合中的value不可重复
 */
public static boolean secKill(String userId,String productId){

    //1.判断userId和productId是否为空
    if (StringUtils.isEmpty(userId) || StringUtils.isEmpty(productId)){
        return false;
    }
    //2.连接redis
    Jedis jedis=new Jedis("127.0.0.1",6379);
    //3.拼接key
     //3.1 库存key
     String kcKey="sk:"+productId+":qt";
     //3.2 秒杀成功用户key
     String userKey="sk:"+productId+":user";
    //4.获取库存,若库存为null,则说明秒杀还未开始
    if (jedis.get(kcKey)==null){
        System.out.println("秒杀未开始,请等待!");
        jedis.close();
        return false;
    }
    //5.判断用户是否重新秒杀操作,一个用户只能秒杀一次
    if (jedis.sismember(userKey,userId)){
        System.out.println("您已经秒杀成功,不能重复秒杀!");
        jedis.close();
        return false;
    }
    //6.判断库存数量小于等于0,秒杀结束
    if (Integer.parseInt(jedis.get(kcKey))<1){
        System.out.println("商品已经被秒杀完了!");
        jedis.close();
        return false;
    }
    //7.秒杀的过程
     //7.1 库存-1
     jedis.decr(kcKey);
     //7.2 把秒杀成功的用户加到清单当中
    jedis.sadd(userKey,userId);
    System.out.println(productId+"用户秒杀成功!");
    jedis.close();
    return true;
}
5.2 模拟并发

​ 可以使用ab工具来模拟多请求、多并发环境,在多并发环境下,会导致以下问题:

  • 超卖问题:redis中库存为负数,超卖了。
  • 连接超时问题:多并发环境下访问连接redis可能会导致连接超时。
5.3 连接池

jedis连接池工具类

​ Jedis连接池使用的是单例模式,双重检查。

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class JedisPoolUtil {

    private static volatile JedisPool jedisPool=null;

    private JedisPoolUtil(){}

    public static JedisPool getJedisPoolInstance(){
        if (jedisPool==null){
            synchronized(JedisPoolUtil.class){
                if (jedisPool==null){
                    JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
                    jedisPoolConfig.setMaxTotal(200);
                    jedisPoolConfig.setMaxIdle(32);
                    jedisPoolConfig.setMaxWaitMillis(100*1000);
                    jedisPoolConfig.setBlockWhenExhausted(true); //ping PONG

                    jedisPool = new JedisPool(jedisPoolConfig, "127.0.0.1", 6379,60000);
                }
            }
        }
        return jedisPool;
    }

    public static void release(JedisPool jedisPool, Jedis jedis){
        if (jedis!=null){
            jedisPool.returnResource(jedis);
        }
    }
}
5.4 使用连接池解决超时问题

​ 通过连接池得到jedis对象,创建jedis对象改后的代码:

//2.连接redis
//Jedis jedis=new Jedis("127.0.0.1",6379);

//通过连接池创建Jedis对象
JedisPool jedisPoolInstance = JedisPoolUtil.getJedisPoolInstance();
Jedis jedis = jedisPoolInstance.getResource();
5.5 解决超卖问题

​ 可以通过监视(watch)key,开启事务的方式来解决超卖问题。

问题】:但是在多次测试之后会发现超卖问题确实解决了,又出现了库存遗留问题,就是库存中有500个商品,现在有2000个请求,商品应该会被抢完,但实际上并没有。

public class secKill {


/***
 * 秒杀方法
 * @param userId 用户id,用来记录秒杀成功的用户
 * @param productId 产品id,用来实现秒杀之后产品数量-1
 * @return 返回某一个用户是否秒杀成功
 */
/*
   注意:
    1.在redis中库存使用String数据类型,key-value
    2.在redis中秒杀成功的用户清单使用Set,key-value1,value2,value3...
        并且Set集合中的value不可重复
 */
public static boolean secKill(String userId,String productId){

    //1.判断userId和productId是否为空
    if (StringUtils.isEmpty(userId) || StringUtils.isEmpty(productId)){
        return false;
    }

    //2.连接redis
    //Jedis jedis=new Jedis("127.0.0.1",6379);

    //通过连接池创建Jedis对象
    JedisPool jedisPoolInstance = JedisPoolUtil.getJedisPoolInstance();
    Jedis jedis = jedisPoolInstance.getResource();

    //3.拼接key
     //3.1 库存key
     String kcKey="sk:"+productId+":qt";
     //3.2 秒杀成功用户key
     String userKey="sk:"+productId+":user";

    //4.获取库存,若库存为null,则说明秒杀还未开始
    if (jedis.get(kcKey)==null){
        System.out.println("秒杀未开始,请等待!");
        jedis.close();
        return false;
    }

    //5.判断用户是否重新秒杀操作,一个用户只能秒杀一次
    if (jedis.sismember(userKey,userId)){
        System.out.println("您已经秒杀成功,不能重复秒杀!");
        jedis.close();
        return false;
    }

    /**
     * 从这之后,开始对库存进行监视,开启事务
     */
    //6.判断库存数量小于等于0,秒杀结束
    //监视库存
    jedis.watch(kcKey);

    String s = jedis.get(kcKey);
    if (Integer.parseInt(s)<1){
        System.out.println("商品已经被秒杀完了!");
        jedis.close();
        return false;
    }

    //7.秒杀的过程
    //使用事务
    Transaction multi = jedis.multi();

    //组队操作
    multi.decr(kcKey);//库存减1
    multi.sadd(userKey,userId);//秒杀成功时将用户加入名单

    //执行操作
    List<Object> results = multi.exec();

    if (results==null || results.size()==0){
        System.out.println("秒杀失败");
        jedis.close();
        return false;
    }
    return true;

}
}
5.6 解决库存遗留问题

​ 使用LUA脚本语言,可以将复杂的或者多步的redis操作写为一个脚本,一次提交给redis执行,减少反复连接redis的次数,提升性能。

LUA脚本是类似redis的事务,有一定的原子性,不会被其他命令插队,可以完成一些redis事务性的操作,但是注意redis的lua脚本功能,只有在redis2.6以上的版本中可以使用。

​ redis2.6之后,通过lua脚本解决抢夺问题,实际上是redis利用其单线程的特性,用任务队列的方式解决多任务并发问题。

LUA脚本

import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

public class SecKill_redisByScript {

    private static final  org.slf4j.Logger logger =LoggerFactory.getLogger(SecKill_redisByScript.class) ;

    public static void main(String[] args) {
        JedisPool jedispool =  JedisPoolUtil.getJedisPoolInstance();

        Jedis jedis=jedispool.getResource();
        System.out.println(jedis.ping());

        Set<HostAndPort> set=new HashSet<HostAndPort>();

        //	doSecKill("201","sk:0101");
    }

    static String secKillScript ="local userid=KEYS[1];\r\n" +
            "local prodid=KEYS[2];\r\n" +
            "local qtkey='sk:'..prodid..\":qt\";\r\n" +
            "local usersKey='sk:'..prodid..\":usr\";\r\n" +
            "local userExists=redis.call(\"sismember\",usersKey,userid);\r\n" +
            "if tonumber(userExists)==1 then \r\n" +
            "   return 2;\r\n" +
            "end\r\n" +
            "local num= redis.call(\"get\" ,qtkey);\r\n" +
            "if tonumber(num)<=0 then \r\n" +
            "   return 0;\r\n" +
            "else \r\n" +
            "   redis.call(\"decr\",qtkey);\r\n" +
            "   redis.call(\"sadd\",usersKey,userid);\r\n" +
            "end\r\n" +
            "return 1" ;

    static String secKillScript2 =
            "local userExists=redis.call(\"sismember\",\"{sk}:0101:usr\",userid);\r\n" +
                    " return 1";

    public static boolean doSecKill(String uid,String prodid) throws IOException {

        JedisPool jedispool =  JedisPoolUtil.getJedisPoolInstance();
        Jedis jedis=jedispool.getResource();

        //String sha1=  .secKillScript;
        String sha1=  jedis.scriptLoad(secKillScript);
        Object result= jedis.evalsha(sha1, 2, uid,prodid);

        String reString=String.valueOf(result);
        if ("0".equals( reString )  ) {
            System.err.println("已抢空!!");
        }else if("1".equals( reString )  )  {
            System.out.println("抢购成功!!!!");
        }else if("2".equals( reString )  )  {
            System.err.println("该用户已抢过!!");
        }else{
            System.err.println("抢购异常!!");
        }
        jedis.close();
        return true;
    }
}

五、Redis持久化

​ Redis是存在内存中的数据库,但也可也持久化到磁盘中,下面介绍Redis如何持久化到磁盘中。

1.Redis持久化的方式

​ Redis中有两种持久化的方式,分别是RDB和AOF。

2.Redis持久化之RDB

2.1 RDB是什么?

​ 在指定的时间间隔内将内存中的数据集快照写入磁盘内,也就是行话讲的Snapshot快照,它恢复时是将快照文件直接读到内存中

2.2 执行备份过程

​ Redis会单独创建一个子进程(fork)来进行持久化,会将数据写入一个临时文件中待持久化结束之后,再用这个临时文件替换上次持久化好的文件。整个过程中,主线程是不做任何IO操作的,确保了极高的性能,如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感。RDB的缺点是最后一次持久化后的数据可能会丢失

2.3 fork
  • fork的作用是复制一个与当前进程一模一样的进程,新进程的所有数据(变量、环境变量、程序计数器等)数值和原进程一致,但是是一个全新的进程,并且为原进程的一个子进程。
  • 在Linux系统中,fork会产生一个和父进程完全相同的子进程,但子进程此后会常被exec系统调用,出于效率,Linux引进了”写时复制技术“。
  • 一般情况下父进程和子进程会共用一段物理内存,只有进程空间的各段的内容要发生改变的时候,才会将父进程的内容复制给子进程一份。
3.Redis持久化之AOF

3.1 AOF是什么?

​ 以日志的形式记录每个写操作(增量保存),将Redis执行过的所有写指令记录下来(读操作不记录),只许追加文件但不可以改写文件**,reids启动之初会读取该文件重新构建数据。换言之,redis重启就是根据日志文件的内容将写指令从前到后执行一次以完成数据恢复

3.2 AOF持久化流程
  1. 客户端的请求写命令会被append追加到AOF缓冲区内
  2. AOF缓冲区根据AOF持久化策略(always、everysec、no)将操作sync同步到磁盘的AOF文件中
  3. AOF文件大小超过重写策略或手动重写时,会对AOF文件rewrite重写,压缩AOF文件容量
  4. Redis服务重启时,会重新load加载AOF文件中的写操作达到数据恢复的目的。
3.3 AOF默认不开启

​ 可以在redis.conf中配置文件名称,默认为appendonly.aof

​ AOF文件的保存路径,同RDB的路径一致。

3.4 AOF和RDB同时开启

​ 当AOF和RDB同时开启的时候,系统会默认使用AOF,数据不会存在丢失

3.5 AOF同步频率设置
  • appendfsync always:始终同步,每次Redis的写入都会立刻记入日志,性能较差但数据完整性比较好
  • appendfsync everysec:每秒同步,每秒记入日志一次,如果宕机,本秒的数据可能会丢失
  • appendfsync no:redis不主动进行同步,把同步时机交给操作系统

六、Redis主从复制

1.主从复制是什么?

​ 主机数据更新之后根据配置和策略,自动同步到备份的master/slaver机制,Master以写为主,Slave以读为主。

​ 一个应用配有多台服务器,一般是一台主服务器进行写操作,多台从服务器进行读操作。

2.主从复制的好处

  • 读写分离,性能扩展:在主服务器中专门进行写操作,在从服务器中进行读操作,这样会使性能更高。

  • 容灾快速恢复:当一台从服务器挂掉之后,可以快速地从其他从服务器中进行读取。
    在这里插入图片描述

3.搭建一主多从

在这里插入图片描述

4.复制原理

  1. 当从服务器连接上主服务器之后,从服务器向主服务器发送进行同步的消息
  2. 主服务器接到从服务器发来的同步消息,把主服务器数据进行持久化成rdb文件,把rdb文件发送给从服务器,从服务器拿到文件会进行读取
  3. 每当主服务器进行写操作之后都会和从服务器进行数据同步
5.从机宕机处理

5.1 一主二仆

​ 一主二仆有以下特点:

  • 当从服务器挂掉重启之后又会变成主服务器
  • 在从服务器挂掉到重启的这一段时间内,如果主服务器写入数据,从服务器重启之后仍然会同步数据
  • 当主服务器挂掉之后,从服务器不会变成主服务器,仍然是从服务器。主服务器重启之后仍然是主服务器
5.2 薪火相传

​ 当从服务器过多时,主服务器写入一个数据不可能给所有的从服务器进行数据同步,这样效率太低。

​ 所以主服务器只会给一个从服务器进行数据同步,然后由这个从服务器给其他从服务器进行数据同步。

【缺点】:当主服务器给从服务器数据同步之后,这个从服务器如果挂掉了,那么其他从服务器就不会得到数据同步。

5.2.1 操作步骤
  1. 选取一台通知其他从服务器的从服务器,暂定这台从服务器名称为从服务器1
  2. 让其他从服务器将主机设置为从服务器1
  3. 从服务器1和主服务器不做任何操作

【注意】:

  • 一台从服务器下还可以设置从服务器
  • 当其他从服务器将主机设置为从服务器1时,就会取消在主服务器下的从服务器名额
5.3 反客为主

​ 当主服务器挂掉之后,从服务器会反客为主,成为主服务器。

​ 在主服务器挂掉之后,给从服务器使用"slaveof no one"命令就会反客为主,从服务器就会变为主服务器,但是这个操作需要手动实现,下面讲的哨兵模式就是这个的自动版。

5.4 哨兵模式

​ 哨兵模式是反客为主的自动版,当主服务器挂掉之后,从服务器会自动成为主服务器,能够监控主机是否故障,如果故障了根据投票数自动将从服务器转换为主服务器。

​ 当主服务器挂掉之后被从服务器代替成为主服务器,那么当挂掉的主服务器重启之后就会成为之后主服务器的从服务器。

5.4.1 复制延迟

​ 由于所有的写操作都是现在Master上操作的,然后同步到Slave上,所以Master同步到Slave机器有一定的延时,当系统很忙的时候,延迟问题会更加严重,Slave机器数量增加也会使这个问题更加严重。

5.4.2 选择主机的条件
  • 选择优先级靠前的,"slave-priority"的值越靠前(越小),优先级越高
    在这里插入图片描述

  • 选择偏移量最大的,即从机中数据总量和主机中数据总量相差最小的

  • 选择runid最小的从服务器:每个redis实例启动之后都会随机生成一个30位的runid

七、Redis集群

1.什么是集群?

两个问题

  • 容量不够,redis如何进行扩容?

    答:使用多台服务器就可以

  • 并发写操作,redis如何分摊?

    答:在读写操作的时候,我们使用一主多从的模式,但是在多并发环境下进行写操作时,一台服务器也承受不了压力,可以使用集群解决。

    集群

    Redis集群实现了对Redis的水平扩容,即启动N个redis节点,将整个数据分布存储在这N个节点中,每个节点存储总数据的1/N。

​ Redis集群通过分区来提供一定程度上的可用性,即使集群中有一部分节点失效或者无法通讯,集群也可以继续处理命令请求。

后面有时间会将集群这块重新做一个模块进行学习

八、应用问题解决

1.缓存穿透

1.1 问题描述

​ key对应的数据在数据源并不存在,每次针对此key的请求从缓存获取不到,请求都会压到数据源,从而可能压垮数据源。比如一个不存在的用户id获取用户信息,不论缓存还是数据库都没有,若黑客利用此漏洞进行攻击可能压垮数据库。
在这里插入图片描述

  • 应用服务器压力变大:当有大量的请求访问应用服务器
  • 缓存命中率降低:正常情况是先查缓存,缓存查到了返回,查不到查数据库,但是访问的这些数据,大部分缓存中都没有
  • 一直查询数据库:所有数据都去查数据库,数据库压力剧增
1.2 常见的四种解决方案
1.2.1 对空值缓存

​ 如果一个查询返回的数据为空(不管数据是否存在),仍然把这个空结果(null)进行缓存,设置空结果的过期时间会很短,最长不超过5分钟。

1.2.2 设置可访问的名单(白名单)

​ 使用bitmaps类型定义一个可以访问的名单,名单id组为bitmaps的偏移量,每次访问和bitmaps中的id进行比较,如果访问id不在bitmaps里面,进行拦截,不允许访问。

1.2.3 采用布隆过滤器

​ 布隆过滤器实际上是一个很长的二进制向量(位图)和一系列随即映射函数(哈希函数)。

​ 布隆过滤器可以用于检索一个元素是否在一个集合中,它的优点是空间效率和查询效率都远远超过一把的算法,缺点是有一定的误识别率和删除困难。

​ 将所有可能存在的数据哈希到一个足够大的bitmaps中,一个一定不存在的数据会被这个bitmaps拦截掉,从而避免了对底层存储系统的查询压力。

1.2.4 进行实时监控

​ 当发现Redis的命中率开始急速降低,需要排查访问对象和访问的数据,和运维人员配合,可以设置黑名单限制服务。

2.缓存击穿
2.1 问题描述

​ key对应的数据存在,但是redis中某个key过期,此时若有大量的请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大量并发的请求可能会瞬间把后端DB压垮。

2.2 问题特点
  • 数据库压力瞬时增加
  • redis中没有出现大量过期的key
  • redis正常运行

​ 综上,可以将问题描述为:当redis中有一个热点数据的key过期的时候,这时正好有大量访问此key的请求,由于redis中过期,只能去数据库中查看,大量的访问请求会导致数据库压垮。

2.3 解决方案

​ key可能会在某些时间点被超高并发的访问,是一种非常“热点”的数据,这个时候需要考虑一个问题,缓存被“击穿”的问题。

  1. 预先设置热门数据:在redis高峰访问之前,把一些热门数据提前存到redis中,加大这些热点数据的过期时间。
  2. 实时调整:现场监控哪些数据热门,实时调整key的过期时长。
  3. 使用锁在这里插入图片描述
3.缓存雪崩

3.1 现象

​ 在极短的时间内,redis中有大量key过期,导致请求命中率过低,直接访问数据库,数据库压力太大崩溃。

3.2 解决方案
  1. 构建多级缓存架构:nginx缓存+redis缓存+其他缓存等
  2. 使用锁或队列:使用锁或队列来保证一次不会有大量线程对数据库进行读写,从而避免失效时大量的并发请求落到底层存储系统上,不使用高并发情况
  3. 设置过期标志更新缓存:记录缓存数据是否过期(设置提前量),如果过期会触发通知另外的线程在后台去更新实际key的缓存
  4. 将缓存失效时间分散开:比如我们在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
4.分布式锁

4.1 问题描述

​ 随着业务发展的需要,原单体单机部署的系统被演化为分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同的机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题

4.2 分布式锁的实现方案
  • 基于数据库的分布式锁
  • 基于redis的分布式锁:性能最高
  • 基于Zookeeper的分布式锁:可靠性最高
4.3 基于Redis实现分布式锁
4.3.1 命令

setnx KEY VALUE

​ 使用以上命令来设置分布式锁,这个key只能设置一次,只有这个key被删除之后(锁释放之后),才可以继续使用这个key。
在这里插入图片描述

4.3.2 设置过期时间

​ 如果这个锁一直没有释放,其他的就不能去使用这个锁,所以为了防止此问题,给这个锁中加入了过期时间,命令如下:

expire KEY SECOND
在这里插入图片描述

4.3.3 不是原子性

​ 设置分布式锁、设置过期时间,这是两个操作,并不符合原子性,所以会出现问题,那么可以使用下面命令一次将这两个操作同时处理:

set KEY VALUE nx ex SECOND
在这里插入图片描述

4.3.4 代码实现分布式锁
@RestController
public class getBlock {

    @Autowired
    private RedisTemplate redisTemplate;

    public void getLock(){
        //1.获取锁并设置过期时间,set lock 111 nx ex 20
        Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", 111,20,TimeUnit.SECONDS);
        //2.获取锁成功,查询num的值
        if (lock){
            Object value = redisTemplate.opsForValue().get("num");
            //2.1 判断num为空则return
            if (StringUtils.isEmpty(value)){
                return;
            }
            //2.2 有值就转成int
            int num = Integer.parseInt(value + "");
            //2.3 把redis的num+1
            redisTemplate.opsForValue().set("num",++num);
            //2.4 释放锁
            redisTemplate.delete("lock");
        }else {
            //3.获取锁失败,隔1s再试
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值