资讯详情

资讯详情

SQLServer查询实战:SELECT、WHERE、排序与函数避坑指南

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 NULLSELECT Name, Bonus FROM Employees WHERE Bonus IS NULL; SELECT Name, Bonus FROM Employees WHERE Bonus IS NOT NULL;NULL的另一个坑是参与运算后会把结果“吃掉”。比如前面算年收入时如果Bonus是NULLSalary * 12 Bonus的结果就变成NULL哪怕Salary有值。解法是用ISNULL(Bonus, 0)把NULL转成0再参与计算。再比如与NULL有关的逻辑判断。WHERE Bonus 3000这个条件会把Bonus是NULL的行也过滤掉因为NULL比较结果是UNKNOWNUNKNOWN不满足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 PERCENTWITH 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)9LEFT()从左截取LEFT(SQLServer, 3)SQLRIGHT()从右截取RIGHT(SQLServer, 6)ServerSUBSTRING()从指定位置截取SUBSTRING(SQLServer, 4, 6)ServerCHARINDEX()查找子串位置CHARINDEX(Server, SQLServer)4REPLACE()替换指定内容REPLACE(a-b-c, -, )abcLTRIM()/RTRIM()去左右空格LTRIM( abc)abcUPPER()/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和CONVERTSELECT 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人。这就说明条件组合出了问题。继续看SQLSELECT 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)的结果都是UNKNOWNWHERE最终一行都过滤不出来。这个坑的根源还是NULL的语义UNKNOWN既不是TRUE也不是FALSENOT IN遇到NULL就变成UNKNOWN。这个问题最稳妥的解法是换成NOT EXISTSSELECT 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过滤的细节里头有不少容易搞混的点到时候再和大家细聊。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →