当前位置: 首页 > news >正文

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. Entity@Data@TableName("sys_config")publicclassSysConfig{Longid;StringconfigKey;StringconfigValue;Stringremark;}// 2. MapperpublicinterfaceSysConfigMapperextendsBaseMapper<SysConfig>{}// 3. Service 接口publicinterfaceSysConfigServiceextendsIService<SysConfig>{}// 4. ServiceImpl@ServicepublicclassSysConfigServiceImplextendsServiceImpl<SysConfigMapper,SysConfig>implementsSysConfigService{}// 5. Controller@RestController@RequestMapping("/config")publicclassSysConfigController{@AutowiredSysConfigServiceservice;@GetMapping("/{key}")publicSysConfiggetByKey(@PathVariableStringkey){returnservice.lambdaQuery().eq(SysConfig::getConfigKey,key).one();}// ... 另外 4 个接口}

注:MP 现在支持lambdaQuery()链式形式,比new LambdaQueryWrapper<>()简洁不少,我用了较新的写法。

Spring Data JPA(3-4 个文件)
@Entity@Table(name="sys_config")@DatapublicclassSysConfig{@Id@GeneratedValueLongid;StringconfigKey;StringconfigValue;Stringremark;}publicinterfaceSysConfigRepositoryextendsJpaRepository<SysConfig,Long>{Optional<SysConfig>findByConfigKey(Stringkey);}@RestController@RequestMapping("/config")publicclassSysConfigController{@AutowiredSysConfigRepositoryrepo;@GetMapping("/{key}")publicSysConfiggetByKey(@PathVariableStringkey){returnrepo.findByConfigKey(key).orElse(null);}// ...}

JPA 的方法名推导很爽——但仅限于简单查询。

JOOQ(生成代码 + 调用)
// 生成的代码:SysConfigRecord, Tables.SYS_CONFIG(自动)// 业务代码@RestController@RequestMapping("/config")publicclassSysConfigController{@AutowiredDSLContextdsl;@GetMapping("/{key}")publicSysConfigRecordgetByKey(@PathVariableStringkey){returndsl.selectFrom(SYS_CONFIG).where(SYS_CONFIG.CONFIG_KEY.eq(key)).fetchOne();}}

类型安全到极致——表名字段名全是常量。但你得维护 codegen 流水线。

DLZ-DB(2 个文件)
@Data@TableName("sys_config")publicclassSysConfig{Longid;StringconfigKey;StringconfigValue;Stringremark;}@RestController@RequestMapping("/config")publicclassSysConfigController{@GetMapping("/{key}")publicSysConfiggetByKey(@PathVariableStringkey){returnDB.Pojo.select(SysConfig.class).eq(SysConfig::getConfigKey,key).queryBean();}// 增删改查全部 1 行}

没有 Mapper,没有 Service 接口,没有 codegen。但代价是——你失去了"标准分层"。

场景 B:动态条件 + 分页

需求:根据传入参数动态拼条件(status 可空、name 可空模糊、时间区间可空),按创建时间倒序分页。

MyBatis-Plus
Page<User>page=userService.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)
Specification<User>spec=(root,query,cb)->{List<Predicate>ps=newArrayList<>();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 标准的代价。

JOOQ
Conditioncond=noCondition();if(status!=null)cond=cond.and(USER.STATUS.eq(status));if(StrUtil.isNotBlank(name))cond=cond.and(USER.NAME.like("%"+name+"%"));if(startTime!=null)cond=cond.and(USER.CREATE_TIME.ge(startTime));List<UserRecord>list=dsl.selectFrom(USER).where(cond).orderBy(USER.CREATE_TIME.desc()).limit(pageSize).offset((pageNum-1)*pageSize).fetch();

类型安全很好,但分页要手动算 offset,没有原生Page对象。

DLZ-DB
returnDB.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_EXTRACT@Select("SELECT * FROM user WHERE JSON_EXTRACT(profile, '$.address.city') = #{city}")List<User>findByCity(Stringcity);// 更新:JSON_SET@Update("UPDATE user SET profile = JSON_SET(profile, '$.address.city', #{city}) WHERE id = #{id}")intupdateCity(Longid,Stringcity);// 实体上配 TypeHandler 才能映射成对象@TableField(typeHandler=JacksonTypeHandler.class)privateUserProfileprofile;
JPA
// AttributeConverter 处理 JSON ↔ 对象@Convert(converter=UserProfileConverter.class)privateUserProfileprofile;// 查询要写原生 SQL 或 Hibernate JSON 扩展@Query(value="SELECT * FROM user WHERE profile->>'$.address.city' = :city",nativeQuery=true)List<User>findByCity(@Param("city")Stringcity);

部分更新更痛苦——读出来 → 改对象 → 整个写回。

JOOQ
// 需要 jOOQ Pro 才有完整 JSON 支持;社区版要手写 SQLdsl.selectFrom(USER).where(jsonValue(USER.PROFILE,"$.address.city").eq(city)).fetch();
DLZ-DB
// 查询List<ResultMap>list=DB.Table.select("user").eq("profile.address.city","杭州")//内测中,使用方式可讨论.queryResultMapList();// 部分更新DB.Table.update("user").set("profile.address.city","上海")//内测中,使用方式可讨论.eq("id",userId).execute();// 路径取值ResultMapr=DB.Table.select("user").eq("id",1).queryOne();Stringcity=r.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>标签:

<sqlsqlId="key.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 1=1 [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>

调用时传哪个参数,哪段条件就生效:

List<ResultMap>rows=DB.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 配不同方言版本,运行时按当前数据源类型自动选用(源码SqlHolderm_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表的配置页面:

// 配置画面保存按钮:写入/更新一条报表 SQLSysSqlsql=newSysSql();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-db
  • Maven:top.dlzio:dlz-db-spring-boot-starter(或dlz-db-solon-plugin)
  • GitHub:https://github.com/dingkui/dlz-db
  • Gitee:https://gitee.com/dlzio/dlz-db

如果你觉得本文有失偏颇,欢迎在评论区贴你的反例代码——我会更新到下一版对比里。


本文所有代码示例均可运行,DLZ-DB 部分来自项目测试用例DbPojoTest.javaDbBatchTest.java。MyBatis-Plus / JPA / JOOQ 示例基于其官方文档当前版本(MP 3.5+、Spring Data JPA 3.x、JOOQ 3.18+)。

http://www.jsqmd.com/news/930081/

相关文章:

  • 快速同步数千首离线音乐歌词:LRCGET 终极解决方案
  • 基于Arduino与PID控制的自平衡机器人制作全攻略
  • 医学动画生产力革命:Sora 2上线48小时内,三甲医院放射科动画交付提速7.8倍(临床验证数据实录)
  • VisionFive单板计算机驱动I2C LCD屏幕完整教程
  • 工业设计师必抢的Sora 2三大核心能力,错过本轮OTA升级将永久缺失物理引擎精度补偿模块
  • 3分钟重塑城通网盘下载体验:从等待者到掌控者的思维升级
  • OpenAI 65亿美元收购Jony Ive硬件公司io + Windsurf收购告吹:AI软硬一体化战略大决战
  • QQ群数据采集利器:3分钟学会批量获取社群信息的专业方法
  • MiniCPM5-1B - 随身AI智能助手 擅长工具使用和复杂推理,长上下文处理能力强 一键整合包下载
  • 茶叶目标检测实战工程包:YOLOv5训练+多格式数据集适配+模型导出与API预留
  • 想进几个TG技术交流群学习,结果被SMSfee拦了三天
  • 在线去除视频水印怎么操作?全场景方法与优质工具汇总
  • ESP8266通过Whatabot实现WhatsApp消息收发:从环境搭建到智能家居应用
  • Matlab版太阳风粒子运动仿真工具:含电势求解与轨迹可视化
  • 石油光缆抢修升级:鼎讯信通光缆路由追踪仪优势解析
  • 终极指南:在Windows上轻松安装安卓应用的APK-Installer完整教程
  • Claude Code Git 工作流:分支、提交与代码审查怎么配合
  • Visual C++ Redistributable AIO 终极指南:3分钟解决Windows软件运行问题
  • 关系型数据库核心原理拆解:SQL解析、事务引擎、存储结构全链路分析
  • 还在手动熬夜转写讲座录音?2026年这3个微软文字转语音技巧,1分钟转完1小时音频
  • 056、多 GPU 分布式训练实战:DDP 配置、通信后端选型与加速比优化
  • 基于555定时器的振动传感器DIY:从机械触发到电子锁存的完整实现
  • DC-DC升压模块改造LED头灯:原理、实践与续航性能实测
  • 2026青岛翡翠回收权威指南:合扬奢侈品回收,25年标杆护航,高价变现无套路 - 合扬奢侈品交易中心
  • 腾讯云调用IP定位
  • 二维码+IC卡层控型梯控系统,通过二维码扫码 + Mifare One IC卡刷卡双模认证方式实现电梯楼层权限控制。采用IC卡写扇区技术,用户容量无上限,适用于住宅、办公楼、酒店等多种场景
  • EEG与fNIRS多模态融合:基于神经血管耦合的工作压力客观评估
  • 从平面到立体:RPG Maker MV/MZ插件集如何重塑你的游戏视觉体验
  • 【c语言数据结构】——单链表专题
  • 暗黑2存档编辑器终极指南:3分钟学会免费修改D2/D2R游戏角色