EF Core 8 SaveChangesAsyncでレコードが二重登録される原因と対策|Quartzジョブ重複実行の防ぎ方

EF Core 8でAddAsync→SaveChangesAsyncを呼ぶだけなのに、同じデータが2件入る…。この症状はEFのバグに見えますが、多くの場合は「同じINSERT処理が2回走っている」設計・運用上の問題です。本記事ではQuartzジョブを例に、原因の切り分けから再発防止策まで整理します。

目次

現象の整理:Idだけ違って内容が同じ行が2件入る

まず、今回の症状をもう一度「事実ベース」で整理します。対象はEF Core 8(Entity Framework Core)で、汎用リポジトリ風のメソッドからエンティティを追加しています。

public async Task AddAsync<T>(T entity) where T : class
{
    await _dbContext.Set<T>().AddAsync(entity);
    await _dbContext.SaveChangesAsync();
}

登録対象の例として、Devices テーブルに対応する Device エンティティがあります。Id はDBで自動採番(Identity)です。

[Table("Devices")]
public class Device
{
    [DatabaseGenerated(DatabaseGeneratedOption.Identity), Key, Required]
    public long Id { get; set; }  // 自動採番

    [MaxLength(33)]
    public required string Pod { get; set; }

    [Required]
    public EnergyDirection Direction { get; set; }
}

ところが、SaveChangesAsync() のタイミングで「同じ内容の行が2件」挿入されます。DB上では Id が違うだけで Pod と Direction が同じです。

select * from "Devices";
 Id |                Pod                | Direction
----+-----------------------------------+-----------
  1 | AT009000000000000000056789800E    |         1
  2 | AT009000000000000000056789800E    |         1

さらに、登録ロジックはQuartzのバックグラウンドジョブ(Job)内から呼ばれています。PoDで事前検索し、存在しない場合のみINSERTする想定です。

public async Task InsertionToDatabase()
{
    if (device == null)
    {
        device = _mapper.Map<Device>(deviceDto);
        await _datarepo.AddAsync(device);
        device = await _datarepo.GetDeviceByPoDAsync(deviceDto.Pod);
    }
    else
    {
        _logger.LogInformation($"Device with PoD {deviceDto.Pod} already exists, skipping insert.");
    }
}

「存在チェックしているのに二重登録される」「デバッグするとSaveChangesAsyncの瞬間に2件増えるように見える」という点が、原因調査を難しくします。

まず押さえるポイント:SaveChangesAsyncが1回で同一行を2回INSERTするケースは基本的に起きにくい

結論から言うと、通常の構成(Identity主キー、単純なエンティティ1件追加)で 1回の SaveChangesAsync() が同じテーブルに同じ行を2回INSERTする ことは基本的に考えにくいです。疑うべきは、次のどちらかです。

  • アプリ側がINSERT処理を2回呼んでいる(ジョブが二重実行されている、同じメソッドが2回通っている、並列処理で競合している 等)
  • DB側がINSERTを増幅している(INSERTトリガー、複製/連携処理、特殊な仕組み 等)

ここで重要なのは、AddAsync はDBへINSERTを発行しているわけではなく、DbContextのChangeTrackerに「Added」を登録するのが主な役割だという点です。INSERTが発行されるのは、基本的に SaveChangesAsync が呼ばれたタイミングです。

つまり「SaveChangesAsyncの瞬間に2件増える」のは、次のような状況でも起こります。

  • ジョブ(または同じ処理)が連続で2回走り、SaveChangesAsyncが2回呼ばれている
  • 同時実行で2つの処理がほぼ同時に走り、結果として ほぼ同じタイミングで2回INSERTされる

見た目は「1回のSaveChangesAsyncが2回INSERTした」ように見えても、実際には 2回のSaveChangesAsyncが短い間隔で実行されている、というパターンが非常に多いです。

原因候補の全体像:どこを疑い、どう切り分けるか

二重登録(重複挿入)は原因が散らばりやすいので、最初に「疑う順番」を決めると調査が一気に速くなります。以下は実務での優先度が高い切り分け表です。

原因候補ありがちな状況まずやる確認対処の方向性
Quartzジョブが二重実行StartNowと手動TriggerJobが両方有効、複数トリガー、複数ホストで同一ジョブAddAsyncにログを入れて呼び出し回数を見るトリガーを1つに統一、同時実行禁止、クラスタ設定見直し
存在チェックの競合(レースコンディション)同じPodを並列処理、同時起動、複数インスタンス同時刻に同じPodのログが複数出ていないかユニーク制約、冪等化、トランザクション/ロック
DBトリガーで二重INSERTINSERTトリガーが同テーブルへINSERTしている、監査テーブルと混同トリガー一覧を確認トリガーの修正/停止、監査設計の見直し
SaveChangesが多重呼び出しSaveChangesInterceptor、DbContextのオーバーライド、イベントで再度保存SaveChangesAsyncの呼び出し元をスタックで追う二重実行箇所を除去、設計を単純化
ユニーク制約が無く重複を許している「同じPodは1件だけ」の前提がDBで保証されていない重複を禁止すべきキーを整理ユニークインデックス追加、例外処理で検知

この中で、今回提示されているQuartzの設定内容を見る限り、最優先で疑うべきは Quartzジョブの二重実行 です。

最有力:Quartzの設定でジョブが2回走っている

今回のQuartz設定では、Job登録時にトリガー側で StartNow() を指定しています。StartNow() は「スケジューラ開始時に即時実行する」トリガーです。

builder.Services.AddQuartz(q =>
{
    var jobKey = new JobKey("DatabaseInsertionScheduler");

    q.AddJob<DatabaseInsertionScheduler>(opts => opts.WithIdentity(jobKey));

    q.AddTrigger(opts => opts
        .ForJob(jobKey)
        .WithIdentity("DatabaseInsertionScheduler")
        .StartNow()
        .WithSimpleSchedule(x => x.WithRepeatCount(0)));
});

さらに、アプリ起動後のコードで TriggerJob(jobKey) を呼んで、同じJobを明示的にもう一度起動しています。

using (var scope = app.Services.CreateScope())
{
    var scheduler = await scope.ServiceProvider
                               .GetRequiredService<ISchedulerFactory>()
                               .GetScheduler();

    var jobKey = new JobKey("DatabaseInsertionScheduler");

    if (await scheduler.CheckExists(jobKey))
    {
        Console.WriteLine("Manually triggering DatabaseInsertionScheduler job...");
        await scheduler.TriggerJob(jobKey);
        Console.WriteLine("Job triggered successfully!");
    }
}

この構成だと、実行の流れは次のようになります。

  • アプリ起動 → Quartz起動 → StartNowによりジョブが1回実行
  • 起動後コードが走る → TriggerJobで同じジョブをもう1回実行
  • 結果としてINSERTロジックが2回走り、同じPodを2回INSERTしてしまう

「SaveChangesAsyncのタイミングで2件入ったように見える」というのは、実際には 2回目のジョブがすぐ後ろで走っていて、2回目のSaveChangesAsyncが連続して実行されている 状態であることが多いです。デバッガでステップ実行していると余計に分かりづらくなります(処理が止まり、タイミングがずれて“同じ瞬間に起きた”ように見えるためです)。

対策:ジョブの起動経路を1つに統一する

二重登録の最短解はシンプルで、「StartNow」か「TriggerJob」どちらか片方にすることです。トリガーが1つに絞れれば、同じInsert処理が二重で走る可能性が大きく下がります。

方式実行タイミングメリット注意点
StartNowを使うアプリ起動時に自動で1回運用が簡単、起動時処理に向く起動直後に別のトリガーを追加すると二重実行しやすい
手動TriggerJobのみ任意のタイミング(テスト/操作)検証に便利、条件で起動できる本番で意図せず呼ばれると事故りやすい
環境で切替(推奨)開発は手動、本番はStartNowなどテストと本番を両立できる切替条件を明確にしないと混乱する

例:StartNowを残し、手動トリガーを無効化する

最も簡単なのは、テスト用の TriggerJob を外す(コメントアウトする)ことです。起動時に1回だけ動かしたいなら、StartNow() だけで十分です。

// テスト用の手動トリガーは削除または無効化する
// await scheduler.TriggerJob(jobKey);

例:手動トリガーだけにし、StartNowを外す

「起動時は動かしたくないが、任意に起動したい」なら、トリガーから StartNow() を外して、手動トリガーに寄せます。

q.AddTrigger(opts => opts
    .ForJob(jobKey)
    .WithIdentity("DatabaseInsertionScheduler")
    // .StartNow() を外す
    .WithSimpleSchedule(x => x.WithRepeatCount(0)));

例:開発環境だけ手動トリガーを許可する

検証コードを残す場合は、環境でガードするのが安全です。「うっかり本番でも二重に叩いた」を避けられます。

if (app.Environment.IsDevelopment())
{
    using var scope = app.Services.CreateScope();
    var scheduler = await scope.ServiceProvider
        .GetRequiredService<ISchedulerFactory>()
        .GetScheduler();

    var jobKey = new JobKey("DatabaseInsertionScheduler");
    if (await scheduler.CheckExists(jobKey))
    {
        await scheduler.TriggerJob(jobKey);
    }
}

ここまでで二重登録が止まるケースは非常に多いです。とはいえ、もう一段だけ安全側に倒すと「再発率」がさらに下がります。

同時実行・多重起動を防ぐ:Quartz側で“同じジョブの重複実行”を抑える

「StartNow + 手動Trigger」のように明確な二重起動がなくても、実運用では次の理由でジョブが重複実行されることがあります。

  • アプリを複数台で動かしていて、各インスタンスが同じQuartzジョブを持っている
  • スケジューラが複数作られている(DI設定/ホスト構成)
  • ジョブ実行が長引き、次のトリガーと重なる
  • ミスファイア(遅延)後にまとめて実行される

そこでおすすめなのが、Quartzの [DisallowConcurrentExecution] を使い、同一ジョブ定義の同時実行を防ぐことです(同じスケジューラ内での並行実行を抑止します)。

[DisallowConcurrentExecution]
public class DatabaseInsertionScheduler : IJob
{
    public async Task Execute(IJobExecutionContext context)
    {
        // ここでInsertionToDatabase等を呼ぶ
    }
}

注意点:これは「同じスケジューラ内」の同時実行抑止が主です。アプリを複数インスタンスで起動していて、それぞれが独立してジョブを持つ場合は、別途「ジョブストアのクラスタリング」や「分散ロック」「DBのユニーク制約」など、システム全体での二重実行対策が必要になります。

「存在チェックしてからINSERT」は競合に弱い:二重登録が起きる典型パターン

今回のロジックは「PoDで検索→無ければINSERT」という構造です。一見正しそうですが、同時実行があると簡単に破綻します。

例えば、同じPoDを2つの処理がほぼ同時に扱うと、次のレースコンディションが起きます。

  • 処理A:PoDで検索 → 見つからない(null)
  • 処理B:PoDで検索 → 見つからない(null)
  • 処理A:INSERT
  • 処理B:INSERT

結果としてDBには同じPodの行が2件できます。これはEF Core 8や SaveChangesAsync の問題というより、“存在チェックとINSERTが分離している”ことが原因です。

では、どうすれば良いか。実務では次の優先順位で対策するのが堅実です。

  • DBで一意性を保証する(ユニークインデックス)
  • アプリは冪等(同じ入力を何回実行しても結果が同じ)になるように例外処理を組み込む
  • 必要ならトランザクションやロック、UPSERT(Insert-if-not-exists)に寄せる

主キーIdとAutoMapperの設定:ここは“正しくしておく”が、直接原因になりにくい

Entityの Id がIdentity(自動採番)なら、アプリ側で Id を埋めないのが基本です。AutoMapperでDTO→Entity変換をする場合、Id に値が入ると意図せず「既存Entity扱い」や別の問題を生む可能性があります。

そのため、次のように Id を無視する設定は妥当です。

CreateMap<DeviceDTO, Device>()
    .ForPath(dest => dest.Id, opt => opt.Ignore());

ただし、今回の「Idだけ違って同じ内容が2件」という現象は、Identityが正常に動いているサインでもあります。つまり、Id設定の不整合より先に“二重実行”や“競合”を疑うのが近道です。

とはいえ、念のため次の2点は確認しておくと安心です。

  • DTO側に Id があり、いつの間にか値が入っていないか
  • Entity生成後に Id を触っていないか(デバッグ用コードやテストコード含む)

ログで即断する:AddAsyncが何回呼ばれたかをまず確定させる

二重登録調査で一番効果が高いのは、「INSERT処理が何回呼ばれたか」をログで確定させることです。疑うより、観測して決めます。

リポジトリの AddAsync の先頭でログを出すだけで、ほとんどのケースは方向性が決まります。

public async Task AddAsync<T>(T entity) where T : class
{
    _logger.LogInformation("AddAsync called. Entity type: {Type}", typeof(T).Name);

    await _dbContext.Set<T>().AddAsync(entity);
    await _dbContext.SaveChangesAsync();
}

さらに踏み込むなら、「Podも一緒に出す」ほうが調査が速くなります。汎用メソッドで難しい場合は、呼び出し側(InsertionToDatabase)で出しましょう。

_logger.LogInformation("Inserting device. Pod={Pod}, Direction={Direction}", deviceDto.Pod, deviceDto.Direction);

ログが2回出れば、SaveChangesAsyncではなく “呼び出しが2回”です。ログが1回なのにDBに2件増えるなら、DBトリガーや別プロセスからのINSERTを疑います。

EF CoreのSQLログを出して「INSERTが何回発行されたか」を見る

呼び出し回数の次に効くのが、EF Coreが実際に発行したSQLの観測です。アプリログにSQL(Database.Command)を出すと、INSERTが2回発行されたのか、1回なのかが一目で分かります。

設定方法はプロジェクトにより異なりますが、一般的にはログレベルを上げます(例として appsettings.json のイメージ)。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information",
      "Quartz": "Information"
    }
  }
}

SQLログで INSERT INTO "Devices" が2回出ていればアプリ側で2回実行されています。1回しか出ないのにDBに2件できるならDB側(トリガー等)の可能性が上がります。

DB側も念のため確認:INSERTトリガーがないか

二重登録の原因として「DBトリガー」は頻度こそ高くないものの、ハマると時間を溶かします。特に、監査目的や同期目的でトリガーを使っている環境では、アプリが1回INSERTしてもDB内部で追加INSERTが走ることがあります。

今回のSQL例はダブルクォートが使われており、PostgreSQL系の匂いがあります。PostgreSQLの場合、トリガー確認の一例は次の通りです(環境に合わせてスキーマ名は調整してください)。

-- Devicesテーブルに紐づくトリガーを確認(PostgreSQL例)
SELECT
  tgname AS trigger_name,
  tgenabled,
  pg_get_triggerdef(oid) AS trigger_def
FROM pg_trigger
WHERE tgrelid = 'Devices'::regclass
  AND NOT tgisinternal;

トリガーが見つかった場合は、定義を確認し、同テーブルへのINSERTや「INSERTの代わりに追加処理」が入っていないかをチェックします。

重複を仕組みで止める:ユニークインデックス(推奨)

「同じPodのDeviceは1件だけ」というビジネス要件があるなら、最終防衛線として DBに一意制約(ユニークインデックス) を持たせるのが強力です。アプリがどれだけ気を付けても、将来の改修や運用で二重実行が起きる可能性はゼロにできません。DBが拒否してくれる設計にしておくと、事故が「静かに重複データが溜まる」状態になりません。

例えばPodが一意なら、次のようなインデックスを検討します。

CREATE UNIQUE INDEX IX_Devices_Pod ON "Devices"("Pod");

ただし、要件によっては (Pod, Direction) の組み合わせで一意にしたいケースもあります。どちらが正しいかはドメイン次第です。

一意にするキー向いている要件注意点
PodのみPodがデバイスの完全な識別子で、方向(Direction)は属性に過ぎないDirection違いでも同じPodを持てなくなる
Pod + Direction同じPodでもDirection別に別レコードとして扱う必要がある要件が曖昧だと後からデータ整合性で悩む

EF Core側でマイグレーションに載せたい場合はFluent APIで定義するのが管理しやすいです。

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Device>()
        .HasIndex(x => x.Pod)
        .IsUnique();

    // もし (Pod, Direction) を一意にするなら
    // modelBuilder.Entity<Device>()
    //     .HasIndex(x => new { x.Pod, x.Direction })
    //     .IsUnique();
}

ユニーク制約違反を「想定内」として扱う

ユニークインデックスを入れると、二重実行が起きたときに例外になります。ここで「落ちる」のではなく、「既にあるならOK」という扱いにしたい場合は例外を捕捉して冪等化します。

try
{
    await _datarepo.AddAsync(device);
}
catch (DbUpdateException ex)
{
    // ユニーク制約違反なら「既に登録済み」とみなす(実装はDBに合わせて判定)
    _logger.LogWarning(ex, "Duplicate insert detected. Pod={Pod}", device.Pod);
}

DB製品によって例外の中身が違うため、運用しているDBのエラーコード(PostgreSQLならSQLSTATE、SQL ServerならNumber等)を見て判定するのが確実です。難しければ「まずログに出して、どの例外が来るかを観測してから分岐」を作ると安全です。

リポジトリ設計の見直し:SaveChangesをメソッド内で呼ぶと“意図せず複数回保存”になりやすい

今回の根本原因はQuartzの二重実行が濃厚ですが、設計面の話として、リポジトリの AddAsync の中で SaveChangesAsync を呼ぶ形は、次のリスクがあります。

  • 上位の処理が複数回Addするたびに、保存が細切れになり、結果として保存回数が増える
  • トランザクション境界が見えづらく、調査が難しくなる
  • 「追加だけしたい」「最後にまとめて保存したい」要件に対応しづらい

可能なら「AddはAddだけ」「保存はUnit of Work(SaveChanges)でまとめる」形にすると、“いつ保存されたか”が明確になり、二重実行の検知も楽になります。

// 例:追加と保存を分離
public Task AddAsync<T>(T entity) where T : class
{
    _dbContext.Set<T>().Add(entity);
    return Task.CompletedTask;
}

public Task SaveAsync()
{
    return _dbContext.SaveChangesAsync();
}

ただし、既存の設計を大きく変えなくても、今回の問題は「ジョブ二重起動の解消」と「ユニーク制約」で十分に収束するケースが多いです。

今回のケースで優先してやるべきチェックと修正

最後に、実際に手を動かす順番を「やることリスト」としてまとめます。調査と対策を混ぜるのがポイントで、観測 → 原因の確定 → 仕組みで再発防止の流れにすると最短で終わります。

  • Quartzの起動経路を1つにする(StartNowかTriggerJobのどちらかを外す)
  • AddAsync / InsertionToDatabaseにログを入れる(Pod付きで呼び出し回数を確定)
  • EF CoreのSQLログでINSERT発行回数を確認(実際にINSERTが何回発行されたかを見る)
  • [DisallowConcurrentExecution]で同時実行を抑止(同一スケジューラ内の重複を防ぐ)
  • DBにユニークインデックスを追加(要件に合うキーで一意性を保証)
  • 念のためDBトリガーの有無を確認(アプリ1回でDBが増幅していないか)

この6点まで入れておくと、「今止まる」だけでなく「将来どこかで二重実行が混ざっても被害が出ない」状態に近づきます。EF Core 8の SaveChangesAsync 自体を疑う前に、まずは ジョブの実行回数と、INSERT発行回数 を観測して、原因を短時間で確定させるのが王道です。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次