基于IndexedDb和Web Worker的大数据量处理方案

7 阅读4分钟

1. 背景

最近在项目中遇到一个批量创建的场景,存在嵌套关系:群组A包含小组B,小组B下包含用户C1、产品C2、硬件C3。目前场景是A最大有20个,A下最大可以包含20个B,而B下的C1C2C3则上限各为1000,所以整个创建过程,能产生的对象理论上能达到120万,每个对象存在键值对10到40个不等。由于这个对象甚至是一个vue的响应式监听对象,所以实际上无法完成创建流程浏览器就会崩溃。针对这个问题,产品要求优化实现并且将上限提高至50*50*3000。

2. 卡顿、报错原因分析

  • 对象数量过大,且统一维护在tableData这个响应式数组里,实际上对于C1C2C3而言,仅需要在点击对应的设置弹窗时回显已选中以及提交时需要,并不必要维护在tableData里导致响应式监听消耗过大的内存导致浏览器卡死崩溃,这是重点。

  • 在提交前的压缩处理过程中直接报错,定位到执行JSON.stringify这一步时异常退出,浏览器虽然没有明确规定JSON.stringify的处理能力,但是估计组装后的数据依旧过大(750万个对象),导致依旧无法字符串化

3. 方案设计

通过分析以及自己前期的一些方案试错,可以总结出归根结底还是对象数量过大,导致性能开销降不下来,所以方案必须围绕对象的拆分以及字段的精简。思路如下:

  • 首先后端在选择阶段返回了C1C2C3的全量数据,但是提交时,后端并不需要对应的全量数据,所以要求后端提供最新的API文档,剔除非必要字段
  • C1C2C3并不需要响应式(对于表格而言),所以可以建立与B的联系以后抽离保存。保存的方式前端手段其实有限,无非是创建一个非响应式对象存在内存、cookie和storage api(毫无疑问会爆根本不用考虑)、indexedDb以及FileSystemAccess API(难以维护),所以确定使用indexedDb存储,键值对的存储方式恰好方便我们建立与组B的联系
  • 提交数据前的组装终究是无法避免处理这批庞大的数据,所以为了避免页面渲染崩溃,决定在提交的数据处理新开一个worker线程,并且经过测试,组装后的数据大小依旧在500M以上,所以还要考虑接口切片压缩提交

所以最终结论就是使用indexedDb拆分大数组,将原本三层嵌套总共750万对象的大数组降为50*50的二维数组,提交时通过新开Worker线程处理数据切片压缩,保证主线程渲染稳定。

4. 实现

数据抽离简单实现,业务相关的就不多赘述

import localforage from "localforage";

const indexedDb = localforage.createInstance({
    name: 'default',
});

export function saveDataToDb(groupId, {users, products, devices}) {
    indexedDb.setItem(groupId, {users, products, devices})

    // 返回对应数量用于展示选中个数
    return {
        userCount: users.length,
        productCount: products.length,
        deviceCount: devices.length
    }
}

export function getDataFromIndexedDb(groupId) {
    return indexedDb.getItem(groupId)
}

新开Worker以及数据组装、切片、压缩

主线程

// Submit.vue 伪代码
const worker = new Worker('/submit.worker.js', {type: 'module'}) // 支持ESM模式
worker.onmessage = ({data}) => {
    if (data === 'finish') {
        // 一些其他处理
        // ...
        // 注销worker
        worker.terminate()
    }
}

worker.postMessage(tableData)

worker

// submit.worker.js
import pako from 'https://cdn.jsdelivr.net/npm/pako@2.0.0/dist/pako.esm.js'

function openDB(storeName) {
    return new Promise((resolve, reject) => {
        const req = indexedDB.open('default');

        req.onupgradeneeded = () => {
            const db = req.result;
            if (!db.objectStoreNames.contains(storeName)) {
                db.createObjectStore(storeName, { keyPath: null, autoIncrement: true });
            }
        };

        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
    });
}

async function readFromDB(keys) {
    if (!keys.length) {
        return {};
    }

    const STORE_NAME = 'keyvaluepairs';
    const db = await openDB(STORE_NAME);

    return new Promise((resolve, reject) => {
        const tx = db.transaction(STORE_NAME, 'readonly');
        const store = tx.objectStore(STORE_NAME);

        let completed = 0;
        const map = {};
        keys.forEach((key) => {
            const req = store.get(key);

            // 批量读取,节省多次打开数据库的开销
            req.onsuccess = () => {
                map[key] = req.result.data;
                completed += 1;

                if (completed === keys.length) {
                    db.close();
                    resolve(map);
                }
            };
            req.onerror = () => reject(req.error);
        });
    });
}


/**
 * @desc 压缩post请求数据
 * @param {string} str
 * @returns {string}
 */
function gzip(str) {
    const u8arr = pako.gzip(new TextEncoder().encode(str), { level: 6, os: 255 });

    let binary = '';
    for (let i = 0; i < u8arr.length; i++) {
        binary += String.fromCharCode(u8arr[i]);
    }

    return btoa(binary);
}

onmessage = async ({data}) => {
    // 伪代码
    const chunks = []
    for await (const item of data) {
        const groupIds = item.groups.map(group => group.id)
        const groupDataMap = await readFromDB(groupIds)

        for await (const group of item.groups) {
            // 数据组装、切片
        }
    }

    for await (const chunk of chunks) {
        await sendRequestData(gzip(chunk))
    }

    postMessage('finish')
}

5. 最后

如此优化过后,不但上限扩充到50*50,操作时也不再将浏览器卡死闪退了。前端完成任务,压力来到后端这边咯!