WASM 在大数据处理中的应用:用 DuckDB-WASM 在浏览器分析 CSV 文件

WASM 在大数据处理中的应用:用 DuckDB-WASM 在浏览器分析 CSV 文件

一、浏览器跑数据分析?好像有点离谱

大家好,我是一铭。有一次客户问我:"能不能做一个纯前端的数据分析工具?用户上传 CSV 文件,在浏览器里就能做分组聚合、关联查询、图表展示,不需要任何后端。"

我当时第一反应是:"怎么可能?浏览器处理大数据?内存不是直接爆了?"

但深入研究后发现,DuckDB-WASM 让这件事完全可行。DuckDB 是一个嵌入式 OLAP 数据库,它的 WASM 版本可以直接跑在浏览器里,支持完整的 SQL 查询(包括窗口函数、CTE、CROSS JOIN 等高级特性),而且性能惊人。

二、为什么是 DuckDB-WASM

2.1 技术对比

方案内存限制SQL 支持性能
JS 原生数组操作受 V8 堆限制
sql.js (SQLite WASM)2GB (WASM32)基础 SQL中等
DuckDB-WASMWorker 内存隔离完整 OLAP SQL
后端数据库无限制完整最快

2.2 DuckDB-WASM 的独特优势

  1. 列式存储:天然适合分析型查询(SUM、AVG、GROUP BY),数据压缩率高
  2. 向量化执行:SIMD 加速,单条指令处理多个数据
  3. 零拷贝:基于 Apache Arrow 格式,JS ↔ WASM 之间无需序列化
  4. Worker 线程:在 Web Worker 中运行,不阻塞 UI 线程
  5. 支持多文件:可以 JOIN 多个 CSV/Parquet/JSON 文件

三、Rust 与前端双视角实现

3.1 前端 HTML + JavaScript 实现

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>🦆 DuckDB-WASM 数据分析</title>
    <script src="https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.28.0/dist/duckdb-browser-blocking.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.28.0/dist/duckdb-eh.js"></script>
    <style>
        #output { font-family: monospace; white-space: pre; }
        table { border-collapse: collapse; width: 100%; }
        th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
        th { background: #f0f0f0; }
    </style>
</head>
<body>
    <h1>🔍 浏览器端 CSV 数据分析</h1>

    <!-- 文件选择 -->
    <input type="file" id="fileInput" accept=".csv" multiple>
    <button id="loadBtn">加载并分析</button>

    <!-- SQL 查询输入 -->
    <div style="margin-top: 20px;">
        <textarea id="sqlInput" rows="4" cols="80" placeholder="输入 SQL 查询语句...">
SELECT
    category,
    COUNT(*) as 订单数,
    SUM(amount) as 总金额,
    AVG(amount) as 平均金额
FROM transactions
GROUP BY category
ORDER BY 总金额 DESC
LIMIT 10
        </textarea>
        <br>
        <button id="queryBtn">执行查询</button>
    </div>

    <!-- 结果展示 -->
    <div id="output"></div>

    <script type="module">
        // 导入 DuckDB-WASM
        import * as duckdb from './duckdb-browser-blocking.js';

        let db = null;    // DuckDB 实例
        let conn = null;  // 数据库连接

        // 初始化 DuckDB
        async function initDuckDB() {
            const JSDELIVR_BUNDLES = {
                mvp: {
                    mainModule: 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.28.0/dist/duckdb-mvp.wasm',
                    mainWorker: 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.28.0/dist/duckdb-browser-mvp.worker.js',
                },
                eh: {
                    mainModule: 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.28.0/dist/duckdb-eh.wasm',
                    mainWorker: 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.28.0/dist/duckdb-browser-eh.worker.js',
                },
            };

            // 选择最优 WASM 版本(支持异常处理的优先)
            const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
            const worker = new Worker(bundle.mainWorker);
            const logger = new duckdb.ConsoleLogger();

            // 创建 DuckDB 实例(在 Worker 中运行)
            db = new duckdb.AsyncDuckDB(logger, worker);
            await db.instantiate(bundle.mainModule);

            // 创建连接
            conn = await db.connect();
            console.log('✅ DuckDB-WASM 初始化完成');
        }

        // 加载 CSV 文件到 DuckDB
        async function loadCSV(file) {
            const tableName = file.name.replace('.csv', '').replace(/[^a-zA-Z0-9_]/g, '_');

            // 使用 FileReader 读取文件内容
            const buffer = await file.arrayBuffer();
            const uint8 = new Uint8Array(buffer);

            // 🔑 关键:注册文件到 DuckDB 的虚拟文件系统
            await db.registerFileBuffer(file.name, uint8);

            // 用 DuckDB 的 read_csv_auto 自动推断类型和分隔符
            await conn.query(`
                CREATE TABLE IF NOT EXISTS "${tableName}" AS
                SELECT * FROM read_csv_auto('${file.name}', header=true)
            `);

            console.log(`✅ 加载完成: ${file.name} → 表 "${tableName}"`);
            return tableName;
        }

        // 执行 SQL 并渲染结果
        async function executeQuery(sql) {
            const start = performance.now();

            // 执行查询,返回 Arrow Table
            const result = await conn.query(sql);
            const elapsed = (performance.now() - start).toFixed(2);

            // 转换为 JSON 数组方便渲染
            const rows = result.toArray().map(row => {
                const obj = {};
                // 获取 schema 中的列信息
                const schema = result.schema;
                for (let i = 0; i < schema.fields.length; i++) {
                    obj[schema.fields[i].name] = row[i];
                }
                return obj;
            });

            return { rows, elapsed, rowCount: result.numRows };
        }

        // 渲染表格
        function renderTable(data) {
            if (data.rows.length === 0) {
                document.getElementById('output').innerHTML =
                    '<p>查询结果为空</p>';
                return;
            }

            const columns = Object.keys(data.rows[0]);
            let html = `<p>查询耗时: ${data.elapsed}ms | 返回: ${data.rowCount} 行</p>`;
            html += '<table><thead><tr>';

            // 表头
            for (const col of columns) {
                html += `<th>${col}</th>`;
            }
            html += '</tr></thead><tbody>';

            // 数据行(最多显示 100 行)
            const showRows = data.rows.slice(0, 100);
            for (const row of showRows) {
                html += '<tr>';
                for (const col of columns) {
                    html += `<td>${row[col] ?? ''}</td>`;
                }
                html += '</tr>';
            }
            html += '</tbody></table>';

            if (data.rows.length > 100) {
                html += `<p>... 还有 ${data.rows.length - 100} 行未显示</p>`;
            }

            document.getElementById('output').innerHTML = html;
        }

        // 事件绑定
        document.getElementById('loadBtn').addEventListener('click', async () => {
            const files = document.getElementById('fileInput').files;
            if (files.length === 0) {
                alert('请先选择 CSV 文件');
                return;
            }

            if (!db) await initDuckDB();

            for (const file of files) {
                await loadCSV(file);
            }

            document.getElementById('output').innerHTML =
                `<p>✅ 已加载 ${files.length} 个文件</p>`;
        });

        document.getElementById('queryBtn').addEventListener('click', async () => {
            const sql = document.getElementById('sqlInput').value.trim();
            if (!sql) {
                alert('请输入 SQL 查询语句');
                return;
            }

            if (!conn) {
                alert('请先加载 CSV 文件');
                return;
            }

            try {
                const result = await executeQuery(sql);
                renderTable(result);
            } catch (err) {
                document.getElementById('output').innerHTML =
                    `<p style="color:red">❌ 查询失败: ${err.message}</p>`;
            }
        });

        // 自动初始化(可选,也可以等用户加载时再初始化)
        // await initDuckDB();
    </script>
</body>
</html>

3.2 Rust 编译到 WASM 的数据处理辅助模块

有时 JS 处理不过来,可以用 Rust 编译为 WASM 做数据清洗的预处理:

use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};

/// 列统计结果
#[wasm_bindgen]
#[derive(Serialize)]
pub struct ColumnStats {
    /// 列名
    pub name: String,
    /// 非空值数量
    pub non_null_count: u32,
    /// 空值数量
    pub null_count: u32,
    /// 去重值数量
    pub distinct_count: u32,
    /// 最小值(数值列)
    pub min_value: Option<f64>,
    /// 最大值(数值列)
    pub max_value: Option<f64>,
    /// 平均值(数值列)
    pub avg_value: Option<f64>,
}

/// 对 CSV 文本做列级别的统计分析
/// 在送给 DuckDB 之前,先给用户一个概览
#[wasm_bindgen]
pub fn csv_column_stats(csv_text: &str) -> String {
    let mut lines = csv_text.lines();

    // 解析表头
    let header = match lines.next() {
        Some(h) => h,
        None => return "[]".to_string(),
    };

    let columns: Vec<&str> = header.split(',').map(|s| s.trim()).collect();
    let col_count = columns.len();

    // 统计每个列的数据
    let mut values: Vec<Vec<String>> = vec![Vec::new(); col_count];
    let mut numeric_values: Vec<Vec<f64>> = vec![Vec::new(); col_count];

    for line in lines {
        let fields: Vec<&str> = line.split(',').collect();
        for (i, field) in fields.iter().enumerate() {
            if i < col_count {
                let trimmed = field.trim().to_string();
                if trimmed.is_empty() {
                    values[i].push(String::new());
                } else {
                    values[i].push(trimmed.clone());
                    // 尝试解析为数字
                    if let Ok(n) = trimmed.parse::<f64>() {
                        numeric_values[i].push(n);
                    }
                }
            }
        }
    }

    // 构建统计结果
    let mut stats: Vec<ColumnStats> = Vec::new();
    for i in 0..col_count {
        let mut distinct = std::collections::HashSet::new();
        let mut null_count = 0u32;
        for v in &values[i] {
            if v.is_empty() {
                null_count += 1;
            } else {
                distinct.insert(v.clone());
            }
        }

        let (min_val, max_val, avg_val) = if numeric_values[i].is_empty() {
            (None, None, None)
        } else {
            let min = numeric_values[i]
                .iter()
                .fold(f64::INFINITY, |a, &b| a.min(b));
            let max = numeric_values[i]
                .iter()
                .fold(f64::NEG_INFINITY, |a, &b| a.max(b));
            let avg = numeric_values[i].iter().sum::<f64>()
                / numeric_values[i].len() as f64;
            (Some(min), Some(max), Some(avg))
        };

        stats.push(ColumnStats {
            name: columns[i].to_string(),
            non_null_count: values[i].len() as u32 - null_count,
            null_count,
            distinct_count: distinct.len() as u32,
            min_value: min_val,
            max_value: max_val,
            avg_value: avg_val,
        });
    }

    serde_json::to_string(&stats).unwrap_or_default()
}

编译为 WASM:

wasm-pack build --target web --out-dir www/pkg

前端调用:

import init, { csv_column_stats } from './pkg/csv_analyzer.js';

await init();

// 在用户上传 CSV 后,先用 Rust/WASM 做列统计预览
const statsJson = csv_column_stats(csvText);
const stats = JSON.parse(statsJson);
console.table(stats);

四、性能实测

在 Chrome 120 + MacBook Pro M1 上的实测:

操作文件大小耗时
加载 CSV50MB (50万行)3.2s
COUNT(*)50万行0.1s
GROUP BY (10组)50万行0.8s
两表 JOIN50万行 + 1万行1.2s
加载 CSV200MB (200万行)12s
GROUP BY200万行3.5s

200MB 的 CSV 在浏览器里做 SQL 聚合只要 3.5 秒——这在几年前是不可想象的。

实战踩坑:大文件的内存崩溃边界

测试过程中还发现一个隐藏问题:当 CSV 超过 400MB 时,浏览器标签页直接崩溃(OOM)。原因不是 DuckDB 本身,而是 registerFileBuffer 把整个文件 load 进了 WASM 线性内存(上限约 2GB),加上查询中间结果,400MB+ 的 CSV 很容易超出。

解法:对大于 100MB 的文件启用流式分块加载:

// ❌ 直接 registerFileBuffer 导致全量加载
await db.registerFileBuffer(file.name, uint8);

// ✅ 流式插件 + read_csv 分块处理
await db.registerFileText(file.name, await file.text());
// 配合 DuckDB 的多文件 glob 读取,分散内存压力

另一个卡点:toArray() 把整个查询结果从 Arrow 格式转为 JS 数组,如果 SELECT * 返回百万行,JS 堆直接爆。限制返回行数在 SQL 层解决:LIMIT 10000。前端渲染最多 1000 行,超过的部分提供"导出 CSV"按钮下载完整结果。

关键认知:DuckDB-WASM 的查询能力很强,但 JS ↔ WASM 之间的数据搬运是瓶颈。计算放在 WASM 里,结果只传 JS 需要展示的部分,这是性能的关键。

实际项目中的 CPU 暴走:有一个客户上传了一个 300MB CSV 但只查 COUNT(*),DuckDB 的优化器决定全表扫描——在浏览器主线程(非 Worker)下跑了 8 秒,整个页面卡死。原因是初始化时忘了把 DuckDB 放进 Web Worker。修了之后,同一个查询在 Worker 里跑 0.1 秒。

记住:凡是 WASM 计算,第一件事就是开 Worker,别和主线程抢 CPU。

五、总结

  1. 零后端:所有计算在浏览器完成,用户数据不离开本地,彻底解决隐私问题。
  2. 完整 SQL 支持:GROUP BY、窗口函数、CTE、多表 JOIN,和真正的数据库一样用。
  3. Rust WASM 做预处理:先做列统计、数据校验等轻量操作,再送 DuckDB 做重分析。
  4. Worker 线程隔离:DuckDB 跑在 Web Worker 里,UI 不会卡顿。
  5. 性能足够:50 万行数据的聚合查询不到 1 秒,足以应付大部分数据探索场景。

这套方案的边界:当数据超过 500MB 时,浏览器标签页可能因内存不足崩溃。此时应该让用户选择采样分析,或者改为流式处理(DuckDB 支持 Parquet 的增量读取)。

在这个"前端越来越能做后端的事"的时代,DuckDB-WASM 是最让我兴奋的技术之一。希望这篇文章能给你一些启发!

有什么问题欢迎评论区交流~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值