做数据开发这几年,被业务部门临时喊去导数据是家常便饭。上午要一份近半年的订单明细,下午又要把用户表拉出来做分析,而且点名要Excel,因为业务方和领导都只看Excel。数据量小的时候还好,复制粘贴几分钟完事;可一旦碰上几十万行、十几张表一起导,手动操作不仅慢,还容易在筛选、复制、粘贴的过程中漏数据。后来我把整套流程整合成了一个 Python 脚本,从数据库连接、查询、清洗到 Excel 生成一次性跑完,批量导出这件事才算真正解脱了双手。
这个需求听起来简单,实际落地时坑其实不少。字符集、内存占用、Excel 行数上限、数据类型精度、连接超时,每一个都能让人折腾半天。我把自己实际踩过的坑和最终跑通的方案整理成这篇,从需求拆解到环境配置,再从核心代码到分批优化、常见故障排查,一次性聊透。
1. 需求拆解与方案选型:批量导出不是无脑循环
1.1 “批量”到底包含哪几层需求
很多人一听“批量导出”,第一反应就是写个 for 循环查表、导表,好像三五分钟就能搞定。但真正做了几次之后就会发现,“批量”这个词背后其实是四层需求的叠加。
第一层是多张表同时导出。业务方往往不会只要一张表,而是说“把订单表、用户表、商品表、退款表都给我”。这些表可能散落在同一个库里,也可能分布在不同的库甚至不同的数据库实例上。
第二层是单表数据量大。订单表动辄几百万行,全部捞进内存跑一次 SELECT *,稍不留神 Python 进程就卡死了。更麻烦的是,Excel 单表有 1048576 行的硬性限制,超过这个量就必须拆分成多个文件或工作表。
第三层是定时重复跑。这种导出需求通常不是一次性的。运营人员每月、每周都会要相同的数据,如果每次都要人肉执行一次,脚本就失去了意义,得做成能挂在定时任务里的工具。
第四层是格式交付。业务方拿到的 Excel 不是丢一张原始数据就完事了。表头要加粗、背景要上色,列宽要合理,筛选要打开,最好首行冻结。他们拿过去能直接看、直接筛、直接做透视表。
所以“批量导出数据库数据至 Excel 文件”这件事,本质上不是“查出来、写进去”两行代码,而是一条从数据库到最终交付文件的完整流水线。想清楚这层,再去设计架构,才会少走弯路。
1.2 技术选型:pandas + SQLAlchemy + openpyxl 的组合为什么最稳
Python 生态里做这件事的工具不少,但我最终固定下来的是 pandas + SQLAlchemy + openpyxl 三件套。
pandas 负责数据处理。它的 read_sql 可以直接执行 SQL 并返回 DataFrame,to_excel 一行代码就能生成 Excel,是当前数据链路里封装最成熟、坑最少的方式。虽然它对超大文件不太友好,但配合分批策略后,完全满足绝大多数业务场景。
SQLAlchemy 负责数据库连接。很多人习惯直接用 pymysql 或 psycopg2 连库,但这有个问题:一旦换了数据库类型,代码就要跟着改。而用 SQLAlchemy 的话,只需要改一个连接字符串,业务代码完全不动。我自己的环境里既有 MySQL 又有 Oracle,偶尔还有 PostgreSQL,靠这一层抽象省了不少事。国产数据库如达梦、人大金仓,只要提供对应的驱动,SQLAlchemy 基本都能兼容。
openpyxl 负责 Excel 修饰。pandas 的 to_excel 能出文件,但样式处理能力很弱。用 openpyxl 可以精确控制单元格字体、填充色、列宽、冻结窗格、数据筛选,甚至还能对做好的 Excel 做二次加工。它是 pandas 的互补品,而不是替代品。
这三个库的组合,覆盖了“连接、查询、处理、输出、美化”整条链路。从实际项目来看,这是性价比最高的一套方案。相比之下,直接操作 xlsxwriter 或纯 openpyxl 写文件,代码量大、灵活性差,非特殊需求没必要绕远路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与数据库连接配置:先把地基打好
2.1 依赖安装:别装错驱动库
环境部分不算难,但有几个细节容易翻车。
bash复制python -m pip install pandas sqlalchemy openpyxl
连接 MySQL 还需要装驱动。这里要注意,不要用 pip install mysql,那个项目已经很久没维护了,正确装法是:
bash复制python -m pip install pymysql
如果连的是 Oracle,就装 cx_Oracle;PostgreSQL 就装 psycopg2-binary;SQL Server 装 pyodbc。每个数据库驱动不同,安装前先确认你的目标库是哪种。
装完最好验证一下版本是否兼容。pandas 和 openpyxl 偶有版本不匹配导致 to_excel 报错的情况,我遇到过 pandas 1.5 配 openpyxl 3.0 时写入日期列报 ValueError 的问题,升级 openpyxl 到 3.1+ 就正常了。建议装完统一执行:
python复制import pandas, openpyxl, sqlalchemy
print(pandas.__version__, openpyxl.__version__, sqlalchemy.__version__)
确保三者都有明确版本输出再继续。
2.2 连接串写法与字符集设置:80% 的乱码问题出在这里
SQLAlchemy 的连接串有固定格式,以 MySQL 为例:
python复制from sqlalchemy import create_engine
engine = create_engine(
"mysql+pymysql://root:123456@127.0.0.1:3306/demo_db?charset=utf8mb4"
)
这里最容易被忽略的是结尾的 ?charset=utf8mb4。很多刚上手的朋友漏掉这个参数,导出的 Excel 里中文变成一堆问号,排查半天还不知道问题出在哪。
utf8mb4 是 MySQL 的完整 UTF-8 实现,能覆盖四字节的 emoji 字符。如果库里已经存了特殊表情符号,而连接串只写 utf8,这些字符读出来也会丢。所以稳妥起见,统一用 charset=utf8mb4。
如果是 Oracle,连接串写法稍有区别:
python复制engine = create_engine(
"oracle+cx_oracle://scott:tiger@127.0.0.1:1521/?service_name=ORCLPDB1"
)
PostgreSQL 写法:
python复制engine = create_engine(
"postgresql+psycopg2://postgres:123456@127.0.0.1:5432/demo_db"
)
一个值得注意的点:连接字符串里的密码如果包含 @、:、/ 等特殊字符,必须做 URL 编码,否则解析连接串时会直接报错。处理方式是用 urllib.parse.quote_plus 对密码编码后拼进去,这一条经验在对接第三方数据库时特别有用。
2.3 Excel 写入引擎选择:openpyxl 和 xlsxwriter 如何取舍
pandas 的 to_excel 底层可以切换多个引擎,常见的是 openpyxl 和 xlsxwriter。
两者的核心区别在于:openpyxl 既能读又能写,且支持对已有 Excel 文件做二次修改,适合“先有数据文件、再做样式加工”的场景。xlsxwriter 只写不读,但写入性能更好,对大文件更友好,功能也更丰富,比如可以直接插入图表、设置条件格式。
我自己的实践是:数据量在 10 万行以内时,两者差别不大;超过 50 万行时,xlsxwriter 的写入速度优势开始显现。但考虑到我们经常需要对已生成的文件追加样式、改变列宽、设置冻结窗格,openpyxl 在流程上更顺。综合来看,日常批量导出用 openpyxl 就够,如果哪天确实要生成超大 Excel,再单独为那个场景切 xlsxwriter。
在 to_excel 里指定引擎的方式很简单:
python复制df.to_excel("output.xlsx", engine="openpyxl", index=False)
不写也行,pandas 会自动根据文件后缀选择。
3. 核心代码实现:从单表到多表批量导出
3.1 单表导出:先把最基础的流程跑通
我不喜欢一上来就上全量代码,先从最基础的单表导出开始,把整条链路跑通,再逐步扩展。
看这段核心逻辑:
python复制import pandas as pd
from sqlalchemy import create_engine
engine = create_engine(
"mysql+pymysql://root:123456@127.0.0.1:3306/demo_db?charset=utf8mb4"
)
sql = "SELECT * FROM orders WHERE create_time >= '2024-01-01'"
df = pd.read_sql(sql, engine)
df.to_excel("订单明细_2024.xlsx", index=False, sheet_name="订单明细")
第一行负责建立数据库连接;第二行写业务查询逻辑;第三行把查询结果加载成 DataFrame;第四行写入 Excel 文件。这里 index=False 很重要,不写的话 Excel 里会多出一列行号,既难看又容易让业务方误解数据内容。
有个经验:写单表导出时,先在 SQL 里做字段筛选和过滤,而不是等数据加载到 pandas 里再操作。MySQL 服务器性能通常比本地 Python 进程强,能在 SQL 里做完的过滤、聚合、排序,就别拖到 Python 里做。这样既减少传输数据量,也降低内存压力。
跑通这段代码,你就完成了整个项目的地基。后面所有多表批量、分批优化,都是在这个基础上做扩展。
3.2 多表自动发现与批量循环导出
单表跑通之后,批量导出多张表就很简单了。最直接的方式是手动写表名列表,但我在实际项目中更推荐用 information_schema 自动发现表名。这样做的好处是:新表加入库中后,脚本自动覆盖,不需要来回改代码。
python复制import pandas as pd
from pathlib import Path
from sqlalchemy import create_engine
engine = create_engine(
"mysql+pymysql://root:123456@127.0.0.1:3306/demo_db?charset=utf8mb4"
)
OUTPUT_DIR = Path("export_result")
OUTPUT_DIR.mkdir(exist_ok=True)
IGNORE_TABLES = {"sys_config", "audit_log", "schema_version"}
def get_all_tables(engine):
sql = """
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY table_name
"""
return pd.read_sql(sql, engine)["table_name"].tolist()
for table in get_all_tables(engine):
if table in IGNORE_TABLES:
continue
df = pd.read_sql(f"SELECT * FROM `{table}`", engine)
df.to_excel(OUTPUT_DIR / f"{table}.xlsx", index=False)
print(f"[OK] {table}: {len(df)} 行")
这里有几个细节必须注意。
第一,表名要加反引号。表名里如果带了数字开头、特殊符号,或者碰巧是 SQL 保留字,不带反引号直接拼接就会语法报错。虽然大部分表名很规矩,但写工具类代码时,防御性编程是必要的。
第二,文件名要考虑系统兼容性。Windows 下 Excel 文件名不能包含 \/:*?"<>| 等字符。表名通常没问题,但如果表名里有点号或特殊字符,做导出前最好清洗一下文件名:
python复制import re
def safe_filename(name):
return re.sub(r'[\\/*?:"<>|]', "_", name)
第三,打印处理进度。几十张表导出不是瞬时完成,每一张表完成后打印日志,方便中途发现问题时定位到具体表。正式跑批时还可以把日志写入文件,我一般用 Python 的 logging 模块,控制台和文件双输出。
这种“自动发现表 + 排除清单 + 循环导出”的模式,在数据仓库日常交付中非常实用。它可以应付大部分“把库里所有业务表导出来”的需求。
3.3 样式定制:让导出的 Excel 拿去就能直接用
用 to_excel 导出的原始文件,只有数据没有格式。业务方拿到手后,通常要自己做列宽调整、表头加粗、筛选按钮等,这些重复劳动完全可以交给脚本。
我的做法是:先用 pandas 导出原始文件,再用 openpyxl 打开文件做样式加工,两步分离,逻辑清晰。
python复制import pandas as pd
from openpyxl import load_workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter
engine = create_engine(
"mysql+pymysql://root:123456@127.0.0.1:3306/demo_db?charset=utf8mb4"
)
df = pd.read_sql("SELECT * FROM orders LIMIT 1000", engine)
df.to_excel("orders_formatted.xlsx", index=False)
wb = load_workbook("orders_formatted.xlsx")
ws = wb.active
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
center_alignment = Alignment(horizontal="center", vertical="center")
for cell in ws[1]:
cell.font = header_font
cell.fill = header_fill
cell.alignment = center_alignment
for col_idx in range(1, ws.max_column + 1):
max_len = 0
letter = get_column_letter(col_idx)
for row in range(1, min(ws.max_row, 100) + 1):
value = ws[f"{letter}{row}"].value
if value is not None:
max_len = max(max_len, len(str(value)))
ws.column_dimensions[letter].width = max_len + 4
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
wb.save("orders_formatted.xlsx")
这段代码做的事情:表头白字加粗、深蓝底色;根据前 100 行的最大内容长度自动设置列宽;冻结首行;开启筛选按钮。最终交付的文件,业务方打开就是一份可直接使用的报表,不需要再做任何加工。
列宽计算这里有个小优化:只扫前 100 行而不是全部行。文件有几十万行时,逐行统计长度会非常慢,前 100 行基本能代表大部分内容长度,性能损耗可以忽略。
这个“先导出、再美化”的流程,是我在项目里用得最多也最稳定的方案。
4. 大表数据分批导出:性能与内存的平衡
4.1 一次性全量查询的问题:内存是最大瓶颈
当单表数据量到达几十万甚至上百万行时,最直接的问题是内存。pd.read_sql("SELECT * FROM big_table", engine) 会把查询结果全部加载到内存里,然后做类型推断、生成 DataFrame 副本,内存占用往往是你预期数据的 3 到 5 倍。
我测过一个 30 万行的订单表,单行数据约 200 字节,原始数据只有 60MB 左右。但一次性 read_sql 后,Python 进程内存从 150MB 直接涨到 1.8GB。原因在于:MySQL 驱动底层拿到的是字符串结果集,pandas 要把这些字符串解析成 int、float、datetime 等类型,中间会创建大量临时对象,垃圾回收还没来得及处理,内存峰值就上去了。
8GB 内存的开发机上还能勉强扛住,但如果你同时导出多张表,或者生产服务器内存本身就紧张,进程直接被杀掉是常有的事。
所以大表导出,必须分批。
4.2 分批导出方案:三种方式怎么选
我实际用过三种分批策略,各有适用场景。
方案一:chunksize 参数(最简单)
pandas 的 read_sql 直接支持分批读取:
python复制import pandas as pd
chunks = pd.read_sql("SELECT * FROM orders", engine, chunksize=20000)
for i, chunk in enumerate(chunks):
mode = "w" if i == 0 else "a"
header = True if i == 0 else False
chunk.to_excel(f"orders_part_{i}.xlsx", index=False)
chunksize=20000 表示每次只取 2 万行。这种方式的优点是代码改动极小,缺点是如果你把这 2 万行分批写到同一个 Excel 文件里,to_excel 每次都是全量重写文件,效率反而低。更好的做法是每批生成独立文件,最后再合并,或者直接用 openpyxl 的 write_only 模式逐批追加。
方案二:主键或时间范围分段(最推荐)
利用 WHERE id >= 下限 AND id < 上限 来切分数据:
python复制BATCH_SIZE = 10000
min_id = pd.read_sql("SELECT MIN(id) FROM orders", engine).iloc[0, 0]
max_id = pd.read_sql("SELECT MAX(id) FROM orders", engine).iloc[0, 0]
current = min_id
while current <= max_id:
end = current + BATCH_SIZE
df = pd.read_sql(
f"SELECT * FROM orders WHERE id >= {current} AND id < {end} ORDER BY id",
engine
)
print(f"导出 {current} ~ {end},共 {len(df)} 行")
current = end
这种方式的优势是每批查询可以独立重试,哪一批失败了重新跑哪一批就可以,不用整张表重新导。如果表没有主键但有时间字段,也可以按时间区间分段,比如按天、按月。我处理一个 5 年的流水表时,就是按月切分,每个月一个独立文件,这样交付给业务方时还顺手做了数据分片,他们用起来也方便。
方案三:服务端游标(内存最优)
MySQL 的 pymysql 默认会把查询结果一次性拉到客户端,要逐行处理必须用游标。这里推荐 SSCursor(Server Side Cursor):
python复制import MySQLdb
from MySQLdb.cursors import SSCursor
conn = MySQLdb.connect(
host="127.0.0.1",
user="root",
passwd="123456",
db="demo_db",
charset="utf8mb4",
cursorclass=SSCursor
)
cur = conn.cursor()
cur.execute("SELECT * FROM orders")
while True:
row = cur.fetchone()
if row is None:
break
# 每拿到一行,写一次 Excel
这种方式客户端内存几乎不涨,因为数据始终留在 MySQL 服务器端。但它有几个限制:游标存活期间,同一连接上不能执行其他查询;如果中途断开,游标就失效了。所以它更适合那种“纯读取、逐行写入”的场景。
三种方案的综合结论:日常用 chunksize 或主键分段足够,追求极致内存优化才上 SSCursor。我自己的主力方案是主键分段,因为它和“批处理 + 断点重跑”结合最好。
4.3 实测效果:30 万行数据的资源占用对比
我在自己 8GB 内存的笔记本上做了一次简单对比,表是 30 万行、12 列,含日期和浮点数类型:
| 方案 | 峰值内存 | 完成耗时 | 备注 |
|---|---|---|---|
| 一次性 read_sql | 约 1.8GB | 约 40 秒 | 内存波动大,有被杀风险 |
| chunksize 分批(2万/批) | 约 400MB | 约 55 秒 | 内存稳定,耗时略增 |
| 主键分段(1万/批) | 约 350MB | 约 60 秒 | 可断点重跑 |
| SSCursor 游标 | 约 120MB | 约 75 秒 | 内存最优,耗时最长 |
这个测试是我在实际环境里跑出来的,不是理论推算。结论很明显:内存和耗时是跷跷板,但站在稳定性角度,内存占用 350MB 和 1.8GB 相比,完全不是一个风险级别。对于生产环境,优先级永远是稳定第一、速度第二。
需要提醒的是,上面耗时包含写入 Excel 的时间。Excel 写入本身就是性能瓶颈,尤其是包含大量日期格式和字符串的列,openpyxl 写起来会明显比写 CSV 慢。如果对性能要求极高、对格式要求不高,可以先落 CSV 再用工具转 Excel,或者直接交付 CSV。
5. 常见问题与排查技巧实录
5.1 中文乱码:连接串和编码格式都要查
中文乱码是我遇到最多的一个问题,而且场景还不止一个。
第一种是在连接串里漏了 charset=utf8mb4,读取时就变成乱码。这种问题根本不需要去 pandas 层排查,先检查连接串。修改连接串后,重新建立 engine,问题基本解决。
第二种是导出的 CSV 文件在 Excel 里打开乱码。CSV 本身没有严格的编码标记,Excel 默认用本地编码集打开,而 to_csv 默认写的是 UTF-8。解决方案是导出时指定 utf-8-sig 编码:
python复制df.to_csv("output.csv", index=False, encoding="utf-8-sig")
utf-8-sig 会在文件开头加 BOM 头,Excel 识别到 BOM 后就知道这是 UTF-8 编码,中文就不会乱。这个细节,很多从 Linux 转过来的开发容易踩。
第三种是 Excel 文件本身正常,但某些单元格内容里有特殊字符导致显示异常,多为数据源里混入了不可见控制字符。遇到这种情况,可以在 pandas 里对字符串列做一次清洗:
python复制df["col"] = df["col"].astype(str).str.replace(r"[\x00-\x1f]", "", regex=True)
这会把 ASCII 控制字符剥掉,保住正常内容。
5.2 Excel 行数上限与文件损坏
Excel 的 xlsx 格式单表最多 1048576 行,这是硬限制,超过这个数直接写不进去。旧版 xls 格式更惨,只有 65536 行。
我处理过一个千万级流水表的导出需求,当时第一反应就是按时间拆文件。按天拆分后,每天一个文件,既有自然分区的意义,也绕开了 Excel 行数限制。拆分逻辑和主键分段基本一致,只是按日期字段切:
python复制from datetime import date, timedelta
start = date(2024, 1, 1)
end = date(2024, 12, 31)
current = start
while current <= end:
next_day = current + timedelta(days=1)
df = pd.read_sql(
"SELECT * FROM orders WHERE create_time >= %s AND create_time < %s",
engine,
params=[current, next_day]
)
df.to_excel(f"orders_{current}.xlsx", index=False)
print(f"导出 {current} 完成,{len(df)} 行")
current = next_day
注意这里使用了 params 参数而不是直接拼字符串,这是防止 SQL 注入的正确姿势,虽然批量导出场景多为内部工具,但这个习惯值得养成。
另外提一句文件损坏。to_excel 写大文件偶尔会报 ZipFile 相关的错误,多半是磁盘空间不足或文件被其他程序占用。排查顺序:先看磁盘、再看文件是否被 Excel 打开,最后检查 openpyxl 版本。
5.3 长任务超时与中断恢复
数据库连接有一个 wait_timeout 参数,默认通常是 8 小时,但很多云数据库默认只有几分钟。批量导出如果跑了很久,中间没有任何查询活动,连接可能被数据库主动断开,然后脚本报 MySQL Connection not available 错。
我的对策是控制每批任务时长。主键分段后,每批查询理论上都在秒级或分钟级完成,连接不会长时间空闲。另外,SQLAlchemy 的 pool_pre_ping=True 参数可以在每次从连接池拿连接时先做一个轻量检测,如果连接失效会自动重连:
python复制engine = create_engine(..., pool_pre_ping=True)
这个参数一行代码,省掉了大量连接断开的坑。批量导出脚本建议加上。
如果脚本中途失败,需要支持断点重跑。主键分段天然支持:记录当前处理到哪一行,重跑时直接从断点继续。我通常会把断点记录在一个 progress.json 文件里,每处理完一批就更新一次,失败后重新执行脚本时自动读取断点。
5.4 数值精度和日期格式的坑
Excel 内部对数字的精度是 15 位有效数字,超过 15 位的数字会被舍入。身份证号、订单流水号这类字段如果以数字形式存储,导出后会变成 1.23457E+17 之类的科学计数法,还会丢失精度。
解决方案是在 SQL 查询时就把大整数转成字符串:
sql复制SELECT CAST(id_card AS CHAR) AS id_card FROM users;
或者在 pandas 里转:
python复制df["id_card"] = df["id_card"].astype(str)
这里有个细节:pandas 的 astype(str) 对 float 类型可能会输出 123456789012345600.0,所以要确保转换前字段是 int64 或原始字符串类型。最稳妥的方式还是在 SQL 层做 CAST,因为数据库类型定义最权威。
日期格式方面,datetime64[ns] 写入 Excel 后通常显示为 2024-01-01 00:00:00,如果想要只显示年月日,可以在 to_excel 前格式化:
python复制df["create_date"] = df["create_date"].dt.strftime("%Y-%m-%d")
不过这样列类型会变成字符串,如果有后续计算需求就别这么做。时间字段通常保持原样导出最保险,Excel 识别日期类型后,业务方自己改显示格式很容易。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 中文全部变成问号 | 连接串缺 charset=utf8mb4 |
连接串补上字符集参数 |
| CSV 打开乱码 | 文件编码是 UTF-8 无 BOM | 用 utf-8-sig 导出 |
| 身份证号变成科学计数法 | Excel 数字精度限制 | SQL 里 CAST 成字符串 |
| 内存飙升、进程被杀 | 一次性加载全量数据 | 改用分批读取 |
| 写入 Excel 报 ZipFile 错 | 磁盘满或文件被占用 | 清理磁盘、关闭占用程序 |
| 中途连接断开 | wait_timeout 超时 |
加 pool_pre_ping=True,分批执行 |
| 超过 1048576 行写不进去 | xlsx 单表行数上限 | 按时间或主键拆分文件 |
最后再分享一个我自己的习惯:把表名、连接串、导出目录、样式开关全部集中到一个 config 配置里,而不是散落在代码各处。这样换一个库、加一张表、改一下导出路径,只需要改配置文件,不需要动业务代码。项目跑得越久,这种“配置与代码分离”的做法带来的便利就越明显。
批量导出这个需求看起来不起眼,但真要做好,涉及的知识点横跨数据库、内存管理、文件格式、编码处理多个层面。把每一步都摸透,你收获的不只是一段脚本,而是一整套能稳定支撑业务的数据交付能力。
