sql server 插入数据后,究竟该怎么获取自增主键?
在 sql server 开发中,一个非常常见的需求是:
插入一条主表数据
↓
拿到数据库生成的 id
↓
继续插入明细数据
例如:
insert into bsyc_purchase_plan(name, plan_no, ...)
values ('2026采购计划', 'pp20260001', ...);
-- 怎么拿到刚刚生成的 id?
看起来只是一个简单问题,但 sql server 实际上提供了多种方式:
output inserted.idscope_identity()@@identityident_current()select max(id)output ... into
它们表面上都是“获取 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 可以返回 insert、update、delete、merge 所影响的行;对于 insert,inserted 表示插入后的数据,因此可以直接取得数据库生成的 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 文档明确指出,@@identity 与 scope_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 设计以及整个软件架构。
以上为个人经验,希望能给大家一个参考,也希望大家多多支持代码网。
发表评论