多人同时改同一份数据,一致性靠两道防线:独占控制(锁=一次只准一人)与事务(要么全成、要么全不算)——但锁上得不对,两个互等就是死锁。
上游:数据库课 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 退出)。锁是药也是毒,剂量与顺序都要设计。
常见误区
- 「加了锁就万事大吉」——锁范围太大吞吐暴跌、范围太小防不住、顺序不一致死锁;锁是设计出来的不是贴上去的。
- 「事务只对数据库有意义」——消息队列、文件批处理、任何「多步必须同生共死」的场景都需要事务思想(5-9 里 Saga 补偿就是分布式版)。
- 「死锁是数据库的事与我无关」——线程池、协程、分布式任务调度里死锁天天见;统一加锁顺序是每个程序员都要刻进肌肉记忆的守则。
自测三问(点击展开)
1. **丢失更新是怎么发生的?** 答:两方同时读、后写的覆盖先写的(读与写之间被插队)。 2. **事务的原子性解决什么问题?** 答:多步操作中途失败时的「半成品」——commit 全生效或 rollback 全作废。 3. **死锁四条件?最实用的破解法?** 答:互斥、持有并等待、不可抢占、循环等待;实用解=统一加锁顺序打破循环等待。- ☑ 6-11 完成 —— 锁与事务双防线+死锁互等局
- ☐ 下一节 6-12 租赁服务器、VPS
相关笔记
- 数据库 4-14 · 事务 —— 库内版的完整展开
- 循环 —— Demo 里被打断的三步循环
- 数据库 4-17 · 死锁 —— 银行转账的死锁例
- ⌨️ 编程原理 · 索引 —— 全课路线图