guava cache 的refreshAfterWrite模式存在无效key的问题记录

1 篇文章 0 订阅

问题暴露

某个缓存key和对应的value被缓存到guava后,一段时间后该key对应的记录在数据库中被删除;
当这个key再次被获取的时候,到达refreshAfterWrite设置时间触发refresh方法, 然后委托给我们自己实现的reload方法,我们的reload方法又调用了load方法来重新刷新该key对应的最新值。那么这个时候问题来了。因为我们知道,如果一个key曾经没有存在过,触发load方法的时候,我们return null,最终guava不会去增加一个key来保存这一对无效键值对。
但是现在我们的问题是这个key是存在的,只是现在刷新的时候发现值不存在了,变成了null。程序如果不管这种情况的话,由于guava不接收value为null的缓存,然后内部会抛出异常,最终那本该不存在的旧值就又会一直占用这个key的缓存。然后获取缓存的线程又会只会判断是否为null,然后执行业务,最终就会拿着被删除的垃圾数据走业务,而且这个垃圾数据会一直存在。

问题演示

下面会简单写一个demo,然后用来演示上面那个问题, 步骤描述

  • 有一个对象为Person, 然后使用guavarefreshAfterWrite来进行缓存
  • 某一个Person被缓存后,随后这个对象对应的记录在数据库被删除了
  • 然后发现我们从缓存中获取对应被删除的对象的key即使已经刷新了无数遍发现数据还是会一直存在,而不是null
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListenableFutureTask;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import jdk.reflect.StringUtil;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.text.MessageFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * <p>description</p >
 *
 * @author Snowball
 * @version 1.0
 * @date 2020/08/27 14:46
 */
@Slf4j
public class Demo {
    /**
     * 原始数据集合,模拟数据库记录
     */
    private final static Map<String, Person> DATA_MAP;

    /**
     * guava缓存Person
     */
    private static LoadingCache<String, Person> INIT_LOADING_CACHE;

    /**
     * reload线程池
     */
    private final static ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(),
            Runtime.getRuntime().availableProcessors() * 2, 60L, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(500), new ThreadFactoryBuilder().setNameFormat("reload-executor-pool").build());

    static {
        // 初始化原始数据,模拟数据库中的记录
        DATA_MAP = new HashMap<>();
        DATA_MAP.put("1", new Person("1", "jack"));
        DATA_MAP.put("2", new Person("2", "tom"));

        // 初始化缓存
        initLazyCache();
    }

    /**
     * 初始化缓存
     */
    private static void initLazyCache() {
        INIT_LOADING_CACHE = CacheBuilder.newBuilder()
                .maximumSize(500)
                .recordStats()
                .softValues()
                .refreshAfterWrite(3, TimeUnit.SECONDS)
                .build(new CacheLoader<String, Person>() {
                    @Override
                    public Person load(String key) throws Exception {
                        System.out.println("=================load===================: " + key);
                        if (StringUtil.isBlank(key)) {
                            return null;
                        }
                        String[] data= key.split(CacheKeyEnum.SPLIT_CHAR);
                        // LoadingCacheDemo1:INIT:{0}
                        String id = data[2];
                        return get(id);
                    }

                    @Override
                    public ListenableFuture<Person> reload(String key, Person oldValue) throws Exception {
                        // 如果使用异步的话,短期内多次获取,因为此时异步尚未执行结束,大概率会造成获取的时候使用的还是旧的缓存值
                        ListenableFutureTask<Person> task = ListenableFutureTask.create(() -> load(key));
                        EXECUTOR.execute(task);
                        // 可选是否采用闭锁再刷新的时候让读线程等待刷新完成,一般不需要,就异步刷新即可
                        task.get();
                        return task;
                    }
                });
        // 这里也可以直接初始化缓存,就不用懒加载获取某个key的时候再去缓存
        DATA_MAP.forEach((k, v) -> {
            INIT_LOADING_CACHE.put(MessageFormat.format(CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), k), v);
        });
    }
    public static void main(String[] args) throws InterruptedException {
        System.out.println("==============================================initLoad===========================================");

        String id = "2";
        System.out.println("由于缓存进行了启动初始化加载,所以第一次如果key存在,会直接拿缓存");
        logTimeGet(INIT_LOADING_CACHE, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);

        DATA_MAP.put(id, new Person(id, DATA_MAP.get(id).getUsername() + "被修改了"));
        System.out.println("id=2的对象,由于没有到刷新时间, 所以用的还是旧值");
        logTimeGet(INIT_LOADING_CACHE, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);

        Thread.sleep(3000);
        System.out.println("故意睡眠超过过期时间,过期之后的第一次请求触发刷新操作,由于我们使用了闭锁,让获取值的线程强制等待刷新任务完整,所以这里会耗时比较久,但值是最新的");
        logTimeGet(INIT_LOADING_CACHE, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);

        // 下面就要演示如果这个key被删除了,其实最终guava不接受为null的value,会导致这个无效key一直存在,且对应的value为最后一次缓存的数据
        DATA_MAP.remove(id);
        // 故意超过过期时间,保证会触发reload
        Thread.sleep(3000);
        System.out.println("模拟数据库删除key=2的数据");
        // 我们已经使用了闭锁来保证刷新时的同步获取来保证演示效果, 这里guava会抛出异常CacheLoader returned null for key LoadingCacheDemo1:INIT:2.
        // 因为它不接受为null的value, 最终这个缓存会无法置空, 然后就悲剧了,这个key的value我们以为是null,实际一直是最后一次缓存的数据
        logTimeGet(INIT_LOADING_CACHE, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);
        System.out.println("打印一下缓存中的数据,需要说明的是,这里不是由于缓存未刷新造成的数据不一致,而是由于刷新时对应key的记录变成了null而guava不接受所造成的");
        System.out.println(INIT_LOADING_CACHE.asMap());

        EXECUTOR.shutdown();
    }

    /**
     * 模拟获取原始数据
     * @param key
     * @return
     */
    public static Person get(String key) {
        try {
            // 模拟向数据库中取数据需要耗时1000毫秒
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return DATA_MAP.get(key);
    }

    private static <T> void logTimeGet(LoadingCache<String, T> loadingCache, String template, String... parameter) {
        long before = System.currentTimeMillis();
        System.out.println("缓存中[" + MessageFormat.format(template, parameter) + "]对应的值: "
                + LocalCacheUtil.getGuavaCache(loadingCache, template, parameter));
        long after = System.currentTimeMillis();
        System.out.println("耗时: " + (after - before));
        System.out.println("--------------------------------------");
    }


    /**
     * 获取对应缓存中的值
     * @param loadingCache
     * @param template
     * @param parameter
     * @param <T>
     * @return
     */
    public static <T> T getGuavaCache(LoadingCache<String, T> loadingCache, String template, String... parameter) {
        try {
            return loadingCache.get(MessageFormat.format(template, parameter));
        } catch (CacheLoader.InvalidCacheLoadException e) {
            return null;
        } catch (Exception e) {
            log.error("查询缓存异常 template={},parameter={}", template, parameter);
            return null;
        }
    }

    /**
     * 缓存key维护
     */
    public enum CacheKeyEnum {
        /**
         * 演示初始化加载的缓存
         * {0} id
         */
        LOADING_CACHE_DEMO1_INIT("LoadingCacheDemo1:INIT:{0}"),

        ;

        public static final String SPLIT_CHAR = ":";

        CacheKeyEnum(String template) {
            this.template = template;
        }

        private final String template;

        public String getTemplate() {
            return template;
        }
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Person {

        private String id;

        private String username;
    }

}

打印结果如下:

==============================================initLoad===========================================
由于缓存进行了启动初始化加载,所以第一次如果key存在,会直接拿缓存
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom)
耗时: 1
--------------------------------------
id=2的对象,由于没有到刷新时间, 所以用的还是旧值
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom)
耗时: 0
--------------------------------------
故意睡眠超过过期时间,过期之后的第一次请求触发刷新操作,由于我们使用了闭锁,让获取值的线程强制等待刷新任务完整,所以这里会耗时比较久,但值是最新的
=================load===================: LoadingCacheDemo1:INIT:2
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom被修改了)
耗时: 1038
--------------------------------------
模拟数据库删除key=2的数据
=================load===================: LoadingCacheDemo1:INIT:2
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom被修改了)
耗时: 1016
--------------------------------------
打印一下缓存中的数据,需要说明的是,这里不是由于缓存未刷新造成的数据不一致,而是由于刷新时对应key的记录变成了null造成的
{LoadingCacheDemo1:INIT:1=Demo.Person(id=1, username=jack), LoadingCacheDemo1:INIT:2=Demo.Person(id=2, username=tom被修改了)}

解决思路

  1. 首先明白问题是出现在这个key曾经存在过,然后被删除之后重新从数据库查询发现为null之后,然后我们直接将null交给guava,本以为会让这个value为null,然后业务判断不为null即可,但是最终guava不受理一个存在的key被重置为null, 才会引起这个问题
  2. 那么就只能从默认值下手了。给每个要缓存的对象一个代表无效的空对象,如果为集合则为空集合,然后在load方法内部通过key去获取最新值的时候需要判断这个值是否为null, 如果为null并且这个key在缓存中存在,则返回我们预定义的空对象。记住一定要加上这个key之前在缓存中存在才做默认值的处理。因为如果key不存在我们返回null, guava根本不会去缓存这个key。如果不加这个判断,我们就让guava多缓存了一堆无效数据了。
  3. 在通过guava获取值的地方,除了要判断值不为null之外,还要判断这个值是不是我们之前存入的代表无效value的对象。如果是的话,这个数据不可使用,不能走业务代码。同时调用LoadingCacheinvalidate方法来将key失效掉

步骤2对应调整

  • 增加要缓存对象的默认无效值
    public static final Person INVALID_PERSON = new Person();
    
  • 调整guava缓存定义时的load方法,调整部分如下
    Person person = get(id);
    // 如果值为null且这个key之前存在过, 要放入默认值;如果值为null但这个key不存在,guava本就不会缓存这个key
    if (person == null && INIT_LOADING_CACHE.asMap().containsKey(key)) {
        System.out.printf("【%s】对应的记录被删除,存入默认值\r\n", key);
        person = Person.INVALID_PERSON;
    }
    return person;
    

步骤3调整
介于步骤3,实在是对在使用的地方有侵入,我们可以更改一下前面写的获取缓存的工具方法,除了允许调用方传入要从哪个缓存中取数据,以及对应的key之外,我们再增加默认对象参数,然后我们再工具方法中去完成步骤3的动作,然后发现获取的值为传入的默认对象后,调用invalidate方法并且返回null给调用方,这样就可以让获取缓存的地方无感这一切,依然只判断null即可

public static <T> T getGuavaCacheCheckDefault(LoadingCache<String, T> loadingCache, T defaultValidValue, String template, String... parameter) {
    try {
        String key = MessageFormat.format(template, parameter);
        T t = loadingCache.get(key);
        if (defaultValidValue instanceof String) {
            if (Objects.equals(t, defaultValidValue)) {
                log.info("key: {}对应的值为无效值: {}, 转换为null返回并清除该key", key, t);
                loadingCache.invalidate(key);
                return null;
            }
        } else {
            if (t == defaultValidValue) {
                log.info("key: {}对应的值为无效值: {}, 转换为null返回并清除该key", key, t);
                loadingCache.invalidate(key);
                return null;
            }
        }
        return t;
    } catch (CacheLoader.InvalidCacheLoadException e) {
        return null;
    } catch (Exception e) {
        log.error("查询缓存异常 template={},parameter={}", template, parameter);
        return null;
    }
}

最终完成代码和演示效果

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListenableFutureTask;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import jdk.reflect.StringUtil;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.text.MessageFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * <p>description</p >
 *
 * @author Snowball
 * @version 1.0
 * @date 2020/08/27 14:46
 */
@Slf4j
public class Demo {
    /**
     * 原始数据集合,模拟数据库记录
     */
    private final static Map<String, Person> DATA_MAP;

    /**
     * 初始化直接缓存全部资
     */
    private static LoadingCache<String, Person> INIT_LOADING_CACHE;

    /**
     * reload线程池
     */
    private final static ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(),
            Runtime.getRuntime().availableProcessors() * 2, 60L, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(500), new ThreadFactoryBuilder().setNameFormat("reload-executor-pool").build());

    static {
        // 初始化原始数据
        DATA_MAP = new HashMap<>();
        DATA_MAP.put("1", new Person("1", "jack"));
        DATA_MAP.put("2", new Person("2", "tom"));

        // 初始化缓存
        initLazyCache();
    }

    /**
     * 初始化缓存
     */
    private static void initLazyCache() {
        INIT_LOADING_CACHE = CacheBuilder.newBuilder()
                .maximumSize(500)
                .recordStats()
                .softValues()
                .refreshAfterWrite(3, TimeUnit.SECONDS)
                .build(new CacheLoader<String, Person>() {
                    @Override
                    public Person load(String key) throws Exception {
                        System.out.println("=================load===================: " + key);
                        if (StringUtil.isBlank(key)) {
                            return null;
                        }
                        String[] data= key.split(CacheKeyEnum.SPLIT_CHAR);
                        // LoadingCacheDemo1:INIT:{0}
                        String id = data[2];
                        Person person = get(id);
                        // 如果值为null且这个key之前存在过, 要放入默认值;如果值为null但这个key不存在,guava本就不会缓存这个key,我们没必要增加
                        if (person == null && INIT_LOADING_CACHE.asMap().containsKey(key)) {
                            System.out.printf("【%s】对应的记录被删除,存入默认值\r\n", key);
                            person = Person.INVALID_PERSON;
                        }
                        return person;
                    }

                    @Override
                    public ListenableFuture<Person> reload(String key, Person oldValue) throws Exception {
                        // 如果使用异步的话,短期内多次获取,因为此时异步尚未执行结束,大概率会造成获取的时候使用的还是旧的缓存值
                        ListenableFutureTask<Person> task = ListenableFutureTask.create(() -> load(key));
                        EXECUTOR.execute(task);
                        // 可选是否采用闭锁再刷新的时候让读线程等待刷新完成,一般不需要,就异步刷新即可
                        task.get();
                        return task;
                    }
                });
        // 这里也可以直接初始化缓存,就不用懒加载获取某个key的时候再去缓存
        DATA_MAP.forEach((k, v) -> {
            INIT_LOADING_CACHE.put(MessageFormat.format(CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), k), v);
        });
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println("==============================================initLoad===========================================");

        String id = "2";
        System.out.println("由于缓存进行了启动初始化加载,所以第一次如果key存在,会直接拿缓存");
        logTimeGet(INIT_LOADING_CACHE, Person.INVALID_PERSON, guava.cache.CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);

        DATA_MAP.put(id, new Person(id, DATA_MAP.get(id).getUsername() + "被修改了"));
        System.out.println("id=4的对象,由于没有到刷新时间, 所以用的还是旧值");
        logTimeGet(INIT_LOADING_CACHE, Person.INVALID_PERSON, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);

        Thread.sleep(3000);
        System.out.println("故意睡眠超过过期时间,过期之后的第一次请求触发刷新操作,由于我们使用了闭锁,让获取值的线程强制等待刷新任务完整,所以这里会耗时比较久,但值是最新的");
        logTimeGet(INIT_LOADING_CACHE, Person.INVALID_PERSON, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);

        // 下面就要演示如果这个key被删除了,其实最终guava不接受为null的value,会导致这个无效key一直存在,且对应的value为最后一次缓存的数据
        DATA_MAP.remove(id);
        // 故意超过过期时间,保证会触发reload
        Thread.sleep(3000);
        System.out.println("模拟数据库删除key=4的数据");
        // 我们已经使用了闭锁来保证刷新时的同步获取来保证演示效果, 这里写的工具方法内部会判断是否为默认值,然后转换为null返回,并且删除key
        logTimeGet(INIT_LOADING_CACHE, Person.INVALID_PERSON, CacheKeyEnum.LOADING_CACHE_DEMO1_INIT.getTemplate(), id);
        System.out.println("打印一下缓存中的数据,我们发现上面工具完成了返回null的操作方便我们使用的地方判断,而且也完成了对该key的删除");
        System.out.println(INIT_LOADING_CACHE.asMap());

        EXECUTOR.shutdown();
    }

    /**
     * 模拟获取原始数据
     * @param key
     * @return
     */
    public static Person get(String key) {
        try {
            // 模拟向数据库中取数据需要耗时1000毫秒
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return DATA_MAP.get(key);
    }

    private static <T> void logTimeGet(LoadingCache<String, T> loadingCache, T invalidValue, String template, String... parameter) {
        long before = System.currentTimeMillis();
        System.out.println("缓存中[" + MessageFormat.format(template, parameter) + "]对应的值: "
                + LocalCacheUtil.getGuavaCacheCheckDefault(loadingCache, invalidValue, template, parameter));
        long after = System.currentTimeMillis();
        System.out.println("耗时: " + (after - before));
        System.out.println("--------------------------------------");
    }


    /**
     * 获取对应缓存中的值
     * @param loadingCache
     * @param template
     * @param parameter
     * @param <T>
     * @return
     */
    public static <T> T getGuavaCache(LoadingCache<String, T> loadingCache, String template, String... parameter) {
        try {
            return loadingCache.get(MessageFormat.format(template, parameter));
        } catch (CacheLoader.InvalidCacheLoadException e) {
            return null;
        } catch (Exception e) {
            log.error("查询缓存异常 template={},parameter={}", template, parameter);
            return null;
        }
    }

    /**
     * 缓存key维护
     */
    public enum CacheKeyEnum {
        /**
         * 演示初始化加载的缓存
         * {0} id
         */
        LOADING_CACHE_DEMO1_INIT("LoadingCacheDemo1:INIT:{0}"),

        ;

        public static final String SPLIT_CHAR = ":";

        CacheKeyEnum(String template) {
            this.template = template;
        }

        private final String template;

        public String getTemplate() {
            return template;
        }
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Person {

        /**
         * 代表无效值的Person对象
         */
        public static final Person INVALID_PERSON = new Person();

        private String id;

        private String username;
    }
}

打印效果如下:

==============================================initLoad===========================================
由于缓存进行了启动初始化加载,所以第一次如果key存在,会直接拿缓存
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom)
耗时: 1
--------------------------------------
id=4的对象,由于没有到刷新时间, 所以用的还是旧值
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom)
耗时: 0
--------------------------------------
故意睡眠超过过期时间,过期之后的第一次请求触发刷新操作,由于我们使用了闭锁,让获取值的线程强制等待刷新任务完整,所以这里会耗时比较久,但值是最新的
=================load===================: LoadingCacheDemo1:INIT:2
缓存中[LoadingCacheDemo1:INIT:2]对应的值: Demo.Person(id=2, username=tom被修改了)
耗时: 1033
--------------------------------------
模拟数据库删除key=4的数据
=================load===================: LoadingCacheDemo1:INIT:2
【LoadingCacheDemo1:INIT:2】对应的记录被删除,存入默认值
16:58:23.510 [main] INFO  guava.cache.LocalCacheUtil - key: LoadingCacheDemo1:INIT:2对应的值为无效值: Demo.Person(id=null, username=null), 转换为null返回并清除该key
缓存中[LoadingCacheDemo1:INIT:2]对应的值: null
耗时: 1019
--------------------------------------
打印一下缓存中的数据,我们发现上面工具完成了返回null的操作方便我们使用的地方判断,而且也完成了对该key的删除
{LoadingCacheDemo1:INIT:1=Demo.Person(id=1, username=jack)}
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Guava Cache是Google提供的一套Java工具包中的一部分,它是一套非常完善的本地缓存机制(JVM缓存)。它的设计灵感来源于ConcurrentHashMap,可以按照多种策略来清理存储在其中的缓存值,同时保持很高的并发读写性能。在使用Guava Cache时,可以通过get()或者put()等方法进行缓存操作,当进行这些操作时,Guava Cache会进行惰性删除,即在获取或者放置缓存的时候判断缓存是否过期并进行删除。在Guava Cache的核心原理中,使用Segment来进行缓存值的定位和管理。在创建Guava Cache对象时,可以使用CacheLoader来自动加载数据到缓存中,当缓存不存在时,CacheLoader会负责获取数据并将其放置到缓存中。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *3* [[由零开始]Guava Cache介绍和用法](https://blog.csdn.net/qq497811258/article/details/108260969)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] - *2* [Guava Cache简介、应用场景分析、代码实现以及核心的原理](https://blog.csdn.net/weixin_44795847/article/details/123702038)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值