DataForge:基于 Python 的数据库批量导出 Excel 工具——从架构到部署的全流程实战

DataForge:基于 Python 的数据库批量导出 Excel 工具------从架构到部署的全流程实战

本文记录了 DataForge 的完整开发过程:一个支持 Oracle/PostgreSQL/SQL Server 多数据库、千万级行数流式导出、断点续传、依赖自动安装的桌面级数据导出工具。文章涵盖架构设计、核心技术难点、踩坑实录及最终 PyInstaller 打包部署方案。


一、项目背景

在实际的企业数据治理场景中,经常需要从 Oracle、PostgreSQL、SQL Server 等数据库中批量提取数据导出为 Excel 文件。痛点集中在:

  • 数据量大 :单表可达 3000 万行,传统工具(如 pandas to_excel)内存溢出
  • 数据库多样:Oracle 11g 依赖 Instant Client,SQL Server 依赖 ODBC Driver,部署困难
  • 网络不稳定:导出中途断连需从头重来
  • 多任务管理:需要并发导出多个 SQL,跟踪进度

DataForge 正是为解决这些问题而生。


二、技术栈与架构设计

技术选型

层级 技术 理由
后端框架 FastAPI + Uvicorn 异步支持好,SSE 推送原生兼容
数据库连接 SQLAlchemy + oracledb/psycopg2/pyodbc 统一引擎接口,多数据库支持
Excel 写入 XlsxWriter(流式模式) 常量内存写入,支持千万行
状态持久化 SQLite 轻量级,无需额外部署
前端 原生 HTML + JavaScript 单文件部署,无构建依赖
打包 PyInstaller 生成单 exe,免 Python 环境

架构分层

复制代码
┌─────────────────────────────────────────┐
│           前端 (static/index.html)        │
│  任务列表 · 连接管理 · 进度监控 · 依赖安装  │
├─────────────────────────────────────────┤
│           FastAPI 路由层 (main.py)        │
│  /api/tasks · /api/connections · /api/deps│
│  SSE 进度推送 · 文件夹浏览 · 打开目录       │
├─────────────┬───────────┬───────────────┤
│ task_manager│ exporter  │  database.py  │
│ 任务调度/取消│ 流式导出/断点│ 连接引擎/thick模式│
├─────────────┴───────────┴───────────────┤
│        checkpoint.py (SQLite 持久化)      │
│        deps_check.py (依赖检测/安装)       │
├─────────────────────────────────────────┤
│     PyInstaller 打包 → DataForge.exe      │
│     deps/ (Oracle zip + ODBC MSI)        │
└─────────────────────────────────────────┘

三、核心技术实现

3.1 千万级行数流式导出 + 文件分片

这是本项目的核心难点。传统 pandas.read_sql() + to_excel() 会一次性加载全部数据到内存,3000 万行直接 OOM。

解决方案 :SQLAlchemy stream_query_with_headers() 逐行读取 + XlsxWriter 流式写入。

python 复制代码
# exporter.py --- 导出主循环
with engine.connect() as conn:
    result = conn.execution_options(stream_results=True).execute(text(sql))
    
    while True:
        row = result.fetchone()
        if row is None:
            break
        
        # 检查是否需要切换到下一个分片文件
        if rows_in_current_file >= max_rows_per_file:
            workbook.close()
            current_file_index += 1
            rows_in_current_file = 0
            workbook = xlsxwriter.Workbook(_file_path())
            worksheet = workbook.add_worksheet()
            # 写表头
            for col_idx, col_name in enumerate(headers):
                worksheet.write(0, col_idx, col_name)
        
        # 写入行数据
        for col_idx, value in enumerate(row):
            worksheet.write(rows_in_current_file + 1, col_idx, value)
        
        rows_in_current_file += 1
        exported_rows += 1
        
        # 周期性保存断点
        if exported_rows % batch_checkpoint_size == 0:
            _save_checkpoint()
        
        # 检查取消标记
        if _cancelled:
            break

关键设计

  • stream_results=True 让 SQLAlchemy 使用服务端游标,不一次性加载全部数据
  • max_rows_per_file 达到阈值时关闭当前文件、新建下一个分片(文件名 -序号 后缀)
  • 每批次(默认 10000 行)保存一次断点到 SQLite

3.2 断点续传机制

导出中途断连或取消后,重新启动可从上次位置继续。

python 复制代码
# exporter.py --- 断点续传
def run(self, resume: bool = False):
    if resume:
        # 从 SQLite 读取上次断点
        ckpt = checkpoint.load(self.task_id)
        if ckpt:
            skip_rows = ckpt.exported_rows
            current_file_index = ckpt.current_file_index
            # 跳过已导出的行
            for _ in range(skip_rows):
                result.fetchone()

断点信息存储在 SQLite 的 checkpoints 表中,包含:

  • task_id:任务 ID
  • exported_rows:已导出行数
  • current_file_index:当前文件序号
  • rows_in_current_file:当前文件已写入行数

3.3 Oracle 11g 兼容:thick 模式 + Instant Client

踩坑实录oracledb 库默认使用 thin 模式(纯 Python 实现),但 thin 模式不支持 Oracle 12.1 以下版本 。连接 Oracle 11g 时报 DPY-3010 错误。

解决方案:检测到 Oracle Instant Client 时切换到 thick 模式。

python 复制代码
# database.py --- thick/thin 模式切换
def _init_oracle_thin_or_thick(config, url):
    import oracledb
    
    client_dir = _find_oracle_client_dir(config)
    
    if client_dir:
        # 将 client_dir 加入 PATH 头部,防止系统 PATH 中的旧版干扰
        current_path = os.environ.get("PATH", "")
        if client_dir not in current_path:
            os.environ["PATH"] = client_dir + os.pathsep + current_path
        oracledb.init_oracle_client(lib_dir=client_dir)
        logger.info("Oracle 使用 thick 模式(client: %s)", client_dir)
    else:
        logger.info("Oracle 使用 thin 模式(未检测到 Oracle Client)")

Oracle Client 多级检测链

python 复制代码
# database.py --- _find_oracle_client_dir()
# 优先级:
# 1. 配置中的 oracle_client_lib_dir(支持子目录递归查找)
# 2. deps_check 自动安装位置(oracle_client/ 目录递归查找 oci.dll)
# 3. 环境变量 TNS_ADMIN
# 4. 环境变量 ORACLE_HOME
# 5. 常见安装路径

DPI-1072 踩坑oracledb 4.0.2 要求 Instant Client 19.16+ ,而旧版 D:\instantclient_19_5 是 19.5 版本,导致 DPI-1072: the Oracle Client library version is unsupported

解决:下载 Instant Client 19.32 版本,并通过程序自动解压安装。

3.4 依赖自动检测与安装

这是部署到其他电脑时的核心功能。程序启动时自动检测 Oracle Client 和 ODBC Driver 17 是否可用,缺失时通过 UI 一键安装。

3.4.1 Oracle Instant Client 检测
python 复制代码
# deps_check.py
def get_oracle_client_lib_dir():
    """递归查找 oci.dll"""
    # 1. 自动安装位置 oracle_client/
    if ORACLE_INSTALL_DIR.exists():
        for oci_dll in ORACLE_INSTALL_DIR.rglob("oci.dll"):
            return str(oci_dll.parent)
    # 2. TNS_ADMIN 环境变量
    # 3. ORACLE_HOME 环境变量
    # 4. 常见路径
3.4.2 ODBC Driver 17 检测(Windows 注册表)
python 复制代码
# deps_check.py
def check_odbc_driver():
    import winreg
    try:
        key = winreg.OpenKey(
            winreg.HKEY_LOCAL_MACHINE,
            r"SOFTWARE\ODBC\ODBCINST.INI\ODBC Driver 17 for SQL Server"
        )
        version, _ = winreg.QueryValueEx(key, "Driver")
        return {"installed": True, "version": version}
    except FileNotFoundError:
        return {"installed": False}
3.4.3 Oracle zip 自动解压(无需提权)
python 复制代码
def install_oracle():
    zip_path = _find_oracle_zip()  # deps/instantclient-*.zip
    with zipfile.ZipFile(str(zip_path), "r") as zf:
        zf.extractall(str(ORACLE_INSTALL_DIR))
    # 递归查找 oci.dll
    for oci_dll in ORACLE_INSTALL_DIR.rglob("oci.dll"):
        return {"success": True, "lib_dir": str(oci_dll.parent),
                "need_restart": True}  # oracledb 每进程只能 init 一次
3.4.4 ODBC MSI 提权安装(ShellExecuteExW)

MSI 静默安装需要管理员权限,subprocess.run(["msiexec", ...]) 在非管理员进程下会返回错误码 740。

python 复制代码
# deps_check.py --- 使用 ShellExecuteExW + runas 提权
def _run_msi_elevated(msi_path, timeout_sec=120):
    import ctypes
    from ctypes import wintypes
    
    SEE_MASK_NOCLOSEPROCESS = 0x00000040
    
    class SHELLEXECUTEINFO(ctypes.Structure):
        _fields_ = [
            ("cbSize", wintypes.DWORD),
            ("fMask", wintypes.ULONG),
            # ... 完整结构体定义
            ("hProcess", wintypes.HANDLE),
        ]
    
    sei = SHELLEXECUTEINFO()
    sei.cbSize = ctypes.sizeof(sei)
    sei.fMask = SEE_MASK_NOCLOSEPROCESS
    sei.lpVerb = "runas"  # 请求 UAC 提权
    sei.lpFile = "msiexec"
    sei.lpParameters = f'/i "{msi_path}" /quiet /norestart IACCEPTMSODBCSQLLICENSETERMS=YES'
    sei.nShow = 0  # SW_HIDE
    
    ctypes.windll.shell32.ShellExecuteExW(ctypes.byref(sei))
    
    # 同步等待安装完成
    if sei.hProcess:
        ctypes.windll.kernel32.WaitForSingleObject(
            sei.hProcess, timeout_sec * 1000
        )
        ctypes.windll.kernel32.CloseHandle(sei.hProcess)

关键技术点

  • ShellExecuteW 无法等待完成(异步返回),ShellExecuteExW + hProcess 句柄可同步等待
  • SEE_MASK_NOCLOSEPROCESS 标志让 hProcess 返回进程句柄
  • Oracle zip 只需解压无需提权;ODBC MSI 需要 UAC 提权

3.5 SSE 实时进度推送

前端通过 EventSource 连接后端 SSE 端点,每秒接收任务进度。

python 复制代码
# main.py --- SSE 端点
@app.get("/api/tasks/{task_id}/progress")
async def task_progress(task_id: str):
    async def event_stream():
        while True:
            state = manager.get_state(task_id)
            if state is None:
                break
            # 推送进度数据
            payload = _state_to_dict(state)
            yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
            # 终态时关闭流
            if state.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, 
                                TaskStatus.CANCELLED):
                yield "event: done\ndata: {}\n\n"
                break
            await asyncio.sleep(1)
    return StreamingResponse(event_stream(), media_type="text/event-stream")

3.6 任务取消:立即状态更新 + engine 标记

踩坑实录 :最初取消只设 engine 内部标记,状态仍为 RUNNING,要等导出循环下次迭代才变更。前端看不到变化,且 SSE 持续推送 RUNNING 覆盖 UI。

解决方案

python 复制代码
# task_manager.py --- cancel_task
def cancel_task(self, task_id: str) -> bool:
    state = self._states.get(task_id)
    
    # 幂等:已取消的直接返回
    if state.status == TaskStatus.CANCELLED:
        return True
    
    # 立即更新状态并持久化
    state.status = TaskStatus.CANCELLED
    state.end_time = datetime.now()
    ckpt.save_task_state(state, config_json)
    
    # 同时设置 engine 取消标记,让导出循环安全退出
    engine = self._engines.get(task_id)
    if engine:
        engine.cancel()
    
    return True

前端取消成功后立即关闭 SSE 连接,避免 RUNNING 覆盖:

javascript 复制代码
// index.html --- doCancel
async function doCancel(taskId) {
    await fetch(`/api/tasks/${taskId}/cancel`, { method: 'POST' });
    if (sseConnections[taskId]) {
        sseConnections[taskId].close();  // 立即关闭 SSE
        delete sseConnections[taskId];
    }
    // 乐观更新本地状态
    state.tasks[idx].status = 'cancelled';
    renderTasks();
    loadTasks();  // 与后端最终状态对齐
}

3.7 PyInstaller 打包路径兼容

打包为 exe 后,文件路径逻辑需要区分开发模式和打包模式:

python 复制代码
# main.py
if getattr(sys, "frozen", False):
    # PyInstaller 打包模式
    STATIC_DIR = Path(sys._MEIPASS) / "static"  # 临时解压目录
    EXE_DIR = Path(sys.executable).resolve().parent  # exe 所在目录
else:
    # 开发模式
    STATIC_DIR = Path(__file__).resolve().parent / "static"
    EXE_DIR = Path(__file__).resolve().parent

DEFAULT_OUTPUT_DIR = str(EXE_DIR / "导出数据")
python 复制代码
# checkpoint.py
if getattr(sys, "frozen", False):
    DB_PATH = str(Path(sys.executable).resolve().parent / "export_state.db")
else:
    DB_PATH = str(Path(__file__).resolve().parent / "export_state.db")

关键点

  • 静态文件从 _MEIPASS(临时解压目录)读取
  • 运行时数据(SQLite、导出文件、deps/)放在 exe 同级目录
  • deps/ 不打包进 exe(避免体积膨胀 80MB+),放在 exe 同级

四、前端关键实现

4.1 文件夹选择器(非系统 file dialog)

由于浏览器安全限制,<input type="file" webkitdirectory> 无法获取绝对路径。DataForge 实现了后端驱动的文件夹浏览器:

javascript 复制代码
// 前端调用后端 API 浏览目录
async function browseDir(path) {
    const r = await fetch(`/api/browse/dir?path=${encodeURIComponent(path)}`);
    const data = await r.json();
    // 渲染盘符按钮 + 目录列表
    renderFolderList(data.dirs);
}

// 后端 API
@app.get("/api/browse/dir")
def browse_dir(path: str) -> dict:
    p = Path(path)
    dirs = [str(d) for d in p.iterdir() if d.is_dir()]
    return {"dirs": dirs, "parent": str(p.parent)}

4.2 onclick 路径转义问题

踩坑onclick="openExportFolder('E:\Desktop\测试导出')" 中的反斜杠被当作转义字符。

解决 :改用 data 属性存储路径,避免内联字符串转义:

javascript 复制代码
// 错误写法 --- 反斜杠转义问题
`<button onclick="openExportFolder('${escapeHtml(t.output_dir)}')">`

// 正确写法 --- data 属性存储
`<button data-open-dir="${escapeHtml(t.output_dir)}" 
  onclick="openExportFolder(this.getAttribute('data-open-dir'))">`

4.3 依赖状态横幅

启动时调用 /api/deps/status,根据结果渲染绿色(就绪)或黄色(缺失)横幅:

javascript 复制代码
async function loadDepsStatus() {
    const r = await fetch('/api/deps/status').then(r => r.json());
    state.depsStatus = r;
    renderDepsBanner(r);
}

function renderDepsBanner(status) {
    const oracleOk = status.oracle?.installed;
    const odbcOk = status.odbc?.installed;
    const allOk = oracleOk && odbcOk;
    // 绿色横幅:✓ 依赖就绪
    // 黄色横幅:⚠ 部分依赖缺失 + 🔧 一键安装按钮
}

五、部署方案

5.1 打包命令

batch 复制代码
pyinstaller --onefile --name "DataForge" ^
  --add-data "static;static" ^
  --add-data "config.py;." ^
  --add-data "models.py;." ^
  --add-data "checkpoint.py;." ^
  --add-data "task_manager.py;." ^
  --add-data "exporter.py;." ^
  --add-data "database.py;." ^
  --add-data "deps_check.py;." ^
  --hidden-import "oracledb" ^
  --hidden-import "psycopg2" ^
  --hidden-import "pyodbc" ^
  --collect-all "oracledb" ^
  --collect-all "xlsxwriter" ^
  main.py

5.2 部署目录结构

复制代码
dist/
├── DataForge.exe                    (37.4 MB --- 主程序)
└── deps/
    ├── instantclient-...19.32...zip (78.6 MB --- Oracle Client)
    └── msodbcsql17_x64.msi          (4.5 MB --- ODBC Driver)

5.3 部署到其他电脑

  1. 复制整个 dist/ 文件夹到目标电脑
  2. 双击 DataForge.exe,程序自动启动 Web 服务并打开浏览器
  3. 页面顶部显示依赖状态横幅
  4. 如缺失依赖,点击「一键安装」:
    • Oracle:自动解压 zip 到 oracle_client/ 目录
    • ODBC:UAC 提权后静默安装 MSI
  5. Oracle 安装后需重启程序(oracledb 每进程只能 init 一次)
  6. ODBC 安装后无需重启(pyodbc 动态读取驱动列表)

六、踩坑总结

问题 原因 解决方案
DPY-3010 thin 模式不支持 Oracle 11g 切换 thick 模式 + Instant Client
DPI-1072 Instant Client 19.5 太旧,oracledb 4.0.2 要求 19.16+ 升级到 19.32 版本
ORA-00911 Oracle 不允许 AS 前子查询别名 修正 count 查询 SQL
权限拒绝 Permission denied 输出目录不可写或文件被 Excel 锁定 文件夹选择器 + 错误提示
取消后仍显示"导出中" 状态未立即更新 + SSE 覆盖 立即设 CANCELLED + 关闭 SSE
onclick 路径失效 反斜杠被当作 JS 转义字符 改用 data 属性存储路径
PATH 中旧版 Oracle Client 干扰 系统 PATH 中的旧 oci.dll 优先加载 将正确路径插入 PATH 头部
PyInstaller 路径错误 frozen 模式下 __file__ 指向临时目录 sys._MEIPASSsys.executable 区分

七、项目结构

复制代码
DataForge/
├── main.py            # FastAPI 路由 + 启动入口
├── config.py          # 数据库连接配置 + TaskConfig
├── models.py          # TaskState 数据结构
├── database.py         # 数据库引擎创建 + Oracle thick 模式
├── exporter.py        # 流式导出引擎 + 断点续传
├── task_manager.py     # 任务调度 + 取消逻辑
├── checkpoint.py      # SQLite 持久化
├── deps_check.py      # 依赖检测 + 自动安装
├── static/
│   └── index.html     # 前端单文件(任务/连接/进度/依赖)
├── deps/              # 安装包(随 exe 分发)
│   ├── instantclient-*.zip
│   └── msodbcsql17*.msi
├── build_exe.bat      # 一键打包脚本
├── run.bat            # 开发运行脚本
└── requirements.txt

八、总结

DataForge 的开发过程涵盖了以下几个值得分享的工程实践:

  1. 流式导出 + 断点续传:通过 SQLAlchemy 服务端游标 + XlsxWriter 流式写入,实现常量内存下的千万行导出;SQLite 周期性保存断点,支持中断后恢复。

  2. Oracle 11g 兼容:从 thin→thick 模式切换、Instant Client 多级检测、DPI-1072 版本兼容、PATH 优先级修复,完整解决了 Oracle 老版本连接问题。

  3. 依赖自动安装 :通过 ShellExecuteExW + runas 实现 MSI 静默提权安装,通过 zip 解压实现 Oracle Client 免提权安装,让部署到任意 Windows 电脑成为可能。

  4. PyInstaller 打包 :区分 _MEIPASSexe 目录,静态文件打包进 exe,运行时数据放在 exe 同级,实现真正的"拷贝即用"。

  5. SSE 实时进度:前端 EventSource + 后端 StreamingResponse,每秒推送导出进度;取消时立即关闭 SSE 避免状态覆盖。

DataForge --- 数据锻造引擎,将原始数据库锻造为精美 Excel。

需要项目源码程序的请留言并注明邮箱 | 技术栈: Python + FastAPI + XlsxWriter + SQLite + PyInstaller

相关推荐
实心儿儿2 小时前
MySQL 的安装
数据库·mysql·adb
小玮看世界3 小时前
[Python]从合并区间到传感器融合区:合并区间在传感器区域融合的实际落地
开发语言·python
Java陈序员3 小时前
轻量运维面板!一款现代化的服务器控制面板工具!
运维·服务器·python·react.js·github
2601_962097363 小时前
1. 使用 C 或 C++ 扩展 Python
python·api·c·引用计数·扩展模块
Yanjun2i3 小时前
Agent学习记录五:Pydantic验证
人工智能·python·学习
程序员阿鹏4 小时前
为什么MySQL InnoDB选择B+树?
数据结构·数据库·b树·sql·mysql·算法·缓存
月光船幽幽4 小时前
跨范式映射的稳定接口设计
人工智能·python·算法
杜大哥4 小时前
python程序:如何查看电脑【电池电量的剩余百分比】 和 【是否插入连接着充电器】?
开发语言·python
wuyk5554 小时前
Python网络爬虫入门到实战 第01章:爬虫到底是什么?原理、流程、合法性、风险全解析(零基础必看)
开发语言·爬虫·python