URL
date
AI summary
slug
status
tags
summary
type

背景

近期产品提出了一个要支持内嵌图片的导入需求:创建教师需要上传身份证正反面照片,所以通过excel批量导入教师时也需要支持从excel里读取出对应图片。流程大致为:
  1. 把excel里的图片解析出来并上传到oss
  1. 导入记录并把图片列替换为对应的oss链接
具体的开发同学也实现了相应的需求,但是在生产环境运行的过程中,由于打开的文件描述符数量过高产生了告警。我们先铺垫一些关于内嵌图片的基础知识,再来分析出现问题的原因。

调研

经过简单的调研和实验,我们发现原生excel应该是不支持在单元格里内嵌图片的,它的图片都是浮动的,和单元格本身并没有什么关系,可以理解成两个图层,拿到对应的图片你都很难对应上是哪一行,可能只能通过计算坐标来判断。但是咱们的国货之光WPS竟然支持内嵌图片。而产品也是用的WPS,才提出的这个需求。
那这个需求我们就只需要考虑WPS内嵌图片即可。我们观察WPS内嵌图片的单元格,发现它其实是通过一个函数来实现的:
=DISPIMG("ID_9FCD4F7C629341C59434794446D5C01E",1)
而同样的文件在MicroSoft Excel里打开,内嵌图片列无法正常显示,取而代之的是一串字符串:
=@_xlfn.DISPIMG("ID_49C5BB73DFE221E0BE39AA65A9284EAA",1)
似乎这个函数是WPS自定义的,并且通过第一个参数关联到了对应图片。
我们知道,xlsx本质上其实是个zip压缩包,我们把含有WPS内嵌图片的excel的后缀重命名为zip,然后解压出来看看里面的东西。当然你也可以直接使用vim命令观察。
notion image
截图中标红的三处就是内嵌图片实现的关键:

xl/media目录

media目录下包含了所有的内嵌图片
❯ ls media image1.jpeg image2.png

xl/_rels/cellimages.xml.rels

xl/_rels/cellimages.xml.rels里记录了图片地址和RelationshipId的关系。
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <Relationships xmlns="<http://schemas.openxmlformats.org/package/2006/relationships>"> <Relationship Id="rId2" Type="<http://schemas.openxmlformats.org/officeDocument/2006/relationships/image>" Target="media/image2.png"/> <Relationship Id="rId1" Type="<http://schemas.openxmlformats.org/officeDocument/2006/relationships/image>" Target="media/image1.jpeg"/> </Relationships>

xl/cellimages.xml

xl/cellimages.xml里记录了RelationshipId和PicId之间的关系
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <etc:cellImages xmlns:xdr="<http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing>" xmlns:r="<http://schemas.openxmlformats.org/officeDocument/2006/relationships>" xmlns:a="<http://schemas.openxmlformats.org/drawingml/2006/main>" xmlns:etc="<http://www.wps.cn/officeDocument/2017/etCustomData>"> <etc:cellImage> <xdr:pic> <xdr:nvPicPr> <xdr:cNvPr id="1065" name="ID_2C8199E26F7F4A378C8ADCDC3A7CE98D"/> <xdr:cNvPicPr> <a:picLocks noChangeAspect="1"/> </xdr:cNvPicPr> </xdr:nvPicPr> <xdr:blipFill> <a:blip r:embed="rId1"/> <a:stretch> <a:fillRect/> </a:stretch> </xdr:blipFill> <xdr:spPr> <a:xfrm> <a:off x="9001760" y="12969240"/> <a:ext cx="592455" cy="424180"/> </a:xfrm> <a:prstGeom prst="rect"> <a:avLst/> </a:prstGeom> </xdr:spPr> </xdr:pic> </etc:cellImage> <etc:cellImage> <xdr:pic> <xdr:nvPicPr> <xdr:cNvPr id="1066" name="ID_2E5F1DAAEB624DF8A66A50314FD2493C"/> <xdr:cNvPicPr> <a:picLocks noChangeAspect="1"/> </xdr:cNvPicPr> </xdr:nvPicPr> <xdr:blipFill> <a:blip r:embed="rId2"/> <a:stretch> <a:fillRect/> </a:stretch> </xdr:blipFill> <xdr:spPr> <a:xfrm> <a:off x="9154160" y="3956050"/> <a:ext cx="539750" cy="719455"/> </a:xfrm> <a:prstGeom prst="rect"> <a:avLst/> </a:prstGeom> </xdr:spPr> </xdr:pic> </etc:cellImage> </etc:cellImages>
PicId就是单元格里内嵌图片自定义函数的参数,通过PicId找到对应的图片地址(PicId -> RelationshipId -> 图片地址)
调研过程参考了这篇文章,并且也参考了大量的代码,然而原文中的代码对于资源释放存在问题,这也是导致文件描述符过高的原因。

问题分析

既然是文件描述符数量过高,那我们查看一下对应进程打开的文件描述符先,发现确实有不少未关闭的文件描述符。里面多数都是xlsx格式的文件,并且每个文件都被打开了多次,但是被打开的次数不完全一样。另外对应的文件都已经被deleted了。看起来就是虽然删除了文件,但是有引用没有释放。我们以其中一个xlsx文件为例看一下:
[root@prod-app0010 ~]# ll /proc/14301/fd | grep xlsx | grep temp1965231543966580944 | wc -l 38 [root@prod-app0010 ~]# ll /proc/14301/fd | grep xlsx | grep temp1965231543966580944 lr-x------ 1 root root 64 May 28 08:18 12 -> /tmp/temp1965231543966580944.xlsx (deleted) lr-x------ 1 root root 64 May 28 15:51 198 -> /tmp/temp1965231543966580944.xlsx (deleted) lr-x------ 1 root root 64 May 28 15:51 200 -> /tmp/temp1965231543966580944.xlsx (deleted) lr-x------ 1 root root 64 May 28 13:16 230 -> /tmp/temp1965231543966580944.xlsx (deleted) lr-x------ 1 root root 64 May 28 15:51 281 -> /tmp/temp1965231543966580944.xlsx (deleted) lr-x------ 1 root root 64 May 28 15:51 282 -> /tmp/temp1965231543966580944.xlsx (deleted) ...省略
而这个导入任务的执行过程中,由于导入的文件是在OSS上的,我们会先把OSS上的文件下载到本地,生成临时的xlsx文件。从文件名以及文件描述符的创建时间都对得上:
private File createTempFile(InputStream inputStream) { // 文件根目录 File tempFile = null; try { tempFile = File.createTempFile("temp", ".xlsx"); } catch (IOException e) { throw new BusinessException("创建临时文件io异常:" + e); } }
不过有2个文件描述符有点奇怪,创建时间分别是08:18和13:16,而其余都是在15:51,暂时还不清楚原因,不过这不是本篇文章的重点。
于是我去review了一下代码,初步找到了下面这段问题代码:
private InputStream openFile(String filePath) { try { // 文件根目录 File file = new File(rootPath); ZipFile zipFile = new ZipFile(file); ZipInputStream zipInputStream = new ZipInputStream(Files.newInputStream(file.toPath())); ZipEntry nextEntry = null; while ((nextEntry = zipInputStream.getNextEntry()) != null) { String name = nextEntry.getName(); if (name.equalsIgnoreCase(filePath)) { return zipFile.getInputStream(nextEntry); } } } catch (IOException e) { log.error("IO Exception occurred", e); } catch (Exception e) { log.error("Unexpected Exception occurred", e); } return null; }
这里存在两个问题:
  1. 资源泄露:对于ZipFile和ZipInputStream都没有关闭,造成了资源泄露
  1. 重复打开文件:ZipFile和ZipInputStream都是通过同一个文件创建的,这是不必要的。ZipFile本身就可以用来获取ZipEntry的输入流,因此不需要同时使用ZipInputStream。
这也是造成此次文件描述符数量过高的原因,修复方案就不过多赘述了。因为Review过程中我还发现了一些其他的问题,所以这里并不打算基于原来的代码进行bugfix,准备重构一版本来彻底解决这些问题。

重构

除了上面描述的两个问题之外,原来的代码还存在如下几个问题:
  1. 我们团队解析Excel都使用EasyExcel,而这个需求的代码多数是直接复制过来的,所以也用了原文里的原生POI包解析的Excel,一是代码不够简洁可读性不低,二也不符合我们的规范。而了解了背后的原理之后,使用EasyExcel来处理内嵌图片也非常easy
  1. 尽量减少打开Zip包的次数,而不是每获取一个文件,都需要打开一次Zip包,并且尽量可以一次性获取更多的资源
  1. 尽量精简代码,增加可读性
重构之后的逻辑:
  1. 新增@WpsImage注解,用于在实体类上标注内嵌图片列
  1. 读取目标Excel里的两个xml文件,构造出id->图片地址的映射
  1. 使用EasyExcel读取目标Excel列
  1. @WpsImage注解的列上读取内嵌图片的文本信息(也就是=@_xlfn.DISPIMG("ID_49C5BB73DFE221E0BE39AA65A9284EAA",1)
  1. 解析出图片id,再拿到图片的具体地址
  1. 把图片上传到OSS,拿到URL
  1. @WpsImage注解的列替换成URL
  1. 进行后续的导入流程
重构后的代码如下,基类是我们原本对基于Excel导入的封装,这个子类只是前置对于读取到的数据做了预处理,然后把处理后的结果交给原来的基类处理即可。
public abstract class ExcelImageImporter<T extends BaseResultHeader, IDENTITY> extends ExcelImporter<T, IDENTITY> { protected File excelFile; protected File taskDir; public static final Integer MAX_FILE_SIZE = 20 * 1024 * 1024; public ExcelImageImporter(ImporterTask importerTask, Class<T> headerClazz) { this(importerTask, headerClazz, null); } public ExcelImageImporter(ImporterTask importerTask, Class<T> headerClazz, Integer headerRowNum) { this(importerTask, 500, headerClazz, headerRowNum); } public ExcelImageImporter(ImporterTask importerTask, int batchSize, Class<T> headerClazz, Integer headerRowNum) { super(importerTask, batchSize, headerClazz, headerRowNum); this.taskDir = new File(System.getProperty("export.file.path"), String.valueOf(importerTask.getId())); this.taskDir.mkdir(); } /** * 截取函数中图片的ID */ private static String subImageId(String imageId) { return imageId.substring(imageId.indexOf("ID_"), imageId.lastIndexOf("\\"")); } @Override protected void doImport(List<T> dataList) { // 获取具体图片 // excelFile总共打开两次,这里是第一次,为了获取图片和单元格的对应关系 Map<String, String> name2PicPath = getName2PicPath(); Set<String> pathSet = new HashSet<>(name2PicPath.values()); Map<String, String> picPath2Name = name2PicPath.entrySet().stream().collect(Collectors.toMap(key -> key.getValue(), v -> v.getKey())); ReflectionUtils.doWithFields(dataList.get(0).getClass(), field -> { WpsImg annotation = field.getAnnotation(WpsImg.class); if (annotation != null) { ReflectionUtils.makeAccessible(field); // 提取图片列 Set<String> imgNameSet = dataList.stream().map(data -> { String imgName = (String) ReflectionUtils.getField(field, data); return subImageId(imgName); }).collect(Collectors.toSet()); // excelFile总共打开N+1次,N是内嵌图片列的数量 try (ZipFile zipFile = new ZipFile(excelFile)) { Map<String, String> name2Url = new HashMap<>(); // 上传图片 ZipUtil.read(zipFile, zipEntry -> { String imgPath = zipEntry.getName().replace("xl/", ""); if (pathSet.contains(imgPath)) { String name = picPath2Name.get(imgPath); // 只上传必要的图片 if (imgNameSet.contains(name)) { try (InputStream inputStream = zipFile.getInputStream(zipEntry)) { // 确认一下这里的上传bucket等信息 FilePutParam filePutParam = new FilePutParam("haoduo-vip", "excel-image/" + importerTask.getId() + "/" + imgPath, inputStream); FilePutResult filePutResult = fileRepo.putFile(filePutParam); name2Url.put(name, filePutResult.getUrl()); } catch (IOException e) { throw new RuntimeException(e); } } } }); // 图片列转换成图片地址 dataList.stream().forEach(data -> { String imgName = subImageId((String) ReflectionUtils.getField(field, data)); String imgUrl = name2Url.get(imgName); ReflectionUtils.setField(field, data, imgUrl); }); } catch (ZipException e) { throw new RuntimeException(e); } catch (IOException e) { throw new RuntimeException(e); } } }); try { FileUtils.deleteDirectory(this.taskDir); } catch (IOException e) { log.error("delete directory occurs error", e); } super.doImport(dataList); } private Map<String, String> getName2PicPath() { Map<String, String> name2RId = new HashMap<>(); Map<String, String> rid2Path = new HashMap<>(); try (ZipFile zipFile = new ZipFile(excelFile)) { ZipUtil.read(zipFile, zipEntry -> { String name = zipEntry.getName(); if (name.equals("xl/cellimages.xml")) { try (InputStream inputStream = zipFile.getInputStream(zipEntry)) { List<Element> elements = XmlUtils.getElementsByXPath(inputStream, "/etc:cellImages/etc:cellImage/xdr:pic"); for (Element element : elements) { String picName = XmlUtils.getAttrByXPath(element, "xdr:nvPicPr/xdr:cNvPr", "name"); String rId = XmlUtils.getAttrByXPath(element, "xdr:blipFill/a:blip", "embed"); name2RId.put(picName, rId); } } catch (IOException e) { throw new RuntimeException(e); } } else if (name.equals("xl/_rels/cellimages.xml.rels")) { try (InputStream inputStream = zipFile.getInputStream(zipEntry)) { Element rootElement = XmlUtils.getRootElement(inputStream); // 处理每个关系 for (Element imageRelationship : rootElement.elements()) { String imageRid = imageRelationship.attribute("Id").getValue(); String imagePath = imageRelationship.attribute("Target").getValue(); rid2Path.put(imageRid, imagePath); } } catch (IOException e) { throw new RuntimeException(e); } } }); } catch (ZipException e) { throw new RuntimeException(e); } catch (IOException e) { throw new RuntimeException(e); } Map<String, String> name2PicPath = new HashMap<>(); for (Map.Entry<String, String> entry : name2RId.entrySet()) { String name = entry.getKey(); String rId = entry.getValue(); String path = rid2Path.get(rId); name2PicPath.put(name, path); } return name2PicPath; } @Override protected PreCheckResult<T> preReadAndCheck(InputStream inputStream) { // 先保存到本地磁盘,后续还要持续读取 excelFile = new File(taskDir, FilenameUtils.getName(importerTask.getTargetFileUrl())); // copyInputStreamToFile 内部有close逻辑 try { FileUtils.copyInputStreamToFile(inputStream, excelFile); } catch (IOException e) { throw new RuntimeException(e); } try { return super.preReadAndCheck(new FileInputStream(excelFile)); } catch (FileNotFoundException e) { throw new RuntimeException(e); } } @Override protected String fileCheck(FileMetaResult fileMeta) { if (fileMeta.getSize() > MAX_FILE_SIZE) { return "文件过大,不允许导入"; } return null; } }

问题记录

在测试过程中,有几个问题让我比较疑惑,并且最终也没有找到令我信服的资料,特此记录一下。

ZipFile 文件描述符可复用?

这个是测试过程中发现的,第一次new ZipFile(fileName)会打开一个文件描述符,但是同样的文件再次new的时候,从系统层面观察,并没有发现创建新的文件描述符。
而FileInputStream,每new一次,都会打开一个文件描述符

ZipFile 文件描述符会自动回收?

这个也是在测试过程中发现的,创建ZipFile之后,不主动close,但是过一段时间从系统层面观察,竟然发现文件描述符不存在了
基于上面这两点,资源泄露时期任务执行完稳定之后的文件描述符的数量刚好等于zip包里图片的数量 + 2(2个xml文件)

容器内的文件描述符创建时间

由于我们的应用都是跑在Docker里的,我进入容器内查看,发现文件描述符的创建时间貌似和我进入容器的时间有关:
[root@41a0eb5b051c apps]# ll /proc/1/fd | grep xlsx | grep xlsx | grep temp1965231543966580944 | wc -l 38 [root@41a0eb5b051c apps]# ll /proc/1/fd | grep xlsx | grep xlsx | grep temp1965231543966580944 lr-x------ 1 root root 64 May 28 19:14 12 -> /tmp/temp1965231543966580944.xlsx (deleted) lr-x------ 1 root root 64 May 28 19:14 198 -> /tmp/temp1965231543966580944.xlsx (deleted) lr-x------ 1 root root 64 May 28 19:14 200 -> /tmp/temp1965231543966580944.xlsx (deleted) lr-x------ 1 root root 64 May 28 19:14 230 -> /tmp/temp1965231543966580944.xlsx (deleted) lr-x------ 1 root root 64 May 28 19:14 281 -> /tmp/temp1965231543966580944.xlsx (deleted) lr-x------ 1 root root 64 May 28 19:14 282 -> /tmp/temp1965231543966580944.xlsx (deleted) lr-x------ 1 root root 64 May 28 19:14 300 -> /tmp/temp1965231543966580944.xlsx (deleted) lr-x------ 1 root root 64 May 28 19:14 329 -> /tmp/temp1965231543966580944.xlsx (deleted) lr-x------ 1 root root 64 May 28 19:14 330 -> /tmp/temp1965231543966580944.xlsx (deleted) ...省略

参考

  1. 为什么明明删除了文件,磁盘空间却没有释放?
  1. Linux文件已删除,引用未释放(deleted)
  1. java读取Excel,(支持WPS嵌入式图片)
MySQL JSON字段部分更新实验使用多值索引(Multi-Value Index)导致Canal同步异常