SQLServer的日常使用里,八成以上的时间都在和数据查询打交道。不管是开发写后台接口、运维排查数据问题,还是数据分析师取数做报表,落到数据库层面,最常用的语句就是SELECT。这一篇是SQLServer系列教程的第三章,前面咱们已经聊过环境准备、库表设计和基础数据操作,从这一章开始进入整个SQLServer里最核心、也最值得花时间啃的部分——数据的查询。
很多刚接触SQLServer的朋友第一次写完SELECT时,觉得挺简单,等真正写复杂查询才发现到处是坑。我见过不少同事在数据查询上翻车,翻得最多的不是不会写,而是一些小地方没搞明白,比如WHERE和OR的优先级、NULL的判断方式、日期边界怎么处理、字符串转数字隐式转换导致慢查询。这一篇作为“查询(一)”,先把地基打牢:SELECT怎么写、WHERE怎么过滤、排序和限量怎么做、常用字符串日期函数怎么用,最后把几个典型的查询翻车场景完整复盘一遍。
这篇内容适合三类人:刚学SQLServer的学生或转岗数据分析师、写业务代码但对数据库不熟的开发,以及想系统补一遍查询基础、把平时“能跑但不敢保证对”的SQL写得更严谨的从业者。
1. 为什么说查询是SQLServer里最值得先啃的硬骨头
1.1 数据库日常工作的真实占比
说句实在话,日常数据库工作里,查询的占比远高于建表、写存储过程、做备份这些操作。应用系统跑起来之后,绝大部分数据库压力都来自查询请求。ORM框架最终生成的也大多是SELECT语句,报表系统更不用说,一页报表背后可能就是十几个查询拼接出来的结果。
正因如此,查询写得好不好,直接决定了一个系统的响应速度和稳定性。我见过太多“功能能跑但一上线就卡死”的案例,根子往往不是服务器性能不行,而是查询语句本身有硬伤:该过滤的没过滤、排序放在内存里做、条件列上套了函数导致索引失效。所以我把“数据的查询”单独拆成第三章,而且大概率要分成两篇甚至三篇来讲。
1.2 理解SELECT的逻辑执行顺序,比背语法更关键
很多教材喜欢按书写顺序讲SELECT:先写SELECT,再写FROM,再写WHERE、ORDER BY。但SQL Server服务器执行时并不是按这个顺序来的。它的逻辑执行顺序大致是:
FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> DISTINCT -> ORDER BY -> OFFSET/FETCH这个顺序对新手来说特别重要。举个例子,很多人写完WHERE条件时,想直接引用SELECT里定义的别名:
SELECT Name, Salary * 12 AS AnnualSalary FROM Employees WHERE AnnualSalary > 100000;这条语句会直接报错,因为WHERE在SELECT之前执行,此时AnnualSalary这个别名根本还不存在。而ORDER BY就不一样了,它排在SELECT后面执行,所以下面这条又是合法的:
SELECT Name, Salary * 12 AS AnnualSalary FROM Employees ORDER BY AnnualSalary;理解这套执行顺序之后,很多报错和“诡异”结果不用猜,一想就通。后面讲GROUP BY、HAVING时,这个顺序也是理解一切的基础。我建议你把这行执行顺序抄下来贴在屏幕边上,写SQL的时候心里先过一遍。
1.3 本文的示例表结构
为了让后面的例子有统一的落点,我设计一张最简化的员工表。你可以照着在本地库里建一下:
CREATE TABLE Employees ( EmployeeID INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(50), Department NVARCHAR(50), Salary DECIMAL(10,2), Bonus DECIMAL(10,2), HireDate DATETIME ); INSERT INTO Employees (Name, Department, Salary, Bonus, HireDate) VALUES ('张三', 'IT', 12000, 2000, '2021-06-15'), ('李四', 'IT', 9500, 1500, '2022-03-20'), ('王五', 'HR', 8000, 1000, '2020-11-01'), ('赵六', 'Sales', 15000, NULL, '2019-08-10'), ('钱七', 'Sales', 11000, 3000, '2023-01-15'), ('孙八', 'HR', 8500, 800, '2021-09-30');后面的所有示例都围绕这张表展开。Bonus字段我故意放了一个NULL,后面讲NULL的坑时用得着。
2. SELECT取数:全表、指定列、别名与计算列的基本功
2.1 SELECT * 什么时候能用,什么时候别用
最简单的查询就是把整张表的所有列都捞出来:
SELECT * FROM Employees;初学阶段这样写没问题,查出来能看到全部数据。但在生产环境里,我强烈建议不要轻易写SELECT *。原因有三点:
- 如果表有几十个列,而业务只需要其中两三个,白白在网络层传输了大量无用数据;
- 表结构一旦变更(比如后面加了字段),程序里拿到的结果集会跟着变,很容易引发莫名的报错;
- 覆盖索引无法发挥作用,查询性能上不去。
比较稳妥的写法是列名写清楚:
SELECT Name, Department, Salary FROM Employees;这里涉及的取舍说白了是个习惯问题,但一旦形成“取什么列就写什么列”的习惯,后面写复杂查询时会省掉很多麻烦。
2.2 别名与计算列
给列起别名用AS,也可以直接省略AS。不过我这里建议保留AS,代码可读性好很多:
SELECT Name AS 姓名, Department AS 部门, Salary AS 薪资 FROM Employees;如果别名包含空格或中文,最稳妥的做法是用方括号括起来,或者用双引号:
SELECT Salary * 12 AS [年薪(税前)] FROM Employees;别名有一个使用边界必须牢记:WHERE里不能用别名(因为WHERE先执行),ORDER BY里可以用别名,HAVING里可以用别名。很多人死记硬背记不住,你把第一节的逻辑执行顺序想明白就理解了。
计算列在实际查询里非常常见,比如根据薪水和奖金算年收入、根据单价和数量算总价。在SELECT里可以直接写表达式:
SELECT Name, Salary, ISNULL(Bonus, 0) AS 实际奖金, Salary * 12 + ISNULL(Bonus, 0) AS 年收入 FROM Employees;这里用了ISNULL(Bonus, 0),是因为NULL参与任何数值运算,结果都会变成NULL。这个细节到第3节讲NULL时还会再强调。
2.3 DISTINCT去重的正确理解
DISTINCT用来去重,但它去重的是“整行”,而不是“某一列”。举个例子:
SELECT DISTINCT Department FROM Employees;这条能把部门去重,得到IT、HR、Sales三条。但下面这条就不一样了:
SELECT DISTINCT Department, Name FROM Employees;它会把(DEPARTMENT, NAME)作为一个整体,看组合有没有重复。表里每个人名字都不同,所以这条查出来依然是6行,DISTINCT没有达到“按部门去重但显示姓名”的效果。很多人在这里踩坑,以为加了DISTINCT只管第一列,实际上它管的是整行。
如果需要按某个字段去重、但还想显示其他字段,那要用的不是DISTINCT,而是ROW_NUMBER()配合PARTITION BY的窗口函数。这个等到第6节讲重复数据处理时会给出具体写法。
3. WHERE过滤逻辑:把数据捞准之前先理解运算符优先级
3.1 基础比较与逻辑组合
WHERE负责从表里筛出符合条件的行,是查询中最常用也最容易出错的环节。基础比较运算符很简单:=、<>、>、<、>=、<=。真正容易出问题的是多个条件组合时,AND和OR的优先级。
跟绝大多数编程语言一样,AND的优先级高于OR。看下面这个例子,我想查IT部门且薪资大于10000,或者Sales部门的所有人:
SELECT Name, Department, Salary FROM Employees WHERE Department = 'IT' AND Salary > 10000 OR Department = 'Sales';直觉上是“(IT且薪资高)或者(Sales)”,这条的写法确实也是这个意思。但如果你本意是想查“IT部门中(薪资高或者属于Sales部门的人)”,那必须加括号:
SELECT Name, Department, Salary FROM Employees WHERE Department = 'IT' AND (Salary > 10000 OR Department = 'Sales');不加括号的结果完全不同。这种错误在真实项目里出现过无数次,而且很隐蔽,因为不仔细看根本发现不了。所以我处理多条过滤条件时,习惯无条件加括号,宁可多写几个括号,也不让优先级问题留隐患。
3.2 IN、BETWEEN、LIKE的使用边界
IN用来匹配一个列表:
SELECT Name, Department FROM Employees WHERE Department IN ('IT', 'HR');NOT IN要格外小心:如果IN后面跟的是子查询,而子查询结果里包含NULL,问题就来了——NOT IN遇到NULL会返回UNKNOWN,最终结果往往是空集,一条数据都查不出来。举个典型例子:
-- 假设有个表ResignedEmployees,包含已离职员工ID,其中有NULL SELECT Name FROM Employees WHERE EmployeeID NOT IN (SELECT EmployeeID FROM ResignedEmployees);如果子查询里EmployeeID有NULL,这条语句通常返回空结果。这个坑非常经典,我把它放到第6节专门复盘。
BETWEEN是包含边界的,也就是说BETWEEN 1000 AND 2000实际是>= 1000 AND <= 2000。用在日期上要特别小心,因为日期时间类型带时分秒。比如:
SELECT Name, HireDate FROM Employees WHERE HireDate BETWEEN '2022-01-01' AND '2022-12-31';你以为查的是2022年全年,但实际上只到2022-12-31 00:00:00,也就是说2022年12月31日当天任何00:00:00之后入职的数据都会丢。处理日期区间,我后面会反复推荐一个口诀:用>=和<,别用BETWEEN。
LIKE做模糊查询,%代表任意长度字符,_代表单个字符:
-- 姓张的所有员工 SELECT Name FROM Employees WHERE Name LIKE '张%'; -- 名字总共两个字,且以“李”开头的(需要注意不同排序规则对中文的处理) SELECT Name FROM Employees WHERE Name LIKE '李_';通配符也可以用[ ]指定字符集合,比如LIKE '[张李王]%'表示以张、李、王任意一个开头的字符串。如果字段本身含有%或_,要查它们就需要转义:
SELECT Name FROM Employees WHERE Name LIKE '%\%%' ESCAPE '\';这里ESCAPE '\'声明反斜杠是转义符,第二个%就被当作普通字符了。实际项目里这个场景不多,但遇到了要能想起来。
3.3 NULL的特殊性:查询里最容易忽视的坑
NULL表示“未知”,它不是0,也不是空字符串。任何与NULL直接做比较的结果都是UNKNOWN,所以下面这种写法永远查不到数据:
-- 错误写法,永远查不到Bonus为空的员工 SELECT Name FROM Employees WHERE Bonus = NULL;正确写法必须用IS NULL或IS NOT NULL:
SELECT Name, Bonus FROM Employees WHERE Bonus IS NULL; SELECT Name, Bonus FROM Employees WHERE Bonus IS NOT NULL;NULL的另一个坑是参与运算后会把结果“吃掉”。比如前面算年收入时,如果Bonus是NULL,Salary * 12 + Bonus的结果就变成NULL,哪怕Salary有值。解法是用ISNULL(Bonus, 0)把NULL转成0再参与计算。
再比如与NULL有关的逻辑判断。WHERE Bonus <> 3000这个条件,会把Bonus是NULL的行也过滤掉,因为NULL比较结果是UNKNOWN,UNKNOWN不满足WHERE过滤条件。想要“不是3000的所有人,包括没有奖金的”,必须写成:
SELECT Name FROM Employees WHERE Bonus <> 3000 OR Bonus IS NULL;这部分内容看起来零碎,但数据库查询的绝大多数“莫名其妙”都来自NULL。我甚至觉得,能把NULL处理明白的工程师,查询水平已经不低了。
4. 排序与限量查询:让返回结果真正可控
4.1 ORDER BY的排序规则
查询结果默认顺序是不保证的,想要可控的展示顺序必须用ORDER BY。基础用法是按列升降序:
SELECT Name, Department, Salary FROM Employees ORDER BY Department ASC, Salary DESC;先按部门升序,同一部门内按薪资降序。注意DESC只作用于紧挨着它的那一列,不要被“按部门升序,再按薪资降序”整体误解。
ORDER BY可以使用SELECT里的别名,也可以用表达式:
SELECT Name, Salary * 12 AS AnnualSalary FROM Employees ORDER BY AnnualSalary DESC;这里有个细节:ORDER BY里面的列即使没有出现在SELECT列表里,也可以使用(当然在T-SQL里是可以的)。也就是说,你可以按某个字段排序,但不显示它。
关于NULL的排序位置:SQL Server默认认为NULL比任何值都小,所以升序时NULL排在最前,降序时NULL排在最后。这个行为和Oracle不同,Oracle默认是NULL最大,跨数据库比对结果时要注意。
如果需要自定义NULL的排位,可以用CASE WHEN表达式把NULL映射成一个特殊值:
SELECT Name, Bonus FROM Employees ORDER BY CASE WHEN Bonus IS NULL THEN 1 ELSE 0 END, Bonus DESC;这样NULL就不会因为升序而跑到最前面了。
4.2 TOP和OFFSET-FETCH
限制返回行数,最传统的是TOP:
-- 按薪资降序取前3名 SELECT TOP 3 Name, Salary FROM Employees ORDER BY Salary DESC;TOP有两个变体:TOP n PERCENT按百分比取,比如TOP 10 PERCENT;WITH TIES可以把与最后一名并列的行也带出来:
SELECT TOP 3 WITH TIES Name, Salary FROM Employees ORDER BY Salary DESC;如果第3名有两个人薪资一样,WITH TIES会把两个人都返回,结果可能超过3行。这个特性在实际场景里很有用,比如“排行榜前3但并列要保留”。
SQL Server 2012以后更推荐用OFFSET-FETCH做分页,它才是标准的分页写法:
SELECT Name, Salary FROM Employees ORDER BY Salary DESC OFFSET 0 ROWS FETCH NEXT 2 ROWS ONLY; -- 第1页,每页2条 SELECT Name, Salary FROM Employees ORDER BY Salary DESC OFFSET 2 ROWS FETCH NEXT 2 ROWS ONLY; -- 第2页OFFSET是跳过的行数,FETCH NEXT是取多少行。这里必须搭配ORDER BY,否则分页没有意义,因为顺序都不固定,分页就乱套了。
4.3 一个排序+分页的综合例子
假设业务方要按部门分组看薪资排名,每页取2个人。比较省事的写法是先把主要字段查出来,再在外面包一层排序:
SELECT Name, Department, Salary FROM Employees ORDER BY Department ASC, Salary DESC OFFSET 0 ROWS FETCH NEXT 2 ROWS ONLY;但对于“每个部门取薪资前2名”这种需求,单纯ORDER BY加OFFSET-FETCH是做不到的,必须用窗口函数ROW_NUMBER()配合PARTITION BY。这已经属于进阶查询了,这里先不展开,等讲到窗口函数那一篇再细说。
5. 字符串、日期与类型转换:查询中绕不开的函数组合拳
5.1 字符串处理函数实战
查询里字符串处理频率极高。SQLServer的字符串函数记住下面这些就够用了:
| 函数 | 作用 | 示例 | 结果 |
|---|---|---|---|
LEN() | 返回字符串长度 | LEN('SQLServer') | 9 |
LEFT() | 从左截取 | LEFT('SQLServer', 3) | SQL |
RIGHT() | 从右截取 | RIGHT('SQLServer', 6) | Server |
SUBSTRING() | 从指定位置截取 | SUBSTRING('SQLServer', 4, 6) | Server |
CHARINDEX() | 查找子串位置 | CHARINDEX('Server', 'SQLServer') | 4 |
REPLACE() | 替换指定内容 | REPLACE('a-b-c', '-', '') | abc |
LTRIM()/RTRIM() | 去左右空格 | LTRIM(' abc') | abc |
UPPER()/LOWER() | 大小写转换 | UPPER('sql') | SQL |
举个例子,从员工编号里截取部门代码,或者从完整地址里取省份,都可以用这些函数组合:
SELECT SUBSTRING('EMP-IT-001', 5, 2) AS 部门代码;CHARINDEX经常用来判断某个字符是否存在,比如筛选邮箱里包含@的记录:
SELECT Name, Email FROM Employees WHERE CHARINDEX('@', Email) > 0;5.2 日期函数与区间查询
SQLServer里日期函数也不少,核心的几个:
SELECT GETDATE(); -- 当前日期时间 SELECT DATEADD(DAY, 7, GETDATE()); -- 7天后的日期 SELECT DATEDIFF(DAY, '2022-01-01', GETDATE()); -- 两个日期相差的天数 SELECT YEAR(GETDATE()), MONTH(GETDATE()), DAY(GETDATE()); SELECT DATEPART(WEEKDAY, GETDATE()); -- 当前是星期几实际开发里,最常见的需求是查“今天”“本周”“本月”的数据。这里我有一个一直沿用的避坑写法:
-- 查今天的数据 SELECT * FROM Orders WHERE OrderDate >= '2024-01-15' AND OrderDate < '2024-01-16'; -- 更常规的写法是拿当天零点作为起点 SELECT * FROM Orders WHERE OrderDate >= CONVERT(DATETIME, CONVERT(VARCHAR(10), GETDATE(), 120)) AND OrderDate < CONVERT(DATETIME, CONVERT(VARCHAR(10), DATEADD(DAY, 1, GETDATE()), 120));为什么不直接写BETWEEN '2024-01-15' AND '2024-01-15'?因为OrderDate如果带时分秒,BETWEEN的写法会漏掉当天23:59:59以后的数据。用>=和<可以精确控制边界。
还有一条非常重要的性能原则:条件列上尽量不要套函数。比如:
-- 不推荐,会导致该列索引失效 SELECT * FROM Orders WHERE YEAR(OrderDate) = 2024; -- 推荐,保留列本身的形态 SELECT * FROM Orders WHERE OrderDate >= '2024-01-01' AND OrderDate < '2025-01-01';在WHERE的列上套函数,等于每次都要算完全表的行才比较,索引基本废掉。日期函数这份心得,是我当年排查一个慢查询时花了整整一个下午才总结出来的教训。
5.3 类型转换与“字符串转数字”的那些坑
热搜词里很多人搜“sqlserver 字符串转数字”,说明这个需求非常普遍。最常见的两个函数是CAST和CONVERT:
SELECT CAST('123' AS INT); -- 123 SELECT CONVERT(INT, '456'); -- 456区别在于CONVERT多一个可选的样式参数,做日期格式化时特别有用:
SELECT CONVERT(VARCHAR(10), HireDate, 120); -- 得到 '2021-06-15' SELECT CONVERT(VARCHAR(8), HireDate, 112); -- 得到 '20210615'字符串转数字的坑在于:如果字符串里混入了非数字字符,整个转换直接报错。比如CAST('12a3' AS INT)在执行时会抛出“将varchar转换为int时,转换失败”的异常。
更危险的是隐式转换。假设表里的EmployeeNo列是VARCHAR类型,存的是'1001'这样的值,你写了:
SELECT Name FROM Employees WHERE EmployeeNo = 1001;SQL Server会把列里的字符串隐式转成数字再比较,或者把参数转成字符串,具体走哪种取决于字段类型和参数类型的优先级。比较麻烦的是这种隐式转换可能导致索引失效,全表扫描,数据量大时非常慢。
一张万能的排查思路:发现一条查询慢,打开实际执行计划,看到CONVERT_IMPLICIT这种运算符,基本说明存在隐式转换,优先检查条件列类型和参数类型是否一致。
如果你要做的转换可能失败,又不想让语句报错,可以用TRY_CAST或TRY_CONVERT。转换失败时返回NULL,而不是抛异常:
SELECT TRY_CAST('abc' AS INT); -- NULL,不报错 SELECT TRY_CONVERT(DATETIME, '2024-13-01'); -- NULL,不报错这个函数在清洗脏数据时是真好用,比如批量导入外部数据时,可以先转一把看哪些是坏的。
6. 几个容易翻车的查询场景与排查路径
6.1 逻辑优先级导致的错误结果
先说一个我在自己项目里排查过的真实场景。
现象:某统计报表里的“IT部门薪资大于10000,加上Sales部门”的人数,怎么查都比预期多。
初步排查:先把所有条件都拆开单独跑,发现IT部门单独查2人,Sales单独查2人,加起来4人,但合在一起查出5人。这就说明条件组合出了问题。
继续看SQL:
SELECT COUNT(*) FROM Employees WHERE Department = 'IT' OR Salary > 10000 AND Department = 'Sales';想表达的是“IT部门薪资大于10000,加上Sales部门所有人”。但因为AND优先级高于OR,实际执行的是:
WHERE Department = 'IT' OR (Salary > 10000 AND Department = 'Sales')也就是“所有IT部门的人”加上“Sales部门中薪资大于10000的人”。Sales里薪资大于10000的有两人,但加上IT的所有人,总体数据就变多了。
根因清楚了,修复很简单:把第二个条件括号包起来:
SELECT COUNT(*) FROM Employees WHERE Department = 'IT' AND Salary > 10000 OR Department = 'Sales';这类问题最大的风险是不报错,结果看起来也“合理”,但悄悄多了或少了数据。做过数据分析的朋友都懂,数据错比查不出来更可怕。
6.2 NOT IN 与 NULL 的隐蔽问题
另一个高频翻车点是NOT IN配子查询。现象是:查询“没有下属部门的员工”,返回结果是0行,但按业务常识应该有数据。
复现一下。有一张Department表,里面有一个部门的上级编号是NULL,表示顶级部门。然后我写:
SELECT EmployeeID FROM Employees WHERE DepartmentID NOT IN (SELECT DepartmentID FROM Departments);如果子查询返回的DepartmentID列表里存在NULL,那么对每一行来说,DepartmentID NOT IN (..., NULL)的结果都是UNKNOWN,WHERE最终一行都过滤不出来。
这个坑的根源还是NULL的语义:UNKNOWN既不是TRUE也不是FALSE,NOT IN遇到NULL就变成UNKNOWN。这个问题最稳妥的解法是换成NOT EXISTS:
SELECT e.EmployeeID FROM Employees e WHERE NOT EXISTS ( SELECT 1 FROM Departments d WHERE d.DepartmentID = e.DepartmentID );NOT EXISTS是逐行判断是否存在,遇到NULL也不会把整条结果集带崩,逻辑上更符合人的直觉。我现在的习惯是:能用NOT EXISTS就不用NOT IN,省得后面还担心NULL问题。
6.3 在无主键/无id表中找出重复数据
热搜词里“sqlserver 删除重复数据只保留一条 无id”这个问题很常见。很多导入的表没有主键、没有唯一标识字段,查重和后续清理都很难办。
不直接展开DELETE,先说说怎么把重复记录找出来。思路是给它编号,把相同分组内的行按顺序打上1、2、3这样的序号,序号大于1的就是重复数据。
WITH Ranked AS ( SELECT Name, Department, HireDate, ROW_NUMBER() OVER ( PARTITION BY Name, Department, HireDate ORDER BY (SELECT NULL) ) AS rn FROM Employees ) SELECT * FROM Ranked WHERE rn > 1;这里把Name、Department、HireDate三个字段当作“业务上的唯一键”,按它们分组编号。ORDER BY (SELECT NULL)是个小技巧,表示“这几行之间没有先后顺序,随便编”,用于不需要按特定顺序编号的场景。
查出来之后,后续想删的话,就按rn = 1保留、rn > 1删除的思路处理。因为涉及DELETE的语法细节,下一篇讲增删改查的后半段再展开。这里先强调,查询是删除的前提,数据没查清楚千万不要动DELETE。
6.4 查询性能的初步自查思路
查询慢的时候,我没见过几个人上手就是对的,都是靠排查链路一点点捋。我的标准动作是:
- 先看执行计划,是不是全表扫描。如果条件列上没索引,大概率扫全表。
- 再看有没有隐式转换,执行计划里搜
CONVERT_IMPLICIT,有就要改类型匹配。 - 再看是不是
SELECT *带出了大量无用列。 - 最后看WHERE的筛选率,如果结果集本来就接近全表,那有没有索引意义也不大。
这四条每一条都对应一类真实问题。比如前面提到的条件列套函数、NOT IN配NULL子查询导致意外结果、隐式转换导致索引失效,都是我在生产环境里一个一个踩过来的。
当然,索引优化和查询计划深度分析是后面章节的重头戏,这里先种个种子。这一篇“查询(一)”重点在于把SELECT的骨架搭稳,把WHERE、排序、日用函数这些最常用的东西练扎实。
我教新人的时候,通常让他们先把这一篇里的内容练到闭着眼睛能写出来,再去碰聚合、分组和JOIN。因为后面那些高级玩法,全部建立在这些基本功之上。下一篇我会继续讲聚合函数、GROUP BY分组以及HAVING过滤的细节,里头有不少容易搞混的点,到时候再和大家细聊。