Redis缓存穿透、缓存雪崩、缓存击穿(并发) 分析以及解决方案附带案例详解

一、缓存穿透

(一)缓存穿透概念

缓存穿透,是指查询一个数据库一定不存在的数据。
正常的使用缓存流程大致是,数据查询先进行缓存查询,如果key不存在或者key已经过期,再对数据库进行查询,并把查询到的对象,放进缓存。如果数据库查询对象为空,则不放进缓存。
查询一个根部不存在的Key, 必然就会每次都去查询数据库,而每次查询都是空,每次又都不会进行缓存。假如有恶意攻击,就可以利用这个漏洞,对数据库造成压力,甚至压垮数据库。
代码流程如下:
点击这里查看redisutils https://blog.csdn.net/HBliucheng/article/details/112614392

public class redisServiceImpl {
    @Value("${userId}")
    private String userId;
    @Autowired
    private RedisUtils redisUtils;

    public String getById(int id) {
        /*
        *  1.参数传入对象主键ID
            2.根据redisKey从缓存中获取对象
            3.如果缓存对象不为空,直接返回
            4.如果缓存对象为空,进行数据库查询
            5.如果从数据库查询出的对象不为空,则放入缓存
        * */
        // 根据id构建redis主键ID
        String redisKey = userId + id;
        // 根据redisKey从缓存中获取对象的name值
        String redisValue = (String) redisUtils.get(redisKey);
        // 如果缓存对象不为空,直接返回
        if(null!=redisValue){
            //
            return redisValue;
        }
        // 如果缓存中未查询到值,查询数据库,然后放入缓存中
        // 枚举类MysqlData  模拟从mysql中查询数据
        String name = MysqlData.getById(id);
        // 如果数据库中查询到的值不为空则存入redis中,返回查询到的数据
        if(null!=name){
            redisUtils.set(redisKey,name);
            return name;
        }
        return null;
    }
}

(二)解决方案

1,缓存空对象

当存储层不命中后,即使返回的空对象也将其存储起来,同事设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护了后端数据源
在这里插入图片描述
解决案例代码

@Service
public class redisServiceImpl {
        @Value("${userId}")
        private String userId;
        @Autowired
        private RedisUtils redisUtils;

        public String getById(int id) {
        /*
        *  1.参数传入对象主键ID
            2.根据redisKey从缓存中获取对象
            3.如果缓存对象不为空,直接返回
            4.如果缓存对象为空,进行数据库查询
            5.如果从数据库查询出的对象不为空,则放入缓存
        * */
            // 根据id构建redis主键ID
            String redisKey = userId + id;
            // 根据redisKey从缓存中获取对象的name值
            String redisValue = (String) redisUtils.get(redisKey);
            // 如果缓存对象不为空,直接返回
            if(null!=redisValue){
                //
                return redisValue;
            }
            // 如果缓存中未查询到值,查询数据库,然后放入缓存中
            // 枚举类MysqlData  模拟从mysql中查询数据
            String name = MysqlData.getById(id);
            // 空值也保存到缓存中并设置过期时间
            redisUtils.setEx(redisKey,name,3000, TimeUnit.MICROSECONDS);
            return name;
        }
}

但是这中方法会存在两个问题:
1,如果空值能够被缓存起来,这就意味着缓存需要更多的空间存储更过的键,因为这当中可能会有很多的空值的键
2,即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对需要保存一致性的业务会有影响

2,布隆过滤器

(1)布隆过滤器原理以及介绍

在这里插入图片描述

在这里插入图片描述

(2)代码实现

a,布隆过滤器测试(谷歌)

pom文件

 <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>29.0-jre</version>
        </dependency>

1,测试数据不存在时

package com.example.redisdemo;

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

/**
 * 布隆滤器测试
 * @author LiuCheng
 * @data 2021/1/26 11:21
 */
@SpringBootTest
public class BloomFilterTest {
    /**
     * 预计要插入多少数据
     */
    private static int size = 1000000; //一百万
    /**
     * 误判率
     */
    private static double fpp = 0.001;
    /**
     * 布隆过滤器
     */
    private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), size, fpp);
    @Test
    public void test1(){
        // 插入一百万样本数据
        for (int i = 0; i < size; i++) {  // 从 0 ~ 1000000 (不包含1000000)
            bloomFilter.put(i);
        }


        // 用另外 十万测试数据,测试误判率
        int count = 0;

        for (int i = size; i < size + 100000; i++) {  //  1000000 ~ 1100000 (不包含1100000)

            if ( bloomFilter.mightContain(i)  ) {
                count++;
                System.out.println(i + "误判了");
            }
        }

        System.out.println("总共的误判数:" + count);
    }
}

测试结果
在这里插入图片描述
误判数据和fpp 参数有关

2,测试数据存在时

@Test
    public void test2(){
        // 插入样本数据
        for (int i = 0; i <size ; i++) {
            bloomFilter.put(i);
        }
        // 记数
        int count=0;
        for (int i = 0; i <size ; i++) {
//            本来存在的数据  判断不会有误差
            if(bloomFilter.mightContain(i)){
                count++;

            }
        }
        System.out.println("总次数 " +count);
    }

测试结果
在这里插入图片描述

b,Redis整合布隆过滤器

pom文件引入GA坐标

 <!-- Redis 客户端工具 redisson 实现对 Redisson 的自动化配置-->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.13.4</version>
        </dependency>

yml

spring:
  redis:
    host: 192.168.119.3
    port: 6379
#    connect-timeout: 3000
userId: "userId:"

1,redis布隆过滤器测试

package com.example.redisdemo;

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import org.junit.jupiter.api.Test;
import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.TransportMode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;

/**
 * Redis布隆滤器测试
 *
 * @author LiuCheng
 * @data 2021/1/26 11:21
 */
@SpringBootTest
public class RedisBloomFilterTest {
    @Value("${spring.redis.host}")
    private String redistHost;
    @Value("${spring.redis.port}")
    private Integer redistPort;
    /**
     * 预计要插入多少数据
     */
    private static int size = 10000; //一万
    /**
     * 误判率
     */
    private static double fpp = 0.001;


    /**
     * 布隆过滤器
     */
    @Test
    public void test1() {
        String redisAddress = "redis://" + redistHost + ":" + redistPort;
        Config config = new Config();
        config.useSingleServer().setAddress(redisAddress);
        RedissonClient redissonClient = Redisson.create(config);
        RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("sample");
        bloomFilter.tryInit(size, fpp);
        // 插入数据
        for (int i = 0; i < size; i++) {
            bloomFilter.add(String.valueOf(i));
        }
        // 用1000条数据测试
        int count = 0;
        for (int i = size; i < size + 1000; i++) {
            if (bloomFilter.contains(String.valueOf(i))) {
                count++;
                System.out.println("误判了 " + count);
            }
        }
        System.out.println("一起误判了 " + count);
        int sure = 0;
        for (int i = 0; i < size; i++) {
            if (bloomFilter.contains(String.valueOf(i))) {
                sure++;

            }
        }
        System.out.println("总次数 " + sure);
        redissonClient.shutdown();

    }

}

测试结果
在这里插入图片描述

2,redis布隆过滤器实战案例

config类,用于初始化布隆过滤器用

package com.example.redisdemo.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.TransportMode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * @author LiuCheng
 * @data 2021/1/14 14:25
 */
@Configuration
public class RedisConfig {
    /**
     * 预计要插入多少数据
     */
    private static int size = 100000; // 十万

    /**
     * 期望的误判率
     */
    private static double fpp = 0.001;
    @Value("${spring.redis.host}")
    private String redistHost;
    @Value("${spring.redis.port}")
    private Integer redistPort;
    
    @Bean
    public RBloomFilter redisBloomFilter(){
        String redissAddress="redis://"+redistHost+":"+redistPort;
        Config config = new Config();
//        config.setTransportMode(TransportMode.EPOLL);
        config.useSingleServer().setAddress(redissAddress);
        RedissonClient redissonClient = Redisson.create(config);
        RBloomFilter<Object> bloomFilter = redissonClient.getBloomFilter("userInfo");
        bloomFilter.tryInit(size,fpp);
        return  bloomFilter;


    }
}

数据存储层,相当于mysql数据库,简单点来写了个枚举

package com.example.redisdemo.data;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;

/**
 * mysql数据库
 * redis数据库还未安装,先用枚举模拟数据库的值
 * @author LiuCheng
 * @data 2021/1/26 9:52
 */
@Getter
@AllArgsConstructor
@Slf4j
public enum MysqlData {
    ZS(1001,"张三"),
    LI(1002,"李四"),
    WW(1003,"王五"),
    ZL(1004,"赵六")
    ;
    private int id;
    private String name;
    // 模拟dao层获取数据
    public static String getById(int id){
    // 只为记录是操作数据库,在mybatis中这里会打印sql
     log.info("操作数据库,查询条件   " +id);
        MysqlData[] mysqlDatas=values();
        for (MysqlData mysqlData:mysqlDatas) {
            if(mysqlData.getId()==id){
                return mysqlData.getName();
            }
        }
        return null;
    }
}

布隆过滤器初始化,将数据库存在的数据放入缓存中,实战中请查询数据库初始化

package com.example.redisdemo.common;

import com.alibaba.fastjson.JSONObject;
import com.example.redisdemo.data.MysqlData;
import org.redisson.api.RBloomFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;

/**
 *初始化, 将所有的 redisKey  "放" 入布隆过滤器中
 * @author LiuCheng
 * @data 2021/1/26 17:05
 */
@Component
public class RBloomFilterDataInit {
    @Value("${userId}")
    private String USERID_PREIFX;
    @Autowired
    private RBloomFilter rBloomFilter;

    @PostConstruct
    public void init(){
        MysqlData[] mysqlDatas=MysqlData.values();
        for (MysqlData mysqldata :mysqlDatas ) {
            String redisKey=USERID_PREIFX +mysqldata.getId();
            rBloomFilter.add(redisKey);

        }
    }
}

业务层

package com.example.redisdemo.service;

import com.example.redisdemo.data.MysqlData;
import com.example.redisdemo.utils.RedisUtils;
import org.redisson.api.RBloomFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
 * redisService层
 *
 * @author LiuCheng
 * @data 2021/1/26 9:50
 */
@Service
@Slf4j
public class RedisServiceImpl {
    @Value("${userId}")
    private String USERID_PREIFX;
    @Autowired
    private RedisUtils redisUtils;
    @Autowired
    private RBloomFilter rBloomFilter;
     public String bloomFilterGetById(int id) {
     log.info("查询数据 "  + id);
        String redisKey = USERID_PREIFX + id;
        /*  1.如果这个 key  本来存在的  一定不会有判断失误
            2.如果这 key    本来不存在的  可能会判断失误
            注意这里 把本来就不存在的 key 判断失误 ( 判断成了 存在 )
            最多也就是多查一次数据库而已, 没有什么大问题, 失误率可以自己设置, 设置小一点 如 0.001
            就可以挡住大部分的请求
        */
        // 如果存在
        if(rBloomFilter.contains(redisKey)){
            String redisVal= (String)redisUtils.get(redisKey);
            // 如果查询到了 直接返回
            if(null!=redisVal){
                return redisVal;
            }
            // 如果未查询到,则从数据库查询
            String name = MysqlData.getById(id);
            // 如果数据库查询到了,则存入缓存中
            if(null!=name){
                redisUtils.set(redisKey,name);
                return name;
            }
        }
        return null;
    }
}

测试结果
在这里插入图片描述

查询 1001数据,由于redis缓存中没有存入1001数据,所以第一次会走一次数据库,后就不会走数据,直接从缓存中查询。查询1006的数据,由于布隆过滤器的作用,不会走数据库查询直接返回空值

(3)总结

a,布隆过滤器判断数据存在时 准确率100%
b,布隆过滤器判断数据不存在时 准确率不是100%, 也就是说存在误差, 不准确, 由过滤器的误判率 fpp设置调节
c,布隆过滤器 可以判断某样东西 一定不存在 或者
布隆过滤器 可以判断某样东西 可能存在

(4)应用场景

判断一个元素是否存在一个集合中
a,检查一个用户是否在白名单中
b,在 FBI,一个嫌疑人的名字是否已经在嫌疑名单上
c,在网络爬虫里,一个网址是否被访问过
d,yahoo, gmail等邮箱垃圾邮件过滤功能

二、缓存雪崩

(一)缓存雪崩概念

缓存雪崩,是指在某一个时间段,缓存集中过期, 集中失效(可能是物理原因)。

举例:
1 马上就要到双十一零点( 00:00 ),很快就会迎来一波抢购,大量的并发访问, 这波热门商品时间比较集中的放入了缓存(可以理解为统一时间并发的) , 假设缓存是一个小时。那么到了凌晨一点钟 ( 01:00 ) 的时候,这批商品的缓存就都过期了。也就是缓存集中过期, 集中失效. 而再对这批商品的访问查询,就都落到了数据库上,对于数据库而言,就会产生周期性的压力波峰。
2 系统在某一个大量并发访问的时候, 突然缓存服务器宕机, 缓存突然全部丢失, 全部失效 (物理原因) 此时大量的缓存失效导致, 大量并发请求到达数据库DB,
给数据库造成压力冲击, 这就是缓存雪崩.

(二)缓存雪崩解决方法

1,时间失效

对应举例1中的情况
a, 数据预热,在正式部署之前,先把可能的数据预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中,将缓存失效时间分散开,让缓存的失效时间尽量均匀点,比如我们可以在原有的失效时间基础上增加一个随机值 而且热点的数据, 尽量过期时间设置的更加长一些, 冷门的数据可以相对设置过期时间短一些。
b,限流降级,在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数,比如对某个key只允许一个线程查询数据和写缓存,其他线程等待(该方法会在下面的缓存击穿中说明)
编码实现

 /**
     * 缓存雪崩解决方案
     * @return
     */
    public String snowsLideGetById(int id){
        log.info("查询数据 "  + id);
        //构建rediskey
        String redisKey=USERID_PREIFX+id;
        //布隆过滤器判断 redisKey 不存在
        if(!rBloomFilter.contains(redisKey)){
            return null;
        }
        // 从缓存中去获取数据
        String name = (String) redisUtils.get(redisKey);
        // 如果查询到了
        if(null!=name){
            return name;
        }
        // 未查询到时 从数据库查询数据,并返回且分散过期时间
        String userName = MysqlData.getById(id);
        if(null!=userName){
            Long tineOut=getExpireTime();
            // 放到缓存中, 时间取随机, 时间单位小时,按实际需求而定
            redisUtils.setEx(redisKey,userName,tineOut, TimeUnit.HOURS);
            return  userName;
        }
        return null;

    }
    private Long getExpireTime(){
        long min=24;
        long max=72;
        Random random = new Random();

        long timeOut= min+(long)random.nextDouble()*(max-min);
                return timeOut;
    }

测试结果
在这里插入图片描述
当这个值过期后,再次访问的时候,又会从新设置一个新值

2,物理因素

对应举例2中的情况
一个简单方案就是使用缓存集群 Redis Sentinel 或者 Redis Cluster
缓存集群高可用, 避免单节点故障; 避免了单机宕机造成缓存雪崩。
详细的缓存集群 Redis Sentinel搭建https://blog.csdn.net/HBliucheng/article/details/112845681

三、缓存击穿 (缓存并发)

(一)缓存击穿概念

缓存击穿. 是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个key进行访问,
当这个key在失效的瞬间,持续的大并发就击穿缓存,大量的并发直接请求数据库, 造成数据库的压力,
就像在一个屏障上凿开了一个洞。

(二)解决办法

1加锁

对缓存查询加锁, (首先是经过布隆过滤器, 必然不存在的Key直接返回) ,
如果key不在缓存中,就加锁,然后去查寻DB数据库, 然后放入缓存中,最后解锁;
这里的锁可以使用Redis锁; 其他进程如果发现有锁就等待,然后等到解锁后, 直接去缓存中查;
用锁的方式,会造成部分请求等待, 但问题不严重, 只是部分请求,短暂的等待;

案例代码

a,redis锁

package com.example.redisdemo.common;

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

/**
 * redis锁
 *
 * @author LiuCheng
 * @data 2021/1/27 10:38
 */
@Component
public class RedisLock {
    @Autowired
    private RedissonClient redissonClient;

    /**
     * 加锁
     * @param lockKey
     * @return
     */
    public RLock lock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock();
        return lock;
    }

    /**
     * 释放锁
     *
     * @param lockKey
     */
    public void unlock(String lockKey) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.unlock();
    }

    /**
     * 释放锁
     *
     * @param lock
     */
    public void unlock(RLock lock) {
        lock.unlock();
    }

    /**
     * 带超时的锁
     * @param lockKey
     * @param timeOut 超时时间   单位:秒
     * @return
     */
    public RLock lock(String lockKey, int timeOut) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(timeOut, TimeUnit.SECONDS);
        return lock;
    }

    /**
     * 带超时的锁
     *
     * @param lockKey
     * @param timeUnit 时间单位
     * @param timeOut  超时时间
     * @return
     */
    public RLock lock(String lockKey, TimeUnit timeUnit, int timeOut) {
        RLock lock = redissonClient.getLock(lockKey);
        lock.lock(timeOut, timeUnit);
        return lock;
    }

    /**
     * 尝试获取锁
     *
     * @param lockKey
     * @param waitTime  最多等待时间
     * @param leaseTime 上锁后自动释放锁时间
     * @return
     */
    public boolean tryLock(String lockKey, int waitTime, int leaseTime) {
        RLock lock = redissonClient.getLock(lockKey);
        try {
            return lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
            return  false;
        }
    }

    /**
     * 阐释获取锁
     * @param lockKey
     * @param timeUnit 时间单位
     * @param waitTime 最多等待时间
     * @param leaseTime 上锁后自动释放锁时间
     * @return
     */
    public boolean tryLock(String lockKey, TimeUnit timeUnit,int waitTime,int leaseTime){
        RLock lock = redissonClient.getLock(lockKey);
        try {
            return lock.tryLock(waitTime,leaseTime,timeUnit);
        } catch (InterruptedException e) {
            e.printStackTrace();
            return false;
        }
    }
}

b,业务代码

/**
     * redis锁
     * @param id
     * @return
     */
    public String lockGetById(int id){
        log.info("查询数据 "  + id);
        // 构建rediskey
        String redisKey=USERID_PREIFX+id;
        // 布隆过滤器判断,redisKey是否存在
        if(!rBloomFilter.contains(redisKey)){
            return null;
        }
        // 从缓存中取数据
        String name = (String) redisUtils.get(redisKey);
        if(null!=name){
            return name;
        }
        // 如果未查询到,则查询数据库,查询数据库之前先加锁
            redisLock.lock(String.valueOf(id));
        try {
            // 再一次查询缓存,是为了分布式并发情况下,其他的并发请求,能在解锁之后再次查询缓存
            String afterName = (String) redisUtils.get(redisKey);
            if(null!=afterName){
                return name;
            }
            // 从数据库中查询
            String selectName = MysqlData.getById(id);
            if(null!=selectName){
                redisUtils.set(redisKey,selectName);
                return  selectName;
            }
            return null;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        } finally {
            // 释放锁
            redisLock.unlock(String.valueOf(id));
        }
    }

2,设置过期时间永不过期

对一些已知的热门数据, 例如一些( 爆款商品 ) 直接设置 缓存永不过期
小提示:缓存数据不设置过期时间,默认永不过期,TTL值为 -1

Gitee地址 https://gitee.com/newACheng/redis-concurrent/tree/master/

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
对于 Redis 缓存的穿透、击穿和雪崩问题,可以采取以下解决方案: 1. 缓存穿透:当请求的数据在缓存中不存在时,会直接访问数据库,如果有恶意攻击者大量请求不存在的数据,会给数据库造成很大压力。解决方案可以是在查询前进行参数校验,比如对请求的参数进行合法性检查,或者使用布隆过滤器等技术来快速判断请求的数据是否存在。 2. 缓存击穿:当某个热点数据过期或被删除时,大量请求同时涌入,导致请求直接访问数据库。为了解决这个问题,可以使用互斥锁(Mutex)或者分布式锁来避免多个请求同时访问数据库。在获取锁之前,首先尝试从缓存获取数据,如果缓存中存在,则直接返回;如果不存在,则获取锁,并从数据库中获取数据并放入缓存,最后释放锁。 3. 缓存雪崩:当缓存中的大量数据同时过期时,会导致大量请求直接访问数据库,给数据库带来巨大压力。为了解决这个问题,可以采取以下措施: - 设置合理的缓存过期时间,使得不同数据的过期时间错开,避免同时失效。 - 使用热点数据预加载,提前将热点数据加载到缓存中,避免同时失效。 - 使用多级缓存架构,将缓存分为多个层级,不同层级的缓存设置不同的过期时间,从而降低缓存失效的风险。 - 引入限流和熔断机制,对请求进行限制,避免大量请求同时访问数据库。 通过以上措施,可以有效地解决 Redis 缓存穿透、击穿和雪崩问题。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值