Tallate

该吃吃该喝喝 啥事别往心里搁

缓存系统如何工作

缓存的两个特征

  1. 在一个层次化的系统中,缓存一定是一个快速子系统,数据存在缓存中时,能避免每次从慢速子系统中存取数据。对应到互联网应用来说,Redis 就是快速子系统,而数据库就是慢速子系统了。
  2. 缓存系统的容量大小总是小于后端慢速系统的,我们不可能把所有数据都放在缓存系统中。

Redis作为旁路缓存

作为缓存,我们在访问数据时可能会:

  • 命中:直接将缓存中的数据返回;
  • miss:缓存缺失,回源到MySQL读取数据,加载到缓存中;

这种情况下,Redis就作为旁路缓存使用,读取缓存、读取数据库和更新缓存的操作都需要在应用程序中来完成。

  • 如果是只读缓存,那么上述的旁路缓存设计就足够了;
  • 如果是读写缓存,还有同步直写(写缓存的同时写DB)和异步写回(写缓存后异步写DB)这两种策略,各有优缺点。
    同步直写效率低,但是一致性高;
    异步写回效率高,但是一致性低。

Redis 加入秒杀系统

秒杀系统特点

  1. 瞬时并发量非常高
    一般数据库并发能力是千级别,而Redis的并发能力是万级别甚至更高(加入Cluster以后会更高)。
    所以Redis在秒杀系统中的主要作用就是拦截大部分的流量。
  2. 读多写少
    用户秒杀下单前需要先检查商品是否还有库存,查询库存其实就是简单的键值对查询,而Redis最擅长的就是键值对的存储。

秒杀流程梳理

上面提到Redis的高并发、数据结构特性使得它很适合秒杀场景,但是具体是哪些功能会用到Redis呢?

  1. 秒杀活动前
    秒杀活动前的一段时间,用户会不断地刷新商品详情页,这个阶段一般会尽量把商品详情页的页面元素静态化,然后使用CDN或是浏览器把这些静态化元素缓存起来。
    这个阶段已经有CDN和浏览器,还不需要Redis。
  2. 秒杀活动开始阶段
    这时大量用户会不断刷新商品详情页并点击秒杀按钮,会产生大量并发请求查询库存,如果通过则触发库存扣减和订单处理。
    这个阶段的主要压力在于库存的查验,可以使用Redis保存库存并用于查验。
    这里库存的扣减也是放到Redis中执行的,如果放到数据库中,一方面同步数据库和缓存会带来额外的开销,另一方面如果下单量超过了实际库存可能出现超售。
    库存的查验和扣减需要保证原子性,可以通过Redis的事务或Lua脚本来实现,或者使用分布式锁来同步。
  3. 秒杀活动结束后
    这个阶段虽然还会有用户刷新(等待其他用户退单),但是请求已经变得很少了,服务端一般能应付。

缓存满了怎么办?

缓存淘汰(缓存失效策略和主键失效机制)

作为缓存系统都要定期清理无效数据,就需要一个主键失效和淘汰策略,比如 Redis 只能存 5G 数据,可是你写了 10G,那多出来的 5G 数据怎么删?你的数据已经设置了过期时间,但是时间到了,内存占用率还是比较高?这就需要深入到 Redis 的主键失效和淘汰策略中去了。

key 的过期时间控制

在 Redis 当中,有生存期的 key 被称为 volatile。在创建缓存时,要为给定的 key 设置生存期,当 key 过期的时候(生存期为 0),它可能会被删除。

  1. 影响生存时间的一些操作
    生存时间可以通过使用 DEL 命令来删除整个 key 来移除,或者被 SET 和 GETSET 命令覆盖原来的数据,也就是说,修改 key 对应的 value 和使用另外相同的 key 和 value 来覆盖以后,当前数据的生存时间不同。
    比如说,对一个 key 执行 INCR 命令,对一个列表进行 LPUSH 命令,或者对一个哈希表执行 HSET 命令,这类操作都不会修改 key 本身的生存时间。另一方面,如果使用 RENAME 对一个 key 进行改名,那么改名后的 key 的生存时间和改名前一样。
    RENAME 命令的另一种可能是,尝试将一个带生存时间的 key 改名成另一个带生存时间的 another_key ,这时旧的 another_key (以及它的生存时间)会被删除,然后旧的 key 会改名为 another_key ,因此,新的 another_key 的生存时间也和原本的 key 一样。使用 PERSIST 命令可以在不删除 key 的情况下,移除 key 的生存时间,让 key 重新成为一个 persistent key 。
  2. 如何更新生存时间
    可以对一个已经带有生存时间的 key 执行 EXPIRE 命令,新指定的生存时间会取代旧的生存时间。过期时间的精度已经被控制在 1ms 之内,主键失效的时间复杂度是 O(1),
    EXPIRE 和 TTL 命令搭配使用,TTL 可以查看 key 的当前生存时间。设置成功返回 1;当 key 不存在或者不能为 key 设置生存时间时,返回 0 。
  3. 最大缓存配置
    在 redis 中,允许用户设置最大使用内存大小 server.maxmemory
    默认为 0,没有指定最大缓存,如果有新的数据添加,超过最大内存,则会使 redis 崩溃,所以一定要设置。redis 内存数据集大小上升到一定大小的时候,就会实行数据淘汰策略。

key 的删除策略

redis 采用的是定期删除+惰性删除策略。

  1. 为什么不用定时删除策略?
    定时删除,用一个定时器来负责监视 key,过期则自动删除。虽然内存及时释放,但是十分消耗 CPU 资源。在大并发请求下,CPU 要将时间应用在处理请求,而不是删除 key,因此没有采用这一策略.
  2. 定期删除+惰性删除是如何工作的呢?
    定期删除,redis 默认每个 100ms 检查,是否有过期的 key,有过期 key 则删除。需要说明的是,redis 不是每个 100ms 将所有的 key 检查一次,而是随机抽取进行检查(如果每隔 100ms,全部 key 进行检查,redis 岂不是卡死)。因此,如果只采用定期删除策略,会导致很多 key 到时间没有删除。
    于是,惰性删除派上用场。也就是说在你获取某个 key 的时候,redis 会检查一下,这个 key 如果设置了过期时间那么是否过期了?如果过期了此时就会删除。
  3. 采用定期删除+惰性删除就没其他问题了么?
    不是的,如果定期删除没删除 key。然后你也没即时去请求 key,也就是说惰性删除也没生效。这样,redis 的内存会越来越高。那么就应该采用内存淘汰机制。

Redis 的数据淘汰策略

在 redis.conf 中有一行配置

1
# maxmemory-policy volatile-lru

redis 提供 6种数据淘汰策略:

  1. no-enviction(驱逐):禁止驱逐数据,当内存不足以容纳新写入数据时,新写入操作会报错(应该没人用)
  2. volatile-lru:当内存不足以容纳新写入数据时,从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰(这种情况一般是把 redis 既当缓存,又做持久化存储的时候才用。不推荐)
  3. volatile-ttl:当内存不足以容纳新写入数据时,从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰(不推荐)
    可以在一些需要“置顶”的业务场景里采用,比如一些新闻、视频需要置顶,这些数据不需要设置过期时间,volatile-ttl就不会删除它们。
  4. volatile-random:当内存不足以容纳新写入数据时,从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰(不推荐)
  5. allkeys-lru:当内存不足以容纳新写入数据时,从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰(推荐)
  6. allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰

注意这里的 6 种机制:

  • volatile 和 allkeys 规定了是对已设置过期时间的数据集淘汰数据还是从全部数据集淘汰数据;
  • lru、ttl 以及 random 是三种不同的淘汰策略,ttl 和 random 比较容易理解、实现也会比较简单,lru 会对 key 按失效时间排序,然后取最先失效的 key 进行淘汰。
  • 如果没有设置 expire 的 key, 不满足先决条件(prerequisites); 那么 volatile-lru, volatile-random 和 volatile-ttl 策略的行为, 和 noeviction(不删除) 基本上一致。

使用策略规则

  1. 如果数据呈现幂律分布,也就是一部分数据访问频率高,一部分数据访问频率低,则使用 allkeys-lru
  2. 如果数据呈现平等分布,也就是所有的数据访问频率都相同,则使用 allkeys-random

自定义缓存淘汰策略

除了缓存服务器自带的缓存失效策略之外(Redis 默认有 6 种策略可选),我们还可以根据具体的业务需求自定义缓存淘汰策略,常见的策略有两种:

  1. 定时去清除过期的缓存;
  2. 当有用户请求过来时,再判断这个请求所用到的缓存是否过期,过期的话就去底层系统得到新数据并更新缓存;

两种策略各有优劣,第一种的缺点是维护大量缓存的 key 是比较麻烦的,第二种的缺点是每次用户请求过来都要判断缓存失效,逻辑相对比较复杂。需要根据应用场景的特点来权衡选择。

缓存淘汰的实现

  1. 设置过期时间
    过期时间到了后,Redis 会在读的时候判断是否过期并清除,或者由一个定时任务执行清除操作。
  2. 超过 maxmemory 回收
    可以设置淘汰机制,比如 LRU、LFU。

LRU 算法的一种简单实现
简单版本的 LRU 算法分两个部分:

  1. 一个链表记录 key 的最终访问次序,比如最新访问的在链表头部,最久没访问的在链表末尾,LRU 淘汰机制就是删除链表末尾的节点;
  2. 一个散列表记录某个 key 是否存在,并可以找到其在链表中的位置;

Redis 中的 LRU 实现

Redis-LRU算法
代码位置:evict.c/freeMemoryIfNeeded
Redis 中并没有直接使用上述的 LRU 算法,主要是因为维护LRU链表开销较大,而是退一步使用了抽样淘汰的机制:

  1. 每次从缓存对象集合中随机取出一部分样本(20个key),进行下面的过期检测;
  2. 按 LRU 算法排序;
  3. 取 idle 值(评分)最小的淘汰;
  4. 如果有多于25%的key是过期了的,则重复步骤1。

为了支持LRU,Redis中使用了一个全局的LRU时钟server.lruclock:

1
2
3
4
#define REDIS_LRU_BITS 24

// 最近一次使用时钟
unsigned lruclock:REDIS_LRU_BITS; /* Clock for LRU eviction */

Redis会在serverCron()中调用updateLRUClock来定期更新LRU时钟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define REDIS_LRU_CLOCK_MAX ((1<<REDIS_LRU_BITS)-1) /* Max value of obj->lru */
#define REDIS_LRU_CLOCK_RESOLUTION 1000 /* LRU clock resolution in ms */

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
...

server.lruclock = getLRUClock();

...
}

unsigned int getLRUClock(void) {
return (mstime()/REDIS_LRU_CLOCK_RESOLUTION) & REDIS_LRU_CLOCK_MAX;
}
  • LRU 时间的精度可以通过修改 REDIS_LRU_CLOCK_RESOLUTION 常量来改变。
  • 更新频率即serverCron被调用的频率,和hz参数有关,默认为100ms一次。

计算一个key的最长没有访问时间时,需要注意时钟回卷的情况:
server.unixtime是系统当前的unix时间戳,当lruclock的值超出REDIS_LRU_CLOCK_MAX时,会从头开始计算,所以在计算一个key的最长没有访问时间时,可能key本身保存的lru访问时间会比当前的lruclock还要大,这个时候需要计算额外时间:

1
2
3
4
5
6
7
8
9
10
// 使用近似 LRU 算法,计算出给定对象的闲置时长
unsigned long long estimateObjectIdleTime(robj *o) {
unsigned long long lruclock = LRU_CLOCK();
if (lruclock >= o->lru) {
return (lruclock - o->lru) * REDIS_LRU_CLOCK_RESOLUTION;
} else {
return (lruclock + (REDIS_LRU_CLOCK_MAX - o->lru)) *
REDIS_LRU_CLOCK_RESOLUTION;
}
}

Redis中和LRU相关的淘汰策略包括:

  • volatile-lru:设置了过期时间的key参与近似的lru淘汰策略;
  • allkeys-lru:所有的key均参与近似的lru淘汰策略。
    涉及淘汰的源码如下所示:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    int freeMemoryIfNeeded(void) {
    ...

    /* volatile-lru and allkeys-lru policy */
    // 如果使用的是 LRU 策略,
    // 那么从一集 sample 键中选出 IDLE 时间最长的那个键
    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) {
    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);

    /* Remove the entry from the pool. */
    sdsfree(pool[k].key);
    /* Shift all elements on its right to left. */
    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 the key exists, is our pick. Otherwise it is
    * a ghost and we need to try the next element. */
    if (de) {
    bestkey = dictGetKey(de);
    break;
    } else {
    /* Ghost... */
    continue;
    }
    }
    }
    }

    ...
    }
  • Redis会基于server.maxmemory_samples配置选取固定数目的key,然后比较它们的lru访问时间,然后淘汰最近最久没有访问的key,maxmemory_samples的值越大,Redis的近似LRU算法就越接近于严格LRU算法,但是相应消耗也会变高,对性能有一定影响,maxmemory_samples这个值默认为5。

缓存系统中存在的问题

旁路缓存的雪崩、击穿、穿透问题
旁路缓存的不一致问题

缓存穿透

缓存穿透即即黑客故意去请求缓存中不存在的数据,导致所有的请求都怼到数据库上,从而数据库连接异常。这也是经常提的缓存命中率问题。
应付大规模缓存穿透的方案如下:

  1. 利用互斥锁,缓存失效的时候,先去获得锁,得到锁了,再去请求数据库。没得到锁,则休眠一段时间重试
  2. 采用异步更新策略,无论 key 是否取到值,都直接返回。value 值中维护一个缓存失效时间,缓存如果过期,异步起一个线程去读数据库,更新缓存。需要做缓存预热(项目启动前,先加载缓存)操作。
  3. 提供一个能迅速判断请求是否有效的拦截机制,比如,利用布隆过滤器,内部维护一系列合法有效的 key,将这些数据 hash 到一个足够大的 bitmap 中。迅速判断出,请求所携带的 Key 是否合法有效。如果不合法,则直接返回。
  4. 如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过 5 分钟,通过这个直接设置的默认值存放到缓存,这样第二次到缓存中获取就有值了,而不会继续访问数据库,这种办法最简单粗暴。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public Object queryProduct() {
    int cacheTime = 30;
    String cacheKey = "product";

    String cacheValue = getFromRedis(cacheKey);
    if (cacheValue != null) {
    return cacheValue;
    } else {
    // 击穿到db
    cacheValue = getFromDB();
    if (cacheValue == null) {
    // 如果发现为空,则缓存个默认值
    cacheValue = "";
    }
    putToRedis(cacheKey, cacheValue, cacheTime);
    return cacheValue;
    }
    }
    把空结果也给缓存起来,这样下次同样的请求就可以直接返回空了,即可以避免当查询的值为空时引起的缓存穿透。同时也可以单独设置一个缓存区域存储空值,对要查询的key进行进行预先校验,然后再放行给后面的正常缓存处理逻辑。

缓存雪崩

缓存雪崩即缓存同一时间大面积的失效(例如:我们设置缓存时采用了相同的过期时间,在同一时刻出现大面积的缓存过期),这个时候又来了一波请求,结果请求都怼到数据库上,而对数据库 CPU 和内存造成巨大压力,从而导致数据库连接异常,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃。
缓存雪崩的解决方案如下:

  1. 使用互斥锁,但是该方案吞吐量明显下降了,适用于并发量不是特别多的情况下。具体地来说,使用最多的方案是加锁排队,伪代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public Object queryProduct() {
    int cacheTime = 30;
    String cacheKey = "product";
    String lockKey = cacheKey;

    String cacheValue = getFromRedis(cacheKey);
    if (cacheValue != null) {
    return cacheValue;
    } else {
    synchronized (lockKey) {
    cacheValue = getFromRedis(cacheKey);
    if (cacheValue != null) {
    return cacheValue;
    } else {
    cacheValue = getFromDB();
    putToRedis(cacheKey, cacheValue, cacheTime);
    }
    }
    return cacheValue;
    }
    }
    加锁排队只是为了减轻数据库的压力,并没有提高系统吞吐量。假设在高并发下,缓存重建期间key是锁着的,这时过来1000个请求、999个都在阻塞,同样会导致用户等待超时,属于治标不治本的方案,而且还需要解决分布式锁的问题。
  2. 设置过期标志更新缓存。给每一个缓存数据增加相应的缓存标记,记录缓存是否失效,如果缓存标记失效,则更新数据缓存,实现伪代码如下所示:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public Object queryProduct() {
    int cacheTime = 30;
    String cacheKey = "product";
    // 缓存标记
    String signKey = cacheKey + "_sign";

    String signValue = getFromRedis(signKey);
    String cacheValue = getFromRedis(cacheKey);
    if (signValue != null) {
    return cacheValue;
    } else {
    putToRedis(signKey, "1", cacheTime);
    threadPool.submit(() -> {
    cacheValue = getFromDB();
    putToRedis(cacheKey, cacheValue, cacheTime * 2);
    });
    return cacheValue;
    }
    }
    缓存标记记录缓存数据是否过期,如果过期会触发通知另外的线程在后台去更新实际 key 的缓存。
    缓存数据的过期时间比缓存标记的时间延长 1 倍,例如:标记缓存时间 30 分钟,数据缓存 60 分钟,这样,当缓存标记 key 过期后,实际缓存还能把旧数据返回给调用端,直到另外的线程在后台更新完成后,才会返回新缓存。
  3. 给缓存的失效时间,加上一个随机值,避免集体失效。
  4. 双缓存。我们有两个缓存,缓存 A 和缓存 B。缓存 A 的失效时间为 20 分钟,缓存 B 不设失效时间。自己做缓存预热操作。然后细分以下几个小点:
    1. 从缓存 A 读数据库,有则直接返回
    2. A 没有数据,直接从 B 读数据,直接返回,并且异步启动一个更新线程。
    3. 更新线程同时更新缓存 A 和缓存 B。

缓存预热

缓存预热就是系统上线后,提前将相关的缓存数据直接加载到缓存系统,避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户可以直接查询事先被预热的缓存数据。常见的缓存预热方案包括:

  1. 直接写个缓存刷新页面,上线时手工操作下;
  2. 数据量不大,可以在项目启动的时候自动进行加载;
  3. 定时刷新缓存。

缓存和数据库双写一致性问题

一致性问题是分布式常见问题,讨论比较多的是最终一致性和强一致性。数据库和缓存双写,就必然会存在不一致的问题。
在回答这个问题前,必须先强调一个前提,就是如果对数据有强一致性要求,不能放缓存。我们所做的一切,只能保证最终一致性,从根本上来说,只是降低不一致发生的概率,无法完全避免,因此,我们说有强一致性要求的数据,不能放缓存。

  • 首先,采取正确更新策略,先更新数据库,再删缓存。
  • 其次,因为可能存在删除缓存失败的问题,提供一个补偿措施即可,例如利用消息队列

具体的设计方案和优缺点可以参考:【原创】分布式之数据库和缓存双写一致性方案解析

下面是对所有策略的分析:

先失效缓存 -> 后更新数据库数据

  1. 缺点
    如果缓存失效失败,根据策略可能会影响后续的正常的数据更新操作
    直接失效缓存会增加后续的一次缓存查询的 Miss
  2. 优点
    避免数据库更新成功,缓存失效失败,导致缓存中是旧数据
  3. 场景
    对缓存准确率要求比较高的业务
  4. 异常情况
    线程 A 需要更新数据库数据,失效缓存;
    线程 B 发现缓存没有命中,查询数据库中取出旧的值;
    线程 A 更新数据库数据,提交事务,线程 A 将数据放入缓存。

延时双删

在《先失效缓存 -> 后更新数据库数据》这种方案的基础上,增加了一个延时过期的步骤。
即:过期Redis -> 更新数据库 -> 延迟一会再过期一次Redis
这样就可以缓解上边提到的脏读问题了。
但是缺点是过期两次,会占用更多的数据库资源。

先更新数据库数据 -> 后失效缓存

  1. 缺点
    如果数据更新成功,但是缓存失效失败,缓存中存放的是旧数据
    直接失效缓存会增加一次缓存查询的 Miss
  2. 优点
    更新数据不会强依赖缓存,就算失效缓存失败,也不会影响数据库的更新
  3. 场景
    对缓存和数据库的一致性要求不是很高的场景
  4. 异常情况
    在更新数据库数据和失效缓存之前的所有查询,查询到的都是旧数据

更新数据库数据 -> 更新缓存

  1. 优点
    避免了一次额外的缓存查询 Miss
    实时性比较高
  2. 缺点
    数据库更新成功,但是更新缓存失败,缓存中存储的是旧数据
    选择同步还是异步来更新缓存呢?如果是同步更新,更新磁盘成功了,但是更新缓存失败了,你是不是要反复重试来保证更新成功?如果多次重试都失败,那这次更新是算成功还是失败呢?如果是异步更新缓存,怎么保证更新的时序?
    比如,我先把一个文件中的某个数据设置成 0,然后又设为 1,这个时候文件中的数据肯定是 1,但是缓存中的数据可不一定就是 1 了。因为把缓存中的数据更新为 0,和更新为 1 是两个并发的异步操作,不一定谁会先执行。
    这些问题都会导致缓存的数据和磁盘中的数据不一致,而且,在下次更新这条数据之前,这个不一致的问题它是一直存在的。当然,这些问题也不是不能解决的,比如,你可以使用分布式事务来解决,只是付出的性能、实现复杂度等代价比较大。
  3. 场景
    缓存粒度比较小,缓存的数据不需要经过计算(更新商品数据,但是缓存还需要用户数据)
  4. 异常情况
    A 线程查询缓存发现缓存中没有数据,查询数据库;
    B 线程更新数据库并且更新了缓存;
    A 再把查询的数据放入缓存,缓存中将会是旧数据

更新缓存 -> 更新数据库数据

  1. 优点
    避免了一次额外的缓存查询 Miss
  2. 缺点
    缓存更新成功,但是数据库更新失败,导致缓存数据是旧数据;
    并且更新缓存失败,根据策略可能导致更新数据库失败。
  3. 场景
    缓存粒度比较小,缓存的数据不需要经过计算(更新商品数据,但是缓存还需要用户数据)
  4. 异常情况
    在更新缓存成功和更新数据库数据之前拿到的缓存是和数据库不一致的(不过这种情况造成的负面影响很小)

更新数据库数据 -> 定时同步到缓存

  1. 优点
    实现简单、鲁棒性高
    就算某次同步过程中发生了错误,等到下一个同步周期也会自动把数据纠正过来。
  2. 缺点
    缓存更新不实时。
    如果缓存的数据太大,更新速度慢到无法接受,可以选择增量更新,每次只更新从上次缓存同步至今这段时间内变化的数据,代价是实现起来会稍微有些复杂。

缓存更新总结

  • 如果对一致性要求没有那么高,一般是先更新数据库然后删除缓存。
  • 如果对一致性要求比较高,那么在缓存删除失败后,需要把删除事件放到队列里消费。
  • 如果对一致性要求更高点,那么需要将更新数据库、更新缓存、查询缓存的操作放到一个队列里消费

如何解决 redis 的并发竞争 key 问题

这个问题大致就是,同时有多个子系统去 set 一个 key。这个时候要注意什么呢?大家思考过么。百度上的答案基本都是推荐用 redis 事务机制,但这里不推荐使用 redis 的事务机制。因为我们的生产环境,基本都是 redis 集群环境,做了数据分片操作,你一个事务中有涉及到多个 key 操作的时候,这多个 key 不一定都存储在同一个 redis-server 上。因此,Redis 的事务机制,十分鸡肋

  1. 如果对这个 key 操作,不要求顺序
    这种情况下,准备一个分布式锁,大家去抢锁,抢到锁就做 set 操作即可,比较简单。
  2. 如果对这个 key 操作,要求顺序
    假设有一个 key1,系统 A 需要将 key1 设置为 valueA,系统 B 需要将 key1 设置为 valueB,系统 C 需要将 key1 设置为 valueC.
    期望按照 key1 的 value 值按照 valueA–>valueB–>valueC 的顺序变化。这种时候我们在数据写入数据库的时候,需要保存一个时间戳。假设时间戳如下
    系统 A key 1 {valueA 3:00}
    系统 B key 1 {valueB 3:05}
    系统 C key 1 {valueC 3:10}
    那么,假设这会系统 B 先抢到锁,将 key1 设置为{valueB 3:05}。接下来系统 A 抢到锁,发现自己的 valueA 的时间戳早于缓存中的时间戳,那就不做 set 操作了。以此类推。
    其他方法,比如利用队列,将 set 方法变成串行访问也可以。

缓存降级

当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。
降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。
在进行降级之前要对系统进行梳理,看看哪些服务是必须誓死保护的、哪些是可降级的。比如可以参考日志级别设置预案:

  1. 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
  2. 警告:有些服务在一段时间内成功率有波动(如在 95~100%之间),可以自动降级或人工降级,并发送告警;
  3. 错误:比如可用率低于 90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
  4. 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

缓存污染

什么是缓存污染呢?在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。这种情况,就是缓存污染。

我们来看一下各种过期策略是否能解决缓存污染问题:

  • allkeys-random:对所有key进行随机的淘汰,因为不确定之后同一个key是否还会被访问到,所以这个策略会导致缓存缺失问题。
  • volatile-random:和allkeys-random类似。
  • volatile-ttl:针对的是设置了过期时间的数据,把这些数据中剩余存活时间最短的筛选出来并淘汰掉,这种策略并不能直接反映数据被再次访问的情况,也有导致缓存缺失的问题。
    一般业务会根据数据生效时间范围来决定数据的过期时间,因此过期时间短的很有可能就是用一下就不用的数据,所以这种情况下volatile-ttl是可以缓解缓存污染问题的。
  • lru
    把使用最少的淘汰掉。
    但是使用LRU策略在处理扫描式单次查询操作时,无法解决缓存污染,因为这些key被扫描过一次后最近访问时间都是一样的。因为存在这种问题,因此Redis4.0增加了LRU淘汰策略。
  • lfu
    与 LRU 策略相比,LFU 策略中会从两个维度来筛选并淘汰数据:一是,数据访问的时效性(访问时间离当前时间的远近);二是,数据的被访问次数。
    LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。

缓存数据迁移

什么时候会遇到要将Redis数据迁入、迁出的情况?比如要将Sentinel集群迁移到Cluster集群。
目前一个比较常用的Redis数据迁移工具是Redis-shake。
Redis-shake 的基本运行原理,是先启动 Redis-shake 进程,这个进程模拟了一个 Redis 实例。然后,Redis-shake 进程和数据迁出的源实例进行数据的全量同步。
源实例先把 RDB 文件传输给 Redis-shake,Redis-shake 会把 RDB 文件发送给目的实例,等到同步完毕后,源实例再将增量命令发送给Redis-shake,Redis-shake 负责把这些增量命令再同步给目的实例。

前置知识

GFS

Fault-Tolerant Virtual Machines(容错虚拟机)

Raft

概念

  • App: App信息
  • AppNamespace: App下Namespace的元信息
  • Cluster: 集群信息
  • Namespace: 集群下的namespace
  • Item: Namespace的配置,每个Item是一个key, value组合
  • Release: Namespace发布的配置,每个发布包含发布时该Namespace的所有配置
  • Commit: Namespace下的配置更改记录
  • Audit: 审计信息,记录用户在何时使用何种方式操作了哪个实体。

Apollo架构

Apollo架构——图片来自Apollo-github

  • Apollo Client:为应用提供配置查询功能;
  • Apollo Config Service:提供配置的读取、推送等功能,服务对象是 Apollo Client;
  • Apollo Portal:Apollo管理界面,为开发者提供配置修改功能;
  • Apollo Admin Service:提供配置修改、发布等功能,服务对象是Apollo Portal。

服务发现和负载均衡

在Apollo中,Config Service和Admin Service都是多实例无状态部署的,需要将自己注册到Eureka
Eureka负责服务发现,在Eureka之上Apollo又架了一层Meta Server用于封装Eureka的服务发现接口:

  • Client通过域名访问Meta Server获取Config Service服务列表(IP+Port),而后直接通过IP+Port访问服务,同时在Client侧会做load balance错误重试
  • Portal通过域名访问Meta Server获取Admin Service服务列表(IP+Port),而后直接通过IP+Port访问服务,同时在Portal侧会做load balance错误重试

为什么Apollo使用Eureka而不是别的服务发现组件,比如ZooKeeper?

  1. 提供了完整的服务注册和发现实现,用起来省心;
  2. 项目基础是SpringCloud,已经有应用Eureka的基础;

Meta Server部署在哪里?
Meta Server只是一个逻辑角色,在部署时和Config Service是在一个JVM进程中的,所以IP、端口和Config Service一致

服务端实现原理

服务端的主要任务是维护配置信息,以及将配置信息推送到客户端。

服务端推送大体流程

  1. 用户在Portal操作配置发布
  2. Portal调用Admin Service的接口操作发布
  3. Admin Service发布配置后,发送ReleaseMessage给各个Config Service
  4. Config Service收到ReleaseMessage后,通知对应的客户端

发送ReleaseMessage给Config Service的过程

Apollo配置信息的发布——图片来自Apollo-github
用户操作配置发布后,Admin Service会往ReleaseMessage表插入一条消息记录,然后Config Service定时轮询这张表来消费消息。
ApolloPortol会调Admin服务发出消息,这时,Admin Service作为Producer发出消息,各个ConfigService作为Consumer消费消息,为了减少对外部的依赖,Apollo发送消息的功能是通过数据库自己实现的一个简单的消息队列

  1. Admin操作Release:com.ctrip.framework.apollo.portal.controller.ReleaseController#createRelease
    在Admin Service的后台操作界面上可以看到Release操作入口。
  2. Admin向ReleaseMessage表插入一条消息记录:com.ctrip.framework.apollo.adminservice.controller.ReleaseController#publish
    该消息的内容就是配置发布的AppId+Cluster+Namespace。
    注意在“发消息”前,先把发布信息存到了Release表中。
  3. 定时扫描消息,
    扫描消息:com.ctrip.framework.apollo.biz.message.ReleaseMessageScanner
    定时任务逻辑:批量处理,每次扫描500条,每条消息分别触发所有消息监听器(ReleaseMessageListener)。
    定时任务线程池配置:每100毫秒执行一次,core线程数只有1,但是总线程数为Integer.MAX_INT。
  4. Config Service通知客户端
    代码入口:NotificationControllerV2#handleMessage
    注意NotificationControllerV2这个Controller本身也是个消息监听器。
    Config Service会从消息中获取配置发布的AppId+Cluster+Namespace,然后通知客户端(通知客户端的过程见下面)。

Config Service通知客户端的过程

  1. 客户端发起一个HTTP请求到Config Service
    入口:/notifications/v2,对应NotificationControllerV2(注意和上面的消息监听器是同一个类)。
  2. Config Service将请求挂起
    通过Spring 的 DeferredResult将请求挂起,默认等待60秒。
    如果在等待期间有该客户端关注的配置(Namespace)发布,则NotificationControllerV2会调用DeferredResult#setResult传入变化的Namespace信息,同时该请求也会立刻返回。
    反之,如果60秒内都没有该客户端关注的配置发布,则返回HTTP状态码304给客户端。
  3. 客户端请求最新的Namespace配置
    如果Config Service返回了配置信息、客户端获取到变化的Namespace信息后,客户端就会立即请求Config Service获取该Namespace的最新配置。

客户端实现原理

客户端主要任务是从Config Service获取配置信息(Push和Pull都有),并在本地维护一个配置文件缓存。
Apollo配置在客户端的维护——图片来自Apollo-github

客户端long-polling

客户端会和服务端维持一个长连接,以及时接收到配置的变更信息。
入口:com.ctrip.framework.apollo.internals.RemoteConfigLongPollService#startLongPolling
客户端长轮询的是Config Service的配置变更通知接口。当有新通知时就会触发RemoteConfigRepository,立即轮询Config Service的配置读取/configs/{appId}/{clusterName}/{namespace:.+}接口。

客户端定时Pull

客户端定时从Config Service拉取应用的配置信息,使用的接口和上面的long-polling一样:/configs/{appId}/{clusterName}/{namespace:.+}
入口:RemoteConfigRepository#scheduleLongPollingRefresh
这是一个fallback机制,主要目的是防止推送机制失效导致配置不更新。
客户端定时拉取会上报本地版本,所以一般情况下,对于定时拉取的操作,服务端都会返回304 - Not Modified。
定时频率默认为每5分钟拉取一次,客户端也可以通过在运行时指定System Property: apollo.refreshInterval来覆盖,单位为分钟。

客户端本地对配置的维护

  1. 客户端从Apollo配置中心服务端获取到应用的最新配置后,会保存在内存中
  2. 客户端会把从服务端获取到的配置在本地文件系统缓存一份
    在遇到服务不可用,或网络不通的时候,依然能从本地恢复配置
  3. 应用程序可以从Apollo客户端获取最新的配置、订阅配置更新通知

参考

  1. Java客户端使用指南
  2. Apollo配置中心设计

  1. 业务背景
  2. 业务逻辑
  3. 收益
  4. 技术选型
    服务器几台、QPS多少、存储、缓存、MQ等
  5. 架构图
  6. 规划
  7. 个人角色 / 做了什么
  8. 技术难点
  9. 遇到的问题
  10. 复盘 / 未来规划
阅读全文 »

和朋友交流的一些开放性设计问题的记录,因为是开放性问题,所以肯定是没有唯一答案的,这里只是记录我们讨论时认为比较好的回答。

阅读全文 »

背景

在pinpoint上观察到一些服务器频繁发生长时间FULL GC,直接影响业务执行效率。
一次FullGC的记录
观察gc日志发现,老年代几乎已经耗尽,也没有发生Promotion FailedConcurrent Mode Failure,最有可能的情况是年轻代正常晋升时发现老年代的空间已经达到了-XX:CMSInitiatingOccupancyFraction,所以触发老年代回收以释放更多空间。
为此,可以采用的解决方式几乎只有2个:

  1. 物理增大老年代,可以直接把年轻代空间割让给老年代,实际上上图中年轻代:老年代≈2:1不大合理,一般根据实际情况,年轻代:老年代的比值是1:1.5到1:3都是可以的,但是不会出现年轻代比老年代大的情况,除非绝大多数对象都是朝生夕死的。
  2. 排查内存泄露,常见于一些错误配置、或者本地缓存(一个大Map)之类的。

排查方式

一、直连VisualVM

1、配置jmx启动参数,表示允许远程连接

1
2
3
4
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.port=9988
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.authenticate=false

2、使用VisualVM连接
使用VisualVM直连

直连有两个缺点:

  • 实时性差,问题发生可能就那么几分钟,不可能让人一直等着服务器出问题;
  • 安全性差,暴露一个端口就意味着暴露整个项目及其连接的数据库,是一个隐患。

二、配置参数实时导出

1、导出位置
-XX:HeapDumpPath=$CATALINA_BASE/logs/
2、FULL GC前DUMP
-XX:+HeapDumpBeforeFullGC
3、FULL GC后DUMP
-XX:+HeapDumpAfterFullGC
4、OOM
-XX:+HeapDumpOnOutOfMemoryError
配完这个参数后,FULL GC后内存仍不足才会DUMP,单纯System.gc或FULL GC不会引起DUMP。

三、手动dump分析

到服务器上找到JVM进程PID后执行jmap:

1
2
jmap -dump:format=b,file=<文件名> [pid]
gzip <文件名>

下载到本地,因为线上服务器没有开启下载端口,因此下载文件必须要中转一下,先上传到一台暴露到公网的服务器,再从该服务器上下载,如果没有这样的服务器也可以用github代替。
在本地使用eclipse MAT分析。

问题排查记录

手动导出内存dump文件:
robin的内存dump文件
仔细观察其中对象占用的内存空间,发现一个大对象占用了接近1/3的内存空间,发现是com.alibaba.druid.stat.JdbcDataSourceStat中有个LinkedHashMap类型的成员变量占用了大量空间没有释放,在源码中对应一个sqlStatMap。
占用大量内存空间的大对象
Druid中的sqlStatMap
从源码可知,打开开关之后,每次执行SQL前后都会把SQL记到这个sqlStatMap里,在JVM实例运行期间会一直存在(基本都在老年代),这个开关主要用于监控SQL执行情况,对SQL语句的执行本身没有影响,且我们这边统计SQL(包括慢查询等)都是基于MySQL本身的功能、并没有用到这个配置。
可以把这个开关关掉,因为我们根本没有使用Druid内置的SQL监控功能,关闭后预计可以释放25%左右的堆空间。
另外附:
1、配置_StatFilter:https://github.com/alibaba/druid/wiki/%E9%85%8D%E7%BD%AE_StatFilter
2、每个sql语句都会长期持有引用,加快FullGC频率 #1664:https://github.com/alibaba/druid/issues/1664

0%