1. 为什么我们还在为“连个数据库”反复折腾?——从裸写SQL到全自动ORM的演进真相
你有没有过这种经历:刚接手一个老项目,翻看DAO层代码,发现一半是JDBC原生拼接,一半是MyBatis XML混着注解,中间还夹着几个用JdbcTemplate封装的工具方法,而新模块又悄悄上了Spring Data JPA的@Query和@EntityGraph……不是团队不统一,而是每种连接方式背后,都卡着一段真实业务场景、一次性能压测失败、一场上线前的紧急回滚,以及某个开发者深夜盯着线程堆栈时突然想通的取舍逻辑。
这根本不是“选哪个框架更高级”的问题,而是数据访问层在不同规模、不同稳定性要求、不同团队能力水位下的自然分形。JDBC像一把瑞士军刀——没有依赖、全手动、能干所有事,但每次拧螺丝都要自己找扳手;MyBatis像定制西装——SQL完全可控,映射清晰,可一旦表结构批量变更,XML里上百个<resultMap>就得同步改;Hibernate则像精装公寓——开箱即用,懒加载、二级缓存、脏检查一应俱全,可当你想绕过它查个复合索引优化的聚合SQL时,得先跟它的HQL翻译器斗智斗勇。
我参与过三个典型项目:某金融后台系统因审计合规要求,必须100%掌控每条SQL的执行路径与参数绑定过程,最终全量采用JDBC+自研SQL审计拦截器;某电商中台在QPS破5万后,发现Hibernate的@OneToMany默认懒加载触发N+1查询,切到MyBatis后通过<collection fetchType="eager">精准控制关联加载粒度,TP99下降42%;而某内部运营工具,开发周期仅两周,团队半数是实习生,直接上Spring Data JPA,CrudRepository接口加几个@Query注解,三天跑通全部CRUD,上线后才发现findAll()默认查全表,靠Pageable分页和@Query("select u from User u where u.status = ?1")硬编码条件才稳住内存。
这些不是技术优劣的PK,而是数据访问层在“确定性”“开发效率”“运行时可控性”三者间的动态平衡。今天这篇总结,不列教科书定义,不比抽象性能数字,只讲每种方式在真实压测环境、灰度发布、线上排查中的表现差异,以及我亲手踩过的、文档里绝不会写的坑。
2. JDBC:当“可控”成为唯一刚需时的终极选择
2.1 它从来不是“过时”,而是被刻意雪藏的核武器
很多人把JDBC当成“学Java第一课就该淘汰的古董”,这是巨大误解。在支付清结算、银行核心账务、证券交割等强一致性场景中,JDBC反而是首选。原因很简单:所有ORM框架最终都编译成JDBC调用,而绕过中间层,等于拿掉了所有不可控的翻译环节。某支付系统曾因Hibernate在批量插入时自动将INSERT INTO t VALUES (?,?)拆成单条执行(未配置rewriteBatchedStatements=true),导致TPS从8000骤降至300,回滚到JDBC后,手动启用addBatch()+executeBatch(),配合MySQL的rewriteBatchedStatements=true参数,TPS重回7500+。
JDBC的“原始感”恰恰是它的安全带。比如防SQL注入,MyBatis用#{}、JPA用?1,本质都是预编译,但一旦业务方为图省事写${}或@Query(value = "select * from user where name = '"+name+"'", nativeQuery = true),防线瞬间崩溃。而JDBC强制你写preparedStatement.setString(1, name),参数与SQL物理隔离,从语法层面杜绝拼接风险。
提示:JDBC真正的门槛不在API,而在资源管理。
Connection/Statement/ResultSet必须显式关闭,且顺序不能错(ResultSet → Statement → Connection)。我见过最惨的案例是某报表服务因finally块中未判空直接调用rs.close(),当rs为null时抛NullPointerException,导致连接池连接泄露,凌晨三点告警炸群。
2.2 现代JDBC实践:告别Class.forName(),拥抱连接池与模板化
别再用Class.forName("com.mysql.cj.jdbc.Driver")了。JDBC 4.0+规范要求驱动类实现java.sql.Driver并提供META-INF/services/java.sql.Driver文件,JVM会自动加载。真正要关注的是连接池选型与使用姿势。
| 连接池 | 初始化耗时 | 泄漏检测能力 | 监控埋点支持 | 适用场景 |
|---|---|---|---|---|
| HikariCP | 极低(毫秒级) | leakDetectionThreshold精确到毫秒 | Micrometer原生集成 | 高并发微服务 |
| Druid | 中等 | removeAbandonedOnBorrow粗粒度 | 自带Web监控页面 | 需要运维可视化的传统系统 |
| Tomcat JDBC | 较高 | 依赖testOnBorrow心跳 | JMX基础指标 | Spring Boot 2.0前遗留项目 |
实操中,HikariCP是绝对主流。关键配置不是maximumPoolSize,而是connection-timeout和validation-timeout。某次大促前压测,发现数据库响应延迟升高,但应用无报错——根源是connection-timeout=30000(30秒),当DB慢查询堆积,连接池等待超时后抛异常,而业务代码捕获后仅打日志未降级,导致请求雪崩。改为connection-timeout=1000(1秒)+connection-init-sql=SELECT 1,配合熔断器,故障面缩小80%。
JDBC模板化是提效关键。Spring的JdbcTemplate本质就是对JDBC的try-catch-finally模板封装,但很多团队自己造轮子。我推荐极简方案:用DataSourceUtils.getConnection(dataSource)获取连接,配合TransactionSynchronizationManager判断当前是否在事务中,避免手动管理事务传播行为。例如:
public List<User> findActiveUsers() { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DataSourceUtils.getConnection(dataSource); ps = conn.prepareStatement("SELECT id, name FROM user WHERE status = ?"); ps.setInt(1, UserStatus.ACTIVE.getCode()); rs = ps.executeQuery(); List<User> users = new ArrayList<>(); while (rs.next()) { users.add(new User(rs.getLong("id"), rs.getString("name"))); } return users; } catch (SQLException e) { throw new DataAccessException("Query active users failed", e); } finally { // 必须按rs→ps→conn顺序关闭,且判空 if (rs != null) try { rs.close(); } catch (SQLException ignored) {} if (ps != null) try { ps.close(); } catch (SQLException ignored) {} if (conn != null) try { // 若在Spring事务中,此处不真正关闭,由事务管理器回收 DataSourceUtils.releaseConnection(conn, dataSource); } catch (SQLException ignored) {} } }这段代码看似繁琐,但每个try-catch都在处理真实风险:rs.close()可能因网络中断抛SQLException,DataSourceUtils.releaseConnection()在事务中会跳过关闭,避免破坏事务上下文。这些细节,正是JDBC“可控性”的代价与回报。
2.3 踩坑实录:那些让JDBC在生产环境跪倒的隐形地雷
时区陷阱:MySQL默认时区为
SYSTEM,JDBC URL若未指定serverTimezone=GMT%2B8,JavaTimestamp传入时会按JVM本地时区转换,导致时间字段偏移8小时。某订单系统因此出现“用户下单时间早于系统时间”的诡异BUG,排查三天才发现是测试环境MySQL时区为UTC,而生产为CST。大字段处理:
TEXT/BLOB类型在ResultSet中需用getBlob()/getClob()获取流,若直接getString(),某些驱动(如旧版MySQL Connector/J)会将整个大对象加载进内存,OOM风险极高。正确姿势是rs.getBinaryStream("content")配合IOUtils.copy(stream, outputStream)流式处理。批处理失效:MySQL需在JDBC URL中添加
rewriteBatchedStatements=true,否则addBatch()仍会发送多条独立SQL。PostgreSQL则需reWriteBatchInserts=true。这个参数不生效,批处理性能与单条无异。连接泄漏的幽灵:
DataSourceUtils.getConnection()获取的连接,在DataSourceUtils.releaseConnection()未调用时,会永久占用连接池。我们曾用Arthas监控com.zaxxer.hikari.HikariDataSource的getActiveConnections(),发现某定时任务因@Scheduled(fixedDelay = 1000)未加try-catch,异常后连接未释放,24小时后连接池耗尽。
这些坑,没有ORM框架能帮你兜底。JDBC的“裸奔”状态,逼你直面数据库与JVM交互的每一处毛细血管。
3. MyBatis:SQL与Java的精密缝合术,以及它拒绝妥协的边界
3.1 为什么MyBatis在中大型项目中成为事实标准?
MyBatis的核心价值,不是“比Hibernate简单”,而是在SQL完全自主权与Java对象映射便利性之间,划出一条可精确控制的分界线。它的哲学是:“SQL归DBA,对象归Java,我只做精准翻译”。
看一个典型场景:某物流系统需要根据运单号、收件人手机号、发货时间范围组合查询,前端传参是Map<String, Object>,后端需动态拼接WHERE条件。用JPA的Criteria API写出来像这样:
CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<Order> cq = cb.createQuery(Order.class); Root<Order> root = cq.from(Order.class); List<Predicate> predicates = new ArrayList<>(); if (params.containsKey("waybillNo")) { predicates.add(cb.equal(root.get("waybillNo"), params.get("waybillNo"))); } if (params.containsKey("phone")) { predicates.add(cb.like(root.get("receiverPhone"), "%" + params.get("phone") + "%")); } cq.where(predicates.toArray(new Predicate[0]));而MyBatis只需在XML中写:
<select id="findOrdersByCondition" resultType="Order"> SELECT * FROM order WHERE 1=1 <if test="waybillNo != null and waybillNo != ''"> AND waybill_no = #{waybillNo} </if> <if test="phone != null and phone != ''"> AND receiver_phone LIKE CONCAT('%', #{phone}, '%') </if> </select>前者要理解JPA元模型、CriteriaBuilder生命周期;后者所见即所得,DBA可直接审核SQL,Java开发专注参数传递逻辑。这就是MyBatis在复杂查询场景不可替代的原因——它把SQL的“领域语言”还给了数据库专家,把Java的“对象语言”留给了应用开发者。
3.2 映射引擎的暗面:#{}与${}的生死线
MyBatis的#{}是预编译占位符,${}是字符串替换。几乎所有SQL注入漏洞都源于滥用${}。但${}并非洪水猛兽,它有不可替代的合法用途:
- 动态表名/列名:分库分表场景下,
SELECT * FROM ${tableName} WHERE id = #{id},此时tableName来自白名单校验后的变量,非用户输入。 - ORDER BY子句:
ORDER BY ${sortColumn} ${sortOrder},sortColumn需从枚举["create_time","update_time","status"]中取值,sortOrder限定为"ASC"或"DESC"。
关键在于校验必须在MyBatis执行前完成。我见过最危险的写法:
<!-- 危险!用户可传入任意SQL片段 --> <select id="dynamicQuery" resultType="User"> SELECT * FROM user ORDER BY ${orderByClause} </select>正确做法是:
// Controller层 public List<User> getUsers(@RequestParam String sortColumn, @RequestParam String sortOrder) { // 白名单校验 if (!Arrays.asList("name", "age", "create_time").contains(sortColumn)) { throw new IllegalArgumentException("Invalid sort column"); } if (!Arrays.asList("ASC", "DESC").contains(sortOrder.toUpperCase())) { throw new IllegalArgumentException("Invalid sort order"); } Map<String, Object> params = new HashMap<>(); params.put("sortColumn", sortColumn); params.put("sortOrder", sortOrder.toUpperCase()); return userMapper.dynamicQuery(params); }<!-- XML中直接使用,无校验逻辑 --> <select id="dynamicQuery" resultType="User"> SELECT * FROM user ORDER BY ${sortColumn} ${sortOrder} </select>注意:
${}拼接的SQL无法被连接池的SQL防火墙(如Druid的wallFilter)拦截,因为防火墙在JDBC层,而${}在MyBatis解析阶段已替换完成。所以校验必须前置到业务层。
3.3 一级缓存与二级缓存:你以为的“加速”可能是“灾难”
MyBatis一级缓存(SqlSession级别)默认开启,但极易被误用。典型反模式:
// 同一个SqlSession中两次查询同一ID User user1 = userMapper.selectById(1L); userMapper.updateStatus(1L, "ACTIVE"); // 修改数据库 User user2 = userMapper.selectById(1L); // 仍返回旧值!原因:一级缓存未感知到updateStatus操作(除非updateStatus也走同一个SqlSession),user2直接从缓存返回user1的旧对象。解决方案只有两个:1)确保读写分离,写操作后手动sqlSession.clearCache();2)禁用一级缓存(localCacheScope=STATEMENT),代价是失去缓存收益。
二级缓存(Mapper级别)更危险。它基于Serializable序列化,若实体类含ThreadLocal、InputStream等非序列化字段,反序列化时直接NotSerializableException。某项目因User类有个transient Map<String, Object> extraData,但extraData里存了HttpSession对象,二级缓存序列化失败,日志刷屏。
更隐蔽的是缓存穿透与雪崩。MyBatis二级缓存无过期策略,全靠flushInterval刷新。若设置<cache flushInterval="60000"/>,意味着每分钟全量刷缓存,高并发下大量SQL重放。我们最终弃用二级缓存,改用Redis+本地Caffeine两级缓存,由业务代码控制@Cacheable(key="#id")的精准过期。
3.4 插件机制:在SQL执行链路上钉入你的监控探针
MyBatis插件(Interceptor)是其最被低估的能力。它允许你在Executor、ParameterHandler、ResultSetHandler、StatementHandler四大核心组件的方法前后植入逻辑。我们用它实现了三件事:
SQL执行耗时监控:在
Executor.update()和Executor.query()前后打点,记录sql,params,costTime,上报至ELK。发现某接口平均耗时200ms,其中180ms花在ResultSetHandler.handleResultSets(),定位到是<collection>嵌套查询未加fetchType="eager",触发N+1。敏感字段脱敏:在
ResultSetHandler.handleResultSets()后,遍历结果集,对idCard,phone字段自动调用DesensitizationUtil.mask(),避免日志打印明文。慢SQL自动采样:当
costTime > 500ms,自动抓取Stack Trace和Bind Parameters,生成诊断快照,供SRE分析。
插件代码极简:
@Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}) }) public class SqlCostInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { long start = System.currentTimeMillis(); try { return invocation.proceed(); } finally { long cost = System.currentTimeMillis() - start; if (cost > 500) { log.warn("Slow SQL detected: {} ms, MappedStatement: {}", cost, invocation.getArgs()[0]); } } } }这种深度介入能力,是JPA/Hibernate插件体系难以企及的——它们更倾向声明式配置,而MyBatis给你的是手术刀。
4. JPA与Hibernate:当“约定优于配置”遇上现实世界的数据库异构
4.1 JPA是规范,Hibernate是实现,但90%的“JPA问题”其实是Hibernate问题
新手常混淆JPA与Hibernate。JPA(Java Persistence API)是一套接口规范(如EntityManager,@Entity),Hibernate、EclipseLink、OpenJPA都是其实现。但现实中,Spring Data JPA默认绑定Hibernate,且多数教程、文档、甚至IDE提示都指向Hibernate特有行为,导致“JPA问题”实为Hibernate问题。
最典型的例子:@GeneratedValue(strategy = GenerationType.IDENTITY)。JPA规范中IDENTITY表示数据库自增主键,但MySQL与PostgreSQL实现迥异:
- MySQL:
INSERT INTO user(name) VALUES(?)后,SELECT LAST_INSERT_ID()获取ID; - PostgreSQL:需创建
SERIAL类型列,并在INSERT后执行SELECT currval('user_id_seq')。
Hibernate为兼容两者,引入hibernate.id.new_generator_mappings=true(默认开启),此时IDENTITY实际走SequenceStyleGenerator,在MySQL中仍用LAST_INSERT_ID(),但在PostgreSQL中却尝试用序列——若表未建序列,直接报错。解决方案是显式指定@GenericGenerator:
@Entity public class User { @Id @GeneratedValue(generator = "pg-seq") @GenericGenerator(name = "pg-seq", strategy = "org.hibernate.id.enhanced.SequenceStyleGenerator", parameters = {@Parameter(name = "sequence_name", value = "user_id_seq")}) private Long id; }提示:永远不要相信JPA规范文档里的“跨数据库兼容”。Hibernate的方言(Dialect)层做了大量适配,但适配逻辑藏在源码里。遇到奇怪行为,直接查
org.hibernate.dialect.*Dialect类的getSelectInsertIdDelegate()方法。
4.2 懒加载(Lazy Loading):优雅的陷阱与暴力的解法
@OneToMany/@ManyToOne默认懒加载,是Hibernate最优雅的设计,也是线上事故最高发区。典型场景:REST接口返回Order对象,Order含@OneToMany(mappedBy = "order") List<OrderItem>,前端调用order.getItems().size()触发懒加载,但此时Session已关闭(Spring MVC在DispatcherServlet渲染视图后关闭),抛LazyInitializationException。
常见错误解法:
@Transactional加在Controller上:治标不治本,延长Session生命周期,但高并发下易引发连接池耗尽;FetchType.EAGER:全量加载关联,N+1变1+N,内存爆炸;JOIN FETCH:HQL中写SELECT o FROM Order o JOIN FETCH o.items,但无法动态控制,且复杂查询时HQL难维护。
我们的解法是DTO投影 + 手动JOIN:
// 定义DTO,只包含需要的字段 public class OrderWithItemsDto { private Long orderId; private String orderNo; private List<ItemSummary> items; // 嵌套DTO,非Entity } // JPQL投影查询 @Query("SELECT new com.example.dto.OrderWithItemsDto(" + "o.id, o.orderNo, " + "NEW com.example.dto.ItemSummary(i.id, i.name, i.quantity)) " + "FROM Order o " + "JOIN o.items i " + "WHERE o.id = :orderId") OrderWithItemsDto findOrderWithItems(@Param("orderId") Long orderId);此方案优势:1)SQL只查必要字段,无N+1;2)DTO不可变,避免脏数据污染;3)ItemSummary可自由组合字段,不受Entity约束。代价是需手写构造函数,但比起线上OOM,这点工作量微不足道。
4.3 二级缓存:Hibernate的“双刃剑”,用不好就是缓存雪崩
Hibernate二级缓存(@Cacheable)比MyBatis更激进,默认缓存整个Entity。问题在于:缓存失效策略与数据库事务不一致。
场景:Service A执行em.merge(user)更新用户状态,Service B同时执行em.find(User.class, userId),若B的查询命中二级缓存,返回旧状态,造成数据不一致。Hibernate提供@Cache(usage = CacheConcurrencyStrategy.TRANSACTIONAL),但需底层缓存(如Infinispan)支持XA事务,配置复杂且性能损耗大。
我们最终采用写穿透(Write-Through)+ 缓存标记方案:
- 所有写操作(
save/update/delete)后,主动调用cache.evict(User.class, userId)清除缓存; - 对高频读场景,用Redis缓存
userId -> UserDto,写操作时同步删除Redis Key; - 为防缓存击穿,对
findById加分布式锁,锁粒度为"user:" + userId。
此方案放弃Hibernate二级缓存,但换来100%的数据一致性保障。在金融、电商核心链路,这是必须付出的代价。
5. Spring Data JPA:当“约定”走向极致,开发者成了配置工程师
5.1 方法名查询:从魔法到反模式的临界点
Spring Data JPA的findByUsernameAndStatusOrderByCreateTimeDesc()是其最大亮点,也是最大陷阱。它通过解析方法名自动生成JPQL,极大提升CRUD效率。但当查询条件超过5个,方法名变成:
List<Order> findByUserIdAndStatusInAndCreateTimeBetweenAndAmountGreaterThanEqualAndIsDeletedFalse( Long userId, List<String> statuses, LocalDateTime start, LocalDateTime end, BigDecimal amount);这已不是代码,而是密码。更糟的是,它无法表达OR逻辑(如status = 'PAID' OR status = 'SHIPPED'),也无法处理LIKE模糊查询的%位置(usernameLike只能前缀匹配)。
我们的实践是:简单查询用方法名,复杂查询回归@Query。但@Query也有坑:
// 错误:原生SQL中未转义单引号,若name='O'Reilly',SQL语法错误 @Query(value = "SELECT * FROM user WHERE name = '#{name}'", nativeQuery = true) // 正确:用参数占位符 @Query(value = "SELECT * FROM user WHERE name = ?", nativeQuery = true) List<User> findByName(@Param("name") String name);注意:
@Query(nativeQuery = true)中?是JDBC占位符,#{}是SpEL表达式,仅在@Query非native模式下有效。混用必报错。
5.2 分页的幻觉:PageRequest.of(0, 10)真的只查10条吗?
Page<T> findAll(Pageable pageable)看似高效,但底层执行的是SELECT COUNT(*) FROM user+SELECT * FROM user LIMIT 10 OFFSET 0。当表数据达千万级,COUNT(*)可能耗时数秒,成为性能瓶颈。
解决方案有三:
- 游标分页(Cursor-based Pagination):用上一页最后一条记录的
id作为下一页起点,WHERE id > ? ORDER BY id LIMIT 10,避免COUNT; - ES同步:将数据同步至Elasticsearch,用
search_after实现深度分页; - 业务妥协:限制最大页码(如
maxPage = 100),超出则提示“请用搜索筛选”。
我们选方案1,因它零额外组件,且符合关系型数据库特性。实现关键:Pageable需传入Sort.by("id").ascending(),查询时用WHERE id > :lastId。
5.3@EntityGraph:解决N+1的银弹,还是另一颗地雷?
@EntityGraph通过JOIN FETCH一次性加载关联,是解决懒加载N+1的官方方案。但它的坑在于:图定义是静态的,无法动态切换。
例如,用户列表页只需user.name,详情页需user.profile和user.orders。若定义@NamedEntityGraph包含所有关联,列表页也会加载profile和orders,浪费I/O。
我们的解法是运行时动态构建EntityGraph:
@Repository public class UserRepository extends JpaRepository<User, Long> { public List<User> findUsersWithProfile(List<Long> userIds) { EntityManager em = entityManager(); EntityGraph<User> graph = em.createEntityGraph(User.class); graph.addAttributeNodes("profile"); // 只加载profile TypedQuery<User> query = em.createQuery( "SELECT u FROM User u WHERE u.id IN :ids", User.class); query.setParameter("ids", userIds); query.setHint("org.hibernate.fetchGraph", graph); return query.getResultList(); } }此方案灵活,但需手动管理EntityManager,脱离了Spring Data JPA的“约定”舒适区。这恰是技术选型的本质:每一步简化,都以牺牲一部分控制力为代价。
6. 终极决策树:如何为你的下一个项目选择数据访问层?
6.1 不是“选框架”,而是“选协作模式”
我把数据访问层选择,抽象为三个维度的坐标轴:
X轴:SQL控制粒度(0=全托管,10=全手动)
JPA/Hibernate ≈ 2,Spring Data JPA ≈ 3,MyBatis ≈ 6,JDBC ≈ 10Y轴:开发速度衰减率(0=无衰减,10=随复杂度指数衰减)
Spring Data JPA ≈ 1(简单CRUD飞快,复杂查询陡峭),MyBatis ≈ 4,JDBC ≈ 8Z轴:线上问题排查成本(0=低,10=高)
JDBC ≈ 2(日志即SQL),MyBatis ≈ 4(需看XML+日志),Hibernate ≈ 7(HQL→SQL→执行计划三层转换)
项目落地时,我画一张三维坐标图,邀请DBA、后端、测试共同标注:
- DBA标SQL复杂度(X轴):是否涉及窗口函数、递归CTE、复杂JOIN?
- 后端标迭代节奏(Y轴):是否需两周内上线MVP?是否长期维护?
- 测试标监控能力(Z轴):是否有完善的SQL慢查询告警?是否能快速复现线上问题?
当三点聚拢在左下角(低复杂度、快迭代、强监控),Spring Data JPA是首选;当三点分散(DBA坚持手写SQL、后端需应对大促压测、测试缺乏SQL分析能力),MyBatis+JDBC混合是更稳健的选择。
6.2 我们团队的“混合架构”实践:各司其职,绝不越界
在当前主力项目中,我们采用分层数据访问策略:
核心交易链路(支付、清算、风控):JDBC + HikariCP + 自研SQL审计SDK
理由:每条SQL必须可审计、可回溯、可压测,ORM的翻译层是不可接受的风险点。中台服务(用户中心、商品中心):MyBatis + PageHelper + Redis二级缓存
理由:查询模式固定(按ID、按状态、分页),MyBatis的XML可版本化管理,DBA可直接Review。内部工具(运营后台、BI报表):Spring Data JPA + QueryDSL
理由:开发人力紧张,QueryDSL提供类型安全的动态查询,比手写@Query更可靠。实时计算模块(Flink CDC同步):纯JDBC Batch + Canal解析
理由:CDC需监听binlog,JDBC的addBatch()与Canal的Entry解析天然契合,无ORM干扰。
这种混合不是技术混乱,而是让每种工具在它最擅长的战场发挥极致。就像一支军队,步兵(JDBC)攻坚,炮兵(MyBatis)压制,空军(JPA)快速部署,没有谁比谁高级,只有是否用对地方。
6.3 给新人的三条铁律
永远先写SQL,再写Java:在IntelliJ中用Database工具连上测试库,手写并优化好SQL,再考虑用哪种框架封装。没验证过的SQL,不值得任何ORM包装。
日志是你的第一调试器:在
application.yml中开启logging.level.org.springframework.jdbc.core.JdbcTemplate=DEBUG(JDBC)、logging.level.org.mybatis.spring.SqlSessionUtils=DEBUG(MyBatis)、logging.level.org.hibernate.SQL=DEBUG(Hibernate),让每条执行的SQL和参数暴露无遗。线上问题80%靠日志定位。压测不是上线前才做的事:用JMeter模拟100并发,执行核心查询,观察数据库
SHOW PROCESSLIST、连接池活跃连接数、GC日志。JDBC的executeBatch()在1000条时TPS 5000,MyBatis的<foreach>在500条时TPS 3000,这些数字必须实测,而非文档。
最后分享一个小技巧:在pom.xml中用<exclusions>排除Hibernate的slf4j-api冲突,比在logback-spring.xml里写十行<logger>更彻底。技术选型的终点,不是找到“最好”的框架,而是找到让你和团队睡得着觉的那个。