58

【唯实践】Memcached使用那些事

 6 years ago
source link: http://mp.weixin.qq.com/s/z6f_AkXk_WeAfej3ZQmE7w
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

【唯实践】Memcached使用那些事

Original 蔡恒旋 唯技术 2017-12-11 10:52 Posted on

背景介绍

Image

截至编写此文,A系统已经成功上线2年有多,作为国内主流电商公司核心基础公共服务系统之一,每天承载了数十亿级别流量。自上线以来成功经受住了双十一、周年店庆等检验,为唯品会提供了高效稳定的接口服务。作为稳定服务体系的重要一环,缓存自然是重中之重,本文将对A系统中使用到的缓存组件spymemcached+memcached进行参数阐述,结合spymemcached源码,温故而知新,也算是对过去工作的一次总结,以期能帮助大家避免重复掉坑,并提出一些最优实践建议。

spymemcached简介

Image

A系统于memcached通信主要是通过java组件spymemcached来完成,spymemcached底层是通过Java NIO机制来实现,是一个易于使用,提供异步操作并且单线程实现的客户端。

spymemcahed的特点分析

spymemcahed具有如下特点:

1、高效存储;
2、强力兼容服务器和网络各种中断;
3、全面提供异步操作API;
4、单线程简单高效;
5、极致优化带来高吞吐量处理能力。
有兴趣的可以访问官网了解更多:
https://code.google.com/archive/p/spymemcached/

spymemcached中的一些核心组件:

spymemcaced核心组件关系图.png

MemcachedClient:
MemcachedClient是spymemcached对外提供API Facade。

MemcachedConnection:
MemcachedConnection是spymemcached的核心组件,它是管理mc链接的manager

NodeLocator:
NodeLocator是用于查找key对应的mc实例,其之类包含常用的查找算法,如ArrayModNodeLocator、KetamaNodeLocator。A系统中使用的就是实现了Ketama 一致性哈希算法的KetamaNodeLocator。

MemcachedNode:
MemcachedNode负责与单个mc实例建立连接,处理真正的mc操作。其实现类中通过结合NIO的使用,提供高效mc操作能力。

Operation:
在spymemcaced中任何mc操作都被抽象为Operation。作为传递单位在MemcachedConnection与MemcachedNode之间进行交互。同时一个相同的操作分别也按照不同协议,提供不同实现,使用者可按需使用。

memcached使用那些事:

Image

事件一:memcached实例假死引发的思考

A系统上线之初,系统流量慢慢接入,似乎一切尽在掌控。然而就在一个阳光明媚的周日早上,监控中心的一个电话打破了这美好时光,故障来了。A系统前端接口在9点左右突然响应急剧上升,后台日志发现有大量超时error。经与DBA配合排查,初步断定是A系统mc集群中的一个mc实例又跪了。但奇怪的是,按照我们之前测试场景的认知:一个mc实例跪了,根据一致性hash算法,应该会自动找到替代节点,而不应该出现生产环境上大量timeout的情况。

经DBA排查,出现故障的mc实例并不是直接down掉,而是hang住了,处于假死状态,导致spymemcached无法立刻判断该mc实例处于下线状态,造成后面的请求依然会尝试使用该mc实例。然而一个有趣的现象引起了我们注意,每个应用实例针对该故障mc实例的TimeoutException都刚好不超过1000个,是巧合吗?这就得从spymemcached应对TimeoutException的处理机制说起了。从源码可知,spymemcached会为每个MemcachedNode设置独立的计数器,当某个实例的timeout累积到了一定次数之后,spymemcached就会设置该MemcachedNode不可用,并断开与它的连接并尝试重新建立。如果重新建立不成功,该MemcachedNode就会一直处于不可用状态。

1.DEFAULT_MAX_TIMEOUTEXCEPTION_THRESHOLD就是timeout计数器的阀值,默认为998个,这也是为什么所有的TimeoutException都没有超过1000的缘故;

2、MemcachedConnection在处理每个operation时都会在handleIO()时触发timeout计数器检查;

3、超过timeout阀值之后,像故障mc的状况,新链接将无法创建而导致该实例被spymemcached视作下线,新的mc operation将被rehash到存活的实例中去。

所有的代码都在MemcachedConnection和DefaultConnectionFactory的之内,具体如下:

 * Default implementation of ConnectionFactory.

 * <p>

 * This implementation creates connections where the operation queue is an

 * ArrayBlockingQueue and the read and write queues are unbounded

 * LinkedBlockingQueues. The <code>Redistribute</code> FailureMode is always

 * used. If other FailureModes are needed, look at the ConnectionFactoryBuilder.

 * </p>

public class DefaultConnectionFactory extends SpyObject implements

    ConnectionFactory {

   * Maximum number + 2 of timeout exception for shutdown connection.

  public static final int DEFAULT_MAX_TIMEOUTEXCEPTION_THRESHOLD = 998;

 * Main class for handling connections to a memcached cluster.

public class MemcachedConnection extends SpyThread {

   * Check if one or more nodes exceeded the timeout Threshold.

  private void checkPotentiallyTimedOutConnection() {

    boolean stillCheckingTimeouts = true;

    while (stillCheckingTimeouts) {

        for (SelectionKey sk : selector.keys()) {

          MemcachedNode mn = (MemcachedNode) sk.attachment();

          if (mn.getContinuousTimeout() > timeoutExceptionThreshold) {

            getLogger().warn("%s exceeded continuous timeout threshold", sk);

            lostConnection(mn);

        stillCheckingTimeouts = false;

      } catch(ConcurrentModificationException e) {

        getLogger().warn("Retrying selector keys after "

          + "ConcurrentModificationException caught", e);

        continue;

   * Helper method for {@link #handleIO()} to encapsulate everything that

   * needs to be checked on a regular basis that has nothing to do directly

   * with reading and writing data.

   * @throws IOException if an error happens during shutdown queue handling.

  private void handleOperationalTasks() throws IOException {

    checkPotentiallyTimedOutConnection();

    if (!shutDown && !reconnectQueue.isEmpty()) {

      attemptReconnects();

    if (!retryOps.isEmpty()) {

      ArrayList<Operation> operations = new ArrayList<Operation>(retryOps);

      retryOps.clear();

      redistributeOperations(operations);

    handleShutdownQueue();

   * Handle all IO that flows through the connection.

   * This method is called in an endless loop, listens on NIO selectors and

   * dispatches the underlying read/write calls if needed.

  public void handleIO() throws IOException {

    handleOperationalTasks();

知道原因就好办了,从源码可知,理论上spymemcached已经通过timeout计数器帮我们针对问题mc实例做了隔离,如此算来,该机房我们的应用集群有30台机器,只要能够忍受1000*30=3w次TimeoutException,我们完全可以不去处理该mc实例。但出于对生产环境的敬畏之心,我们还是赶紧让DBA直接将该问题mc实例从生产环境摘除下线,利用一致性hash算法让mc操作快速地分散到其它mc节点。

此次故障虽已顺利解决,但却给我们带来了另外的思考(每次timeout花费时间将近3s,这对于一个平均响应时间1ms以内的系统是无法接受的)——我们系统无法快速地隔离响应(虽然spymemcached已经帮我们做了一定程度的隔离,但我们认为依然不够)。

那有什么办法在遇到同样故障的情况下依然保有某种程度的高效响应呢?为此我们修改了代码,将原来同步读写mc的操作变成了异步操作,并在异步操作的代码里设置了超时时间(300ms),这样哪怕同样的故障场景发生,起码我们可以将响应时间控制在合理的范围。

以下以mc批量读取操作为例。

修改前代码:

MemcachedClient memcachedClient = (MemcachedClient) (((SpyMemcachedConnection) connection).getNativeConnection());

    return memcachedClient.getBulk(keys);

} catch (InterruptedException | ExecutionException | TimeoutException e) {

    logger.error("mc getbulk error", e);

修改后代码:

MemcachedClient memcachedClient = (MemcachedClient) (((SpyMemcachedConnection) connection).getNativeConnection());

    return memcachedClient.asyncGetBulk(keys).get(300, TimeUnit.MILLISECONDS);

} catch (InterruptedException | ExecutionException | TimeoutException e) {

    logger.error("mc getbulk error", e);

TIPS 最佳实践:

Fail fast, 以上述故障场景为例,在高响应大并发的场景下,穿透缓存回源db获取相应信息并非不可接受,所以有时候快速失败(Fail fast)是更好的一种选择。

事件二:OperationTimeoutException之谜

从上面的场景一我们建议Failfast,那是否是将超时时间设得越短越好?答案显然是否定的。就A系统的后端域后台任务而言,每天需要完成大量批量mc操作,mc操作主要集中在类似缓存预热/缓存更新上,这类操作对每笔操作的时间要求不是十分严格,可是却需要确保百分百的成功率,但凡有任何更新失败,都会影响线上售卖。

针对这种情况,我们做了以下措施:

1.将mc操作的OperationTimeoutException时间设置为2500ms;

2.针对mc操作失败的加入了重试机制。

尽然我们已经设置了较大的超时时间,按理说不应该出现OperationTimeoutException了,然而每天预热程序都有少量的超时出现:

[2017-10-10 06:58:47.491][ERROR] [init_cache_db-pool-12] [c.vip.venus.data.memcached.core.MemcachedAccessor]

>>> net.spy.memcached.OperationTimeoutException: Timeout waiting for bulk values: waited 2,500 ms.                                                              

Node status: Connection Status { ... active: true, authed: true, last read: 2,905 ms ago }

    at net.spy.memcached.MemcachedClient.getBulk(MemcachedClient.java:1567)

    at net.spy.memcached.MemcachedClient.getBulk(MemcachedClient.java:1601)

    at net.spy.memcached.MemcachedClient.getBulk(MemcachedClient.java:1616)

所以我们怀疑有以下几种可能:

1、批量操作的item数太多需要更大;

2、网络抖动;

3、spymemcached性能瓶颈。

但后面的跟踪分析都一一否定了上述假设。

批量操作的item数太多需要更大?
首先,我们发现OperationTimeoutException出现的情况,就算是在操作单独一个mc key时候也会出现,这就很奇怪,很能说明问题了——即OperationTimeoutException与数量没有必然的因果关系。

网络抖动?

其次,若是网络抖动问题,同台机器&同个实例的操作应该一起出现OperationTimeoutException问题,然而现实并没有。

spymemcached性能瓶颈?

在开发环境,我们模拟线上加大线程,加大并发去测试,也无法重现线上问题。

最终网上的一篇技术讨论使我豁然开朗。

http://grokbase.com/t/gg/spymemcached/137nxny78q/issues-with-operationtimeoutexception

于是我们转向了应用的gc日志:

2017-10-10T06:58:44.152+0800: 3523.843: [GC [PSYoungGen: 1310002K->547861K(1488576K)] 3306395K->2545237K(3585728K), 0.3244270 secs] [Times: user=5.74 sys=0.00, real=0.33 secs]

2017-10-10T06:58:44.738+0800: 3524.430: [GC [PSYoungGen: 1488533K->608553K(1307712K)] 3485909K->2675273K(3404864K), 0.4456690 secs] [Times: user=5.90 sys=2.07, real=0.44 secs]

2017-10-10T06:58:45.184+0800: 3524.875: [Full GC [PSYoungGen: 608553K->0K(1307712K)] [PSOldGen: 2066720K->960493K(2097152K)] 2675273K->960493K(3404864K) [PSPermGen: 32161K->32161K(262144K)], 2.2263360 secs] [Times: user=2.23 sys=0.00, real=2.23 secs]

2017-10-10T06:58:47.640+0800: 3527.331: [GC [PSYoungGen: 699136K->183558K(1398144K)] 1659629K->1144051K(3495296K), 0.1224060 secs] [Times: user=2.13 sys=0.04, real=0.12 secs]

2017-10-10T06:58:48.049+0800: 3527.741: [GC [PSYoungGen: 882694K->174247K(1398144K)] 1843187K->1134740K(3495296K), 0.0936960 secs] [Times: user=1.66 sys=0.00, real=0.09 secs]

2017-10-10T06:58:48.454+0800: 3528.145: [GC [PSYoungGen: 873383K->259059K(958208K)] 1833876K->1219552K(3055360K), 0.1439410 secs] [Times: user=2.55 sys=0.01, real=0.15 secs]

2017-10-10T06:58:48.855+0800: 3528.547: [GC [PSYoungGen: 958195K->257351K(1399808K)] 1918688K->1217845K(3496960K), 0.1370100 secs] [Times: user=2.44 sys=0.00, real=0.14 secs]

从日志系统中可以看到,OperationTimeoutException和Full GC在时间点上是吻合的,它俩结对出现,问题到此就豁然开朗了,罪魁祸首就是应用程序的FUll GC导致STW。解决办法自然就是整改优化代码,尽量减少应用的Full GC,至于如何减少Full GC这个话题完全可以拉出一篇新的讨论了,在此便不再展开:)。

TIPS 最佳实践:

Design For Failure,任何涉及准确性高的系统都需要在设计之初就考虑怎么处理失败的状况,而可以幂等操作的retry就是其中有效的方式之一。

天下武功为快不破,系统性能恶化或者异常往往来自于大而重的设计,轻而快才是程序优化的方向。

事件三:memcached实例连环挂机带来的影响

从场景一可以看到,利用一致性hash算法,spymemcached可以自动应对某个mc实例down掉的情况,从而进行rehash。然而假设线上不是只有某个mc实例down掉,而是2个,3个,...n个呢?为此,我们模拟了A系统生产环境逐个mc down掉的状况,接下来会出现什么呢?让我们一探究竟。

拿100个sku,让key均匀的分布在15个mc实例中,压力机开10个线程,每次请求使用批量10个sku,在压力机一直运行的情况下,不断地逐个关闭mc实例,并监控其TPS及响应时间。

测试结果:

从测试结果可以看到,只要down掉的mc实例超过6个,TPS会有断崖式的下降。说好的spymemcached会自动rehash的情况没有出现。实际情况再一次和我之前的认知不一致了,跟踪spymemcached其源码,具体如下:

public final class KetamaNodeLocator extends SpyObject implements NodeLocator {

public Iterator<MemcachedNode> getSequence(String k) {

    // Seven searches gives us a 1 in 2^7 chance of hitting the

    // same dead node all of the time.

    return new KetamaIterator(k, 7, getKetamaNodes(), hashAlg);

也就是说spymemcached在hash了7次之后就直接采取回源的策略了,所以如果要让spymemcached继续重试rehash,完全可以通过新增一个NodeLocator子类,重写getSequence方法增大重试次数。

public Iterator<MemcachedNode> getSequence(String k) {

    return new KetamaIterator(k, 14, getKetamaNodes(), hashAlg);

注:个人意见而言并非重试次数越多越好,得根据自身业务通过测试得到其最优值。

事件四:缓存压缩优化之连锁反应

随着A系统的日常访问量节节攀升(截至目前为止录得最大qps为33w,每天总流量高峰可达40亿次),还有数据量的不断累积(20亿条数据左右)。在大访问量和大数据量访问的过程中,最直接的体现就是网卡流量。netin/netout的不断攀升,后来进行的线上压测都最终由于网卡流量告警而鸣金收兵。所以针对系统的优化势在必行,而关于优化我们最先想到的就是压缩缓存大小,通过此举应该可以获得几个好处:

1、减少网络流量,避免告警;

2、较少mc的占用内存大小;

3、更少的网络流量,带来更快的响应速度;

于是乎我们通过一些方式减少了缓存的大小:

1、从业务上压缩数据量的大小;

2、对字段进行最小编码,从而获得数据体积的压缩,减少网络传输压力。

完了就愉快顺利上线了,然而事与愿违的状况有发生了,问题又来了。

1、mc占用内存的大小不减反增加;

2、网络流量mc实例的net-out确实减少了;

3、接口响应出现强烈的抖动,而且非常之有规律,呈锯齿状,波动不停。

通过反复的查找,问题终于被定为出来了,变动上线之后,导致到一个明显的情况是mc的淘汰数不断上升,而在这之前,该指标一直为0,留意到淘汰率的变化之后,状况就解释的通了,响应时间不断抖动的原因就是不断的有一批key被淘汰出来,从而导致回源db获取数据造成响应下降。

问题mc实例移除率.png

道理是明白了,可为什么会发生淘汰?

这就得从mc的内存模型说起了,下面是memcached的一个内存示意图:

slab class

该图片可以简单的理解为:

1、memcached所占用的均被Slab Class * 所拥有;

2、我们往memcached所塞的key-value是按Chunk存储的;

3、每个Slab Class扩容时是以Page为单位的,默认是1M,然后再按Slab中Chunk的大小切成容量一致的多个小块;

4、memcached对已经分配的Slab中Chunk的个数不会重新分配,除非通过命令强制调整或重启实例;

5、memcached对已过期的item做懒清除,只有新的item被划分到对应slab,需要用到对应的chunk时才会根据LRU等具体算法对其内存进行重新利用。

此时我们查了下问题的mc实例:

问题mc实例Slab详情

从slab分布详情真可以看出304B这个slab不断的在做evict,同时480B和600B也有不少移除,至此问题根源已经清晰:

1、304B Slab不断的evict是造成缓存击穿,接口响应不断周期抖动的具体原因;

2、在对mc内容进行压缩之前,value主要分布在752B~3.5K之间的slabs中;

3、在对mc内容进行压缩之后,value主要分布在304B~600B之间的slabs中;

4、由于压缩之前的几个大slab被大量数据填塞后,留给其它slab的空间就不多了;

5、所以压手内容之后的slab由于无法申请更多的内存,而只能通过不断的移除来满足新增key的使用。

至此,我们通过安排mc实例在低流量时间进行重启,重启之后evict又恢复了往日的移除率,基本为零;而接口响应也如之前说预计在更小的网络开销下得到了将近50%的性能提升。

mc内容压缩问题处理前后的移除率

写在最后

Image

memcached使用只是我们系统的一部分,而一个稳定高效的系统远远不止这些,需要有很多相关配套措施。例如为了克服跨机房的调用延时,我们在主要机房都部署了相应mc集群和应用集群,而更多的mc集群也对我们提出了更高要求,比方说我们得保证各个集群中的缓存一致性问题。另外我们也使用loacal cache和mc结合使用,组件优势互补以达到类似消除洪峰等更好的效果。而这所有的所有,都是在系统发展到一定程度或者遇到了某些问题之后,慢慢调整和进化而来。公司在不断发展,技术人员在不断进步,而系统也在不断进化中日益强大,我在想,这应该就是技术人追求的发展道路。

推荐阅读

【唯实践】JVM老生代增长过快问题排查

唯品会大数据平台优化

Image

唯品会高性能负载均衡VGW揭秘

“唯技术”一档专为技术人发声的公众号

欢迎投稿!!

只要是技术相关的文章尽管砸过来!

Image




About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK