SQL Server 基础 T-SQL
SQL Server 使用 T-SQL。它和 MySQL、PostgreSQL 在分页、自增、时间函数、变量、批处理语法上都有差异。
如果你是零基础,不要把 T-SQL 理解成“SQL Server 版 SQL 语法表”。商业项目里 T-SQL 要解决的是:建表、保证唯一性、写入数据、返回自增主键、分页查询、批量更新、事务控制、错误处理和排查执行计划。
学习目标
学完本页,要能做到:
- 能独立创建订单、资产、用户这类业务表。
- 知道
identity、nvarchar、datetime2、decimal为什么常用。 - 能写
insert/update/delete/select/join/group by。 - 能用
output inserted/deleted拿到变更前后数据。 - 能解释
top、offset fetch分页为什么会有深分页问题。 - 能写事务、捕获异常、回滚并抛出错误。
- 能知道哪些写法会让索引失效或导致锁范围扩大。
一条业务 SQL 在系统里扮演什么角色
flowchart TD
A["业务请求<br/>创建资产"] --> B["应用组装 T-SQL"]
B --> C["SQL Server 解析和优化"]
C --> D["访问索引页和数据页"]
D --> E["写事务日志"]
E --> F["提交事务"]
F --> G["返回主键或影响行数"]学习 SQL 不能只看结果对不对,还要看它会访问多少页、会不会加锁、会不会写很多日志、有没有唯一约束兜底。
常用类型
| 类型 | 场景 | 原理和注意点 |
|---|---|---|
bigint | 主键、业务 ID | 范围大,适合长期增长;不要用 int 承载多年订单量 |
identity | 自增列 | 数据库自动生成递增值;适合单库自增,不等于分布式全局 ID |
decimal(18,2) | 金额 | 精确十进制;不要用 float 存钱 |
varchar | 非 Unicode 字符 | 存英文编码内容更省空间 |
nvarchar | Unicode 字符 | 中文、姓名、地址更常用;占用空间通常更大 |
datetime2 | 推荐时间类型 | 精度和范围优于老的 datetime |
bit | 布尔 | 保存 0/1/NULL;业务开关要考虑 NULL 语义 |
uniqueidentifier | GUID | 全局唯一方便,但随机 GUID 做聚集键会导致页分裂 |
为什么中文字段常用 nvarchar
varchar 按数据库排序规则和代码页解释字符,跨语言字符可能出现兼容问题。nvarchar 使用 Unicode,适合中文姓名、医院名称、科室名称、地址等。
但不要所有字段无脑 nvarchar(max):
- 字段过宽会让页内能放的行变少。
- 索引键长度有限制。
- 大字段会增加 IO 和缓存压力。
- 列表页不应该频繁查询大文本字段。
表设计 Demo:医疗资产表
下面这个表不是为了展示语法,而是展示商业表设计要素:主键、业务唯一键、状态、金额、时间、约束和索引。
create table dbo.Asset (
Id bigint identity(1,1) not null primary key,
AssetNo varchar(64) not null,
AssetName nvarchar(128) not null,
HospitalId bigint not null,
DeptId bigint not null,
Status tinyint not null default 1,
OriginalAmount decimal(18,2) not null default 0,
CreatedAt datetime2 not null default sysdatetime(),
UpdatedAt datetime2 not null default sysdatetime(),
RowVersion rowversion not null,
constraint UX_Asset_AssetNo unique (AssetNo)
);
create index IX_Asset_Hospital_Status_CreatedAt
on dbo.Asset(HospitalId, Status, CreatedAt desc)
include (AssetNo, AssetName, DeptId);为什么这样设计:
| 设计 | 为什么 | 不这样会怎样 |
|---|---|---|
Id identity | 数据库内部主键简单高效 | 没主键会影响定位、关联和更新 |
AssetNo 唯一约束 | 防止重复资产编号 | 并发导入时应用判断可能失效 |
Status tinyint | 状态查询高频,节省空间 | 状态用字符串会浪费空间且容易不统一 |
datetime2 | 记录创建和更新时间 | 排查、同步、归档没有时间线 |
rowversion | 乐观锁和并发更新检测 | 多人编辑可能覆盖彼此变更 |
| 联合索引 | 支持医院资产列表查询 | 只建单列索引仍可能扫描很多行 |
Insert
insert into dbo.Asset(AssetNo, AssetName, HospitalId, DeptId, OriginalAmount)
output inserted.Id, inserted.AssetNo, inserted.CreatedAt
values ('A-20260706-001', N'CT 设备', 1001, 2001, 3600000.00);output inserted 类似 PostgreSQL 的 returning,可以返回插入后的值。
output inserted/deleted 怎么理解
SQL Server 在 DML 中可以把变更后的行或变更前的行返回出来:
| 写法 | 含义 |
|---|---|
inserted | insert/update 后的新行 |
deleted | delete 前的旧行,update 前的旧行 |
更新时记录审计日志:
update dbo.Asset
set Status = 2,
UpdatedAt = sysdatetime()
output
deleted.Id,
deleted.Status as OldStatus,
inserted.Status as NewStatus,
inserted.UpdatedAt
where AssetNo = 'A-20260706-001'
and Status = 1;如果影响行数为 0,说明资产不存在或状态不是 1,业务层不能继续认为更新成功。
Top 和分页
select top (20) Id, AssetNo, AssetName
from dbo.Asset
where Status = 1
order by CreatedAt desc;分页:
select Id, AssetNo, AssetName
from dbo.Asset
where HospitalId = 1001
and Status = 1
order by CreatedAt desc
offset 100 rows fetch next 20 rows only;深分页同样会慢,仍要考虑游标分页。
深分页为什么慢
offset 100000 rows fetch next 20 rows only 的含义不是“直接跳到第 100001 行”。数据库仍要按条件和排序找到前 100020 行,然后丢掉前 100000 行。
flowchart TD
A["offset 100000 fetch 20"] --> B["按条件找满足行"]
B --> C["按 order by 排序或扫描有序索引"]
C --> D["跳过前 100000 行"]
D --> E["返回 20 行"]商业项目中列表页可以用“游标分页”:
select top (20) Id, AssetNo, AssetName, CreatedAt
from dbo.Asset
where HospitalId = 1001
and Status = 1
and CreatedAt < @LastCreatedAt
order by CreatedAt desc;这种方式适合下一页、下拉加载、消息列表、采集记录列表。它不适合必须任意跳页的后台报表,报表要结合筛选条件、归档和异步导出。
Join:业务关联怎么写
create table dbo.Department (
Id bigint identity(1,1) not null primary key,
HospitalId bigint not null,
DeptName nvarchar(128) not null
);
select a.AssetNo, a.AssetName, d.DeptName
from dbo.Asset a
join dbo.Department d on d.Id = a.DeptId
where a.HospitalId = 1001
and a.Status = 1
order by a.CreatedAt desc;Join 不是越多越高级。Join 慢通常来自:
- Join 条件没有索引。
- 大表先 Join 后过滤,导致中间结果过大。
- 返回字段太多,导致 Key Lookup 或读页过多。
- 统计信息不准,优化器选错 Join 顺序。
Group By:统计怎么写
select DeptId, Status, count(*) as AssetCount
from dbo.Asset
where HospitalId = 1001
group by DeptId, Status;统计类 SQL 要注意:
where先过滤,再group by。- 大范围统计会扫描很多数据,不能用在高频接口里硬跑。
- 日报、月报常用离线汇总表或异步任务。
- 分组字段和过滤字段可考虑组合索引,但不能为每个报表建一堆大索引。
Update:必须带条件和状态保护
错误写法:
update dbo.Asset
set Status = 2;这会更新整张表,是生产事故。
正确写法要带业务条件:
update dbo.Asset
set Status = 2,
UpdatedAt = sysdatetime()
where AssetNo = 'A-20260706-001'
and Status = 1;为什么带旧状态:
- 防止重复操作。
- 防止并发下状态被别人改过还继续覆盖。
- 可以通过影响行数判断是否更新成功。
Delete:商业系统更常见软删除
核心业务数据通常不直接物理删除,而是加删除标记:
alter table dbo.Asset add IsDeleted bit not null default 0;
update dbo.Asset
set IsDeleted = 1,
UpdatedAt = sysdatetime()
where AssetNo = 'A-20260706-001'
and IsDeleted = 0;软删除优点是可审计、可恢复、避免误删;代价是所有查询都要带 IsDeleted = 0,索引也要考虑这个条件。
Upsert 注意
SQL Server 有 merge,但生产使用要谨慎,很多团队更偏向显式事务里先 update 再 insert,或通过唯一约束捕获冲突。
示例:根据资产编号保存资产。
begin tran;
update dbo.Asset
set AssetName = @AssetName,
DeptId = @DeptId,
UpdatedAt = sysdatetime()
where AssetNo = @AssetNo;
if @@rowcount = 0
begin
insert into dbo.Asset(AssetNo, AssetName, HospitalId, DeptId)
values (@AssetNo, @AssetName, @HospitalId, @DeptId);
end
commit tran;这段代码仍然要有唯一约束兜底。两个事务并发都发现 @@rowcount = 0 时,可能同时 insert,最终必须由 UX_Asset_AssetNo 拦住重复。
事务和异常处理
SQL Server 事务要配合 try/catch,失败时回滚。
begin try
begin tran;
update dbo.Asset
set Status = 2,
UpdatedAt = sysdatetime()
where AssetNo = @AssetNo
and Status = 1;
if @@rowcount = 0
begin
throw 50001, '资产不存在或状态不允许变更', 1;
end
insert into dbo.AssetAudit(AssetNo, ActionName, CreatedAt)
values (@AssetNo, 'CHANGE_STATUS', sysdatetime());
commit tran;
end try
begin catch
if @@trancount > 0
rollback tran;
throw;
end catch;原理:
begin tran后,多条 SQL 属于同一个事务。- 中间任何一步失败,都应该
rollback。 throw保留错误,让应用层知道失败。- 不要 catch 后吞掉异常,否则应用以为成功,数据却没有完成。
常见函数写法
| 需求 | 写法 |
|---|---|
| 当前时间 | sysdatetime() |
| 空值兜底 | isnull(col, 0) 或 coalesce(col, 0) |
| 字符串拼接 | concat(a, b) |
| 类型转换 | cast(value as bigint) / try_convert(bigint, value) |
| 条件表达式 | case when Status = 1 then N'启用' else N'停用' end |
注意:对索引列包函数可能导致索引无法高效使用。
不推荐:
where convert(varchar(10), CreatedAt, 120) = '2026-07-06'推荐范围查询:
where CreatedAt >= '2026-07-06'
and CreatedAt < '2026-07-07'最小可运行 Demo
create table dbo.AssetAudit (
Id bigint identity(1,1) not null primary key,
AssetNo varchar(64) not null,
ActionName varchar(64) not null,
CreatedAt datetime2 not null default sysdatetime()
);
declare @AssetNo varchar(64) = 'A-20260706-002';
begin try
begin tran;
insert into dbo.Asset(AssetNo, AssetName, HospitalId, DeptId, OriginalAmount)
output inserted.Id, inserted.AssetNo
values (@AssetNo, N'超声设备', 1001, 2002, 800000.00);
insert into dbo.AssetAudit(AssetNo, ActionName)
values (@AssetNo, 'CREATE');
commit tran;
end try
begin catch
if @@trancount > 0 rollback tran;
throw;
end catch;这个 Demo 覆盖了建表、插入、返回主键、事务、审计日志和异常回滚,是后台系统最常见的写入模式。
常见坑和排查
| 问题 | 表现 | 原因 | 处理 |
|---|---|---|---|
忘记 where | 大量数据被更新 | 更新语句缺少条件 | 生产更新先 select 验证范围 |
| 深分页慢 | 页码越大越慢 | 需要跳过大量行 | 游标分页或异步导出 |
| 日期函数包列 | 明明有索引还慢 | 索引列被函数处理 | 改范围查询 |
| 金额用 float | 金额出现精度问题 | 二进制浮点不精确 | 使用 decimal |
| 并发重复插入 | 出现重复业务号 | 只靠应用判断 | 数据库唯一约束兜底 |
| catch 吞异常 | 应用以为成功 | 回滚后没抛错 | catch 后 throw |
面试标准回答
SQL Server 使用 T-SQL,常见特点包括 identity 自增、top 查询、offset fetch 分页、output inserted/deleted 返回变更前后数据、datetime2 时间类型、nvarchar 支持 Unicode。商业项目里 T-SQL 不只是增删改查,还要配合唯一约束、事务、异常回滚、状态条件更新和执行计划排查。分页要注意深分页问题,日期过滤要优先范围查询,唯一性必须由数据库唯一约束兜底,不能只靠应用代码判断。