Конфликты параллелизма: оптимистический параллелизм
21LINQ --- LINQ to DataSet и SQL --- Конфликты параллелизма: оптимистический параллелизм
»» В ДАННОЙ СТАТЬЕ ИСПОЛЬЗУЕТСЯ ИСХОДНЫЙ КОД ДЛЯ ПРИМЕРОВ
Когда одно соединение с базой данных пытается обновить часть данных, которая была изменена в другом соединении после того, как запись была прочитана первым соединением, возникает конфликт параллельного доступа. То есть, если первый процесс читает данные, после чего те же данные читает второй процесс, и второй процесс обновляет те же данные до того, как это сможет сделать первый процесс, возникает конфликт, когда первый процесс попытается обновить эти данные.
Также верно, что если первый процесс обновляет данные перед вторым процессом, то второй процесс столкнется с конфликтом параллельного доступа при попытке обновить данные. Если несколько соединений могут обращаться к базе данных и проводить изменения, то возникновение конфликтов — вопрос лишь времени и везения.
Когда возникает конфликт, приложение должно предпринять какие-то действия для его разрешения. Например, администратор веб-сайта может просматривать страницу, отображающую данные для обычного пользователя, которая позволяет администратору обновлять эти данные. Если после того, как страница администратора прочитает пользовательские данные из базы данных, а обычный пользователь обратится к странице, отображающей его данные, и внесет изменение, то возникнет конфликт когда администратор сохранит свои изменения в базе данных.
Если же конфликт не возникнет, то изменения обычного пользователя будут переписаны и утеряны. Может случиться иначе: изменения обычного пользователя будут сохранены, а изменения администратора — утеряны. Какое поведение следует считать правильным в каждом конкретном случае — это сложная проблема. Первый шаг — обнаружить ее. Второй шаг — разрешить.
Существуют два базовых подхода к разрешению конфликтов параллельного доступа — оптимистический и пессимистический.
Как следует из названия, оптимистический подход к разрешению конфликтов параллельного доступа исходит из того, что в большинстве случаев конфликты не происходят. Поэтому никаких блокировок на данные во время их чтения из базы не устанавливается.
Когда вдруг случится конфликт при попытке обновить одни и те же данные, тогда им и следует заняться. Оптимистическая обработка конфликтов параллелизма более сложна, чем пессимистическая, но работает лучше в современных приложениях с очень большим количеством пользователей. Представьте, насколько раздражало бы, если бы каждый раз, когда необходимо взглянуть на лот на любимом аукционном сайте, это не удавалось бы сделать потому, что кто-то другой в этот момент просматривает тот же самый лот, и нужная запись блокирована, потому что кто-то предлагает свою цену за него. Вряд ли такое проектное решение можно назвать удачным.
В LINQ to SQL используется оптимистический подход к обработке конфликтов параллелизма. К счастью, LINQ to SQL в состоянии обнаружить и разрешить конфликты параллельного доступа настолько просто, насколько это вообще возможно. В нем даже предусмотрен метод обработки разрешения, если вам это подходит.
Обнаружение конфликтов
Как уже упоминалось, первым шагом является обнаружение конфликтов. В LINQ to SQL реализовано два подхода к обнаружению конфликтов параллелизма. Если указано свойство IsVersion атрибута Column свойства сущностного класса, и оно имеет значение true, то значение этого, и только этого, свойства сущностного класса, будет использовано для определения возникновения конфликта параллельного доступа.
Если ни одно из свойств сущностного класса не имеет установленного в true свойства IsVersion атрибута Column, то LINQ to SQL позволяет управлять тем, какие именно свойства сущностного класса участвуют в обнаружении конфликтов параллельного доступа через свойство UpdateCheck атрибута Column, заданного на отображаемом свойстве сущностного класса. Перечисление UpdateCheck предусматривает три возможных значения: Never, Always и WhenChanged.
UpdateCheck
Если свойство UpdateCheck атрибута для отображаемого свойства сущностного класса установлено в UpdateCheck.Never, то это свойство сущностного класса не будет участвовать в обнаружении конфликтов параллельного доступа.
Если же оно установлено в UpdateCheck.Always, то такое свойство сущностного класса всегда будет участвовать в разрешении конфликтов параллелизма, независимо от того, было ли изменено его значение с момента первоначального извлечения и помещения в кэш объекта DataContext.
Если свойство UpdateCheck установлено в UpdateCheck.WhenChanged, то это свойство сущностного класса будет участвовать в проверке обновления, только если его значение было изменено с момента первоначальной загрузки в кэш объекта DataContext. Если свойство UpdateCheck атрибута не указано, по умолчанию принимается UpdateCheck.Always.
Помочь разобраться в формальной работе обнаружения конфликтов может помочь понимание его текущей реализации. Когда вызывается метод SubmitGhanges, процессор изменений генерирует необходимые операторы SQL, чтобы сохранить все изменения сущностных объектов в базе данных. Когда требуется обновить запись, то вместо того, чтобы указывать только первичный ключ в конструкции where для нахождения соответствующей записи для обновления, наряду с первичным ключом он перечисляет все столбцы, участвующие в обнаружении конфликта.
Если свойство UpdateCheck атрибута сущностного класса установлено в UpdateCheck.Always, столбец, на который отображено это свойство сущностного класса, и его исходное значение будут всегда присутствовать в конструкции where.
Если свойство UpdateCheck атрибута сущностного класса установлено в UpdateCheck.WhenChanged, то в процессе будет участвовать лишь текущее значение свойства сущностного объекта, которое было изменено по сравнению с его исходным значением, в конструкции where будет указано его исходное значение. Если свойство UpdateCheck имеет значение UpdateCheck.Never, то столбец, на который отображено данное свойство сущностного класса, в конструкции where присутствовать не будет.
Например, предположим, что в сущностном объекте Customer указано свойство UpdateCheck для CompanyName как UpdateCheck.Always, ContactName — как UpdateCheck.WhenChanged и ContactTitle как UpdateCheck.Never. Если все эти три свойства сущностного класса были модифицированы в объекте заказчика, то сгенерированный оператор SQL будет выглядеть следующим образом:
Update Customers
Set CompanyName = 'Art Sanders Park',
ContactName = 'Samuel Arthur Sanders',
ContactTitle = 'President'
Where CompanyName = 'Lonesome Pine Restaurant' AND
ContactName = 'Fran Wilson' AND
CustomerID = 'LONEP'
В приведенном примере значения столбцов в выражении where — это исходные значения, прочитанные из базы данных при первом извлечении сущностного объекта, успешном завершении вызова метода SubmitChanges или же вызове метода Refresh.
Поскольку свойство UpdateCheck атрибута свойства CompanyName указано как UpdateCheck.Always, соответствующий ему столбец будет включен в конструкцию where, независимо от того, было ли это свойство изменено в сущностном объекте. А так как свойство UpdateCheck атрибута свойства ContactName определено как UpdateCheck.WhenChanged, и значение этого свойства сущностного класса было изменено в сущностном объекте, оно также включается в выражение where.
Из-за того, что свойство UpdateCheck атрибута свойства ContactTitle задано как UpdateCheck.Never, соответствующий ему столбец не включен в выражение where, несмотря на то, что значение этого свойства также было изменено.
При выполнении этого оператора SQL, если любое из значений свойств сущностного класса, указанных в части where, не будет соответствовать тому, что есть в базе данных, то запись не будет найдена, а, следовательно, и не будет обновлена. Именно так обнаруживается конфликт параллельного доступа. В случае обнаружения конфликта генерируется исключение ChangeConflictException. Чтобы увидеть, как выглядит сгенерированный оператор update, давайте рассмотрим следующий код:
// Используйте свое подключение
Northwind db = new Northwind(@"Data Source=MICROSOF-1EA29E\SQLEXPRESS;
Initial Catalog=C:\NORTHWIND.MDF;
Integrated Security=True");
db.Log = Console.Out;
Customer cust = db.Customers.Where(c => c.CustomerID == "LONEP").SingleOrDefault();
string name = cust.ContactName; // чтобы восстановить позднее
cust.ContactName = "Neo Anderson";
db.SubmitChanges();
// Восстановление базы данных.
cust.ContactName = name;
db.SubmitChanges();
Об этом запросе сказать особо нечего. Фактически единственное, что здесь следует отметить — это вызов операции SingleOrDefault вместо Single, которая используется обычно — просто, чтобы подстраховаться на случай, если запись не будет найдена. В этом случае известно, что запись будет найдена, но нужно напомнить, что всегда необходимо заботиться о том, чтобы код мог правильно справиться с подобными ситуациями.
Все, что действительно интересует это сгенерированный оператор update. Посмотрим на результат:
Обратите внимание, что в первом операторе update конструкция where указывает, что ContactName должно быть равно "Fran Wilson", т.е. первоначальному значению ContactName. Если какой-то другой процесс изменит ContactName после его прочтения, ни одна запись не будет соответствовать условию where, поэтому ни одна запись не будет обновлена.
Поскольку ни одно из свойств сущностного класса Customer не указывает свойство атрибута UpdateCheck, все они по умолчанию устанавливаются в UpdateCheck.Always, так что в конструкции where этого оператора update присутствуют все отображенные свойства сущностного класса.
SubmitChanges()
Обнаружение конфликтов параллельного доступа происходит при вызове метода SubmitChanges. Во время вызова этого метода можно указать, должен ли прерываться процесс сохранения изменений в базе данных в случае первого обнаружения конфликта или же необходимо пытаться провести все изменения, накапливая конфликты. Это поведение управляется посредством аргумента ConflictMode, который может быть передан методу SubmitChanges.
Если передать значение ConflictMode.FailOnFirstConflict, то процесс будет прерван после первого же конфликта. Если же передать ConflictMode.ContinueOnConflict, то процесс продолжит попытки провести все необходимые изменения, даже если возникнут конфликты. Если вы предпочтете не указывать ConflictMode, то метод SubmitChanges по умолчанию примет ConflictMode.FailOnFirstConflict.
Независимо от заданного ConflictMode, если объемлющая транзакция не активна в данном контексте при вызове метода SubmitChanges, транзакция будет создана для всех попыток изменения базы данных, предпринимаемых в рамках данного вызова метода SubmitChanges. Если же активна объемлющая транзакция, то DataContext использует ее. При генерации исключения во время вызова метода SubmitChanges транзакция будет отменена. Это значит, что даже изменения не конфликтующих сущностных объектов, успешно помещенные в базу данных, также будут отменены.
Если случается конфликт параллельного доступа, то независимо от того, установлен ConflictMode в FailOnFirstConflict или ContinueOnConflict, исключение ChangeConflictException будет сгенерировано.
За счет перехвата исключения ChangeConflictException обнаруживается возникновение конфликта.
Разрешение конфликтов
Как только конфликт параллельного доступа обнаружен путем перехвата исключения ChangeConflictException, следующим шагом, скорее всего, будет его разрешение. Вы можете предпочесть предпринять какое-то другое действие, но разрешение конфликтов — наиболее вероятный выход. Поначалу этот процесс может показаться чудовищно сложным, но, к счастью, LINQ to SQL также облегчает эту задачу, предоставляя в наше распоряжение метод ResolveAll и два метода Resolve.
RefreshMode
При действительном разрешении конфликта с использованием встроенной функциональности разрешения LINQ to SQL посредством вызова метода ResolveAll или Resolve с помощью режима RefreshMode можно управлять способом разрешения конфликта. Его три допустимых значения — KeepChanges, KeepCurrentValues и OverwriteCurrentValues. Эти опции управляют тем, какие данные остаются в свойствах сущностного объекта, когда DataContext выполняет разрешение конфликта.
Опция RefreshMode.KeepChanges сообщает методу ResolveAll или Resolve о том, что нужно загрузить изменения из базы данных в текущие значения свойств сущностного класса для каждого столбца, измененного с момента начальной загрузки данных, если только текущий пользователь также не изменил это свойство. В этом случае его значение сохраняется. Порядок приоритетов предохранения данных, от низшего к высшему, такой: первоначальные значения свойств сущностного класса, перезагруженные измененные значения столбцов базы данных, значения свойств сущностного класса, измененные текущим пользователем.
Опция RefreshMode.KeepCurrentValues сообщает методу ResolveAll или Resolve о том, что нужно сохранить первоначальные значения свойств сущностного класса и изменения текущего пользователя, отклонив все изменения, проведенные в базе после начальной загрузки. Порядок приоритетов предохранения данных, от низшего к высшему, такой: первоначальные значения свойств сущностного класса, измененные текущим пользователем значения свойств сущностного класса.
Опция RefreshMode.OverwriteCurrentValues сообщает методу ResolveAll или Resolve о том, что нужно загрузить изменения из базы данных для любого столбца, измененного с момента начальной загрузки данных, и отбросить изменения свойств сущностного класса, проведенные текущим пользователем. Порядок приоритетов предохранения данных, от низшего к высшему, такой: первоначальные значения свойств сущностного класса, затем перезагруженные значения столбцов.
Подходы к разрешению конфликтов
Существуют три подхода к разрешению конфликтов: простейший, легкий и ручной. Простейший подход заключается в вызове метода ResolveAll на коллекции DataContext.ChangeConflicts с передачей RefreshMode и необязательного булевского значения, говорящего о том, нужно ли автоматически разрешать удаленные записи.
Автоматическое разрешение удаленных записей означает маркировку соответствующего удаленного сущностного объекта как успешно удаленного, даже когда оно не произошло из-за конфликта параллельного доступа, чтобы при следующем вызове метода SubmitChanges объект DataContext не пытался удалить снова записи базы данных, соответствующие удаленным сущностным объектам. По сути, в LINQ to SQL говорится, что он должен представлять, будто бы записи были успешно удалены, потому что кто-то удалил их раньше.
Легкий подход состоит в перечислении всех объектов ObjectChangeConflict из коллекции DataContext.ChangeConflicts с вызовом метода Resolve на каждом из них.
Если же, однако, нужна какая-то специальная обработка, всегда есть выбор обработать разрешение конфликта самостоятельно, перечислив элементы коллекции ChangeConflicts объекта DataContext, а затем перечислив все элементы коллекции MemberConflicts объекта ObjectChangeConflict, вызывая метод Resolve на каждом объекте MemberChangeConflict из этой коллекции. Даже при ручной обработке имеющиеся методы позволяют решить эту задачу достаточно легко.
- DataContext.ChangeConflicts.ResolveAll()
Разрешение конфликтов не сложно. Для этого просто перехватывается исключение ChangeConflictException и вызывается метод ResolveAll на коллекции DataContext.ChangeConflicts. Все что требуется — это решить, какой режим RefreshMode использовать, и нужно ли автоматически разрешать конфликты удаленных записей.
Применение такого подхода вызовет одинаковое разрешение всех конфликтов на базе переданного RefreshMode. Если необходим более тонкий контроль при разрешении конфликтов, используйте один из более сложных подходов, которые рассматриваются ниже.
В следующем примере конфликт разрешается с использованием этого подхода. Поскольку пример довольно сложен, он сопровождается дополнительными пояснениями:
// Используйте свое подключение Northwind db = new Northwind(@"Data Source=MICROSOF-1EA29E\SQLEXPRESS; Initial Catalog=C:\NORTHWIND.MDF; Integrated Security=True"); Customer cust = db.Customers.Where(c => c.CustomerID == "LAZYK").SingleOrDefault(); ExecuteStatementInDb(String.Format( @"update Customers set ContactName = 'Samuel Arthur Sanders' where CustomerID = 'LAZYK'")); cust.ContactTitle = "President"; try { db.SubmitChanges(ConflictMode.ContinueOnConflict); } catch (ChangeConflictException) { db.ChangeConflicts.ResolveAll(RefreshMode.KeepChanges); try { db.SubmitChanges(ConflictMode.ContinueOnConflict); cust = db.Customers.Where(c => c.CustomerID == "LAZYK").SingleOrDefault(); Console.WriteLine("\nContactName = {0} : ContactTitle = {1}\n", cust.ContactName, cust.ContactTitle); } catch (ChangeConflictException) { Console.WriteLine("Конфликт возник снова, завершение..."); } } // Сбросить базу данных в исходное состояние. ExecuteStatementInDb(String.Format( @"update Customers set ContactName = 'John Steel', ContactTitle = 'Marketing Manager' where CustomerID = 'LAZYK'"));
В этом коде сначала создается экземпляр объекта Northwind, унаследованного от DataContext. Затем с помощью LINQ to SQL запрашивается заказчик и вносится изменение в значение столбца ContactName извлеченного заказчика в базе данных посредством ADO.NET. Тем самым закладывается фундамент для потенциального конфликта параллельного доступа.
Далее проводится изменение в сущностном объекте и попытка сохранить его в базе данных. Обратите внимание, что вызов метода SubmitChanges помещен в блок try/catch. Чтобы правильно обнаруживать конфликты параллельного доступа, перехватывается исключение ChangeConflictException. Понадобится только вызвать метод ResolveAll и снова попытаться сохранить изменения.
В приведенном коде вызывается метод ResolveAll, которому передается RefreshMode со значением KeepChanges. Затем вновь вызывается метод SubmitChanges, помещенный в собственный блок try/catch. После этого из базы данных опять запрашивается заказчик и на консоль выводятся значения ContactName и ContactTitle заказчика, чтобы доказать, что ни изменение ADO.NET, ни изменение LINQ to SQL не утеряны. Если этот вызов метода SubmitChanges сгенерирует исключение, об этом просто выдается сообщение и дальнейшие попытки прекращаются.
В конце база данных восстанавливается в исходное состояние, чтобы пример можно было запускать более одного раза.
Если присмотреться, то если не считать кода, вызвавшего конфликт, который обычно писать не придется, и кода восстановления базы данных в конце примера, который также писать не придется, то разрешение конфликтов параллелизма при таком подходе чрезвычайно просто. Вы помещаете вызов метода SubmitChanges в блок try/catch, перехватываете исключение ChangeConflictException, вызываете метод ResolveAll и повторяете вызов метода SubmitChanges. Вот и все. Взглянем на результат запуска кода:
Как видно по результатам, и изменение ADO.NET в ContactName, и изменение LINQ to SQL в ContactTitle сохранены в базе данных. Это очень простой подход к разрешению конфликтов параллельного доступа.
- ObjectChangeConflict.Resolve()
Если разрешение всех конфликтов с тем же самым RefreshMode не работает, можно выбрать подход с перечислением всех конфликтов из коллекции DataContext.ChangeConflicts и обрабатывать их индивидуально. Можно обрабатывать каждый из них, вызывая на нем метод Resolve. Это позволит передавать разные значения RefreshMode для каждого конфликта.
Разрешение конфликтов на этом уровне сродни разрешению их на уровне сущностного объекта. Переданный RefreshMode будет применен к каждому свойству сущностного класса в конфликтующем сущностном объекте. Если нужен более тонкий контроль, чем позволяет такой подход, рассмотрите использование ручного подхода, который рассматривается далее.
В следующем примере этот подход будет продемонстрирован. Код будет таким же, как и в предыдущем, только вызов метода DataContext.ChangeConflicts.ResolveAll будет заменен перечислением коллекции ChangeConflicts:
... catch (ChangeConflictException) { foreach (ObjectChangeConflict conflict in db.ChangeConflicts) { Console.WriteLine("Конфликт произошел в заказчике {0}.", ((Customer)conflict.Object).CustomerID); Console.WriteLine("Вызов Resolve ..."); conflict.Resolve(RefreshMode.KeepChanges); Console.WriteLine("Конфликт разрешен.\n"); } try { ...
Обратите внимание, что вместо вызова метода DataContext.ChangeConflicts.ResolveAll выполняется перечисление коллекции ChangeConflicts с вызовом метода Resolve на каждом объекте ObjectChangeConflict из этой коллекции. Затем, как и в предыдущем примере, снова вызывается метод SubmitChanges, запрашивается заказчик и на консоль выводятся важнейшие свойства сущностного класса. Конечно, после этого база данных восстанавливается в исходном состоянии.
Вот результат работы этого видоизмененного кода:
Все работает как и хотелось. В реальном рабочем коде может понадобиться запустить цикл с вызовами метода SubmitChanges и разрешением конфликта — просто чтобы противостоять невезению, связанному с появлением дополнительных конфликтов при таком редком стечении обстоятельств. Если вы сделаете это, не забудьте каким-то образом ограничить количество итераций цикла, чтобы предотвратить бесконечное его выполнение в случае, когда в системе возникнут серьезные неполадки.
- MemberChangeConflict.Resolve()
При первом подходе вызывается метод для разрешения всех конфликтов одним способом. Это — простейший способ разрешения конфликтов. При втором подходе вызывается метод для разрешения конфликта для единственного конфликтующего объекта. Это обеспечивает гибкость для разрешения конфликтов с каждым сущностным объектом по-своему. Это — легкий способ. Что осталось? Остался подход к разрешению конфликтов вручную.
Пусть объяснения вас не пугают. Даже при нормальном подходе обнаружение конфликтов параллелизма, вероятно, проще, чем можно было ожидать. Принятие такого подхода позволит применять разные значения RefreshMode для индивидуальных свойств сущностного объекта.
Подобно второму подходу к разрешению конфликтов, производится перечисление объектов ObectChangeConflict из коллекции DataContext.ChangeConflicts. Но вместо вызова метода Resolve на каждом объекте ObectChangeConflict выполняется перечисление коллекции MemberConflicts и вызов метода Resolve каждого объекта MemberChangeConflict.
На этом уровне объект MemberChangeConflict принадлежит к определенному свойству сущностного класса из конфликтующего сущностного объекта. Это позволяет отступать от общего RefreshMode для любого свойства сущностного класса по своему усмотрению.
Метод Resolve позволяет передавать либо RefreshMode, либо действительное значение, которое должно сменить текущее значение. Это обеспечивает замечательную гибкость.
В примере ручного разрешения конфликтов, приведенном ниже, предполагается существование требования, что если случится конфликт со столбцом ContactName в базе данных, код должен оставить значение базы как есть, но любые другие столбцы в записи могут быть обновлены.
Чтобы реализовать это, используется тот же базовый код, что и в предыдущих примерах, но вместо вызова метода Resolve на объекте ObjectChangeConflict выполняется перечисление членов коллекции MemberConflicts каждого объекта. Затем для каждого объекта MemberConflict из этой коллекции, если свойством сущностного объекта, вызвавшего конфликт является ContactName, то поддерживается значение в базе данных за счет передачи RefreshMode, равного RefreshMode.OverwriteCurrentValues, методу Resolve. Если же конфликтующее свойство сущностного объекта — не ContactName, поддерживается измененное значение за счет передачи методу Resolve значения RefreshMode, равного RefreshMode.KeepChanges.
Кроме того, чтобы сделать пример более интересным, когда база данных обновляется с помощью ADO.NET для создания конфликтной ситуации, также обновляется столбец ContactTitle. Это заставит конфликтовать два свойства сущностного объекта. Одно — ContactName — должно быть обработано так, чтобы сохранилось значение из базы данных. Второе — ContactTitle — должно быть обработано так, чтобы сохранилось значение, установленное LINQ to SQL.
Рассмотрим следующий модифицированный код:
... ExecuteStatementInDb(String.Format( @"update Customers set ContactName = 'Samuel Arthur Sanders', ContactTitle = 'CEO' where CustomerID = 'LAZYK'")); cust.ContactName = "Alex Erohin"; cust.ContactTitle = "President"; ... catch (ChangeConflictException) { foreach (ObjectChangeConflict conflict in db.ChangeConflicts) { Console.WriteLine("\nКонфликт произошел в заказчике {0}.", ((Customer)conflict.Object).CustomerID); foreach (MemberChangeConflict memberConflict in conflict.MemberConflicts) { Console.WriteLine("Вызов Resolve для {0} ...", memberConflict.Member.Name); if (memberConflict.Member.Name.Equals("ContactName")) { memberConflict.Resolve(RefreshMode.OverwriteCurrentValues); } else { memberConflict.Resolve(RefreshMode.KeepChanges); } Console.WriteLine("Конфликт разрешен.\n"); } } try { ...
Одним из существенных изменений является то, что также обновляется ContactTitle через ADO.NET. Это приводит к конфликту в двух свойствах объекта при вызове SubmitChanges. Затем вместо вызова метода Resolve на объекте ObjectChangeConflict перечисляются члены коллекции MemeberCinflicts с проверкой каждого свойства сущностного объекта. Если текущим свойством являеься ContactName, вызывается метод Resolve с RefreshMode, установленным в RefreshMode.KeepChanges, чтобы сохранить значение, определенное кодом LINQ to SQL.
Взглянем на результаты работы кода:
Здесь видно, что оба свойства — ContactName и ContactTitle — конфликтовали, и эти конфликты были разрешены. Также, просматривая вывод свойств ContactName и ContactTitle в конце, можно заметить, что значение из базы данных было сохранено для свойства ContactName, но проигнорировано для свойства ContactTitle, которое сохранило значение, установленное кодом LINQ to SQL. Именно этого и планировалось добиться.
Действительный код, обрабатывающий разрешение конфликта вручную, не так плох. Но, конечно, все эти усилия оправданы только для специализированного разрешения конфликтов.