Giao diện
OLTP vs OLAP
Database của ứng dụng (đơn hàng, user, thanh toán) và database cho báo cáo (doanh thu theo tháng, phân tích hành vi khách hàng) phục vụ hai kiểu tải rất khác nhau, và một hệ thống được tối ưu cho kiểu này thường kém ở kiểu kia. Trang này giải thích sự khác nhau đó, vì sao lưu theo cột nhanh hơn nhiều cho báo cáo, cách mô hình hoá kho dữ liệu, và cách đưa dữ liệu từ database của ứng dụng sang.
Hai kiểu tải
| OLTP (online transaction processing) | OLAP (online analytical processing) | |
|---|---|---|
| Ví dụ | Đặt hàng, xem đơn của tôi, cập nhật tồn kho | Doanh thu theo tháng và kênh bán, tỉ lệ khách quay lại |
| Mỗi query | Đọc ghi vài dòng, theo khoá | Quét hàng triệu tới hàng tỷ dòng, chỉ vài cột |
| Số query | Rất nhiều, mỗi query vài ms | Ít, mỗi query vài giây tới vài phút |
| Ghi | Liên tục, từng dòng, nhiều client cùng lúc | Nạp theo lô, ít khi sửa |
| Schema | Chuẩn hoá (Chuẩn hoá) | Phá chuẩn có chủ đích (star schema) |
| Hệ thống | MySQL, PostgreSQL | ClickHouse, BigQuery, Snowflake, Redshift, DuckDB |
Chạy báo cáo nặng thẳng trên database của ứng dụng gây hai vấn đề. Báo cáo chạy chậm, vì database lưu theo dòng phải đọc cả những cột không cần. Và nó tranh CPU, I/O, buffer pool với request của người dùng, làm ứng dụng chậm theo. Chuyển báo cáo sang read replica tránh được vấn đề thứ hai, nhưng không giải quyết vấn đề thứ nhất.
Vì sao lưu theo cột nhanh hơn cho báo cáo
Database lưu theo dòng đặt mọi cột của một dòng cạnh nhau trong page (Cấu trúc lưu trữ). Một query chỉ cần cột total vẫn phải đọc cả page, tức đọc luôn mọi cột khác. Lưu theo cột đặt mọi giá trị của một cột liền nhau, nên ba điều cùng có lợi:
- Chỉ đọc cột cần dùng. Báo cáo dùng 4 trên 12 cột thì chỉ đọc chừng ấy.
- Nén rất mạnh. Các giá trị cùng cột cùng kiểu, nhiều giá trị lặp lại (kênh bán chỉ có 3 giá trị), nên nén bằng cách mã hoá từ điển, lưu số lần lặp liên tiếp hay chỉ lưu phần chênh giữa các giá trị liền nhau.
- Xử lý theo lô (vectorized execution). Engine xử lý cả một khối hàng nghìn giá trị của một cột trong mỗi bước thay vì từng dòng một, tận dụng tốt cache và lệnh SIMD của CPU. Tài liệu DuckDB mô tả đúng cách làm này.
Bấm để chọn những cột mà query dùng (5.000.000 dòng):
580 MBlưu theo dòng phải đọc
23 MBlưu theo cột phải đọc
25 lầnít hơn
| Cột | Byte mỗi giá trị | Lưu theo dòng | Lưu theo cột |
|---|---|---|---|
| id | 8 | đọc (nằm chung page) | bỏ qua |
| order_date | 4 | đọc (nằm chung page) | đọc, nén khoảng 20 lần |
| customer_id | 4 | đọc (nằm chung page) | bỏ qua |
| product_id | 4 | đọc (nằm chung page) | bỏ qua |
| store_id | 4 | đọc (nằm chung page) | bỏ qua |
| qty | 4 | đọc (nằm chung page) | bỏ qua |
| unit_price | 4 | đọc (nằm chung page) | bỏ qua |
| discount_pct | 4 | đọc (nằm chung page) | bỏ qua |
| total | 8 | đọc (nằm chung page) | đọc, nén khoảng 2 lần |
| channel | 6 | đọc (nằm chung page) | đọc, nén khoảng 30 lần |
| status | 6 | đọc (nằm chung page) | đọc, nén khoảng 30 lần |
| note | 32 | đọc (nằm chung page) | bỏ qua |
Lưu theo dòng đặt cả 12 cột của một dòng cạnh nhau trong page, nên muốn đọc cột total của mọi dòng thì phải đọc cả page, tức đọc luôn 11 cột còn lại. Lưu theo cột đặt mọi giá trị của một cột cạnh nhau, nên chỉ đọc đúng những cột được chọn. Cột ít giá trị khác nhau như channel, status nén rất mạnh.
Hệ số nén từng cột là giả định; chọn cả 12 cột thì ra khoảng 580 MB và 128 MB, gần số đo thật 558 MB (PostgreSQL) và 125 MB (DuckDB).
Trên lab, cùng 5 triệu dòng bán hàng 12 cột: PostgreSQL lưu trong 558 MB, DuckDB (lưu theo cột) chỉ 125 MB. Query doanh thu theo tháng và kênh bán mất khoảng 300 ms trên PostgreSQL (có chạy song song), còn DuckDB chỉ khoảng 12 ms. Chạy một luồng thì DuckDB vẫn nhanh hơn khoảng 15 lần.
Đổi lại, lưu theo cột kém ở đúng việc của OLTP. Sửa một dòng nghĩa là chạm vào nhiều file cột, đã nén. Mô hình đồng thời cũng khác: tài liệu DuckDB nói tại mỗi thời điểm chỉ một process được vừa đọc vừa ghi file database, và hai thread cùng sửa một dòng thì thread thứ hai nhận lỗi conflict. Đó là thiết kế cho phân tích, không cho hàng trăm client cùng ghi. Trên lab, tra một dòng theo khoá hay vài nghìn lệnh UPDATE tuần tự chưa thấy DuckDB chậm hơn, nhưng đó là vì DuckDB chạy ngay trong process của Node và không phải đi qua mạng như PostgreSQL. Điểm yếu thật nằm ở ghi đồng thời từ nhiều client.
Mô hình hoá kho dữ liệu: star schema
Kho dữ liệu (data warehouse) thường được tổ chức theo star schema:
- Bảng fact: mỗi dòng là một sự kiện đo được, như một dòng bán hàng, gồm các con số (số lượng, thành tiền) và khoá trỏ tới các dimension. Bảng này rất dài.
- Bảng dimension: mô tả ngữ cảnh của sự kiện: sản phẩm (tên, danh mục, hãng), khách hàng (vùng, phân khúc), ngày (ngày trong tuần, tháng, quý, ngày lễ), cửa hàng. Các bảng này ngắn và rộng.
dim_date
│
dim_product ── fact_sales ── dim_customer
│
dim_storeQuery báo cáo là quét bảng fact, JOIN với vài dimension để lọc và gom nhóm. Dimension được phá chuẩn có chủ đích: tên danh mục nằm ngay trong dim_product thay vì một bảng riêng, để query ít JOIN hơn.
Dimension thay đổi theo thời gian, ví dụ khách hàng chuyển vùng. Nếu muốn báo cáo cũ vẫn tính theo vùng cũ thì giữ lịch sử của dimension bằng SCD type 2: mỗi phiên bản là một dòng có thời gian hiệu lực (Versioning).
Đưa dữ liệu sang: ETL, ELT và CDC
- ETL (extract, transform, load): lấy dữ liệu từ database của ứng dụng, biến đổi ở một hệ trung gian, rồi nạp vào kho. Cách truyền thống.
- ELT (extract, load, transform): nạp dữ liệu thô vào kho trước, rồi biến đổi bằng SQL ngay trong kho, vì kho dữ liệu hiện đại đủ mạnh. Công cụ như dbt quản lý các bước biến đổi bằng SQL có version. Đây là cách phổ biến hiện nay.
Lấy dữ liệu ra khỏi database của ứng dụng có hai cách:
- Chạy query định kỳ lấy dòng thay đổi theo cột
updated_at. Đơn giản, nhưng bỏ sót dòng bị xoá, và lặp lại query nặng lên database. - CDC (change data capture): đọc binlog hay WAL để lấy mọi thay đổi, kể cả xoá, gần như tức thời và không chạy thêm query lên database (MySQL chương 9). Debezium là công cụ hay dùng, đẩy thay đổi qua Kafka vào kho.
Dữ liệu trong kho vì vậy luôn chậm hơn database của ứng dụng: vài giây với CDC, vài giờ với job chạy theo đêm. Báo cáo cần số chính xác tới từng giây (số dư tài khoản hiện tại) thì vẫn đọc từ database của ứng dụng.
Chọn công cụ
| Công cụ | Là gì | Hợp khi |
|---|---|---|
| Read replica của MySQL, PostgreSQL | Bản sao lưu theo dòng | Báo cáo nhẹ, dữ liệu vừa phải, cần số mới |
| DuckDB | Engine phân tích lưu theo cột chạy ngay trong process, đọc được cả Parquet, CSV | Phân tích trên một máy, notebook, job xử lý dữ liệu, dữ liệu tới vài trăm GB |
| ClickHouse | Database phân tích lưu theo cột, tự host hoặc dịch vụ cloud | Log, sự kiện, dashboard truy vấn nhanh trên hàng tỷ dòng |
| BigQuery, Snowflake, Redshift | Kho dữ liệu dịch vụ cloud, tách lưu trữ khỏi tính toán | Kho dữ liệu chung của công ty, nhiều nguồn, nhiều nhóm cùng dùng |
Với hệ thống nhỏ và vừa, read replica hoặc PostgreSQL với bảng tổng hợp sẵn (Bảng tổng hợp) thường đã đủ. Tách sang kho lưu theo cột khi báo cáo quét quá nhiều dữ liệu, hoặc khi cần gộp dữ liệu từ nhiều nguồn.
Ghi nhớ
- OLTP là nhiều query nhỏ đọc ghi theo khoá; OLAP là ít query lớn quét nhiều dòng nhưng ít cột. Đừng chạy báo cáo nặng trên database của ứng dụng.
- Lưu theo cột chỉ đọc cột cần dùng, nén mạnh và xử lý theo lô: trên lab nhanh hơn PostgreSQL khoảng 25 lần với một báo cáo, và nhỏ hơn 4,5 lần.
- Lưu theo cột kém với ghi đồng thời nhiều dòng nhỏ; mỗi kiểu hệ thống hợp với một kiểu tải.
- Kho dữ liệu dùng star schema: bảng fact dài chứa con số, các bảng dimension mô tả ngữ cảnh, giữ lịch sử bằng SCD type 2.
- Đưa dữ liệu sang bằng CDC đọc binlog hay WAL; ELT nạp thô rồi biến đổi bằng SQL ngay trong kho.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-23):
| Nguồn | Dùng cho phần |
|---|---|
| DuckDB, Why DuckDB | DuckDB thiết kế cho tải phân tích (OLAP), chạy nhúng trong process, engine xử lý theo cột và theo lô |
| DuckDB docs, Concurrency | Một process được vừa đọc vừa ghi file database; nhiều thread trong process dùng MVCC và optimistic concurrency, sửa cùng một dòng thì thread thứ hai lỗi conflict |
Từ kiến thức chung, chưa đối chiếu lần này: star schema theo cách của Kimball; ETL, ELT và dbt; Debezium; đặc điểm của ClickHouse, BigQuery, Snowflake, Redshift; các kỹ thuật nén theo cột.
Số đo chi tiết
Đo bằng script lab/oltp-olap/compare.mjs ngày 2026-10-23 trên MacBook M2: PostgreSQL 17 trong Docker (8 CPU), DuckDB 2.0 qua @duckdb/node-api trong process Node (ghi ra file), hai lượt. Bảng sales 5.000.000 dòng, 12 cột, sinh bằng cùng một công thức trên hai hệ.
Kết quả
| PostgreSQL 17 (lưu theo dòng) | DuckDB (lưu theo cột) | |
|---|---|---|
| Nạp dữ liệu và tạo khoá chính | 8,1–9,2 giây | 2,0–2,3 giây |
| Dung lượng | 558 MB (heap, chưa tính index) | 125 MB (cả file, đã nén) |
| Báo cáo doanh thu theo tháng và kênh năm 2025, chạy song song | 297–331 ms (3 tiến trình) | 12,1–12,4 ms (8 thread) |
| Cùng báo cáo, một luồng | 814–842 ms | 55–56 ms |
| Lấy một đơn theo id | 0,31–0,38 ms (có một vòng mạng) | 0,42–0,43 ms (trong process) |
2.000 lệnh UPDATE một dòng, tuần tự | 1,1–1,4 giây (mỗi lệnh một vòng mạng và một commit) | 0,7–0,8 giây (trong process) |
Phần OLTP không phải phép so công bằng: PostgreSQL đi qua mạng Docker, DuckDB thì không, và phép đo chưa có ghi đồng thời từ nhiều client, là chỗ DuckDB thật sự yếu.
Code đã dùng để chạy thử
Chạy bằng npm run lab -- oltp-olap sau npm run lab:up, khoảng 20 giây. Cần package @duckdb/node-api (có trong lab/package.json). Xem Lab.
lab/oltp-olap/compare.mjs
js
// OLTP vs OLAP: cùng dữ liệu bán hàng 5.000.000 dòng, 12 cột, trên PostgreSQL 17 (lưu theo dòng)
// và DuckDB (lưu theo cột, chạy ngay trong process Node, ghi ra file để đo dung lượng).
// Việc của OLAP: tổng doanh thu theo tháng và kênh bán của năm 2025 (đọc 3 cột trên mọi dòng).
// Việc của OLTP: lấy một đơn theo id, và 2.000 lệnh UPDATE một dòng.
// Lưu ý khi đọc phần OLTP: DuckDB chạy trong cùng process nên không tốn vòng mạng, PostgreSQL thì mỗi lệnh đi qua mạng Docker.
import pg from 'pg'
import { DuckDBInstance } from '@duckdb/node-api'
import { rmSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { PG } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'
const N = 5_000_000
const ms = (v) => (v >= 100 ? Math.round(v).toLocaleString('vi-VN') : v.toFixed(v < 1 ? 3 : 1).replace('.', ',')) + ' ms'
const mb = (b) => Math.round(Number(b) / 1e6).toLocaleString('vi-VN') + ' MB'
async function best(fn, n = 3) { let m = Infinity; for (let i = 0; i < n; i++) { const t0 = performance.now(); await fn(); m = Math.min(m, performance.now() - t0) } return m }
// Cùng một công thức sinh dữ liệu cho cả hai, chỉ khác cú pháp. i chạy từ 1 tới N.
const cols = (i, dateExpr) => `${i} AS id, ${dateExpr} AS order_date, ${i} % 200000 AS customer_id, ${i} % 5000 AS product_id,
${i} % 300 AS store_id, 1 + ${i} % 5 AS qty, 10000 + (${i} * 37) % 990000 AS unit_price, (${i} % 4) * 5 AS discount_pct,
(1 + ${i} % 5) * (10000 + (${i} * 37) % 990000) AS total, CASE ${i} % 3 WHEN 0 THEN 'web' WHEN 1 THEN 'app' ELSE 'store' END AS channel,
CASE ${i} % 10 WHEN 0 THEN 'refunded' ELSE 'paid' END AS status, 'ghi chú đơn hàng số ' || ${i} AS note`
const REPORT = `SELECT date_trunc('month', order_date) AS m, channel, SUM(total) AS revenue, COUNT(*) AS n
FROM sales WHERE order_date >= DATE '2025-01-01' AND order_date < DATE '2026-01-01' AND status = 'paid'
GROUP BY 1, 2 ORDER BY 1, 2`
// ---------- PostgreSQL ----------
ensureDb('pg', 'olap')
const p = new pg.Client({ ...PG, database: 'olap' })
await p.connect()
await p.query('DROP TABLE IF EXISTS sales')
let t0 = performance.now()
await p.query(`CREATE TABLE sales AS SELECT ${cols('i', "DATE '2024-01-01' + (i % 730)")} FROM generate_series(1, ${N}) AS i`)
await p.query('ALTER TABLE sales ADD PRIMARY KEY (id)')
await p.query('VACUUM ANALYZE sales')
const pgLoad = performance.now() - t0
const pgSize = (await p.query("SELECT pg_relation_size('sales') AS b")).rows[0].b
const workers = (await p.query('SHOW max_parallel_workers_per_gather')).rows[0].max_parallel_workers_per_gather
const pgReport = await best(() => p.query(REPORT))
await p.query('SET max_parallel_workers_per_gather = 0')
const pgReport1 = await best(() => p.query(REPORT))
await p.query('RESET max_parallel_workers_per_gather')
const pgPoint = await best(() => p.query('SELECT * FROM sales WHERE id = $1', [1234567]), 20)
t0 = performance.now()
for (let k = 0; k < 2000; k++) await p.query('UPDATE sales SET status = $1 WHERE id = $2', ['refunded', 1 + k * 997])
const pgUpd = performance.now() - t0
await p.end()
// ---------- DuckDB ----------
const file = fileURLToPath(new URL('./tmp-olap.duckdb', import.meta.url))
rmSync(file, { force: true }); rmSync(file + '.wal', { force: true })
const inst = await DuckDBInstance.create(file)
const d = await inst.connect()
t0 = performance.now()
await d.run(`CREATE TABLE sales AS SELECT ${cols('i', "DATE '2024-01-01' + CAST(i % 730 AS INTEGER)")} FROM range(1, ${N + 1}) t(i)`)
await d.run('ALTER TABLE sales ADD PRIMARY KEY (id)')
await d.run('CHECKPOINT')
const duckLoad = performance.now() - t0
const duckBytes = Number((await d.runAndReadAll('SELECT total_blocks * block_size AS b FROM pragma_database_size()')).getRowObjects()[0].b)
const threads = (await d.runAndReadAll("SELECT current_setting('threads') AS t")).getRowObjects()[0].t
const duckReport = await best(() => d.runAndReadAll(REPORT))
await d.run('SET threads = 1')
const duckReport1 = await best(() => d.runAndReadAll(REPORT))
await d.run(`RESET threads`)
const duckPoint = await best(() => d.runAndReadAll('SELECT * FROM sales WHERE id = 1234567'), 20)
t0 = performance.now()
for (let k = 0; k < 2000; k++) await d.run(`UPDATE sales SET status = 'refunded' WHERE id = ${1 + k * 997}`)
const duckUpd = performance.now() - t0
d.closeSync?.()
rmSync(file, { force: true }); rmSync(file + '.wal', { force: true })
console.log(`\n=== ${N.toLocaleString('vi-VN')} dòng bán hàng, 12 cột ===`)
console.log(' PostgreSQL 17 (theo dòng) DuckDB (theo cột)')
console.log(`Nạp dữ liệu và tạo khoá chính ${ms(pgLoad).padEnd(32)} ${ms(duckLoad)}`)
console.log(`Dung lượng bảng ${mb(pgSize).padEnd(32)} ${mb(duckBytes)} (cả file, đã nén)`)
console.log(`Báo cáo theo tháng và kênh (song song) ${(ms(pgReport) + `, ${Number(workers) + 1} tiến trình`).padEnd(32)} ${ms(duckReport)}, ${threads} thread`)
console.log(`Báo cáo theo tháng và kênh (một luồng) ${ms(pgReport1).padEnd(32)} ${ms(duckReport1)}`)
console.log(`Lấy một đơn theo id (khoá chính) ${ms(pgPoint).padEnd(32)} ${ms(duckPoint)}`)
console.log(`2.000 lệnh UPDATE một dòng (autocommit) ${ms(pgUpd).padEnd(32)} ${ms(duckUpd)}`)