Наверх ▲

Решардинг Redis "наживую"

Андрей Смирнов Андрей Смирнов Руководитель разработки, разработчик, фанат Go, Python, DevOps и больших нагрузок. Руководил разработкой backend-сервисов в стартапе Qik, после его покупки продолжил работать в компаниях Skype и Microsoft. До этого участвовал в разработке и руководил созданием таких проектов, как damochka. Василий Евсеенко Василий Евсеенко Старший разработчик web-команды Skype Moscow (ex-Qik).

 Андрей Смирнов: Добрый день, нас зовут Андрей Смирнов и Василий Евсеенко. Мы представляем компанию "Skype", наш московский офис. Мы сегодня поговорим о решардинге Redis «наживую».

Во-первых, мы расскажем о том, как мы используем Redis. Может быть, после этого станет понятно, как и почему мы делаем те или иные вещи. Мы представляем in-memory хранилище, в котором хранятся достаточно ценные данные, но не бесконечно ценные. Поэтому периодически стандартный функционал Redis делает дамп (англ. dump) своей базы на диск в фоновом режиме. Там нет никаких файлов типа "Append Only File", и так далее. Мы понимаем, что в периодах между этими дампами мы можем потерять текущие изменения. Мы к этому готовы, мы это осознаем. Это первый аспект.

Второй аспект. Чтобы вы могли просто представить масштаб того, о чем мы будем говорить, скажу, что сегодня у нас порядка 600 экземпляров Redis (отдельных экземпляров процесса) и порядка 240 гигабайт хранимых данных. Это позволит вам представлять, о чем идет речь.

Что такое решардинг?

У нас есть в конечном итоге один сервер Redis, и мы хотим разделить его на несколько серверов меньшего размера. Это наша задача. Почему мы написали «наживую»? Это означает «без остановки». Наша система продолжает работать, а мы это разделение осуществили.

Дам краткий план нашего доклада. Вначале мы чуть-чуть поговорим о подготовке к решардингу. Что нам нужно для этого понять? Как работает Redis, и какие подготовительные действия мы можем выполнить.

Потом поговорим об идее того, как это осуществить. Затем обсудим детали и проблемы функционирования, и так далее. Затем, если останется время, можем немного поговорить о том, как сократить использование памяти Redis.

Проведем аналогию с пирогом. Допустим, у нас есть пирог. Мы его нарезали на какое-то количество частей. 

При этом мы ожидали, что у нас будет какая-то романтическая вечеринка, и гостей будет мало-мало. Потом вдруг гостей набежало очень много, и мы поняли, что нам надо срочно что-то делать.

Мы начали "резать" куски нашего "пирога" еще раз. Но при этом "гости" их "едят". В это время мы куски "разрезаем", а "гости" продолжают есть, ничего не замечая. Но, в конце концов, у каждого свой "кусок".

Почему чаще всего приходится делать решардинг?

Либо нагрузка вырастает по какой-то причине, либо (чаще всего) причиной становится размер данных. Данные "распухают" настолько, насколько мы изначально не ожидали, не планировали и не хотели.

В любом случае, что бы там ни было, шардинг может осуществляться двумя способами. Либо у нас статическая функция, когда мы просто хешируем ключ по модулю количества шардов. Либо мы выбираем второй вариант: у нас есть некая таблица, которая динамически определяет то, что нам нужно… Нам необходимо каждый раз выполнять команду "lookup" для каждого ключа в соответствии с тем номером сервера, где он находится.

То, что шардинг помогает при масштабировании, всем понятно. Он немного увеличивает доступность в той ситуации, если у нас запрос локализован на одном шарде. Если у нас один экземпляр Redis или шард выходит из строя, какая-то часть запросов получает статус "failed", остальные работают успешно. Это один вариант. 

Ортогональный по отношению к этому вариант. Если у нас запрос затрагивает все шарды (допустим, у нас их много), за счет параллелизма мы можем уменьшить время отклика. Мы можем отправить запрос одновременно на кучу узлов. Параллельно каждый из этих запросов будет меньше. Получить результаты удастся чуть-чуть быстрее. Но при этом мы теряем в доступности.

Чаще всего, когда мы начинаем, у нас один сервер и мало опыта. Потом мы понимаем, что, скорее всего, сервер будет не один, и что-то закладываем на будущее. Может быть, мы заранее подумали, что серверов будет больше.

Но на практике серверов обычно становится еще больше, чем мы предполагали. Получается, что с шардами постоянно надо что-то делать, куда-то их перемещать. Это выходит из строя, там схема "ведущий-ведомый" (англ. master-slave), там новый сервер, тут памяти не хватает, тут диски медленные, тут надо в другой дата-центр переезжать… Начинаются постоянные перемещения. Достаточно тяжело делать это вручную или постоянно обо всем помнить, чтобы наше приложение продолжало работать корректно.

Virtual Buckets как средство упросить работу

Можно попробовать немного облегчить эту ситуацию для себя. Можно заранее предусмотреть себе некий уровень абстракции, который поможет дальше управлять этим набором шардов. Это идея Virtual Buckets. Она не новая, не нам принадлежит, но достаточно удобна и очень поможет в нашей ситуации.

Идея очень простая. Будем для себя виртуально представлять, что серверов у нас много. Например, 4 тысячи. У меня на слайде нарисована табличка на 20 серверов. Но реально серверов у нас гораздо меньше. У меня в примере 6 реальных серверов, которые расположены внизу, и таблица Virtual Buckets, которая занимает 20 ячеек. В каждой ячейке этой таблицы написан номер сервера. 

При этом мы хешируем наш изначальный ключ не на реальные серверы, которых 6, а в ячейки этой виртуальной таблицы. Мы хешируем ключ на каждую из них. Таблица Virtual Buckets на самом деле реальная. 

Если серверов станет больше, функция хеширования не изменится. Мы просто поменяем какие-то элементы в таблице Virtual Buckets. Там, где был сервер 1, станет 1.A, а второй 1.B и так далее.

Плюс к тому есть нижняя полоска из квадратов под каждым сервером. Мы можем хранить некое состояние этого Virtual Bucket. Например, если у нас сервер вышел из строя, мы можем пометить, что он вообще недоступен. При обращении к нему мы должны давать полный отказ. Или, например, у нас была обеспечена отказоустойчивость, и сервер доступен только на чтение. 

Если мы находимся в процессе разделения какого-то одного сервера (под №3, например) на несколько серверов, мы будем делать это разделение не случайным образом. Мы возьмем те Virtual Buckets, которые находятся на сервере 3. Половину из них мы унесем на один сервер, половину – на другой. 

Мы можем прямо в процессе этого перемещения делать в этой таблице какие-то особые пометки, зная, что один Virtual Bucket уже "переехал", а другой "поедет" следующим. Мы можем эту карту менять прямо на ходу. Можно предусмотреть ее изначально. Это простой маленький слой, ничего не стоит ее сделать. Но потом она может очень сильно помочь.

Пара слов о Redis

Я думаю, все представляют, что такое Redis. Мне наиболее близко его сравнение с Memcache. Это in-memory хранилище "ключ-значение" (англ. Key-Value storage). Memcache, как мне кажется, представляет собой RISC-процессор, какой-нибудь типичный UltraSPARC, у которого совсем маленький, но достаточный для решения задач набор операций. Redis – это, скорее, CISC-процессор, в котором сумасшедшее количество возможных функций, операций, типов данных. Это два диаметрально противоположных варианта. С точки зрения скорости работы, он такой же быстрый.

В чем отличие от Memcache?

У него есть хоть какая-то персистентность. Он необязательно атомарный, это необязательно ACID, это не база данных. Но данные он все-таки умеет сохранять. Хотя бы тот фоновый дамп, которым мы активно пользуемся, это уже большая помощь.

Есть и возможность устроить репликацию, возможность иметь конфигурацию "ведущий-ведомый" (англ. master-slave). Это нам очень сильно поможет. Об этом мы поговорим дальше.

 

Помимо вопроса «зачем шардить вообще», в Redis есть вещи, которые "говорят", что шардить нужно. Redis построен по сетевой модели асинхронного ввода-вывода. Соответственно, он тоже использует максимум одно ядро процессора, поэтому, в общем-то, неразумно на одном сервере запускать один Redis. Это первая причина.

Вторая причина – размер данных Redis не может превышать размер оперативной памяти. Она конечна, поэтому тоже имеет смысл шардить. У Redis была некая фишка под названием "виртуальная память" (англ. Virtual memory). Никогда не пытайтесь ею пользоваться. Заявлялось, что она якобы позволяет иметь подкачку (англ. swap) и обслуживать больше ключей, чем доступно памяти. Но в действительности она не работает. Очень большая деградация производительности.

Третий момент. Очень часто даже на 64-битных машинах Redis "гоняет" 32-битные конфигурации. Зачем это нужно, поговорим позже. Но в этой ситуации объем доступной одному процессу памяти ограничен тремя гигабайтами. Соответственно, это еще меньше, чем объем доступной памяти. Шардить нужно еще больше.

Вообще типична ситуация, когда на одном сервере запускается 256 экземпляров Redis. Никакой проблемы в этом нет при условии, что по мере роста объема данных мы можем эти экземпляры Redis потихоньку выносить на другие машины, например.

Но наш доклад о том, что мы не рассчитали, и вначале создали кластер Redis слишком маленького размера. Нам его не хватает, и мы хотим поделить его на еще более мелкие кусочки.

Чтобы подойти к этой идее, надо немного поговорить о том, как работает репликация в Redis. На этой картинке голубым обозначены экземпляры Redis. Буквой S обозначен ведомый (англ. slave), буквой M – ведущий (англ. master). 

Репликация

Репликация состоит из трех этапов. На первом этапе ведомый обращается к ведущему, посылает сигнал о том, что он ведомый для него. Ведомый изначально пустой. Ведущий тут же запускает процедуру фонового сохранения. Он, на самом деле, "форкается", используется Copy-On-Write и так далее.

В процессе этой работы он создаст на диске дамп своей памяти на тот момент, когда появился ведомый. Это RDB-файл. Параллельно с этим ведущий начнет записывать все команды на изменение данных, которые к нему пришли с этого момента. Он делает это совершенно атомарно после того, как он начал сохранять. 

Когда он сделает свой дамп, он начинает этот дамп перекачивать на ведомый. Дамп идет к ведомому, посылаются команды, он все продолжает записывать. Затем, когда дамп полностью перекачан, он начинает выполнять сперва команды из буфера, которые он записал, а потом те, которые к нему приходят, передавать ведомому. Ведомый выполняет их у себя. 

Изначально у них одинаковое состояние. RDB-файл определяет их начальное состояние. Потом приходящие команды их одинаково модифицируют. В реальности он отправляет не ту команду, которая пришла от клиента прямо один в один. В некоторых ситуациях ему нужно ее несколько модифицировать, чтобы сохранить корректность. Но в конечном итоге состояние обоих экземпляров Redis корректно. 

Так работает репликация.

Наша идея совершенно проста. Мы встраиваемся в этот процесс репликации. Вот наш изначальный шард (слева на слайде), который содержит много-много данных. Вот будущий шард (справа на слайде), изначально он пуст. Мы хотим, чтобы он содержал какую-то часть изначального набора ключей (условно, одну четверть).

В этот процесс репликации мы встраиваемся собственным прокси, который для этого ведомого выглядит как ведущий, а для этого ведущего выглядит как ведомый. Ни один экземпляр Redis не замечает, что мы тут что-то подменили. Этот прокси фильтрует протокол репликации таким образом, что до этого ведомого экземпляра доходят только те ключи, которые нужны. 

В данной ситуации есть еще дополнительное упрощение. Мы используем те самые Virtual Buckets, о которых я говорил раньше. Очень удобно прямо в имени ключа закодировать номер того Virtual Bucket, к которому он относится. Это можно сделать прозрачно на уровне драйвера доступа к Redis.

У нас на изначальном Redis лежало 256 Virtual Buckets. Нам надо разделить их на 4 части по 64 Virtual Buckets (мы знаем их номера). Соответственно, некий набор префиксов ключей мы должны пропустить через этот шард. Он будет содержать одну четверть.

С точки зрения обобщения, эта идея подходит не только для Redis. Был бы такой протокол репликации, в котором передается начальное состояние, а потом все-все-все изменения. Чтобы каждый раз нам было достаточно имени ключа, чтобы определить конечный шард. У Redis все команды устроены так, что ключ там есть. Не может быть такого, что мы по команде (кроме команды FLUSH, которая все очищает) не поняли бы, к какому ключу эта команда относится, на какой шард нам ее пропускать или не пропускать.

 

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

Теперь попробуем от этого микроуровня подняться на макроуровень. Допустим, мы все это реализовали, процесс у нас отлажен, все работает.

На слайде – наше приложение. Слева наш старый кластер экземпляров Redis, он маленький. Мы хотим увеличить его, просто увеличив количество экземпляров в нем. Справа у нас новый кластер, в нем гораздо больше экземпляров, но каждый из них меньшего размера. 

Чтобы осуществить этот замысел, мы применили какое-то количество тех самых прокси (уровень проксирования). Изначально наше приложение работает со старым кластером (оно с ним и работало). Через уровень проксирования данные перетекают в новый кластер. В новом кластере у нас те же самые данные, только они разделены по большему количеству экземпляров Redis. 

Есть наше приложение. Понятно, что в какой-то момент мы должны переключить все запросы нашего приложения со старого кластера на новый, но чисто технически мы не можем осуществить это мгновенно. Если приложение представляет собой один процесс, у нас всегда будет некая задержка, когда одна часть экземпляров приложения работает со старым кластером, другая – с новым. 

Мы могли бы между приложением и Redis поставить некий прокси, через который мы бы осуществили переключение. Но если он будет в единственном экземпляре, то это будет точкой отказа. Если их будет много, то будет та же самая проблема: переключить их все одновременно нельзя.

Вопрос сейчас стоит в том, как переключить. При условии, что данные замечательно перетекают, все хорошо, все нормально. Но эта ситуация динамическая.

Как осуществить переключение, чтобы это было корректно? 

Действительно ли возможно то, о чем мы заявили в начале доклада? Возможно ли «наживую» осуществить эту процедуру решардинга?

Мне кажется, что ответ состоит в том, какие именно данные хранятся и как они используются. Я могу привести как минимум два случая, когда это будет корректно и некорректно.

Пример корректного решардинга. Часто при использовании Redis мы храним счетчик просмотров и счетчик обращений к каким-то объектам. Для каждого объекта есть ключ, в котором хранится число просмотров, например. Когда происходит просмотр, мы инкрементируем этот ключ. Когда надо показать число просмотров, мы просто получаем его командой get из Redis. Такой элементарный сценарий.

В случае если у нас происходит переключение со старого кластера на новый, то возможна ситуация, когда команды-инкременты идут как на старые узлы, так и на новые. Но в конечном итоге (в чем прелесть Redis) эти инкременты просуммируются, и в новом кластере окажется полное число просмотров. При том, что мы читаем эти просмотры. Если мы будем их читать в момент этого переключения, мы можем получать неполное значение. Мы можем читать из старого кластера, а уже часть эземпляров инкрементирует его в новом, в старом просто этого не видно. Это будет какая-то неконсистентность на момент переключения. Но сами по себе данны о числе просмотров не потеряются.

Или возьмем второй пример (он тоже касается использования Redis). Там можно создать числовой ключ, значение которого мы инкрементируем и каждый раз используем как автоинкрементное значение. Мы хотим, чтобы оно было уникальным. Это является частью нашей идеи.

Допустим, у нас этот ключ находится в старом и в новом кластере (пусть сейчас они находятся в правильном синхронизированном состоянии, а ключ имеет одно и то же значение – например, 5 в старом и 5 в новом). Что случится, если у нас произойдет одновременное обращение приложения? Одна часть, первое приложение пойдет в старый кластер, сделает инкремент. Вторая часть пойдет в новый кластер, потому что конфигурация обновилась, и сделает там инкремент. Они оба получат значение 6, что само собой некорректно. Мы ожидали, что там будет 6,7.

Например, для такой задачи без атомарного переключения ничего не получится, и нам нужно либо как-то деградировать функциональность приложения, либо сделать что-то еще.

Еще один вариант – порассуждать о множестве. Я этого делать не буду. Можете просто сами подумать об операциях с множеством. У Redis (я, может, об этом не упомянул) с его богатым набором типов данных (строки, числа, целые и вещественные, массивы, множества, сортированное множество и так далее) предусмотрен и богатый набор операций. Например, добавить элементы в множество (множество не содержит дубликатов), удалить элементы из множества, получить, пересечь два множества и записать результат этого пересечения в третье.

Интересный вопрос: возможно ли это переключение «наживую», в зависимости от того, какие данные вы храните, какими операциями вы пользуетесь?

На этом я свою часть заканчиваю, передаю слово коллеге. 

Василий Евсеенко: Для начала я немного расскажу про то, как Redis устроен.

Redis – это хранилище "ключ-значение", где ключ – это строка, а значение – это либо строка, либо список, хеш или множество от строк. Протокол Redis текстово-бинарный. Что это значит? У него все управляющие конструкции, в том числе размеры полей, в виде ASCII-текста, а сами поля могут быть бинарными. На слайде изображен протокол версии 2. Есть еще протокол версии 1, который очень похож на протокол Memcache. Но он не позволяет задавать, например, ключ, содержащий пробелы.

Также Redis может сохранять свое состояние на диск. Делает это он в виде снимка (англ. snapshot). Как уже было сказано, он умеет делать в какой-то момент времени форк. Дальше содержимое своей памяти он "сливает" на диск в формате RDB.

О формате RDB

В отличие от клиент-серверного протокола, формат RDB не документирован. Он вообще никак не документирован, кроме как в исходниках Redis. RDB-файл создается однократно, как снимок, и в дальнейшем не модифицируется. Каждый раз, когда Redis делает новый снимок, он его заново пересоздает.

Мы попробуем разобраться, как устроен внутри RDB.

Как оказалось, формат у него достаточно простой. Заголовок, а дальше идет последовательность элементов (англ. item). Элемент – это пара ключ-значение.

Каждый элемент имеет тип, который задает тип того, чем является значения. В случае строки у нас структура довольно простая.

В случае чего-то более сложного получается древовидная структура, где строки кодируются так же, как в языке Паскаль. Вначале у нас идет счетчик. Количество символов в строке. Строки у нас могут содержать нулевые байты.

Протокол репликации Redis

Протокол тоже очень простой. Он представляет собой надмножество клиент-серверного протокола. Сначала у нас ведомый дает запрос SYNC к ведущему. После этого ведущий делает RDB-файл и отдает его, префиксируя его размером. Поскольку файл RDB бинарный, мы должны знать его размер, чтобы определить, где он заканчивается.

В момент создания RDB-файла Redis начинает запоминать команды "update", которые приходят от клиентов, которые дальше этот файл RDB патчат. После того как ведущий "отгружает" ведомому RDB-файл, он начинает выдавать команды. Сначала те, которые были у него в очереди, потом те, которые приходят в реальном времени. 

Также раз в 10 секунд выдается ping, чтобы ведомый мог понять, что ведущий еще "жив".

Наша задача фильтрации довольно простая. Сначала мы должны отфильтровать RDB-файл. Из всех тех ключей, которые там есть, выбрать только те, которые нужны для данного ведомого.

После этого мы фильтруем команды.

С фильтрацией RDB есть некоторая проблема: мы должны указать размер RDB до фильтрации. Но мы не знаем, что у нас получится после фильтрации. Есть два варианта.

Первый вариант простой. Просто сохранять на диск, фильтровать. Дальше, когда мы знаем результирующий размер, мы можем уже его указать и RDB-файл "положить" в сокет.

Но мы использовали более красивый вариант. В Redis есть так называемые пустые элементы. Аналог инструкции "NOP" в процессоре. Они имеют длину в один байт "0xff". Собственно, тип элемента у нас "0xff". После TBL он признает, что он пустой. Можно просто "добить" наш отфильтрованный RDB-файл до исходного размера. Мы можем все делать поточно, ничего не писать на диск и сильно увеличить скорость.

Что собой представляет наш кластер? Это набор шардов, где каждый шард состоит из ведущего и ведомого серверов. Ведущий на одной машине, ведомый – на другой. На одной машине может быть несколько шард. Ведущий и ведомый всегда разделены.

Как уже было сказано, фильтрация у нас работает через прокси, который для ведущего прикидывается ведомым. Соответственно, новый шард прикидывается ведущим. 

Теперь про то, как мы все это делали. Мы используем Python и Twisted.

Почему мы используем Python?

Потому что нам важна скорость разработки. Например, для такой задачи, как решардинг, так как мы решардинг производим не особо часто, его время не сильно критично. Будет это занимать день или час, нам некритично. Критично – сколько мы потратим времени, чтобы написать прокси. Например, его написание вместе с тестированием у нас заняло несколько дней. Если бы мы писали на "Си", на C++, хорошо бы, если бы это заняло неделю.

Почему Twisted?

Те, кто работает с Python, знает, что в там самая большая проблема – это блокировка GIL (Global Interpreter Lock). В классическом CPython виртуальная машина не многопоточна. Даже если у нас несколько потоков, то одновременно у нас активен только один. Все остальные ждут этой глобальной блокировки. 

Если мы хотим писать что-то на Python, то надо это делать либо в отдельных процессах (например, используя мультипроцессинг), либо делать это асинхронно. Асинхронно это можно делать вручную, но это очень тяжело. Можно использовать такой фреймворк, как Twisted, который позволяет писать асинхронные приложения очень просто.

Из собственного опыта: поначалу не представляешь, «как вообще во всем этом можно разобраться, как это все работает». Порог вхождения в Twisted довольно высокий. Но, разобравшись с этим, понимаешь, что теперь можно написать приложение, которое будет работать одновременно с десятками тысяч активных сокетов и делать это ничуть не менее эффективно, чем то же самое приложение на Си или C++.

Как все устроено внутри?

У нас есть Twisted-процесс. Twisted представляет собой паттерн под названием цикл событий (англ. Event loop) или реактор (англ. Reactor). Мы в цикле получаем событие, обрабатываем его, потом получаем новое событие и так далее. Мы использовали в качестве основы модуль Twisted под названием TX Redis, который реализует клиентский протокол Redis. Поскольку это протокол-репликация, он практически целиком повторяет клиентский протокол. Мы его модифицировали, добавили фильтрацию RDB. Когда определяется новый шард, наш прокси прозрачно передает на ведомый сервер запрос SYNC, а дальше фильтрует получившийся в результате RDB-файл и команды. 

 

Наши результаты

Как уже говорилось, Twisted позволяет нам писать довольно быстрые приложения. Например, у нас одна машина с 16-ю экземплярами Redis (24 гигабайта памяти) шардится на 16 новых машин где-то за один день.

Если бы мы это писали на языках более низкого уровня, это работало бы быстрее. Но в данном случае для нас критична скорость разработки. Писать неделю или две, чтобы потом работало час, когда все будет запущено. Или писать два дня, и оно будет работать день, для нас это более выигрышно.

Дальше. Кроме CPython, есть другие интерпретаторы. Есть Jython, есть IronPython, есть PyPy. PyPy – это достаточно новый проект, он представляет собой интерпретатор Python на Python. Сначала может показаться, что это ерунда. Но, кроме этого, он содержит в себе Jit-компилятор. Используя PyPy с включенным Jit, мы можем в 4 раза обойти по скорости стандартный CPython, который написан на "Си".

Также объясню, почему было сказано про 32-битные экземпляры Redis. Если использовать 64-битный Redis, когда у нас данных много, но все элементы маленькие, то мы начинаем тратить по памяти в два раза больше из-за того, что у нас указатель более длинный. На 64-битной машине мы используем 32-битные экземпляры Redis.

Андрей Смирнов: У нас есть немного времени. Продолжим тему памяти в Redis. Redis по сравнению с Memcache устроен очень просто. Для всего, что он хранит, он делает Malloc. Если это строчка в 4 байта, он все равно будет делать Malloc, чтобы ее сохранить. 

Это, вообще говоря, кажется совершенно сумасшедшей вещью. Автор Redis обнаружил, что у него на хранении данных служебные структуры, которые нужны библиотеке "Си", чтобы поддерживать работу Malloc, занимают чуть ли не больше места или, по крайней мере, сопоставимы с тем размером полезных данных, которые он хранит. К слову скажу, что внутренняя организация памяти Memcache совершенно другая. 

Но оказалось очень интересно, что к моменту, когда был написан Redis, библиотеки Malloc дошли уже до таких высот, что при использовании современных вариантов Malloc (новая версия Redis 2.4 по умолчанию использует внутри себя свой Malloc) применяются оптимизации, которые пришли из реального опыта работы с ним и которые позволяют это убрать. Никакой проблемы нет.

Но все равно Redis внутри хранит много указателей. Это приводит к тому, что компиляция 32-битного Redis уменьшает использование памяти. Это уменьшает использование памяти – сборка с правильным Malloc в Redis 2.4 идет по умолчанию, никаких проблем больше нет. В Redis 2.4 уменьшенное использование памяти при Copy-On-Write. Это может быть существенно, если сервер уже загружен по памяти. Эти фоновые сохранения в старых версиях Redis, даже когда он сохранял в фоне, этот экземпляр, который вроде как пишет дамп на диск, не должен был бы ничего в своей области памяти менять. Но он, к сожалению, менял. Из-за этого возрастало требование по памяти в этот момент записи. В Redis 2.4 это поправили.

Еще один важный момент, на который мы тоже натолкнулись совершенно случайно (просто об этом не всегда задумываешься). Redis пытается сохранить значение, которое в нем находится, оптимально. В том числе в памяти и в RDB. Если мы храним в Redis число (хотя мы по протоколу передаем всегда как строчку, с точки зрения передачи всего протокола), но если он видит, что это число, он будет сохранять его как число. Это может существенно уменьшить объем памяти, который нам нужен. По сравнению с сохранением строкового представления того же самого числа.

То же самое относительно float. Что такое список в Redis? Он вообще формально должен храниться в памяти как связанный список элементов. Если элементы маленькие, то это служебные структуры на указатели к следующим. Обычный связанный список. Он довольно большой. Но если список маленький (10 элементов), он будет хранить его компактно.

То же самое относится к множествам или хешам. Он может хранить его либо как хеш, либо в компактном представлении. Это зависит от размера структуры данных. Когда вы работаете с ним или пытаетесь что-то в нем померить, надо смотреть на это внимательно. Такие аспекты иногда имеют большое значение.

Наверное, на этом все. Мы готовы ответить на ваши вопросы с удовольствием.

Вопросы и ответы

Вопрос из зала: Первое замечание небольшое. Про тот фикс с Copy-On-Write. Он, по-моему, был в 2.2.8. Он уже есть в версии 2.2.

Второе. Ваша система с Virtual Buckets…

Андрей Смирнов: Только она не наша.

Вопрос из зала: Она у вас реализована.

Почему у вас одна нода из сервера 2 красная?

Андрей Смирнов: Не знаю. Потому что, например, я решил ее переместить или что-нибудь еще.

Вопрос из зала: То есть вы можете перемещать.

Андрей Смирнов: Это просто пример. Причина может быть…

Вопрос из зала: Окей. Вопрос, собственно, не в этом. Предполагает ли это использование какого-то поискового сервиса? Как это у вас реализовано? У вас есть сервис, в котором вы постоянно запрашиваете местонахождение Virtual Bucket?

Андрей Смирнов: Хороший вопрос. На самом деле, мне кажется, тут разные этапы. Я просто хотел сказать, что если вначале не предусмотреть Virtual Buckets, потом будет тяжело. В самом простом варианте Virtual Buckets просто хранятся в памяти. Это просто массив. Забудем про эту нижнюю часть. Ее очень легко хранить, строить память. Не надо даже ничего делать. Это элементарно. Но это уже дает тот уровень абстракции, который позволит дальше эту штуку вынести в конфиг.

Следующий уровень: мы эту карту вынесем в какую-либо сетевую штуку, которая позволит нам ее состояние держать синхронно между каким-то количеством наших приложений. Ее можно держать в том же самом Redis и по PupSub подписываться на ее изменения, чтобы все приложения "узнавали", что какой-то Virtual Bucket больше недоступен, и у себя это обновляли.

Эта карта маленькая, с ней можно что-то сделать. Но, прежде чем городить этот огород, разумнее начать с того, что в ней этот уровень есть. Это самое главное. Его можно смело делать большим. 4 тысячи, 8 тысяч Virtual Buckets Никаких проблем не будет, но это позволит отлично работать уже на следующем уровне.

Вопрос из зала: Дополняющий вопрос. Ты говоришь: «Хранить это в Redis и подписываться по PupSub». Но это если у нас Twisted и какой-то такой демон. Если у нас обычный обработчик на PHP и Apache, и он синхронный, то мы по PupSub ничего не сможем получать.

Андрей Смирнов: Можно в начале обработки запроса получить ее. Или получать ее каждую секунду, каждые 500 миллисекунд, если прошло больше, чем… Всегда можно придумать какую-то схему.

Вопрос из зала: Ну, да. Задержка и все такое.

Андрей Смирнов: Задержка, допустим.

Вопрос из зала: Точка отказа.

Андрей Смирнов: Можно в 10-ти экземплярах Redis хранить, читать из любого. Любая система, которая будет устойчива к сбоям, становится сложнее. Без вопросов.

Вопрос из зала: Скажите, пожалуйста. Если у вас Redis работает с гигабайтом памяти, с какой частотой можно сохранять данные на диск, чтобы он не замедлял работу? Адекватное сохранение интересует.

Андрей Смирнов: Во-первых, он никогда два раза одновременно не запустит фоновое сохранение. Это само собой. Конфигурируется он обычно по количеству изменений. У него комбинации количества изменений и времени, но можете настроить его так, чтобы он сохранял после каждого изменения. По сути, эффективно: он закончит первое сохранение, тут же начнет следующее. Никакой проблемы нет. Но он пишет на диск последовательно, из фонового процесса. Этот RDB-файл пишется не случайным образом. Если у вас на машине один Redis, то, ради Бога, хоть постоянно сохраняйте.

Вопрос из зала: То есть это не будет замедлять работу?

Андрей Смирнов: У Redis – нет, потому что это будет обмен с диска.

Вопрос из зала: Сколько примерно по времени будет происходить одна запись?

Андрей Смирнов: Сколько примерно гигабайт записать на диск?

Вопрос из зала: 1 гигабайт.

Андрей Смирнов: Сколько записать 1 гигабайт на диск, столько и будет. Операций у него в памяти почти нет.

Вопрос из зала: Почему вы выбрали Twisted вместо Gevent, например?

Василий Евсеенко: Twisted позволяет делать более удобные штуки. В Twisted есть такая вещь, как "отложенность". Во-первых, Twisted позволяет работать над немодифицированным Python.

Андрей Смирнов: Еще название более благозвучное.

Вопрос из зала: Ваш инструмент будет очень полезным, если его выложить в общий доступ. Он уже достаточно зрелый? Я имею в виду прокси-инструмент, который фильтрует данные "на лету".

Василий Евсеенко: Да, он достаточно зрелый. Но, к сожалению, не мы решаем, что мы можем выложить.

Андрей Смирнов: Из хороших новостей: мы хотим. Но это сегодня не решение Open Source. Соответственно, вопрос в преодолении административных барьеров. Я не думаю, что здесь есть какая-то принципиальная проблема. Это просто вопрос того, что надо получить на это разрешение.

Вопрос из зала: Вопрос к Василию. RDB вы все-таки фильтруете? Или вы с помощью NOP "забиваете"?

Василий Евсеенко: Нет. Как устроен RDB? Это набор элементов. Каждый из них "ключ-значение". Мы просто по нему идем последовательно…

Реплика из зала: Нет, вы рассказывали, что у вас тут два варианта.

Василий Евсеенко: Да, мы "забиваем" с помощью NOP’ами.

Вопрос из зала: Зачем? Может, вообще не надо ничего трогать?

Андрей Смирнов: Нет, смотрите. Вот идет RDB-файл по протоколу репликации (грубо говоря, 1 гигабайт). Если мы шардим в 4 раза, то он в процессе фильтрации уменьшится в 4 раза. Он будет иметь размер 256 мегабайт примерно. Если у нас ключи равномерно распределены.

Реплика из зала: Вам нужен точный размер файла…

Андрей Смирнов: Секундочку. Это мое первое соображение. Но протокол репликации устроен так, что мы вначале должны сказать точный до байта размер RDB-файла. Поэтому мы тупо говорим то, что нам прислал изначально ведущий сервер. RDB-файл не может увеличиться в процессе фильтрации. Мы указываем размер, который прислал ведущий, фильтруем ее. Потом у нас, в конце концов, оказывается, что еще 750 мегабайт мы не передали. Мы фильтровали, 256 мегабайт записали. Ведомый наш ждет…

Реплика из зала: В процессе фильтрации считайте, сколько вы выкинули.

Андрей Смирнов: Мы считаем, скорее, сколько мы записали. У нас еще осталось сколько-то, сколько мы не передали. Вместо того чтобы что-то делать, мы просто передаем несколько NOP “0xff”.

Вопрос из зала: Все целиком?

Андрей Смирнов: Все остальное с помощью NOP добиваем до размера. Ведомый замечательно их прочитает. NOP он проигнорирует, все хорошо. 

Вопрос из зала: Вы размер уменьшаете тем самым?

Андрей Смирнов: Размер RDB-файла формально остается тем же самым, но конец ее "забит" некоторым количеством NOP.

Вопрос из зала: Много служебных структур? Можно вообще не фильтровать, просто передавать и все.

Андрей Смирнов: Тогда у нас не получится решардинга.

Вопрос из зала: Почему не получится?

Андрей Смирнов: Все ключи передадим. Наша задача – передать только те ключи…

Реплика из зала: Размер тот же самый же получится.

Андрей Смирнов: Нет. Несколько NOP не приведут к увеличению размера. Объем хранимых данных в Redis меньше. Несколько NOP ни к чему не приводят.

Вопрос из зала: Извините, я хотел бы дополнить предыдущего спрашивающего. Если предложенную им концепцию «передавать все» дополнить патчем Redis в десяток строк, который бы научил его обрабатывать только часть данных, а часть данных выкидывать. Вам не кажется, что это сделало бы сегодняшний доклад куда более коротким?

Андрей Смирнов: Нет, мне не кажется.

Вопрос из зала: Поясните, чем такой подход плох.

Андрей Смирнов: Нет, он ничем не плох, ни в коем случае. Можно прокси, о котором мы рассказывали, включить в Redis как часть Redis, и все будет замечательно. Более того, сейчас делают Redis Cluster, в котором будет и решардинг, и поддержка отказоустойчивости, и так далее. Это то, что находится в разработке. Мы говорим о сегодняшней версии Redis, для которой проблема решардинга актуальна. Если Redis Cluster будет классным, весь этот доклад можно вообще выкинуть.

Мы начали говорить о том, что, в принципе, мы могли это реализовать на "Си", без вопросов. Вопрос был во времени разработки. Это заведомо разные задачи. Модифицировать Redis не то чтобы плохо. Это просто некие дополнительные затраты на последующую поддержку этого патча. По мере того как Redis будет развиваться, нам надо будет добиваться того, чтобы наш патч все еще на него накладывался и работал.

Вопрос из зала: Тот же вопрос про RDB-файл. Почему нельзя сделать четверть… Если вы на четверть сделали пессимистично, то все остальное можно добить командами set... или как они называются?

Андрей Смирнов: Какая разница?

Реплика из зала: Разница, что вы не будете передавать 750 мегабайт по сети в вашем примере.

Андрей Смирнов: Мне кажется, это займет пару секунд. Это не так…

Вопрос из зала: Пару секунд?

Андрей Смирнов: Ну, десяток секунд. Сколько займет передача этих NOP? Redis их просто читает, он ничего с ними не делает.

Реплика из зала: Нет, я понимаю, что Redis просто читает.

Василий Евсеенко: Тут еще другая проблема. У нас на входе есть RDB-файл. Если мы хотим его разобрать на команды set, нам нужно будет его где-то сохранять на прокси, чего мы совершенно не хотим делать.

Вопрос из зала: Нет. Почему ты не можешь передать, сколько ты можешь, в RDB-файле, а то, что не влезло, передать командами set?

Василий Евсеенко: Потому что я должен в самом начале задать размер…

Вопрос из зала: Если ты скажешь "одна четверть" и сделаешь только четверть... То, что у тебя не влезет, обычно будет достаточно малым, ты просто передашь это командами set.

Андрей Смирнов: Зачем? Нет, можно. Можно вообще все так передать. Можно написать прокси, который будет читать заодно Redis. Можно придумать очень много сложных способов, я согласен. Просто зачем?

Реплика из зала: Я не вижу особенных сложностей здесь.

Василий Евсеенко: У нас здесь была основная задача – чтобы это работало быстро. 

Реплика из зала: Ладно, я понял. Быстро, так быстро.

Андрей Смирнов: Ситуация какая. Представьте. Куча серверов, мониторинг, все красное, недостаток свободной оперативной памяти, подкачка, периодически уже IOPS появляются. Ситуация критическая. Это пишется не в ситуации «чем бы нам еще заняться». Ситуация была не совсем академическая. Нужно было решить конкретную проблему.

Вопрос из зала: Единственное, чего я не услышал, это момент более или менее выверенного перехода. Это было бы интересно. Когда один экземпляр работает со старым кластером, другой – с новым. Если бы вы осветили эту проблему гораздо более глубже (миграция кластера), было бы гораздо интереснее, чем как распарсить RDB и вызвать этот файл.

Андрей Смирнов: Хорошо, учтем. Спасибо!

Комментарии

Нет ни одного комментария

Только пользователи могут оставлять комментарии

Возможно, вам будет интересно:

Дмитрий Сатин

Дмитрий Сатин

Советник министра в Министерстве связи и массовых коммуникаций Российской Федерации, евангелист юзабилити

Довольно интимный, интроспективный доклад о самомотивации не для всех. Дмитрий Сатин объясняет, "как не стереться в пыль".

Зоя Мишунина

Зоя Мишунина

Менеджер по маркетингу и PR ITmozg.ru

Зарплаты на рынке зашкаливают - специалистов не хватает. Разбираемся, почему так происходит.

Алексей Юрченко

Алексей Юрченко

Работает в области репликации баз данных с 2003 года.

Алексей Юрченко говорит об использовании синхронной репликации в глобальных сетях между географически распределенными узлами.