准备做一个全库数据字典导出功能的时候,我才第一次认认真真把JDBC的DatabaseMetaData从头翻到尾。之前一直觉得它就是个查表结构的接口,真上手才发现,表结构查询、字段类型解析、主键判断、索引信息提取这四件事,每件事都有自己的一套方法和返回结构,参数忽而是通配符忽而是精确值,返回的ResultSet在不同数据库驱动下面目全非。折腾几天之后,我把这些逻辑收敛成了一个叫MetaUtil的工具类,封装了连接元数据反射、字段类型归一化、主键与索引解析,加了一层缓存,总算能"一条链式调用拿回全部元数据"了。这篇文章会把工具的设计过程和排坑细节完整写出来,给正在写代码生成器、数据字典、或任何需要与数据库结构打交道的同学做个参考。
1. 从JDBC原生元数据API的痛点说起
1.1 四个方法,四种让人记混的参数约定
先放结论:JDBC原生API把"取元数据"这件事拆成了四个入口,getTables、getColumns、getPrimaryKeys、getIndexInfo,参数设计还不太一样。我列个表,大家感受下:
| 方法 | catalog/schema | 表名参数 | 第四参 | 第五参 |
|---|---|---|---|---|
| getTables | schemaPattern | tableNamePattern | types[] | 无 |
| getColumns | schemaPattern | tableNamePattern | columnNamePattern | 无 |
| getPrimaryKeys | schema | table(精确) | 无 | 无 |
| getIndexInfo | schema | table(精确) | unique | approximate |
前两个方法的表名参数是pattern,支持百分号通配符;后两个就是精确表名。很多同学第一次写的时候都会顺手把getPrimaryKeys的第三参传成"%",结果要么报错要么返回空。还有一个容易混淆的点:MySQL习惯用catalog,Oracle和PostgreSQL习惯用schema,JDBC方法却把catalog和schema分成两个参数,导致写通用代码时总要判断"当前这个库到底该填哪个"。
就算你把参数背熟了,返回值也是个问题。每个方法返回的ResultSet列结构都不一样,而且不同驱动实现并不完全遵循同一个标准。IS_AUTOINCREMENT这一列,有的驱动返回,有的驱动直接不返回,直接用rs.getBoolean("IS_AUTOINCREMENT")可能当场抛异常。这种事情只有踩过一次才有印象。
1.2 返回列命名差异带来的连锁反应
我举个例子。getColumns返回的列里,TYPE_NAME是数据库原始类型,DATA_TYPE是java.sql.Types里的数字类型码。MySQL会返回"INT UNSIGNED"这种带杂质的类型名,PostgreSQL会返回"int4"这种内部名,Oracle对不带精度的NUMBER会返回COLUMN_SIZE=0。如果你只在业务代码里写一版"根据getColumns结果生成Java类型"的逻辑,换一个库就得改一堆if else。
还有一个让很多人忽略的细节:部分MySQL驱动版本默认不读information_schema,导致REMARKS字段(表注释/字段注释)为null。解决方法是JDBC URL里加上useInformationSchema=true,但这个问题在MySQL 8.0驱动里可能表现不一样,所以最实在的做法是先把输出打印出来,再看驱动版本决定要不要加参数。
1.3 MetaUtil要解决的问题
我提炼了一下,原生API的痛点可以归成三件事:
- 逻辑碎片化:一个"查表结构"的需求要拼凑四五个方法,每个方法都要写资源释放和空值判断。
- 类型方言化:同一份元数据在不同数据库里字段名、类型名、特殊标记都不一样,业务代码扛不住这种差异。
- 性能重复化:一般应用启动时只需要扫描一次全库元数据,但如果每张表都现场反射,几百张表就是几百次查询。
MetaUtil的思路很简单,但做扎实不容易:把四件事整合成几个面向领域对象的方法,内部统一处理驱动差异,外层套一级缓存。具体设计时,表结构查询要带系统表过滤和缓存,字段类型解析要做类型归一化,主键判断要还原联合主键顺序,索引信息要过滤统计行和表达式索引。没有哪个细节是单靠理论推出来的。接下里的几个章节,我就按这四个功能点逐个拆开讲,包括每一步的取舍和踩过的坑。
2. 表结构查询:系统表过滤与两级缓存
2.1 别看到TABLE_TYPE等于TABLE就直接收
getTables返回的ResultSet里最关键的四列是TABLE_CAT、TABLE_SCHEM、TABLE_NAME、TABLE_TYPE。注意TABLE_TYPE这个列并不只有"TABLE",还有VIEW、SYSTEM TABLE、SYSTEM VIEW、GLOBAL TEMPORARY、LOCAL TEMPORARY,甚至个别数据库还会返回其他类型。
很多初版代码会直接写成"TABLE_TYPE = 'TABLE'",然后发现视图丢了。我的建议是:MetaUtil的listTables方法接收一个types参数,默认只返回TABLE,但如果你想连视图一起导出,可以显式传入new String[]{"TABLE", "VIEW"}。这没什么技术含量,但能避免做数据字典的时候来回改代码。
2.2 系统表不一定都叫SYSTEM TABLE
真正麻烦的是系统表。MySQL里information_schema、performance_schema、mysql、sys这几个库的表,通常TABLE_TYPE会被标记为"SYSTEM VIEW"或"SYSTEM TABLE",但并不是所有驱动都这么老实。PostgreSQL的pg_catalog表也会按"TABLE"上报,如果你不想在输出里看到一堆pg_开头的表,就得在逻辑里按TABLE_SCHEM过滤。
我在MetaUtil里采用的是"黑名单+白名单"双保险:先支持调用方传入schema白名单,比如MySQL场景只查某个业务库;再内置一份常见系统schema名单(information_schema、pg_catalog、sys、mysql、performance_schema等)。这样即使某个驱动把系统表标记成"TABLE",也不会被带出来。
2.3 元数据缓存:启动时扫一次,后面全靠缓存
表结构在运行期几乎是不变的,这种数据天生适合缓存。但缓存粒度要想清楚:表名列表是一个粒度,单表解析出来的TableMeta对象是另一个粒度。表删了又建、字段加了,缓存却还在,这是最常见的问题。
我的设计是两级缓存:第一级缓存全库的表名列表,key是schema或数据源标识;第二级缓存每张表的TableMeta,key是schema:tableName。两级缓存都用ConcurrentHashMap,解析时用computeIfAbsent,避免多线程同时初始化。同时提供refresh()方法手动清空,也支持一个可配置的TTL兜底,防止有人改了表结构但忘记刷新。
提示:缓存只解决重复读取的问题。如果真要感知"外部DBA在线改了表结构"这种变更,TTL必须设置得足够短,或者干脆提供外部刷新接口,让运维改完表后主动调一次。
2.4 一个压测试错结果
我在模拟项目X里做了个简单压测:500张表,平均每张15个字段,直接全量反射一次大概需要1.8秒(本地MySQL,连接复用)。这个数字本身就能说明问题——很多代码生成器启动时会全库扫一遍,如果不做缓存,每次启动都要在原地等近两秒。加上缓存后,第一次启动还是1.8秒,之后每次初始化直接是毫秒级。对于那种"应用启动后要立刻生成实体类映射关系"的场景,这一点性能差异体感非常明显。
3. 字段类型解析:类型映射里最容易翻车的三个细节
3.1 先看DATA_TYPE,再看TYPE_NAME,最后看COLUMN_SIZE
getColumns返回的字段信息很多,但真正用于类型映射的核心列就几个:DATA_TYPE、TYPE_NAME、COLUMN_SIZE、DECIMAL_DIGITS、NULLABLE、REMARKS、IS_AUTOINCREMENT。我的建议是映射顺序严格按"DATA_TYPE -> TYPE_NAME -> COLUMN_SIZE/DECIMAL_DIGITS"来。
DATA_TYPE是java.sql.Types里的整数,这个最稳定,适合做主分类。比如TINYINT、SMALLINT、INTEGER、BIGINT归入整数大类;CHAR、VARCHAR、LONGVARCHAR归入字符串大类;DATE、TIME、TIMESTAMP归入时间大类。TYPE_NAME只在需要细分时才用。举个例子,同样是Types.NUMERIC,在Oracle里对应NUMBER,在SQL Server里对应NUMERIC/DECIMAL,在PostgreSQL里对应NUMERIC,如果不看TYPE_NAME,你是分不出精度语义差别的。
COLUMN_SIZE和DECIMAL_DIGITS决定数值范围。decimal(10,2)这种类型,COLUMN_SIZE=10,DECIMAL_DIGITS=2,映射到Java里就是BigDecimal,这个逻辑比较直白。但要注意SQL Server的nvarchar(MAX),COLUMN_SIZE返回的是Integer.MAX_VALUE,如果不做特殊判断,直接把这个值写到DDL脚本里会出问题。
3.2 unsigned和自增标记别用默认的getBoolean
MySQL的"INT UNSIGNED"在TYPE_NAME里会带一个UNSIGNED字样,DATA_TYPE却仍然是Types.INTEGER。如果你只按DATA_TYPE映射,Java侧会得到一个Integer,但unsigned的int最大可以到4294967295,超出Integer范围。更合理的映射策略是:看到TYPE_NAME里的UNSIGNED标记时,把INTEGER提到LONG、把BIGINT提到BigInteger。这个细节在手写代码生成器时特别容易漏。
IS_AUTOINCREMENT这个列我要单独说一句:真的别直接rs.getBoolean("IS_AUTOINCREMENT")。有些驱动不返回这一列,一调用就抛SQLException;有些驱动返回的是"YES"/"NO"字符串。稳妥写法是先用getObject读取,再判空、toString,最后和"YES"比较:
Object autoInc = null; try { autoInc = rs.getObject("IS_AUTOINCREMENT"); } catch (SQLException ignore) { // 驱动不返回该列时,按未知处理 } boolean autoIncrement = autoInc != null && "YES".equalsIgnoreCase(String.valueOf(autoInc));3.3 TINYINT(1)到底是不是boolean
这个坑简直可以用来当面试题。MySQL的tinyint(1)在语义上只是tinyint的显示宽度为1,很多建表规范里喜欢用它做状态字段,取值可能为0、1、2。但MySQL驱动在默认配置下会把tinyint(1)当作BIT类型返回,到JDBC这一层DATA_TYPE就是Types.BIT。如果你写代码生成器,这一列容易被映射成Java的boolean,一旦它存的是2,反序列化就会出问题。
处理方法有两个:一是在JDBC URL里加tinyInt1isBit=false,让驱动恢复TINYINT的本来面目;二是解析字段类型时优先参考TYPE_NAME,因为TYPE_NAME一般仍然是"TINYINT"。我倾向于第二种,因为URL参数依赖DBA的配置习惯,改不动别人的环境时,代码里兜底更可控。
提示:tinyint(1)是MySQL特有的显示宽度,不是boolean类型。代码生成器如果直接把这一列映射成Java boolean,遇到取值2的数据就会出错。
3.4 Oracle和PostgreSQL的两个特殊习惯
Oracle的NUMBER不带精度时,getColumns返回COLUMN_SIZE=0且DECIMAL_DIGITS=0。这其实是"精度未知"的意思,代码生成器里只能按BigDecimal处理,因为你不知道它到底能放多大数。如果按普通整数处理,就可能丢失数据。判断条件是typeName为NUMBER且decimalDigits为0且columnSize为0,命中后一律走BigDecimal分支。
PostgreSQL的TYPE_NAME经常返回int4、int8、varchar这样的内部类型名,直接拿来做映射会一脸懵。但它DATA_TYPE是标准的,所以处理思路还是那个:先看DATA_TYPE,再看TYPE_NAME。varchar的COLUMN_SIZE也可以在TYPE_NAME带括号时解析出来,但既然JDBC已经给了,没必要自己解析字符串。
4. 主键判断:KEY_SEQ排序与"假主键"识别
4.1 getPrimaryKeys返回行里到底有什么
getPrimaryKeys(catalog, schema, table)返回的ResultSet,核心列是COLUMN_NAME、KEY_SEQ、PK_NAME。注意它的表名参数是精确匹配,不支持百分号通配符,这是和getTables最大的区别。没有主键约束时返回的是空ResultSet,不是null,所以不要做空指针判断,要做"有没有行"判断。
KEY_SEQ是主键列在联合主键里的序号,从1开始,代表该列在索引中的位置。看起来简单,但有一个隐藏问题:虽然JDBC规范要求驱动返回的表行应该包含KEY_SEQ,但不同驱动对行顺序的保证程度不一样。有的驱动按建表顺序返回,有的驱动按索引顺序返回,有的驱动干脆乱序。所以你必须自己按KEY_SEQ排序,不能依赖驱动返回的行顺序。
4.2 联合主键的顺序还原
把KEY_SEQ排序这个点展开说。假设一张表的联合主键是(id, code, tenant_id),建表语句是先id再code再tenant_id,那么getPrimaryKeys里KEY_SEQ分别应该是1、2、3。但如果你只按ResultSet的默认顺序拼接主键字符串,可能得到"id, tenant_id, code"这种错误结果。在做代码生成、查询条件拼接、或导出DDL时,这种顺序错误很难一眼发现。
我在MetaUtil里的做法是:读完全部主键行之后,构造一个列表并按KEY_SEQ升序排序,再拼接或存储。顺便还会把PK_NAME存到主键元数据里,因为有些场景下需要知道约束的名字。
List<PrimaryKeyMeta> pks = new ArrayList<>(); while (rs.next()) { PrimaryKeyMeta pk = new PrimaryKeyMeta(); pk.setColumnName(rs.getString("COLUMN_NAME")); pk.setKeySeq(rs.getShort("KEY_SEQ")); pk.setPkName(rs.getString("PK_NAME")); pks.add(pk); } pks.sort(Comparator.comparingInt(PrimaryKeyMeta::getKeySeq));4.3 没有物理主键时的替代方案
很多老系统的表并没有真正的主键约束,只有唯一索引加NOT NULL。这时getPrimaryKeys返回空,但业务上这些列确实承担着主键职责。MetaUtil提供的一个方法是:当主键为空时,自动扫描唯一索引,把其中NON_UNIQUE=false且所有索引列IS_NULLABLE=NO的索引当作逻辑主键候选。
这里要提醒一下评估标准:唯一索引允许存在多个NULL值,所以列可空的唯一索引绝对不能当主键候选。还要排除那种带有create_time、update_time这类明显不属于主键语义的列,这一步可以用列名黑名单做。逻辑主键不是物理事实,如果拿不准,宁可什么都不给,也别给错。
4.4 主键信息在SQL生成和缓存中的价值
主键判断在做代码生成时不只是为了输出DDL。生成Update语句时,很多人直接按所有字段拼SET,再拼一个WHERE id = ?,如果这张表没有单列主键而是联合主键,where条件就要拼多个列,而且顺序最好和主键索引一致。MetaUtil返回的primaryKeyColumns就是这个环节的输入。如果没有主键判断,代码生成器就只能用"全字段匹配"的逻辑,写出来的更新语句既难看又容易误更新。
主键信息其实属于表结构的一部分,单独再维护一套缓存很没必要。我在TableMeta里直接放一个primaryKeyColumns列表,和字段列表、索引列表一起塞进缓存。这样调用方只需要一次getTableMeta,就能同时拿到主键、字段、索引,不用分三次打元数据接口,这对减少数据库连接压力也有帮助。
5. 索引信息提取:驱动差异与列顺序还原
5.1 getIndexInfo的参数语义与统计行过滤
getIndexInfo(catalog, schema, table, unique, approximate)返回的ResultSet,关键列有INDEX_NAME、NON_UNIQUE、ORDINAL_POSITION、COLUMN_NAME、TYPE。其中unique参数很有迷惑性:传true只返回唯一索引,但主键索引也是唯一索引,所以主键会被一起带出来。approximate参数建议传false,它表示是否允许返回估算值,传true可能拿到统计信息,对精确判断没好处。
返回结果里有一种特殊的行:TYPE列等于tableIndexStatistic(常量0),这种行代表的是表的统计信息而不是真实索引,必须过滤掉。这一步踩的人特别多,因为很多教程不谈TYPE列,导致最后拼出来的"索引列"忽长忽短,排查半天才发现是统计行混进来了。
注意:getIndexInfo返回结果里TYPE列等于0的行是统计信息,不是索引,任何索引解析逻辑都必须先过滤这一行。
5.2 唯一索引、普通索引、联合索引的判定
索引解析的核心操作是按INDEX_NAME分组,组内再按ORDINAL_POSITION排序,得到每个索引的列集合。NON_UNIQUE字段决定是否唯一:false表示唯一索引或主键索引,true表示普通索引。拿到列集合之后,与前面TableMeta里保存的主键列集合做对比,如果完全一致,就可以标记为"主键索引";否则按unique标记为"唯一索引"或"普通索引"。
这里我建议把索引列保存在一个有序列表里,因为联合索引的列顺序和查询优化直接相关。比如(idx_a, idx_b)和(idx_b, idx_a)是两个完全不同的索引,一个对WHERE a=?友好,一个对WHERE b=?友好。顺序错了,后面拿索引信息做查询优化分析时,结论就跑偏了。
5.3 两个容易踩的驱动坑
第一个坑:MySQL 8.0支持函数索引后,部分Connector/J版本在getIndexInfo返回的COLUMN_NAME列是null,同时在EXPRESSION列里放函数表达式。老代码如果直接getString("COLUMN_NAME"),拼出来的索引定义就是"null",数据字典没法看。稳一点的做法是:读COLUMN_NAME,如果为null,就尝试读EXPRESSION列,再不行就用" "占位。
String columnName = rs.getString("COLUMN_NAME"); if (columnName == null) { String expression = null; try { expression = rs.getString("EXPRESSION"); } catch (SQLException ignore) { } columnName = expression != null ? "<expr:" + expression + ">" : "<expression>"; }第二个坑:某些SQL Server驱动在返回聚集索引时,会把表的物理存储结构相关行也一并返回,导致索引数量比实际多。这种情况没有通用银弹,我用的就是"INDEX_NAME + ORDINAL_POSITION列数量"双校验,把列数异常(比如没有任何列)的行直接丢弃。
5.4 索引信息的一个延伸玩法
元数据里的索引信息除了做数据字典,还能用来做简单的SQL覆盖分析。比如某张表有个联合索引(a, b),业务上经常要SELECT a, b WHERE a=?,这时索引列集合能覆盖查询列集合,走这个索引就是典型的覆盖索引场景,可以提示业务不要回表。虽然这还不是真正的执行计划分析,但在一堆候选索引里做初筛,性价比非常高。
6. MetaUtil的最终API形态与扩展建议
6.1 领域模型设计
工具最终面向调用方的是几个纯POJO:TableMeta包含表名、schema、catalog、表注释、字段列表、主键列集合、索引列表;ColumnMeta包含列名、JDBC类型码、数据库类型名、长度、小数位、可空、自增、注释、序号;IndexMeta包含索引名、是否唯一、有序的索引列集合。所有驱动差异都被挡在底层Dialect里,业务代码永远不知道外面连的是MySQL还是Oracle。
底层要有一个Dialect接口,MySQL方言负责解析UNSIGNED、TINYINT(1);Oracle方言负责处理NUMBER精度未知;PostgreSQL方言负责把int4这类内部名字归一化。宁可第一版只实现MySQL,也要把接口预留出来,后面接新数据库就是加一个实现类的事。
6.2 典型调用代码长什么样
实际使用非常简单。从连接池拿一个Connection,构造一次MetaUtil,后面所有元数据都通过缓存返回:
try (Connection conn = dataSource.getConnection()) { MetaUtil metaUtil = new MetaUtil(conn); List<String> tables = metaUtil.listTables("my_schema"); for (String table : tables) { TableMeta meta = metaUtil.getTableMeta("my_schema", table); System.out.printf("表: %s 注释: %s 字段数: %d 主键: %s 索引数: %d%n", meta.getTableName(), meta.getRemarks(), meta.getColumns().size(), meta.getPrimaryKeyColumns(), meta.getIndexes().size()); } }如果你的项目需要生成Java实体类,ColumnMeta里的DATA_TYPE和TYPE_NAME已经够用了,直接在遍历里写switch分支就能生成对应的Java类型;如果要做数据字典导出,把ColumnMeta一行行写进Excel就行,连转换逻辑都不用额外写。
6.3 这个工具后续还能变成什么
在我自己的使用体验里,MetaUtil收敛了JDBC元数据的复杂度之后,真正的想象空间是它上层能做很多事。比如表结构变更对比:两个环境的元数据快照做一次diff,哪些表新增了字段、哪些索引删掉了,一目了然。再比如代码生成器:从表到实体类、Mapper接口、建表SQL,过去要到处问"这个字段类型到底是什么",现在直接从一个对象里取。
最后分享一个小经验:做这类工具时,Connection的生命周期比预期更重要。DatabaseMetaData是根据创建它的Connection生成的,Connection一旦关闭,这个元数据对象就会失效。MetaUtil内部不持有连接,每次解析都从外部传入的Connection里取元数据,结果落进缓存,用完连接马上归还连接池。所以你不用为了缓存而独占一个数据库连接,这也是它能直接集成到现有业务系统连接池里的原因。