ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SQL Server随机查询一条记录的优化与存储过程封装实践

SQL Server随机查询一条记录的优化与存储过程封装实践 我最早接触这个需求是在某业务系统里被同事喊去救火帮我从订单表里随机抽一条记录做复核。当时我想都没想直接写了SELECT TOP 1 * FROM Orders ORDER BY NEWID()。表不大跑起来倒也还行。直到后来换到一张几百万行的日志表这条SQL直接跑了十几秒把tempdb都吃出压力了我才意识到随机查询一条表记录这件事远没有表面看起来那么简单。同时那段经历也让我把存储过程的封装和使用重新系统地过了一遍这篇文章就是完整复盘。这个内容适合三类人一是经常要在SQL Server里做随机抽奖、随机出题、数据抽样、随机复核的开发或运维二是想封装通用存储过程但不知道参数怎么设计、动态SQL怎么做防注入的初级开发者三是遇到过存储过程换个参数就变慢这类问题想弄明白原因的人。我会从随机SQL的取舍讲起再说到存储过程的封装思路、完整实现最后分享几个生产环境里的暗坑和一个延伸的权重随机场景。1. 随机取一条记录三种写法到底差在哪先说结论随机查询最常用的三种思路性能差距可以达到几十倍而且各自都有隐藏的限制。把它们放在一起对比才能理解后面的封装为什么那样设计。1.1 最容易写但最容易被坑的ORDER BY NEWID()NEWID()会给每一行生成一个GUID作为排序键SQL Server必须把全表所有行的GUID算出来然后做一次完整的排序最后取TOP 1。这个方案的随机性质量最好但代价是数据量越大排序成本越高而且是超线性增长。我在一张约500万行的测试表上做过对比开启SET STATISTICS TIME ON之后这条SQL的CPU时间和占用时间都在9到12秒之间tempdb的分配量会明显上涨。对于偶发执行的小表这个体验还能忍如果这个操作被某个接口频繁调用或者在数据量动辄上亿的生产表上执行基本上就是事故。SET STATISTICS TIME ON; SET STATISTICS IO ON; SELECT TOP 1 * FROM dbo.Orders ORDER BY NEWID(); SET STATISTICS TIME OFF; SET STATISTICS IO OFF;新手最容易犯的错是没有加TOP 1或者在子查询里用ORDER BY NEWID()去匹配一条随机主键然后外层再去查全行。后者看似聪明实际上子查询依然会对全表生成GUID并排序成本一点没省。1.2 快得惊人但“不够公平”的TABLESAMPLETABLESAMPLE是SQL Server提供的采样语法它按数据页8KB页面为单位取样而不是按行取样。因为底层读取的页面数量固定所以它的速度非常快500万行表只要零点几秒。SELECT TOP 1 * FROM dbo.Orders TABLESAMPLE(1000 ROWS);但它有几个非常容易踩的坑返回行数不精确。TABLESAMPLE(1000 ROWS)意思是大约1000行实际可能返回800行也可能返回1200行。它是按页采样不是按行等概率。如果表里数据的物理分布不均匀某些区域的记录被抽中的概率就会偏高不是严格的随机。对小表很不友好。如果表的总页数太少可能一条都取不到。它不能直接配合WHERE做精确过滤后再随机因为采样发生在过滤之前过滤后可能只剩很少的行甚至0行。所以TABLESAMPLE适合对随机性要求不高、追求速度的海量数据抽样不适合做抽奖、取复核记录这类需要公平性的场景。1.3 我最终采用的组合方案ID下界粗筛 局部NEWID精排既然NEWID()慢是慢在全表排序那思路就很直接了先用低成本的方式把参与随机排序的数据范围缩小再在小范围内用NEWID()精排。如果表上有自增主键且整体分布均匀有空洞但不大可以用这种写法DECLARE MinId BIGINT, MaxId BIGINT, RandomLowerBound BIGINT; SELECT MinId MIN(Id), MaxId MAX(Id) FROM dbo.Orders; -- 在ID区间内随机取一个下界 SET RandomLowerBound MinId CAST(RAND() * (MaxId - MinId) AS BIGINT); SELECT TOP 1 * FROM dbo.Orders WITH (NOLOCK) WHERE Id RandomLowerBound ORDER BY NEWID();MAX(Id)因为有主键索引走的是索引统计成本极低一亿行的表也就毫秒级。RAND()生成一个0到1之间的随机数用它把下界随机定位到ID区间的某一个位置然后WHERE Id RandomLowerBound把扫描范围缩小到ID区间的尾部最后在这个小范围内做ORDER BY NEWID()取一条。实测500万行表这套组合方案的耗时基本稳定在300毫秒以内比直接NEWID()快了30倍左右。这个方案的随机性虽然不如对全表做NEWID()那样严格但只要ID分布没有明显的聚簇偏差对于抽奖、取复核记录、抽样测试已经完全够用。1.4 三种方案的实测对比方案核心原理500万行耗时随机公平性典型适用场景ORDER BY NEWID()每行生成GUID后全表排序约10秒最好小表、对随机性要求极高的场景TABLESAMPLE(1000 ROWS)按数据页采样约0.2秒较差受物理分布影响大数据量近似的抽样统计ID下界粗筛 局部NEWID先定位随机ID范围再小范围排序约0.3秒较好依赖ID连续性中大型表随机取单条或N条这里要特别提醒如果表的主键不是自增ID而是业务订单号、UUID这类不连续的值那就不能用ID下界这个方案。替代做法是先在应用层或存储过程里生成一个有序的序号列或者用ROW_NUMBER() OVER (ORDER BY (SELECT NULL))先做一个稳定的物理顺序编号再在这个编号上做随机偏移。代价是ROW_NUMBER()本身需要扫描全表性能不如有索引的ID下界方案但仍然可以通过在临时表里只保留主键来降低开销。2. 把随机查询封装成存储过程为什么值得做方法选好之后下一步就是封装。很多开发者习惯把SQL直接写在应用代码里等业务需要第二个调用方、第三个调用方时又各自复制一份改一改。我把随机查询封装成存储过程不是因为它有多高级而是因为在真实项目里它有四个很实际的好处。2.1 封装带来的四个实际好处第一是可复用性。同一个随机取数逻辑在项目里可能同时被抽奖接口、每日推荐接口、测试数据构造脚本、运营后台人工抽查功能使用。如果每个调用方都写一遍SQL一旦发现性能方案要调整比如从NEWID()换成ID下界方案就要改好几个地方。封装成存储过程后只有一处维护点。第二是性能调优的收敛。随机查询的性能和表的规模强相关如果应用层直接拼SQLDBA想加WITH (NOLOCK)或者调整采样策略必须追着开发改代码、重新发布应用。存储过程可以单独优化、单独上线不需要动应用代码。第三是权限控制。可以让只读账号只拥有存储过程的执行权限而不是直接给它底层表的查询权限。这在需要开放数据给运营或者第三方做抽样分析时很有价值调用方只知道过程名和参数不需要知道底层表结构。第四是避免应用层拼接SQL的风险。应用层如果直接用字符串拼接表名、拼接排除ID列表很容易出现注入问题把动态SQL的拼接限制在存储过程内部至少可以把风险收敛在一个可控的范围内。2.2 参数接口的设计先把边界想清楚封装存储过程最忌讳一上来就写代码先花十分钟把参数接口想清楚后面能省很多返工的功夫。我的随机查询过程经过几次需求调整最后定了这几个入参和一个出参参数名类型是否必填含义TableNameNVARCHAR(128)是要查询的表名格式建议带schemaTopNINT否默认1要随机返回的记录条数ExcludeIdsNVARCHAR(MAX)否需要排除的主键ID列表用逗号分隔RandomSeedINT否随机种子用于可复现的测试场景RowCountINT输出参数否实际返回的行数供调用方判断ExcludeIds这个参数是后来被业务逼出来的。运营做抽奖时不希望同一个用户连续多次被抽中所以每次抽完要排除已经发放过奖品的用户ID。最初我的方案是把排除逻辑直接写死在SQL里后来发现不同调用方排除的ID集合完全不同于是改成参数传入。RandomSeed是为了测试场景设计的。RAND(seed)传入固定种子后每次执行的随机结果序列是一样的。这个特性在复现线上问题、写自动化测试时非常有用虽然生产环境的调用不会传这个参数。出参RowCount的价值在于调用方需要知道这次调用实际返回了多少行避免出现明明要求2条结果只返回了1条的情况时应用层没有感知。2.3 动态表名的风险与白名单校验存储过程不能直接把表名作为参数传给一条静态SQL去执行因为表名不能绑定变量。要实现TableName参数就必须构造动态SQL。这是最容易出问题的环节如果表名直接拼进SQL字符串等于把存储过程变成一个注入入口。我做了三层防御第一层用sysname类型承接表名。sysname在SQL Server里实际上是NVARCHAR(128)也就是数据库标识符的最长长度能挡住一大部分超长恶意串。第二层拼SQL时用QUOTENAME()包裹表名。QUOTENAME(Ndbo.Orders)会返回[dbo].[Orders]即使表名里有空格或特殊字符也不会破坏语法。第三层执行前校验表是否真的存在。通过OBJECT_ID(TableName, NU)只允许用户表拒绝视图和系统表再通过约定规则禁止名字里包含tempdb相关的临时表关键字。这样即使调用方传入了恶意内容它在到达动态SQL执行之前就会被拦下。3. 一个可复用的随机查询存储过程完整实现与拆解下面是这套方案最终落地的完整存储过程。为了便于理解我把整体实现拆成四步讲解每一步都说明为什么这样写。看到这个脚本时如果你刚接触存储过程建议先把它完整抄下来跑通再动手调整参数。3.1 第一步参数定义与入参校验CREATE OR ALTER PROCEDURE dbo.usp_GetRandomRows TableName NVARCHAR(128), TopN INT 1, ExcludeIds NVARCHAR(MAX) NULL, RandomSeed INT NULL, RowCount INT OUTPUT AS BEGIN SET NOCOUNT ON; DECLARE SqlText NVARCHAR(MAX); DECLARE ObjectId INT; -- 入参基础校验 IF TableName IS NULL OR LTRIM(RTRIM(TableName)) N BEGIN RAISERROR(NTableName 不能为空, 16, 1); RETURN -1; END; IF TopN IS NULL OR TopN 0 BEGIN RAISERROR(NTopN 必须大于0, 16, 1); RETURN -2; END; SET ObjectId OBJECT_ID(TableName, NU); IF ObjectId IS NULL BEGIN RAISERROR(N表 %s 不存在或不是用户表, 16, 1, TableName); RETURN -3; END;这里有两个存储过程的基础点值得重温。SET NOCOUNT ON是必须养成的习惯它让过程不再返回受影响行数这类无用信息否则应用层拿到的结果集会多一个干扰信息ORM在某些极端情况下会被搞蒙。RETURN返回整数状态码是无参数输出的一种约定调用方可以通过EXEC ret dbo.usp_GetRandomRows ...拿到它。3.2 第二步动态SQL的构造与防注入继续往下核心的动态SQL构造是关键。DECLARE IdColumnName SYSNAME NId; -- 如果存在 IDENTITY 列尽量使用主键策略这里简化处理固定用 Id 列 -- 实际业务表如果没有 Id 列建议在调用前先明确主键列名或改为查询 sys.columns 动态识别 IF RandomSeed IS NULL SET SqlText NSELECT TOP (TopN) * FROM QUOTENAME(TableName) N WITH (NOLOCK) WHERE Id CAST(RAND() * (SELECT MAX(Id) FROM QUOTENAME(TableName) N) AS BIGINT) ORDER BY NEWID();; ELSE SET SqlText NSELECT TOP (TopN) * FROM QUOTENAME(TableName) N WITH (NOLOCK) WHERE Id CAST(RAND(RandomSeed) * (SELECT MAX(Id) FROM QUOTENAME(TableName) N) AS BIGINT) ORDER BY NEWID();; -- 处理排除ID列表 IF ExcludeIds IS NOT NULL AND LTRIM(RTRIM(ExcludeIds)) N BEGIN DECLARE ExcludeCondition NVARCHAR(MAX); SET ExcludeCondition N AND Id NOT IN ( ExcludeIds N); -- 将条件插入到 WHERE 之后 SET SqlText REPLACE(SqlText, N ORDER BY NEWID();, ExcludeCondition N ORDER BY NEWID();); END; DECLARE ParmDefinition NVARCHAR(200) NTopN INT, RandomSeed INT; EXEC sp_executesql SqlText, ParmDefinition, TopN TopN, RandomSeed RandomSeed; SET RowCount ROWCOUNT; END; GO这里有几个容易被忽略但非常重要的细节为什么用sp_executesql而不是EXEC SqlText因为sp_executesql支持参数化查询TopN和RandomSeed作为参数传入而不是拼进SQL字符串。这样既能让执行计划复用虽然动态SQL每次重新编译但参数化至少避免了把输入值变成SQL代码又能防止注入。反观EXEC只能执行拼好的字符串一旦有参数拼进去就有风险。为什么ExcludeIds是直接拼接的而不是参数化的因为IN列表的长度不固定参数化无法直接绑定一个动态列表。实际项目中ExcludeIds通常来自应用层的白名单集合里面只允许数字ID我们会在本段代码之前对该参数做一次正则校验只保留数字和逗号把其他字符全部剔除。这一步很重要宁可多写一个校验函数也不能让调用方的字符串直接进入动态SQL。为什么WITH (NOLOCK)要谨慎使用因为随机查询本质上是分析型需求对脏读容忍度较高加NOLOCK能避免共享锁阻塞生产库的写入。但如果你的表是资金账户、订单支付这类强一致场景请务必去掉NOLOCK否则可能读到未提交的事务数据。3.3 第三步补一个最简单的排除ID校验函数上面提到了要对ExcludeIds做字符清洗我用的是一段简单的循环替换逻辑把除数字和逗号之外的所有字符都删除同时避免出现1,2,,3,这类连续逗号。-- 在构造SQL前增加如下校验 DECLARE CleanExclude NVARCHAR(MAX); SET CleanExclude ExcludeIds; IF CleanExclude LIKE N%[^0-9,]% ESCAPE ! BEGIN DECLARE Pos INT 1; DECLARE ch NCHAR(1); WHILE Pos LEN(CleanExclude) BEGIN SET ch SUBSTRING(CleanExclude, Pos, 1); IF ch NOT LIKE N[0-9] AND ch N, SET CleanExclude REPLACE(CleanExclude, ch, N); SET Pos Pos 1; END; -- 合并连续逗号 WHILE CHARINDEX(N,,, CleanExclude) 0 SET CleanExclude REPLACE(CleanExclude, N,,, N,); END; IF LTRIM(RTRIM(CleanExclude)) N SET CleanExclude NULL; ELSE SET ExcludeIds CleanExclude;这个过程在数据量很大的IN列表下不是最高效的但胜在简单可控。如果你追求性能可以把ExcludeIds改成表值参数Table-Valued Parameter让应用层传一个ID列表进来存储过程用LEFT JOIN完成排除这样既不拼字符串也天然防注入。表值参数的使用并不复杂先定义一个用户定义表类型CREATE TYPE dbo.IdList AS TABLE (Id BIGINT PRIMARY KEY)然后在过程参数里写ExcludeIds dbo.IdList READONLY。考虑到本文主要是重温存储过程的基础封装这里不过度展开但强烈建议有批量排除需求的读者走这条路。3.4 第四步调用示例与结果验证编译完成后调用方式非常直观DECLARE rowCount INT; EXEC dbo.usp_GetRandomRows TableName Ndbo.Orders, TopN 5, ExcludeIds N101,102,103, RowCount rowCount OUTPUT; SELECT rowCount AS ReturnedRows;我测试时会刻意覆盖几种边界情况空表、数据量只有1行、表名不存在的场景、排除ID把所有数据都排除掉的场景。空表和全排除会返回0行存储过程不会报错但调用方要能正确处理空结果集表名不存在会被第三步的校验拦下返回状态码-3。4. 存储过程进入生产后的三个暗坑脚本能跑通只是第一步。我在把这类过程推到生产环境、交给别的同事使用时又陆续踩到三个暗坑每一个都值得单独拿出来说。4.1 参数嗅探与动态SQL的天然规避先说参数嗅探。静态SQL的存储过程在第一次执行时会根据当时的参数值生成执行计划之后再次执行时即使传入的参数值不同也可能沿用旧计划。当参数值的数据分布差异很大时就会出现同一个过程参数A秒回参数B跑死的诡异现象。我们的随机查询存储过程用的是动态SQL每次执行都会重新编译因此天然避免了参数嗅探问题。但正因为每次编译也就失去了缓存执行计划的收益。如果哪天你把这套逻辑改成静态SQL又发现换参数后变慢可以尝试OPTION (RECOMPILE)强制每次重新编译。需要注意的是RECOMPILE是拿CPU换稳定性不能盲目加在频繁调用的小查询上。4.2 随机查询会不会把库堵死锁、事务与并发随机查询通常在业务高峰期被触发比如秒杀结束后抽奖、周年庆运营活动。如果底层表非常大RAND()加NEWID()的查询会持续较长时间期间持有共享锁可能阻塞该表上的写入操作。我见过某个活动上线后订单表因为一个抽样存储过程被堵到写入超时的案例。解法有三个层次对抽样场景明确允许脏读使用WITH (NOLOCK)。把调用放到只读副本上读写分离不要和业务主库混在一起。避免在事务内部调用随机查询过程。存储过程被包在外部事务里时锁的粒度会被放大可能把一个本来毫秒级的共享锁变成大事务里的长锁。另外还要注意存储过程必须设计成可并发调用的。我们的过程里没有使用任何全局临时表和静态游标每次调用是独立的因此并发执行没有问题。这一点在封装时就要想好过程里一旦用了CREATE TABLE #temp这类临时对象并发调用时各会话之间是隔离的但如果用了##全局临时表就一定要做好并发冲突的清理逻辑。4.3 权限、部署和版本管理动态SQL的存储过程有一个权限陷阱如果你只授予调用方EXECUTE权限却没授予它访问底层表的SELECT权限过程会执行失败因为动态SQL是在调用方的安全上下文里解析表访问权限的。这是动态SQL和静态SQL存储过程的显著区别后者默认拥有者的权限就够了。所以给只读账号授权时需要同时执行GRANT EXECUTE ON dbo.usp_GetRandomRows TO readonly_user; GRANT SELECT ON dbo.Orders TO readonly_user;更稳妥的做法是创建专用角色把多个需要开放的数据表查询权限统一收进角色里避免后期逐个表授权。部署方面推荐用CREATE OR ALTER PROCEDURE语法SQL Server 2016 SP1以上支持它能在不删除已有权限的前提下更新过程定义。旧版本只能用IF EXISTS DROP CREATE但那样会导致ACL权限丢失需要重建权限。每次修改后脚本一定要提交进代码仓库和表结构变更放一起管理否则生产环境的时间长了很容易出现库里跑的过程和仓库里脚本对不上的情况。5. 延伸案例按权重随机分配任务随机取一条记录只是最基础的需求真实业务里更常见的是按权重分配。比如项目里有100个待复核的任务需要分配给复核员复核员资历不同分配权重也不同资深复核员权重3普通复核员权重2新手复核员权重1。如果用纯随机平均分资深和新手拿到一样多的工作既不公平也容易引发团队抱怨。按权重随机的算法本质上是把每个人的权重想象成一条长度不等的线段然后把整条线段做一次随机落点落在哪一段就选中哪个人。5.1 按权重随机的存储过程实现假设任务表dbo.RecheckTasks有TaskId、RecheckerId、Weight我们新增一个存储过程按每个人的权重随机选出一个复核员CREATE OR ALTER PROCEDURE dbo.usp_PickRecheckerByWeight AS BEGIN SET NOCOUNT ON; DECLARE TotalWeight FLOAT; DECLARE RandomPoint FLOAT; SELECT TotalWeight SUM(Weight) FROM dbo.Recheckers; IF TotalWeight IS NULL OR TotalWeight 0 BEGIN RAISERROR(N复核员权重不可为空或小于等于0, 16, 1); RETURN -1; END; SET RandomPoint RAND() * TotalWeight; SELECT TOP 1 RecheckerId, RecheckerName FROM ( SELECT RecheckerId, RecheckerName, SUM(Weight) OVER (ORDER BY RecheckerId) AS CumulativeWeight FROM dbo.Recheckers ) AS T WHERE CumulativeWeight RandomPoint ORDER BY CumulativeWeight; END; GO这里的关键点是RAND() * TotalWeight只调用一次算出随机落点然后在子查询里用SUM(Weight) OVER (ORDER BY RecheckerId)算出每个复核员的累积权重区间。WHERE CumulativeWeight RandomPoint ORDER BY CumulativeWeight取第一个累积权重跨过随机点的记录这个记录就是被选中的人。5.2 实测效果与并发注意点我在测试环境用一组数据验证过3个人权重分别为3、2、1连续跑1000次选中次数大约是50%、33%、17%与权重比例吻合说明这个实现是可靠的。相比每次取一个人再重新计算随机点这套方案的执行计划是稳定的性能也很快很少超过几毫秒。但按权重随机有个天然的并发问题两个应用实例同时调用时可能选中同一个复核员。解决办法是在获得候选RecheckerId后紧接着用UPDATE ... WHERE RecheckerId RecheckerId AND Status N空闲这种原子操作去抢占这个复核员抢不到就重试整个随机过程。更进阶的写法是把随机选择更新占用放到一个存储过程里用UPDLOCK, READPAST锁定提示防止重复分配。这部分涉及并发控制已经超出随机查询本身就不再展开了。写在最后的实际操作心得我在这套随机查询存储过程上反复改过好几版最大的体会是封装存储过程不是因为存储过程比应用代码高级而是为了把性能策略、权限边界、风险控制收敛到一个可维护的点上。随机取数看似简单真正落地时牵涉到的排序成本、采样偏差、动态SQL防注入、权限授权每一环都值得认真处理。最后分享一个调试时特别有用的小技巧在存储过程里加一个隐藏参数Debug BIT 0当它被设置为1时不执行动态SQL而是把构造好的SqlText直接输出出来。这样遇到线上问题时可以先看到实际执行的SQL文本再决定是参数问题还是表数据分布问题。这个小开关帮我排查了不少调用方传参诡异的疑难杂症。随机查询这条思路往浅了写是几条SQL往深了写就是一堆架构问题希望这篇实践记录能帮你在自己的项目里少踩几个坑。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进