结合源码看Redis过期策略

注意事项

笔者所看的源码是redis稳定版 6.2 版本的

常用的过期策略

1. 定时过期,主动过期

这个策略是需要一个过时器,对每一个key都设计一个定时器。

优点:对内存友好,但是严重消耗CPU,对CPU非常不友好,这个redis没有采用

为什么redis不采用这个过期策略呢?

想想, 每一个key都要一个定时器,是不是特别消耗CPU,而redis又是一个特别注重吞吐量的数据库,这样过期势必会大大的降低吞吐。

2. 惰性过期

只有来访问的时候才知道是否过期。就像从冰箱拿东西,只有去拿检查的时候,才知道是否过期。

redis的过期策略之一

int expireIfNeeded(redisDb *db, robj *key) {
    // 如果没有过期,直接返回
    if (!keyIsExpired(db,key)) return 0;

    /* If we are running in the context of a slave, instead of
     * evicting the expired key from the database, we return ASAP:
     * the slave key expiration is controlled by the master that will
     * send us synthesized DEL operations for expired keys.
     *
     * Still we try to return the right information to the caller,
     * that is, 0 if we think the key should be still valid, 1 if
     * we think the key is expired at this time. */
     // 如果是从节点
    if (server.masterhost != NULL) return 1;

    /* If clients are paused, we keep the current dataset constant,
     * but return to the client what we believe is the right state. Typically,
     * at the end of the pause we will properly expire the key OR we will
     * have failed over and the new primary will send us the expire. */
    // 正在选取主节点的时候不允许过期
    if (checkClientPauseTimeoutAndReturnIfPaused()) return 1;

    /* Delete the key */
    // 过期的key加一
    server.stat_expiredkeys++;
    // 传播到从服务器和AOF文件,
    propagateExpire(db,key,server.lazyfree_lazy_expire);
    // 发送事件
    notifyKeyspaceEvent(NOTIFY_EXPIRED,
        "expired",key,db->id);
    // 如果服务器在惰性删除开启,异步做key的删除,如果没有开启,就同步做删除
    int retval = server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
                                               dbSyncDelete(db,key);
    // 如果删除成功就发送通知
    if (retval) signalModifiedKey(NULL,db,key);
    return retval;
}

结合上面的代码可以知道:

  1. 从节点不能主动删除过期的key
  2. 在选取主节点的时候不能删除过期的key
  3. 一旦判断可以删除,那么redis主节点在删除之前,会发起删除通知到slave从节点
  4. 如果服务器开启懒过期,那么会异步删除过期的key。
  5. 如果删除成功就会通知客户端的监听者

3. 定期过期

每隔一段时间扫描,然后把过期的key删除。

/* Try to expire a few timed out keys. The algorithm used is adaptive and
 * will use few CPU cycles if there are few expiring keys, otherwise
 * it will get more aggressive to avoid that too much memory is used by
 * keys that can be removed from the keyspace.、
 *
 * 尝试让几个超时的密钥失效。使用的算法是自适应的,并将使用少量的CPU周期,如果有少量过期的键,否则它将采取更积极的措施,以避免过多的内存被可以从键空间中删除的键使用。
 *
 * Every expire cycle tests multiple databases: the next call will start
 * again from the next db. No more than CRON_DBS_PER_CALL databases are
 * tested at every iteration.
 *
 * 每个过期周期测试多个数据库:下一个调用将从下一个数据库再次启动。每次迭代都只测试CRON_DBS_PER_CALL数据库。
 *
 * The function can perform more or less work, depending on the "type"
 * argument. It can execute a "fast cycle" or a "slow cycle". The slow
 * cycle is the main way we collect expired cycles: this happens with
 * the "server.hz" frequency (usually 10 hertz).
 *
 * 函数可以执行更多或更少的工作,这取决于“type”参数。它可以执行 “快周期”或“慢周期”。慢周期是我们收集过期周期的主要方式:这种情况发生在“server.hz”上。频率(通常为10赫兹)。
 *
 * However the slow cycle can exit for timeout, since it used too much time.
 * For this reason the function is also invoked to perform a fast cycle
 * at every event loop cycle, in the beforeSleep() function. The fast cycle
 * will try to perform less work, but will do it much more often.
 *
 * 然而,慢周期可能超时退出,因为它使用了太多的时间。因此,在beforeSleep()函数中,也会在每个事件循环周期调用该函数来执行快速循环。快速循环将尝试执行更少的工作,但将做得更频繁。
 *
 * The following are the details of the two expire cycles and their stop
 * conditions:
 *
 * If type is ACTIVE_EXPIRE_CYCLE_FAST the function will try to run a
 * "fast" expire cycle that takes no longer than ACTIVE_EXPIRE_CYCLE_FAST_DURATION
 * microseconds, and is not repeated again before the same amount of time.
 * The cycle will also refuse to run at all if the latest slow cycle did not
 * terminate because of a time limit condition.
 *
 * If type is ACTIVE_EXPIRE_CYCLE_SLOW, that normal expire cycle is
 * executed, where the time limit is a percentage of the REDIS_HZ period
 * as specified by the ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC define. In the
 * fast cycle, the check of every database is interrupted once the number
 * of already expired keys in the database is estimated to be lower than
 * a given percentage, in order to avoid doing too much work to gain too
 * little memory.
 *
 * The configured expire "effort" will modify the baseline parameters in
 * order to do more work in both the fast and slow expire cycles.
 *
 * 以下是两个到期周期及其停止条件的详细信息:
 *
 * 如果type为 ACTIVE_EXPIRE_CYCLE_FAST,
 * 则该函数将尝试运行不超过 ACTIVE_EXPIRE_CYCLE_FAST_DURATION 微秒的“快速”过期周期,并且在相同的时间量之前不会再次重复。如果最新的慢速周期,则该周期也将完全拒绝运行 由于时间限制条件未终止。
 *
 * 如果type为 ACTIVE_EXPIRE_CYCLE_SLOW,
 * 则执行该正常的过期周期,其中时间限制是由ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC定义的REDIS_HZ周期的百分比。
 * 在快速周期中,一旦数据库中已经过期的键的数量估计低于给定的百分比,就会中断对每个数据库的检查,以避免避免进行过多的工作来获得太少的内存。
 *
 * 配置的到期“effort”将修改基线参数,以便在快速和缓慢的到期周期中进行更多工作。
 */

#define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 /* Keys for each DB loop. 每个DB循环的键,就是每次取20个 */
#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 /* Microseconds. 快速过期周期 1000微妙 */
#define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /*   Max % of CPU to use. 正常过期周期占用CPU时间百分比 */
#define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 /* % of stale keys after which we do extra efforts. 主动失效周期可接受的阶段 过时密钥的百分比,在此之后我们会付出额外的工作量 */

void activeExpireCycle(int type) {
    /* Adjust the running parameters according to the configured expire
     * effort. The default effort is 1, and the maximum configurable effort
     * is 10.
     * 根据配置的失效时间调整运行参数。 默认工作量为1,最大可配置工作量为10。
     */
    unsigned long
            effort = server.active_expire_effort - 1, /* Rescale from 0 to 9. */
    // 每次循环取的键数  值为 20 + 20 / 4 * 努力程度
    // (20 到 25 之间, 这取决于 server.active_expire_effort 的配置的值为多少)
    // 因为 server.active_expire_effort 默认为1 ,所以 config_keys_per_loop 默认为 20
    config_keys_per_loop = ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP +
                           ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP / 4 * effort,
    // 快速过期周期 值为 1000 + 1000 / 4 * 努力程度
    // (1000 微秒 到 1250 微秒之间, 这取决于 server.active_expire_effort 的配置的值为多少)
    // 因为 server.active_expire_effort 默认为1 ,所以 config_cycle_fast_duration 默认为 1000
    config_cycle_fast_duration = ACTIVE_EXPIRE_CYCLE_FAST_DURATION +
                                 ACTIVE_EXPIRE_CYCLE_FAST_DURATION / 4 * effort,
    // 正常周期 CPU 占比百分比 值为 25 + 2 * 额外工作量(25 到 43, 这取决于 server.active_expire_effort 的配置的值为多少)
    // 默认 25
    config_cycle_slow_time_perc = ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC +
                                  2 * effort,
    // 回收阈值 值为 10 - 努力程度 (1- 10 这取决于 server.active_expire_effort 的配置的值为多少 默认10)
    config_cycle_acceptable_stale = ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE -
                                    effort;

    /* This function has some global state in order to continue the work
     * incrementally across calls.
     * 这个函数有一些全局状态,以便在调用之间增量地继续工作。*/
    static unsigned int current_db = 0; /* Next DB to test. 下一个DB偏移量 */
    static int timelimit_exit = 0;      /* Time limit hit in previous call? 前一次调用是否超过了 时间限制*/
    static long long last_fast_cycle = 0; /* When last fast cycle ran.上一次快速周期结束时间 */

    int j, iteration = 0;
    // CRON_DBS_PER_CALL 每次定期收集调用需要扫描数据库总数 值为16
    int dbs_per_call = CRON_DBS_PER_CALL;
    // start 为开始时间 timelimit 是回收过期key时间限制,elapsed 已过多少时间
    long long start = ustime(), timelimit, elapsed;

    /* When clients are paused the dataset should be static not just from the
     * POV of clients not being able to write, but also from the POV of
     * expires and evictions of keys not being performed.
     *
     * 当客户端暂停时,数据集应该是静态的,不仅是因为客户端无法写入的POV,而且还应该是由于过期的POV和未执行密钥退出而导致的。
     * 这个是选取主节点的过程,删除过期的键是不允许的
     */
    if (checkClientPauseTimeoutAndReturnIfPaused()) return;
    // 如果是快速过期删除模式
    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {
        /* Don't start a fast cycle if the previous cycle did not exit
         * for time limit, unless the percentage of estimated stale keys is
         * too high. Also never repeat a fast cycle for the same period
         * as the fast cycle total duration itself.
         * 如果前一个周期没有在时间限制内退出,则不要开始快速周期,除非估计的陈旧key的百分比过高。
         * 也不要在与快速循环总持续时间本身相同的时间段内重复快速循环。*/
        // stat_expired_stale_perc 是可能已过期的key的百分比 如果小于回收阈值 直接返回
        if (!timelimit_exit &&
            server.stat_expired_stale_perc < config_cycle_acceptable_stale)
            return;
        // 如果开始时间小于上一个 快速周期结束时间
        if (start < last_fast_cycle + (long long) config_cycle_fast_duration * 2)
            return;
        // 如果可以回收,那么上一次结束时间就是开始时间,
        last_fast_cycle = start;
    }

    /* We usually should test CRON_DBS_PER_CALL per iteration, with
     * two exceptions:
     *
     * 1) Don't test more DBs than we have.
     * 2) If last time we hit the time limit, we want to scan all DBs
     * in this iteration, as there is work to do in some DB and we don't want
     * expired keys to use memory for too much time.
     * 通常,我们应该在每次迭代中测试CRON_DBS_PER_CALL,但有两个例外:
     * 1)不要测试比我们更多的数据库。
     * 2)如果上次我们达到了时间限制,则我们希望扫描此迭代中的所有数据库,因为某些数据库中有工作要做,并且我们不希望过期的键占用过多的内存。*/
    // 如果允许调用的数据库数大于16 或者 前一次已经命中
    if (dbs_per_call > server.dbnum || timelimit_exit)
        // 赋值
        dbs_per_call = server.dbnum;

    /* We can use at max 'config_cycle_slow_time_perc' percentage of CPU
     * time per iteration. Since this function gets called with a frequency of
     * server.hz times per second, the following is the max amount of
     * microseconds we can spend in this function.
     * 我们可以使用每次迭代的最大CPU时间百分比“ config_cycle_slow_time_perc”。 由于此函数以每秒server.hz次的频率被调用,因此以下是我们可以在此函数中花费的最大微秒数。*/
    // 允许回收的最大时间 值为 默认25% * 1百万微妙(1s) / 服务器赫兹 (默认10)
    // 所以下面也有默认值 默认为 25000 微妙
    timelimit = config_cycle_slow_time_perc * 1000000 / server.hz / 100;
    // 是否超过时间限制
    timelimit_exit = 0;
    // 校验过期回收时间限制
    if (timelimit <= 0) timelimit = 1;

    if (type == ACTIVE_EXPIRE_CYCLE_FAST)
        // 如果是快速收集模式, 收集时间限制在一毫秒
        timelimit = config_cycle_fast_duration; /* in microseconds. */

    /* Accumulate some global stats as we expire keys, to have some idea
     * about the number of keys that are already logically expired, but still
     * existing inside the database.
     * 随着key的过期,累积一些全局统计信息,以了解已在逻辑上过期但仍存在于数据库中的密钥的数量。*/
    // 总共抽取的样本数量
    long total_sampled = 0;
    // 总的过期key数量
    long total_expired = 0;
    // 循环扫描库,但是前提是这个库前一次没有命中
    for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
        /* Expired and checked in a single loop. */
        // 这个库每一次采样 选取的样本,和这个库每一次采样过期的key数
        unsigned long expired, sampled;
        // 获取当前db
        redisDb *db = server.db + (current_db % server.dbnum);

        /* Increment the DB now so we are sure if we run out of time
         * in the current DB we'll restart from the next. This allows to
         * distribute the time evenly across DBs.
         * 现在增加数据库,因此我们确定如果当前数据库中的时间用完了,我们将从下一个数据库重新启动。 这样可以将时间平均分配到各个DB。*/
        current_db++;

        /* Continue to expire if at the end of the cycle there are still
         * a big percentage of keys to expire, compared to the number of keys
         * we scanned. The percentage, stored in config_cycle_acceptable_stale
         * is not fixed, but depends on the Redis configured "expire effort".
         *
         * 如果在周期结束时,与我们扫描的密钥数量相比,仍有很大一部分密钥要过期,请继续过期。
         * 存储在 config_cycle_acceptable_stale 中的百分比不是固定的,而是取决于Redis配置的“expire effort”。*/
        do {
            unsigned long num, slots;
            long long now, ttl_sum;
            int ttl_samples;
            iteration++;

            /* If there is nothing to expire try next DB ASAP. */
            // 如果这个db过期hash表里没有什么要收集的,把库里的平均收集时间设置为0,结束循环,直接扫下一个DB
            // num是已经使用的槽
            if ((num = dictSize(db->expires)) == 0) {
                // 这个数据库的平均收集时间为0
                db->avg_ttl = 0;
                break;
            }
            // 过期字典里的槽大小
            slots = dictSlots(db->expires);
            // 现在时间
            now = mstime();

            /* When there are less than 1% filled slots, sampling the key
             * space is expensive, so stop here waiting for better times...
             * The dictionary will be resized asap.
             * 当少于1%的已填充插槽时,对密钥空间进行采样非常昂贵,因此请在这里等待更好的时间...字典将尽快调整大小。*/
            // 如果槽数大于初始化的hash表大小并且过期字典已经使用的槽很少,也直接扫下一个DB
            // 很少 有用的槽乘以一百比上 槽数都小于1
            if (slots > DICT_HT_INITIAL_SIZE &&
                (num * 100 / slots < 1))
                break;

            /* The main collection cycle. Sample random keys among keys
             * with an expire set, checking for expired ones.
             * 主要的收集周期。 在具有过期集的键中对随机键进行采样,检查是否有过期键。*/
            expired = 0;
            sampled = 0;
            // 生存时间总和
            ttl_sum = 0;
            // 生存样本数
            ttl_samples = 0;
            // num 已经使用的槽 设置为 20 或者小于20
            if (num > config_keys_per_loop)
                num = config_keys_per_loop;

            /* Here we access the low level representation of the hash table
             * for speed concerns: this makes this code coupled with dict.c,
             * but it hardly changed in ten years.
             *
             * Note that certain places of the hash table may be empty,
             * so we want also a stop condition about the number of
             * buckets that we scanned. However scanning for free buckets
             * is very fast: we are in the cache line scanning a sequential
             * array of NULL pointers, so we can scan a lot more buckets
             * than keys in the same time.
             *
             * 在这里,出于速度方面的考虑,我们访问哈希表的低级表示形式:这使该代码与dict.c结合在一起,但十年来几乎没有改变。
             *
             * 注意,哈希表的某些位置可能为空,因此我们还需要一个关于我们扫描的存储桶数量的停止条件。
             * 但是,扫描空闲存储桶的速度非常快:我们正在高速缓存行中扫描NULL指针的顺序数组,因此我们可以同时扫描比键更多的存储桶。*/
            long max_buckets = num * 20;
            long checked_buckets = 0;
            // 如果本次样本数小于要取的数,已经检查的桶小于最大可扫描的桶数,循环
            while (sampled < num && checked_buckets < max_buckets) {
                for (int table = 0; table < 2; table++) {
                    // 第一个table 并且过期字典没有再hash 直接跳出循环
                    // 每一个字典有了两张hash table
                    //  ht[0]指向旧哈希表, ht[1]指向扩容后的新哈希表. 这个就是说,这个字典没有新hash表,所以不用扫描
                    if (table == 1 && !dictIsRehashing(db->expires)) break;
                    // idx是游标 下面语句就是取 每一个table 每一个桶对应的链表
                    unsigned long idx = db->expires_cursor;
                    idx &= db->expires->ht[table].sizemask;
                    dictEntry *de = db->expires->ht[table].table[idx];
                    // 生存时间
                    long long ttl;

                    /* Scan the current bucket of the current table. */
                    checked_buckets++;
                    // 循环 桶对应的链表
                    while (de) {
                        /* Get the next entry now since this entry may get
                         * deleted. */
                        dictEntry *e = de;
                        de = de->next;
                        // 生存时间是key 可以生存到的时间 减去 现在时间
                        ttl = dictGetSignedIntegerVal(e) - now;
                        // 判断并回收key 过期数加1
                        if (activeExpireCycleTryExpire(db, e, now)) expired++;
                        if (ttl > 0) {
                            // 如果 key 没有过期
                            /* We want the average TTL of keys yet
                             * not expired. */
                            // 总共生存时间累加
                            ttl_sum += ttl;
                            // 生存的样本数加一
                            ttl_samples++;
                        }
                        // 样本数加一
                        sampled++;
                    }
                }
                // 游标移动
                db->expires_cursor++;
            }
            total_expired += expired;
            total_sampled += sampled;

            /* Update the average TTL stats for this database. */
            // 如果生存的样本大于0
            if (ttl_samples) {
                // 计算 平均生存时间 设置db 平均生存时间
                long long avg_ttl = ttl_sum / ttl_samples;

                /* Do a simple running average with a few samples.
                 * We just use the current estimate with a weight of 2%
                 * and the previous estimate with a weight of 98%.
                 * 用几个样本做一个简单的移动平均数。 我们仅使用权重为2%的当前估计值和权重为98%的先前估计值。*/
                if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
                db->avg_ttl = (db->avg_ttl / 50) * 49 + (avg_ttl / 50);
            }

            /* We can't block forever here even if there are many keys to
             * expire. So after a given amount of milliseconds return to the
             * caller waiting for the other active expire cycle.
             * 即使有很多要过期的密钥,我们也不能在这里永远阻止。 因此,在给定的毫秒数后,返回到调用方,等待另一个活动的到期周期。*/
            // 判断有木有扫描完所有db
            if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
                // 消耗的时间
                elapsed = ustime() - start;
                // 如果消耗的时间 大于限制时间,直接退出
                if (elapsed > timelimit) {
                    timelimit_exit = 1;
                    // 服务器过早退出过期循环数加1
                    server.stat_expired_time_cap_reached_count++;
                    break;
                }
            }
            /* We don't repeat the cycle for the current database if there are
             * an acceptable amount of stale keys (logically expired but yet
             * not reclaimed). 如果存在可接受数量的过期key(逻辑上已过期但尚未回收),我们将不为当前数据库重复该周期。*/
            // 本次扫描的样本为0(这个数据库没有数据),  或者过期数大于10% 就一直扫描
        } while (sampled == 0 ||
                 (expired * 100 / sampled) > config_cycle_acceptable_stale);
    }
    // 已经过了多少时间
    elapsed = ustime() - start;
    // 设置服务器累计使用的微妙数
    server.stat_expire_cycle_time_used += elapsed;
    // 仅当经过时间>= 达到配置的阈值时,才添加样本。潜在的添加样本
    latencyAddSampleIfNeeded("expire-cycle", elapsed / 1000);

    /* Update our estimate of keys existing but yet to be expired.
     * Running average with this sample accounting for 5%. */
    double current_perc;
    if (total_sampled) {
        // 计算本次扫描秒的key百分比
        current_perc = (double) total_expired / total_sampled;
    } else
        current_perc = 0;
    // 设置 可能已过期的key的百分比
    server.stat_expired_stale_perc = (current_perc * 0.05) +
                                     (server.stat_expired_stale_perc * 0.95);
}

大致的步骤

  1. 16 个库挨个扫描
  2. 每一个库,取20个(默认)设置了过期时间的 key。
  3. 检查删除20个里面过期的key。
  4. 如果每次过期的大于所取样本的10%(默认)或 没有过期(样本数为0)的,那么循环第一步 和 第二步。
终止扫描的条件

全局扫描秒终止条件:

  • 使用的时间大于限制时间(默认是25ms / 25000微妙),如果是快速过期 时间更短(默认 1ms),这个终止是立即终止,不循环库了。
  • 每一个库都扫描完了。

每一个库终止条件:

  • 取的样本不为0,并且过期比例小于10% 就不扫描了

需要注意的是。

  1. 定期扫描发生的时候会在 redis 哈希扩容时触发
  2. 定期扫描会在 aeMain-主服务器循环 和 processEventsWhileBlocked-在RDB / AOF加载期间处理客户端 时调用。

为什么要上限25毫秒和10%的阈值

前文说过了,redis是高吞吐量的,不限制过期回收的时间,那么redis单线程吞吐量势必会大大降低。

定期扫描 25毫秒的限制和10%阈值的结合,可以分散过期处理的压力。过期的键没有10%,代表没有继续清理的必要。

关于异步删除

异步删除,是先unlink 某一个key,然后后台线程在慢慢的删除。unlink之后,客户端就读不出key的值了。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Redis 过期策略是控制 Redis key 生命周期的重要手段,以下是 Redis 过期策略的知识体系: 1. 过期时间:过期时间是指 Redis key 存在的时间,可以通过 EXPIRE 命令和 PEXPIRE 命令来设置。EXPIRE 命令设置的过期时间是一个固定的时间,而 PEXPIRE 命令设置的过期时间是一个相对时间,即从当前时间开始计算。 2. 过期删除:过期删除是指 Redis 在 key 过期后自动删除 key 的机制。Redis 提供了惰性过期和定期删除两种过期删除策略。 3. 惰性过期:惰性过期是指 Redis 在访问 key 时检查 key 是否过期,如果过期则删除 key。这种策略可以减少 Redis 的负载,但可能会导致过期 key 的数量增多,占用更多的内存空间。 4. 定期删除:定期删除是指 Redis 在每隔一段时间扫描整个数据库,删除过期的 key。这种策略可以减少过期 key 的数量,但可能会导致 Redis 的性能下降。 5. 淘汰策略:当 Redis 内存空间不足时,会触发淘汰策略,即删除一些不常用或者过期的 key,来腾出更多的空间。Redis 提供了多种淘汰策略,例如 LRU(最近最少使用)、LFU(最不经常使用)等。 总之,Redis 过期策略是控制 Redis key 生命周期的重要手段,可以通过过期时间、过期删除、淘汰策略等方式来控制 Redis 中 key 的存储和释放。在面试中,还需要掌握 Redis 过期策略的原理、机制、优缺点、调优等方面的知识。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值