U8C持久层设计拆解:JdbcSession、DbException与结果集映射
简介:《用友U8 cloud V1.0-持久层技术红皮书》面向用友U8C开发人员与云ERP后端工程师,聚焦持久层数据访问的设计与实现,帮助读者理解JdbcSession会话创建、异常处理、结果集读取及更新操作等核心机制。文档梳理U8 cloud数据访问层高性能、灵活、安全、易维护等特点,并展开JDBC FrameWork中单条与多条记录查询、流式处理、无参数及带参数更新、批量更新和日期时间、Blob/Clob等特殊参数类型的处理方式,还涉及普通Java Bean对象持久化的定义、映射、读取与写入。包内仅含1个PDF文件,约493KB,轻量便于随查随用,已有93人学习。其价值在于以红皮书形式给出较完整的目录框架、技术细节和示例代码,适合开发人员在接口联调、数据访问封装与性能优化时对照查阅,也可作为团队持久层规范与最佳实践的参考材料。
1. 从一行 update 说起:这层持久化到底在替谁偿还
在 U8C 开发里写过session.executeUpdate(sql)的人,常会生出一种错觉:这层框架薄得像不存在,不过是对 JDBC 的一次简单包装。反直觉的地方恰好在这里。这套跑在企业级 ERP 上的持久层,最终交给业务代码的常用 API 只有七八个方法,剩下全部复杂度都被压进了三个看不见的地方:会话的生命周期、异常语义的归一、结果集到对象的映射。这三件事恰恰是手写 JDBC 时最容易复制粘贴、也最容易出错的部分。
红皮书开篇给了一个估算:引入对象-关系映射中间件,能省掉与对象持久化有关的差不多 40% 编程工作量。这个数字背后真正做的事,是把“每个业务类自己拿连接、自己拼 SQL、自己 catch SQLException、自己关结果集”这种分散的风险,集中收拢到一层里统一处理。搞懂这一层的设计动机,比记住它的 API 更有价值,因为一旦业务开始跨库、批量写入、并发上来,出问题的几乎都是这三个地方。
2. JdbcSession:把连接、语句与事务收进一个逻辑会话
2.1 PersistenceManager 单例与 getJdbcSession 的创建开销
这套框架的入口就两个对象:PersistenceManager和它吐出来的JdbcSession。前者是进程级单例,负责握数据源、管会话;后者代表“一次业务操作”的上下文,SQL 都从它身上走。原文示例里把sessionManager先声明成null再在 try 里赋值,看起来啰嗦,其实是为了让finally块在getInstance()本身抛异常时不会踩到空指针。这种写法在框架源码里很常见,属于血泪经验沉淀下来的模板。
PersistenceManager sessionManager = null; try { sessionManager = PersistenceManager.getInstance(); // 进程级单例,通常绑定默认数据源 JdbcSession session = sessionManager.getJdbcSession(); // 开一次逻辑会话 // 业务操作:查询、单条更新、批量提交 } catch (DbException e) { // 统一异常入口,业务层不再直接面对 SQLException } finally { if (sessionManager != null) { sessionManager.release(); // 归还会话,物理连接回到池里 } }这段代码里,getJdbcSession()是开销最小的一步,它从池里借连接、初始化会话上下文,真正贵的是没写出来的release()。PersistenceManager是单例但会话不是,每一次业务调用都要重新拿一次会话,用完必须还。原文在 Blob、Clob 的示例里用的是session.closeAll(),其它示例用的都是sessionManager.release(),两种收尾方式同时存在,这一点后面在避坑章节还会再提一次,因为它是最容易埋雷的细节之一。
数据源并非只能有一个。原文提到BaseDAO dao = new BaseDAO(dataSource)可以指定数据源,说明框架内部是按数据源隔离会话的,多库场景下PersistenceManager默认数据源只覆盖主库,跨库操作必须显式换数据源。这一点在设计上留了口子:会话对象本身不感知库,数据源才感知。
2.2 四象限更新:带参与批量到底省了什么
原文把更新拆成四种形态:无参数单条、带参数单条、带参数批量、无参数批量。拆得这么细不是为了凑 API,而是对应 PreparedStatement 的两种能力:参数绑定和执行计划复用。无参更新会把整条 SQL 直接交给语句对象,适合 DDL 化的固定脚本;带参更新才走预编译,参数对象SQLParameter按?出现顺序依次入位。
String sql = "update bd_invmandoc set pk_invmandoc = ? where pk_invmandoc = ?"; SQLParameter parameter = new SQLParameter(); parameter.addParam("0001AA10000000000DDD"); // 对应第 1 个 ? parameter.addParam("0001AA10000000000DDD"); // 对应第 2 个 ? session.executeUpdate(sql, parameter);SQLParameter是一个有序容器,addParam的顺序就是占位符的顺序,框架在内部遍历这个容器,逐个调用setObject、setNull一类的方法。参数顺序错位是这套 API 里最常见的事故类型,编译器不会提醒你,数据库只会告诉你“类型不匹配”或者更糟,静默写错了行。
批量的价值在另一处。addBatch(sql, parameter)不立即执行,而是把语句和参数对攒进会话内部的批次队列,直到executeBatch()一次性推给数据库。
String sql = "update bd_invmandoc set pk_invmandoc = ? where pk_invmandoc = ?"; SQLParameter parameter = new SQLParameter(); parameter.addParam("0001AA10000000000DDD"); parameter.addParam("0001AA10000000000DDD"); session.addBatch(sql, parameter); // 入队,不落库 int rows = session.executeBatch(); // 一次性提交整个批次这里有个容易误解的点:executeBatch()在 JDBC 原生语义里返回的是int[],每个元素对应一条语句的影响行数,而原文把它赋给了一个int。常见的实现思路是框架内部把数组做了累加,返回总影响行数,方便业务判断“批量更新到底成没成”。如果业务需要逐条判断成败,就不能只看这个总数,得自己在批次里做分段,或者改用逐条执行加事务控制。批量越大,单次网络往返摊得越薄,但事务持有时间也越长,锁竞争随之上升,这个取舍没有通用答案,只能按表的写入热点来调。
2.3 Null、Blob、Clob:三种不能直接 addParam 的参数
普通参数走addParam就够了,但这三种类型必须走专用方法,原因是 JDBC 层面它们的绑定方式不同。null的问题在于setObject(i, null)无法推断 SQL 类型,多数驱动会报错或按默认类型猜;Blob、Clob 则是流式对象,需要驱动按大对象协议处理。
SQLParameter parameter = new SQLParameter(); parameter.addNullParam(Types.INTEGER); // 必须显式给 SQL 类型,否则驱动无法推断 parameter.addBlobParam(bytes); // 二进制大对象,原文示例用 new Object() 占位 parameter.addClobParam(longText); // 字符大对象,原文示例用 new String() 占位 session.addBatch(sql, parameter); int rows = session.executeBatch();addNullParam(Types.INTEGER)的参数是java.sql.Types里的常量,它决定了驱动发出去的到底是NULL::int还是NULL::varchar。在强类型数据库上填错类型,轻则报错,重则被隐式转换后写进一个语义不对的值。Blob 与 Clob 的入参在原文示例里写得很随意,用new Object()、new String()占位,我一般会这么理解:框架内部接收的是承载数据的对象,真正的流化转换在绑定阶段完成,业务侧只需要保证传进去的对象能被序列化成字节流或字符流即可。大对象字段的坑不在写入,而在读取,一次性把几 MB 的 Clob 读进内存再映射成 String,堆压力会直接反映在 GC 曲线上,涉及大字段的查询最好单独走一条精简列的 SQL。
3. DbException:把 N 种数据库错误码折叠成一套语义
3.1 转换发生在哪一层
JDBC 原生抛的是SQLException,它带着厂商错误码、SQLState 和一段人类可读的消息。这三种信息在不同数据库之间毫无共性:同一个“唯一约束冲突”,在一种库里是某个五位数错误码,在另一种库里是另一个编号,SQLState 甚至可能都是同一类但语义不同。业务代码如果直接 catchSQLException再判断错误码,等于把数据库种类硬编码进了业务层,迁移时就是灾难。
DbException是这套框架给出的答案:一个抽象异常类,专门承接SQLException到数据访问语义的转换。框架为不同数据库提供了各自的DbException实现,每个实现内部维护一张错误码到语义方法的映射表,把厂商私有的数字翻译成通用判断。
try { // 数据访问过程 } catch (DbException e) { if (e.isBadSQLGrammar()) { // 语法错误:SQL 拼错、表或列不存在 // 属于开发期缺陷,记录完整 SQL 与绑定参数,不要吞掉 } if (e.isDataIntegrityViolation()) { // 数据一致性:唯一约束、外键、非空 // 转成业务可读的提示,而不是把数据库原文抛给前端 } } finally { sessionManager.release(); }注意这两个判断是可以同时为假的,也就是说DbException覆盖的是一组“常见且可归类”的错误,不是全部。连接超时、死锁回滚这类问题往往落到别的判断分支上,或者干脆需要看原始错误码。这正是抽象异常必须保留原始信息的原因:语义方法给业务用,原始码给排查用。
3.2 判定链:错误码怎么变成 isBadSQLGrammar
转换的核心是一张映射表,结构大致是“厂商错误码 → 语义分类”。常见的实现思路是在DbException子类的构造阶段就完成分类,把SQLException里的 errorCode 取出来查表,命中则置位对应的布尔标志,没命中则归入“未知”并保留原始异常作为 cause。
// 抽象异常的结构示意,具体分类在各数据库子类里完成 public abstract class DbException extends RuntimeException { protected int vendorCode; // 数据库原始错误码,排查时必看 protected String sqlState; // 标准状态码,跨库时比 errorCode 更稳 public abstract boolean isBadSQLGrammar(); public abstract boolean isDataIntegrityViolation(); }判定顺序也有讲究。语法错误优先判,因为这类错误的根因最明确,一旦命中就不用再往下猜;数据一致性其次,它对应的是业务规则被违反;两者都不命中才进兜底分支。如果判定顺序反了,一条语法错误的 SQL 有可能因为错误码区间重叠被误判成一致性冲突,最后排查方向完全跑偏。
这里还有一层隐含的设计取舍:语义分类是有限的。框架只抽象最常被业务分支处理的那几类,剩下的交给原始码。抽象得太多,映射表会变成维护负担,每接一种新数据库都要重做一遍;抽象得太少,业务又退回去看错误码。停在“够用”的位置,是这个设计能长期活下去的关键。
3.3 用抽象异常换来的迁移能力
回到数据访问层那四条特点:透明性、易于迁移、降低业务对象复杂度、易于维护。前两条几乎完全由DbException撑着。业务对象不认识数据库,所以它可以被搬到另一种库上;迁移只涉及数据访问层,因为错误码的翻译也在这一层。
具体落到代码上,迁移时你要改的东西只有三样:数据源配置、DbException的子类实现(框架通常已经提供)、以及那些确实依赖特定数据库语法的 SQL。前两样是配置级的,第三样才是真正的工作量,也正因如此,把 SQL 集中写在一层里,比散落在几十个业务类里要好迁移得多。集中化的代价是那一层会变得很厚,好处是厚的地方只有一个。
还有一点值得说:DbException继承了运行时异常,业务代码不被强制 catch。这个选择在事务边界上很关键,因为受检异常会污染方法签名,一层层往上抛,最后到处都是throws SQLException。运行时异常让签名干净了,但也意味着忘记 catch 就真的一路冒到顶层,所以事务边界上必须有统一的兜底处理,否则一次约束冲突就能把整条请求打到错误页。
4. ResultProcessor 与对象映射:结果集到对象的最后一段路
4.1 九种处理器其实是两组正交维度
ResultProcessor是一个只有一个方法的接口,方法签名大致是processResultSet(ResultSet rs),不同的实现返回不同的结果对象。原文列了九种默认实现,看起来很多,其实可以按两个正交维度切开:行数维度(单行还是多行)和承载类型维度(对象数组、Map、JavaBean、单列值)。把这两个维度交叉,九种实现的位置立刻清楚了。
| 处理器 | 行数 | 承载类型 | 典型用途 |
|---|---|---|---|
| ArrayProcessor | 单行 | Object[] | 取一行,按列序访问 |
| ArrayListProcessor | 多行 | List<Object[]> | 报表式批量读取 |
| MapProcessor | 单行 | HashMap | 列名即 key,结构不固定时好用 |
| MapListProcessor | 多行 | List<Map> | 动态列查询 |
| ColumnProcessor | 单行 | 单值 | count、sum 一类聚合 |
| BeanProcessor | 单行 | JavaBean | 单条实体查询 |
| BeanListProcessor | 多行 | List<Bean> | 列表页查询 |
| BeanMappingProcessor | 单行 | JavaBean(按映射元数据) | 列名与属性名不一致 |
| BeanMappingListProcessor | 多行 | List<Bean>(按映射元数据) | 同上,多行场景 |
数组和 Map 两种承载方式的差别在耦合度。数组按位置取值,SQL 里列的顺序一变,业务代码就得跟着改;Map 按列名取值,列序无关但每个值都是Object,取出来要强转,类型安全靠人肉保证。Bean 是第三种,也是业务代码最愿意用的,因为它把类型信息带回来了。
聚合查询是ColumnProcessor的主场,返回单个值而不是包装对象。这在实现上意味着一行一列的读取路径被单独优化过,不用构造任何容器。如果业务里出现大量select count(*),走这条路径比走MapProcessor再取第一个 key 要干净得多。
4.2 Bean 映射靠列名与 setter 对齐
BeanProcessor的映射规则原文写得很直白:结果集里有个叫name的列,只要 JavaBean 有getName(),值就自动塞进去。这套规则依赖反射,执行路径是“取列名 → 首字母大写拼出 setter 名 → 反射调用”。
// 自定义处理器的切入点:继承 BaseProcessor,只覆写结果集加工这一段 public class CustomProcessor extends BaseProcessor { @Override public Object processResultSet(ResultSet rs) throws SQLException { List<String> result = new ArrayList<>(); while (rs.next()) { // 按列名取值,不依赖列顺序,避免 SQL 调整后静默错位 result.add(rs.getString("NAME") + "-" + rs.getInt("AGE")); } return result; } }BaseProcessor承担了资源收尾等公共逻辑,子类只需要关心怎么把一行变成业务想要的结构。这个扩展点在真实项目里用得很多,比如需要把一行拆成两个对象、或者需要做字段级加密解密时,默认的九种实现都不够用。
反射映射有两个隐含前提,原文点了一句“必须符合 Java Bean 规范”。这句话的实际含义是:类必须是 public,必须有 public 无参构造,属性必须有标准的 getter/setter。少一样,反射就静默失败或者抛异常。我在实际项目里见过属性名是isValid却写成getIsValid的类,映射结果永远是 null,查了半天才反应过来是命名规范的问题。列名大小写也是同类问题,某些数据库返回的列名全大写,映射时就对不上驼峰命名的 setter,这时就得换成带映射信息的BeanMappingProcessor。
4.3 IMappingMeta:元数据为什么要单独抽成类
IMappingMeta定义了四个方法:getPrimaryKey()、getTableName()、getAttributes()、getColumns()。前两个给的是表级信息,后两个是一对数组,分别对应 JavaBean 的属性名和数据库的列名,两者按下标一一对应。
public class PersonVOMeta implements IMappingMeta { private String[] attributes = new String[] { "id", "name", "age" }; // Bean 属性 private String[] columns = new String[] { "ID", "NAME", "AGE" }; // 表列名,下标对齐 public String getPrimaryKey() { return "ID"; } public String getTableName() { return "Person"; } public String[] getAttributes() { return attributes; } public String[] getColumns() { return columns; } }把映射信息从 Bean 里抽出来单独成类,这个设计值得多想一层。如果把@Column之类的注解直接打在 Bean 上,Bean 就被绑死在持久化框架上了,一旦这个 Bean 要序列化成 JSON 传给前端、或者要跨模块复用,注解就成了噪音。元数据类的方式让 Bean 保持纯粹,映射关系作为独立的一等公民存在,编译期就能检查,运行时按需加载。代价是类数量翻倍,一个实体配一个 VOMeta,命名规范也因此被定死成nc.vo.xxx.XXXVOMeta。
原文还给了一条硬限制:一个 JavaBean 只能映射成一个数据表。这条限制放弃了继承映射、关联映射这些 ORM 高级特性,换来的是实现简单、行为可预测。在 ERP 这种表结构高度稳定、以单表增删改查为主的场景里,这个取舍是划算的;换成内容管理系统那种实体关系复杂的场景,这条限制就会变成枷锁。
4.4 BaseDAO 为什么被推荐成新代码的统一入口
原文明确建议新代码使用nc.bs.dao.BaseDAO,理由是它封装了IUAPQueryBS、IVOPersistence以及superDMO里的所有方法。这三个接口分别对应查询、对象持久化和更底层的 DOM 操作,业务代码如果直接用它们,等于同时依赖三套抽象;用BaseDAO只需要依赖一个门面。
门面模式的典型收益在这里体现得很清楚:底层接口调整时,只要门面签名不变,业务代码不用动。还有一个不那么明显的好处,BaseDAO可以统一决定用哪个数据源、走不走缓存、异常怎么包装。这些横切关注点如果散落在业务层,每个开发者都会写出略微不同的版本,最后排查问题时得先读懂三种风格。
不过门面也有代价。它会不可避免地把底层接口的方法都透出来一部分,导致这个类的接口面越来越大,久而久之变成一个什么都往里塞的上帝类。判断标准其实很简单:如果某个方法只在一个业务模块用到,就不要往门面里加,直接调底层接口。
5. 排查与避坑:持久层里那些静默失败
5.1 会话没释放,连接池在高峰期被抽干
现象是系统跑一段时间后开始报拿不到连接,重启就恢复,过一阵又出现。原因几乎都是release()没放在finally里,业务代码在 try 块中间抛了异常直接跳出去,会话就此泄漏。连接池的容量是有限的,泄漏累积到阈值,新请求全部阻塞。
解决办法是把sessionManager.release()无条件写进finally,并且判断sessionManager非空。原文示例里先声明null再赋值,就是为了这一步。另外要留意原文中两种收尾方式并存的情况:sessionManager.release()和session.closeAll()。前者归还会话到管理器,后者关闭会话内的资源。批量操作和大对象操作之后,稳妥的做法是两者都覆盖到,先关会话内资源,再归还管理器,顺序反了可能出现资源悬挂。
5.2 批量执行忘了控制规模,锁等待拖垮整个事务
现象是批量更新偶发超时,日志里能看到锁等待,但单条执行完全正常。原因是addBatch攒的批次太大,executeBatch()一次性提交几百上千条语句,事务持有时间长,和并发的其它写操作抢同一批行锁。
解决思路是给批次设上限,按固定条数分段提交,比如每 500 条执行一次executeBatch()。分段之后事务边界变多,失败时的回滚粒度也更细,不会出现“一千条里错一条全废”的尴尬。批次大小的取值没有标准答案,得看单条语句的耗时和表的写入热点,通常从几百开始试,压测时观察锁等待时间再调。
5.3 用 addParam 塞 null,驱动直接翻车
现象是插入或更新含空值的字段时报类型转换异常,或者更隐蔽地写进去一个默认值。原因是addParam(null)会让驱动无法推断参数类型,不同驱动的处理策略不一样,有的报错,有的按字符串处理。
正确的写法是addNullParam(Types.XXX),显式告诉驱动这个占位符对应什么 SQL 类型。类型要和表结构严格对齐,整数列给Types.INTEGER,日期列给Types.TIMESTAMP,不要图省事一律给Types.VARCHAR。在强类型数据库上,类型给错的后果比给 null 更严重,因为隐式转换可能成功,数据就这么错着存进去了,等到报表对不上账才发现。这条属于典型的“后悔药没得吃”,写入前校验比事后修数据便宜太多。
5.4 attributes 与 columns 顺序错位,写进了错误的列
现象是查询出来的对象字段值互相串了,比如姓名跑到了年龄字段里,但类型恰好兼容,编译器毫无察觉。原因在IMappingMeta的两个数组是按下标对应的,attributes[0]必须对应columns[0],手工维护时加了一个字段忘了同步另一边,错位就发生了。
解决方式是让两个数组在同一个代码块里成对声明,改一处必须改另一处,中间不要插入其它字段。有条件的话在元数据类里加一段静态校验,长度不一致直接抛异常,把问题挡在启动阶段而不是运行阶段。这类错位最麻烦的地方在于它不报错,只是数据慢慢变脏,等到业务方反馈“客户名字都不对”时,已经积累了大量脏数据。
5.5 自定义 Processor 里没关 ResultSet
现象是自定义结果集处理器上线后,连接数缓慢上涨,几天后需要重启。原因是覆写processResultSet时自己 new 了游标或者开了额外查询,却没有在 finally 里关闭。框架对默认处理器做了资源管理,自定义实现则把责任交回给了开发者。
解决办法很简单:自定义处理器里凡是自己打开的ResultSet、Statement,一律在 finally 里关。如果只是读传进来的那个ResultSet,不要自己去 close,交给框架收尾。这条边界要记清楚,关早了框架后续取不到数据,关晚了自己泄漏。
| 现象 | 根因 | 处理 |
|---|---|---|
| 高峰期拿不到连接 | 会话未在 finally 归还 | release 无条件入 finally |
| 批量更新锁等待 | 批次过大、事务过长 | 分段提交,控制单批条数 |
| 空值写入报类型错 | addParam(null) 无法推断类型 | 改用 addNullParam(Types.XXX) |
| 字段值互相串位 | 两个映射数组下标错位 | 成对声明 + 启动期长度校验 |
| 连接数缓慢上涨 | 自定义处理器未关资源 | 自开资源自关,框架资源不动 |
6. 设计取舍:这套 ORM 停在“够用”的位置上
把这套持久层拆完,会发现它做的每一个决定都在往“简单可预测”那一侧靠:Bean 与表严格一对一,不做继承映射;异常只抽象最常处理的几类,其余保留原始码;结果集处理器给九种默认实现,不够就自己写;映射元数据独立成类,用类数量换 Bean 的干净。这些决定单独看都像是保守,放在一起看,逻辑就清楚了,它服务的是一套要长期维护、要被大量业务开发者使用的企业级系统,而不是一个追求表达力的通用 ORM。
这里最值得琢磨的是“不做”的部分。关联映射不做,意味着业务层要自己拼两次查询再在内存里组装,代码量大一点,但每次查询走了什么索引、命中多少行,完全透明。懒加载不做,意味着不会有那种“看着是取一个字段,实际触发了一串 SQL”的黑匣子行为,性能问题在压测阶段就能暴露,而不是上线后在某条慢查询日志里冒出来。缓存只做框架级的基础缓存,不做对象级的一二级缓存,避免缓存与数据库不一致这种最难排查的问题。
代价当然有。表结构复杂时,业务代码里会出现大量手工组装的逻辑;跨表统计需要写原生 SQL,映射层的优势用不上;批量场景要自己控制提交节奏。这些成本是显性的,写在代码里看得见;而通用 ORM 省下来的那些代码量,换来的是隐性的行为复杂度,出了问题往往要翻到框架源码才能定位。在 ERP 这种“业务逻辑本身已经够复杂”的场景里,把复杂度留在显性的一侧,我认为是对的选择。
验证这套东西是否真的按预期工作,方法不复杂:拿一条带参数的更新,在数据库侧开慢查询日志和会话统计,看一次业务调用到底产生了几条 SQL、几个连接、事务边界在哪。再拿一个批量操作,把批次调到一万条,观察锁等待和事务时长怎么变化。这些观测比读文档更能说明问题,因为它们反映的是真实行为,而不是设计意图。
我自己的习惯是,每次接入一个新的持久层,先写三个最小用例:一条带 null 的插入、一次分段批量更新、一次自定义结果集处理,全部跑通再动业务代码。这个习惯来自一次教训,当时没验证 null 绑定,上线后某张表的空值字段全被写成了默认值,事后修数据修了两天。从那以后我每次碰新框架都强制走一遍这三个用例,宁可前期多花半小时,也不要在生产库上做数据修复。希望帮到你。
本文还有配套的精品资源,点击获取