第 13 章 一对一关系的 ORM 实现
从一个生活场景说起
一对多是"一个人有多套房",一对一则是"一个人只有一张身份证,一张身份证只属于一个人"。
先看一个反例来理解"为什么要拆表"。假设用户表里有 50 个字段,其中 5 个是超大字段:高清头像的二进制数据、个人简介长文等。每次查询用户列表(哪怕只想显示昵称),MySQL 都得把这些"大块头"一起读出来——就像每次查快递单号都要把整个包裹搬出来称一遍。
解决办法:把"大块头"单独放一张表,主表只留一句"详情在隔壁那张表"。两张表通过唯一的外键一对一挂钩。这就是一对一拆表的典型动机:大字段分离。
核心概念
一对一 = 一对多的"限量版"
实现方式和第 11 章几乎一样(外键 + relationship),只差两道"限流闸门":
- ORM 层面:
uselist=False——告诉 SQLAlchemy"这个关联不是列表,是单个对象",user.idcard直接拿到 IDCard 对象而不是[IDCard]; - 数据库层面:外键列加
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 章对照着看,差异就两处:
| 一对多 | 一对一 | |
|---|---|---|
| "一"方的 relationship | Mapped[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; - 访问结果是单个对象而非列表;
- 拆表三理由:大字段、权限隔离、冷热分离;没理由就别拆。