Ohhnews

分类导航

$ cd ..
foojay原文

使用DuckDB-Wasm与Java后端在浏览器中完成数据分析

#duckdb-wasm#java#浏览器端分析#webassembly#sql

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,不需要框架。

为什么值得这么做

把分析移到浏览器会改变数据密集界面的两件事:运行成本和响应速度。五个具体要点:

  1. 交互变得即时。 数据进入标签页后,对几万行数据重新聚合只是一次本地内存中的列式查询。没有网络延迟,没有 JSON 序列化,没有连接池。筛选和下钻即时响应。
  2. 服务器的工作量大幅减少。 在经典模式中,十个用户各点击二十次筛选,意味着在你的基础设施上执行两百次分析查询。而在这里只是十次数据下载。查询在用户的机器上运行,而不是你的。
  3. Java 仍然是唯一事实来源。 这不是"用 JavaScript 取代后端"。Java 仍然决定用户能看到哪些数据,应用安全和业务规则,并产出数据集。浏览器只能对交给它的数据提问。
  4. 这是 SQL,而不是定制的 JavaScript API。 前端说的是与你的数据库和数据团队相同的语言。对于两端都支持的 SQL,你在 DuckDB CLI 里或用 Java 通过 JDBC 原型的查询,可以在浏览器中不加修改地运行。浏览器版本与原生版本并不完全相同:它提供的扩展集更小,并且有自己的 HTTPFS 实现,因此依赖原生文件或网络 I/O 的查询正是两者可能产生分歧的地方。
  5. 可以离线工作,也便于分享。 加载完成后,页面无需服务器即可继续工作。这使它适合通过邮件发送的报告、在会议 Wi-Fi 不稳时使用的仪表板,或者交给你网络之外的人使用的探索工具。

当每个用户的数据集小到可以下载(几 MB 到低几百 MB),而针对它提出的问题数量很大时,这种做法效果最好。

场景

我们将构建一个极简的销售仪表板。Java 服务器生成 1,000 行销售数据(地区、产品、金额),并以 CSV 形式对外提供。浏览器一次性下载该 CSV,加载到 DuckDB-Wasm,用纯 SQL 计算每个地区的收入。

graph LR
    J[Java 后端] -->|一次性发送数据| T
    subgraph T[浏览器标签页]
        D[DuckDB-Wasm]
        D -->|本地 SQL| Q1[筛选]
        D -->|本地 SQL| Q2[分组]
        D -->|本地 SQL| Q3[下钻]
    end

Java 只响应两个请求:页面和数据。此后的一切都是同一标签页内页面与 DuckDB 之间的对话。

一步步构建

你只需要 JDK 17 或更高版本(java -version)。其他什么都不用安装:DuckDB-Wasm 从 jsDelivr CDN 加载。

第 1 步:创建项目文件夹

mkdir duckdb-wasm-java && cd duckdb-wasm-java

第 2 步:编写 Java 后端

创建 Server.java。它使用 JDK 内置的 HttpServer 提供两项内容:HTML 页面,以及生成 1,000 行销售数据的 /api/sales.csv 端点。

$ java
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Random;

public class Server {

    public static void main(String[] args) throws Exception {
        // 仅绑定回环地址:本演示没有鉴权,不要把它暴露到网络上
        var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 8080), 0);

        // 数据端点:由 Java 产出数据
        server.createContext("/api/sales.csv", ex -> {
            var rnd = new Random(42);
            String[] regions = {"North", "South", "East", "West"};
            String[] products = {"Coffee", "Tea", "Juice"};

            var sb = new StringBuilder("region,product,amount\n");
            for (int i = 0; i < 1000; i++) {
                sb.append(regions[rnd.nextInt(regions.length)]).append(',')
                  .append(products[rnd.nextInt(products.length)]).append(',')
                  .append(String.format(Locale.ROOT, "%.2f", 5 + rnd.nextDouble() * 95))
                  .append('\n');
            }
            send(ex, "text/csv", sb.toString().getBytes(StandardCharsets.UTF_8));
        });

        // 静态页面:分析在浏览器中完成
        server.createContext("/", ex ->
            send(ex, "text/html; charset=utf-8", Files.readAllBytes(Path.of("index.html"))));

        server.start();
        System.out.println("Open http://localhost:8080");
    }

    private static void send(HttpExchange ex, String type, byte[] body) throws IOException {
        ex.getResponseHeaders().add("Content-Type", type);
        ex.sendResponseHeaders(200, body.length);
        try (var os = ex.getResponseBody()) {
            os.write(body);
        }
    }
}

有一个细节需要注意:Locale.ROOT。在区域设置为荷兰语或德语的机器上,%.2f 会写成 12,34 而不是 12.34,这会破坏 CSV——而且只在那些使用小数逗号的区域设置机器上才会出现。

第 3 步:编写浏览器端

在同一目录下创建 index.html。脚本启动 DuckDB-Wasm,从 Java 端点获取 CSV,将其注册为虚拟文件,然后执行一次聚合。

$ markup
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>Java + DuckDB-Wasm</title>
  <style>
    body { font-family: system-ui, sans-serif; margin: 2rem; }
    table { border-collapse: collapse; }
    th, td { border: 1px solid #ccc; padding: .4rem .8rem; text-align: right; }
    th:first-child, td:first-child { text-align: left; }
  </style>
</head>
<body>
  <h1>Revenue by region</h1>
  <p id="status">Loading DuckDB…</p>
  <table id="results"></table>

  <script type="module">
    const status = document.getElementById('status');

    try {
      // 动态 import,这样 CDN 失败会落到下面的 catch 里,而不是控制台。
      // 版本被固定,新版本不会在你不知情的情况下改变页面行为。
      const duckdb = await import('https://cdn.jsdelivr.net/npm/@duckdb/[email protected]/+esm');

      // 1. 启动 DuckDB-Wasm
      const bundle = await duckdb.selectBundle(duckdb.getJsDelivrBundles());
      const workerUrl = URL.createObjectURL(
        new Blob([`importScripts("${bundle.mainWorker}");`], { type: 'text/javascript' })
      );
      const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), new Worker(workerUrl));
      await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
      URL.revokeObjectURL(workerUrl);

      // 2. 从 Java 后端获取数据,并注册为一个文件
      status.textContent = 'Fetching data from Java…';
      const response = await fetch('/api/sales.csv');
      if (!response.ok) throw new Error(`Data request failed: ${response.status}`);
      const csv = await response.text();
      await db.registerFileText('sales.csv', csv);

      // 3. 在浏览器中用 SQL 查询
      const conn = await db.connect();
      await conn.query(`CREATE TABLE sales AS SELECT * FROM read_csv('sales.csv')`);
      const result = await conn.query(`
        SELECT region,
               count(*)               AS orders,
               round(sum(amount), 2)  AS revenue,
               round(avg(amount), 2)  AS avg_order
        FROM sales
        GROUP BY region
        ORDER BY revenue DESC
      `);

      // 4. 渲染结果
      const rows = result.toArray().map(r => r.toJSON());
      document.getElementById('results').innerHTML =
        `<tr><th>Region</th><th>Orders</th><th>Revenue</th><th>Avg order</th></tr>` +
        rows.map(r => `<tr><td>${r.region}</td><td>${r.orders}</td>
                           <td>${r.revenue}</td><td>${r.avg_order}</td></tr>`).join('');
      status.textContent = `Analysed ${csv.split('\n').length - 2} rows in the browser.`;

      await conn.close();
    } catch (err) {
      status.textContent = `Could not load the dashboard: ${err.message}`;
      throw err;
    }
  </script>
</body>
</html>

这里发生了几件事:

  • selectBundle 为当前运行的浏览器挑选合适的 WebAssembly 构建。
  • DuckDB 运行在 Web Worker 中,因此繁重的查询不会卡住 UI。Blob 包装层让该 worker 能够绕过跨域规则从 CDN 加载。
  • registerFileText 把下载的 CSV 放进 DuckDB 的虚拟文件系统,于是 read_csv('sales.csv') 就像文件在磁盘上一样工作。
  • 结果以 Apache Arrow 表的形式返回;toArray() 和 toJSON() 把它们转成普通的 JavaScript 对象。

第 4 步:运行它

Java 的单文件源码启动器把编译和运行合成一步:

java Server.java

第 5 步:打开页面

访问 http://localhost:8080。稍等片刻(Wasm 二进制文件首次加载有几 MB,之后会被缓存),你会看到一张表,包含每个地区的订单数、收入和平均订单金额。

Java 只产出了原始行;GROUP BY 在你的浏览器标签页里执行。加一个按产品筛选并重新运行查询的下拉框,它会在不向服务器发出任何额外请求的情况下更新。

与其他技术栈的对比

Java 加 DuckDB-Wasm 只是若干选项之一,而且并非默认答案。下面是它与 Java 团队通常考虑的技术栈的对比。

技术栈查询运行位置每次交互的服务器开销最擅长的场景主要权衡
Java + DuckDB-Wasm(本文)浏览器首次下载后为零对每用户数据集进行快速、交互式探索数据必须能装进标签页;用户能看到你发送的一切
Spring Boot REST + PostgreSQL数据库服务器每次点击一次查询 + JSON 往返大型共享数据、严格的访问控制、事务型应用延迟和服务器负载随每次筛选增长
Java + DuckDB via JDBC(服务端)Java 进程每次点击一次进程内查询无需独立数据库即可对文件和 Parquet 做快速分析每次交互仍有一次网络往返
Vaadin 或其他服务端驱动 UI服务器UI 状态和查询都在服务器上完全不想要 JavaScript 的纯 Java 团队每次交互都是一个服务器事件
SQLite Wasm浏览器下载后为零小型本地存储、带事务写入的离线优先应用面向行存储;并非为重聚合而建
JS 数据类库(Arquero、Danfo.js)浏览器下载后为零使用 JavaScript API 的轻量转换没有 SQL;你的逻辑无法移植到后端
FINOS Perspective浏览器(Wasm)为零,或流式更新透视表格与流式、实时视图更像一个组件,而非通用 SQL 引擎
ClickHouse、Druid、Pinot专用 OLAP 集群每次点击一次查询,但非常快数十亿行、大量并发用户需要运行和付费的基础设施

实践中有三个比较最重要。

  • 对比经典的 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 构建,所以仅靠响应头无法改变这一点。多线程执行意味着既要提供 coi bundle,又要设置跨源隔离头(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 语句:

$ java
try (var conn = DriverManager.getConnection("jdbc:duckdb:");
     var stmt = conn.createStatement()) {
    stmt.execute("""
        COPY (
          SELECT ['North','South','East','West'][1 + floor(random() * 4)::INT] AS region,
                 ['Coffee','Tea','Juice'][1 + floor(random() * 3)::INT]     AS product,
                 round(5 + random() * 95, 2)                                 AS amount
          FROM range(1000)
        ) TO 'sales.parquet' (FORMAT parquet)
        """);
}

使用 Content-Type: application/octet-stream 提供该文件,并在浏览器中将步骤 2 和步骤 3 替换为二进制获取和 Parquet 读取:

$ node
// 2. Fetch Parquet (binary) instead of CSV
const response = await fetch('/api/sales.parquet');
if (!response.ok) throw new Error(`Data request failed: ${response.status}`);
const buf = new Uint8Array(await response.arrayBuffer());
await db.registerFileBuffer('sales.parquet', buf);

// 3. Read it
const conn = await db.connect();
await conn.query(`CREATE TABLE sales AS SELECT * FROM read_parquet('sales.parquet')`);

单文件启动器仍然可用;只需将驱动 JAR 放到 classpath 上:java -cp duckdb_jdbc.jar Server.java。

现在,同一个引擎和同一套 SQL 可以在两个地方运行:JVM 中的 DuckDB 负责准备数据并保障其安全,浏览器中的 DuckDB 负责探索数据。你只需编写一次查询,就可以让它运行在任何合理的一侧。

总结

你不必在 Java 后端和响应式分析前端之间二选一。让 Java 做它擅长的事(掌控数据、执行规则、生成干净的数据集),让 DuckDB-Wasm 处理用户想要探索的一切 SQL。从上面的两个文件开始,添加一个筛选下拉框,原本会发往服务器的查询就会留在浏览器中。