通用,高效,可控的海量数据导出组件-调研篇

245 阅读15分钟

前言

这篇文章我们设计一个数据导出组件,数据导出在后端开发中非常常见。在这里,我们主要讨化当一次性导出较多的数据,如:100W,200W时,应该注意什么问题,有什么解决方式,并且这些解决方式是否可以封装成一个通用的组件

数据导出不是一个新颖的话题,因此,网上有很多相关的文章,但感觉不是很全面,没有讨论完整的导出过程,也没有将逻辑进行抽象通用化作成一个组件,因此,打算写这篇文章。这些文章的内容主要是以下几个方面的讨论,我在调研和设计时也用来进行参考:

  1. 场景合理性讨论:有些人会质疑一次导出上百万数据是否存在这样的场景,如果存在,是否合理。就目前查阅的文章来看,这种场景主要存在于财务领域,比如说审计的时候,需要导出大量的数据进行备份
  2. 技术设计讨论:从后端的角度看导出过程,有哪些手段可以进行化。就目前查阅的文章来看,包括但不局限于:控制单次导出的数量;导出多份小文件进行合并;数据库查询时采用流式查询;对导出文件进行压缩;多线程读取数据,单线程写入文件;将导出请求放到队列中按先进行出处理等等
  3. 现有技术讨论:目前我们在导出数据到Excel文件时,常用的组件是Apache poi,Easy poi,Easy Excel。这些组件通过滑动窗口的思路解决导出数据较大时的内存占用问题。但这些组件只覆盖了导出过程的其中一个环节,不够完整

此文的技术栈是Java,MySQL

数据导出过程

我们开发这个组件是为了解决数据导出的后端部分所遇到的问题并抽象成通用逻辑。因此,我们得先知道完整的导出过程的细节是怎么样的,每个过程当中要注意的事项是什么

数据导出过程中,数据的流向可参见下图

  1. 前端发送导出请求给后端:一个web应用可能会有多个导出接口。例如:一个企业人中管理系统,人员管理页面可能有个导出人员基本信息的入口,对应后端请求路径 service/exportPersonService;员工出勤界面可能有个导出当月人员出勤信息的入口,对应的后端请求路径service/exportPersonChuQing。用户在页面点击导出按钮后,前端将这些导出请求都发送给后端,这些导出请求均被同一个web应用接收。前端在将请求发送给后端前,会做一些导出参数的校验,例如:某些值不能为空,某些值的范围进行限定等
  2. 应用服务器接收前端导出请求:一个web应用可能是集群部署,也可能是单节点部署。前者,导出请求会分散到不同的机器上执行;后者,导出请求会落到同一台机器上。考虑单台机器,机器的资源是有限的,处理导出请求的过程中需要占用一次的资源,因此,要防止同一时间处理多个导出请求,另外,要防暴击
  3. 应用服务器向数据库查询数据:应用服务器基于前端的请求参数生成查询数据库的SQL,将SQL发送给数据库获取要导出的相关数据,可能是单表查询,可能是联表查询等等。因为要导出的数据量可能很多,因此,要防止一次性读取过多数据到内存
  4. 应用服务器加工数据库返回的数据:数据库返回的数据可能并不完全满足导出的要求,比如:一个枚举字段,在数据库里分别用1和2代表男生,女生,而导出的文件里要求显示中文而非数字;有些数据在其它应用服务里,需要调用第三方接口查询。以上这些场景,应用服务器都需要在内存当中对数据库返回的数据进行二次加工,使加工后的数据符合导出的要求。这一些,需要考虑的是提高处理速度,让数据尽快处理完
  5. 应用服务器生成导出文件的内存对象:要导出的数据是写入文件当中的,需要用内存对象进行表示,因此,需要使用合理的内存对象,例如:对于Excel文件,在Apache poi的内存对象是Workbook,Excel中的工作表对应Sheet,工作表中的每一行对应Row,每行的每个单元格对应Cell,且Excel文件还分.xls和.xlsx两种类型的后缀,在内存对象的选择上也有一定的差异。同时,生成的这个对象不能太大,要防止内存溢出
  6. 应用服务器将导出文件的内存对象持久化:导出文件的内存对象需要持久化到当前机器的磁盘当中,需要注意的是,写入磁盘是IO操作,IO操作是非常耗时的,因此,需要考虑如何减少磁盘IO操作
  7. 应用服务器将文件上传至文件服务器:文件的存储一般使用专门的文件服务器,而不是存到应用服务器上,因此,前述步骤中当前机器硬盘中的文件需要上传到文件服务器中,并将本地的文件删掉,以节省空间。这个过程中,文件中的数据不是从机器的硬盘直接到文件服务器,而是先从机器的磁盘到内存,再由内存到文件服务器。因为要将文件读取到内存当中,而根据前面的描述我们知道,导出的数据很大,对应的文件也很大,因此,这一步也要防止内存溢出
  8. 用户获得文件下载路径并下载:文件上传到文件服务器后,文件服务器返回这个文件的下载链接,应用服务器在收到这个链接后,将链接返回给前端的用户,用户收到这个下载链接后就可以通过点击这个下载链接下载文件了,此时请求将直接发送给文件服务器,而不再经过应用服务器

现有思路整理

前面一节已经介绍了导出过程中每一步的细节及注意事项。这一些介绍下如何考虑和解决每一步的注意事项,内容都是基于网上查阅到的文章进行整理而得

    1. 前端发送导出请求给后端

  • 导出参数校验:因为我们做的组件是针对后端部分的,前端就不考虑了

    2. 应用服务器接收前端导出请求

  • 防暴击:从前端角度出发,可以将导出按钮置灰;从后端角度出发,可以使用Redis实现分布式锁,其中Redis的key可以为导出请求URL+导出参数。如果请求是暴击的,这类导出请求会被丢弃不处理
  • 防止同一时间处理多个导出请求:第一种方法是通过信号量控制同一台机器同一时刻可以处理的导出请求数量,注意:没有必要使用Redis,因为我们要控制的是单台机器。当机器处理的导出请求数量达到信号量设置的上限并且此时又收到一个导出请求时,就将导出请求放到一个队列中,队列的实现可以用Kafka,MQ,数据库等,机器按先进先出的原则处理导出队列中的请求;第二种方法是每次处理完导出请求时,记录请求发送的时间,导出请求的参数,导出请求的文件,并设置一个过期时间,在这个过期时间内,如果机器又接收到相同的导出请求,则将上一次得到的导出请求文件直接发给用户,不过这种方式存在风险,数据可能是不最新的,并且也不能完全解决问题,如果某个时间段收到的导出请求都不一样,则原来记录的信息就完全用不上
  • 两者区别说明:前者是指相同请求,此类请求不处理;后者是指不同请求,此类请求需要处理

    3. 应用服务器向数据库查询数据

  • 防止一次性读取过多数据到内存当中:在向数据库查询数据时,使用limit关键字进行分页查询,以限制每次从数据库中读取的数据;或者使用流式查询。流式查询和普通查询的最大差异是无需建立多次连接,仅在一个数据库连接里就能查询出所有数据。执行流式查询时,应用服务器会持有一个用于获取下一条查询数据的迭代器,每执行一行,MySQL服务器便返回一条数据给应用服务器,因此,应用服务器无需像普通查询一样,必须等到所有要查询的数据均返回后才能执行下一步操作,而是按需获取数据,如果内存中的数据太多,就先不获取下一条数据,而是处理完再获取下一条数据,在此期间,数据库的连接一直未断开,直至通过迭代码器获取完最后一条数据后,数据库连接才断开。两者的区别可见下图:(关于普通查询和流式查询的内容此处不详细展开,网上资料较多,但网上有些说法将游标查询和流式查询弄混,因此建议看MySQL的官方文档。如果有需要我再单独写一篇文章)

    4. 应用服务器加工数据库返回的数据

  • 提高处理速度:使用多线程加工数据,线程的数据取决于加工过程对内存的消耗和其它因素,如:第三方接口的限制。例:每条数据的加工需要调用一次第三方接口,而这个接口的QPS并不高,那线程数就要设置低一点,也可以将第三方接口替换成批量查询接口进一步优化

     5. 应用服务器生成导出文件的内存对象

  • 使用合理的内存对象:同样是导出excel文件,poi提供了三种包,HSSF,XSSF,SXSSF,第一种对应.xls后缀的.excel文件,后两种对应.xlsx后缀的excel文件,.xlsx在数据量的支持上要优于.xls。
  • 防止内存溢出:在SXSSF和XSSF中,SXSSF为了防止内存对象过大导致内存溢出,使用了滑动窗口进行优化,具体而言,是设置内存中最多存储的数据行数(指excel文件中的每行数据),当内存中的数据行数超过这个限制后,多出的数据就会先写到硬盘当中,并从内存移除

    6. 应用服务器将导出文件的内存对象持久化

  • 减少磁盘IO操作:在操作系统里,当文件写入磁盘时,会使用缓冲区,缓冲区的作用就是用于提高写入效率,减少对磁盘的IO操作(从原理上看,其实使用的就是滑动窗口的思路),具体而言,当程序向磁盘文件写入数据,这些数据首先被写入机器内存中的缓冲区,而不是立即写入磁盘,当缓冲区满了或者调用了特定的刷新操作,如:flush,数据才会被写入磁盘,这一步操作系统已经帮我们做了,我们也无需再做什么技术优化
  • 易混淆点:5与6均提到的滑动窗口,缓冲区原理是一样的,但指代的东西并不是一个东西。5的Apache poi设置的滑动窗口是在jvm内存里,是仅用于导出excel文件使用的。而6的缓冲区是在机器内存里,是通用的,所有写入磁盘文件的数据都会进缓冲区,即使是5中当数据超过滑动窗口的限制时,数据也是先进到6的缓冲区当中,才被写入磁盘,可结合下图理解:

                      

    7. 应用服务器将文件上传至文件服务器

  • 防止内存溢出:在java里可以使用InputStream或类似的流处理类来将磁盘中的文件读取到内存当中,流式读取不会一次性将整个文件加载到内存当中,而是将数据分成小块,因此可以防止内存溢出

    8. 用户获得文件下载路径并下载

  • 这一步没有注意事项,因此不讨论了

组件目标分析

从前面的分析可知,在整个导出过程中,最重要的事项就是

:机器资源。这是从技术人员的角度出发。但从用户的角度出来,用户关注的是导出的速度尽量快,即:导出性能。因此,这两个指标也是所设计组件所要考虑的东西。这一节,我们首先理清这两个指标的关系,得出组件对这两个指标的权衡考虑;然后,解释文章标题所提及的此处组件的设计关注的三个特点--通用,高效,可控的原因

  • 性能指标和资源指标间的关系

性能指标,具体而言,是指导出速度,导出速度越快越好,最好能做到同步导出;资源指标,具体而言,是指导出过程中对于机器可用资源的占用,只要占用的资源不超过可用资源即可

Q:为什么不是资源占用不是越少越好?

A:如果我们统计从导出开始到导出结束,整个导出过程对资源的占用,在相同的条件下,总的占用是相同的。当然,如果使用不同的技术可能会有所差异,比如:同样的是Excel中每行的数据在内存的表示,使用SXSSF包或者XSSF包可能实际占用的内存有所不同,但在这里,我们不考虑优化这种最底层的数据结构以减小内存,因为难度太大,也没什么优化的空间。而是只考虑占用的资源不超过可用的资源,进而导致机器崩溃就行

将性能指标和资源指标结合起来,即:在不用尽机器可用资源的前提下,尽量提高导出速度

  • 组件三个特点的含义

  • 通用

既然称为组件,肯定就是通用的,在这里我们就是要封装成jar包,供使用方调用。使用方无需关注导出过程中对资源控制的细节,只需关注业务逻辑,即:要导出什么数据,导出的数据要做什么加工逻辑,导出文件的命名等等

  • 高效

高效一方面是指对机器资源的利用率高,具体而言,是指同一时间段尽量将可用的资源均用来处理导出过程。另一方向,高效是指导出速度快。因为单位时间对机器占用的资源越高,表明单位时间内处理的数据越多,导出速度也越快。如下图,右侧的资源使用效率要优于左侧的资源使用效率

  • 可控

因为机器的可用的资源一直在变化的,在导出过程对机器资源的占用是可以控制的,当机器的可用资源较多,就让组件在处理导出过程中使用更多的资源;反之,当机器的可用资源较少,就让组件在处理导出过程中使用较少的资源

文章总结

我们要设计一个针对海量数据导出场景的组件,我们需要知道导出过程的每个步骤及注意事项,这些注意事项现有的技术是怎么解决的。综合这些信息,分析出导出过程中最重要的因素,作为所设计的组件关注的重点

在下一篇文章《通用,高效,可控的海量数据导出组件--设计篇》中,我将介绍这个导出组件的完整设计

文章内容修改记录

2024-11-03 第一次文章撰写