15 September 2011

Lock-free memcache API

PHP
Доброго времени суток, хабражители!
Этот пост есть краткий конспект многих часов раздумий, каляк на бумаге, набросков кода и, в конце-концов, реально работающего кода в продакшене.
Наш сайт (и далее — просто сайт) активно использует мемкеш для горячих данных. Код, заполняющий мемкеш, может работать очень долго (0,5 секунд — это долго) и при этом пользовательские запросы успевают запустить ещё сотню процедур обновления. Последстия понятны, однако долго мы просто не могли их заметить на уровне общей нагрузки. Только когда мы увидели всплески времени на обслуживание некоторых запросов (от возросшей нагрузки они ещё и попадали в SLOW_QUERIES_LOG MySQL) — тогда и закипела работа.


Проблема наглядно


Рассмотрим подробнее сценарий запроса ключа от мемкеша, при котором ключ «протух» или не был ещё установлен.
Чтобы понять проблему я нарисовал небольшую диаграмму:

Workflow запроса данных

Логикой обновления управляет код, т.е. само приложение знает, если ему вернулось FALSE, то надо регенерировать ключ. Как видно из диаграммы, беда возникает в тот момент, когда данные по первому запросу ещё не успели «приготовиться», а мы уже спрашиваем те же данные.
Спасибо хабраюзеру evilbloodydemon за точное определение — ситуация называется «Dog pile effect».

Решение


Выдвигались многочисленные версии — как этого избежать.
Сначала мы подумали о системе локов и очереди обновлений. Но этот сценарий жуть какой медленный.
Потом подумали — если код умеет регенерировать данные — пусть этим и занимается. Надо всего лишь вернуть ему FALSE. И сразу же — переустановить старые данные. Итого: процедура обновления запустится один раз, а данные, которые будут возвращаться в приложение вплоть до конца процесса регенерации — «протухнут» всего лишь на время регенерации.
Для этого мы в мемкеш должны сложить не только сами данные, но и таймаут и время инвалидации ключа. В реальный мемкеш попадает массив на время вдвое большее (чтобы уж наверняка). В документации сказано, что наибольшее время хранения ключа — 30 дней. Т.е. достаточно положить в «обёртку» данные на 15 дней — 1 секунду для верности. Тоже самое касается и ключей с таймаутом = 0 (т.е. навсегда, пока не вытеснится). Ситуаций, когда данные в мемкеше нужны раз в 15 дней — я не встречал. Если у Вас такое произошло — что-то надо менять.
Также мы быстро заметили проблему с инкрементом. Пришлось договариваться, что все ключи инкремента заканчиваются на "_inc", например. И при обнаружении такого ключа мы просто достаём нужные данные, которые наинкрементил сам мемкеш. *Эту вилку я удалил ил метода Memcache_Proxy::get().

Код


Код документирован там где это надо :) Заранее извиняюсь за простыню кода, но сократить больше не получается.
class MC
{
    private static $_proxy;

    // Singleton for our class, extended of native Memcache class
    private static function _proxy()
    {
        if (is_null(self::$_proxy) || self::$_proxy->closed) self::$_proxy = new Memcache_Proxy;
        return self::$_proxy;
    }

    public static function get($key = '')
    {
        return self::_proxy()->get($key);
    }

    public static function set($key = '', $data = NULL, $flag = FALSE, $timeout = 3600)
    {
        return self::_proxy()->set($key, $data, $flag, $timeout);
    }

    public static function delete($key = '')
    {
        return self::_proxy()->delete($key);
    }

    public static function increment($key = '', $increment = 1)
    {
        return self::_proxy()->increment($key, $increment);
    }
}


Класс МС нужен для общени с одним экземпляром мемкеша внутри всего кода без необходимости явно объявлять подключение к мемкешу. Оно создастся при первом обращении к нужному методу в этом классе.

class Memcache_Proxy extends Memcache
{
    public $closed = false;

    public function __construct()
    {
        $this->connect(MEMCACHE_HOST, MEMCACHE_PORT, null);
        $this->closed = false;
    }

    function __destruct()
    {
        $this->close();
        $this->closed = true;
    }

    /**
     * Mirror for $memcache->get() method
     */
    public function get($key = '')
    {
        if (empty($key)) return FALSE;

        $data = parent::get($key);

        if ($data !== FALSE && $this->_is_valid_cache($data))
        {
            if (!isset($data['_dc_cache'])) $data['_dc_cache'] = NULL;
            //check lifetime
            if (time() > $data['_dc_life_end'])
            {
                //expired, save the same for a longer time for other connections
                $this->set($key, $data['_dc_cache'], FALSE, $data['_dc_cache_time']);
                return FALSE;
            }
            else
            {
                //still alive
                return $data['_dc_cache'];
            }
        }
        return FALSE;
    }

    /**
     * Mirror for $memcache->set() method
     */
    public function set($key = '', $data, $flag = FALSE, $timeout = 3600)
    {
        if (empty($key)) return FALSE;
        // Place here "_inc" key check
        if (is_int($data) || $data === FALSE)
            parent::delete($key . '_increment');

        // Maximum timeout = 15 days - 1 second
        if ((int)$timeout == 0 || (int)$timeout > 1295999) $timeout = 1295999;
        return $this->_set($key, $data, $flag, $timeout * 2);
    }

    /**
     * Mirror for $memcache->delete() method
     */
    public function delete($key = '')
    {
        if (empty($key)) return FALSE;
        // Magic for increment. Place here "_inc" key check
        parent::delete($key . '_increment');
        return parent::delete($key);
    }

    public function increment($key, $increment = 1)
    {
        $inc_value = parent::increment($key . '_increment', $increment);

        $data = parent::get($key);
        if ($data === FALSE) return FALSE;

        if ($this->_is_valid_cache($data))
        {
            if ($inc_value === FALSE)
            {
                $inc_value = $data['_dc_cache'] + $increment;
                parent::set($key . '_increment', $inc_value, FALSE, $data['_dc_cache_time'] * 2);
            }

            $time = $data['_dc_life_end'] - time();
            if ($time > 0)
            {
                $this->_set($key, $inc_value, FALSE, $time);
                return $inc_value;
            }
        }
        return $inc_value;
    }

    private function _set($key = '', $data, $flag = FALSE, $timeout = 3600)
    {
        $cache = array('_dc_cache' => $data, '_dc_life_end' => time() + $timeout, '_dc_cache_time' => $timeout);
        return parent::set($key, $cache, $flag, $timeout);
    }

    // Maybe we have pure Memcache data, not our array structure
    private function _is_valid_cache($value)
    {
        return (is_array($value) &&
            isset($value['_dc_life_end']) && isset($value['_dc_cache_time']) &&
            !empty($value['_dc_life_end']) && !empty($value['_dc_cache_time'])
        ) ? TRUE : FALSE;
    }
}


Примеры использования


Код, просто код. Если данные прокисли, запускаем генерацию возвращая FALSE только запросившему и переустанавливаем те же данные на то же время. Таким образом — следующий запросивший получит старые данные до тех пор, пока первый процесс не закончит генерацию и не выполнит MC::set() с актуальными данными. Сразу же после этого все процессы будут получать актуальные данные.

$data = MC::get('some_key');
if ($data === FALSE)
{
    // Может выполняться очень долго
    $data = huge_generate_func_call();
    MC::set('some_key', $data, FALSE, 3600);
}


Т.е. продолжаем пользоваться мемкешом как и раньше. Если существовала обёртка для обращения к мемкешу — можно поправить её и ВООБЩЕ ничего в коде приложения не трогать. Это, кстати, было одно из требований: минимальный рефакторинг для внедрения нового класса мемкеша.

Резюме


На накладные расходы по хранению таймстампа и таймаута можно закрыть глаза, память нынче дешёвая.
То, что данные «протухают» на величину времени, равную времени генерации данных — не смертельно и терпимо, однако новые потоки на генерацию одних и тех же данных не создаются. ЧТД!

P.S.
Предложения и замечания — приветствуются! Орфография — в личку, по существу — в комментарии!

UPD.
Товарищи минусующие — аргументируем свой выбор. Не все родились с талантами Пушкина и Страуструпа!

UPD. 2
Разбираемся с минусами:
1.
Класс МС нужен для того, чтобы в коде ничего не менять. Совсем. Замените его на имя своей обёртки над мемкешем, если такая есть. Если нет — большинство порядочных IDE поддерживают Refactor -> Change ClassName.
2.
Класс МС статический. Так вышло исторически — минимум рефакторинга — основное требование. Переделывать код для Хабра я не стал — основная идея там отражена.
Tags:PHPmemcachelock-free
Hubs: PHP
+41
4.3k 90
Comments 69
Top of the last 24 hours