Liquibase适配达梦数据库:从Unsupported database到自研方言扩展

发布时间:2026/9/16 22:28:44
Liquibase适配达梦数据库:从Unsupported database到自研方言扩展 前一阵接手一个要在国产化环境落地的 Java 服务数据库这块被明确要求换成达梦。我们团队一直用 Liquibase 管理数据结构变更之前跑 MySQL、PostgreSQL 都很省心结果切到达梦后第一次启动日志里直接出现Unsupported database: DM后面还跟着一串找不到匹配方言的报错。一开始我以为是达梦驱动没配好但用 Navicat 连接完全正常说明网络、账号、驱动都没问题真正的问题出在 Liquibase 根本不认识达梦。后来花了两天把 Liquibase 的数据库方言机制完整摸了一遍写了一个达梦专用的 Database 实现类通过 SPI 注册进去最终顺利跑通了从 changelog 到建表、加索引、数据订正的完整流程。这篇就把适配思路、核心代码和实测踩坑点都整理出来给同样被达梦适配卡住的人一条能直接落地的路径。1. 先搞清楚Liquibase 靠什么识别一个数据库1.1 数据库方言的注册与匹配机制Liquibase 支持多种数据库靠的是一套“数据库方言”机制。核心接口是liquibase.database.Database每个内置数据库实现都是它的子类比如MysqlDatabase、OracleDatabase、PostgresDatabase。Liquibase 启动时通过 ServiceLocator 扫描 classpath 下META-INF/services/liquibase.database.Database文件把里面列出的实现类全部实例化并放进DatabaseFactory的候选列表里。真正执行变更时Liquibase 拿到了已经打开的 JDBC Connection会调用DatabaseFactory.getCorrectDatabaseImplementation(connection)找出最合适的数据库实现。匹配的核心方法是isCorrectDatabaseImplementation(DatabaseConnection)哪个实现返回 trueLiquibase 就用哪个。内置实现对 MySQL 的判断逻辑是数据库产品名等于MySQL对 Oracle 的判断是等于Oracle。如果所有实现都不匹配就会抛出类似Unsupported database的异常。这里有一个关键点Liquibase 并不是只靠 JDBC URL 前缀来识别数据库虽然getDefaultDriver(url)会看 URL 来猜驱动类名但最终拍板的是DatabaseMetaData.getDatabaseProductName()。所以哪怕你的 URL 是jdbc:dm://ip:5236Liquibase 里没有专门处理达梦的实现照样会判定为“不支持”这就是Unsupported database: DM的直接来源。1.2 达梦 JDBC 连接的特征与前置验证达梦 JDBC 连接串的典型格式是jdbc:dm://host:5236驱动类名是dm.jdbc.driver.DmDriver。用 Navicat 或 DBeaver 连接时图形工具已经内置了达梦方言所以测起来很容易。但 Liquibase 需要我们自己告诉它“这个数据库是达梦”。写实现类之前我建议先写一个很小的探针程序用达梦驱动建连接后打印两样东西getDatabaseProductName()实际返回什么getDatabaseMajorVersion()返回多少。这一步非常重要因为不同版本的 DmJdbcDriver 对 productName 的处理不完全一致有的返回DM有的返回Dm还有的可能是DM Database。我刚开始就是因为想当然地匹配DM结果驱动版本返回的是Dm导致匹配失败。后面判断逻辑里把大小写和常见变体都兼容了才彻底稳定下来。另外还要确认达梦的模式机制。达梦里“用户”和“模式”基本绑定登录用户是SYSDBA默认就在SYSDBA模式下创建对象。Liquibase 内部有 catalog 和 schema 两个概念如果不对达梦做对应设置后续 changelog 里写 schemaName 或者 catalogName 会容易搞混。达梦本身支持模式但不建议按 catalog 处理所以我们在实现类里要把supportsCatalogs()返回 falsesupportsSchemas()返回 true把默认模式设置成SYSDBA。2. 二选一继承 OracleDatabase 还是 AbstractJdbcDatabase2.1 快速方案继承 OracleDatabase达梦兼容 Oracle 模式所以最直观的方案是写一个类继承OracleDatabase只把驱动、产品名和 shortName 改掉Oracle 的类型映射、DDL 模板全部复用。代码很少import liquibase.database.DatabaseConnection; import liquibase.database.core.OracleDatabase; import liquibase.exception.DatabaseException; public class DmOracleLikeDatabase extends OracleDatabase { Override protected String getDefaultDatabaseProductName() { return DM; } Override public boolean isCorrectDatabaseImplementation(DatabaseConnection databaseConnection) throws DatabaseException { String productName databaseConnection.getDatabaseProductName(); if (productName null) { return false; } String name productName.trim(); return DM.equalsIgnoreCase(name) || DM DATABASE.equalsIgnoreCase(name) || Dm.equalsIgnoreCase(name); } Override public String getDefaultDriver(String url) { if (url ! null url.startsWith(jdbc:dm:)) { return dm.jdbc.driver.DmDriver; } return null; } Override public String getShortName() { return dm; } }这个方案的好处是省工作量Oracle 里常用的VARCHAR2、NUMBER、SEQUENCE、SYSTIMESTAMP这些语法达梦基本都能兼容很多从 Oracle 迁移来的 changelog 几乎不用改。我们内部讨论时一度就想这么定稿。但隐患也很明显。OracleDatabase里有一堆 Oracle 专属逻辑保留字判断用的是 Oracle 词典默认currentDateTimeFunction是SYSTIMESTAMP序列规则、表空间处理都是按 Oracle 语义写的。达梦虽然语法兼容度高但毕竟不是 Oracle把一个达梦数据库完全伪装成 Oracle 去走 Oracle 的逻辑短期内看不出问题一旦 Liquibase 升级或者某个内部行为变化整条链路就可能被 OracleDatabase 的具体实现细节带偏。再者维护者心里永远悬着一个“它到底是不是真 Oracle”的疑问排查问题时会多一层心智负担。2.2 推荐方案从 AbstractJdbcDatabase 写 DmDatabase最终我选择继承AbstractJdbcDatabase每个方法的语义都在自己控制范围内。核心实现如下package com.example.datasource; import liquibase.database.AbstractJdbcDatabase; import liquibase.database.DatabaseConnection; import liquibase.exception.DatabaseException; import java.util.Arrays; import java.util.List; public class DmDatabase extends AbstractJdbcDatabase { private static final ListString DM_PRODUCT_NAMES Arrays.asList(DM, Dm, DM DATABASE); public DmDatabase() { setDefaultSchemaName(SYSDBA); setDefaultCatalogName(SYSDBA); setCurrentDateTimeFunction(SYSDATE); setDatabaseChangeLogTableName(DATABASECHANGELOG); setDatabaseChangeLogLockTableName(DATABASECHANGELOGLOCK); } Override protected String getDefaultDatabaseProductName() { return DM; } Override public boolean isCorrectDatabaseImplementation(DatabaseConnection databaseConnection) throws DatabaseException { if (databaseConnection null) { return false; } String productName databaseConnection.getDatabaseProductName(); if (productName null) { return false; } String name productName.trim(); for (String dmName : DM_PRODUCT_NAMES) { if (dmName.equalsIgnoreCase(name)) { return true; } } return false; } Override public String getShortName() { return dm; } Override public String getDefaultDriver(String url) { if (url ! null url.startsWith(jdbc:dm:)) { return dm.jdbc.driver.DmDriver; } return null; } Override public Integer getDefaultPort() { return 5236; } Override public boolean supportsCatalogs() { return false; } Override public boolean supportsSchemas() { return true; } Override public boolean supportsCatalogInCreate() { return false; } Override public boolean supportsInitiallyDeferrableColumns() { return false; } Override public String getAutoIncrementClause() { return IDENTITY(1,1); } Override public boolean isReservedWord(String object) { return super.isReservedWord(object) || USER.equalsIgnoreCase(object) || LEVEL.equalsIgnoreCase(object) || COMMENT.equalsIgnoreCase(object); } }逐个说下关键点getDefaultDriver判断jdbc:dm:前缀并返回达梦驱动类名这样 Liquibase 可以根据 URL 猜出驱动。isCorrectDatabaseImplementation对 productName 做大小写无关的兼容判断前面探针程序打印出来的是什么这里就兼容什么。getDefaultPort返回 5236方便 Liquibase 在构造 URL 时拼接端口。supportsSchemas返回 true、supportsCatalogs返回 false符合达梦“用户即模式”的习惯。构造函数里把currentDateTimeFunction设成SYSDATE很关键。Liquibase 在生成DATEEXECUTED这类默认时间戳时会用到数据库的当前时间函数默认的NOW()在达梦上不一定被识别改成SYSDATE就稳了。getAutoIncrementClause返回IDENTITY(1,1)这样 changelog 里autoIncrementtrue的列能正确建出自增列。isReservedWord补充了几个常见的达梦保留字避免建表时因为字段名撞上保留字而报错。这两个方案做一张对比表更清楚对比项继承 OracleDatabase继承 AbstractJdbcDatabase代码量少中等类型映射复用完全复用 Oracle默认通用映射需要时自己补保留字处理Oracle 词典默认通用词典可补充达梦保留字维护可控性低高Liquibase 升级风险偏高较低适合场景快速验证、临时跑通长期维护、需要控细节我在实际项目中选了后者多花半天时间但后面跑业务全靠它心里踏实。3. 类型映射与 DDL 生成把 changelog 翻译成达梦认识的 SQL3.1 标识符大小写和保留字达梦在默认情况下不带双引号的表名和字段名会统一转成大写存储。Liquibase 生成 DDL 时默认也不给对象名加双引号所以 changelog 里写tableNameuserInfo到达梦上实际建出来的表名就是USERINFO。如果你再用小写去查就报“表或视图不存在”。解决这个问题有两个方向。第一个是规范 changelog 写法对象名统一大写或者在写表名、列名时显式用双引号包起来例如tableName\userInfo\。第二个是从 DmDatabase 层面重写escapeObjectName让所有对象名都带双引号。我个人推荐第一种贴近达梦使用习惯而且切回其它数据库时不会有引号残留问题。第二种会让生成的 SQL 每个对象名都带双引号调试日志会显得很啰嗦逻辑上也改变了对对象名语义的处理容易引入新问题。保留字问题也需要注意。达梦和 Oracle 的保留字集合有差异Liquibase 默认的保留字库是按常见数据库整理的并不包含达梦特有的一些词。我遇到比较典型的是USER、LEVEL、COMMENT。你在 changelog 里给字段起名user达梦可能直接给一个语法错误但 Lquibase 预检不出来报错位置又指向生成的整条 SQL排查起来挺费劲。所以isReservedWord里尽量把业务上会用到的高频保留字补进去。3.2 自增列、序列的推荐写法达梦支持两种自增方式IDENTITY 列和 SEQUENCE 加触发器。changelog 里最简单的是用autoIncrementtrue配合前面实现的getAutoIncrementClause最终生成的 SQL 类似CREATE TABLE t_user ( id BIGINT IDENTITY(1,1) NOT NULL, user_name VARCHAR(64), created_time TIMESTAMP DEFAULT SYSDATE, CONSTRAINT PK_T_USER PRIMARY KEY (id) )如果需要显式插入主键值或者在插入前要拿到下一个序列值可以用 sequence 标签changeSet id20240101-002 authoryou createSequence sequenceNameSEQ_USER_ID startValue1 incrementBy1/ createTable tableNamet_user_log column nameid typeBIGINT defaultValueSequenceNextSEQ_USER_ID constraints primaryKeytrue nullablefalse/ /column column nameremark typeVARCHAR(255)/ /createTable /changeSet达梦对序列的nextval语法和 Oracle 一致所以defaultValueSequenceNext能正常工作。这里有一个细节达梦的序列和表一样归属于某个模式如果 changelog 里没有显式写 schemaName默认会落在登录用户的模式下面。多用户环境下建议在createSequence里写清楚schemaName避免后续权限和归属问题。3.3 什么时候需要自定义 TypeConverter如果只是常规建表、加字段、加索引继承AbstractJdbcDatabase之后Liquibase 默认的通用类型映射已经够用。我在项目初期跑通全流程时并没有写任何自定义类型转换VARCHAR、BIGINT、TIMESTAMP、CLOB 这些类型生成到达梦上都是对的。但如果项目里大量使用 Oracle 专有类型比如带精度的NUMBER(p,s)、VARCHAR2、BINARY_DOUBLE建议补一个自定义TypeConverter在fromDescription里把非通用类型显式映射成达梦能识别的类型。实现方式是在META-INF/services/liquibase.datatype.TypeConverter文件里列出类名Liquibase 启动时会自动加载。这个模块属于锦上添花初期先把主流程跑通等到实际执行变更时出现类型不识别报错再针对性补就行不需要一开始就把它做得很重。4. 接入 Spring Boot把扩展装进项目并跑起来4.1 依赖与驱动安装pom.xml 里增加 Liquibase 依赖和达梦驱动依赖dependency groupIdorg.liquibase/groupId artifactIdliquibase-core/artifactId version4.23.2/version /dependency dependency groupIdcom.dameng/groupId artifactIdDmJdbcDriver18/artifactId version8.1.2.192/version /dependency达梦官方驱动通常不在公共 Maven 中央仓库。公司私服里如果有直接引用就行没有的话就先下载 jar然后手动安装到本地仓库mvn install:install-file -DfileDmJdbcDriver18.jar \ -DgroupIdcom.dameng \ -DartifactIdDmJdbcDriver18 \ -Dversion8.1.2.192 \ -Dpackagingjar版本选择上JDK 8 及以上用DmJdbcDriver18JDK 7 用DmJdbcDriver17不要装错。我实测的版本组合是 Liquibase 4.23.2、DmJdbcDriver18 8.1.2.192、DM8 服务端Spring Boot 2.7.14整体稳定。4.2 application.yml 配置与 changelog 示例Spring Boot 配置如下spring: datasource: url: jdbc:dm://127.0.0.1:5236 username: SYSDBA password: SYSDBA driver-class-name: dm.jdbc.driver.DmDriver liquibase: enabled: true change-log: classpath:/db/changelog/db.changelog-master.xml如果 DmDatabase 已经通过 SPI 注册成功Liquibase 会自动匹配Spring Boot 端不需要额外指定 databaseClass。如果 SPI 加载因为各种原因失败还可以通过自定义SpringLiquibaseBean 的方式手动把DmDatabase实例传进去或者使用 Liquibase properties 里的databaseClass属性强行指定。不过这两种方式都相当于绕过了自动匹配会让 Spring Boot 的自动配置失去一部分意义所以能走 SPI 尽量走 SPI。changelog 主文件我习惯用 XML可读性最好?xml version1.0 encodingUTF-8? databaseChangeLog xmlnshttp://www.liquibase.org/xml/ns/dbchangelog xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd changeSet id20240101-001 authoryou createTable tableNameT_USER column nameID typeBIGINT autoIncrementtrue constraints primaryKeytrue nullablefalse/ /column column nameUSER_NAME typeVARCHAR(64) constraints nullablefalse/ /column column nameCREATED_TIME typeTIMESTAMP defaultValueDateSYSDATE/ /createTable /changeSet /databaseChangeLog4.3 验证清单启动应用后按这个清单逐项确认日志中能正常看到 Liquibase 执行CREATE TABLE DATABASECHANGELOG和DATABASECHANGELOGLOCK。用 Navicat 或 DBeaver 连到达梦查看DATABASECHANGELOG里有刚刚执行的 changeSet 记录。打开目标模式下有没有生成业务表T_USER以及对应的主键、索引是否创建成功。再执行一次启动确认 Liquibase 不会重复执行已经记录过的 changeSet。这一步能过基本说明适配是通的。接下来就是正常地往 changelog 里加变更集跟操作 MySQL 一样。5. 实测踩坑五个最容易让适配项目翻车的地方5.1 大小写偏好会引发“表不存在”我在第一个业务 changeSet 里写了驼峰表名结果启动时报“表或视图不存在”。查生成的 SQL 才发现表名被达梦转成了大写后面查询逻辑里又用了驼峰自然匹配不上。这个坑很隐蔽因为报错不一定出现在 Liquibase 执行期而是出现在业务代码的第一次查询。解决办法就是前面说的changelog 里的对象名统一大写或者统一加双引号全项目约定一致。不要一半大写一半小写否则后面排查会非常痛苦。5.2 驱动类名和 JDK 版本不匹配达梦驱动有多个版本类名也不一样。DmJdbcDriver18的驱动类是dm.jdbc.driver.DmDriver但旧版本可能是dm.jdbc.driver.DmDriver加别的后缀或者dm.jdbc.driver.Driver。我在第一次加载驱动时直接被抛ClassNotFoundException查了整整一个小时最后发现是同事本地手滑装了一个 JDK 7 版本的驱动 jar。强烈建议在 pom 里锁定依赖版本不要用系统目录里的散装 jar否则团队协作时很容易出现“我本地能跑你那边跑不起来”的尴尬。5.3 DATABASECHANGELOG 初始化失败Liquibase 首次运行时需要建DATABASECHANGELOG和DATABASECHANGELOGLOCK两张表。如果当前用户只有 DML 权限、没有 DDL 权限初始化会直接失败。达梦里用户和模式绑定权限控制也比 MySQL 严格所以我给业务账号授权时特意检查了建表、建索引、建序列的权限。还有一个低概率但真实存在的问题如果达梦服务端的兼容模式设置得比较奇怪TIMESTAMP 类型的默认值函数识别不了也可能卡在初始化阶段。遇到这种情况先把数据库的兼容模式调整成 Oracle 兼容再重试一次大部分问题都能解决。5.4 Liquibase 版本 API 差异导致的编译问题Liquibase 4.x 的接口在不同小版本之间有过微调。比如Database接口在较老版本里要求实现isCorrectDatabaseImplementation(DatabaseConnection)在新版本里又增加了带DatabaseProductName的重载方法。如果你从网上复制一段针对 Liquibase 3.x 的扩展代码大概率编译不过。我在第一次实现时就遇到了getDefaultDatabaseProductName这个方法是 protected 还是 public 的差异问题。建议以你实际引入的 liquibase-core 版本源码为参照不要盲目相信网上文章。最直接的办法是打开反编译类照着MySQLDatabase和OracleDatabase的源码结构来写保证方法签名和当前版本一致。5.5 fat jar 中 SPI 文件没被合并这是 Spring Boot 打包场景最容易踩的坑。项目里如果有多个模块都提供了META-INF/services文件打包成 fat jar 时如果构建插件没有做 SPI 文件合并后打进去的文件会覆盖前面的导致DmDatabase没有被注册。我之前在某次打包后就出现过“本地 IDE 能跑通打成 jar 部署就报 Unsupported database”的诡异问题。排查方法很简单用压缩工具打开最终产物 jar找到META-INF/services/liquibase.database.Database看看里面有没有DmDatabase的完整类名。如果被覆盖了有两种修复方式一是调整打包插件配置使用maven-shade-plugin的ServicesResourceTransformer合并 SPI 文件二是在启动类里手动注册Bean public Liquibase liquibase(DataSource dataSource) { DatabaseFactory.getInstance().register(new DmDatabase()); // 其余 SpringLiquibase 配置 }手动注册方式虽然不如 SPI 优雅但非常可靠尤其在大型多模块项目里能省去很多构建层面的麻烦。我现在做内部基础设施组件时两种方式都会配好SPI 作为默认手动注册作为兜底。最后分享一点个人体会。整个方案跑通之后我把DmDatabase单独抽成了一个 starter 组件放在公司内部基础库里后续项目只要引入依赖就能获得达梦支持不用每接一个新项目就重新踩一遍上面这些坑。如果你的项目也被达梦适配卡住建议先花时间把 Liquibase 匹配数据库的机制看明白再决定是继承 OracleDatabase 还是从 AbstractJdbcDatabase 自己实现千万不要在 changelog 里写一堆针对 Oracle 的黑魔法去迁就一个“假 Oracle”。等将来 Liquibase 官方或者达梦官方把方言彻底收编也许这套代码就可以退役了但在那之前自己维护一个几十行的 DmDatabase是成本最低也最稳妥的选择。