
上个月我接了个活儿。一个电商客户手里有几年的用户行为日志——浏览、下单、退款、点广告,全堆在 JSONL 文件里。
客户想做用户分层模型。我兴冲冲把日志往模型里一塞,模型直接翻脸。
原因很简单:模型要的是"这个用户买了几次、花了多少钱、活跃了几天"这种**用户级特征**。原始日志是"谁在哪一秒干了啥"的零散事件。两者中间差着一层。
那一刻我明白了:原始事件到模型特征之间,缺的不是模型,是一条管道。
我花了 5 天,用 DuckDB + Polars + Parquet 把这条管道搭了起来。今天先讲最底层的认知:三个工具各管一摊,谁也替代不了谁。
先给一句话定义:
类比钉死:它们仨就像一家小饭馆的**仓库、点单台、后厨**。仓库管存货(Parquet),点单台管你要什么菜(SQL 查询),后厨管把食材做成能上桌的菜(Polars 清洗转换)。饭馆不是一个角色在扛,管道也一样。
CSV 是**按行**存:一行一条记录,读的时候整行搬进来。Parquet 是**按列**存:user_id 一列、amount 一列、timestamp 一列。
好处直接看表:
| 对比项 | CSV(行式) | Parquet(列式) |
| ---- | --------- | ----------- |
| 读取方式 | 整行读 | 只读需要的列 |
| 压缩 | 一般 | 更好 |
| 类型 | 全是字符串 | 保留真实类型 |
| 分析查询 | 慢 | 快 |
| 重复解析 | 每次都要解析字符串 | 不用 |
如果模型只要 user\_id、amount、country 三列,CSV 也得把 email、phone、browser 整行搬进来。Parquet 直接跳过这些列,拎你要的走。
这就是列式存储的价值:**用多少,读多少。**
传统数据库要先起服务、建连接、建表。DuckDB 不用,直接在 Python 里跑:
import duckdb
connection = duckdb.connect()它最擅长的,是直接对着 Parquet 文件写 SQL:
result = connection.execute("""
SELECT user\_id, amount
FROM read\_parquet('data/bronze/events.parquet')
WHERE event = 'purchase'
AND amount > 0
""").fetchall()注意这里没有"先把 Parquet 读成 DataFrame"。是 DuckDB 直接查文件。它像是图书馆里那个不用你办卡、对着书架就能报出答案的管理员。
Polars 干的是脏活:类型转换、日期处理、按用户聚合。代码是表达式风格,不是逐行 for 循环。
import polars as pl
events = pl.read\_parquet("data/bronze/events.parquet")
cleaned = events.with\_columns(
pl.col("amount").cast(pl.Float64),
pl.col("timestamp").str.to\_datetime(),
)这句的意思是:把 amount 转成浮点数,timestamp 转成真正的日期类型。一行写完,Polars 在底层并行处理,比你手写循环快得多。
这个项目踩的第一个概念坑,就是这俩。
**JSON** 整个文件必须是一个合法结构,通常是个数组:
[
{"user\_id": 1001, "event": "purchase"},
{"user\_id": 1002, "event": "page\_view"}
]**JSONL**(也叫 NDJSON)每行一个独立 JSON:
{"user\_id": 1001, "event": "purchase"}
{"user\_id": 1002, "event": "page\_view"}类比:JSON 像一整本装订好的书,你得捧着整本读;JSONL 像收据打印机吐出来的纸条,一件一件往下接。
为什么日志用 JSONL?因为它:
电商日志是持续产生的,JSONL 正好对得上这个节奏。
我按"数据成熟度"建了四层目录,灵感来自数据湖的 bronze / silver / gold(铜 / 银 / 金)分层:
| 目录 | 存什么 | 类比 |
| ------------- | ------------- | ------- |
| data/raw | 原始日志,绝不改 | 刚挖出来的矿石 |
| data/bronze | 统一转成的 Parquet | 粗炼后的锭 |
| data/silver | 清洗后的业务明细 | 精炼金属 |
| data/gold | 用户级模型特征 | 成品零件 |
第 1 天我没急着写业务,先把环境跑通。用 uv 管依赖,不用系统 Python:
import duckdb
import polars as pl
import pyarrow
import pydantic
def main() -> None:
print("DuckDB:", duckdb.\_\_version\_\_)
print("Polars:", pl.\_\_version\_\_)
print("PyArrow:", pyarrow.\_\_version\_\_)
print("Pydantic:", pydantic.\_\_version\_\_)
if \_\_name\_\_ == "\_\_main\_\_":
main()运行就一句:
uv run python main.py这条没业务逻辑的脚本,价值在确认三件事:依赖装好了、uv 能跑、四个库都能导入。地基稳了,后面才敢往上盖。
5 天下来我最深的体会一句话:
**Parquet、DuckDB、Polars 不是谁替代谁,而是管道里三个分工明确的工位。**
存归存、查归查、转归转。以前我图省事,什么都往 Pandas + CSV 里塞,结果文件越攒越大、查询越来越慢。分清楚"存储、查询、转换"三件事,代码反而清爽了。
下篇我打算写 Bronze 层怎么把 JSONL 落进 Parquet,以及 DuckDB 怎么在文件上直接筛出购买事件。你平时处理数据,是 Pandas 一把梭,还是会像这样把存储、查询、转换拆开?评论区聊聊你踩过的坑。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。