LINQ

【C#】LINQで実践するデータ更新の基本操作とパフォーマンス向上テクニック

LINQを使えば、クエリで取得したエンティティのプロパティを書き換えSubmitChangesSaveChangesを呼ぶだけで更新できます。

Firstなどで1件だけ取り出すと効率が良く、トラッキングを切った場合はAttachで再度追跡させる必要があり、遅延評価も意識するとミスを防げます。

LINQデータ更新の基本

LINQを使ったデータ更新では、エンティティの状態管理やクエリの評価タイミング、使用するコンテキストの違いを理解することが重要です。

ここでは、エンティティの状態管理の基本から、クエリの遅延評価、そしてDataContextDbContextの違いについて詳しく解説します。

エンティティの状態管理

LINQ to SQLやEntity FrameworkなどのORM(Object-Relational Mapping)では、エンティティの状態を管理することで、どのデータが新規追加、変更、削除されたかを追跡し、効率的にデータベースへ反映します。

エンティティの状態は主に以下の4つに分類されます。

Added状態

Added状態は、新しく作成されたエンティティがまだデータベースに存在しない状態を示します。

新規レコードを挿入する際にこの状態になります。

たとえば、新しい顧客情報を追加する場合、エンティティはAdded状態となり、SaveChangesSubmitChangesを呼び出すとINSERT文が発行されます。

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;
public class Customer
{
    public int CustomerId { get; set; }
    public string Name { get; set; }
}
public class SampleContext : DbContext
{
    public DbSet<Customer> Customers { get; set; }
    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseInMemoryDatabase("TestDb");
}
class Program
{
    static void Main()
    {
        using var context = new SampleContext();
        // 新規エンティティを作成し、Added状態になる
        var newCustomer = new Customer { Name = "山田太郎" };
        context.Customers.Add(newCustomer);
        // 状態を確認
        var entry = context.Entry(newCustomer);
        Console.WriteLine($"EntityState: {entry.State}"); // Added
        // データベースに保存
        context.SaveChanges();
        // 保存後の状態を確認
        Console.WriteLine($"EntityState after SaveChanges: {entry.State}"); // Unchanged
    }
}
EntityState: Added
EntityState after SaveChanges: Unchanged

この例では、新しいCustomerエンティティをAddメソッドで追加すると、状態はAddedになります。

SaveChangesを呼ぶとデータベースに挿入され、状態はUnchangedに変わります。

Modified状態

Modified状態は、既存のエンティティのプロパティが変更された場合に設定されます。

変更されたプロパティだけがUPDATE文の対象となり、効率的にデータベースの更新が行われます。

using var context = new SampleContext();
// 既存のエンティティを追加して保存
var customer = new Customer { Name = "佐藤花子" };
context.Customers.Add(customer);
context.SaveChanges();
// エンティティを取得して名前を変更
var existingCustomer = context.Customers.First();
existingCustomer.Name = "佐藤花子改";
var entry = context.Entry(existingCustomer);
Console.WriteLine($"EntityState before SaveChanges: {entry.State}"); // Modified
context.SaveChanges();
Console.WriteLine($"EntityState after SaveChanges: {entry.State}"); // Unchanged
EntityState before SaveChanges: Modified
EntityState after SaveChanges: Unchanged

この例では、既存のCustomerNameを変更すると、状態はModifiedになります。

SaveChangesで変更がデータベースに反映されると、状態はUnchangedに戻ります。

Unchanged状態

Unchanged状態は、エンティティがデータベースの内容と同期しており、変更がない状態を示します。

通常、データベースから取得した直後や、変更を保存した直後の状態です。

using var context = new SampleContext();
// 新規追加して保存
var customer = new Customer { Name = "鈴木一郎" };
context.Customers.Add(customer);
context.SaveChanges();
// 取得したエンティティはUnchanged状態
var existingCustomer = context.Customers.First();
var entry = context.Entry(existingCustomer);
Console.WriteLine($"EntityState: {entry.State}"); // Unchanged
EntityState: Unchanged

この状態のエンティティは、変更がないためSaveChangesを呼んでもデータベースに対して何も行いません。

Deleted状態

Deleted状態は、エンティティが削除対象であることを示します。

Removeメソッドなどでエンティティを削除するとこの状態になり、SaveChangesでDELETE文が発行されます。

using var context = new SampleContext();
// 既存のエンティティを追加して保存
var customer = new Customer { Name = "田中次郎" };
context.Customers.Add(customer);
context.SaveChanges();
// 削除対象に設定
var existingCustomer = context.Customers.First();
context.Customers.Remove(existingCustomer);
var entry = context.Entry(existingCustomer);
Console.WriteLine($"EntityState before SaveChanges: {entry.State}"); // Deleted
context.SaveChanges();
Console.WriteLine($"EntityState after SaveChanges: {entry.State}"); // Detached
EntityState before SaveChanges: Deleted
EntityState after SaveChanges: Detached

削除後はエンティティはDetached状態となり、コンテキストの追跡対象から外れます。

クエリの遅延評価とデータ更新

LINQのクエリは基本的に遅延評価(Deferred Execution)を採用しています。

これは、クエリを定義した時点では実行されず、実際にデータが必要になった時点で初めてクエリが実行される仕組みです。

たとえば、以下のコードではquery変数にクエリが代入されますが、まだデータベースには問い合わせが発生しません。

using var context = new SampleContext();
// クエリの定義(まだ実行されていない)
var query = context.Customers.Where(c => c.Name.Contains("山田"));
// ここで初めてクエリが実行される
foreach (var customer in query)
{
    Console.WriteLine(customer.Name);
}

遅延評価のメリットは、必要なタイミングで最新のデータを取得できることや、複数のクエリを組み合わせて効率的に処理できることです。

一方で、意図しないタイミングでクエリが実行されることもあるため注意が必要です。

データ更新の際は、クエリの結果を即時評価(Eager Execution)してから操作することが多いです。

即時評価はToList()ToArray()を使ってクエリ結果をメモリ上に展開することで行います。

using var context = new SampleContext();
// 即時評価でリストに変換
var customers = context.Customers.Where(c => c.Name.Contains("山田")).ToList();
// リストに対して更新操作
foreach (var customer in customers)
{
    customer.Name += " 更新済み";
}
context.SaveChanges();

このように即時評価を行うことで、クエリの実行タイミングを明確にし、更新処理を安定して行えます。

DataContextとDbContextの比較

LINQを使ったデータ更新では、DataContext(LINQ to SQL)とDbContext(Entity Framework)がよく使われます。

両者は似ていますが、機能や使い勝手に違いがあります。

項目DataContext (LINQ to SQL)DbContext (Entity Framework)
対応データベース主にSQL ServerSQL Server、MySQL、PostgreSQLなど多様
開発元MicrosoftMicrosoft
ORMの成熟度比較的シンプルで軽量高機能で拡張性が高い
マッピング方法属性ベース、DBMLファイルコードファースト、データベースファースト、属性ベース
トラッキング機能あり(変更追跡は限定的)あり(詳細な変更追跡と状態管理)
パフォーマンス軽量で高速機能が多いためやや重いが最適化可能
サポート状況新規開発では推奨されないことが多い現在の主流ORMで活発に開発・サポートされている

DataContextはLINQ to SQLの中心クラスで、比較的シンプルな設計です。

小規模なアプリケーションやSQL Server専用の環境で使われることが多いです。

一方、DbContextはEntity Frameworkの中心クラスで、多様なデータベースに対応し、複雑なマッピングや高度な機能をサポートしています。

たとえば、DataContextでの更新はSubmitChanges()を呼び出しますが、DbContextではSaveChanges()を使います。

DbContextはエンティティの状態管理がより詳細で、ChangeTrackerを通じて変更内容を細かく制御できます。

// DataContextの例(LINQ to SQL)
using (var db = new DataContext())
{
    var order = db.Orders.First(o => o.OrderID == 1);
    order.ShipName = "新しい配送先";
    db.SubmitChanges();
}
// DbContextの例(Entity Framework)
using (var context = new SampleContext())
{
    var customer = context.Customers.First(c => c.CustomerId == 1);
    customer.Name = "新しい名前";
    context.SaveChanges();
}

まとめると、LINQでのデータ更新を行う際は、使用するORMの特性を理解し、適切なコンテキストを選択することが重要です。

DbContextは機能が豊富で拡張性が高いため、現在の開発では主にこちらが使われています。

DataContextはシンプルで軽量なため、特定の用途やレガシー環境で利用されることが多いです。

データ更新の標準ステップ

更新対象の取得

データ更新の最初のステップは、更新したいエンティティをデータベースから取得することです。

LINQでは、FirstSingleなどのメソッドを使って特定のレコードを取得します。

取得方法によって例外の挙動やパフォーマンスが異なるため、適切に使い分けることが重要です。

FirstとSingleの使い分け

FirstSingleはどちらも条件に合致する最初の要素を取得しますが、動作に違いがあります。

  • Firstは条件に合致する要素が1件以上あれば最初の要素を返します。条件に合致する要素がない場合はInvalidOperationExceptionをスローします
  • FirstOrDefaultは条件に合致する要素がなければnull(参照型の場合)やデフォルト値を返します
  • Singleは条件に合致する要素がちょうど1件であることを期待し、複数件ある場合はInvalidOperationExceptionをスローします。条件に合致する要素がない場合も例外が発生します
  • SingleOrDefaultは条件に合致する要素が0件または1件の場合に対応し、0件ならデフォルト値を返しますが、複数件ある場合は例外をスローします

更新対象が一意に特定できる場合はSingleを使い、複数件存在する可能性がある場合はFirstを使うのが一般的です。

using var context = new SampleContext();
// Firstの例:条件に合う最初の顧客を取得
var firstCustomer = context.Customers.First(c => c.Name.StartsWith("山"));
// Singleの例:IDで一意に特定される顧客を取得
var singleCustomer = context.Customers.Single(c => c.CustomerId == 1);
Console.WriteLine($"First Customer: {firstCustomer.Name}");
Console.WriteLine($"Single Customer: {singleCustomer.Name}");
First Customer: 山田太郎
Single Customer: 山田太郎

Singleは一意性が保証されている場合に使うことで、意図しない複数件取得を防げます。

複合条件によるフィルタ

複数の条件を組み合わせて更新対象を絞り込むこともよくあります。

LINQでは&&演算子やメソッドチェーンで複合条件を記述できます。

using var context = new SampleContext();
// 複合条件でフィルタリング
var targetCustomer = context.Customers
    .Where(c => c.Name.Contains("田中") && c.CustomerId > 10)
    .FirstOrDefault();
if (targetCustomer != null)
{
    Console.WriteLine($"対象顧客: {targetCustomer.Name}");
}
else
{
    Console.WriteLine("該当する顧客が見つかりませんでした。");
}
対象顧客: 田中次郎

複合条件を使うことで、より正確に更新対象を特定できます。

プロパティの変更

取得したエンティティのプロパティを変更することで、データベースの内容を更新します。

プロパティには基本型の値や、他のエンティティを参照するナビゲーションプロパティがあります。

基本型プロパティ

基本型プロパティは、文字列や数値、日付などの単純な値を指します。

これらのプロパティを直接変更することで、更新が反映されます。

using var context = new SampleContext();
var customer = context.Customers.First(c => c.CustomerId == 1);
customer.Name = "山田太郎改";
customer.Age = 35; // 例えば年齢プロパティがある場合
context.SaveChanges();

このように、プロパティに新しい値を代入するだけで、SaveChanges時に変更が検知されてデータベースに反映されます。

ナビゲーションプロパティ

ナビゲーションプロパティは、関連するエンティティを参照するプロパティです。

親子関係や多対多の関連を表現します。

ナビゲーションプロパティを変更することで、関連データの更新や追加、削除が可能です。

using var context = new SampleContext();
// 顧客の注文リストを取得
var customer = context.Customers.Include(c => c.Orders).First(c => c.CustomerId == 1);
// 新しい注文を追加
var newOrder = new Order { OrderDate = DateTime.Now, Amount = 1000 };
customer.Orders.Add(newOrder);
context.SaveChanges();

ナビゲーションプロパティの変更は、関連エンティティの状態も自動的に管理されます。

新規追加はAdded状態、削除はDeleted状態に設定され、SaveChangesで反映されます。

変更の保存

エンティティの変更をデータベースに反映させるには、コンテキストの保存メソッドを呼び出します。

LINQ to SQLではSubmitChanges、Entity FrameworkではSaveChangesが使われます。

SubmitChangesの特徴

SubmitChangesはLINQ to SQLのDataContextで使われるメソッドです。

変更されたエンティティの状態を検知し、INSERT、UPDATE、DELETEのSQL文を自動生成してデータベースに送信します。

  • 変更追跡はDataContextが自動で行います
  • トランザクションは内部で管理され、すべての変更が成功するかロールバックされます
  • 例外が発生した場合はChangeConflictExceptionなどがスローされます
using (var db = new DataContext())
{
    var order = db.Orders.First(o => o.OrderID == 1);
    order.ShipName = "新しい配送先";
    try
    {
        db.SubmitChanges();
        Console.WriteLine("変更を保存しました。");
    }
    catch (ChangeConflictException ex)
    {
        Console.WriteLine($"競合エラー: {ex.Message}");
    }
}

SaveChangesの特徴

SaveChangesはEntity FrameworkのDbContextで使われるメソッドです。

SubmitChangesと同様に変更を検知し、適切なSQLを発行します。

  • ChangeTrackerが詳細な状態管理を行います
  • トランザクションは自動的に管理されますが、必要に応じて明示的にトランザクションを制御可能です
  • 例外はDbUpdateExceptionDbUpdateConcurrencyExceptionなどがスローされます
using var context = new SampleContext();
var customer = context.Customers.First(c => c.CustomerId == 1);
customer.Name = "山田太郎改";
try
{
    context.SaveChanges();
    Console.WriteLine("変更を保存しました。");
}
catch (DbUpdateConcurrencyException ex)
{
    Console.WriteLine($"同時実行エラー: {ex.Message}");
}

例外発生時のロールバック

SubmitChangesSaveChangesの呼び出し中に例外が発生した場合、データベースへの変更はロールバックされます。

これにより、部分的な更新によるデータ不整合を防げます。

ただし、コンテキスト内のエンティティの状態は例外発生後も変更されたままのことが多いため、再試行や状態のリセットが必要になる場合があります。

using var context = new SampleContext();
var customer = context.Customers.First(c => c.CustomerId == 1);
customer.Name = "不正な名前"; // 例えば制約違反を起こす値
try
{
    context.SaveChanges();
}
catch (Exception ex)
{
    Console.WriteLine($"エラー発生: {ex.Message}");
    // 状態をリセットする例
    foreach (var entry in context.ChangeTracker.Entries())
    {
        entry.State = Microsoft.EntityFrameworkCore.EntityState.Unchanged;
    }
}

トランザクションを明示的に使う場合は、TransactionScopeDbContext.Database.BeginTransaction()を利用して、複数の操作をまとめてロールバック可能にできます。

using var context = new SampleContext();
using var transaction = context.Database.BeginTransaction();
try
{
    var customer = context.Customers.First(c => c.CustomerId == 1);
    customer.Name = "更新名";
    context.SaveChanges();
    // 他の更新処理...
    transaction.Commit();
}
catch (Exception ex)
{
    Console.WriteLine($"トランザクションエラー: {ex.Message}");
    transaction.Rollback();
}

このように、例外発生時のロールバック処理を適切に行うことで、データの整合性を保ちながら安全に更新処理を実装できます。

効率的な更新技法

Select句による必要列限定

データベースからエンティティを取得する際、必要な列だけを指定して取得することで、パフォーマンスを向上させられます。

LINQのSelect句を使うと、特定のプロパティだけを抽出してクエリを発行できます。

これにより、不要なデータの転送やメモリ使用を抑えられます。

using var context = new SampleContext();
// 顧客の名前だけを取得
var customerNames = context.Customers
    .Where(c => c.Age > 20)
    .Select(c => new { c.CustomerId, c.Name })
    .ToList();
foreach (var c in customerNames)
{
    Console.WriteLine($"ID: {c.CustomerId}, Name: {c.Name}");
}
ID: 1, Name: 山田太郎
ID: 2, Name: 佐藤花子

この例では、CustomerIdNameだけを取得しているため、SQLのSELECT句も必要な列に限定されます。

大量のデータを扱う場合は、必要な列だけを取得することでネットワーク負荷やメモリ消費を削減できます。

ただし、更新対象のエンティティとして扱う場合は、Selectで匿名型や部分的なデータを取得すると、変更追跡ができなくなるため注意が必要です。

更新が必要な場合は、エンティティ全体を取得するか、後述のAttachを使う方法を検討してください。

AsNoTrackingとAttachの併用

AsNoTrackingは、Entity Frameworkでエンティティの変更追跡を無効にして読み取り専用のクエリを高速化するためのメソッドです。

追跡しないため、変更を加えてもSaveChangesで反映されません。

using var context = new SampleContext();
// 追跡なしで取得
var customer = context.Customers.AsNoTracking().First(c => c.CustomerId == 1);
customer.Name = "変更しても反映されない";
context.SaveChanges(); // 変更は反映されない

この場合、SaveChangesを呼んでも変更はデータベースに反映されません。

変更を反映させるには、Attachメソッドでエンティティをコンテキストに再度追跡させる必要があります。

using var context = new SampleContext();
// 追跡なしで取得
var customer = context.Customers.AsNoTracking().First(c => c.CustomerId == 1);
customer.Name = "変更を反映させるためにAttach";
// 追跡を開始
context.Customers.Attach(customer);
// 状態をModifiedに設定
context.Entry(customer).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
context.SaveChanges();

AsNoTrackingで取得したエンティティは軽量で高速ですが、更新時にはAttachと状態変更を組み合わせることで効率的に更新できます。

大量の読み取りと一部の更新が混在するシナリオで有効です。

AutoDetectChangesEnabledの切替

Entity FrameworkのDbContextは、エンティティの変更を自動的に検知する機能を持っています。

これがAutoDetectChangesEnabledプロパティで制御されており、デフォルトでtrueです。

大量のエンティティを一括で更新する場合、変更検知が頻繁に行われるためパフォーマンスが低下します。

そこで、一時的にAutoDetectChangesEnabledfalseにして手動で変更検知を行う方法があります。

using var context = new SampleContext();
context.ChangeTracker.AutoDetectChangesEnabled = false;
foreach (var customer in context.Customers.Where(c => c.Age > 20))
{
    customer.Name += " 更新";
}
// 手動で変更検知
context.ChangeTracker.DetectChanges();
context.SaveChanges();
context.ChangeTracker.AutoDetectChangesEnabled = true;

この方法により、ループ内での自動検知を抑制し、パフォーマンスを改善できます。

ただし、変更検知を忘れると更新が反映されないため注意が必要です。

バルク更新でのパフォーマンス向上

大量のデータを更新する場合、1件ずつエンティティを取得して変更しSaveChangesを呼ぶ方法は非常に非効率です。

バルク更新を活用すると、SQLのUPDATE文を直接発行して高速に処理できます。

ExecuteCommandの利用

ExecuteCommandはLINQ to SQLのDataContextで、SQL文を直接実行できるメソッドです。

これを使うと、複数レコードを一括で更新できます。

using (var db = new DataContext())
{
    // ShipViaが1の注文のShipNameを一括更新
    string sql = "UPDATE Orders SET ShipName = {0} WHERE ShipVia = {1}";
    int affectedRows = db.ExecuteCommand(sql, "一括更新済み", 1);
    Console.WriteLine($"更新件数: {affectedRows}");
}
更新件数: 5

この方法は高速ですが、SQLインジェクションに注意し、パラメータを必ず使うことが重要です。

ExecuteUpdateの利用

Entity Framework Core 7.0以降では、ExecuteUpdateメソッドが導入され、LINQのクエリに対して直接バルク更新が可能になりました。

これにより、SQLを直接書かずに効率的な更新が行えます。

using var context = new SampleContext();
// ShipViaが1の注文のShipNameを一括更新
int affectedRows = context.Orders
    .Where(o => o.ShipVia == 1)
    .ExecuteUpdate(setters => setters.SetProperty(o => o.ShipName, "一括更新済み"));
Console.WriteLine($"更新件数: {affectedRows}");
更新件数: 5

ExecuteUpdateはLINQの文法で条件を指定でき、型安全かつ可読性が高いのが特徴です。

大量データの更新に最適で、パフォーマンスも大幅に向上します。

これらの技法を組み合わせることで、LINQを使ったデータ更新のパフォーマンスを効果的に改善できます。

特に大量データの更新や読み取りと更新が混在するシナリオで有効です。

同時実行とデータ整合性

楽観的同時実行制御

楽観的同時実行制御は、複数のユーザーやプロセスが同時に同じデータを更新する可能性がある場合に、データの整合性を保つための手法です。

基本的には、更新時に競合が発生していないかをチェックし、競合があればエラーを通知して対処します。

これにより、ロックを長時間保持せずに高い並行性を実現できます。

TimeStamp列の活用

データベースのテーブルにTimeStamp(またはRowVersion)列を設けることで、楽観的同時実行制御を実装できます。

この列はレコードが更新されるたびに自動的に値が変わるバイナリデータで、Entity Frameworkではこの列を使って更新時の競合を検出します。

public class Product
{
    public int ProductId { get; set; }
    public string Name { get; set; }
    [Timestamp]
    public byte[] RowVersion { get; set; }
}
using var context = new SampleContext();
// 1つ目のコンテキストで商品を取得
var product1 = context.Products.First(p => p.ProductId == 1);
// 2つ目のコンテキストで同じ商品を取得
using var context2 = new SampleContext();
var product2 = context2.Products.First(p => p.ProductId == 1);
// 1つ目のコンテキストで更新
product1.Name = "新しい商品名1";
context.SaveChanges();
// 2つ目のコンテキストで更新しようとすると競合が発生
product2.Name = "新しい商品名2";
try
{
    context2.SaveChanges();
}
catch (DbUpdateConcurrencyException ex)
{
    Console.WriteLine("同時実行競合が発生しました。");
}
同時実行競合が発生しました。

[Timestamp]属性を付けたRowVersion列は、更新時にデータベースの現在のバージョンと比較され、異なっていればDbUpdateConcurrencyExceptionがスローされます。

これにより、競合を検知して適切な処理が可能です。

ConcurrencyToken属性

ConcurrencyToken属性は、Entity Framework Coreで楽観的同時実行制御に使うプロパティを指定するための属性です。

[ConcurrencyCheck]とも呼ばれ、TimeStamp列以外の任意の列を競合検出に利用できます。

public class Customer
{
    public int CustomerId { get; set; }
    public string Name { get; set; }
    [ConcurrencyCheck]
    public string Email { get; set; }
}
using var context = new SampleContext();
var customer1 = context.Customers.First(c => c.CustomerId == 1);
using var context2 = new SampleContext();
var customer2 = context2.Customers.First(c => c.CustomerId == 1);
customer1.Email = "newemail1@example.com";
context.SaveChanges();
customer2.Email = "newemail2@example.com";
try
{
    context2.SaveChanges();
}
catch (DbUpdateConcurrencyException)
{
    Console.WriteLine("Email列の競合が検出されました。");
}
Email列の競合が検出されました。

[ConcurrencyCheck]を付けた列は、更新時に元の値と比較され、異なっていれば競合とみなされます。

TimeStamp列が使えない場合や複数列で競合検出したい場合に有効です。

悲観的ロックの検討

悲観的ロックは、データの更新時に他のトランザクションが同じデータにアクセスできないようにロックをかける手法です。

これにより、競合を未然に防ぎますが、ロック待ちによるパフォーマンス低下やデッドロックのリスクがあります。

更新頻度が高く競合が頻発する場合に検討されます。

TransactionScopeでのロック

.NETのTransactionScopeを使うと、トランザクションの範囲を明示的に指定して悲観的ロックを実装できます。

SQL ServerのWITH (UPDLOCK, ROWLOCK)ヒントを使うことで、読み取り時に更新ロックを取得し、他のトランザクションの更新をブロックします。

using System;
using System.Transactions;
using Microsoft.EntityFrameworkCore;
using var context = new SampleContext();
using (var scope = new TransactionScope(TransactionScopeOption.Required,
    new TransactionOptions { IsolationLevel = IsolationLevel.Serializable }))
{
    // ロックをかけてデータを取得
    var product = context.Products
        .FromSqlRaw("SELECT * FROM Products WITH (UPDLOCK, ROWLOCK) WHERE ProductId = {0}", 1)
        .First();
    // 更新処理
    product.Name = "ロック付き更新";
    context.SaveChanges();
    scope.Complete();
}

この例では、TransactionScope内でUPDLOCKを指定したSQLを実行し、対象行に更新ロックをかけています。

これにより、他のトランザクションが同じ行を更新できなくなり、競合を防止します。

ただし、悲観的ロックはロックの競合やデッドロックのリスクがあるため、必要な範囲で最小限に使うことが推奨されます。

パフォーマンスやスケーラビリティを考慮し、楽観的同時実行制御と使い分けることが重要です。

パターン別実装例

親子エンティティの同時更新

親子エンティティの同時更新は、親エンティティとそれに紐づく子エンティティを一括で更新するケースです。

Entity Frameworkではナビゲーションプロパティを活用し、親子関係を保ったまま変更を追跡して保存できます。

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;
public class Order
{
    public int OrderId { get; set; }
    public string CustomerName { get; set; }
    public List<OrderItem> Items { get; set; } = new();
}
public class OrderItem
{
    public int OrderItemId { get; set; }
    public string ProductName { get; set; }
    public int Quantity { get; set; }
    public int OrderId { get; set; }
    public Order Order { get; set; }
}
public class SampleContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();
    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseInMemoryDatabase("TestDb");
}
class Program
{
    static void Main()
    {
        using var context = new SampleContext();
        // 既存の注文を作成して保存
        var order = new Order
        {
            CustomerName = "山田太郎",
            Items = new List<OrderItem>
            {
                new OrderItem { ProductName = "商品A", Quantity = 2 },
                new OrderItem { ProductName = "商品B", Quantity = 1 }
            }
        };
        context.Orders.Add(order);
        context.SaveChanges();
        // 親子エンティティを同時に更新
        var existingOrder = context.Orders.Include(o => o.Items).First();
        existingOrder.CustomerName = "山田花子";
        // 子エンティティの数量を変更
        existingOrder.Items[0].Quantity = 3;
        // 新しい子エンティティを追加
        existingOrder.Items.Add(new OrderItem { ProductName = "商品C", Quantity = 5 });
        // 子エンティティを削除
        var itemToRemove = existingOrder.Items[1];
        context.OrderItems.Remove(itemToRemove);
        context.SaveChanges();
        // 更新結果の確認
        var updatedOrder = context.Orders.Include(o => o.Items).First();
        Console.WriteLine($"顧客名: {updatedOrder.CustomerName}");
        foreach (var item in updatedOrder.Items)
        {
            Console.WriteLine($"商品名: {item.ProductName}, 数量: {item.Quantity}");
        }
    }
}
顧客名: 山田花子
商品名: 商品A, 数量: 3
商品名: 商品C, 数量: 5

この例では、親エンティティOrderCustomerNameを変更し、子エンティティOrderItemの数量変更、新規追加、削除を同時に行っています。

Includeで子エンティティを読み込み、SaveChangesで一括保存しています。

部分更新(パッチ)のアプローチ

部分更新は、エンティティの一部のプロパティだけを更新したい場合に使います。

全プロパティを取得して更新するのは非効率なため、変更したいプロパティだけを指定して更新する方法が有効です。

using var context = new SampleContext();
// 更新対象のエンティティを作成(IDのみ設定)
var customer = new Customer { CustomerId = 1 };
// コンテキストにアタッチ
context.Customers.Attach(customer);
// 変更したいプロパティだけを明示的に変更状態に設定
customer.Name = "部分更新された名前";
context.Entry(customer).Property(c => c.Name).IsModified = true;
// SaveChangesで部分更新を実行
context.SaveChanges();

この方法では、Attachでエンティティを追跡対象にし、変更したいプロパティだけIsModifiedtrueに設定します。

これにより、SQLのUPDATE文は指定した列のみを更新し、効率的に処理できます。

コンカレンシ衝突時の再試行ロジック

楽観的同時実行制御で競合が発生した場合、DbUpdateConcurrencyExceptionがスローされます。

競合を検知したら、再試行やユーザーへの通知などの対応が必要です。

ここでは再試行ロジックの例を示します。

using var context = new SampleContext();
bool saveFailed;
int retryCount = 0;
const int maxRetries = 3;
do
{
    saveFailed = false;
    try
    {
        var customer = context.Customers.First(c => c.CustomerId == 1);
        customer.Name = $"更新回数 {retryCount + 1}";
        context.SaveChanges();
    }
    catch (DbUpdateConcurrencyException ex)
    {
        saveFailed = true;
        retryCount++;
        Console.WriteLine($"競合検出: 再試行 {retryCount} 回目");
        // 競合したエンティティの状態を取得
        foreach (var entry in ex.Entries)
        {
            // データベースの最新値を取得して現在の値に反映
            var databaseValues = entry.GetDatabaseValues();
            if (databaseValues == null)
            {
                Console.WriteLine("データが削除されました。");
                throw;
            }
            entry.OriginalValues.SetValues(databaseValues);
        }
    }
} while (saveFailed && retryCount < maxRetries);
if (saveFailed)
{
    Console.WriteLine("最大再試行回数に達しました。更新を中止します。");
}
else
{
    Console.WriteLine("更新に成功しました。");
}
競合検出: 再試行 1 回目
更新に成功しました。

この例では、SaveChangesで競合が発生した場合、データベースの最新値を取得してエンティティの元の値を更新し、再度保存を試みます。

最大3回まで再試行し、それでも失敗したら処理を中止します。

これにより、競合時のデータ整合性を保ちながら更新を継続できます。

よくあるエラーと対処法

ObjectDisposedException

ObjectDisposedExceptionは、すでに破棄されたオブジェクトにアクセスしようとしたときに発生します。

LINQやEntity Frameworkでよくあるのは、DbContextDataContextのライフサイクルが終了した後に、そのコンテキストを使ってデータ操作を試みた場合です。

SampleContext context;
void Example()
{
    using (context = new SampleContext())
    {
        var customer = context.Customers.First();
        Console.WriteLine(customer.Name);
    }
    // usingブロックを抜けた後にcontextを使うと例外が発生
    var anotherCustomer = context.Customers.First(); // ここでObjectDisposedException
}
System.ObjectDisposedException: 'Cannot access a disposed object.
Object name: 'SampleContext'.'

対処法

  • DbContextDataContextは使い終わったら破棄されるため、破棄後にアクセスしないように設計します
  • 必要なデータはコンテキストが生存している間に取得し、メモリ上に保持しておく
  • 長期間使う場合は、コンテキストのスコープを適切に管理し、必要に応じて新しいインスタンスを作成します

DbUpdateConcurrencyException

DbUpdateConcurrencyExceptionは、楽観的同時実行制御で競合が発生したときにスローされます。

複数のユーザーが同じデータを同時に更新しようとした場合に起こり、データの整合性を保つために例外が通知されます。

try
{
    context.SaveChanges();
}
catch (DbUpdateConcurrencyException ex)
{
    Console.WriteLine("同時実行競合が発生しました。");
    // 競合解決の処理を行う
}

対処法

  • 競合が発生したエンティティの最新データをデータベースから取得し、ユーザーに再確認を促します
  • 再試行ロジックを実装し、競合が解消されるまで更新を試みる
  • 競合解決のために、ユーザーの入力を優先するか、データベースの値を優先するかのポリシーを決める

InvalidOperationException

InvalidOperationExceptionは、LINQやEntity Frameworkの操作で状態が不正な場合に発生します。

例えば、クエリの結果が期待と異なる場合や、エンティティの状態が矛盾している場合などです。

よくあるケースをいくつか挙げます。

  • SingleSingleOrDefaultで複数件の結果が返ったとき
  • FirstSingleで該当データが存在しない場合
  • 追跡されていないエンティティに対して操作を行った場合
// Singleで複数件存在すると例外
var customer = context.Customers.Single(c => c.Name.StartsWith("山"));
// Firstで該当なしの場合も例外
var order = context.Orders.First(o => o.OrderId == 9999);
System.InvalidOperationException: 'Sequence contains more than one element'
System.InvalidOperationException: 'Sequence contains no elements'

対処法

  • Singleを使う場合は、対象が一意であることを保証します
  • 該当データが存在しない可能性がある場合は、FirstOrDefaultSingleOrDefaultを使い、nullチェックを行います
  • エンティティの状態管理を正しく行い、追跡対象外のエンティティに対してはAttachを使います

これらのエラーは、LINQやEntity Frameworkの基本的な使い方を理解し、適切な例外処理や状態管理を行うことで回避できます。

パフォーマンス計測とチューニング

SQLログの確認

Entity FrameworkやLINQ to SQLを使っていると、実際にどのようなSQLが発行されているかを把握することがパフォーマンスチューニングの第一歩になります。

SQLログを確認することで、無駄なクエリや過剰なデータ取得、N+1問題などを発見しやすくなります。

Entity Framework Coreでは、DbContextのログ出力機能を利用してSQLをコンソールやファイルに出力できます。

以下はコンソールにSQLログを出力する例です。

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
public class SampleContext : DbContext
{
    public DbSet<Customer> Customers => Set<Customer>();
    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseInMemoryDatabase("TestDb")
               .LogTo(Console.WriteLine, LogLevel.Information)
               .EnableSensitiveDataLogging();
    }
}
class Program
{
    static void Main()
    {
        using var context = new SampleContext();
        var customers = context.Customers.Where(c => c.Name.Contains("山")).ToList();
    }
}

このコードでは、LogToメソッドでSQLログをコンソールに出力し、EnableSensitiveDataLoggingでパラメータの値も表示しています。

実際のSQL文やパラメータを確認できるため、クエリの最適化に役立ちます。

LINQ to SQLの場合は、DataContextLogプロパティにTextWriterを設定することでSQLログを取得できます。

using (var db = new DataContext())
{
    db.Log = Console.Out;
    var orders = from o in db.Orders
                 where o.OrderDate > DateTime.Now.AddDays(-30)
                 select o;
    foreach (var order in orders)
    {
        Console.WriteLine(order.OrderID);
    }
}

SQLログを確認することで、意図しない全件取得や複雑な結合が発生していないかをチェックし、必要に応じてクエリの見直しやインデックスの追加を検討します。

QueryPlanの取得

SQL Serverなどのデータベースでは、クエリプラン(実行計画)を取得してクエリの実行方法を分析できます。

クエリプランを見ることで、どのインデックスが使われているか、テーブルスキャンが発生しているかなどを把握し、パフォーマンス改善の手がかりを得られます。

SQL Server Management Studio (SSMS)では、クエリを実行する前に「実行計画の表示」を有効にしてクエリプランを確認できます。

LINQで生成されたSQLをコピーしてSSMSで実行し、実行計画を分析するのが一般的です。

また、プログラムからクエリプランを取得したい場合は、SQL Serverの拡張イベントやSET SHOWPLAN_XML ONを使う方法がありますが、通常は開発時にSSMSで確認することが多いです。

クエリプランのポイントは以下の通りです。

  • インデックスの使用状況: 適切なインデックスが使われているか確認します
  • テーブルスキャンの有無: 大量のデータをスキャンしている場合はインデックスの追加を検討
  • 結合の種類: ネストループ結合やマージ結合など、効率的な結合が行われているか
  • コストの高い演算: フィルタやソート、集約のコストをチェック

これらを踏まえて、クエリの書き換えやインデックス設計を行うことでパフォーマンスを向上させられます。

インデックス最適化の影響

インデックスはデータベースの検索性能を大幅に向上させる重要な要素です。

適切なインデックス設計は、LINQで発行されるSQLの実行速度に直結します。

例えば、検索条件に使われる列や結合に使われる外部キー列にはインデックスを張ることが基本です。

逆に、更新頻度が非常に高い列にインデックスを張りすぎると、更新処理のパフォーマンスが低下するためバランスが必要です。

以下はインデックスの効果を示す例です。

-- インデックスなしの場合
SELECT * FROM Customers WHERE Name LIKE '%山%';
-- インデックス作成
CREATE INDEX IX_Customers_Name ON Customers(Name);
-- インデックスありの場合
SELECT * FROM Customers WHERE Name LIKE '山%';

LIKE '%山%'のように前方一致でない検索はインデックスが効きにくいですが、LIKE '山%'のように前方一致の場合はインデックスが有効に働きます。

Entity FrameworkやLINQでは、クエリの書き方によってインデックスの効果が変わることもあるため、SQLログやクエリプランを確認しながら最適化を進めることが重要です。

また、複合インデックスを作成することで複数列の条件を効率的に処理できる場合もあります。

これらの手法を活用してSQLの発行内容や実行計画を把握し、インデックス設計を最適化することで、LINQを使ったデータ更新や検索のパフォーマンスを大幅に改善できます。

まとめ

この記事では、C#のLINQを使ったデータ更新の基本から効率的な技法、同時実行制御、パターン別実装例、よくあるエラー対処法、そしてパフォーマンス計測とチューニングまで幅広く解説しました。

エンティティの状態管理や遅延評価の理解、適切な更新方法の選択、競合時の対応策を身につけることで、安全かつ効率的なデータ操作が可能になります。

さらに、SQLログやクエリプランの確認、インデックス最適化を通じてパフォーマンス向上も実現できます。

これらを活用してLINQによるデータ更新を効果的に行いましょう。

関連記事

Back to top button
目次へ