Pull to refresh

LINQ to SQL и конфликты параллельного доступа

Reading time14 min
Views7.2K
В первой части статьи мы изучили то, каким образом можно найти конфликт параллельного доступа и возможные способы их решения.
Вторая часть статьи посвящена решению этого конфликта, при использовании LINQ to SQL.

Во второй части статьи рассмотрено, как решать конфликты параллельного доступа в LINQ to SQL, и причины появления ChangeConflictException при попытке обновления записей, способы решения.

Обнаружение конфликтов


Как уже упоминалось в 1 части статьи, для поиска конфликта мы можем использовать специальные поле версии, в которой сохраняется версия или дата изменения, либо указывать в условии WHERE предыдущие значения полей, чтобы убедиться, что они не были изменены.

Для использования поле версии, необходимо в сущностном классе (*.designer.cs), описываемом таблицу, пометить это поле свойством IsVersion в атрибуте Column. В этом случае, только это поле будет участвовать для определения возникновения конфликта параллельного доступа.

Если ни одно из полей не помечено, как IsVersion, то LINQ to SQL позволяет нам самим проконтролировать, какие именно поля будут участвовать в обнаружении конфликтов. Для этого, в сущностном классе необходимо установить соответствующее значение свойства UpdateCheck атрибута Column. Оно может быть 3х возможных значений:
  • Never – обозначает, что данное поле не будет участвовать в обнаружении конфликта
  • Always (по-умолчанию) – обозначает, что данное поле всегда будет участвовать в обнаружении конфликта, независимо от того, было ли обновлено его значение с момента первоначальной загрузки в кэш объекта DataContext
  • WhenChanged – это поле будет участвовать, если вы его обновили и пытаетесь сохранить новое значение


И так, перейдем к практической части. Сначала создадим тестовую таблицу:
CREATE TABLE Customers ([CustomerID][nvarchar](5) PRIMARY KEY, [CompanyName][nvarchar](40), [ContactName][nvarchar](30), [ContactTitle][nvarchar](30))

* This source code was highlighted with Source Code Highlighter.


Затем перейдем к нашему проекту в Visual Studio и добавим нашу таблицу к проекту (Add New Item – Linq to Sql Classes).







Теперь откроем файл MyTestDB.designer.cs и перейдем к описанию класса
[global::System.Data.Linq.Mapping.TableAttribute(Name="dbo.Customers")]
public partial class Customer : INotifyPropertyChanging, INotifyPropertyChanged

* This source code was highlighted with Source Code Highlighter.


В нем как раз описаны все поля и их свойства
[global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_CustomerID", DbType="NVarChar(5) NOT NULL", CanBeNull=false, IsPrimaryKey=true)]
public string CustomerID …    
[global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_CompanyName", DbType="NVarChar(40)")]
public string CompanyName …
[global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_ContactName", DbType="NVarChar(30)")]
public string ContactName …
[global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_ContactTitle", DbType="NVarChar(30)")]
public string ContactTitle…

* This source code was highlighted with Source Code Highlighter.


Как я уже писал, свойство UpdateCheck хоть и не задано, имеет значение по-умолчанию Always. Это обозначает, что все поля участвуют в проверке. Давайте убедимся в этом. Напишите следующий код:
 MyTestDBDataContext db = new MyTestDBDataContext();
 db.Log = Console.Out;
 Customer cust = db.Customers.Where(c => c.CustomerID == "LONEP").SingleOrDefault();
 cust.ContactName = "Neo Anderson";
 db.SubmitChanges();

* This source code was highlighted with Source Code Highlighter.


После его запуска, в окне консоли отобразиться следующее:
SELECT [t0].[CustomerID], [t0].[CompanyName], [t0].[ContactName], [t0].[ContactTitle]
FROM [dbo].[Customers] AS [t0]
WHERE [t0].[CustomerID] = @p0
-- @p0: Input NVarChar (Size = 5; Prec = 0; Scale = 0) [LONEP]
-- Context: SqlProvider(Sql2008) Model: AttributedMetaModel Build: 3.5.30729.4926
UPDATE [dbo].[Customers]
SET [ContactName] = @p4
WHERE ([CustomerID] = @p0) AND ([CompanyName] = @p1) AND ([ContactName] = @p2) AND ([ContactTitle] = @p3)
-- @p0: Input NVarChar (Size = 5; Prec = 0; Scale = 0) [LONEP]
-- @p1: Input NVarChar (Size = 24; Prec = 0; Scale = 0) [Lonesome Pine Restaurant]
-- @p2: Input NVarChar (Size = 11; Prec = 0; Scale = 0) [Fran Wilson]
-- @p3: Input NVarChar (Size = 13; Prec = 0; Scale = 0) [Sales Manager]
-- @p4: Input NVarChar (Size = 12; Prec = 0; Scale = 0) [Neo Anderson]
-- Context: SqlProvider(Sql2008) Model: AttributedMetaModel Build: 3.5.30729.4926


Т.е. тут мы видим, каким образом LINQ to SQL преобразовал наш запрос и выполнил его. В Update хорошо видно, что при обновлении, все поля участвовали в обнаружении конфликтов. Если нам это не нужно, то мы можем вручную задать значения свойств UpdateCheck, чтобы проконтролировать. Например, мы можем установить, что CompanyName будет всегда участвовать в обнаружении конфликта, ContactName – только при изменении, а ContactTitle – никогда не будет. Выглядеть тогда это будет так:
[global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_CustomerID", DbType="NVarChar(5) NOT NULL", CanBeNull=false, IsPrimaryKey=true)]
public string CustomerID …    
[global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_CompanyName", DbType="NVarChar(40)" , UpdateCheck = UpdateCheck.WhenChanged)]
public string CompanyName …
[global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_ContactName", DbType="NVarChar(30) ", UpdateCheck = UpdateCheck.Always)]
public string ContactName …
[global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_ContactTitle", DbType="NVarChar(30)" , UpdateCheck = UpdateCheck.Never)]
public string ContactTitle…

* This source code was highlighted with Source Code Highlighter.


И если повторно запустить наш код, то запрос Linq to Sql уже будет другим:
SELECT [t0].[CustomerID], [t0].[CompanyName], [t0].[ContactName], [t0].[ContactTitle]
FROM [dbo].[Customers] AS [t0]
WHERE [t0].[CustomerID] = @p0
-- @p0: Input NVarChar (Size = 5; Prec = 0; Scale = 0) [LONEP]
-- Context: SqlProvider(Sql2008) Model: AttributedMetaModel Build: 3.5.30729.4926
UPDATE [dbo].[Customers]
SET [ContactName] = @p3
WHERE ([CustomerID] = @p0) AND ([CompanyName] = @p1) AND ([ContactName] = @p2)
-- @p0: Input NVarChar (Size = 5; Prec = 0; Scale = 0) [LONEP]
-- @p1: Input NVarChar (Size = 24; Prec = 0; Scale = 0) [Lonesome Pine Restaurant]
-- @p2: Input NVarChar (Size = 11; Prec = 0; Scale = 0) [Fran Wilson]
-- @p3: Input NVarChar (Size = 12; Prec = 0; Scale = 0) [Neo Anderson]
-- Context: SqlProvider(Sql2008) Model: AttributedMetaModel Build: 3.5.30729.4926


Как видите, разница есть! Поле CompanyName не участвовало, а ContactName участвовал, потому что был изменен.

И так, конфликт обнаружение конфликта происходит при вызове SubmitChanges(), и если произошел конфликт, то вы можете управлять процессом решения конфликта. Вы можете указать, необходимо ли прерывать дальнейшие обновления при первом конфликте, или же пытаться провести все изменения, накапливая конфликты. Для этого, необходимо передать ConflictMode при вызове SubmitChanges. Если вы передаете ConflictMode.FailOnFirstConflict, то процесс будет прерван при первом же конфликте, другое значение – ConflictMode.ContinueOnConflict. По-умолчанию, если вы не указываете его – то используется ConflictMode.FailOnFirstConflict.

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

Исключение ChangeConflictException


Независимо от того, установлен ли ConflictMode в FailOnFirstException или ContinueOnConflict – исключение ChangeConflictException всё равно будет сгенерировано.

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

Разрешение конфликта


Как только вы обнаружили конфликт, перехватит исключение ChangeConflictException, ваши следующим шагом, скорее всего, будет его разрешение. В LINQ to SQL есть два метол ResolveAll и два метода Resolve.

RefreshMode


Когда мы хотим разрешить конфликт, используя встроенную функциональность LINQ to SQL посредство вызова метода ResolveAll или Resolve, то контролируем способ решения конфликта, специфицируя режим RefreshMode. Есть три допустимых значения:
  1. KeepChanges указывает методу ResolveAll или Resolve о том, что нужно обновить значения свойств сущностного класса из базы данных, но если пользователь изменил свойство – его значение сохраняется (т.е. ваши изменения не будут потеряны)
  2. KeepCurrentValues указывает, что нужно использовать изменения вашего пользователя, отклонив все изменения, проведенные в базе (значения в базе данных будут перезаписаны вашими, которые были прочитаны при начальной загрузке)
  3. OverwriteCurrentValues, обозначает, что нужно отбросить ваши изменения и загрузить значения из базы данных


Подходы к разрешению конфликтов


Существует три подхода к разрешению конфликтов: простейший, легкий и ручной. Простейший заключается в простом вызове метода ResolveAll на коллекции DataContext.ChangeConflicts с передачей RefreshMode и необязательного булевого значения, говорящего о том, нужно ли автоматически разрешать удаленные записи (в этом случае LINQ to SQL представляет, что записи для удаления были успешно удалены, потому что кто-то их удалил уже до нас)

Легкий подход состоит в перечислении всех objectChangeConflict из коллекции DataContext.ChangeConflicts с вызовом метода Resolve на каждом из них.

При ручном способе, вы перечисляете элементы ChangeConflicts объекта DataContext, а затем перечисляете все элементы MemberConflicts объекта ObjectChangeConflicts, вызывая Resolve на каждом объекте MemberChangeConflict из этой коллекции.

DataContext.ChangeConflicts.ResolveAll()


Разрешение конфликто не сложно. Вы просто перехватываете исключение ChangeConflictException и вызываете метод ResolveAll() на коллекции DataContext.ChangeConflicts. Всё, что от вас требуется – это решить, какой режим RefreshMode использовать, и хотите ли вы автоматически разрешить конфликты удаленных записей. Применение такого подхода вызовет одинаковое разрешение всех конфликтов.
cust.ContactName = "Neo Anderson";
try
{
  db.SubmitChanges();
}
catch (ChangeConflictException)
{
  db.ChangeConflicts.ResolveAll(RefreshMode.KeepChanges);
  {
    try
    {
      db.SubmitChanges();
    }
    catch (ChangeConflictException)
    {
      Console.WriteLine("Конфликт повторился, откатываемся.");
    }
  }
}

* This source code was highlighted with Source Code Highlighter.


В этом примере мы сначала вызываем ResolveAll, а затем снова вызываем метод SubmitChanges снова. Если он вызовет ошибку, то мы откатываемся.

ObjectChangeConflict.Resolve()


Если разрешение конфликтов с тем же RefreshMode или autoResolveDeletes у вас не работает, вы можете выбрать подход с перечисление всех конфликтов из коллекции DataContext.ChangeConflicts и обрабатывать их индивидуально.
      cust.ContactName = "Neo Anderson";
      try
      {
        db.SubmitChanges();
      }
      catch (ChangeConflictException)
      {
        foreach (ObjectChangeConflict conflict in db.ChangeConflicts)
        {
          Console.WriteLine("Конфликт на записи {0}", ((Customer)conflict.Object).CustomerID);
          conflict.Resolve(RefreshMode.KeepChanges);
          Console.WriteLine("Конфликт разрешен. {0}", System.Environment.NewLine);
        }

          try
          {
            db.SubmitChanges();
          }
          catch (ChangeConflictException)
          {
            Console.WriteLine("Конфликт повторился, откатываемся.");
          }
        }
      }

* This source code was highlighted with Source Code Highlighter.

Здесь, аналогично методу ResolveAll, вы выполняем перечисление коллекции ChangeConflicts и вызываем Resolve на каждом объекте ObjectChangeConflict

MemberChangeConflict.Resolve()


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

Чтобы реализовать это, мы используем тот же базовый подход, но вместо вызова Resolve на объекте ObjectChangeConflict, выполняем перечисление членов коллекции MemberConflicts каждого объекта. Затем для каждого объекта MemberConflict из этой коллекции, если свойство сущностного объекта, вызвавшего конфликт – это ContactName, то мы оставим значение в базе данных, передав RefreshMode.OverwriteCurrentValues, методу Resolve. Если же конфликтующее свойство – не ContactName, то обновим на наше, передав методу Resolve значение RefreshMode.KeepChanges.
      cust.ContactName = "Neo Anderson";
      cust.CompanyName = "Lonesome & Pine Restaurant";
      try
      {
        db.SubmitChanges();
      }
      catch (ChangeConflictException)
      {
        foreach (ObjectChangeConflict conflict in db.ChangeConflicts)
        {
          Console.WriteLine("Конфликт на записи {0}", ((Customer)conflict.Object).CustomerID);
          foreach (MemberChangeConflict memberConflict in conflict.MemberConflicts)
          {
            if (memberConflict.Member.Name.Equals("ContactName"))
              memberConflict.Resolve(RefreshMode.OverwriteCurrentValues);
            else
              memberConflict.Resolve(RefreshMode.KeepChanges);
          }
          Console.WriteLine("Конфликт разрешен. {0}", System.Environment.NewLine);
        }
          try
          {
            db.SubmitChanges();
          }
          catch (ChangeConflictException)
          {
            Console.WriteLine("Конфликт повторился, откатываемся.");
          }
        }
      }

* This source code was highlighted with Source Code Highlighter.


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

UPDATE: использование колонки Version


Для колонки Версия, вы можете использовать timestamp или int. Для примера, давайте добавим к нашей таблице колонку, которая будет следить за версией записи
alter table Customers ADD [Version][rowversion] NOT NULL

* This source code was highlighted with Source Code Highlighter.


rowversion – автоматически обновляемое поле при обновлении записи, внутри используется timestamp. Т.е. как бы мы не обновляли, через LINQ to SQL, или еще как, значение поля автоматически будет увеличиваться. Конечно, вы можете использовать другой способ вести номер версии, например, в виде числа и обновлять его с помощью триггера. В данном случае для примера, я воспользовался типом rowversion

Теперь нужно будет обновить представление MyTestDB.dbml, чтобы это новое поле появилось в нашем проекте. После обновления, наше поле будет выглядеть так:
[global::System.Data.Linq.Mapping.ColumnAttribute(Storage="_Version", AutoSync=AutoSync.Always, DbType="rowversion NOT NULL", CanBeNull=false, IsDbGenerated=true, IsVersion=true, UpdateCheck=UpdateCheck.Never)]
public System.Data.Linq.Binary Version

* This source code was highlighted with Source Code Highlighter.

При использовании IsVersion = true, про UpdateCheck можете забыть, оно применяться не будет!!! Всегда для поиска конфликтов будет использоваться поле версии. Остальные свойства: IsDbGenerated – обозначает, что данное поле генерируется базой данных, его изменять нельзя, CanBeNull – не допускает пустого значения. Поле IsVersion предполагает немедленную синхронизацию после вставки или обновления записи

А теперь давайте проверим, как изменились запросы LINQ to SQL, после внедрения поля версии, на следующем коде:
MyTestDBDataContext db = new MyTestDBDataContext();
      db.Log = Console.Out;
      Customer cust = db.Customers.Where(c => c.CustomerID == "LONEP").SingleOrDefault();
      string name = cust.ContactName;
      cust.ContactName = "Neo Anderson";

      Console.WriteLine("Номер версии до обновления - {0} {1}", BitConverter.ToString(cust.Version.ToArray()), System.Environment.NewLine);
      db.SubmitChanges();
      Console.WriteLine("Номер версии после обновления - {0} {1}", BitConverter.ToString(cust.Version.ToArray()), System.Environment.NewLine);
      cust.ContactName = name;
      db.SubmitChanges();
      Console.ReadKey();

* This source code was highlighted with Source Code Highlighter.


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

И так, я также добавил отображение номера версии, чтобы убедиться, что версия действительно обновляется. Результат ниже:
SELECT [t0].[CustomerID], [t0].[CompanyName], [t0].[ContactName], [t0].[ContactTitle], [t0].[Version]
FROM [dbo].[Customers] AS [t0]
WHERE [t0].[CustomerID] = @p0
-- @p0: Input NVarChar (Size = 5; Prec = 0; Scale = 0) [LONEP]
-- Context: SqlProvider(Sql2008) Model: AttributedMetaModel Build: 3.5.30729.4926
Номер версии до обновления - 00-00-00-00-00-00-07-E9
UPDATE [dbo].[Customers]
SET [ContactName] = @p2
WHERE ([CustomerID] = @p0) AND ([Version] = @p1)

SELECT [t1].[Version]
FROM [dbo].[Customers] AS [t1]
WHERE ((@@ROWCOUNT) > 0) AND ([t1].[CustomerID] = @p3)
-- @p0: Input NVarChar (Size = 5; Prec = 0; Scale = 0) [LONEP]
-- @p1: Input Timestamp (Size = 8; Prec = 0; Scale = 0) [SqlBinary(8)]
-- @p2: Input NVarChar (Size = 12; Prec = 0; Scale = 0) [Neo Anderson]
-- @p3: Input NVarChar (Size = 5; Prec = 0; Scale = 0) [LONEP]
-- Context: SqlProvider(Sql2008) Model: AttributedMetaModel Build: 3.5.30729.4926
Номер версии после обновления - 00-00-00-00-00-00-07-EA

UPDATE [dbo].[Customers]
SET [ContactName] = @p2
WHERE ([CustomerID] = @p0) AND ([Version] = @p1)

SELECT [t1].[Version]
FROM [dbo].[Customers] AS [t1]
WHERE ((@@ROWCOUNT) > 0) AND ([t1].[CustomerID] = @p3)
-- @p0: Input NVarChar (Size = 5; Prec = 0; Scale = 0) [LONEP]
-- @p1: Input Timestamp (Size = 8; Prec = 0; Scale = 0) [SqlBinary(8)]
-- @p2: Input NVarChar (Size = 11; Prec = 0; Scale = 0) [Fran Wilson]
-- @p3: Input NVarChar (Size = 5; Prec = 0; Scale = 0) [LONEP]
-- Context: SqlProvider(Sql2008) Model: AttributedMetaModel Build: 3.5.30729.4926


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

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

Последние UPDATE и SELECT появились из-за того, что мы восстанавливаем первоначальное значение.

Пессимистический подход


При пессимистическом подходе к параллелизму нет конфликтов, которые нужно разрешать, потому то база данных блокирована вашей транзакцией, так что никто другой не сможет модифицировать ей у вас за спиной.
      using (System.Transactions.TransactionScope tranaction = new System.Transactions.TransactionScope())
      {
        Customer cust = db.Customers.Where(c => c.CustomerID == "LONEP").SingleOrDefault();
        cust.ContactName = "Neo Anderson";
        cust.CompanyName = "Lonesome & Pine Restaurant";
        db.SubmitChanges();
        tranaction.Complete();
      }

* This source code was highlighted with Source Code Highlighter.


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

Материал для более подробного изучения LINQ to SQL: Джозеф Раттц-мл. «LINQ Язык интегрированных запросов в C#2008 для профессионалов»

UPD: добавлен пример для поля версии
Tags:
Hubs:
+19
Comments14

Articles

Change theme settings