Memcached过期键删除策略

1. 惰性删除。memcached一般不会主动去清除已经过期或者失效的缓存,当get请求一个item的时候,才会去检查item是否失效。

2. flush命令。flush命令会将所有的item设置为失效。

3. 创建的时候检查。Memcached会在创建ITEM的时候去LRU的链表尾部开始检查,是否有失效的ITEM,如果没有的话就重新创建。

4. LRU爬虫。memcached默认是关闭LRU爬虫的。LRU爬虫是一个单独的线程,会去清理失效的ITEM。

5. LRU淘汰。当缓存没有内存可以分配给新的元素的时候,memcached会从LRU链表的尾部开始淘汰一个ITEM,不管这个ITEM是否还在有效期都将会面临淘汰。LRU链表插入缓存ITEM的时候有先后顺序,所以淘汰一个ITEM也是从尾部进行 也就是先淘汰最早的ITEM。

LRU的数据结构和基本操作

Mecached的LRU的链表操作主要在item.c这个文件上的。其中数组heads和tails分别存储不同的LRU的双向链表的头地址和尾部地址。

每个slabs class都会有自己的一个双向链表结构。链表结构主要通过item结构中的两个指针地址来记录item在链表上左右两边位置的item地址值。

1 //item的具体结构
2 typedef struct _stritem {
3     //记录LRU双向链表下一个item的地址
4     struct _stritem *next;  //下一个结构
5     //记录LRU双向链表前一个Item的地址
6     struct _stritem *prev;  //前一个结构
7
8     //....more code
9 } item;

item_link_q方法主要是将一个item添加到LRU链表上面:

 1 //从LRU链表上新增一个Item
 2 //LRU链表是一个双向链表结构
 3 static void item_link_q(item *it) { /* item is the new head */
 4     item **head, **tail;
 5     assert(it->slabs_clsid < LARGEST_ID);
 6     assert((it->it_flags & ITEM_SLABBED) == 0);
 7
 8     head = &heads[it->slabs_clsid];
 9     tail = &tails[it->slabs_clsid];
10     assert(it != *head);
11     assert((*head && *tail) || (*head == 0 && *tail == 0));
12     it->prev = 0;
13     it->next = *head;
14     if (it->next) it->next->prev = it;
15     *head = it;
16     if (*tail == 0) *tail = it;
17     sizes[it->slabs_clsid]++;
18     return;
19 }

 item_unlink_q方法主要是将一个item从LRU链表上面解除:

 1 //从LRU链表上解除Item
 2 static void item_unlink_q(item *it) {
 3     item **head, **tail;
 4     assert(it->slabs_clsid < LARGEST_ID);
 5     head = &heads[it->slabs_clsid];
 6     tail = &tails[it->slabs_clsid];
 7
 8     if (*head == it) {
 9         assert(it->prev == 0);
10         *head = it->next;
11     }
12     if (*tail == it) {
13         assert(it->next == 0);
14         *tail = it->prev;
15     }
16     assert(it->next != it);
17     assert(it->prev != it);
18
19     if (it->next) it->next->prev = it->prev;
20     if (it->prev) it->prev->next = it->next;
21     sizes[it->slabs_clsid]--;
22     return;
23 }

策略1--惰性删除

Memcached的缓存清除策略是惰性的。这个如何来理解?当用户设置了一个缓存数据,缓存有效期为5分钟。当5分钟时间过后,缓存失效,这个时候Memcached并不会自动去检查当前的Item是否过期。当客户端再次来请求这个数据的时候,才会去检查缓存是否失效了,如果失效则会去清除这个数据。

看一下do_item_get这个方法中,判断缓存数据是否失效的代码:

 1 /** wrapper around assoc_find which does the lazy expiration logic */
 2 item *do_item_get(const char *key, const size_t nkey, const uint32_t hv) {
 3 //...code
 4     if (it != NULL) {
 5         //settings.oldest_live主要用来记录flush命令执行的时间
 6         //it->time用来记录item最近set/add/replce等操作的时间(get操作不会改变)
 7         //然后判断it->time是否在执行flush命令之前,如果是执行flush之前,说明该item已经失效
 8         if (settings.oldest_live != 0 && settings.oldest_live <= current_time &&
 9             it->time <= settings.oldest_live) {
10             //LRU链表和HASHTABLE上解除绑定
11             do_item_unlink(it, hv);
12             //删除该Item
13             do_item_remove(it);
14             it = NULL; //返回NULL
15             if (was_found) {
16                 fprintf(stderr, " -nuked by flush");
17             }
18         //检查是否过期,主要是检查有效期时间
19         //如果数据已经过期,则需要清除
20         } else if (it->exptime != 0 && it->exptime <= current_time) {
21             //LRU链表和HASHTABLE上解除绑定
22             do_item_unlink(it, hv);
23             //删除该Item
24             do_item_remove(it);
25             it = NULL;
26             if (was_found) {
27                 fprintf(stderr, " -nuked by expire");
28             }
29         } else {
30             it->it_flags |= ITEM_FETCHED;
31             DEBUG_REFCNT(it, '+');
32         }
33     }
34 //...code
35 }

策略2 -- flush命令

当用户发送一个flush命令的时候,Memcached会将命令之前的所有的缓存都设置为失效。

Memcached不会主动去清除这些item。主要通过两种方式:

1. do_item_flush_expired方法。

  Memcached会在接受到flush命令的时候,将设置全局参数settings.oldest_live =current_time - 1。然后去调用item_flush_expired方法。因为设置全局参数item_flush_expired到调用缓存锁方法之间会有一定的时间差,有可能这个过程中,会有新的item在操作。

  然后Memcached调用do_item_flush_expired方法,去遍历所有的LRU链表。do_item_flush_expired不会将每一个在flush命令前的Item删除,因为这样会非常耗时,而是删除在设置全局变量到加上缓存锁这之间操作的item。这样就能加快flush的速度。

2. 惰性删除方法。

  Memcached会在get操作的时候去判断it->time是否小于settings.oldest_live,如果小于,说明这个item就是过期的。通过这种方法,惰性删除大批量的item数据。

 1 /*
 2  * Flushes expired items after a flush_all call
 3  */
 4 void item_flush_expired() {
 5     mutex_lock(&cache_lock);
 6     do_item_flush_expired();
 7     mutex_unlock(&cache_lock);
 8 }
 9 /* expires items that are more recent than the oldest_live setting. */
10 void do_item_flush_expired(void) {
11     int i;
12     item *iter, *next;
13     if (settings.oldest_live == 0)
14         return;
15     for (i = 0; i < LARGEST_ID; i++) {
16         /* The LRU is sorted in decreasing time order, and an item's timestamp
17          * is never newer than its last access time, so we only need to walk
18          * back until we hit an item older than the oldest_live time.
19          * The oldest_live checking will auto-expire the remaining items.
20          */
21         for (iter = heads[i]; iter != NULL; iter = next) {
22             /* iter->time of 0 are magic objects. */
23             //iter->time 最近一次的访问时间
24             //这边为何是iter->time >= settings.oldest_live?
25             //因为在执行do_item_flush_expired方法前,已经上了cache锁,其它worker是不能操作的
26             //这边过程中,如果遍历每一个Item都去删除,那么这个遍历过程会非常缓慢,会导致客户端一直等待。
27             //
28             //Memcached就想出了一个聪明的办法,从设置settings.oldest_live到上锁之间,还是会有其它客户端
29             //操作item数据,那么Memcache就将这一部分数据先清理(这部分数据非常少量),这样就能加快flush的速度
30             //而剩余iter->time < settings.oldest_live的那大批量的item,会通过惰性删除的方式,在get请求中去判断处理
31             if (iter->time != 0 && iter->time >= settings.oldest_live) {
32                 next = iter->next;
33                 if ((iter->it_flags & ITEM_SLABBED) == 0) {
34                     do_item_unlink_nolock(iter, hash(ITEM_key(iter), iter->nkey));
35                 }
36             } else {
37                 /* We've hit the first old item. Continue to the next queue. */
38                 break;
39             }
40         }
41     }
42 }

策略3 - -分配Item的时候去检查

  1 //创建一个新的Item
  2 item *do_item_alloc(char *key, const size_t nkey, const int flags,
  3                     const rel_time_t exptime, const int nbytes,
  4                     const uint32_t cur_hv) {
  5     uint8_t nsuffix;
  6     item *it = NULL; //item结构
  7     char suffix[40];
  8     //item_make_header 计算存储数据的总长度
  9     size_t ntotal = item_make_header(nkey + 1, flags, nbytes, suffix, &nsuffix);
 10     if (settings.use_cas) {
 11         ntotal += sizeof(uint64_t);
 12     }
 13
 14     //通过ntotal 查询在哪个slabs_class上面
 15     //Memcached会根据存储数据长度的不同,分为N多个slabs_class
 16     //用户存储数据的时候,根据需要存储数据的长度,就可以查询到需要存储到哪个slabs_class中。
 17     //每个slabs_class都由诺干个slabs组成,slabs每个大小为1M,我们的item结构的数据就会被分配在slabs上
 18     //每个slabs都会根据自己slabs_class存储的数据块的大小,会被分割为诺干个chunk
 19     //
 20     //举个例子:
 21     //如果id=1的slabs_class为存储 最大为224个字节的缓存数据
 22     //当用户的设置的缓存数据总数据长度为200个字节,则这个item结构就会存储到id=1的slabs_class上。
 23     //当第一次或者slabs_class中的slabs不够用的时候,slabs_class就会去分配一个1M的slabs给存储item使用
 24     //因为id=1的slabs_class存储小于224个字节的数据,所以slabs会被分割为诺干个大小为224字节的chunk块
 25     //我们的item结构数据,就会存储在这个chunk块上面
 26     unsigned int id = slabs_clsid(ntotal);
 27     if (id == 0)
 28         return 0;
 29
 30     mutex_lock(&cache_lock);
 31     /* do a quick check if we have any expired items in the tail.. */
 32     int tries = 5;
 33     /* Avoid hangs if a slab has nothing but refcounted stuff in it. */
 34     int tries_lrutail_reflocked = 1000;
 35     int tried_alloc = 0;
 36     item *search;
 37     item *next_it;
 38     void *hold_lock = NULL;
 39     rel_time_t oldest_live = settings.oldest_live;
 40
 41     //这边就可以得到slabs_class上第一个item的地址
 42     //item数据结构通过item->next和item->prev 来记录链表结构
 43     //这边是寻找LRU 链表的尾部地址
 44     search = tails[id];
 45
 46     /* We walk up *only* for locked items. Never searching for expired.
 47      * Waste of CPU for almost all deployments */
 48     //tries = 5 这边只尝试5次循环搜索
 49     //search = tails[id] 搜索从LRU链表 的尾部开始
 50     for (; tries > 0 && search != NULL; tries--, search=next_it) {
 51         /* we might relink search mid-loop, so search->prev isn't reliable */
 52         next_it = search->prev;
 53         if (search->nbytes == 0 && search->nkey == 0 && search->it_flags == 1) {
 54             /* We are a crawler, ignore it. */
 55             tries++;
 56             continue;
 57         }
 58         uint32_t hv = hash(ITEM_key(search), search->nkey);
 59         /* Attempt to hash item lock the "search" item. If locked, no
 60          * other callers can incr the refcount
 61          */
 62         /* Don't accidentally grab ourselves, or bail if we can't quicklock */
 63         if (hv == cur_hv || (hold_lock = item_trylock(hv)) == NULL)
 64             continue;
 65         /* Now see if the item is refcount locked */
 66
 67         //一般情况下search->refcount为1,如果增加了refcount之后,不等于2,说明item被其它的worker线程锁定
 68         //refcount往上加1,是锁定当前的item,如果不等于2,说明锁定失败
 69         if (refcount_incr(&search->refcount) != 2) {
 70             /* Avoid pathological case with ref'ed items in tail */
 71             do_item_update_nolock(search);
 72             tries_lrutail_reflocked--;
 73             tries++; //try的次数+1
 74             refcount_decr(&search->refcount); //减去1
 75             itemstats[id].lrutail_reflocked++;
 76             /* Old rare bug could cause a refcount leak. We haven't seen
 77              * it in years, but we leave this code in to prevent failures
 78              * just in case */
 79             if (settings.tail_repair_time &&
 80                     search->time + settings.tail_repair_time < current_time) {
 81                 itemstats[id].tailrepairs++;
 82                 search->refcount = 1;
 83                 do_item_unlink_nolock(search, hv);
 84             }
 85             if (hold_lock)
 86                 item_trylock_unlock(hold_lock);
 87
 88             if (tries_lrutail_reflocked < 1)
 89                 break;
 90
 91             continue;
 92         }
 93
 94         /* Expired or flushed */
 95         //这边判断尾部的Item是否失效,如果已经失效了的话,将当前的失效的item分配给最新的缓存
 96         if ((search->exptime != 0 && search->exptime < current_time)
 97             || (search->time <= oldest_live && oldest_live <= current_time)) {
 98             itemstats[id].reclaimed++;
 99             if ((search->it_flags & ITEM_FETCHED) == 0) {
100                 itemstats[id].expired_unfetched++;
101             }
102             it = search;
103             slabs_adjust_mem_requested(it->slabs_clsid, ITEM_ntotal(it), ntotal);
104             do_item_unlink_nolock(it, hv);
105             /* Iniialize the item block: */
106             it->slabs_clsid = 0;
107
108         //slabs_alloc方法是去分配一个新的内存块
109         } else if ((it = slabs_alloc(ntotal, id)) == NULL) {
110             tried_alloc = 1;
111             //如果设置了不允许LRU淘汰,则返回ERROR
112             if (settings.evict_to_free == 0) {
113                 itemstats[id].outofmemory++;
114             } else {
115                 //这边设置了LRU淘汰
116                 //如果分配失败,则从LRU链表尾部,淘汰一个item
117                 //如果这个item设置了有效期为0,也会被淘汰
118                 itemstats[id].evicted++;
119                 itemstats[id].evicted_time = current_time - search->time;
120                 if (search->exptime != 0)
121                     itemstats[id].evicted_nonzero++;
122                 if ((search->it_flags & ITEM_FETCHED) == 0) {
123                     itemstats[id].evicted_unfetched++;
124                 }
125                 //这边直接将LRU尾部的ITEM淘汰,并且给了最新的ITEM使用
126                 it = search;
127                 //重新计算一下这个slabclass_t分配出去的内存大小
128                 //直接霸占被淘汰的item就需要重新计算
129                 slabs_adjust_mem_requested(it->slabs_clsid, ITEM_ntotal(it), ntotal);
130                 //从哈希表和lru链表中删除
131                 //it->refcount的值为2,所以item不会被删除,只是HashTable和LRU上的链接关系
132                 do_item_unlink_nolock(it, hv);
133                 /* Initialize the item block: */
134                 it->slabs_clsid = 0;
135
136                 /* If we've just evicted an item, and the automover is set to
137                  * angry bird mode, attempt to rip memory into this slab class.
138                  * TODO: Move valid object detection into a function, and on a
139                  * "successful" memory pull, look behind and see if the next alloc
140                  * would be an eviction. Then kick off the slab mover before the
141                  * eviction happens.
142                  */
143                 if (settings.slab_automove == 2)
144                     slabs_reassign(-1, id);
145             }
146         }
147
148         //解除引用锁定
149         refcount_decr(&search->refcount);
150         /* If hash values were equal, we don't grab a second lock */
151         if (hold_lock)
152             item_trylock_unlock(hold_lock);
153         break;
154     }
155
156     /* 如果分配了5次,结果LRU链表尾部的item都是被锁定的,则重新分配一个item */
157     if (!tried_alloc && (tries == 0 || search == NULL))
158         it = slabs_alloc(ntotal, id);
159
160     if (it == NULL) {
161         itemstats[id].outofmemory++;
162         mutex_unlock(&cache_lock);
163         return NULL;
164     }
165
166     assert(it->slabs_clsid == 0);
167     assert(it != heads[id]);
168
169     /* Item initialization can happen outside of the lock; the item's already
170      * been removed from the slab LRU.
171      */
172     it->refcount = 1; //引用的次数 又设置为1   /* the caller will have a reference */
173     mutex_unlock(&cache_lock);
174     it->next = it->prev = it->h_next = 0;
175     it->slabs_clsid = id;
176
177     DEBUG_REFCNT(it, '*');
178     it->it_flags = settings.use_cas ? ITEM_CAS : 0;
179     it->nkey = nkey;
180     it->nbytes = nbytes;
181     //这边是内存拷贝,拷贝到item结构地址的内存块上
182     memcpy(ITEM_key(it), key, nkey);
183     it->exptime = exptime;
184     //这边也是内存拷贝
185     memcpy(ITEM_suffix(it), suffix, (size_t)nsuffix);
186     it->nsuffix = nsuffix;
187     return it;
188 }

策略4 - -LRU爬虫

Memcached会开一个单独的线程对失效的缓存数据进行处理。

 1 //LRU爬虫
 2 static void *item_crawler_thread(void *arg) {
 3     int i;
 4
 5     pthread_mutex_lock(&lru_crawler_lock);
 6     if (settings.verbose > 2)
 7         fprintf(stderr, "Starting LRU crawler background thread\n");
 8     while (do_run_lru_crawler_thread) {
 9     pthread_cond_wait(&lru_crawler_cond, &lru_crawler_lock);
10
11     while (crawler_count) {
12         item *search = NULL;
13         void *hold_lock = NULL;
14
15         for (i = 0; i < LARGEST_ID; i++) {
16             if (crawlers[i].it_flags != 1) {
17                 continue;
18             }
19             pthread_mutex_lock(&cache_lock);
20             search = crawler_crawl_q((item *)&crawlers[i]);
21             if (search == NULL ||
22                 (crawlers[i].remaining && --crawlers[i].remaining < 1)) {
23                 if (settings.verbose > 2)
24                     fprintf(stderr, "Nothing left to crawl for %d\n", i);
25                 crawlers[i].it_flags = 0;
26                 crawler_count--;
27                 crawler_unlink_q((item *)&crawlers[i]);
28                 pthread_mutex_unlock(&cache_lock);
29                 continue;
30             }
31             uint32_t hv = hash(ITEM_key(search), search->nkey);
32             /* Attempt to hash item lock the "search" item. If locked, no
33              * other callers can incr the refcount
34              */
35             if ((hold_lock = item_trylock(hv)) == NULL) {
36                 pthread_mutex_unlock(&cache_lock);
37                 continue;
38             }
39             /* Now see if the item is refcount locked */
40             if (refcount_incr(&search->refcount) != 2) {
41                 refcount_decr(&search->refcount);
42                 if (hold_lock)
43                     item_trylock_unlock(hold_lock);
44                 pthread_mutex_unlock(&cache_lock);
45                 continue;
46             }
47
48             /* Frees the item or decrements the refcount. */
49             /* Interface for this could improve: do the free/decr here
50              * instead? */
51             item_crawler_evaluate(search, hv, i);
52
53             if (hold_lock)
54                 item_trylock_unlock(hold_lock);
55             pthread_mutex_unlock(&cache_lock);
56
57             if (settings.lru_crawler_sleep)
58                 usleep(settings.lru_crawler_sleep);
59         }
60     }
61     if (settings.verbose > 2)
62         fprintf(stderr, "LRU crawler thread sleeping\n");
63     STATS_LOCK();
64     stats.lru_crawler_running = false;
65     STATS_UNLOCK();
66     }
67     pthread_mutex_unlock(&lru_crawler_lock);
68     if (settings.verbose > 2)
69         fprintf(stderr, "LRU crawler thread stopping\n");
70
71     return NULL;
72 }
73
74
75 int start_item_crawler_thread(void) {
76     int ret;
77
78     if (settings.lru_crawler)
79         return -1;
80     pthread_mutex_lock(&lru_crawler_lock);
81     do_run_lru_crawler_thread = 1;
82     settings.lru_crawler = true;
83     if ((ret = pthread_create(&item_crawler_tid, NULL,
84         item_crawler_thread, NULL)) != 0) {
85         fprintf(stderr, "Can't create LRU crawler thread: %s\n",
86             strerror(ret));
87         pthread_mutex_unlock(&lru_crawler_lock);
88         return -1;
89     }
90     pthread_mutex_unlock(&lru_crawler_lock);
91
92     return 0;
93 }

转载于:https://www.cnblogs.com/lizhimin123/p/10592041.html

Memcached学习(五)--LRU删除策略相关推荐

  1. Redis基础(五)——删除策略和内存淘汰机制

    文章目录 删除策略和内存淘汰机制 1 删除策略 1.1 定时删除 1.2 惰性删除 1.3 定期删除 2 内存淘汰机制 删除策略和内存淘汰机制 1 删除策略 Redis是一个内存级数据库,内存中的数据 ...

  2. Redis学习总结(数据类型、持久化、事务、数据删除策略、主从复制、哨兵、缓存雪崩等)

    Redis学习总结 1.Redis是什么 1.概念 2.特点 3.应用场景 2.Linux环境安装redis 3.Redis的数据存储格式 1.String类型 1.String类型的常用操作 2.S ...

  3. Redis系列(五):Redis的过期键删除策略

    Redis系列(五):Redis的过期键删除策略 - 申城异乡人 - 博客园 本篇博客是Redis系列的第5篇,主要讲解下Redis的过期键删除策略. 本系列的前4篇可以点击以下链接查看: Redis ...

  4. Redis 学习 - 2.Redis高级:RDB AOF 事务 锁 删除策略 Bitmaps HyperLogLog GEO

    目录 2. Redis高级 2.1 Redis Linux安装 047-Linux安装redis 048-指定端口启动服务 049-指定配置文件启动服务 050-配置文件启动目录管理 2.2 持久化 ...

  5. Redis[5] key的过期时间删除策略、实现lru算法、持久化配置

    文章目录 Redis[5] key的过期时间删除策略.持久化配置 **Redis6的key过期时间删除策略** Redis服务器实际使用的是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,服务 ...

  6. 【2020尚硅谷Java大厂面试题第三季 04】Redis 9种数据类型使用场景,分布式锁演变步骤,lua脚本,redis事务,Redisson,Redis内存占用,删除策略,内存淘汰策略,手写LRU

    1.安装redis6.0.8 2023 02 02 为:redis-7.0.8.tar.gz 2.redis传统五大数据类型的落地应用 3.知道分布式锁吗?有哪些实现方案?你谈谈对redis分布式锁的 ...

  7. memcached java 客户端优化,分布式缓存技术memcached学习系列(五)—— memcached java客户端的使用...

    Memcached的客户端简介 我们已经知道,memcached是一套分布式的缓存系统,memcached的服务端只是缓存数据的地方,并不能实现分布式,而memcached的客户端才是实现分布式的地方 ...

  8. Python之使用LRU缓存策略进行缓存

    一.Python 缓存 ① 缓存作用 缓存是一种优化技术,可以在应用程序中使用它来将最近或经常使用的数据保存在内存中,通过这种方式来访问数据的速度比直接读取磁盘文件的高很多. 假设我们搭建了一个新闻聚 ...

  9. Redis 缓存删除策略

    Redis(Remote Dictionary Server ),即远程字典服务,是一个开源的使用ANSI C语言编写.支持网络.可基于内存亦可持久化的日志型.Key-Value数据库,并提供多种语言 ...

最新文章

  1. php ip地址地区,PHP查询ip所在地(省份,市)
  2. Docker for windows挂载文件到Nginx目录踩坑小记
  3. windows聚焦壁纸不更新_技术编辑示范win10系统锁屏壁纸聚焦不更新的处理教程
  4. java学习笔记14--多线程编程基础1
  5. 【VS开发】C++线程安全
  6. git使用的基本流程_git命令的基本使用
  7. 我要学python之装饰器
  8. 第 39 级台阶 蓝桥杯
  9. 系统学习NLP(二十四)--详解Transformer (Attention Is All You Need)
  10. 【Python3爬虫】网易云音乐歌单下载
  11. android抓包工具_无需额外工具,简单抓包教程
  12. 抢红包插件实现原理浅析
  13. SMT32F767通过STM32CUBE HAL库配置QSPI和W25Q256驱动
  14. TM4C123G学习记录(4)--关于ROM前缀函数和HWREG函数
  15. 目标检测 (Detection) 算法综述
  16. es routing 简介
  17. Ubuntu16.04 NVIDIA显卡驱动卸载与安装
  18. 内存读数据和磁盘读数据的区别
  19. 地图定位之3D 地图
  20. 文件传输软件 WinSCP及xftp的使用

热门文章

  1. 平板电脑支持html吗,HTML5视频无法在平板电脑上播放(HTML5 video not playing on tablets)...
  2. HDU 1348(Wall)
  3. TLS1.3抓包分析(3)——EncryptedExtentions等
  4. 是什么在影响研究生的痛苦指数?
  5. 数字逻辑复习(Wust)
  6. python-实现多元回归及预测
  7. 2016年头条校招笔试题
  8. 【AI竞赛】GAN实战——TinyMind书法字体生成练习赛开始报名拉!
  9. 基于知识图谱的推荐系统(KGRS)综述
  10. 友善之臂(FriendlyArm)NanoPi无线网络设置