首页 文章 精选 留言 我的

精选列表

搜索[boot],共8303篇文章
优秀的个人博客,低调大师

Spring boot、Redis、ActiveMQ、Nginx、Mycat、Netty、Jvm调优

性能优化 JVM调优 Java程序性能优化 Tomcat Mysql Spring IOC Spring AOP Spring MVC Spring 5新特性 Mybatis 分布式架构 架构核心服务层技术 架构关键技术设施 分布式消息通讯 异步于MQ 分布式缓存 Redis Memcached MongoDB 数据存储 高并发分流技术Nginx 分布式解决方案 应用框架源码解读 微服务专题 你还不知道微服务?怎么加(zhuang)薪(bi) SpringBoot 与微服务的区别于联系 快速构建SpringBoot工程 SpringBoot核心组件剖析 快速集成mybatis实战 快速集成Dubbo及案例实战 构建集成 redis及案例实战 构建Swagger插件实现API 管理及接口测试体系 SpringCloud Zuul路由网关详解源码探析 Ribbon客户端负载均衡原理 Feign声明式服务调用方式 Eureka注册中心构件 Config配置服务中心 svn、git快速集成 Sleuth调用链路跟踪 BUS消息总线技术 Docker虚拟化技术 介绍、安装与使用 compose部署脚本 service服务编排 redis分布式集群部署 docker file构建 通过maven插件打包镜像 部署及运行应用程序kubernetes编配 构建Mysql集群实战 高可用SpringCloud微服务与docker集成实现动态扩容实战 团队协作开发 Git Maven Jenkins Sonar B2C商城项目实战 撸起袖子干实事,项目经验那点事 系统设计 架构设计 数据库设计 部署设计 基础开发框架讲解 用户管理子系统 用户注册登录(二维码技术) 权限控制(shiro) 单点登录(session统一缓存、sso) 商品管理子系统 商品管理(MongoDB) 商品分类(MongoDB) 商品发布(nosql与mysql数据同步) 热卖商品排行(redis) 搜索子系统 基于ES的全文探索 检索需求分析 检索策略 索引设置 分词算法 命中率优化 订单实时统计 订单子系统 分布式环境生成唯一编号(zookeeper) 下单业务流程(消息中间件) 订单可靠性 秒杀功能(redis) 订单管理 订单功能(mongodb) 支付系统 微信支付对接 支付宝对接 银联对接 开发 分布式调度系统 数据统计 数据同步 任务调度配置 后台系统 日常管理 在线IM系统(websockedNIO) 用户行为分析(日志系统设计) 报表大数据查询优化(mysql查询优化) 欢迎大家加入Java高级架构/互联网:779792048本群提供免费的学习指导 架构资料 以及免费的解答不懂得问题都可以在本群提出来 之后还会有职业生涯规划以及面试指导进群修改群备注:开发年限-地区-经验方便架构师解答问题 性能优化 性能调优 JVM调优 Java程序性能优化 Tomcat Mysql 应用框架源码解读 Spring概述 Spring容器 Spring AOP Spring MVC Spring 5新特性 Mybatis 分布式架构 分布式架构思维 架构开发基础 架构核心服务层技术 分布式环境指挥官Zookeeper 分布式消息通讯 异步于MQ 分布式缓存Nosql Redis Memcached 缓存开发专题 MongoDB 数据存储 高并发分流技术Nginx 分布式常见场景解决方案 微服务架构 Springboot SpringCloud Docker虚拟化技术 团队协作开发 Git Maven Jenkins Sonar B2C商城项目实战

优秀的个人博客,低调大师

《Spring Boot + JPA 实战》第8招 关联关系别乱加

## 01、痛点 前几天,团队里一个做了三年后端的兄弟在群里甩了一张堆栈截图,附了一句:“我明明只加了一个订单项,怎么它给我更新了订单表?”我点开一看,Order 实体上标着 @OneToMany,OrderItem 上又标着 @ManyToOne,两边都配了 JoinColumn。保存订单时,Hibernate 先插了 OrderItem,又回头更新 Order 的外键,最后还抛了个约束冲突。这哥们很委屈:“写起来就两个注解的事,咋还翻车了?” 我是老李。今天我们不聊花活,就聊每个写 JPA 的人都会踩的坑:关联映射不是写注解,而是在设计对象图和数据库之间的契约。尤其 Customer → Order → OrderItem → Product 这条链,几乎每个电商项目都会遇到,但能一次写对的人不多。要么是外键为 null,要么是子实体删不掉,要么是双向关系内存和数据库各说各话。更隐蔽的是,很多人根本没意识到问题出在哪,只能靠“清缓存、重新查”硬扛。 ## 02、是什么 要搞明白这个坑,先得把几个概念钉在墙上:owning side、mappedBy、Cascade、orphanRemoval、双向关系同步、DDD 聚合边界。 先说 owning side 和 mappedBy。在 JPA 里,关系映射不是“定义关系”,而是“定义谁负责把关系写进数据库”。拥有外键列的那一方,就是 owning side。对于 @ManyToOne 和 @OneToMany 这种一对多组合,外键总是放在“多”的那一端。也就是说,OrderItem 表里有个 order_id 字段,那么 OrderItem 就是 owning side,Order 上的 @OneToMany 只是反向映射,必须写 mappedBy = "order"。mappedBy 的意思很直白:我不维护外键,我只是对方关系的镜像。如果你在 Order 上写了 @JoinColumn,又不在 OrderItem 上写 @JoinColumn,Hibernate 会以为有两个不同的关系,从而生成两个外键列,或者产生一堆冗余的 UPDATE 语句。 再说 Cascade 和 orphanRemoval。Cascade 描述的是持久化操作在关系上的传播范围。比如 CascadeType.ALL 表示父实体的 persist/merge/remove 等操作会传递给子实体。orphanRemoval 则更精细一点,它回答的问题是:当一个子实体从父集合中被移除,并且不再被任何父引用时,要不要直接删掉数据库记录?把 orphanRemoval 想象成“孤儿清除”,它只对从集合中移除的子实体生效。CascadeType.REMOVE 则是在父实体被删除时级联删除所有子实体。两者容易混淆,但很多场景下会配合使用:父删子也删,子被踢出集合也删。 双向关系同步是内存层面的工作。因为 Java 对象图是双向引用,数据库却只有外键。如果 Order.getItems().add(item) 之后,item.getOrder() 还是 null,那么持久化上下文在脏检查时就可能做出错误的 SQL 操作。真正可靠的同步方式是在聚合根里提供管理方法,比如 addItem、removeItem,把双向引用一起维护。 最后是 DDD 聚合边界。聚合边界决定了一组对象应该作为一个整体被外部访问。Order 就是聚合根,OrderItem 是聚合内部的实体,外部只允许通过 Order 来修改订单项。这样就把“谁负责维护关系”锁定在了聚合内部,避免外部代码随意操作子实体导致不一致。 ## 03、为什么 你可能会问,为什么双向关系这么容易导致 Java 内存状态和数据库状态不一致?核心原因是对象图有多个入口,而数据库只有一个事实来源。当你拥有 Order 和 OrderItem 两个方向的引用时,其实是同一个业务事实在内存里存了两份。如果只更新其中一份,另一份还停留在旧状态,持久化上下文却会在事务提交时尝试同步所有变更。它的脏检查机制会根据对象当前状态生成 SQL,但它不负责帮你调和两个方向之间到底谁说了算。于是你就会看到一些诡异的 SQL:明明只改了一个字段,却多出几条 update;明明从集合里删了,子实体还赖在数据库里。 另外,ManyToMany 在真实项目中经常应该改成显式中间 Entity。因为 ManyToMany 隐藏了中间表,中间表里只有两个外键,加不了任何业务字段。一旦业务需要一个“打标签的时间”或者“角色里的部门范围”,你就得拆。拆成显式中间实体之后,对象图更简单,关系更清晰,还能顺便把聚合边界收紧。 更深一层的原因是,实体之间的关联越多,不代表模型越丰富,反而可能意味着对象图越来越不可控。每一条双向关联都是一条需要维护一致性的路径。如果你在 Customer、Order、OrderItem、Product 之间都放上双向导航,任何一端的变化都可能触发连锁加载和连锁 SQL。更糟的是,级联范围一旦配错,删除一个用户可能会把整条订单链全部带走。这也是为什么很多有经验的开发者会刻意减少实体之间的导航线,能用 ID 引用就不用对象引用,能用单向就不用双向。 ## 04、架构 聊完原理,我们把它放到一个实际的电商订单场景里。聚合边界应该怎么画? 先看 Customer 和 Order 的关系。Customer 是一个聚合根,Order 通常也是独立聚合根。它们之间用单向的 @ManyToOne 从 Order 指向 Customer 就够了,不需要 Customer 上放 @OneToMany 集合。如果你确实需要“某个用户的所有订单”,可以通过 OrderRepository 里加一个 findByCustomerId 来查询,而不是在 Customer 对象上维护一堆 Order。这样聚合边界清楚,也不会因为加载用户时把订单全部扯出来。 再看 Order 和 OrderItem。Order 是聚合根,OrderItem 是聚合内部实体。两者的关系是双向的,但维护权必须交给 Order。Order 上配置 @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true),OrderItem 上配置 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id")。注意,@OneToMany 默认是懒加载,@ManyToOne 默认是急加载,所以这里显式改成 LAZY 可以避免查订单项时把每个订单项关联的 Order 全部查出来。虽然这里 Order 已经在上下文里,但显式 LAZY 是一种习惯,能减少意外查询。 OrderItem 与 Product 的关系。Product 是独立聚合,OrderItem 需要引用商品,但最好不要直接用对象引用去穿到 Product 聚合内部。更稳妥的做法是在 OrderItem 里保留 productId 和必要的快照字段(比如商品名称、单价、下单时的规格)。如果一定需要实体关联来查询,可以使用 @ManyToOne(fetch = FetchType.LAZY) 加 @JoinColumn(name = "product_id"),但务必注意跨聚合的级联要关闭,否则保存订单时可能意外修改商品表。 再看看 ManyToMany。在 OrderItem 里,如果出现商品和优惠券、商品和标签等多对多,真实项目里几乎都要拆成显式中间实体。比如 Product 和 Tag 原来是 @ManyToMany,后来业务要求记录“谁在什么时候打了这个标签”,这时就要新建 ProductTag 实体,包含 product、tag、createdBy、createdAt。这样既保留了多对多关系,又扩展了业务属性,还没有隐式中间表带来的维护难度。 ## 05、实战 下面用代码把这条链串起来。假设我们有四个实体:Customer、Order、OrderItem、Product。 先定义 Customer: ```java @Entity public class Customer { @Id @GeneratedValue private Long id; private String name; // 不配置 orders 集合,需要时通过 OrderRepository 查询 } ``` 这里故意不配置 @OneToMany,保持 Customer 聚合根干净。 再定义 Order: ```java @Entity public class Order { @Id @GeneratedValue private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List items = new ArrayList<>(); public void addItem(OrderItem item) { items.add(item); item.setOrder(this); } public void removeItem(OrderItem item) { items.remove(item); item.setOrder(null); } } ``` addItem 和 removeItem 是唯一允许修改 items 集合的入口。它们同时维护了双向引用。这样外部代码就不会出现只加了一边的情况。 接着定义 OrderItem: ```java @Entity public class OrderItem { @Id @GeneratedValue private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id") private Order order; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "product_id") private Product product; private int quantity; private BigDecimal priceSnapshot; } ``` 最后是 Product: ```java @Entity public class Product { @Id @GeneratedValue private Long id; private String name; private BigDecimal price; } ``` 新增订单项时,可能写成这样: ```java Order order = orderRepository.findById(orderId).orElseThrow(); Product product = productRepository.findById(productId).orElseThrow(); OrderItem item = new OrderItem(); item.setProduct(product); item.setQuantity(2); item.setPriceSnapshot(product.getPrice()); order.addItem(item); // 关键:这里同时设置了 item.order = order orderRepository.save(order); ``` 因为 Order 上的 items 是 mappedBy = "order",真正的 owning side 是 OrderItem 的 order 字段。addItem 方法内部调用了 item.setOrder(this),所以外键不会为 null。同时 cascade = CascadeType.ALL 会在保存 Order 时级联保存 OrderItem。orphanRemoval = true 则在删除订单项时发挥作用。 删除订单项: ```java Order order = orderRepository.findById(orderId).orElseThrow(); OrderItem target = order.getItems().stream() .filter(i -> i.getId().equals(itemId)) .findFirst() .orElseThrow(); order.removeItem(target); // 从集合中移除,并把 target.order 置为 null orderRepository.save(order); ``` 事务提交时,Hibernate 会发现 target 已经不在 items 集合中,并且 order 引用被置为 null,于是触发一条 delete from order_item where id = ?。这就是 orphanRemoval 的效果。 这里有一个常见错误:有人直接在业务代码里写 order.getItems().remove(target),但不调用 item.setOrder(null)。这样会导致 order 集合里移除了,但 item 仍然持有 order 引用。持久化上下文可能认为 item 还有指向 order 的引用,于是不会删除,反而尝试把 order_id 更新为 null 或抛出约束冲突。所以,双向同步必须封装在聚合根内,不能依赖外部自觉。 ## 06、洞见 写了这么多年 JPA,我越来越觉得:实体之间的关联越多,不代表模型越丰富,反而可能意味着对象图越来越不可控。很多项目的实体关系图看起来密密麻麻,线画得很有成就感,但一到运行时,加载策略、级联策略、脏检查互相作用,调试起来异常痛苦。 一个真正成熟的模型,会刻意保持对象图的稀疏。能用单向关联,就不上双向;能用 ID 引用,就不引入对象引用;能用显式中间实体,就不留隐式 @ManyToMany。这样做不是技术上做不到,而是为了可控。每少一条关联线,就少一处需要维护一致性的地方。 订单这个例子就很典型:Customer 不持有 Order 集合,Order 聚合内维护 OrderItem 的双向关系但外部只能通过 Order 操作。Product 作为独立聚合只被引用 ID,不反向持有订单项。整个对象图的方向是清晰的,外键所有权也一清二楚。即使系统后续扩展优惠、退款、分期等,也不会因为关联太密而需要推翻重来。 ## 07、行动 如果你现在的项目里还有 @ManyToMany,建议你尽快动手把它改掉。别等业务字段来了再改,那时候成本更高。 举个具体的行动场景:Product 和 Tag 原来是多对多,现在需要记录“谁在什么时候给商品打了某个标签”。你可以这样做。 第一步,新建中间实体 ProductTag: ```java @Entity public class ProductTag { @Id @GeneratedValue private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "product_id") private Product product; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "tag_id") private Tag tag; private String createdBy; private LocalDateTime createdAt; } ``` 第二步,把 Product 和 Tag 上的 @ManyToMany 去掉,改为通过 ProductTag 来查询关联。可以在 Product 上配置 @OneToMany(mappedBy = "product"),也可以完全不加,通过 ProductTagRepository 查询。我更倾向于后者,因为这样 Product 聚合不负责维护标签关系。 第三步,业务代码从 `product.getTags().add(tag)` 改为: ```java ProductTag productTag = new ProductTag(); productTag.setProduct(product); productTag.setTag(tag); productTag.setCreatedBy("laoli"); productTag.setCreatedAt(LocalDateTime.now()); productTagRepository.save(productTag); ``` 以后如果要加“删除原因”或者“有效期”,直接往 ProductTag 上加字段,完全不会影响 Product 和 Tag 本身的模型。中间实体成了业务概念的载体,而不是一段只有两个外键的隐式关系。 ## 08、小结 JPA 的关联映射,写注解只是第一步,真正的功夫在边界和所有权。owning side 和 mappedBy 决定外键由谁维护;Cascade 和 orphanRemoval 决定生命周期如何传播;双向同步决定内存状态是否一致;DDD 聚合边界决定对象图的复杂度上限。 回到最开始那个问题:为什么双向关系容易内存和数据库不一致?因为内存里有两个事实副本,数据库只有一个。谁负责同步,谁就是聚合根的方法。不要让外部代码直接伸出无数只手去改动集合,否则对象图迟早变成一团乱麻。 下次再写 @OneToMany 时,先停下来想一想:谁持有外键?谁来维护双向引用?子实体从集合移除时,是要删掉还是只解除关系?这三点想清楚,比记住一百个注解配置都有用。 ## 09、链接与概念说明 这里把文中反复提到的几个概念做一次集中说明: - owning side:持有外键列的关系方,数据库根据它来持久化关联。 - mappedBy:标注在非 owning side 上,表示“我不维护外键,只是对方关系的镜像”。 - Cascade:级联操作范围,告诉 JPA 父实体的哪些持久化操作要传递给子实体。 - orphanRemoval:孤儿删除开关,当子实体从父集合中移除时,是否直接删除数据库记录。 - 双向关系同步:在内存中同时维护两个方向的引用,通常封装在聚合根的方法里。 - DDD 聚合边界:将一个聚合根及其内部实体作为一个整体对外访问,外部不允许绕过聚合根直接修改内部实体。 建议进一步阅读:Hibernate 官方文档中关于 association 的部分;Vlad Mihalcea 的博客里有关双向关联同步、@ManyToMany 拆中间实体的文章;以及 Eric Evans 在《领域驱动设计》中关于聚合边界的论述。你不需要一次掌握全部,但每次写实体关系时,回来对照这几个概念,慢慢就能形成肌肉记忆。

资源下载

更多资源
优质分享App

优质分享App

近一个月的开发和优化,本站点的第一个app全新上线。该app采用极致压缩,本体才4.36MB。系统里面做了大量数据访问、缓存优化。方便用户在手机上查看文章。后续会推出HarmonyOS的适配版本。

腾讯云软件源

腾讯云软件源

为解决软件依赖安装时官方源访问速度慢的问题,腾讯云为一些软件搭建了缓存服务。您可以通过使用腾讯云软件源站来提升依赖包的安装速度。为了方便用户自由搭建服务架构,目前腾讯云软件源站支持公网访问和内网访问。

Nacos

Nacos

Nacos /nɑ:kəʊs/ 是 Dynamic Naming and Configuration Service 的首字母简称,一个易于构建 AI Agent 应用的动态服务发现、配置管理和AI智能体管理平台。Nacos 致力于帮助您发现、配置和管理微服务及AI智能体应用。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据、流量管理。Nacos 帮助您更敏捷和容易地构建、交付和管理微服务平台。

Rocky Linux

Rocky Linux

Rocky Linux(中文名:洛基)是由Gregory Kurtzer于2020年12月发起的企业级Linux发行版,作为CentOS稳定版停止维护后与RHEL(Red Hat Enterprise Linux)完全兼容的开源替代方案,由社区拥有并管理,支持x86_64、aarch64等架构。其通过重新编译RHEL源代码提供长期稳定性,采用模块化包装和SELinux安全架构,默认包含GNOME桌面环境及XFS文件系统,支持十年生命周期更新。

用户登录
用户注册