⌨️ 第六章 Web技术与安全
📄 本篇一句话

多人同时改同一份数据,一致性靠两道防线:独占控制(锁=一次只准一人)与事务(要么全成、要么全不算)——但锁上得不对,两个互等就是死锁。

📝 在本章的位置

上游:数据库课 4-14~4-17 讲过事务与死锁的库内版本;4-7 的进程、4-4 的函数调用栈是「执行单元」的地基。这是本章难度顶点:并发 bug 难复现、难调试,防线必须在写代码之前想好。

一、并发:幸福的烦恼

一个请求一个请求排队处理最安全,但网站要同时服务成百上千人——并发不可避免。麻烦随之而来:

时刻 T1:A 查账户余额 1000 元
时刻 T2:B 查账户余额 1000 元
时刻 T3:A 扣 100 → 写回 900
时刻 T4:B 扣 100 → 写回 900      ← 应该是 800!A 的扣款被覆盖

这就是丢失更新——两人同时买最后一张票、两人同时改同一行文档,同款事故。数据不一致的根源:读和写之间隔着别人的操作。

二、独占控制:锁

解法一:改之前上锁——锁住的人才能改,别人等待:

# 最小锁演示:threading.Lock 保护共享计数
import threading

count = 0
lock = threading.Lock()

def work():
    global count
    for _ in range(100000):
        count += 1          # ← 不加锁版本:读-加-写三步可能被打断

threads = [threading.Thread(target=work) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print("无锁结果(常常 < 400000):", count)   # 丢失更新现场

count = 0
def work_safe():
    global count
    for _ in range(100000):
        with lock:          # ← 上锁:一次只准一个线程进
            count += 1

threads = [threading.Thread(target=work_safe) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print("加锁结果(恒等于 400000):", count)

三、事务:要么全成、要么全不算

锁只解决「一次只准一人」,解决不了「多步操作中途失败」:

转账 = A 扣 100 + B 加 100,两步
若第一步成功、第二步断电 → 凭空消失 100 元

事务(Transaction)把多步捆成一个原子单位:ACID 里的 A(原子性)保证全成(commit)或全不算(rollback):

import sqlite3

con = sqlite3.connect(":memory:")
cur = con.cursor()
cur.execute("CREATE TABLE account (name TEXT, balance REAL, CHECK (balance >= 0))")
cur.execute("INSERT INTO account VALUES ('A', 1000), ('B', 500)")

try:
    cur.execute("UPDATE account SET balance = balance - 100 WHERE name = 'A'")
    cur.execute("UPDATE account SET balance = balance + 100 WHERE name = 'B'")
    cur.execute("UPDATE account SET balance = balance - 99999 WHERE name = 'A'")  # 触发 CHECK
    con.commit()
except sqlite3.IntegrityError as e:
    con.rollback()                              # 前两步一并作废
    print("回滚:", e)

print(cur.execute("SELECT * FROM account").fetchall())
# [('A', 1000.0), ('B', 500.0)] —— 钱一分没动,一致性保住

四、死锁:互等的僵局

锁用不好会造出僵局:

线程1:锁住 A → 想要锁 B
线程2:锁住 B → 想要锁 A     ← 两人互等,永远等下去

死锁四条件(缺一不可):互斥、持有并等待、不可抢占、循环等待——工程上的解法就是打破其中一条,最实用的是统一加锁顺序(所有人先 A 后 B,循环等不成立)。数据库课 4-17 的银行转账例、操作系统课的哲学家进餐,都是这同一道题。

动手 Demo:跑出丢失更新,再用锁治好它

上一节的两段计数器代码就是完整 Demo:第一段不加锁,多跑几次常看到 < 400000(丢更新被抓现行);第二段加锁恒等于 400000。

看到什么算成功:无锁版的数字每跑都不一样且偏小;加锁版每次都是精确的 400000。

读结果:count += 1 看着是一行,机器上是「读→加→写」三步——并发把三步切碎了。锁把三步重新焊成原子一步。所有并发 bug 的本质都一样:你以为的原子操作不是原子的。

30 秒实验

把加锁版里的 with lock: 删掉重跑 5 次,记下每次输出——忽大忽小、几乎到不了 400000。再把两个 work 都先 lock.acquire() 再 lock.acquire()(同一线程重复上非重入锁)——程序卡死不退出(自己给自己造了个死锁,Ctrl+C 退出)。锁是药也是毒,剂量与顺序都要设计。

常见误区

自测三问(点击展开) 1. **丢失更新是怎么发生的?** 答:两方同时读、后写的覆盖先写的(读与写之间被插队)。 2. **事务的原子性解决什么问题?** 答:多步操作中途失败时的「半成品」——commit 全生效或 rollback 全作废。 3. **死锁四条件?最实用的破解法?** 答:互斥、持有并等待、不可抢占、循环等待;实用解=统一加锁顺序打破循环等待。

相关笔记