配置版本计数器

Mapper 支持管理 版本 ID 列,该列是单个表列,每次对映射表执行 UPDATE 操作时,该列的值都会递增或以其他方式更新。每次 ORM 发出 UPDATEDELETE 操作到该行时,都会检查此值,以确保内存中保存的值与数据库值匹配。

警告

由于版本控制功能依赖于对内存中对象的记录进行比较,因此该功能仅适用于Session.flush() 过程,在此过程中,ORM 将单个内存行刷新到数据库。它不会在使用 Query.update()Query.delete() 方法执行多行 UPDATE 或 DELETE 操作时生效,因为这些方法只发出 UPDATE 或 DELETE 语句,但不会直接访问被影响的行的内容。

此功能的目的是检测何时两个并发事务在几乎相同的时间修改同一行,或者作为对在未刷新情况下可能重新使用来自先前事务的数据的系统中使用“陈旧”行的保护措施(例如,如果在 Session 上设置了 expire_on_commit=False,则有可能重新使用来自先前事务的数据)。

简单版本计数

跟踪版本最直接的方法是在映射表中添加一个整型列,然后在映射器选项中将其设置为 version_id_col

class User(Base):
    __tablename__ = "user"

    id = mapped_column(Integer, primary_key=True)
    version_id = mapped_column(Integer, nullable=False)
    name = mapped_column(String(50), nullable=False)

    __mapper_args__ = {"version_id_col": version_id}

注意

强烈建议version_id 列设置为 NOT NULL。版本控制功能不支持版本控制列中的 NULL 值。

在上面,User 映射使用列 version_id 跟踪整型版本。当类型为 User 的对象首次刷新时,version_id 列将被赋予“1”的值。然后,对表的后续 UPDATE 将始终以类似于以下方式发出

UPDATE user SET version_id=:version_id, name=:name
WHERE user.id = :user_id AND user.version_id = :user_version_id
-- {"name": "new name", "version_id": 2, "user_id": 1, "user_version_id": 1}

上面的 UPDATE 语句正在更新不仅匹配 user.id = 1 而且还要求 user.version_id = 1 的行,其中“1”是我们已知在此对象上使用的最后一个版本标识符。如果其他事务独立修改了该行,则此版本 ID 将不再匹配,并且 UPDATE 语句将报告没有匹配的行;这是 SQLAlchemy 测试的条件,即我们的 UPDATE(或 DELETE)语句恰好匹配了一行。如果匹配的行数为零,则表示我们的数据版本已过时,并且会引发 StaleDataError

自定义版本计数器/类型

其他类型的计数器或值可用于版本控制。常见类型包括日期和 GUID。当使用备用类型或计数器方案时,SQLAlchemy 提供了一个使用 version_id_generator 参数的挂钩,该参数接受版本生成可调用对象。此可调用对象将接收当前已知版本的 value,并且预计将返回后续版本。

例如,如果我们想使用随机生成的 GUID 跟踪 User 类的版本控制,我们可以这样做(注意,某些后端支持本机 GUID 类型,但这里我们演示使用简单的字符串)

import uuid


class User(Base):
    __tablename__ = "user"

    id = mapped_column(Integer, primary_key=True)
    version_uuid = mapped_column(String(32), nullable=False)
    name = mapped_column(String(50), nullable=False)

    __mapper_args__ = {
        "version_id_col": version_uuid,
        "version_id_generator": lambda version: uuid.uuid4().hex,
    }

每次 User 对象受到 INSERT 或 UPDATE 操作的影响时,持久化引擎都会调用 uuid.uuid4()。在这种情况下,我们的版本生成函数可以忽略 version 的传入值,因为 uuid4() 函数在没有先决条件值的情况下生成标识符。如果我们使用的是顺序版本控制方案(如数字或特殊字符系统),则可以使用给定的 version 来帮助确定后续值。

服务器端版本计数器

version_id_generator 也可以被配置为依赖于由数据库生成的 value。在这种情况下,数据库需要某种方法来在行受到 INSERT 操作的影响时以及 UPDATE 操作时生成新的标识符。对于 UPDATE 情况,通常需要更新触发器,除非所讨论的数据库支持某种其他本机版本标识符。特别是 PostgreSQL 数据库支持名为 xmin 的系统列,该列提供 UPDATE 版本控制。我们可以使用 PostgreSQL xmin 列对我们的 User 类进行版本控制,如下所示

from sqlalchemy import FetchedValue


class User(Base):
    __tablename__ = "user"

    id = mapped_column(Integer, primary_key=True)
    name = mapped_column(String(50), nullable=False)
    xmin = mapped_column("xmin", String, system=True, server_default=FetchedValue())

    __mapper_args__ = {"version_id_col": xmin, "version_id_generator": False}

通过上述映射,ORM 将依赖于 xmin 列来自动提供版本 ID 计数器的新的 value。

ORM 通常不会在发出 INSERT 或 UPDATE 操作时积极获取数据库生成的值,而是将这些列保留为“已过期”,并在下次访问时获取,除非设置了 eager_defaults Mapper 标志。但是,当使用服务器端版本列时,ORM 需要积极获取新生成的 value。这是为了在任何并发事务可能再次更新它之前设置版本计数器。这种获取最好在使用 RETURNING 的 INSERT 或 UPDATE 语句中同时完成,否则,如果随后发出 SELECT 语句,仍然存在潜在的竞争条件,版本计数器可能在获取之前发生变化。

当目标数据库支持 RETURNING 时,对我们的 User 类的 INSERT 语句将如下所示

INSERT INTO "user" (name) VALUES (%(name)s) RETURNING "user".id, "user".xmin
-- {'name': 'ed'}

在上述情况下,ORM 可以通过一条语句获取所有新生成的 主键值 和服务器生成的版本标识符。当后端不支持 RETURNING 时,必须为**每个** INSERT 和 UPDATE 额外发出一个 SELECT 语句,这效率要低得多,而且还会导致版本计数器可能丢失。

INSERT INTO "user" (name) VALUES (%(name)s)
-- {'name': 'ed'}

SELECT "user".version_id AS user_version_id FROM "user" where
"user".id = :param_1
-- {"param_1": 1}

强烈建议只有在绝对必要时,并且只在支持 RETURNING 的后端(目前为 PostgreSQL、Oracle、MariaDB 10.5、SQLite 3.35 和 SQL Server)上使用服务器端版本计数器。

编程或条件版本计数器

version_id_generator 设置为 False 时,我们也可以通过编程方式(和条件方式)在我们的对象上设置版本标识符,就像我们为任何其他映射属性赋值一样。例如,如果我们使用 UUID 示例,但将 version_id_generator 设置为 False,我们可以根据自己的选择设置版本标识符。

import uuid


class User(Base):
    __tablename__ = "user"

    id = mapped_column(Integer, primary_key=True)
    version_uuid = mapped_column(String(32), nullable=False)
    name = mapped_column(String(50), nullable=False)

    __mapper_args__ = {"version_id_col": version_uuid, "version_id_generator": False}


u1 = User(name="u1", version_uuid=uuid.uuid4())

session.add(u1)

session.commit()

u1.name = "u2"
u1.version_uuid = uuid.uuid4()

session.commit()

我们也可以在不增加版本计数器的情况下更新我们的 User 对象;计数器的值将保持不变,并且 UPDATE 语句仍然会检查之前的值。这对于某些只对某些 UPDATE 类别敏感的并发问题方案可能很有用。

# will leave version_uuid unchanged
u1.name = "u3"
session.commit()