☰
Django大数据导出Excel/CSV:从HttpResponse到StreamingHttpResponse的完整方案
2026/9/30 7:31:46 网站建设 项目流程

简介:这是一份面向Django开发者的实用资料,讲解在项目中导出数据到Excel并实现浏览器下载的完整方案。内容基于xlwt库,从后端视图编写到前端Ajax交互均有覆盖,并揭示用HttpResponse直接返回大文件可能引发的MemoryError与nginx超时问题,进而介绍StreamingHttpResponse的流式传输优化思路。资源包为单个PDF文档,整体大小77KB,便于快速阅读与按代码实践。文档中包含后台view.py示例、Excel表头与数据写入逻辑、前端XMLHttpRequest触发下载的完整代码,以及针对百万、千万级数据量下载的排错与优化方法,适合需要为管理后台增加导出功能或希望提升下载性能的读者参考。目前已有1498人学习下载,值得作为Django文件导出需求的速查材料。

1. 给 Django 配上 Excel 导出下载:一份能直接抄的落地方案

只要做后台管理系统,几乎逃不掉“把列表数据导成 Excel”这个需求。这篇文章要解决的是从 Django 后端生成 .xls 文件、通过浏览器触发下载,到前端拿到 Blob 落盘的完整链路,并且会单独讲清楚百万元素级数据导出时的内存与超时问题。适合刚接触 Django 的初学者照着一步步搭通,也适合已经写好普通导出、但一跑大数据就 MemoryError 的熟手直接把方案升级过去。这里不会只贴代码,还会把每个参数和每个坑位都标出来,毕竟这类功能写起来不难,翻车的点全藏在细节里。

2. 基础版后端:用 xlwt 生成工作簿并用 HttpResponse 返回

2.1 先装依赖:xlwt 的适用边界与安装方式

Excel 导出的 Python 库有很多,常见的是 xlwt、xlrd、openpyxl、pandas 这几类。这里用的 xlwt 专门负责生成 .xls 格式的旧版 Excel 文件,不支持 .xlsx,也不支持读取,它的定位很纯粹:写老格式够用、轻量、无额外依赖。如果你的表结构里有公式、图表、数据透视表这类高级对象,xlwt 做不了,需要换 openpyxl;但普通的地名、次数、经纬度这类数据列表,xlwt 完全够用。

安装只有一条命令,建议先在虚拟环境里执行:

pip install xlwt

装完可以在 Django 的 views.py 里import xlwt验证一下,没有报错就说明环境就绪了。这里要特别提醒一点:xlwt 的单元格写入方法write()是零索引的,行和列都从 0 开始数,新手写表头时容易在坐标上搞混,后面代码里会反复看到这个规律。

2.2 视图函数:从 POST 参数到 Excel 文件流的完整链路

先看后端代码,这是整个导出功能的地基。下面的导出函数接收前端传过来的城市名,按城市筛选数据库里的 place 表,然后把这个结果集写入 Excel 工作簿返回给浏览器:

from django.http import HttpResponse from io import BytesIO import xlwt def export_excel(request): city = request.POST.get('city') list_obj = place.objects.filter(city=city) # 响应类型声明为 Excel 文件,浏览器收到后会按附件处理 response = HttpResponse(content_type='application/vnd.ms-excel') response['Content-Disposition'] = 'attachment;filename=' + city + '.xls' if list_obj: ws = xlwt.Workbook(encoding='utf-8') w = ws.add_sheet('sheet1') # 写入表头,四个字段分别对应地名、次数、经度、纬度 w.write(0, 0, u'地名') w.write(0, 1, u'次数') w.write(0, 2, u'经度') w.write(0, 3, u'纬度') excel_row = 1 for obj in list_obj: name = obj.place sum_data = obj.sum lng = obj.lng lat = obj.lat w.write(excel_row, 0, name) w.write(excel_row, 1, sum_data) w.write(excel_row, 2, lng) w.write(excel_row, 3, lat) excel_row += 1 # 工作簿保存到内存缓冲区,再写入响应对象 output = BytesIO() ws.save(output) output.seek(0) response.write(output.getvalue()) return response

这里的逻辑分四层:第一层取前端参数并过滤数据;第二层声明 HTTP 响应类型为老版 Excel 的 MIME 类型;第三层建工作簿、加 sheet、写表头、遍历 ORM 结果集写数据行;第四层把工作簿保存到 BytesIO,再通过response.write()输出。注意output.seek(0)这行代码,BytesIO 的游标在save()之后停在末尾,不重新定位到开头,getvalue()返回的可能不是完整文件流,这个动作不能省。

Content-Disposition的attachment;filename=是最关键的两个头参数之一,attachment告诉浏览器这是下载附件而不是内联展示。文件名拼的是city变量,如果 city 是中文,这里会踩坑,后面避坑章节专门讲。

2.3 小数据量场景的选型理由:什么时候可以放心用 HttpResponse

上面的方案在数据量不超过几千到几万行的场景下非常稳。HttpResponse的工作方式是视图函数一次性把完整的内容拼装好再返回,数据小的时候响应快、代码简单,完全没必要上流式方案。很多教程一上来就推StreamingHttpResponse,实际是过度设计——你导出的可能只是一个月的订单列表,几千行数据,用流式反而让代码复杂度上升。

什么时候该换方案呢?我一般以 5 万行为分界线。超过这个量,HttpResponse的内存峰值就不太好看了,如果服务器是 1G 内存的小机器,20 万行基本就是极限,这在后面的百万级章节会展开。

3. 前端下载链路:XHR 拿 Blob 再触发浏览器保存

3.1 用 XMLHttpRequest 发 POST:请求头、CSRF、响应类型

前端这件事听起来简单,点按钮、发请求、浏览器下载,但直接用window.location或<a>标签跳转是不行的,因为后端接收的是 POST 参数。常规做法用XMLHttpRequest构造异步请求,把响应体按 Blob 处理。

$("#export_excel").click(function () { var csrf = $('input[name="csrfmiddlewaretoken"]').val(); const req = new XMLHttpRequest(); req.open('POST', '/export_excel/', true); req.responseType = 'blob'; req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); req.send('city=' + $('#city').val() + '&&csrfmiddlewaretoken=' + csrf); req.onload = function() { const data = req.response; const blobUrl = window.URL.createObjectURL(data); download(blobUrl); }; });

这段代码有两个关键设置。第一是req.responseType = 'blob',没有这行,响应体拿到的是一个字符串,你把它塞进<a>标签后可能变成乱码或打不开的文件。第二是 CSRF token 的处理:Django 默认开启 CSRF 校验,POST 请求必须带 token。这里选择把 token 拼进请求体,Content-Type是application/x-www-form-urlencoded,所以格式是 key-value 拼接。注意原代码里用了两个&,这是笔误,标准写法是单个&,传多个参数时别写错。

3.2 触发下载:临时<a>标签与download属性的配合

拿到 Blob 后不能直接a.click()完事,要先创建一个对象 URL,然后用隐藏的<a>元素触发点击:

function download(blobUrl) { var city = $("input[name='city']").val(); const a = document.createElement('a'); a.style.display = 'none'; a.download = city + '.xls'; a.href = blobUrl; a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(blobUrl); }

window.URL.createObjectURL会为 Blob 生成一个内存中的临时地址,a.download属性必须显式指定文件名,否则浏览器可能用 URL 里的字符串当名字。revokeObjectURL这行很重要,临时 URL 不释放会积累内存。另外创建出来的<a>元素要removeChild清理掉,不然页面上残留不可见节点,反复点击导出后 DOM 会越来越脏。

这里还有一个很容易忽略的细节:a.download里的文件名后缀应该和后端返回的文件名一致,后端拼的是city + '.xls',前端也应该对应。如果前端不指定、后端 Content-Disposition 里的文件名中文乱码了,用户下载下来的文件就是一串不知所云的字符。

4. 升级百万级下载:从 HttpResponse 切到 StreamingHttpResponse 加流式游标

4.1 为什么 20 万行就崩了:fetchall 的隐形成本

前面的方案能跑,但撑不住大数据量。问题出在两个地方:一是HttpResponse本身一次性把整个文件内容放在内存里返回;二是 ORM 的filter().objects查询结果也是整体加载到内存再遍历。如果你的查询结果是 15 万行、每行有几十个字段,内存占用轻松上 500M。我实测过一台 2G 内存的云服务器,导出 20 万行时 Django 进程直接被系统杀掉。

FileResponse和StreamingHttpResponse都是 Django 内置的流式响应方案。FileResponse适合返回已经存在的静态文件,它内部用迭代器分块读文件;StreamingHttpResponse适合动态生成内容,你给它一个生成器,它边生成边返给客户端。数据库内容实时导出显然属于后者,所以切入点选StreamingHttpResponse。

4.2 流式游标替代普通游标:SSDictCursor 与 fetchone 的配合

用StreamingHttpResponse只能解决响应侧的内存问题,数据侧的瓶颈还在。你如果用cursor.fetchall()一次性把所有行取到 Python 进程里,生成器再有耐心也没用。这里要用 PyMySQL 的流式游标SSDictCursor,它不会把结果集一次性拉到客户端,而是每次fetchone()时才从 MySQL 服务器取一行:

import pymysql from django.http import StreamingHttpResponse def export_big_excel(request): city = request.POST.get('city') conn = pymysql.connect( host='127.0.0.1', port=3306, database='demo', user='root', password='root', cursorclass=pymysql.cursors.SSDictCursor ) cursor = conn.cursor() sql = "SELECT place, sum, lng, lat FROM place WHERE city=%s" cursor.execute(sql, (city,)) response = StreamingHttpResponse(generate_csv(cursor)) response['Content-Type'] = 'application/octet-stream' response['Content-Disposition'] = 'attachment;filename="places_' + city + '.csv"' return response def generate_csv(cursor): cols = ['place', 'sum', 'lng', 'lat'] yield ','.join(cols) + "\n" row = cursor.fetchone() while row is not None: yield ','.join(str(row[col]) for col in cols) + "\n" row = cursor.fetchone() cursor.close() conn.close()

这个版本的要点是while row is not None这个循环模式。fetchone()每次只从数据库服务器取一行,行取完后返回None,循环结束。注意游标和连接对象的关闭时机:必须在生成器内部关闭,因为 Django 拿到响应件后才会迭代生成器,你放在视图函数里return之前关闭,生成器还会继续用,直接抛异常。这是个很典型的翻车点。

4.3 为什么输出改成了 CSV:大数据导出别执着于 .xls

细心的话会发现上面的代码返回的是 CSV 而不是 .xls。这里有个非常现实的原因:xlwt 是内存型库,工作簿的所有数据都要在内存里组织好才能save()输出,数据量到几十万行时,就算不炸也会慢到让人怀疑服务器死机。而 CSV 是纯文本流格式,可以一行一行的yield,天生适配StreamingHttpResponse的迭代模型。

如果你的业务方一定要 .xls 或 .xlsx,有两条路:一条是让运维装个 LibreOffice,后端生成 CSV 后调用libreoffice --headless --convert-to xls做离线转换;另一条是后端用openpyxl的write_only模式,它能边写边刷磁盘,内存占用比 xlwt 低一个数量级。但这两条路都有额外成本,我通常的做法是先问业务方要的是数据还是格式——只要数据能打开,CSV 的兼容性其实更好,Excel 双击 CSV 也不会乱码,只是中文列名要注意编码。

5. 避坑指南:六个真实翻车现场与排查方法

5.1 CSV 中文乱码

现象:导出的 CSV 用 Excel 打开,中文全变成乱码。

原因:Python 默认用 UTF-8 编码文件内容,Excel 老版本默认按 GBK 解析 CSV。

解决:在 CSV 内容头部写入 UTF-8 BOM。在生成器里输出\ufeff前缀即可,yield '\ufeff' + ','.join(cols) + "\n"。BOM 是字节序标记,Excel 看到它就会用 UTF-8 解码。

5.2 Content-Disposition 文件名中文变下划线

现象:浏览器下载时文件名变成_____或一串编码。

原因:HTTP 头默认只支持 ASCII,中文文件名直接拼进去会被浏览器丢弃或替换。

解决:用 RFC 5987 标准格式传文件名,response['Content-Disposition'] = "attachment;filename*=UTF-8''" + quote(city + '.xls'),Python 的urllib.parse.quote会把中文做百分号编码,浏览器能正常还原。

5.3 MemoryError

现象:导出超过 20 万行时报 MemoryError,或者 Django 进程被杀,页面直接 502。

原因:HttpResponse整体拼装、ORM 结果集整体加载、fetchall()全部取回三个环节叠加,内存峰值无法控制。

解决:按第 4 章方案切换StreamingHttpResponse+SSDictCursor+fetchone()。注意 ORM 的.iterator()也有类似效果,可以在部分场景替换。

5.4 nginx 504 Gateway Timeout

现象:大数据导出时,请求处理超过 nginx 默认超时时间,浏览器收到 504。

原因:nginx 默认proxy_read_timeout是 60 秒,导出高清数据时等不到完就被掐断。

解决:先确认加了流式方案,然后调长超时时间。在 nginx 站点配置的 location 里加proxy_read_timeout 300s;和proxy_send_timeout 300s;,改完nginx -s reload。这只是缓解,核心还是在代码层面减少单次请求处理耗时。

5.5 Blob 下载出的文件打不开

现象:下载成功但文件损坏,Excel 提示格式不对。

原因:前端responseType没设置成blob,或者后端返回了错误信息(比如 CSRF 失败返回 403 页面),前端把 HTML 错误页当成 Excel 落盘。

解决:后端返回前打印response.status_code确认是 200;前端在onload里加状态判断,req.status === 200才执行下载逻辑。另外把Content-Type设置正确,错误页的 HTML 和 Excel 二进制流的 MIME 类型完全不同,可以很快区分。

5.6 多次导出后浏览器越来越卡

现象:页面连续导出几次后,内存占用飙升,甚至浏览器崩溃。

原因:createObjectURL生成的临时 URL 没有及时释放。

解决:在a.click()后立即调用window.URL.revokeObjectURL(blobUrl)。这是前端最容易漏的一步,代码见 3.2 节。

6. 从 15 万行到千万行:我把这套流程压进生产环境的实测心得

这套方案我最早是在一个地理信息平台的数据导出功能里落地。当时有个页面要导出一个城市两个月内的 POI 点位,数据量大致在 300 万行左右,表里有地名、经纬度、分类、来源、更新时间等十几个字段。一开始用的HttpResponse加xlwt,测试环境导出 1 万行没问题,我就高估了实际环境。上线首日,运营点了一次全量导出,Django 进程直接 C 掉,nginx 那边报了 504。那是半夜两点,我被电话叫起来查日志,才认真把第 4 章的方案完整改造完。

那次血的教训让我总结了一个验证习惯:写完全新的导出代码,第一件事不是看功能通没通,而是拿生产环境的最大数据量压一轮——开终端跑time curl测总耗时时长,同时用psutil或系统top盯 Django 进程的内存峰值。正常情况,百万行导出时进程常驻内存的浮动应该控制在 20M 以内,如果内存曲线随数据量线性上升,那说明代码里还有哪里在偷偷攒全量数据。我后来每次给导出功能加字段都要检查一遍:ORM 查询是否用了.values()而不是整对象加载,字段列表是否有多余的关联关系被select_related拉进来。

另外一个我说过无数次但每次都有人翻车的地方是游标关闭。写完while fetchone循环后,别忘了生成器最后要cursor.close()和conn.close()。这个操作不会立刻归还 MySQL 连接,但能释放服务器端游标持有的结果集,不然数据库那边每个导出请求都会挂着一个开着的结果集,连接池很快被耗尽。

从那以后,我每次做 Django 的数据导出需求,都强制自己走一遍这三步:先量数据量级,5000 行以内用HttpResponse方案图省事;5 万行以上直接上StreamingHttpResponse;百万级再叠加 SSDictCursor。前端一律用XMLHttpRequest + Blob,下载完立刻revokeObjectURL。这套流程已经稳定跑过好几个项目,希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询