Trino 4 — SQL & Truy vấn liên hợp thực chiến

13 thg 7, 2026 2 lượt xem
#sql
#federated-query
#trino
#sql-functions
#cross-source-join

Trino 4 — SQL & Truy vấn liên hợp thực chiến

Ba bài trước đã dựng nền: Trino 1 — Tổng quan định vị Trino là query engine phân tán không sở hữu dữ liệu, Trino 2 — Kiến trúc Coordinator & Worker mổ xẻ cách một câu SELECT biến thành hàng trăm task song song, và Trino 3 — Connectors giải thích cơ chế connector đọc từng nguồn. Bài này là nơi mọi thứ hội tụ thành giá trị nghiệp vụ: viết SQL để ghép dữ liệu từ nhiều hệ thống khác nhau trong đúng một câu truy vấn, mà không cần ETL bê dữ liệu về một chỗ trước.

Với dân ngân hàng, đây chính là "phép màu": bảng giao dịch nằm trên data lake (Parquet trên S3/HDFS), hồ sơ khách hàng nằm trong PostgreSQL của core, danh mục sản phẩm nằm trong MySQL của hệ CRM — và bạn JOIN cả ba lại trong một câu SELECT để ra báo cáo, không đợi pipeline hằng đêm.

Trino SQL: chuẩn ANSI, quen tay ngay

Tin tốt cho ai đã biết SQL: Trino nói phương ngữ ANSI SQL rất sát chuẩn, không có mấy cú pháp "dị" như một số warehouse. Nếu bạn viết được PostgreSQL, bạn viết được Trino với 90% cú pháp giống hệt. Vài đặc điểm cần nhớ:

  • Định danh 3 cấp: catalog.schema.table. Đây là điểm khác biệt lớn nhất — vì Trino nối nhiều nguồn, bảng phải chỉ rõ nó ở catalog nào (mỗi catalog = một kết nối tới một nguồn qua connector).
  • Kiểu dữ liệu chuẩn: VARCHAR, BIGINT, DOUBLE, DECIMAL(p,s), DATE, TIMESTAMP, TIMESTAMP WITH TIME ZONE, ARRAY, MAP, ROW (kiểu struct), JSON.
  • Phân biệt HOA/thường tên cột: Trino mặc định fold về chữ thường (giống Postgres), đặt trong dấu nháy kép "..." để giữ nguyên.
  • Chuỗi dùng nháy đơn 'abc'; nháy kép dành cho định danh.
  • Ép kiểu bằng CAST(x AS type) hoặc x :: type (cú pháp :: cũng hỗ trợ, tiện khi ROUND số).
  • Không có SELECT ... INTO; muốn tạo bảng dùng CREATE TABLE AS.

Vì chuẩn ANSI, các câu minh hoạ dưới đây chạy được nguyên văn cả trên Trino lẫn PostgreSQL. Khác biệt duy nhất khi lên Trino: các bảng có thể không nằm cùng một nguồn, nên bạn thay tên bảng trần bằng catalog.schema.table.

Truy vấn liên hợp: JOIN xuyên nguồn trong một câu

Đây là tính năng "đinh". Giả sử cụm Trino của NCB có ba catalog cấu hình sẵn:

CatalogConnectorNguồn thực tế
lakeHive/IcebergData lake Parquet trên object storage (giao dịch lịch sử)
corePostgreSQLDB core banking (khách hàng, tài khoản)
crmMySQLHệ CRM (danh mục sản phẩm, phân khúc)

Một câu truy vấn liên hợp chỉ đơn giản là JOIN các bảng ở các catalog khác nhau:

-- Trino: ba bảng ở BA nguồn khác nhau, ghép trong một câu
SELECT
    c.segment,
    p.product_name,
    COUNT(*)              AS so_giao_dich,
    SUM(t.amount)         AS tong_tien
FROM lake.txn.transactions   t          -- Parquet trên data lake
JOIN core.public.customers   c          -- PostgreSQL core banking
       ON t.customer_id = c.id
JOIN crm.catalog.products    p          -- MySQL CRM
       ON t.product_id = p.id
WHERE t.txn_date >= DATE '2026-06-01'
GROUP BY c.segment, p.product_name
ORDER BY tong_tien DESC;

Không cần khai báo gì thêm: Trino thấy tiền tố catalog khác nhau, tự gọi đúng connector cho từng bảng. Người viết SQL không cần biết bảng nào là Parquet, bảng nào là Postgres — đó là ý nghĩa của "liên hợp" (federation).

Trino thực thi cross-source join thế nào — và hệ quả hiệu năng

Đây là phần bắt buộc phải hiểu để không viết ra câu truy vấn giết cụm. Cơ chế cốt lõi:

  1. Mỗi nguồn chỉ biết bảng của chính nó. Không có "join xuyên DB" ở tầng nguồn — PostgreSQL không thể JOIN với Parquet. Vì vậy JOIN luôn xảy ra trong bộ nhớ của Trino engine, không đẩy xuống nguồn được.
  2. Trino đẩy xuống nguồn (pushdown) những gì có thể: quét cột cần thiết (projection), điều kiện lọc (predicate/filter), đôi khi cả LIMIT, aggregate đơn giản, và với connector JDBC là cả một số phép tính. Phần đẩy được thì nguồn tự làm và chỉ trả về ít dữ liệu.
  3. Phần còn lại — nhất là JOIN giữa hai nguồn — engine tự làm: Trino kéo dữ liệu (đã lọc, đã cắt cột) từ mỗi nguồn về worker, dựng hash table và join trong RAM.

Hệ quả trực tiếp về hiệu năng:

  • Filter & projection đẩy xuống được → rẻ. WHERE t.txn_date >= DATE '2026-06-01' trên bảng Parquet sẽ được đẩy xuống dạng partition pruning; WHERE c.city = 'Hà Nội' trên Postgres đẩy thành mệnh đề WHERE ở nguồn. Luôn lọc mạnh nhất có thể ngay trong câu để giảm dữ liệu bơm vào engine.
  • JOIN hai bảng LỚN xuyên nguồn → đắt và nguy hiểm. Nếu cả transactions (data lake, hàng tỉ dòng) lẫn một bảng khác đều lớn, Trino phải kéo lượng lớn dữ liệu về, hash join trong RAM → dễ tràn bộ nhớ, chậm, và tốn băng thông mạng tới nguồn OLTP (có thể làm nghẽn cả core banking đang chạy sản xuất).
  • Mẫu an toàn phổ biến: JOIN một bảng fact rất lớn ở data lake với các bảng dimension nhỏ ở DB quan hệ (khách hàng, sản phẩm, chi nhánh). Bảng nhỏ được broadcast tới các worker, bảng lớn quét song song — pattern này rất hiệu quả.
  • Không có index xuyên nguồn. Đừng kỳ vọng Trino dùng index của Postgres cho phần join trong engine; index chỉ giúp ở bước pushdown filter tại nguồn.

Kiểm chứng bằng EXPLAIN (hoặc EXPLAIN ANALYZE): tìm các node TableScan xem điều kiện nào được đẩy xuống (filterPredicate), và ước lượng số dòng ở mỗi cạnh JOIN. Nếu một cạnh join có hàng trăm triệu dòng đến từ nguồn OLTP, hãy viết lại.

SQL "▶ Chạy được" 1 — Window function: xếp hạng giao dịch trong tài khoản

Trino hỗ trợ đầy đủ window functions (hàm cửa sổ): ROW_NUMBER, RANK, LAG/LEAD, SUM() OVER (...), running total, moving average… Câu ANSI dưới đây minh hoạ đúng tính năng đó và chạy được trên sandbox PostgreSQL (trên Trino, bảng transactions/accounts có thể ở hai catalog khác nhau, ví dụ lake.txn.transactions JOIN core.public.accounts):

-- ▶ Chạy được
WITH ranked AS (
    SELECT
        t.account_id,
        t.id,
        t.amount,
        t.created_at,
        ROW_NUMBER() OVER (
            PARTITION BY t.account_id
            ORDER BY t.amount DESC
        ) AS rn,
        SUM(t.amount) OVER (PARTITION BY t.account_id) AS tong_theo_tk
    FROM transactions t
    WHERE t.kind = 'debit'
)
SELECT account_id, id, amount, created_at,
       ROUND((amount / tong_theo_tk * 100)::numeric, 2) AS pct_trong_tk
FROM ranked
WHERE rn <= 3
ORDER BY account_id, rn;

Câu này lấy 3 giao dịch ghi nợ lớn nhất mỗi tài khoản và tỉ trọng của nó trong tổng chi của tài khoản — kiểu phân tích rất hay dùng để phát hiện giao dịch bất thường.

SQL "▶ Chạy được" 2 — GROUPING SETS/ROLLUP: báo cáo đa cấp

Trino hỗ trợ GROUPING SETS, ROLLUP, CUBE — cho phép gộp nhiều mức tổng hợp trong một lần quét, thay vì chạy nhiều câu rồi UNION. Rất hợp báo cáo có dòng tổng phụ (subtotal) và tổng chung (grand total). Câu sau chạy được trên PostgreSQL và minh hoạ đúng tính năng (trên Trino, accountscustomers có thể ở nguồn khác nhau):

-- ▶ Chạy được
SELECT
    c.city,
    a.currency,
    COUNT(*)                        AS so_tai_khoan,
    ROUND(SUM(a.balance)::numeric, 2) AS tong_so_du
FROM accounts a
JOIN customers c ON a.customer_id = c.id
GROUP BY ROLLUP (c.city, a.currency)
ORDER BY c.city NULLS LAST, a.currency NULLS LAST;

Kết quả trả về đồng thời: số dư theo (city, currency), tổng phụ theo từng city (dòng currency = NULL), và tổng toàn hệ thống (dòng cả hai NULL). Đổi ROLLUP thành GROUPING SETS ((c.city), (a.currency), ()) nếu chỉ cần vài tổ hợp cụ thể.

SQL "▶ Chạy được" 3 — UNNEST + nhiều bảng: bung mảng thành dòng

UNNEST biến một ARRAY/MAP thành nhiều dòng (giống LATERAL unnest của Postgres) — cực mạnh khi làm việc với dữ liệu bán cấu trúc trên data lake (một dòng chứa mảng tag/danh mục). Postgres dùng unnest(ARRAY[...]), cú pháp minh hoạ được tính năng Trino tương ứng. Câu sau JOIN ba bảng và chạy được trên sandbox:

-- ▶ Chạy được
SELECT
    d.name              AS phong_ban,
    thang,
    COUNT(e.id)         AS so_nhan_vien
FROM employees e
JOIN departments d ON e.department_id = d.id
CROSS JOIN UNNEST(ARRAY[1, 2, 3]) AS m(thang)
WHERE EXTRACT(MONTH FROM e.hired_at) = m.thang
GROUP BY d.name, thang
ORDER BY d.name, thang;

Ở đây UNNEST(ARRAY[1,2,3]) bung ba tháng đầu năm thành ba dòng để đếm số nhân viên được tuyển theo từng tháng. Trên Trino, UNNEST thường dùng để bung cột mảng thật (VD UNNEST(product_tags)), cú pháp CROSS JOIN UNNEST(...) AS t(col) là như nhau.

Kho hàm SQL mạnh của Trino

Ngoài các ví dụ trên, Trino có kho hàm phong phú — biết dùng thì viết được logic phức tạp mà không cần bê ra ngoài:

NhómHàm tiêu biểuDùng để
WindowRANK, LAG/LEAD, NTH_VALUE, moving frameXếp hạng, so kỳ, running total
CTE / WITHWITH x AS (...), WITH RECURSIVEChia nhỏ truy vấn, đệ quy (cây phân cấp chi nhánh)
Multi-groupingROLLUP, CUBE, GROUPING SETS, GROUPING()Báo cáo đa cấp subtotal/total
Arrayarray_agg, filter, contains, element_at, array_distinctGom & thao tác mảng
Mapmap_agg, map_keys, map_values, element_at(m, k)Cặp key-value
JSONjson_extract, json_extract_scalar, json_parse, CAST(x AS JSON)Bóc trường từ JSON thô trên lake
UNNESTCROSS JOIN UNNEST(arr) WITH ORDINALITYBung mảng/map thành dòng
Approximateapprox_distinct(x), approx_percentile(x, 0.95), approx_percentile(x, 0.5)Đếm distinct/percentile nhanh, chấp nhận sai số nhỏ
Lambda / higher-ordertransform(arr, x -> x*2), reduce, filter(arr, x -> x > 0)Xử lý mảng theo hàm

Vài lưu ý thực chiến:

  • Approximate functions dùng thuật toán HyperLogLog / t-digest: approx_distinct đếm số khách hàng duy nhất trên hàng tỉ dòng nhanh hơn COUNT(DISTINCT) nhiều lần, sai số ~2.3%. Với dashboard "số khách hoạt động", sai số này chấp nhận được và tiết kiệm khổng lồ. approx_percentile(latency, 0.99) cho p99 mà không phải sort toàn bộ.
  • Lambda cho phép viết logic trên mảng ngay trong SQL: filter(txn_amounts, x -> x > 1000000) lọc các giao dịch trên 1 triệu ngay trong một cột mảng — không cần UNNEST rồi re-aggregate.
  • WITH RECURSIVE hữu ích cho cây phân cấp (chi nhánh → phòng giao dịch → quầy), Trino hỗ trợ nhưng có giới hạn độ sâu.

CTAS, INSERT & view: federated ETL nhẹ

Khi connector hỗ trợ ghi (Hive/Iceberg, Delta, PostgreSQL/MySQL với quyền write, Kafka có giới hạn…), Trino không chỉ đọc mà còn ghi:

-- Materialize kết quả liên hợp thành bảng Iceberg trên lake (ETL nhẹ)
CREATE TABLE lake.report.daily_txn_by_segment AS
SELECT c.segment, DATE(t.created_at) AS ngay, SUM(t.amount) AS tong
FROM lake.txn.transactions t
JOIN core.public.customers c ON t.customer_id = c.id
GROUP BY c.segment, DATE(t.created_at);

-- Chèn thêm dữ liệu tăng dần
INSERT INTO lake.report.daily_txn_by_segment
SELECT ... FROM ... WHERE created_at = DATE '2026-07-06';

Đây chính là federated ETL nhẹ: đọc từ nguồn A (Postgres + lake), ghi kết quả sang nguồn B (Iceberg trên lake) — tất cả bằng SQL, không cần Spark job. Kèm theo:

  • View (CREATE VIEW): đóng gói một truy vấn liên hợp phức tạp thành tên bảng logic để analyst dùng lại, giấu đi chi tiết catalog. Trino có cả view thường (lưu định nghĩa SQL, chạy lại mỗi lần) và, với connector hỗ trợ, materialized view (lưu kết quả, refresh định kỳ).
  • CTAS tiện để "đông cứng" một snapshot báo cáo cho tháng, hoặc chuẩn hoá dữ liệu bán cấu trúc thành bảng cột phẳng cho BI.

Lưu ý: các câu DDL/DML này không chạy trên sandbox read-only ở đây (nên không đánh dấu ▶), nhưng chuẩn Trino thực tế.

Giới hạn: Trino KHÔNG phải warehouse

Đừng lạm dụng. Trino là query engine tương tác, không phải hệ ETL nặng:

  • Không giỏi các job biến đổi lặp, nặng, chạy hàng giờ. Nếu bạn cần xử lý dữ liệu lớn theo pipeline phức tạp, có checkpoint, retry ở mức phần tử — Spark/dbt phù hợp hơn. Xem Spark SQLdbt.
  • Không nên biến thành "cron ETL xuyên nguồn khối lượng lớn" đọc-ghi liên tục giữa các DB OLTP sản xuất — sẽ đè tải lên core.
  • Không thay thế index/OLTP. Truy vấn điểm (lấy 1 khách theo id) nên hỏi thẳng Postgres, đừng đi qua Trino.
  • Federated join bảng lớn × bảng lớn xuyên nguồn là anti-pattern kinh điển: hoặc đưa một bên về data lake trước, hoặc chấp nhận materialize.

Đúng chỗ của Trino: báo cáo và phân tích ad-hoc gộp dữ liệu từ nhiều hệ mà không ETL trước, JOIN fact-lớn-ở-lake với dimension-nhỏ-ở-DB, và ETL nhẹ đọc-A-ghi-B.

Use case thực tế

Bối cảnh (NCB): Khối Kinh doanh cần báo cáo cuối tuần: "Tổng doanh số giao dịch thẻ 7 ngày qua, chia theo phân khúc khách hàng và loại sản phẩm, kèm p95 số tiền/giao dịch và số khách hoạt động." Vấn đề: giao dịch nằm ở data lake (Parquet, ~1,8 tỉ dòng/tháng), phân khúc khách ở PostgreSQL core, danh mục sản phẩm thẻ ở MySQL CRM. Trước đây phải chờ pipeline hằng đêm dựng data mart — mất 1 ngày và cần DE viết job.

Cách làm với Trino (một câu, chạy sáng thứ Hai):

  1. WHERE txn_date >= current_date - INTERVAL '7' DAY → đẩy xuống thành partition pruning trên lake, chỉ quét 7 partition ngày thay vì cả tháng (~420 triệu dòng thay vì 1,8 tỉ).
  2. JOIN với core.public.customers (~4 triệu dòng, bảng dimension nhỏ) và crm.catalog.products (~300 dòng) — cả hai được broadcast, không phải shuffle.
  3. Dùng approx_percentile(amount, 0.95) cho p95 và approx_distinct(customer_id) cho "số khách hoạt động" — nhanh hơn nhiều lần so với percentile_cont/COUNT(DISTINCT), sai số ~2% chấp nhận được cho báo cáo quản trị.
  4. GROUP BY ROLLUP (segment, product_name) → ra luôn subtotal theo phân khúc + grand total.

Kết quả: câu chạy ~40 giây trên cụm 8 worker, không tạo bảng trung gian, không chờ ETL. Analyst tự chạy lại được, không cần DE. Với báo cáo cần lặp hằng ngày cho BI, họ đóng gói thành CREATE VIEW hoặc CTAS materialize snapshot vào Iceberg — chuyển sang mô hình OBT/metric như trong OBT & Metrics hiện đại.

Ghi nhớ

  • Trino SQL theo chuẩn ANSI: ai biết PostgreSQL viết được ngay; khác biệt lớn nhất là định danh 3 cấp catalog.schema.table.
  • Truy vấn liên hợp = JOIN các bảng ở catalog khác nhau trong một câu; người viết không cần biết bảng ở nguồn nào.
  • Cross-source JOIN luôn diễn ra trong bộ nhớ engine — nguồn không tự join với nhau được. Trino chỉ đẩy xuống nguồn được filter/projection/limit/aggregate đơn giản.
  • Luôn lọc mạnh nhất có thể trong câu để pushdown giảm dữ liệu; JOIN fact-lớn-ở-lake × dimension-nhỏ-ở-DB là pattern an toàn. JOIN lớn × lớn xuyên nguồn là anti-pattern.
  • Kho hàm mạnh: window, CTE/WITH, ROLLUP/CUBE/GROUPING SETS, array/map/JSON, UNNEST, approximate (approx_distinct/approx_percentile), lambda.
  • CTAS/INSERT/view (khi connector hỗ trợ write) cho federated ETL nhẹ: đọc nguồn A, ghi nguồn B, tất cả bằng SQL.
  • Trino không phải warehouse/ETL nặng: job biến đổi lặp, nặng, chạy hàng giờ → dùng Spark/dbt; truy vấn điểm → hỏi thẳng OLTP.
  • Dùng EXPLAIN/EXPLAIN ANALYZE để xác nhận điều kiện được pushdown và ước lượng dòng mỗi cạnh JOIN trước khi chạy câu lớn.

Bài viết liên quan

Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.

13 thg 7, 2026 4

Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.

13 thg 7, 2026 4

Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.

13 thg 7, 2026 3

Truy vấn lồng, CTE (WITH), CTE đệ quy và hàm cửa sổ — vũ khí cho phân tích nâng cao.

13 thg 7, 2026 3