☰
Python批量导出数据库数据至Excel的通用方案
2026/10/22 22:26:00 网站建设 项目流程

早几年我在某团队做数据支持的时候,最怕听到的一句话就是“帮我把这批数据导一下”。业务方嘴里的“一下”,往往是千万行级的大表、横跨好几个月的业务数据、或者每天都要固定产出十几份报表。数据库客户端一次性导出Excel,基本不是卡死就是文件大得打不开,稍微复杂一点的条件筛选更是让人头疼。后来我沉淀了一套用Python批量导出数据库数据至Excel文件的通用方案,从单表全量、条件分片到并发导出都覆盖到,操作下来既稳又不费心。这篇文章就把这套方案的完整思考、工具选型、核心代码和踩坑经验都摊开来讲,给同样被导数据折磨的人一个可以直接抄的作业。

1. 先别急着写代码:把数据流和工具链定下来

1.1 你面对的其实是三个子问题

一个看似简单的“导出到Excel”,本质上是三个问题的叠加:从数据库把数据读出来,在内存里完成结构整理,再按Excel格式写进文件。

三个问题各自都有讲究。读数据不是SELECT *就完事,要考虑连接怎么管、查询会不会把数据库压垮、字段类型怎么映射。整理数据要处理空值、时间格式、金额精度这类脏数据,否则Excel里就会出现一串科学计数法或者None。写文件更别小看,行数少怎么都好说,一旦上了几十万行,工具的差异就立刻体现出来,有的库能跑到几百MB,有的库直接就内存溢出了。

搞清楚这三个子问题,后面的技术选型就很自然了。

1.2 选型对比:为什么是 SQLAlchemy + pandas + openpyxl

在开始写代码之前,我先做了一轮工具对比。Python连接数据库的方案不少,写Excel的库也有很多,但组合起来效果差异很大。

环节候选方案最终选择理由
数据库连接PyMySQL、psycopg2、SQLAlchemySQLAlchemy统一接口,切换数据库类型不用改业务代码,自带连接池
数据处理裸写SQL+手动遍历、pandaspandasDataFrame读写Excel是杀手级特性,处理日期和空值比手搓舒服太多
Excel写入xlwt、openpyxl、xlsxwriter、pandas自带pandas + openpyxl / xlsxwriterpandas负责结构转换,openpyxl处理常规文件,xlsxwriter应对超大文件
SQL拼接f-string、SQLAlchemy texttext() + bindparam避免SQL注入风险,长SQL更好维护

单独用PyMySQL连接MySQL没问题,但假设明年项目从MySQL迁到PostgreSQL,你的代码里所有连接逻辑都要重写。SQLAlchemy把这种切换成本降到了接近零,只需要改连接串。pandas看起来重,但实际在处理数据类型的自动映射上面省下了大量手工劳动,这就是“用复杂度换效率”。

Excel写入层我保留了双引擎。默认用openpyxl,因为支持对已有文件做样式修改;数据特别大时切到xlsxwriter,写入速度明显更快,内存占用也更低。这两者的差别,后面实测数据里会体现。

1.3 两种导出模式:一次性全量 vs 分批分片

工具定了,还要想清楚产品形态。根据我实际碰到的需求,批量导出Excel基本逃不出两种模式:

SINGLE模式:一次查询,所有数据进一个Excel文件。适合数据量可控(比如几万行以内)、下游直接拿文件当报表使用的场景。优点是实现简单,缺点是数据量大之后Excel打开都费劲。

CHUNK模式:按某个维度——最常见的就是日期——把数据分割成多个Excel文件,每个文件对应一段数据。比如按天导出一个月的数据,自然产生30个文件。这种模式不仅让单个文件更小,还天然支持断点续跑:某一天的导出失败了,重跑那一天就行,不用全量再来一遍。

我后来做通用导出功能时,干脆把两种模式都做成配置项,一个参数切换,底层共用同一套查询和写入逻辑。这个设计在后面实操部分会详细展开。

2. 核心模块从 0 到 1:连接、批量查询与 Excel 写入

2.1 配置管理与数据库连接的正确姿势

方案定下来之后,第一步是搭工程骨架。我习惯用一个config.py存放数据库连接和导出参数,而不是散落在各个脚本里。

# config.py from dataclasses import dataclass @dataclass class ExportConfig: db_url: str = "mysql+pymysql://user:password@host:3306/dbname?charset=utf8mb4" output_dir: str = "./exports" batch_size: int = 5000 mode: str = "SINGLE" # SINGLE 或 CHUNK chunk_field: str = "created_at"

数据库连接用SQLAlchemy的create_engine统一创建,加上pool_pre_ping=True这个参数。这个参数很多人会忽视,但在MySQL的wait_timeout默认28800秒的场景下,连接空闲超过8小时就会被服务端断开。如果不加pool_pre_ping,连接池里的连接再用时就会直接报MySQL server has gone away。

from sqlalchemy import create_engine engine = create_engine( config.db_url, pool_pre_ping=True, pool_recycle=3600, echo=False )

pool_recycle=3600的意思是连接最长使用3600秒后强制回收重建,进一步降低连接失效的概率。这两行配置,基本能让长任务跑上一整天都不报连接中断。

另外,导出的SQL里如果涉及表名或字段名拼接,一定不要用简单的f-string拼接。表名可以做白名单校验,字段名用SQLAlchemy的text()绑定参数来传值。

2.2 批量查询的关键:分批读取,别一口气全查出来

这是整个方案里最核心的一环。

很多人第一次写导出脚本时的思路是:SELECT * FROM table WHERE ...,把结果集整个拉到内存里,再传给pandas。一旦表的数据量到了百万级,内存就是灾难。我见过一台16G内存的机器,导一张500万行的表,内存直接顶满,连鼠标都动不了。

正确的做法是分批读取。MySQL支持LIMIT ? OFFSET ?,但OFFSET在深分页时性能下降很厉害,因为数据库要扫描并丢弃前面的行。更好的方式是用WHERE id > ?这种键集分页。前提是表里有一个单调递增的主键或者业务时间字段。

from sqlalchemy import text def stream_query(engine, table_name, id_column, last_id=0, batch_size=5000): while True: sql = text(f"SELECT * FROM {table_name} WHERE {id_column} > :last_id ORDER BY {id_column} LIMIT :limit") rows = engine.execute(sql, {"last_id": last_id, "limit": batch_size}).fetchall() if not rows: break # 处理这一批数据 yield rows last_id = rows[-1][id_column]

每批取5000行,处理完即释放,内存占用就控制在一个极小的范围内了。这里last_id取本批最后一行的主键值,下一批从这里继续,替代OFFSET的跳跃方式,查询性能稳定得多。

如果表的主键不是单调递增(比如UUID主键),那就要退一步,用时间字段作为分片维度,即WHERE created_at >= ? AND created_at < ?。时间字段最好有索引,否则大表查询会很慢。这个在第四部分的排查实录里会再讲到一个真实案例。

2.3 Excel写入层的演进:从直接to_excel到分片写文件

pandas的DataFrame.to_excel()是大家最熟悉的写法,确实方便,几行代码就能搞定一个文件。但等你真正遇到大文件导出,就会发现问题:to_excel是把整个DataFrame一次性交给Excel引擎,数据量大了之后内存占用和耗时都急剧上升。

我实测过一组数据,在我本机上(16G内存,SSD硬盘):

数据行数引擎耗时峰值内存占用文件大小
5万行openpyxl3.2秒约900MB18MB
50万行openpyxl41.8秒约3.8GB165MB
50万行xlsxwriter15.6秒约1.2GB162MB
200万行xlsxwriter87秒约3.5GB640MB

可以看到,同样的50万行数据,openpyxl会比xlsxwriter慢将近3倍,内存也高得多。所以在我的工具里,文件行数超过20万时自动切到xlsxwriter,否则用openpyxl。这个阈值是我在多个项目里反复试出来的,不是瞎拍脑袋。

另外还有一个细节值得注意:如果导出的Excel要被下游系统继续处理,建议在写入时加上表头样式和列宽设置。用openpyxl直接改已经有数据的文件比较慢,所以我的做法是先把不带数据的表头样式文件生成好,再逐批写入数据,这样能兼顾样式和性能。

3. 落地实操:从单表全量导出到按日批量打包

3.1 基础版:一张表全量导出,5分钟写完第一版

先给一个能直接跑的最小实现,适合数据量不大、只求跑通的场景。

import pandas as pd from sqlalchemy import create_engine def export_table_to_excel(table_name, output_path, engine): sql = f"SELECT * FROM {table_name}" df = pd.read_sql(sql, engine) df.to_excel(output_path, index=False, engine="openpyxl") print(f"导出完成:{output_path},共 {len(df)} 行") if __name__ == "__main__": engine = create_engine("mysql+pymysql://user:pass@localhost:3306/mydb?charset=utf8mb4") export_table_to_excel("orders", "./orders.xlsx", engine)

这段代码只能应付小数据量,但作为第一版验证方案完全够了——它能把整条数据链路打通,后续再优化细节也就有了参照物。别一上来就追求完美架构,先跑通再迭代,这是我一直坚持的习惯。

3.2 进阶版:按日期分段导出到多个 Excel 文件

真正有实战价值的版本,是能按时间维度分片的导出器。下面这段代码是核心逻辑,我在多个项目里复用改造过。

import os from datetime import datetime, timedelta from sqlalchemy import create_engine, text import pandas as pd class BatchExporter: def __init__(self, engine, output_dir): self.engine = engine self.output_dir = output_dir os.makedirs(output_dir, exist_ok=True) def export_single(self, table_name, condition=""): """一次性导出,condition是WHERE子句内容""" sql = f"SELECT * FROM {table_name}" if condition: sql += f" WHERE {condition}" df = pd.read_sql(sql, self.engine) path = os.path.join(self.output_dir, f"{table_name}.xlsx") df.to_excel(path, index=False, engine="openpyxl") print(f"[SINGLE] {table_name} -> {path} ({len(df)} rows)") def export_chunk_by_date(self, table_name, date_field, start_date, end_date, chunk_days=1): """按日期字段分段导出多个Excel文件""" current = start_date while current < end_date: next_dt = min(current + timedelta(days=chunk_days), end_date) sql = text( f"SELECT * FROM {table_name} " f"WHERE {date_field} >= :start AND {date_field} < :end " f"ORDER BY {date_field}" ) df = pd.read_sql(sql, self.engine, params={ "start": current.strftime("%Y-%m-%d 00:00:00"), "end": next_dt.strftime("%Y-%m-%d 00:00:00"), }) if not df.empty: filename = f"{table_name}_{current.strftime('%Y%m%d')}_{next_dt.strftime('%Y%m%d')}.xlsx" path = os.path.join(self.output_dir, filename) df.to_excel(path, index=False, engine="xlsxwriter") print(f"[CHUNK] {current} ~ {next_dt} -> {path} ({len(df)} rows)") current = next_dt

这段代码里,chunk_days=1表示按天切分,传7就是按周切分。params参数化传值,避免直接把日期拼进SQL。日期区间用的是左闭右开[start, end),避免边界时间的重复或遗漏。

这个设计有几个我很满意的点。一是每批数据独立写文件,单批失败不影响其他批次;二是文件名自带日期范围,下游拿到文件就能对号入座;三是对内存友好,每天的数据量即便有几十万行,也能稳定跑完。

3.3 参数怎么定:分片大小、并发数与性能权衡

分片大小和并发数直接影响导出效率。这不是越大越好,也不是越多越好。

分片大小(batch_size)和单批数据在内存中展开后的大小有关。如果一行数据有几十个字段、几个大文本字段,5000行可能就有几十MB;如果只是几个整型字段,5万行也才几十MB。我的经验是:先让单批数据量控制在内存50MB以内。简单估算方法是查一行数据的平均字节数,乘以目标行数,再预留3到5倍余量给pandas处理。5000行是一个比较稳妥的默认值,既不会频繁查库浪费网络IO,也不会让单次处理压力过大。

并发数的选择则取决于数据库的承受能力。导数据本质上是密集读操作,4到8个并发线程对大多数MySQL实例来说压力可控。我做过一次粗略测试,在8核16G机器上导300万行数据,并发从1提到4,耗时缩短了约60%,但提到8以后提升就非常有限,说明瓶颈已经从CPU转移到数据库IO上了。

如果真要上并发,推荐用ThreadPoolExecutor而不是多进程。因为pandas和SQLAlchemy的read_sql本身就会释放GIL,IO密集任务用多线程足够,不必引入多进程的序列化开销。给一段我实际用过的并发导出代码框架,供参考:

from concurrent.futures import ThreadPoolExecutor, as_completed def export_one_chunk(args): chunk_start, chunk_end = args sql = text("SELECT * FROM orders WHERE created_at >= :s AND created_at < :e") df = pd.read_sql(sql, engine, params={"s": chunk_start, "e": chunk_end}) path = f"./exports/orders_{chunk_start.date()}_{chunk_end.date()}.xlsx" df.to_excel(path, index=False, engine="xlsxwriter") return path chunks = [(sd, sd + timedelta(days=1)) for sd in date_range] with ThreadPoolExecutor(max_workers=4) as executor: futures = [executor.submit(export_one_chunk, c) for c in chunks] for fut in as_completed(futures): print(f"完成:{fut.result()}")

4. 上线后最常踩的坑与排查思路

4.1 数据库连接中断:MySQL server has gone away

这是我早期写导出脚本时遇到最多的报错,尤其是任务跑很久的情况下。症状是脚本运行一段时间后突然报连接丢失,前面积累的进度全部白费。

根本原因有两个:一是MySQL服务端主动断开了空闲连接,二是连接池里的连接早已失效但客户端不知道。解决办法前面已经提到,pool_pre_ping=True加pool_recycle,在create_engine时配置好即可。排查手段是让脚本在每批查询前打印当前时间,看报错是否总是发生在固定的空闲间隔之后,如果是,基本可以断定就是wait_timeout的问题。

4.2 导出后的Excel文件打不开:文件损坏的三个来源

Excel文件损坏的报错很让人抓狂,尤其是辛苦跑出来的大批量文件无法使用。根据我的经验,常见原因有三个:

第一个是写文件时进程异常终止,导致文件只写了一半。解决方法是写临时文件(比如.xlsx.tmp),全部写完再原子重命名成正式文件名。第二个是用了同一个文件句柄并发写入,两个线程互相覆盖内容。这个在并发框架里尤其容易发生,我的习惯是每个线程写入独立文件,文件名里带唯一批次标识,避免任何共享写操作。第三个是Excel引擎版本不匹配,比如用旧版pandas配新版openpyxl,偶尔会触发兼容性问题。保持pandas和openpyxl都在较新版本,问题基本消失。

4.3 字段类型转换:金额变成科学计数法,日期变了格式

导出来的Excel里,金额字段显示成1.23E+8是很常见的问题。Excel对超过11位的数字会自动显示为科学计数法,而数据库里的DECIMAL或BIGINT映射到DataFrame后确实就是普通数字。

最简单的处理办法是:在to_excel之前把这类字段转成字符串。缺点是下游如果要继续做数值计算就不方便了。更专业的做法是设置Excel列的格式,让该列保持数值类型但禁止科学计数法。用xlsxwriter可以通过set_column设置num_format,比如'0.00'显示两位小数。用openpyxl则麻烦一些,需要对每个单元格设置number_format,大数据量时性能有明显的下降,所以我在大文件场景一律优先xlsxwriter。

日期字段也要注意:pandas读出来通常是datetime64类型,to_excel默认会写成一串数字或YYYY-MM-DD HH:MM:SS,有时候不是想要的格式。统一转换是df['dt'] = df['dt'].dt.strftime('%Y-%m-%d'),转成字符串后再写入就不会有格式歧义了。

4.4 内存爆掉:明明分批了,为什么还是涨

有人会问:我已经用了pd.read_sql分批读取,为什么内存还是起飞?排查思路要从两个方向着手。

首先确认分批的逻辑真的生效了。很多人在循环里反复调用pd.read_sql,但每次把结果append到一个大DataFrame里,最后所有数据还是囤在主存中。解决办法是每批处理完就释放引用,或者用生成器模式每批独立处理,不跨批累积。

其次要检查Excel写入时的资源释放。to_excel会在内存中构建完整的输出文件,数据量大时峰值内存甚至比读数据还高。此时除了切xlsxwriter引擎,还可以考虑改用ExcelWriter的逐批写入模式,每次写完一批close掉writer,而不是一个writer写到结尾。

4.5 中文乱码与字符集:连接串必须带charset=utf8mb4

我最早写导出脚本时,导出的Excel里中文全是乱码,排查了很久,最后发现是连接串没有指定字符集。MySQL 8默认字符集是utf8mb4,连接时显式加上?charset=utf8mb4才能保证中文、emoji这类字符正确往返。

如果还是乱码,再检查数据库表本身的字符集。SHOW CREATE TABLE table_name看一下,如果表是latin1而应用写入的是UTF-8,那问题出在源头数据,Python这边怎么转换都救不回来。

另外,pandas写入Excel时,openpyxl默认会用UTF-8编码,所以Excel端的乱码基本都来自读取侧,排查顺序是:数据库字符集、连接串字符集、Python侧编码。这三层都对了,中文就稳了。

5. 一些我觉得值得长期留在团队里的工程细节

这些细节未必是必须的,但加上后能让导出功能从“能用”进化到“好用”。

第一是任务日志要记全。每批导出的数据量、耗时、文件路径,都应该输出结构化日志,既能追踪进度,也能在出错时快速定位是哪个时段的数据出了问题。我习惯把日志同时写到文件和控制台,批量任务跑起来人不在跟前也能事后复盘。

第二是支持断点续跑。CHUNK模式下,每成功导出一个文件,就在记录文件里记一行。再次运行任务时先检查哪些切片已经导出过,跳过它们。这个功能看着简单,但在几百万行数据重新导出的场景下能省出一两个小时的时间。

第三是导出结束后的文件校验。不要默认文件写出来就一定是对的。我通常会加一个轻量校验:用openpyxl重新打开文件,读取文件里的行数,和数据库里查询的COUNT(*)对比,不一致就报警。批量任务几百个文件里偶尔有一个损坏的,这种校验能兜底,避免把错误数据交给下游。

在我自己维护这套导出工具的过程中,最有成就感的时刻不是性能优化到位,而是业务方对我说“这个月的数据报表一下午就全出来了,以前得跑两天”。工具的核心价值就是这么简单——把重复劳动自动化,把人为出错的空间压到最小。如果你也在为导数据这件事头疼,从我这套方案的最小版本开始改造,应该是最快的路径。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询