数据库与 JPA:教科书式规范化的背叛

“数据重复是绝对的邪恶”

大学数据库课上,教授总是激动得唾沫横飞地强调:“第一范式、第二范式、第三范式……重复数据浪费存储空间,还会破坏一致性。拆开,再拆开!”

我非常认真地遵循了这套教导。做毕业项目时,我把表拆成了 10 个、20 个。’User’、’Address’、’City’、’Zipcode’……光是存一个地址,就要用到 3 张表,我的数据库设计可谓“教科书级”完美。

可到了实务里,这种完美的设计却变成了“灾难”。我只是想查一份客户列表,却不得不写 5 次 JOIN。查询变复杂了,速度变慢了,更痛苦的是,把这些数据再搬运进 Java 对象的过程简直折磨人。

“明明只是取一行要展示在页面上的数据,为什么代码会复杂成这样?”

直到那时我才明白。学校教的是“存储”的效率,而在实务里,“读取”的效率往往重要得多。更重要的是,面向对象语言 Java 和关系型数据库之间,横着一条比我想象中更难跨越的河。

把东西整理得很干净(规范化)和让读取更顺手(查询性能)并不是一回事。

范式不一致:方块和圆圈

我们痛苦的根本原因,就是所谓的“范式不一致(Impedance Mismatch)”。

大学时期,我只能靠自己手写 SQL,硬把这两个世界拼在一起。我把 Java 对象拆开,插进数据库里(INSERT),再通过 SELECT 从数据库里取出来,一行一行从 ResultSet 里读出,再逐个塞进 SetList 之类的 Java 集合里。那时我感觉自己不像开发者,更像“数据翻译员”。

为了解决这种枯燥的重复劳动,出现的就是 JPA(Java Persistence API),也就是 ORM(Object-Relational Mapping) 技术。

JPA 与 ORM:用对象来操作数据库的技术

ORM 顾名思义,就是“把对象(Object)和关系型数据库(Relational DB)连接起来的技术”。正如你所理解的,核心就是:把数据库表当作 Java 对象来定义和操作。

我们再也不用手写 CREATE TABLE 查询了。取而代之的是,创建一个 Java 类,再贴上一张叫做 @Entity 的标签。然后 JPA,作为 Java 世界里的 ORM 标准,就会看着这个类说:“哦,原来你需要的是这种形状的表。” 接着自动帮你在数据库里建出来。

保存数据时也不再需要自己写 SQL,而是像往 Java 集合里塞数据一样,写一句 repository.save(member) 就结束了。开发者完全可以站在“面向对象”的视角写代码,而把那些脏兮兮的 SQL 翻译工作统统扔给 JPA。

但正如 Re: Booting 系列里反复学到的那样,便利总是有代价的。也正因为我太相信这个名叫 JPA 的自动翻译机,才在自己的代码里埋下了一颗定时炸弹:N+1 问题。

[Code Verification] N+1 问题,查询炸弹

这是每个第一次使用 JPA 的初级开发者几乎都会踩到的问题。场景很简单:“输出所有会员,以及他们所属团队的名称。”

// 1. 查詢所有成員(產生 1 次查詢)
List<Member> members = memberRepository.findAll();

for (Member member : members) {
    // 2. 輸出每位成員的隊伍名稱
    // 若有 100 位成員,為了抓取隊伍資訊會額外發出 100 次查詢!
    System.out.println(member.getTeam().getName());
}

原本期待的查询:SELECT * FROM Member JOIN Team ...(只执行 1 次)

实际发生的查询:

如果会员有 100 人,查询就会发出 101 次,也就是 1 + N。那如果会员有 1 万人呢?数据库就会被 10001 条查询疯狂轰炸。这就是足以把服务器拖垮的 N+1 问题。JPA 原本是为了让开发者更轻松,才选择“需要时再取(Lazy)”的方式加载数据,结果这种便利反而成了事故的根源。

明明一次就能取完的东西,却被拆成一百次去取,这就是 N+1 问题的低效本质。

实务建议:务实的数据库设计

那么,在实务里到底该怎么做?必须在教科书式的规范化和 JPA 的便利性之间找到平衡。

结语:想更轻松,就得懂得更多

JPA 当然是一场革命。毕竟它把我们从无聊重复的 SQL 劳作中解放了出来。但如果因此就觉得“反正用了 JPA,现在已经不需要懂 SQL 了”,那就很危险了。

JPA 不是魔法师,它只是一个替你写 SQL 的“秘书”。如果你给秘书下了错误的指令,比如错误的映射、不恰当的 EAGER 加载等等,这位秘书就会默默地替你发出 100 条查询,把数据库直接打趴下。要监控并优化 JPA 生成的 SQL 是否足够高效,讽刺的是,你反而得更深入地理解 SQL。轻松背后,总是伴随着责任。

现在我们已经知道,怎样把数据装进对象里了。可这些对象,也就是 Entity,真的能原封不动地发给前端 Vue.js 吗?如果 User 实体里包含密码怎么办?如果把前端根本不该知道的信息也一股脑全送出去,安全性又该怎么办?

下一篇,我们来聊聊 DTO(Data Transfer Object)与 REST API 设计,也就是如何把数据打包好,再安全地交付出去。

發佈留言