ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SQL Server随机取一条记录:NEWID()原理与自定义函数封装

SQL Server随机取一条记录:NEWID()原理与自定义函数封装 先聊个真实的生产场景运营策划了个抽奖活动要求从用户表里随机抽一条记录发奖或者测试环境里要随机挑一个订单来跑回归又或者审核系统需要一个随机抽检功能把待审记录随机捞一条出来。这类需求在 SQL Server 里非常常见第一反应大多都是ORDER BY NEWID()配合TOP 1直接干。真正让人头疼的往往不是怎么写而是写完之后要怎么复用——今天写一遍明天换个表又要写一遍这时候就该把目光投向自定义函数的封装了。这个标题很有意思它把两件事绑在了一起一是随机查询一条表记录的完整写法与原理二是自定义函数的封装和使用的系统回顾。这两个话题单独拎出来都能写一堆合在一起反而更贴近实际工作流——毕竟日常开发中随手写一段 SQL和把它沉淀成一个可复用对象之间隔着的正是对函数边界、性能代价和语法规则的理解。这篇文章就把这条线完整走一遍从最基础的随机查询写法到标量函数、内联表值函数的封装差异再到踩坑记录。适合正在学 SQL Server、写过基本查询但没怎么正经碰过函数封装的人也适合工作里被随机取一条这类需求反复折腾的同学直接照着抄作业就行。1. 随机查询一条记录从需求到实现的完整拆解1.1 几种典型写法与底层原理先说大家最熟的那条一条 SQL 搞定随机抽一条SELECT TOP 1 * FROM dbo.Orders ORDER BY NEWID();原理其实不复杂NEWID()会给结果集里的每一行都生成一个 GUID全局唯一标识符GUID 本身没有规律ORDER BY按 GUID 排序后结果集的顺序就变成了随机打乱的状态再取TOP 1自然拿到了随机的一条。可以理解成给每张纸条写一个随机编号然后洗牌取最上面的那张。好处是写法简单、语义明确小表几千到几万行和临时需求用着非常舒服。但如果你接触过一些稍微讲究性能的场景应该也听过另一种思路先算出表里有几行然后随机取一个偏移量用ROW_NUMBER()定位到那一行。写法大致长这样DECLARE RandRow INT; SELECT RandRow CAST(RAND() * (SELECT COUNT(*) FROM dbo.Orders) 1 AS INT); SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS rn FROM dbo.Orders ) t WHERE t.rn RandRow;这种写法避免了全表排序但有一个致命前提RAND()在调用时如果不给种子每次返回的值确实不同可一旦用在不恰当的位置比如在WHERE或JOIN条件里直接写RAND()SQL Server 可能只计算一次也可能多次计算行为不够直观。更关键的是这类随机偏移写法要求你非常清楚表的数据分布和索引情况如果主键不连续或者表里有很多删除留下的空洞最终的结果分布会明显不均匀。还有一种容易被忽略的写法是TABLESAMPLESELECT TOP 1 * FROM dbo.Orders TABLESAMPLE (1000 ROWS);TABLESAMPLE是按照页面级别做采样的不是一行一行地抽所以数据量非常小的表用TABLESAMPLE时经常莫名其妙地抽不到任何行因为它可能随机选择的数据页恰好没有包含数据。而从数据分布上看TABLESAMPLE会把相邻页面上的记录成批取走随机性远不如NEWID()排序来得均匀。它真正的价值场景是超大表几千万甚至上亿行里大概其随机看一下的需求因为性能极快不需要全表排序。三种方式放在一起做个对比会更直观写法原理性能表现适用场景ORDER BY NEWID()每行生成 GUID 再排序表越大越慢百万行以上明显吃力小表、临时取数、抽奖等需要均匀随机的场景RAND() 行号偏移随机取行号定位不用排序仍需全表编号索引利用率取决于写法大表且主键连续、数据分布规律TABLESAMPLE按页面采样极快几乎不受表大小影响超大数据量的近似随机抽样对结果均匀性要求不高1.2 不同数据量下的选型思路如果你面对的是几千行的配置表ORDER BY NEWID()是没有任何争议的首选多写几个TOP条件都很轻松读起来也清爽。问题是表一旦涨到百万、千万行这个写法就容易让 DBA 找上门来——全表生成 GUID 再排序排序的代价是 O(n log n)加上 GUID 本身长度大比较成本又比整型高一大截跑一次全表排序可能吃掉大量内存和 CPU。我个人的实践经验是画一条分界线表行数在 10 万以内ORDER BY NEWID()随便用10 万到 50 万之间看并发压力和查询频率低频一天几次没问题高频每秒几次就要考虑别的方案50 万以上尽量别再用ORDER BY NEWID()做实时接口的底层逻辑。大表场景里随机偏移思路如果利用好主键可以做成一条高效的索引查找查询SELECT TOP 1 * FROM dbo.Orders WHERE OrderId ( SELECT TOP 1 OrderId FROM dbo.Orders ORDER BY OrderId DESC ) * RAND() ORDER BY OrderId;这里不能直接当标准答案用因为它要求OrderId分布均匀且连续如果 ID 是从 1000 开始的或者中间有大段空洞算出来的偏移量可能直接越过最大值或者落到空区间。更稳妥的做法是先用MIN(OrderId)和MAX(OrderId)算出范围再随机生成一个目标值用WHERE OrderId Target ORDER BY OrderId取第一条。这样做虽然不能保证绝对均匀遇到空洞时后面一条记录会被更大概率的选中但性能非常好每次查询都是索引 Seek。适合那种表巨大、同时业务上差不多随机就行的需求。1.3 随机查询的三个边界问题第一空表问题。SELECT TOP 1在表里没有数据时不会报错只会返回空结果集。但是如果外层直接把它当作标量子查询用就可能把NULL插入到业务字段里引发后续逻辑的连锁问题。我的习惯是抽完后加一个结果判断或者用ISNULL兜底。第二随机分布是否真的均匀。NEWID()产生的 GUID 是伪随机但实际分布足够均匀绝大多数业务场景都可以接受。真正需要注意的是自欺欺人的写法。比如WHERE CHECKSUM(NEWID()) % 100 30这种按概率抽 30% 的写法严格来说它并不是让每个记录以 30% 概率被选中而是把 GUID 映射到取模空间后某些区间的记录天然会被多选或少选尤其当表行数不大时偏差会非常明显。抽奖、测试分流这类场景不建议用这种取巧写法。第三随机不等于每次必不同。如果你连续调用多次随机取一理论上完全可能连续取到同一条记录这是正常的概率现象。业务上如果要求本次随机不能和上次一样就需要把上次结果作为条件排除掉或者用状态表记录历史抽取结果后再随机。这些都要提前和需求方沟通清楚否则上线后运营过来说这个随机是不是坏了怎么两次抽到同一个人你没法解释概率这件事。2. 自定义函数三种形态与使用边界2.1 标量函数、内联表值函数、多语句表值函数要说清楚函数封装得先把自定义函数UDF的三张面孔捋清楚。无论你用的是标量函数还是表值函数本质都是把一段 SQL 逻辑包起来给个名字方便反复调用。标量函数Scalar Function返回的是单个值比如一个整数、一个字符串。典型用法是封装计算逻辑算折扣价、算年龄、拼接展示文本等等。一个最简单的例子CREATE FUNCTION dbo.GetOrderCountByUser(UserId INT) RETURNS INT AS BEGIN DECLARE Count INT; SELECT Count COUNT(*) FROM dbo.Orders WHERE UserId UserId; RETURN Count; END;调用方式很简单SELECT dbo.GetOrderCountByUser(123);也可以在别的查询里直接用。内联表值函数Inline Table-Valued Function返回的是一个表但它有一个硬性要求整个函数体只有一个RETURN (SELECT ...)语句中间不能有变量赋值、不能有BEGIN...END块。比如CREATE FUNCTION dbo.GetOrdersByUser(UserId INT) RETURNS TABLE AS RETURN ( SELECT OrderId, OrderAmount, Status FROM dbo.Orders WHERE UserId UserId );调用方式更像查表SELECT * FROM dbo.GetOrdersByUser(123);。因为函数体就是一个SELECTSQL Server 在优化时会把函数体直接展开到外层查询里就像把这段代码拷贝过去一样所以性能表现往往很好也被称为参数化视图。多语句表值函数Multi-Statement Table-Valued Function允许你在函数里先定义一个表变量然后往里面插入数据最后再RETURN。看着灵活但优化器通常会把它当作黑盒没法做查询展开性能上限被锁死。在绝大多数场景里能写内联表值函数就别写多语句表值函数这是我在实际开发里反复踩过坑之后得出的结论。2.2 函数的几个硬性规则与常见误区自定义函数最大的特点就是纯净。函数里不能有副作用不能INSERT、UPDATE、DELETE数据不能调用大部分存储过程不能执行动态 SQL也不能在逻辑上依赖会话状态。我特别想强调一个大家容易混淆的点随机函数在 UDF 里的限制。很多人听说函数里不能用随机函数就把ORDER BY RAND()写进了函数结果报错然后得出结论SQL Server 不允许随机查询封装成函数——这其实是冤枉了NEWID()。我实测下来的情况是RAND()在用户自定义函数里会被明确禁止报错信息通常带有Invalid use of a side-effecting operator rand within a function之类的字样而NEWID()在标量函数和多语句函数里用起来一般没问题在内联表值函数里更是因为函数体只是RETURN (SELECT...)而几乎不受限制。这个问题我在 4.2 里会专门展开你可以直接作为排查手册用。另一个重要规则是标量函数有隐性逐行调用的坑。你在一个返回一万行的查询里调用标量函数SQL Server 默认不会帮你做优化展开它就是老老实实把函数跑一万遍。如果函数里又有聚合、又有子查询这个性能灾难是呈指数放大的。也是因为这个原因我在很多场合都主张能用内联表值函数的地方就不要为了省事封装成标量函数。2.3 函数、存储过程与视图怎么选很多初学者分不清三者的边界。简单说能直接用在SELECT语句里的就是函数和视图存储过程不能。函数相当于给你一段可复用的逻辑数据只进不出视图本质上是一个预先写好的 SELECT没有参数内联表值函数则是带参数的视图比视图多了一层灵活性。存储过程则是完整的执行单元它可以做事务控制、动态 SQL、修改数据、捕获错误甚至可以返回多个结果集但这些灵活性换来的是它不能被嵌入到FROM子句或SELECT列表里。所以当你觉得哎函数好像做不了这个事的时候先想想是不是自己选错了工具而不是硬把逻辑往函数里塞。比如业务需求是不同的表都要随机抽记录需要把表名作为参数动态切换函数根本做不到它不允许动态 SQL这时应该用存储过程配合EXEC或者sp_executesql来做。反过来如果只是订单表里随机抽一条表名是固定的只是希望返回类型稳定、调用方便函数就是更好的选择。3. 把随机查询封装成函数从标量到表值函数3.1 为什么要封装直接SELECT TOP 1 * FROM dbo.Orders ORDER BY NEWID()不香吗当然香一次两次没问题。但现实业务永远不是只有一处用。抽奖用一次、测试数据准备用一次、运营看板用一次、报表抽检用一次每个地方都甩一段ORDER BY NEWID()结果就是一旦表结构改了你得满项目去搜NEWID()一个一个改。封装的第一个价值是语义化把随机抽一条这个动作变成一个叫GetRandomOrder的函数读代码的人一眼就明白这行是什么意思。封装的第二个价值是单一改动点以后想换随机策略、想加过滤条件、想改排序权重只需要改这一个函数所有调用方自动升级。3.2 标量函数封装返回随机主键先看标量函数的版本毕竟它是很多人脑子里第一反应函数只能返回一个值的直观对应。我们要做一个从订单表随机取一个 OrderId的函数CREATE FUNCTION dbo.GetRandomOrderId() RETURNS INT AS BEGIN DECLARE OrderId INT; SELECT TOP 1 OrderId OrderId FROM dbo.Orders ORDER BY NEWID(); RETURN OrderId; END;调用SELECT dbo.GetRandomOrderId();就会得到一个新的随机订单号。这里要解释一个关键设计为什么先用SELECT TOP 1 OrderId OrderId赋值最后再RETURN OrderId因为标量函数的返回值是一个单一标量你没办法在函数里直接RETURN (SELECT TOP 1 OrderId ...)这种写法在标量函数里是不被允许的必须先把值放进变量再返回。更进一步现实需求很少是全表随机抽往往带着条件。比如只从已支付的订单里抽一条只需要给函数加个状态参数CREATE FUNCTION dbo.GetRandomOrderIdByStatus(Status TINYINT) RETURNS INT AS BEGIN DECLARE OrderId INT; SELECT TOP 1 OrderId OrderId FROM dbo.Orders WHERE Status Status ORDER BY NEWID(); RETURN OrderId; END;调用SELECT dbo.GetRandomOrderIdByStatus(2);。注意这里有个空表风险如果Status 2的记录一条都没有函数返回NULL。调用方做业务判断时要留意。标量函数封装的优点是好懂、调用方便缺点也明显——它只返回一个 ID。如果你不只是想要 ID还想要订单金额、下单时间这些字段那你还得再查一次表。在数据量不大时无所谓在频繁调用时这就是一次多余的往返。于是就有了下面这种更推荐的封装思路。3.3 内联表值函数封装直接返回整行记录如果随机抽取的目的是拿到一整条记录的信息内联表值函数要自然得多。函数直接返回一个表而且这个表是RETURN (SELECT...)直接产出的CREATE FUNCTION dbo.GetRandomOrder() RETURNS TABLE AS RETURN ( SELECT TOP 1 OrderId, CustomerId, OrderAmount, Status, CreateTime FROM dbo.Orders ORDER BY NEWID() );调用方式变成SELECT * FROM dbo.GetRandomOrder();是不是清爽多了因为返回的是表所以你想取哪些字段函数外面再SELECT就好了。想和其他表关联也完全可以SELECT c.CustomerName, o.OrderAmount, o.CreateTime FROM dbo.GetRandomOrder() o LEFT JOIN dbo.Customers c ON o.CustomerId c.CustomerId;注意在这段 JOIN 里dbo.GetRandomOrder()在 FROM 子句里被当成一个普通表来用。SQL Server 的执行计划会把函数内部的SELECT展开到整个查询计划里不会出现先跑完函数再 JOIN的笨办法。这也是我强烈推荐内联表值函数的原因——它不只是写起来清爽性能上限也更高。带过滤条件的随机抽一条写法对称CREATE FUNCTION dbo.GetRandomOrderByStatus(Status TINYINT) RETURNS TABLE AS RETURN ( SELECT TOP 1 OrderId, CustomerId, OrderAmount, Status, CreateTime FROM dbo.Orders WHERE Status Status ORDER BY NEWID() );3.4 扩展封装随机取 N 条记录除了随机一条还有一类需求是随机抽 N 条。比如抽奖要抽出 10 个中奖用户或者测试环境要随机造 100 条样本。封装方法也很直接用TOP (N)语法CREATE FUNCTION dbo.GetRandomOrders(N INT) RETURNS TABLE AS RETURN ( SELECT TOP (N) OrderId, CustomerId, OrderAmount, Status, CreateTime FROM dbo.Orders ORDER BY NEWID() );调用SELECT * FROM dbo.GetRandomOrders(10);这里有个语法细节值得留意TOP (N)必须带括号这是 SQL Server 对常量之外的表达式作为 TOP 参数的强制要求。如果你写成TOP N会直接语法报错。很多从 MySQL 转过来的开发容易踩这个坑。从实际生产角度看随机取 N 条相比于随机取 1 条的性能问题会更明显因为ORDER BY NEWID()依然要对全表做排序然后才能取前 N 条。如果你只需要随机取 5 条而表有 1000 万行那这 1000 万行的 GUID 生成和排序是逃不掉的。对于这种大表场景业界有随机取比 N 大一点的集合再排序的变通思路但会牺牲结果的均匀性需要业务方明确能接受近似随机我一般只在测试环境或离线条路上这么干。3.5 多语句表值函数为什么不推荐用来做随机查询前面提到过多语句表值函数这里用随机查询的封装做一个反面教材。初学者拿到随机取一条的需求因为既想返回多列又对写一个RETURN (SELECT...)不太放心往往会写成CREATE FUNCTION dbo.GetRandomOrder_MSTVF() RETURNS Result TABLE ( OrderId INT, CustomerId INT, OrderAmount DECIMAL(18, 2), Status TINYINT, CreateTime DATETIME ) AS BEGIN INSERT INTO Result (OrderId, CustomerId, OrderAmount, Status, CreateTime) SELECT TOP 1 OrderId, CustomerId, OrderAmount, Status, CreateTime FROM dbo.Orders ORDER BY NEWID(); RETURN; END;功能上没毛病但性能上有一个先天劣势SQL Server 的查询优化器对多语句表值函数内部做的事情几乎无法感知它会把它当成一个黑盒子。外层查询拿到的执行计划可能是先把函数完整执行一遍把结果存到一个表变量里再继续往外传。数据量一大、调用一多效率非常难看。我也见过有人把这种函数用在循环里结果一条一条地随机取数据最后跑了十几分钟没出结果。如果你的场景确实需要这个形态建议至少先测一版内联表值函数方案用实际执行计划对比后再决定。4. 封装过程中的坑与排查技巧实录4.1 函数里用了 RAND() 导致报错网上搜 SQL Server 函数随机 时很多人会给你示范ORDER BY RAND()但把它写进函数时SQL Server 大概率会直接报错错误类似Invalid use of a side-effecting operator rand within a function。原因是 SQL Server 对用户自定义函数内部的函数使用有严格限制RAND()被引擎认定为不允许出现在函数内部。这不是你代码逻辑的问题而是函数边界内的语法限制。正确做法是把ORDER BY RAND()全部换成ORDER BY NEWID()。如果你被网上的旧文章带偏了很久可以在自己的环境里快速验证一下CREATE FUNCTION dbo.TestRand() RETURNS INT AS BEGIN RETURN CAST(RAND() * 100 AS INT); END;这个函数多半创建就会失败或者创建成功后调用时直接报错。而把RAND()换成NEWID()再试CREATE FUNCTION dbo.TestNewId() RETURNS UNIQUEIDENTIFIER AS BEGIN RETURN NEWID(); END;在我接触过的 SQL Server 2012 到 2022 版本里这个函数可以正常创建和调用。这个差异非常值得记到笔记里不是函数不能随机而是函数不能用 RAND()但可以用 NEWID()。4.2 为什么函数内每次调用随机结果不变还有一个容易踩的坑是封装好的随机函数在生产环境跑着跑着突然发现每次返回结果都是一样的。这种问题大多数情况不是函数写错了而是 SQL Server 对函数结果做了缓存。当函数被标记为确定性函数时引擎认为同样的输入一定会得到同样的输出于是在重复调用时直接复用上次结果。但对于ORDER BY NEWID()这类随机逻辑它本质上是非确定性的函数却可能因为某些原因被错误地视为确定性函数从而引发缓存问题。怎么排查先用这个查询看函数的确定性标记SELECT OBJECT_NAME(object_id) AS FunctionName, is_deterministic FROM sys.objects WHERE type FN;如果is_deterministic为 1而你函数里明明用了NEWID()那就要检查是不是用WITH SCHEMABINDING之类的方式定义时触发了引擎的误判或者函数内部其实调用了另一个确定性包装层。解决办法通常是重建函数并避免在封装层做多余的确定性声明更直接的办法是改用内联表值函数因为它的函数体是直接展开的引擎不会对它做整函数级别的结果缓存随机逻辑更稳定。4.3 函数封装后性能反而下降常见的性能退化场景有两种。第一种是标量函数在外层大查询里被逐行调用。比如有一个查询要返回 100 万行用户数据每行里都调用了dbo.GetRandomOrderIdByStatus(...)这种标量函数结果就是随机排序逻辑被执行了 100 万次。这种时候排查执行计划你会看到大量的用户定义函数运算符而且它比普通表扫描还难优化。第二种是随机取 N 条的函数在大表上跑得越来越慢。前面说过ORDER BY NEWID()的 O(n log n) 排序代价表一大这个代价就会被放大。实际优化方向有两个一是在不影响业务的前提下把从全表随机取 N 条改成从最近 7 天数据里随机取 N 条先通过时间字段缩小候选集再用ORDER BY NEWID()二是如果业务允许近似随机可以在应用层先取一个随机 ID 区间再在这个区间内取 N 条。还有个容易忽略的点函数里如果用了SELECT *而表结构后来加了高开销的列类型比如大字段、XML、空间类型所有调用方的返回数据量都会变大。如果只是为了随机抽 ID就不要在函数里返回整行只返回 ID 会轻量得多如果业务确实需要整行就显式列出需要的字段。4.4 排查函数问题的几个实用查询最后整理几条平时排查函数问题时常用的查询都是亲测好用的查看函数定义全文最快最直接的方式EXEC sp_helptext dbo.GetRandomOrder;查看一个表被哪些函数引用这在你准备改表结构前很有用SELECT referencing_object_name OBJECT_NAME(referencing_id), referenced_object_name OBJECT_NAME(referenced_id) FROM sys.sql_expression_dependencies WHERE OBJECT_NAME(referenced_id) Orders;确认某个函数是否存在再删除避免删了报错IF OBJECT_ID(dbo.GetRandomOrder, IF) IS NOT NULL DROP FUNCTION dbo.GetRandomOrder;OBJECT_ID的第二个参数IF表示内联表值函数标量函数则是FN多语句表值函数是TF。封装工作多了以后养成分类型管理的习惯能在批量操作时省下不少麻烦。关于随机查询加函数封装这条线最后再分享一点个人体会。我早期也习惯把所有逻辑都往标量函数里塞觉得调用起来像写代码一样顺手后来在一个报表查询里被标量函数的逐行调用坑到怀疑人生——那条原本几秒能跑完的查询加上函数后直接跑了好几分钟。自那以后凡是遇到随机取记录这种既要返回多条字段、又可能在查询里嵌套使用的场景我都会优先选择内联表值函数只有在返回单个 ID、用在应用层标量赋值时才碰标量函数。至于RAND()和NEWID()的差别每次写函数前都会下意识提醒自己一遍随机这事SQL Server 只给NEWID()开了绿灯。这套思路你用上一段时间再回头看随机查询 函数封装这种需求基本就是肌肉记忆了。
返回列表