Как не сломать продакшен. Рассказ о двух ошибках и советы, как их избежать. Часть 2

0
23
views

Перевод второй части статьи «How Not to Break Production – My Two Big Coding Mistakes and How to Avoid Them».

В первой части статьи автор рассказал об ошибке в продакшене, которую допустил, будучи разработчиком-джуниором. В этой части речь пойдет о второй ошибке, произошедшей семь лет спустя после первой.


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

Семь лет спустя я был уже не джуниором, а самым настоящим сеньором (вах-вах-вах!).

Это был обычный день. Написание кода, пуш, код-ревью, деплой. Планировались только нормальные краши, ничего особенного.

И тут меня пинганули в Slack. «Упало» несколько воркеров, работающих в фоне. Мой коллега сказал, что вроде бы это связано с моим последним пул-реквестом, который я недавно смержил.

Я ответил: «Спасибо, я посмотрю».

Да, там и вправду было провалено довольно много задач, и их число росло.

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

Но вскорости все изменилось к худшему.

Начали выскакивать предупреждения. А если быть ближе к истине, то Slack-уведомления в дежурке начали трезвонить, не умолкая.

Таймауты за таймаутами. Использование базы данных достигло 100%. Возникла угроза блокировки всей системы, а это означало остановку всех продуктов компании.

О нет.

Я не знал, почему это происходит, но был уверен, что дело плохо.

И я сделал единственное, что мог сделать: вылогинился и направился в Мекси… то есть направился немедленно рассказать кому-нибудь еще о возникшей ситуации.

Мы устроили созвон в Zoom (это было еще до того как Zoom приобрел мировую известность благодаря 2020 году).

Обсудив все, мы с коллегами пришли к выводу, что выполнение одного определенного запроса занимало слишком много времени.

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

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

В этом пул-реквесте я обновил фоновую задачу и изменил ее поведение.

Кодовая база того проекта была написана на Ruby on Rails, и внесенное мной изменение было примерно следующим (если вы никогда прежде не видели Rails, не переживайте, я объясню все построчно):

def run(some_status)
  items = Item.where(status: some_status).all
  
  items.each do |item|
    # do some database querying and updating
  end
end

Фоновая задача вызывает функцию run, а в качестве параметра передается значение статуса (some_status).

Первая строка функции запрашивает некоторые записи из базы данных.

Эта строка использует методы из ActiveRecord фреймворка Rails, но по сути это красивая обертка вокруг обычных SQL-запросов.

items = Item.where(status: some_status).all

Эта строка — просто обычное предложение SQL:

SELECT *
FROM items
WHERE status = ?

При запуске запроса значение some_status привязывается к плейсхолдеру (?) в запросе и затем выполняется.

После того как база данных возвращает результаты запроса, Rails берет эти записи и создает из них красивые объекты Ruby.

В общем, мы запрашиваем таблицу items, где status — это определенное значение.

(Опять же, это, вероятно, не точное описание того, что я делал, но довольно понятная иллюстрация).

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

items.each do |item|
  # do some database querying
  # then update the item
end

Вроде как все просто, правда? Получить элементы. Пройтись по этим элементам в цикле. Обновить элементы.

Но кое-что я там не учел, и это было моей ошибкой.

Photo by Zan on Unsplash

Малыш NULL — или nil

Поле status не имеет ограничения NOT NULL. Это означает, что поле status может быть NULL. Или, если говорить языком Ruby, может быть nil (то же самое, что null в других языках).

Очень легко привыкнуть делать запросы на основе таких вещей как ID, которые, как вы знаете, есть всегда. Очень похожая строка…

items = Item.where(id: list_of_ids).all

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

Но в нашем случае есть проблема. Она кроется в этой части кода:

items = Item.where(status: some_status).all

Здесь идет поиск всех записей Item по определенному статусу, который передается через параметр some_status.

Но значение этого столбца может быть NULL (или nil).

И если в качестве параметра some_status будет передан nil, будет предпринята попытка найти все записи Item, где status = nil.

Окей, но звучит вроде как нестрашно, верно?

А дело было в том ужасном факте, что таблица items содержала 40 миллионов строк. И большинство из них не имели статуса.

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

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

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

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

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

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

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

Последней каплей было то, что фоновая задача сама по себе заключалась в обновлении всех этих записей.

По чистой случайности там был один спасательный круг.

Результат этого запроса не был упорядоченным. То есть по умолчанию более старые (или первые) записи таблицы возвращались первыми.

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

Кроме того, задача проваливалась на ранних стадиях выполнения и перезапускалась, в результате чего снова и снова обновлялись самые маленькие и старые части таблицы.

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

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

Простое исправление

Когда мы разбирали мой пул-реквест на Zoom-совещании, кто-то в конечном итоге нашел ошибку и указал на проблемную строку (на запрос, который искал записи по их статусу).

Но даже когда мне указали на проблему, я по-прежнему не смог ее увидеть. Лишь когда мне объяснили повторно (а может и в третий раз — я уж не помню), у меня, наконец, щелкнуло.

Строка выглядела примерно так:

items = Item.where(status: some_status).all

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

К счастью, исправить это было просто. Нужно было защитить функцию и сделать быстрый возврат, если значение статуса — nil:

def run(some_status)
  return if some_status.nil?
  
  items = Item.where(status: some_status).all
  
  ...
end

Если вы не знакомы с Ruby and Rails, вот то же самое в другой форме:

def run(some_status)
  if some_status == nil
    return
  end
  
  items = Item.where(status: some_status).all
  
  ...
end

В общем, если some_statusnil, просто сделай ранний выход из функции и убирайся оттуда.

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

Поскольку это был 2019 год, у нас были уже лучшие решения для бэкапов. Мы загрузили последний бэкап и (осторожно) сделали апдейт, чтобы вернуть данные в первоначальное состояние.

АпдейтГейт2019 наконец закончился.

Чему нас учат эти истории

Из этих двух историй можно сделать несколько выводов.

Первый — инструкции обновления — страшная вещь.

Обе мои ошибки были связаны с инструкциями UPDATE. При обновлении или удалении данных будьте предельно внимательны. Следите за тем, чтобы обновить только необходимые данные.

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

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

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

Третий вывод: я не знаю никого, кто был бы уволен за ошибку.

Что касается меня, в обоих случаях товарищи по команде понимали, что это и в самом деле лишь ошибка.

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

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

Послесловие

Я рад, что теперь у меня впереди еще шесть лет без серьезных ошибок (шутка).

Я точно знаю, что когда-нибудь ошибусь снова, потому что ошибки просто случаются.

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

ОСТАВЬТЕ ОТВЕТ

Please enter your comment!
Please enter your name here