上周一接到一个让我血压升高的需求:把 MaxCompute 里一张近 2 亿行的订单明细表导出成本地文件,一份给业务同事做复盘,一份给财务做审计。同事说得很轻松:"你就导个 txt 或者 Excel 嘛,Python 不就行。"我心里清楚,这事没那么简单。MaxCompute 上跑 SQL 快,但把海量数据拉到本地,内存、网络、Excel 行数上限全是坑。这篇就把我完整的导出方案和踩坑记录写出来,内容包括:为什么不能一把梭 to_pandas、Tunnel Session 的正确打开方式、如何把几千万行数据写进真正能打开的 Excel、以及几个只在生产环境才会暴露的细节。如果你也经常要从 MaxCompute 拉数给业务、审计或下游系统,这篇应该能帮你节省一个下午的试错时间。
1. 导出任务的三道坎:数据量、内存与Excel行数上限
1.1 海量数据的第一道坎:单机内存装不下
很多人写导出脚本的第一反应是:把表拉下来,存成列表,再写文件。这在数据量几百 MB 时没毛病,但超过几千万行就完全变味了。
以订单明细为例,假设每行平均 300 字节,1 亿行就是约 30 GB 的原始数据。就算你本地机器是 64 GB 内存,Python 对象本身还有额外开销:一个字符串对象 49 字节起步,一个 int 对象 28 字节起步。也就是说,30 GB 的裸数据在 Python 内存里轻松膨胀到 100 GB 以上,直接把进程拖垮。
这里要强调一句:数据在 MaxCompute 服务端跑得完,不代表你本地内存装得下。Hadoop/大数据平台的运算能力和客户端机器完全是两回事。导出逻辑的核心不是"一次拉多少",而是"一次只拿一小块,处理完立刻释放"。
1.2 第二道坎:Excel文件自身的104万行天花板
Excel 单张工作表的行数上限是 1,048,576 行,也就是 2 的 20 次方。这是产品设计层面的硬限制,不是设置问题,也不是换台性能更好的电脑就能解决的。
这意味着,超过 100 万行的数据,绝对不能直接塞进一个 sheet。哪怕你只是中间步骤把数据全塞进去了,最终用户打开文件也会看到"数据已损坏"或者"文件未完全加载"——行数被截断到 104 万行,后面的数据直接消失。这种事故一旦发生在审计场景,责任完全在导出方。
所以在设计阶段就要决定:是拆成多个 sheet,还是拆成多个文件,还是换 CSV 格式。我的建议是,如果数据量在 200 万行以内,多 sheet 方案最方便,一个文件就能交付;超过 500 万行,多文件方案更稳妥,因为单个 xlsx 文件过大时,打开速度和崩溃概率都会上升。
1.3 第三道坎:自带工具与浏览器下载的尴尬
有人会问:MaxCompute 控制台不是自带下载功能吗?DataWorks 不是可以建数据集成任务吗?为什么还要自己写 Python?
自带的查询下载功能适合百万行以内的场景,本质上是把查询结果拉到前端再走浏览器下载。量一大,浏览器会先卡死,然后连接超时,最后下载中断,连个断点续传都没有。数据集成任务确实能导更多,但通常需要你配置目标端、选择同步方式,在很多临时取数场景下,建任务比写脚本还麻烦。
更重要的是,自带的导出通常不给你精细控制:文件编码、分隔符、字段顺序、null 值的处理方式、日期格式,这些全要看系统默认。但业务那边往往有明确要求:"手机号不能变成科学计数法""日期必须是 yyyy-mm-dd hh:mm:ss""空值填 NULL 不要留空"。这种需求,自己写脚本反而最可控。
2. 选型:PyODPS、Tunnel Session和Excel写入库怎么搭配
2.1 从MaxCompute取数,你其实有三个层次
PyODPS 是 MaxCompute 官方 Python SDK,取数方式可以粗分成三个层次:
table.to_df().to_pandas():一句话把整张表变成本地 DataFrame。适合小表、测试数据、快速验证。海量数据下这就是内存炸弹。table.tunnel.open_reader():直接打开一个数据读取器,可以迭代 Record。它比 to_pandas 好一些,但依然是"一次性把整个数据块拉到本地缓冲"的模型,数据量大了照样吃内存,而且没有位置偏移的概念,中断之后只能从头再来。table.tunnel.create_tunnel_session():创建一个 Tunnel Session,支持通过 offset/limit 精确读取任意一段数据。这是大数据量导出的正路,稍后我会写完整代码。
你可能会问:既然有第三种方案,为什么前两种还存在?因为前两种的 API 足够简单,适合快速验证和写临时脚本。官方设计上,Tunnel Session 主要是给分布式数据交换用的,支持多连接并发读取同一个表的不同分片,非常适合我们这种"导出到本地文件"的场景。
2.2 三种Excel写入方案对比
就算数据成功拉到了本地,写 Excel 也不是随便用一个库就行的。我简单对比一下三种主流方案:
| 方案 | 内存表现 | 百万行写入效率 | 适用场景 |
|---|---|---|---|
| pandas.to_excel | 差:底层 openpyxl 会先把整个 sheet 的对象都放进内存 | 慢,百万行大概率 OOM | 万行级别小表 |
| openpyxl 直接写 | 中下:不支持真正的流式写入,DOM 模型内存占用高 | 慢,不推荐百万行 | 需要修改现有 xlsx 模板 |
| xlsxwriter 流式写 | 好:constant_memory 模式逐行写,内存占用平稳 | 快,百万行可接受 | 生成新的大 xlsx 文件,首选 |
很多教程会推荐 openpyxl,但 openpyxl 是"读写都支持"的库,它的写模式把单元格对象全部挂在内存里。我实测过,openpyxl 写 50 万行 X 20 列,内存占用超过了 6 GB,而同样数据量 xlsxwriter 开 constant_memory 模式,内存占用稳定在几百 MB 级别。
2.3 我的默认选型
根据数据量和目标格式,我的默认选型简单粗暴:
- 数据量小于 10 万行,且只需一次性验证:
to_pandas()+pandas.to_excel(),怎么简单怎么来。 - 数据量 10 万到 1000 万行,目标是 txt/CSV:Tunnel Session 流式读,分片写本地文件。
- 数据量 100 万到 500 万行,目标是 Excel:Tunnel Session 流式读,xlsxwriter 多 sheet 写。
- 数据量超过 500 万行,目标是 Excel:不推荐 xlsx 了,直接拆多个 CSV(UTF-8 with BOM)交付,或者先导出到 OSS 再让业务用 BI 工具看。
这套选型逻辑不是拍脑袋,核心就一条:不要让数据在本地内存里积压超过 10 万行。只要守住这一条,导出脚本就不会因为内存问题挂掉。
3. 一把梭的to_pandas写法为什么一定会炸
3.1 看起来人畜无害的示例代码
先看一段典型的新手代码,我一开始也是这么写的:
from odps import ODPS o = ODPS('access_id', 'access_key', project='my_project', endpoint='endpoint') t = o.get_table('user_orders_delta') df = t.to_df().to_pandas() df.to_excel('user_orders_delta.xlsx', index=False) print('导出完成')这段代码在 1 万行时,跑起来非常爽。在 10 万行时,会等一阵子。在 100 万行时,你已经可以泡杯茶等它慢慢死。在 1000 万行时,你会看到一个内存占用曲线直线拉升的进程,然后在某个瞬间被操作系统杀掉。
3.2 炸法一:内存直接拉满
to_df().to_pandas()的内部流程是:PyODPS 通过 tunnel 把整张表的原始数据拉到客户端,全部解析成 Python 对象,再组装成 pandas DataFrame。整个过程没有任何分片,数据全部驻留在内存。
我一次实际运行中,一张 800 万行、25 列的表,运行到第 3 分钟时内存占用到了 78 GB,当时机器总共 64 GB,系统直接开始 swap,最后 OOM Killer 把进程杀了。日志最后一行是Killed,连报错堆栈都没有。
3.3 炸法二:Excel写出来了但打开就报"数据被截断"
有时候内存没炸,你以为成功了,其实坑在后面。800 万行写进一个 xlsx,pandas 底层用的 openpyxl 会把整个 DataFrame 转成单元格对象再写文件。这个过程中内存可能还没炸,但生成的文件超过 1 GB。Excel 打开这个文件需要很长的解析时间,而且因为超过单表行数上限,文件打开时会提示数据被截断,后面的 700 万行等于白写。
哪怕你只导 100 万行以内,pandas + openpyxl 也可能因为列里有特殊字符、日期时间格式,导致生成的文件打开报错。比如某个字段里有不可见控制字符,Excel 会认为文件损坏。
3.4 炸法三:跑到99%网络闪断,一切重来
还有一个最容易被忽视的问题:一次性把数据全部拉回来,意味着整个导出过程是一条长连接。本地网络稍微抖动一下,或者 MaxCompute 服务端做连接回收,整次导出就直接失败。没有检查点、没有重试逻辑,你只能从头再跑一遍。如果你正在导一张运行了 20 分钟的大表,这种失败是非常崩溃的。
基于这些教训,我的结论是:to_pandas()只用来做小数据量验证,所有正式导出脚本都应该走 Tunnel Session 分片读取。也就是接下来要写的方案。
4. 海量导出txt的工程化写法
4.1 为什么是Tunnel Session:支持偏移读取和并发
Tunnel Session 的核心优势是,它允许你以(offset, limit)的方式精确读取表的某一段数据。这就带来两个直接收益:
- 内存可控:每次只读几千到几万行,处理完就丢,内存占用几乎不会增长。
- 可断点延续:记录已经读取到的 offset,中断后可以从该 offset 继续,不需要重跑整个任务。
一个常被忽略的点是,Tunnel 读取的数据必须保持一致。如果表是分区表,最好指定一个静态分区;如果是非分区表,请确保导出期间表没有被写入。因为 offset 是基于读取瞬间的数据快照定位的,如果表在导出过程中发生变化,offset 对应的行就不是你原本要导出的行了。
4.2 一个可复用的txt导出函数
下面这段代码是我现在常用的模板,可以直接复制改参数使用。注释里我标注了几个关键决策,后续再详细解释。
import datetime from odps import ODPS def export_table_to_txt( table, output_path, sep="\t", batch=50000, columns=None, header=True, null_value="", encoding="utf-8-sig", ): """ 将 MaxCompute 表流式导出为文本文件。 :param table: PyODPS Table 对象 :param output_path: 本地输出文件路径 :param sep: 字段分隔符,默认 TAB :param batch: 单次从 tunnel 读取的行数 :param columns: 导出的字段列表,默认全部字段 :param header: 是否输出表头 :param null_value: NULL 值的填充字符串 :param encoding: 文件编码,默认 utf-8-sig,方便 Excel 打开 """ if columns is None: columns = [c.name for c in table.schema.columns] session = table.tunnel.create_tunnel_session() try: # 尝试预估总量,只用于日志展示;大表 count 可能很慢,失败也无所谓 total = -1 try: total = table.count(timeout=30) except Exception: pass written = 0 offset = 0 with open(output_path, "w", encoding=encoding, newline="") as f: if header: f.write(sep.join(columns) + "\n") while True: with session.open_reader(offset=offset, limit=batch) as reader: batch_written = 0 for record in reader: values = [] for col in columns: val = record[col] if val is None: values.append(null_value) elif isinstance(val, datetime.datetime): values.append(val.strftime("%Y-%m-%d %H:%M:%S")) else: s = str(val) # 字段内包含分隔符或换行符时,转义后再写入 if sep in s or "\n" in s or "\r" in s: s = s.replace("\\", "\\\\") \ .replace(sep, "\\t") \ .replace("\n", "\\n") \ .replace("\r", "\\r") values.append(s) f.write(sep.join(values) + "\n") batch_written += 1 written += batch_written f.flush() print(f"[{datetime.datetime.now():%H:%M:%S}] " f"written={written} rows" f"{', total=' + str(total) if total >= 0 else ''}") # 本批不足 batch 行,说明已到表尾 if batch_written < batch: print("导出完成") break offset += batch finally: session.close() # 使用示例 o = ODPS('access_id', 'access_key', project='my_project', endpoint='endpoint') t = o.get_table('user_orders_delta') export_table_to_txt( t, 'user_orders_delta.txt', sep='\t', batch=50000, header=True, null_value='NULL', )这里有几个值得说明的细节:
newline="":避免 Windows 下 Python 自动把\n转成\r\n的坑。如果业务明确要 Windows 换行,可以把newline=""去掉或者改成newline="\r\n"。table.count(timeout=30):我用一个 try 包裹,并设置较短超时。大表 count 要跑一次完整的 MapReduce 统计,可能耗时几十秒到几分钟,如果只是为了显示进度条,不值得等这么久。- 转义策略:我只处理了 TAB、换行、反斜杠三种。如果你的数据里可能有别的分隔符,比如逗号分隔 CSV,那么逗号也要替换;核心原则是"不能让字段里的字符破坏文件的行列结构"。
4.3 编码与换行转义:让Excel打开不乱的细节
txt 文件的编码选择有个很容易踩的坑:用 Excel 直接打开一个 UTF-8 编码的 txt/CSV,中文大概率乱码。原因是 Excel 默认用 ANSI(中文系统上是 GBK)去猜文件编码,遇到没有 BOM 的 UTF-8 文件就会解读错误。
解决办法有两种:第一,写文件时用utf-8-sig编码,这个编码会在文件开头写入 BOM 头,Excel 看到 BOM 就知道是 UTF-8;第二,直接转成 GBK 编码文件,但 GBK 对字符覆盖率有限,遇到生僻字或特殊符号会报编码错误。我推荐第一种,utf-8-sig是通用解法,既兼容 Excel,也兼容 Python、Java 等程序读取。
字段内的转义细节也容易忽略。比如某个备注字段里有换行符,如果直接写入文件,Excel 打开后会认为换行是记录结束,导致这一行数据被拆成两行甚至多行,下游拿到文件后对不上列数。最稳妥的方式是:写入前统一处理字段中的\n、\r、分隔符,把它们转成可读的转义字符,比如\n转成\\n。
另外还有个经验:优先用 TAB 做分隔符,而不是逗号。原因很简单,业务数据里的逗号太常见了,英文地址、备注、名字里都可能出现;TAB 出现的概率极低,处理成本更小。如果下游系统要求 CSV 格式,那建议用标准 CSV 库的 quote 机制,而不是自己简单 replace。
4.4 进度感知、断点续传与并发加速
进度感知是这个方案里非常容易被低估的一环。几千万行的导出可能要跑十几分钟,如果没有任何输出,你会完全不知道任务是在正常推进还是已经卡死。我习惯每个批次打印一次"已写行数"和当前时间,日志里能清楚地看到速率变化,网络抖动也能第一时间发现。
断点续传方面,我推荐"按文件分段"而不是"按行号续传"。具体做法是:在导出前把总行数估算出来,按 50 万行一段拆成多个目标文件,比如part_0000.txt、part_0001.txt。每段对应一段 offset 区间,哪一段失败了就只重跑那一段,最后用cat(Linux)或copy /b(Windows)合并。这个方案比记录 offset 更稳健,因为记录 offset 的脚本遇到网络重连、进程重启,状态容易丢失,而文件级别的任务天然是幂等的。
并发加速是可选项。Tunnel Session 允许多个连接同时读取不同的(offset, limit),你可以用多进程把表拆成 4 段,同时读。我实测在千兆内网下,单连接大概能跑到 40-60 MB/s,4 并发能翻到接近 200 MB/s。但注意并发数不要太大,Tunnel 服务端有连接数配额,一般 4 到 8 个并发是安全值。这里我只给方向,不展开写多进程代码,因为大部分场景单连接顺序读也够用,并发主要省时间。
5. 海量导出Excel的正确姿势
5.1 104万行的产品逻辑:多Sheet还是多文件
前面说过,Excel 单个 sheet 上限是 1,048,576 行。超过之后有两个选择:多 Sheet 或者多文件。
多 Sheet 方案适合数据量在 200-300 万行、用户希望"一个文件搞定"的场景。实现上,每写满 80-90 万行就切换到新的 sheet,给业务预留一些余量,避免 Excel 打开后因为接近上限而出现奇怪的格式问题。
多文件方案适合更大数据量,或者用户明确要求"按月份/按渠道拆分"的场景。好处是文件生成后可以并行交付、按需重跑;坏处是业务方需要手动合并,如果你的用户不擅长 Excel,合并这一步很容易出错。
我的经验是,如果只是临时取数,多 Sheet 最好用;如果是长期任务,且数据量经常会超过 500 万行,尽量把交付物改成 CSV 文件或者引导业务直接到 BI 系统里看。xlsx 文件在打开大文件时的体验,谁用谁知道,800 MB 的 xlsx 光是双击打开就要等半分钟。
5.2 用xlsxwriter写百万行级别的xlsx
下面是多 Sheet 导出的核心代码,重点看两个东西:constant_memory模式和 format 设置。
import datetime import xlsxwriter from odps import ODPS def export_table_to_xlsx_multi_sheet( table, output_path, sheet_max_rows=900000, columns=None, ): if columns is None: columns = [c.name for c in table.schema.columns] workbook = xlsxwriter.Workbook(output_path, {'constant_memory': True}) # 文本格式:手机号、订单号等长数字必须用文本格式,否则精度会丢 fmt_text = workbook.add_format({'num_format': '@'}) # 日期格式:直接写 datetime 对象,配合格式可以正常显示 fmt_datetime = workbook.add_format({'num_format': 'yyyy-mm-dd hh:mm:ss'}) sheet_idx = 0 row_idx = 1 # 0 行是表头 sheet = None session = table.tunnel.create_tunnel_session() try: offset = 0 batch = 50000 while True: with session.open_reader(offset=offset, limit=batch) as reader: batch_written = 0 for record in reader: if row_idx == 1 or row_idx % sheet_max_rows == 1: sheet_idx += 1 sheet = workbook.add_worksheet(f"part_{sheet_idx}") for c, col in enumerate(columns): sheet.write(0, c, col) row_idx = 1 for c, col in enumerate(columns): val = record[col] if val is None: sheet.write_blank(row_idx, c, None) elif isinstance(val, datetime.datetime): sheet.write_datetime(row_idx, c, val, fmt_datetime) elif isinstance(val, (int, float)) and len(str(val)) >= 12: # 长整型必须是文本,否则 Excel 会转成科学计数法并丢精度 sheet.write_string(row_idx, c, str(val), fmt_text) elif isinstance(val, bool): sheet.write_boolean(row_idx, c, val) else: sheet.write(row_idx, c, val) row_idx += 1 batch_written += 1 offset += batch print(f"已导出到 excel,当前记录行号 {offset}") if batch_written < batch: break finally: session.close() workbook.close() print(f"Excel 文件已生成:{output_path}")写百万行级别的 xlsx,最关键的constant_memory模式必须要开。这个模式下,xlsxwriter 会在内存里只保留当前行和前一行之间的数据,写完一行就立刻压缩写入文件,内存占用几乎不随行数增长。代价是某些功能不可用,比如不能在写入后再修改单元格格式、不能设置重复行高的优化等。对于纯导出场景,这些限制完全可以接受。
另外一个容易忽略的性能优化:尽量少创建格式对象。xlsxwriter 里每个 format 对象创建后,会在最终文件中占用一个格式索引,格式数量越多,文件越大,写入越慢。对于导出任务,只需要文本格式、日期格式、表头格式三四个就足够了,不需要为每个单元格单独设置格式。
5.3 手机号变科学计数、日期变数字:类型保护清单
导出 Excel 时,最难缠的不是数据量,而是数据类型在 Excel 里的表现。我整理了一个经常出现的坑和对策:
| 源数据类型 | Excel 中的表现 | 对策 |
|---|---|---|
| 手机号(17位以内) | 显示为科学计数法,末尾变 000 | 写成字符串,并设为文本格式 |
| 订单号/身份证(18-20位) | 精度丢失,后几位变成 0 | 写成字符串,并设为文本格式 |
| 日期时间 | 显示为一串数字(序列号) | 写 datetime 对象并指定日期格式 |
| Decimal 金额 | 有时显示为一长串小数 | 先 format 成两位小数字符串或保留 Decimal |
| 布尔值 | 显示为 TRUE/FALSE | 可直接写布尔值 |
长数字这个坑最隐蔽。如果你用 pandas 直接导出,pandas 会帮你推断类型,把一列 bigint 全当数字写进 Excel。Excel 内部用双精度浮点存储数字,超过 15 位的整数精度就会丢。很多业务表的订单号恰恰就是 18 位-20 位,所以导出后经常会看到订单号后几位全是 0,这种数据一旦用于核对,就是灾难。
我在代码里对int/float且字符串长度 >= 12 的情况统一写字符串,就是出于这个原因。如果你确定某些字段就是要数字格式参加计算,比如金额、数量,那就不应该走文本路线;但订单号、手机号这类"数字形态的文本"必须走文本路线。
5.4 打开速度和文件体积的平衡
xlsx 文件体积和打开速度是另一个容易被忽略的问题。100 万行 x 30 列,最终文件大小可能在 200-500 MB 之间,Excel 打开需要连续解析里面的 XML 文件,体验会很差。我一般的处理是:
- 控制单个 sheet 行数在 90 万以内,避免逼近 104.86 万上限。
- 列数不要全字段导出,只导出业务真正要用的列。导出 30 列和导出 10 列,文件大小差距巨大。
- 不要给每一行都设置行高和边框,这些样式会让 XML 文件膨胀数倍。
- 如果数据里有大段文本字段(比如备注、评论),建议截断到合理长度,或者单独放到另一个 sheet。一个超长文本字段会让单元格文件非常大。
6. 搬到DataWorks定时调度后暴露的另一些坑
6.1 环境缺库:xlsxwriter不存在的日子
本地写得顺风顺水,一上 DataWorks 定时调度,第一个问题就是环境依赖。
DataWorks 的 PyODPS 节点虽然内置了odps基础包,但xlsxwriter、openpyxl这类第三方库并不一定预装。解决办法要么在上传资源时把轮子包进去,要么干脆不在节点里写 Excel,而是先导出成 CSV 到对象存储,再用专门的数据处理任务把 CSV 转成 Excel。
我的观点是:大数据平台上的节点环境以稳定为第一优先级,不要为了生成一个 Excel 去折腾平台依赖。业务真正需要看数据时,CSV + 对象存储下载链接往往比一个巨型 xlsx 更实用。真的遇到必须交付 xlsx 的场景,我倾向于让调度只负责把数据落到对象存储,然后在本地或一台有 Python 环境的机器上跑转换脚本。
6.2 任务超时与内存限制
调度节点通常有运行时长上限和内存限制。一个导出 2000 万行的任务,单连接顺序读可能需要 30-60 分钟,这个时长在某些平台的默认超时设置下很容易被掐断。
我之前踩过这个坑:本地跑了 50 分钟的任务,搬到调度上跑到 35 分钟就报"instance is running timeout"被终止。后来改成分区并行——把一个月的数据拆成 30 个分区,每个分区对应一个调度任务,每个任务只导 1-2 个 GB,耗时控制在 10 分钟以内,就再没出过问题。
如果你的表是非分区表,那就用前面说的分片思路,把任务拆成多个 offset 段并行执行。调度系统天生适合这种"并行分片 + 失败重跑"的逻辑,本质上就是把一个长任务拆成多个短任务。
6.3 更好的玩法:文件落OSS再转Excel
最后分享一个我现在更常用的落地方式:不要让调度节点把文件写到节点本地,节点是容器环境,容器一销毁文件就没了。正确做法是,用 Tunnel 直接把数据写入对象存储服务里的临时文件,比如导成多个 CSV 分区文件,然后业务方通过下载链接获取。
对整个流程做个总结性的偏好排序:纯 txt 且量级在千万行以下,用本机脚本 + Tunnel Session 单连接顺序写;量大加速用多进程分片;数据要给非技术同事看,先用 Tunnel 落到对象存储,再用 xlsxwriter 或其他转换工具生成多 sheet 的 Excel。每一步之间用明确的文件命名和记录清单衔接,整个链条清晰可查。
最后说一个我自己的小习惯:每次导出,我都会在输出目录里顺带写一个meta.json,记录表名、分区、导出时间、字段列表、总行数。别小看这个文件,当别人拿着你导出的数据来问"这个字段是啥意思""这是什么时候导的"时,打开meta.json就能回答,不用再翻代码和日志。数据交付这件事,做到可追溯,才算真正闭环。