news 2026/10/10 15:00:52

SQL Server实战指南:从建库建表到索引优化的完整T-SQL进阶

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SQL Server实战指南:从建库建表到索引优化的完整T-SQL进阶

我在一家传统企业里接手过一套跑了将近十年的业务系统,库里有二十多张表,最核心的是一张订单流水表,每天新增十几万行。刚开始那会儿,我连在SELECT后面正确组合过滤条件都要想半天,更别提看懂执行计划。后来花了两三个月时间,把SQL Server从基础操作到T-SQL查询整个体系系统捋了一遍,再回头看那些旧报表里的慢查询,基本一眼就能定位问题。这篇文章就是那段经历的浓缩版——不绕弯子,从版本选型讲到建库建表、增删改查、查询逻辑、多表连接、窗口函数,最后收在慢SQL排查和索引优化,适合刚入门的开发、运维,以及所有需要跟SQL Server打交道的人。我尽量把“为什么这样做”也讲清楚,而不只是丢一段能跑的代码。

1. 版本选型与环境准备:第一个坑往往在写SQL之前

很多人学SQL Server直接下载一个最新版就开始建表,结果学到一半发现功能对不上,或者连接工具装不上,非常泄气。所以我建议先把版本和工具这件事理顺。

1.1 免费版怎么选:Developer、Express与评估版别搞混

SQL Server的版本很容易把人绕晕,尤其是免费的那几个,它们的定位完全不同。

  • Developer(开发版):免费,功能接近企业版,几乎什么都能用,包括所有高级功能和完整的T-SQL语法。唯一限制是不能用于生产环境。个人学习、本机开发、写Demo,选这个最合适。
  • Express(快速版):也是免费的,但功能被砍掉不少,数据库大小限制在10GB左右,内存使用上限也有限制。适合小型应用和教学场景,但如果想做数据量稍大的练习,会很快撞到天花板。
  • Evaluation(评估版):180天试用,功能完整,到期需要重新安装或者升级授权。很多人装完发现是试用版,过几个月还要折腾一次,没必要。

我个人的建议很简单:学习就用Developer版,功能齐全,不需要担心授权问题。如果只是想在最小环境里跑一个接口测试,Express也能凑合,但别指望它能扛住比较重的查询练习。

版本对比可以这样记:

版本费用功能生产可用推荐场景
Developer免费几乎完整否开发学习、本机实践
Express免费精简是(轻量)小应用、入门教学
Evaluation免费试用180天完整否短期评估、临时测试
Standard付费中档是中小型生产环境
Enterprise付费全面是大型企业核心库

1.2 安装和连接工具里的两个细节:认证模式与排序规则

安装过程本身没什么好说的,下一步到完成就行,真正影响后面开发的是这两个设置。

第一是身份验证模式。安装时会有“Windows身份验证”和“混合模式”两个选项。建议直接选混合模式,并给sa账号设置一个强密码。原因很现实:后续你用各种工具连接、写脚本、跑自动化任务时,SQL账号会更方便,而纯Windows认证往往会因为权限、域环境等问题白白卡住。当然,生产环境里sa账号要谨慎使用,但学习阶段不用想太多。

第二是排序规则。默认一般是SQL_Latin1_General_CP1_CI_AS,意思大致是不区分大小写(CI)、区分重音(AS)。如果你的业务只涉及中英文,默认值基本够用。但如果你要处理某些特殊字符、或者要和已有数据库保持一致的规则,就得在安装时想清楚,之后改排序规则是一项比较麻烦的操作。

连接工具方面,SSMS(SQL Server Management Studio)依然是主力,功能全面,看执行计划、写查询、管理对象都很顺手。新出的Azure Data Studio更轻量,跨平台,适合日常写SQL。我自己的习惯是:SSMS做管理和性能调优,Azure Data Studio写日常脚本。两个都装上,没坏处。

1.3 没有图形界面时,sqlcmd也能救急

还有一个小技巧,很多人直到进了没有图形界面的服务器环境才意识到。SSMS装不上或者不想装的时候,用自带的sqlcmd命令行一样能跑查询和脚本。

sqlcmd -S localhost -U sa -P YourPassword -Q "SELECT @@VERSION"

如果你想执行一个脚本文件,可以这样:

sqlcmd -S localhost -U sa -P YourPassword -i C:\scripts\init.sql

这个工具在做环境初始化、批量执行脚本时特别有用。我甚至有段时间直接在命令行里练习T-SQL,不依赖任何图形工具,反而对语句的输入输出理解更清楚。

2. 建库建表:数据地基里的约束、默认值和标识列一次想清楚

环境准备好之后,第一件事就是建库建表。很多人在这里就是用CREATE DATABASE和CREATE TABLE各敲一遍,然后就急着写INSERT,其实地基没打牢,后面写再多查询都是错的。

2.1 从CREATE DATABASE说起:数据文件与自动增长的取舍

先看一个最简单的建库语句:

CREATE DATABASE ShopDB;

这一行执行完,系统会在默认路径下创建主数据文件(.mdf)和日志文件(.ldf),并且沿用model库的默认配置。问题就在这里:默认的自动增长设置往往偏保守,数据量上来之后,文件会频繁扩展,产生碎片,影响写入性能。

生产库里我见过最多的做法是手动指定初始大小和增长策略,比如:

CREATE DATABASE ShopDB ON PRIMARY ( NAME = N'ShopDB', FILENAME = N'D:\Data\ShopDB.mdf', SIZE = 512MB, FILEGROWTH = 256MB ) LOG ON ( NAME = N'ShopDB_log', FILENAME = N'D:\Data\ShopDB_log.ldf', SIZE = 256MB, FILEGROWTH = 128MB );

这里的关键是避免“默认一次性增长1MB”那种寒酸配置。只要不是磁盘空间极度紧张,设置一个合理的初始大小,让文件基本保持稳定,比频繁自动增长要靠谱得多。学习阶段用默认配置没问题,但你要知道生产环境通常不是这么干的。

2.2 CREATE TABLE时把约束设计好,少补一万个洞

建表可以说是整个数据库设计里最需要动脑的一步。很多新手只关注“这张表有哪些字段”,却忽略了字段属性。字段属性设计得好,查询写起来顺手,数据脏的概率也低。

看一个例子:

CREATE TABLE Customers ( CustomerID INT IDENTITY(1,1) PRIMARY KEY, CustomerName NVARCHAR(100) NOT NULL, Email NVARCHAR(200) NULL, Age TINYINT CHECK (Age >= 18 AND Age <= 120), CreatedDate DATETIME NOT NULL DEFAULT(GETDATE()) );
  • IDENTITY(1,1):自增列,常用于主键。记住自增不保证连续,事务回滚之后会留下空隙,这是正常现象。
  • PRIMARY KEY:主键约束,唯一标识一行,默认生成聚集索引。
  • NOT NULL与NULL:决定字段是否允许为空,建议按业务语义严格设置,没必要非空的字段就不要给NULL。
  • CHECK:检查约束,比如年龄不能小于18,从根源上阻止垃圾数据进入表。
  • DEFAULT:默认值,比如创建时间自动取系统时间。

实际开发中,很多数据质量问题其实是用一个CHECK或DEFAULT就能避免的。比如订单表的状态字段,加一个CHECK (Status IN ('待支付','已支付','已发货','已完成')),后面写统计逻辑时至少不用处理一堆乱七八糟的状态值。

2.3 ALTER TABLE大表加列:一次阻塞事故的复盘

建表时想不周全,后面就免不了改表。而ALTER TABLE在SQL Server里有时候会惹出大事。

我之前在一个几千万行的订单表上执行过类似这样的一句:

ALTER TABLE Orders ADD Remark NVARCHAR(500) NULL;

看起来很简单,加一个可空列而已。但实际上在旧版本SQL Server里,即使加可空列也可能需要修改表的元数据并短暂持有Schema修改锁(Sch-M lock),大表上的这个操作会阻塞其他所有访问,严重的会把线上查询全部堵住。

那次事故的结果是:应用超时、用户投诉、紧急回滚操作。后来我们调整了流程:大表结构变更一定要排在业务低峰期,并且先评估影响行数;如果确实紧急,可以用多个小批次的方式,或者考虑新建一张扩展表来承载新字段。

这个教训让我明白一件事:建表阶段的每一个设计决定,都是在为未来省时间。约束、默认值、可空性,都要按真实业务去定义,别想着“以后再加”。

3. 增删改查:INSERT、UPDATE、DELETE里的实战细节

表结构定好,接下来就是数据的增删改查。这部分看起来很简单,但边界情况比想象中多得多。

3.1 INSERT的四种写法,各自应对不同场景

最基础的单行插入:

INSERT INTO Customers (CustomerName, Email, Age) VALUES ('张三', 'zhangsan@example.com', 25);

SQL Server 2008+支持一次插入多行,经常用来自动生成测试数据:

INSERT INTO Customers (CustomerName, Email, Age) VALUES ('张三', 'zhangsan@example.com', 25), ('李四', 'lisi@example.com', 30), ('王五', 'wangwu@example.com', 22);

从其他表复制数据,用INSERT...SELECT:

INSERT INTO CustomersArchive (CustomerID, CustomerName, Email, Age) SELECT CustomerID, CustomerName, Email, Age FROM Customers WHERE CreatedDate < '2023-01-01';

还有一种比较特殊的SELECT INTO,它不先建表,而是直接根据查询结果创建一张新表:

SELECT CustomerID, CustomerName, Email INTO CustomersBackup FROM Customers;

SELECT INTO很适合做临时备份、快速拆分数据,但要注意它会新建表,主键、默认值、约束统统不会继承,只保留字段和数据类型。

3.2 UPDATE和DELETE的边界:事务、锁和多行匹配

UPDATE和DELETE最容易出问题的不是语句本身,而是执行前有没有想清楚会影响多少行。

多表更新是T-SQL里比较容易出偏差的写法。比如:

UPDATE o SET o.Status = '已作废' FROM Orders o JOIN OrderDetails d ON o.OrderID = d.OrderID WHERE d.ProductID = 12345;

如果一条订单对应多个明细行,且多行明细都满足WHERE条件,那么SQL Server对这个订单的更新结果是不确定的,可能取其中一行匹配的结果覆盖另一行。遇到这种多表关联更新,最好的做法是先用子查询把目标订单ID精确限定出来,再更新主表:

UPDATE Orders SET Status = '已作废' WHERE OrderID IN ( SELECT OrderID FROM OrderDetails WHERE ProductID = 12345 );

这样逻辑上更清晰,也不容易出现重复匹配的奇怪行为。

DELETE和TRUNCATE的取舍也用得很多。我整理了一张表:

操作是否记录逐行日志能否重置IDENTITY能否与WHERE连用外键引用时
DELETE是否可以检查约束
TRUNCATE否(只记录页释放)是不可以不允许

注意一点,TRUNCATE虽然快,但有外键引用的表不能直接截断。另外TRUNCATE会重置自增ID,所以如果业务上需要保留“下次新增从某个数开始”,用DELETE更可控。

3.3 一次误删数据的恢复经验:开着事务再动手

这条经验是我个人的血泪总结。某次要清理测试库里的旧数据,本意只删一个月前的记录,结果WHERE条件写漏了,差点把整张表清空。好在那时我习惯性地先开了事务:

BEGIN TRAN; DELETE FROM Orders; -- 执行完发现影响行数不对,立刻回滚 ROLLBACK TRAN;

如果确认没问题,再执行COMMIT TRAN;。

这种“先开事务、验证影响行数、再提交”的习惯,我后来一直保留着。哪怕是删除几百行的小操作,也会多一个心眼。毕竟SQL Server里ROLLBACK是唯一的后悔药。

4. SELECT的效率根源:先搞懂查询的逻辑执行顺序

很多人写SELECT时习惯先想“我要哪些列”,再想“过滤什么条件”。但真正高效的方法是用逻辑执行顺序来指导思考。

4.1 逻辑执行顺序是什么

一条SELECT语句看似是从SELECT开始的,但数据库引擎实际按这样的逻辑顺序处理:

  1. FROM:确定数据来源,包括连接和派生表
  2. WHERE:过滤源数据行
  3. GROUP BY:按列分组
  4. HAVING:过滤分组后的结果
  5. SELECT:选择并计算目标列
  6. ORDER BY:排序

这个顺序直接解释了很多新手的困惑:为什么不能在WHERE里使用SELECT别名。比如这段代码会报错:

SELECT CustomerName AS Name FROM Customers WHERE Name = '张三';

因为WHERE在SELECT之前执行,别名Name在WHERE阶段还不存在。正确的写法是直接写WHERE CustomerName = '张三',或者包一层子查询。

理解执行顺序还有个好处,就是能更快判断一条过滤条件到底该放在WHERE还是HAVING。

4.2 三值逻辑:NULL让你莫名丢数据的元凶

SQL里有一个反直觉的概念:NULL不是一个值,它表示“未知”。所以:

SELECT * FROM Customers WHERE Age = NULL;

这样永远查不到数据。NULL = NULL的结果不是TRUE,而是UNKNOWN。正确写法是:

SELECT * FROM Customers WHERE Age IS NULL;

我自己踩过的坑是在统计数量时,用COUNT(列名)和COUNT(*)得到完全不同的结果:

  • COUNT(*)统计所有行。
  • COUNT(列名)跳过该列为NULL的行。

如果某列允许NULL,且你希望统计非空数量,用COUNT(列名)是对的;如果只是想统计总行数,用COUNT(*)。这个区别在报表统计里特别容易造成数据对不上。

处理NULL常用ISNULL或COALESCE:

SELECT CustomerName, ISNULL(Email, '无邮箱') AS Email FROM Customers;

COALESCE更灵活,可以传多个参数,返回第一个非NULL值。

4.3 分页方案:TOP、OFFSET-FETCH和ROW_NUMBER的取舍

查询做到一定规模,分页是绕不开的。SQL Server里常见的有三种写法。

TOP适合取前N条:

SELECT TOP 10 * FROM Orders ORDER BY OrderID DESC;

OFFSET-FETCH是更现代的分页方式,SQL Server 2012+支持:

SELECT * FROM Orders ORDER BY OrderID DESC OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;

这个写法含义很清晰:跳过20行,取接下来10行。缺点是OFFSET跳过的行数越多,扫描成本越高,深分页时性能会下降。

ROW_NUMBER分页是另一种常见方案,尤其适合需要先过滤再取页数的场景:

SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (ORDER BY OrderID DESC) AS RowNum FROM Orders ) AS t WHERE t.RowNum BETWEEN 21 AND 30;

三种方案没有绝对好坏。小表和日志查询用OFFSET-FETCH完全够;数据量大且经常跳到最后几页,可以考虑用键值定位等更精细的分页方式,这个已经属于进阶优化了。

5. 多表查询:JOIN、EXISTS与递归CTE的使用边界

单表查询练熟之后,真正的业务场景大多是多个表联合查询。这块内容很杂,我先挑三个最实用的点讲透。

5.1 LEFT JOIN里过滤条件放ON还是WHERE,区别很大

这是很多SQL开发者都会搞混的一个细节。先看错误示范:

SELECT c.CustomerName, o.OrderID FROM Customers c LEFT JOIN Orders o ON c.CustomerID = o.CustomerID WHERE o.Status = '已支付';

表面上看,这条语句是“左连接所有顾客,再过滤已支付订单”。但实际执行时,WHERE o.Status = '已支付'会把左表里没有订单的顾客全部过滤掉,LEFT JOIN就退化成了INNER JOIN。

正确写法是把过滤条件放进ON子句:

SELECT c.CustomerName, o.OrderID FROM Customers c LEFT JOIN Orders o ON c.CustomerID = o.CustomerID AND o.Status = '已支付';

这样顾客没有已支付订单,仍然会保留在结果里,只是订单列显示NULL。ON决定连接结果,WHERE决定最终结果集——这两者的分工不同,做报表时尤其要注意。

5.2 EXISTS、IN、JOIN:到底哪个快

很多人一听说IN慢,就全部改成EXISTS,其实没这么绝对。现代优化器会把一些IN转换成EXISTS或JOIN,性能差距没有想象中那么大。但有一个坑是必须记住的:NOT IN遇到NULL会全军覆没。

SELECT * FROM Orders WHERE CustomerID NOT IN ( SELECT CustomerID FROM Blacklist WHERE Note IS NOT NULL );

如果子查询返回的结果里包含NULL,整个NOT IN查询会返回0行,因为“任何值与NULL比较”都是UNKNOWN。这时用NOT EXISTS更安全:

SELECT * FROM Orders o WHERE NOT EXISTS ( SELECT 1 FROM Blacklist b WHERE b.CustomerID = o.CustomerID );

我的习惯是:需要“存在性判断”时优先用EXISTS,需要“取关联列值”时用JOIN,需要“数量少且明确无NULL”时IN也没问题。归根到底还是要看执行计划。

5.3 递归CTE查树:组织架构与层级结构

递归CTE(Common Table Expression)是处理层级数据很好用的工具,比如组织架构、商品分类、BOM清单。SQL Server里递归CTE的结构非常清晰:

WITH EmployeeTree AS ( -- 锚定成员:根节点 SELECT EmployeeID, ManagerID, EmployeeName, 1 AS Level FROM Employees WHERE ManagerID IS NULL UNION ALL -- 递归成员:查下级 SELECT e.EmployeeID, e.ManagerID, e.EmployeeName, t.Level + 1 FROM Employees e JOIN EmployeeTree t ON e.ManagerID = t.EmployeeID ) SELECT * FROM EmployeeTree;

这里有一个新手容易遇到的报错:递归默认最大层级是100,超过会报错。如果业务层级很深,可以加OPTION (MAXRECURSION 0)取消限制,或者指定一个合理上限,比如OPTION (MAXRECURSION 500)。

另外要特别注意递归成员里必须有UNION ALL,且递归查询不能出现循环引用,否则会陷入死循环。我用这类查询做过商品类目的无限层级展示,速度和可读性都比用程序递归拼SQL好得多。

6. 窗口函数:复杂统计的优雅解法

窗口函数是我认为学习T-SQL时最值得花时间的一块。很多以前要写子查询、临时表的统计,用窗口函数几行就能算完。

6.1 ROW_NUMBER、RANK、DENSE_RANK:三种排名的区别一次理清

三者的语法很像:

ROW_NUMBER() OVER (ORDER BY Score DESC) RANK() OVER (ORDER BY Score DESC) DENSE_RANK() OVER (ORDER BY Score DESC)

区别体现在并列时:

函数并列处理示例排名
ROW_NUMBER并列也强制给不同序号1, 2, 3, 4
RANK并列结果相同,后面跳号1, 1, 3, 4
DENSE_RANK并列结果相同,后面不跳号1, 1, 2, 3

举个例子,如果一班学生有两个人都是95分,用ROW_NUMBER会给其中一人第1名、另一人第2名;用RANK两个都是第1名,第三个学生就是第3名;用DENSE_RANK两个都是第1名,第三个学生是第2名。

做成绩排名、销售排名时,RANK和DENSE_RANK更符合业务直觉,ROW_NUMBER则更适合分页取固定行数。弄清并列时的业务规则,再决定用哪个。

6.2 PARTITION BY与LAG/LEAD:算占比和环比不用写子查询

窗口函数最大的优势是可以把“分组内的明细行”和“聚合结果”同时保留。比如统计每个地区销售额占比:

SELECT Region, SalesAmount, SUM(SalesAmount) OVER (PARTITION BY Region) AS RegionTotal, SalesAmount * 1.0 / SUM(SalesAmount) OVER (PARTITION BY Region) AS RegionShare FROM Sales;

每一行都带着自己的销售额,还能看到该地区合计以及占比,这在报表里非常实用。

计算环比时,用LAG取前一行的值:

SELECT OrderMonth, TotalAmount, LAG(TotalAmount, 1) OVER (ORDER BY OrderMonth) AS PrevMonthAmount, (TotalAmount - LAG(TotalAmount, 1) OVER (ORDER BY OrderMonth)) / LAG(TotalAmount, 1) OVER (ORDER BY OrderMonth) AS MoM FROM MonthlySales;

LAG向前取,LEAD向后取,第二个参数是偏移量,默认1行。用这个函数之后,我几乎不再写自连接去算环比了。

6.3 窗口函数的执行位置与一个经典错误

窗口函数在GROUP BY和HAVING之后、ORDER BY之前执行。这意味着你不能直接在WHERE里过滤窗口函数的结果:

-- 这段会报错 SELECT * FROM Sales WHERE ROW_NUMBER() OVER (ORDER BY SalesAmount DESC) = 1;

需要套一个子查询,把窗口函数的结果先算出来,再在外层过滤:

SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (ORDER BY SalesAmount DESC) AS rn FROM Sales ) AS t WHERE t.rn = 1;

记住这个执行顺序,能少走很多弯路。

7. 实战的一条主线:用订单数据把前面所有知识串起来

理论讲再多,不如跟着一个完整案例走一遍。下面这个例子几乎涵盖了前面所有知识点。

7.1 场景与建表

假设有一个简化的销售系统,三张核心表:用户表、商品表、订单表。

CREATE TABLE Users ( UserID INT IDENTITY(1,1) PRIMARY KEY, UserName NVARCHAR(50) NOT NULL, CreatedDate DATETIME NOT NULL DEFAULT(GETDATE()) ); CREATE TABLE Products ( ProductID INT IDENTITY(1,1) PRIMARY KEY, ProductName NVARCHAR(100) NOT NULL, Price DECIMAL(10,2) NOT NULL ); CREATE TABLE Orders ( OrderID INT IDENTITY(1,1) PRIMARY KEY, UserID INT NOT NULL REFERENCES Users(UserID), ProductID INT NOT NULL REFERENCES Products(ProductID), Quantity INT NOT NULL CHECK (Quantity > 0), Amount DECIMAL(10,2) NOT NULL, OrderDate DATETIME NOT NULL DEFAULT(GETDATE()) );

然后往表里插入一批测试数据,关键字眼在于“批量”:

INSERT INTO Users (UserName) VALUES ('A同学'), ('B同学'), ('C同学'), ('D同学'); INSERT INTO Products (ProductName, Price) VALUES ('键盘', 199.00), ('鼠标', 99.00), ('显示器', 1299.00); INSERT INTO Orders (UserID, ProductID, Quantity, Amount, OrderDate) VALUES (1, 1, 1, 199.00, '2024-01-05'), (1, 2, 2, 198.00, '2024-01-08'), (2, 3, 1, 1299.00, '2024-01-09'), (3, 1, 1, 199.00, '2024-02-03'), (4, 2, 3, 297.00, '2024-02-11'), (2, 1, 2, 398.00, '2024-02-21'), (3, 3, 1, 1299.00, '2024-03-15'), (1, 3, 1, 1299.00, '2024-03-18');

7.2 从普通聚合到多维度统计

业务需求一:统计每个月的订单数和订单金额。

SELECT YEAR(OrderDate) AS OrderYear, MONTH(OrderDate) AS OrderMonth, COUNT(*) AS OrderCount, SUM(Amount) AS TotalAmount FROM Orders GROUP BY YEAR(OrderDate), MONTH(OrderDate) ORDER BY OrderYear, OrderMonth;

这里顺便提醒一下:对OrderDate列使用YEAR()和MONTH()函数虽然方便,但会让索引失效。数据量小无所谓,数据量大建议改成范围条件,或者新增一个冗余的月份列。这正好呼应了后面的性能优化章节。

业务需求二:统计每个用户的下单金额排名。

SELECT u.UserName, SUM(o.Amount) AS UserTotal, RANK() OVER (ORDER BY SUM(o.Amount) DESC) AS UserRank FROM Users u JOIN Orders o ON u.UserID = o.UserID GROUP BY u.UserName ORDER BY UserRank;

这个查询有意思的点在于:窗口函数RANK()作用在SUM(o.Amount)上,也就是先聚合再排名。

7.3 用窗口函数同时拿到排名、占比和明细

业务需求三:老板想要一张表,同时看到每笔订单的信息、该用户的总消费、该用户在所有用户中的排名,以及这笔订单占该用户总消费的比例。

SELECT u.UserName, o.OrderID, o.OrderDate, o.Amount, SUM(o.Amount) OVER (PARTITION BY u.UserID) AS UserTotal, RANK() OVER (ORDER BY SUM(o.Amount) OVER (PARTITION BY u.UserID) DESC) AS UserRank, o.Amount * 1.0 / SUM(o.Amount) OVER (PARTITION BY u.UserID) AS OrderShare FROM Users u JOIN Orders o ON u.UserID = o.UserID ORDER BY u.UserName, o.OrderDate;

注意这里出现了窗口函数嵌套,写法比较密。实际项目里我会拆成两层:先聚合出“用户总消费排名”,再和订单明细关联。这样每个查询职责单一,也方便后续调整。

这个例子很好地说明了为什么窗口函数值得学:传统GROUP BY会把明细行压缩掉,而窗口函数能在保留明细的同时给出汇总维度,灵活得多。

8. 查询写完了,上线前先检查索引和执行计划

最后一个章节我要聊性能优化。不是说每个查询都要优化,而是至少要学会“什么时候该优化”“怎么看出问题”。

8.1 聚集索引与非聚集索引:别再给每列都建索引

索引的原理用一句话概括:它是书的目录,帮助数据库快速找到目标数据行。

  • 聚集索引决定数据在表里的物理存储顺序,每表只能有一个。默认主键就是聚集索引。点查和范围查都很快,但频繁插入更新会带来页分裂成本。
  • 非聚集索引是独立的目录结构,指向数据行。每表可以有多个。适合高频WHERE和JOIN字段。

建索引的基本思路是:高频查询的过滤条件、连接条件、排序字段最值得建。同时索引不是越多越好,每张索引都会拖慢写入速度,因为每次写入都要同步维护索引。我见过有人给一张10列的表建了8个索引,结果查询还是慢,因为写库时锁争用和索引维护本身就够呛。

实际开发里,我一般通过执行计划观察到底需不需要索引,而不是拍脑袋。

8.2 执行计划里我只看这三个东西

打开执行计划的方式很简单:在SSMS里写好SQL,按一下执行计划按钮,或者快捷键Ctrl+M后执行。新手不要被一堆图标吓到,我建议先看三个点。

第一,Scan还是Seek。如果执行计划里出现的是“聚集索引扫描”或“表扫描”,意味着SQL Server把整张表翻了一遍。表小无所谓,表大就危险。出现“索引查找”或“键查找”则说明用了索引,通常是好事。

第二,估计行数与实际行数差距。估计1行实际10万行,说明统计信息过期,优化器选错了计划。这时往往需要更新统计信息:

UPDATE STATISTICS Orders;

第三,键查找(Key Lookup)。非聚集索引找到了匹配行,但查询还要回表拿其他列,数据量大时这可能成为瓶颈。解决方案是做一个覆盖索引,把需要的列包含进索引里:

CREATE NONCLUSTERED INDEX IX_Orders_UserID ON Orders (UserID) INCLUDE (OrderDate, Amount);

这属于比较常见的调优手段,理解原理之后并不复杂。

8.3 三种常见的慢SQL和改法

最后分享三个我反复在业务代码里见到的慢查询模式。

模式一:在列上使用函数导致索引失效

SELECT * FROM Orders WHERE YEAR(OrderDate) = 2024;

问题在于YEAR(OrderDate)让索引无法使用。改成范围查询:

SELECT * FROM Orders WHERE OrderDate >= '2024-01-01' AND OrderDate < '2025-01-01';

模式二:隐式转换。当字段是varchar而参数是int时,SQL Server可能对字段做类型转换,导致索引失效。比如:

-- 假设 OrderNo 是 varchar 类型 SELECT * FROM Orders WHERE OrderNo = 100123;

规范做法是把参数转成字符串,或者干脆保证参数类型一致:

SELECT * FROM Orders WHERE OrderNo = '100123';

模式三:前导模糊查询:

SELECT * FROM Products WHERE ProductName LIKE '%键盘%';

这种写法因为通配符在开头,索引基本派不上用场。数据量小可以忽略;数据量大时,可以评估全文索引,或者按业务改成前缀匹配LIKE '键盘%'。

我把这三个模式的要点汇总一下:

问题慢的原因推荐改法
列上使用函数无法走索引改写为范围条件
字段与参数类型不一致隐式转换屏蔽索引统一参数类型
前导模糊查询无法用索引全文索引或改前缀查询

写完查询,先自查这三条,很多基础性能问题都能避开。

我最后想说的是,SQL Server的入门并不难,难的是把每个操作背后的原理吃透。建表时多想一步约束,写查询时多想一步执行顺序,上线前多看一眼执行计划,这三件事做到位,你的T-SQL水平就已经超过大多数“键盘上跑SQL”的开发了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 14:58:10

轻量化Sprint Board落地指南:让迭代进度一眼可见

做过产品研发的人都知道&#xff0c;迭代管理最怕的不是需求多&#xff0c;而是“看不见到底进行到哪了”。我见过不少团队号称敏捷&#xff0c;但每天的进度只存在于产品经理的Excel里&#xff0c;开发和测试各讲各话&#xff0c;迭代结束发现一堆半成品。后来我坚持在各个团队…

作者头像 李华
网站建设 2026/10/10 14:56:37

EXE解压工具实战:从自解压包中提取Python源码与资源文件

简介&#xff1a;这是一款面向开发者、逆向工程师及软件分析人员的EXE文件资源提取工具&#xff0c;专用于解包非安装类EXE中嵌入的图像、文本、音频等原始资源&#xff0c;解决程序资源复用、界面素材提取与二进制结构分析等实际需求。压缩包共167个文件&#xff0c;含42个可执…

作者头像 李华
网站建设 2026/10/10 14:56:32

Linux性能优化与安全加固实战:从观察到落地的核心手段

自学Linux这件事&#xff0c;很多人卡在第十五天到第二十天这个坎上。前面的基础命令、文件权限、进程管理都学完了&#xff0c;突然发现真正干活的时候完全不知道从哪里下手。第十八天这个节点&#xff0c;我个人体会是最容易出成就感的时候&#xff0c;因为你开始碰两件特别实…

作者头像 李华
网站建设 2026/10/10 14:55:28

React Native鸿蒙适配实战:待办列表组件从白屏到流畅运行

最近把一个 React Native 的待办事项列表组件完整跑到了鸿蒙设备上&#xff0c;整个过程比预想中曲折不少&#xff0c;但收获也很大。待办事项列表看起来是入门级 demo&#xff0c;实际上它是移动端高频交互场景的一个典型缩影&#xff1a;既要有增删改查这种基础数据操作&…

作者头像 李华
网站建设 2026/10/10 14:55:01

gvim命令大全实战指南:从模式寄存器到批量替换与避坑

简介&#xff1a;这份gvim命令大全面向Vim/gvim初学者与需要快速查阅快捷键的开发者&#xff0c;系统整理了图形化Vim环境下的常用操作指令&#xff0c;帮助解决编辑效率低、命令记不牢的问题。资源包内共1个doc文档&#xff0c;约90KB&#xff0c;以纯文本形式罗列命令与简要说…

作者头像 李华
网站建设 2026/10/10 14:51:55

Spring Boot + Vue前后端分离律所案件管理系统开发全解析

1. 项目概述与核心需求拆解1.1 律所管理系统到底解决了什么问题我先把这个项目放在一个真实的场景里聊一聊。你在一个律师事务所里&#xff0c;日常办公最头疼的是什么&#xff1f;不是打官司&#xff0c;而是案件信息的流转和管理。一个律师手头同时跟进七八个案子&#xff0c…

作者头像 李华