Row store vs column store — vì sao OLAP lưu dữ liệu theo cột
Vì sao đổi cách sắp dữ liệu trên đĩa làm query phân tích nhanh gấp nhiều lần? Row vs column store, column compression, vectorized processing — agnostic.
TL;DR: Một bảng có thể lưu trên đĩa theo hai cách. Row-oriented xếp mọi cột của một dòng cạnh nhau — đọc/ghi nguyên một dòng chỉ chạm một chỗ, hoàn hảo cho giao dịch OLTP. Column-oriented xếp mọi giá trị của một cột cạnh nhau — query phân tích chỉ cần vài cột trên hàng triệu dòng sẽ đọc đúng phần cần, bỏ qua phần thừa, nên thắng OLAP. Lưu theo cột còn mở ra hai đòn bẩy lớn: column compression (giá trị cùng cột giống nhau nên nén rất tốt — run-length, dictionary) và vectorized processing (xử lý cả khối giá trị một lúc). Hiểu sự đối lập này giải thích vì sao warehouse và database giao dịch chọn mô hình đĩa khác nhau.
Ở bài 01, TaskFlow đơ khi một query "tổng giờ task theo phòng ban theo tháng" quét 18 triệu dòng. Ta đã chốt: tách OLAP sang warehouse. Nhưng có một chi tiết bị bỏ ngỏ — vì sao warehouse chạy chính query đó nhanh hơn nhiều lần dù dữ liệu y hệt?
Câu trả lời nằm ở cách dữ liệu được sắp trên đĩa. Cùng một bảng, đổi thứ tự byte trên ổ cứng, query phân tích có thể nhanh gấp hàng chục lần. Bài này mổ cơ chế row-store vs column-store, vì sao mỗi loại thắng một workload, và hai đòn bẩy mà lưu-theo-cột mở ra.
1. Analogy — Tủ hồ sơ nhân viên và bảng lương
Một phòng nhân sự lưu thông tin nhân viên theo hai cách hình dung được:
- Tủ hồ sơ cá nhân (row-oriented): mỗi nhân viên một bìa kẹp, trong đó có tất cả thông tin — tên, phòng ban, lương, ngày vào, số điện thoại. Cần xem toàn bộ hồ sơ một người? Rút đúng một bìa, có ngay mọi thứ. Nhưng muốn tính tổng lương cả công ty? Phải mở từng bìa, lật tới trang lương, đọc, rồi gấp lại — chạm vào cả đống thông tin không liên quan.
- Bảng lương tổng hợp (column-oriented): một tờ giấy chỉ liệt kê lương của mọi nhân viên, xếp liền nhau. Tính tổng lương? Cộng một mạch xuống tờ giấy đó, không động tới tên hay số điện thoại. Nhưng muốn xem toàn bộ hồ sơ một người? Phải ghép thông tin từ nhiều tờ (tờ lương, tờ phòng ban, tờ ngày vào) — cực hơn.
Hai cách cùng lưu một tập dữ liệu, nhưng tối ưu cho hai câu hỏi ngược nhau: "cho tôi cả một người" so với "cho tôi một thuộc tính của tất cả người".
| Phòng nhân sự | Hệ thống dữ liệu |
|---|---|
| Tủ hồ sơ cá nhân — mỗi bìa đủ thông tin một người | Row store — mọi cột của một dòng nằm cạnh nhau |
| Rút một bìa xem cả hồ sơ — nhanh | Đọc/ghi một dòng theo khoá — nhanh (OLTP) |
| Bảng lương — một thuộc tính của mọi người, liền nhau | Column store — mọi giá trị một cột nằm cạnh nhau |
| Cộng một mạch xuống cột lương — nhanh | Quét + aggregate vài cột trên triệu dòng — nhanh (OLAP) |
| Ghép hồ sơ một người từ nhiều tờ — chậm | Dựng lại nguyên một dòng từ nhiều cột — chậm |
Row store = tủ hồ sơ cá nhân (cả một người, nhanh). Column store = bảng lương (một thuộc tính của tất cả, nhanh). Cùng dữ liệu, tối ưu hai câu hỏi ngược nhau.
2. Row-oriented — mọi cột của một dòng nằm cạnh nhau
Database giao dịch truyền thống lưu theo dòng: toàn bộ giá trị các cột của một dòng được xếp liên tiếp trên đĩa, dòng này tiếp dòng kia.
Row-oriented tren dia -- moi dong la mot khoi lien tuc:
[id=1 | title="Fix login" | effort=3 | dept=A | created=...]
[id=2 | title="Add cache" | effort=5 | dept=B | created=...]
[id=3 | title="Write docs" | effort=2 | dept=A | created=...]
...
Hệ quả của cách sắp này:
- Đọc nguyên một dòng = một lần chạm. Lấy task
id=2với mọi cột? Engine nhảy tới đúng vị trí dòng đó, đọc liền một khối. Tối ưu cho OLTP — vốn đọc/ghi nguyên dòng theo khoá. - Ghi/sửa một dòng cũng gọn.
INSERTmột task = ghi thêm một khối ở cuối;UPDATEmột task = sửa tại chỗ một khối. Không phải đụng tới nơi khác. - Nhưng quét vài cột thì lãng phí. Query
SUM(effort)chỉ cần cộteffort. Vì các giá trịeffortnằm rải rác, mỗi giá trị kẹp giữatitle,dept,createdcủa cùng dòng, engine buộc phải kéo cả khối dòng vào bộ nhớ rồi mới nhặt raeffort. Đọc cảtitledài dòng chỉ để vứt đi — phần lớn I/O bị phí.
Đây chính là gốc rễ nỗi đau OLTP-chạy-OLAP ở bài 01: row store sắp dữ liệu sai chiều cho query phân tích.
3. Column-oriented — mọi giá trị của một cột nằm cạnh nhau
Hệ thống phân tích đảo ngược: lưu theo cột. Mọi giá trị của cùng một cột được gom liền nhau; mỗi cột là một "tệp" riêng trên đĩa.
Column-oriented tren dia -- moi cot la mot khoi lien tuc:
id: [1, 2, 3, 4, 5, ...]
title: ["Fix login", "Add cache", "Write docs", ...]
effort: [3, 5, 2, 8, 1, ...]
dept: [A, B, A, C, A, ...]
created: [..., ..., ..., ...]
Thứ tự dòng được giữ nhất quán giữa các cột (giá trị thứ k của mọi cột thuộc cùng dòng k), nên vẫn ghép lại được nguyên dòng khi cần.
Vì sao cách này thắng OLAP:
- Đọc đúng cột cần, bỏ qua phần thừa. Query
SUM(effort)chỉ mở khốieffortvà cộng một mạch — không bao giờ chạmtitlehaycreated. Nếu bảng có 50 cột mà query dùng 2, column store đọc khoảng2/50lượng dữ liệu so với row store. Đó là phần lớn lý do query phân tích nhanh hơn hẳn. - Quét tuần tự, thân thiện với phần cứng. Các giá trị một cột nằm liền nhau nên đọc là một chuỗi tuần tự dài — đĩa và bộ nhớ đọc tuần tự nhanh hơn nhiều so với nhảy lung tung.
Đổi lại: dựng lại nguyên một dòng (mọi cột của một task) phải nhặt giá trị từ nhiều khối cột rời rạc rồi ghép — tốn hơn row store. Ghi/sửa một dòng cũng phải động vào nhiều tệp cột. Vì thế column store dở cho OLTP nhưng đúng tủ cho OLAP (vốn quét nhiều dòng, ít cột, hiếm khi sửa từng dòng).
flowchart TB
subgraph ROW["Row store -- doc SUM(effort)"]
R1["Dong 1: id,title,effort,dept,created"]
R2["Dong 2: id,title,effort,dept,created"]
R3["Dong 3: id,title,effort,dept,created"]
R1 -.->|"keo ca dong, nhat effort"| RX["Doc ca title/dept/created (phi)"]
R2 -.-> RX
R3 -.-> RX
end
subgraph COL["Column store -- doc SUM(effort)"]
C1["Khoi effort: 3,5,2,8,1,..."]
C1 -->|"cong mot mach"| CX["Chi doc dung cot effort"]
end4. Đòn bẩy 1 — Column compression
Lưu theo cột mở ra một lợi thế mà row store khó đạt: nén rất tốt. Lý do: các giá trị trong cùng một cột đồng nhất về kiểu và thường lặp lại nhiều. Cột dept chỉ có vài chục giá trị; cột status chỉ todo/doing/done; cột country lặp đi lặp lại. Khi chúng nằm liền nhau, các thuật toán nén đơn giản phát huy mạnh:
- Run-length encoding (RLE): thay vì lưu
A, A, A, A, B, B, Alưu thành "A nhân 4, B nhân 2, A nhân 1". Cột đã sắp xếp hoặc ít giá trị phân biệt nén xuống rất nhỏ. - Dictionary encoding: lập một từ điển ánh xạ mỗi giá trị phân biệt sang một số nguyên nhỏ (
A→0, B→1, C→2), rồi cột chỉ lưu dãy số nguyên đó. Cột chuỗi dài lặp lại biến thành dãy số gọn, vừa nhẹ vừa so sánh nhanh.
Nén mang lại hai cái lợi cộng hưởng: tốn ít chỗ đĩa hơn, và quan trọng hơn — đọc ít byte hơn từ đĩa cho cùng một lượng dữ liệu logic. Vì query phân tích thường bị nghẽn ở băng thông đĩa/bộ nhớ (đọc rất nhiều dòng), nén ít byte hơn đồng nghĩa quét nhanh hơn. Row store nén kém hơn vì mỗi khối trộn lẫn nhiều kiểu dữ liệu (số kế chuỗi kế ngày), ít chỗ lặp liền kề để khai thác.
Nén ăn theo sự lặp lại nằm gần nhau. Trong một cột, các giá trị cùng "loại" và hay trùng (mọi dept, mọi status) đứng kề — đất vàng cho RLE/dictionary. Trong một dòng, giá trị kế tiếp là một cột khác (số rồi chuỗi rồi ngày) — không có cấu trúc lặp để nén. Đó là lý do column store thường nén gấp nhiều lần row store trên cùng dữ liệu.
5. Đòn bẩy 2 — Vectorized processing
Đòn bẩy thứ hai là ở cách CPU xử lý, không chỉ cách lưu. Vì một cột là một dãy giá trị cùng kiểu nằm liền nhau, engine có thể nạp cả một khối giá trị vào bộ nhớ và áp dụng phép tính lên cả khối một lượt — gọi là vectorized processing (xử lý theo lô/vector).
So sánh với cách xử lý từng-dòng-một (tuple-at-a-time) của database giao dịch: với mỗi dòng, engine giải mã bản ghi, định vị cột, rồi mới tính — lặp lại chi phí điều phối cho từng dòng. Khi quét hàng triệu dòng, chi phí điều phối lặp lại đó cộng dồn rất lớn.
Vectorized engine làm khác: lấy ví dụ WHERE effort > 4, nó nạp một khối hàng nghìn giá trị effort rồi so sánh cả khối với 4 trong một vòng lặp chặt, sinh ra một khối kết quả. Cách này tận dụng tốt cache CPU và các chỉ thị xử lý song song trên cùng một lệnh, nên thông lượng quét cao hơn hẳn so với chạm từng dòng. Cộng với dữ liệu đã nén ở mục 4, nhiều engine còn tính trực tiếp trên dữ liệu nén (ví dụ đếm trên mã dictionary mà chưa cần giải nén) — tiết kiệm thêm.
Tóm lại, ba thứ cộng hưởng: lưu-theo-cột giúp đọc đúng cột, nén giúp đọc ít byte, vectorized giúp tính nhanh trên khối. Cả ba đều bắt nguồn từ một quyết định — sắp dữ liệu theo cột.
6. Bảng so sánh — row store vs column store
| Tiêu chí | Row store | Column store |
|---|---|---|
| Sắp trên đĩa | Mọi cột một dòng liền nhau | Mọi giá trị một cột liền nhau |
| Đọc nguyên một dòng | Nhanh (một lần chạm) | Chậm (ghép từ nhiều cột) |
| Quét vài cột trên triệu dòng | Chậm (kéo cả cột thừa) | Nhanh (đọc đúng cột cần) |
| Ghi/sửa một dòng | Nhanh, tại chỗ | Chậm (đụng nhiều tệp cột) |
| Khả năng nén | Kém (trộn nhiều kiểu) | Tốt (RLE, dictionary) |
| Kiểu xử lý hợp | Từng dòng (tuple-at-a-time) | Theo lô (vectorized) |
| Workload phù hợp | OLTP — giao dịch nhỏ | OLAP — phân tích lớn |
Đây không phải "cái nào tốt hơn" mà là "tốt cho việc gì". Một hệ thống dữ liệu trưởng thành thường dùng cả hai: row store cho database giao dịch phục vụ ứng dụng, column store cho warehouse phục vụ phân tích — đúng tinh thần tách OLTP/OLAP ở bài 01.
7. Pitfall — dùng nhầm mô hình cho workload
Mỗi mô hình lưu trữ tối ưu cho một pattern. Dùng nhầm thì hiệu năng tệ ngay cả khi "công nghệ nghe có vẻ hiện đại".
- Đẩy OLTP lên column store. Một ứng dụng nhiều
INSERT/UPDATEtừng dòng (đặt hàng, cập nhật trạng thái) chạy trên column store sẽ chậm: mỗi lần ghi phải động vào nhiều tệp cột, và việc đọc-nguyên-một-dòng (lấy cả hồ sơ một đơn hàng) phải ghép từ nhiều khối. Column store sinh ra để quét lớn ít ghi, không phải giao dịch. - Chạy phân tích lớn trên row store. Đây là nỗi đau bài 01:
SUM/GROUP BYtrên triệu dòng trong row store kéo theo mọi cột thừa. Thêm index cũng không cứu được vì bản chất là quét nhiều dòng, không phải tra một dòng.
SAI: he thong dat hang (nhieu INSERT/UPDATE tung dong) -> column store
-> moi ghi dung nhieu cot, doc nguyen don hang phai ghep -> cham
SAI: bao cao SUM/GROUP BY trieu dong -> row store
-> keo ca cot thua, quet phi phan lon I/O -> cham
DUNG: row store cho giao dich (OLTP) + column store cho phan tich (OLAP)
Quy tắc: chọn layout theo pattern truy cập chủ đạo (đọc nguyên dòng theo khoá → row; quét vài cột trên nhiều dòng → column), không theo độ "mới" của công nghệ.
8. 📚 Deep Dive
- Designing Data-Intensive Applications (Kleppmann) — Chương 3 "Storage and Retrieval", mục "Column-Oriented Storage" — nguồn nền tảng, agnostic: giải thích column store, column compression (bitmap/RLE) và vectorized processing không gắn engine cụ thể.
- Wikipedia — Column-oriented DBMS — định nghĩa, lịch sử, và đối chiếu row vs column store.
Ghi chú: DDIA Chương 3 là nguồn chuẩn cho cơ chế lưu theo cột — đọc kỹ mục "Column Compression" để hiểu vì sao sắp dữ liệu theo cột mở ra nén tốt, và mục "Sort Order in Column Storage" để thấy sắp xếp cột làm nén còn mạnh hơn. Wikipedia cho định nghĩa và bức tranh tổng quan.
9. Liên hệ các bài khác
- Bài 01 — OLTP vs OLAP: bài này giải thích cơ chế lưu trữ đằng sau lý do hai workload ở bài 01 cần mô hình đĩa khác nhau — đọc bài 01 trước để có bối cảnh.
- Bài 03 — Dimensional modeling & star schema: warehouse column-store còn đi kèm cách tổ chức bảng riêng (lược đồ chiều) để tối ưu đọc — đọc tiếp sau bài này.
- Module 6 — Vì sao cần index: index tăng tốc tra một dòng theo khoá (OLTP) — đối lập với column store tăng tốc quét nhiều dòng vài cột (OLAP); so sánh hai hướng tối ưu giúp thấy rõ vì sao mỗi workload chọn cấu trúc khác.
- Module 5 — Normalization 1NF–3NF: chuẩn hoá tối ưu cho OLTP; bài 03 sẽ cho thấy warehouse cố ý đi ngược để tối ưu đọc.
10. Tóm tắt
- Row store xếp mọi cột của một dòng liền nhau → đọc/ghi nguyên dòng theo khoá nhanh → thắng OLTP.
- Column store xếp mọi giá trị một cột liền nhau → query phân tích đọc đúng cột cần, bỏ qua phần thừa → thắng OLAP.
- Khi bảng có nhiều cột mà query chỉ dùng vài cột, column store đọc ít dữ liệu hơn nhiều lần — gốc của tốc độ phân tích.
- Column compression (run-length, dictionary) nén tốt vì giá trị cùng cột đồng kiểu và hay lặp → ít chỗ đĩa + ít byte đọc.
- Vectorized processing xử lý cả khối giá trị một cột một lượt, tận dụng cache CPU → thông lượng quét cao hơn xử lý từng dòng.
- Chọn layout theo pattern truy cập chủ đạo, không theo độ "mới": đọc nguyên dòng → row; quét vài cột nhiều dòng → column.
11. Tự kiểm tra
Q1Cùng một bảng 50 cột, vì sao query 'SUM(effort)' chạy nhanh hơn nhiều trên column store so với row store?▸
Vì column store xếp mọi giá trị của cột effort liền nhau thành một khối. Query chỉ cần mở đúng khối đó và cộng một mạch — không bao giờ chạm 48 cột còn lại. Lượng dữ liệu đọc xấp xỉ chỉ bằng phần cột cần trên tổng số cột.
Trên row store, các giá trị effort nằm rải rác, mỗi giá trị kẹp giữa title, dept, created của cùng dòng. Engine buộc phải kéo cả khối dòng vào bộ nhớ rồi mới nhặt ra effort — đọc và vứt đi phần lớn dữ liệu thừa, lãng phí I/O.
Q2Vì sao column store nén tốt hơn row store đáng kể, và điều đó giúp gì cho tốc độ quét?▸
Nén ăn theo sự lặp lại nằm gần nhau. Trong một cột, các giá trị cùng kiểu và hay trùng (mọi status chỉ todo/doing/done, mọi dept vài chục giá trị) đứng kề nhau — đất vàng cho run-length và dictionary encoding. Trong một dòng, giá trị kế tiếp là một cột khác (số rồi chuỗi rồi ngày), không có cấu trúc lặp để nén.
Nén tốt giúp tốc độ vì query phân tích thường nghẽn ở băng thông đĩa/bộ nhớ. Đọc ít byte hơn cho cùng lượng dữ liệu logic nghĩa là quét nhanh hơn — đôi khi engine còn tính trực tiếp trên dữ liệu nén mà chưa cần giải nén.
Q3Vectorized processing khác xử lý từng-dòng-một (tuple-at-a-time) ở điểm nào, và vì sao hợp với column store?▸
Xử lý từng dòng giải mã bản ghi, định vị cột rồi tính — lặp lại chi phí điều phối cho từng dòng; quét triệu dòng thì chi phí đó cộng dồn rất lớn.
Vectorized nạp cả một khối hàng nghìn giá trị của một cột rồi áp phép tính lên cả khối trong một vòng lặp chặt. Cách này hợp column store vì một cột vốn đã là dãy giá trị cùng kiểu nằm liền nhau — sẵn sàng nạp thành khối. Nó tận dụng tốt cache CPU và xử lý song song, nên thông lượng quét cao hơn nhiều.
Q4Một ứng dụng đặt hàng (nhiều INSERT/UPDATE từng đơn, hay đọc nguyên một đơn) nên dùng row store hay column store? Vì sao?▸
Nên dùng row store. Pattern truy cập chủ đạo ở đây là ghi/sửa từng dòng và đọc nguyên một đơn hàng theo khoá — đúng sở trường của row store: một lần chạm là có cả dòng, ghi/sửa tại chỗ một khối.
Nếu ép lên column store, mỗi INSERT/UPDATE phải động vào nhiều tệp cột, và đọc nguyên một đơn phải ghép giá trị từ nhiều khối cột rời rạc — chậm hơn hẳn. Column store sinh ra cho quét lớn ít ghi (phân tích), không cho giao dịch.
Q5Đổi lại lợi thế quét nhanh, column store phải trả giá gì? Khi nào cái giá đó không thành vấn đề?▸
Cái giá: dựng lại nguyên một dòng phải nhặt giá trị từ nhiều khối cột rời rạc rồi ghép — tốn hơn row store; ghi/sửa từng dòng cũng phải đụng nhiều tệp cột.
Cái giá đó không thành vấn đề trong workload OLAP: query phân tích hiếm khi cần nguyên một dòng đầy đủ (nó aggregate vài cột trên nhiều dòng), và dữ liệu warehouse thường nạp theo lô định kỳ (ETL/ELT) chứ không sửa từng dòng liên tục. Vì thế column store hi sinh đúng thứ mà OLAP ít cần, để đổi lấy tốc độ quét mà OLAP cần nhất.
Bài tiếp theo: Dimensional modeling — star & snowflake schema
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên