Pull to refresh

Comments 38

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

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

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

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

Честно говоря, всю сознательную жизнь в роли разраба хранимок под Оракле, да и потом я использовал только стандартный уровень изоляции, он же 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 и все

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

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

Можно написать: 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?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Самое веселое, я решил потратить пару минут жизни и глянуть, что там в доке на 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

:)

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

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

Это базовое требование, которое должно быть ОСМЫСЛЕННО реализовано. И проблема именно в осмысленности. 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 вниз при необходимости, а не наоборот.

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

и тут

Моя позиция заключается в том, что 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 потому что база все делает за меня :)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

При явной блокировке через SELECT FOR UPDATE Postgresql изменяет блокируемую строку, это приводит к записи на диск.

Прикольно, почитал доку ... короче PostgreSQL до Оракла еще работать и работать :)

Что интересно, это явно указано в доке, хотя сами "локи" живут в памяти

Хотя, я тут имею гипотезу, которую вы можете исправить:

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

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

Плюс ... данные то все равно потянуться, так как слово SELECT в конструкции упомянуто не зря, да и по логике эти данные нужны

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

Прикольно, почитал доку ... короче PostgreSQL до Оракла еще работать и работать :)

Некорректно сравнить. Oracle корректнее сравнить с тем же PostgresPro, но ни как с базовой бесплатной PostgreSQL.

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

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

Если почитать документацию, то можно понять. Что при Serializable нет необходимости делать блокировку, достаточно отслеживать последовательность запросов без сохранения блокировок на диск, а вот при SELECT FOR UPDATE нужно явно сохранять стейт блокировки на диск, в случае например отключения света. Иными словами, каждым SELECT FOR UPDATE мы вынуждаем СУБД уже делать запись на диск, без разница, успешна, или не успешна транзакция, такого при Serializable нет.

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

Не все так просто. Количество прерванных транзакций зависит от того, как настроен расход памяти за счет параметров max_pred_locks_per_transaction и max_connections. Тут надо искать баланс между расходом памяти и производительностью.
Более того. Производительность сильно зависит от корректности запроса.
Также надо учитывать, что предикативные блокировки учитывают индекс, хотя не всех типов, но b-tree и ряд других распространённых уже давно поддерживаются.

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

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

Зачем? Блокировки - это часть работы именно движка, а не данных. Без транзакций, которые их сделали, они не имеют смысла и должны быть удалены по факту удаления транзакции/сессии. Я просто был удивлен, что вообще эта запись нужна.

Возможно это простой способ обойти необходимость шарить "знание" в кластерном режиме

Иными словами, каждым SELECT FOR UPDATE мы вынуждаем СУБД уже делать запись на диск, без разница, успешна, или не успешна транзакция, такого при Serializable нет.

Ну так уже произошло чтение с диска, какая разница? Или PostgreSQL, даже если рекорд уже в буфферах, пишет все равно на диск сразу?

А чтение произойдет всегда, потому что оно нужно для бизнес логики

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

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

Также надо учитывать, что предикативные блокировки учитывают индекс, хотя не всех типов, но b-tree и ряд других распространённых уже давно поддерживаются.

Ну это для все блокировок, как минимум так, где это возможно. Как раз этот нюанс я словил, когда foreign key не был подкреплен индексом. В этом случае ставился table lock и ловим deadlock. Но это как пример

Что все узкие места при Serializable уровне прекрасно находятся + ты получаешь уверенность в изоляции.

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

Возможно это простой способ обойти необходимость шарить "знание" в кластерном режиме

Тут не знаю. В такие подробности не уходил. Частично может это связано с режимом горячего резерва или архитектуры в целом. Функционал СУБД далеко уходит за границы ACID транзакций.

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

Но ведь SELECT FOR UPDATE как раз и дает эту иллюзию. В курсе что такой запрос подвержен аномалиям транзакций согласно спецификации SQL?

Но ведь SELECT FOR UPDATE как раз и дает эту иллюзию. В курсе что такой запрос подвержен аномалиям транзакций согласно спецификации SQL?

Нет :)

А что за аномалия?

А что за аномалия?

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

Да я понял. Я к тому какие могуть быть там аномалии если я использую SELECT FOR UPDATE?

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

Read Committed is the default isolation level in PostgreSQL. When a transaction uses this isolation level, a SELECT query (without a FOR UPDATE/SHARE clause) sees only data committed before the query began; it never sees either uncommitted data or changes committed by concurrent transactions during the query's execution. 

Т.е. если просто читать, а это надо для отчета - читай и все будет прекрасно :)

Но автор сознательно активирует

UPDATEDELETESELECT 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 command 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 would-be updater 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 second updater can proceed with updating the originally found row. If the first updater commits, the second updater will ignore the row if the first updater deleted it, otherwise it will attempt to apply its operation to the updated version of the row. The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition. If so, the second updater proceeds with its operation using the updated version of the row. In the case of SELECT FOR UPDATE and SELECT FOR SHARE, this means it is the updated version of the row that is locked and returned to the client.

После чего деланно удивляется ... ну вот, фигня

Выглядит как банальное засовывание пальцев в розетку с последующим удивлением "а меня то за что"?

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

Установка по умолчанию уровня Serializable это хороший подход, который снизит когнитивную нагрузку с команды разработки и повысит надежность бизнес логики, особенно на начальном этапе проекта. Этот подход нельзя назвать антипаттерном, и утверждать что это выбор ленивых разработчиков, довольно спорный пункт. Особенно учитывая что мы с 12-ой версии PostgreSQL имеем возможность при этом уровне изоляции распараллеливать запросы, что в купе со стандартным паттернами пулов запросов к БД дает существенный прирост в производительности на фоне поддержки индексов и избегания записей на диск.

Не призываю всем использовать Serializable уровень по умолчанию, лишь считаю это одним из хороших выборов в архитектуре приложения и его явно стоит рассматривать в тех или иных ситуациях. Надо понимать что БД лишь один из компонентов, и бывают OLTP и OLAP виды нагрузок в разных соотношениях и абсолютных цифрах объема за единицу времени. Для многих более легким способом будет выбор выстраивания работы с данными в виде ядра данных на основе PostgreSQL с уровнем изоляции Serializable + битмапы на Redis, очень простая, надежная и производительная схема.

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

Да, Oracle и старые версии PostgreSQL приучили нас в свое время, что Serializable не соответствует спецификации SQL, и могут возникать аномалии транзакций, но на дворе 2024-ый год, и архитектура СУБД развиваются постоянно, а с ними и производительность как самих СУБД, так и инструментов и архитектурных паттернов.

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

Спасибо, думаю вы меня убедили

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

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

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

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

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

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

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

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

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

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

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

Мы не нашли доказательств того, что производительность уровня изоляции «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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Sign up to leave a comment.