Как стать автором
Обновить

Молчание Ruby-эксепшенов: транзакционный Rails/PostgreSQL триллер

Время на прочтение9 мин
Количество просмотров6.4K
Автор оригинала: Andrey Novikov, Evil Martians

Это история о том, почему вы никогда не должны замалчивать ошибки, когда вы внутри транзакции в базе данных. Узнайте, о том как правильно использовать транзакции и что делать, когда их использовать — не вариант. Спойлер: речь пойдёт об advisory locks в PostgreSQL!


Я работал над проектом, в котором пользователи могут импортировать большое количество тяжёлых сущностей (назовём их товарами — products) из внешнего сервиса в наше приложение. К каждому товару при этом загружается ещё больше разнообразных связанных с ним данных с внешних API. Нередка ситуация, когда пользователю нужно загрузить сотни товаров вместе со всеми-всеми зависимостями, в итоге импорт одного товара занимает ощутимое время (30-60 секунд), а весь процесс может порядочно так затянуться. Пользователю может надоесть ждать результата и у него есть право нажать кнопку «Отмена» в любой момент и приложение должно быть полезным с тем количеством товаров, которые удалось загрузить к этому моменту.


«Прерываемый импорт» реализован так: в начале для каждого товара создаётся временная запись-задача в табличке в БД. Для каждого товара запускается фоновая задача импорта, которая скачивала товар, сохраняла в базу вместе со всеми зависимостями (всё делает в общем) и уже в самом конце удаляет свою запись-задачу. Если к моменту, когда фоновая задача запустится, записи в базе не будет — задача просто тихо завершается. Таким образом, для отмены импорта достаточно просто удалить все задачи и всё.


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


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


Это, конечно, мелочь, но пользователей порядком озадачивало, поэтому неплохо было бы исправить. У меня было два пути: как-то определить и «убить» уже запущенные задачи или при нажатии кнопки отмены подождать, пока они завершатся и «умрут своей смертью», прежде чем перекидывать пользователя дальше. Я выбрал второй путь — ждать.


Транзакционные блокировки спешат на помощь


Для всех, кто работает с (реляционными) базами данных, ответ очевиден: используйте транзакции!


При этом важно помнить, что в большинстве РСУБД записи, обновляемые внутри транзакции, будут заблокированы и недоступны для изменения другими процессами до тех пор, пока данная транзакция не завершится. Так же будут заблокированы и записи, отобранные с помощью SELECT FOR UPDATE.


Точно наш случай! Я обернул задачи импорта отдельных товаров в транзакцию и заблокировал запись-задачу в самом начале:


ActiveRecord::Base.transaction do
  task = Import::Task.lock.find_by(id: id) # SELECT … FOR UPDATE значит «попридержи эту запись для меня»
  return unless task # Её кто-то удалил? Значит, можно ничего не делать!
  # Делаем много тяжёлых операций
  task.destroy
end

Теперь, когда пользователь захочет отменить импорт, операция останова импорта удалит задачи для ещё неначавшихся импортов и будет вынуждена ждать завершения уже идущих:


user.import_tasks.delete_all # ждём тут завершения всех уже идущих импортов

Просто и элегантно! Я прогнал тесты, проверил импорт локально и на стейджинге и задеплоил «в бой».


Не так быстро…


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


Ошибки в логах тоже не воодушевляли: PG::InFailedSqlTransaction с бэктрейсом, ведущим в код, выполнявший невинные SELECTы. Да что происходит вообще?


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


  1. Конкурентная вставка конфликтующих записей в базу данных.
  2. Автоматическая отмена транзакций в PostgreSQL после ошибок.
  3. Замалчивание проблем (Ruby exceptions) в коде приложения.

Проблема первая: Конкурентная вставка конфликтующих записей


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


Для нахождения и переиспользования одинаковых зависимостей в коде приложения есть проверки, но теперь, когда мы используем транзакции, эти проверки стали бесполезны: если транзакция А создала зависимую запись, но ещё не завершилась, то транзакция Б не сможет узнать о её существовании и попытается создать дублирующую запись.


Проблема вторая: Автоматическая отмена транзакций в PostgreSQL после ошибок


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


ALTER TABLE product_deps ADD UNIQUE (user_id, characteristics);

Если ещё идущая транзакция A вставила новую запись и параллельно с ней транзакция Б пытается вставить запись с такими же значениями полей user_id и characteristics — транзакция Б получит ошибку:


BEGIN;
INSERT INTO product_deps (user_id, characteristics) VALUES (1, '{"same": "value"}');
-- Now it will block until first transaction will be finished
ERROR:  duplicate key value violates unique constraint "product_deps_user_id_characteristics_key"
DETAIL:  Key (user_id, characteristics)=(1, {"same": "value"}) already exists.
-- And will throw an error when first transaction have commited and it is become clear that we have a conflict

Но есть одна особенность, про которую нельзя забывать — транзакция Б после обнаружения ошибки будет автоматически отменена и вся проделанная в ней работа пойдёт насмарку. Однако, эта транзакция по прежнему открыта в «ошибочном» состоянии, но при любой попытке выполнить любой, даже самый безобидный запрос в ответ будут возвращаться лишь ошибки:


SELECT * FROM products;
ERROR:  current transaction is aborted, commands ignored until end of transaction block

Ну и совсем излишне говорить, что всё, что было введено в базу в этой транзакции, сохранено уже не будет:


COMMIT;  -- Даже если мы попытаемся сохранить всё, что сделали
ROLLBACK -- РСУБД просто отклонит наш запрос и закроет транзакцию без изменений

Проблема третья: Замалчивание проблем


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


def process_stuff(data)
  # Магия, много магии
rescue StandardError
  nil # Счастливой отладки, суки
end

Автор кода тут как бы говорит нам: «Мы попытались, у нас не получилось, но ничего страшного, продолжаем без этого». И хотя причины такого выбора могут быть вполне объяснимы (не всё можно обработать на уровне приложения), именно это делает любую логику, основанную на транзакциях, невозможной: «выкинутый» эксепшен не сможет всплыть наверх, к блоку transaction, и не сможет вызвать корректный откат транзакции (ActiveRecord ловит все ошибки в этом блоке, откатывает транзакцию и кидает их снова).


Идеальный шторм


И вот как все эти три фактора сошлись, чтобы создать идеальный шторм баг:


  • Приложение в транзакции пытается вставить конфликтующую запись в базу данных и при этом вызывает ошибку "duplicate key" из PostgreSQL. Однако, эта ошибка не вызывает отката транзакции в приложении, так как она «замалчивается» внутри одной из частей приложения.
  • Транзакция становится недействительной, но приложение про это не знает и продолжает свою работу. При любой попытке обратиться к БД приложение снова получает ошибку, на этот раз "current transaction is aborted", но и эта ошибка может быть «выкинута»…
  • Вы наверное уже поняли — что-то в приложении продолжает ломаться, но никто не узнает про это до тех пор, пока выполнение не дойдёт до первого места, где нет чересчур жадного rescue и где ошибка может в конце концов всплыть, быть выведена в лог, зарегистрирована в трекере ошибок — что угодно. Но это место будет уже очень далеко от того места, которое стало первопричиной ошибки, и лишь одно это превратит отладку в кошмар.

Альтернатива транзакционным блокировкам в PostgreSQL


Охота за rescue в коде приложения и переписывание всей логики импорта — не вариант. Долго. Мне нужно было быстрое решение и у постгреса оно нашлось! У него есть встроенное решение для блокировок, альтернативное блокировке записей в транзакциях, встречайте — сессионные рекомендательные блокировки (session-level advisory locks). Я использовал их следующим образом:


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


Затем я в каждой операции импорта беру разделяюмую (shared) блокировку на какой-то уникальный для всего импорта ключ (например, созданный из идентификатора пользователя и хэша от названия класса операции):


SELECT pg_advisory_lock_shared(42, user.id);

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


Операция отмены импорта при этом удаляет все записи-задачи из базы данных и пытается взять исключительную (exclusive) блокировку на тот же ключ. При этом ей придётся подождать, пока все разделяемые блокировки будут освобождены:


SELECT pg_advisory_lock(42, user.id)

И это всё! Теперь «отмена» будет ждать, пока все уже «бегущие» импорты отдельных товаров завершатся.


Более того, теперь, когда мы не связаны транзакцией, мы можем использовать маленький хак, чтобы ограничить время ожидания отмены импорта (на случай, если какой-то импорт «залипнет»), потому что нехорошо надолго блокировать поток веб-сервера (да и заставлять пользователя ждать):


transaction do
  execute("SET LOCAL lock_timeout = '30s'")
  execute("SELECT pg_advisory_lock(42, user.id)")
rescue ActiveRecord::LockWaitTimeout
  nil # мы устали ждать (в этот момент транзакция уже откачена)
end

Ловить ошибку снаружи блока transaction безопасно, поскольку ActiveRecord уже откатит транзакцию.


Но что делать с конкурентной вставкой одинаковых записей?


К сожалению, я не знаю решения, которое бы хорошо работало при конкурентных вставках. Есть следующие подходы, но они все будут блокировать параллельные вставки до тех пор, пока первая из транзакций не завершится:


  • INSERT … ON CONFLICT UPDATE (доступно начиная с PostgreSQL 9.5) во второй транзакции заблокируется до завершения первой и потом вернёт запись, которая была вставлена первой транзакцией.
  • Заблокировать какую-то общую запись в транзакции перед тем, как прогонять валидации на вставку новой записи. Здесь мы будем ждать до тех пор, пока вставленная в другой транзакции запись не станет видна и валидации не смогут полноценно отработать.
  • Взять какую-нибудь общую рекомендательную блокировку — эффект тот же, что и для блокирования общей записи.

Ну а если вы не боитесь работать с ошибками уровня базы, можно просто ловить ошибку уникальности:


def import_all_the_things
  # Начните транзакцию здесь, не раньше
  Dep.create(user_id, chars)
rescue ActiveRecord::RecordNotUnique
  retry
end

Только убедитесь, что данный код уже не обёрнут в транзакцию.


Почему они блокируются?

Ограничения UNIQUE и EXCLUDE блокируют потенциальные конфликты, не позволяя им быть записанным в одно и то же время. Например, если у вас есть ограничение уникальности на целочисленную колонку и одна транзакция вставляет строку со значением 5, то другие транзакции, которые тоже пытаются вставить 5, будут заблокированы, но транзакции, которые пытаются вставить 6 или 4 сразу выполнятся успешно, без блокировки. Поскольку минимальный фактический уровень изоляции транзакций в PostgreSQL — это READ COMMITED, то транзакция не вправе видеть незафиксированные изменения от других транзакций. Поэтому INSERT с конфликтующим значением не может быть принят или отвергнут до тех пор, пока первая транзакция не зафиксирует свои изменения (тогда вторая получит ошибку уникальности) или не откатится (тогда вставка во второй транзакции пройдёт успешно). Прочитайте об этом подробнее в статье от автора ограничений EXCLUDE.

Предотвращаем катастрофу в будущем


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


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


# Так объявляется вспомогательный модуль
module NoTransactionAllowed
  class InTransactionError < RuntimeError; end

  def call(*)
    return super unless in_transaction?
    raise InTransactionError,
          "#{self.class.name} doesn't work reliably within a DB transaction"
  end

  def in_transaction?
    connection = ApplicationRecord.connection
    # service transactions (tests and database_cleaner) are not joinable
    connection.transaction_open? && connection.current_transaction.joinable?
  end
end

# И так используется
class Deps::Import < BaseService
  prepend NoTransactionAllowed

  def call
    do_import
  rescue ActiveRecord::RecordNotUnique
    retry
  end
end

Теперь если кто-то попытается обернуть опасный сервис в транзакцию, то он сразу получит ошибку (если, конечно, не будет её «замалчивать»).


Итоги


Главный урок, которые следует вынести: будьте осторожны с исключениями. Не обрабатывайте все подряд, ловите только те исключения, которые вы знаете, как обрабатывать и позвольте остальным дойти до логов. Никогда не замалчивайте исключения (только если вы не 100% уверены, зачем вы это делаете). Чем раньше ошибка будет замечена — тем проще будет отладка.


И не перемудрите с транзакциями в БД. Это не панацея. Используйте наши гемы isolator и after_commit_everywhere — они помогут вашим транзакциям стать полностью дуракоустойчивыми.


Что почитать


Exceptional Ruby от Avdi Grimm. Эта небольшая книга научит вас правильно работать с существующими исключениями в Ruby и расскажет, как правильно спроектировать систему исключений для вашего приложения.


Using Atomic Transactions to Power an Idempotent API от @Brandur. В его блоге много полезных статей про надёжность приложений, Ruby и PostgreSQL.

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 14: ↑14 и ↓0+14
Комментарии2

Публикации

Истории

Работа

Ruby on Rails
4 вакансии
Программист Ruby
4 вакансии

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань