MyBatis-Plus、JPA、JOOQ 用了一圈后,我为什么还是自己写了个 ORM 这不是又一篇XX 框架最好的软广。这是一份选型笔记——记录我在不同项目里用过 MyBatis-Plus、Spring Data JPA、JOOQ 之后为什么最后还是决定自己造了一个 7000 行的 ORM。文章里所有代码都能跑所有缺点都不掩饰包括 DLZ-DB 自己的。一、开场:被 ORM 教做人的十年写 Java 近 20 年, 接触过无数的历史项目新项目, 从最开始的原生jdbc, 到 ORM, 主力从 Hibernate 一路用到 MyBatis、MyBatis-Plus,中间被一个金融项目逼着学了 JOOQ。每换一个我都以为这次终于对了,然后被现实揍醒:Hibernate 时代:被一个延迟加载的LazyInitializationException折磨了一整周MyBatis 时代:在 Mapper.xml 里foreach套choose套if,写完自己都看不懂MyBatis-Plus 时代:6 个文件起步的 Entity/Mapper/Service/ServiceImpl/Controller/DTO,简单 CRUD 写得想哭JOOQ 时代:每次改表结构都要重跑codegen,CI 流水线慢了 5 分钟最后我接手一个 SaaS 多租户项目,租户数据源要动态注册。DS(tenant_xxx)是编译期注解硬编码的——我对着它瞪了一下午,然后开了个新仓库。那个仓库后来变成了 DLZ-DB。下面是这 4 个框架(加上 DLZ-DB)在三个真实场景里的对比。代码我都跑过,包括别家的——尽量公平。二、60 秒认识 4 个对手框架一句话定位代表用户MyBatis-PlusMyBatis 增强工具,国内 ORM 第一阵营80% 的国内 Java 项目Spring Data JPAJSR-338 标准实现(底层 Hibernate)欧美主流、国内大厂部分团队JOOQ类型安全的 SQL DSL,靠代码生成器金融、报表、SQL 重度项目DLZ-DB7000 行的轻量 ORM,链式 API 零 Mapper中小项目、SaaS、AI 辅助开发定位不同,对比的目的不是谁赢,而是在你的场景下谁更顺手。三、场景实战:同需求,4 套写法场景 A:给sys_config表加 CRUD需求:四个字段(id、key、value、remark),要 5 个接口(增、改、删、按 key 查、列表)。MyBatis-Plus(5 个文件)// 1. EntityDataTableName(sys_config)publicclassSysConfig{Longid;StringconfigKey;StringconfigValue;Stringremark;}// 2. MapperpublicinterfaceSysConfigMapperextendsBaseMapperSysConfig{}// 3. Service 接口publicinterfaceSysConfigServiceextendsIServiceSysConfig{}// 4. ServiceImplServicepublicclassSysConfigServiceImplextendsServiceImplSysConfigMapper,SysConfigimplementsSysConfigService{}// 5. ControllerRestControllerRequestMapping(/config)publicclassSysConfigController{AutowiredSysConfigServiceservice;GetMapping(/{key})publicSysConfiggetByKey(PathVariableStringkey){returnservice.lambdaQuery().eq(SysConfig::getConfigKey,key).one();}// ... 另外 4 个接口}注:MP 现在支持lambdaQuery()链式形式,比new LambdaQueryWrapper()简洁不少,我用了较新的写法。Spring Data JPA(3-4 个文件)EntityTable(namesys_config)DatapublicclassSysConfig{IdGeneratedValueLongid;StringconfigKey;StringconfigValue;Stringremark;}publicinterfaceSysConfigRepositoryextendsJpaRepositorySysConfig,Long{OptionalSysConfigfindByConfigKey(Stringkey);}RestControllerRequestMapping(/config)publicclassSysConfigController{AutowiredSysConfigRepositoryrepo;GetMapping(/{key})publicSysConfiggetByKey(PathVariableStringkey){returnrepo.findByConfigKey(key).orElse(null);}// ...}JPA 的方法名推导很爽——但仅限于简单查询。JOOQ(生成代码 调用)// 生成的代码:SysConfigRecord, Tables.SYS_CONFIG(自动)// 业务代码RestControllerRequestMapping(/config)publicclassSysConfigController{AutowiredDSLContextdsl;GetMapping(/{key})publicSysConfigRecordgetByKey(PathVariableStringkey){returndsl.selectFrom(SYS_CONFIG).where(SYS_CONFIG.CONFIG_KEY.eq(key)).fetchOne();}}类型安全到极致——表名字段名全是常量。但你得维护 codegen 流水线。DLZ-DB(2 个文件)DataTableName(sys_config)publicclassSysConfig{Longid;StringconfigKey;StringconfigValue;Stringremark;}RestControllerRequestMapping(/config)publicclassSysConfigController{GetMapping(/{key})publicSysConfiggetByKey(PathVariableStringkey){returnDB.Pojo.select(SysConfig.class).eq(SysConfig::getConfigKey,key).queryBean();}// 增删改查全部 1 行}没有 Mapper,没有 Service 接口,没有 codegen。但代价是——你失去了标准分层。场景 B:动态条件 分页需求:根据传入参数动态拼条件(status 可空、name 可空模糊、时间区间可空),按创建时间倒序分页。MyBatis-PlusPageUserpageuserService.lambdaQuery().eq(status!null,User::getStatus,status).like(StrUtil.isNotBlank(name),User::getName,name).ge(startTime!null,User::getCreateTime,startTime).orderByDesc(User::getCreateTime).page(newPage(pageNum,pageSize));MP 的三参eq(condition, column, value)设计得不错,动态条件可读性还行。Spring Data JPA(Specification)SpecificationUserspec(root,query,cb)-{ListPredicatepsnewArrayList();if(status!null)ps.add(cb.equal(root.get(status),status));if(StrUtil.isNotBlank(name))ps.add(cb.like(root.get(name),%name%));if(startTime!null)ps.add(cb.greaterThanOrEqualTo(root.get(createTime),startTime));returncb.and(ps.toArray(newPredicate[0]));};returnuserRepo.findAll(spec,PageRequest.of(pageNum,pageSize,Sort.by(createTime).descending()));JPA 在动态查询上最啰嗦。Criteria API 是 JSR 标准的代价。JOOQConditioncondnoCondition();if(status!null)condcond.and(USER.STATUS.eq(status));if(StrUtil.isNotBlank(name))condcond.and(USER.NAME.like(%name%));if(startTime!null)condcond.and(USER.CREATE_TIME.ge(startTime));ListUserRecordlistdsl.selectFrom(USER).where(cond).orderBy(USER.CREATE_TIME.desc()).limit(pageSize).offset((pageNum-1)*pageSize).fetch();类型安全很好,但分页要手动算 offset,没有原生Page对象。DLZ-DBreturnDB.Pojo.select(User.class).eq(status!null,User::getStatus,status).like(StrUtil.isNotBlank(name),User::getName,name).ge(startTime!null,User::getCreateTime,startTime).orderByDesc(User::getCreateTime).queryPage(pageNum,pageSize);和 MP 风格一致——这点 DLZ-DB 没有刻意标新立异,直接借鉴了 MP 的成熟设计。小结:动态查询场景下,MP 和 DLZ-DB 写法几乎并列第一,JPA 最啰嗦,JOOQ 居中。场景 C:JSON 字段查询 部分更新需求:user表有profileJSON 字段({address:{city:杭州}}),要按 city 查询,并支持只更新 city 不动其他字段。MyBatis-Plus// 查询:写原生 SQL 用 JSON_EXTRACTSelect(SELECT * FROM user WHERE JSON_EXTRACT(profile, $.address.city) #{city})ListUserfindByCity(Stringcity);// 更新:JSON_SETUpdate(UPDATE user SET profile JSON_SET(profile, $.address.city, #{city}) WHERE id #{id})intupdateCity(Longid,Stringcity);// 实体上配 TypeHandler 才能映射成对象TableField(typeHandlerJacksonTypeHandler.class)privateUserProfileprofile;JPA// AttributeConverter 处理 JSON ↔ 对象Convert(converterUserProfileConverter.class)privateUserProfileprofile;// 查询要写原生 SQL 或 Hibernate JSON 扩展Query(valueSELECT * FROM user WHERE profile-$.address.city :city,nativeQuerytrue)ListUserfindByCity(Param(city)Stringcity);部分更新更痛苦——读出来 → 改对象 → 整个写回。JOOQ// 需要 jOOQ Pro 才有完整 JSON 支持;社区版要手写 SQLdsl.selectFrom(USER).where(jsonValue(USER.PROFILE,$.address.city).eq(city)).fetch();DLZ-DB// 查询ListResultMaplistDB.Table.select(user).eq(profile.address.city,杭州)//内测中,使用方式可讨论.queryResultMapList();// 部分更新DB.Table.update(user).set(profile.address.city,上海)//内测中,使用方式可讨论.eq(id,userId).execute();// 路径取值ResultMaprDB.Table.select(user).eq(id,1).queryOne();Stringcityr.getStr(profile.address.city,未知);ResultMap继承自JSONMap,路径访问是原生能力,底层自动转JSON_EXTRACT/JSON_SET。小结:JSON 字段是 DLZ-DB 设计上的差异化优势——它没把数据当严格 ORM 实体看,所以反而处理半结构化数据更顺手。四、多维度评分(中立版)我把容易造假数据的指标都去掉了,只留可以定性判断的维度:维度MyBatis-PlusJPAJOOQDLZ-DB学习曲线低中高低代码量(简单 CRUD)中中中低动态查询易用度高低中高复杂 JOIN / 报表 SQL高(XML)低高(类型安全)高(SQL 模板)SQL 热更新(改完不重启)弱弱弱(要重跑 codegen)高(在线编辑热加载)类型安全中中高中启动时间中慢中快运行时动态数据源中(DS)弱中高JSON 字段支持弱弱中高生态/插件丰富度高高中低社区规模极高(16k Star)极高中低(起步)文档完整度高极高高中AI 辅助友好度中中中高二级缓存 / 乐观锁等高级特性中高中弱启动时间没给具体数字,因为它高度依赖项目规模、JVM 参数、扫描范围。但定性结论是稳的:JPA 因为要扫实体 DDL 对比通常最慢,DLZ-DB 因为没有 Mapper/XML 扫描通常最快。五、被低估的强项:报表 SQL 与热植入很多人以为 DLZ-DB 是个只会单表 CRUD 的轻量库,复杂报表得另请高明。恰恰相反——报表和复杂自定义 SQL,是 DLZ-DB 灵活度最高的地方。它通过预设 SQL(Key-SQL)来管理复杂查询,既能写任意原生 SQL,又带了几个 MyBatis XML 都不具备的能力。1. SQL 模板:动态条件 嵌套复用预设 SQL 用[...]包裹动态条件,参数为空自动忽略,不用写if标签:sqlsqlIdkey.report.orderStat![CDATA[ SELECT d.dept_name, COUNT(o.id) AS cnt, SUM(o.amount) AS total FROM orders o JOIN dept d ON o.dept_id d.id WHERE 11 [AND o.status #{status}] [AND o.create_time #{startTime}] [AND o.create_time #{endTime}] GROUP BY d.dept_name [HAVING SUM(o.amount) #{minTotal}] ]]/sql调用时传哪个参数,哪段条件就生效:ListResultMaprowsDB.Sql.select(key.report.orderStat).addPara(status,1).addPara(startTime,2026-01-01)// endTime / minTotal 不传对应条件自动消失.queryResultMapList();多表 JOIN、GROUP BY、HAVING、子查询——任意原生 SQL 都能写。还支持#{key.xxx}嵌套引用其他预设 SQL 片段,做条件复用。2. 方言感知同一个 sqlKey 可以为 MySQL、PostgreSQL、Oracle 配不同方言版本,运行时按当前数据源类型自动选用(源码SqlHolder的m_dialect_sql)。3. SQL 热植入:在配置画面改 SQL,不重启生效这是 MyBatis / JOOQ 都做不到的:预设 SQL 可以存在数据库表里,通过后台配置画面在线编辑,改完热加载即时生效,无需重新打包、无需重启服务。开启很简单:dlz:db:use-db-sql:truesql:select sql_key as k, sql_value as s from sys_sql# 默认即此可自定义启动时框架会执行这条 SQL,把sys_sql表里的每一行加载成预设 SQL(源码SqlHolder.loadDbSql())。你只要做一个管理sys_sql表的配置页面:// 配置画面保存按钮写入/更新一条报表 SQLSysSqlsqlnewSysSql();sql.setSqlKey(report.orderStat);sql.setSqlValue(SELECT ... FROM orders o JOIN dept d ON ... [AND o.status#{status}]);DB.Pojo.insertOrUpdate(sql);// 然后热加载新 SQL 立即可用无需重启SqlHolder.reLoad();// 清空内存 SQL 缓存并从文件 数据库重新加载reLoad()会清掉内存里的 SQL 缓存,重新从.sql文件和数据库表加载一遍(源码SqlHolder.reLoad()→load()loadDbSql())。这意味着报表 SQL 可以交给运营/实施人员在后台改,改完点一下刷新就生效——对那些客户天天要改报表口径的项目,这是杀手级能力。横向对比:改一条报表 SQL 要付出什么框架改报表 SQL 的代价MyBatis / MyBatis-Plus改 XML → 重新打包 → 重启服务JPA改Query/ 原生 SQL → 重新编译 → 重启JOOQ改 DSL → 重跑 codegen → 重新编译 → 重启DLZ-DB配置画面改sys_sql→ 点刷新 → 立即生效所以严格说,报表/复杂自定义 SQL 的灵活度上,DLZ-DB 是这几个框架里最优的。它不跟你比 Lambda 拼 JOIN,它直接让你写最原始、最可控的 SQL,还能热更新。六、DLZ-DB 不适合什么(认真讲)横评写到这里,我必须老实交代 DLZ-DB 的短板,否则前面再客观也没意义。1. Lambda 构造器不做类型安全的 JOIN需要说明的是:这是Lambda 链式 API的边界,不是 DLZ-DB 处理复杂查询的边界。DB.Pojo的 Lambda 构造器(.eq()/.like()那套)定位是单表 CRUD,它不提供JOIN ... ON的类型安全表达式。如果你想要 JOOQ 那种USER.DEPT_ID.eq(DEPT.ID)编译期校验的多表连接,DLZ-DB 没有。但复杂 JOIN、报表、多表聚合,DLZ-DB 是用预设 SQL 模板解决的——见下一节,那其实是它的强项。这里只是提醒:别指望 Lambda 构造器替你拼 JOIN。2. 团队规范强制 Mapper/Service 分层如果你的团队架构规范明确要求Controller → Service → Mapper → DAO三层分层,DLZ-DB 的在 Controller 里直接调DB.Pojo会让代码审查直接打回。这不是技术问题,是组织问题。3. 需要重 ORM 特性的项目二级缓存、乐观锁版本号、OneToMany级联保存、懒加载、JPA 标准 API 兼容——这些 DLZ-DB 都没有。需要的话,JPA/Hibernate 才是正道。4. 生态 / 社区MyBatis-Plus 16k Star,分页插件、代码生成器、动态权限插件一抓一大把。DLZ-DB 刚开源不到一年,遇到问题你可能需要自己跟源码——好处是核心 7000 行你确实能跟得动,坏处是 Stack Overflow 上搜不到答案。5. 预设 SQL 的 IDE 支持DB.Sql.select(key.user.find)的字符串 key 重构时不会被 IDE 自动跟随。MyBatis 的 Mapper 接口 XML 一一对应在这方面更直观。一句话总结:如果你已经在 MP 生态里跑得很顺、团队规范严格、需要重 ORM 特性,不一定要换。DLZ-DB 是给那些被 MyBatis-Plus 的样板代码磨得没脾气、但又不想要 JPA 黑魔法的项目准备的。七、选型决策树把上面的内容压缩成一张决策图:需要类型安全的多表 JOIN 编译期校验? ├── 是 → JOOQ(DSL 类型安全) └── 否 ↓ 报表 SQL 要频繁改、最好能在后台热更新? ├── 是 → DLZ-DB(预设 SQL 热植入) └── 否 ↓ 团队规范强制 Mapper/Service 三层分离? ├── 是 → MyBatis-Plus(生态完整、规范契合) └── 否 ↓ 需要标准 ORM(级联、二级缓存、跨数据库迁移)? ├── 是 → Spring Data JPA └── 否 ↓ 是 SaaS / 多租户 / 动态数据源 / JSON 字段重度场景? ├── 是 → DLZ-DB(设计契合) └── 否 ↓ 追求少写代码 快速迭代 AI 友好? ├── 是 → DLZ-DB └── 否 → MyBatis-Plus(默认稳妥选择)八、写在最后10 年前我以为框架是越功能多越好;5 年前我以为框架是越约定优于配置越好;今天我觉得——框架的价值,是让你做决定的时候少写样板代码,而不是替你做决定。MyBatis-Plus、JPA、JOOQ 都是好框架,它们在各自的领域里很成熟。DLZ-DB 不是来取代它们的,它只是给现有框架不够顺手的场景多一个选项。如果你看完这篇还想试一下 DLZ-DB:项目:dlz-dbMaven:top.dlzio:dlz-db-spring-boot-starter(或dlz-db-solon-plugin)GitHub:https://github.com/dingkui/dlz-dbGitee:https://gitee.com/dlzio/dlz-db如果你觉得本文有失偏颇,欢迎在评论区贴你的反例代码——我会更新到下一版对比里。本文所有代码示例均可运行,DLZ-DB 部分来自项目测试用例DbPojoTest.java、DbBatchTest.java。MyBatis-Plus / JPA / JOOQ 示例基于其官方文档当前版本(MP 3.5、Spring Data JPA 3.x、JOOQ 3.18)。