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

录成绩是一个事务写一批:中间断了要整批回滚,否则老师不知道哪几个录了。 重复录入不是错误,是「要覆盖吗」——它需要提示加确认,不是报错。 改单个成绩要留痕:谁改的、什么时候、从多少改到多少。没有日志,家长一问就答不上来。

📝 本章和前面几章的关系

这一节是整章业务味最浓的。 前面几节都在处理「一条记录」,这一节第一次处理「一批记录」—— 而批量操作的所有麻烦,都在「中途失败」上。 教材 20.10 分三小节:概述、技术分析、实现过程。 「实现过程」里那句「如果该科已录过就提示是否覆盖」是全节的题眼。 沙箱没有 JSP,页面用表格输出复刻; 批量插入用 executemany 真跑,事务用 BEGIN/COMMIT 真开—— 行为跟 JDBC 的 setAutoCommit(false) 一致。

一、录入前先把名单拉出来

条件 为什么必须加
class_id = ? 只录这个班的
state = '在读' 转走的不该出现在录入表里
ORDER BY sno 顺序稳定,跟纸质名单对得上

名单必须带 state='在读'。 否则转走的学生也会出现在录入表里—— 老师会给他录分,录完发现这个人在名单里查不到。 顺序也要稳定。 老师是照着纸质名单录的——页面顺序如果每次都变,他会录串行。 这类问题不会报错,只会「分数录到别人头上」。

二、一次录一个班:一个事务一批

做法 中途失败会怎样
逐条 INSERT 各自提交 前几条进了库,后面没进,不知道边界
一个事务批量 INSERT 整批回滚,一条都没进

为什么这一批必须是一个事务? 因为「录了一半」比「一条没录」更难处理—— 老师不知道哪几个录了、哪几个没录,只能全都重录一遍,而重录会撞 UNIQUE。 「一条没录」是干净的状态:重来一次就行。 「录了一半」是不干净的状态:要先查清楚,才能决定怎么补。 事务的价值就是把「不干净」变成「干净」。

三、重复录入:不是错误,是「要覆盖吗」

实现路线 并发下会不会漏
先查「录过没」,没录过再插 会——两个人同时查,都显示「没录过」
直接插,让 UNIQUE 拦 不会——约束是原子的

选第二条。 先查再插在并发下会漏,而约束永远不会漏。 拦下来之后再查一次「已经有哪些」,提示就准确了。 这就是「能靠约束保证的,别靠代码检查」的又一次应用。 第 19 章入职校验工号用的是同一招。

四、覆盖:用 UPDATE 而不是删了重插

做法 最坏情况
删了重插 删完插失败,数据没了
逐条 UPDATE 一条没改成,旧数据还在

「删了重插」的风险是「删完插失败,数据没了」。 UPDATE 不会,最坏情况是「一条没改成」。 凡是「覆盖旧数据」,优先 UPDATE;只有「整批替换」才考虑先删后插,而且必须在一个事务里。 这条原则有个好听的名字叫「幂等」——同一批操作执行两次,结果和执行一次一样。 UPDATE 天然幂等,DELETE + INSERT 不是。

五、改成绩要留痕

做法 好不好
直接 UPDATE tb_score SET score=? 简单,但「谁改的、改前多少」查不到
加一张 tb_score_log 多写一行,但能翻账

成绩改错是家常便饭,而且家长会问「为什么和上次看的不一样」。 没有日志,这个问题永远答不上来。 日志表最少三列:谁改的、什么时候、从多少改到多少。 写日志和改成绩必须在一个事务里——否则会出现「改了但没记」或者「记了但没改」。

六、动手 Demo:批量录入、重复拦截、覆盖更新

这一段把「录一个班的物理成绩」走完整条路:先拉名单、批量插入、再录一次被拦、最后走覆盖。 重点是第二次录入:整批回滚,条数一条没变。

# ① 名单:只含在读,顺序稳定
sql = '''SELECT id, sno, sname FROM tb_student
          WHERE class_id = ? AND state = '在读'
          ORDER BY sno'''

# ② 批量录入:一个事务
batch = [(i, '物理', TERM, 60.0 + (i * 7) % 40) for i in range(1, 13)]
c.execute('BEGIN')
c.executemany('INSERT INTO tb_score'
              ' (stu_id, subject, term, score) VALUES (?,?,?,?)', batch)
c.execute('COMMIT')

# ③ 再录一次:UNIQUE 拦住,整批回滚
try:
    c.execute('BEGIN')
    c.executemany(SAME_SQL, batch)
    c.execute('COMMIT')
except Exception as e:
    c.execute('ROLLBACK')
    print('整批回滚:', e)

# ④ 覆盖:走 UPDATE
c.execute('BEGIN')
c.executemany('UPDATE tb_score SET score = ?'
              ' WHERE stu_id = ? AND subject = ? AND term = ?', newvals)
c.execute('COMMIT')

实跑输出:

--------------------------------------------------------------------
成绩录入与修改:一次一个班
--------------------------------------------------------------------


  ① 录入前先把名单拉出来
  --------------------------------------------------------------------


  1> SELECT s.id, s.sno, s.sname
     FROM tb_student s
     WHERE s.class_id = 1 AND s.state = '在读'
     ORDER BY s.sno
  id  sno       sname
  --------------------
  1   20260101  张一鸣
  2   20260102  李思彤
  3   20260103  王浩然
  4   20260104  赵雨欣
  (4 行受影响)
  名单必须带 state='在读'。
  否则转走的学生也会出现在录入表里——老师会给他录分,录完发现这个人在名单里查不到。

  ② 一次录一个班:一个事务 40 行
  --------------------------------------------------------------------

  录之前,物理成绩条数:0
  一个事务插 12 条 → 物理成绩条数:12
  为什么这一批必须是一个事务?
  因为「录了一半」比「一条没录」更难处理——老师不知道哪几个录了、哪几个没录,只能全都重录一遍,
  而重录会撞 UNIQUE。

  ③ 重复录入:不是错误,是「要覆盖吗」
  --------------------------------------------------------------------

  再录一次同一批 → 整批回滚:
  UNIQUE constraint failed: tb_score.stu_id, tb_score.subject, tb_score.term
  回滚后条数仍是:12
  注意「整批回滚」这四个字。
  如果没包事务,前几条会成功、后面撞唯一键失败——结果是一半新一半旧,谁也说不清哪些是新的。
  正确的交互是:撞了唯一键之后,查一次「这批里哪些已经有了」,弹出「有 12 条已存在,是否覆盖」,
  用户确认后走 UPDATE。这就是「业务状态」和「错误」的区别——它不该只给一个报错。

  ④ 覆盖:用 UPDATE 而不是删了重插
  --------------------------------------------------------------------


  1> SELECT sno, sname, subject, score
     FROM v_score
     WHERE subject = '物理' AND cname = '2026级计算机1班'
     ORDER BY score DESC
  sno       sname   subject  score
  --------------------------------
  20260104  赵雨欣  物理     90
  20260103  王浩然  物理     85
  20260102  李思彤  物理     80
  20260101  张一鸣  物理     75
  (4 行受影响)
  「删了重插」的风险是「删完插失败,数据没了」。UPDATE 不会,最坏情况是「一条没改成」。
  凡是「覆盖旧数据」,优先 UPDATE;只有「整批替换」才考虑先删后插,而且必须在一个事务里。

  ⑤ 改单个成绩:要留痕
  --------------------------------------------------------------------
| 做法 | 好不好 |
|---|---|
| 直接 UPDATE tb_score SET score=? | 简单,但「谁改的、改前多少」查不到 |
| 加一张 tb_score_log | 多写一行,但能翻账 |

  成绩改错是家常便饭,而且家长会问「为什么和上次看的不一样」。没有日志,这个问题永远答不上来。
  日志表最少三列:谁改的、什么时候、从多少改到多少。
  写日志和改成绩必须在一个事务里——否则会出现「改了但没记」或者「记了但没改」。

第③步的回滚值得盯三秒: 条数一条没变。 如果没包事务,前几条会成功、第某条撞唯一键失败——结果是一半新一半旧,谁也说不清哪些是新的。 第④步的 UPDATE 条件是三个:谁、哪科、哪学期。 少一个就会更新到别人的成绩上——而且不会报错,因为「更新了 12 行」看起来是对的。 「整批回滚」这个词在 JDBC 里叫 rollback(), 在 SQL 里叫 ROLLBACK,在 Python 的 sqlite3 里也是 ROLLBACK—— 名字一样,行为一样,只有 API 不同。

30 秒实验

  1. 把第②步的 BEGIN/COMMIT 去掉,再跑第③步,看条数变成多少。 会多出几条——那就是「录了一半」。
  2. 把覆盖的 UPDATE 条件里的 term 去掉,看会不会改到别的学期。 会——而且不报错。
  3. 想一想:如果要求「改成绩必须填原因」,这个原因字段该放哪? 放日志表,不放成绩表。 成绩表存「现在是多少」,日志表存「怎么变成这样的」。

常见误区

  1. 「逐条插入就行,反正就几十条」。 中途失败会留一半,而重录会撞唯一键。 必须包事务。
  2. 「录过了就报错,让老师自己删」。 这是正常业务状态。 要给「覆盖吗」的选择。
  3. 「成绩改了不用记,反正能查」。 家长问「为什么和上次不一样」时就答不上来。 日志表三列就够。
自测三问(点开对答案) 1. 批量录入为什么必须一个事务? —— **因为「录了一半」比「一条没录」更难处理**——**老师不知道边界在哪,重录还会撞唯一键。** 2. 为什么选「直接插让约束拦」而不是「先查再插」? —— **先查再插在并发下会漏**(两个人同时查都显示没录过),**约束是原子的,不会漏。** 3. 覆盖为什么用 `UPDATE` 不用「删了重插」? —— **删了重插一旦插失败,数据就没了。** **`UPDATE` 最坏是「没改成」。**

下一篇 20.11,多条件查询: 四个条件十六种组合,靠两个列表一起长来解决——还有一个「看着能跑其实会炸」的拼接错法。

相关笔记