准备做一个全库数据字典导出功能的时候我才第一次认认真真把JDBC的DatabaseMetaData从头翻到尾。之前一直觉得它就是个查表结构的接口真上手才发现表结构查询、字段类型解析、主键判断、索引信息提取这四件事每件事都有自己的一套方法和返回结构参数忽而是通配符忽而是精确值返回的ResultSet在不同数据库驱动下面目全非。折腾几天之后我把这些逻辑收敛成了一个叫MetaUtil的工具类封装了连接元数据反射、字段类型归一化、主键与索引解析加了一层缓存总算能一条链式调用拿回全部元数据了。这篇文章会把工具的设计过程和排坑细节完整写出来给正在写代码生成器、数据字典、或任何需要与数据库结构打交道的同学做个参考。1. 从JDBC原生元数据API的痛点说起1.1 四个方法四种让人记混的参数约定先放结论JDBC原生API把取元数据这件事拆成了四个入口getTables、getColumns、getPrimaryKeys、getIndexInfo参数设计还不太一样。我列个表大家感受下方法catalog/schema表名参数第四参第五参getTablesschemaPatterntableNamePatterntypes[]无getColumnsschemaPatterntableNamePatterncolumnNamePattern无getPrimaryKeysschematable精确无无getIndexInfoschematable精确uniqueapproximate前两个方法的表名参数是pattern支持百分号通配符后两个就是精确表名。很多同学第一次写的时候都会顺手把getPrimaryKeys的第三参传成%结果要么报错要么返回空。还有一个容易混淆的点MySQL习惯用catalogOracle和PostgreSQL习惯用schemaJDBC方法却把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_SIZE0。如果你只在业务代码里写一版根据getColumns结果生成Java类型的逻辑换一个库就得改一堆if else。还有一个让很多人忽略的细节部分MySQL驱动版本默认不读information_schema导致REMARKS字段表注释/字段注释为null。解决方法是JDBC URL里加上useInformationSchematrue但这个问题在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或数据源标识第二级缓存每张表的TableMetakey是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_SIZEgetColumns返回的字段信息很多但真正用于类型映射的核心列就几个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_SIZE10DECIMAL_DIGITS2映射到Java里就是BigDecimal这个逻辑比较直白。但要注意SQL Server的nvarchar(MAX)COLUMN_SIZE返回的是Integer.MAX_VALUE如果不做特殊判断直接把这个值写到DDL脚本里会出问题。3.2 unsigned和自增标记别用默认的getBooleanMySQL的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里加tinyInt1isBitfalse让驱动恢复TINYINT的本来面目二是解析字段类型时优先参考TYPE_NAME因为TYPE_NAME一般仍然是TINYINT。我倾向于第二种因为URL参数依赖DBA的配置习惯改不动别人的环境时代码里兜底更可控。提示tinyint(1)是MySQL特有的显示宽度不是boolean类型。代码生成器如果直接把这一列映射成Java boolean遇到取值2的数据就会出错。3.4 Oracle和PostgreSQL的两个特殊习惯Oracle的NUMBER不带精度时getColumns返回COLUMN_SIZE0且DECIMAL_DIGITS0。这其实是精度未知的意思代码生成器里只能按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存到主键元数据里因为有些场景下需要知道约束的名字。ListPrimaryKeyMeta 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_UNIQUEfalse且所有索引列IS_NULLABLENO的索引当作逻辑主键候选。这里要提醒一下评估标准唯一索引允许存在多个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 领域模型设计工具最终面向调用方的是几个纯POJOTableMeta包含表名、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); ListString 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里取元数据结果落进缓存用完连接马上归还连接池。所以你不用为了缓存而独占一个数据库连接这也是它能直接集成到现有业务系统连接池里的原因。
阅读完成 · 觉得有帮助?