1. 先把"序号"这件事拆开看
做报表、做分页、做去重取最新一条,只要涉及 SQL 排名和行号,几乎绕不开"生成序号"这个动作。我刚入行那会儿,接手一个后台报表需求,要在明细里加一列连续的行号,当时第一反应是"这不应该在程序里循环的时候顺便加一吗",结果数据量一上来,前端直接卡死,最后老老实实回到数据库层写窗口函数。从那之后我就养成了一个习惯:凡是能用一条 SQL 把序号算出来的场景,就不往应用层搬。
这篇内容讲的四种生成序号的方式,分别是窗口函数、会话变量递推、相关子查询计数、引擎托管(伪列/标识列/序列对象/数字表)。这四种不是并列的替代品,而是四代数据库能力叠加出来的四层解决方案:老版本数据库没有窗口函数,只能用变量或者子查询;新版本数据库有了窗口函数,前两种基本可以退休;而真正需要"持久化编号"或者"生成一个纯数字序列"的时候,又得回头用第四种。文章适合写过基础 SQL、但被序号问题坑过一两次的同学,也适合做数据仓库、做后端接口、天天跟分页打交道的人。四种方式我都会给可执行的写法、适用边界,以及我在实际项目里踩到的坑,你可以直接抄到自己的环境里跑一遍对照。
1.1 三种语义:行号、排名、编号
很多关于序号的困惑,根子在概念混着用。我一般把它拆成三类,拆清楚之后选型就没什么纠结了。
第一类是行号(row number),纯粹的行位置,1、2、3、4 顺着排,重复值也各占一个号,谁也不并列。它的用途是"给结果集打上位置标签",比如分页、导出、取第 N 行。第二类是排名(rank),跟排序值绑定,两个值相同就应该并列,比如成绩并列第 2,那下一名就是第 4。这一类的变体是密集排名,并列之后下一个是第 3。第三类是编号(id / serial),它是持久化写到表里的一列,跟着行本身走,插入就生成,删了也不会重排,甚至可能出现空洞。
这三类的实现手段完全不同:行号和排名是查询期的计算结果,可以用窗口函数、变量、子查询算;编号是存储期的属性,只能靠自增列或者序列对象生成。很多人把"行号"和"编号"搞混,写出SELECT ROW_NUMBER() OVER() AS id然后拿这个 id 去做关联更新,结果一跑第二次数据发现 id 全变了,数据对不上。这个坑我见过不止一次,本质是没分清"每次算都变"和"插入就固定"的区别。
注意:查询期算出来的行号只在本次结果集内有效,绝对不能当业务主键用。
1.2 四种路线的取舍逻辑
我把四种方式的边界压成一张表,你拿着这张表基本就能决定该写哪种。
| 方式 | 核心语法 | 适用数据库版本 | 优势 | 主要代价 |
|---|---|---|---|---|
| 窗口函数 | ROW_NUMBER() OVER(...) | 较新版本普遍支持 | 语义清晰、可分区、可并列排名 | 需要排序,大结果集有内存开销 |
| 会话变量递推 | @rn := @rn + 1 | 老旧版本常见 | 兼容老版本、写法短 | 顺序无保证、新版本已不推荐 |
| 相关子查询计数 | (SELECT COUNT(*) ...) | 所有版本 | 通用、可解释性强 | 复杂度高,数据量大就崩 |
| 引擎托管 | 伪列 / 自增列 / 序列 / 数字表 | 依具体产品 | 并发安全、可持久化 | 有空洞、不连续、需额外对象 |
选型时我遵循一个很简单的顺序:先问这个序号要不要落库。要落库,直接走第四种的标识列或序列对象,别犹豫,其他三种都算不出来稳定编号。再问需不需要分区和并列。要,就窗口函数,别自己造。最后才问数据库版本。如果连窗口函数都不支持,说明这套环境大概率是老系统,那就用变量或子查询顶一下,同时在代码里注释清楚"这是兼容写法,升级后请替换"。
这个顺序能挡掉八成错误选型。我见过有人用相关子查询给十万行数据算行号,跑了四十多秒,换成窗口函数之后不到一秒,改完他自己都不好意思。也见过有人用ROW_NUMBER()去生成业务流水号,上线之后发现并发下重号,这就是把查询期结果当成了持久化编号。
2. 方式一:窗口函数ROW_NUMBER,现代数据库的默认答案
只要你的数据库版本不是特别老,序号问题首选就是窗口函数。它的价值不只是"能算",而是把"分区内排序后编号"这个语义用声明式语法表达出来了,你写的是"我要什么",而不是"你要怎么循环"。这一点在后期维护时差别巨大,变量写法三个月后没人看得懂,窗口函数一眼能读出意图。
2.1 OVER子句三件套与最常用骨架
OVER括号里其实就三样东西:分区、排序、窗口范围。生成行号的场景里,窗口范围通常留空,用默认值即可。
-- 最简单的全局行号:按创建时间排序 SELECT ROW_NUMBER() OVER (ORDER BY created_at, id) AS rn, id, created_at, amount FROM orders WHERE status = 'PAID';上面这段有两个细节值得说。第一,我在ORDER BY里除了业务字段created_at还加了个id。这个叫排序补位或者打破平局,原因后面小节细讲,但你先记住"排序字段不确定唯一,就补一个唯一列"。第二,窗口函数不能直接写在WHERE里,因为它是在WHERE、GROUP BY之后才计算的。想过滤行号,必须包一层。
-- 想取第 11 到 20 行,必须包一层子查询或 CTE WITH numbered AS ( SELECT ROW_NUMBER() OVER (ORDER BY created_at, id) AS rn, id, created_at, amount FROM orders WHERE status = 'PAID' ) SELECT * FROM numbered WHERE rn BETWEEN 11 AND 20;分区版本是日常用得最多的,比如"每个客户按订单时间从早到晚编号":
SELECT ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at, id) AS seq_in_customer, customer_id, id, created_at FROM orders;这个seq_in_customer就是"这个客户的第几单"。你要取每个客户最近一单,用seq_in_customer = 1过滤即可,这也是窗口函数最经典的去重取最新套路。
提示:如果数据库支持
QUALIFY之类的过滤子句就省一层,不支持就老老实实包 CTE。别为了少写一行去嵌套三层子查询。
2.2 并列值、NULL与排序稳定性
窗口函数家族一共有三个编号函数,差别只在于遇到并列值时的处理,这个必须背下来,不然报表数字对不上会很难解释。
| 函数 | 并列处理 | 结果示例(值 100,100,90) | 用途 |
|---|---|---|---|
ROW_NUMBER() | 不并列,强行错开 | 1, 2, 3 | 行号、分页、去重 |
RANK() | 并列且跳号 | 1, 1, 3 | 名次(有并列第一,就没有第二) |
DENSE_RANK() | 并列不跳号 | 1, 1, 2 | 等级、档位划分 |
选哪个完全取决于业务口径。销售排行榜一般用RANK,因为"并列第一、下一位第三"符合直觉;评级分档用DENSE_RANK,因为档位本身是连续的,跳号没道理;而分页和去重必须用ROW_NUMBER,因为它保证每行一个唯一号,不会漏数据也不会重复取。
排序稳定性是另一个高频坑。当ORDER BY的字段存在重复值或 NULL 时,窗口函数仍然会给每一行分配唯一的行号,但谁先谁后是不确定的。这意味着同一个查询跑两次,同一行的行号可能不一样。如果你的分页接口依赖这个行号,用户翻第二页时就会看到第一页出现过的数据,这就是经典的"翻页重复和漏行"。
解决办法很朴素:在ORDER BY末尾追加上主键或任何唯一列。
-- 不稳定的写法:create_time 有大量相同值 ROW_NUMBER() OVER (ORDER BY create_time) -- 稳定的写法:相同时间再按唯一列排 ROW_NUMBER() OVER (ORDER BY create_time, id)至于 NULL,各家数据库的默认排序位置不一样,有的把 NULL 当最小值排最前,有的当最大值排最后。窗口函数本身不会报错,但序号分布会跟你想的不一样。稳妥做法是显式指定 NULL 的位置,或者在排序表达式里先做一次COALESCE归一。
-- 让 NULL 统一排到最后,序号分布可控 ROW_NUMBER() OVER ( ORDER BY CASE WHEN score IS NULL THEN 1 ELSE 0 END, score DESC, id )2.3 分页、去重取最新与组内累计三个高频场景
窗口函数在序号上的实战,八成集中在下面三个场景,我直接把可复用的写法列出来。
场景一:稳定分页。传统LIMIT 10 OFFSET 20在深翻页时性能很差,而且排序不稳定就出错。用行号包一层,配合唯一排序键,结果稳定。
WITH paged AS ( SELECT ROW_NUMBER() OVER (ORDER BY create_time DESC, id DESC) AS rn, id, title, create_time FROM articles WHERE is_published = 1 ) SELECT id, title, create_time FROM paged WHERE rn > 20 AND rn <= 30;顺带说一句,深翻页真正的性能问题不在行号,而在"数据库仍然要算前 20 行的序号再丢掉"。真要优化大偏移量,得改成游标式分页,也就是"从上一页最后一行的排序值往后取",窗口函数解决的是正确性问题,不是性能问题,这点别搞混。
场景二:去重取最新。一张表因为重复导入出现多条同一业务键记录,要保留最新一条。
WITH ranked AS ( SELECT ROW_NUMBER() OVER (PARTITION BY biz_key ORDER BY update_time DESC, id DESC) AS rn, * FROM source_table ) SELECT * FROM ranked WHERE rn = 1;这个写法我用了很多年,比GROUP BY加MAX再自连接干净太多,尤其是表字段多的时候。
场景三:组内累计。行号本身还能当"累计"的基础,比如求每天累计金额:
SELECT order_date, amount, ROW_NUMBER() OVER (ORDER BY order_date, id) AS rn, SUM(amount) OVER (ORDER BY order_date, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total FROM daily_orders;这里ROWS BETWEEN ... AND CURRENT ROW就是前面说的"窗口范围",默认范围在有名次并列时行为可能跟你预期不一致,显式写出来最保险。
注意:窗口函数执行顺序在
WHERE和GROUP BY之后、ORDER BY之前。所以在WHERE里过滤行号一定报错,在ORDER BY里再套窗口函数也可能出意外。
3. 方式二:会话变量递推,老版本数据库的经典解法
如果说窗口函数是"声明我要什么",那会话变量递推就是"我自己数给你看"。它的原理特别朴素:先定义一个计数器变量,初始化为 0,然后逐行执行变量 = 变量 + 1,把每次的值取出来当行号。在窗口函数还不普及的年代,这套写法几乎是唯一解,我早年维护的一套报表系统里到处都是它。
3.1 变量赋值为什么要包一层派生表
先看标准写法,这是很多老博客上流传的模板:
SELECT @rn := @rn + 1 AS rn, t.id, t.amount FROM orders t CROSS JOIN (SELECT @rn := 0) init ORDER BY t.amount DESC;这里的(SELECT @rn := 0)是个派生表,作用是给变量做初始化。没有它,变量值是上一条语句遗留下来的,你可能拿到从 137 开始的序号。这个衍生表只返回一行,所以用交叉连接不会放大行数,只是把初始化动作挂进来。
但这段代码有个致命问题:它依赖执行顺序,而这个顺序在规范上是不保证的。数据库优化器有权利先算表达式再排序,或者边排序边算,甚至并行处理。你看到的结果"大部分时候是对的",恰恰是最危险的情况,因为它不会稳定地报错,只会在数据量变化、执行计划切换的时候偶尔错乱。
我踩过的最典型的一次是:给派生表里的子查询加了ORDER BY,以为排序能传上去。
-- 危险写法:派生表里的 ORDER BY 可能被优化器直接丢弃 SELECT @rn := @rn + 1 AS rn, t.* FROM (SELECT * FROM orders ORDER BY amount DESC) t CROSS JOIN (SELECT @rn := 0) init;派生表和外层查询本质上是"无序集合"的关系,内层排序对外层没有任何约束力,很多数据库会直接把内层的ORDER BY优化掉。所以正确的姿势是:排序必须写在外层,且外层不要再有任何可能打乱顺序的操作。
3.2 分组累计与"间隔分组"的变体
变量写法真正的独门绝技,是处理窗口函数不太容易表达的"间隔分组",也就是常说的 gap and islands 问题。比如一串日期,要把连续区间归成一组,每段给个组号。
SELECT day, @grp := @grp + (CASE WHEN DATEDIFF(day, @prev_day) = 1 THEN 0 ELSE 1 END) AS group_no, @prev_day := day AS cur_day FROM calendar CROSS JOIN (SELECT @grp := 0, @prev_day := NULL) init ORDER BY day;这段的关键在于同一行里两个变量的赋值顺序:必须先算group_no,再更新prev_day。如果反过来写,group_no拿到的就是更新后的prev_day,逻辑全乱。这种"顺序即逻辑"的写法可读性极差,我在代码评审里见到这类语句,一律要求加注释说明每一步的意图,否则接手的人根本不敢动。
分组累计的常见写法是这样:
SELECT dept, emp, salary, @dept_total := IF(@cur_dept = dept, @dept_total + salary, salary) AS dept_running, @cur_dept := dept AS cur_dept FROM emp_salary CROSS JOIN (SELECT @dept_total := 0, @cur_dept := NULL) init ORDER BY dept, salary;它的逻辑是"部门变了就重新开始累加"。但同样的活儿,窗口函数一行就写完:
SELECT dept, emp, salary, SUM(salary) OVER (PARTITION BY dept ORDER BY salary, emp ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS dept_running FROM emp_salary;对比一下就能看出维护成本差别,这也是为什么我一有机会就把变量写法替换掉。
3.3 这套写法的寿命与迁移建议
会话变量这套玩法有一个明确的"寿命"信号:新版本已经把它标记为不推荐了。较新的版本里,在SELECT语句中给用户变量赋值这件事已经在逐步收紧,官方文档也明确说过这类表达式的求值顺序是未定义的。这意味着你现在能跑,不代表升级之后还能跑,更不代表换个执行计划还能跑。
我的建议很务实:存量代码先别急着动,新代码一律不用。存量里如果要改,优先改三类:一是结果被下游强依赖的(比如导出报表的序号列),二是数据量已经涨到执行计划会变的,三是排在升级清单里的。改法就是老老实实翻译成窗口函数,逻辑一一对应,风险很低。
还有个小细节:如果老环境确实不支持窗口函数,而你又必须稳定出序号,可以考虑把结果落到临时表再读取。因为临时表插入时是确定顺序的,从临时表按插入顺序读回来(带上自增列)能拿到稳定序号。这个方案笨,但稳,我在几个极端环境里用过。
提示:变量写法如果非要用,务必把变量初始化、排序、赋值三件事在同一个语句层级完成,不要跨子查询传递。
4. 方式三:相关子查询计数,最朴素也最通用
第三种方式几乎不需要任何版本前提,任何支持子查询的数据库都能跑。它的思路是把"第几名"翻译成一句人话:比我大的有几个,我就是第几个加一。这句话直译成 SQL 就是相关子查询。
4.1 写法拆解与执行代价
SELECT t1.id, t1.amount, (SELECT COUNT(*) FROM orders t2 WHERE t2.amount > t1.amount) + 1 AS rn FROM orders t1;先算内层再算外层,t1每出一行,内层就扫一遍t2表统计更大的数量,加一就是行号。逻辑上无懈可击,问题是这是典型的平方级复杂度。一万行数据,内层平均扫五千行,合计五千万次比较;十万行就是五十亿次。我做过一次实测,在同一台机器上,十万行的表用这个写法跑了四十多秒,换成窗口函数不到一秒,差距不是几倍,是几十倍。所以这个写法的定位很明确:要么数据量小,要么数据本身有强约束能走索引,否则不要上生产。
分组排名同理,只是把比较条件多带一个分区字段:
SELECT t1.dept, t1.emp, t1.salary, (SELECT COUNT(*) FROM emp_salary t2 WHERE t2.dept = t1.dept AND t2.salary > t1.salary) + 1 AS rank_in_dept FROM emp_salary t1;这里千万注意别写成"大于等于",否则有并列薪资时所有人都会往后顺延一位,排名全错。想要密集排名,就把"统计比我大的不同薪资个数"再绕一层,代价更高,但逻辑是对的。
4.2 索引怎么给,数据量多大就该换
这个写法能不能用,全看索引。针对上面那个"部门内排名"的例子,最优索引是(dept, salary)这样的复合索引:内层按dept等值定位,再按salary范围扫描统计数量,走覆盖索引,不用回表。
CREATE INDEX idx_emp_dept_salary ON emp_salary (dept, salary);给了这个索引之后,单次内层查询的成本从"扫全表"降到"扫部门内数据",实际表现会好很多。但复杂度性质没变,仍然是每个外层行都要查一次内层,行数一多必然崩。
我给的经验阈值是这样的:表小于一万行,随便用;一万到十万行,必须确认有复合索引并且实测过;超过十万行,直接换窗口函数,别挣扎。这不是绝对数字,不同数据库的优化器能力差异很大,但量级判断基本靠谱。另外要留意,某些优化器会把相关子查询改写成连接加聚合,那时候性能会突然好起来,所以一定要看执行计划,别凭感觉猜。
4.3 它反而更合适的两种场合
虽然性能一般,但我确实有两个场景会主动用相关子查询,而不是窗口函数。
第一种是计算字段式的排名,且要求"并列不跳号"这种定制口径。窗口函数只给你三档标准答案,遇到"相同只算一个、不同就前进一位但空档不算"这类特殊规则,子查询的表达反而更直白,改起来也更容易,因为你是在写业务条件,不是在猜函数语义。
第二种是需要在外层继续用序号做关联或过滤的场景。窗口函数的结果必须包一层才能用,层级一深可读性就崩;而相关子查询算出来的是一个标量列,可以直接出现在SELECT列表里,也可以在HAVING里参与条件,写起来层级更浅。我在写一些临时排查脚本时会偏爱这种写法,因为它不需要WITH包裹,一行行读下来很顺。
还有一种是"取每组前 N 条"的古老写法,本质也是相关子查询:
SELECT t1.* FROM orders t1 WHERE (SELECT COUNT(*) FROM orders t2 WHERE t2.customer_id = t1.customer_id AND t2.amount > t1.amount) < 3;意思是"组内比我大的不到 3 个",等价于"我是前三名"。这种写法在老代码里很多,读的时候一眼能认出来,改的时候直接换成ROW_NUMBER() ... <= 3就行。
注意:相关子查询里的比较运算符是
>还是>=,直接决定并列值怎么处理,改之前先确认业务口径,别凭直觉改。
5. 方式四:把编号权交给引擎,伪列、标识列与序列对象
前面三种都是"查询时算",第四种是"引擎替我管"。这类方式的特点是序号跟数据本身绑定,具有持久性,代价是可能有空洞、可能不连续、可能需要额外建对象。用之前一定要想清楚:你要的是"每次查询重算的行号",还是"插进去就固定不改的编号",这两个答案完全不同。
5.1 伪列ROWNUM的赋值时机,一个经典坑
有些数据库提供伪列来直接出行号,最典型的就是ROWNUM。它最大的特点是在行被筛选出来的时候赋值,而不是在排序之后赋值,顺序是"先出行、赋号、再排序"。所以下面这个写法是错的:
-- 错误:序号在排序前就固定了,输出是乱的 SELECT ROWNUM AS rn, id, amount FROM orders ORDER BY amount DESC;输出结果里amount从大到小排好了,但rn却是乱的,因为rn在排序之前就已经按"物理读取顺序"分配完了。正确做法是先把排序做完,再在外面套一层取序号:
-- 正确:先排序成子查询,外层再赋号 SELECT ROWNUM AS rn, t.* FROM ( SELECT id, amount FROM orders ORDER BY amount DESC, id ) t;这个坑非常经典,我在面试别人的时候会拿来问,十个人有六个答不上来。与之配套的还有分页限制,ROWNUM的过滤条件写在不同层级,结果完全不同:
-- 想取前 10 条:必须嵌套,内层取号外层过滤 SELECT * FROM ( SELECT ROWNUM AS rn, t.* FROM (SELECT id, amount FROM orders ORDER BY amount DESC, id) t WHERE ROWNUM <= 10 ) WHERE rn > 0;记一个口诀就够了:伪列永远在排序前、过滤时生效,所以凡是要"排序后再编号",就包一层再包一层。
5.2 自增列与序列对象的语义差别
需要持久化编号时,主流有两条路:表上的自增列,和独立的序列对象。两者看起来都是"每次加一",细节差别不小。
| 维度 | 自增列 / 标识列 | 序列对象 |
|---|---|---|
| 归属 | 绑在具体表的一列上 | 独立对象,可被多表共用 |
| 取值时机 | 插入时自动生成 | 显式调用取值函数,取完可先存着 |
| 事务回滚 | 已消耗的号不回退 | 同样不回退 |
| 预取批量 | 一般不支持 | 支持一次取一段,减少争用 |
| 跨表统一编号 | 不方便 | 天然支持 |
| 重设起点 | 通常要重建或改属性 | 直接调整下一个值 |
选型逻辑很简单:单表主键用它自己的自增列;需要多表共用一套编号、或者要"先拿号再慢慢插"的场景,用序列对象。举个例子,订单系统和退款系统共用一个流水号池,那就必须用序列,否则两边号段会撞。而单表的自增主键,用列属性最省事,不用额外管理对象。
批量插入时还有个细节值得提:序列对象通常支持"每次预取一批号"来减少锁争用,代价是预取的号一旦没用完就作废了。这就是空洞的来源之一,后面细说。
5.3 取号为什么会有空洞,怎么面对
新手第一次看到编号不连续,第一反应都是"数据库出 bug 了"。不是,空洞是正常现象,来源至少有下面几种。
| 空洞来源 | 具体机制 | 要不要处理 |
|---|---|---|
| 事务回滚 | 号已取出,插入失败回滚,号不回收 | 不处理,正常 |
| 唯一键冲突 | 插入报错,号已消耗 | 不处理,正常 |
| 批量预分配 | 一次取 100 个号,只用了 60 个 | 调小预取量 |
| 删除数据 | 行被删,号自然空出 | 不处理,除非业务要求连续 |
| 实例重启 | 部分数据库以内存缓存号段,重启丢失 | 不处理 |
我的态度很明确:编号保证唯一,不保证连续。业务上如果真需要"连续编号",比如给客户看的纸质单据号,那就不该用自增列,而应该另建一张号段表,在事务里加锁取号并同时记下"已用",用额外的表承担"连续性"这个职责。这个方案会带来锁竞争,并发高的时候就是瓶颈,所以一定要先问清楚业务方:到底是要"唯一凭证号"还是"连续凭证号",这两个需求成本差一个数量级。
还有一个容易忽略的点:不要用编号列的大小去推断业务顺序。多条并发插入时,先拿到号的事务可能后提交,最终写入顺序和编号顺序不一致。如果业务上"按写入时间排序"很重要,就单独加一个时间列,别指望编号列。
提示:给客户展示的编号建议做一次"业务编码 + 日期 + 补零序号"的拼装,把连续性诉求转移到拼装逻辑里,别硬抠自增列。
5.4 递归CTE造数字序列与日期序列
还有一种"生成序号"是完全不同的需求:不是给已有行编号,而是凭空造出一个 1 到 N 的序列,用于补全缺失日期、生成辅助表、做笛卡尔展开。这种场景最趁手的工具是递归 CTE。
-- 生成 1 到 100 的数字序列 WITH RECURSIVE nums AS ( SELECT 1 AS n UNION ALL SELECT n + 1 FROM nums WHERE n < 100 ) SELECT n FROM nums;这段代码的骨架值得记住:锚点成员给起点,递归成员给递推规则,终止条件写在递归成员的WHERE里。少了终止条件就会无限递归,轻则报错,重则把临时空间撑爆。不同数据库的递归上限设置有差异,深递归之前先确认参数,别一上来就生成一百万行。
把数字序列转成日期序列,就是补全报表空缺日期的标准做法:
-- 生成最近 30 天的日期,左连接业务数据补零 WITH RECURSIVE days AS ( SELECT CURRENT_DATE - INTERVAL '29' DAY AS d UNION ALL SELECT d + INTERVAL '1' DAY FROM days WHERE d < CURRENT_DATE ) SELECT days.d AS stat_date, COALESCE(SUM(o.amount), 0) AS total_amount FROM days LEFT JOIN orders o ON DATE(o.created_at) = days.d GROUP BY days.d ORDER BY days.d;这个模式我在做日报、做趋势图接口时反复用。它解决的核心问题是"没有数据的那天在结果集里直接消失",导致前端折线图断点。补零之后曲线连续,视觉效果立刻正常。很多团队把这活儿放到应用层用循环拼,其实一条 SQL 就够,而且不用来回查库。
生成数字序列还有一个高频用途:给明细行编号做行转列的辅助索引。比如要把一行里的多个属性拆成多行,或者要给每行生成 3 个占位,数字表就是最方便的驱动源。
-- 数字表驱动,把每个订单展开成 3 行占位 WITH RECURSIVE nums AS ( SELECT 1 AS n UNION ALL SELECT n + 1 FROM nums WHERE n < 3 ) SELECT o.id, nums.n AS slot_no FROM orders o CROSS JOIN nums;6. 常见问题与排查实录
序号类问题有个共同特点:不报错,只出错。查询能跑,结果看着也像那么回事,等到业务方拿着报表来对账才发现数字对不上。所以排查的时候不能靠"有没有报错",得靠"结果稳不稳定"。
6.1 序号不连续,先分清是语义空洞还是写法错误
看到序号断档,第一步不是改代码,而是判断这是正常的语义空洞还是写法导致的错乱。判断方法很简单:连续跑三次同样的查询,看断档位置是否一致。
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 断档位置每次跑都一样 | 数据被删过,或取号时回滚过 | 查数据本身,不用改 SQL |
| 断档位置每次跑都不一样 | 排序不稳定,缺唯一排序键 | 检查ORDER BY是否唯一 |
| 序号从非 1 开始 | 变量未初始化,或过滤条件在外层 | 检查变量初始化语句位置 |
| 序号重复 | 用了RANK而非ROW_NUMBER | 确认函数选择 |
| 序号跳号且跳得很规律 | 批量预分配号段未用完 | 检查序列的预取设置 |
| 前后两页出现同一条数据 | 排序不唯一,分页边界漂移 | 排序末尾补唯一列 |
这张表我贴在团队文档里,新人遇到问题自己对一遍,能省掉一大半沟通成本。其中最容易被误判的是第二种和第六种,本质都是排序不稳定,但表现完全不同:一个是"看着断档",一个是"看着重复"。根因都是ORDER BY的字段不唯一,优化器每次给的顺序可能不同。
6.2 翻页重复与漏行,九成是排序不唯一
有个接口上线后被投诉"列表里同一条数据出现两次",我查了半天,最后发现是排序键用了状态字段。状态只有三四个值,几十万条数据按它排序,顺序完全随机,每次查询行号分配都不一样,翻页时自然就重复了。
修复方案就一行:在ORDER BY末尾追加主键。
-- 修复前:状态值重复严重,行号随机 ROW_NUMBER() OVER (ORDER BY status DESC) -- 修复后:状态相同再按唯一列定序 ROW_NUMBER() OVER (ORDER BY status DESC, id DESC)这个改动看起来很不起眼,但它是分页正确性的基石。我现在的习惯是:凡是带ORDER BY的地方,只要涉及分页或者行号,末尾必须有一个唯一列。哪怕当前数据不会重复,将来加个联合唯一索引、加个软删除标记都可能引入重复。
还有一种更隐蔽的情况:伪造了一个"看起来唯一"的排序键,比如按字符串时间排。字符串时间格式不统一时(有的带毫秒有的不带),排序结果会跟你想的完全不一样。这种问题在导入数据之后才暴露,排查成本很高,建议一开始就把时间字段类型统一掉。
6.3 性能排查,先看排序再看索引
窗口函数慢,八成慢在排序。ROW_NUMBER() OVER (PARTITION BY a ORDER BY b)这种结构,理想情况是有一个(a, b)的复合索引,让数据库直接按索引顺序读,省掉排序步骤。没有这个索引,数据库就得把所有数据捞出来做一次外部排序,数据量一大就往磁盘上溢写。
看执行计划时重点盯两个词:排序和临时表。看到排序操作而且估算行数很大,基本可以断定是索引没配好。补上复合索引之后再看一次,如果排序消失了,说明顺序读生效了。
分区字段的选择也有讲究。我见过有人按一个高基数字段分区,比如按订单号分区,结果每个分区只有一行,窗口函数等于没分区,白白多了一层开销。分区的意义是"分区内行数少、排序对象小",基数太高的字段分不出批次,基数太低的(比如状态)又会导致单分区巨大。合适的粒度一般是"每个分区几十到几千行"这个量级。
还有一个容易忽略的点:窗口函数是在WHERE之后执行的,如果过滤条件能把数据量砍掉大半,那先过滤再编号是自然的;但如果你需要"先编号再过滤",比如取全局第 100 到 110 名,那就无法提前减少排序量。这时候要么接受成本,要么改成游标式分页,从排序值的边界往后取。
7. 选型速查与几条实操心得
四种方式摆在一起,其实决策路径很短:要持久化的号走引擎,要查询期的号走窗口函数,老环境兜底用变量,特殊口径用子查询。我把这张决策表放在最后,方便你直接对着选。
7.1 一张决策表
| 你的需求 | 首选方案 | 备选方案 | 一定不要用 |
|---|---|---|---|
| 分页显示行号 | 窗口函数 | 子查询(小数据量) | 会话变量 |
| 榜单排名(有并列) | RANK/DENSE_RANK | 子查询定制 | ROW_NUMBER |
| 每组取最新一条 | 窗口函数 + 分区 | 子查询 | 变量递推 |
| 业务主键 / 流水号 | 序列对象 / 标识列 | 号段表(要连续时) | 任何查询期编号 |
| 补全缺失日期 | 递归 CTE | 数字辅助表 | 应用层循环查库 |
| 老版本无窗口函数 | 变量递推 + 落临时表 | 相关子查询 | 依赖派生表内排序 |
| 连续区间分组 | 变量递推 | 窗口函数做差值分组 | 单纯ROW_NUMBER |
注意最后一行,连续区间分组这个需求,窗口函数也能做,思路是"用行号和日期做差,差值相同即为连续",但对刚上手的人来说不如变量写法直观。我一般会给出窗口函数版本,因为它在数据量大的时候更稳。
7.2 我踩过的几个坑
第一个坑是把行号当成了业务字段。有个报表导出功能,前端把行号存进了缓存,下次打开直接复用,结果数据更新之后行号全错位。教训是:行号只在展示层用,绝不参与任何状态存储。
第二个坑是在视图里写窗口函数,然后拿视图做多层嵌套。视图层层嵌套之后,窗口函数的执行位置会变得很难预测,某些优化器会把外层过滤条件下推,导致行号在过滤前就算完了,结果完全不对。我的做法是:带窗口函数的逻辑尽量只包一层,超过两层就拆成临时表或者物化视图。
第三个坑是递归 CTE 忘了写终止条件。当时是想生成一个日期序列,公式写反了,条件是"小于起始日",结果递归永远不终止,直接把会话撑到超时。后来我养成了习惯:递归 CTE 写完之后,先手动把上限改成 10 跑一遍,确认能正常结束,再放开真实上限。
最后一个不算坑但值得说的经验:能不写序号就不写。很多需求表面要"第几行",实际要的是"取前 N 条"或者"每组取一条",这时候直接写过滤条件比算行号再过滤效率高得多。我在评审时会先问一句"这个序号是给人看的还是给程序用的",如果是给程序用的,八成有更直接的写法。
真要说还有什么小技巧,就是给窗口函数的排序键建一张检查清单:唯一性、NULL 位置、类型一致性、是否与索引顺序匹配。这四项过一遍,序号类的问题基本就绝迹了。我现在写任何带OVER的语句,都会下意识看一眼ORDER BY里有没有那个兜底的唯一列,这个动作已经成本能了。