← 中文资源

简体中文版本

丢失更新

丢失更新发生在两个事务读取同一个值并都写回时,其中一个修改被静默覆盖。

阅读英文原文

工作原理

事务 A 读取 balance = 100,事务 B 也读取 100。A 写入 110 并提交,B 写入 120 并提交。两笔 +10 入账后的正确余额是 120,结果恰好是 120,但那是因为两笔金额相同。若分别是 +10 和 +5,A 写入 110,B 写入 105,最终 105 完全丢失了 A 的入账。

异常的关键是读写序列,而不是行锁持有时长。如果读取发生在写锁之外,两个事务都可能基于同一个旧值写入。数据库的检测方式不同:PostgreSQL 可重复读会在第二次更新时抛出序列化错误,而某些引擎在某些隔离级别下允许覆盖。

预防

使用原子语句:UPDATE accounts SET balance = balance + 10 WHERE id = 7。数据库会串行化增量。或者使用乐观并发:增加 version 列,执行 WHERE id = 7 AND version = 3 的更新,若影响零行则重试。悲观锁 SELECT ... FOR UPDATE 可行,但会在应用逻辑期间持锁并可能死锁。当多行构成一个不变量时,可串行化隔离加应用重试是正确的。

何时选择

简单算术和计数器使用原子 SQL。人工编辑记录的表单使用乐观并发,让冲突显示给用户而不是静默重试。高争用的短临界区使用悲观锁。跨服务边界时,除非操作幂等且带版本,否则避免读改写。

权衡与示例

原子更新简单快速,但无法表达任意应用逻辑。乐观并发能检测冲突,却需要重试交互,在高争用下可能反复失败。悲观锁防止冲突,但降低吞吐并带来死锁风险。

示例:两名客服同时打开同一工单,各自通过替换整个标签列表来添加标签。第二次保存覆盖了第一名客服的标签。把标签存入独立表并加唯一约束,或给工单加版本号,可以保留两次编辑。整份保存 JSON 文档也有同样的问题。

代码示例

from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any


class TransactionAborted(RuntimeError):
    pass


@dataclass
class Store:
    data: dict[str, Any] = field(default_factory=dict)
    commits: int = 0
    rollbacks: int = 0

    @contextmanager
    def transaction(self) -> Iterator["Transaction"]:
        snapshot = dict(self.data)
        tx = Transaction(self)
        try:
            yield tx
        except BaseException:
            self.data = snapshot
            self.rollbacks += 1
            raise
        else:
            self.commits += 1


@dataclass
class Transaction:
    store: Store

    def set(self, key: str, value: Any) -> None:
        self.store.data[key] = value

    def delete(self, key: str) -> None:
        self.store.data.pop(key, None)


def main() -> None:
    store = Store(data={"balance": 100})
    with store.transaction() as tx:
        tx.set("balance", 80)
        tx.set("currency", "USD")
    print(store.data, store.commits, store.rollbacks)

    try:
        with store.transaction() as tx:
            tx.set("balance", 0)
            raise TransactionAborted("payment declined")
    except TransactionAborted as error:
        print("rolled back:", error)
    print(store.data, store.commits, store.rollbacks)


if __name__ == "__main__":
    main()
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session

engine = create_engine(
    'postgresql+psycopg://user:pass@localhost/reporting',
    isolation_level='READ COMMITTED',
    pool_size=10,
    max_overflow=5,
    pool_pre_ping=True,
)

reporting_engine = engine.execution_options(
    isolation_level='REPEATABLE READ'
)


def transfer_funds(session: Session, src: int, dst: int, amount: int) -> None:
    session.execute(
        text('UPDATE accounts SET balance = balance - :amt WHERE id = :id'),
        {'amt': amount, 'id': src},
    )
    row = session.execute(
        text('SELECT balance FROM accounts WHERE id = :id'),
        {'id': src},
    ).scalar_one()
    if row < 0:
        raise ValueError('overdraft rejected')
    session.execute(
        text('UPDATE accounts SET balance = balance + :amt WHERE id = :id'),
        {'amt': amount, 'id': dst},
    )


with Session(engine) as session:
    with session.begin():
        transfer_funds(session, src=1, dst=2, amount=75)

with Session(reporting_engine) as session:
    snapshot = session.execute(
        text('SELECT sum(balance) FROM accounts')
    ).scalar_one()
    print('consistent snapshot total:', snapshot)