配置版本计数器

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 参数为此方案提供了一个钩子,该参数接受版本生成可调用对象。此可调用对象传递当前已知版本的值,并期望返回后续版本。

例如,如果我们想使用随机生成的 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 也可以配置为依赖于数据库生成的值。在这种情况下,数据库需要某种方法来在行受到 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 计数器的新值。

ORM 通常不会在发出 INSERT 或 UPDATE 时主动获取数据库生成的值,而是将这些列保留为“过期”,并在下次访问时获取它们,除非设置了 eager_defaults Mapper 标志。但是,当使用服务器端版本列时,ORM 需要主动获取新生成的值。这是为了在任何并发事务再次更新版本计数器之前设置版本计数器。这种获取最好也使用 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 Database、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()