Skip to content

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 khoDoanh 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ố queryRất nhiều, mỗi query vài msÍt, mỗi query vài giây tới vài phút
GhiLiên tục, từng dòng, nhiều client cùng lúcNạp theo lô, ít khi sửa
SchemaChuẩn hoá (Chuẩn hoá)Phá chuẩn có chủ đích (star schema)
Hệ thốngMySQL, PostgreSQLClickHouse, 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ộtByte mỗi giá trịLưu theo dòngLưu theo cột
id8đọc (nằm chung page)bỏ qua
order_date4đọc (nằm chung page)đọc, nén khoảng 20 lần
customer_id4đọc (nằm chung page)bỏ qua
product_id4đọc (nằm chung page)bỏ qua
store_id4đọc (nằm chung page)bỏ qua
qty4đọc (nằm chung page)bỏ qua
unit_price4đọc (nằm chung page)bỏ qua
discount_pct4đọc (nằm chung page)bỏ qua
total8đọc (nằm chung page)đọc, nén khoảng 2 lần
channel6đọc (nằm chung page)đọc, nén khoảng 30 lần
status6đọc (nằm chung page)đọc, nén khoảng 30 lần
note32đọ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_store

Query 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, PostgreSQLBản sao lưu theo dòngBáo cáo nhẹ, dữ liệu vừa phải, cần số mới
DuckDBEngine phân tích lưu theo cột chạy ngay trong process, đọc được cả Parquet, CSVPhâ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
ClickHouseDatabase phân tích lưu theo cột, tự host hoặc dịch vụ cloudLog, sự kiện, dashboard truy vấn nhanh trên hàng tỷ dòng
BigQuery, Snowflake, RedshiftKho dữ liệu dịch vụ cloud, tách lưu trữ khỏi tính toánKho 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ớ

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Đư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ồnDùng cho phần
DuckDB, Why DuckDBDuckDB 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, ConcurrencyMộ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ính8,1–9,2 giây2,0–2,3 giây
Dung lượng558 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 song297–331 ms (3 tiến trình)12,1–12,4 ms (8 thread)
Cùng báo cáo, một luồng814–842 ms55–56 ms
Lấy một đơn theo id0,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)}`)