速读#
Spring Data JPA 的震撼在于继承接口就有 CRUD,但“白送”也意味着查询生成、实体状态和 flush 时机被框架藏起来。省事可以要,理解缺口不能放着不管;SQL 日志只是把抽象重新变得可见的第一步,后面还要看参数、查询计划和真实查询次数。
继承接口,CRUD 全白送#
public interface NoteRepository extends JpaRepository<Note, Long> { List<Note> findByTitleContaining(String keyword);}save、findById、deleteById 现成;findByTitleContaining 还会被 Spring Data 解析成查询语义,再由 Hibernate 按 MySQL 方言生成类似 where title like ? 的 SQL。第一次见挺震撼:我没手写 SQL,查询就能工作。但 Containing 通常意味着参数两侧加 %,普通 B-tree 索引很难利用前导通配符;数据量上来后,它可能从一行漂亮的方法名变成一次全表扫描。需要前缀搜索就改成 StartingWith,需要任意文本检索则评估 MySQL FULLTEXT 或专门的搜索服务,选择要由 EXPLAIN 和实际数据决定。
震撼之后:理解缺口#
「白送」意味着 SQL 是它生成的,不是我写的。不盯生成的 SQL,就不知道实际在干什么。任务完成了,理解留在框架那边。
这种状态我认识:Dockerfile 多阶段构建那次也「能跑不懂」,后来想加缓存优化才发现完全不理解 COPY --from=build 在干嘛。这次没有重蹈:
白送的第一时间就把 SQL、绑定参数和查询次数变得可观察。
spring: jpa: hibernate: ddl-auto: update # 开发期自动建表,生产慎用 properties: hibernate: format_sql: true
logging: level: org.hibernate.SQL: DEBUG org.hibernate.orm.jdbc.bind: TRACEspring.jpa.show-sql=true 也能快速看到 SQL,但它直接写标准输出,参数与日志上下文都不完整;开发环境用日志分类更容易控制。绑定参数的 TRACE 可能包含邮箱、令牌或业务数据,生产环境不能常开,也不能把这段配置原样带上线。生产排查更适合慢查询日志、指标和采样链路,针对单条慢 SQL 再用 EXPLAIN ANALYZE 看执行计划。
盯着日志后很快见到了“省事的另一面”:先用一条查询取回 N 个实体,业务代码或 JSON 序列化随后逐个访问懒加载关联,于是又发出 N 条查询,这才是典型的 N+1。单纯执行 findAll() 不一定立刻触发,真正的触发点往往藏在循环、映射器和 Open Session in View 支撑的序列化阶段。根子是对象导航与集合查询之间的错配;如果只看 Controller 里那一行仓库调用,很容易把后续 SQL 当成不存在。
看见之后才有得选:列表接口只取需要字段时用 DTO projection;确实要关联实体时用 @EntityGraph 或 JPQL join fetch;数据量很小也可以接受现状。集合 fetch join 与分页组合可能造成内存分页或结果膨胀,不能把“全部 join 成一条”当万能优化。我还会在 Web 项目里评估关闭 spring.jpa.open-in-view,让懒加载留在事务边界内,避免模板或 JSON 序列化阶段悄悄访问数据库。选哪个不重要,重要的是选择发生在看过查询次数、数据规模和执行计划之后。
另一个容易藏住的细节是 save() 不等于 SQL 当场执行。JPA 在事务中维护实体状态,新实体通常走 persist,已有实体可能走 merge;对已经托管的实体修改字段,脏检查会在 flush 或事务提交时生成 UPDATE,甚至不需要再调用 save。如果不知道 flush 边界,日志里“这一行怎么没有 SQL”或“异常怎么到提交才出现”都会显得像玄学。抽象省掉了样板代码,也要求我理解事务、实体状态和数据库约束真正在哪一刻生效。
两个「能 ≠ 该」#
| 配置 / 做法 | 方便在哪 | 该不该 |
|---|---|---|
ddl-auto: update | 开发期自动建表 | 生产让框架改表风险高,变更应受控 |
| 密码写进配置文件 | 省事 | 配置进 Git 后密钥收不回来;用 ${DB_PASSWORD} |
ddl-auto 这一个键就值得停一下:update 会尝试把现有 schema 调整到映射需要的状态,但不同 Hibernate 版本和数据库方言对改列、约束、重命名的处理并不可靠,没有审阅步骤、版本记录和回滚方案;create 或 create-drop 更会重建表。开发期临时库可以图方便,生产环境用 Flyway、Liquibase 或受控 SQL 迁移记录每次变化,应用侧设为 validate 检查实体与表是否一致,而不是让启动过程顺便改库。
工具给的「方便」和工程上的「该不该」是两个问句,得分开答。
JPA vs MyBatis-Plus#
| JPA | MyBatis-Plus | |
|---|---|---|
| 主要模型 | 实体状态、工作单元、关联与脏检查 | SQL / Mapper 为中心,并提供 CRUD 与条件构造器 |
| 查询控制 | 派生查询、JPQL、Criteria、native SQL | XML、注解 SQL、Wrapper 与自定义 Mapper |
| 主要成本 | 懒加载、flush、级联和生成 SQL 需要理解 | SQL 映射、结果组装和跨数据库差异由项目维护 |
两边都能把简单 CRUD 写得很短,也都能完成复杂查询,区别不是“谁高级”,而是谁掌握 SQL 形状、实体生命周期和团队调试习惯。我的场景以实体关系和事务内修改为主,JPA 的工作单元模型值钱;报表、手工优化 SQL 和数据库特性占主导时,我更愿意让 Mapper 明确表达查询。先看主要工作负载,再选要承担哪一类理解成本。
抽象不是越深越好#
这条线是从 SQLite 练手一路延续下来的:练手零运维 → 工程化换 MySQL → 再加 ORM。每加一层抽象,多一层理解缺口。
抽象是“省掉的样板”和“藏住的机制”之间的跷跷板。 SQL 日志、查询计划、事务测试和受控迁移,是把关键细节重新拉回视野的方法。
实体长这样时,骨架才算立住:
@Entity@Table(name = "notes")public class Note { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 200) private String title; @Lob @Column(nullable = false) private String body;
protected Note() {}}实体注解表达的是映射意图,生产表的精确类型、索引和约束仍以迁移文件为准。GenerationType.IDENTITY 适合 MySQL 自增主键,但获取主键往往需要较早执行 INSERT,会影响批量写入策略;当批处理真的成为瓶颈时再根据测量调整,不提前为不存在的吞吐量设计。
带走两个判断:
- 白送的便利,要用 SQL、参数、查询次数和执行计划对冲理解缺口
- 能自动 ≠ 该自动;生产配置受控
