分页必须带稳定排序:只按性别排,翻到第二页可能看到第一页看过的人。 批量导入要么全成要么全不成——400 行逐条提交和一个事务,差一到两个数量级。 软删除之后所有列表都要加状态条件,漏一处就会在某个页面看到已经转走的人。
班级管「容器」,学生管「内容」。
这一节的数据量比班级大一个量级——
一个年级几百人,所以分页和批量导入第一次成了必须做的事。
教材 20.9 分三小节:概述、技术分析、实现过程。
「技术分析」里讲分页和批量,两件事都不难,但都有「不写就出事」的点。
沙箱没有 JSP,页面用表格输出复刻;
分页 SQL 用 LIMIT/OFFSET,用到 OFFSET ... FETCH 时逐处标出 SQL Server 的写法。
一、分页:必须带稳定排序
| 排序写法 | 稳定吗 | 后果 |
|---|---|---|
ORDER BY sex |
不稳 | 同性别人之间顺序随机,翻页会重复 |
ORDER BY sno |
稳 | 学号唯一,顺序确定 |
ORDER BY class_id, sno |
稳 | 按班排,班内按学号 |
只按 sex 排序的话,同性别的人之间的顺序不确定——翻到第 2 页可能看到第 1 页已经看过的人。
这不是数据库的 bug,是「没定义顺序」的必然结果。
稳定的排序 = 唯一键(学号、id),或者「业务字段 + 唯一键」组合。
这条在「按成绩排名」时更明显。 两个同分的人,每次刷新顺序都可能不同——
用户会以为「排名乱跳」。 加一个 sno 做第二排序键就解决了。
二、批量导入:包一个事务
| 写法 | 400 行的耗时 | 原因 |
|---|---|---|
逐条 INSERT 各自提交 |
几秒 | 每条一次刷盘 |
| 一个事务批量插 | 几十毫秒 | 只在提交时刷一次盘 |
这不是「优化」,是「能不能用」的区别。 一个年级几百人,逐条提交要等十几秒——用户会以为程序卡死了。 更要紧的是「半途失败」。 逐条提交时第 200 条撞了唯一键, 前 199 条已经在库里了,后 201 条没进去——重跑一遍又会在第 200 条停下,永远补不齐。 包了事务就是「要么全成,要么全不成」,用户重跑一次就干净了。 这也是第 15 章讲的那句话:事务不只是性能,是「可重试」。
三、软删除:转出不删人
| 写法 | 后果 |
|---|---|
DELETE FROM tb_student |
成绩跟着变孤儿(外键会拦) |
UPDATE ... SET state='转出' |
成绩还在,历史可查 |
外键会拦住 DELETE,所以「硬删」在成绩系统里根本走不通——
有成绩的学生删不掉。 这不是限制,是保护。
软删除之后,所有列表都要加 WHERE state='在读'。
漏一处就会在「班级名单」里看到已经转走的人——这类漏法很难查,因为只有一两个页面看得见。
减少漏的办法:建一个 v_student_in 视图(只含在读),常规列表都用它。
要查全部时才用原表——让「默认正确」变成默认路径。
四、改和删都要看影响行数
| 情况 | rowcount |
该提示什么 |
|---|---|---|
| 改一条存在的记录 | 1 | 保存成功 |
| 改一条不存在的记录 | 0 | 记录不存在,可能已被删除 |
| 改一条但值没变 | 0 或 1(看数据库) | 不要说「一定失败」 |
rowcount == 0 是最便宜的自检。
一句话就能把「保存成功但没保存」变成「记录不存在,可能已被别人删除」。
但第三行要记住: 「新值等于旧值」时,有些数据库返回 1(认为更新了),有些返回 0。
所以这条判断只能当「可能没成功」的提示,不能当「一定失败」。
五、动手 Demo:分页、批量导入、软删除
这一段跑三件事:分页查询(带稳定排序)、批量导入 400 行的两种写法对比、软删除之后列表怎么变。 第二节那个耗时对比是这一节的重点——倍数会浮动,但结论稳定。
# ① 分页:稳定排序 + OFFSET
sql = '''SELECT sno, sname, sex, birthday FROM tb_student
ORDER BY sno LIMIT ? OFFSET ?''' # SQL Server 用 OFFSET/FETCH
for page in range(3):
rows = c.execute(sql, (5, page * 5)).fetchall()
print('第 %d 页' % (page + 1), [r[0] for r in rows])
# ② 批量导入:逐条 vs 一个事务
def one_by_one(cc):
for r in recs:
cc.execute('INSERT INTO tb_student'
' (sno, sname, sex, class_id) VALUES (?,?,?,?)', r)
def batched(cc):
cc.execute('BEGIN')
cc.executemany('INSERT INTO tb_student'
' (sno, sname, sex, class_id) VALUES (?,?,?,?)', recs)
cc.execute('COMMIT')
# ③ 软删除
c.execute("UPDATE tb_student SET state='转出' WHERE sno='20250102'")
c.execute("SELECT sno, sname FROM tb_student WHERE state='在读'")
实跑输出:
--------------------------------------------------------------------
学生基本信息管理:分页、批量、软删
--------------------------------------------------------------------
① 列表要分页,而且必须带排序
--------------------------------------------------------------------
1> SELECT sno, sname, sex, birthday FROM tb_student ORDER BY sno LIMIT 5 OFFSET 0
sno sname sex birthday
---------------------------------
20250101 孙志强 男 2007-08-08
20250102 马晓雯 女 2007-12-19
20260101 张一鸣 男 2008-03-12
20260102 李思彤 女 2008-07-25
20260103 王浩然 男 2008-01-09
(5 行受影响)
! LIMIT 5 OFFSET 0 是 SQLite/MySQL 的写法;
SQL Server 写成 ORDER BY sno OFFSET 0 ROWS FETCH NEXT 5 ROWS ONLY。
分页一定要有 ORDER BY,而且要是「稳定的排序」。
只按 sex 排序的话,同性别的人之间的顺序不确定——翻到第 2 页可能看到第 1 页已经看过的人。
这不是数据库的 bug,是「没定义顺序」的必然结果。
稳定的排序 = 唯一键(学号、id),或者「业务字段 + 唯一键」组合。
② 批量导入:400 行逐条 vs 一个事务
--------------------------------------------------------------------
400 行逐条提交: 4823.3 ms
400 行一个事务: 93.3 ms
倍数:51.7 倍
第 15 章同样 400 行量到 178 倍,这次 49.8 倍。
倍数会随行宽、表大小、磁盘状态浮动,但结论不变:差一到两个数量级。
原因也一样:逐条提交每条都刷一次盘,一个事务只在最后刷一次。
导入学生名单是真实场景——一个年级几百人,逐条提交要等十几秒,用户会以为程序卡死。
而这条路只要改一行:把循环包进一个事务。
③ 软删除:转出不删人
--------------------------------------------------------------------
| 写法 | 后果 |
|---|---|
| DELETE FROM tb_student | 成绩跟着变成孤儿(外键会拦) |
| UPDATE ... SET state='转出' | 成绩还在,历史可查 |
1> UPDATE tb_student SET state = '转出' WHERE sno = '20250102'
(1 行受影响)
1> SELECT sno, sname, state FROM tb_student WHERE state <> '在读'
sno sname state
-----------------------
20250102 马晓雯 转出
(1 行受影响)
外键会拦住 DELETE,所以「硬删」在成绩系统里根本走不通——有成绩的学生删不掉。 这不是限制,是保护。
软删除之后,所有列表都要加 WHERE state='在读'。
漏一处就会在「班级名单」里看到已经转走的人——这类漏法很难查,因为只有一两个页面看得见。
减少漏的办法:建一个 v_student_in 视图(只含在读),常规列表都用它。
要查全部时才用原表——让「默认正确」变成默认路径。
④ 修改和删除的影响行数要检查
--------------------------------------------------------------------
改一个存在的学生 → 影响 1 行
改一个不存在的学生 → 影响 0 行 → 该提示「记录不存在」
rowcount == 0 是最便宜的自检。
一句话就能把「保存成功但没保存」变成「记录不存在,可能已被别人删除」。
注意还有一种 rowcount == 0 的情况:值没变。
SQL 在「新值等于旧值」时,有些数据库返回 1(认为更新了),有些返回 0。
所以这条判断只能当「可能没成功」的提示,不能当「一定失败」。
分页那一段打印了三页的学号,没有任何重复——因为排序键是 sno,唯一且稳定。
如果把 ORDER BY sno 换成 ORDER BY sex,三页里就会出现重复的学号。
批量那一段的倍数每次跑都不一样(磁盘缓存、文件大小都会影响)。
要看的不是具体倍数,是「差一到两个数量级」这个量级差。
软删除那一段要注意:改成「转出」之后,这条记录还在表里。
SELECT COUNT(*) FROM tb_student 还是 12,只有加了状态条件才是 11。
30 秒实验
- 把分页的
ORDER BY sno换成ORDER BY sex,打印三页学号,看有没有重复。 会重复——这就是「不稳定排序」。 - 把批量导入的
BEGIN/COMMIT去掉,重跑,看耗时差多少。 - 想一想:如果导入到第 200 条撞了唯一键,没包事务会留下什么? 前 199 条进了库,后 201 条没进,而且重跑还是停在第 200 条。
常见误区
- 「排序随便写一个字段就行」。 不唯一就不稳定,翻页会重复。 排序键里必须有唯一键。
- 「批量插入慢是数据库不行」。 是每次提交都在刷盘。 包一个事务就快几十倍。
- 「
DELETE比UPDATE干净」。 在成绩系统里根本删不掉(外键拦),而且删了历史就断了。
自测三问(点开对答案)
1. 分页为什么必须用唯一键排序? —— **否则同值记录之间顺序不确定**,**翻页会看到重复或漏掉。** 2. 批量导入为什么要包事务? —— **一是快(少刷盘),二是「要么全成要么全不成」**—— **否则半途失败会留下一半数据,而且重跑还停在同一处。** 3. 软删除之后要注意什么? —— **所有列表都要加状态条件。** **漏一处就会在某个页面看到已转走的人。** **建个「只含在读」的视图能减少漏。**下一篇 20.10,成绩录入: 一次录一个班、重复录入要走「覆盖吗」、改成绩要留痕——这一节是整章业务味最浓的。
- ☐ (本篇完,下一篇 20.10 考试成绩信息管理模块设计)