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トリガーで二重INSERT | INSERTトリガーが同テーブルへ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発行回数 を観測して、原因を短時間で確定させるのが王道です。

コメント