本文所有依赖版本、报错原文、性能数字都来自同一台机器上的一次连续实测: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 + JDBC | JVM | 要在 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 里。
什么时候它仍然是对的
- 基础镜像本身带编译链(比如
python:3.12-slim加build-essential),构建期编译、运行期不用; - 你要在 ARM(鲲鹏/飞腾)上跑,不想为一个 Python 脚本拖一个 JRE;
- 内存预算紧到装不下 JVM。
反过来说,如果现场只有 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.2 | 1.7.x 起要求 JDK 9+,装了直接起不来 |
| JDK 11 / 17 | 1.5.x 及以上均可 | — |
| Python 3.13 | 避开 1.6.0 | import 阶段直接段错误(不是异常,是崩进程) |
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_sql | 9.6 s | 注意:这里没传 coerce_float=False,金额是 float |
polars.read_database | 8.4 s | 比 pandas 快约 12% |
dmkit q 导 CSV | 13.9 s | 多出来的是进程启动 + 序列化 |
两条能直接用的经验:
- 大结果集用
fetchmany分块,别用fetchall。快到 25% 且内存可控,这是分流式读取的天然优势,不是玄学。 - 写入别一行一条
execute。executemany在 JDBC 上是真 batch(6 300 行/秒这个量级),一行一条会掉到几十行/秒。
并发:一个连接能不能多线程用
实测结果: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 类型的时间戳,都不会在跑通的那一刻提醒你。
如果你在别的版本、别的发行版上踩到不一样的坑,发我一封邮件,说清版本号和报错原文就行。这篇会按反馈继续修。