快轉到主要內容
  1. 教學文章/

DuckDB 遠端 Parquet 實戰:S3/R2、httpfs、Secrets 與 Pushdown

·7 分鐘· loading · loading · ·
Python DuckDB Parquet S3 Cloudflare R2 Httpfs Data-Engineering
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 108: 本文

featured

一. 前言:資料在雲端,不代表要先下載
#

很多資料管線都有同一個儀式:先把 S3 檔案下載到本機,解壓、查詢,最後再清掉暫存檔。 資料只有幾 MB 時沒什麼感覺;bucket 累積幾十 GB 的 Parquet 後,真正浪費時間的常常不是 SQL,而是搬回根本用不到的欄位與月份。 DuckDB 的 httpfs extension 可以直接讀取 HTTPS、S3 與 S3-compatible object storage。 配合 Parquet 的 columnar layout、row group statistics 與 HTTP Range request,查詢只需要取得相關片段。 這篇拍拍君會完成一條可重用的遠端查詢流程:

  • 用 HTTPS 公開檔案理解 Range read
  • 用 DuckDB Secrets 安全連接 S3 與 Cloudflare R2
  • 用 glob 與 Hive partition 掃描多檔資料集
  • 用 projection/filter pushdown 減少傳輸
  • EXPLAIN ANALYZE 找出遠端 I/O 問題

如果還沒碰過 DuckDB,建議先讀 DuckDB 基礎篇;想理解 Parquet schema 與 dataset,則可搭配 PyArrow 實戰

二. 安裝:DuckDB 加上 httpfs
#

建立一個乾淨專案:

uv init duckdb-remote-demo
cd duckdb-remote-demo
uv add duckdb

使用 pip 也可以:

python -m pip install duckdb

進入 DuckDB CLI 或 Python connection 後,安裝並載入 httpfs

INSTALL httpfs;
LOAD httpfs;

INSTALL 會把 extension 安裝到本機,通常只需一次;LOAD 則載入目前的 DuckDB process。 一般連網環境第一次使用 HTTPS 或 S3 時也會自動載入官方 extension,但部署與除錯時明確寫出兩步,失敗位置比較清楚。 Python 版可以集中在 connection 初始化:

from __future__ import annotations

import duckdb


def open_analytics() -> duckdb.DuckDBPyConnection:
    con = duckdb.connect("analytics.duckdb")
    con.execute("INSTALL httpfs")
    con.execute("LOAD httpfs")
    return con

若只是一次性查詢,可改成 duckdb.connect(":memory:")。 會重複查同一批遠端資料時,重用 connection 通常更有效率,因為 metadata 與部分遠端資料可以留在 cache。

三. 先從公開 HTTPS Parquet 開始
#

S3 認證會增加變數,所以先用公開 HTTPS 檔案確認整條路徑:

SELECT
    play_name,
    count(*) AS line_count
FROM read_parquet(
    'https://blobs.duckdb.org/data/shakespeare.parquet'
)
GROUP BY play_name
ORDER BY line_count DESC
LIMIT 5;

DuckDB 會透過 httpfs 讀取遠端檔,不要求你先 curl/tmp。 一開始先看欄位,別急著 SELECT *

DESCRIBE SELECT *
FROM read_parquet(
    'https://blobs.duckdb.org/data/shakespeare.parquet'
);

也可以只看 file-level metadata:

SELECT
    num_rows,
    num_row_groups,
    file_size_bytes,
    created_by
FROM parquet_file_metadata(
    'https://blobs.duckdb.org/data/shakespeare.parquet'
);

DuckDB 可以先取得 Parquet footer,再用 HTTP Range request 讀需要的 column chunks。 CSV 是 row-oriented text,多數情況仍得把整份內容讀過才能判斷哪些列符合條件。 遠端分析優先選 Parquet,不是因為副檔名比較潮,而是它讓 query engine 有機會少讀很多資料。

四. 連接 S3:用 Secrets Manager 管認證
#

舊文章常用 SET s3_access_key_idSET s3_secret_access_key。 DuckDB 現在建議使用 Secrets Manager:可以設定 type、provider 與 scope,也比較不容易在檢查 settings 時意外曝光認證。 在 AWS CLI 已登入、環境能取得 credentials 時,使用 credential chain:

CREATE OR REPLACE SECRET analytics_s3 (
    TYPE s3,
    PROVIDER credential_chain,
    REGION 'ap-northeast-1',
    SCOPE 's3://pypy-analytics/events/'
);

接著直接查詢:

SELECT count(*) AS rows
FROM read_parquet(
    's3://pypy-analytics/events/year=2026/month=08/*.parquet'
);

SCOPE 會讓 credential 只套用在指定 prefix。 同一個 process 讀多個組織或環境的 bucket 時,DuckDB 會選擇路徑最精確的 secret scope。 若是短命 API key,也能用 config provider:

CREATE OR REPLACE SECRET temporary_s3 (
    TYPE s3,
    PROVIDER config,
    KEY_ID 'REPLACE_ME',
    SECRET 'REPLACE_ME',
    SESSION_TOKEN 'REPLACE_ME',
    REGION 'ap-northeast-1',
    SCOPE 's3://pypy-analytics/events/'
);

這只是語法模板;真實專案不要把值 commit 進 SQL 或 Python。 應由部署平台的 secret store、AWS profile、role 或短期環境變數提供。 CREATE SECRET 預設是 process 生命週期內的 temporary secret。 CREATE PERSISTENT SECRET 雖方便,但官方文件提醒 persistent secrets 會以未加密 binary 格式存在磁碟;共用主機上不要把它當加密保管箱。

五. Cloudflare R2:使用 r2:// 與專用類型
#

R2 提供 S3-compatible API,DuckDB 另外提供 R2 secret type,會依 ACCOUNT_ID 組出 endpoint:

CREATE OR REPLACE SECRET analytics_r2 (
    TYPE r2,
    PROVIDER config,
    KEY_ID 'REPLACE_ME',
    SECRET 'REPLACE_ME',
    ACCOUNT_ID 'REPLACE_WITH_ACCOUNT_ID',
    SCOPE 'r2://pypy-lake/events/'
);

查詢時也使用 r2://

SELECT
    event_type,
    count(*) AS event_count
FROM read_parquet(
    'r2://pypy-lake/events/year=2026/month=08/*.parquet'
)
GROUP BY event_type
ORDER BY event_count DESC;

R2、MinIO、lakeFS 與其他 S3-compatible service 的支援不一定完全相同。 公開讀取依靠 Range request,私有讀取需要認證,glob 需要 ListObjectsV2,遠端寫入則需要 multipart upload。 所以「能讀一個已知 object」不代表 glob 與寫入也一定能用。

六. 多檔、Glob 與 Hive Partition Pruning
#

真實資料湖通常每天或每小時新增一批 Parquet:

s3://pypy-analytics/events/
├── year=2026/month=07/part-000.parquet
├── year=2026/month=08/part-000.parquet
└── year=2026/month=08/part-001.parquet

DuckDB 可以把 Hive-style path 裡的 key 當成 column:

SELECT
    event_type,
    sum(amount) AS revenue
FROM read_parquet(
    's3://pypy-analytics/events/**/*.parquet',
    hive_partitioning = true
)
WHERE year = 2026
  AND month = 8
GROUP BY event_type
ORDER BY revenue DESC;

這裡有兩層減量:partition filter 先排除整個 object,Parquet filter 再依 row group statistics 跳過不相關資料。 需要追查來源時,選取 filename virtual column:

SELECT filename, event_id, event_type
FROM read_parquet(
    's3://pypy-analytics/events/year=2026/month=08/*.parquet'
)
WHERE event_type = 'purchase'
LIMIT 20;

如果不同日期新增 optional column,可使用 union_by_name = true 按欄名對齊,缺少欄位會變成 NULL。 它適合漸進新增欄位,不該拿來掩蓋 incompatible type change。 先檢查 schema 更安全:

SELECT file_name, name, type, logical_type
FROM parquet_schema(
    's3://pypy-analytics/events/year=2026/month=08/*.parquet'
)
WHERE name IN ('event_id', 'occurred_at', 'amount');

若需要更通用的 storage API,例如 memory filesystem、zip 或 GCS,可讀 fsspec 實戰;這篇專注 DuckDB 自己的遠端掃描能力。

七. Projection 與 Filter Pushdown:少讀才是真的快
#

昂貴版本通常長這樣:

SELECT *
FROM read_parquet(
    's3://pypy-analytics/events/year=2026/month=08/*.parquet'
);

若只要每日營收,應在 SQL 裡直接選欄位、過濾與聚合:

SELECT
    CAST(occurred_at AS DATE) AS day,
    sum(amount) AS revenue
FROM read_parquet(
    's3://pypy-analytics/events/year=2026/month=08/*.parquet'
)
WHERE status = 'paid'
  AND occurred_at >= TIMESTAMP '2026-08-20 00:00:00'
GROUP BY day
ORDER BY day;

DuckDB 會自動做兩件事:

  • Projection pushdown:只讀 occurred_atamountstatus
  • Filter pushdown:把條件推進 Parquet scan,用 statistics 跳過 row group

Pushdown 不代表任何 WHERE 都能完全下推。 複雜函式、型別轉換、資料分布與缺少 statistics 都可能降低 pruning 效果。 因此不要憑感覺,要看 query plan:

EXPLAIN ANALYZE
SELECT sum(amount)
FROM read_parquet(
    's3://pypy-analytics/events/year=2026/month=08/*.parquet'
)
WHERE status = 'paid'
  AND occurred_at >= TIMESTAMP '2026-08-20 00:00:00';

遠端檔案的 EXPLAIN ANALYZE 可以顯示 request 數量與傳輸量。 比較版本時不要只看總秒數;網路會抖,讀了多少 object、發出多少 request、傳輸多少 bytes 更能解釋差異。

八. 用 Metadata 判斷能不能跳過
#

Parquet row group statistics 決定 filter pruning 有多少發揮空間:

SELECT
    row_group_id,
    path_in_schema,
    stats_min_value,
    stats_max_value,
    row_group_num_rows
FROM parquet_metadata(
    's3://pypy-analytics/events/year=2026/month=08/part-000.parquet'
)
WHERE path_in_schema IN ('occurred_at', 'status', 'amount')
ORDER BY row_group_id, path_in_schema;

若每個 row group 的 occurred_at 只涵蓋一天,查單日就能跳過其他 group。 若時間完全亂序,每個 group 的 min/max 都跨整月,filter 仍正確,但 pruning 空間變小。 產生資料時,可以讓常用 filter column 大致有序,並選擇合理 row group size:

COPY (
    SELECT * FROM clean_events ORDER BY occurred_at
)
TO 'events-2026-08.parquet' (
    FORMAT parquet,
    COMPRESSION zstd,
    ROW_GROUP_SIZE 100000
);

另外別把 partition 切得太碎。 每分鐘一個小檔會製造大量 LIST、HEAD 與 GET,metadata overhead 甚至可能大於真正掃描資料的時間。 查詢端的 pushdown 上限,往往在資料產生時就已經決定一半。

九. Python 實戰:受控的遠端摘要器
#

把路徑留在程式內,使用者只傳年月,避免任意 SQL 與 bucket path:

from dataclasses import dataclass

import duckdb


@dataclass(frozen=True)
class MonthlyQuery:
    year: int
    month: int

    @property
    def parquet_glob(self) -> str:
        if not 2020 <= self.year <= 2100:
            raise ValueError("year 超出允許範圍")
        if not 1 <= self.month <= 12:
            raise ValueError("month 必須介於 1 到 12")
        return (
            "s3://pypy-analytics/events/"
            f"year={self.year}/month={self.month:02d}/*.parquet"
        )

查詢先在 DuckDB 完成 filter 與 aggregation,再回傳小結果:

SUMMARY_SQL = """
    SELECT
        event_type,
        count(*) AS event_count,
        sum(amount) FILTER (WHERE status = 'paid') AS paid_amount
    FROM read_parquet(?)
    GROUP BY event_type
    ORDER BY event_count DESC
"""


def monthly_summary(
    con: duckdb.DuckDBPyConnection,
    query: MonthlyQuery,
):
    return con.execute(
        SUMMARY_SQL,
        [query.parquet_glob],
    ).fetch_arrow_table()

最後建立 scoped secret:

con = open_analytics()
con.execute("""
    CREATE OR REPLACE SECRET analytics_s3 (
        TYPE s3,
        PROVIDER credential_chain,
        REGION 'ap-northeast-1',
        SCOPE 's3://pypy-analytics/events/'
    )
""")
print(monthly_summary(con, MonthlyQuery(2026, 8)))

fetch_arrow_table() 適合把小結果交給 PyArrow、Polars 或其他 columnar tool。 不要把整份遠端明細轉成 pandas 之後才過濾,那會放棄前面努力爭取的 pushdown。

十. 常見錯誤與上線檢查
#

Connection error for HTTP HEAD:先查 endpoint、region、DNS 與 TLS;AWS bucket region 不符時,應在 secret 指定正確 region 或 endpoint。 403 Forbidden:檢查 scope、credential 與 IAM;glob 還需要列舉 object 的權限。 單檔能讀但 glob 不行:S3 glob 依賴 ListObjectsV2,自架 service 若未實作完整 list API,就傳入明確檔案清單。 少量查詢仍很慢:用 EXPLAIN ANALYZE 檢查 SELECT *、filter pushdown、partition pruning、tiny files 與 connection 是否反覆重建。 上線前再走一次 checklist:

  • Secrets 由平台注入,沒有 key 進入 repo
  • Secret 使用最小必要 SCOPE 與 IAM 權限
  • 查詢明確選欄位,日期與 partition filter 盡早出現
  • EXPLAIN ANALYZE 記錄 request 與傳輸量
  • Schema 演進有檢查,不濫用 union_by_name
  • 避免大量 tiny Parquet files
  • 服務重用 connection,並設定合理 retry 邊界

DuckDB 會 cache 一部分遠端資料與 metadata,可用下列查詢檢查:

FROM duckdb_external_file_cache();

分析資料最好採 immutable object key,再用 manifest 或 partition 指向新版本,避免查詢期間 object 被覆寫。

結語:把 SQL 移到資料旁邊
#

DuckDB 讀遠端 Parquet 的重點,不是少打一個 aws s3 cp。 真正價值是把 projection、filter、partition pruning 與 aggregation 推到掃描階段,只拉回答案需要的 bytes。 httpfs 解決連線,Secrets Manager 管認證,Parquet metadata 提供跳讀線索,EXPLAIN ANALYZE 則讓你確認優化真的發生。 先從公開 HTTPS 練習,再接測試 bucket;先量 request 與 bytes,再談快不快。 拍拍君保證,第一次看到幾十 GB 的 dataset 只傳回幾 MB,就很難再接受「先全部下載再說」了。🦆

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 108: 本文

相關文章

Python fsspec 實戰:統一讀寫本機、S3、HTTP 與資料管線路徑
·7 分鐘· loading · loading
Python Fsspec Filesystem S3 Data-Engineering ETL
Python PyArrow 實戰:Parquet、Schema 與跨工具資料交換
·8 分鐘· loading · loading
Python PyArrow Apache Arrow Parquet Data-Engineering ETL
Textual + DuckDB 實戰:終端機資料 Dashboard 小工具
·6 分鐘· loading · loading
Python Textual DuckDB TUI Dashboard Data-Analysis
Streamlit + DuckDB 實戰:本地資料查詢 Dashboard
·8 分鐘· loading · loading
Python Streamlit DuckDB SQL Dashboard Data-Analysis
Textual 測試實戰:Pilot、pytest-asyncio 與 Snapshot Regression
·7 分鐘· loading · loading
Python Textual TUI Pytest Pytest-Asyncio Pilot Snapshot Testing
uv 管理 Python 版本:Install、Find、Pin、Upgrade 與直譯器選擇
·9 分鐘· loading · loading
Python Uv Python Versions Interpreter Virtualenv Developer-Tools