1. 场景介绍

假设我们正在为一个在线教育平台构建后台,需要分页展示所有课程,并且每展示一门课程,都要同时列出选修了该课程的所有学生。这是一个典型的多对多关系。


2. 表结构与测试数据

我们先创建三张表并插入一些示例数据。

表 1: courses (课程表)

存储课程的基本信息。

sql
 
CREATE TABLE courses (
  course_id INT PRIMARY KEY,
  title VARCHAR(100) NOT NULL,
  create_time TIMESTAMP NOT NULL
);

-- 插入测试数据
INSERT INTO courses (course_id, title, create_time) VALUES
(101, '基础数学', '2023-01-01 10:00:00'),
(102, '大学英语', '2023-01-02 11:00:00'), -- 这是一门热门课程,有很多学生选修
(103, '高等物理', '2023-01-03 09:00:00'),
(104, '编程入门', '2023-01-04 14:00:00');

表 2: students (学生表)

存储学生的基本信息。

sql
 
CREATE TABLE students (
  student_id INT PRIMARY KEY,
  name VARCHAR(100) NOT NULL
);

-- 插入测试数据
INSERT INTO students (student_id, name) VALUES
(1, '小明'),
(2, '小红'),
(3, '小刚'),
(4, '小丽'),
(5, '小强');

表 3: student_courses (学生选课表)

这是关联表,表示学生和课程之间的多对多关系。

sql
 
CREATE TABLE student_courses (
  student_id INT,
  course_id INT,
  PRIMARY KEY (student_id, course_id),
  FOREIGN KEY (student_id) REFERENCES students(student_id),
  FOREIGN KEY (course_id) REFERENCES courses(course_id)
);

-- 插入测试数据:模拟选课情况
-- ‘大学英语’ (102) 有3个学生选修,其他课程1-2个
INSERT INTO student_courses (student_id, course_id) VALUES
(1, 101), -- 小明选修基础数学
(2, 102), -- 小红选修大学英语
(3, 102), -- 小刚选修大学英语
(4, 102), -- 小丽选修大学英语(热门课程)
(1, 103), -- 小明选修高等物理
(5, 103), -- 小强选修高等物理
(4, 104); -- 小丽选修编程入门

数据关系可视化:

课程 '大学英语'(102)
    ↑
    |--- 被学生『小红』选修
    |--- 被学生『小刚』选修
    |--- 被学生『小丽』选修

课程 '高等物理'(103)
    ↑
    |--- 被学生『小明』选修
    |--- 被学生『小强』选修

3. 问题重现:直接分页为什么不行?

如果我们想按课程创建时间降序排列,每页显示2门课程,第一页的简单查询是:

 
SELECT c.course_id, c.title, s.name AS student_name
FROM courses c
LEFT JOIN student_courses sc ON c.course_id = sc.course_id
LEFT JOIN students s ON sc.student_id = s.student_id
ORDER BY c.create_time DESC
LIMIT 2 OFFSET 0;

查询结果会出乎意料:

course_idtitlestudent_name
104 编程入门 小丽
103 高等物理 小明 <-- 第一页结束了!?
103 高等物理 小强

问题分析:
数据库严格按照 LIMIT 2 的要求,只返回了前2行数据。但这2行数据只包含了一门半课程(一门完整的“编程入门”和半门“高等物理”,缺少了学生“小强”)。“大学英语”这门课根本没能出现在第一页。这完全不符合我们“按课程分页”的业务需求。


4. 解决方案:使用窗口函数进行去重分页

现在,我们来详解你提供的优化SQL,它如何一步步解决这个问题。

SQL 代码分解讲解

-- 第一步:使用公共表表达式(CTE)为课程排序编号
WITH unique_courses AS (
  SELECT *,
         ROW_NUMBER() OVER (ORDER BY create_time DESC) as rn
  FROM courses
),

-- 第二步:根据上一步的编号进行分页,精准取出第1页的2门课程
paginated_courses AS (
  SELECT * FROM unique_courses WHERE rn BETWEEN 1 AND 2
)

-- 第三步:将分页得到的课程与学生关联,获取这些课程的完整信息
SELECT 
  c.course_id, 
  c.title, 
  c.create_time,
  s.name AS student_name
FROM paginated_courses c
LEFT JOIN student_courses sc ON c.course_id = sc.course_id
LEFT JOIN students s ON sc.student_id = s.student_id
ORDER BY c.create_time DESC, c.course_id, s.student_id; -- 保持排序正确

第一部分:unique_courses CTE

SELECT *,
       ROW_NUMBER() OVER (ORDER BY create_time DESC) as rn
FROM courses
  • ROW_NUMBER() OVER (ORDER BY create_time DESC): 这是一个窗口函数。它不会改变结果集的行数,而是为每一行计算一个序号。

  • ORDER BY create_time DESC: 在窗口函数内,它指定了编号的规则——按课程创建时间从新到旧排序。

  • as rn: 将计算出的序号命名为 rn(row number的缩写)。

  • 执行结果:这个CTE会生成一个临时结果,为每一门课程分配一个唯一的、连续的序号。

course_idtitlecreate_timern
104 编程入门 2023-01-04 14:00:00 1
103 高等物理 2023-01-03 09:00:00 2
102 大学英语 2023-01-02 11:00:00 3
101 基础数学 2023-01-01 10:00:00 4

第二部分:paginated_courses CTE

SELECT * FROM unique_courses WHERE rn BETWEEN 1 AND 2
  • 这一步非常简单:从 unique_courses 中筛选出序号为 1 和 2 的行。

  • 这就是分页的核心:我们是在课程主体上做分页,而不是在最终连接后的结果集上分页。我们精准地取出了第1页的2门课程:编程入门 和 高等物理

第三部分:主查询

SELECT ...
FROM paginated_courses c
LEFT JOIN student_courses sc ON c.course_id = sc.course_id
LEFT JOIN students s ON sc.student_id = s.student_id
ORDER BY ...
  • 现在,我们用这2门课程 (c) 去左连接 student_courses (sc) 和 students (s) 表。

  • 因为“编程入门”有1个学生,“高等物理”有2个学生,所以最终连接后会产生 1 + 2 = 3 行数据。

  • 最终结果:

course_idtitlecreate_timestudent_name
104 编程入门 2023-01-04 14:00:00 小丽
103 高等物理 2023-01-03 09:00:00 小明
103 高等物理 2023-01-03 09:00:00 小强

成功! 这就是我们想要的结果:

  1. 分页正确:第一页确实只包含了2门课程(104和103)。

  2. 数据完整:每门课程的所有学生信息都完整地展示了出来,没有丢失。

  3. 性能尚可:虽然“大学英语”有3个学生,但因为它不在这一页,所以不需要进行连接计算,节省了资源。数据库只需要为2门课连接对应的学生。

5. 总结

这种方法的核心智慧在于:将“分页”和“连接”这两个操作解耦。

  1. 先分页(基于主体):在主体表(courses)上利用窗口函数进行排序和分页计算,精准地确定要显示哪些课程。

  2. 后连接(获取明细):将分页得到的主体数据(2门课)再去关联其他表,获取这些主体对应的所有明细数据(学生)。

这样就完美规避了在多对多关系中直接使用 LIMIT 导致的数据截断和丢失问题。虽然最终返回的行数可能多于每页设定的数量,但这是业务逻辑的正确体现——以课程为主体进行分页。

 posted on 2025-08-23 14:34  xibuhaohao  阅读(2)  评论(0)    收藏  举报