Redis 学习笔记-下【高阶篇+面试题】

Redis 高阶学习 (+面试题)


1. Redis 单线程 vs 多线程

1.1 面试题

  1. Redis 是单线程还是多线程?
  2. IO 多路复用?
  3. Redis 为什么这么快?

1.2 Redis 发展流程

Redis4之后才慢慢支持多线程,直到Redis6/7后才稳定。我们所说的Redis是单线程指的是Redis的网络IO和键值对读写是由一个线程完成的,但是Redis的其他功能,比如RDB、AOF、异步删除、集群数据同步等等,其实是由额外的线程来执行的,Redis命令的工作线程是单线程的,但是对于整个Redis来说,是多线程的。

对于Redis的单线程来说:

  • 使得其开发和维护更加简单;
  • Redis使用IO多路复用功能来监听多个socket连接客户端,这样就可以使用一个线程连接来处理多个请求,减少线程切换;
  • 对于Redis系统来说,主要的性能瓶颈是内存和网络而非CPU

对于整个Redis为什么后面要采用多线程 *:(多核CPU的利用和单线程Redis的痛点)

  • 当被删除的key是一个非常大的对象时,del指令会使得Redis主线程卡顿(大key)。于是Redis4新增了多线程模块,此版本中的多线程主要是为了解决删除数据效率低的问题。
    • UNLINK key
    • FLUSHDB / FLUSHALL async
  • Redis6/7(真正的多线程):为了处理原先用单线程完成从网络IO到实际的读写处理的过程慢的问题,Redis6/7采用多个IO线程来处理网络请求,提高网络请求处理的并行度,但是对于读写操作命令仍然是单线程处理的。

主线程和IO线程是如何协作完成请求处理的(四个阶段):

阶段一:服务端和客户端建立socket连接,并分配处理线程

主线程负责接收建立连接请求,当有客户端请求和实例建立socket连接时,主线程会创建和客户端的连接,并把socket放入全局等待队列中,紧接着,主线程通过轮询的方法把socket连接分配给IO线程。

阶段二:IO线程读取并解析请求

主线程一旦把socket分配给IO线程,就会进入阻塞状态,等待IO线程完成客户端请求读取和解析,因为有多个IO线程在并行处理,所以这个过程很快。

阶段三:主线程执行请求操作

等到IO线程解析完请求,主线程还是会以单线程的方式执行这些命令操作。

阶段四:IO线程写回socket和主线程清空全局队列

当主线程执行完这些请求操作后,会把需要返回的结果写入缓冲区,然后,主线程会阻塞等待IO线程,把这些结果写回到socket中,并返回给客户端。和IO线程读取和解析请求一样,IO线程回写到socket时,也是有多个线程在并发执行,所以写回socket的速度也会很快,等到IO线程写回到socket完毕,主线程会清空全局队列,等待客户端的后续请求。

1.3 Redis 为什么快

影响Redis快慢的因素:CPU(非主要)、内存、网络IO

  • IO多路复用:一个或者一组线程处理多个TCP连接,使用单进程就能实现同时处理多个客户端的连接,无需创建或维护过多的线程或进程。
    • IO:网络IO,尤其在操作系统层面指的是内核态和用户态之间的读写操作
    • 多路:多个客户端连接
    • 复用:复用一个或者多个线程
  • epoll函数

Redis6/7默认关闭多线程,若Redis实例的CPU开销不大但是吞吐量没有提升,可以考虑Redis7的多线程机制,加快网络处理。(对于小数据包,Redis服务器可以处理 8w~10w 的 QPS)

io-threads 4
io-thread-do-reads yes

2. 大 Key 问题(BigKey)

2.1 面试题

  1. 海量数据里查询某一固定前缀的 key?
  2. 如何生产上限制 key * / flushdb / flushall 等危险命令以防止误删误用?
  3. MEMORY USAGE 命令?
  4. BigKey多大算big?如何发现?如何删除?如何处理?
  5. BigKey怎么调优?惰性释放 lazyfree?
  6. MoreKey 问题,生产上redis数据库有1000w记录,如何遍历?keys * 可以吗?

2.2 MoreKey 案例

模拟案例:

for(( i=1;i<=100*10000;i++ )); do echo "set k$i v$i" >> /tmp/redisTest.txt ; done;
cat /tmp/redisTest.txt | redis-cli -p 端口 -a 密码 --pipe

生产上通过配置 redis.conf 设置来限制 key * / flushdb / flushall 等危险命令:

rename-command keys ""
rename-command flushdb ""
rename-command flushall ""

或者用 scan 命令替代 keys * 命令:(SCAN、SSCAN、HSCAN、ZSCAN)

SCAN Cursor [MATCH Pattern] [COUNT Count]

2.3 BigKey 案例

大的内容不是key本身,而是对应的value值。

开发规范:

String 类型控制在10KB以内,hash、list、set、zset元素个数不要超过 5000 个。

非字符串的 bigkey,不要用 del 删除,使用 HSCAN、SSCAN、ZSCAN 方式渐进式删除,同时要注意防止 bigkey 过期时间自动删除的问题

① String 是 value,最大 512MB 但是 ≥ 10KB 就是 bigkey

② list、hash、set、zset,个数超过 5000 就是 bigkey

危害:

  • 内存不均,集群迁移困难
  • 超时删除,大 key 删除作梗
  • 网络流量阻塞

如何发现?

  • redis-cli --bigkeys

  • MENORY USAGE

    • 给出一个 key 和它的值在 RAM 中所占用的字节数。返回的结果是 key 值,以及为管理该 key 分配的内存总字节数。

如何删除?

  • string:一般用 del,如果过于庞大用 unlink
  • hash:使用 HSCAN 每次获取少量的 field-val ,再用 hdel 删除每个 field
  • list:使用 ltrim 渐进式逐步删除,直到全部删除为止。ltrim KEY_NAME START END
  • set:使用 SSCAN 每次获取少量的元素,慢慢删除。
  • zset:使用 ZSCAN每次获取少量的元素,慢慢删除。

调优 BigKey:redis.conf

阻塞和非阻塞删除的命令:

lazyfree-lazy-server-del yes
replica-lazy-flush yes
lazyfree-lazy-user-del yes

3. 缓存双写一致性之更新策略

查询业务逻辑:先从Redis查询,找到了直接返回,若没有找到,去 MySQL 查询返回,同时写进Redis。

3.1 面试题

  1. 如何解决双写的数据一致性问题?
  2. 双写一致性是先动缓存 Redis 还是数据库 MySQL?为什么?
  3. 延时双删做过吗?会有什么问题?
  4. 查询 Redis 无 MySQL 有,为了保证数据双写一致性回写 Redis 需要注意什么?双检加锁策略是什么?如何尽量避免缓存击穿?
  5. Redis 和 MySQL 双写 100% 会有纰漏,做不到强一致性,如何保证最终一致性

3.2 双写一致性

  • 若 Redis 中有数据:需要和数据库的值相同
  • 若 Redis 中无数据:数据库中的值要是最新的,且准备写回 Redis

按照缓存操作来分:只读缓存、读写缓存

读写缓存:

  • 同步直写策略(对于特别重要的数据、热点敏感数据、即时生效的数据)
    • 写数据库后也同步写 Redis 缓存,缓存和数据库中的数据一致。
    • 对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略。
  • 异步缓写策略(比如仓库、物流系统等)
    • 正常业务运行中,MySQL 数据变动了,但是可以在业务上容许一定时间后才作用于 Redis。
    • 异常情况出现了,不得不将失败的动作重新修补,有可能需要借助 kafka 或者 RabbitMQ 等消息中间件,实现重试重写。

3.3 双检加锁策略(只读缓存)

public User findUserById(Integer userId){
    String key = USER_KEY + userId;
    // 从 Redis 查询
    User user = (User) redisTemplate.opsForValue().get(key);
    if (user == null){
        // 第一次在 Redis 中没找到
        // 加锁先让一个线程进来,避免巨量线程涌入 MySQL 将其击穿
        synchronized (this){
            // 再从 Redis 查询,后面涌进来的线程可以从这里读取 Redis 缓存的数据
            user = (User) redisTemplate.opsForValue().get(key);
            if (user == null){
                // 第二次还是再 Redis 没查到,说明当前是第一个线程进来了,去 MySQL 查询
                user = jdbcTemplate.queryForObject("QUERY SQL", User.class);
                if(user == null){
                    // TODO redis + mysql都没有数据,按需求记录 null 值的 key,列入黑名单等。
                } else{
                    // MySQL 查询到了,保存至 redis 缓存
                    redisTemplate.opsForValue().setIfAbsent(key, user, 2L, TimeUnit.DAYS);
                }
            }
        }
    }
    return user;
}

3.4 数据库和缓存最终一致性的几种更新策略(读写缓存)

可以停机的情况下:

  • 挂牌报错、凌晨升级、温馨提示、服务降级
  • 单线程操作,这样重量级的数据操作最好不要多线程

四种策略:(非停机情况)

  • (×)先更新数据库,再更新缓存

    • MySQL 更新成功,Redis 中更新出现异常,使得数据库和缓存数据不一致。
    • 高并发的情况下,多个线程的 MySQL 和 Redis 操作交织在一起,容易引起数据不一致
  • (×)先更新缓存,再更新数据库

    • 一般把 MySQL 作为底单数据库,保证最后解释
    • 高并发的情况下,多个线程的 MySQL 和 Redis 操作交织在一起,容易引起数据不一致
  • ( ! )先删除缓存,再更新数据库:利用回写机制

    • 删除缓存后,立即去 MySQL 中读取数据可能会读到未更新完的旧值写入缓存,导致后面总是产生数据不一致。

    解决方式:延时双删策略

    public void updateUser(User user){
     String key = USER_KEY + user.getId();
     try {
         // 第一次删除 Redis 缓存
         redisTemplate.delete(key);
         // TODO 更新 MySQL
         jdbcTemplate.execute("DELETE SQL");
         // 暂停 2 秒
         try {
             TimeUnit.SECONDS.sleep(2);
         } catch (InterruptedException e) {e.printStackTrace();}
         // 第二次删除 Redis 缓存
         redisTemplate.delete(key);
     }catch (Exception e){e.printStackTrace();}
    }
    

    携带的问题:休眠时间怎么定?吞吐量低怎么办?

    休眠时间:

    • 估算:统计读数据的耗时,在此基础上加上白毫秒
    • 新启动一个后台监控程序:WatchDog 监控程序,加时

    吞吐量:

    • 第二次的删除作为异步删除
    public void updateUser(User user){
        String key = USER_KEY + user.getId();
        try {
            // 第一次删除 Redis 缓存
            redisTemplate.delete(key);
            // TODO 更新 MySQL
            jdbcTemplate.execute("DELETE SQL");
            // 暂停 2 秒
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {e.printStackTrace();}
            // 第二次删除 Redis 缓存
            // 异步删除,加大吞吐量
            CompletableFuture.supplyAsync(()-> redisTemplate.delete(key)).whenComplete((t, u)->{
                System.out.println("-------t:"+t);
                System.out.println("-------u:"+u);
            }).exceptionally(e->{
                System.out.println(e.getMessage());
                return true;
            }).get();
        }catch (Exception e){e.printStackTrace();}
    }
    
  • ( ! )先更新数据库,再删除缓存:利用回写机制

    • 可能在删除缓存之前就读到旧值,不过不影响最终数据一致性

    解决方案:使用消息队列

    可以把要删除的缓存值或者要更新的数据库值暂存到消息队列中(kafka / RabbitMQ)

    当程序没有成功删除缓存值或者更新数据库值时,可以从消息队列重新读取这些值,然后再次进行删除或更新。

    如果成功删除或更新,我们就把这些值从消息队列中去除,以免重复操作。

    如果超过一定的次数还没有成功,就需要向业务层发送报错信息,通知运维人员。

分布式场景下很难保证实时一致性,一般都是最终一致性。


4. Redis 和 MySQL 双写一致性案例

4.1 面试题

  1. MySQL 有记录改动(增删改),如何立即同步到 Redis?

binlog 日志监听:Canal

官方文档:alibaba/canal: 阿里巴巴 MySQL binlog 增量订阅&消费组件 (github.com)

4.2 案例实操

① MySQL 数据库端:

查看 MySQL 版本、当前主机的二进制日志(SHOW MASTER STATUS

查看 SHOW VARIABLES LIKE 'log_bin';

开启 MySQL 的 binlog 的写入功能:my.conf 重启

[mysqld]
log-bin=mysql-bin	# 开启binlog
binlog-format=ROW	# 选择 ROW 模式
server_id=1		    # 配置 MySQL  replcation需要定义,不要和canal的slaveId重复

授权 canal 连接 MySQL

DROP USER IF EXISTS 'canal'@'%';
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal';
GRANT ALL PRIVILEGES ON *.* TO 'canal'@'%' IDENTIFIED BY 'canal';
FLUSH PRIVILEGES;
SELECT * FROM mysql.user;

② canal 服务端(1.1.7 为例)

下载:Releases · alibaba/canal (github.com)

解压后配置:conf/example/instance.properties (注意适配的 jdk 版本)

canal.instance.master.address=192.168.56.44:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal

启动 canal:bin/startup.sh

检查 server 日志,查看 example 的日志

③ canal 客户端

<!-- canal -->
<dependency>
    <groupId>com.alibaba.otter</groupId>
    <artifactId>canal.client</artifactId>
    <version>1.1.0</version>
</dependency>
<!-- Spring jdbc -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-jdbc</artifactId>
    <version>6.0.6</version>
</dependency>
<!-- mysql驱动 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.33</version>
</dependency>
<!-- druid -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.2.22</version>
</dependency>
spring:
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    username: root
    password: xxxxxx
    url: jdbc:mysql://192.168.56.44:3306/db_bktest?useUnicode=true&characterEncoding=utf-8&useSSL=false
    driver-class-name: com.mysql.cj.jdbc.Driver
    druid:
      test-while-idle: false
public class RedisCanalClient {
    static RedisTemplate<String, Object> redisTemplate;
    private static final Integer _60SECONDS = 60;
    private static final String REDIS_IP_ADDR = "192.168.56.44";
    public static void main(String[] args){
        // 创建链接
        CanalConnector connector = CanalConnectors.newSingleConnector(
                new InetSocketAddress(REDIS_IP_ADDR, 11111),
                "example",
                "",
                ""
        );
        int batchSize = 1000;
        int emptyCount = 0;
        try {
            connector.connect();
//            connector.subscribe(".*\\..*");
            connector.subscribe("db_bktest.t_user");
            connector.rollback();
            int totalEmptyCount = 10 * _60SECONDS;
            while (emptyCount < totalEmptyCount) {
                Message message = connector.getWithoutAck(batchSize); // 获取指定数量的数据
                long batchId = message.getId();
                int size = message.getEntries().size();
                if (batchId == -1 || size == 0) {
                    emptyCount++;
                    System.out.println("empty count : " + emptyCount);
                    try {Thread.sleep(1000);} catch (InterruptedException e) {}
                } else {
                    emptyCount = 0;
                    // System.out.printf("message[batchId=%s,size=%s] \n", batchId, size);
                    printEntry(message.getEntries());
                }

                connector.ack(batchId); // 提交确认
                // connector.rollback(batchId); // 处理失败, 回滚数据
            }
            System.out.println("empty too many times, exit");
        } finally {
            connector.disconnect();
        }
    }

    private static void printEntry(List<Entry> entrys) {
        for (Entry entry : entrys) {
            if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
                continue;
            }
            RowChange rowChage = null;
            try {
                rowChage = RowChange.parseFrom(entry.getStoreValue());
            } catch (Exception e) {
                throw new RuntimeException("ERROR ## parser of eromanga-event has an error , data:" + entry.toString(),
                        e);
            }
            EventType eventType = rowChage.getEventType();
            System.out.println(String.format("================&gt; binlog[%s:%s] , name[%s,%s] , eventType : %s",
                    entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(),
                    entry.getHeader().getSchemaName(), entry.getHeader().getTableName(),
                    eventType));

            for (RowData rowData : rowChage.getRowDatasList()) {
                if (eventType == EventType.DELETE) {
                    redisDelete(rowData.getBeforeColumnsList());
                } else if (eventType == EventType.INSERT) {
                    redisInsert(rowData.getAfterColumnsList());
                } else {    // eventType == EventType.UPDATE
                    System.out.println("-------&gt; before");
//                    redisUpdate(rowData.getBeforeColumnsList());
                    System.out.println("-------&gt; after");
                    redisUpdate(rowData.getAfterColumnsList());
                }
            }
        }
    }

    private static void printColumn(List<Column> columns) {
        for (Column column : columns) {
            System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
        }
    }

    private static void redisInsert(List<Column> columns){
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns) {
            System.out.println(column.getName() + " : " + column.getValue() + "    insert=" + column.getUpdated());
            jsonObject.put(column.getName(),column.getValue());
        }
        if (!columns.isEmpty()){
            redisTemplate.opsForValue().set(columns.get(0).getValue(),jsonObject.toJSONString());
        }
    }

    private static void redisDelete(List<Column> columns){
        for (Column column : columns) {
            System.out.println(column.getName() + " : " + column.getValue() + "    delete=" + column.getUpdated());
        }
        if (!columns.isEmpty()){
            redisTemplate.delete(columns.get(0).getValue());
        }
    }

    private static void redisUpdate(List<Column> columns){
        JSONObject jsonObject = new JSONObject();
        for (Column column : columns) {
            System.out.println(column.getName() + " : " + column.getValue() + "    update=" + column.getUpdated());
            jsonObject.put(column.getName(),column.getValue());
        }
        if (!columns.isEmpty()){
            redisTemplate.opsForValue().set(columns.get(0).getValue(),jsonObject.toJSONString());
        }
    }
}

5. 实战 bitmap、hyperloglog、GEO

5.1 面试题

  1. 电商直播,商品评论,排序+展现+取前10条评论?
  2. 打卡签到如何统计?
  3. 统计点击量、访问量?
  4. 公司系统上线后,UV、PV、DAU 分别是多少?

对于亿级数据的收集+清洗+展现

痛点:存的进、取的快、多维度

5.2 统计的类型

  • 聚合统计:统计多个集合元素的聚合结果,就是交差并等集合统计

  • 排序统计

  • 二值统计

  • 基数统计

5.3 Hyperloglog

  • UV:Unique Visitor,独立访客,一般理解为客户端 ip(需要去重)
  • PV:Page View,页面浏览量
  • DAU:Daily Active User,日活跃用户量
  • MAU:Monthly Active User,月活跃用户量
PFADD key value1 [value2..]
PFCOUNT key
PFMERGE new_key key1 key2

bitmap:样本元素越多内存消耗急剧增大,难以管控,对于亿级统计不合适

概率算法(Hyperloglog 是一种概率算法的实现)

通过牺牲准确率来换取空间,对于不要求绝对准确率的场景下可以使用,因为概率算法不直接存储数据本身,通过一定的概率统计方法预估基数值,同时保证误差在一定范围内,由于又不存储数据故此可以大大节约空间。

Hyperloglog 的特点:有误差(误差仅仅在 0.81%左右

5.4 bitmap

结合下一章完成。


6. 布隆过滤器 BloomFilter *

6.1 面试题

  1. 有50亿个电话号码,如何快速准确的判断这些电话号码是否已经存在
  2. 布隆过滤器了解过吗?
  3. 安全连接网址,全球数十亿的网址判断?
  4. 黑名单校验?识别垃圾邮件?
  5. 白名单校验?识别出合法用户进行后续处理?

6.2 布隆过滤器是什么

由一个初值为零的 bit 数组和多个哈希函数构成,用来快速判断集合中是否存在某个元素

目的:减少内存占用

方式:不保存数据信息,只是在内存中做一个是否存在的标记 flag

是一种类似于 set 的数据结构,只是统计结果在巨量数据下有点小瑕疵,不够完美。

特点:高效地插入和查询,占用空间少,返回的结果是不确定性的+不够完美。

重点:一个元素如果判断结果为存在时,元素不一定存在;但是判断结果不存在时,则一定不存在。

  • 布隆过滤器可以添加元素,但是不能删除元素
  • 由于涉及 hashcode 判断依据,删掉元素会导致误判率增加。

总结:有是可能有,无是肯定无。

6.3 布隆过滤器原理

布隆过滤器是一种专门用来解决去重问题的高级数据结构,实质上是一个大型位数组和几个不同的无偏 hash 函数(无偏表示分布均匀)。由一个初值都为零的 bit 数组和多个哈希函数构成,用来快速判断某个数据是否存在。但是和 HyperLogLog 一样,他也有一点点不准确,存在一定的误判概率。

添加 key 时:

使用多个 hash 函数对 key 进行 hash 运算得到一个整数索引值,对位数组长度进行取模运算得到一个位置,每个 hash 函数都会得到一个不同的位置,将这几个位置都置1就完成了add操作。

查询 key 时:

只要有其中一位是零就表示这个 key 不存在,但如果都是1,则不一定存在对应的 key。

使用三步骤:

  • 初始化 bitmap

  • 添加占位坑

  • 判断是否存在

6.4 使用场景

  • 解决缓存穿透的问题,和 redis 结合 bitmap 使用
    • 将已存在数据的 key 存在布隆过滤器中,相当于在 Redis 前面挡着一个布隆过滤器
    • 当有新的请求时,先到布隆过滤器中查询是否存在
    • 如果布隆过滤器中不存在该条数据直接返回
    • 如果布隆过滤器中已存在,采取查询缓存 Redis,如果 Redis 里没有查询到则在去 MySQL 数据库
  • 黑名单校验,识别垃圾邮件
  • 安全连接网址,全球上10亿的网址判断

6.5 整合布隆过滤器

二进制数组构建过程:

  1. 预加载符合条件的记录
  2. 计算每条记录 hash 值
  3. 计算 hash 值对应的 bitmap 数组位置
  4. 修改值为1

步骤设计:

  • Redis 的 setbit / getbit
  • setbit 的构建过程
    • @PostConstruct 初始化白名单数据
    • 计算元素的 hash 值
    • 通过上一步的 hash 值算出对应的二进制数组的坑位
    • 将对应坑位的值修改为数字1,表示存在
  • getbit 查询是否存在
    • 计算元素的 hash 值
    • 通过上一步 hash 值算出对应的二进制数组的坑位
    • 返回对应坑位的值,0表示无,1表示存在

在这里插入图片描述

/**
* BloomFilterUtils 工具类
*/
@Slf4j
public class BloomFilterUtils {
    private static final String CACHE_KEY_USER = "user:";
    private static final int SLOT_SIZE = Integer.MAX_VALUE;
    @Resource private RedisTemplate<String,Object> redisTemplate;
    @Resource private UserMapper userMapper;
    /**
     * 初始化白名单数据 (whitlistuser)
     */
    @Async
    @PostConstruct
    public void init(){
        Long[] ids = userMapper.getAllIds();
        Arrays.stream(ids).forEach((id)->{
            // 白名单数据加载到布隆过滤器
            String key = CACHE_KEY_USER + id;
            // 计算 Hash 值,由于存在负数所以取绝对值
            long index = getSlot(key);
            // 设置 redis 里面的 bitmap 对应类型的槽位,将该值设置为 1
            redisTemplate.opsForValue().setBit("whitlistuser", index, true);
        });
    }
    @Async
    public void addWithBloomFilter(String checkName, String key){
        long index = getSlot(key);
        redisTemplate.opsForValue().setBit(checkName, index, true);
    }
    public boolean checkWithBloomFilter(String checkName, String key){
        long index = getSlot(key);
        return Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(checkName, index));
    }
    private long getSlot(String key){
        int hashValue = Math.abs(key.hashCode());
        return hashValue % SLOT_SIZE;
    }
}
private User cacheUserById(Long userId){
    String key = CACHE_KEY_USER + userId;
    //=========== 布隆过滤器 =============
    if (!bloomFilterUtils.checkWithBloomFilter("whitlistuser", key)){
        log.error("白名单无该用户,key:{}",key);
        throw new BusinessException("该用户不在白名单");
    }
    //===================================
    //  Redis 查询
    User user = (User) redisTemplate.opsForValue().get(key);
    if (user == null){
        synchronized (this){
            user = (User) redisTemplate.opsForValue().get(key);
            if (user == null){
                //  Redis 没查到,去 MySQL 查询
                user = this.getById(userId);
                if (user == null){
                    //  redis + mysql都没有数据,按需求记录 null 值的 key,列入黑名单等。
                    throw new UnknownUserException("未知用户");
                }
                // 保存至 redis 缓存
                redisTemplate.opsForValue().setIfAbsent(key, user,2L, TimeUnit.DAYS);
            }
        }
    }
    return user;
}

6.6 优缺点

  • 不能删除元素,否则会导致误判率增加,因为 hash 冲突同一个位置可能存的东西是多个共有的,删除掉一个元素的同时可能也把别的元素删了
  • 存在误判,不能精准过滤(有是可能有,无是肯定无)

6.7 布谷鸟过滤器(了解)

为了解决布隆过滤器不能删除元素的问题


7. 缓存四大问题

7.1 面试题

  1. 四大问题分别是什么,详细讲讲?
  2. 假如出现了缓存不一致,有哪些修补方案?

7.2 缓存预热

7.3 缓存雪崩

发生:

  • Redis 主机挂了,Redis 全盘崩溃,偏硬件运维
  • Redis 中有大量 key 同时过期大面积失效,偏软件开发

预防+解决:

  • Redis 中 key 设置永不过期 or 过期时间错开
  • Redis 缓存集群实现高可用
    • 主从+哨兵
    • Redis cluster
    • 开启 Redis 持久化机制 rdb / aof,尽快恢复缓存集群
  • 多缓存结合预防雪崩
    • ehcache本地缓存 + Redis缓存
  • 服务降级
    • Hystrix 或者阿里 sentinel 限流&降级
  • 加钱版:阿里云——云数据库Redis版

7.4 缓存穿透

请求去查一条记录,先查 Redis 无,再查 MySQL 无,都查询不到该条记录,但是请求每次都会打到数据库上面去,导致后台数据库压力暴增,这种现象叫做缓存穿透。

解决方式:空对象缓存或者缺省值+布隆过滤器

  • 空对象缓存或者缺省值
    • 回写增强:让 Redis 存入 MySQL 也查不到的 key 设置为缺省值,但是只能解决 key 相同的情况
  • Google 布隆过滤器 Guava 解决缓存穿透
public class GuavaUtils {
    private static final int BLOOM_SIZE = 1000000;
    private static final double fpp = 0.03;
    private static final BloomFilter<Long> bloomFilter = BloomFilter.create(Funnels.longFunnel(),BLOOM_SIZE,fpp);
    @Resource private UserMapper userMapper;

    @Async
    @PostConstruct
    public void init(){
        Long[] allIds = userMapper.getAllIds();
        Arrays.stream(allIds).forEach(bloomFilter::put);
    }
    public boolean isUserInWhitelist(Long userId) {
        return bloomFilter.mightContain(userId);
    }
    public void addUserInWhitelist(Long userId){
        bloomFilter.put(userId);
    }
}

7.5 缓存击穿

就是大量的请求同时查询一个 key 时,此时这个 key 正好在缓存失效了,导致大量的请求都打到数据库上。

方案一:差异失效时间,对于访问频繁的热点 key,干脆就不设置过期时间。

方案二:互斥更新,采用双检加锁策略。

7.6 小结

缓存问题产生原因解决方案
缓存更新方式数据变更、缓存时效性同步更新、失效更新、异步更新、定时更新
缓存不一致同步更新失败、异步更新增加重试、补偿任务、最终一致
缓存穿透恶意攻击空对象缓存、BloomFilter 过滤器
缓存击穿热点 key 失效互斥更新、随机退避、差异化失效时间
缓存雪崩缓存挂掉快速失败熔断、主从模式、集群模式

8. Redis 分布式锁

8.1 面试题

  1. Redis 除了拿来做缓存,还可以基于 Redis 有什么用法?

数据共享,分布式 Session、分布式锁、全局ID、计算器、点赞、位统计、购物车、轻量级消息队列、抽奖、点赞、签到、打卡、差集交集并集、热点新闻

  1. Redis 做分布式锁要注意什么?
  2. 用 setnx 实现的?这个合适吗?如何考虑分布式锁的可重入问题
  3. 如果 Redis 是单点部署的,会有什么问题?
  4. Redis 集群模式下比如主从模式,CAP 方面会不会有问题?

Redis 集群是 AP,Redis 单机是 C

  1. 介绍下 Redlock?
  2. Redis 分布式锁如何续期?看门狗知道吗?

8.2 锁的种类

  • 单机版同一个JVM虚拟机内:synchronized 或者 Lock 接口。
  • 分布式多个JVM虚拟机内:单机的线程锁机制不再起作用,资源类在不同的服务器之间共享了。

8.3 设计分布式锁

一个靠谱的分布式锁需要具备的条件:

  • 独占性:任何时刻只能有且仅有一个线程持有
  • 高可用:Redis 集群环境下,不能因为某个节点挂了而出现获取锁和释放锁失败的情况。高并发请求下,性能也要好
  • 防死锁:杜绝死锁,必须有超时控制机制或者撤销操作,有个兜底的终止跳出方案
  • 不乱抢:不能私下 unlock 别人的锁,只能自己加锁自己释放
  • 重入性:同一个节点的同一个线程如果获得锁之后,它可以再次获得这个锁

setnx key value

set key value [EX seconds] [PX milliseconds] [NX|XX]

重点:JUC 中 AQS 锁的规范落地参考 + 可重入考虑 + Lua脚本 + Redis命令一步步实现分布式锁。

8.4 基础案例

/**
 * 自旋重试,比较安全
 */
public void service(String nodeName, String threadName){
    String REDIS_LOCK = "LOCK";
    String lock = UUID.randomUUID().toString() + Thread.currentThread().getId();
    while (!redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, lock)){
        // 每隔 20ms 重试加锁
        try {TimeUnit.MILLISECONDS.sleep(50);} catch (InterruptedException e) {throw new RuntimeException(e);}
    }
    try {
        // TODO
        
    } finally {
        redisTemplate.delete(REDIS_LOCK);
    }
}

问题:没有过期时间,一旦微服务宕机,无法执行到 finally 释放锁导致一直在加锁状态

—— 宕机与过期 + 防止死锁 (v 1.1)

public void service(String nodeName, String threadName){
    String REDIS_LOCK = "LOCK";
    String lock = UUID.randomUUID().toString() + Thread.currentThread().getId();
    // 加上过期时间
    while (!redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, lock, 5L, TimeUnit.SECONDS)){
        // 每隔 20ms 重试加锁
        try {TimeUnit.MILLISECONDS.sleep(50);} catch (InterruptedException e) {throw new RuntimeException(e);}
    }
    try {
        // TODO
        
    } finally {
        redisTemplate.delete(REDIS_LOCK);
    }
}

问题: redisTemplate.delete(REDIS_LOCK); 可能会误删别人的锁。

—— 解决误删(v 1.2)

public void service(String nodeName, String threadName){
    String REDIS_LOCK = "LOCK";
    String lock = UUID.randomUUID().toString() + Thread.currentThread().getId();
    // 加上过期时间
    while (!redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, lock, 5L, TimeUnit.SECONDS)){
        // 每隔 20ms 重试加锁
        try {TimeUnit.MILLISECONDS.sleep(50);} catch (InterruptedException e) {throw new RuntimeException(e);}
    }
    try {
        // TODO
        
    } finally {
        // 判断锁是自己的才删除锁
        if (lock.equals(redisTemplate.opsForValue().get(REDIS_LOCK))){
           redisTemplate.delete(REDIS_LOCK);
        }
    }
}

问题:
if (lock.equals(redisTemplate.opsForValue().get(REDIS_LOCK))){ redisTemplate.delete(REDIS_LOCK); }
不是原子命令。

—— Lua 脚本解决多条命令原子化 (v 1.3)

Lua 脚本是一种轻量小巧的脚本语言,设计目的是嵌入应用程序中,为应用程序提供吗、灵活的扩展和定制功能。Redis 调用 Lua 脚本通过 eval 命令保证代码执行的原子性,直接用 return 返回脚本执行后的结果值

公式:

EVAL lua脚本 参数个数 [key [key ..]] [arg [arg ..]]

helloworld 入门:EVAL "return 'helloworld'" 0

Redis 官网:

EVAL "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 k1 v1
public void service(String nodeName, String threadName){
    String REDIS_LOCK = "LOCK";
    String lock = UUID.randomUUID().toString() + Thread.currentThread().getId();
    // 加上过期时间
    while (!redisTemplate.opsForValue().setIfAbsent(REDIS_LOCK, lock, 5L, TimeUnit.SECONDS)){
        // 每隔 20ms 重试加锁
        try {TimeUnit.MILLISECONDS.sleep(50);} catch (InterruptedException e) {throw new RuntimeException(e);}
    }
    try {
        // TODO
        
    } finally {
        // 判断锁是自己的才删除锁
        // lua 脚本实现原子性删除
        String luaScript = """
                    if redis.call('get',KEYS[1]) == ARGV[1] then
                        return redis.call('del',KEYS[1])
                    else
                        return 0
                    end
                """;
         redisTemplate.execute(new DefaultRedisScript(luaScript, Boolean.class), List.of(REDIS_LOCK), lock);
    }
}

**—— 可重入锁 + 设计模式 *** (v 1.4)

如何兼顾锁的可重入性问题?(同一个节点的同一个线程如果获得锁之后,它可以再次获得这个锁)

可重入锁,又叫递归锁,是指同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提是锁的是同一个对象),不会因为之前已经获取过还没释放而阻塞。(synchronized 和 ReentrantLock 都是可重入锁)

lock / unlock 配合可重入锁进行 AQS 源码分析:(lock 了几次就要 unlock 几次)

结论:采用 K K V 结构存储 —— hset (也可以用 zset)

通过 hset + Lua 脚本实现可重入锁:

private static final String REDIS_REENTRANT_LOCK = "REDISLOCK";
private static final int EXPIRE_MILLISECONDS = 30;
private final ThreadLocal<String> uuid = new ThreadLocal<>();
@Resource private RedisTemplate redisTemplate;

public Boolean lock() {
    String lockTag = uuid.get();
    if (lockTag == null || lockTag.isBlank()){
        uuid.set(UUID.randomUUID().toString().replaceAll("-","") + "-" + Thread.currentThread().getId());
        lockTag = uuid.get();
    }
    String luaScript = """
        			if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then
            			redis.call('hincrby',KEYS[1],ARGV[1],1)
                       	 redis.call('expire',KEYS[1],ARGV[2])
                         return 1
            		else
                         return 0
                	 end
                """;
    while (Boolean.FALSE.equals((redisTemplate.execute(new DefaultRedisScript(luaScript, Boolean.class), List.of(REDIS_REENTRANT_LOCK), lockTag, EXPIRE_MILLISECONDS)))){
           try {TimeUnit.MILLISECONDS.sleep(50);} catch (InterruptedException e) {throw new RuntimeException(e);}
    }
    return true;
}

/**
     * 释放锁
     */
public void unlock() {
    String lockTag = uuid.get();
    String luaScript = """
                	if redis.call('hexists',KEYS[1],ARGV[1]) == 0 then
                    	return 1
                    elseif redis.call('hincrby',KEYS[1],ARGV[1],-1) == 0 then
                    	return redis.call('del',KEYS[1])
                    else
                        return 0
                    end
                """;
    // 1=false, 0-true
    Long res = (Long) redisTemplate.execute(
                new DefaultRedisScript(luaScript, Long.class),
                List.of(REDIS_REENTRANT_LOCK),
                lockTag
    );
    if (res == null){
        uuid.remove();
        throw new RuntimeException("RedisReentrantLock unlock failed !!");
    }
    if (res.equals(1L)) {
        uuid.remove();
    }
}

**—— 自动续期 *** (v 1.5)

确保 RedisLock 过期时间大于业务执行时间的问题

public class RedisReentrantLock {
    private static final String REDIS_REENTRANT_LOCK = "REDISLOCK";
    private static final int EXPIRE_SECONDS = 30;
    private final InheritableThreadLocal<String> uuid = new InheritableThreadLocal<>();
    private TimerTask renewalTask;
    private static final Timer timer = new Timer(true); // 单例Timer,使用Daemon线程
    @Resource private RedisTemplate redisTemplate;
    /**
     * 加锁
     */
    public Boolean lock() {
        String lockTag = uuid.get();
        if (lockTag == null || lockTag.isBlank()){
            uuid.set(UUID.randomUUID().toString().replaceAll("-","") + "-" + Thread.currentThread().getId());
            lockTag = uuid.get();
        }
        String luaScript = """
                    if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then
                    	redis.call('hincrby',KEYS[1],ARGV[1],1)
                    	redis.call('expire',KEYS[1],ARGV[2])
                    	return 1
                    else
                    	return 0
                    end
                """;
        while (Boolean.FALSE.equals((redisTemplate.execute(new DefaultRedisScript(luaScript, Boolean.class), List.of(REDIS_REENTRANT_LOCK), lockTag, EXPIRE_SECONDS)))){
            try {TimeUnit.MILLISECONDS.sleep(50);}
            catch (InterruptedException e) {throw new RuntimeException(e);}
        }
//        System.out.println("--->"+lockTag);
        updateExpire(lockTag);
        return true;
    }
    /**
     * 自动续期
     */
    private void updateExpire(String lockTag){
        if (renewalTask != null) {
            renewalTask.cancel(); // 取消之前的任务
        }
        String luaScript = """
                            if redis.call('hexists',KEYS[1],ARGV[1]) == 1 then
                                return redis.call('expire',KEYS[1],ARGV[2])
                            else
                                return 0
                            end
                        """;
        renewalTask = new TimerTask() {
            @Override
            public void run() {
                try {
                    if (lockTag == null) {
                        System.err.println("Lock tag is null, not renewing.");
                        return;
                    }
//                    System.out.println("--->"+ lockTag);
                    if (Boolean.TRUE.equals(redisTemplate.execute(
                            new DefaultRedisScript(luaScript, Boolean.class),
                            List.of(REDIS_REENTRANT_LOCK),
                            lockTag,
                            EXPIRE_SECONDS))) {
                        System.out.println("Lock renewed.");
                        updateExpire(lockTag); // 继续续期
                    } else {
                        System.err.println("Failed to renew the lock.");
                    }
                } catch (Exception e) {
                    System.err.println("Error during lock renewal: " + e.getMessage());
                }
            }
        };
        timer.schedule(renewalTask, (EXPIRE_SECONDS * 1000 * 2) / 3); // 调整续期时间间隔
    }
    /**
     * 释放锁
     */
    public void unlock() {
        String lockTag = uuid.get();
        String luaScript = """
                    if redis.call('hexists',KEYS[1],ARGV[1]) == 0 then
                        return 1
                    elseif redis.call('hincrby',KEYS[1],ARGV[1],-1) == 0 then
                        return redis.call('del',KEYS[1])
                    else
                        return 0
                    end
                """;
        // 1=false, 0-true
        Long res = (Long) redisTemplate.execute(
                new DefaultRedisScript(luaScript, Long.class),
                List.of(REDIS_REENTRANT_LOCK),
                lockTag
        );
//        System.out.println(threadTag + " unlock done !");
        if (res == null){
            uuid.remove();
            throw new RuntimeException("RedisReentrantLock unlock failed !!");
        }
        if (res.equals(1L)) {
            uuid.remove();
//            System.out.println(threadTag + "removed !");
        }
    }
    @PreDestroy
    private void shutdownTimer(){
        timer.cancel();
        timer.purge();
    }
}

8.5 总结

synchronized 只能在单体系统使用,在分布式系统内无效。

  • Redis 分布式锁 setnx,缺点:不符合 AQS 规范,非可重入锁
    • 在代码层面要在 finally 释放锁
    • 需要有过期时长
    • 判断锁后删除的操作要原子性
  • 使用 hset 实现分布式锁的可重入性

9. Redlock 算法和底层源码分析

9.1 自研 Redis 分布式锁的要点

  • 按照 JUC 的 Lock 接口的设计规范
  • lock() 的加锁逻辑
    • 加锁:在 Redis 中,给 key 设置一个值,为了避免死锁,加上过期时间
    • 自旋:再重新尝试加锁的时候选择隔断自旋重试,而非递归重试,预防 OOM
    • 续期:给加锁的过期时间续上一段时间以保证在执行业务逻辑的时候一直拥有锁
  • unlock() 的释放锁逻辑
    • 只能删自己的锁,并保证原子性

9.2 Redis 分布式锁 - Redlock 红锁算法

它实现了比普通单实例方法更安全的 DLM(分布式锁管理器)

基础案例中的分布式锁有什么缺点:当 Redis master在线程A获得锁后宕机了,线程B会去获取上位的 Redis slave的锁,就可能线程A和线程B获得了相同的锁,是不安全的。

Redlock 算法设计理念

用来实现基于多个实例的分布式锁,锁变量由多个实例维护,即使有实例发生了故障,锁变量依旧存在,客户端还可以完成锁操作。

每一个实例都是 master,依次尝试从这多个实例获取锁,在超时时间内获得了半数以上的实例的锁,则锁才算获取成功。反之表示获取锁失败,客户端需要在每个实例上进行解锁。官方建议用 5 个实例。

9.3 使用 Redisson 搭建(单机版)

<dependency>
   <groupId>org.redisson</groupId>
   <artifactId>redisson</artifactId>
   <version>3.36.0</version>
</dependency>
/**
 * Redisson 配置(单机)
 * @return redisson
 */
@Bean
public Redisson redisson(){
    Config config = new Config();
    config.useSingleServer()
        .setAddress("redis://192.168.56.44:6375")
        .setDatabase(0)
        .setPassword("xxxxxx");
    return (Redisson) Redisson.create(config);
}
public void serviceBySRedisson(){
    RLock redissonLock = redisson.getLock("REDIS_LOCK");
    redissonLock.lock();
    try {
        // TODO
        
    } finally {
        // 需要判断是否被锁 && 是否是当前线程的锁 *
        if (redissonLock.isLocked() && redissonLock.isHeldByCurrentThread()){
            redissonLock.unlock();
        }
    }
}

9.4 Redisson 源码解析

从加锁、可重入、续命、解锁的方面来解析

  • Redis 分布式锁过期了,但是业务代码还没处理完:额外起一个线程,定期检查线程是否还持有锁,如果有则延长过期时间

Redisson 里面实现了这个方案,使用“看门狗”定期检查(每1/3的锁时间检查一次),如果线程还持有锁,则刷新过期时间。在获取锁成功后,给锁加一个 watchdog,watchdog 会起一个定时任务,在锁没有被释放且快要过期的时候会续期。

… (与基础案例差不多)

9.5 Redisson 集群版搭建

这些节点全是 master,完全独立,不存在出从关系 !

相对于单 Redis 节点来说,优点在于防止了单节点故障导致整个服务停止运行的情况。

保证分布式锁的有效性和安全性的要求如下:

  • 互斥性:任何时刻只能有一个 client 获取锁
  • 释放死锁:即使锁定资源的服务崩溃或者分区,任然能释放锁
  • 容错性:只要多数 Redis 节点在使用,client 就可以获取锁和释放锁

基于故障转移实现的 Redis 主从无法真正实现 RedLock。

基于 Redis 的 Redisson 红锁 RedissonRedLock 实现了 RedLock 介绍的加锁算法。也可以将多个 RLock 对象关联为一个红锁,每个 RLock 对象实例可以来自于不同的 Redisson 实例。

MultiLock 多重锁实现:(3个实例)

@Bean(name = "redissonClient1")
RedissonClient redissonClient1(){
    Config config = new Config();
    SingleServerConfig singleServerConfig = config.useSingleServer()
        .setAddress("redis://192.168.56.44:6373")
        .setTimeout(REDISSON_CLIENT_TIMEOUT)
        .setConnectionPoolSize(COON_POOL_SIZE)
        .setConnectionMinimumIdleSize(COON_MIN_IDLE_SIZE)
        .setPassword("xxxxxxx");
    return Redisson.create(config);
}
@Bean(name = "redissonClient2")
RedissonClient redissonClient2(){...}
@Bean(name = "redissonClient3")
RedissonClient redissonClient3(){...}
@Resource private RedissonClient redissonClient1;
@Resource private RedissonClient redissonClient2;
@Resource private RedissonClient redissonClient3;
public void serviceByCRedisson() {
    RLock lock1 = redissonClient1.getLock(REDIS_LOCK);
    RLock lock2 = redissonClient2.getLock(REDIS_LOCK);
    RLock lock3 = redissonClient3.getLock(REDIS_LOCK);
    RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2, lock3);
    lock.lock();
    try {
       	// TODO
        
    } finally {
        lock.unlock();
    }
}

10. Redis 的缓存过期淘汰算法

10.1 面试题

  1. 生产上Redis内存设置多少?
  2. 如何配置、修改Redis的内存大小?
  3. 内存满了怎么办?
  4. Redis清理内存的方式?定期删除和惰性删除了解过吗?
  5. Redis缓存淘汰策略有哪些?分别是什么?用哪个?
  6. Redis的LRU了解过吗?手写?
  7. LRU和LFU算法的区别?

10.2 Redis内存

redis.conf 中:

Redis 最大内存(默认是0):maxmemory <bytes>

如果不设置最大内存或者设置最大内存为0,在64位操作系统下不限制内存大小,再32位操作系统下最多使用3GB内存。一般推荐Redis设置内存为最大物理内存的四分之三

若Redis内存满了(Redis内存使用超过了设置的最大值),会报错 OOM。

若没有加上过期时间会导致数据写满 maxmemory,为了避免这种情况,Redis 有了内存淘汰策略。

10.3 Redis 删除数据

Redis 过期键的删除策略:

  • 立即删除:能最大保证数据的实时性,对内存很友好,但是会占用cpu的时间,造成额外的压力,产生大量的性能消耗,同时也会影响读取的效率。
  • 惰性删除:数据到达过期时间,不做处理,等待下次访问该数据的时候,再删除。对内存不太友好。
    • 开启惰性淘汰:lazyfree-lazy-eviction=yes
  • 定期删除:定期删除策略每隔一段时间执行一次删除过期键的操作并通过限制删除操作的执行时长和频率(随机抽取检查)来减少对cpu时间的影响。

都不够完美,需要缓存淘汰策略。

10.4 Redis 缓存淘汰策略

LRU 和 LFU:

  • LRU(Least Recently Used):最近最少使用页面置换算法。
  • LFU(Least Frequently Used):最近最不常用页面置换算法。
策略描述
noeviction不会驱逐任何key,即使内存达到上限也不置换,所有能引起内存增加的命令都会返回error(默认)
allkeys-lru对所有key使用LRU算法进行删除
volatile-lru对所有设置了过期时间的key使用LRU算法进行删除
allkeys-random对所有key进行随机删除
volatile-random对所有设置了过期时间的key随机删除
volatile-ttl删除马上要过期的key
allkeys-lfu对所有key使用LFU算法进行删除
volatile-lfu对所有设置了过期时间的key使用LFU算法进行删除

选择:

  • 在所有的 key 都是最近最经常使用,那么就需要选择 allkeys-lru 进行置换最近最不经常使用的 key,如果不确定使用哪种策略,推荐使用 allkeys-lru
  • 如果所有的 key 的访问概率都差不多的,那么可以选择 allkeys-random 策略去置换数据
  • 如果对数据有足够的了解,能够为 key 指定 hint(通过expire/ttl指定),那么可以选择 volatile-ttl 进行置换

10.5 其他建议

尽量避免 Big Key

开启惰性淘汰,lazyfree-lazy-eviction=yes


11. Redis 数据类型的底层数据结构

11.1 跳表

12. epoll函数和IO多路复用

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值