开始前,会这些就够了
- 认识 SELECT 与 UPDATE;能运行一个 Python 文件
假设仓库原有十件文具,两个工作人员都先读到十。甲准备扣三件,算出剩七;乙准备扣五件,算出剩五。如果两人都直接保存自己算好的数,后保存的五就会盖过七,甲的那次扣减没有体现在最终数字里。
数据库可以忠实执行两条保存命令,程序也可以两次都收到成功,但业务结果仍不对。这叫丢失更新:后来的修改基于旧信息,把先前修改的效果覆盖了。
保存前,加一句“版本还一样吗”
除了库存,再保存一个 version 版本号。读取时把两者一起拿走;写回时,要求数据库里的版本仍等于刚才读到的版本,并在这次成功修改中把版本加一。检查与修改放在同一条 UPDATE 里。
这种办法常叫乐观锁:读完后不一直占着锁等人做决定,而是在保存时检查这份依据是否已经过时。这个名字不表示数据库内部完全不用锁,也不保证所有人最后都能保存成功。
先留住两份旧读值,再依次保存
下面建一个只在内存里的 SQLite 数据库,先读两次,得到两份相同的旧值。第一轮不检查版本,第二轮增加版本条件;每轮都从库存十、版本零重新开始。
程序没有启动两个线程,而是按固定顺序重放“都读完,再先后保存”的过程。isolation_level=None 让这里的语句各自完成,没有把整个读取、思考、保存过程放进同一个长事务,因此不要把结果理解为 SQLite 的事务锁失效。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import sqlite3
from contextlib import closing
def new_database():
db = sqlite3.connect(":memory:", isolation_level=None)
db.execute("CREATE TABLE item (id INTEGER PRIMARY KEY, "
"stock INTEGER NOT NULL CHECK (stock >= 0), "
"version INTEGER NOT NULL)")
db.execute("INSERT INTO item VALUES (1, 10, 0)")
return db
def read_item(db):
return db.execute(
"SELECT stock, version FROM item WHERE id = 1"
).fetchone()
def save(db, old, amount, check_version):
old_stock, old_version = old
sql = "UPDATE item SET stock = ?, version = version + 1 WHERE id = 1"
parameters = [old_stock - amount]
if check_version:
sql += " AND version = ?"
parameters.append(old_version)
return db.execute(sql, parameters).rowcount
def main():
for check_version in (False, True):
with closing(new_database()) as db:
first, second = read_item(db), read_item(db)
print("检查版本" if check_version else "不检查版本")
print("两份旧读值:", first, second)
print("A 影响行数:", save(db, first, 3, check_version))
print("B 影响行数:", save(db, second, 5, check_version))
print("最终(库存, 版本):", read_item(db))
if __name__ == "__main__":
main()
怎样运行:Python 3.10+ 标准库;保存为 optimistic_update.py,运行 python optimistic_update.py。
运行后对照结果
不检查版本
两份旧读值: (10, 0) (10, 0)
A 影响行数: 1
B 影响行数: 1
最终(库存, 版本): (5, 2)
检查版本
两份旧读值: (10, 0) (10, 0)
A 影响行数: 1
B 影响行数: 0
最终(库存, 版本): (7, 1)
影响零行,是需要处理的结果
不检查版本时,甲把库存改成七,乙再用旧读值算出的五覆盖它,两条 UPDATE 都影响一行。检查版本时,甲成功后版本已经变成一;乙仍带着零来保存,条件不再满足,所以影响零行,库存保持七。
rowcount 是这次 UPDATE 影响的行数,不是“数据库没报异常就算成功”的装饰信息。不过零行本身不能独立证明一定是版本冲突:记录被删除时,也可能一行都匹配不到,需要重新读取并分辨。
重新读一遍,再由业务决定下一步
乙遇到冲突后,不能只换个新版本号,把已经算好的五原样提交。应该先读取当前库存,重新判断这次请求还是否成立、能否重新计算,或是否需要让人确认。反复发送同一份旧依据,也不会让它自动变新。
所有会修改这条记录的路径都要遵守版本规则,否则绕过检查的写入仍可能覆盖别人。这一行的版本检查也不能代替权限、库存限制或跨表事务;本例保留库存不能小于零的数据库约束,但没有实现完整下单业务。
记住这三点
- 两次 SQL 都执行成功,不代表两次业务修改都被保留下来。
- 版本条件与实际修改要放进同一条更新语句,并检查影响行数。
- 冲突之后先重读、再判断,不能把旧计算结果盲目写回。
别把例子用过头
- 乐观锁并不表示数据库完全不使用锁;这里演示的是应用版本检查。
- 影响零行还可能因为记录不存在,不能只凭这个数字认定原因。
- 这是顺序执行的内存实验,没有测量真实多连接并发、锁等待或生产性能。
自己试一小步
如果两份旧读值都已拿到,但记录在保存前被删除,页面应该怎样区分“别人改过”和“已经不存在”?
- 只在本地内存实验中加入一个删除时刻,先写下你预计的影响行数和重读结果。
- 给更新失败后的分支加一次读取,分别记录记录存在与不存在时能够得到的证据。
- 再设计一个需要人工确认的修改场景,写清为什么不能自动换版本后继续提交。
做到这些,就算完成
- 没有只看有无异常就向用户报告保存成功。
- 能够说明判断来自哪次读取,以及判断之后是否仍可能发生新变化。
- 没有把数据库练习结果当成完整库存或订单系统的保证。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- SQLite:UPDATE (新标签页)↗
Details:WHERE 条件决定哪些行被修改,没有行满足时影响零行并非 SQL 错误。
- Python 3.10:sqlite3 (新标签页)↗
Cursor.rowcount、isolation_level与事务控制:影响行数、内存连接及本例自动提交的使用方式。 - SQLite:Isolation In SQLite (新标签页)↗
Isolation Between Database Connections:SQLite 会对写入加锁并串行,需区分事务隔离与跨语句保存在应用中的旧读值。
找一份资料继续练
先选一项,按下面的范围继续学;不必从头读完整门课程。
MySQL 事务小实验:提交与撤销一组修改 (新标签页)↗
只读自动提交、COMMIT 和 ROLLBACK,对照本篇标出两次读取和两次更新各属于哪次事务;先画时间线再改 SQL。
查看学习准备与访问说明 →Deadlock Empire:亲手决定两个线程谁先走 (新标签页)↗
从入门引导开始,读懂示例的 C# 写法,只跟踪两个线程和一个共享变量,手写正常与出错的交错顺序。
查看学习准备与访问说明 →