Skip to content

第 13 章 一对一关系的 ORM 实现 ​

从一个生活场景说起 ​

一对多是"一个人有多套房",一对一则是"一个人只有一张身份证,一张身份证只属于一个人"。

先看一个反例来理解"为什么要拆表"。假设用户表里有 50 个字段,其中 5 个是超大字段:高清头像的二进制数据、个人简介长文等。每次查询用户列表(哪怕只想显示昵称),MySQL 都得把这些"大块头"一起读出来——就像每次查快递单号都要把整个包裹搬出来称一遍。

解决办法:把"大块头"单独放一张表,主表只留一句"详情在隔壁那张表"。两张表通过唯一的外键一对一挂钩。这就是一对一拆表的典型动机:大字段分离。

核心概念 ​

一对一 = 一对多的"限量版" ​

实现方式和第 11 章几乎一样(外键 + relationship),只差两道"限流闸门":

  1. ORM 层面:uselist=False——告诉 SQLAlchemy"这个关联不是列表,是单个对象",user.idcard 直接拿到 IDCard 对象而不是 [IDCard];
  2. 数据库层面:外键列加 unique=True——MySQL 保证同一个 user_id 在 idcard 表里最多出现一次。

两道闸门缺一不可:只设 uselist=False 而不加唯一约束,相当于嘴上宣称"一对一",数据库却放任一个用户挂多张卡——迟早出事故。

模型定义(逐行讲解) ​

python
from sqlalchemy import create_engine, ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

class Base(DeclarativeBase): ...

class User(Base):
    __tablename__ = "user"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50))

    # 一对一:uselist=False 表示不是列表,而是单个对象
    idcard: Mapped["IDCard"] = relationship(back_populates="user", uselist=False)

class IDCard(Base):
    __tablename__ = "idcard"
    id: Mapped[int] = mapped_column(primary_key=True)
    card_no: Mapped[str] = mapped_column(String(18), unique=True)
    # unique=True:数据库层面保证一个用户只能有一张卡
    user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), unique=True)

    user: Mapped[User] = relationship(back_populates="idcard")

engine = create_engine(
    "mysql+pymysql://root:123456@localhost:3306/demo_db?charset=utf8mb4",
    echo=True,
)
Base.metadata.create_all(engine)

与第 11 章对照着看,差异就两处:

一对多一对一
"一"方的 relationshipMapped[list[X]](列表)Mapped[X] + uselist=False(单个)
外键列约束普通外键外键 + unique=True

使用示例 ​

python
from sqlalchemy.orm import Session

with Session(engine) as session:
    user = User(name="张三")
    user.idcard = IDCard(card_no="110101199001011234")   # 像赋值属性一样建立关联
    session.add(user)                                    # 级联保存,IDCard 一并插入
    session.commit()

    print(user.idcard.card_no)   # 直接访问关联对象(不是列表!不需要 [0])
    print(user.idcard.user.name) # 反向访问:张三

对比一对多的写法 user.articles[0],一对一的 user.idcard 直接就是对象,用起来更自然。

关键知识点 ​

概念说明
uselist=False关联返回单个对象而不是列表
unique=True(外键上)数据库层面保证一对一

深入理解:唯一约束在 MySQL 里长什么样? ​

unique=True 会让 MySQL 为 user_id 列创建唯一索引:

sql
CREATE TABLE idcard (
    id INTEGER NOT NULL AUTO_INCREMENT,
    card_no VARCHAR(18),
    user_id INTEGER,
    PRIMARY KEY (id),
    UNIQUE (card_no),
    UNIQUE (user_id),
    FOREIGN KEY(user_id) REFERENCES user (id)
)

有了唯一索引,往 idcard 表插入第二个 user_id=1 的行时,MySQL 会直接报 Duplicate entry 错误——从数据库层面把死了"一人多卡"的可能。

进阶内容 ​

1. 什么时候值得拆一对一? 三个正当理由:

  • 大字段分离:头像、附件、长文本放副表,主表查询轻装上阵;
  • 权限隔离:敏感信息(身份证、银行卡)单独一张表,用不同的数据库账号/加密策略保护;
  • 冷热分离:高频访问的字段放主表,几乎不读的归档字段放副表。

2. 不该拆的时候别拆。 如果只是"字段有点多"就拆表,代价是每次都要 JOIN 或两次查询,代码变复杂、性能反而下降。字段直接放主表是默认选项,拆表需要理由。

3. 一对一也可以设级联。 删除用户时连带删除身份证:

python
idcard: Mapped["IDCard"] = relationship(
    back_populates="user", uselist=False, cascade="all, delete-orphan"
)

注意事项 ​

  • 只在 ORM 里写 uselist=False 不够,外键列必须加 unique=True,否则 MySQL 不阻止一个用户挂多张卡。
  • 一对一拆表的场景:大字段分离(如用户头像二进制数据)、权限隔离、冷热数据分离。
  • 如果不是必须拆表,字段直接放主表更简单——一对一拆表是一种需要理由的设计。
  • 一对一同样遵守懒加载规则:Session 关闭后访问 user.idcard 会报 DetachedInstanceError(见第 11 章)。

本章小结 ​

  • 一对一 = 一对多 + 两道闸门:ORM 层 uselist=False,数据库层外键 unique=True;
  • 访问结果是单个对象而非列表;
  • 拆表三理由:大字段、权限隔离、冷热分离;没理由就别拆。