Start Debugging

EF Core 11 で insert 時にデータベースのシーケンスから主キーを生成する方法

EF Core 11 の UseSequence でキーを IDENTITY から SQL Server のシーケンスへ移す方法です。EF が実際に発行する SQL、明示的なキー値が IDENTITY_INSERT なしで通るようになる理由、int 列に bigint シーケンスをつないでしまう罠、そして設計時に見込んでおくべき欠番を扱います。

短い答えとして、キーのプロパティに対して UseSequence を呼び出してください。EF Core はそのプロパティを ValueGenerated.OnAdd に設定し、マイグレーションで列に DEFAULT (NEXT VALUE FOR [schema].[SequenceName]) 制約を付け、insert の OUTPUT 句で生成された値を読み戻します。ラウンドトリップの回数は IDENTITY とまったく同じで、バッチ処理のされ方も同じであり、さらに SET IDENTITY_INSERT なしで明示的なキー値を挿入できます。噛みついてくるのは 2 点です。シーケンスの型 (自分で宣言しない限り EF は bigint のシーケンスを作ります) と、SQL Server が回避不能と文書化している欠番です。

// .NET 11, C# 14, EF Core 11
modelBuilder.Entity<Order>()
    .Property(o => o.Id)
    .UseSequence("OrderNumbers", "shared");

この記事の SQL は、EF Core 10.0.11 と .NET SDK 10.0.201 の組み合わせで、EF Core 自身の ICommandBatchPreparerGenerateCreateScript() から取得したものです。EF Core 11 は .NET 11 ランタイムを必要とし、このマシンにはそれが入っていないためです。ただし今回はその影響が普段より小さくなっています。EF Core 11 のリリースノートにはシーケンスやキー値生成に関する項目が一つもなく、SqlServerPropertyBuilderExtensions.UseSequencemain でも変更されていません。以下のステートメントはすべて EF の実際の出力であり、私が書き写したものではありません。観察に稼働中のサーバーが必要な挙動 (ロールバックによる欠番、キャッシュの喪失) は SQL Server のドキュメントを引用し、その旨を明記しています。

キーを IDENTITY から動かす理由

IDENTITY は SQL Server の既定であり、ほとんどのテーブルではこれで問題ありません。そこから離れる動機になるのは次の 3 つの状況です。

2 行版

シーケンスを宣言し、キーをそこに向けます。

// .NET 11, C# 14, EF Core 11
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.HasSequence<int>("DocumentNumbers", schema: "shared")
        .StartsAt(1000)
        .IncrementsBy(1);

    modelBuilder.Entity<Order>()
        .Property(o => o.Id)
        .UseSequence("DocumentNumbers", "shared");

    modelBuilder.Entity<Invoice>()
        .Property(i => i.Id)
        .UseSequence("DocumentNumbers", "shared");
}

UseSequence はプロパティに 3 つの設定を行います。値生成戦略を SqlServerValueGenerationStrategy.Sequence にすること、シーケンスの名前とスキーマ、そして ValueGenerated.OnAdd です。さらに、以前に設定されていた hi-lo や identity のシード構成をクリアします。モデルをダンプすると確認できます。

Order.Id:   ValueGenerated=OnAdd, Strategy=Sequence, DefaultValueSql=NEXT VALUE FOR [shared].[DocumentNumbers]
Invoice.Id: ValueGenerated=OnAdd, Strategy=Sequence, DefaultValueSql=NEXT VALUE FOR [shared].[DocumentNumbers]

DefaultValueSql を EF が埋めてくれている点に注目してください。この文字列はあなたが書いたものではありませんし、UseSequence を使う場合は自分で書くべきではありません。

マイグレーションが生成するもの

dotnet ef migrations add InitialCreateSequence の呼び出しと、列への defaultValueSql を出力します。

// .NET 11, EF Core 11 migration output
migrationBuilder.EnsureSchema(name: "shared");

migrationBuilder.CreateSequence<int>(
    name: "DocumentNumbers",
    schema: "shared",
    startValue: 1000L);

migrationBuilder.CreateTable(
    name: "Orders",
    columns: table => new
    {
        Id = table.Column<int>(type: "int", nullable: false,
            defaultValueSql: "NEXT VALUE FOR [shared].[DocumentNumbers]"),
        Name = table.Column<string>(type: "nvarchar(max)", nullable: false)
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_Orders", x => x.Id);
    });

これはデータベース上では次のようになります。

-- SQL Server, generated by EF Core
CREATE SEQUENCE [shared].[DocumentNumbers] AS int START WITH 1000 INCREMENT BY 1 NO CYCLE;

CREATE TABLE [Orders] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [shared].[DocumentNumbers]),
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Orders] PRIMARY KEY ([Id])
);

列に IDENTITY はありません。既定値制約が付いた普通の int です。

EF が実際に送る INSERT

ここは原理から推論すると多くの人が取り違える部分です。シーケンスによるキーは追加のラウンドトリップを必要としません。EF は列を insert から省き、既定値を発火させ、同じステートメントの中で値を読み戻します。

-- one Order, EF Core 11
SET IMPLICIT_TRANSACTIONS OFF;
SET NOCOUNT ON;
INSERT INTO [Orders] ([Name])
OUTPUT INSERTED.[Id]
VALUES (@p0);

1 回の SaveChangesAsync で注文を 3 件追加すると、EF は IDENTITY の場合と同じ MERGE ... OUTPUT の形を使います。これにより、返されたキーを位置によって追跡中のエンティティに対応付けられます。

-- three Orders in one batch, EF Core 11
SET IMPLICIT_TRANSACTIONS OFF;
SET NOCOUNT ON;
MERGE [Orders] USING (
VALUES (@p0, 0),
(@p1, 1),
(@p2, 2)) AS i ([Name], _Position) ON 1=0
WHEN NOT MATCHED THEN
INSERT ([Name])
VALUES (i.[Name])
OUTPUT INSERTED.[Id], i._Position;

バイト単位で見ても、これは IDENTITY のキーが生成するものと同じです。シーケンスへの切り替えは EF のバッチ戦略を何も変えないので、行ごとの SELECT NEXT VALUE FOR を心配していたのなら不要です。それが起きるのは UseHiLo の場合だけで、これは別の戦略です (後述します)。自分のモデルで確認したい場合、EF Core が生成する SQL をログに出すのは設定 4 行ほどで済みます。

明示的なキー値、多くのチームが乗り換える理由

キーを自分で設定すると、EF はそのプロパティが CLR の既定値ではなくなったことに気付き、列を insert に含め、OUTPUT 句を外します。

// .NET 11, C# 14, EF Core 11
db.Orders.Add(new Order { Id = 5000, Name = "imported" });
await db.SaveChangesAsync();
INSERT INTO [Orders] ([Id], [Name])
VALUES (@p0, @p1);

IDENTITY のキーでもまったく同じステートメントが生成されますが、呼び出しの前後で自分で IDENTITY_INSERT を切り替えない限り、SQL Server は Cannot insert explicit value for identity column in table 'Orders' when IDENTITY_INSERT is set to OFF として拒否します。シーケンスに支えられた列であれば切り替えるものは何もありません。列には既定値があり、値を渡せばそれを上書きするだけです。これが実務上の違いであり、切り替え後にインポートやデータ移行のコードが大幅に短くなる理由です。

これには 2 つの注意点があります。

ゼロは明示的な値ではありません。 EF は CLR の既定値と比較して「利用者がキーを設定した」かどうかを判断します。new Order { Id = 0 }new Order { } と区別できないので、シーケンスが発火します。

-- Order { Id = 0, Name = "zero" }
INSERT INTO [Orders] ([Name])
OUTPUT INSERTED.[Id]
VALUES (@p0);

データ上ゼロが正当なキーであるなら、モデルでプロパティを null 許容にするか、CLR の既定値ではない値を使ってください。

両者を混ぜるとバッチが分かれます。 明示的なキーを持つエンティティと持たないエンティティを追加すると、EF は 1 つの MERGE ではなく 2 つの別々のステートメントを、生成される行を先にして発行します。

SET NOCOUNT ON;
INSERT INTO [Orders] ([Name])
OUTPUT INSERTED.[Id]
VALUES (@p0);
INSERT INTO [Orders] ([Id], [Name])
VALUES (@p1, @p2);

ラウンドトリップは依然として 1 回ですが、バッチ化による利点は失われます。一括インポートでは、明示的なキーを伴う insert を独立した SaveChanges の呼び出しにまとめてください。スループットこそが目的である場合は、これ以上のチューニングに進む前に EF Core 11 と Dapper の一括インサート比較の数値を見ておく価値があります。

int 列につながれた bigint シーケンス

ここが鋭い刃です。UseSequence は、宣言した覚えのないシーケンス名も平然と受け取り、EF は SQL Server の既定の型、つまり bigint でそれを作ります。

// no HasSequence call anywhere in the model
modelBuilder.Entity<Doc>().Property(d => d.Id).UseSequence("OrderNumbers");
CREATE SEQUENCE [OrderNumbers] START WITH 1 INCREMENT BY 1 NO CYCLE;

CREATE TABLE [Docs] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [OrderNumbers]),
    ...
);

AS int がありません。CREATE SEQUENCE のドキュメントは明確です。“If no data type is provided, the bigint data type is used as the default.” bigint のシーケンスが int 列に値を供給する構成は、最初の 2,147,483,647 個までは問題なく動き、その後は列が格納できない数値を渡し始めます。ほとんどのテーブルではまだ遠い先の話ですが、その間ずっと静かな設定ミスであり続け、どのテストにも現れません。

望む型でシーケンスを宣言すれば、この不整合はなくなります。

// .NET 11, C# 14, EF Core 11
modelBuilder.HasSequence<int>("OrderNumbers").StartsAt(1000);
modelBuilder.Entity<Doc>().Property(d => d.Id).UseSequence("OrderNumbers");
CREATE SEQUENCE [OrderNumbers] AS int START WITH 1000 INCREMENT BY 1 NO CYCLE;

経験則として、UseSequence に暗黙にシーケンスを作らせてはいけません。常に同じシーケンスを指す HasSequence<T> と組み合わせてください。

命名と、ドキュメントの誤った 1 行

引数なしで UseSequence() を呼ぶと、EF がシーケンス名を決めます。

modelBuilder.Entity<Doc>().Property(d => d.Id).UseSequence();
// -> CREATE SEQUENCE [DocSequence] ...

nameSuffix パラメーターの XML ドキュメントは、これを “the name that will suffix the table name” と説明しています。そうではありません。テーブル名を変更してもシーケンス名は動きません。

modelBuilder.Entity<Doc>().ToTable("ArchivedDocuments");
modelBuilder.Entity<Doc>().Property(d => d.Id).UseSequence();
// -> CREATE SEQUENCE [DocSequence]
// -> CREATE TABLE [ArchivedDocuments] ([Id] int NOT NULL DEFAULT (NEXT VALUE FOR [DocSequence]), ...)

名前は CLR エンティティ型の短い名前と、既定で "Sequence" となるサフィックスから作られます。クラス名を変更するとシーケンス名も気付かないうちに変わり、これはまさにマイグレーションに DropSequenceCreateSequence の意外な組を生む類の事象です。シーケンスには明示的に名前を付けてください。

モデル全体に効くスイッチもあり、これは各キーに専用のシーケンスを与えます。

// .NET 11, C# 14, EF Core 11
modelBuilder.UseKeySequences();
// -> CREATE SEQUENCE [DocSequence] ...
// -> CREATE SEQUENCE [NoteSequence] ...
// -> [Docs].[Id]  int    DEFAULT (NEXT VALUE FOR [DocSequence])
// -> [Notes].[Id] bigint DEFAULT (NEXT VALUE FOR [NoteSequence])

作成されるすべてのシーケンスに、同じ bigint の注意点が当てはまります。

UseSequence と HasDefaultValueSql

EF Core のシーケンスのドキュメントは、既定値の式を手で書く古い方式を示しています。

modelBuilder.HasSequence<int>("OrderNumbers").StartsAt(1000);
modelBuilder.Entity<Doc>()
    .Property(d => d.Id)
    .HasDefaultValueSql("NEXT VALUE FOR OrderNumbers");

insert の SQL は UseSequence とバイト単位で同一です。違いはモデル側にあります。

UseSequenceHasDefaultValueSql
ValueGeneratedOnAddOnAdd
戦略SequenceNone
既定値の SQLEF が生成し、区切り文字付き自分で書いたものがそのまま出力される
シーケンスの改名HasSequence の呼び出しを 1 か所更新文字列もすべての箇所で更新

「そのまま出力される」という行が重要です。書いた文字列は区切り文字なしで、タイプしたとおりに DDL へ落ちます。

[Id] int NOT NULL DEFAULT (NEXT VALUE FOR OrderNumbers)

シーケンスが区切りを必要とする名前のスキーマに置かれた瞬間、あるいは誰かが空白を入れた瞬間に、これは壊れます。UseSequence は角かっこをすでに付けた NEXT VALUE FOR [shared].[DocumentNumbers] を生成します。キーには UseSequence を選んでください。HasDefaultValueSql は、UseSequence が対応していないキー以外の列のために取っておきます。

キー以外の列: 注文番号と請求書番号

よくある変種は、IDENTITY の代理キーに加えて、人が目にする番号をシーケンスから取るという形です。ここでは HasDefaultValueSql が正しい道具です。

// .NET 11, C# 14, EF Core 11
modelBuilder.HasSequence<int>("TicketNumbers").StartsAt(500).IncrementsBy(10);

modelBuilder.Entity<Ticket>()
    .Property(t => t.TicketNumber)
    .HasDefaultValueSql("NEXT VALUE FOR TicketNumbers");

値を設定しないままにすると EF は列を OUTPUT の一覧に加え、設定すると列の一覧の側へ移します。

-- new Ticket { Name = "t1" }
INSERT INTO [Tickets] ([Name])
OUTPUT INSERTED.[Id], INSERTED.[TicketNumber]
VALUES (@p0);

-- new Ticket { Name = "t2", TicketNumber = 42 }
INSERT INTO [Tickets] ([Name], [TicketNumber])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1);

CLR 既定値の規則は同じで、TicketNumber = 0 は未設定として読まれます。

欠番は必ず出るので、それを前提に設計する

システムのどこかがキーを欠番のないカウンターとして扱っているなら、シーケンスはそれを壊しますし、IDENTITY でも同様に壊れます。CREATE SEQUENCE のドキュメントは率直にこう述べています。“Sequence numbers are generated outside the scope of the current transaction. They’re consumed whether the transaction using the sequence number is committed or rolled back.”

欠番の原因はもう 1 つあります。シーケンスは既定で CACHE であり、SQL Server は値のブロックをメモリ上に先行確保し、ブロックの境界だけを永続化します。同じドキュメントによれば、“an unexpected shutdown (such as a power failure) might result in the loss of sequence numbers remaining in the cache.” つまりクラッシュはキャッシュのブロックを丸ごと焼き払えます。

NO CACHE は値ごとにシステムテーブルへの書き込みを払う代わりにこの窓を狭めますが、それでもドキュメントはこう注意しています。“gaps can still occur if numbers are requested using the NEXT VALUE FOR or sp_sequence_get_range functions, but then the numbers are either not used or are used in uncommitted transactions.”

EF の fluent API はこれを表現できません。SequenceBuilder が公開しているのは StartsAtIncrementsByHasMinHasMaxIsCyclic だけです。マイグレーションで生の SQL に頼ってください。

// .NET 11, EF Core 11
migrationBuilder.Sql("ALTER SEQUENCE [shared].[DocumentNumbers] NO CACHE;");

これは規制当局から求められている場合にだけ行い、既定にはしないでください。本当に欠番のない法定文書番号が必要なら、シーケンスからではなく、別のトランザクション管理されたテーブルで生成してください。

UseSequence と UseHiLo

UseHiLo はシーケンスに支えられたもう一方の戦略で、挙動はまったく異なります。

modelBuilder.Entity<HiLoOrder>().Property(h => h.Id).UseHiLo("HiLoOrderSequence");
// -> CREATE SEQUENCE [HiLoOrderSequence] START WITH 1 INCREMENT BY 10 NO CYCLE;
// -> [HiLoOrders].[Id] int NOT NULL   (no default constraint)

列に既定値は付きません。EF はシーケンスを一度呼んで 10 個分のブロックを確保し、その後はクライアント側でそのブロックからキーを配ります。つまりキーは insert より前に判明します (メモリ上でオブジェクトグラフを組み立てるときに便利です) が、その代償としてブロックを使い切るたびに別のラウンドトリップが発生し、DbContext がブロックの途中で破棄されるたびにずっと大きな欠番が生じます。UseSequence は生成をサーバー側に置き、UseHiLo はクライアント側へ移します。SaveChanges より前にキーを手元に持つ必要が特にないのであれば、UseSequence を選んでください。

既存の IDENTITY テーブルを移行する

ALTER TABLE ... ALTER COLUMN では IDENTITY プロパティを追加も削除もできません。文書化された制限が認めているのは、既存の identity 列の型を、identity プロパティをサポートする別の型へ変えることだけです。したがってその場で行うマイグレーションは存在せず、列を置き換える必要があります。手順は次のとおりです。

  1. SELECT ISNULL(MAX(Id), 0) FROM dbo.Orders で現在の最大値を読み取り、読み取りから切り替えまでの間に挿入される行のための安全余裕を足します。
  2. キーに modelBuilder.HasSequence<int>("DocumentNumbers", "shared").StartsAt(<high-water mark + margin>)UseSequence("DocumentNumbers", "shared") を追加し、マイグレーションを生成します。
  3. 生成された本体を、シーケンスを作成し、Id にシーケンスの既定値を持つ新しいテーブルを構築し、INSERT INTO ... SELECT で行をコピーし、古いテーブルを削除して新しいテーブルの名前を変更する SQL に置き換えます。そのテーブルを指す外部キーは、入れ替えの前後で削除と再作成が必要です。
  4. マイグレーションはトランザクション内で実行し、実行後に SELECT current_value FROM sys.sequences WHERE name = 'DocumentNumbers' が既存の最大キーより上にあることを確認します。

知っておく価値のある詳細が 2 つあります。HasData によるシードはこのモデルに合いません。EF はシードデータにリテラルのキー値を要求し、ストアで生成されるキーを暗黙にシードさせないためで、これがシードエンティティはゼロ以外の値が必要なため追加できないの原因です。シーケンスであれば明示的な値が正当なので、キーをそのまま渡せば済みます。また、テーブル入れ替えのために手書きのマイグレーション SQL を書くのであれば、EF Core 11 のマイグレーションでデータを失わずにテーブル名を変更するときと同じ慎重さが必要です。構造変更に対する生成出力は出発点であって、答えではありません。

以上を終えたら最後に確認すべきことがあります。もう一度 dotnet ef migrations add を実行し、空のマイグレーションが生成されることを確かめてください。モデル上の型がデータベース上の型と一致しないシーケンスや、クラス名の変更に伴って移動してしまった暗黙命名のシーケンスは、生成のたびに幽霊のような DropSequenceCreateSequence として現れます。rowversion 列も同じ理由で同種の幽霊差分を生み、EF Core 11 で rowversion トークンによる楽観的同時実行制御を実装するの手順では、その追跡時に DDL ではなく注釈を読む方法を扱っています。

参考資料

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る