DuckDB – 专为笔记本电脑打造的数据工具,现已支持 Clojure (2023)
DuckDB – Data power tools for your laptop, now in Clojure (2023)

原始链接: https://techascent.com/blog/just-ducking-around.html

基于 Clojure 的 `tech.ml.dataset` (TMD) 库非常适合函数式数据科学,但在处理超出系统内存的数据集时存在局限性。传统方法通常仅限于抽样或子集化,而通过 `tmducken` 库集成 **DuckDB** 则无需复杂的分布式集群即可解决这些扩展性问题。 DuckDB 提供了一个高性能的向量化 SQL 引擎,可与 TMD 无缝协作。DuckDB C 接口的最新更新支持高效的批量数据处理,使用户能够在几秒钟内于本地机器上执行复杂操作,例如连接数亿行数据。 这种集成的关键优势包括: * **内存效率:** 系统支持惰性批量处理和“零拷贝”查询路径,确保操作永远不会超出内存限制。 * **性能:** DuckDB 利用自动 minmax 和 ART 索引来加速查询速度。 * **协同效应:** 在保留 TMD 函数式编程优势的同时,获得了专业数据库强大的存储和关系查询能力。 通过结合 DuckDB 和 TMD,开发人员可以在标准硬件上处理海量数据集,为 Spark 等复杂的分布式系统提供了一种高效且经济的替代方案。

这篇 Hacker News 帖子讨论了 DuckDB 在 Clojure 生态系统中的应用,重点介绍了 `tmducken` 和 `ducktape` 等工具,它们将这款高性能数据库直接集成到了 Clojure 的工作流中。 核心要点包括: * **性能优于 JDBC:** 用户更青睐这些 Clojure 封装库而非标准 JDBC 驱动,因为它们利用了 DuckDB 的批处理 C-API,在执行大型关联查询和数据处理时效率更高。 * **多功能性:** DuckDB 被誉为“强大引擎”,能够查询多种格式(JSON、Parquet、CSV)并连接到各种数据源,包括 OLTP 数据库和云对象存储。 * **可扩展性:** 多位开发者指出,对于许多“大数据”任务,使用 DuckDB 和本地 NVMe 存储的单节点架构,通常比管理复杂的 Spark 集群更快且成本更低。 * **应用场景:** 除了标准分析外,参与者还强调了它在可观测性方面的实用性,即“湖仓一体”架构允许对本地缓存的热数据进行快速的自托管查询,而以 S3 作为冷数据的可靠来源。 总体而言,社区认为这些工具是高性能单节点数据工程领域的一项重大进步。
相关文章

原文

2023-09-02

Establishing the Need

Our in-memory column-major data processing platform, tech.ml.dataset (TMD), drives the future of functional data science. When data gets large enough not to fit in memory, one can continue with TMD by operating on samples of data, or otherwise filtering to relevant subsets to fit in bounds imposed by the working environment. Moreover, one can accomplish persistence, for data small and large, with nippy, arrow, or parquet.

When data becomes large enough, for example sets of .csv files on the order of ~100GB with relational aspects to them, the tools in their current state can become unwieldy. One is tempted to get involved in nonfunctional sparky cluster snafus. Of course, maintaining some level of transactional interaction and a simple disk IO model is still super-desirable. Local disks are big enough, and local chips are fast enough, no need to do anything rash.

Relational databases are well adapted for out-of-memory storage and fast relational queries - but, how to leverage this without giving up the advantages of functional programming, and TMD's column-major processing model? JDBC, along with Postgres, provide a good first answer to this question, but it's irritating to perform a full row-to-column conversion through an inefficient, non-batched API in order to get the data through JDBC and into TMD.

A New Challenger Appears

DuckDB showed up via a github issue in May of 2021 and tmducken was minimally integrated with their C bindings by December of that year. In that version, all query results were returned at once, and so needed to fit in memory. Also, in those early days of DuckDB there was not a specific high performance append or insert system, so IO was limiting potential performance, and Postgres persisted as the adjunct processing system to TMD. Much has changed since then.

In the last two years, DuckDB improved a lot. Importantly, the C interface now provides a batched system for both inserts and querying, which enables processing very large joins - more on that later. These improved capabilities can now be leveraged in Clojure, through TMD, to access DuckDB's state of the art vectorized SQL execution engine, and it's good.

Actual Use

Building on our previous post, there is a 50 gigabyte .csv file with 3 years of transaction data, totaling 400,000,000 rows:

$ ll -h data.csv
-rw-rw-r-- 1 harold harold 50G Aug  8 09:49 data.csv

Loading that into DuckDB is surprisingly easy - though, you do have to wait 2 minutes:

$ time duckdb data.ddb 'CREATE TABLE data AS FROM "data.csv";'
100% ▕████████████████████████████████████████████████████████████▏

real	1m50.091s
user	21m42.693s
sys	0m57.887s
$ ll -h data.ddb
-rw-rw-r-- 1 harold harold 18G Sep  6 10:57 data.ddb

So, that reduced the file to 18GB, which includes all of the indexes (!) created automatically by DuckDB.

The data is in there:

$ duckdb data.ddb
v0.8.1 6536a77232
Enter ".help" for usage hints.
D SELECT COUNT(*) AS n FROM data;
┌───────────┐
│     n     │
│   int64   │
├───────────┤
│ 400000000 │
└───────────┘
D DESCRIBE TABLE data;
┌────────────────┬─────────────┬─────────┬─────────┬─────────┬─────────┐
│  column_name   │ column_type │  null   │   key   │ default │  extra  │
│    varchar     │   varchar   │ varchar │ varchar │ varchar │ varchar │
├────────────────┼─────────────┼─────────┼─────────┼─────────┼─────────┤
│ customer-id    │ VARCHAR     │ YES     │         │         │         │
│ day            │ BIGINT      │ YES     │         │         │         │
│ inst           │ TIMESTAMP   │ YES     │         │         │         │
│ month          │ BIGINT      │ YES     │         │         │         │
│ brand          │ VARCHAR     │ YES     │         │         │         │
│ style          │ VARCHAR     │ YES     │         │         │         │
│ sku            │ VARCHAR     │ YES     │         │         │         │
│ year           │ BIGINT      │ YES     │         │         │         │
│ transaction-id │ VARCHAR     │ YES     │         │         │         │
│ quantity       │ BIGINT      │ YES     │         │         │         │
│ price          │ DOUBLE      │ YES     │         │         │         │
├────────────────┴─────────────┴─────────┴─────────┴─────────┴─────────┤
│ 11 rows                                                    6 columns │
└──────────────────────────────────────────────────────────────────────┘

Accessing this from Clojure, through TMD, is also easy:

user> (require '[tmducken.duckdb :as duckdb])
nil
user> (require '[tech.v3.dataset :as ds])
nil
user> (duckdb/initialize!)
Sep 06, 2023 11:00:12 AM clojure.tools.logging$eval7454$fn__7457 invoke
INFO: Attempting to load duckdb from "./binaries/libduckdb.so"
true
user> (def db (duckdb/open-db "data.ddb"))
#'user/db
user> (def conn (duckdb/connect db))
#'user/conn
user> (time (duckdb/sql->dataset conn "SELECT COUNT(*) AS n FROM data"))
"Elapsed time: 10.305756 msecs"
:_unnamed [1 1]:

|         n |
|----------:|
| 400000000 |

Now, imagine management lets us know that there is another dataset, that captures information for each sku about what colors the product is. That needs to be in the database as well:

user> (-> (let [colors ["red" "green" "blue" "yellow" "purple" "black" "white"]]
            (->> (for [brand (range 100)
                       style (range 10)
                       item (range 10)]
                   (let [sku (format "sku-%s-%s-%s" brand style item)
                         n (rand-int 8)]
                     (for [color (take n (shuffle colors))]
                       {"sku" sku
                        "color" color})))
                 (apply concat)))
          (ds/->dataset {:dataset-name "colors"}))
colors [35179 2]:

|        sku |  color |
|------------|--------|
|  sku-0-0-0 |    red |
|  sku-0-0-0 |   blue |
|  sku-0-0-0 |  white |
|  sku-0-0-0 | yellow |
|  sku-0-0-0 |  black |
|  sku-0-0-0 |  green |
|  sku-0-0-1 |  black |
|  sku-0-0-1 | yellow |
|  sku-0-0-1 |   blue |
|  sku-0-0-1 | purple |
|        ... |    ... |
| sku-99-9-8 | yellow |
| sku-99-9-8 | purple |
| sku-99-9-8 |  black |
| sku-99-9-8 |    red |
| sku-99-9-8 |  white |
| sku-99-9-8 |   blue |
| sku-99-9-8 |  green |
| sku-99-9-9 | purple |
| sku-99-9-9 |   blue |
| sku-99-9-9 |  black |
| sku-99-9-9 |  green |
user> (duckdb/create-table! conn *1)
"colors"
user> (duckdb/insert-dataset! conn *2)
35179

You know where this is going. We need to join the 400M transactions, each with a sku, with this data that implies each sku has about 3.51 colors.

Luckily, their first request is relatively simple. "How many items of each color were sold in March of 2021?", and "When can we know?" they ask.

;; First, because you can, join 1.4 billion rows on your laptop in 2.5s...
user> (time (duckdb/sql->dataset conn "SELECT COUNT(*) FROM data INNER JOIN colors ON data.sku = colors.sku;"))
"Elapsed time: 2486.620275 msecs"
:_unnamed [1 1]:

| count_star() |
|-------------:|
|   1416737859 |

;; Then, answer their question...
user> (time (duckdb/sql->dataset conn "SELECT color, COUNT(*) FROM data INNER JOIN colors ON data.sku = colors.sku WHERE data.year='2021' AND data.month='3' GROUP BY color;"))
"Elapsed time: 1077.723309 msecs"
:_unnamed [7 2]:

|  color | count_star() |
|--------|-------------:|
|    red |      5714223 |
| yellow |      5652010 |
|  black |      5720753 |
|   blue |      5750846 |
|  white |      5689916 |
|  green |      5816652 |
| purple |      5671959 |

We can know 1s from now. Here are the answers.

Maybe the next question is not well suited to SQL, and doing the processing in Clojure, with TMD, would be better. This example reduces over every transaction of the boss' favorite sku, sorted chronologically (in 1s):

user> (time
       (reduce (fn [eax ds]
                 (conj eax (ds/row-count ds)))
               []
               (duckdb/sql->datasets conn "SELECT * FROM data WHERE sku='sku-50-5-5' ORDER BY inst")))
"Elapsed time: 1067.480751 msecs"
[2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 2048 576 733]

Of course, this reduction function is trivial, but it proves the point - realized datasets are available for arbitrary processing, through reduction, by a mechanism that will never run out of memory.

DuckDB also supports a zero copy query pathway. If no chunk of the query result needs to escape the reducing function, then the machine can possibly do less work. In the example below this is turned on and accessed by passing the {:reduce-type :zero-copy-imm} option.

When processing can be expressed in this manner, this is theoretically the lowest memory pathway available.

user> (time (let [sql "SELECT * FROM data WHERE sku='sku-50-5-5' ORDER BY inst"
                  options {:reduce-type :zero-copy-imm}]
              (reduce (fn [eax zc-ds]
                        (conj eax (ds/row-count zc-ds)))
                      []
                      (duckdb/sql->datasets conn sql options))))
"Elapsed time: 1037.480113 msecs"
[2048 531 2048 531 2048 531 2048 531 2048 531 2048 531 2048 531 2048 531 2048 531 2048 531 2048 531 2048 531 2048 531 2048 531 2048 531 1408]

Hopefully that gives a sense of the power afforded by this system, now.

Some Interesting DuckDB TidBits

DuckDB automatically stores all numeric data in minmax indexes - also called BRIN indexes. These do not add significantly to the original data size but do dramatically increase query performance. It will also automatically create ART indexes in the case of unique or primary key columns. Finally, users can optionally create indexes for categorical columns but the drawback is increased disk storage size and potentially slower transactions.

DuckDB is written in standard C++11 and is thus fairly portable - they had a variant built for the mac m-1 quickly and if you had another platform and wanted some special sauce we would feel comfortable compiling and modifying the database for that platform. The src directory of their codebase, as of this writing (September 2023) clocks in at about 100,000 LOC of C++ code:

(base) chrisn@chrisn-lp2:~/dev/cnuernber/duckdb$ cloc src
    1612 text files.
    1612 unique files.
       0 files ignored.

github.com/AlDanial/cloc v 1.90  T=0.83 s (1936.0 files/s, 203773.8 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
C++                            831          13749           8912          99454
C/C++ Header                   679           7754           9903          28287
CMake                          101             49              0           1541
Markdown                         1              7              0             15
-------------------------------------------------------------------------------
SUM:                          1612          21559          18815         129297
-------------------------------------------------------------------------------

DuckDB is MIT licensed, has an open github development model, and their community is quick to answer questions. Developing such a high quality power tool in such an open manner is honorable.

Wrapping Up

Try out our duckdb integration, or to hire us to do it for you. DuckDB complements TMD well and greatly increases a small team's ability to efficiently manage and process large datasets without needing to resort to expensive distributed solutions. This integration supports and represents the value of high quality and efficient compute tools to truly democratize data processing by enabling functional solutions on laptops that others with duller tools will reach for a cluster to manage.


TechAscent: Getting our ducks in a row.

Contact us

联系我们 contact @ memedata.com