🪟 第20章 Java与SQL Server实现学生成绩管理系统
📄 一句话概括

分页必须带稳定排序:只按性别排,翻到第二页可能看到第一页看过的人。 批量导入要么全成要么全不成——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 秒实验

  1. 把分页的 ORDER BY sno 换成 ORDER BY sex,打印三页学号,看有没有重复。 会重复——这就是「不稳定排序」。
  2. 把批量导入的 BEGIN/COMMIT 去掉,重跑,看耗时差多少。
  3. 想一想:如果导入到第 200 条撞了唯一键,没包事务会留下什么? 前 199 条进了库,后 201 条没进,而且重跑还是停在第 200 条。

常见误区

  1. 「排序随便写一个字段就行」。 不唯一就不稳定,翻页会重复。 排序键里必须有唯一键。
  2. 「批量插入慢是数据库不行」。 是每次提交都在刷盘。 包一个事务就快几十倍。
  3. 「DELETE 比 UPDATE 干净」。 在成绩系统里根本删不掉(外键拦),而且删了历史就断了。
自测三问(点开对答案) 1. 分页为什么必须用唯一键排序? —— **否则同值记录之间顺序不确定**,**翻页会看到重复或漏掉。** 2. 批量导入为什么要包事务? —— **一是快(少刷盘),二是「要么全成要么全不成」**—— **否则半途失败会留下一半数据,而且重跑还停在同一处。** 3. 软删除之后要注意什么? —— **所有列表都要加状态条件。** **漏一处就会在某个页面看到已转走的人。** **建个「只含在读」的视图能减少漏。**

下一篇 20.10,成绩录入: 一次录一个班、重复录入要走「覆盖吗」、改成绩要留痕——这一节是整章业务味最浓的。

相关笔记