Redis基础

Redis介绍

特征

  • 键值对类型,值支持多种类型

  • 单线程,命令具备原子性,线程安全

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

  • 支持数据持久化

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

启动

  • 默认启动

    • redis-server

  • 指定配置启动

  • 开机自启

    • vim /etc/systemd/system/redis.service

    • [Unit]
      Description=Advanced key-value store
      After=network.target
      Documentation=http://redis.io/documentation, man:redis-server(1)
      ​
      [Service]
      Type=forking
      ExecStart=/usr/bin/redis-server /etc/redis/redis.conf
      ExecStop=/bin/kill -s TERM $MAINPID
      PIDFile=/run/redis/redis-server.pid
      TimeoutStopSec=0
      Restart=always
      User=redis
      Group=redis
      RuntimeDirectory=redis
      RuntimeDirectoryMode=2755
      ​
      UMask=007
      PrivateTmp=yes
      LimitNOFILE=65535
      PrivateDevices=yes
      ProtectHome=yes
      ReadOnlyDirectories=/
      ReadWriteDirectories=-/var/lib/redis
      ReadWriteDirectories=-/var/log/redis
      ReadWriteDirectories=-/var/run/redis
      ​
      NoNewPrivileges=true
      CapabilityBoundingSet=CAP_SETGID CAP_SETUID CAP_SYS_RESOURCE
      MemoryDenyWriteExecute=true
      ProtectKernelModules=true
      ProtectKernelTunables=true
      ProtectControlGroups=true
      RestrictRealtime=true
      RestrictNamespaces=true
      RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
      ​
      # redis-server can write to its own config file when in cluster mode so we
      # permit writing there by default. If you are not using this feature, it is
      # recommended that you replace the following lines with "ProtectSystem=full".
      ProtectSystem=true
      ReadWriteDirectories=-/etc/redis
      ​
      [Install]
      WantedBy=multi-user.target
      Alias=redis.service

数据结构

key-value的数据库,key一般为String类型,value可以是以下形式

  • String

  • Hash

  • List

  • Set

  • SortedSet

  • GEO

  • BitMap

  • HyperLog

基本操作

通用命令

查看符合的key(模糊查询) 不建议在生产环境使用,造成线程阻塞

KEYS 参数

keys * 获取所有keys

keys n* 获取所有n开头keys

删除指定的key

del 键名(支持多个键名)

del k1 k2 k3

判断key是否存在

exists 键名(支持多个键名)

给一个key设置有效期,有效期到期key会被自动删除

expire 键名 秒

查看一个key的剩余有效期

ttl 键名

返回结果为-1代表永久有效

返回结果为-2代表被移除

Key的层级结构

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

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

  • user相关的key:heima:user:1

  • product相关的key:heima:product:1

如果Value是一个Java对象,例如一个User对象,则可以将对象序列化为JSON字符串后存储:

KEYVALUE
heima:user:1{"id":1, "name": "Jack", "age": 21}
heima:product:1{"id":1, "name": "小米11", "price": 4999}

一旦我们向redis采用这样的方式存储,那么在可视化界面中,redis会以层级结构来进行存储,形成类似于这样的结构,更加方便Redis获取数据

String类型

string,int,float

常用命令

  • SET:添加或者修改已经存在的一个String类型的键值对

  • GET:根据key获取String类型的value

  • MSET:批量添加多个String类型的键值对

  • MGET:根据多个key获取多个String类型的value

  • INCR:让一个整型的key自增1

  • INCRBY:让一个整型的key自增并指定步长,例如:incrby num 2 让num值自增2

  • INCRBYFLOAT:让一个浮点类型的数字自增并指定步长

  • SETNX:添加一个String类型的键值对,前提是这个key不存在,否则不执行

  • SETEX:添加一个String类型的键值对,并且指定有效期

Hash类型

value是一个无序列表

Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD:

常用命令

  • HSET key field value:添加或者修改hash类型key的field的值

  • HGET key field:获取一个hash类型key的field的值

  • HMSET:批量添加多个hash类型key的field的值

  • HMGET:批量获取多个hash类型key的field的值

  • HGETALL:获取一个hash类型的key中的所有的field和value

  • HKEYS:获取一个hash类型的key中的所有的field

  • HINCRBY:让一个hash类型key的字段值自增并指定步长

  • HSETNX:添加一个hash类型的key的field值,前提是这个field不存在,否则不执行

List类型

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

特征也与LinkedList类似:

  • 有序

  • 元素可以重复

  • 插入和删除快

  • 查询速度一般

常用来存储一个有序数据,例如:朋友圈点赞先后顺序列表,评论列表等。

常用命令

  • LPUSH key element ... :向列表左侧插入一个或多个元素

  • LPOP key:移除并返回列表左侧的第一个元素,没有则返回nil

  • RPUSH key element ... :向列表右侧插入一个或多个元素

  • RPOP key:移除并返回列表右侧的第一个元素

  • LRANGE key star end:返回一段角标范围内的所有元素

  • BLPOP和BRPOP:与LPOP和RPOP类似,只不过在没有元素时等待指定时间,而不是直接返回nil

Set类型

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

  • 无序

  • 元素不可重复

  • 查找快

  • 支持交集.并集.差集等功能

常用命令

  • SADD key member ... :向set中添加一个或多个元素

  • SREM key member ... : 移除set中的指定元素

  • SCARD key: 返回set中元素的个数

  • SISMEMBER key member:判断一个元素是否存在于set中

  • SMEMBERS:获取set中的所有元素

  • SINTER key1 key2 ... :求key1与key2的交集

  • SDIFF key1 key2 ... :求key1与key2的差集

  • SUNION key1 key2 ..:求key1和key2的并集

SortedSet类型

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

SortedSet具备下列特性:

  • 可排序

  • 元素不重复

  • 查询速度快

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

常用命令

  • 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范围内的元素

  • ZDIFF.ZINTER.ZUNION:求差集.交集.并集

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

  • 升序获取sorted set 中的指定元素的排名:ZRANK key member

  • 降序获取sorted set 中的指定元素的排名:ZREVRANK key memeber

Jedis

导入依赖

<!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>4.0.0</version>
        </dependency>

建立连接

void setUp(){
        // 建立连接
        jedis = new Jedis("192.168.31.160", 6379);
        // 密码
        jedis.auth("123456");
        // 选择库
        jedis.select(0);
    }

redis操作

@Test
    void testString(){
        // 插入数据
        String result = jedis.set("name", "sb");
        System.out.println("result = " + result);
​
        // 获取数据
        String name = jedis.get("name");
        System.out.println("name = " + name);
    }
​
    @Test
    void testHash() {
        HashMap<String, String> hashMap = new HashMap<>();
        hashMap.put("name", "test");
        hashMap.put("age", "21");
        hashMap.put("sex", "man");
        jedis.hset("user:1", "name", "sb");
        jedis.hset("user:1", "age", "22");
        jedis.hset("user:1", "sex", "man");
        jedis.hset("user:2", hashMap);
​
        Map<String, String> map = jedis.hgetAll("user:2");
        System.out.println(map);
    }

释放连接

void testDown() {
        if (jedis != null) {
            jedis.close();
        }
    }

Jedis连接池

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

有关池化思想,并不仅仅是这里会使用,很多地方都有,比如说我们的数据库连接池,比如我们tomcat中的线程池,这些都是池化思想的体现。

public class JedisConnectionFacotry {
​
     private static final JedisPool jedisPool;
​
     static {
         //配置连接池
         JedisPoolConfig poolConfig = new JedisPoolConfig();
         poolConfig.setMaxTotal(8);
         poolConfig.setMaxIdle(8);
         poolConfig.setMinIdle(0);
         poolConfig.setMaxWaitMillis(1000);
         //创建连接池对象
         jedisPool = new JedisPool(poolConfig,
                 "192.168.31.160",6379,1000,"123456");
     }
​
     public static Jedis getJedis(){
          return jedisPool.getResource();
     }
}

代码说明:

  • 1) JedisConnectionFacotry:工厂设计模式是实际开发中非常常用的一种设计模式,我们可以使用工厂,去降低代的耦合,比如Spring中的Bean的创建,就用到了工厂设计模式

  • 2)静态代码块:随着类的加载而加载,确保只能执行一次,我们在加载当前工厂类的时候,就可以执行static的操作完成对 连接池的初始化

  • 3)最后提供返回连接池中连接的方法.

SpringDataRedis

SpringData是Spring中数据操作的模块,包含对各种数据库的集成,其中对Redis的集成模块就叫做SpringDataRedis,官网地址:Spring Data Redis

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

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

  • 支持Redis的发布订阅模型

  • 支持Redis哨兵和Redis集群

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

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

  • 支持基于Redis的JDKCollection实现

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

Redis实战

起步

添加依赖

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>

application.yml配置

server:
  port: 8081
spring:
  application:
    name: hmdp
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/hmdp?useSSL=false&serverTimezone=UTC
    username: root
    password: 123456
  redis:
    host: 192.168.31.160
    port: 6379
    password: 123456
    lettuce:
      pool:
        max-active: 10
        max-idle: 10
        min-idle: 1
        time-between-eviction-runs: 10s
  jackson:
    default-property-inclusion: non_null # JSON处理时忽略非空字段
mybatis-plus:
  type-aliases-package: com.hmdp.entity # 别名扫描包
logging:
  level:
    com.hmdp: debug
​
---
server:
  servlet:
    encoding:
      force-request: true
      force-response: true
    context-path: /api

缓存问题及解决方案

缓存穿透

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。

解决方案

  • 在服务器端,接收参数时业务接口中过滤不合法的值,null,负值,和空值进行检测和空值。

  • bloom filter(布隆过滤):类似于哈希表的一种算法,用所有可能的查询条件生成一个bitmap,在进行数据库查询之前会使用这个bitmap进行过滤,如果不在其中则直接过滤,从而减轻数据库层面的压力。

  • 空值缓存:一种比较简单的解决办法,在第一次查询完不存在的数据后,将该key与对应的空值也放入缓存中,只不过设定为较短的失效时间,例如几分钟,这样则可以应对短时间的大量的该key攻击,设置为较短的失效时间是因为该值可能业务无关,存在意义不大,且该次的查询也未必是攻击者发起,无过久存储的必要,故可以早点失效。

缓存雪崩

因为缓存服务挂掉或者热点缓存失效,所有请求都去查数据库,导致数据库连接不够或者数据库处理不过来,从而导致整个系统不可用。

解决方案

  • 在原有的失效时间上加上一个随机值,比如1-5分钟随机。这样就避免了因为采用相同的过期时间导致的缓存雪崩。

  • 使用熔断机制。当流量到达一定的阈值时,就直接返回“系统拥挤”之类的提示,防止过多的请求打在数据库上。至少能保证一部分用户是可以正常使用,其他用户多刷新几次也能得到结果。

  • 提高数据库的容灾能力,可以使用分库分表,读写分离的策略。

  • 使用redis集群

缓存击穿

缓存击穿实际上是缓存雪崩的一个特例,缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。击穿与雪崩的区别即在于击穿是对于某一特定的热点数据来说,而雪崩是全部数据。

解决方案

  • 使用互斥锁。如果缓存失效的情况,只有拿到锁才可以查询数据库,降低了在同一时刻打在数据库上的请求,防止数据库打死。当然这样会导致系统的性能变差。

  • 设置逻辑过期:针对与热点缓存数据缓存到期。为防止缓存时间到期数据清除,同时大量请求需要查询该数据,导致大量请求进入数据库。可将热点数据添加逻辑过期时间,不使用redis自带的过期时间,可避免字段在大量访问的时间段内缓存过期。当请求查询该字段时,首先检查该字段的逻辑过期时间,判断是否过期,如果没有过期则直接返回结果;如果数据过期,则生成一个新的线程用于查询数据库并更新redis,同时对查询数据库的方法加锁,查询结束后更新redis释放锁。在数据逻辑时间过期到redis更新数据完成之间,其他请求都会返回redis中的旧数据,不会降低系统的qps。缺点为无法保证数据的一致性,在redis更新期间其他线程使用的仍然是旧数据。

使用

注入StringRedisTemplate CacheClient

StringRedisTemplate stringRedisTemplate;
​
RedisClient redisClient;
​
public ShopServiceImpl(StringRedisTemplate stringRedisTemplate, RedisClient redisClient) {
    this.stringRedisTemplate = stringRedisTemplate;
    this.redisClient = redisClient;
}

RedisClient工具类

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
​
import java.time.LocalDateTime;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
​
/**
 * Redis操作工具类
 * 包含将任意类型数据序列化为String类型存储方法
 * 根据id获取String存储反序列化为任意类型的方法
 * 存储并设置逻辑过期时间方法
 * 防止缓存穿透的查询方法
 * 使用逻辑过期防止缓存穿透查询方法
 */
@Slf4j
@Component
public class RedisClient {
​
    private static final Long CACHE_NULL_TTL = 2L;
    private static final Long LOCK_TTL = 2L;
    private static final String LOCK_KEY = "lock:";
​
    StringRedisTemplate stringRedisTemplate;
​
    public RedisClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }
​
    /**
     * 以字符串形式存入redis,入参任意类型的值,序列化为string类型存入redis
     *
     * @param key   键名
     * @param value 值(任意类型)
     * @param time  ttl
     * @param unit  时间单位
     */
    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }
​
​
    /**
     * 以字符串形式存入redis,入参任意类型的值,序列化为string类型存入redis,
     * 同时执行传入的数据库方法
     *
     * @param key        键名
     * @param value      值(任意类型)
     * @param dbFallback 数据库方法 如"id2 -> getById(id2)"
     * @param time       ttl
     * @param unit       时间单位
     */
    @Transactional(rollbackFor = Exception.class)
    public void set(String key, Object value, Function<Object, Object> dbFallback, Long time, TimeUnit unit) {
        dbFallback.apply(value);
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }
​
​
    /**
     * 存入redis,设置逻辑过期时间
     *
     * @param key   键名
     * @param value 值(任意类型)
     * @param time  ttl
     * @param unit  时间单位
     */
    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
        // 设置逻辑过期
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        // 写入redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }
​
    /**
     * 根据id查询redis中的数据,并将查询到的数据反序列化为任意类型返回
     *
     * @param keyPrefix 查询id的前缀 如"login:code:"
     * @param id        需要查询的id
     * @param type      返回值类型 如"Shop.class"
     * @param <R>       返回值类型 如"Shop.class"
     * @param <ID>      id的类型
     * @return 指定类型的返回值
     */
    public <R, ID> R queryByKey(String keyPrefix, ID id, Class<R> type) {
        String jsonStr = stringRedisTemplate.opsForValue().get(keyPrefix + id);
        return JSONUtil.toBean(jsonStr, type);
    }
​
    /**
     * 使用redis查询防止缓存穿透方法
     * 首先在缓存中查询,如果id已存在redis则直接返回查询结果,若命中保存的null,则返回null。
     * 如果redis未存储此id,调用用户写入的查询逻辑去做数据库查询,如果命中则写入redis并返回结果,
     * 若未命中则将null以查询id为键名写入redis,防止缓存穿透。
     *
     * @param keyPrefix  查询id的前缀 如"login:code:"
     * @param id         需要查询的id
     * @param type       返回值类型 如"Shop.class"
     * @param <R>        返回值类型 如"Shop.class"
     * @param <ID>       id的类型
     * @param dbFallback 数据库查询函数 如"id2 -> getById(id2)"
     * @param time       ttl
     * @param unit       时间单位
     */
    public <R, ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
​
        String key = keyPrefix + id;
​
        // 从redis查询缓存
        String json = stringRedisTemplate.opsForValue().get(key);
​
        // 判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 存在则返回
            return JSONUtil.toBean(json, type);
        }
        // 判断命中的是否为空值
        if (json != null) {
            // 返回错误信息
            return null;
        }
​
        // 不存在,去数据库查询
        R r = dbFallback.apply(id);
        // 不存在,返回错误
        if (r == null) {
            // 将空值写入redis,防止缓存穿透
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 存在,写入redis
        this.set(key, r, time, unit);
        return r;
    }
​
    // 创建线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
​
    /**
     * 使用逻辑过期时间防止缓存穿透
     *
     * @param keyPrefix  查询id的前缀 如"login:code:"
     * @param id         需要查询的id
     * @param type       返回值类型 如"Shop.class"
     * @param <R>        返回值类型 如"Shop.class"
     * @param <ID>       id的类型
     * @param dbFallback 数据库查询函数 如"id2 -> getById(id2)"
     * @param time       ttl
     * @param unit       时间单位
     */
    public <R, ID> R queryWithLogicalExpire(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
​
        String key = keyPrefix + id;
​
        // 从redis获取缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 判断是否存在
        if (StrUtil.isBlank(json)) {
            // 不存在直接返回
            return null;
        }
        // redis命中,把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
​
        // 判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 未过期,返回信息
            return r;
        }
        // 已过期,缓存重建
        // 获取互斥锁
        String lockKey = LOCK_KEY + id;
        boolean isLock = tryLock(lockKey);
​
        // 判断是否获取成功
        if (isLock) {
            // 成功获取,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R r1 = dbFallback.apply(id);
                    // 写入redis
                    this.setWithLogicalExpire(key, r1, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unLock(lockKey);
                }
            });
        }
        return r;
    }
​
    // 获取锁
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_TTL, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }
​
    // 释放锁
    private void unLock(String key) {
        stringRedisTemplate.delete(key);
    }
}

RedisData实体类

该实体类用于实现redis字段逻辑过期,使用逻辑过期方法防止缓存击穿

import lombok.Data;
​
import java.time.LocalDateTime;
​
@Data
public class RedisData {
    // 逻辑过期
    private LocalDateTime expireTime;
    private Object data;
}

使用方法

// 将数据存入redis,十分钟后过期
@Test
void cacheClientSave() {
    User user = new User();
    user.setPhone("12345678911");
    user.setNickName("sb");
    user.setPassword("123456");
    redisClient.set("test:1", user, 10L, TimeUnit.MINUTES);
}
​
// 将数据存入数据库后同时存入redis
@Test
    void cacheClientSave1() {
        User user = new User();
        user.setId("1")
        user.setPhone("18376711111");
        user.setNickName("sb");
        user.setPassword("123456");
        redisClient.set("test:1", user, (a)->userMapper.insert(user), 10L, TimeUnit.MINUTES);
    }
​
// 根据key查询redis数据
@Test
    void cacheClientQuery() {
        User user = redisClient.queryByKey("test:", "1", User.class);
        System.out.println(user);
    }
​
// 查询redis同时解决缓存穿透
Shop shop = redisClient.queryWithPassThrough(
                CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);
​
// 逻辑过期解决缓存击穿
Shop shop = redisClient.queryWithLogicalExpire(
                CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值