这个系列写到第四篇,前面几篇把 NestJS 的基础工程、鉴权、配置管理都跑通了,今天聊一个让不少兄弟头疼的问题:国产化项目要求数据库换成达梦,但开发环境一直是 MySQL,而且业务代码已经写了一堆,不可能推倒重来。
我接到这个需求的时候,第一反应是搜 TypeORM 有没有达梦官方 driver,搜了一圈发现——基本没有像 mysql2 那种开箱即用的东西。达梦的 Node.js 生态非常薄弱,网上能搜到的资料大多是 Java 的 JDBC 方案,偶尔有几篇 Node 的还停留在「能连上」的阶段,连分页、事务、时间字段这种常规操作都覆盖不全。
这篇文章我把整条路走了一遍之后的东西整理出来:一套 NestJS 代码,通过配置切换就能同时跑在 MySQL 和达梦上,业务层零感知。内容覆盖驱动选型、数据源动态装配、方言差异治理、以及我在实际项目中踩过的几个大坑。适合正在做信创适配的 Node 后端同学,尤其是那种客户环境已经定死达梦、但团队开发机还在用 MySQL 的项目。
1. 先搞清楚达梦在 Node 生态里的真实地位
达梦(DM)是国内用得比较多的国产关系型数据库,核心卖点是高度兼容 Oracle 语法,同时也能切到 MySQL 兼容模式。但这是站在 SQL 层面说的,跟 Node.js 能不能连上它是两回事。
1.1 Node.js 生态的现状
达梦官方的客户端驱动,在 Java 世界有 JDBC,Python 有 dmPython,.NET 有 Provider,但 Node.js 这边长期没有一等公民级的官方驱动。哪怕现在能拿到官方提供的 Node 组件,文档也少得可怜,更不用说像 mysql2 那样有完善的连接池、预处理语句、类型转换方案。
TypeORM 这边更直接,内置 driver 列表里没有达梦。这带来一个连锁反应:NestJS 文档里的标准写法 TypeOrmModule.forRoot({ type: 'mysql', ... }) 只能对付 MySQL,面对达梦你会发现自己连 type 字段都不知道填什么。
1.2 三条现实的接入路径
我调研下来发现,Node 项目接达梦基本上有三条路:
第一条:用官方或社区提供的 Node 驱动直连。 这条路最顺,但需要看达梦版本和你拿到的驱动包是否匹配。达梦 8 有一些第三方封装,但成熟度参差不齐,挑的时候要重点看它对 prepared statement、事务、BLOB 这几个硬骨头的支持程度。
第二条:JDBC Bridge 旁路。 Node 服务不直接连达梦,而是通过一个极薄的 Java 服务做 SQL 透传。这个方案看起来绕,但在信创项目里非常常见,因为客户环境里 Java 栈是主流,JDBC 驱动是最稳的。代价是多一个进程要部署和守护,网络链路多一跳。
第三条:驱动层伪装成 MySQL。 达梦兼容 MySQL 模式时,大部分 SQL 语法是接近的。于是有人选择在应用层继续用 type: 'mysql',把差异尽量收敛到 SQL 方言层。这个方案对代码侵入最小,但对团队 SQL 规范要求很高,稍不注意就会埋雷。
1.3 我的结论
我最终采用的是「官方/社区驱动直连为主,JDBC Bridge 兜底,方言差异统一收口」的组合策略。核心思路一句话:不管底层驱动是怎么连上的,上层一定要把「数据源类型」和「业务代码」彻底解耦。 业务代码永远不写 if (dbType === 'dm') 这种分支,所有差异都收口到配置层和查询构造层。
这一步想清楚了,后面所有工作都围绕这个原则展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 驱动接入:从「连不上」到「稳定连接」
驱动是地基,这块不踏实,后面全白搭。我建议你按下面的顺序做验证,不要一上来就接 NestJS。
2.1 环境准备
我这边客户给的是达梦 8.1,部署在麒麟 V10 上,默认端口 5236。如果你第一次接触达梦,先用达梦自带的 Manager 客户端或者 DBeaver 连一次,确认三件事:库能连、账户有权限、测试表能建。
DBeaver 连达梦需要手动加驱动,在数据库驱动管理器里新建驱动,把达梦安装目录下的 DmJdbcDriver.jar 填进去,URL 模板写 jdbc:dm://{host}:{port}。先用 DBA 账户连上去,创建项目专用的业务账号,顺手把字符集确认了。达梦默认字符集如果跟 MySQL 不一致,后面中文乱码会折腾到你怀疑人生。
2.2 官方 Node 驱动的接入体验
达梦官方在不同时期发布过 Node.js 驱动包,名称在 npm 上出现过多个版本,包名未必统一。我的建议是:优先找你们项目资料库或达梦官网开发者专区里的安装包,不要随便 npm install 一个来路不明的包,国产数据库驱动这种基础组件,来源要可信。
拿到驱动包之后,先写一个 30 行的连接测试脚本,不要直接上 TypeORM。验证四件事:
- 能建立连接并执行
SELECT 1 - 预处理语句能跑,参数占位符语法不会报错
- 事务能正常
BEGIN / COMMIT / ROLLBACK - 中文读写不乱码
这四步全部通过,驱动才算基本可用。我这边的经验是,前三步通常两天内能跑通,第四步才是真正的拦路虎,后面单独讲。
2.3 JDBC Bridge 兜底方案
如果你拿到的驱动包质量不行,或者达梦版本太老,那就走 JDBC Bridge。我设计的 Bridge 是一个 Spring Boot 小服务,只暴露内网接口,接收 SQL 和参数,内部用 JDBC 执行后返回结果集。
Bridge 接口设计成 JSON 格式,POST /query,请求体包含 sql、params、timeout 三个字段,响应统一包一层 success / data / error。千万不要把 Bridge 暴露到公网,也不要做成通用查询网关,只允许内部业务服务访问,并且按来源 IP 做白名单。
Bridge 方案的优点是稳定,JDBC 是达梦支持最到位的连接方式,什么分页、事务、BLOB 都有现成解决方案。缺点是 Node 到 Bridge 之间多了一次序列化和网络传输,性能有一定损失,但业务量不大的场景完全够用。
2.4 让 TypeORM 接受这个驱动
接下来是关键步骤:TypeORM 没有达梦 driver,怎么把上面的连接能力接进去?
我的做法是编写一个自定义 Driver 类,以 TypeORM 的 MysqlDriver 为基底扩展。之所以选 MysqlDriver 而不是 OracleDriver,是因为达梦开 MySQL 兼容模式后,SQL 方言、字段类型、标识符规则都更接近 MySQL,适配成本最低。
typescript复制import { MysqlDriver } from 'typeorm/driver/mysql/MysqlDriver';
export class DmDriver extends MysqlDriver {
// 在这里覆盖连接创建、方言方法、数据类型映射等
// 比如创建连接时,替换成达梦驱动的连接逻辑
async connect() {
// 基于 dmdb 或 JDBC Bridge 建立真实连接
// 然后复用 MysqlDriver 的查询执行能力
}
}
这里我不放完整实现,因为不同版本的 TypeORM 内部 API 略有差异。但方向是明确的:扩展 MysqlDriver,只覆盖差异点,其余交给父类。 这样 TypeORM 的 DataSource、Repository、QueryBuilder 全部可以复用,业务代码完全不用感知底层是 MySQL 还是达梦。
连接池参数也建议显式配置,不要用默认值。我用的配置是 max: 10、min: 2、connectionTimeout: 5000、idleTimeout: 60000,并发不高的情况下这个组合比较稳。如果是 JDBC Bridge 方案,连接池要建在 Bridge 那侧,Node 到 Bridge 之间用 HTTP 连接池,两边超时都要设置,避免请求堆积。
3. 数据源动态装配:一套 TypeOrmModule 配置接管两个库
驱动层搞定了,接下来要解决的是「一套代码怎么在两种库之间无缝切换」。我的方案是:所有差异由环境变量驱动,启动时决定连哪个库。
3.1 配置项设计
我定义了一套统一的数据库环境变量,不管底层是 MySQL 还是达梦,都用同一组变量名:
| 环境变量 | 说明 | 示例 |
|---|---|---|
DB_TYPE |
数据库类型,mysql 或 dm |
dm |
DB_HOST |
数据库地址 | 192.168.1.10 |
DB_PORT |
端口,达梦默认 5236,MySQL 默认 3306 | 5236 |
DB_USERNAME |
用户名 | app_user |
DB_PASSWORD |
密码 | ****** |
DB_DATABASE |
库名 | app_db |
DB_SYNC |
是否自动同步表结构,仅开发环境开 | false |
这套变量的好处是,项目成员迁移到另一种库时,只需要改 .env 文件,代码一行不动。
3.2 按环境加载 DataSource 的工厂函数
核心代码是这个 createDatabaseOptions 函数:
typescript复制import { DataSourceOptions } from 'typeorm';
import { DmDriver } from './drivers/dm.driver';
export function createDatabaseOptions(config: ConfigService): DataSourceOptions {
const dbType = config.get('DB_TYPE', 'mysql');
const baseOptions: DataSourceOptions = {
type: 'mysql',
host: config.get('DB_HOST'),
port: parseInt(config.get('DB_PORT'), 10),
username: config.get('DB_USERNAME'),
password: config.get('DB_PASSWORD'),
database: config.get('DB_DATABASE'),
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
synchronize: config.get('DB_SYNC') === 'true',
logging: config.get('DB_LOGGING') === 'true',
timezone: '+08:00',
charset: 'utf8mb4',
maxQueryExecutionTime: 1000,
};
if (dbType === 'dm') {
return {
...baseOptions,
// 自定义驱动接管连接逻辑
driver: new DmDriver(),
};
}
return baseOptions;
}
注意这里面我在驱动层做了一个细节处理:当 DB_TYPE=dm 时,通过 driver 字段替换掉默认的 MySQL 连接逻辑,这个能力不同版本的 TypeORM 接入方式可能不一样,但只要自定义 Driver 类实现了对应接口,整体思路是通用的。
3.3 注册到 NestJS 模块
在 DatabaseModule 里用 TypeOrmModule.forRootAsync 动态注入:
typescript复制@Module({})
export class DatabaseModule {
static forRoot(): DynamicModule {
return {
module: DatabaseModule,
imports: [
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => createDatabaseOptions(config),
}),
],
};
}
}
在 app.module.ts 里引入:
typescript复制@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
DatabaseModule.forRoot(),
// 其他业务模块
],
})
export class AppModule {}
到这里,整个 NestJS 应用已经具备了「启动时根据环境变量决定连 MySQL 还是达梦」的能力。
3.4 业务代码零感知的关键
这一步做完后,业务层用法跟之前完全一样:
typescript复制@Injectable()
export class UserService {
constructor(
@InjectRepository(UserEntity)
private readonly userRepo: Repository<UserEntity>,
) {}
async findByEmail(email: string): Promise<UserEntity | null> {
return this.userRepo.findOne({ where: { email } });
}
}
没有任何 if 判断,没有 dbType 分支。底层连的是 MySQL 还是达梦,业务层完全不关心。这就是「一套代码双库跑」的核心——把差异隔离在配置层和驱动层,业务代码不碰数据库类型。
这里多说一句:如果你的项目真的需要「同时连接」两个库,而不是「切换」连接,NestJS 也支持注册两个 DataSource,用不同的名称区分。但大部分国产化项目的实际需求是「开发用 MySQL,生产用达梦」,并不是两个库同时在线,所以「切换」就够用了。
4. 方言差异集中治理:分页、函数、类型映射和保留字
即使有了自定义 Driver,MySQL 和达梦在 SQL 方言上的差异依然存在。这些差异如果散落在各个 Service 里,就是定时炸弹。我的原则是:所有方言差异必须集中治理,团队约定统一写法。
4.1 分页查询:尽量用 QueryBuilder
MySQL 的分页是 LIMIT offset, count,达梦在 MySQL 兼容模式下也能用 LIMIT,但如果你连的达梦实例跑在 Oracle 兼容模式下,LIMIT 直接报错。
最稳妥的做法是:所有分页查询一律走 TypeORM 的 QueryBuilder 的 take 和 skip 方法,不要手写 LIMIT。
typescript复制const [list, total] = await this.userRepo.findAndCount({
where: { status: 1 },
take: 20,
skip: 0,
});
findAndCount 和 find 的 take/skip 参数,TypeORM 底层会按照当前 driver 的方言生成对应的分页 SQL。自定义 Driver 只要把方言方法实现对了,分页这件事业务层永远不用关心。
4.2 函数差异:能不用就不用
MySQL 的 DATE_FORMAT、IFNULL、GROUP_CONCAT 这些函数,在达梦里不一定有同名的。比如 IFNULL,MySQL 在用,达梦 Oracle 模式要用 NVL,虽然 MySQL 兼容模式也支持 IFNULL,但你不能赌每个环境都开了兼容模式。
我的建议是:时间格式化、字符串拼接这类操作,尽量在应用层用 JS 处理,不要写进 SQL。 实在要在 SQL 里用的,写一个方言函数注册表:
| 功能 | MySQL | 达梦(Oracle 兼容模式) |
|---|---|---|
| 空值处理 | IFNULL(a, b) |
NVL(a, b) |
| 时间格式化 | DATE_FORMAT(t, '%Y-%m-%d') |
TO_CHAR(t, 'YYYY-MM-DD') |
| 字符串拼接 | CONCAT(a, b) |
a || b 或 CONCAT(a, b) |
| 取当前时间 | NOW() |
NOW() 或 SYSDATE |
这张表建好之后,所有涉及方言函数的调用点都走一个统一封装,禁止业务代码里出现裸的 DATE_FORMAT 或 TO_CHAR。
4.3 类型映射:建表不再头疼
实体字段类型在两种库下的 DDL 差异很大。TypeORM 的 synchronize 在开发环境可以自动建表,但如果你不控制实体字段类型,两边建出来的表结构可能完全不同。
我整理了一个常用的映射关系,写实体时按这个来选类型:
| TypeORM 实体类型 | MySQL 生成 | 达梦生成 |
|---|---|---|
varchar |
varchar(255) |
varchar(255) |
int |
int |
int |
bigint |
bigint |
bigint |
datetime |
datetime |
timestamp |
text |
text |
text 或 clob |
boolean |
tinyint(1) |
tinyint |
这里最容易出问题的是时间字段。MySQL 的 datetime 和达梦的 timestamp 精度、默认值行为都有差异。我的做法是:实体里时间字段统一用 datetime,并显式写 precision: 0,避免小数秒的差异。
4.4 保留字和大小写:最隐蔽的坑
这个坑我踩得最惨。MySQL 里 order、comment、rank、level 这类词,在某些版本和模式下可以当字段名用,但达梦在 Oracle 兼容模式下这些全是保留字,SQL 一执行就报 ORA-00900 类似的错误。
解决方案分两层:
第一层是编码规范:字段命名统一加业务前缀,比如 order_no、user_level,从源头避开保留字。
第二层是实体显式指定列名:
typescript复制@Entity('sys_user')
export class UserEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'user_level' })
userLevel: number;
}
@Column 的 name 属性显式指定了数据库列名,即使属性名叫 userLevel,生成的 SQL 也用的是 user_level,不会跟保留字冲突。
大小写也是个大坑。MySQL 在 Linux 下默认对表名大小写敏感,达梦的默认行为又不一样。我的经验是:所有表名、字段名统一小写加下划线,并且在数据库连接参数里把大小写敏感相关的选项固定住。 否则项目在开发环境跑得好好的,一到客户那边就报 table not found。
4.5 通过 Service 层约束 SQL 写法
方言治理不能只靠自觉,我还在团队里定了几条硬约束:
- 禁止在业务代码里写裸 SQL,所有 SQL 必须经过 Repository 或 QueryBuilder
- 禁止使用数据库特有的 SQL 函数,除非已经注册到方言映射表
- 禁止在 Service 里拼 SQL 字符串,动态条件一律用 QueryBuilder 的条件表达式
这几条约束写进项目 README 和代码评审 checklist 之后,双库兼容的稳定性高了很多。
5. 踩坑实录:从「单测过了」到「验收环境跑挂」的几个问题
这一章是全文最有价值的部分,全部是我真实遇到过的坑。如果你正在做双库适配,建议重点看。
5.1 时间字段差 8 小时
现象:开发环境 MySQL 查出来的时间没问题,验收环境达梦查出来的时间全部慢 8 小时。
排查过程:我先看了达梦数据库服务器的时间,date 命令显示 CST 时区没问题。再用 DBeaver 直接查达梦,时间也正常。到这里基本确定问题出在连接链路上。
最后定位到是 JDBC Bridge 那层转发时,时区参数没配对。Java 的 JDBC 连接时需要一个 serverTimezone 参数,如果没设或者设错,ResultSet 转成字符串时会用 JVM 默认时区,就出现了 8 小时偏差。
处理方式:连接达梦时显式指定时区参数,同时整个项目的约定是——数据库统一存 UTC,应用层按本地时区展示。 这样即使某个环节时区配置漏了,也不至于偏差到不可容忍。
5.2 GROUP BY 严格模式差异
现象:一条统计 SQL 在 MySQL 里跑得好好的,到达梦直接报错。
原因:MySQL 有个 ONLY_FULL_GROUP_BY 模式,很多开发机默认没开,所以在 MySQL 里 SELECT 非聚合列不会报错。但达梦的默认行为更接近 Oracle,对 GROUP BY 的列校验非常严格,只要 SELECT 的列没出现在 GROUP BY 里就报错。
处理方式:开发环境统一开启 ONLY_FULL_GROUP_BY,从源头保证 SQL 规范。MySQL 5.7+ 的默认配置其实已经开了这个模式,但如果你的开发机是 5.6 或者是自行改过配置的,一定要补上。
5.3 中文字符集乱码
现象:写进去的英文正常,中文全是问号或者乱码。
排查过程:先查数据库表字符集,达梦是 UTF-8,没问题。再查连接参数,我发现达梦驱动默认字符集可能不是 UTF-8,跟 MySQL 的 utf8mb4 行为不一样。
处理方式:连接层强制指定字符集,建表时也显式指定 CHARSET=UTF8。最关键的一点是——表结构一定要 DBA 审核,不要让 synchronize 自动生成。 synchronize 生成的 DDL 在 MySQL 下是 utf8mb4,到达梦下可能就变了。
5.4 synchronize 的坑
说到 synchronize,这是双库兼容里最容易埋雷的地方。开发环境开着 synchronize: true,本地 MySQL 自动建表,一切正常。到了验收环境,达梦那边 synchronize 一开,TypeORM 按照 MySQL 的方言生成建表语句,达梦执行后字段类型、索引、自增全都可能不对。
我的方案:开发环境可以开 synchronize,测试和验收环境一律关闭,表结构由 migration 脚本或 DBA 手动审核建表。 如果团队没有专门的 DBA,就把 migration 脚本跑一遍之后,让懂数据库的同事 review 一遍 DDL。
5.5 事务锁和死锁的差异
MySQL 和达梦的锁机制差异很大。MySQL 默认 InnoDB 行锁,间隙锁在可重复读隔离级别下会有很多隐藏行为。达梦的锁机制更接近 Oracle,写读互相不阻塞的 MVCC 模型。
我在一次批量更新操作中,MySQL 环境下完全没有问题,到达梦后两个事务并发更新同一批数据,出现了死锁报错。排查发现是业务代码里更新多条记录的循环里,两条 SQL 的执行顺序在两个事务里不一致,导致互相等待。
处理方式:批量更新尽量按主键排序后执行,保证所有事务的加锁顺序一致。这个经验在两种库里都适用,只是达梦对死锁更敏感,SQL 执行顺序不一致更容易暴露问题。
5.6 大字段 TEXT/BLOB 的处理
大字段读取在两种库下的行为差异也值得注意。MySQL 的 TEXT 类型直接读没问题,达梦如果是 CLOB 类型,某些驱动会把值流式返回,需要额外处理。
我当时遇到的问题是:从达梦读一个 JSON 配置字段,驱动返回的是一个流对象而不是字符串,JSON.parse 直接报错。
处理方式:实体字段用 text 类型,在达梦侧映射为 CLOB 时,驱动层做一次显式类型转换,把 CLOB 读成字符串再交给应用层。这个逻辑收口在自定义 Driver 里,业务代码不需要关心。
6. 上线前自查:双库回归测试怎么做
代码写完、单测过了,离上线还差一步:双库回归。很多项目死在「本地好好的,一上客户环境就挂」。
6.1 搭一套双库验证环境
我强烈建议在 CI 里同时跑两个数据库的集成测试。MySQL 用 Docker 起一个:
bash复制docker run -d \
--name mysql \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=root \
-e MYSQL_DATABASE=testdb \
mysql:8.0
达梦如果手头有镜像或者客户提供了测试环境,也挂到 CI 里跑同一个 test suite。两个数据库跑同一套集成测试,任何方言差异都能在合并代码之前暴露出来。
如果没有 CI 条件,那就在本地准备两个 .env 文件,.env.mysql 和 .env.dm,每次发版前全量跑一遍测试:
bash复制# 跑 MySQL 测试
NODE_ENV=test DB_TYPE=mysql npm run test:e2e
# 跑达梦测试
NODE_ENV=test DB_TYPE=dm npm run test:e2e
6.2 方言覆盖用例清单
我整理了一份最小测试用例集,双库回归前必须全部过一遍:
| 类型 | 用例 |
|---|---|
| 连接 | 连接池创建、断线重连 |
| CRUD | 单表增删改查 |
| 分页 | 小数据量分页、大数据量深分页 |
| 条件查询 | 等值、模糊、范围、IN |
| 聚合 | GROUP BY + 聚合函数 |
| 排序 | 单字段、多字段、空值排序 |
| 事务 | 开启、提交、回滚、并发更新 |
| 时间 | 时间字段写入、读取、时区比较 |
| 中文 | 中文写入、读取、模糊匹配 |
| 大字段 | TEXT 类型读写 |
| 自增主键 | 连续插入、显式指定主键 |
这张表覆盖了 90% 以上的双库兼容问题,跑一遍基本就能知道还有哪些地方没适配到位。
6.3 达梦侧的初始调优
达梦装好之后有几个参数建议提前调,不然性能差距会很明显。连接数、内存池大小、日志相关参数都是常见的调整点。具体参数名和推荐值在不同版本略有差异,我这边不贴死配置,一句话:找 DBA 或供应商要一份对应版本的调优基线,照着设一遍。
我们当时遇到的最典型的性能问题,是一条带子查询的报表 SQL 在 MySQL 下 200ms,到达梦要 2 秒。排查后发现是达梦的优化器没有走索引。处理方式是在达梦侧手动加 HINT 或者调整 SQL 写法,把子查询改写成 JOIN,性能立刻恢复正常。
6.4 运维侧的小提示
上线之后,达梦的日常运维跟 MySQL 不太一样,这里提几个点:
- 达梦的逻辑备份工具是
dexp和dimp,类似 MySQL 的mysqldump,但命令参数不一样 - 达梦的命令行工具叫
disql,默认端口 5236 - 如果线上出问题,先用达梦的 Manager 图形工具查会话和锁,再考虑重启服务
这些运维知识不在本文范围内,但双库适配的项目迟早要碰到,提前知道至少不慌。
最后说一个我自己的实操习惯:双库适配这件事,做完之后一定要把「驱动适配 + 方言映射 + 测试清单」沉淀成团队内部的公共包,不要散落在项目代码里。我这次就是把这些抽成了一个内部 npm 包,后续新项目直接引用,团队其他人再也不用重新踩一遍这些坑。如果你也在做类似的适配,建议往这个方向走,一次投入,长期复用。
