Mybatis缓存机制

Mybatis 提供一级缓存和二级缓存的机制。
一级缓存是 SQLSession 级别的缓存在操作数据库时,每个 SqlSession 类的实例对象中有一个数据结构(HashMap)可以用于存储缓存数据。不同的 SqlSession 类的实例对象的数据区域(HashMap)是互不影响的。
二级缓存是 Mapper 级别的缓存,多个 SqlSession 类的实例对象操作同一个 Mapper 配置文件中的 SQL 语句,多个 SqlSession 类的实例对象可以共用二级缓存,二级缓存是跨 SqlSession 的。
Mybatis 的缓存模式如下图所示:
在这里插入图片描述
每个 SqlSession 类的实例对象自身有一个一级缓存,而查询同一个 Mapper 映射文件的 SqlSession 类的实例对象之间又共享同一个二级缓存。

一、一级查询缓存

一级查询缓存存在于每一个 SqlSession 类的实例对象中,当第一次查询某一个数据时,SqlSession 类的实例对象会将该数据存入一级缓存区域,在没有收到改变数据的请求之前,用户再次查询该数据,都会从缓存中获取该数据,而不是再次连接数据库进行查询。

1.1 一级缓存原理阐述

在这里插入图片描述
上图阐述了一个 SqlSession 类的实例对象下的一级缓存的工作原理。当第一次查询 id 为 1 的用户信息时,SqlSession 首先到一级缓存区域查询,如果没有相关数据,则从数据库查询。然后 SqlSession 将查询结果保存到一级缓存区域。在下一次查询时,如果 SqlSession 执行了 commit 操作(即执行了修改、添加和删除),则会清空它的一级缓存区域,以此来保证缓存中的信息是最新的,避免脏读现象发生。如果在这期间 SqlSession 一直没有执行 commit 操作修改数据,那么下一次查询 id 为 1 的用户信息时,SqlSession 在一级缓存中就会发现该信息,然后从缓存中获取用户信息。

1.2 一级缓存测试示例

Mybatis 默认支持一级缓存,不需要在配置文件中配置一级缓存的相关数据。
使用同一个 SqlSession 对象,对 id 为 1 的员工查询两次。

EmployeeCacheMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.EmployeeCacheMapper">

    <select id="getEmpById" resultType="com.example.pojo.Employee">
        select * from tbl_employee where id = #{id}
    </select>

</mapper>

EmployeeCacheMapper.java

package com.example.mapper;

import com.example.pojo.Employee;

public interface EmployeeCacheMapper {

    public Employee getEmpById(Integer id);
}

测试方法:

package com.example;

import com.example.mapper.EmployeeCacheMapper;
import com.example.pojo.Employee;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.testng.annotations.Test;

import java.io.IOException;
import java.io.InputStream;

public class MybatisCacheTest {

    public SqlSessionFactory getSqlSessionFactory() throws IOException {
        InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        return sqlSessionFactory;
    }

    @Test
    public void test01() throws IOException {
        SqlSessionFactory sqlSessionFactory = getSqlSessionFactory();
        SqlSession sqlSession = sqlSessionFactory.openSession();
        try{
            EmployeeCacheMapper mapper = sqlSession.getMapper(EmployeeCacheMapper.class);
            Employee emp01 = mapper.getEmpById(1);
            System.out.println(emp01);
            Employee emp02 = mapper.getEmpById(1);
            System.out.println(emp02);
            System.out.println(emp01 == emp02);
        } finally {
            sqlSession.close();
        }
    }
}

控制台结果:

[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==>  Preparing: select * from tbl_employee where id = ? 
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==> Parameters: 1(Integer)
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] <==      Total: 1
Employee{id=1, lastName='tomcat', email='tom@123.com', gender='0', department=null}
Employee{id=1, lastName='tomcat', email='tom@123.com', gender='0', department=null}
true

当第一次查询 id 为 1 的员工信息时,执行了 SQL 语句,而第二次查询 id 为 1 的员工信息时,没有任何的日志输出。而对于两次取出的员工对象的比对,也证明了第二次数据不是从数据库查询出来的,是从一级缓存中获取的。

1.3 一级缓存失效的四种情况

1、SqlSession 不同

测试语句如下:

@Test
public void test02() throws IOException {
    SqlSessionFactory sqlSessionFactory = getSqlSessionFactory();
    SqlSession sqlSession = sqlSessionFactory.openSession();
    try{
        EmployeeCacheMapper mapper = sqlSession.getMapper(EmployeeCacheMapper.class);
        Employee emp01 = mapper.getEmpById(1);
        System.out.println(emp01);
        SqlSession sqlSession1 = sqlSessionFactory.openSession();
        EmployeeCacheMapper mapper1 = sqlSession1.getMapper(EmployeeCacheMapper.class);
        Employee emp02 = mapper1.getEmpById(1);
        System.out.println(emp02);
        System.out.println(emp01 == emp02);
    } finally {
        sqlSession.close();
    }
}

控制台结果:

[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==>  Preparing: select * from tbl_employee where id = ? 
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==> Parameters: 1(Integer)
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] <==      Total: 1
Employee{id=1, lastName='tomcat', email='tom@123.com', gender='0', department=null}

[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==>  Preparing: select * from tbl_employee where id = ? 
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==> Parameters: 1(Integer)
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] <==      Total: 1
Employee{id=1, lastName='tomcat', email='tom@123.com', gender='0', department=null}
false

2、SqlSession 相同,查询条件不同

分别查询 id 为 1 和 id 为 2 个员工信息
测试方法:

 @Test
 public void test03() throws IOException {
     SqlSessionFactory sqlSessionFactory = getSqlSessionFactory();
     SqlSession sqlSession = sqlSessionFactory.openSession();
     try{
         EmployeeCacheMapper mapper = sqlSession.getMapper(EmployeeCacheMapper.class);
         Employee emp01 = mapper.getEmpById(1);
         System.out.println(emp01);
         Employee emp02 = mapper.getEmpById(2);
         System.out.println(emp02);
         System.out.println(emp01 == emp02);
     } finally {
         sqlSession.close();
     }
 }

控制台结果:

[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==>  Preparing: select * from tbl_employee where id = ? 
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==> Parameters: 1(Integer)
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] <==      Total: 1
Employee{id=1, lastName='tomcat', email='tom@123.com', gender='0', department=null}
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==>  Preparing: select * from tbl_employee where id = ? 
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==> Parameters: 2(Integer)
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] <==      Total: 1
Employee{id=2, lastName='jerry', email='jerry@123.com', gender='0', department=null}
false

3、SqlSession 相同,两次查询之间执行了增删改操作

增删改操作可能会对数据有影响
EmployeeCacheMapper.xml:

<insert id="addEmp" databaseId="mysql" parameterType="employee" useGeneratedKeys="true" keyProperty="id">
    insert into tbl_employee (id, last_name, email, gender)
    values (#{id}, #{lastName}, #{email}, #{gender})
</insert>

EmployeeCacheMapper.java

package com.example.mapper;

import com.example.pojo.Employee;

public interface EmployeeCacheMapper {
    public void addEmp(Employee employee);
}

测试方法:

@Test
public void test04() throws IOException {
    SqlSessionFactory sqlSessionFactory = getSqlSessionFactory();
    SqlSession sqlSession = sqlSessionFactory.openSession();
    try{
        EmployeeCacheMapper mapper = sqlSession.getMapper(EmployeeCacheMapper.class);
        Employee emp01 = mapper.getEmpById(1);
        System.out.println(emp01);
        Employee employee = new Employee();
        employee.setLastName("meichaofeng");
        employee.setEmail("meichaofeng@123.com");
        employee.setGender("0");
        mapper.addEmp(employee);
        Employee emp02 = mapper.getEmpById(1);
        System.out.println(emp02);
        System.out.println(emp01 == emp02);
    } finally {
        sqlSession.close();
    }
}

控制台结果:

[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==>  Preparing: select * from tbl_employee where id = ? 
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==> Parameters: 1(Integer)
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] <==      Total: 1
Employee{id=1, lastName='tomcat', email='tom@123.com', gender='0', department=null}
[main] [com.example.mapper.EmployeeCacheMapper.addEmp]-[DEBUG] ==>  Preparing: insert into tbl_employee (id, last_name, email, gender) values (?, ?, ?, ?) 
[main] [com.example.mapper.EmployeeCacheMapper.addEmp]-[DEBUG] ==> Parameters: null, meichaofeng(String), meichaofeng@123.com(String), 0(String)
[main] [com.example.mapper.EmployeeCacheMapper.addEmp]-[DEBUG] <==    Updates: 1
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==>  Preparing: select * from tbl_employee where id = ? 
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==> Parameters: 1(Integer)
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] <==      Total: 1
Employee{id=1, lastName='tomcat', email='tom@123.com', gender='0', department=null}
false

4、SqlSession 相同,但手动清除了一级缓存

测试方法:

 @Test
 public void test05() throws IOException {
     SqlSessionFactory sqlSessionFactory = getSqlSessionFactory();
     SqlSession sqlSession = sqlSessionFactory.openSession();
     try{
         EmployeeCacheMapper mapper = sqlSession.getMapper(EmployeeCacheMapper.class);
         Employee emp01 = mapper.getEmpById(1);
         System.out.println(emp01);
         sqlSession.clearCache(); // 清空一级缓存
         Employee emp02 = mapper.getEmpById(1);
         System.out.println(emp02);
         System.out.println(emp01 == emp02);
     } finally {
         sqlSession.close();
     }
 }

控制台结果:

[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==>  Preparing: select * from tbl_employee where id = ? 
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==> Parameters: 1(Integer)
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] <==      Total: 1
Employee{id=1, lastName='tomcat', email='tom@123.com', gender='0', department=null}
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==>  Preparing: select * from tbl_employee where id = ? 
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==> Parameters: 1(Integer)
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] <==      Total: 1
Employee{id=1, lastName='tomcat', email='tom@123.com', gender='0', department=null}
false

二、二级查询缓存

2.1 二级缓存原理阐述

一级缓存是基于同一个 SqlSession 类的实例对象的。但是,有些时候在 Web 工程中会将执行查询操作的方法封装在某个 Service 方法中,当查询完一次后,Service 方法结束,此时 SqlSession 类的实例对象就会关闭,一级缓存就会被清空。此时若再次调用 Service 方法查询同一个信息,新打开一个 SqlSession 类的实例对象,由于一级缓存区域是空的,因而无法从缓存中获取信息。
当出现上面的情况而无法使用一级缓存时,可以使用二级缓存。二级缓存存在于 Mapper 实例中,当多个 SqlSession 类的实例对象加载相同的 Mapper 文件,并执行其中的 SQL 配置时,它们就共享一个 Mapper 缓存。与一级缓存类似,当 SqlSession 类的实例对象加载 Mapper 进行查询时,会先去 Mapper 的缓存区域寻找该值,若不存在,则去数据库查询,然后将查询出来的结果存储到缓存区域,待下查询相同数据时从缓存区域中获取。当某个 SqlSession 类的实例对象执行了增、删、改等改变数据的操作时,Mapper 实例都会清空其二级缓存。
二级缓存原理如图所示:
在这里插入图片描述
与一级缓存相比,二级缓存的范围更大。多个 SqlSession 类的实例对象可以共享一个 Mapper 的二级缓存区域。一个 Mapper 有一个自己的二级缓存区域(按照 namespace 划分),两个 Mapper 的 namespace 如果相同,name这两个 Mapper 执行 SQL 查询会被缓存在同一个二级缓存中。
要开启二级缓存,需要执行两部操作。

第一步,在 Mybatis 的全局配置文件中配置 setting 属性,设置名为“cacheEnabled”的属性值为“true”即可:

<settings>
    <setting name="cacheEnabled" value="true"/>
</settings>

第二步,在需要开启二级缓存的具体 mapper.xml 文件中开启二级缓存

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.EmployeeCacheMapper">
    <!--开启本 Mapper 的 namespace 下的二级缓存-->
    <cache></cache>
</mapper>

第三步,POJO 需要实现 Serializable 接口

2.2 二级缓存相关属性

<cache eviction="" type="" flushInterval="" readOnly="" size="" blocking=""></cache>
  • eviction:缓存回收策略:
    LRU—最近最少使用的:移除最长时间不被使用的对象
    FIFO—先进先出:按对象进入缓存的顺序来移除它们
    SOFT—软引用:移除基于垃圾回收器状态和软引用规则的对象
    WEAK—弱引用:更积极地移除基于垃圾回收器状态和弱引用规则的对象。

  • flushInterval:刷新间隔,单位毫秒。缓存多长时间清空一次。
    默认情况是不设置,也就是没有刷新间隔,缓存仅仅调用语句时刷新

  • size:引用数目,正整数
    代表缓存最多可以存储多少个对象,太大容易导致内存溢出

  • readOnly:只读,true/false
    true:只读缓存,会给所有调用者返回缓存对象的相同实例(直接将数据在缓存中的引用交给用户)。因此这些对象不能被修改。这提供了很重要的性能优势。
    false:读写缓存,会返回缓存对象的拷贝(通过序列化)。这会慢一些,但是安全,因此默认是false

  • type:指定自定义缓存的全类名

2.3 二级缓存测试实例

二级缓存注意事项:

  • 二级缓存默认不开启,需要手动配置
  • Mybatis 提供二级缓存的接口以及实现,缓存实现要求 POJO 实现 Serializable 接口
  • 二级缓存在 SqlSession 关闭或提交之后才会生效

EmployeeCacheMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.EmployeeCacheMapper">
    <!--开启本 Mapper 的 namespace 下的二级缓存-->
    <cache></cache>

    <select id="getEmpById" resultType="com.example.pojo.Employee">
        select * from tbl_employee where id = #{id}
    </select>
</mapper>

EmployeeCacheMapper.java

package com.example.mapper;

import com.example.pojo.Employee;

public interface EmployeeCacheMapper {
    public Employee getEmpById(Integer id);
}

测试代码:

@Test
public void test06() throws IOException {
    SqlSessionFactory sqlSessionFactory = getSqlSessionFactory();
    SqlSession sqlSession = sqlSessionFactory.openSession();
    SqlSession sqlSession2 = sqlSessionFactory.openSession();
    try{
        EmployeeCacheMapper mapper = sqlSession.getMapper(EmployeeCacheMapper.class);
        EmployeeCacheMapper mapper2 = sqlSession2.getMapper(EmployeeCacheMapper.class);
        Employee emp01 = mapper.getEmpById(1);
        System.out.println(emp01);
        sqlSession.close();
        Employee emp02 = mapper2.getEmpById(1);
        System.out.println(emp02);
        System.out.println(emp01 == emp02);
        sqlSession2.close();
    } finally {

    }
}

控制台结果

[main] [com.example.mapper.EmployeeCacheMapper]-[DEBUG] Cache Hit Ratio [com.example.mapper.EmployeeCacheMapper]: 0.0
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==>  Preparing: select * from tbl_employee where id = ? 
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] ==> Parameters: 1(Integer)
[main] [com.example.mapper.EmployeeCacheMapper.getEmpById]-[DEBUG] <==      Total: 1
Employee{id=1, lastName='tomcat', email='tom@123.com', gender='0', department=null}
[main] [com.example.mapper.EmployeeCacheMapper]-[DEBUG] Cache Hit Ratio [com.example.mapper.EmployeeCacheMapper]: 0.5
Employee{id=1, lastName='tomcat', email='tom@123.com', gender='0', department=null}
false

总结:

  • 第二查询是从二级缓存中拿到的数据,并没有发送新的SQL,两次返回的Employee 实例对象并不是同一个对象,而是缓存对象的拷贝(通过序列化)
  • POJO 如果不实现序列化,程序会报错,会提示 Employee 未实现序列化
Caused by: java.io.NotSerializableException: com.example.pojo.Employee
	at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1184)
	at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:348)
	at java.util.ArrayList.writeObject(ArrayList.java:768)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at java.io.ObjectStreamClass.invokeWriteObject(ObjectStreamClass.java:1154)
	at java.io.ObjectOutputStream.writeSerialData(ObjectOutputStream.java:1496)
	at java.io.ObjectOutputStream.writeOrdinaryObject(ObjectOutputStream.java:1432)
	at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1178)
	at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:348)
	at org.apache.ibatis.cache.decorators.SerializedCache.serialize(SerializedCache.java:97)
	... 36 more

三、缓存有关设置

  • 全局 setting 的 cacheEnabled
    配置二级缓存的开关。一级缓存一直是打开的

  • select 标签的 useCache 属性
    配置这个 select 是否使用二级缓存。一级缓存一直是使用的

  • sql 标签的 flushCache 属性
    增删改默认 flushCache=true。sql 执行以后,会同时清空一级和二级缓存。查询默认 flushCache=false

  • sqlSession.clearCache()
    只是用来清除一级缓存

  • 当在某一个作用域(一级缓存 Session/二级缓存 namespace)进行了增删改操作后,默认该作用于下所有的 select 中的缓存将被clear

四、Mybatis 和 ehcache 缓存框架整合

  • Ehcache 具有快速、精干等特点,是 Hibernate 中默认的 CacheProvider
  • Mybatis 定义了 Cache 接口方便我们进行自定义扩展

4.1 整合步骤

  • 导入 ehcache 包,以及整合包

pom.xml

<dependency>
  <groupId>org.ehcache</groupId>
  <artifactId>ehcache</artifactId>
  <version>3.9.9</version>
</dependency>
<dependency>
  <groupId>org.mybatis.caches</groupId>
  <artifactId>mybatis-ehcache</artifactId>
  <version>1.2.2</version>
</dependency>
  • 编写ehcache.xml 配置文件
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="../config/ehcache.xsd">
    <!--diskStore:缓存数据持久化的⽬录 地址 -->
    <diskStore path="ehcache" />
    <defaultCache
            maxElementsInMemory="1000"
            maxElementsOnDisk="10000000"
            eternal="false"
            overflowToDisk="false"
            diskPersistent="true"
            timeToIdleSeconds="120"
            timeToLiveSeconds="120"
            diskExpiryThreadIntervalSeconds="120"
            memoryStoreEvictionPolicy="LRU">
    </defaultCache>
</ehcache>
  • 在 mapper.xml 文件中配置 cache 标签
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mapper.EmployeeCacheMapper">
    <!--开启本 Mapper 的 namespace 下的二级缓存-->
    <cache type="org.mybatis.caches.ehcache.EhcacheCache"></cache>
</mapper>

4.2 cache-ref 标签

参照缓存:若想在命名空间中共享相同的缓存配置和实例,可以使用 cache-ref 标签来引用另外一个缓存。
例如在 EmployeeMapper.xml 中引用 EmployeeCacheMapper.xml 中的缓存配置

<cache-ref namespace="com.example.mapper.EmployeeCacheMapper"/>

4.3 应用场景与局限性

参考自Java3y大佬的文章

4.3.1 应用场景

对查询频率高,变化频率低的数据建议使用二级缓存。
对于访问多的查询请求且用户对查询结果实时性要求不高,此时可采用 Mybatis 二级缓存技术降低数据库访问量,提高访问速度。
业务场景比如:

  • 耗时较高的统计分析 SQL
  • 电话账单查询 SQL 等

实现方法如下:通过设置刷新间隔时间,由 Mybatis 每隔一段时间自动清空缓存、根据数据变化频率设置缓存刷新间隔 flushInterval,比如设置为 30 分钟、60 分钟、24 小时等,根据需求而定。

4.3.2 局限性

Mybatis 二级缓存对细粒度的数据级别的缓存实现不好,比如如下需求:对商品信息进行缓存,由于商品信息查询访问量大,但是要求用户每次都能查询最新的商品信息,此时如果使用 Mybatis 的二级缓存就无法实现当一个商品变化时只刷新该商品的缓存信息而不刷新其他商品的信息,因为 Mybatis 的二级缓存区域以 Mapper 为单位划分,当一个商品信息变化会将所有商品信息的缓存数据全部清空。解决此类问题需要在业务层根据需求对数据有针对性缓存。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值