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:任务 IDexported_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 部署到其他电脑
- 复制整个
dist/文件夹到目标电脑 - 双击
DataForge.exe,程序自动启动 Web 服务并打开浏览器 - 页面顶部显示依赖状态横幅
- 如缺失依赖,点击「一键安装」:
- Oracle:自动解压 zip 到
oracle_client/目录 - ODBC:UAC 提权后静默安装 MSI
- Oracle:自动解压 zip 到
- Oracle 安装后需重启程序(
oracledb每进程只能 init 一次) - 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._MEIPASS 和 sys.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 的开发过程涵盖了以下几个值得分享的工程实践:
-
流式导出 + 断点续传:通过 SQLAlchemy 服务端游标 + XlsxWriter 流式写入,实现常量内存下的千万行导出;SQLite 周期性保存断点,支持中断后恢复。
-
Oracle 11g 兼容:从 thin→thick 模式切换、Instant Client 多级检测、DPI-1072 版本兼容、PATH 优先级修复,完整解决了 Oracle 老版本连接问题。
-
依赖自动安装 :通过
ShellExecuteExW+runas实现 MSI 静默提权安装,通过 zip 解压实现 Oracle Client 免提权安装,让部署到任意 Windows 电脑成为可能。 -
PyInstaller 打包 :区分
_MEIPASS和exe目录,静态文件打包进 exe,运行时数据放在 exe 同级,实现真正的"拷贝即用"。 -
SSE 实时进度:前端 EventSource + 后端 StreamingResponse,每秒推送导出进度;取消时立即关闭 SSE 避免状态覆盖。
DataForge --- 数据锻造引擎,将原始数据库锻造为精美 Excel。
需要项目源码程序的请留言并注明邮箱| 技术栈: Python + FastAPI + XlsxWriter + SQLite + PyInstaller