阅读 90

如何按目录统计OSS对象大小

随着云服务的不断发展,越来越多的资源被存储在云上。 对象存储就是其中一种应用非常广泛的存储方式,比如阿里云的OSS、百度云的BOS、亚马逊的S3。 虽然对于用户来说使用比较方便,但对于开发者来说却往往会受限于 SDK,面临不少问题。 比如统计OSS对象的大小这么一个看似简单的功能就是如此。

业务场景

公司的一些项目文件存储在阿里云的OSS上,需要按照目录来统计存储的对象大小,从而计算存储成本以及估算下行流量成本。 这些对象有下面3个特点: 1 数量多。数量在百万以上,而且还会无限增长。 2 修改少。上传以后,大概率不会进行修改。 3 周期短。对象只存储 3 个月,3 个月后自动清除。

SDK 带来的问题

阿里云 SDK 并没有直接提供按照文件夹统计的 API 函数,而是需要通过 ListObjects 函数来列举每个对象的大小,然后进行手动统计。

关于 ListObjects 函数它的请求参数如下图所示。

描述信息比较详细,但参数不多,总结起来有下面几个要点:

  1. 每次查询可以设置返回记录数量,这个值最大1000。
  2. 如果一次无法完成列举,那么会返回一个 NextMarker 值,查询时将这个值作为 Marker 参数就可以获取下一页的数据。
  3. 可以通过设置 Prefix 参数,列举出某个目录下及子目录下的对象。
  4. 可以通过 Delimiter 参数来列举目录。

也就是说,ListObjects函数类似于一个迭代器,当对象数量较多时,只能通过分页标记来逐次查询。 设想如果有10万个对象,那么至少需要调用100次,每次2~3秒,耗时3~5分钟。 而我们项目目前数量不多,在120万左右,总耗时将达到40~60分钟,这太长了! 尤其是对于部署于云端的函数计算程序而言,10分钟已经超时。

怎么优化查询统计操作?

参考前端性能优化的思路,一般有3类解决方法:

1 压缩 比如 HTTP 协议中采用 gzip 压缩返回数据。由于 API 函数并没有提供过滤字段或者压缩参数,所以返回的数据大小是不可变的,压缩这种方式行不通。

2 拆分 拆分按目的又可以分为两类,一类是延迟请求数据,比如前端路由懒加载组件,一类是并发,比如大文件分片上传。

懒加载对于文件统计而言没有任何意义,并不能减少最终消耗时间。

采用并发的话理论上实可以缩短接口查询时间,但操作起来会有些问题,因为ListOjects函数采用的是迭代器方式,每一次查询都依赖上一次返回的NextMarker值,这样的设定就强行让查询变成了串行操作。

3 缓存 缓存一般能优化的多次重复操作,因为当缓存命中时可以直接得到结果。比如前端的强缓存和协商缓存。

直接缓存查询结果意义不大,因为ListObjects函数并没有提供类似 HTTP 协议的判断机制来检查缓存的有效性,还是要通过调用ListObjects函数来校验缓存的有效性。

但是缓存就完全没有意义了吗?当然不是~

拆分与缓存

如果将每次查询的参数缓存起来(主要是NextMarker),那么当再次查询时就可以实现并发查询。 具体实现思路如下:

  1. 首次查询时逐页进行查询,当目录下对象数量超过1000时,按照目录缓存当前查询参数。小于1000时不会产生分页,缓存参数意义不大。
  2. 再次查询时,先获取缓存对象。查询每个目录是否有缓存参数,如果存在则根据缓存参数进行并发查询,不存在则直接查询。
  3. 当返回结果和缓存参数的NextMarker值不一致时,证明文件有变动,放弃当前并发查询结果,重新逐次进行查询。
  4. 对于每次查询结果,对象数量大于1000的都更新缓存参数。

为了进一步压榨阿里云函数计算的并发能力,采取了两个措施:

  1. 由 Python 改为 Node.js。由于之前考虑到项目可能会交给后端同事维护,所以选择了 Python,但 Python 的线程和协程并发性能都不如 Node.js,多进程的话工作量比较大,所以改为 Node.js。
  2. 无限并发。把缓存参数全部读出,然后分别调用 ListObjects 函数,最后再统计返回结果。

但并发执行后抛出了一个类似下面 XML 解析的错误(由于错误内容太多,进行了截取)。

Error: Unclosed root tag
Line: 1284
Column: 8
Char:
raw xml: <?xml version="1.0" encoding="UTF-8"?>
......
复制代码

考虑到 API 函数的实现原理是将通过 HTTP 请求获得的 XML 字符串,先转成 XML 再转换成 JSON 数据并返回,所以猜测是返回的 XML 字符串不全,导致解析成 XML 对象失败。

于是改为只查询统计报错的目录,但并没有出现错误。

不得已进入 SDK 源码进行断点调试,最终发现是请求超时导致报错。

根本原因是并发数太多造成了排队等待响应结果的情况,所以导致等待超时,但这个报错信息误导性太强。。。

并发与队列

要解决排队等待的问题,解决方式也很简单,那就是控制并发数量

怎么控制呢?受益于 JavaScript 引擎事件循环的启发,建立了一个任务队列。

当任务队列不为空时,不断轮询这个任务队列。

当执行任务数量未达到上限时,取出任务执行,并将计数器加1。

执行完任务后计数器减一。核心代码如下:

function schedule() {
  /* running 计数器,记录当前执行的任务数
   * concurrency 并发数限制
   */
  while(queue.length > 0 && running < concurrency) {
    running++
    // queue 任务队列
    let task = queue.shift()
    // singleList 函数基于 API 函数的封装
    singleList.call(null, task.params, task.key)
    .then(task.resolve, task.reject)
    .finally(() => {
      running--;
      //
      if(queue.length > 0) timeout = setTimeout(schedule, 0)
      console.info('waiting:', queue.length, 'running:', running)
    })
  }
}
复制代码

最终在阿里云函数计算上的执行效果,统计120万个对象并写入数据库,耗时 69 秒,占用内存 962 MB。

Duration: 69451.69 ms, Billed Duration: 69500 ms, Memory Size: 3072 MB, Max Memory Used: 962.17 MB

目前这个性能已经能满足业务需求,即使文件数量再增加一个数量级,也能在10分钟内执行完成(阿里云函数计算超时限制最大600秒)。

优化

理论上来说,还能通过增加并发数来缩短时间。 具体思路: 设置一个主进程,把任务分配给不同的进程进行执行,从而进一步增加并发能力。 分配方式可以根据对象数量分配,比如每100个任务分配一个进程。


原文链接:tech.gtxlab.com/oss-file.ht… 作者信息:朱德龙,人和未来高级前端工程师。