我在一家传统企业里接手过一套跑了将近十年的业务系统,库里有二十多张表,最核心的是一张订单流水表,每天新增十几万行。刚开始那会儿,我连在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开始的,但数据库引擎实际按这样的逻辑顺序处理:
- FROM:确定数据来源,包括连接和派生表
- WHERE:过滤源数据行
- GROUP BY:按列分组
- HAVING:过滤分组后的结果
- SELECT:选择并计算目标列
- 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”的开发了。