🏺 第19章 企业人事管理系统
📄 一句话概括

一对一和一对多,两种关联,一个共同的事故: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 秒实验

  1. 把部门数从 30 改成 300(同时把员工数改成 20 万), 看倍数怎么变——这就是「N+1 随数据增长恶化」的直接证据。
  2. 把第二种写法改成 select count(distinct emp_id), 看结果还是不是 2 万——连表时如果部门表有重复行,count(*) 会重复计。
  3. 把懒加载那段改成「先一次查出所有部门的员工数,再在内存里累加」, 看它变成几条 SQL——这就是 batch-size 的思路。

常见误区

  1. 「懒加载是优化,肯定比立即加载快」。 单次访问是快,但在循环里访问就是 N+1。
  2. 「数据少的时候没问题,以后再说」。 N+1 恰恰是随数据增长才暴露的, 上线时看不出来,半年后才慢。
  3. 「用了 ORM 就不用管 SQL 了」。 ORM 只是帮你拼 SQL,拼出几条、拼成什么样,还是你在决定。
  4. 「共享主键和外键唯一索引等价」。 共享主键只能一对一, 外键唯一索引可以扩展成一多,代价是多一个索引。
自测三问(点开对答案) 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 是什么」到「一个能跑的系统」。 接下来该做的不是继续读,是拿这四张表自己写一遍。

相关笔记