Mybatis(六): 缓存模块详解

Mybatis(六): 缓存模块详解

mybatis


今天我们进行 Mybatis相关内容的第六篇文章,在本篇文章中我们将介绍下 Mybatis的缓存模块。

相信大家都听过Mybatis的缓存,应该都知道其有一级二级缓存,那么Mybatis的缓存的结构是怎样的以及其是如何工作的呢?相信看完本篇内容后你能找到答案。

1 模块结构

缓存模块位于org.apache.ibatis.cache包下,这个包结构如下:

image-20210914164822319

Mybatis的缓存模块使用了装饰器模式,这里不再对这个设计模式进行介绍了,不了解的同学可以自行去学习,Mybatis的缓存模块结构如下:

  • Cache 缓存的顶层接口 装饰器模式中的组件
  • impl 这个包下定义了Cache的基本实现类 目前只有一个PerpetualCache实现 装饰器模式中的具体组件
  • decorators 里面定义了多种类型的装饰器 其实也是Cache的实现类,并维护了一个Cache对象
    • BlockingCache 阻塞装饰器,只能由一个线程进行查询操作
    • FifoCache 限制大小按照先进先出的规则删除缓存的装饰器
    • LoggingCache 日志装饰器 具有记录操作次数和打印日志的功能
    • LruCache 限制大小按照LRU规则删除缓存的装饰器
    • ScheduledCache 按照一定频率删除缓存的装饰器
    • SerializedCache 序列化装饰器
    • SoftCache 软引用装饰器
    • SynchronizedCache 同步装饰器
    • TransactionalCache 二级缓存缓冲区
    • WeakCache 弱引用装饰器
  • CacheKey Mybatis中缓存的key对应的对象
  • TransactionalCacheManager 二级缓存缓冲区管理器 里面会调用TransactionalCache对应的方法

2 CachePerpetualCache

Cache接口是缓存的顶层接口,这个接口中定义了缓存应该具有的功能,其源码如下:

public interface Cache {
    // 获取缓存对象的id
    String getId();

    // 向缓存中添加数据 key是CacheKey对象 value是查询结果
    void putObject(Object key, Object value);

    // 查询缓存
    Object getObject(Object key);

    // 删除缓存
    Object removeObject(Object key);

    // 清空缓存
    void clear();

    // 获取缓存的数量
    int getSize();

    default ReadWriteLock getReadWriteLock() {
        return null;
    }
}

PerpetualCacheCache接口的基本实现,其使用的是一个Map实现了缓存的功能,其源码如下:

public class PerpetualCache implements Cache {
    // 缓存的标识
    private final String id;
    // 存储缓存的map
    private final Map<Object, Object> cache = new HashMap<>();

    public PerpetualCache(String id) {
        this.id = id;
    }

    @Override
    public String getId() {
        return id;
    }

    @Override
    public int getSize() {
        return cache.size();
    }

    @Override
    public void putObject(Object key, Object value) {
        cache.put(key, value);
    }

    @Override
    public Object getObject(Object key) {
        return cache.get(key);
    }

    @Override
    public Object removeObject(Object key) {
        return cache.remove(key);
    }

    @Override
    public void clear() {
        cache.clear();
    }

    @Override
    public boolean equals(Object o) {
        if (getId() == null) {
            throw new CacheException("Cache instances require an ID.");
        }
        if (this == o) {
            return true;
        }
        if (!(o instanceof Cache)) {
            return false;
        }

        Cache otherCache = (Cache) o;
        return getId().equals(otherCache.getId());
    }

    @Override
    public int hashCode() {
        if (getId() == null) {
            throw new CacheException("Cache instances require an ID.");
        }
        return getId().hashCode();
    }

}

3 装饰器

装饰器其实也是Cache的一个实现类,这些类都在decorators路径下,在上面我们已经列举了相应的类,这里我们分别介绍下这些装饰器的实现逻辑。

3.1 BlockingCache

BlockingCache是一个阻塞装饰器,这个装饰器可以保证在同一时刻只有一个线程能调用查询缓存的方法。

这个类的属性如下:

// 阻塞时长
private long timeout;
// Cache对象
private final Cache delegate;
// 存储key对应的CountDownLatch对象   用于实现加锁逻辑
private final ConcurrentHashMap<Object, CountDownLatch> locks;

BlockingCache.putObject的逻辑如下:

public void putObject(Object key, Object value) {
    try {
        // 添加缓存
        delegate.putObject(key, value);
    } finally {
        // 释放锁
        releaseLock(key);
    }
}

这里的逻辑是调用Cache对象的putObject方法,之后调用releaseLock方法,这个方法的逻辑如下:

private void releaseLock(Object key) {
    // 删除map中的key
    CountDownLatch latch = locks.remove(key);
    if (latch == null) {
        throw new IllegalStateException("Detected an attempt at releasing unacquired lock. This should never happen.");
    }
    // 释放同步状态
    latch.countDown();
}

BlockingCache.getObject方法的逻辑如下:

@Override
public Object getObject(Object key) {
    // 获取key的锁
    acquireLock(key);
    // 查询缓存
    Object value = delegate.getObject(key);
    if (value != null) {
        // 释放锁  删除掉locks中的key
        releaseLock(key);
    }
    return value;
}
// 获取锁
private void acquireLock(Object key) {
    // 创建CountDownLatch对象
    CountDownLatch newLatch = new CountDownLatch(1);
    while (true) {
        // 如果不存在则添加返回null   如果存在返回当前值
        CountDownLatch latch = locks.putIfAbsent(key, newLatch);
        // 没有其他线程在读数据
        if (latch == null) {
            break;
        }
        try {
            if (timeout > 0) {
                // CountDownLatch中的方法   在一定时间内获取到同步状态
                boolean acquired = latch.await(timeout, TimeUnit.MILLISECONDS);
                if (!acquired) {
                    // 未获取到抛出异常
                    throw new CacheException(
                        "Couldn't get a lock in " + timeout + " for the key " + key + " at the cache " + delegate.getId());
                }
            } else {
                // 获取同步状态
                latch.await();
            }
        } catch (InterruptedException e) {
            throw new CacheException("Got interrupted while trying to acquire lock for key " + key, e);
        }
    }
}

看到这里相信大家应该能明白这个类是如何实现阻塞功能的了吧,通过向ConcurrentHashMap中添加数据,添加成功则表示获取锁成功,否则调用CountDownLatch.await()方法等待其他线程释放同步状态。

3.2 FifoCache

这个装饰器是限制缓存的大小(默认为1024),当缓存数量超过设定值后,会按照先进先出的规则来删除最早添加的缓存。这个类的属性如下:

//被装饰的Cache对象
private final Cache delegate;
// 记录key进入缓存的顺序
private final Deque<Object> keyList;
// 缓存项的最大数量
private int size;

我们只看下这个类的putObject()方法,其源码如下:

@Override
public void putObject(Object key, Object value) {
    // 判断是否超过大小   并清理之前的缓存
    cycleKeyList(key);
    delegate.putObject(key, value);
}

private void cycleKeyList(Object key) {
    // 添加到尾部
    keyList.addLast(key);
    if (keyList.size() > size) {
        // 从头部移除
        Object oldestKey = keyList.removeFirst();
        // 从缓存中删除
        delegate.removeObject(oldestKey);
    }
}

在这个类中维护了一个队列,用来记录缓存添加的顺序,当添加数据时会先在这个队列的尾部添加数据,如果这是长度超过了设定值,则获取队列的第一个缓存的key,然后从缓存中移除。

3.3 LruCache

这个装饰器也会限制缓存的大小,其会按照近期最少使用的方法进行缓存的删除,其属性如下:

// 被修饰的缓存对象
private final Cache delegate;
// 使用的是LinkedHashMap 调用setSize时会为其设值
private Map<Object, Object> keyMap;
// 用来记录最少使用的key
private Object eldestKey;

这个类中有一个setSize()的方法,会在这个类的构造方法中进行调用,源码如下:

public void setSize(final int size) {
    // 创建一个LinkedHashMap
    keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
        private static final long serialVersionUID = 4267176411845948333L;
        // LinkedHashMap.put()方法中会调用这个方法判断是否超长
        @Override
        protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
            boolean tooBig = size() > size;
            if (tooBig) {
                eldestKey = eldest.getKey();
            }
            return tooBig;
        }
    };
}

这个类的putObject方法逻辑如下:

public void putObject(Object key, Object value) {
    delegate.putObject(key, value);
    
    cycleKeyList(key);
}

private void cycleKeyList(Object key) {
    // keyMap中添加数据
    keyMap.put(key, key);
    // 如果最少使用的key不为空
    if (eldestKey != null) {
        // 删除最少使用的缓存
        delegate.removeObject(eldestKey);
        // 设置为空
        eldestKey = null;
    }
}

这个类的getObject方法逻辑如下:

public Object getObject(Object key) {
    keyMap.get(key); // touch
    return delegate.getObject(key);
}

这个类的LRU算法是通过LinkedHashMap实现的,重写了LinkedHashMapremoveEldestEntry方法,当添加元素时会调用到这个方法,在这里判断是否超过长度,如果超过则删除最少使用的缓存。

3.4 LoggingCache

这个装饰器会记录查询缓存和查询到缓存的数量,这个类的字段如下:

// 日志对象
private final Log log;
// Cache对象
private final Cache delegate;
// 获取缓存的数量
protected int requests = 0;
// 获取到缓存的数量
protected int hits = 0;

这个类的getObject方法如下:

public Object getObject(Object key) {
    // requests加一
    requests++;
    final Object value = delegate.getObject(key);
    if (value != null) {
        // 查询到缓存 hits加一
        hits++;
    }
    if (log.isDebugEnabled()) {
        // 如果支持debug   打印日志
        log.debug("Cache Hit Ratio [" + getId() + "]: " + getHitRatio());
    }
    return value;
}

3.5 ScheduledCache

这个装饰器会以一定的频率清空缓存,这个类的字段如下:

// 缓存装饰器
private final Cache delegate;
// 清理间隔 默认为一小时
protected long clearInterval;
// 上次清理时间
protected long lastClear;

这个实现的也比较简单哈,当调用putObjectgetObjectremoveObject方法时都会调用clearWhenStale方法,判断下是否需要清除缓存,这个方法如下:

private boolean clearWhenStale() {
    if (System.currentTimeMillis() - lastClear > clearInterval) {
        clear();
        return true;
    }
    return false;
}

3.6 SerializedCache

这个装饰器提供了序列化的功能,在存储缓存时会将value进行序列化,当查询缓存时再进行反序列化,这两个方法逻辑如下:

// 序列化
private byte[] serialize(Serializable value) {
    try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
         ObjectOutputStream oos = new ObjectOutputStream(bos)) {
        oos.writeObject(value);
        oos.flush();
        return bos.toByteArray();
    } catch (Exception e) {
        throw new CacheException("Error serializing object.  Cause: " + e, e);
    }
}
// 反序列化
private Serializable deserialize(byte[] value) {
    SerialFilterChecker.check();
    Serializable result;
    try (ByteArrayInputStream bis = new ByteArrayInputStream(value);
         ObjectInputStream ois = new CustomObjectInputStream(bis)) {
        result = (Serializable) ois.readObject();
    } catch (Exception e) {
        throw new CacheException("Error deserializing object.  Cause: " + e, e);
    }
    return result;
}

3.7 SoftCacheWeakCache

这两个装饰器的实现逻辑很相似,唯一不同的时一个是一个是将value封装为SoftReference,一个封装为WeakReference。我们这里就看下SoftCache的实现逻辑。

SoftCache的成员变量如下:

// 会将最近使用的缓存添加到该队列,避免GC回收
private final Deque<Object> hardLinksToAvoidGarbageCollection;
// 引用队列   用于记录被GC回收的缓存项
private final ReferenceQueue<Object> queueOfGarbageCollectedEntries;
private final Cache delegate;
// 强连接的个数 默认为256 当长度超过该值后会从hardLinksToAvoidGarbageCollection移除数据
private int numberOfHardLinks;

这个缓存中存储数据会将value封装成一个SoftEntry对象,这个类如下:

private static class SoftEntry extends SoftReference<Object> {
    private final Object key;

    SoftEntry(Object key, Object value, ReferenceQueue<Object> garbageCollectionQueue) {
        super(value, garbageCollectionQueue);
        this.key = key;
    }
}

SoftCache.putObject方法逻辑如下:

public void putObject(Object key, Object value) {
    // 清除已经被GC的数据
    removeGarbageCollectedItems();
    delegate.putObject(key, new SoftEntry(key, value, queueOfGarbageCollectedEntries));
}

putObject方法会先调用清除被GC的缓存,然后再进行数据保存,清除GC的缓存逻辑如下:

private void removeGarbageCollectedItems() {
    SoftEntry sv;
    while ((sv = (SoftEntry) queueOfGarbageCollectedEntries.poll()) != null) {
        delegate.removeObject(sv.key);
    }
}

SoftCache.getObject的逻辑如下

public Object getObject(Object key) {
    Object result = null;
    @SuppressWarnings("unchecked") // assumed delegate cache is totally managed by this cache
    SoftReference<Object> softReference = (SoftReference<Object>) delegate.getObject(key);
    if (softReference != null) {
        result = softReference.get();
        if (result == null) {
            // 如果已经被GC回收,从缓存中移除
            delegate.removeObject(key);
        } else {
            // See #586 (and #335) modifications need more than a read lock
            synchronized (hardLinksToAvoidGarbageCollection) {
                // 添加到头部
                hardLinksToAvoidGarbageCollection.addFirst(result);
                // 如果大小超过限制
                if (hardLinksToAvoidGarbageCollection.size() > numberOfHardLinks) {
                    // 移除最早添加的数据
                    hardLinksToAvoidGarbageCollection.removeLast();
                }
            }
        }
    }
    return result;
}

3.8 SynchronizedCache

这个装饰器是在方法上增加synchronized关键字实现的同步功能,这里不展开说明了。

4 CacheKey

CacheKey是作为缓存key的对象,在Mybatis中的缓存并不是使用了一个简单的字符串类型,而是将查询封装成一个CacheKey的对象作为key进行缓存的。这个类的字段如下:

private final int multiplier;
// hashCode
private int hashcode;
// 校验和
private long checksum;
// updateList集合的大小
private int count;
// 由该集合中的所有对象共同决定两个CacheKey是否相同
private List<Object> updateList;

这个类的代码比较简单,update的逻辑如下:

public void update(Object object) {
    // 计算object的hashCode
    int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object);
    // count值增加1
    count++;
    // checkSum值加上baseHashCode
    checksum += baseHashCode;
    // baseHashCode*count
    baseHashCode *= count;
    // 计算hashCode
    hashcode = multiplier * hashcode + baseHashCode;
    // 添加到updateList中
    updateList.add(object);
}

重写的equals方法如下:

public boolean equals(Object object) {
    if (this == object) {
        return true;
    }
    if (!(object instanceof CacheKey)) {
        return false;
    }

    final CacheKey cacheKey = (CacheKey) object;

    if (hashcode != cacheKey.hashcode) {
        return false;
    }
    if (checksum != cacheKey.checksum) {
        return false;
    }
    if (count != cacheKey.count) {
        return false;
    }

    for (int i = 0; i < updateList.size(); i++) {
        Object thisObject = updateList.get(i);
        Object thatObject = cacheKey.updateList.get(i);
        if (!ArrayUtil.equals(thisObject, thatObject)) {
            return false;
        }
    }
    return true;
}

5 Mybatis中缓存的使用

Mybatis中的缓存分为一级缓存和二级缓存两个,一级缓存默认便是生效的,二级缓存默认没有生效。二级缓存的范围要比一级缓存的范围大,一级缓存的使用范围是SqlSession级别的,二级缓存的范围是namespace级别的。

若想使用Mybatis的二级缓存需要满足如下两点:

  • Mybatis配置文件中配置cacheEnable属性为true,默认便是true
  • 在映射文件中配置<cache>标签

5.1 Mybatis时对缓存的处理

在这部分我们看下,Mybatis在启动时对缓存做了那些内容,在这里我们直接介绍使用的地方,具体的流程我们这里先不进行介绍。

5.1.1 cacheEnable的使用

cacheEnable属性的使用位置在Configuration.newExecutor方法中,当开启这个配置后,会创建CachingExecutor执行器,方法的源码如下:

public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    executorType = executorType == null ? defaultExecutorType : executorType;
    executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
    Executor executor;
    if (ExecutorType.BATCH == executorType) {
        executor = new BatchExecutor(this, transaction);
    } else if (ExecutorType.REUSE == executorType) {
        executor = new ReuseExecutor(this, transaction);
    } else {
        executor = new SimpleExecutor(this, transaction);
    }
    // 是否启用二级缓存
    if (cacheEnabled) {
        executor = new CachingExecutor(executor);
    }
    executor = (Executor) interceptorChain.pluginAll(executor);
    return executor;
}

CachingExecutorMybatis中会使用二级缓存的执行器,在这个类中维护了一个TransactionalCacheManager对象。

5.1.2 cache节点的解析

cache节点的解析入口在XMLMapperBuilder.cacheElement()方法,这个方法的逻辑如下:

private void cacheElement(XNode context) {
    // <cache>节点不为空
    if (context != null) {
        // 获取type属性  默认为PERPETUAL
        String type = context.getStringAttribute("type", "PERPETUAL");
        // 通过别名获取对应的类型
        Class<? extends Cache> typeClass = typeAliasRegistry.resolveAlias(type);
        // 获取eviction属性  默认为LRU
        String eviction = context.getStringAttribute("eviction", "LRU");
        // 获取eviction别名对应的类型
        Class<? extends Cache> evictionClass = typeAliasRegistry.resolveAlias(eviction);
        // 获取flushInterval属性
        Long flushInterval = context.getLongAttribute("flushInterval");
        // 获取size属性
        Integer size = context.getIntAttribute("size");
        // 获取redaOnly属性  默认为false
        boolean readWrite = !context.getBooleanAttribute("readOnly", false);
        // 获取blocking属性  默认为false
        boolean blocking = context.getBooleanAttribute("blocking", false);
        // 获取子节点数据
        Properties props = context.getChildrenAsProperties();
        // 创建Cache对象并添加到Configuration.caches中
        builderAssistant.useNewCache(typeClass, evictionClass, flushInterval, size, readWrite, blocking, props);
    }
}

public Cache useNewCache(Class<? extends Cache> typeClass,
                         Class<? extends Cache> evictionClass,
                         Long flushInterval,
                         Integer size,
                         boolean readWrite,
                         boolean blocking,
                         Properties props) {
    // 创建Cache对象
    Cache cache = new CacheBuilder(currentNamespace)// namespace作为缓存id
        .implementation(valueOrDefault(typeClass, PerpetualCache.class))
        .addDecorator(valueOrDefault(evictionClass, LruCache.class))
        .clearInterval(flushInterval)
        .size(size)
        .readWrite(readWrite)
        .blocking(blocking)
        .properties(props)
        .build();
    // 将cache添加到configuration中
    configuration.addCache(cache);
    // 记录当前命名空间使用的cache
    currentCache = cache;
    return cache;
}

CacheBuilder是缓存的创建者,这个类的属性如下:

// cache的id
private final String id;
// Cache接口的实现类
private Class<? extends Cache> implementation;
// 装饰器列表
private final List<Class<? extends Cache>> decorators;
// cache大小
private Integer size;
// 清理时间周期
private Long clearInterval;
// 是否可读写
private boolean readWrite;
// 配置信息
private Properties properties;
// 是否阻塞
private boolean blocking;

CacheBuilder.builder()方法逻辑如下:

public Cache build() {
    // 如果未指定实现和装饰器   这个方法进行设置默认值
    setDefaultImplementations();
    // 创建Cache对象
    Cache cache = newBaseCacheInstance(implementation, id);
    // 设置cache的属性
    setCacheProperties(cache);
    // issue #352, do not apply decorators to custom caches
    // 判断cache的类型  如果是PerpetualCache类型  添加装饰器
    if (PerpetualCache.class.equals(cache.getClass())) {
        for (Class<? extends Cache> decorator : decorators) {
            cache = newCacheDecoratorInstance(decorator, cache);
            setCacheProperties(cache);
        }
        // 添加标准装饰器
        cache = setStandardDecorators(cache);
    } else if (!LoggingCache.class.isAssignableFrom(cache.getClass())) {
        // 如果不是LoggingCache的子类   添加LoggingCache
        cache = new LoggingCache(cache);
    }
    return cache;
}

private Cache setStandardDecorators(Cache cache) {
    try {
        MetaObject metaCache = SystemMetaObject.forObject(cache);
        // 如果size不为空且有对应的setter方法,设置size
        if (size != null && metaCache.hasSetter("size")) {
            metaCache.setValue("size", size);
        }
        // 如果clearInterval不为空  添加定时清理装饰器
        if (clearInterval != null) {
            cache = new ScheduledCache(cache);
            ((ScheduledCache) cache).setClearInterval(clearInterval);
        }
        // 如果支持读写  添加Serialized装饰器
        if (readWrite) {
            cache = new SerializedCache(cache);
        }
        // 添加LoggingCache
        cache = new LoggingCache(cache);
        // 同步装饰器
        cache = new SynchronizedCache(cache);
        // 支持阻塞 添加阻塞装饰器
        if (blocking) {
            cache = new BlockingCache(cache);
        }
        return cache;
    } catch (Exception e) {
        throw new CacheException("Error building standard cache decorators.  Cause: " + e, e);
    }
}

5.2 缓存的使用

缓存的使用是在执行器的查询方法中,执行器具体的逻辑我们在执行器部分再进行介绍,我们这里只看下使用缓存的逻辑,入口为CachingExecutor.query()方法,方法的逻辑如下:

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    // 获取Cache对象
    Cache cache = ms.getCache();
    // cache对象不为空
    if (cache != null) {
        // 判断是否需要刷新缓存
        flushCacheIfRequired(ms);
        if (ms.isUseCache() && resultHandler == null) {
            ensureNoOutParams(ms, boundSql);
            @SuppressWarnings("unchecked")
            // 查询二级缓存
            List<E> list = (List<E>) tcm.getObject(cache, key);
            // 二级缓存中不存在
            if (list == null) {
                // 调用BaseExecutor中的方法
                list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
                // 将查询结果添加到二级缓存中
                tcm.putObject(cache, key, list); // issue #578 and #116
            }
            return list;
        }
    }
    // // 调用BaseExecutor中的方法
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

BaseExecutor.query()方法的逻辑如下:

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
    if (closed) {
        throw new ExecutorException("Executor was closed.");
    }
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
        clearLocalCache();
    }
    List<E> list;
    try {
        queryStack++;
        list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
        if (list != null) {
            handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
        } else {
            list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
        }
    } finally {
        queryStack--;
    }
    if (queryStack == 0) {
        for (DeferredLoad deferredLoad : deferredLoads) {
            deferredLoad.load();
        }
        // issue #601
        deferredLoads.clear();
        if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
            // issue #482
            clearLocalCache();
        }
    }
    return list;
}

6 总结

今天的文章到这里就结束了,在本文中我们介绍了如下内容:

  • 缓存模块的结构
  • 缓存的实现及各种装饰器的使用
  • 如何启用二级缓存
  • Mybatis启动时对缓存的处理
  • Mybatis执行SQL时如何使用缓存

大家可以想想如果我不想用Mybatis自带的缓存实现,我应该如何去自定义一个缓存实现类并使用呢?

欢迎关注公众号:Bug搬运小能手

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值