一次性上传 1000 张图片, 总量 10GB 的方案设计

背景

最近有一个上传文件方面的需求,上传图片,用户可以选择文件夹上传。

文件夹里的图片可能很多,而且由于特殊的项目背景,用户选择的图片可能会比较大,10MB左右。

这里就需要做一些方案上的设计了,确保整个过程的流畅和容错。

方案设计

这个过程中需要考虑到的细节主要是以下几点:

  • 重复图片的认定
  • 图片上传任务的控制
  • 上传失败和上传中断处理

图片去重

hash

图片去重这一块,我们可以在前端做hash,但是对于10MB左右的图片,走hash的话,每一张大概要花费30-60ms不等的时间,如果每张图片都先算hash,1000张这样大小的图片就要等待30s~60s,然后才能开始进行上传任务,我们觉得这个等待时间有点久了。

文件路径 + 文件名 + 文件大小

由于我们的项目支持文件夹选择上传,再递归遍历文件夹的过程中,我们是可以拿到图片的相对路径的,再加上图片的文件名,以及文件的size,我们决定就将这三者条件的联合作为图片是否上传过的对比凭证。

那么这样一来,在选择了文件之后,就可以立马得到这些信息,那么此时就可以把整个文件列表传给后端,后端返回哪些文件重复,哪些文件不重复。

这样就能快速拿到我们需要进行上传的图片列表了。

对文件做浏览器持久化缓存

为什么要在前端做持久化的缓存?

多文件的上传任务是一个耗时的任务,这个过程可能会出现不确定的情况,比如网页关了,或者浏览器关了,而前端无法记住文件在客户机器上的位置,如果没有持久化的存储,那么用户就必须手动再去选择一下文件或者文件夹,用户体验没那么友好。

那么我们就需要一个像localStorage那样的存储,浏览器关了也不会消失的存储。

既然这样说了,那localStorage 肯定是不行的,因为它无法保存file类型的数据,也就是无法保存文件,就算能保存图片,大小也不够,5M左右的大小,都装不下一张图片。

我们用indexDB来做文件的存储。

上传过程的并发控制

前面两步设计好了,下面就该发上传的请求了,1000张图片,不可能走一个请求的,我们让每个图片走一个上传请求,也就是发1000个请求来上传这些图片。

如果用的是HTTP1.1,就要考虑并发控制的问题了,因为你不可能1000个请求一次性无脑发出去,那么这样,用户就必须等1000个上传任务完成,才能操作页面,因为接下来的网络请求都要排队,要等1000个图片上传完。

我们的设计是,上传过程中,用户不用干等,可以比较流畅的进行其他操作,留一个任务列表展示当前的任务状态。

如果用的是HTTP2.0, 虽然可以不用做并发控制,浏览器会把其他的请求提前,但是也要等当前的某个请求结束,因为并发数也是有限的,实测每个可能会等待个3s以内。

而且因为这个任务的主要瓶颈是1000个10MB图片的上传,是带宽。所以HTTP2.0的优势,多路复用,头部压缩,并不能很好的体现出来,也就是说总任务时长差不多。所以我们最终没有升级成HTTP2.0,还是用的HTTP1.1。

对HTTP1.1,chrome浏览器对同一个域名的并发数最大是6,所以并发数可以设置为5,留一点网络资源给前台任务。

失败和中断处理

失败和中断的任务主要还是依赖了本地持久化存储。

我们在第一次拿到后端返回的需要上传的文件列表的时候,就应该把这个文件列表加上文件本身存进indexDB 中,然后做上传,成功上传一个,就从indexDB中删除一条,那么最后留下来的,就是没有成功的。

当然也可以在上传任务中加入catch,这样可以对失败的任务做自动重传,这个不是必须的。

部分实现

对indexDB操作的封装

js 复制代码
class FileStorage {
  private db: IDBDatabase | null = null
  private dbName = 'fileStore'
  private storeName = 'files'
  private openCallback: () => void

  constructor(open = () => {}) {
    this.openCallback = open
    this.openDatabase()
  }

  // 打开或创建数据库
  private openDatabase(): void {
    const request = indexedDB.open(this.dbName, 1)

    request.onupgradeneeded = (event) => {
      this.db = (event.target as IDBOpenDBRequest).result
      if (!this.db.objectStoreNames.contains(this.storeName)) {
        this.db.createObjectStore(this.storeName, {
          keyPath: 'id',
          autoIncrement: true
        })
      }
    }

    request.onsuccess = (event) => {
      this.db = (event.target as IDBOpenDBRequest).result
      this.openCallback()
    }

    request.onerror = (event) => {
      console.error('Error opening database:', event)
    }
  }

  // 插入整个 fileList 数组
  public async insertFileList(fileList: fileInfoWithId[]): Promise<void> {
    const fileContents = await Promise.all(
      fileList.map((file) => this.readFileAsArrayBuffer(file.raw))
    )

    await this.transaction(async (transaction) => {
      const objectStore = transaction.objectStore(this.storeName)
      for (let i = 0; i < fileList.length; i++) {
        const file = fileList[i]
        const content = fileContents[i]
        await this.insertObjectStore(objectStore, { ...file, content })
      }
    })
  }

  // 插入单个 fileInfo
  public async insertFile(fileInfo: fileInfoWithId): Promise<void> {
    this.insertFileList([fileInfo])
  }

  // 读取文件为 ArrayBuffer
  private readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
    return new Promise((resolve, reject) => {
      const reader = new FileReader()
      reader.readAsArrayBuffer(file)
      reader.onload = () => resolve(reader.result as ArrayBuffer)
      reader.onerror = (error) => reject(error)
    })
  }

  // 插入对象到 objectStore
  private async insertObjectStore(
    objectStore: IDBObjectStore,
    file: fileInfoWithId & { content: ArrayBuffer }
  ): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = objectStore.add(file)
      request.onsuccess = () => resolve()
      request.onerror = (error) => reject(error)
    })
  }

  // 根据 ID 列表删除数据
  public async deleteByIds(ids: number[]): Promise<void> {
    await this.transaction(async (transaction) => {
      const objectStore = transaction.objectStore(this.storeName)
      for (const id of ids) {
        const request = objectStore.delete(id)
        await this.waitForRequest(request)
      }
    })
  }

  // 根据单个 ID 删除数据
  public async deleteById(id: number): Promise<void> {
    await this.transaction(async (transaction) => {
      const objectStore = transaction.objectStore(this.storeName)
      const exists = await this.checkIfExists(objectStore, id)
      if (!exists) {
        console.warn(`No file found with id ${id}`)
        return
      }
      console.log(`Deleting file with id ${id}`)
      const request = objectStore.delete(id)
      await this.waitForRequest(request)
    })
  }

  // 检查是否存在指定 ID 的数据
  private async checkIfExists(
    objectStore: IDBObjectStore,
    id: number
  ): Promise<boolean> {
    return new Promise((resolve) => {
      const getRequest = objectStore.get(id)

      getRequest.onsuccess = () => {
        resolve(getRequest.result !== undefined) // 如果 result 是 undefined,说明没有找到该 ID
      }

      getRequest.onerror = (error) => {
        console.error('Check existence error:', error)
        resolve(false)
      }
    })
  }

  // 查找所有数据
  public async findAll(): Promise<fileInfoWithId[]> {
    return await this.transaction(async (transaction) => {
      const objectStore = transaction.objectStore(this.storeName)
      const request = objectStore.getAll()
      return await this.waitForRequest(request)
    })
  }

  // 清空整个对象存储中的所有数据
  public async clearDatabase(): Promise<void> {
    await this.transaction(async (transaction) => {
      const objectStore = transaction.objectStore(this.storeName)
      const request = objectStore.clear()

      await this.waitForRequest(request)
      console.log('Database cleared successfully')
    })
  }

  // 带分页的查找功能
  public async findWithPagination(
    page: number,
    pageSize: number
  ): Promise<fileInfoWithId[]> {
    return await this.transaction(async (transaction) => {
      const objectStore = transaction.objectStore(this.storeName)
      const start = (page - 1) * pageSize
      const result: fileInfoWithId[] = []
      let currentIndex = 0

      const cursorRequest = objectStore.openCursor()

      return new Promise<fileInfoWithId[]>((resolve, reject) => {
        cursorRequest.onerror = (error) => reject(error)

        cursorRequest.onsuccess = (event) => {
          const cursor = (event.target as IDBRequest).result
          if (cursor) {
            if (currentIndex >= start && currentIndex < start + pageSize) {
              result.push(cursor.value)
            }
            if (currentIndex < start + pageSize) {
              currentIndex++
              cursor.continue()
            } else {
              resolve(result)
            }
          } else {
            resolve(result)
          }
        }
      })
    })
  }

  // 创建事务并执行回调
  private async transaction<T>(
    callback: (transaction: IDBTransaction) => Promise<T>
  ): Promise<T> {
    return new Promise((resolve, reject) => {
      if (!this.db) {
        reject(new Error('Database not initialized'))
        return
      }

      const transaction = this.db.transaction([this.storeName], 'readwrite')
      callback(transaction).then(resolve).catch(reject)
      transaction.oncomplete = () => {
        console.log('Transaction completed')
      }

      transaction.onerror = (error) => {
        console.error('Transaction error:', error)
        reject(error)
      }
    })
  }

  // 等待请求完成
  private waitForRequest<T>(request: IDBRequest<T>): Promise<T> {
    return new Promise((resolve, reject) => {
      request.onsuccess = () => resolve(request.result)

      request.onerror = (error) => {
        console.error('Request error:', error)
        reject(error)
      }
    })
  }
}

export default FileStorage

并发任务控制

js 复制代码
class concurrencyControl {
  private maxConcurrency: number
  private queue: any[]
  private running: number
  private onAllTasksCompleted: Function

  constructor(
    maxConcurrency: number,
    onAllTasksCompleted: Function = () => {}
  ) {
    this.maxConcurrency = maxConcurrency
    this.queue = []
    this.running = 0
    this.onAllTasksCompleted = onAllTasksCompleted
  }

  addQueue(queue: Function[]) {
    this.queue.push(...queue)
    this.run()
  }

  addTask(task: Function) {
    this.queue.push(task)
    this.run()
  }

  run() {
    while (this.running < this.maxConcurrency && this.queue.length) {
      this.running++
      const task = this.queue.shift()
      Promise.resolve(task()).finally(() => {
        this.running--
        this.run()
        if (this.running === 0 && this.queue.length === 0) {
          this.onAllTasksCompleted()
        }
      })
    }
  }
}

export default concurrencyControl
相关推荐
却尘7 分钟前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare8 分钟前
浅浅看一下设计模式
前端
Lee川11 分钟前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix38 分钟前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人41 分钟前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl1 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人1 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼1 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端
布列瑟农的星空1 小时前
前端都能看懂的Rust入门教程(三)——控制流语句
前端·后端·rust