多对多(Many-to-Many)关系是数据库设计中的经典场景,如学生选课、用户角色、商品标签等。本文通过一个有趣的案例(男生女生组建家庭),深入讲解 SQLAlchemy 中多对多关系的三种实现方式和最佳实践。

一、场景介绍

我们将构建一个关系模型:

  • 男生(Boy)女生(Girl):多对多关系
  • 家庭(Home):中间表,记录配对关系和家庭信息

业务规则:

  • 一个男生可以和多个女生组建家庭
  • 一个女生可以和多个男生组建家庭
  • 每个家庭有独立的属性(如家庭名称)

二、多对多关系的三种实现方式

2.1 方式一:显式中间表(推荐)

当中间表需要额外字段时,这是最佳选择。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from sqlalchemy import String, Integer, Column, ForeignKey, create_engine
from sqlalchemy.orm import relationship, declarative_base, create_session

Base = declarative_base()

class Home(Base):
"""家庭中间表"""
__tablename__ = 'home'
__mapper_args__ = {
'confirm_deleted_rows': False # 解决级联删除警告
}

id = Column(Integer, primary_key=True)
boy_id = Column(Integer, ForeignKey("boy.id"))
girl_id = Column(Integer, ForeignKey("girl.id"))
home_name = Column(String(10)) # 中间表的额外字段

# 与两端的关系
boy = relationship("Boy", back_populates="homes")
girl = relationship("Girl", back_populates="homes")

关键点:

  • 中间表有独立的主键 id
  • 包含两个外键指向两端
  • 可以添加额外的业务字段(如 home_name

2.2 方式二:通过中间表访问(双向导航)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Girl(Base):
__tablename__ = 'girl'
id = Column(Integer, primary_key=True)
name = Column(String(15), nullable=False)

# 方式1: 通过中间表访问
homes = relationship(
'Home',
back_populates='girl',
cascade="all, delete-orphan"
)

# 方式2: 直接访问对方(使用 secondary)
boys = relationship(
'Boy',
secondary='home', # 指定中间表
back_populates='girls',
overlaps='boy,girl,homes' # 避免冲突警告
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Boy(Base):
__tablename__ = 'boy'
id = Column(Integer, primary_key=True)
name = Column(String(15), nullable=False)

# 方式1: 通过中间表访问
homes = relationship(
'Home',
back_populates='boy',
cascade="all, delete-orphan"
)

# 方式2: 直接访问对方(使用 secondary)
boys = relationship(
'Girl',
secondary='home',
back_populates='boys',
overlaps='boy,girl,homes'
)

说明:

  • homes:访问中间表记录(可以获取额外字段)
  • boys/girls:直接访问对方对象(便捷方式)
  • secondary:指定中间表名称
  • overlaps:解决多个 relationship 重叠的警告

2.3 方式三:纯关联表(无额外字段)

如果中间表不需要额外字段,可以使用 Table 定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from sqlalchemy import Table

# 纯关联表
student_course = Table(
'student_course',
Base.metadata,
Column('student_id', Integer, ForeignKey('student.id')),
Column('course_id', Integer, ForeignKey('course.id'))
)

class Student(Base):
__tablename__ = 'student'
id = Column(Integer, primary_key=True)
name = Column(String)
courses = relationship('Course', secondary=student_course, back_populates='students')

class Course(Base):
__tablename__ = 'course'
id = Column(Integer, primary_key=True)
name = Column(String)
students = relationship('Student', secondary=student_course, back_populates='courses')

三、数据库初始化

1
2
3
4
5
6
7
8
9
# 创建数据库引擎
my_engine = create_engine("sqlite:///test.db", echo=True)

# 删除旧表并重新创建
Base.metadata.drop_all(my_engine)
Base.metadata.create_all(my_engine)

# 创建会话
db_session = create_session(bind=my_engine, autocommit=False, autoflush=True)

参数说明:

  • echo=True:输出 SQL 语句,便于调试
  • autocommit=False:手动控制事务
  • autoflush=True:查询前自动刷新

四、数据操作

4.1 添加数据方式一:正向添加(从 Girl 到 Boy)

1
2
3
4
5
6
# 创建女生,并通过 boys 关系直接关联男生
g = Girl(name='mei')
g.boys = [Boy(name='jason'), Boy(name='jack')]

db_session.add(g)
db_session.commit()

执行结果:

  • 创建 1 个女生记录
  • 创建 2 个男生记录
  • 自动创建 2 条 Home 中间表记录

优点:

  • 代码简洁
  • 自动处理关联
  • 适合批量创建

4.2 添加数据方式二:反向添加(从 Boy 到 Girl)

1
2
3
4
5
6
# 创建男生,并通过 girls 关系关联女生
b = Boy(name='jie')
b.girls = [Girl(name='ran'), Girl(name='meng')]

db_session.add(b)
db_session.commit()

说明:

  • 正向和反向添加效果相同
  • SQLAlchemy 自动维护双向关系

4.3 添加数据方式三:显式创建中间表记录

当需要设置中间表的额外字段时,必须显式创建中间表对象:

1
2
3
4
5
6
7
8
9
10
11
12
# 创建男生和女生
aaa = Boy(name='aaa')
bbb = Girl(name='bbb')

# 创建中间表记录并设置额外字段
abhome = Home()
abhome.boy = aaa
abhome.girl = bbb
abhome.home_name = 'sweet home' # 设置中间表的额外字段

db_session.add(abhome)
db_session.commit()

使用场景:

  • 需要设置中间表的额外属性
  • 需要更精细的控制
  • 处理复杂的业务逻辑

五、查询操作

5.1 正向查询:从 Girl 查询 Boy

1
2
3
4
5
6
7
8
9
10
11
12
13
# 查询女生
girl = db_session.query(Girl).filter(Girl.name == 'mei').first()

# 方式1: 通过 boys 直接访问男生
print(f"女生 {girl.name} 的男朋友们:")
for boy in girl.boys:
print(f" - {boy.name}")

# 方式2: 通过 homes 访问中间表(可获取额外信息)
print(f"女生 {girl.name} 的家庭信息:")
for home in girl.homes:
print(f" 家庭名称:{home.home_name}")
print(f" 男生:{home.boy.name}")

输出示例:

1
2
3
女生 mei 的男朋友们:
- jason
- jack

5.2 反向查询:从 Boy 查询 Girl

1
2
3
4
5
6
7
# 查询男生
boy = db_session.query(Boy).filter(Boy.name == 'jie').first()

# 直接访问女朋友
print(f"男生 {boy.name} 的女朋友们:")
for girl in boy.girls:
print(f" - {girl.name}")

5.3 查询中间表信息

1
2
3
4
5
6
7
# 查询所有家庭
homes = db_session.query(Home).all()

for home in homes:
print(f"家庭:{home.home_name}")
print(f" 男生:{home.boy.name}")
print(f" 女生:{home.girl.name}")

5.4 复杂查询:带条件的关联查询

1
2
3
4
5
6
7
8
9
10
11
12
from sqlalchemy import and_

# 查询特定男生和特定女生的家庭
home = db_session.query(Home).join(Boy).join(Girl).filter(
and_(
Boy.name == 'aaa',
Girl.name == 'bbb'
)
).first()

if home:
print(f"家庭名称:{home.home_name}")

5.5 统计查询

1
2
3
4
5
6
7
8
9
10
from sqlalchemy import func

# 统计每个女生有多少个男朋友
result = db_session.query(
Girl.name,
func.count(Home.id).label('boyfriend_count')
).join(Home).group_by(Girl.id).all()

for name, count in result:
print(f"{name}{count} 个男朋友")

六、级联删除

6.1 配置级联删除

1
2
3
4
5
6
class Girl(Base):
homes = relationship(
'Home',
back_populates='girl',
cascade="all, delete-orphan" # 级联删除配置
)

级联策略:

  • all:传播所有操作(save-update, merge, delete, etc.)
  • delete:删除父对象时删除子对象
  • delete-orphan:父对象解除关联时删除孤儿对象

6.2 执行删除操作

1
2
3
4
# 删除女生(会自动删除关联的 Home 记录)
g = db_session.query(Girl).filter(Girl.id == 1).first()
db_session.delete(g)
db_session.commit()

执行结果:

  • 删除 Girl 记录
  • 自动删除所有关联的 Home 记录
  • Boy 记录保留(因为 Boy 没有设置级联删除)

6.3 关闭级联删除警告

1
2
3
4
class Home(Base):
__mapper_args__ = {
'confirm_deleted_rows': False # 关闭删除确认
}

七、完整示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
from sqlalchemy import String, Integer, Column, ForeignKey, create_engine
from sqlalchemy.orm import relationship, declarative_base, create_session

Base = declarative_base()

# 中间表
class Home(Base):
__tablename__ = 'home'
__mapper_args__ = {'confirm_deleted_rows': False}
id = Column(Integer, primary_key=True)
boy_id = Column(Integer, ForeignKey("boy.id"))
girl_id = Column(Integer, ForeignKey("girl.id"))
home_name = Column(String(10))
boy = relationship("Boy", back_populates="homes")
girl = relationship("Girl", back_populates="homes")

# 女生表
class Girl(Base):
__tablename__ = 'girl'
id = Column(Integer, primary_key=True)
name = Column(String(15), nullable=False)
homes = relationship('Home', back_populates='girl', cascade="all, delete-orphan")
boys = relationship('Boy', secondary='home', back_populates='girls',
overlaps='boy,girl,homes')

# 男生表
class Boy(Base):
__tablename__ = 'boy'
id = Column(Integer, primary_key=True)
name = Column(String(15), nullable=False)
homes = relationship('Home', back_populates='boy', cascade="all, delete-orphan")
girls = relationship('Girl', secondary='home', back_populates='boys',
overlaps='boy,girl,homes')

# 数据库初始化
engine = create_engine("sqlite:///test.db", echo=True)
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)
session = create_session(bind=engine, autocommit=False, autoflush=True)

# 添加数据 - 方式1:正向
g = Girl(name='mei')
g.boys = [Boy(name='jason'), Boy(name='jack')]
session.add(g)
session.commit()

# 添加数据 - 方式2:反向
b = Boy(name='jie')
b.girls = [Girl(name='ran'), Girl(name='meng')]
session.add(b)
session.commit()

# 添加数据 - 方式3:显式中间表
aaa = Boy(name='aaa')
bbb = Girl(name='bbb')
abhome = Home(boy=aaa, girl=bbb, home_name='sweet home')
session.add(abhome)
session.commit()

# 查询
girl = session.query(Girl).filter(Girl.id == 1).first()
print(f"{girl.name} 的男朋友:{[b.name for b in girl.boys]}")

# 删除(级联)
session.delete(girl)
session.commit()

session.close()

八、三种方式对比

方式 适用场景 优点 缺点
显式中间表 中间表有额外字段 灵活、可扩展 代码稍复杂
secondary 参数 简单多对多 代码简洁 无法访问中间表字段
纯 Table 对象 纯关联表 最简洁 功能受限

九、最佳实践

9.1 何时使用显式中间表

推荐使用场景:

  • 中间表需要额外字段(如创建时间、状态等)
  • 需要对中间表进行独立查询
  • 关系本身有业务含义(如订单-商品关系)

不推荐场景:

  • 纯粹的关联关系,无额外信息
  • 简单的标签、分类场景

9.2 命名规范

1
2
3
4
5
6
7
8
9
# 推荐命名
class Girl(Base):
homes = relationship('Home', ...) # 中间表对象列表
boys = relationship('Boy', ...) # 关联对象列表

# 避免混淆
class Girl(Base):
home_list = relationship('Home', ...) # 明确是列表
boy_list = relationship('Boy', ...)

9.3 级联策略选择

1
2
3
4
5
6
7
8
# 场景1: 强依赖关系(订单-订单项)
cascade="all, delete-orphan"

# 场景2: 独立实体(用户-角色)
cascade="save-update, merge"

# 场景3: 不级联
cascade=""

9.4 性能优化

1
2
3
4
5
6
7
8
9
10
11
from sqlalchemy.orm import joinedload

# 预加载关联对象,避免 N+1 查询
girls = db_session.query(Girl).options(
joinedload(Girl.boys)
).all()

# 使用 subqueryload 处理一对多
girls = db_session.query(Girl).options(
subqueryload(Girl.homes)
).all()

十、常见问题

Q1: overlaps 警告如何解决?

1
2
# 当同时定义 homes 和 boys 时,会出现警告
boys = relationship('Boy', secondary='home', overlaps='boy,girl,homes')

Q2: 如何避免循环导入?

1
2
# 使用字符串引用
relationship('Boy', ...) # 而不是 relationship(Boy, ...)

Q3: 删除时如何保留关联对象?

1
2
3
4
5
# 不使用 delete-orphan
cascade="save-update, merge"

# 或手动解除关联
girl.boys.remove(boy)

十一、总结

本文通过一个完整的多对多关系案例,详细介绍了:

  • ✅ 三种多对多关系的实现方式
  • ✅ 正向、反向、双向查询技巧
  • ✅ 级联删除的配置和使用
  • ✅ 中间表额外字段的处理
  • ✅ 性能优化和最佳实践

核心要点:

  1. 有额外字段时,使用显式中间表模型
  2. 简单关联时,使用 secondary 参数
  3. 合理配置级联策略,避免数据异常
  4. 使用预加载优化查询性能

掌握这些技巧,你就能轻松应对各种复杂的多对多关系场景了!