使用DuckDB-Wasm与Java后端在浏览器中完成数据分析
DuckDB-Wasm 在浏览器标签页内运行 DuckDB 分析数据库。Java 后端一次性发送数据集,此后每一次筛选和聚合都是本地 SQL 查询,而不再是又一轮服务器请求。下面是一个无需任何构建工具的两文件示例,并讨论这种做法何时合适、何时不合适。
每一次点击都是一次往返
大多数展示分析数据的 Java Web 应用都遵循同样的模式。用户选择一个筛选条件,浏览器调用 REST 端点,Java 服务对数据库执行 GROUP BY,把结果序列化为 JSON 并返回。改一下日期范围:又一次往返。从"按地区"切换到"按产品":又一次往返。
这么做行得通,对很多应用来说也合理。当往返次数累积起来时,有几种标准的应对方式:缓存常见响应、预先聚合成汇总表或物化视图、把多个查询合并到一个端点,或者把数据迁移到专门的 OLAP 存储(如 ClickHouse 或 Druid)。每种方式都有帮助,也都带来需要运行、调优或失效处理的东西。但它们都没有改变基本格局:服务器回答每一个问题,用户在网络上等待他们其实已经下载过一次的数据。
DuckDB-Wasm 采取了不同的做法。DuckDB 这个进程内分析数据库被编译成 WebAssembly,完全运行在浏览器标签页中。你的 Java 后端只发送一次数据;此后每一次聚合、筛选和连接都是本地 SQL 查询。本文将构建这种做法的最小版本:一个 Java 文件和一个 HTML 文件,不需要 Maven,不需要 npm,不需要框架。
为什么值得这么做
把分析移到浏览器会改变数据密集界面的两件事:运行成本和响应速度。五个具体要点:
- 交互变得即时。 数据进入标签页后,对几万行数据重新聚合只是一次本地内存中的列式查询。没有网络延迟,没有 JSON 序列化,没有连接池。筛选和下钻即时响应。
- 服务器的工作量大幅减少。 在经典模式中,十个用户各点击二十次筛选,意味着在你的基础设施上执行两百次分析查询。而在这里只是十次数据下载。查询在用户的机器上运行,而不是你的。
- Java 仍然是唯一事实来源。 这不是"用 JavaScript 取代后端"。Java 仍然决定用户能看到哪些数据,应用安全和业务规则,并产出数据集。浏览器只能对交给它的数据提问。
- 这是 SQL,而不是定制的 JavaScript API。 前端说的是与你的数据库和数据团队相同的语言。对于两端都支持的 SQL,你在 DuckDB CLI 里或用 Java 通过 JDBC 原型的查询,可以在浏览器中不加修改地运行。浏览器版本与原生版本并不完全相同:它提供的扩展集更小,并且有自己的 HTTPFS 实现,因此依赖原生文件或网络 I/O 的查询正是两者可能产生分歧的地方。
- 可以离线工作,也便于分享。 加载完成后,页面无需服务器即可继续工作。这使它适合通过邮件发送的报告、在会议 Wi-Fi 不稳时使用的仪表板,或者交给你网络之外的人使用的探索工具。
当每个用户的数据集小到可以下载(几 MB 到低几百 MB),而针对它提出的问题数量很大时,这种做法效果最好。
场景
我们将构建一个极简的销售仪表板。Java 服务器生成 1,000 行销售数据(地区、产品、金额),并以 CSV 形式对外提供。浏览器一次性下载该 CSV,加载到 DuckDB-Wasm,用纯 SQL 计算每个地区的收入。
Java 只响应两个请求:页面和数据。此后的一切都是同一标签页内页面与 DuckDB 之间的对话。
一步步构建
你只需要 JDK 17 或更高版本(java -version)。其他什么都不用安装:DuckDB-Wasm 从 jsDelivr CDN 加载。
第 1 步:创建项目文件夹
第 2 步:编写 Java 后端
创建 Server.java。它使用 JDK 内置的 HttpServer 提供两项内容:HTML 页面,以及生成 1,000 行销售数据的 /api/sales.csv 端点。
有一个细节需要注意:Locale.ROOT。在区域设置为荷兰语或德语的机器上,%.2f 会写成 12,34 而不是 12.34,这会破坏 CSV——而且只在那些使用小数逗号的区域设置机器上才会出现。
第 3 步:编写浏览器端
在同一目录下创建 index.html。脚本启动 DuckDB-Wasm,从 Java 端点获取 CSV,将其注册为虚拟文件,然后执行一次聚合。
这里发生了几件事:
selectBundle为当前运行的浏览器挑选合适的 WebAssembly 构建。- DuckDB 运行在 Web Worker 中,因此繁重的查询不会卡住 UI。Blob 包装层让该 worker 能够绕过跨域规则从 CDN 加载。
registerFileText把下载的 CSV 放进 DuckDB 的虚拟文件系统,于是read_csv('sales.csv')就像文件在磁盘上一样工作。- 结果以 Apache Arrow 表的形式返回;
toArray()和toJSON()把它们转成普通的 JavaScript 对象。
第 4 步:运行它
Java 的单文件源码启动器把编译和运行合成一步:
第 5 步:打开页面
访问 http://localhost:8080。稍等片刻(Wasm 二进制文件首次加载有几 MB,之后会被缓存),你会看到一张表,包含每个地区的订单数、收入和平均订单金额。
Java 只产出了原始行;GROUP BY 在你的浏览器标签页里执行。加一个按产品筛选并重新运行查询的下拉框,它会在不向服务器发出任何额外请求的情况下更新。
与其他技术栈的对比
Java 加 DuckDB-Wasm 只是若干选项之一,而且并非默认答案。下面是它与 Java 团队通常考虑的技术栈的对比。
实践中有三个比较最重要。
-
对比经典的 Spring Boot + Postgres 方案。 这大概是你今天在用的东西。凡是事务型的、规模大的,以及用户绝不能看到超出查询返回范围的行的场景,都继续用它。在上层加上 DuckDB-Wasm 用于探索性视图,也就是用户对同一份规模适中的数据做多种切分的地方。
-
对比通过 JDBC 在服务端使用 DuckDB。 同样的引擎,同样的 SQL,不同的位置。当数据规模大或敏感时,服务端 DuckDB 是更好的选择。当交互性比数据量更重要时,DuckDB-Wasm 是更好的选择。因为 SQL 完全相同,你可以在两者之间移动查询而无需重写。
-
对比其他浏览器内方案。 SQLite Wasm 是一个可靠的嵌入式数据库,但它是为逐行读写而非扫描和聚合而建的。JavaScript 数据框库更轻量,但让你用 JavaScript API 而不是 SQL 来表达分析逻辑。DuckDB-Wasm 的定位是标签页内的完整分析型 SQL 引擎,支持 Parquet、CSV、JSON 和 Arrow。
什么时候不该这么做
在把分析移到客户端之前,先了解其局限。
- 安全。 任何你发送到浏览器的内容,用户都能读取,无论你的 UI 是否展示它。在发送之前用 Java 按权限做好筛选。DuckDB-Wasm 是表现层引擎,不是访问控制层。
- 数据规模。 浏览器标签页有内存预算,WebAssembly 还有自己的上限。几 MB 很轻松,几 GB 则不行。对于大数据,在服务端查询,或者让 DuckDB-Wasm 通过 HTTP range 请求只读取远程 Parquet 文件中它需要的部分。
- 首次加载。 引擎是几 MB 的下载。浏览器会缓存它,但对于用户只访问一次、只为看一个数字的页面,直接用服务器渲染出那个值会更快。
- 新鲜度。 标签页中的数据是一个快照。如果用户需要逐秒的实时数字,你需要一套刷新策略,或者优先考虑流式的工具。
- 线程。 本示例是单线程运行的。
getJsDelivrBundles()返回的是mvp和eh构建,而不是线程所需的coi构建,所以仅靠响应头无法改变这一点。多线程执行意味着既要提供coibundle,又要设置跨源隔离头(Cross-Origin-Opener-Policy和Cross-Origin-Embedder-Policy)。对于本文的场景,单线程已经足够,但在做基准测试之前你要清楚这一点。
一条经验法则:如果你乐于把这份数据集作为附件通过邮件发给用户,那你大概也可以把它发送到他们的浏览器。## 下一步:服务器端与浏览器端都使用 DuckDB
CSV 让示例保持易读,但它是下载效率最低的格式。更自然的升级方式是让 Java 使用 DuckDB JDBC 驱动(Maven Central 上的 org.duckdb:duckdb_jdbc)写入 Parquet。Parquet 是列式且压缩的格式,因此下载体积会缩小,DuckDB-Wasm 无需解析文本即可读取。
在 Java 端,该端点变成一条 COPY 语句:
使用 Content-Type: application/octet-stream 提供该文件,并在浏览器中将步骤 2 和步骤 3 替换为二进制获取和 Parquet 读取:
单文件启动器仍然可用;只需将驱动 JAR 放到 classpath 上:java -cp duckdb_jdbc.jar Server.java。
现在,同一个引擎和同一套 SQL 可以在两个地方运行:JVM 中的 DuckDB 负责准备数据并保障其安全,浏览器中的 DuckDB 负责探索数据。你只需编写一次查询,就可以让它运行在任何合理的一侧。
总结
你不必在 Java 后端和响应式分析前端之间二选一。让 Java 做它擅长的事(掌控数据、执行规则、生成干净的数据集),让 DuckDB-Wasm 处理用户想要探索的一切 SQL。从上面的两个文件开始,添加一个筛选下拉框,原本会发往服务器的查询就会留在浏览器中。