录成绩是一个事务写一批:中间断了要整批回滚,否则老师不知道哪几个录了。 重复录入不是错误,是「要覆盖吗」——它需要提示加确认,不是报错。 改单个成绩要留痕:谁改的、什么时候、从多少改到多少。没有日志,家长一问就答不上来。
这一节是整章业务味最浓的。
前面几节都在处理「一条记录」,这一节第一次处理「一批记录」——
而批量操作的所有麻烦,都在「中途失败」上。
教材 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 秒实验
- 把第②步的
BEGIN/COMMIT去掉,再跑第③步,看条数变成多少。 会多出几条——那就是「录了一半」。 - 把覆盖的
UPDATE条件里的term去掉,看会不会改到别的学期。 会——而且不报错。 - 想一想:如果要求「改成绩必须填原因」,这个原因字段该放哪? 放日志表,不放成绩表。 成绩表存「现在是多少」,日志表存「怎么变成这样的」。
常见误区
- 「逐条插入就行,反正就几十条」。 中途失败会留一半,而重录会撞唯一键。 必须包事务。
- 「录过了就报错,让老师自己删」。 这是正常业务状态。 要给「覆盖吗」的选择。
- 「成绩改了不用记,反正能查」。 家长问「为什么和上次不一样」时就答不上来。 日志表三列就够。
自测三问(点开对答案)
1. 批量录入为什么必须一个事务? —— **因为「录了一半」比「一条没录」更难处理**——**老师不知道边界在哪,重录还会撞唯一键。** 2. 为什么选「直接插让约束拦」而不是「先查再插」? —— **先查再插在并发下会漏**(两个人同时查都显示没录过),**约束是原子的,不会漏。** 3. 覆盖为什么用 `UPDATE` 不用「删了重插」? —— **删了重插一旦插失败,数据就没了。** **`UPDATE` 最坏是「没改成」。**下一篇 20.11,多条件查询: 四个条件十六种组合,靠两个列表一起长来解决——还有一个「看着能跑其实会炸」的拼接错法。
- ☐ (本篇完,下一篇 20.11 基本信息数据查询模块设计)