zoukankan      html  css  js  c++  java
  • Redis系列--内存淘汰机制(含单机版内存优化建议)

    https://blog.csdn.net/Jack__Frost/article/details/72478400?locationNum=13&fps=1

    每台redis的服务器的内存都是有限的,而且也不是所有的内存都用来存储信息。而且redis的实现并没有在内存这块做太多的优化,所以实现者为了防止内存过于饱和,采取了一些措施来管控内存。

    文章结构:(1)内存策略;(2)内存释放机制原理;(3)项目中如何合理应用淘汰策略;(4)单机版Redis内存优化注意点。

    一、内存策略:先来吃份官方文档

    最大内存的设置是通过设置maxmemory来完成的,格式为maxmemory bytes ,当目前使用的内存超过了设置的最大内存,就要进行内存释放了, 当需要进行内存释放的时候,需要用某种策略对保存的的对象进行删除。Redis有六种策略(默认的策略是volatile-lru。)

     redis中当内存超过限制时,按照配置的策略,淘汰掉相应的key-value,使得内存可以继续留有足够的空间保存新的数据。redis 确定驱逐某个键值对后,会删除这个数据并,并将这个数据变更消息发布到本地(AOF 持久化)和从机(主从连接)。

    (1)volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰。

    (2)volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰

    (3)volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰

    (4)allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰

    (5)allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰

    (6)no-enviction:禁止淘汰数据

    除此之外还有一个配置项,就是maxmemory-samples,默认值是3,因为上面的策略代码实现的都是近似算法,所以不管是lru算法,还是ttl,都并不是在数据库中所有的数据为基础的算法,因为当数据库的数据很多的时候,这样效率太低,所以代码中都是基于maxmemory-samples个数据的近似算法。详情请读下文。

    置换策略是如何工作的:

    1)客户端执行一条新命令,导致数据库需要增加数据(比如set key value)

    2)Redis会检查内存使用,如果内存使用超过maxmemory,就会按照置换策略删除一些key

    3)新的命令执行成功

    注意:

    如果我们持续的写数据会导致内存达到或超出上限maxmemory,但是置换策略会将内存使用降低到上限以下。

    如果一次需要使用很多的内存(比如一次写入一个很大的set),那么,Redis的内存使用可能超出最大内存限制一段时间。

    二、内存释放机制原理:

    (1)概述:

    当mem_used内存已经超过maxmemory的设定,对于所有的读写请求,都会触发redis.c/freeMemoryIfNeeded(void)函数以清理超出的内存。注意这个清理过程是阻塞的,直到清理出足够的内存空间。所以如果在达到maxmemory并且调用方还在不断写入的情况下,可能会反复触发主动清理策略,导致请求会有一定的延迟。

    清理时会根据用户配置的maxmemory-policy来做适当的清理(一般是LRU或TTL),这里的LRU或TTL策略并不是针对redis的所有key,而是以配置文件中的maxmemory-samples个key作为样本池进行抽样清理。

    maxmemory-samples在redis-3.0.0中的默认配置为5,如果增加,会提高LRU或TTL的精准度,redis作者测试的结果是当这个配置为10时已经非常接近全量LRU的精准度了,并且增加maxmemory-samples会导致在主动清理时消耗更多的CPU时间,有如下建议:

    1)尽量不要触发maxmemory,最好在mem_used内存占用达到maxmemory的一定比例后,需要考虑调大hz以加快淘汰,或者进行集群扩容。

    2)如果能够控制住内存,则可以不用修改maxmemory-samples配置;如果Redis本身就作为LRU cache服务(这种服务一般长时间处于maxmemory状态,由Redis自动做LRU淘汰),可以适当调大maxmemory-samples。

    (2)内存管理源码解析:参考博文

    Redis释放内存是由函数freeMemoryIfNeeded完成的,redis用processCommand函数处理每条命令,函数中在真正处理命令之前都会调用freeMemoryIfNeeded函数,这个函数会判断当前使用的内存是否超过了最大使用内存,如果超过,就会根据内存释放策略释放内存。

    freeMemoryIfNeeded函数首先会计算出当前使用了多少内存,注意,这里并不会包括slaves 输出缓存以及AOF缓存,源码如下:

    int freeMemoryIfNeeded(void) {
        size_t mem_used, mem_tofree, mem_freed;
        int slaves = listLength(server.slaves);
    
        /* Remove the size of slaves output buffers and AOF buffer from the
         * count of used memory. 
         */ 
         //计算占用内存大小时,并不计算slave output buffer和aof buffer,因此maxmemory应该比实际内存小,为这两个buffer留足空间。
        mem_used = zmalloc_used_memory();
        if (slaves) {
            listIter li;
            listNode *ln;
    
            listRewind(server.slaves,&li);
            while((ln = listNext(&li))) {
                redisClient *slave = listNodeValue(ln);
                unsigned long obuf_bytes = getClientOutputBufferMemoryUsage(slave);
                if (obuf_bytes > mem_used)
                    mem_used = 0;
                else
                    mem_used -= obuf_bytes;
            }
        }
        if (server.appendonly) {
            mem_used -= sdslen(server.aofbuf);
            mem_used -= sdslen(server.bgrewritebuf);
        }
    //判断已经使用内存是否超过最大使用内存,如果没有超过就返回REDIS_OK,
        /* Check if we are over the memory limit. */
        if (mem_used <= server.maxmemory) return REDIS_OK;
    //当超过了最大使用内存时,就要判断此时redis到底采用的是那种内存释放策略,根据不同的策略,采取不同的手段。
    //(1)首先判断是否是为no-enviction策略,如果是,则返回REDIS_ERR,然后redis就不再接受任何写命令了。
        if (server.maxmemory_policy == REDIS_MAXMEMORY_NO_EVICTION)
            return REDIS_ERR; /* We need to free memory, but policy forbids. */
    
        /* Compute how much memory we need to free. */
        mem_tofree = mem_used - server.maxmemory;
        mem_freed = 0;
        //(2)接下来就判断淘汰策略是基于所有的键还是只是基于设置了过期时间的键,如果是针对所有的键,就从server.db[j].dict中取数据,如果是针对设置了过期时间的键,就从server.db[j].expires中取数据。
        while (mem_freed < mem_tofree) {
            int j, k, keys_freed = 0;
    
            for (j = 0; j < server.dbnum; j++) {
                long bestval = 0; /* just to prevent warning */
                sds bestkey = NULL;
                struct dictEntry *de;
                redisDb *db = server.db+j;
                dict *dict;
        //(3)然后判断是不是random策略,包括volatile-random 和allkeys-random,这两种策略是最简单的,就是在上面的数据集中随便去一个键,然后删掉。
                if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||
                    server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_RANDOM)
                {
                    dict = server.db[j].dict;
                } else {
                    dict = server.db[j].expires;
                }
                if (dictSize(dict) == 0) continue;
    //接着又判断allkeys-random还是volatile-ttl策略
                /* volatile-random and allkeys-random policy */
                if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_RANDOM ||
                    server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_RANDOM)
                {
                    de = dictGetRandomKey(dict);
                    bestkey = dictGetEntryKey(de);
                }//如果是random delete,则从dict中随机选一个key
    //然后就是判断是lru策略还是ttl策略,如果是lru策略就采用lru近似算法
                /* volatile-lru and allkeys-lru policy */
                else if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||
                    server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU)
                {
                    for (k = 0; k < server.maxmemory_samples; k++) {
                        sds thiskey;
                        long thisval;
                        robj *o;
    
                        de = dictGetRandomKey(dict);
                        thiskey = dictGetEntryKey(de);
                        /* When policy is volatile-lru we need an additonal lookup
                         * to locate the real key, as dict is set to db->expires. */
                        if (server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU)
                            de = dictFind(db->dict, thiskey); //因为dict->expires维护的数据结构里并没有记录该key的最后访问时间
                        o = dictGetEntryVal(de);
                        thisval = estimateObjectIdleTime(o);
    
                        /* Higher idle time is better candidate for deletion */
                        if (bestkey == NULL || thisval > bestval) {
                            bestkey = thiskey;
                            bestval = thisval;
                        }
                    }//为了减少运算量,redis的lru算法和expire淘汰算法一样,都是非最优解,lru算法是在相应的dict中,选择maxmemory_samples(默认设置是3)份key,挑选其中lru的,进行淘汰
                }
    //如果是ttl策略。ttl策略很简单,就是取maxmemory_samples个键,然后比较他们的过期时间,然后从这些键中找到最快过期的那个键,就是我们将要删除的键。
                /* volatile-ttl */
                else if (server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_TTL) {
                    for (k = 0; k < server.maxmemory_samples; k++) {
                        sds thiskey;
                        long thisval;
    
                        de = dictGetRandomKey(dict);
                        thiskey = dictGetEntryKey(de);
                        thisval = (long) dictGetEntryVal(de);
    
                        /* Expire sooner (minor expire unix timestamp) is better
                         * candidate for deletion */
                        if (bestkey == NULL || thisval < bestval) {
                            bestkey = thiskey;
                            bestval = thisval;
                        }
                    }//注意ttl实现和上边一样,都是挑选出maxmemory_samples份进行挑选
                }
    //根据不同的策略,我们找到了将要删除的键,下面就是将他们删除的时候了,删除选定的键值对
                /* Finally remove the selected key. */
                if (bestkey) {
                    long long delta;
    
                    robj *keyobj = createStringObject(bestkey,sdslen(bestkey));
                    // 发布数据更新消息,主要是AOF 持久化和从机
                    propagateExpire(db,keyobj); //将del命令扩散给slaves
    
        // 注意, propagateExpire() 可能会导致内存的分配,
        // propagateExpire() 提前执行就是因为redis 只计算
        // dbDelete() 释放的内存大小。倘若同时计算dbDelete()
        // 释放的内存和propagateExpire() 分配空间的大小,与此
        // 同时假设分配空间大于释放空间,就有可能永远退不出这个循环。
        // 下面的代码会同时计算dbDelete() 释放的内存和propagateExpire() 分配空间的大小
                    /* We compute the amount of memory freed by dbDelete() alone.
                     * It is possible that actually the memory needed to propagate
                     * the DEL in AOF and replication link is greater than the one
                     * we are freeing removing the key, but we can't account for
                     * that otherwise we would never exit the loop.
                     *
                     * AOF and Output buffer memory will be freed eventually so
                     * we only care about memory used by the key space. */
                  // 只计算dbDelete() 释放内存的大小
                    delta = (long long) zmalloc_used_memory();
                    dbDelete(db,keyobj);
                    delta -= (long long) zmalloc_used_memory();
                    mem_freed += delta;
                    server.stat_evictedkeys++;
                    decrRefCount(keyobj);
                    keys_freed++;
    
                    /* When the memory to free starts to be big enough, we may
                     * start spending so much time here that is impossible to
                     * deliver data to the slaves fast enough, so we force the
                     * transmission here inside the loop. */
                     // 将从机回复空间中的数据及时发送给从机
                    if (slaves) flushSlavesOutputBuffers();
                }
            }//在所有的db中遍历一遍,然后判断删除的key释放的空间是否足够,未能释放空间,且此时redis 使用的内存大小依旧超额,失败返回
            if (!keys_freed) return REDIS_ERR; /* nothing to free... */
        }
        return REDIS_OK;
    }

    此函数是在执行特定命令之前进行调用的,并且在当前占用内存低于限制后即返回OK。因此可能在后续执行命令后,redis占用的内存就超过了maxmemory的限制。因此,maxmemory是redis执行命令所需保证的最大内存占用,而非redis实际的最大内存占用。(在不考虑slave buffer和aof buffer的前提下)。

    TTL 数据淘汰机制 :

    redis 数据集数据结构中保存了键值对过期时间的表,即 redisDb.expires。

    定义:

    从过期时间的表中随机挑选几个键值对,取出其中 ttl 最大的键值对淘汰。同样你会发现,
    redis 并不是保证取得所有过期时间的表中最快过期的键值对,而只是随机挑选的几个键值对中的。

    freeMemoryIfNeeded函数关于TTL的源码:

    //挑选将要过期的数据
    else if (server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_TTL) {
     // server.maxmemory_samples 为随机挑选键值对次数
        // 随机挑选server.maxmemory_samples 个键值对,驱逐最快要过期的数据
        for (k = 0; k < server.maxmemory_samples; k++) {
            sds thiskey;
            long thisval;
    
            de = dictGetRandomKey(dict);
            thiskey = dictGetKey(de);
            thisval = (long) dictGetVal(de);
    
            /* Expire sooner (minor expire unix timestamp) is better
             * candidate for deletion */
            if (bestkey == NULL || thisval < bestval) {
                bestkey = thiskey;
                bestval = thisval;
            }
        }
    }

    LRU 数据淘汰机制 :

    在服务器配置中保存了 lru 计数器 server.lrulock,会定时(redis 定时程序 serverCorn())更新,server.lrulock 的值是根据 server.unixtime 计算出来的。另外,从 struct redisObject 中可以发现,每一个 redis 对象都会设置相应的 lru。可以想象的是,每一次访问数据的时候,会更新 redisObject.lru。

    LRU 数据淘汰机制定义: 

    在数据集中随机挑选几个键值对,取出其中 lru 最小的键值对淘汰。所以,你会发现,redis 
    并不是保证取得所有数据集中最近最少使用(LRU)的键值对,而只是随机挑选的几个键值对中的键值对。

    freeMemoryIfNeeded函数关于LRU的源码:

    //不同的策略,操作的数据集不同
    if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||
        server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_RANDOM)
    {
        dict = server.db[j].dict;
    } else {//操作的是设置了过期时间的key集
        dict = server.db[j].expires;
    }
    if (dictSize(dict) == 0) continue;
    
    /* volatile-random and allkeys-random policy */
    //随机选择进行淘汰
    if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_RANDOM ||
        server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_RANDOM)
    {
        de = dictGetRandomKey(dict);
        bestkey = dictGetKey(de);
    }
    
    /* volatile-lru and allkeys-lru policy */
    //具体的LRU算法
    else if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||
        server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU)
    {
        struct evictionPoolEntry *pool = db->eviction_pool;
    
        while(bestkey == NULL) {
            //选择随机样式,并从样本中作用LRU算法选择需要淘汰的数据
            evictionPoolPopulate(dict, db->dict, db->eviction_pool);
            /* Go backward from best to worst element to evict. */
            for (k = REDIS_EVICTION_POOL_SIZE-1; k >= 0; k--) {
                if (pool[k].key == NULL) continue;
                de = dictFind(dict,pool[k].key);
                sdsfree(pool[k].key);
                //将pool+k+1之后的元素向前平移一个单位
                memmove(pool+k,pool+k+1,
                    sizeof(pool[0])*(REDIS_EVICTION_POOL_SIZE-k-1));
                /* Clear the element on the right which is empty
                 * since we shifted one position to the left.  */
                pool[REDIS_EVICTION_POOL_SIZE-1].key = NULL;
                pool[REDIS_EVICTION_POOL_SIZE-1].idle = 0;
                //选择了需要淘汰的数据
                if (de) {
                    bestkey = dictGetKey(de);
                    break;
                } else {
                    /* Ghost... */
                    continue;
                }
            }
        }
    }

    LRU算法实现在evictionPoolPopulate方法内.(有兴趣的朋友再进行相关查阅了解吧)


    三、项目中如何合理应用淘汰策略:

    此部分部分转载于此博主此博客

    (1)合理设置maxmemory:

    修改方式有两种:

    1. 通过CONFIG SET 设定:

    127.0.0.1:6379> CONFIG GET maxmemory
    1) "maxmemory"
    2) "0"
    127.0.0.1:6379> CONFIG SET maxmemory 80MB
    OK
    127.0.0.1:6379> CONFIG GET maxmemory
    1) "maxmemory"
    2) "83886080"

    2.修改配置文件redis.conf:配置文件讲解

    maxmemory 80mb

    注意:在64bit系统下,maxmemory设置为0表示不限制Redis内存使用,在32bit系统下,maxmemory隐式不能超过3GB。

    当Redis内存使用达到指定的限制时,就需要选择一个置换的策略。

    (2)置换策略的选择:

    当Redis内存使用达到maxmemory时,就会使用设置好的maxmemory-policy进行对老数据的置换。

    设置maxmemory-policy的方法和设置maxmemory方法类似,通过redis.conf或是通过CONFIG SET动态修改。

    如果没有匹配到可以删除的key,那么volatile-lru、volatile-random和volatile-ttl策略和noeviction替换策略一样——不对任何key进行置换。

    选择合适的置换策略是很重要的,这主要取决于你的应用的访问模式,当然你也可以动态的修改置换策略,并通过用Redis命令——INFO去输出cache的命中率情况,进而可以对置换策略进行调优。

    针对一些策略所使用的场景:

    1)allkeys-lru:如果我们的应用对缓存的访问符合幂律分布(也就是存在相对热点数据),或者我们不太清楚我们应用的缓存访问分布状况,我们可以选择allkeys-lru策略。

    在所有的key都是最近最经常使用,那么就需要选择allkeys-lru进行置换最近最不经常使用的key,如果你不确定使用哪种策略。

    设置是失效时间expire会占用一些内存,而采用allkeys-lru就没有必要设置失效时间,进而更有效的利用内存

    2)allkeys-random:如果我们的应用对于缓存key的访问概率相等,则可以使用这个策略。

    如果所有的key的访问概率都是差不多的,那么可以选用allkeys-random策略去置换数据。

    3)volatile-ttl:这种策略使得我们可以向Redis提示哪些key更适合被eviction。

    如果对数据有足够的了解,能够为key指定hint(通过expire/ttl指定),那么可以选择volatile-ttl进行置换

    4)volatile-lru策略和volatile-random策略适合我们将一个Redis实例既应用于缓存和又应用于持久化存储的时候,然而我们也可以通过使用两个Redis实例来达到相同的效果,值得一提的是将key设置过期时间实际上会消耗更多的内存,因此我们建议使用allkeys-lru策略从而更有效率的使用内存。


    四、Redis内存优化注意点:(先针对单机版)

    (1)Redis的编码:此部分参考此博主此博客

    概述:

    很多数据类型都可以通过特殊编码的方式来进行存储空间的优化。其中,Hash、List和由Integer组成的Sets都可以通过该方式来优化存储结构,以便占用更少的空间,在有些情况下,可以省去9/10的空间。

    这些特殊编码对于Redis的使用而言是完全透明的,事实上,它只是CPU和内存之间的一个交易而言。如果内存使用率方面高一些,那么在操作数据时消耗的CPU自然要多一些,反之亦然。在Redis中提供了一组配置参数用于设置与特殊编码相关的各种阈值。

    阈值详细设置:

        #如果Hash中字段的数量小于该参数值,Redis将对该Key的Hash Value采用特殊编码。
        hash-max-zipmap-entries 64
        #如果Hash中各个字段的最大长度不超过512字节,Redis也将对该Key的Hash Value采用特殊编码方式。
        hash-max-zipmap-value 512
        #下面两个参数的含义基本等同于上面两个和Hash相关的参数,只是作用的对象类型为List。
        list-max-ziplist-entries 512
        list-max-ziplist-value 64
        #如果set中整型元素的数量不超过512时,Redis将会采用该特殊编码。
        set-max-intset-entries 512
        #如果zset中的元素数量小于128或者各字段长度不超过64,redis会对zset采用特殊编码 
        zset-max-ziplist-entries 128
        zset-max-ziplist-value 64

    倘若某个已经被编码的值再经过修改之后超过了配置信息中的最大限制,那么Redis会自动将其转换为正常编码格式,这一操作是非常快速的,但是如果反过来操作,将一个正常编码的较大值转换为特殊编码,Redis的建议是,在正式做之前最好先简单测试一下转换效率,因为这样的转换往往是非常低效的。

    (2)使用bit位级别操作和byte字节级别操作来减少不必要的内存使用:

    bit位级别操作:GETRANGE, SETRANGE, GETBIT and SETBIT

    byte字节级别操作:GETRANGE and SETRANGE

    Redis提供了GETRANGE/SETRANGE/GETBIT/SETBIT四个用于字符串类型Key/Value的命令。通过这些命令,我们便可以像操作数组那样来访问String类型的值数据了。比如唯一标识用户身份的ID,可能仅仅是String值的其中一段子字符串。这样就可以通过GETRANGE/SETRANGE命令来方便的提取。再有就是可以使用BITMAP来表示用户的性别信息,如1表示male,0表示female。用这种方式来表示100,000,000个用户的性别信息时,也仅仅占用12MB的存储空间,与此同时,在通过SETBIT/GETBIT命令进行数据遍历也是非常高效的。

    (3)尽可能地使用hashes哈希,因为小Hashes会被编码成一个非常小的空间。

    由于小的Hash类型数据占用的空间相对较少,因此我们在实际应用时应该尽可能的考虑使用Hash类型,比如用户的注册信息,这其中包括姓名、性别、email、年龄和口令等字段。我们当然可以将这些信息以Key的形式进行存储,而用户填写的信息则以String Value的形式存储。然而Redis则更为推荐以Hash的形式存储,以上信息则以Field/Value的形式表示。

    现在我们就通过学习Redis的存储机制来进一步证明这一说法。在该篇博客的开始处已经提到了特殊编码机制,其中有两个和Hash类型相关的配置参数:hash-max-zipmap-entries和hash-max-zipmap-value。至于它们的作用范围前面已经给出,这里就不再过多的赘述了。现在我们先假设存储在Hash Value中的字段数量小于hash-max-zipmap-entries,而每个元素的长度又同时小于hash-max-zipmap-value。这样每当有新的Hash类型的Key/Value存储时,Redis都会为Hash Value创建定长的空间,最大可预分配的字节数为:

     total_bytes = hash-max-zipmap-entries * hash-max-zipmap-value
    
    • 1
    • 2

    这样一来,Hash中所有字段的位置已经预留,并且可以像访问数组那样随机的访问Field/Value,他们之间的步长间隔为hash-max-zipmap-value。只有当Hash Value中的字段数量或某一新元素的长度分别超过以上两个参数值时,Redis才会考虑将他们以Hash Table的方式进行重新存储,否则将始终保持这种高效的存储和访问方式。不仅如此,由于每个Key都要存储一些关联的系统信息,如过期时间、LRU等,因此和String类型的Key/Value相比,Hash类型极大的减少了Key的数量(大部分的Key都以Hash字段的形式表示并存储了),从而进一步优化了存储空间的使用效率。

    (4)过期策略的合理设计:

    请参考此博客点此处

    (5)合理的内存分配:

    如果maxmemory没有设置的Redis会继续分配内存,因为它认为合适的,因此它可以(逐渐)吃了你的全部可用内存。因此,通常建议配置一些限制。您可能还需要设置maxmemory策略,默认的是:noeviction(这不是在一些旧版本的Redis的默认值)。

    这使得Redis的返回内存不足的错误写命令,如果当它到达了极限 - 这反过来可能会导致应用程序错误,但不会导致因为内存饥饿而整机死亡。

    (6)在存到Redis之前先把你的数据压缩下。

    (7)一些真正的实际Redis内存设计方案会在本系列的后面写出。比如:共享对象,【string】–>【hash】–>【segment-hash】的优化


    好了,Redis系列(四)–内存淘汰机制(含单机版内存优化建议)讲完了,这是项目过程中Redis内存优化的笔记,现在罗列给大家,这是积累的必经一步,我会继续出这个系列文章,分享经验给大家。欢迎在下面指出错误,共同学习!!你的点赞是对我最好的支持!!

  • 相关阅读:
    数据库设计 一对多 多对多 无限级菜单 设计方法
    线程创建的四种方式
    Linux常用指令---grep(搜索过滤)
    Netty4实战
    linux查找日志技巧
    javascript---关于字符串和数组的方法
    angularJs---route
    JavaScript---详解scroll
    JavaScript--location
    socket中 emit和on的写法
  • 原文地址:https://www.cnblogs.com/alsf/p/9399009.html
Copyright © 2011-2022 走看看