Мы не понимаем, как более низкие уровни изоляции влияют на приложения.

Возможно, 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 и Oracle;

  • «repeatable read» в MySQL/InnoDB (но есть важный нюанс, см. ниже) и YugabyteDB.

Хуже того, некоторые СУБД вольно интерпретируют стандарт и вкладывают свой собственный смысл в именования. Так, в MySQL/InnoDB гарантии уровня изоляции «repeatable read» выполняются только для read-only транзакций, что отражено в официальной документации. Из-за этого в Hermitage указано, что «repeatable read» в MySQL/InnoDB скорее (отчасти) «read committed» («monotonic atomic view»). А в 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 из них имели проблемы с правильным использованием. Мы считаем, что это подтверждает тезис о том, что контроль параллелизма является сложным, и даже опытные разработчики регулярно допускают ошибки.

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

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

Эта статья описывает первоначальную реализацию 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» в качестве уровня изоляции по умолчанию.

Комментарии (27)


  1. Akina
    01.07.2024 09:32

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

    Важная оговорка - полученный результат неконсистентный исключительно с точки зрения внешней для СУБД логики. А вот с точки зрения самого SQL сервера с данными всё в порядке - ибо у него нет такого правила согласованности данных, которое бы не допускало отсутствие в таблице хотя бы одной записи с on_call=true.

    И вся вина за проблему - именно на кривом исполнении операции.


    1. eivanov Автор
      01.07.2024 09:32
      +2

      Мы как раз видим проблему в перекладывании (по умолчанию) ответственности за согласованность с СУБД на разработчиков приложений. В полноценных ACID-транзакциях такая ситуация невозможна, т.к. изоляция подразумевает сериализацию транзакций. А в примере, который мы привели, проблема вызвана отсутствием сериализации (по умолчанию).


  1. Batalmv
    01.07.2024 09:32
    +3

    Честно говоря, всю сознательную жизнь в роли разраба хранимок под Оракле, да и потом я использовал только стандартный уровень изоляции, он же read committed и нет проблем. Указанный пример кода - ну, тут проблемы есть начиная от дизайна базы, так как очевидно, что человек ответственный не вообще, а в конкретную смену, значит где-то будет объект, который свяжет вместе место дежурства, смену и человека. И значит по такой бизнес задаче надо делать не оновление объекта "человек", а ячейки в графике дежурств, что уже исключает подобного рода ошибку.

    Ну и код понятно кривой от слова совсем, так как еще "древние" писали, что если хотите обновлять поле, на котором есть бизнес условие, либо прикрутите констрейнт, либо делайте явно select for update.

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

    ---------------------------

    Что касается " Serializable" - вот вы мне поясните, транзакции выполняются строго "последовательно". Понятно что последовательность не вообще, иначе база превратится однопоточный тормоз, но в "контексте транзакции". Дальше я могу только теоретизировать (так как я с трудом могу понять, зачем другие уровни кроме read committed, шутка), но вот хотелось бы понять - ЗАЧЕМ

    Берем ваш же пример, бизнес задачу, делаем табличку

    create table A (id int, depId int, empId int, stratDate time)

    где храним всех ответственных. В одном рекорде храним факт того, что сотрудник Х сидит на смене в департаменте начиная с определенного времени. Куда можно внести длину смены (4 часа) и даже ограничить возможные точки начала смены, но мне лениво

    В чем проблема read committed?

    Новая запись - делаем инсерт, если такая есть - получает нарушение уникальности и до свидания. Изи.

    Делаем обновление - тоже все прекрасно, обновления делаются строго последовательно

    Да, наверное, если хотим одновременно поменять Боба на Алису, а Алису на Боба - чего-то там (эх, давно это было, лениво проверять реакцию) будет, но для такой операции вас прекрасно спасает select for update

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

    ------------------

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

    Я вспомнил, что когда-то на собеседовании джавистов, давали примеры на многопоточные транзакции. И все было грустно. В лучшем случае поставлю аннтотацию и все. А реально сесть и подумать, как код будет работать в многопоточной среде - даже и близко мысли нет

    А с таким подходом не спасет ничего :(


    1. eivanov Автор
      01.07.2024 09:32
      +1

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

      Вы абсолютно правильно уловили суть поста. Но только мы считаем, что использование "Serializable" соответствует KISS и не означает лень. Найденные нами результаты исследований показывают, что ошибки из-за использования слабых уровней изоляций встречаются достаточно часто. И это неудивительно: я не знаю ни одного программиста, который бы не делал ошибок (в т.ч. в СУБД, ОС, компиляторах и т.п.). При этом выявлять concurrency баги в приложениях БД очень трудно.

      А с таким подходом не спасет ничего :(

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

      Ну и код понятно кривой от слова совсем, так как еще "древние" писали, что если хотите обновлять поле, на котором есть бизнес условие, либо прикрутите констрейнт, либо делайте явно select for update.

      С тех пор многое изменилось. Диски и сеть стали в разы быстрее. СУБД тоже стали лучше. А методология разработки обычно говорит, что оптимизировать надо только тогда, когда это необходимо.

      create table A (id int, depId int, empId int, stratDate time)
      где храним всех ответственных. В одном рекорде храним факт того, что сотрудник Х сидит на смене в департаменте начиная с определенного времени. Куда можно внести длину смены (4 часа) и даже ограничить возможные точки начала смены, но мне лениво
      В чем проблема read committed?

      В Вашем примере не хватает деталей, к сожалению. Предположу, что ради простоты и наглядности pk является (id, depId, empId, time), т.е. в одно и то же время в одном департаменте может быть несколько дежурных (и это не один и тот же человек). Если мы хотим обеспечить ограничение, что всегда есть хотя бы 1 дежурный, то нам потребуется select + delete. И тут надо либо serializable, либо select for update, как Вы и указали (как и в первоначальном примере). Но почему просто не использовать serializable?


      1. Batalmv
        01.07.2024 09:32
        +2

        С тех пор многое изменилось. Диски и сеть стали в разы быстрее. СУБД тоже стали лучше. А методология разработки обычно говорит, что оптимизировать надо только тогда, когда это необходимо.

        Дело в том, что оптимизация хорошо работает так:

        Делаешь основу, потом что-то добавляешь. Идеально - когда вставляешь кеш. Делаешь напрямую, потом не ломая добавляешь.Т.е. очень важно, когда возможность оптимизации заложена (условно есть интерфейс), к которому всегда можно прикрутить реализацию

        С многопоточностью так не работает.

        Это базовое требование, которое должно быть ОСМЫСЛЕННО реализовано. И проблема именно в осмысленности. serializable сам по себе ничего не решает от слова совсем. Ну выполнились транзакции последовательно, или ... не выполнилась одна, что стало более возможно. А что хотели?

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

        И это неудивительно: я не знаю ни одного программиста, который бы не делал ошибок (в т.ч. в СУБД, ОС, компиляторах и т.п.). При этом выявлять concurrency баги в приложениях БД очень трудно

        Да, потому что надо думать, когда кодишь :)

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

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

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

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

        В Вашем примере не хватает деталей, к сожалению. Предположу, что ради простоты и наглядности pk является (id, depId, empId, time), т.е. в одно и то же время в одном департаменте может быть несколько дежурных (и это не один и тот же человек).

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

        Но почему просто не использовать serializable?

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


      1. Batalmv
        01.07.2024 09:32
        +2

        Самое веселое, я решил потратить пару минут жизни и глянуть, что там в доке на PostgreSQL 16, которы вы же и взяли

        Это просто прекрасно, честно

        https://www.postgresql.org/docs/current/transaction-iso.html#XACT-REPEATABLE-READ

        UPDATEDELETEMERGESELECT FOR UPDATE, and SELECT FOR SHARE commands behave the same as SELECT in terms of searching for target rows: they will only find target rows that were committed as of the transaction start time. However, such a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the repeatable read transaction will wait for the first updating transaction to commit or roll back (if it is still in progress). If the first updater rolls back, then its effects are negated and the repeatable read transaction can proceed with updating the originally found row. But if the first updater commits (and actually updated or deleted the row, not just locked it) then the repeatable read transaction will be rolled back with the message

        ERROR:  could not serialize access due to concurrent update
        

        because a repeatable read transaction cannot modify or lock rows changed by other transactions after the repeatable read transaction began.

        Т.е. мы будем получать кучу ошибок и начинать сначала

        -----------

        Но следующий уровень еще прекраснее, я даже могу словить fail на обычном select, просто потому, что кто-то шарится рядом :)

        никаких side effects, абсолютно безвредно, используйте https://www.postgresql.org/docs/current/transaction-iso.html#XACT-SERIALIZABLE

        :)

        Если серьезно, вам бы к Остеру в соавторы податься


        1. eivanov Автор
          01.07.2024 09:32

          С многопоточностью так не работает.

          Это базовое требование, которое должно быть ОСМЫСЛЕННО реализовано. И проблема именно в осмысленности. serializable сам по себе ничего не решает от слова совсем. Ну выполнились транзакции последовательно, или ... не выполнилась одна, что стало более возможно. А что хотели?

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

          Я с Вами в этом никак не соглашусь. И мне кажется, что пример с многопоточностью в джаве в данном случае не совсем уместен. Когда у меня многопоточное приложение, это моя ответственность за то, чтобы оно работало правильно. Мы же говорим с Вами о транзакциях и concurrency control (строго говоря речь не о многопоточности, а о конкурентном выполнении). Приложение может быть однопоточным и отправлять асинхронные запросы (транзакции, шаги транзакций) в базу. Конкурентное/параллельное (и в следствие многопоточное) выполнение - всё это происходит внутри СУБД. Моя позиция заключается в том, что concurrency control - часть СУБД, и это ответственность СУБД сделать так, чтобы транзакции были ACID. Но никак не пользователь должен управлять блокировками, concurrency и т.п. Я считаю, что стандарт SQL это отражает и именно поэтому там по умолчанию уровень изоляции "serializable".

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

          Я не понимаю, почему в контексте нашего обсуждения "serializable" - это костыль, а "select for update" нет. Я бы сказал, что наоборот :)

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

          Решает: работало неправильно - стало работать правильно. А о локах должны думать разработчики СУБД.

          Т.е. мы будем получать кучу ошибок и начинать сначала

          Могу привести в пример TPC-C, где обычно от базы требуется repeatable read / serializable (если не делать это на стороне клиента). На 8000 транзакций в секунду (это соответствует 16K warehouses с efficiency около 100% и это очень много) приходится всего 300-400 ошибок/с сериализации. Т.е. примерно 4%. Безусловно, есть приложения, где это гораздо более критично: вот тогда можно подумать о более слабом уровне изоляции и использовать контроль со стороны приложения. И всё, что я пытался донести, это то, что надо двигаться от serializable вниз при необходимости, а не наоборот.


          1. Batalmv
            01.07.2024 09:32
            +2

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

            и тут

            Моя позиция заключается в том, что concurrency control - часть СУБД, и это ответственность СУБД сделать так, чтобы транзакции были ACID.

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

            СУБД ничего не делает с точки зрения выполнения требований, она всего лишь определяет, как и что она делает на низком уровне

            Я не понимаю, почему в контексте нашего обсуждения "serializable" - это костыль, а "select for update" нет. Я бы сказал, что наоборот :)

            Потому что в случае "select for update" - я знаю, что я делаю. А знает ли разработчки хотя бы базовые нюансы, описанные тут: PostgreSQL: Documentation: 16: 13.2. Transaction Isolation - скорее всего нет.

            Ну вы же почему-то ничего не написали про ограничения и нюансы режимов изоляции. отличных от read committed :)

            Решает: работало неправильно - стало работать правильно. А о локах должны думать разработчики СУБД.

            Нет, о них должны знать все, кто делает что-то в базе. Либо, сорян - ОРМ, аннотация и чуть-что - зовем базоида :)

            И всё, что я пытался донести, это то, что надо двигаться от serializable вниз при необходимости, а не наоборот.

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

            4% ошибок - ну вы же понимаете, что остальные 96% сработали бы одинаково в любом раскладе :)

            Ну и смотря как посмотреть, если у вас миллион в день, то 4% == 40000 :)

            Ну и все таки, мне как-то веры в доку разработчика базы больше :)

            ------------------------

            Ну и я больше доверяют тому разработчику, которые подумал над требованиями и сделал, чем тому, который говорит - я всегда использую аннотацию/Serializable потому что база все делает за меня :)


            1. eivanov Автор
              01.07.2024 09:32

              Ну и я больше доверяют тому разработчику, которые подумал над требованиями и сделал, чем тому, который говорит - я всегда использую аннотацию/Serializable потому что база все делает за меня :)

              Тут каждый решает сам для себя: так и репликацию можно самому делать. Тогда базе останется только буква A и половинка D из ACID :)

              Ну вы же почему-то ничего не написали про ограничения и нюансы режимов изоляции. отличных от read committed :)

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


              1. Batalmv
                01.07.2024 09:32
                +1

                Тут каждый решает сам для себя: так и репликацию можно самому делать. Тогда базе останется только буква A и половинка D из ACID :)

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

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

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

                Кстати, если верить доке, в вашем примере, если Алиса и Боб одновременно хотят "свалить", то условно Алиса сделает операцию, а Боб вообще получит ошибку еще на открытии формы, так как даже SELECT не пройдет :)

                Ну ... понятно, все можно победить, но ... это будет нежданчик

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

                Согласен, просто даже дока того же PostgreSQL дает куда больши пищи для размышлений :)

                А без этого фокус на "стартуйте с Serializable Isolation Level" нельяз сказать, чтобі явно противоречит ей, но как минимум сильно диссонирует


                1. eivanov Автор
                  01.07.2024 09:32

                  В голой базе нет вообще ничего

                  Как разработчик СУБД, я бы поспорил с этим утверждением :)

                  любую бизнес задачу решает база + КОД

                  Безусловно. Но не надо делать в коде то, что делает база. А если очень нравится писать код СУБД, то лучше его для СУБД и писать :)


                  1. Batalmv
                    01.07.2024 09:32

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

                    Как разработчик СУБД, я бы поспорил с этим утверждением :)

                    Для реализации БИЗНЕС требований - ничего :) А если подняться до этого уровня, точнее когда - тогда и не будет проблем с уровнем изоляции :)


            1. erkinaltai
              01.07.2024 09:32

              Потому что в случае "select for update" - я знаю, что я делаю.

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


              1. Batalmv
                01.07.2024 09:32

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

                А можете указать, как именно грузится "диск" в данном случае? Просто реально интересно.


                1. erkinaltai
                  01.07.2024 09:32

                  При явной блокировке через SELECT FOR UPDATE Postgresql изменяет блокируемую строку, это приводит к записи на диск.
                  При Serializable же используется свой механизм предикативной блокировки, который по факту не приводит к изменениям строк, строки не блокируются, просто когда идет фиксация транзакции, Postgres выявляет аномалии, если он их обнаружил, то обрывает транзакцию.


  1. michael_v89
    01.07.2024 09:32

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

    Для блокировки из приложения можно использовать функции БД pg_advisory_lock / GET_LOCK, они принимают произвольный идентификатор. Или использовать сторонние системы типа Redis. Надо вызывать эти функции для любой бизнес-логики, связанной с изменением данных. Так обычно не делают, и это неправильно. Идентификатор можно формировать как "название сущности + разделитель + первичный ключ", иногда есть смысл использовать название бизнес-процесса.

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


    1. geradeaus
      01.07.2024 09:32

      и все это время транзакция будет открыта.

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


      1. michael_v89
        01.07.2024 09:32

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


  1. MANAB
    01.07.2024 09:32

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


    1. eivanov Автор
      01.07.2024 09:32

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

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


  1. SergeyPo
    01.07.2024 09:32

    Бывало задумывался над тем какой уровень изоляции правильно использовать с практической точки зрения, но все никак не мог изучить этот вопрос на должном уровне. Теперь все понятно :) Спасибо за статью!


    1. eivanov Автор
      01.07.2024 09:32

      Рад, что статья оказалась полезной для Вас.


  1. jobber_man
    01.07.2024 09:32
    +1

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

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

    Цитата из документации:

    Range locks are placed in the range of key values that match the search conditions of each statement executed in a transaction. This blocks other transactions from updating or inserting any rows that would qualify for any of the statements executed by the current transaction. This means that if any of the statements in a transaction are executed a second time, they will read the same set of rows. The range locks are held until the transaction completes. This is the most restrictive of the isolation levels because it locks entire ranges of keys and holds the locks until the transaction completes. Because concurrency is lower, use this option only when necessary.

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


    1. eivanov Автор
      01.07.2024 09:32

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

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

      1. Если надо учесть constraint, включающий в себя подобный широкий диапазон строк, то обычно не так важно, где произойдет сериализация: в базе или на стороне клиента (особенно когда ping небольшой).

      2. Как часто транзакции, которые требуют сериализацию, пересекаются по диапазону ключей, на который берется лок. Если у приложения требование, скажем, 50 ms на p99, а транзакции выполняются за миллисекунду + сеть, то сериализация небольшого числа таких транзакций не будет проблемой.

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


      1. jobber_man
        01.07.2024 09:32
        +1

        обычно не так важно, где произойдет сериализация: в базе или на стороне клиента

        Если у вас сериализация на клиенте, то зачем вам транзакции в базе?

        Если у приложения требование, скажем, 50 ms на p99, а транзакции выполняются за миллисекунду + сеть, то сериализация небольшого числа таких транзакций не будет проблемой.

        А может и будет. В примере выше нередко бывает так, что сериализация это дедлок и отстрел транзакции-жертвы секунд через 30. На три порядка выше целевого по p99, упс.

        Полностью с вами согласен, что serializable по умолчанию - безопаснее. Аппелировал к сильному утверждению, что нет доказательств, что производительность "serializable" ощутимо хуже. С оговоркой "в большинстве случаев" было бы нормально. Ещё лучше было бы сделать оговорку, что при невысокой concurrency. Т.к. при высокой конкуренции, даже без блокировок, множественные ретраи могут эту производительность убить. Другие уровни изоляции именно для решения данной проблемы и были оставлены.


        1. eivanov Автор
          01.07.2024 09:32

          Полностью с вами согласен, что serializable по умолчанию - безопаснее. Аппелировал к сильному утверждению, что нет доказательств, что производительность "serializable" ощутимо хуже. С оговоркой "в большинстве случаев" было бы нормально. Ещё лучше было бы сделать оговорку, что при невысокой concurrency. Т.к. при высокой конкуренции, даже без блокировок, множественные ретраи могут эту производительность убить. Другие уровни изоляции именно для решения данной проблемы и были оставлены.

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

          А может и будет. В примере выше нередко бывает так, что сериализация это дедлок и отстрел транзакции-жертвы секунд через 30. На три порядка выше целевого по p99, упс.

          Хорошее замечание. В Postgres дефолтный таймаут для дедловок 1 секунда и это минимальное рекомендуемое значение. Я привык к тому, что в YDB оптимистичные блокировки и дедлоков нет: кто первый, тот и закомитился - остальные сразу получили ошибку "transaction locks invalidated". Поэтому в большинстве случаев останется достаточно времени на ретрай.

          Если у вас сериализация на клиенте, то зачем вам транзакции в базе?

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


  1. AlexeyK77
    01.07.2024 09:32

    25 лет назад, когда СУБД были вообще центром всего в книгах и статьях относительно обеспечения целостности транзакций был очень важный раздел про то, что такое блокировки, типы блокировок и какие алгоритмы, а значит и стоимость по ресурсам используются для обеспечения этих блокировок. Без этой основы не возникнет инженерного понимания и может возникнуть соблазн перейти исключительно на serializable.

    Я уже дано этим не знаимаюсь, но в памяти всплывают особенности: table level, page level, row level блокировок и алгоритмов их автоматической эскалации. Где зранится информация о блокировках: в памяти, на диске. Типы блокировок: intent, exclusive, shared. Выделение пулов памяти для хранение информации о блокировках (сейчас может все автоматом, но во времена Sybase ASE 11,12 или древнего оракла это надо было знать и мониторить). И это только начало, мастхэв юного писателя логики на СУБД.