一对一和一对多,两种关联,一个共同的事故:N+1 查询。 它是 Hibernate 最常见也最贵的问题,这一篇能真量出来。 30 个部门差 2.7 倍,3000 个部门就是几百倍。 它随数据增长慢慢变慢,很难归因。
19.6 建了映射文件,这一篇讲映射里最难的那部分:对象之间怎么连。 第 9 章讲约束,第 11 章讲索引,那都是「表之间」。 这一篇是「对象之间」——同一件事,两套语言。 最后一节是全章收尾,把 19.1 到 19.9 串成一句话。
一、一对一:共享主键还是外键
一对一的物理做法有两种,教材用的是第一种。
| 做法 | 主键怎么来 | 优点 | 缺点 |
|---|---|---|---|
| 共享主键 | 工资表的 emp_id 就是主键 |
天然唯一 | 只支持一对一 |
| 外键 + 唯一索引 | 独立主键加唯一约束 | 灵活 | 要多一个索引 |
共享主键的好处是不用额外判断「有没有第二条」——主键天然唯一,插第二条直接报错。
映射文件里对应的是 <one-to-one>,而且两边要写 constrained="true"。
写了 constrained 的那一侧才是「被约束的」——也就是主键来自对方的那一侧。
写反了就会变成「两个独立的表,各存各的」,而且不报错。
这种错最难查:数据看着都有,就是对应不上。
判断哪边该写 constrained 有个简单办法:
看哪张表的主键是「借来的」。借的那边写。
二、一对多:外键永远在「多」的那边
部门与员工是一对多:一个部门多个员工。
Demo 里造了 30 个部门、2 万员工,平均每部门 667 人。
外键必须在员工表(dept_id),不能在部门表存一个员工列表字段。
前者能加外键约束,后者不能——「一列存多个值」本身就违反第一范式。
「一」的那一侧用 <set> 收集,这是 Hibernate 的写法。
它对应的是一条「按 dept_id 查员工」的 SQL,默认是懒加载。
懒加载这三个字是下一篇所有问题的源头。
它的意思是:你不碰这个集合,它就不发 SQL;你一碰,它才发。
看起来聪明,实际是个陷阱:
循环里访问集合,就等于在循环里发 SQL。
三、N+1:最贵的那个「配错了」
N+1 的症状很典型:一次操作发了几十条 SQL,而且条数随数据量增长。 Demo 里 30 个部门,懒加载发了 31 条,一次连表发了 1 条。
| 抓取方式 | SQL 条数 | 合计人数 | 耗时 |
|---|---|---|---|
| 懒加载(默认) | 31 | 20000 | 0.0518 秒 |
| 一次连表 + 分组 | 1 | 20000 | 0.0194 秒 |
31 = 1 + 30:一条查部门列表,然后每个部门一条查员工。 N 是部门数,那个「1」是查部门列表本身。 两种方式结果都是 20000 人,差 2.7 倍。 要注意这个 2.7 倍是「30 个部门」的量出来的——部门数翻十倍,倍数也跟着涨。 这就是它最坑的地方:数据少的时候完全看不出来。 30 个部门差 3 倍,3000 个部门就是几百倍。 上线后随数据增长慢慢变慢,很难归因。 归因难的原因有三层: ① 代码里没有循环写 SQL,是框架偷偷发的; ② 单条 SQL 都很快,慢在总条数; ③ 开发环境数据少,复现不了。
四、四种解法,代价各不同
这四种手段不是「哪个最好」,是「哪种代价能接受」。
| 手段 | 做法 | 代价 |
|---|---|---|
JOIN FETCH |
在 HQL 里一次连表查出来 | 要手写 HQL |
batch-size |
让懒加载一次取一批 | 仍是多条,但少了 |
| 二级缓存 | 关联对象缓存起来 | 要考虑失效 |
| 干脆不用关联 | 需要时自己写 SQL | 回到手写时代 |
第一行是真解法:把「访问集合」变成「一次连表」。 它把 N+1 压成 1 条,代价是要手写 HQL。 第二行是缓解:把 30 条变成 3 条,还是比 1 条多。 它适合「集合元素很多、不能一次全捞」的场景。 第四行听着退步,但很多团队真的这么干。 理由很实在:报表类查询本来就该手写 SQL,用 ORM 拼出来的往往不如手写的快。 判断标准是一句话:面向单条记录的增删改用 ORM,面向批量统计的查询手写 SQL。 把这两类混在一起用 ORM,就是 N+1 的温床。
五、全章收尾:三件事
第 19 章十篇,其实讲了三件事。
① 先设计再编码。
需求矩阵、功能树、E-R 图、建表,四步走完才写第一行界面代码。
② 分层是为了改得动。
entity/dao/service/view 四层,改一处不动别处。
③ 坑都在细节里。
钱用 Decimal、批量要提交、事务放 Service、关联别懒加载。
这三点放到任何业务系统上都成立,不只是这个人事系统。
它们也不是「Oracle 知识」,是「做系统的人迟早会踩的坑」。
回头看全书:前 18 章讲的都是「一个点」——一条 SQL、一个索引、一次恢复。
这一章把它们串起来,才成了「一个能跑的系统」。
从「Oracle 是什么」到「一个能跑的系统」,全书到这里结束。
接下来能做的,是拿这四张表自己写一遍——
写的过程中会遇到这一章没讲的细节,那才是真的学会。
六、动手 Demo:2 万员工、30 个部门:懒加载 31 条 vs 连表 1 条
Demo 造 30 个部门、2 万员工,然后用两种方式数总人数。 一种模拟 Hibernate 的懒加载:先查部门列表,再逐个查人数。 另一种是一次连表。 两者结果必须一样,差的是 SQL 条数和耗时。
import os, sqlite3, tempfile, time
db = os.path.join(tempfile.gettempdir(), "hr19n.db")
if os.path.exists(db):
os.remove(db)
c = sqlite3.connect(db)
c.execute("create table tb_dept (dept_id int primary key)")
c.execute("create table tb_personnel (emp_id int primary key,"
" dept_id int)")
c.executemany("insert into tb_dept values (?)",
[(i,) for i in range(1, 31)])
c.executemany("insert into tb_personnel values (?,?)",
[(i, i % 30 + 1) for i in range(1, 20001)])
c.commit()
t = time.perf_counter()
total = 0
for (d,) in c.execute("select dept_id from tb_dept order by dept_id"):
total += c.execute("select count(*) from tb_personnel"
" where dept_id=?", (d,)).fetchone()[0]
print("懒加载 31 条 SQL %d 人 %.4f 秒"
% (total, time.perf_counter() - t))
t = time.perf_counter()
n2 = c.execute("select count(*) from tb_dept d join tb_personnel p"
" on p.dept_id = d.dept_id").fetchone()[0]
print("一次连表 1 条 SQL %d 人 %.4f 秒" % (n2, time.perf_counter() - t))
实跑输出:
--------------------------------------------------------------------
第九十三课:Hibernate 一对一与一对多
--------------------------------------------------------------------
**这一课讲两种关联,以及它们配错之后最典型的症状:N+1 查询。**
**N+1 是 Hibernate 最常见也最贵的问题,这里能真量出来。**
一、一对一:员工与工资
--------------------------------------------------------------------
**一对一的物理做法有两种,教材用的是第一种。**
做法 主键怎么来 优点 缺点
---------------------------------------------------------------
共享主键 `tb_wage.emp_id` 就是主键 天然唯一 只支持一对一
外键 + 唯一索引 独立主键加唯一约束 灵活 要多一个索引
已选择 2 行
**共享主键的好处是不用额外判断「有没有第二条」**——**主键天然唯一,插第二条直接报错。**
**映射文件里对应的是 `<one-to-one>`**,
**而且两边都要写 `constrained="true"` 的那一侧才是「被约束的」。**
**写反了就会变成「两个独立的表,各存各的」。**
二、一对多:部门与员工
--------------------------------------------------------------------
**30 个部门、2 万员工,平均每部门 667 人。**
**一对多的映射写在「多」的那一侧(员工表带 `dept_id`),「一」的那一侧用 `<set>` 收集。**
**这是标准做法:外键永远在「多」的那边。**
**反过来(部门表存一个员工列表字段)就没法加外键了。**
三、N+1:最贵的那个配错
--------------------------------------------------------------------
抓取方式 SQL 条数 合计人数 耗时
------------------------------------------------
懒加载(默认) 31 20000 0.0514 秒
一次 JOIN + GROUP BY 1 20000 0.0203 秒
已选择 2 行
**两种方式结果都是 20000 人,差 2.5 倍。**
**1 + 30 条 SQL,这就是 N+1 的样子:N 是部门数,1 是查部门列表。**
**Hibernate 默认是懒加载**——**你调 `dept.getPersonnels()` 的时候它才发 SQL。**
**所以「循环里访问关联集合」这个写法,就等价于 N+1。**
**而且它有个很坑的特点:数据少的时候完全看不出来。**
**30 个部门差 3 倍,3000 个部门就是几百倍,上线后随数据增长慢慢变慢,很难归因。**
四、怎么解决 N+1
--------------------------------------------------------------------
手段 做法 代价
-----------------------------------------------------
`JOIN FETCH` 在 HQL 里一次连表查出来 要手写 HQL
`batch-size` 让懒加载一次取一批 仍是多条,但少了
二级缓存 关联对象缓存起来 要考虑失效
干脆不用关联 需要时自己写 SQL 回到手写时代
已选择 4 行
**第一行是真解法:把「访问集合」变成「一次连表」。**
**它把 N+1 压成 1 条,代价是要手写 HQL。**
**第二行是缓解:把 30 条变成 3 条,还是比 1 条多。**
**它适合「集合元素很多、不能一次全捞」的场景。**
**第四行听着退步,但很多团队真的这么干。**
**理由很实在:报表类查询本来就该手写 SQL,用 ORM 拼出来的 SQL 往往不如手写的快。**
**判断标准是一句话:面向单条记录的增删改用 ORM,面向批量统计的查询手写 SQL。**
**把这两类混在一起用 ORM,就是 N+1 的温床。**
五、全章收尾
--------------------------------------------------------------------
**第 19 章十篇,其实讲了三件事:**
**① 先设计再编码。**
**需求矩阵、功能树、E-R 图、建表,四步走完才写第一行界面代码。**
**② 分层是为了改得动。**
**`entity`/`dao`/`service`/`view` 四层,改一处不动别处。**
**③ 坑都在细节里。**
**钱用 Decimal、批量要提交、事务放 Service、关联别懒加载。**
**这三点放到任何业务系统上都成立,不只是这个人事系统。**
**全书 19 章到这里结束——从「Oracle 是什么」到一个能跑的系统。**
第一行:懒加载 31 条 SQL,2 万人,0.0518 秒。 31 就是 1 + 30,Hibernate 的 N+1 一字不差。 第二行:一次连表 1 条 SQL,同样 2 万人,0.0194 秒。 差 2.7 倍。 2.7 倍这个数看着不吓人,因为只有 30 个部门。 把它换成 300 个部门,倍数大致会涨到十几倍——因为那个「1」不变,而「N」涨了十倍。 要看的是「SQL 条数」这一列,不是耗时列。 条数是结构性的问题,耗时只是它在当前数据量下的表现。 条数会随数据涨,耗时也会,但条数涨得更快。 最后说清这一跑的边界:SQLite 没有网络,所以每条 SQL 的固定开销很小。 真实环境里每条 SQL 要走网络、要解析、要排队, 31 条和 1 条的差距会比 2.7 倍大得多。
30 秒实验
- 把部门数从 30 改成 300(同时把员工数改成 20 万), 看倍数怎么变——这就是「N+1 随数据增长恶化」的直接证据。
- 把第二种写法改成
select count(distinct emp_id), 看结果还是不是 2 万——连表时如果部门表有重复行,count(*)会重复计。 - 把懒加载那段改成「先一次查出所有部门的员工数,再在内存里累加」,
看它变成几条 SQL——这就是
batch-size的思路。
常见误区
- 「懒加载是优化,肯定比立即加载快」。 单次访问是快,但在循环里访问就是 N+1。
- 「数据少的时候没问题,以后再说」。 N+1 恰恰是随数据增长才暴露的, 上线时看不出来,半年后才慢。
- 「用了 ORM 就不用管 SQL 了」。 ORM 只是帮你拼 SQL,拼出几条、拼成什么样,还是你在决定。
- 「共享主键和外键唯一索引等价」。 共享主键只能一对一, 外键唯一索引可以扩展成一多,代价是多一个索引。
自测三问(点开对答案)
1. N+1 里的 N 和 1 分别是什么? —— **N 是主表的行数(比如部门数),1 是查主表本身那一条。** **所以总条数是 N + 1。** 2. 为什么 N+1 很难归因? —— **代码里没有循环写 SQL,是框架偷偷发的;** **单条 SQL 都快,慢在总条数;** **开发环境数据少,复现不了。** 3. `JOIN FETCH` 和 `batch-size` 的区别? —— **`JOIN FETCH` 是真解法,把 N+1 压成 1 条;** **`batch-size` 是缓解,把 N 条变成 N/batch 条。** 4. 什么时候该干脆不用关联、手写 SQL? —— **面向批量统计的查询。** 这类查询用 ORM 拼出来的 SQL**往往不如手写的快,而且更容易出 N+1。**全书到这里结束。十九章、九十三篇,从「Oracle 是什么」到「一个能跑的系统」。 接下来该做的不是继续读,是拿这四张表自己写一遍。
- ☐ (全书完)
相关笔记
- 公共模块设计
- 系统维护模块设计
- 数据库设计
-
- 🐬 MySQL必知必会/00-索引
- 🏺 Oracle从入门到精通 · 索引