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-WASM | Worker 内存隔离 | 完整 OLAP SQL | 快 |
| 后端数据库 | 无限制 | 完整 | 最快 |
2.2 DuckDB-WASM 的独特优势
- 列式存储:天然适合分析型查询(SUM、AVG、GROUP BY),数据压缩率高
- 向量化执行:SIMD 加速,单条指令处理多个数据
- 零拷贝:基于 Apache Arrow 格式,JS ↔ WASM 之间无需序列化
- Worker 线程:在 Web Worker 中运行,不阻塞 UI 线程
- 支持多文件:可以 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 上的实测:
| 操作 | 文件大小 | 耗时 |
|---|---|---|
| 加载 CSV | 50MB (50万行) | 3.2s |
| COUNT(*) | 50万行 | 0.1s |
| GROUP BY (10组) | 50万行 | 0.8s |
| 两表 JOIN | 50万行 + 1万行 | 1.2s |
| 加载 CSV | 200MB (200万行) | 12s |
| GROUP BY | 200万行 | 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。
五、总结
- 零后端:所有计算在浏览器完成,用户数据不离开本地,彻底解决隐私问题。
- 完整 SQL 支持:GROUP BY、窗口函数、CTE、多表 JOIN,和真正的数据库一样用。
- Rust WASM 做预处理:先做列统计、数据校验等轻量操作,再送 DuckDB 做重分析。
- Worker 线程隔离:DuckDB 跑在 Web Worker 里,UI 不会卡顿。
- 性能足够:50 万行数据的聚合查询不到 1 秒,足以应付大部分数据探索场景。
这套方案的边界:当数据超过 500MB 时,浏览器标签页可能因内存不足崩溃。此时应该让用户选择采样分析,或者改为流式处理(DuckDB 支持 Parquet 的增量读取)。
在这个"前端越来越能做后端的事"的时代,DuckDB-WASM 是最让我兴奋的技术之一。希望这篇文章能给你一些启发!
有什么问题欢迎评论区交流~

773

被折叠的 条评论
为什么被折叠?



