ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

SQL Server随机查询与自定义函数封装:从NEWID到主键定位的选型与踩坑实录

SQL Server随机查询与自定义函数封装:从NEWID到主键定位的选型与踩坑实录 上周业务方丢过来一个需求从订单明细表里随机捞一条记录出来做展示。听起来就是一条 SQL 的事我随手写了ORDER BY NEWID()数据量也就几十万行跑起来确实没毛病。但需求方跟了一句后面可能每个模块都要用最好能封装成一个公共方法。这话一出来事情就不简单了。借着这个契机我把 SQL Server 里随机查询一条表记录的几种常见方案从头测了一遍也认认真真把自定义函数的封装和使用重新捋了一遍。这篇文章就是这次整理的全部内容适合刚接触 SQL Server 自定义函数、或者写完随机查询只停留在“能用”阶段的朋友。1. 随机查询的几种经典写法从简单到能扛大数据量说句实在话随机查询这条路上我见过最多的就是ORDER BY NEWID()一把梭。写法确实简单到没朋友但一碰上数据量变大问题就出来了。所以要聊随机查询必须先把这几种方案放在一起看知道各自能扛到什么量级再谈封装才有意义。1.1 ORDER BY NEWID()五分钟能写完五百万行跑不动最经典的写法长这样SELECT TOP 1 * FROM dbo.Orders ORDER BY NEWID();原理不难NEWID()给每行生成一个 GUID然后 SQL Server 对全表做一次排序最后取第一行。因为 GUID 足够随机所以结果在概率上接近均匀分布。问题也恰恰出在这。表里有几行就要生成几个 GUID、排一次序。几十万行的时候Top N Sort 配合内存还能顶住一旦到了五百万行往上排序落盘的代价就很直观了CPU 飙升tempdb 可能被打爆逻辑读直接是几万页起步。我在大表上跑过一次这写法一个 800 万行的表光ORDER BY NEWID()就跑了八九秒。业务方在旁边问“是不是卡死了”我只能笑笑说“在跑了”。从那以后我基本不在生产环境的大表上用这种写法。1.2 TABLESAMPLE页面抽样快但随机性有自己的脾气TABLESAMPLE是 SQL Server 专门为抽样提供的语法按数据页而不是按行做随机选择速度快得惊人SELECT TOP 1 * FROM dbo.Orders TABLESAMPLE (10000 ROWS) ORDER BY NEWID();看起来 nice但它有两个很现实的脾气。一个是“可能抽取 0 行”。TABLESAMPLE是按页抽页大小、行大小都会影响最终抽到的行数并不是保证给够 10000 行。如果表本身很小或者数据集中挤在少数几页返回 0 行的概率并非不存在。另一个是“物理分布偏斜”。TABLESAMPLE倾向于采样更小的数据页如果表里存在页密度不均的情况随机性就不够均匀。对“抽一条出来展示”这种需求勉强能用对“抽一条做奖品中奖人”这种业务你就要慎重了。我一直把它定位成“快速取样本集”而不是“精确随机取一条”。真正的随机查询还要看下面这种。1.3 基于主键加随机数的定位法大多数生产环境的选择如果表上有主键或者唯一聚集索引可以用随机数值去定位一条记录。思路是先拿到主键的最小值和最大值在区间里生成一个随机数再取“大于等于这个随机数”的第一行。DECLARE minId INT, maxId INT, targetId INT; SELECT minId MIN(Id), maxId MAX(Id) FROM dbo.Orders; SELECT targetId CAST(minId (maxId - minId) * RAND() AS INT); SELECT TOP 1 * FROM dbo.Orders WHERE Id targetId ORDER BY Id ASC;这个方案快在哪MIN(Id)和MAX(Id)在有聚集索引的情况下走的是索引两端取值代价极小。后面的WHERE Id targetId走聚集索引定位也是毫秒级。整条 SQL 的逻辑读大概就是十几个页跟全表扫描排序完全不是一个量级。但要注意一个隐藏细节如果主键是自增列并且存在大量删除造成的空洞随机数落在空洞区间时会顺延到下一条存在的记录。也就是说空洞后面的那条记录被选中的概率会被放大。我一般这么处理如果业务对“绝对均匀”没有硬性要求这种近似随机完全够用如果要求严格均匀就得用行号方式配合统计信息去做了。2. 封装成自定义函数之前先把这几个问题想明白随机查询的方案选定了接下来才进入标题里真正的重头戏自定义函数的封装和使用。封装不是把 SQL 塞进一个函数就算完事这里有几个需要提前想明白的问题。想清楚了后面写代码就是水到渠成想不清楚封装出来的函数大概率是给自己挖坑。2.1 散落各处的随机SQL为什么最后都成了维护负担我见过不少项目里随机查记录的需求散落在存储过程、报表查询、后台任务里。每个地方都写一遍ORDER BY NEWID()或者主键定位法用的还是不同的表、不同的字段。这带来两个问题。第一逻辑不统一。A 模块用NEWID()B 模块用RAND()定位C 模块干脆先 SELECT 全部再在程序里随机。看起来都是随机实际随机性和性能差别很大。一旦线上出问题排查的时候得一个模块一个模块看。第二改造成本高。某天你发现主键定位法在大表上更好用想全面替换就得在所有用随机查询的地方挖地三尺。漏掉一个线上就出现“有的快有的慢”的诡异现象。封装成一个公共函数至少能把随机策略收敛到一个地方。后续想优化算法、调整随机种子只改一行代码所有调用方自动生效。这就是封装最朴素的价值。2.2 标量函数和表值函数随机取记录应该选谁这是封装前必须做的选择题。SQL Server 自定义函数主要分两类。标量函数返回单个值适合“给我一个随机主键”这种场景表值函数返回一个结果集适合“给我一整行随机记录”这种场景。函数类型返回内容适合场景性能注意点标量函数单个标量值只需要主键或单个字段在 SELECT 列表中逐行调用开销大内联表值函数表结果集需要完整记录或参与 JOIN本质是宏替换性能接近裸 SQL多语句表值函数表结果集逻辑复杂、必须用临时表有填充表变量开销需要谨慎以“随机查一条记录”这个需求来说我首选表值函数。因为表值函数的返回值可以参与JOIN、WHERE、UNION能直接当一个数据源用。标量函数则适合更单纯的需求只要一个随机主键别的不管。2.3 内联表值函数和多语句表值函数一个语法替换一个实体填充同样是表值函数内联和多语句的差别非常大我建议能选内联就别选多语句。内联表值函数的写法是RETURNS TABLERETURN (SELECT ...)函数体没有BEGIN...END。它本质上是视图的扩展SQL Server 在调用时会直接把函数体里的 SQL 合并到外层语句里执行。这意味着内联函数的性能几乎等同于你写裸 SQL优化器能看到完整上下文也能生成正确的执行计划。多语句表值函数则是RETURNS table TABLE (...)配合BEGIN...END在函数体内往表变量里插数据最后返回。问题在于函数执行时必须先把表变量填满再交给外层语句继续处理。优化器对这个表变量的行数估算通常会猜一个固定值比如 100 行一旦实际数据量偏差大执行计划会非常难看。对随机查询这种逻辑并不复杂的场景内联表值函数是天然首选。接下来的实操部分我就以内联为主展开。3. 亲手封装一个随机取记录的函数代码细节与调用效果现在进入动手环节。我以一张常见的订单表为例来做完整封装演示。表结构很简单Id是自增聚集索引主键其他字段随意。关键是看封装思路和函数代码怎么组织。3.1 设计函数签名输入参数、输出形状、容错逻辑封装函数前先回答三个问题输入什么、输出什么、出错了怎么办。输入方面最简单的场景不需要任何参数——就是“从订单表里取一条随机记录”。但更现实的做法是预留一个主键范围参数支持“从某个区间的订单里随机抽一条”这样同一套函数能适配抽奖、报表抽样、测试数据构造等不同场景。输出方面我设计成内联表值函数返回一整行订单记录。使用方可以SELECT *也可以SELECT Id非常灵活。容错方面如果表是空的MAX(Id)为 NULL函数必须返回空结果集而不是报错。这个用WHERE Id ISNULL(targetId, -1)就能兜住。3.2 标量函数封装示例返回一个随机主键先看标量函数版本。它的定位很单纯只返回一个随机主键不承诺别的。CREATE FUNCTION dbo.fn_GetRandomOrderId() RETURNS INT AS BEGIN DECLARE minId INT, maxId INT, targetId INT; SELECT minId MIN(Id), maxId MAX(Id) FROM dbo.Orders; IF minId IS NULL OR maxId IS NULL RETURN NULL; SET targetId CAST(minId (maxId - minId) * RAND() AS INT); SELECT TOP 1 targetId Id FROM dbo.Orders WHERE Id targetId ORDER BY Id ASC; RETURN targetId; END; GO调用时直接SELECT dbo.fn_GetRandomOrderId();。这里有个容易被忽略的细节函数体里我用RAND()生成随机数。RAND() 本身不是副作用函数可以在标量函数里正常使用。但如果你的随机策略想用NEWID()在第 4 章的坑一里会专门讲它在这里直接写会报错。3.3 内联表值函数封装示例直接返回一整行记录随机查询的核心场景是“取一整条记录”这时候内联表值函数更好用CREATE FUNCTION dbo.fn_GetRandomOrder() RETURNS TABLE AS RETURN ( SELECT TOP 1 o.* FROM dbo.Orders AS o WHERE o.Id ( SELECT CAST(MIN(o2.Id) (MAX(o2.Id) - MIN(o2.Id)) * RAND() AS INT) FROM dbo.Orders AS o2 ) ORDER BY o.Id ASC ); GO调用方式极其自然SELECT * FROM dbo.fn_GetRandomOrder();也可以参与 JOINSELECT o.OrderNo, c.CustomerName FROM dbo.fn_GetRandomOrder() AS o LEFT JOIN dbo.Customers AS c ON o.CustomerId c.Id;注意函数体里没有任何BEGIN...END直接RETURN (SELECT ...)这是内联表值函数的标志。SQL Server 会把它视作带参数的视图调用时直接和外部查询合并执行。使用TOP 1之前一定要配合ORDER BY o.Id ASC这样才是拿“最小的大于等于目标随机数的那条记录”。很多人在内联函数里写ORDER BY却忽视 TOP结果发现排序被忽略行为变得诡异这一点第 4 章坑三会展开说。3.4 更复杂的调用场景通过参数控制随机范围如果业务方说“只要最近 30 天创建的订单里随机挑一条”我们就需要参数了。把范围和主键定位逻辑组合起来CREATE FUNCTION dbo.fn_GetRandomOrderInRange ( minId INT, maxId INT ) RETURNS TABLE AS RETURN ( SELECT TOP 1 o.* FROM dbo.Orders AS o WHERE o.Id CAST(minId (maxId - minId) * RAND() AS INT) AND o.Id BETWEEN minId AND maxId ORDER BY o.Id ASC ); GO使用示例SELECT * FROM dbo.fn_GetRandomOrderInRange(1000, 50000);把范围参数暴露出来函数就从“只解决一个问题”变成“能适配一批场景”。这也是封装的意义之一穷举变化的那部分而不是把需求写死。4. 封装过程中真正踩过的四个坑函数限制与业务现实的碰撞函数封装看着不难真写起来处处是限制。尤其 SQL Server 对自定义函数有比较严格的规则我在实际封装随机查询函数时踩过不少坑。把这些记录下来比直接抄代码更有价值。4.1 坑一标量函数里直接用 NEWID() 直接报错传参才是出路最典型的一个坑就是把随机查询的ORDER BY NEWID()思路直接搬到用户自定义函数里。假设你这么写CREATE FUNCTION dbo.fn_BadRandomId() RETURNS INT AS BEGIN DECLARE id INT; SELECT TOP 1 id Id FROM dbo.Orders ORDER BY NEWID(); RETURN id; END; GOSQL Server 直接给你甩一个硬错误Invalid use of a side-effecting operator newid within a function.原因在于NEWID()被归类为 side-effecting有副作用运算符而用户自定义函数中禁止出现副作用操作。自定义函数要求可预测、无副作用这是 SQL Server 的硬性规定。解决方案不是放弃而是把NEWID()从函数体里“请”出去通过参数传进来CREATE FUNCTION dbo.fn_GetRandomIdBySeed(seed UNIQUEIDENTIFIER) RETURNS INT AS BEGIN DECLARE minId INT, maxId INT, targetId INT; SELECT minId MIN(Id), maxId MAX(Id) FROM dbo.Orders; IF minId IS NULL OR maxId IS NULL RETURN NULL; SET targetId CAST(minId (maxId - minId) * ABS(CHECKSUM(seed)) / 2147483647.0 AS INT); SELECT TOP 1 targetId Id FROM dbo.Orders WHERE Id targetId ORDER BY Id ASC; RETURN targetId; END; GO调用时在外面生成随机种子SELECT dbo.fn_GetRandomIdBySeed(NEWID());这个思路也适用于多语句表值函数。只要是带BEGIN...END的函数体NEWID()就明令禁止。从设计角度理解SQL Server 希望函数是“纯函数”同样的输入应当产生同样的输出至少不能改变外部状态。GUID 生成器显然不满足这个约束。4.2 坑二函数里不能拼表名“通用随机表函数”的路走不通踩完 NEWID 的坑我当时还尝试过一步到位的“通用方案”传表名进去一个函数解决所有表的随机取记录。-- 设想中的用法实际不可能实现 SELECT * FROM dbo.fn_GetRandomFromTable(Orders);函数内部想用拼出来的动态 SQL 去查表在存储过程里可以用EXECUTE但在用户自定义函数中是禁区。函数不允许执行动态 SQL也不允许改变数据库状态所以这种“表名参数化”方案直接被判死刑。那怎么办我的处理思路有两种。第一种把函数定位成“针对特定表、特定主键的专用函数”每个业务表各自封装一个命名清晰即可。别嫌啰嗦这反而是 SQL Server 里比较正统的做法——函数本来就是静态绑定的数据库对象。第二种如果确实需要一个通用随机取行工具就别硬塞进用户自定义函数里改用存储过程或直接在应用层设计。比如写一个存储过程接收表名和主键列名内部用动态 SQL 处理。功能和灵活性都更好代价是丢失了“可以在查询中直接引用”的能力。我在实际项目中最终是“专用表函数 少量动态过程”的组合两边的边界很清晰。4.3 坑三内联表值函数里的 ORDER BY 必须配合 TOP否则失效内联表值函数本质上是“带参数的视图”SQL Server 在解析时会把它内部的结果当作一个派生表展开。如果在函数体里只写-- 这段代码放在内联函数中是不对的示例 RETURN ( SELECT o.* FROM dbo.Orders AS o ORDER BY NEWID() );传入外层的查询如果自己带了 ORDER BY或者干脆没有 ORDER BY函数内部那个ORDER BY NEWID()很可能被优化器直接忽略。原因很简单视图结果集本身没有顺序保证顺序只对最终输出有意义中间层的排序属于无意义动作。解决方案配合TOP 1。一旦出现TOP 1 ... ORDER BY NEWID()优化器就必须计算表达式的值才能挑选第一行排序语义强制生效。下面这个才是内联函数里真正有效的写法CREATE FUNCTION dbo.fn_GetRandomOrder() RETURNS TABLE AS RETURN ( SELECT TOP 1 o.* FROM dbo.Orders AS o ORDER BY NEWID() ); GO在我备份的测试环境里把TOP 1加上掉反复执行结果很快就出现了明显偏向表物理顺序的记录加上TOP 1之后分布才恢复随机。这个坑很隐蔽因为它不会报错只会让你得到“貌似随机实际偏斜”的数据。4.4 坑四循环里逐行调用标量函数性能直接崩塌封装好函数之后还有一个使用层面的坑。比如业务方想要“每个分类随机取一条记录”有人会这么写-- 反面示例循环逐行调用标量函数 DECLARE categoryId INT, randomOrderId INT; DECLARE cur CURSOR FOR SELECT DISTINCT CategoryId FROM dbo.Orders; OPEN cur; FETCH NEXT FROM cur INTO categoryId; WHILE FETCH_STATUS 0 BEGIN SELECT randomOrderId dbo.fn_GetRandomOrderId(); -- 用 randomOrderId 做点什么 FETCH NEXT FROM cur INTO categoryId; END; CLOSE cur; DEALLOCATE cur;标量函数在循环里逐行调用每次调用都是完整的函数上下文切换。几十个分类还好要是几千个分类时间直接爆炸。正确做法是放弃循环和标量函数改用内联表值函数 窗口函数一次集合操作搞定SELECT * FROM ( SELECT o.*, ROW_NUMBER() OVER (PARTITION BY o.CategoryId ORDER BY NEWID()) AS rn FROM dbo.Orders AS o ) AS t WHERE t.rn 1;如果数据量大再把NEWID()排序换为主键定位法做近似随机但思路始终不变——能用集合操作解决的绝不用循环。5. 实测验证随机分布与性能延迟的真实数据光讲原理和代码还不够我把几种方案放在同一张测试表上做了实测。这里贴出数据和方法供大家复现和参考。测试环境用的是我手头一台开发机SQL Server 2019CPU 8 核内存 16G数据表和索引都是默认配置。5.1 造一张 40 万行的测试表先建表CREATE TABLE dbo.TestOrders ( Id INT IDENTITY(1,1) PRIMARY KEY, OrderNo CHAR(10), CustomerId INT, Amount DECIMAL(10,2), CreatedAt DATETIME2 DEFAULT SYSDATETIME() ); GO用批量方式插入 40 万行测试数据。为了模拟真实空洞插入后在中间随机删掉约 10% 的行再用DBCC SHOWCONTIG和统计信息确认表结构。测试前执行SET STATISTICS IO ON; SET STATISTICS TIME ON;记录逻辑读、CPU 时间和总耗时。有空洞的表正是主键定位法最容易被质疑的场景测试它才有参考价值。5.2 随机性分布抽查随机性的验证方式把 Id 按 1 万为区间分成 40 个桶每种方案连续执行 20000 次统计落进每个桶里的次数。实测结果节选Id 范围ORDER BY NEWID()主键RAND定位TABLESAMPLE1 ~ 1000048652147210001 ~ 2000051749448820001 ~ 3000050351252530001 ~ 40000495508510数据总分布基本均匀基本均匀偶有偏斜结论在这个测试数据上ORDER BY NEWID()和主键RAND 定位法的随机分布都接近均匀。TABLESAMPLE偶发偏斜因为删除操作造成了页密度变化它的物理抽样逻辑天然带有偏向性。5.3 性能对比实测数据性能数据对比表如下40 万行表取 10 次平均查询方式逻辑读CPU 时间(ms)总耗时(ms)执行计划特征ORDER BY NEWID()约 4200 页620约 780Clustered Index Scan SortTABLESAMPLE NEWID()约 820 页90约 120表扫描 少量排序主键RAND 定位约 12 页5约 8索引定位无 sort内联表值函数封装的主键定位约 12 页5约 8与裸 SQL 几乎一致40 万行时ORDER BY NEWID()跑 0.8 秒看似还行但逻辑读是主键定位法的 350 倍。随着数据量翻倍增长差距还会继续拉大尤其是 tempdb 的压力很难扛。TABLESAMPLE快归快随机性偏斜打消了我对它的生产信心。内联表值函数封装和裸 SQL 性能完全一致这也验证了前面说的“内联函数本质是宏替换”这一判断。5.4 量级不同选型也不同综合性能、随机分布和实现成本我给出一份基于量级的选型建议。数据量级推荐方案理由小于 5 万行ORDER BY NEWID()实现最简单随机性最好性能完全可接受5 万 ~ 200 万行主键RAND 定位封装成内联表值函数性能好随机性近似均匀代码复用200 万行以上且空洞严重主键RAND 定位 多次采样取一单次定位变大多次采样消除空洞影响任何量级抽样但允许小偏斜TABLESAMPLE速度极快但不适合严格均匀的场景如果你的表没有可用作定位的索引优先考虑为随机查询单独建一个覆盖索引。没有索引的随机定位法就是全表扫描和NEWID()方案殊途同归。最后分享一个小扩展看到这里函数封装的基本用法已经完整了。我还留了一招常用的扩展思路既然已经封装好了“取一条随机记录”那“取 N 条随机记录”能不能复用当然可以直接在外层调用时用TOP (N)包一层SELECT TOP (5) * FROM dbo.fn_GetRandomOrder() CROSS JOIN (SELECT 1 AS dummy) AS x ORDER BY NEWID();或者干脆再写一个“取 N 条随机记录”的内联表值函数在内部随机生成 N 个目标 Id 区间然后批量UNION出来。这样随机查询的能力就从“一条”扩展成“一批”业务侧只用面对一个统一入口。我在实际项目中最后沉淀下来的就是一个内联表值函数加一个标量函数前者负责返回随机记录后者负责在特殊场景只拿主键。每次有新的随机查询需求我第一反应都是先查这两个函数还够不够用而不是再到业务代码里重新写ORDER BY NEWID()。这个习惯帮我省掉了不少不必要的重复开发和线上排查时间。如果你也正好在封装自定义函数或者被随机查询的性能问题困扰希望这些实测数据和踩坑记录能帮你直接越过那些我绕过的弯路。
RELATED READING

延伸阅读

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