Memcached二三事儿

Posted by admin on 2013, January 5

转自http://huoding.com/2012/12/30/205

Memcached绝对称得上是NoSQL老兵!可惜随着时间的推移,Redis等后起之秀羽翼渐丰,Memcached相比之下已呈颓势。那我们还用不用学习它?答案是肯定的!毕竟仍然有很多项目依赖着它,如果忽视它,一旦出了问题就只有干瞪眼的份儿了。

网络上关于Memcached的资料可以说是浩如烟海,其中不乏一些精彩之作,比如说由爱好者翻译的「Memcached全面剖析」系列文章,在中文社区广为流传,虽然已经是几年前的文章了,但是即便现在读起来,依然感觉收获良多,推荐大家多看几遍:

  1. Memcached的基础

  2. 理解Memcached的内存存储

  3. Memcached的删除机制和发展方向

  4. Memcached的分布式算法

  5. Memcached的应用和兼容程序

当然,官方Wiki永远是最权威的资料,即便是里面的ReleaseNotes也不要放过。

实际应用Memcached时,我们遇到的很多问题都是因为不了解其内存分配机制所致,下面就让我们以此为开端来开始Memcached之旅吧!

为了规避内存碎片问题,Memcached采用了名为SlabAllocator的内存分配机制。内存以Page为单位来分配,每个Page分给一个特定长度的Slab来使用,每个Slab包含若干个特定长度的Chunk。实际保存数据时,会根据数据的大小选择一个最贴切的Slab,并把数据保存在对应的Chunk中。如果某个Slab没有剩余的Chunk了,系统便会给这个Slab分配一个新的Page以供使用,如果没有Page可用,系统就会触发LRU机制,通过删除冷数据来为新数据腾出空间,这里有一点需要注意的是:LRU不是全局的,而是针对Slab而言的。

一个Slab可以有多个Page,这就好比在古代一个男人可以娶多个女人;一旦一个Page被分给某个Slab后,它便对Slab至死不渝,犹如古代那些贞洁的女人。但是女人的数量毕竟是有限的,所以一旦一些男人娶得多了,必然另一些男人就只剩下咽口水的份儿,这在很大程度上增加了社会的不稳定因素,于是乎我们要解放女性。

好在Memcached已经意识到解放女性的重要性,新版本中Page可以调配给其它的Slab:

shell> memcached -o slab_reassign,slab_automove

换句话说:女人可以改嫁了!这方面,其实Memcached的儿子Twemcache革命得更彻底,他甚至写了一篇大字报,以事实为依据,痛斥老子的无能,有兴趣的可以继续阅读:Random Eviciton vs Slab Automove

了解Memcached内存使用情况的最佳工具是:Memcached-tool。如果我们发现某个Slab的Evicted不为零,则说明这个Slab已经出现了LRU的情况,这通常是个危险的信号,但也不能一概而论,需要结合Evict_Time来做进一步判断。

在Memcached的使用过程中,除了会遇到内存分配机制相关的问题,还有很多稀奇古怪的问题等着你呢,下面我选出几个有代表性的问题来逐一说明:

Cache失效后的拥堵问题

通常我们会为两种数据做Cache,一种是热数据,也就是说短时间内有很多人访问的数据;另一种是高成本的数据,也就说查询很很耗时的数据。当这些数据过期的瞬间,如果大量请求同时到达,那么它们会一起请求后端重建Cache,造成拥堵问题,就好象在北京上班做地铁似的,英文称之为:stampeding herd,老外这里的用词还是很形象的。

一般有如下几种解决思路可供选择:

首先,我们可以主动更新Cache。前端程序里不涉及重建Cache的职责,所有相关逻辑都由后端独立的程序(比如CRON脚本)来完成,但此方法并不适应所有的需求。

其次,我们可以通过加锁来解决问题。以PHP为例,伪代码大致如下:

<?php

function query()
{
    $data = $cache->get($key);

    if ($cache->getResultCode() == Memcached::RES_NOTFOUND) {
        if ($cache->add($lockKey, $lockData, $lockExpiration)) {
            $data = $db->query();
            $cache->add($key, $data, $expiration);
            $cache->delete($lockKey);
        } else {
            sleep($interval);
            $data = query();
        }
    }

    return $data;
}

?>

不过这里有一个问题,代码里用到了sleep,也就是说客户端会卡住一段时间,就拿PHP来说吧,即便这段时间非常短暂,也有可能堵塞所有的FPM进程,从而使服务中断。于是又有人想出了柔性过期的解决方案,所谓柔性过期,指的是设置一个相对较长的过期时间,或者干脆不再直接设置数据的过期时间,取而代之的是把真正的过期时间嵌入到数据中去,查询时再判断,如果数据过期就加锁重建,如果加锁失败,不再sleep,而是直接返回旧数据,以PHP为例,伪代码大致如下:

<?php

function query()
{
    $data = $cache->get($key);

    if (isset($data['expiration']) && $data['expiration'] < $now) {
        if ($cache->add($lockKey, $lockData, $lockExpiration)) {
            $data = $db->query();
            $data['expiration'] = $expiration;
            $cache->add($key, $data);
            $cache->delete($lockKey);
        }
    }

    return $data;
}

?>

问题到这里似乎已经圆满解决了,且慢!还有一些特殊情况没有考虑到:设想一下服务重启;或者某个Cache里原本没有的冷数据因为某些情况突然转换成热数据;又或者由于LRU机制导致某些键被意外删除,等等,这些情况都可能会让上面的方法失效,因为在这些情况里就不存在所谓的旧数据,等待用户的将是一个空页面。

好在我们还有Gearman这根救命稻草。当需要更新Cache的时候,我们不再直接查询数据库,而是把任务抛给Gearman来处理,当并发量比较大的时候,Gearman内部的优化可以保证相同的请求只查询一次后端数据库,以PHP为例,伪代码大致如下:

<?php

function query()
{
    $data = $cache->get($key);

    if ($cache->getResultCode() == Memcached::RES_NOTFOUND) {
        $data = $gearman->do($function, $workload, $unique);
        $cache->add($key, $data, $expiration);
    }

    return $data;
}

?>

说明:如果多个并发请求的$unique参数一样,那么实际上Gearman只会请求一次。

Multiget的无底洞问题

Facebook在Memcached的实际应用中,发现了Multiget无底洞问题,具体表现为:出于效率的考虑,很多Memcached应用都已Multiget操作为主,随着访问量的增加,系统负载捉襟见肘,遇到此类问题,直觉通常都是通过增加服务器来提升系统性能,但是在实际操作中却发现问题并不简单,新加的服务器好像被扔到了无底洞里一样毫无效果。

为什么会这样?让我们来模拟一下案发经过,看看到底发生了什么:

我们使用Multiget一次性获取100个键对应的数据,系统最初只有一台Memcached服务器,随着访问量的增加,系统负载捉襟见肘,于是我们又增加了一台Memcached服务器,数据散列到两台服务器上,开始那100个键在两台服务器上各有50个,问题就在这里:原本只要访问一台服务器就能获取的数据,现在要访问两台服务器才能获取,服务器加的越多,需要访问的服务器就越多,所以问题不会改善,甚至还会恶化。

不过,作为被告方,Memcached官方开发人员对此进行了辩护

请求多台服务器并不是问题的症结,真正的原因在于客户端在请求多台服务器时是并行的还是串行的!问题是很多客户端,包括Libmemcached在内,在处理Multiget多服务器请求时,使用的是串行的方式!也就是说,先请求一台服务器,然后等待响应结果,接着请求另一台,结果导致客户端操作时间累加,请求堆积,性能下降。

如何解决这个棘手的问题呢?只要保证Multiget中的键只出现在一台服务器上即可!比如说用户名字(user:foo:name),用户年龄(user:foo:age)等数据在散列到多台服务器上时,不应按照完整的键名(user:foo:name和user:foo:age)来散列的,而应按照特殊的键(foo)来散列的,这样就保证了相关的键只出现在一台服务器上。以PHP的 Memcached客户端为例,有getMultiByKeysetMultiByKey可供使用。

Nagle和DelayedAcknowledgment的延迟问题

老实说,这个问题和Memcached没有半毛钱关系,任何网络应用都有可能会碰到这个问题,但是鉴于很多人在写Memcached程序的时候会遇到这个问题,所以还是拿出来聊一聊,在这之前我们先来看看NagleDelayedAcknowledgment的含义:

先看看Nagle:

假如需要频繁的发送一些小包数据,比如说1个字节,以IPv4为例的话,则每个包都要附带40字节的头,也就是说,总计41个字节的数据里,其中只有1个字节是我们需要的数据。为了解决这个问题,出现了Nagle算法。它规定:如果包的大小满足MSS,那么可以立即发送,否则数据会被放到缓冲区,等到已经发送的包被确认了之后才能继续发送。通过这样的规定,可以降低网络里小包的数量,从而提升网络性能。

再看看DelayedAcknowledgment:

假如需要确认每一个包的话,那么网络中将会充斥着数不胜数的ACK,从而降低了网络性能。为了解决这个问题,DelayedAcknowledgment规定:不再针对单个包发送ACK,而是一次确认两个包,或者在发送响应数据的同时捎带着发送ACK,又或者触发超时时间后再发送ACK。通过这样的规定,可以降低网络里ACK的数量,从而提升网络性能。

Nagle和DelayedAcknowledgment虽然都是好心,但是它们在一起的时候却会办坏事。下面我们举例说说Nagle和DelayedAcknowledgment是如何产生延迟问题的:

客户端需要向服务端传输数据,传输前数据被分为ABCD四个包,其中ABC三个包的大小都是MSS,而D的大小则小于MSS,交互过程如下:

首先,因为客户端的ABC三个包的大小都是MSS,所以它们可以耗无障碍的发送,服务端由于DelayedAcknowledgment的存在,会把AB两个包放在一起来发送ACK,但是却不会单独为C包发送ACK。

接着,因为客户端的D包小于MSS,并且C包尚未被确认,所以D包不会立即发送,而被放到缓冲区里延迟发送。

最后,服务端触发了超时阈值,终于为C包发送了ACK,因为不存在未被确认的包了,所以即便D包小于MSS,也总算熬出头了,可以发送了,服务端在收到了所有的包之后就可以发送响应数据了。

说到这里,假如你认为自己已经理解了这个问题的来龙去脉,那么我们尝试改变一下前提条件:传输前数据被分为ABCDE五个包,其中ABCD四个包的大小都是MSS,而E的大小则小于MSS。换句话说,满足MSS的完整包的个数是偶数个,而不是前面所说的奇数个,此时又会出现什么情况呢?答案我就不说了,留给大家自己思考。

知道了问题的原委,解决起来就简单了:我们只要设置socket选项为TCP_NODELAY即可,这样就可以禁用Nagle,如此一来,少了一个巴掌,问题自然就拍不响了。

如果大家意犹未尽,可以继续浏览:TCP Performance problems caused by interaction between Nagle’s Algorithm and Delayed ACK

希望本文能让大家在使用Memcached的过程中少走一些弯路。相对于Memcached,其实我更喜欢Redis,从功能上看,Redis可以说是Memcached的超集,不过Memcached自有它存在的价值,即便已呈颓势,但是:老兵永远不死,只是慢慢凋零。