HIS系统数据导出:Python连接Oracle实战,我踩过的7个坑

医院里最常听到的一句话是:"能不能帮我导一下上个月的数据?"

HIS系统自带的导出功能要么是固定模板改不了,要么导出来是加密的。我在医院做了20年数据,最后还是自己写脚本连数据库最省事。

这篇把我用Python连Oracle导数据的完整流程写出来,重点讲7个真实踩过的坑——这些坑文档的坑,不写清楚你迟早要撞一遍。

环境准备

pip install cx_Oracle oracledb

注意:cx_Oracle 已经改名叫 python-oracledb 了,新项目直接用新版:

import oracledb  # 新版
# import cx_Oracle  # 旧版,写法几乎一样

新版的好处是不需要装Oracle客户端(thin模式),这点省了很大的事。老版本装Instant Client能把人折腾疯。

最基础的:连上去查一条

import oracledb

conn = oracledb.connect(
    user="his_query",
    password="your_password",
    host="192.168.1.100",
    port=1521,
    service_name="ORCL"   # 不是 SID!见坑1
)

cursor = conn.cursor()
cursor.execute("SELECT * FROM inp_bill_detail WHERE ROWNUM <= 10")

for row in cursor:
    print(row)

cursor.close()
conn.close()

能跑通这段,剩下都是细节问题。

坑1:service_name 和 SID 分不清

这是新手第一个绊脚石。Oracle有两种标识数据库的方式:

# 方式A:service_name(现在主流)
conn = oracledb.connect(user=..., password=..., host=..., port=1521,
                        service_name="ORCL")

# 方式B:SID(老系统常见)
dsn = oracledb.makedsn("192.168.1.100", 1521, sid="ORCL")
conn = oracledb.connect(user=..., password=..., dsn=dsn)

怎么知道该用哪个? 问DBA,或者自己试:

-- 查 service_name
SELECT value FROM v$parameter WHERE name = 'service_names';

-- 查 SID
SELECT instance_name FROM v$instance;

我见过有人卡在这三天,最后发现是填错了这两个之一。报错信息是 ORA-12505 或 ORA-12514,看到这俩基本就是这个问题。

坑2:连接池不关,数据库被你拖垮

导出一次数据开一个连接看起来没问题,但如果你在循环里反复连:

# ❌ 错误写法:循环里反复开关连接
for dept in dept_list:
    conn = oracledb.connect(...)   # 每次都新建
    cursor = conn.cursor()
    cursor.execute("SELECT ... WHERE dept = :1", [dept])
    ...
    conn.close()

医院HIS的连接数是有限制的。你这么跑,DBA会直接打电话过来。

正确做法:一个连接复用,或者用连接池:

# ✅ 方式一:一个连接复用到底
conn = oracledb.connect(...)
cursor = conn.cursor()
for dept in dept_list:
    cursor.execute("SELECT ... WHERE dept = :1", [dept])
    rows = cursor.fetchall()
    ...
conn.close()

# ✅ 方式二:连接池(并发场景)
pool = oracledb.create_pool(
    user="his_query", password="...", dsn="...",
    min=2, max=10, increment=1
)
conn = pool.acquire()
...
pool.release(conn)
pool.close()

坑3:SQL注入 —— 拼接字符串的代价

我知道你想这么写:

# ❌ 千万别这么干
dept = input("输入科室: ")
sql = f"SELECT * FROM inp_bill_detail WHERE dept_name = '{dept}'"
cursor.execute(sql)

如果有人输入 ' OR '1'='1,整张表就出来了;输入带分号的,可能直接删库。医院数据出事是要担责任的。

必须用绑定变量:

# ✅ 正确:用 :1 :2 占位
sql = "SELECT * FROM inp_bill_detail WHERE dept_name = :1 AND bill_date >= :2"
cursor.execute(sql, [dept, start_date])

不只是安全问题,绑定变量还能让Oracle复用执行计划,速度也更快。没有理由不用。

坑4:日期字段的坑

Oracle的日期处理和其他数据库不太一样,我在这栽过跟头。

from datetime import datetime

# ❌ 错误:直接传字符串,依赖数据库的 NLS_DATE_FORMAT 设置
sql = "SELECT * FROM t WHERE bill_date >= '2026-01-01'"

# ✅ 正确:传 datetime 对象
sql = "SELECT * FROM t WHERE bill_date >= :1"
cursor.execute(sql, [datetime(2026, 1, 1)])

# ✅ 或者在SQL里显式转换,最稳妥
sql = "SELECT * FROM t WHERE bill_date >= TO_DATE(:1, 'YYYY-MM-DD')"
cursor.execute(sql, ["2026-01-01"])

还有个更隐蔽的:日期范围查询的边界问题

-- ❌ 这样会漏掉 1月31日 这天中 00:00 之后的所有数据
WHERE bill_date BETWEEN TO_DATE('2026-01-01','YYYY-MM-DD')
                    AND TO_DATE('2026-01-31','YYYY-MM-DD')

-- ✅ 正确写法:上界用第二天
WHERE bill_date >= TO_DATE('2026-01-01','YYYY-MM-DD')
  AND bill_date <  TO_DATE('2026-02-01','YYYY-MM-DD')

因为 2026-01-31 会被当成 2026-01-31 00:00:00,当天其余23小时的数据全丢了。这个bug我查了两天才反应过来。

坑5:CLOB字段读不出来

HIS里有些字段是CLOB类型(比如病历文本),直接读会拿到一个对象而不是字符串:

cursor.execute("SELECT patient_id, diagnosis FROM medical_record")
for pid, diag in cursor:
    print(diag)   # 输出: <oracledb.LOB object at 0x...>

解决:让驱动自动转换

# 方式一:建连接时指定(推荐)
def output_type_handler(cursor, name, default_type, size, precision, scale):
    if default_type == oracledb.DB_TYPE_CLOB:
        return cursor.var(oracledb.DB_TYPE_LONG, arraysize=cursor.arraysize)
    if default_type == oracledb.DB_TYPE_NCLOB:
        return cursor.var(oracledb.DB_TYPE_LONG_NVARCHAR, arraysize=cursor.arraysize)
    if default_type == oracledb.DB_TYPE_BLOB:
        return cursor.var(oracledb.DB_TYPE_LONG_RAW, arraysize=cursor.arraysize)

conn.outputtypehandler = output_type_handler

加上这段,CLOB直接当字符串用,省事多了。

坑6:中文乱码

这个在Windows上几乎必然会遇到。

# 连接时指定编码
conn = oracledb.connect(
    user=..., password=..., dsn=...,
    encoding="UTF-8",
    nencoding="UTF-8"
)

如果还是乱码,先确认数据库的字符集:

SELECT * FROM nls_database_parameters
WHERE parameter IN ('NLS_CHARACTERSET', 'NLS_NCHAR_CHARACTERSET');

如果是 ZHS16GBK 而你的客户端用UTF-8,就会乱。两边对齐就行。

还有个Windows特有的坑:Python脚本输出中文到控制台报错。加这句:

import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')

坑7:数据量大,一次性fetchall撑爆内存

# ❌ 几十万行数据这么干,内存直接爆
cursor.execute("SELECT * FROM inp_bill_detail")  # 假设50万行
rows = cursor.fetchall()   # 全部载入内存

正确做法:分批取

# ✅ 方式一:fetchmany 循环
cursor.execute("SELECT * FROM inp_bill_detail")
cursor.arraysize = 1000    # 每次网络往返取多少行
while True:
    batch = cursor.fetchmany(1000)
    if not batch:
        break
    process(batch)   # 处理这一批

# ✅ 方式二:直接迭代游标(最简单,驱动自动分批)
cursor.execute("SELECT * FROM inp_bill_detail")
for row in cursor:   # 底层自动分批,内存友好
    process(row)

arraysize 调大能减少网络往返次数,我一般设1000。太小了慢,太大了内存涨。

完整实战脚本:导出住院明细到Excel

把上面所有坑都避开的完整版本,这是我在用的简化版:

# -*- coding: utf-8 -*-
"""
HIS住院明细导出工具
导出指定时间范围的住院费用明细到Excel
"""
import sys, io
import oracledb
import openpyxl
from datetime import datetime
from openpyxl.styles import Font, PatternFill, Alignment

sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')

# ---------- 配置 ----------
DB_CONFIG = {
    "user": "his_query",
    "password": "your_password",
    "host": "192.168.1.100",
    "port": 1521,
    "service_name": "ORCL",
    "encoding": "UTF-8",
    "nencoding": "UTF-8",
}

SQL = """
SELECT
    b.patient_id        AS 病案号,
    p.patient_name      AS 姓名,
    b.dept_name         AS 科室,
    b.item_name         AS 项目名称,
    b.item_price        AS 单价,
    b.item_qty          AS 数量,
    b.total_cost        AS 金额,
    b.bill_date         AS 记账日期
FROM inp_bill_detail b
LEFT JOIN patient_info p ON b.patient_id = p.patient_id
WHERE b.bill_date >= TO_DATE(:1, 'YYYY-MM-DD')
  AND b.bill_date <  TO_DATE(:2, 'YYYY-MM-DD')
  AND (:3 IS NULL OR b.dept_name = :3)
ORDER BY b.bill_date, b.patient_id
"""


def clob_handler(cursor, name, default_type, size, precision, scale):
    """CLOB自动转字符串"""
    if default_type in (oracledb.DB_TYPE_CLOB, oracledb.DB_TYPE_NCLOB):
        return cursor.var(oracledb.DB_TYPE_LONG, arraysize=cursor.arraysize)


def export_to_excel(start_date, end_date, dept, output_file):
    conn = oracledb.connect(**DB_CONFIG)
    conn.outputtypehandler = clob_handler     # 坑5
    cursor = conn.cursor()
    cursor.arraysize = 1000                   # 坑7

    try:
        # 坑3:绑定变量,绝不拼接
        cursor.execute(SQL, [start_date, end_date, dept])

        # 拿列名
        columns = [d[0] for d in cursor.description]

        wb = openpyxl.Workbook()
        ws = wb.active
        ws.title = "住院明细"
        ws.append(columns)

        total = 0
        # 坑7:迭代游标,不一次性载入
        for row in cursor:
            ws.append(list(row))
            total += 1
            if total % 10000 == 0:
                print(f"  已读取 {total} 行...")

        # 美化
        fill = PatternFill("solid", start_color="4472C4")
        for cell in ws[1]:
            cell.fill = fill
            cell.font = Font(bold=True, color="FFFFFF")
            cell.alignment = Alignment(horizontal="center")
        ws.freeze_panes = "A2"
        ws.auto_filter.ref = ws.dimensions

        wb.save(output_file)
        print(f"\n✓ 导出完成:{total} 行 → {output_file}")

    finally:
        cursor.close()
        conn.close()   # 坑2:务必关闭


if __name__ == "__main__":
    export_to_excel(
        start_date="2026-09-01",
        end_date="2026-10-01",   # 坑4:用上界,不用BETWEEN
        dept=None,               # 传None查全部科室
        output_file=r"D:\导出\住院明细_202609.xlsx"
    )

几个补充说明

只读账号:一定要申请一个只读权限的查询账号,别用业务账号跑查询。万一写错SQL,只读账号改不了数据,这是保命的。

查之前先EXPLAIN:数据量大的表,先看看执行计划:

EXPLAIN PLAN FOR SELECT ...;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

看到 TABLE ACCESS FULL(全表扫描)且表很大,就该考虑加索引或者加WHERE条件了。

生产库和测试库分开:脚本先在测试库跑通,确认无误再上生产。

写在最后

连Oracle导出数据这件事,代码本身不复杂,复杂的是这些环境细节和问题排查。我把7个坑都写出来了,你照着做基本能一次跑通。

真正值钱的不是这段代码,而是知道哪里会出问题、出问题怎么查——这是做了20年数据才积累出来的。

代码都在上面了,改改连接配置和SQL就能用。跑不通的欢迎来问。

作者:雲澤 | 医院IT运维,20年医院数据经验

关注我,每周分享Python实战技巧和服务器运维经验。

posted @ 2026-10-14 13:44  雲澤  阅读(2)  评论(0)    收藏  举报