MsSql

关注公众号 jb51net

关闭
首页 > 数据库 > MsSql > SQLServer插入数据后获取自增主键

SQLServer插入数据后怎么获取自增主键

作者:nbsaas-boot

还在为SQLServer插入数据后如何获取自增主键而困惑吗?本文详细对比OUTPUT INSERTED.id、SCOPE_IDENTITY()、@@IDENTITY等方法的区别,帮你理解作用域、并发安全性和触发器影响,轻松选择最可靠的获取ID方式

SQL Server 插入数据后,究竟该怎么获取自增主键?

在 SQL Server 开发中,一个非常常见的需求是:

插入一条主表数据
        ↓
拿到数据库生成的 ID
        ↓
继续插入明细数据

例如:

insert into bsyc_purchase_plan(name, plan_no, ...)
values ('2026采购计划', 'PP20260001', ...);

-- 怎么拿到刚刚生成的 id?

看起来只是一个简单问题,但 SQL Server 实际上提供了多种方式:

它们表面上都是“获取 ID”,但作用域、并发安全性、触发器影响、批量插入能力完全不同

如果系统需要兼容 SQL Server 2008 及以上版本,理解这些区别非常重要。

先理解 SQL Server 的 IDENTITY

假设有这样一张表:

create table bsyc_purchase_plan
(
    id bigint identity(1,1) not null primary key,
    name varchar(100) not null,
    plan_no varchar(50) not null
);

其中:

id bigint identity(1,1)

表示数据库自动生成:

1
2
3
4
5
...

应用程序插入数据时一般不传 id

insert into bsyc_purchase_plan
(
    name,
    plan_no
)
values
(
    '2026采购计划',
    'PP20260001'
);

问题就变成:

数据库刚刚生成的那个 ID,到底应该怎么拿?

方式一:OUTPUT INSERTED.id

这是非常直接的一种方式。

insert into bsyc_purchase_plan
(
    name,
    plan_no
)
output inserted.id
values
(
    '2026采购计划',
    'PP20260001'
);

数据库直接返回:

id
----
101

OUTPUT 可以返回 INSERTUPDATEDELETEMERGE 所影响的行;对于 INSERTINSERTED 表示插入后的数据,因此可以直接取得数据库生成的 Identity、计算列等值。

这实际上非常符合函数式思维:

insert(data) -> inserted row

而不是:

insert(data)
然后再想办法查询 id

优点:

语义非常清楚:

insert ...
output inserted.id
values ...

意思就是:

插入数据,并把插入后的 id 返回给我。

而且它不仅能返回 ID:

output
    inserted.id,
    inserted.plan_no,
    inserted.name

例如:

insert into bsyc_purchase_plan
(
    name,
    plan_no
)
output
    inserted.id,
    inserted.plan_no,
    inserted.name
values
(
    '2026采购计划',
    'PP20260001'
);

这对 API、脚本、数据处理程序非常方便。Microsoft 也明确说明,OUTPUT 可用于取得插入后产生的 Identity 或计算列。

方式二:SCOPE_IDENTITY()

这是 SQL Server 很经典的一种写法。

insert into bsyc_purchase_plan
(
    name,
    plan_no
)
values
(
    '2026采购计划',
    'PP20260001'
);

select scope_identity() as id;

SCOPE_IDENTITY() 返回:

当前 Session、当前 Scope 中最后产生的 Identity 值。

SQL Server 中所谓 Scope,可以理解成当前的存储过程、触发器、函数或者 SQL Batch。

例如:

declare @id bigint;

insert into bsyc_purchase_plan
(
    name,
    plan_no
)
values
(
    '2026采购计划',
    'PP20260001'
);

set @id = cast(scope_identity() as bigint);

select @id as id;

这里为什么建议:

cast(scope_identity() as bigint)

因为 SCOPE_IDENTITY() 返回的数据类型实际上是:

numeric(38,0)

而我们的主键是:

bigint

因此显式转换更加清晰。

什么时候 SCOPE_IDENTITY() 比 OUTPUT 更方便?

例如典型的:

创建采购计划
      ↓
拿到 plan_id
      ↓
插入采购计划维度
      ↓
插入采购计划明细

这种情况下:

declare @plan_id bigint;

insert into bsyc_purchase_plan
(
    name,
    plan_no
)
values
(
    '2026采购计划',
    'PP20260001'
);

set @plan_id = cast(scope_identity() as bigint);

insert into bsyc_purchase_plan_item
(
    plan_id,
    product_code
)
values
(
    @plan_id,
    '10001'
);

select @plan_id as id;

代码非常自然。

因此可以简单记:

只想返回 ID
    ↓
OUTPUT

后面的 SQL 还需要使用 ID
    ↓
SCOPE_IDENTITY()

当然,这并不是绝对规则,因为 OUTPUT INTO 同样能够解决后一类问题。

方式三:@@IDENTITY

还有一种历史悠久的写法:

insert into bsyc_purchase_plan
(
    name,
    plan_no
)
values
(
    '2026采购计划',
    'PP20260001'
);

select @@identity;

它与 SCOPE_IDENTITY() 的最大区别是:

SCOPE_IDENTITY()
当前 Session
+
当前 Scope

@@IDENTITY
当前 Session
+
所有 Scope

Microsoft 文档明确指出,@@IDENTITYSCOPE_IDENTITY() 都限制在当前 Session,但 @@IDENTITY 不限制 Scope。

这个区别在存在触发器时非常关键

为什么一般不推荐 @@IDENTITY?

假设:

purchase_plan
    ↓ insert
Trigger
    ↓ insert
operation_log

两张表都有 Identity:

purchase_plan.id = 101

operation_log.id = 9001

执行:

insert into purchase_plan(...)
values (...);

然后触发器执行:

insert into operation_log(...)
values (...);

此时:

select scope_identity();

可能得到:

101

而:

select @@identity;

可能得到:

9001

因为 @@IDENTITY 会跨 Scope 获取当前 Session 最后生成的 Identity,所以触发器中的 Identity 可能覆盖你真正想要的值。

Microsoft 文档就使用了类似的触发器场景解释两者差异,并特别指出复制机制中的触发器也可能影响 @@IDENTITY

因此工程实践中:

SCOPE_IDENTITY()  ✅

@@IDENTITY       ⚠️ 尽量不用

方式四:IDENT_CURRENT()

还有一个函数:

select ident_current('bsyc_purchase_plan');

例如返回:

101

很多刚接触 SQL Server 的开发者会觉得:

这个不是更好吗?我直接指定表名了。

实际上恰恰相反。

IDENT_CURRENT('表名') 返回的是:

指定表最后产生的 Identity,不限制 Session,也不限制 Scope。

因此:

Session A
insert -> id = 101

Session B
insert -> id = 102

Session A
IDENT_CURRENT(...)

Session A 完全可能看到:

102

而不是自己的:

101

所以:

insert into bsyc_purchase_plan(...)
values (...);

select ident_current('bsyc_purchase_plan');

不能用于可靠地获取“我刚刚插入的 ID”。

Microsoft 也明确提醒,不能依赖 IDENT_CURRENT + IDENT_INCR 去预测下一个 Identity,因为其他 Session 可以同时插入数据。

它更适合:

查看表当前 Identity 状态
诊断
管理
监控

而不是业务代码获取新插入记录的主键。

方式五:SELECT MAX(id)

还有一种非常常见但危险的代码:

insert into bsyc_purchase_plan(...)
values (...);

select max(id)
from bsyc_purchase_plan;

单用户测试的时候:

插入 101

MAX(id) = 101

看起来一点问题都没有。

但是生产环境:

线程 A
insert -> 101

线程 B
insert -> 102

线程 A
select max(id)

结果:

102

线程 A 拿到了线程 B 的 ID。

问题的本质不是 MAX 性能,而是:

MAX(id)

表达的是:

当前整张表最大的 ID。

而我们真正需要的是:

当前这个 INSERT 产生的 ID。

它们根本不是同一个语义。

所以:

select max(id)

用于获取刚插入的主键,在并发系统中属于典型错误设计。

几个方法真正的区别是什么?

可以从两个维度理解:

Session
Scope

假设:

Session A
    Scope 1
        insert table_a

        trigger
            Scope 2
                insert table_b

Session B
    insert table_a

那么:

方法Session 限制Scope 限制指定表
SCOPE_IDENTITY()
@@IDENTITY
IDENT_CURRENT()
OUTPUT INSERTED.id当前 DML当前 DML当前 DML
MAX(id)

Microsoft 对三个 Identity 函数的定义可以总结为:SCOPE_IDENTITY() 是当前 Session + 当前 Scope;@@IDENTITY 是当前 Session + 任意 Scope;IDENT_CURRENT() 则是指定表 + 任意 Session + 任意 Scope。

如果我们用集合关系表达:

SCOPE_IDENTITY
        ↓
范围最小

@@IDENTITY
        ↓
扩大到整个 Session

IDENT_CURRENT
        ↓
扩大到所有 Session

所以业务程序最常使用的通常是范围最明确的方式。

其实 OUTPUT 还有一个非常重要的能力:批量获取 ID

假设一次插入三条数据:

insert into bsyc_purchase_plan
(
    name,
    plan_no
)
output
    inserted.id,
    inserted.plan_no
values
    ('采购计划A', 'PP001'),
    ('采购计划B', 'PP002'),
    ('采购计划C', 'PP003');

可能得到:

id      plan_no
----------------
101     PP001
102     PP002
103     PP003

这时候 SCOPE_IDENTITY() 就做不到同样的事情。

因为:

select scope_identity();

只返回当前 Scope 中最后产生的一个 Identity,而不是整个批次的 ID 集合。@@IDENTITY 在多行插入情况下同样只返回最后生成的 Identity。

所以:

单条 INSERT
    OUTPUT / SCOPE_IDENTITY 都可以

批量 INSERT
    OUTPUT 明显更合适

这也是 OUTPUT 最大的价值之一。

OUTPUT INTO:更强的玩法

很多人知道:

output inserted.id

但不知道还有:

output inserted.id into ...

例如:

declare @ids table
(
    id bigint,
    plan_no varchar(50)
);

insert into bsyc_purchase_plan
(
    name,
    plan_no
)
output
    inserted.id,
    inserted.plan_no
into @ids(id, plan_no)
values
    ('采购计划A', 'PP001'),
    ('采购计划B', 'PP002'),
    ('采购计划C', 'PP003');

select *
from @ids;

Microsoft 的 OUTPUT 语法明确支持把结果写入表或者表变量,而不是直接返回给客户端。

这样就可以:

INSERT
   ↓
OUTPUT
   ↓
@ids
   ↓
继续参与后续 SQL

例如:

insert into purchase_plan_log
(
    plan_id,
    plan_no
)
select
    id,
    plan_no
from @ids;

这种模式在:

批量创建订单
批量创建采购计划
批量创建任务
批量导入数据
主从表生成

里面非常好用。

OUTPUT 和触发器还有一个容易踩坑的地方

很多人会认为:

output inserted.id

在任何表上都可以直接使用。

实际上并不是。

如果目标表针对对应的 DML 操作存在启用状态的 Trigger,那么:

output ...

如果不带 INTO,会受到限制。

Microsoft 文档明确说明:如果 OUTPUT 没有搭配 INTO,那么对应 DML 的目标表不能存在该操作的启用 Trigger。

比如:

insert into orders(...)
output inserted.id
values (...);

而:

orders
存在启用的 INSERT Trigger

就可能无法直接这样使用。

这时可以考虑:

declare @result table
(
    id bigint
);

insert into orders(...)
output inserted.id into @result
values (...);

select id
from @result;

或者直接采用:

scope_identity()

因此在大量老 ERP、DRP、财务系统中,如果表上 Trigger 很多,SCOPE_IDENTITY() 往往更加省心。

OUTPUT 返回的是“触发器之前”的数据

还有一个高级细节。

OUTPUT INSERTED.xxx 返回的是:

DML 完成后的值
+
Trigger 执行之前的值

Microsoft 文档对此有明确说明。

所以假设:

insert ...

之后 Trigger 又修改了某些字段,那么:

output inserted.xxx

得到的不一定是 Trigger 最终修改完成后的最终数据库状态。

id 来说通常没有影响,但如果你:

output inserted.status,
       inserted.amount,
       inserted.xxx

就需要意识到这一点。

还有一个经常被误解的问题:Identity 不保证连续

假设:

100
101
102

下一次 INSERT 失败了。

之后再 INSERT,完全可能出现:

104

而不是:

103

这是正常现象。

SQL Server 的 Identity 值即使因为语句失败或者事务回滚没有最终提交,也可能已经消耗,因此 Identity 序列可能产生空洞。

因此:

Identity

应该理解成:

自动产生的唯一标识。

而不是:

永远连续的业务流水号。

如果业务需要:

CG2026000001
CG2026000002
CG2026000003

这种严格业务编号,应当设计独立的编号生成机制,而不是依赖 Identity 连续性。

最终推荐

如果是普通业务系统,可以采用下面的原则。

场景一:插入一条,直接返回 ID

首选:

insert into bsyc_purchase_plan
(
    name,
    plan_no
)
output inserted.id
values
(
    '采购计划',
    'PP001'
);

简单、直观。

场景二:插入之后,后续 SQL 继续使用 ID

推荐:

declare @id bigint;

insert into bsyc_purchase_plan
(
    name,
    plan_no
)
values
(
    '采购计划',
    'PP001'
);

set @id = cast(scope_identity() as bigint);

-- 后续业务
insert into ...
values (@id, ...);

select @id as id;

尤其是老系统中存在大量 Trigger 时,这种方式非常实用。

场景三:批量插入

推荐:

output inserted.id

甚至:

output inserted.id into @ids

因为它天然处理的是:

一组输入
        ↓
一组 INSERT
        ↓
一组生成结果

场景四:不要再使用这些方式获取当前 INSERT 的 ID

谨慎甚至避免:

@@identity

不要用于这个目的:

ident_current('table')

更不要:

select max(id)

一张表记住所有区别

方法单条插入批量插入Trigger 安全性并发安全推荐度
OUTPUT INSERTED.id⚠️ 直接 OUTPUT 有限制⭐⭐⭐⭐⭐
SCOPE_IDENTITY()❌ 只能拿最后一个✅ 不受 Trigger Scope 干扰⭐⭐⭐⭐⭐
OUTPUT INTO更灵活⭐⭐⭐⭐⭐
@@IDENTITY❌ 可能被 Trigger 影响当前 Session 内⭐⭐
IDENT_CURRENT()
MAX(id)无关

从架构角度理解这件事情

其实这个问题本质上不是:

SQL Server 有哪几个获取 ID 的函数?

而是一个非常典型的上下文边界问题

我们真正需要表达的是:

y = f(x)

其中:

x = 要插入的数据

f = INSERT

y = 数据库真正生成的数据

理想模型应该是:

InsertedRow = Insert(Input)

所以从这个角度看:

insert ...
output inserted...

其实是最接近这个模型的 SQL 设计:

Input
   ↓
INSERT
   ↓
Output

而:

insert ...

select max(id) ...

实际上已经变成:

Input
   ↓
INSERT

Database Global State
   ↓
SELECT MAX

第二个查询依赖的是数据库全局状态,而不是第一次 INSERT 的直接输出,因此并发问题自然就出现了。

这也是为什么现代系统设计越来越强调:

Input → Operation → Output

而不是:

执行操作
   ↓
再从全局状态猜测刚才发生了什么

结论

SQL Server 获取新增主键并不复杂,真正需要理解的是 Session、Scope、Trigger 和并发边界

可以最终记成三句话:

单条插入返回结果:
OUTPUT INSERTED.id

后续 SQL 需要继续使用:
SCOPE_IDENTITY()

批量插入:
OUTPUT / OUTPUT INTO

而下面三种:

@@IDENTITY
IDENT_CURRENT()
MAX(id)

虽然某些场景“看起来也能拿到 ID”,但它们表达的语义与“获取我刚刚插入的这条数据的 ID”并不完全一致。

数据库编程中,一个非常值得坚持的原则就是:

不要从全局状态推断局部操作的结果;能直接取得操作结果,就直接取得结果。

这不仅适用于 SQL Server 的 Identity,同样适用于事务、消息队列、分布式系统、API 设计以及整个软件架构。

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

您可能感兴趣的文章:
阅读全文