Pull to refresh

Comments 116

проблема тривиальна, а решение… не очень

ну решение тоже более-менее стандартно вроде бы.
блокировка перед изменением «общего» ресурса вроде обычный подход, нет?
Ответил ниже
SWMR lock, либо stored procedure. Откат транзакций слишком тесно связан с координатором транзакций (т.е. СУБД), чтобы завязывать эти откаты на клиента.
В целом — да. Но здесь пересекаются два типа блокировок: блокировки кода и блокировки базы данных, что добавляет пикантности. Я не исключаю, что и финт с innerTransaction — тривиальнейшая вещь, но так как я к ней пришел методом проб и ошибок, мне так не показалось. Ну и, как я писал, мне не удалось найти постов, касающимся этого вопроса (при всей его тривиальности).
Недавно я решал похожую задачу, но для SQl Server.

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

Именно поэтому я остановился на варианте выполнения проверки и вставки новой записи на уровне БД.

Примерно так:
INSERT INTO T WITH (TABLOCKX)
([To], [From], Name, ...)
SELECT @To, @From, Name,…
WHERE NOT EXISTS (SELECT * FROM T WHERE Name = @Name ...)

Ключевой момент здесь — WITH (TABLOCKX)

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

Поясните, пожалуйста, за счет чего решение блокировками кода имеет меньшую масштабируемость? И с чем, собственно, сравниваем?

Честно говоря, не разобрался в коде. Вы сначала вставляете, а потом делаете выборку. Зачем? И что далет Tablockx? Блокирует всю таблицу?
Нет, показанный в примере запрос это запрос вида INSERT FROM SELECT

т.е. мы вставляем то, что нашли. SQL так хитро устроен, что не всегда все происходит в таком порядке, как написано :-)

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

TABLOCKX это блокировка таблицы на время выполнения одной инструкции с этой таблицей (тут я могу быть не точным — специально в мануал сейчас не смотрел). Т.е. другие такие же запросы будут ждать пока не отработает этот. И никаких явных транзакций!

Теперь понял, спасибо. Тем не менее, мне кажется, что этот код решает другую задачу. Ваш код решает проблему с атомарностью выполения select+insert, которой у меня как раз и не было. Ну хорошо, в рамках одной транзакции (ведь select в вашем коде происходит в соответствии с правилами IsolationLevel текущей транзакции) ваш код выполнится и вставит запись при необходимости. Но это совершенно не решает проблему, озвученную в посте: добавленное значение не будет видно другим транзакциям (если, конечно, речь не о Read Uncommitted).
TABLOCKX не блокирует таблицу на время транзакции. Только на время выполнения одной инструкции. Поэтому новые записи будут видны другим транзакциям. Я не ручаюсь за все сказанное на 100%, поскольку я не обладаю познаниями DBA в SQL Server, но у меня все прекрасно работает в таком виде.
А здесь не важно, на сколько инструкций TABLOCKX блокирует базу данных. Важно то, что ваша команда будет выполняться в рамках транзакции с некоторым IsolationLevel (общая транзакция, которая начиналась в методе Start и позволяла откатиться при необходимости, ведь никуда не делась), и она не увидит данные, вставленные другой транзакцией, пока та не закоммититься (повторюсь, если не используется Read Uncommitted).
Я рассматриваю свой пример в контексте своего кода, а не вашего. В моем случае, на всю библиотеку нет ни одной явной транзакции. Т.е. в коде удалось избежать использования транзакций.
Ну мы же в комментариях решаем не вашу проблему, а проблему, обозначенную в статье? ;) И эту проблему, судя по всему, ваш код не решает.
Точнее, вы хотите сказать, что у Вас используется подход с autocommit?
А ведь dieron уточнил, что нет именно «явных» (explicit) транзакций. Так что да, autocommit (ну или как это называется в SQL Server)
причем этот код без доп. телодвижений и проблем работает только в случае нахождения БД на том же компьютере.
когда БД на другой машине, TransactionScope работает через distributed transactions, а настроить ее и заставить работать то еще удовольствие. Ну и на быстродействии это сказывается очень сильно. Для более менее хайлоада это вообще табу.
Давайте по пунктам.

Да, при использовании в рамках TransactionScope более одного соединения, транзакция становится распределенной? У меня не возникало проблем ни с настройкой, ни с тем, чтобы заставить распределенные транзакции работать.

Насчет производительности. Вы не могли бы подтвердить свои слова каким-нибудь benchmark'ом? Я полагаю, что производительность распределенных транзакций действительно ниже производительности обычных, но не думаю, что это сказывается настолько, что это стало бы «табу» и bottleneck'ом.
И да, я не уловил в вашем посте связь между распределенностью транзакции и нахождением базы данных на том же компьютере. Насколько я знаю, распределенность транзакции определяется использованием нескольких соединений, а не удаленностью базы данных.
Если честно, то я давно с этим сталкивался, но по моему распределенные транзакции возникали если в коннекте к БД был указан не локальный адрес.
Каюсь, сейчас освежил память, действительно, DTC зависит от кол-ва конектов.
Наш DBA только при упоминании использования транзакций в коде, хватается за раскаленную кочергу.
Возможно, в рамках решаемых вами задач это оправданно. Но фанатизм здесь ни к чему.

DBA: Не используем транзакции в коде!!!111
Developer: А если транзакция включает ресурсы помимо записей базы данных?
DBA: Не используем транзакции в коде!!!111
Developer: А если у нас есть сто тысяч строк кода на C#, в который нужно добавить поддержку транзакций, причем реализовать это можно за человеко-неделю. Если же портировать этот код на PL-SQL/T-SQL/..., то потребуется гораздо больше ресурсов, усложнится отладка и написание тестов?
DBA: Не используем транзакции в коде!!!111
и т. д.

Если приведенный диалог возможен при разговоре с вашим DBA, то пора бы уже и вам взяться за раскаленную кочергу.
Я согласен, что не бывает единственно верного решения, всегда есть нюансы. Но к примеру у нас в проекте (приличный хайлоад) таких ситуаций не возникало.
Вы часто приводите в пример Ваш проект. Хорошо бы уже тогда озвучить задачу, чтобы примерно представлять размеры и сложность проекта.
А разве не «внешний» (по отношению к БД) код должен управлять транзакциями? Ведь одно нажатие на кнопку может выражаться в вызове, например, нескольких ХП, которые должны быть либо выполнены либо нет. Я не совсем понимаю где, в этом случае, транзакция должна начинаться и завершаться, если не в коде приложения?
Ну мне кажется что управление транзакцией в БД должна управлять сама БД, но это мое мнение.
В разных ситуациях по разному, если приложение не критично по производительности, то такой вариант очень уместен в силу легкости его поддержки, читаемости и т.д., но когда встает вопрос производительности, то этот подход уже не катит.
Скорее всего мы говорим о разных типах задач, так что видимо оба подхода имеют право на жизнь, хотя мне ближе мой :)
Если честно, я не вижу смысла выносить логику GetOrCreateCompany в код, это гораздо легче и правильней делать на стороне БД.
А ради чего? Что нам даст вынос логики GetOrCreateCompany в хранимую процедуру?
Как я уже писал, это как-минимум поможет сделать ваш код масштабируемым. Но, если вам точно не нужно будет в будущем запускать этот код на больше чем одной машине одновременно — то тогда лучше все блокировки реализовывать исключительно в коде. Хотя везде есть свои ньюансы.
То есть выше под масштабируемостью вы имели в виду возможности запуска этого кода на нескольких машинах? Для этого нужно лишь написать соответствующий CurrentThreadLocker, и я не вижу в этом большой проблемы.
А я как-раз не вижу тривиального способа реализовать CurrentThreadLocker в условиях, когда код запускается на нескольких машинах, разбросанных по интернету :-)

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

Во-первых, для блокировки нам нужен некоторый общий ресурс. На эту роль есть очевидный кандидат — база данных. Заведем табличку CurrentThreadLocks и будем записывать в поля, занят ли мьютекс с тем или иным именем. Атомарность чтения+записи реализуем за счет средств конкретной СУБД.

Я тут еще загуглил distribute mutex. Количество релевантных статей как бы намекает на то, что с реализацией распределенных мьютексов проблем нет :)
Я, кстати, так и не понял, чем вас не устраивает стандартная конструкция lock () {}, что вы реализовали свою? Кроме lock() в .Net есть еще несколько средств для синхронизации потоков, на любой вкус, так сказать. Да и вызывать .Lock в одном методе, а освобождать ресурс в другом не очень красиво :-) Посмотрите как можно использовать IDisposable в связке с using для неявного освобождения ресурсов. И ваш CurrentThreadLocks может обрести человеческое лицо несмотря на то, что он наверняка дублирует уже существующий класс.

«Заведем табличку CurrentThreadLocks» — и будем мучаться с кодом для синхронизации еще и этой таблички :-)
Я, кстати, так и не понял, чем вас не устраивает стандартная конструкция lock () {}, что вы реализовали свою?

CurrentThreadLocks выполняет две основные функции:
1. Реализует рекурсивный Lock таким образом, чтобы для выхода было достаточно один раз вызвать Exit. Напомню, Monitor.Enter позволяет потоку, владеющему ресурсом, повторно его залочить (например, если будет несколько вызовов метода GetOrCreateCompany), но при этом для освобождения ресурса необходимо столько же раз вызывать для него Monitor.Exit. Благодаря реализуемому CurrentThreadLocks подходу я могу в конце транзакции лишь один раз вызвать Exit для каждого ресурса. Рекурсивные локи считаются плохой практикой, но я пока не вижу, как бы это можно было отрефакторить.
2. Управляет объектами синхронизации для данного потока.

«Заведем табличку CurrentThreadLocks» — и будем мучаться с кодом для синхронизации еще и этой таблички :-)

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

Я тоже думал, что первый пункт функционала CurrentThreadLocker'а дублирует какой-нибудь стандартный примитив, но не обнаружил такового.
Даст более быстрый код (не для всех проектов это принципиально), легче отлаживать и искать баги, ну и соответственно нет тех проблем про которые статья.
Конкретно в вашем примере вместо одного конекта и запроса к БД получается 2.

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


Вы серьезно? Код базы данных легче отлаживать и искать в нем баги? Может и стабы с моками легче писать :)

, ну и соответственно нет тех проблем про которые статья.


Разве? Каким образом?
Конкретно в вашем примере вместо одного конекта и запроса к БД получается 2.

ConnectionPool + широкий канал до базы сводят это к миллисекундам. В рамках этого поста меня все же интересует решение принципиальной проблемы, а не экономия нескольких миллисекунд (в моем случае это является предварительной оптимизацией в самом худшем ее проявлении).
Единственное, необходимо добавить, что в данном подходе необходимо иметь несколько «резервных» коннектов для выполнения таких вот операций с двумя коннектами. Так что, ИМХО, в высоконагруженной системе имеет смысл подумать еще раз, прежде чем использовать отдельный коннект.
Согласен. Точнее и не скажешь, нам нужен именно «резервный» connection, потому что мы не знаем, будет ли TransactionScope использовать один или два соединения. Хотя, с другой стороны, если для второго запроса он воспользуется вторым соединением, первое-то не будет простаивать — его начнет использовать другой поток.
Мне кажется конкретно в вашем случае транзакции в коде только добавляют проблем.
А какие есть альтернативы?
легче отлаживать и искать баги

Вы серьезно? Код базы данных легче отлаживать и искать в нем баги? Может и стабы с моками легче писать :)

, ну и соответственно нет тех проблем про которые статья.

Разве? Каким образом?
А какая вообще задача перед вами стоит? Что это за система такая, в которой множество потоков может паралельно создавать обьекты с одинаковыми именами в базе, при чем с такой частотой, что это превращается в серьезную проблему? Ну если это не секрет, конечно.
Да и операция GetOrCreate сама по себе выглядит как-то странно, никогда с такой не встречался. Нарушается принцип single-responsibility — операции чтения должны только читать (при этом операции записи могут возвращать результат своей работы, но не наоборот).
В общем, у меня такое впечатление, что у вас проблемы на более высоком уровне — на уровне самой архитектуры, поетому такая внешне простая задача и решается столь извращенным способом (в силу своей нестандартности).
похоже на парсер больших массивов
А зачем парсеру результат метода Get? Там скорее должно быть InsertOrUpdate или же InsertIfNotExists. Все равно ж непонятно.
так многопоточный же
А зачем парсеру результат метода Get?
А что в этом странного? Workflow примерно следующий:
1. Некоторый метод получает строку — имя компании
2. Он отдает строку и хочет получить в замен идентификатор компании: не важно, придется ли для этого считать существующую или создать новую
Простое наблюдение: если имя компании уникально, то оно и есть идентификатор, который однозначно ее определяет. Если у вас распределенная система и вопрос стоит в генерировании уникальных идентификаторов, а имя компании для этих целей почему-то не подходит, то стоит обратить внимание на те же GUIDы — они для этого и созданы.
какая вообще задача перед вами стоит? Что это за система такая, в которой множество потоков может паралельно создавать обьекты с одинаковыми именами в базе, при чем с такой частотой, что это превращается в серьезную проблему? Ну если это не секрет, конечно.
Да ничего особенного. Просто есть движок, которому на вход подается очень много входных данных для параллельной обработки. Обработка в том числе включает и операции типа GetOrCreate.
Да и операция GetOrCreate сама по себе выглядит как-то странно, никогда с такой не встречался. Нарушается принцип single-responsibility — операции чтения должны только читать (при этом операции записи могут возвращать результат своей работы, но не наоборот).
Странно? По-моему обычная сервисная операция (на самом деле это и есть сервисная операция, принимающая в виде зависимости два репозитория; выше приведен упрощенный пример). Не думаю, что GetOrCreate нарушает SRP: я не вижу у него более одной причины для изменения.
В общем, у меня такое впечатление, что у вас проблемы на более высоком уровне — на уровне самой архитектуры, поетому такая внешне простая задача и решается столь извращенным способом (в силу своей нестандартности).
Я думал об этом… но архитектурного решения описанной проблемы не нашел: все упирается в транзакции — хоть ты тресни.
Просто есть движок, которому на вход подается очень много входных данных для параллельной обработки
Сферический в вакууме? В реальной жизни входные данные откуда-то беруться. Я пока что не вижу ни одной причины, по которой вообще нужно вставлять что-то в базу данных при первом запросе обьекта по имени. Если весь сыр бор из-за того, что вам нужно генерировать идентификатор, то выше я уже написал: используйте GUID.
Вполне может быть что из-за этой задачи никто не позволит структуру БД менять. Я бы зарезервировал на БД дипазон идентификаторов, не знаю правда есть ли такое в myqsl, и генерил бы ключи локально.
Ох не люблю я вот этого, когда и задачу решить надо и структуру БД поменять при нужде нельзя. Сам в свое время сталкивался с такой политикой. Повбывавбы.
Если так, то кейс очень извращенный и поэтому решение тоже будет извращенным. Кстати, ниже предлагают клевое решение с блокировкой по хешу — я бы обратил на него внимание.
Пожалуйста, не придумывайте за меня ограничения. Если бы такое ограничение было, я бы его оговорил.
Прошу прощения, если ненароком запутал дискуссию.
А я никак не пойму, почему вам операция GetOrCreate кажется такой странной.

Пример:
Некоторой функции на вход подается строка с именем компании. Ей нужен объект, соответствующий этой компании. Объект, это не только идентификатор и название, это еще и все остальные свойства объекта и связанных сущностей. Эта функция, отдает название компании функции GetOrCreate, которая уже в свою очередь считывает, а при необходимости — добавляет новую. Вот и все.
А в чем проблема сделать таблицу company_locks (name_hash_mod_1024 number not null), заполнить числами от 0 до 1023 и перед вставкой делать select for update?
Вы не могли бы немного подробнее описать предлагаемое решение?
Идея в том чтобы перенести блокировки на уровень БД но при этом не блокировать всю таблицу на время выполнения транзакции. Итак
Прелюдия:
1) Создаем таблицу company_locks с одной колонкой id типа number.
2) Вставляем туда 1024 записи с значением id от 0 до 1023.

Сам процесс:
3) В самом начале метода GetOrCreateCompany делаем запрос который блокирует запись в таблице company_locks c id = companyName.hashCode() % 1024.

Это нам гарантирует, что вставка одинаковых записей будет выполняться последовательно, а вставка разных записей с большой вероятностью параллельно.
Примеры запросов, которые блокируют запись: select * from company_locks where id =? for update или update company_locks set id = id where id =?
А что мне это даст? Да, я перенесу блокировку на уровень базы данных (при этом, к слову, потеряв в производительности блокировки), но суть-то от этого не поменяется: мне эту блокировку так же нужно будет держать до коммита завладевшей ей транзакции.
Добавлю, что ваше решение направлено в сторону distributed lock'ов, которое уже не раз поднималось в комментариях. Это, безусловно, будет кстати, когда мне нужно будет работать из более, чем одного процесса. Но к самой сути топика, к сожалению, имеет мало отношения: основная проблема заключается в необходимости протаскиваня локов до коммита с целью, чтобы другие транзакции увидели изменения.
Почему я использую Serializable? Да потому что он прикрывает мою задницу. Есть ряд мест, в которых с невысокой вероятностью возможны конфликты, и их отдетектит только Serializable. Благодаря этому я могу пока не заморачиваться на разные ухищрения с локами, ибо знаю, что Serializable меня выручит. Когда я буду полностью уверен в своем коде, а в базе данных будут constraint'ы на все возможные конфликты (эти constaint'ы сейчас эмулируются Serializable), я, наверное, перейду на Read Committed.

В контексте предложенного мной решения у Read Committed есть одно серьезное преимущество. В режиме Read Committed я могу использовать по одному объекту синхронизации на одинаковый объект. В этом случае равенство объектов определяется прозрачно и понятно. В случае же Serializable я должен учитывать, какие локи наложит база данных на ту или иную выборку, поэтому определение равенства объектов превращается в муку (на сложных выборках), да к тому же определение равенства объектов с учетом особенностей реализации локов СУБД попахивает.

Но есть нюанс. Новая модификация, добавленная в Update, использует короткие локи, и даже с одним эксклюзивным локом кладет на лопатки предыдущее решение (даже в режиме Read Committed с количеством локов, равным количеству уникальных объектов). Причина одна: этой реализации не нужно протаскивать лок до коммита транзакции.
По мне так лучше поставить по-умолчанию Serializable, а потом где нужно убрать лишние блокировки хинтами. Ставить сразу Read Committed — по мне как преждевременная оптимизация.

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

Хотя я не бородатый DBA, могу быть неправ.
Собственно, вы ту же мысль высказали. Мы любим Serializable, потому что он прикрывает нашу безалаберность, некомпетентность, забывчивость и т. д. Он просто надежнее.
Зачем здесь вообще блокировка? Поле CompanyName делается уникальным, если Имя не найдено — просто INSERT, если вернулась ошибка — сделать повторное чтение.
Если нет возможности менять схему БД, то, вероятно, нет возможности добавить соответствующий constraint.
Пардон, не вижу условия о том, что схему менять нельзя. Но судя по описанию — это действительно тривиальная проблема, которая решается тривиальным unique constraint-ом.
Добавил Update с модифицированным решением
Делать блокировки в клиентском коде для разруливания обращений к базе — плохо. Например потому что с той же таблицей может работать и другой код, которому тоже нужно как-то синхронизироваться. Ну или если два процесса будет работать.

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

if (not exists select * from Companies where name = @name)
if (not exists select * from Companies with (updlock, serializable) where name = @name)
insert into Companies… blabla…

Кстати такой подход к блокировке называется Double-checked locking и часто встречается в статьях про синглтоны.

В принципе можно и сразу делать второй select, c with (updlock). Не знаю как это повлияет на производительность, нужно замерять на живом примере.
Делать блокировки в клиентском коде для разруливания обращений к базе — плохо. Например потому что с той же таблицей может работать и другой код, которому тоже нужно как-то синхронизироваться. Ну или если два процесса будет работать.
Согласен. Выше уже предлагались варианты переноса локов в базу данных. Но ведь проблема-то реально не в этом (неужели я бы не сделал распределенную блокировку, если бы нужно было работать из нескольких процессов?). Наверное, это я в статье неправильно расставил акценты, раз половина комментариев посвящены не основной проблеме.

Кстати такой подход к блокировке называется Double-checked locking и часто встречается в статьях про синглтоны.
Doble-checked locking (кстати, для синглтонов в чистом виде он не всегда работает из-за кэширования памяти) это прекрасно, но выполнять приведенные вами запросы вы будете в рамках одной транзакции (не видя незакоммиченные изменения других транзакций), а значит поставленную задачу это не решает.
Распределенные блокировки — нетривиальная задача. Кстати она очень похожа на ту задачу, которую ты с их помощью хочешь решить.

Насчет незакомиченных данных. Смотри как все будет:
— Транзакция 1 делает select with (updlock)
— Транзакция 2 делает select with (updlock). Т.к. первая транзакция уже залочила этот рейндж, вторая ждет
— Транзакция 1 делает insert и заканчивается (комиттит данные)
— Теперь транзакция 2 может продолжаться (блокировки от первой сняты). И она видит данные первой, т.к. они закомиччены. Т.к. запись есть и if не срабатывает.
Сейчас проверил — все работает как я сказал, но есть тонкости с range-блокировками:

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

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

В общем там нужно еще поэкспериментировать, но похоже лучше все-таки с double-checked locking делать.
Ммм… То есть вы предлагаете считывать не с read lock (как это по умолчанию делается в транзакциях), а с write lock? Интересно, что в общем-то именно такую логику я и эмулирую из кода (я подразумеваю, что вы тоже имели в виду двухуровневый подход: сначала считываем без блокировки, а если данных нет — уже считваем с блокировками). Решение перекинуть эту логику в базу данных, наверное, правильное, но сопряжено с некоторыми проблемами:

Во-первых, это не решит главной проблемы, касающейся протаскивания блокировки до коммита

Во-вторых, я уже выше говорил, что на клиенте я могу использовать по объекту синхронизации на каждый уникальный объект, а если я доверюсь локам базы, то блокировка будет происходить по правилам базы, и я уверен, что это будет менее эффективно: потому что из кода мне виднее, какие объекты являются повторяющимися, а какие нет. Это не очень серьезный недостаток (к тому же неактуальный для Serializable), и я просто решил его обозначить. Кстати, в режиме Serializable использование локов базы, а не кода, наверное, даже будет преимуществом. Ведь по умолчанию Serializable расставляет read-локи на range, и мне из кода приходится гадать, как он их там расставил. А так я просто буду ставить write-локи вместо read-локов и полностью доверюсь базе.

В-третьих, сейчас я использую ORM, и очень этим доволен. Мне неизвестен способ заставить ORM (в моем случае — Entity Framework) сгенерировать select c write-lock'ом. Видимо, в этом случае придется использовать голый SQL. Очень не хочется :)
Ага. Я предлагаю поднять блокировку чуть выше, это обычный способ избавления от дедлоков — ставить блокировки в правильном порядке.

> Во-первых, это не решит главной проблемы, касающейся протаскивания блокировки до коммита

Можешь пояснить этот момент?

Рассмотрим ситуацию, когда две транзакции пытаются добавить запись:
Transaction 1: Считал с блокировкой
Transaction 1: Добавил значение
Transaction 1: Если я сейчас сниму блокировку, то вторая транзакция его не увидит и попытается добавить свое значение. Поэтому я сниму транзакцию только после коммита.
Transaction 1: Коммит
Transaction 1: Снимаю блокировки
Transaction 2: Понеслась
* сниму транзакцию = сниму блокировку
Причем, наверное, 4 и 5 пункт можно объединить: блокировки при коммите должны сами сброситься.
Ты имеешь ввиду что если после добавления записи в таблицу много еще другого кода, он будет ждать пока блокировка не снимется? И это типа плохо для параллельности?
Ну во-первых эта ситуация будет только в случае если две транзакции добавляют одну и ту же несуществующую еще фирму. В других случаях блокировки не будет.

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

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

В коде, что в update, получится тоже самое, если мой SQL-код запустить в отдельной транзакции. В этом случае если ниже что-то случиться и будет rollback, добавленная запись останется в базе.
Ну во-первых эта ситуация будет только в случае если две транзакции добавляют одну и ту же несуществующую еще фирму. В других случаях блокировки не будет.
Я в самом начале поста обозначил, что в виду очень высокой степени параллелизации для меня эта проблема существенна. На вход системе подается большой объем однотипных данных и особенно на начальном этапе очень много таких конфликтов: данные новые и каждая транзакция, не обнаружив необходимой записи, пытается их добавить.

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

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

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

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

Только недавно мне рассказывали товарищи базисты, как они клиентский код распараллелили, а получилось только медленнее. Все-таки база данных сложная штука — там кэши всякие, дисковые операции и т.п. Поэтому там лучше семь раз отмерить сначала.
Есть еще и третий вариант. Как реализовать нужно думать, но тема такая: мы даем другим транзакциям использовать новую запись сразу после добавления, а потом отслеживаем ситуацию когда ни одна из транзакций не завершилась успехом, в этом случае запись добавиться не должна. Но тут решение будет сильно завязано на логику приложения.
Ммм… Непонятно :)

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

Хорошая практика в этом случае — замерить результаты в самом простом варианте и сравнивать их с оптимизированным. Иначе может получиться лишняя сложность на пустом месте. Я бы на твоем месте попробовал мой вариант, т.к. совершенно не факт что твои доводы насчет производительности верны.
Так я замеряю. Второй подход быстрее первого, и я отписал об этом в статье. Ваш код я не протестировал по следующей причине: для его реализации мне нужно существенно переработать код, но при этом я на 99.999% уверен, что выигрыша в производительности не будет. Ему просто неоткуда взяться.

Только недавно мне рассказывали товарищи базисты, как они клиентский код распараллелили, а получилось только медленнее.
Нет, ну это уже другая история. Я пробую разные варианты, тестирую их и последовательно выбираю оптимальный.
Дело не в выигрыше. Дело в том, что может проигрыша не быть, но при этом вариант с локами в базе будет проще и правильнее.
Так а где проще-то? Что делать с ORM? Заморачиваться на вьюшки для каждой таблицы? По-моему это не проще.
Я в итоге прислушался к вашему совету — реализовал считывание с блокировкой на запись. Правда, стоит заметить, что это не будет работать в Read Committed: там в любом случае нужны искусственные блокировки.
Модификация, которую я добавил в Update лишена этого очень существенного недостатка, но при этом обладает другим
>В-третьих, сейчас я использую ORM, и очень этим доволен

Вот что нагуглилось:

create view companies_WithUpdate as select * from Companies with (updlock);

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

Даже если в коде будет сделан таймаут, по нему нельзя будет узнать причину — дедлок ли это был, или это админ на сервере порнуху копировал.
А мне кажется, в большинстве случаев реально пофиг, почему транзакция свалилась. Свалилась — пробуем еще раз.
А не поделитесь ссылкой на это решение? :)
Что-то не могу найти. Но там собственно кроме совета использовать вьюху не было ничего.
В последнем решении с получением/добавлением в независимой транзакции есть существенная проблема при Serializable изоляции. Первая транзакция просто не увидит добавленную строку. Соответственно будет проблема с внешними ключами ссылающимися на эту таблицу.
ЗЫ. Вы частично обходите проблему чтением в новой транзакции, но это локальное решение.
Не понял. Какую транзакцию вы называете первой? Есть основная, первая «независимая» и вторая «независимая»
Все равно не понял. Я напишу workflow, как его вижу я, а вы добавьте, куда в мои рассуждения (предполагающие, что проблем нет) вкралась ошибка, ок?

1. Первой независимой транзакцией делаем выборку. Если данные есть — их и используем.
2. Если данных нет, — создаем их в новой транзакции, предварительно залочив процесс создания.
3. Лок снимается — и другие транзакция, войдя в лок, увидят данные, которые мы только что добавили.

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

Очень сильно зависит от реализации Serializable. В postgresql не увидели бы ибо там не видно ничего, что изменено/добавлено/удалено после начала транзакции.
Хоть к приведенной реализации этот момент отношения не имеет (так как данные основной транзакции поставляются независимые), но все равно интересно. Я не проверял, но всегда считал, что Serializable по стандарту должен работать именно так, как я описал. А то, о чем вы говорите, больше похоже на Snapshot: «it reads the last committed values that existed at the time it started»
PostgreSQL Serializable Isolation Level

When a transaction is using the serializable level, a SELECT query only sees data committed before the transaction began; it never sees either uncommitted data or changes committed during transaction execution by concurrent transactions.
Вот это да! В MySQL то же самое:
All consistent reads within the same transaction read the snapshot established by the first read.
А ведь это логично. В этом и заключается отличие Repeatable Red и Serializable от Read Committed.

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

По итогам существенно переработал статью (в том числе перевел первую реализацию на Read Committed).

И еще я хотел бы уточнить у вас один момент. Как должен вести себя Repeatable Read с фантомными вставками? Я понимаю, что он их не блокирует. Но должен ли он их показывать при следующих выборках? Судя по названию «Repeatable read», — нет, но при этом Википедия говорит «en.wikipedia.org/wiki/Isolation_(database_systems)»:
In REPEATABLE READ mode, the range would not be locked, allowing the record to be inserted and the second execution of Query 1 to return the new row in its results.

Документация MySQL при этом говорит, что Repeatable Read работает в рамках одного снепшота (как и Serializable).

Это Мускул отклоняется от стандарта?
Уровни изоляции требует отсутствия артефактов (например, Phantom Reads), а не их присутствия. Поэтому для Repeatable Read важно чтобы записи которые уже были прочитаны не изменялись. Однако если при этом не будет новых записей, то это прекрасно.

Например в PostgreSQL и Oracle реализовано только два уровня изоляции транзакций: Read Commited и Serializable. В MySQL и MS SQL вроде все.
Я сначала все-таки ошибся с оценкой реализации Serializable под MySQL. С одной стороны, используется snapshot от начала транзакции, но при этом каждая выборка происходит с блокировкой на чтение (lock in shared mode), которая блокирует актуальные данные (иначе было бы странно). То есть первое решение может работать в режиме Serializable без каких-либо проблем.

Не исключаю, что этот же нюанс актуален и для PostgreSQL.
А не проще добавить уникальный индекс?
Уникальные индексы присутствуют
Sign up to leave a comment.

Articles