В данной статье я постараюсь вспомнить и описать все сложности и подводные камни, которые встречались на пути реализации задач, связанных с проектом Ответы, также расскажу и про архитектуру проекта.
Все началось с того, что мой контракт подошел к концу (в течение года я участвовал в работе над почтой Mail.ru). «Снова меня ждут приключения», — пронеслось в мыслях, — «новая страна, новая работа». Я пошел к своему начальству и в ходе обсуждений все-таки получил порцию приключений в виде интересной задачки — заняться проектом Ответы.
По состоянию на 15 октября 2012 года, проект посещали в среднем 3,5 млн. уникальных посетителей в сутки.
Подсчет велся по уникальным «Кукам» (Cookie), которые выдаются каждому посетителю. Если в браузере посетителя отключен прием cookies, то посетитель не засчитывается.Визитной карточкой проекта можно было назвать скрин, который, уверен, всем очень хорошо знаком
Набор используемых технологий выглядел следующим образом:
- CentOS 5 i386
- mod_perl под Апачем
- Nginx
- SQL — база данных
- раскладка кода в бой с помощью rsync
Архитектура:
имя | предназначение | cpu | ram, Gb | hdd | примечание |
primus | фронт | 2xE5504 | 8 | 2 x sata | точка входа, балансер |
alpha | бек | 2xXeon старые | 2 | 2 x scsi | на нем пускаем все кроновые скрипты |
argon | бек | 2xE5504 | 8 | 2 x sata | |
butan | бек | 2x E5506 | 4 | 2 x sata | |
luna | бек | 2xE5405 | 8 | 2 x sata | тут крутилась wap и m версия |
oktan | бек | 2xE5405 | 8 | 2 x sata | |
metan | бек | 2xE5506 | 4 | 2 x sata | |
propan | бек | 2xE5405 | 4 | 2 x sata | |
radon | бек | 2xE5504 | 8 | 2 x sata | |
neon | бек | 2xE5-2620 | 16 | 2 x sata | новый |
nitrogen | бек | 2xE5-2620 | 16 | 2 x sata | новый |
plasma | бек | 2xE5-2620 | 16 | 2 x sata | новый |
titan | база | 2 x E5620 | 96 | 4sata + 20sas | мастер |
buran | база | 2 x E5620 | 64 | 2 sata + 4 ssd | основная реплика |
maximus | база | 2 x 5110 | 32 | 2 sata + 4 scsi | реплика для админки |
minimus | база | 2 x 5110 | 16 | 2 sata + 4 scsi | реплика для админки |
coloss | база | 2 x 5130 | 8 | 6 scsi | база комментариев |
- нестабильность проекта
- специалистов в отделе эксплуатации для данной SQL-базы мало, когда падает база, то не всегда доступен тот единственный спец, который может понять, что и почему
- вечная проблема отставания реплик, решать ее некому
- разработка нового функционала затруднена, так как 90% логики внутри базы
Задача от руководства
Обеспечить стабильную работу сервиса и возможность его дальнейшего развития, в частности, запустить мобильную версию.Миграция на Mysql и вынос логики процессов в перлячку — решит большинство наших проблем.
Первым делом подняли мониторинг. Про него писал в статье (кстати,
переписал его на AnyEvent, производительность выросла с 50 до 240 тыс. строк в секунду).
Он открыл глаза на следующие вещи:
- Падаем каждый день!
- Огромный фон ошибок 504, в сутки примерно 50 тыс.
В чем причины 504?
- Медленный HTML-парсер, он делает свою работу при отображении странички вопроса, то есть на лету.
- Боты, которые скачивают контент.
- Яндекс и Гугл индексируют.
Пришло письмо из Яндекса:
Для otvet.mail.ru мы начали увеличивать нагрузку, но произвести более 200 запросов в минуту неВсплыла еще одна проблема: на балансере у нас стоит ограничение 1 запрос в секунду с одного IP, если его увеличить, то не справляемся с нагрузкой.
получается, так как начинаем получать HTTP 503 независимо от времени суток. Возможно ли
устранить это на Вашей стороне?
Поскольку для проекта Ответы поисковый трафик — это самое важное, то действовать нужно быстро. Все нервничают, обстановка накаляется =).
Из самого простого, легкого и дешевого — докупили 3 бекэнда: neon, plasma и nitrogen. Добавили оперативки в балансер primus), включили в nginx кеширование html для всех неавторизированных пользователей, которых примерно 40%. Ограничение на балансере увеличили до 15 запросов в секунду.
Падать каждый день перестали, Яндекс больше не жаловался на нас. Критичные проблемы решены, теперь можно подумать о задаче.
Забегая вперед, скажу, что в новом API, проблемы с медленным HTML были решены. Мы применили сишный самописный (Perl XS) парсер и парсили только при сохранении в базу. Для почты такое решение не подходит, потому что если кто-то находит XSS, то что бы перепарсить все письма, нужно много времени (5 петабайт данных), поэтому проще и быстрее в парсер добавить защиту и парсить на лету.
Миграция и мобильная версия
Миграция должна удовлетворять двум условиям: сохранение целостности данных и работоспособность проекта 24/7. Поэтому выключить проект и перенести данные — не получится.
Будем делать API, на основе которого будет все работать, как мобильная версия, так и будущая «большая». API будет читать только из мускуля, а писать в обе базы: старую и новую.
Для того, чтобы в MySQL доезжали изменения из старой базы, заведем очередь, в которую будем писать, какие сущности нужно синхронизировать (взять из старой и добавить или заменить их в новой).
Для этого всю базу нужно разбить на сущности. Например, задание на пересинхронизацию ответа, вопроса, пользователя, всех ответов пользователя и т.д.
Также нужно учесть, что обработчики этой очереди будут работать параллельно, так как поток задач на синхронизацию огромен. У нас в итоге успевало обработаться 12 форков.
Это все нужно для того, чтобы в любой момент времени можно было добавить задачи в очередь и полностью пересинхронизировать нужные сущности.
Итак, понеслась. 10 января вышел перловик Alex Q. Очередь реализовали в мускуле. Купили два сервера для мускуля (назвали «береза» и «сосна») и два фронта («барон» и «бурбон»). Накудрили мускуль мастер и реплику. Научили старую версию писать задания в очередь для пересинхронизации. Это был очень рутинный и мучительно долгий процесс. Нужно было найти каждое место в перлячке, которое пишет в старую базу, и подпереть его сответствующим заданием на пересинхронизацию.
В этот момент главное не забывать про следующие вещи:
- Cron-jobs внутри базы. Тут пришлось реализовывать аналог в новой версии, так как не хотели вмешиваться в код пакета внутри базы.
- Обычные крон скрипты. Для них мы написали аналогичные в новой версии.
- Ну и самое коварное, это — триггеры. В нашем случае оказалось, что боевая версия базы отличалась от dev-версии. Поэтому тщательно и скрупулезно проверяйте это.
Каждая из перечисленных выше ошибок стоила 14 дней. Почему так много? Об этом узнаете дальше.
Итак, старая версия умеет писать в очередь, проверяем нагрузку. Раскатываем старую версию сначала на 2 бека, потом еще на 4, смотрим на мускуль, все ок, накатили на все беки, нагрузку держит. Переходим к следующему этапу.
Написали обработчик очереди, который умеет форкаться и следить за чайлдами: если они падают, то он плодит новые. Он берет порцию заданий и пересинхронизирует их, превращая в нужную нам структуру. В будущем мы заметили, что из двух стратегий — запустить 12 форков, из которых каждый разгребает все виды заданий, — медленней, чем запустить 4 форка, которые выполняют задания А, 4 форка которые выполняют задание Б и т. д.
Объясняется это тем, что обработчик очереди помнит сущности, которые он пересинхронизировал за текущий проход, и пропускает их, если встречает опять, а при ограничении типов сущностей вероятность попасть в уже обработанное задание — намного выше.
В итоге научили старую версию писать в очередь, обработчики очереди готовы. В бой запущена старая версия. Очередь копится, но пока что не разгребается.
Теперь нам нужно перенести уже накопленные данные из одной базы в другую. Их порядка 500 гигабайт. Как это сделать? Спасибо Alex Q =)
Задача:
Миграция больших таблиц (до 1500 миллионов записей) из старой SQL-базы в MySQL. При этом структура может (незначительно) меняться, например, в старой SQL-базе булевы поля были CHAR(1) (hidden может иметь значения '' или 'H'), а в MySQL станут TINYINT (hidden может быть 0 или 1).Одновременно с переносом данных может происходить добавление и изменение данных в старой базе. Проблема разруливается созданием очереди, в которую пишется информация о том, какие данные в старой базе изменились или добавились. Пока таблица мигрирует, в очереди накапливаются задания на «досинхронизацию»; очередь потом разбирается отдельным скриптом.
Миграция:
- Импорт в MySQL через LOAD DATA INFILE пачками по 1000 (по умолчанию, переопределяется ключами запуска) строк, потому что так значительно быстрее, чем через INSERT.
- В качестве файла передачи данных использовать FIFO pipe, потому что тогда все данные прогоняются через память без записи на диск. Должен быть существенно быстрее, чем через файлы.
- скрипт читает N строк из старой SQL-базы (10000, например).
- скрипт отфоркивает дочерний процесс. В этом процессе открывается FIFO на чтение, и из него в MySQL делает LOAD DATA INFILE.
- родитель пишет по K строк (1000, например) в fifo. После каждой пачки закрывает файлхэндл пайпа, ждёт выхода дочернего процесса и повторяет с пункта 2, до тех пор, пока не кончатся данные, взятые в пункте 1.
26 февраля 2013 — первый коммит. К 6 марта 2013 готов рабочий вариант. Сделали функцию, которая принимает описание входящей таблицы (старой SQL-базы) и выходной таблицы (MySQL) old_to_MySQL(), и дальше уже всё делается через неё. Для того, чтобы при внезапном факапе не начинать с нуля — храним id последнего смигрированного row для каждой таблицы в MySQL-табличке old_migrate.
Используем LOAD INFILE REPLACE, а не LOAD INFILE IGNORE, чтобы при восстановлении после факапа не потерять изменения данных в старой SQL-базе.
15 марта была временно решена проблема с default-значениями: если из старой SQL-базы приходит NULL, а в MySQL колонка NOT NULL DEFAULT 0, то при попытке вставки NULL мы падаем. Описание схемы теперь имеет набор ключевых колонок, которые обязаны быть, во все остальные мы вставляем DEFAULT, если приходит NULL.
20 марта началась война против SIGPIPE.
Этапы войны:
- Добавили $SIG{PIPE}, который пытается повторить вызов old_to_MySQL() со старыми параметрами с сохранённой позиции. Долгое время всё работало.
- 13-14 ноября SIGPIPE strikes back. Починил порядок работы с пайпом: сначала открываем fifo на чтение, потом — на запись. Во время борьбы с сигпайпом добавили croak/carp как замену die/warn, и наткнулись на баг 72467 в Carp (attempt to copy freed scalar), и поменяли всё обратно.
- Не помогло. Не помог и sleep через секунду после открытия пайпа на чтение. Перелезли на временные файлы. Стало заметно быстрее за счет откручивания всех sleep, которые служили костылями в борьбе с сигпайпом. Стало хорошо.
Война с сигпайпом длилась два дня, 13 и 14 ноября. Это были очень насыщенные два дня.
Итого, чтобы перенести эти 500 гигабайт, нам понадобилось 14 дней.
Пока шел перенос, уже была готова альфа-версия нового API (Perl, fast_cgi).
Перенесли данные, запустили на корпоративных пользователей тестовую мобильную версию. Смотрим, тестируем. Случайно в логах старой версии заметили ошибку, что на одном из беков перлячка ругается на отсутствие модуля DBD. Это значит, что с этого фронта не уходят задания в очередь и базы не консистентны. Оу, шит. Пришлось заново все переносить. Прошло 14 дней =).
Теперь, после переноса данных, можно начать разгребать очередь, а она накопилась огромная, около 300 млн заданий.
Запустили обработчики — слишком медленно. Искали причину тормозов, оказалось, что нужен индекс в табличке, в которой у нас очередь. Ждать опять 14 дней очень не хотелось. Создали еще одну такую же табличку, но уже с индексом. Раскатили старую версию, чтобы она писала в новую табличку с индексом. Это была InnoDB. Добавили индекс. Разгребли и убили табличку. Переименовали все обратно.
Вроде работает. Идем дальше.
Новое API должно уметь писать в обе базы. Пишем асинхронно, чтоб не ждать лишнее время. Для этого в таблицах новой базы делаем запас по id с помощью начальной позиции автоинкремента. Например, в старой базе максимальный id вопроса 1000, тогда в аналогичной таблице новой базы мы ставим начальную позицию автоинкремента 2000.
Добавляем через новое API вопрос, у него получается айдишник 2001, добавляем его в старую базу, там у него айдишник 1001, делаем апдейт в новой базе запись с айдишником 2001 превращаем в запись с айдишником 1001, таким образом айдишники для сущностей будут совпадать в наших базах.
Дальше мы узнали что после рестарта мускуля или альтера таблицы автоинкремент сбрасывается на максимальное значение primary key. Поэтому для избежания этого, добавили в MySQL базу сущности с большим айдишником равным стартовому числу автоикремента.Все дальнейшие факапы решались вопросом добавления в очередь задач на пересинхронизацию нужных сущностей.
Вспомнил еще один момент: как-то из отдела эксплуатации до нас дошла новость, что, возможно, придется погасить один из датацентров, а учитывая, что мобильная версия уже запущена в бой и тот факт, что сервера со старой базой находятся в этом датацентре, а новые MySQL-базы в другом — стало совсем невесело. Но, к счастью, прогнозы не оправдались.
Представим, что это случилось.
Погасили датацентр со старыми базами — тогда быстро кудрим фронт в другом датацентре (мы это сделали сразу же, как узнали про эту новость), переключаем мобильную версию в режим «только чтение» и всех редиректим на мобильную версию.
Погасили датацентр с новыми базами — отключаем запись в очередь для синхронизации. После того, как датацентр заработает, кидаем в очередь все сущности, созданные после начала проблем, и тихонечко пересинхронизируем.
В марте запустили в бой мобильную версию. Туда приходило порядка 500 тыс уников в сутки. Нагрузку держим, все работает шустро.
Архитектура проекта приняла следующий вид:
имя | предназначение | cpu | ram, Gb | hdd | примечание |
baron | фронт | 2xE5-2620 | 32 | 2 x sata | httpd + mod_fcgid, большой запас по мощности |
burbon | фронт | 2xE5-2620 | 32 | 2 x sata | httpd + mod_fcgid, большой запас по мощности |
bereza | база | 2xE5-2609 | 96 | 2 x sata + 10 x ssd | mysql мастер |
sosna | база | 2xE5-2609 | 96 | 2 x sata + 10 x ssd | mysql реплика |
pihta | база | 2xE5-2609 | 96 | 2 x sata + 10 x ssd | mysql реплика |
vagon | мемкеш | 2xE5-2620 | 64 | 2 x sata | два инстанса memcached |
Сделали, раскатили ее на регион Москва. Увидели, что при нагрузке 800 запросов в секунду корится апач. Сначала подозрения упали на mod_fcgid, но оказалось, что виновник mod_rpaf. Спасибо Mons, он мастерски отgdbил корку апача.
Отказались от mod_rpaf, прокидывали айпишник через заголовок X-Real-IP с помощью nginx.
Раскатили на всю Россию, нагрузку держим. Сделали новую «большую» версию, начали всех редиректить. 19 ноября закрыли старую версию.
Убрали к чертям ограничение на балансере primus (15 запросов в секунду с одного IP). Нагрузку держим полет нормальный. Примерно 950 запросов в секунду у API.
Посещаемость в среднем 6,5 млн в сутки.
На всех серверах используем CentOS 64bit, выкатка в бой происходит посредством RPM пакетов. На данный момент перлячка все еще крутится под апачем2 (mod_fcgid). После того как yum накатил пакетик, по очереди перезапускаем беки.
На фронте используем вот такую штуку:
proxy_next_upstream timeout error http_502 http_504;
То есть, если пользователю придет 502 или 504, то его пустит на другой бек, таким образом минимизируем дискомфорт из-за рестарта апача.
Графики по нагрузке можно посмотреть тут.
Надеюсь, что данная заметка поможет вам не наступать на те грабли, по которым прошлись мы! Помните, лучше учиться на чужих ошибках, чем на своих! :)