2026-10-31  ·  应用开发

Python 连达梦:三条通道怎么选,以及为什么官方驱动反而最难装

应用开发

本文所有依赖版本、报错原文、性能数字都来自同一台机器上的一次连续实测:Python 3.13.14 / JayDeBeApi 1.2.3 / JPype1 1.5.2 / pandas 3.1.0 / polars 2.1.0 / SQLAlchemy 2.1.6 / JDK 1.8.0_131,服务端 DM8(03134284172-20260803-340005-20093 Pack65)跑在 Linux 上,客户端在 Windows。性能数字的前提是跨公网段(经 SSH 隧道转发),本机走 localhost 会明显更快,别拿它们当绝对值。

三条路,复杂度放在不同的地方

搜「Python 连达梦」,能搜到三条路。它们不是三个档次的方案,是三种复杂度安置方式,你总得接住其中一份。

通道复杂度放在哪什么时候它是正解主要代价
disql 子进程shell 层要把 disql 自己的命令(管理、备份、导出)串起来解析文本输出;引号与环境变量踩坑
官方 dmPython编译链镜像自带 gcc 与内核头文件,能装能编源码分发,装不上就是装不上
jaydebeapi + JDBCJVM要在 Python 里算数据、喂 pandas / polars要 JDK;四处默认转换坑

下面三条都摊开讲,包括各自翻车的地方。急着用的可以先记住一句:做数据处理走 JDBC,代价是装个 JRE 再改几行转换器,换来的是你能像用 PostgreSQL 一样用达梦。

大概率先撞上这三句报错

pip install dmPython
# ...
error: command 'gcc' failed with exit status 1

或者更干脆一点,装完了导不进来:

import dmPython
# ModuleNotFoundError: No module named 'dmPython'

再或者装上了,运行时找不到驱动库:

ImportError: libdmdpi.so: cannot open shared object file: No such file or directory

这三句话指向同一件事:达梦的 Python 官方驱动是 C 扩展,源码分发。它要好几个前提同时成立——编译器在、内核头文件在、DM 客户端库路径对、glibc 版本不炸。这些前提你本机开发时通常都成立,在你真正要去的那台机器上通常都不成立。

通道一:disql,最好的命令行,最差的程序接口

disql 是达梦自带的 SQL 命令行,位置跟 Oracle 的 sqlplus 一样。好在零安装、跟数据库同版本、能跑所有 SP_* 系统过程。坏在它是给人看的,不是给程序调用的。

三个坑,都在 Linux 上实测过

坑一:环境变量得自己设。 DM 装完不会给你生成 dm_env.sh,不设就是找不到库:

export DM_HOME=/opt/dmdbms
export PATH=$DM_HOME/bin:$PATH
export LD_LIBRARY_PATH=$DM_HOME/bin:$DM_HOME/lib:$LD_LIBRARY_PATH

不设的话,报错是 libdisql_dll.so: cannot open shared object file。有个最省事的替代:直接 cd 到 $DM_HOME/bin 再执行,那个 so 就在同目录,必然能找到。

顺带一个实测细节:$DM_HOME 只有 dmdba 用户的 shell 里有值,用 root 跑 echo $DM_HOME 出来是空的。所以这串 export 写进谁的环境,得看你用哪个账号跑脚本。

坑二:口令里带 @,必须「外单引号 + 内双引号」。 不带 @ 的口令怎么写都行,带了就只有一种写法对:

# 口令是 Dm@2026 —— 这样写可以
disql -S 'SYSDBA/"Dm@2026"@127.0.0.1:5236' -e "SELECT 1;"

外层用双引号(哪怕是转义过的)会被 disql 拆错;#{} 那种扩展写法跟外层单引号冲突。这条坑的报错是 [-2501] 用户名或密码错——它会让你以为口令记错了,其实是引号错了。

坑三:执行脚本文件要用反引号,而且 -e 不能跟 /NOLOG 一起用。

disql -S 'SYSDBA/"Dm@2026"@127.0.0.1:5236' \`/tmp/check.sql   # 对
disql -S 'SYSDBA/"Dm@2026"@127.0.0.1:5236' /tmp/check.sql     # 错:当连接串解析,退出码 2
disql -S /NOLOG -e "SELECT 1;"                                # 错:[-70025] 连接未打开

别拿它当程序接口

能跑,但你会写出一堆这种代码:起子进程、拿 stdout、按列宽切字符串、用正则认表头。它的输出长这样(列宽随内容变、中文对齐算的是字节不是字符):

行号     NAME        AMT
----------  ----------  ----------
1           A           1.23

这套解析代码活不过一次版本升级,也活不过一次字符集变更。更要命的是错误处理:SQL 报错时它的退出码只有 0/1/2/127 四种,你分不清「连不上」「表不存在」「语法错」,只能再回去 grep 输出里的错误码。

所以 disql 该用在只有它能干的事上:SP_* 系统过程、备份恢复、dexp/dimp 的配套操作、在没有 Python 的机器上做一次性核查。要拿数据、要算、要喂 DataFrame,别用它。

通道二:dmPython,官方驱动反而最难装

dmPython 是达梦官方的 Python 驱动,也是很多文档里推荐的第一选择。理论上它最合适:原生 DB-API 2.0、不带 JVM、内存占用小、跟数据库同版本发布。

问题出在分发形态:源码包,装的时候现场编译。

实测:CentOS 7 上就没装上

在一台 CentOS 7 的测试机上试过,卡在编译链本身:

pip install dmPython
# ...
error: command 'gcc' failed with exit status 1

根因不是 dmPython 的代码有问题,是那台机器装不上 gcc。CentOS 7 已 EOL,官方 yum 源整体失效,yum install gcc 直接失败;换 vault 归档源能装但极慢。这跟本篇主题无关,但恰好说明一件事:

dmPython 的可靠性取决于你现场有没有编译能力,而现场恰恰是最缺编译能力的地方。能编译它的机器(你的开发本、标准化的 CI 镜像)通常不是需要连达梦的那台。

本文不对 dmPython 做性能实测,因为我没能在测试机上装起来,所以不给任何数字。想走这条,先确认三件事:gcc 在、python3-devel(或 kernel-headers)在、DM 客户端库的路径在 LD_LIBRARY_PATH 里。

什么时候它仍然是对的

反过来说,如果现场只有 SSH,不能装包、不能编译,这条路可以直接划掉。

通道三:jaydebeapi + JDBC,绕道 Java 反而最稳

思路很朴素:既然达梦的 Java 驱动是官方最完整、跟随版本最紧的那一个,那就让 Python 借 Java 的路走一遍。桥是 JPype,包装是 jaydebeapi,交付物是一个 jar 加两个 pip 包。

Java 通道在达梦上确实是第一

不是偏好问题,是投入问题。达梦的 JDBC 驱动是官方主推、随版本更新、被自家产品(DTS、DM 管理工具、DEM)大量使用的那一套,用得多的东西坑就被踩平了。反过来,Python、Node、Go 这些生态的驱动用户基数小,很多边界情况没人踩过——没踩过不等于没问题,等于问题还在那儿,只是别人没遇到。

驱动可靠性跟被使用的人次正相关,跟语言优劣无关。

装:两个 pip 包 + 一个 jar

pip install JayDeBeApi==1.2.3 JPype1==1.5.2

版本不是随便写的,这个组合有硬约束(实测):

你的 JDK能用的 JPype1现象
JDK 8≤ 1.5.21.7.x 起要求 JDK 9+,装了直接起不来
JDK 11 / 171.5.x 及以上均可—
Python 3.13避开 1.6.0import 阶段直接段错误(不是异常,是崩进程)

jar 在 DM 安装目录的 drivers/jdbc/ 下,按 JDK 版本挑一个。我用的这个文件是 DmJdbcDriver8.jar,约 1.6 MB。把它拷到你项目里或者固定一个路径,别每次从安装目录现找。

七处默认坑:不修,数据就是错的

这不是用起来别扭的问题,是值本身错了。前四处都在 jaydebeapi 的类型转换表里,第四处是写这篇时用类型探针现抓的,最阴;后三处在调用它的框架那边,各自换了个花样。

坑一:DECIMAL 一律被转成 float,金额丢精度

jaydebeapi 的 _java_to_py_bigdecimal() 里,scale != 0 的 DECIMAL 走的是 doubleValue()。也就是说你的 decimal(18,2) 金额列,到 Python 里是 float64。

float64 只有 15–16 位有效数字。日常金额看着没问题(1.23 的 repr 还是 1.23),但只要参与汇总、或者位数变多,误差就会显形:

>>> from decimal import Decimal
>>> float(Decimal("99999999.99"))
99999999.99
>>> sum([float(Decimal("0.1"))] * 10) == 1.0
False

对账场景里,这个差额会被放大成「系统算错了」。而且它在 df.dtypes 里只表现为一个平平无奇的 float64,不看代码根本想不到。

坑二:TIMESTAMP 被主动转成字符串

_to_datetime() 的最后一行是 return str(d)。所以你读出来的时间列,dtype 是 object,里面装的是 '2026-10-31 09:00:00' 这样的字符串。

>>> df["TS"].dtype
dtype('O')
>>> df["TS"].iloc[0]
'2026-10-31 09:00:00.0'

后果是时间序列分析全部要手动 to_datetime,而且带纳秒后缀的字符串格式还不统一,转换会零散地失败几行。

坑三:字符串和 CLOB 返回 Java 对象,polars 直接拒收

VARCHAR / CHAR / CLOB 拿到的是 java.lang.String 的包装对象,不是 Python 原生 str。pandas 会把它当 object 塞着(能用但不对),polars 更直接:

TypeError: cannot parse input of type 'JChar'

这句话是很多人第一次用 polars 读达梦时看到的,然后以为是 polars 不支持——其实是不支持「Java 对象混进 Python 列表」。

坑四:整数返回 java.lang.Integer,而 isinstance 会放它过去

这一处是写本文时用类型探针抓出来的,也是最阴的一处,因为它看起来完全正常:

>>> v = rows[0][0]              # 一列 int
>>> isinstance(v, int)
True                             # ← 类型检查全过
>>> json.dumps(v)
'1'                              # ← 序列化也过
>>> type(v)
<class 'java.lang.Integer'>      # ← 但它并不是 int

JPype 让 java.lang.Integer 继承了 Python 的 int,所以 isinstance 会放它过去。可它终究是个 Java 对象:喂给 polars、或者任何按 type() 分派的地方,照样炸。

这一处的教训在检查方式上:验证转换器有没有生效,必须用 type(v) is int,不能用 isinstance(v, int)。前者问「它是什么」,后者只问「它像不像」。

整数、浮点这几种也得一并换掉,别只盯 DECIMAL 和 TIMESTAMP:

def _as_int(rs, col):
    v = rs.getObject(col)
    return None if v is None else int(v)


def _as_float(rs, col):
    v = rs.getObject(col)
    return None if v is None else float(v)


for code in (4, 5, -6, -5):      # INTEGER / SMALLINT / TINYINT / BIGINT
    conv[code] = _as_int
for code in (8, 6, 7):           # DOUBLE / FLOAT / REAL(这三个本来就该是 float)
    conv[code] = _as_float

DOUBLE / FLOAT / REAL 保持 float 是对的,别顺手改成 Decimal——那会白白变慢,而且它本来就是浮点语义。

还有一个更隐蔽的:_converters 一开始是 None

你想改转换器,得先去改 jaydebeapi._converters。但它不是一 import 就有的,要等 JVM 起来、jaydebeapi 反射出 java.sql.Types 之后才被填上。所以在 import 语句旁边改它,改的是 None,静默失败,一点报错都没有。

正确顺序是:startJVM → connect → 改转换器。

通用修法:换掉转换表的项,别改内置函数

关键认识是:_converters 是模块级 dict,connect() 把同一个对象的引用交给 Connection 和 Cursor。所以原地替换它的项,对所有已存在和之后新建的连接都立即生效,不需要猴子补丁,也不需要重启。

下面这段是验证过的完整实现,拿去直接用:

from __future__ import annotations

import datetime
from decimal import Decimal

import jaydebeapi
import jpype

# java.sql.Types 的常量值(手写,免得为几个整数去反射一遍 JVM)
T_DECIMAL, T_NUMERIC = 3, 2
T_TIMESTAMP, T_DATE = 93, 91
T_CHAR, T_VARCHAR, T_LONGVARCHAR, T_CLOB = 1, 12, -1, 2005


def _as_decimal(rs, col):
    v = rs.getBigDecimal(col)
    return None if v is None else Decimal(str(v))


def _as_datetime(rs, col):
    v = rs.getTimestamp(col)
    if v is None:
        return None
    # getNanos() 给 9 位,datetime 只吃 6 位微秒
    return datetime.datetime.strptime(str(v)[:19], "%Y-%m-%d %H:%M:%S").replace(
        microsecond=int(str(v.getNanos())[:6])
    )


def _as_str(rs, col):
    # getString 返回 java.lang.String,必须 str() 成原生 str,否则 polars 报 JChar
    v = rs.getString(col)
    return None if v is None else str(v)


def install_converters() -> bool:
    """把 jaydebeapi 的模块级转换表改成原生 Python 类型。

    必须在 startJVM() + connect() 之后调用:这之前 _converters 还是 None。
    """
    conv = jaydebeapi._converters
    if conv is None:
        return False

    for code in (T_DECIMAL, T_NUMERIC):
        conv[code] = _as_decimal
    conv[T_TIMESTAMP] = _as_datetime
    conv[T_DATE] = _as_datetime
    for code in (T_CHAR, T_VARCHAR, T_LONGVARCHAR, T_CLOB):
        conv[code] = _as_str
    return True


DRIVER = "dm.jdbc.driver.DmDriver"


def connect(host: str, port: int, user: str, password: str, jar: str):
    if not jpype.isJVMStarted():
        jpype.startJVM(jpype.getDefaultJVMPath(), "-Xmx1g", classpath=[jar])
    conn = jaydebeapi.connect(DRIVER, f"jdbc:dm://{host}:{port}", [user, password], jar)
    install_converters()   # ← 顺序不能动:必须在 connect 之后
    return conn

注意别顺手把 DOUBLE 也改成 Decimal——它本来就该是 float,改了反而慢且失真。只动你确定需要的类型。

坑五(pandas 的):coerce_float=False

就算你把 DECIMAL 修成了 Decimal,pd.read_sql 还会把它转回去。coerce_float 默认是 True,意思是「尽量把数值列变成 float」:

import pandas as pd

# 错:前面白修了,AMT 又变回 float64
df = pd.read_sql("select * from t_order", conn)

# 对
df = pd.read_sql("select * from t_order", conn, coerce_float=False)

这一步最容易漏,因为它在你自己的代码里,不在第三方库里。

坑六(polars 的):infer_schema_length=None

polars 默认只拿前 100 行推断类型。如果某一列前 100 行恰好全是 NULL(在宽表、稀疏列上很常见),它会把这列定成 Null 类型,后面一旦有值就炸。要么关掉推断,要么显式给 schema:

import polars as pl

cur = conn.cursor()
cur.execute("select id, name, amt, ts, flag, note from t_order")
cols = [str(d[0]) for d in cur.description]
rows = cur.fetchall()
pdf = pl.DataFrame(rows, schema=cols, orient="row", infer_schema_length=None)

坑七(SQLAlchemy 的):default_schema_name 会被覆盖成 None

如果你想用 SQLAlchemy(ORM、或者 pd.read_sql(..., engine)),得自己写个最小方言。这里有一个特别阴的坑:

class DMDialect(DefaultDialect):
    name = "dm"
    driver = "jaydebeapi"
    paramstyle = "qmark"
    default_schema_name = "SYSDBA"      # ← 写了也没用

    def _get_default_schema_name(self, connection):
        # 必须实现这个钩子,且必须返回真值。
        # DefaultDialect.initialize() 在首次连接后会调用它,
        # 默认实现返回 None,把上面那个类属性覆盖掉。
        return "SYSDBA"

不实现的话,后续所有带 owner='...' 的反射查询都会变成 owner='None',结果是空表清单,而且不报错。你会以为连上了但库里没表。

旁路:只取个数的话,还有更轻的一档

上面三条都是在 Python 进程里连库。如果你的需求其实是跑一条 SQL 把结果拿出来,还有更轻的形态:单文件可执行程序,通过子进程调用,输出 CSV/JSON。

我这边的工具是 dmkit(dm-tools),Go 编译的单文件,扔到任何机器就能跑,不需要解释器、不需要 JDK:

dmkit q --dsn 'dm://SYSDBA:YourPassw0rd@192.0.2.10:5236' --sql "select count(*) from t_order" --format json

它的优势是部署成本为零,劣势也明确:只能通过 shell 调用。一旦你要「取数 → 在 Python 里算 → 再回写」,就得走 CSV 中间文件,多一次进程启动和一次序列化往返。同一个 10 万行查询,走 dmkit q 导 CSV 比 jaydebeapi 直读多花了约 4.5 秒,基本都花在这上面(数字见下一节)。

所以分工是:dmkit 管「扔进任何机器就能取数」,jaydebeapi 管「要在进程里编排的活」。不冲突。

实测:10 万行 × 6 列,跨公网隧道

前提是 Windows 客户端走 SSH 隧道连 Linux 上的 DM8 单机实例。所有数字都含公网 RTT,本机 localhost 会比这快,别直接拿去当基准。表结构 (id int pk, name varchar(50), amt decimal(18,2), ts timestamp, flag char(1), note varchar(100)),10 万行。

项目耗时说明
JVM 启动0.26 s进程内一次,之后所有连接共享
首次建连(含 JVM)1.55 s冷启动,含驱动加载
第二个连接(复用 JVM)0.11 s—
单条查询往返257 ms几乎全是公网 RTT,不是达梦慢
executemany 2000 行0.32 s≈ 6 300 行/秒,是真 batch
fetchall 10 万行9.4 s≈ 10 700 行/秒
fetchmany(10000) 分块7.5 s≈ 13 400 行/秒,比 fetchall 快约 25%
pandas.read_sql9.6 s注意:这里没传 coerce_float=False,金额是 float
polars.read_database8.4 s比 pandas 快约 12%
dmkit q 导 CSV13.9 s多出来的是进程启动 + 序列化

两条能直接用的经验:

并发:一个连接能不能多线程用

实测结果:8 个线程共用一个 Connection,无报错。但这只能说明它不炸,不能说明它正确——JDBC 的 Connection 本来就不是设计成多线程共享的,达梦不拦你不等于语义安全。

要并发的正确做法是每线程一个 Connection,共享一个 JVM。JVM 启动成本只付一次(0.26 s),连接复用成本是 0.11 s 量级,开几个线程都不心疼。

怎么选:问自己三个问题

顺序问题是的话选
1现场能装包、能编译吗?不能 → 直接划掉 dmPython,别浪费时间
2你要的是「一条 SQL 出结果」,还是「在 Python 里算」?前者 → 单文件 dmkit q 就够;后者 → 往下走
3这台机器容得下一个 JRE(约 100–200 MB)吗?容得下 → jaydebeapi + JDBC,这是本文推荐的默认答案

三个都「否」的时候,才回到用 disql 子进程硬扛——而且要清楚你在用正则解析报表,随时可能被版本升级打断。

常见问题

为什么不用 pymysql 那种「纯 Python 驱动」的思路?

因为不存在。达梦的协议没有公开的纯 Python 实现,官方只给 C 扩展和 Java 驱动。这是生态规模决定的,不是谁偷懒。

jaydebeapi 每次导入都要起 JVM,脚本不是会很慢?

JVM 启动 0.26 s,且一个进程只付一次。真正的成本在首次建连(1.55 s)。如果你要跑几百次短脚本,应该改成常驻进程或连接池,而不是反复起进程——反复起进程的话,Go 单文件工具反而是更合适的选择。

改了 _converters,会影响已经在用的连接吗?

会,而且是立即生效。因为 connect() 交给 Connection 的是同一个 dict 对象的引用,你原地改项,所有连接看到的都是新的。这就是为什么必须严格按 startJVM → connect → install_converters 这个顺序——反过来改的是 None,静默无效。

错误怎么区分?连接失败和 SQL 报错混在一起了

实测下来反直觉的一点:达梦 JDBC 的编译期错误根本没有错误码。

我原本按 disql 的经验写了一张 -2106 表不存在 / -2007 语法错 的映射表,真跑起来发现全是空转。实际形态是两种:

错误类型异常形态有没有码
编译期(表/列不存在、语法错)dm.jdbc.driver.DMException,args[0] 是纯字符串没有,只有中文消息
执行期(除零、约束冲突)被包成 DatabaseError,args[0] 是 Java 对象有,getErrorCode() / getSQLState()

实测的原始报文长这样,注意里面没有任何方括号数字:

dm.jdbc.driver.DMException: 第1 行附近出现错误:
无效的表或视图名[NO_SUCH_T]

所以分类只能以中文文案为主,错误码和 SQLState 为辅:

TEXT_RULES = [
    ("无效的表或视图名", "object"),
    ("无效的列名",      "object"),
    ("语法分析出错",    "syntax"),
    ("用户名或口令",    "auth"),
]


def get_error_code(exc):
    """执行期错误(除零、约束冲突)才有;编译期错误这里是 None。"""
    for arg in getattr(exc, "args", ()):
        fn = getattr(arg, "getErrorCode", None)
        if callable(fn):
            return fn()
    return None


def classify(exc):
    text = str(exc)
    code = get_error_code(exc)
    if code in (-6103,):                 # 只收实测确认过的码
        return "sql"
    for needle, category in TEXT_RULES:
        if needle in text:
            return category
    return "sql"                         # 认不出来就交给上层人判断

认不出来的错误宁可落到一个宽泛类别,也别硬塞进一个具体类别。分类错了比不分类更坏,因为脚本会据此做错误的重试或告警。

能在 Docker 里跑吗?镜像多大?

能。基础镜像用 python:3.12-slim 加一个 JRE headless,最终镜像大约 400 MB 上下(JRE 占大头)。如果这个体积不能接受,说明你的场景更适合单文件工具,而不是 JDBC。

这套东西在 ARM 上能用吗?

JDK 有 ARM64 版本,理论上能。但我没有在鲲鹏/飞腾上实测过,所以不给结论。要在国产化环境里用,先单独验证 JPype 的二进制轮子有没有对应架构,这一步最容易卡住。

说到底

选通道,本质是在选你愿意把复杂度放在哪一层,不是在选哪个方案更好。

disql 把复杂度推给 shell,dmPython 推给编译链,JDBC 推给 JVM。三条路都要付账,区别只在于你现场有没有能力付这一份。有 JDK、要在 Python 里算数据,就选 JDBC,然后把前面那七处转换坑一次改干净;只有 SSH 不能装包,就老老实实用 disql 加反引号。

至于那些看起来装上了、跑起来也不报错,但值已经错了的坑,它们比装不上危险得多。float64 的金额、object 类型的时间戳,都不会在跑通的那一刻提醒你。

如果你在别的版本、别的发行版上踩到不一样的坑,发我一封邮件,说清版本号和报错原文就行。这篇会按反馈继续修。

← 返回目录