Skip to content

SQL Server 基础 T-SQL

SQL Server 使用 T-SQL。它和 MySQL、PostgreSQL 在分页、自增、时间函数、变量、批处理语法上都有差异。

如果你是零基础,不要把 T-SQL 理解成“SQL Server 版 SQL 语法表”。商业项目里 T-SQL 要解决的是:建表、保证唯一性、写入数据、返回自增主键、分页查询、批量更新、事务控制、错误处理和排查执行计划。

学习目标

学完本页,要能做到:

  1. 能独立创建订单、资产、用户这类业务表。
  2. 知道 identitynvarchardatetime2decimal 为什么常用。
  3. 能写 insert/update/delete/select/join/group by
  4. 能用 output inserted/deleted 拿到变更前后数据。
  5. 能解释 topoffset fetch 分页为什么会有深分页问题。
  6. 能写事务、捕获异常、回滚并抛出错误。
  7. 能知道哪些写法会让索引失效或导致锁范围扩大。

一条业务 SQL 在系统里扮演什么角色

mermaid
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 字符存英文编码内容更省空间
nvarcharUnicode 字符中文、姓名、地址更常用;占用空间通常更大
datetime2推荐时间类型精度和范围优于老的 datetime
bit布尔保存 0/1/NULL;业务开关要考虑 NULL 语义
uniqueidentifierGUID全局唯一方便,但随机 GUID 做聚集键会导致页分裂

为什么中文字段常用 nvarchar

varchar 按数据库排序规则和代码页解释字符,跨语言字符可能出现兼容问题。nvarchar 使用 Unicode,适合中文姓名、医院名称、科室名称、地址等。

但不要所有字段无脑 nvarchar(max)

  1. 字段过宽会让页内能放的行变少。
  2. 索引键长度有限制。
  3. 大字段会增加 IO 和缓存压力。
  4. 列表页不应该频繁查询大文本字段。

表设计 Demo:医疗资产表

下面这个表不是为了展示语法,而是展示商业表设计要素:主键、业务唯一键、状态、金额、时间、约束和索引。

sql
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

sql
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 中可以把变更后的行或变更前的行返回出来:

写法含义
insertedinsert/update 后的新行
deleteddelete 前的旧行,update 前的旧行

更新时记录审计日志:

sql
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 和分页

sql
select top (20) Id, AssetNo, AssetName
from dbo.Asset
where Status = 1
order by CreatedAt desc;

分页:

sql
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 行。

mermaid
flowchart TD
    A["offset 100000 fetch 20"] --> B["按条件找满足行"]
    B --> C["按 order by 排序或扫描有序索引"]
    C --> D["跳过前 100000 行"]
    D --> E["返回 20 行"]

商业项目中列表页可以用“游标分页”:

sql
select top (20) Id, AssetNo, AssetName, CreatedAt
from dbo.Asset
where HospitalId = 1001
  and Status = 1
  and CreatedAt < @LastCreatedAt
order by CreatedAt desc;

这种方式适合下一页、下拉加载、消息列表、采集记录列表。它不适合必须任意跳页的后台报表,报表要结合筛选条件、归档和异步导出。

Join:业务关联怎么写

sql
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 慢通常来自:

  1. Join 条件没有索引。
  2. 大表先 Join 后过滤,导致中间结果过大。
  3. 返回字段太多,导致 Key Lookup 或读页过多。
  4. 统计信息不准,优化器选错 Join 顺序。

Group By:统计怎么写

sql
select DeptId, Status, count(*) as AssetCount
from dbo.Asset
where HospitalId = 1001
group by DeptId, Status;

统计类 SQL 要注意:

  1. where 先过滤,再 group by
  2. 大范围统计会扫描很多数据,不能用在高频接口里硬跑。
  3. 日报、月报常用离线汇总表或异步任务。
  4. 分组字段和过滤字段可考虑组合索引,但不能为每个报表建一堆大索引。

Update:必须带条件和状态保护

错误写法:

sql
update dbo.Asset
set Status = 2;

这会更新整张表,是生产事故。

正确写法要带业务条件:

sql
update dbo.Asset
set Status = 2,
    UpdatedAt = sysdatetime()
where AssetNo = 'A-20260706-001'
  and Status = 1;

为什么带旧状态:

  1. 防止重复操作。
  2. 防止并发下状态被别人改过还继续覆盖。
  3. 可以通过影响行数判断是否更新成功。

Delete:商业系统更常见软删除

核心业务数据通常不直接物理删除,而是加删除标记:

sql
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,或通过唯一约束捕获冲突。

示例:根据资产编号保存资产。

sql
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,失败时回滚。

sql
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;

原理:

  1. begin tran 后,多条 SQL 属于同一个事务。
  2. 中间任何一步失败,都应该 rollback
  3. throw 保留错误,让应用层知道失败。
  4. 不要 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

注意:对索引列包函数可能导致索引无法高效使用。

不推荐:

sql
where convert(varchar(10), CreatedAt, 120) = '2026-07-06'

推荐范围查询:

sql
where CreatedAt >= '2026-07-06'
  and CreatedAt < '2026-07-07'

最小可运行 Demo

sql
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

面试标准回答

text
SQL Server 使用 T-SQL,常见特点包括 identity 自增、top 查询、offset fetch 分页、output inserted/deleted 返回变更前后数据、datetime2 时间类型、nvarchar 支持 Unicode。商业项目里 T-SQL 不只是增删改查,还要配合唯一约束、事务、异常回滚、状态条件更新和执行计划排查。分页要注意深分页问题,日期过滤要优先范围查询,唯一性必须由数据库唯一约束兜底,不能只靠应用代码判断。