Стоит ли бояться serializable-транзакций больше, чем труднонаходимых багов?

от автора

Мы не понимаем, как более низкие уровни изоляции влияют на приложения.  Возможно, READ COMMITTED достаточно хорош, потому что люди не знают, насколько у них на самом деле грязные данные...  Энди Павло на SIGMOD 2017 
All you need is ACID

All you need is ACID

Ключевые идеи

  • В базах данных транзакции обладают свойствами ACID, где «I» означает изоляцию транзакций при одновременном (concurrent) выполнении.

  • Наличие (serializable) изоляции гарантирует, что результат транзакций, выполненных параллельно, будет таким же, как если бы они были выполнены в некотором последовательном порядке.

  • Сериализация выполнения транзакций не бесплатна с точки зрения производительности.

  • Многие СУБД поддерживают более слабые уровни изоляции, оставляя за разработчиком выбор подходящего. В монолитных СУБД более слабый уровень изоляции часто используется по умолчанию. Так, в PostgreSQL и MySQL это «read committed». В распределённых СУБД чаще по умолчанию более строгие уровни: «repeatable read» в YugabyteDB и TiDB, «serializable» в CockroachDB и YDB.

  • Слабые уровни изоляции могут быть причиной очень труднонаходимых багов. Причём эти баги могут вызвать уязвимости в безопасности.

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

В этом посте мы постараемся ответить на два важных вопроса:

  1. Достаточно ли часто более слабые уровни изоляции вызывают баги в приложениях?

  2. Так ли сильно ухудшается производительность СУБД и приложений при использовании serializable-транзакций, или слухи об этом сильно преувеличены?

Ответив на эти вопросы, мы пришли к заключению, что использование более слабых уровней изоляции по умолчанию является формой преждевременной оптимизации и корнем всех зол. Поэтому, если в вашей СУБД по умолчанию не serializable, как в CockroachDB и YDB, то мы настоятельно рекомендуем изменить настройки.

Тонкости уровней изоляции

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

--- Transaction 1                            Transaction 2 UPDATE t SET color = 'чёрный'        UPDATE t SET color = 'белый'   WHERE color = 'белый';              WHERE color = 'чёрный'; 

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

Обычно мы предполагаем, что транзакции всегда имеют свойства ACID:

  • Atomicity (Атомарность): все части транзакции либо коммитятся целиком, либо отменяются (абортятся). Мартин Клеппманн считает, что название «Abortability (Отменяемость)» точнее отражает смысл и помогает избежать путаницы между атомарным коммитом и атомарной видимостью.

  • Consistency (Согласованность): исторически добавлено для создания более благозвучной аббревиатуры и скорее специфично для приложений, чем для СУБД.

  • Isolation (Изоляция): параллельно выполняемые транзакции изолированы друг от друга. Результаты выполнения транзакций должны выглядеть так, как если бы они выполнялись последовательно одна за другой.

  • Durability (Устойчивость): закомиченные данные никогда не теряются.

«Изоляция» в первую очередь означает сериализуемость, но в качестве компромисса между безопасностью (safety) и производительностью могут использоваться более слабые уровни изоляции:

  • read uncommitted;

  • read committed;

  • repeatable read.

Serializable является уровнем изоляции по умолчанию, по крайней мере начиная со стандарта SQL:1999, включая его последнюю версию SQL:2023 (ISO/IEC 9075:2023). Это также уровень изоляции по умолчанию в CockroachDB и YDB. Однако многие другие СУБД используют более слабые уровни изоляции по умолчанию, в частности:

  • «read committed» в PostgreSQL, MySQL/InnodDB и Oracle;

  • «repeatable read» в YugabyteDB.

Хуже того, некоторые СУБД вольно интерпретируют стандарт и вкладывают свой собственный смысл в именования. Так, в MySQL/InnodDB «repeatable read» — скорее «read committed» (по крайней мере в более старых версиях). А в Oracle «serializable» не является действительно serializable, а представляет собой более слабый «repeatable read (snapshot isolation)» (разработчики приложений могут обойти это ограничение). Подробности можно найти в этом слегка устаревшем посте или его более новой версии 2022 года, а также на странице проекта Hermitage, посвящённой Oracle. В некотором смысле это напоминает ситуацию со многими Citus-подобными решениями шардирования Postgres: их многошардовые транзакции не ACID.

Теперь вернёмся к первоначальному красочному примеру: с уровнем изоляции «serializable» действительно все значения станут либо белыми, либо чёрными. Однако с «read committed» (уровень изоляции по умолчанию в PostgreSQL) некоторые значения могут поменять цвет, а некоторые нет. С «repeatable read» ожидается, что значения поменяются местами: чёрные станут белыми, а белые станут чёрными. Здесь можно найти другие интересные примеры.

От искусства и искусственных примеров перейдём к более реалистичному случаю, позаимствованному из книги Мартина Клеппмана «Высоконагруженные приложения. Программирование, масштабирование, поддержка». Пусть мы хотим реализовать приложение для управления дежурствами врачей. В каждую смену дежурит несколько врачей, при этом любой может прервать своё дежурство, если после этого останется хотя бы один дежурный. Возьмём PostgreSQL 16:

CREATE TABLE shift (id int, name text, on_call boolean);  INSERT INTO shift VALUES   (1, 'Alice', true),   (1, 'Bob', true);  SELECT * FROM shift WHERE id = 1 AND on_call;  id | name  | on_call ----+-------+---------   1 | Alice | t   1 | Bob   | t (2 rows) 

Теперь и Алиса, и Боб хотят одновременно отменить своё дежурство:

 --- Alice                              --- Bob BEGIN;                                 BEGIN;  SELECT count(*) FROM shift             SELECT count(*) FROM shift   WHERE id = 1 AND on_call;              WHERE id = 1 AND on_call;  count                                  count -------                                -------      2                                      2 (1 row)                                (1 row)  UPDATE shift                           UPDATE shift  SET on_call = false                     SET on_call = false  WHERE id = 1 AND name = 'Alice';        WHERE id = 1 AND name = 'Bob';  COMMIT;                                COMMIT; 

Проверим результат:

SELECT * FROM shift WHERE id = 1;  id | name  | on_call ----+-------+---------   1 | Alice | f   1 | Bob   | f (2 rows) 

Важно понимать, что мы выполнили две транзакции параллельно и в результате получили неконсистентный результат. Причиной является то, что по умолчанию Postgres использует уровень изоляции «read committed». С «serializable» мы бы не попали в ситуацию, когда в больнице никого нет.

Мы решили не углубляться в теорию, потому что уже написано много отличных книг и учебников по базам данных. Мы очень рекомендуем уже упомянутую книгу «Высоконагруженные приложения. Программирование, масштабирование, поддержка» Мартина Клеппмана и «Database Internals» Алекса Петрова. Серия статей «Isolation Levels in Modern SQL Databases Series» Френка Пашота тоже может стать отличным источником практических знаний. Мартин Клеппман проделал отличную работу, тестируя «I» в ACID в рамках своего проекта Hermitage.

Вместо этого мы попытаемся понять плюсы и минусы отсутствия уровня изоляции «serializable» по умолчанию.

Преждевременная оптимизация?

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

Существует и другой взгляд на использование слабых уровней изоляции. Приведём цитату Мартина Клеппмана: «К сожалению, мы плохо понимаем более слабые уровни изоляции. Несмотря на то, что мы имеем с ними дело более 20 лет, не так много людей могут с ходу объяснить разницу, скажем, между read committed и repeatable read. И это проблема, потому что когда вы не знаете, какие гарантии предоставляет база данных, вы не можете знать наверняка, есть ли в вашем коде ошибки, вызванные параллельным выполнением транзакций и гонками между ними».

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

  1. Часто ли более слабые уровни изоляции приводят к ошибкам?

  2. Действительно ли влияние на производительность уровня «serializable» настолько значительное?

В 2017 году Питер Бейлис и Тодд Варшавски из Стэнфордского университета опубликовали крайне интересную статью «ACIDRain: Concurrency-Related Attacks on Database-Backed Web Applications». Они проанализировали 12 популярных приложений для электронной коммерции, написанных на четырёх языках и развернутых на более чем 2 миллионах веб-сайтов, выявили и подтвердили 22 критические атаки ACIDRain, позволяющие злоумышленникам превышать лимиты подарочных карт и красть товары. Согласно статье, из 22 уязвимостей пять были вызваны слабыми уровнями изоляции транзакций. Причиной остальных 17 стали другие способы неправильного использования транзакций баз данных.

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

  • Вот история атак на биржи биткойнов Flexcoin и Poloniex. В результате все биткойны Flexcoin были украдены, и сама Flexcoin была вынуждена закрыться. Ровно то же самое произошло с Poloniex из-за точно такой же ошибки. Автор поста поделился очень интересной и важной мыслью: такие ошибки не являются уязвимостью в безопасности, так как ни несанкционированный доступ, ни сбой в системе авторизации не происходят — приложение просто было неправильно спроектировано.

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

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

В ходе наших изысканий мы обнаружили ещё одну интересную, но уже более свежую статью. Авторы назвали операции с базами данных, координируемые приложением, ad hoc транзакциями. Логика ad hoc транзакций реализует контроль параллелизма на стороне приложения. Они проверили 8 популярных веб-приложений с открытым исходным кодом и обнаружили 91 ad hoc транзакцию, 71 из которых играли критическую роль. 53 из них имели проблемы с правильным использованием. Мы считаем, что это подтверждает тезис о том, что контроль параллелизма является сложным, и даже опытные разработчики регулярно допускают ошибки.

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

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

Эта статья описывает первоначальную реализацию serializable snapshot isolation (SSI, что является другим названием для «serializable») в PostgreSQL. Авторы пришли к заключению, что «serializable» работает лишь немного хуже, чем «repeatable read». На практике, конечно, существуют ситуации, когда ошибки сериализации вызывают повторные попытки выполнения транзакций, что снижает производительность, но эти случаи, в отличие от ошибок параллелизма, можно решить относительно легко.

Единственное сравнение «repeatable read» и «read committed», которое мы нашли, это устаревший пост от Percona. Они пришли к выводу, что под нагрузкой TPC-C (самый распространённый и широко используемый OLTP-бенчмарк) между этими двумя режимами почти нет разницы. Мы считаем, что отсутствие свежих публикаций на эту тему только подтверждает этот вывод.

Заключение

Современные исследования показывают, что более слабые уровни изоляции транзакций нередко приводят к ошибкам (concurrency bugs). Кроме того, во многих СУБД по умолчанию используются именно слабые уровни изоляции, что требует особого внимания разработчиков приложений. Доля ошибок, связанных с использованием более низкого уровня изоляции, чем необходимо, может составлять порядка 20% от всех ошибок, связанных с транзакциями. Такие ошибки часто приводят к уязвимостям безопасности, которые уже были использованы злоумышленниками.

Мы не нашли доказательств того, что производительность уровня изоляции «serializable» ощутимо хуже. Например, CockroachDB и YDB используют уровень изоляции serializable по умолчанию и показывают достойные результаты производительности даже при сравнении с PostgreSQL.

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

Как отметил C.A.R. Hoare в своей лекции на вручении премии Тьюринга: «Есть два способа создания программ: один способ — сделать их настолько простыми, чтобы было очевидно, что в них нет ошибок, и другой способ — сделать их настолько сложными, чтобы в них не было очевидных ошибок». Мы считаем, что именно поэтому вам действительно стоит рассмотреть возможность переключения на «serializable» в качестве уровня изоляции по умолчанию.


ссылка на оригинал статьи https://habr.com/ru/articles/825768/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *