
做 Java 后端开发的朋友多半都跟 Hibernate 打过交道。网上时不时有人问“Hibernate 还有人用吗”但只要你在用 JPA 规范、或者接手过老项目迟早会撞上它的延迟加载机制。面试高频题里有它线上性能事故里也有它尤其是那句“LazyInitializationException”几乎每个用 Hibernate 的人都至少被它折磨过一次。这篇文章我就把 Hibernate 延迟加载的配置方式、底层逻辑和踩坑经验一次性讲清楚适合正在学 Hibernate 的初学者也适合想系统梳理延迟加载配置的实战开发者。先说一句总纲延迟加载并不是“配置越复杂越好”而是“知道什么场景该用哪种配置”。所以我不光会给你 XML 和注解的写法还会解释每一步背后的原理以及那些配置组合起来会产生什么副作用。把这套东西理顺了你就能做到既不错用懒加载又能避开 N1 查询这类隐形炸弹。1. 为什么需要延迟加载先搞懂它解决什么问题1.1 ORM 的性能陷阱关联对象查询的开销Hibernate 是 ORM 框架核心工作是让 Java 对象和数据库表之间建立映射。你写一个session.get(Order.class, 1L)Hibernate 会先查订单表这是直觉上的操作。但麻烦出在关联对象上一张订单往往关联多个订单明细OrderItem、关联一个客户Customer如果 Hibernate 每次加载 Order 时都把这几张表全部 join 出来查询开销会成倍上升。你可以算一笔粗账假设一个订单有 20 条明细关联 1 个客户。如果采用全量立即加载一次get操作可能产生 3 条 SQL如果列表页要展示 100 个订单数据库就要处理 300 次数据装配。实际上这还是比较理想的情况真实系统里对象嵌套的深度可能达到三层、四层全量立即加载会在根节点上做“爆炸式”的关联查询很多慢 SQL 就是这么来的。延迟加载解决的正是这个问题先只加载当前必需的根对象关联对象先不查等真正用到的时候再触发额外的 SQL 查询。一句话概括就是“用到才查不用不查”。这种策略牺牲了一点即时访问的便利换来了主查询路径的大幅瘦身。1.2 延迟加载的核心原理代理对象和持久化上下文Hibernate 的延迟加载不是靠魔法实现的它的底层是 Java 动态代理机制。当你配置了延迟加载的关联属性Hibernate 不会直接返回真实实体对象而是返回一个代理对象Proxy。这个代理对象继承了目标实体类表面上看类型没变但内部多了一个“拦截器”。代理对象在被访问某个属性方法时会先检查对应的关联数据是否已经初始化。如果没有则获取当前 Session准确说是持久化上下文来执行 SQL把真实数据加载进去然后再把调用转发给真实对象。这就是为什么“在 Session 还开着的时候访问懒加载属性会自动触发 SQL”而“Session 关闭后访问就会抛异常”的根本原因。这里要注意一个关键点延迟加载必须依赖持久化上下文Session 缓存。Session 不仅是一堆 API 的门面它内部维护着实体状态、快照、一级缓存代理对象完成初始化需要借助这个上下文去查询数据库。一旦 Session 关闭代理对象就失去了“后勤保障”再去触碰它会直接抛LazyInitializationException。1.3 延迟加载与立即加载的取舍延迟加载不是银弹它和立即加载各有利弊。立即加载的好处是对象图完整离开了 Session 照样能访问关联数据适合那些业务上“一定要用到关联数据”的场景比如订单详情页需要同时展示订单头、明细、和收货人信息。坏处是查询成本高而且如果开发人员顺手写了个列表查询每个订单对象都会把全部关联拖出来很容易造成性能灾难。延迟加载的好处是性能开销可控启动快适合列表页、概要查询等大多数场景。但代价是“调用时机敏感”把实体类传到 Service 层之外、或者做 JSON 序列化时一不留神就会触发异常或者产生一串意外 SQL。所以我的经验是默认配置尽量开启延迟加载然后在明确需要关联数据的查询里用 join fetch、HQL 显式指定加载策略来覆盖默认行为。这种“默认懒、局部勤”的组合是实战中比较稳妥的思路后面我会详细展开讲。2. 延迟加载的三种配置维度class、property、collection2.1 实体级别的 lazy 配置类上的开关Hibernate 的懒加载配置在不同层级上有不同含义最容易被忽略的是实体类级别的配置。在 hbm.xml 映射文件里class标签上有一个lazy属性默认值是true。class namecom.example.Order tablet_order lazytrue id nameid columnorder_id/ property nameorderNo columnorder_no/ many-to-one namecustomer classcom.example.Customer columncustomer_id lazyproxy/ /class类级别的lazytrue意味着session.load()方法会返回一个代理对象而不会立刻查库。这个配置在早期 Hibernate 版本里默认值是false从 Hibernate 3 开始才调整为true。很多老人习惯说“load 是懒加载get 是立即加载”其实这个行为差异就是由类上的 lazy 配置决定的。实际操作中类级别的lazytrue配置你一般不用改动保持默认即可。但有一个点要留意如果某个类没有无参构造函数或者类是 final 的Hibernate 就无法生成代理子类这时lazytrue配置会失效Hibernate 可能运行时报错或者自动降级为立即加载。这是一个非常隐晦的坑排查的时候往往要查很久。2.2 属性级别的懒加载字节码增强这个少见选项你可能会觉得奇怪属性还能单独设置懒加载确实可以。在 hbm.xml 中property标签也支持lazy属性比如某个大文本字段content你可以在映射里这样写property namecontent columncontent typetext lazytrue/这种属性级别的懒加载有个前提条件必须启用 Hibernate 的字节码增强功能。普通的代理是类级别的也就是整个实体被代理而属性级别的懒加载需要在编译期对实体类进行字节码增强把属性的 getter 方法改造成“访问时再加载”。字节码增强配置用起来不太方便需要引入专门的 Maven 插件而且增强了之后实体类的表现会和普通 POJO 有些差异对象被反序列化或者通过反射访问属性时可能会绕过增强逻辑造成数据加载不完整。我在实际项目里几乎没有见过团队用属性级懒加载因为大字段的问题可以通过分表、单独查询等手段解决没必要在 ORM 层引入这种编译期魔法。2.3 集合级别的懒加载最常用、也是坑最多的配置集合属性的懒加载才是 Hibernate 日常开发中接触最频繁的部分。set、list、bag、map这些集合映射标签都有lazy和fetch两个核心属性。set nameitems tablet_order_item lazytrue fetchselect key columnorder_id/ one-to-many classcom.example.OrderItem/ /setJPA 注解方式对应为OneToMany(mappedBy order, fetch FetchType.LAZY) private ListOrderItem items new ArrayList();集合懒加载的默认策略是lazytrue也就是返回一个PersistentSet或PersistentBag这些集合类型不是普通的HashSet、ArrayList它们内部持有 Session 引用在迭代、获取 size、contains 等操作时才会触发初始化 SQL。这里有一个非常有意思的细节同样是懒加载集合Set 和 List 的行为不完全一样。Hibernate 对List的处理依赖索引列底层默认会把 List 映射为 Bag 类型而 Bag 允许重复元素、没有索引概念初始化时也稍有差异。这个差异在equals和hashCode方法实现不当的时候会引发奇怪的 bug比如把实体放进HashSet结果出现重复对象。3. 深入 fetch 策略select、join、batch、subselect3.1 fetchselect最朴素的逐条查询fetchselect是 Hibernate 集合属性的默认抓取策略含义是“当需要初始化这个懒加载集合时额外执行一条 select 查询”。它的执行时序是这样的先查 Order 主表等到代码真正访问 order.getItems() 时再执行类似select * from t_order_item where order_id ?的查询。这种方式逻辑最简单、最容易理解但陷阱在于循环访问时会引出经典的 N1 查询。我用一个具体场景说明查询 10 个订单每次访问order.getItems()都会发一条明细查询 SQL最终会变成 1 次主查询加 10 次明细查询。如果你做了嵌套两层关联比如订单还要访问明细里的商品信息那就可能变成 1 次主查询加 10 次明细查询再加 100 次商品查询。SQL 数量爆炸就在一瞬间。fetchselect适合数据量不大、或者单条数据详情展示的场景。如果你要做列表页就必须用下面几种策略来优化。3.2 fetchjoinSQL 层面先 join 再说fetchjoin的含义是在主查询里就通过 SQL 的 join 或者 fetch join 把关联数据一并查出来相当于“配置层面的立即加载”。set nameitems tablet_order_item lazytrue fetchjoin/当你用session.get()或者 HQL 查询加载 Order 时Hibernate 会自动生成一条 join 查询把 items 一起初始化。注意这里的“懒加载配置”和“感知层面”是有微妙冲突的你明明设置了lazytrue但fetchjoin会让它“不懒”因为关联数据已经随着主查询一次性加载完成了。这个策略也有坑它产出的 SQL 中 join 出来的数据是重复行。比如一个订单有 20 条明细查询结果集就是 20 行Hibernate 底层需要做去重和对象装配。如果两个集合都设置成 fetchjoin比如一个订单同时 join 明细和物流记录结果集的笛卡尔积会让数据量暴涨这时的 SQL 效率反而比 select 策略更低。所以fetchjoin只建议用于“单集合关联、且业务上必然要立即用到”的场景。多集合、多层关联时禁止滥用。3.3 batch-size批量延迟加载的实战配置Hibernate 的batch-size是一个极其实用、但很多人不知道的参数。它可以配置在集合标签或实体标签上作用是把多个懒加载请求合并成一条 in 查询。我们继续用订单和明细的例子当 10 个订单的 items 都处于懒加载状态时如果一次性循环访问它们默认会产生 10 条查询 SQL。但如果配置了batch-size10Hibernate 会先收集当前 Session 中 10 个订单的 ID然后拼接一条select * from t_order_item where order_id in (?, ?, ...)查询一次性把 10 个订单的明细全部加载出来。XML 配置方式set nameitems tablet_order_item lazytrue fetchsubselect batch-size10 key columnorder_id/ one-to-many classcom.example.OrderItem/ /setJPA 注解方式OneToMany(mappedBy order, fetch FetchType.LAZY) BatchSize(size 10) private ListOrderItem items new ArrayList();还有一个全局配置项hibernate.default_batch_fetch_size可以给所有实体设置默认批量大小。我在实践中建议全局配置 15 到 20 之间注意不要设得太大因为 in 查询的列表过长也会造成 SQL 性能下降和数据库解析压力增加。batch-size的优点是不会改变你的查询触发方式还是正常的按需懒加载但把多条 SQL 合并成一条属于比较优雅的折中方案。3.4 fetchsubselect基于子查询的批量加载与batch-size类似的还有一个策略叫fetchsubselect。它同样是解决 N1 的问题但思路不同当某个实体的懒加载集合被访问时取“最初加载这些实体的查询条件”拼成一条子查询来一次性加载所有关联集合。举个例子先用 HQL 查出 10 个订单查询条件是from Order o where o.status 1然后访问第一个订单的 itemsHibernate 不是发order_id in (1,2,...)的查询而是拼出下面这样的 SQLselect * from t_order_item where order_id in (select order_id from t_order where status 1)注意subselect策略会在查询条件上做文章这在某些情况下会带来可观的性能提升因为它把多个离散 ID 的 in 查询换成了基于原查询条件的子查询。但这个策略有个缺点如果最初的查询条件本身很复杂、代价高子查询的代价也不会低。而且在一级缓存、分页查询等场景下子查询的语义和实际预期可能有偏差调试起来比较费劲。所以subselect我对它的评价是“有能力但不好驾驭”适合你对 SQL 生成逻辑很熟悉、而且能掌控查询前提的场景。一般团队里我更推荐把batch-size设好大部分情况就够用了。4. 实操配置XML 和注解两种家族要分清4.1 传统 XML 映射方式完整示例如果你接管的是老项目很可能还在用 hbm.xml 映射文件。这种方式的配置粒度非常细每个关联属性都能单独控制。我整理一份比较有代表性的完整配置hibernate-mapping packagecom.example class nameOrder tablet_order lazytrue id nameid columnorder_id generator classnative/ /id property nameorderNo columnorder_no/ property nameamount columnamount/ !-- 多对一默认 proxy 懒加载 -- many-to-one namecustomer classCustomer columncustomer_id lazyproxy fetchselect/ !-- 一对多默认 true 懒加载 -- set nameitems tablet_order_item lazytrue fetchselect batch-size15 key columnorder_id/ one-to-many classOrderItem/ /set /class /hibernate-mapping要点拆开讲一下。many-to-one的lazy有三个可选值proxy、no-proxy、false。proxy表示用代理实现懒加载这是默认推荐值no-proxy需要在编译期做字节码增强效果是“访问具体属性时才加载”平时很少用false就是立即加载。如果你写博客或者教学示例经常会看到lazyfalse的写法但在线上系统里请尽量不要这么干除非你确认这个多对一关联的查询频率特别高、而且数据量不大。配置完 XML 之后记得要在hibernate.cfg.xml或者 Spring 的sessionFactory配置里引入映射文件property namehibernate.default_batch_fetch_size15/property mapping resourcecom/example/Order.hbm.xml/4.2 JPA 注解方式完整示例现在的 Spring Boot 项目几乎不用 XML而是用 JPA 注解。注解方式的默认规则和 Hibernate 原生不同OneToMany和ManyToMany默认FetchType.LAZYManyToOne和OneToOne默认FetchType.EAGER。这个差异极其关键很多踩坑案例都源于不理解这个默认值差异。完整示例Entity Table(name t_order) public class Order { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String orderNo; ManyToOne(fetch FetchType.LAZY) JoinColumn(name customer_id) private Customer customer; OneToMany(mappedBy order, fetch FetchType.LAZY) BatchSize(size 15) private ListOrderItem items new ArrayList(); }这里我强烈建议不要相信“默认值就是最佳实践”这句话。ManyToOne默认是 EAGER它意味着只要查订单就必然会查出客户。如果这个客户信息在业务上大部分时候不需要那就是白白的 join 开销。我的做法是在实体设计阶段就把每个关联属性显式写成FetchType.LAZY即使使用默认值的地方也写出来一目了然后来维护的人也不会误判意图。还有一个小细节注解的包名要写对实体上用的是javax.persistence.FetchType或jakarta.persistence.FetchType取决于你用的 Spring Boot 版本而不是 Hibernate 的org.hibernate.annotations.Fetch。Hibernate 的Fetch注解是补充原生策略用的比如Fetch(FetchMode.SUBSELECT)这两个概念容易搞混。4.3 Spring Boot 集成项目中的全局配置要点在 Spring Boot 项目中配置 Hibernate 延迟加载主要是通过application.yml来设置全局参数spring: jpa: hibernate: ddl-auto: none properties: hibernate: default_batch_fetch_size: 15 show_sql: true format_sql: true open-in-view: false这里有几个参数可以重点解释一下。show_sql和format_sql是调试利器建议开发环境开着生产环境关闭。default_batch_fetch_size是全局批大小。open-in-view是 Spring Boot 2.0 之后争议比较大的参数默认值是true它会保证整个 HTTP 请求周期内 Session 不关闭从而避免视图层访问懒加载数据时抛LazyInitializationException。但你往下看我会专门说这个默认值在现代架构里往往弊大于利。另外再提一句hibernate.enable_lazy_load_no_trans这个参数是一个“看起来很美”的坑。开启之后即使没有事务Hibernate 也会为每次懒加载临时开一个事务表面上解决了异常实际上会带来大量短连接和性能问题。这个参数我是坚决不推荐使用的。5. 经典坑LazyInitializationException 与 N1 查询5.1 LazyInitializationException 的产生与解决思路这是 Hibernate 最著名的运行时异常几乎每个使用延迟加载的人都会遇到。它的出现总是伴随同样的关键信息could not initialize proxy - no Session。这个异常通常在 Session 关闭之后代码仍然访问代理对象或未初始化的懒加载集合时抛出。形成过程一般都是这样的Service 层开启事务加载 Order 实体后返回给 Controller此时事务提交、Session 关闭Controller 层或者视图层里调用order.getCustomer().getName()触发了对懒加载代理的访问但 Session 已经没了。解决办法我自己总结有四种按推荐程度排列在事务边界内完成数据访问在 Service 层就提前把需要的关联数据取出来组合成 DTO 返回。这是最干净、最推荐的方式。使用 join fetch 强制初始化在查询阶段就用 fetch join 把需要的对象图一次性加载出来跳出LazyInitializationException。使用 Open Session in View让 Session 生命周期覆盖整个请求省的改业务逻辑但要看下面说的副作用。全局开启enable_lazy_load_no_trans绝对别用这条它会掩盖问题同时制造更多新问题。5.2 Open Session in View便捷还是纵容Spring Boot 默认把spring.jpa.open-in-view设为 true很多团队并不知道这个默认值的存在。它的工作机制是注册一个 Filter在请求开始时绑定 Session 到当前线程请求结束时解除绑定。这样 Controller 和视图层访问懒加载属性时Session 仍然可用LazyInitializationException自然就不会发生。但便利的代价非常大。首先它无限拉长了数据库连接和 Session 的持有时间数据库连接是稀缺资源一个请求可能只做了少量查询却把连接占用了整个请求周期。其次它掩盖了 N1 查询问题让开发人员无意识地写出高 SQL 数量的代码性能问题在压测时才集中爆发。再者在多线程模型和长耗时任务里这种方式会带来意想不到的 Session 线程绑定问题。所以我把open-in-view称之为“纵容模式”。如果你在维护老项目一时半会儿改不了代码可以先用它顶着如果是新项目我建议直接设置false从第一天起就用 DTO 和显式查询来管理对象图。5.3 N1 查询问题与实战应对方案N1 是延迟加载配置里最经典、最常被考的性能问题。它说的是查询 1 个主实体但为了把 N 个关联数据都加载出来额外执行了 N 条查询。比如查 1 个订单然后访问它的 20 条明细如果加载每个明细又去查它的商品就变成 120 条 SQL。随着数据量增加SQL 数线性甚至指数增长。应对 N1 的方法我已经在前面零散提过这里给你一个完整的排查路径和决策表。场景推荐方案说明列表页展示关联数据可接受多条 SQLBatchSize(size15)把 N 条查询合并成 ceil(N/size) 条 in 查询详情页展示必须拿到完整对象图HQLjoin fetch用一条 SQL 取出所有关联数据关联层级多、对象图复杂拆分成多个 DTO 查询用查询接口或投影避免对象图膨胀访问频率很高的只读属性二级缓存缓存实体属性减少重复查询数据库动态过滤、聚合统计直接用 criteria / SQL 查询不要在 ORM 层做过度封装有个排查 N1 的辅助手段是开启 SQL 日志统计一个操作实际产生了几条 SQL。如果一次详情页操作产生了 50 条 SQL那你就要考虑是不是循环里访问了懒加载属性。实际经验中50% 以上的 N1 问题都出在 for 循环访问集合属性代码里一个不起眼的for (Order order : orders) { order.getItems().size(); }就能在日志里引爆大量查询。6. 常见问题速查与避坑清单6.1 问题速查表我把自己这些年攒的经验整理成一张速查表。这表适合贴在你的开发文档里遇到对应问题直接按表排查。现象可能原因解决方案访问懒加载属性抛 LazyInitializationExceptionSession 已关闭代理对象访问事务内访问/join fetch/DTO日志里出现了海量关联查询N1循环访问懒加载集合batch-size、join fetch设置了 lazytrue但查询仍带出全部关联默认 EAGER一对多常见显式修改为 FetchType.LAZYsession.load()返回后立即访问报错类 no-args 缺失或 final代理失效补全无参构造、去掉 final两个集合配置 join fetch笛卡尔积导致结果集膨胀拆查询或改成 select/batchJSON 序列化时抛异常Jackson 访问了 lazy 属性使用 DTO或配置序列化排除equals/hashCode 引用了懒加载属性比较对象时触发 SQL用 ID 作为 equals 依据6.2 几个我踩过的坑第一个坑是toString() 方法。我在一个老项目里看到实体重写了toString里面拼了关联对象的信息。平时单条查询时没什么问题一旦在日志监控、异常输出、调试器里触发了toString就会无声无息地把懒加载 Collection 初始化掉等真正常规查询的时候发现 SQL 比预期多了一倍。排查了半天最终定位到是 Tomcat 的异常日志打印触发的。第二个坑是Jackson 序列化。如果把 Hibernate 实体直接作为 Controller 的返回对象Jackson 为了序列化关联属性会调用 getter 方法这会触发懒加载。结果要么是LazyInitializationException要么是序列化出来的对象图过于庞大、甚至出现循环引用。前端拿到 200 个订单的完整 JSON体积直接爆表。解决办法是统一返回 DTO只在 DTO 里放前端真正需要的字段。第三个坑是分页查询时的 count 查询与懒加载之间的关联。Hibernate 在分页时通常会执行一条 count 查询如果你在分页结果集里继续访问懒加载属性又会在每行上触发额外查询。我见过一个后台管理系统的列表页一次请求产生了两百多条 SQL查询速度从 50 毫秒变成 3 秒。最后就是靠batch-size20和查询里增加join fetch才压回来。6.3 配置和性能排查的日常建议日常开发里我建议你养成几个习惯。第一本地开发永远打开show_sql和format_sql每写完一个查询就盯一下日志里 SQL 的数量。第二写每个关联映射时都显式标注fetch FetchType.LAZY不要依赖 JPA 的默认值。第三对“返回实体给前端”保持警惕能转 DTO 就转 DTO这是最防弹的做法。如果你觉得手动盯 SQL 太累可以引入像 p6spy 这样的工具它会把 Hibernate 底层执行的 SQL 原样打印出来包括参数值、执行耗时。配合慢查询日志基本能定位绝大多数延迟加载相关的性能问题。7. 由配置引发的更深层思考延迟加载与对象关系映射的边界讲到这里延迟加载的配置方法已经比较完整了。但我想再往前推一步延迟加载表面上是个可以配置的开关实际上它背后暗含了一个团队对“对象模型边界”的认知。用过 Hibernate 的朋友大多体会过这种矛盾实体类设计得“很干净”关联关系画得很整齐但跑起来之后对象图的懒加载要么帮了你要么坑了你。这个不确定性来源于 ORM 隐藏了一个关键点——从数据库到对象图的完整实例化过程并不是原子的。你看到的 Order 对象可能只是“半个对象”它的关联属性还停留在代理状态。这在业务代码里会带来认知负担。所以慢慢你会发现延迟加载配置得像模像样还不够更稳妥的做法是把实体层和传输层拆开实体只负责持久化业务逻辑对外传输使用明确的 DTO。这样延迟加载的边界就被限定在了 Service/Repository 内部Controller、序列化、前端都不需要关心代理状态的问题。我把这个做法叫做“懒加载不出服务层”它比任何一种配置策略都更省心。实战中的取舍原则结合前面所有内容给你几条可以照着操作的原则默认全懒除极少数必要场景外所有关联都设FetchType.LAZY。查询显式加载在 Repository 的查询方法里用join fetch、EntityGraph指定本次查询需要的关联数据而不是改实体默认 fetch 策略。批量兜底设置全局default_batch_fetch_size15防止某些查询漏配置导致N1。禁止跨层访问懒加载Controller、前端不要直接依赖实体关联属性。不要开enable_lazy_load_no_trans用 DTO 和显式查询解决问题不要用全局开关掩盖问题。从配置到架构一个完整的实现片段最后给你一个可以直接抄作业的实践思路。假设有一个订单查询场景Controller 返回订单基本信息加明细数量不需要全部明细内容。Service 层Override Transactional(readOnly true) public OrderDetailDTO getOrderDetail(Long orderId) { Order order orderRepository.findWithItems(orderId); return OrderDetailDTO.from(order); }Repository 层Query(select distinct o from Order o left join fetch o.items where o.id :id) OptionalOrder findWithItems(Param(id) Long id);Controller 层只接收OrderDetailDTO不直接触碰实体关联。这样无论实体上的 lazy 配置是什么实际 SQL 都只有一条既不会抛异常也没有 N1。这种写法的好处是配置层的lazytrue是安全兜底查询层的join fetch是精准加载两者并行不悖。团队里有人忘了加 fetch 时兜底方案会保证不炸只是性能差一点加日志监控就能及时抓住。有人在不需要关联数据的时候误加了 fetch也问题不大最多多一个 join 的开销比 N1 好多了。我个人这十年用 Hibernate 的心得其实就是一句话配置是死的对象图是活的搞清楚对象图在哪个作用域内被访问你就知道延迟加载该怎么配了。遇到LazyInitializationException先不要焦虑先想清楚这个关联数据到底是该“查询时加载”还是“事务内加载”比换个全局开关要重要得多。最后再分享一个可以立刻用起来的小技巧把你的实体关联全部显式标注为FetchType.LAZY然后在 Repository 层给每个查询方法都设计好 fetch 策略写清楚“这个方法会加载哪些关联”这个 Project 到后面维护起来会舒服非常多。