• Redis内存回收机制-内存淘汰策略和过期策略


            Redis是基于内存操作的非关系型数据库,在内存空间不足的时候,为了保证程序的运行和命中率,就会淘汰一部分数据。如何淘汰数据?这就是Redis的内存回收策略。

            Redis中的内存回收策略主要有两个方面:

            Redis过期策略:删除已经过期的数据;

            Redis淘汰策略:内存使用到达maxmemory上限时触发内存淘汰策略回收数据。

            在Redis 官方文档中,有下面一段话:

    1. There are two special functions called periodically by the event loop:
    2. 1. `serverCron()` is called periodically (according to `server.hz` frequency), and performs tasks that must be performed from time to time, like checking for timedout clients.
    3. 2. `beforeSleep()` is called every time the event loop fired, Redis served a few requests, and is returning back into the event loop.
    4. Inside server.c you can find code that handles other vital things of the Redis server:
    5. * `call()` is used in order to call a given command in the context of a given client.
    6. * `activeExpireCycle()` handles eviciton of keys with a time to live set via the `EXPIRE` command.
    7. * `freeMemoryIfNeeded()` is called when a new write command should be performed but Redis is out of memory according to the `maxmemory` directive.

            其中 activeExpireCycle() 和 freeMemoryIfNeeded() 就是内存回收策略的实现。而 activeExpireCycle() 的调用在 serverCron() 和 beforeSleep() 方法里面,定期删除过期的 key,而freeMemoryIfNeeded() 方法主要是当内存不够时,进行内存淘汰策略回收数据。

            所以, Redis 采用「惰性删除+定期删除」这两种策略配和使用,在 CPU 使用上和避免内存浪费之间取得平衡。        

    1、Redis过期策略

            如果一个 key 过期了,Redis会通过过期删除策略对过期的 key 进行删除。

            Redis的过期策略有两种:惰性删除和定期删除。惰性删除为被动删除策略,而定期删除为主动删除策略。

            通过EXPIRE命令和PEXPIRE命令,可以设置Redis中的某个 key 的生存时间(Time To Live,TTL),在经过设置的时间之后,服务器就会自动删除生存时间为0的 key 。

            设置 key 的过期时间命令如下:

            EXPIRE :设置 key 的生存时间,ttl单位为秒。

            PEXPIRE :设置 key 的生存时间,ttl单位为毫秒。

            EXPIREAT :设置 key 的过期时间,timestamp为指定的秒数时间戳。

            PEXPIREAT :设置 key 的过期时间,timestamp为指定的毫秒数时间戳。

            SETEX :设置 key 的值为value,并设置超时时间,ttl单位为毫秒。

    1.1、被动方式(passive way)

            Redis不会主动扫描过期的 key 进行删除,而是在客户端尝试访问 key 时,检查 key 是否过期

    • 如果 key 已经失效,就删除 key ;
    • 如果 key 没有失效,就返回该 key 的值;

            但是那些从未被查询过的 key ,即使这些 key 已经失效了,也无法删除。

            惰性删除策略的优点:对CPU时间来说是最友好的,程序只会在获取 key 时才进行过期检查,并且只会删除当前执行的 key ,而不会在删除其他无关的过期 key 。

            惰性删除策略的缺点:对内存不够友好,如果一个 key 已经过期,这个 key 有可能仍然保留在数据库中,所占用的内存不会释放。如果数据库中有非常多的过期 key ,而这些过期 key 又恰好没有被访问到的话,那么它们也许永远也不会被删除(除非用户手动执行FLUSHDB),我们甚至可以将这种情况看作是一种内存泄漏——无用的垃圾数据占用了大量的内存。

            惰性删除的实现

            Redis 解析完客户端执行的命令请求之后,会调用函数 processCommand 处理该命令请求,而处理命令请求之前还有很多校验逻辑,其中就有最大内存检测。如果配置文件中使用指令“maxmemory ”设置了最大内存限制,且当前内存使用量超过了该配置阈值,服务器会拒绝执行带有“m”(CMD_DENYOOM)标识的命令,如SET命令、APPEND命令和LPUSH命令等。

    1. int processCommand(client *c) {
    2. int is_denyoom_command = (c->cmd->flags & CMD_DENYOOM) ||
    3. (c->cmd->proc == execCommand &&
    4. (c->mstate.cmd_flags & CMD_DENYOOM));
    5. /* Handle the maxmemory directive.
    6. *
    7. * Note that we do not want to reclaim memory if we are here re-entering
    8. * the event loop since there is a busy Lua script running in timeout
    9. * condition, to avoid mixing the propagation of scripts with the
    10. * propagation of DELs due to eviction. */
    11. if (server.maxmemory && !server.lua_timedout) {
    12. int out_of_memory = freeMemoryIfNeededAndSafe() == C_ERR;
    13. /* freeMemoryIfNeeded may flush slave output buffers. This may result
    14. * into a slave, that may be the active client, to be freed. */
    15. if (server.current_client == NULL) return C_ERR;
    16. int reject_cmd_on_oom = is_denyoom_command;
    17. /* If client is in MULTI/EXEC context, queuing may consume an unlimited
    18. * amount of memory, so we want to stop that.
    19. * However, we never want to reject DISCARD, or even EXEC (unless it
    20. * contains denied commands, in which case is_denyoom_command is already
    21. * set. */
    22. if (c->flags & CLIENT_MULTI &&
    23. c->cmd->proc != execCommand &&
    24. c->cmd->proc != discardCommand) {
    25. reject_cmd_on_oom = 1;
    26. }
    27. if (out_of_memory && reject_cmd_on_oom) {
    28. rejectCommand(c, shared.oomerr);
    29. return C_OK;
    30. }
    31. }
    32. return C_OK;
    33. }

            过期的惰性删除策略由 evict.c/freeMemoryIfNeededAndSafe()方法调用db.c/expireIfNeeded()实现, 所有读写数据库的Redis命令在执行之前都会调用freeMemoryIfNeededAndSafe() 对 key 进行校验

            如果过期:那么expireIfNeeded函数将输入的 key 从数据库删除;

            如果未过期:那么expireIfNeeded不做任何动作;

    1. /* This is a wrapper for freeMemoryIfNeeded() that only really calls the
    2. * function if right now there are the conditions to do so safely:
    3. *
    4. * - There must be no script in timeout condition.
    5. * - Nor we are loading data right now.
    6. *
    7. */
    8. int freeMemoryIfNeededAndSafe(void) {
    9. if (server.lua_timedout || server.loading) return C_OK;
    10. return freeMemoryIfNeeded();
    11. }
    1. /* This function is called when we are going to perform some operation
    2. * in a given key, but such key may be already logically expired even if
    3. * it still exists in the database. The main way this function is called
    4. * is via lookupKey*() family of functions.
    5. *
    6. * The behavior of the function depends on the replication role of the
    7. * instance, because slave instances do not expire keys, they wait
    8. * for DELs from the master for consistency matters. However even
    9. * slaves will try to have a coherent return value for the function,
    10. * so that read commands executed in the slave side will be able to
    11. * behave like if the key is expired even if still present (because the
    12. * master has yet to propagate the DEL).
    13. *
    14. * In masters as a side effect of finding a key which is expired, such
    15. * key will be evicted from the database. Also this may trigger the
    16. * propagation of a DEL/UNLINK command in AOF / replication stream.
    17. *
    18. * The return value of the function is 0 if the key is still valid,
    19. * otherwise the function returns 1 if the key is expired. */
    20. int expireIfNeeded(redisDb *db, robj *key) {
    21. if (!keyIsExpired(db,key)) return 0;
    22. /* If we are running in the context of a slave, instead of
    23. * evicting the expired key from the database, we return ASAP:
    24. * the slave key expiration is controlled by the master that will
    25. * send us synthesized DEL operations for expired keys.
    26. *
    27. * Still we try to return the right information to the caller,
    28. * that is, 0 if we think the key should be still valid, 1 if
    29. * we think the key is expired at this time. */
    30. if (server.masterhost != NULL) return 1;
    31. /* Delete the key */
    32. server.stat_expiredkeys++;
    33. propagateExpire(db,key,server.lazyfree_lazy_expire);
    34. notifyKeyspaceEvent(NOTIFY_EXPIRED,
    35. "expired",key,db->id);
    36. return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
    37. dbSyncDelete(db,key);
    38. }

    1.2、主动方式(active way)

            定期删除策略是每隔一段时间,程序就对数据库进行一次检查,从设置失效时间的 key 中,选择一部分失效的 key 进行删除操作。定期删除策略需要根据服务器的运行情况,合理地设置删除操作的执行时长和执行频率,减少删除操作对CPU时间的影响。

            如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,以至CPU时间过多地消耗在删除过期 key 上面。

            如果删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除策略一样,出现浪费内存的情况。

            Redis 服务启动后,内部维护一个定时任务 databasesCron() ,默认每100ms执行一次(通过配置hz控制,1000/server.hz)。定时任务中删除过期 key 逻辑采用了自适应算法, 根据 key 的过期比例、 使用快慢两种速率模式回收 key 。定期删除策略通过 expire.c/activeExpireCycle(int type)实现,其中的入参 type 为 ACTIVE_EXPIRE_CYCLE_FAST(快)、ACTIVE_EXPIRE_CYCLE_SLOW(慢)回收模式。

    1. int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    2. server.hz = server.config_hz;
    3. /* Adapt the server.hz value to the number of configured clients. If we have
    4. * many clients, we want to call serverCron() with an higher frequency. */
    5. if (server.dynamic_hz) {
    6. while (listLength(server.clients) / server.hz >
    7. MAX_CLIENTS_PER_CLOCK_TICK)
    8. {
    9. server.hz *= 2;
    10. if (server.hz > CONFIG_MAX_HZ) {
    11. server.hz = CONFIG_MAX_HZ;
    12. break;
    13. }
    14. }
    15. }
    16. /* Handle background operations on Redis databases. */
    17. databasesCron();
    18. server.cronloops++;
    19. return 1000/server.hz;
    20. }

            Redis每隔 100ms 随机测试一些 key ,根据 key 的过期比例,使用快慢两种速率回收 key ,Redis 6.0之前的版本回收具体的流程:

            1、随机选取20个带有过期时间的 key ;

            2、删除其中已经过期的 key ;

            3、如果超过25%的 key 被删除,则重复执行步骤1

            定期删除不断重复的进行过期检测,直到过期的 keys 的百分百低于25%,这意味着,在任何给定的时刻,最多会清除1/4的过期 keys。

            整个执行流程如下:

            具体实现在 expire.c/activeExpireCycle() 方法里面,ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 表示每次循环随机获取过期 key 的数量,默认值为20,循环终止条件为 expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4,当过期 key 的数量小于等于5时,终止循环。

    1. /* Try to expire a few timed out keys. The algorithm used is adaptive and
    2. * will use few CPU cycles if there are few expiring keys, otherwise
    3. * it will get more aggressive to avoid that too much memory is used by
    4. * keys that can be removed from the keyspace.
    5. *
    6. * No more than CRON_DBS_PER_CALL databases are tested at every
    7. * iteration.
    8. *
    9. * This kind of call is used when Redis detects that timelimit_exit is
    10. * true, so there is more work to do, and we do it more incrementally from
    11. * the beforeSleep() function of the event loop.
    12. *
    13. * Expire cycle type:
    14. *
    15. * If type is ACTIVE_EXPIRE_CYCLE_FAST the function will try to run a
    16. * "fast" expire cycle that takes no longer than EXPIRE_FAST_CYCLE_DURATION
    17. * microseconds, and is not repeated again before the same amount of time.
    18. *
    19. * If type is ACTIVE_EXPIRE_CYCLE_SLOW, that normal expire cycle is
    20. * executed, where the time limit is a percentage of the REDIS_HZ period
    21. * as specified by the ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC define. */
    22. void activeExpireCycle(int type) {
    23. for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
    24. int expired;
    25. redisDb *db = server.db+(current_db % server.dbnum);
    26. /* Increment the DB now so we are sure if we run out of time
    27. * in the current DB we'll restart from the next. This allows to
    28. * distribute the time evenly across DBs. */
    29. current_db++;
    30. /* Continue to expire if at the end of the cycle more than 25%
    31. * of the keys were expired. */
    32. do {
    33. /* We don't repeat the cycle if there are less than 25% of keys
    34. * found expired in the current DB. */
    35. } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
    36. }
    37. }

            Redis 6.0版本对 activeExpireCycle() 方法进行了调整,Redis 每隔100ms,就对一些 key 进行采样检查,检查是否过期,如果过期就进行删除,具体流程:

            1、采样一定个数的key,采样的个数可以进行配置,并将其中过期的 key 全部删除;

            2、如果过期 key 的占比超过可接受的过期 key 的百分比,则重复执行步骤1

            3、如果过期 key 的比例降至可接受的过期 key 的百分比以下,结束回收流程。

            其中从库中的过期 key 只能通过主库进行删除。

    1. void activeExpireCycle(int type) {
    2. /* Adjust the running parameters according to the configured expire
    3. * effort. The default effort is 1, and the maximum configurable effort
    4. * is 10. */
    5. unsigned long
    6. effort = server.active_expire_effort-1, /* Rescale from 0 to 9. */
    7. config_cycle_acceptable_stale = ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE-
    8. effort;
    9. /* Accumulate some global stats as we expire keys, to have some idea
    10. * about the number of keys that are already logically expired, but still
    11. * existing inside the database. */
    12. long total_sampled = 0;
    13. long total_expired = 0;
    14. for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
    15. /* Expired and checked in a single loop. */
    16. unsigned long expired, sampled;
    17. redisDb *db = server.db+(current_db % server.dbnum);
    18. /* Increment the DB now so we are sure if we run out of time
    19. * in the current DB we'll restart from the next. This allows to
    20. * distribute the time evenly across DBs. */
    21. current_db++;
    22. /* Continue to expire if at the end of the cycle there are still
    23. * a big percentage of keys to expire, compared to the number of keys
    24. * we scanned. The percentage, stored in config_cycle_acceptable_stale
    25. * is not fixed, but depends on the Redis configured "expire effort". */
    26. do {
    27. unsigned long num, slots;
    28. long long now, ttl_sum;
    29. int ttl_samples;
    30. iteration++;
    31. /* If there is nothing to expire try next DB ASAP. */
    32. if ((num = dictSize(db->expires)) == 0) {
    33. db->avg_ttl = 0;
    34. break;
    35. }
    36. slots = dictSlots(db->expires);
    37. now = mstime();
    38. /* When there are less than 1% filled slots, sampling the key
    39. * space is expensive, so stop here waiting for better times...
    40. * The dictionary will be resized asap. */
    41. if (num && slots > DICT_HT_INITIAL_SIZE &&
    42. (num*100/slots < 1)) break;
    43. /* The main collection cycle. Sample random keys among keys
    44. * with an expire set, checking for expired ones. */
    45. expired = 0;
    46. sampled = 0;
    47. ttl_sum = 0;
    48. ttl_samples = 0;
    49. if (num > config_keys_per_loop)
    50. num = config_keys_per_loop;
    51. /* Here we access the low level representation of the hash table
    52. * for speed concerns: this makes this code coupled with dict.c,
    53. * but it hardly changed in ten years.
    54. *
    55. * Note that certain places of the hash table may be empty,
    56. * so we want also a stop condition about the number of
    57. * buckets that we scanned. However scanning for free buckets
    58. * is very fast: we are in the cache line scanning a sequential
    59. * array of NULL pointers, so we can scan a lot more buckets
    60. * than keys in the same time. */
    61. long max_buckets = num*20;
    62. long checked_buckets = 0;
    63. while (sampled < num && checked_buckets < max_buckets) {
    64. for (int table = 0; table < 2; table++) {
    65. if (table == 1 && !dictIsRehashing(db->expires)) break;
    66. unsigned long idx = db->expires_cursor;
    67. idx &= db->expires->ht[table].sizemask;
    68. dictEntry *de = db->expires->ht[table].table[idx];
    69. long long ttl;
    70. /* Scan the current bucket of the current table. */
    71. checked_buckets++;
    72. while(de) {
    73. /* Get the next entry now since this entry may get
    74. * deleted. */
    75. dictEntry *e = de;
    76. de = de->next;
    77. ttl = dictGetSignedIntegerVal(e)-now;
    78. if (activeExpireCycleTryExpire(db,e,now)) expired++;
    79. if (ttl > 0) {
    80. /* We want the average TTL of keys yet
    81. * not expired. */
    82. ttl_sum += ttl;
    83. ttl_samples++;
    84. }
    85. sampled++;
    86. }
    87. }
    88. db->expires_cursor++;
    89. }
    90. total_expired += expired;
    91. total_sampled += sampled;
    92. /* We can't block forever here even if there are many keys to
    93. * expire. So after a given amount of milliseconds return to the
    94. * caller waiting for the other active expire cycle. */
    95. if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
    96. elapsed = ustime()-start;
    97. if (elapsed > timelimit) {
    98. timelimit_exit = 1;
    99. server.stat_expired_time_cap_reached_count++;
    100. break;
    101. }
    102. }
    103. /* We don't repeat the cycle for the current database if there are
    104. * an acceptable amount of stale keys (logically expired but yet
    105. * not reclaimed). */
    106. } while (sampled == 0 ||
    107. (expired*100/sampled) > config_cycle_acceptable_stale);
    108. }
    109. }

            当失效的 key 占检测样本的 key 的比例小于等于10%,就终止循环,结束回收流程。判断条件 (expired*100/sampled) > config_cycle_acceptable_stale) ,其中 config_cycle_acceptable_stale 的值为10。config_cycle_acceptable_stale的计算公式如下:

            config_cycle_acceptable_stale = ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE-

    effort;

            effort = server.active_expire_effort-1, /* Rescale from 0 to 9. */

            server.active_expire_effort 的默认值为1,ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE的默认值为10,所以结束回收的条件就是 (expired(失效的key的数量)/sampled(抽取key的数量)*100) > 10 。

            定期删除的优点是:通过定期删除过期 key ,有效地减少了因为过期 key 带来的内存浪费。

            定期删除的缺点是:会漏掉了很多过期 key ,然后你也没及时去查,也就没走惰性删除,造成大量过期 key 堆积在内存里。

            Redis服务器使用的是惰性删除和定期删除两种策略,通过配合使用这两种删除策略,服务器可以很好地在合理使用CPU时间和避免浪费内存空间之间取得平衡。

    1.3、RDB、AOF对过期 key 的处理

    1.3.1、RDB对过期 key 的处理

            执行SAVE命令或者BGSAVE命令创建一个新的RDB文件时,程序会对数据库中的 key 进行检查,已过期的 key 不会被保存到新创建的RDB文件中。

    在启动Redis服务器时,如果服务器开启了RDB功能,那么服务器将对RDB文件进行载入:

            如果服务器以主服务器模式运行,在载入RDB文件时会对文件中的 key 进行检查,未过期的 key 会被载入到数据库中,而过期的 key 则会被忽略。

            如果服务器以从服务器模式运行,那么在载入RDB文件时,文件中保存的所有 key ,不论是否过期,都会被载入到数据库中。不过,因为主从服务器在进行数据同步的时候,从服务器的数据库就会被清空。

    1.3.2、AOF对过期 key 的处理

    当服务器以AOF持久化模式运行时,如果数据库中的某个 key 已经过期,但它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期 key 而产生任何影响。当过期 key 被惰性删除或者定期删除之后,程序会向AOF文件追加(append)一条DEL命令,来显式地记录该 key 已被删除。因此,数据库中包含过期 key 不会对AOF重写造成影响。

    2、Redis内存淘汰机制

    2.1、内存淘汰策略(maxmemory-policy)

    当Redis使用内存空间超过 maxmemory 的值时,Redis将根据配置的淘汰策略,尝试删除一部分 key 。Redis 4.0之前一共实现了6种内存淘汰策略,在4.0之后,又增加了volatile-lfu和all keys -lfu 2种淘汰策略。

            根据淘汰数据类型分成两类:

    • 设置过期时间 key 的淘汰策略:volatile-random、volatile-ttl、volatile-lru、volatile-lfu;
    • 所有 key 范围的淘汰策略:all keys -lru、all keys -random、all keys -lfu;

            Redis 默认淘汰策略是noeviction,在使用的内存空间超过maxmemory值时,并不会淘汰数据,如果缓存被写满,只读命令GET等可以正常执行,而执行SET, LPUSH等命令,Redis将会报错。

            Redis淘汰策略:

    • noeviction(默认):新写入操作会报错;
    • volatile-ttl:在设置了过期时间的 key 中,越早过期的越先被删除;
    • volatile-random:在设置了过期时间的 key 中,随机移除某个 key ;
    • volatile-lru:在设置了过期时间的 key 中,使用LRU算法,移除最近最少使用的 key ;
    • volatile-lfu:在设置了过期时间的 key 中,使用LFU算法,移除最最不经常使用的 key ;
    • all keys -lru:在 key 中,使用LRU算法,移除最近最少使用的 key ;
    • all keys -lfu:在 key 中,使用LFU算法,移除最近最少使用的 key ;
    • all keys -random:在 key 中,随机移除某个 key ;

    备注:

    内存淘汰策略 Redis 官方文档

    Key eviction | Redis

    2.1、最大内存和内存回收策略

            maxmemory用来设置redis存放数据的最大的内存大小,一旦超出这个内存大小之后,就会立即使用Redis的淘汰策略,来淘汰内存中的一些对象,以保证新数据的存入。当maxmemory限制达到的时,Redis会使用的行为由 Redis的maxmemory-policy配置指令来进行配置。

            通过redis.conf可以设置maxmemory的值,或者之后使用CONFIG SET命令来配置maxmemory的值。

    1. // 设置最大的内存限制
    2. config set maxmemory 100mb

            修改Redis的配置文件 redis.confg 配置maxmemory的最大值。

    maxmemory 100mb  #内存限制为100mb

            maxmemory设置为0表示没有内存限制。64位系统默认值为0,32位系统默认内存限制为3GB。

    1. /* 32 bit instances are limited to 4GB of address space, so if there is
    2. * no explicit limit in the user provided configuration we set a limit
    3. * at 3 GB using maxmemory with 'noeviction' policy'. This avoids
    4. * useless crashes of the Redis instance for out of memory. */
    5. if (server.arch_bits == 32 && server.maxmemory == 0) {
    6. serverLog(LL_WARNING,"Warning: 32 bit instance detected but no memory limit set. Setting 3 GB maxmemory limit with 'noeviction' policy now.");
    7. server.maxmemory = 3072LL*(1024*1024); /* 3 GB */
    8. server.maxmemory_policy = MAXMEMORY_NO_EVICTION;
    9. }

    2.3、淘汰策略回收过程

            理解回收进程如何工作是非常重要的:

            1、客户端运行了新的命令,添加了新的数据

            2、Redi检查内存使用情况,如果大于maxmemory的限制, 则根据设定好的策略进行回收。

            3、执行新的命令

            Redis的LRU算法并非完整的实现。这意味着Redis并没办法选择最佳候选来进行回收,也就是最久未被访问的 key 。相反它会尝试运行一个近似LRU的算法,通过对少量 keys 进行取样,然后回收其中一个最好的 key (被访问时间较早的)。

            不过从Redis 3.0算法已经改进为回收 key 的候选池子。这改善了算法的性能,使得更加近似真是的LRU算法的行为。

            Redis LRU有个很重要的点,你通过调整每次回收时检查的采样数量,以实现调整算法的精度。这个参数可以通过以下的配置指令调整:

            maxmemory-samples 5

            Redis为什么不使用真实的LRU实现是因为这需要太多的内存。不过近似的LRU算法对于应用而言应该是等价的。

    参考

    《Redis设计与实现》—— 黄健宏

    《Redis5设计与源码分析》—— 陈雷

  • 相关阅读:
    嵌入式软件工程师面试题——2025校招社招通用(十一)
    Milvus 入门教程
    合宙Air101 的LCD怎么用Arudino IDE驱动
    云原生周刊:Docker 推出 Docker Build Cloud
    SDK各类型广告的效果
    【Handler机制分析】
    04【NIO核心组件之Buffer】
    DVWA靶场-CSRF跨站请求伪造
    【可靠性测试】什么是可靠性测试:定义、方法和工具
    STM32 串口接收中断被莫名关闭
  • 原文地址:https://blog.csdn.net/zhangchaoyang/article/details/133927463