前一阵接手一个要在国产化环境落地的 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),哪个实现返回 true,Liquibase 就用哪个。内置实现对 MySQL 的判断逻辑是数据库产品名等于MySQL,对 Oracle 的判断是等于Oracle。如果所有实现都不匹配,就会抛出类似Unsupported database的异常。
这里有一个关键点:Liquibase 并不是只靠 JDBC URL 前缀来识别数据库,虽然getDefaultDriver(url)会看 URL 来猜驱动类名,但最终拍板的是DatabaseMetaData.getDatabaseProductName()。所以哪怕你的 URL 是jdbc:dm://ip:5236,Liquibase 里没有专门处理达梦的实现,照样会判定为“不支持”,这就是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()返回 false,supportsSchemas()返回 true,把默认模式设置成SYSDBA。
2. 二选一:继承 OracleDatabase 还是 AbstractJdbcDatabase
2.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 List<String> 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 里autoIncrement="true"的列能正确建出自增列。isReservedWord补充了几个常见的达梦保留字,避免建表时因为字段名撞上保留字而报错。
这两个方案做一张对比表更清楚:
| 对比项 | 继承 OracleDatabase | 继承 AbstractJdbcDatabase |
|---|---|---|
| 代码量 | 少 | 中等 |
| 类型映射复用 | 完全复用 Oracle | 默认通用映射,需要时自己补 |
| 保留字处理 | Oracle 词典 | 默认通用词典,可补充达梦保留字 |
| 维护可控性 | 低 | 高 |
| Liquibase 升级风险 | 偏高 | 较低 |
| 适合场景 | 快速验证、临时跑通 | 长期维护、需要控细节 |
我在实际项目中选了后者,多花半天时间,但后面跑业务全靠它,心里踏实。
3. 类型映射与 DDL 生成:把 changelog 翻译成达梦认识的 SQL
3.1 标识符大小写和保留字
达梦在默认情况下,不带双引号的表名和字段名会统一转成大写存储。Liquibase 生成 DDL 时默认也不给对象名加双引号,所以 changelog 里写tableName="userInfo",到达梦上实际建出来的表名就是USERINFO。如果你再用小写去查,就报“表或视图不存在”。
解决这个问题有两个方向。第一个是规范 changelog 写法:对象名统一大写,或者在写表名、列名时显式用双引号包起来,例如tableName="\"userInfo\""。第二个是从 DmDatabase 层面重写escapeObjectName,让所有对象名都带双引号。我个人推荐第一种,贴近达梦使用习惯,而且切回其它数据库时不会有引号残留问题。第二种会让生成的 SQL 每个对象名都带双引号,调试日志会显得很啰嗦,逻辑上也改变了对对象名语义的处理,容易引入新问题。
保留字问题也需要注意。达梦和 Oracle 的保留字集合有差异,Liquibase 默认的保留字库是按常见数据库整理的,并不包含达梦特有的一些词。我遇到比较典型的是USER、LEVEL、COMMENT。你在 changelog 里给字段起名user,达梦可能直接给一个语法错误,但 Lquibase 预检不出来,报错位置又指向生成的整条 SQL,排查起来挺费劲。所以isReservedWord里尽量把业务上会用到的高频保留字补进去。
3.2 自增列、序列的推荐写法
达梦支持两种自增方式:IDENTITY 列和 SEQUENCE 加触发器。changelog 里最简单的是用autoIncrement="true",配合前面实现的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 id="20240101-002" author="you"> <createSequence sequenceName="SEQ_USER_ID" startValue="1" incrementBy="1"/> <createTable tableName="t_user_log"> <column name="id" type="BIGINT" defaultValueSequenceNext="SEQ_USER_ID"> <constraints primaryKey="true" nullable="false"/> </column> <column name="remark" type="VARCHAR(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> <groupId>org.liquibase</groupId> <artifactId>liquibase-core</artifactId> <version>4.23.2</version> </dependency> <dependency> <groupId>com.dameng</groupId> <artifactId>DmJdbcDriver18</artifactId> <version>8.1.2.192</version> </dependency>达梦官方驱动通常不在公共 Maven 中央仓库。公司私服里如果有,直接引用就行;没有的话就先下载 jar,然后手动安装到本地仓库:
mvn install:install-file -Dfile=DmJdbcDriver18.jar \ -DgroupId=com.dameng \ -DartifactId=DmJdbcDriver18 \ -Dversion=8.1.2.192 \ -Dpackaging=jar版本选择上,JDK 8 及以上用DmJdbcDriver18,JDK 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 version="1.0" encoding="UTF-8"?> <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd"> <changeSet id="20240101-001" author="you"> <createTable tableName="T_USER"> <column name="ID" type="BIGINT" autoIncrement="true"> <constraints primaryKey="true" nullable="false"/> </column> <column name="USER_NAME" type="VARCHAR(64)"> <constraints nullable="false"/> </column> <column name="CREATED_TIME" type="TIMESTAMP" defaultValueDate="SYSDATE"/> </createTable> </changeSet> </databaseChangeLog>4.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,是成本最低也最稳妥的选择。