在SaaS系统中,用户点击“导出Excel”看起来只是一个按钮,后台实际上可能要完成权限验证、条件解析、数据库查询、数据快照、文件生成、对象存储、任务调度以及下载授权等一整套流程。如果数据只有几百条,点击按钮以后直接生成文件并返回浏览器没有太大问题;但如果一个租户需要导出数十万甚至数百万条订单记录,继续采用同步HTTP请求就容易出现PHP执行超时、数据库连接长期占用、内存耗尽以及Web服务器并发下降等问题。
因此,专业SaaS系统通常会把导出分成两个模式。小型导出可以同步完成,大型导出则创建后台任务。用户点击导出以后,Web请求只负责验证身份和权限、保存导出条件、创建任务记录,然后立即返回一个任务ID。真正的数据查询和Excel生成由后台Worker执行。任务完成以后,用户在页面看到“导出完成”,再下载已经生成的文件。
第一步是建立导出任务,而不是马上查询全部数据。任务表可以记录id、tenant_id、user_id、导出类型、查询条件、任务状态、进度、文件路径、错误信息、创建时间和完成时间等信息。例如状态可以设计为pending、running、completed、failed和expired。这样用户关闭浏览器以后,后台任务仍然可以继续执行,也方便管理员查询历史导出记录。
权限检查必须发生在任务创建阶段,同时最好在Worker真正执行任务时再次验证关键授权条件。因为导出任务可能等待几分钟甚至更长时间,用户权限可能在任务执行之前发生变化。不能因为创建任务时有权限,就假设整个生命周期内权限永远有效。
多租户SaaS尤其需要注意tenant_id。租户ID应该由经过认证的用户上下文确定,而不是直接相信浏览器提交的参数。查询数据库时,租户范围应该成为数据访问层的一部分。例如订单查询应当始终形成类似WHERE tenant_id = :tenant_id的约束,同时再叠加日期、状态、客户等业务条件。对于权限更加复杂的系统,还应该把用户能够访问的组织、部门或数据范围加入查询条件。
数据查询阶段不建议默认使用SELECT *。导出字段应该由服务端根据导出类型明确规定,例如订单导出只读取订单号、客户名称、订单金额、状态和创建时间。这样既减少数据库和PHP的数据处理量,也避免数据库后来增加了一个敏感字段以后,SELECT *意外把它一起导出。
小数据量可以采用普通分页,但大型导出不应该机械地使用LIMIT/OFFSET。例如第一页读取1000条,第二页使用OFFSET 1000,随着OFFSET越来越大,数据库可能需要扫描和跳过越来越多的数据。对于大批量顺序导出,更适合使用Keyset Pagination,例如按照递增主键读取:WHERE id > :last_id ORDER BY id LIMIT :batch_size。每处理完一个批次,就记录最后一条记录的ID,再继续下一批。
如果业务要求按照created_at排序,则可以使用稳定的复合排序键,例如ORDER BY created_at, id,并使用对应的范围条件。这样不仅适合大型数据集,也能够降低深分页带来的数据库负担。
导出过程中还需要考虑数据一致性,但这并不意味着应该从第一条数据开始就开启一个持续几十分钟的数据库事务。长时间保持事务会增加MVCC历史版本、锁和数据库资源压力,在某些情况下甚至影响正常业务。更常见的做法是根据业务要求确定一致性策略:可以在任务创建时记录一个截止时间,例如只导出任务创建时已经存在的数据;也可以使用数据库提供的合适快照机制;对于不要求严格时间点一致的报表,则允许任务执行过程中读取最新数据。
真正的大型导出应该采用流式处理。程序每次从数据库读取一个有限批次,将这一批数据写入文件,然后释放内存,再处理下一批。不能先执行一个几百万行的查询,再通过fetchAll()全部加载到PHP内存中。否则数据库虽然能够返回结果,PHP-FPM进程却可能因为内存限制直接终止。
Excel文件生成也需要选择合适的库和方式。PHPExcel已经是较老的项目,不应该作为现代PHP SaaS系统的新项目默认方案。对于XLSX导出,应选择仍在维护的库,并根据数据量采用能够降低内存占用的写入方式。如果导出的数据只是二维表格,而且用户不要求复杂的Excel公式、样式和多个工作表,那么CSV有时反而更加适合大型数据导出,因为文件结构简单、生成速度快、内存占用低。
XLSX本身实际上是一个ZIP封装的XML文件集合,因此生成大型XLSX并不是简单地“把数组转换成Excel”。如果需要百万级数据、复杂格式、多个Sheet、公式或者自动筛选等功能,就应该评估具体Excel库的内存模型和写入模式,而不能仅仅依赖$writer->save('php://output')就认为已经实现了真正的流式大型导出。
大型文件通常也不应该让PHP请求一直保持HTTP连接直到文件生成完成。更合理的架构是Worker生成文件以后,把文件保存到对象存储或专用文件存储中,例如生成一个具有随机名称的内部对象键,然后在数据库中记录文件位置。用户下载时,Web应用只需要检查用户是否有权限访问该导出任务,再生成一个具有短时有效期的下载地址,或者通过下载接口进行权限验证后输出文件。
这里还涉及一个容易被忽略的安全问题:Excel文件本身就是敏感数据载体。单纯使用AES加密数据库字段,并不能解决“导出的Excel被下载以后泄露”的问题。真正需要保护的是整个导出生命周期,包括谁能够创建导出、能够导出哪些字段、文件保存在哪里、下载地址是否可以被猜测、下载链接是否长期有效、文件多久自动删除以及后台日志是否记录了敏感数据。
因此,导出文件名称不应该使用简单的export.xlsx。可以采用随机对象Key,并通过数据库任务ID建立映射。下载权限应该在服务端验证,而不是因为用户知道一个文件路径就可以直接下载。对于高敏感数据,还可以考虑短期签名URL、下载次数限制、审计日志以及文件自动过期删除。
Excel导出还存在一个特殊的安全问题,即公式注入。假如数据库中的客户名称、产品名称或其他文本字段可能以=、+、-或@等字符开头,某些Excel环境可能将其解释为公式。如果这些数据来自用户输入,导出程序应该根据业务需要进行Excel公式注入防护,而不是简单地把数据库字符串原样写入工作表。
后台任务则需要具备失败重试能力。数据库暂时不可用、对象存储网络异常、Worker重启等都属于可能发生的瞬时故障。任务系统应该区分可重试错误和不可重试错误,并记录重试次数。重试不能无限循环,通常需要设置最大次数,并采用指数退避和随机抖动,避免大量任务在同一时间反复冲击数据库。
大型SaaS还应该避免多个用户同时重复生成完全相同的导出文件。例如同一个租户在几分钟内连续点击五次“导出”,系统没有必要同时启动五个大型查询。可以通过导出参数哈希、任务去重或者幂等键判断是否已经存在相同任务。对于允许复用的报表,还可以直接返回最近一次尚未过期的结果。
性能监控同样不能只看“导出成功还是失败”。生产环境至少应该记录任务数量、每批处理耗时、总处理行数、数据库查询时间、文件生成时间、文件大小、失败次数、重试次数以及Worker执行时间。如果某次导出突然从两分钟变成二十分钟,通过这些指标才能判断究竟是数据库查询变慢、对象存储变慢,还是Excel生成阶段出现了性能问题。
对于超大数据集,还应该重新考虑“Excel是不是正确的输出格式”。Excel工作表本身存在行数限制,现代.xlsx单个工作表最多约1048576行。如果业务可能产生数百万甚至上千万条记录,继续强行生成一个Excel文件并不一定合理。可以拆分多个Sheet或多个文件,也可以提供CSV、Parquet等更适合大数据处理的格式,让用户根据实际用途选择。
一个比较成熟的SaaS导出架构可以概括为:用户提交导出请求,服务端进行身份和权限验证,创建导出任务;队列把任务交给Worker;Worker按照租户和业务条件查询数据,采用Keyset Pagination分批读取;每批数据进入流式文件生成器;文件完成以后保存到对象存储;数据库更新任务状态和文件元数据;用户获得经过权限验证的短期下载地址;任务和文件超过保留时间以后自动清理。
这种设计还有一个重要优势,就是Web服务器和数据库不会因为一次大型导出而被一个用户长期占用。用户看到的是一个简单的“正在生成”状态,后台实际上则是一个独立的异步计算任务。对于小型导出可以保留同步模式,对于大型导出自动切换到队列模式,从而在用户体验和系统资源之间取得合理平衡。
SaaS系统的Excel导出真正难的地方,从来不是调用一个Excel库把数据写进单元格,而是如何控制整个数据生命周期。从租户隔离、权限判断,到数据库批量读取、快照策略、流式写入、异步任务、文件存储、下载授权和自动清理,每一层都可能成为性能或安全问题。把导出设计成一个独立的后台任务,而不是一个“点击按钮以后PHP脚本一直跑”的HTTP请求,才是大型SaaS系统更可靠的工程实现方式。
用户点击导出以后,SaaS 系统怎样在后台生成 Excel 文件
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP