🧪 第5章 建立图片列表页面
📄 一句话概括

外键只存一个整数,要用户的名字就得 join 过去。 relationship 不改表结构,它只是告诉 ORM 两个类怎么互相找。 循环里访问关系字段会变成 N+1:实测 1 条列表加 2 条用户,joinedload 压成 1 条。

📝 本章和前面几章的关系

这一篇对应教材 5.4 到 5.4.3:用 SQL 连结表格、确认 SQL 的事务、关联性建立。 这是本章唯一标三颗星的一篇,因为 N+1 是 ORM 最贵的一课。

一、为什么需要 join

user_image 表里只有一个 user_id 整数,没有用户的名字。页面上要显示「谁传的」,名字在另一张表里。 两条路:先查图片列表,再对每张图单独查一次用户;或者一次把两张表拼起来。 前者写起来简单,代价是 SQL 条数随行数增长。后者一句 SQL 解决,但要想清楚用什么关联方式。

这就是这一篇的全部内容:怎么拼,以及拼不拼得对。

二、原生 SQL 的 JOIN

select i.id, i.filename, u.name
from user_image i
join user u on u.id = i.user_id
order by i.id

实测结果三行:cat.jpg 与 dog.png 都是张三,street.jpg 是李四。 JOIN 的直觉是「把两张表拼宽」:列变多,行数不一定变。 普通 join 只保留匹配上的行,left join 才保留没匹配的。 on 后面那个条件不能省。只写 join user 而不给 on,SQLite 会按同名列自动匹配,别的数据库直接报错。

三、SQLAlchemy 里写同一个 JOIN

st = (db.select(UserImage.id, UserImage.filename, User.name)
      .join(User, User.id == UserImage.user_id)
      .order_by(UserImage.id))
for r in db.session.execute(st):
    print(r[0], r[1], r[2])

实测输出和手写 SQL 一模一样:三行,两列字符串一列名字。 select 里挑哪几列,结果就只有几列——这一点和原生 SQL 完全对应,ORM 没有偷偷多查。 好处在改字段名时:手写 SQL 里那些列名是字符串,改一个漏一个; 这里写的是属性,改名编译器会替你发现。

四、relationship:从对象直接走到对象

class UserImage(db.Model):
    # ...
    user = db.relationship("User", backref="images")

配了这行之后,img.user 直接拿到那个 User 对象,user.images 拿到他名下所有图片。 实测 img.user 是 <User 张三>,u.images 是 [<UserImage cat.jpg>, <UserImage dog.png>]。 它不改表结构,也不改任何一行数据。只是一份「这两个类怎么互相找」的说明。

backref 顺手在对面加了一个反向属性。 不想用 backref 可以写 back_populates,两边各写一次,意图更明确。

五、N+1:ORM 最贵的一课

有了 relationship,最容易写出的代码是这个:拿到图片列表,在循环里取 r.user.name。 看起来是一行代码,实测发了 3 条 SQL:一条取列表,两条各取一个用户。 这就是 N+1:1 条查列表,再加 N 条查关联。行数从 3 变成 3000 的时候,SQL 条数也跟着变成 3000。

为什么这次只有 2 条而不是 3 条?因为两个用户里有一个已经在会话缓存里,第二次不再发 SQL。 缓存让这个数字看起来还行,也就更容易被忽略。 加上 joinedload 之后,实测压成 1 条:它用一条带 join 的语句把两边的数据一起取回来。 代价是主表数据会被复制(一张图一行),所以它只适合「一对多里取那个一」这个方向。 反过来「取那个多」要用 selectinload。

六、事务边界

教材 5.4.2 讲「确认 SQL 的事务」,测法是开两个连接看得到什么。 实测:flush 之后本会话查到 4 条(原来 3 条),另一个连接仍然查到 3 条。 commit 之后,另一个连接才看到 4 条。 flush 只让「自己」看见,commit 才让「别人」看见。

这和第 2 章那次 flush 与 rollback 是同一件事的两面:flush 把 SQL 发出去了,但事务还没结束, 所以既能被撤销,也还不算数。 写批量任务时这条尤其要紧:一次 flush 几百条再慢慢处理,中途出错 rollback,一条都不会留下。

七、动手 Demo:两张表怎么连:JOIN、relationship 与 N+1

这个 Demo 有两张表、两个用户、三张图,然后把同一件事用四种写法做一遍:原生 SQL、SQLAlchemy join、 relationship、joinedload。 第五节数 SQL 条数,第六节开两个连接看事务边界。

# 配一次关系,之后就能从对象走到对象
class UserImage(db.Model):
    user_id = db.Column(db.Integer,
                        db.ForeignKey("user.id"), nullable=False)
    user = db.relationship("User", backref="images")

# 一次性把关联数据取回来,避免 N+1
rows = db.session.execute(
    db.select(UserImage)
    .options(joinedload(UserImage.user))
    .order_by(UserImage.id)).scalars().all()

实跑输出:

--------------------------------------------------------------------
两张表怎么连:JOIN、relationship 与 N+1
--------------------------------------------------------------------

  ① 外键在库里长什么样
  --------------------------------------------------------------------
  user_image 上的外键 -> [('user_id', '->', 'user', 'id')]
  ! 表里只有 user_id 这个整数,没有用户的名字。
  要名字就得去 user 表拿——这一篇剩下的内容全在解决「怎么拿」。

  ② 原生 SQL:JOIN 就是把两张表拼宽
  --------------------------------------------------------------------
    1  cat.jpg       张三
    2  dog.png       张三
    3  street.jpg    李四
  ! JOIN 的结果是「拼宽」:列变多,行数变不变取决于关联方式。
  普通 join 只留匹配上的,left join 才保留没匹配的。

  ③ SQLAlchemy 里写同一个 JOIN
  --------------------------------------------------------------------
    1  cat.jpg       张三
    2  dog.png       张三
    3  street.jpg    李四
  ! select 里挑哪几列,结果就只有几列——和手写 SQL 完全对应。
  区别只在于列名是 Python 属性,改字段名时不至于漏掉某个字符串。

  ④ 配 relationship:从对象直接走到对象
  --------------------------------------------------------------------
  img.user -> <User 张三>
  u.images -> [<UserImage cat.jpg>, <UserImage dog.png>]
  ! relationship 不改表结构,它只是告诉 ORM「这两个类怎么互相找」。
  backref 顺手在 User 那边也加了一个反向属性。

  ⑤ N+1:循环里取关系字段要发几条 SQL
  --------------------------------------------------------------------
  3 张图逐个取上传者,一共发了 3 条 SQL
      SELECT user_image.id, user_image.filename, user_image.uploaded_at, user_image.user
      SELECT user.id, user.name
      SELECT user.id, user.name
  加上 joinedload 之后 -> 1 条 SQL
  ! 第一条取列表,后面每条记录再各取一次用户——这就是 N+1。
  行数从 3 变成 3000 的时候,SQL 条数也跟着变成 3000。
  ! joinedload 用一条带 join 的语句把用户一起取回来。
  代价是它会连主表数据一起复制,所以只适合「一对多里取那个一」这种方向。

  ⑥ 事务:没 commit 的,别人看不见
  --------------------------------------------------------------------
  flush 之后本会话查到 -> 4 条(原来 3)
  另一个连接查到 -> 3 条
  commit 之后另一个连接查到 -> 4 条
  ! flush 只让「自己」看见,commit 才让「别人」看见。
  这就是第 2 章那次 flush 与 rollback 实验的另一半答案。

第二节和第三节的输出一样,说明 ORM 没有偷偷多查列。 第五节那 3 条 SQL 里,前两条取列表、后两条取用户——第一次取用户之后进了会话缓存, 所以只发了 2 条而不是 3 条。 第六节那两个 3 与 4,是「别人看不看得见」最直接的证据。

30 秒实验

  1. 把 join 改成 outerjoin,看没有图片的那个用户会不会出现在结果里。
  2. 把 joinedload 换成 selectinload,再数一次 SQL 条数。
  3. 想一想:为什么 joinedload 不适合反方向(从用户取图片列表)?

常见误区

  1. 在循环里访问关系字段:N+1,行数一多就变成几百条 SQL。
  2. 以为 relationship 会建外键:它只描述关系,外键得自己在列上声明。
  3. join 忘了 on:别的数据库直接报错,SQLite 会按同名列猜。
  4. flush 之后以为数据已经存了:另一个连接看不见,rollback 就没了。
自测三问(点开对答案) 1. N+1 具体指什么? —— 1 条查列表加 N 条查关联,行数越多 SQL 越多。 2. relationship 改了表结构吗? —— 没有,它只是告诉 ORM 两个类怎么互相找。 3. flush 与 commit 的区别在哪? —— flush 发出 SQL 但事务未结束,本会话可见,别人看不见。

第 5 章到此结束。下一篇做上传:文件怎么收、怎么存、怎么防着点。

相关笔记