qinghuan
← 回到知识探索

后端现场 / 动手实验

两人都改成功,为什么一人的更新不见了?

给保存动作加上“仍是我刚才读到的版本”这一条件,让旧值覆盖变成可以发现的冲突。

展开本文目录
  1. 保存前,加一句“版本还一样吗”
  2. 先留住两份旧读值,再依次保存
  3. 影响零行,是需要处理的结果
  4. 重新读一遍,再由业务决定下一步
  5. 记住这三点
  6. 别把例子用过头
  7. 自己试一小步
  8. 查原始资料
  9. 找一份资料继续练
开始前,会这些就够了
  • 认识 SELECT 与 UPDATE;能运行一个 Python 文件
遇到不熟悉的词,先看这 1 个解释
乐观锁的版本检查
读取数据时记下版本;保存时要求版本仍相同,并在同一次更新中增加版本。条件不满足时,旧结果不能直接盖上去。
打开完整术语小抄 ↗

假设仓库原有十件文具,两个工作人员都先读到十。甲准备扣三件,算出剩七;乙准备扣五件,算出剩五。如果两人都直接保存自己算好的数,后保存的五就会盖过七,甲的那次扣减没有体现在最终数字里。

数据库可以忠实执行两条保存命令,程序也可以两次都收到成功,但业务结果仍不对。这叫丢失更新:后来的修改基于旧信息,把先前修改的效果覆盖了。

保存前,加一句“版本还一样吗”

除了库存,再保存一个 version 版本号。读取时把两者一起拿走;写回时,要求数据库里的版本仍等于刚才读到的版本,并在这次成功修改中把版本加一。检查与修改放在同一条 UPDATE 里。

这种办法常叫乐观锁:读完后不一直占着锁等人做决定,而是在保存时检查这份依据是否已经过时。这个名字不表示数据库内部完全不用锁,也不保证所有人最后都能保存成功。

先留住两份旧读值,再依次保存

下面建一个只在内存里的 SQLite 数据库,先读两次,得到两份相同的旧值。第一轮不检查版本,第二轮增加版本条件;每轮都从库存十、版本零重新开始。

程序没有启动两个线程,而是按固定顺序重放“都读完,再先后保存”的过程。isolation_level=None 让这里的语句各自完成,没有把整个读取、思考、保存过程放进同一个长事务,因此不要把结果理解为 SQLite 的事务锁失效。

展开收起代码 · 小实验python · 42 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
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 都执行成功,不代表两次业务修改都被保留下来。
  • 版本条件与实际修改要放进同一条更新语句,并检查影响行数。
  • 冲突之后先重读、再判断,不能把旧计算结果盲目写回。

别把例子用过头

  • 乐观锁并不表示数据库完全不使用锁;这里演示的是应用版本检查。
  • 影响零行还可能因为记录不存在,不能只凭这个数字认定原因。
  • 这是顺序执行的内存实验,没有测量真实多连接并发、锁等待或生产性能。

自己试一小步

如果两份旧读值都已拿到,但记录在保存前被删除,页面应该怎样区分“别人改过”和“已经不存在”?

  1. 只在本地内存实验中加入一个删除时刻,先写下你预计的影响行数和重读结果。
  2. 给更新失败后的分支加一次读取,分别记录记录存在与不存在时能够得到的证据。
  3. 再设计一个需要人工确认的修改场景,写清为什么不能自动换版本后继续提交。

做到这些,就算完成

  • 没有只看有无异常就向用户报告保存成功。
  • 能够说明判断来自哪次读取,以及判断之后是否仍可能发生新变化。
  • 没有把数据库练习结果当成完整库存或订单系统的保证。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • SQLite:UPDATE (新标签页)↗SQLite · 英文

    Details:WHERE 条件决定哪些行被修改,没有行满足时影响零行并非 SQL 错误。

    已读 Overview 与 Details;本文版本条件是原创教学示例,不是照搬文档中的库存聚合例子。

  • Python 3.10:sqlite3 (新标签页)↗Python Software Foundation · 英文

    Cursor.rowcount、isolation_level 与事务控制:影响行数、内存连接及本例自动提交的使用方式。

    已读对应 API 和事务控制说明;代码实测 Python 3.14.0、SQLite 3.50.4,没有声称测试全部支持版本。

  • SQLite:Isolation In SQLite (新标签页)↗SQLite · 英文

    Isolation Between Database Connections:SQLite 会对写入加锁并串行,需区分事务隔离与跨语句保存在应用中的旧读值。

    已读连接间隔离与写入串行说明;本实验没有运行 WAL 并发测试。

找一份资料继续练

先选一项,按下面的范围继续学;不必从头读完整门课程。