Cơ sở dữ liệu quan hệRelational Databases
Thuộc bộ kiến thức Data Engineer Roadmap.
Tổng quan
Phần lớn data pipeline bắt đầu vòng đời từ một relational database. Ở đâu đó phía sau ứng dụng mà công ty bạn đang vận hành — service checkout, CRM, hệ thống billing — luôn có một instance PostgreSQL, MySQL, hay SQL Server âm thầm làm việc mà relational database làm tốt nhất: ghi lại từng business event riêng lẻ, từng row một, với các đảm bảo về tính đúng đắn cực kỳ nghiêm ngặt. Với vai trò data engineer, database đó không phải là database “của bạn”. Bạn không sở hữu schema của nó, không thiết kế index cho nó, và dưới góc nhìn của nó, bạn là một vị khách không mời. Việc của bạn là lấy dữ liệu ra khỏi đó — một cách đáng tin cậy, incremental, và không làm hỏng ứng dụng đang phụ thuộc vào nó.
Note này giả định bạn đã biết relational database là gì: table, row, SQL, transaction ACID, normal form, index. Phần nền tảng đó đã được trình bày chi tiết trong Backend — Relational Databases, và note này dựa vào đó thay vì lặp lại. Điều note này bổ sung là góc nhìn của data engineer trên cùng công nghệ đó: tại sao database hoàn hảo cho ứng dụng lại thù địch với các truy vấn analytical bạn muốn chạy trên nó, transaction isolation và index thay đổi cách bạn viết extraction query như thế nào, tại sao replica tồn tại, và các RDBMS engine chính khác nhau ra sao ở khả năng mà data engineer quan tâm nhất — lấy stream các thay đổi ra khỏi chúng rẻ và đáng tin cậy đến mức nào. Mọi thứ ở đây đều nối trực tiếp vào Data Sources & Ingestion (cách bạn thực sự pull dữ liệu) và Data Modeling & Warehousing (dữ liệu có hình dạng gì khi đã vào warehouse).
Kiến thức nền tảng
OLTP vs. OLAP: hệ thống nguồn vs. hệ thống đích
Mental model quan trọng nhất trong note này là ranh giới giữa hệ thống OLTP (Online Transaction Processing) và OLAP (Online Analytical Processing). Đây không chỉ là một phân biệt kỹ thuật — đó là lý do toàn bộ ngành data engineering tồn tại. Nếu một database có thể phục vụ tốt cả hai loại workload, sẽ chẳng cần extract, transform, và load dữ liệu đi đâu cả; ứng dụng có thể query thẳng production để dựng dashboard. Nhưng chúng không thể, nên chúng ta phải xây pipeline.
Một hệ thống OLTP được tối ưu cho nhiều transaction nhỏ, nhanh, chạy đồng thời: INSERT INTO orders ..., UPDATE inventory SET qty = qty - 1 WHERE sku = ..., mỗi lần một row (hoặc một nhóm nhỏ row liên quan), hàng nghìn lần mỗi giây, từ hàng nghìn user đồng thời. Schema của nó được normalize (xem bên dưới) để việc ghi từng row rẻ và an toàn. Index của nó được xây để point lookup (WHERE id = ?) và range scan nhỏ chạy nhanh.
Một hệ thống OLAP được tối ưu cho workload ngược lại: ít truy vấn nhưng lớn, nặng về đọc, quét và tổng hợp hàng triệu đến hàng tỷ row — “tổng doanh thu theo khu vực theo tháng trong ba năm qua.” Schema của nó được denormalize (star/snowflake) để giảm thiểu join, storage của nó thường là columnar, và nó được xây để quét khối lượng dữ liệu khổng lồ hiệu quả thay vì cập nhật từng row nhanh.
| Khía cạnh | OLTP (nguồn) | OLAP (đích) |
|---|---|---|
| Mục đích | Vận hành business (ghi nhận transaction) | Phân tích business (báo cáo về transaction) |
| Vai trò điển hình trong data stack | Hệ thống nguồn mà data engineer extract từ đó | Đích (data warehouse) mà data engineer load vào |
| Thiết kế schema | Normalized (3NF), nhiều table hẹp | Denormalized (star/snowflake schema), ít table rộng |
| Kiểu truy vấn | Point lookup, range scan nhỏ, ghi từng row | Aggregation lớn, quét gần như toàn bộ table/partition |
| Concurrency | Hàng nghìn transaction đọc/ghi ngắn mỗi giây | Một vài truy vấn read-only chạy lâu, cùng lúc |
| Cách lưu trữ | Row-oriented (đọc/ghi cả một row hiệu quả) | Column-oriented (đọc một column trên nhiều row hiệu quả) |
| Khối lượng dữ liệu mỗi truy vấn | Vài row | Hàng triệu đến hàng tỷ row |
| Độ tươi của dữ liệu | Real-time, luôn cập nhật | Thường là batch/near-real-time (chậm hơn vài phút đến vài giờ) |
| Engine ví dụ | PostgreSQL, MySQL, SQL Server, Oracle | Snowflake, BigQuery, Redshift, ClickHouse |
Tại sao không chạy analytics trực tiếp trên OLTP production
Với sự phân tách này, một câu hỏi hiển nhiên là: tại sao không chạy thẳng truy vấn báo cáo GROUP BY trên database Postgres production và bỏ qua toàn bộ pipeline? Với một app nhỏ, người ta làm đúng như vậy trong một thời gian — rồi họ dừng lại, vì vài lý do cụ thể.
Lock contention và blocking. Một câu SELECT analytical chạy lâu quét một table lớn có thể, tùy isolation level và engine, giữ read lock hoặc buộc storage engine phải giữ lại các phiên bản row cũ (xem MVCC bên dưới) trong suốt thời gian truy vấn chạy. Trong khi đó, ứng dụng đang cố UPDATE và INSERT vào chính table đó. Trong trường hợp xấu nhất (cấu hình MySQL/InnoDB cũ, hoặc database không có MVCC đúng nghĩa) bạn gặp lock wait thật sự: câu ghi của ứng dụng bị block phía sau report của bạn. Ngay cả với engine MVCC tránh được kiểu block reader/writer cổ điển, một transaction chạy lâu vẫn có thể chặn DDL, ngăn vacuum/cleanup thu hồi không gian, và giữ lại checkpoint hoặc WAL truncation.
Resource contention. Ngay cả khi không có locking, một scan analytical vẫn cạnh tranh cùng một tập tài nguyên hữu hạn với production traffic: CPU, disk I/O, buffer cache/shared buffer pool, và băng thông mạng. Một câu SELECT SUM(total_cents) FROM orders trên 50 triệu row sẽ vui vẻ đẩy working set nóng của ứng dụng ra khỏi cache, khiến mọi truy vấn OLTP tiếp theo phải rơi về đọc disk. Trang checkout của bạn chậm đi không phải vì bug, mà vì ai đó vừa chạy một report.
Optimizer chọn sai plan cho công việc. Một engine được tune cho OLTP và các số liệu thống kê (statistics) của nó được tối ưu cho các truy vấn ứng dụng chạy — index lookup trên vài row. Một truy vấn analytical ad-hoc với nhiều join và aggregation trên phần lớn table có thể kích hoạt full table scan, hoặc một join plan tồi, mà một warehouse engine (được xây và tune đúng cho workload đó) sẽ xử lý gọn gàng hơn nhiều.
Blast radius và access control. Database production chứa PII, dữ liệu thanh toán, và các trường nhạy cảm khác dưới access control nghiêm ngặt. Chạy truy vấn analytical tùy ý trên production buộc bạn phải cấp quyền đọc rộng cho analyst (vấn đề bảo mật/tuân thủ — xem Data Security & Compliance) hoặc dồn mọi câu hỏi qua đội data như một điểm nghẽn.
Đây chính là lập luận cốt lõi cho toàn bộ ngành ETL/ELT (xem ETL/ELT & Data Pipelines): copy dữ liệu, theo lịch của riêng bạn, bằng một phương pháp giảm thiểu tác động lên nguồn, vào một hệ thống được xây cho workload analytical, rồi để mọi người query hệ thống đó thoải mái bao nhiêu tùy thích.
Transaction và isolation: recap dưới góc nhìn data engineer
Backend — Relational Databases trình bày đầy đủ ACID và bốn isolation level của SQL (Read Uncommitted, Read Committed, Repeatable Read, Serializable); phiên bản ngắn gọn cho data engineer là: isolation level mà transaction extraction job của bạn chạy dưới đó quyết định “consistent” nghĩa là gì đối với snapshot bạn vừa pull được.
Nếu extraction query của bạn chạy ở READ COMMITTED (mặc định trong PostgreSQL và SQL Server) và bạn extract từ nhiều table trong cùng một batch logic — chẳng hạn orders và order_items — mà không bọc toàn bộ extraction trong một transaction duy nhất, hoàn toàn có thể xảy ra chuyện một write đồng thời chèn một order mới cùng các item của nó giữa hai câu SELECT của bạn, hoặc tệ hơn, bạn thấy order nhưng chưa thấy item của nó (vì transaction của ứng dụng insert cả hai chưa commit item, hay commit muộn hơn một chút sau khi bạn đọc). Kết quả là một tập file extract mà từng row riêng lẻ đều hợp lệ nhưng cùng nhau lại không nhất quán — một order không có item, hoặc một foreign key trong extract của bạn trỏ tới một row bạn chưa extract.
Hai cách giảm thiểu thực tế:
- Chạy toàn bộ extraction như một transaction duy nhất ở
REPEATABLE READ(PostgreSQL gọi đây là “repeatable read”; nó cho bạn một snapshot nhất quán tại thời điểm transaction bắt đầu thông qua MVCC, không tốn thêm chi phí locking cho reader). Điều này đảm bảo mọi table bạn đọc trong transaction đó phản ánh cùng một mốc thời gian. - Ưu tiên extraction dựa trên log/CDC (binlog, WAL) thay vì ad-hoc snapshot query cho bất cứ thứ gì cần tính nhất quán chặt giữa các table, vì replication log giữ nguyên ranh giới transaction — bạn có thể nhóm các thay đổi theo transaction ID của nguồn và áp dụng chúng atomically ở downstream.
Điều cần nhớ về failure mode thực tế: READ UNCOMMITTED (dirty read) không có chỗ đứng ở bất kỳ đâu gần một extraction job production — bạn có thể pull row từ một transaction sau đó rollback, âm thầm làm hỏng warehouse của bạn bằng dữ liệu chưa từng thực sự xảy ra.
Indexing dưới góc nhìn hiệu năng extraction
Note backend giải thích B-tree index là gì và tại sao nó tăng tốc point lookup; góc nhìn data engineering hẹp hơn và rất cụ thể: mệnh đề WHERE và ORDER BY trong extraction query của bạn phải khớp với một index có sẵn, nếu không bạn sẽ full-table-scan một table production mỗi lần chạy.
Pattern extraction phổ biến nhất là incremental extraction dùng watermark column — thường là một timestamp updated_at hoặc một column id/version tăng đơn điệu mà ứng dụng đã sẵn duy trì (thường là vì mục đích audit của chính nó, khá tiện). Mỗi lần chạy pipeline ghi lại giá trị watermark lớn nhất nó thấy, và lần chạy tiếp theo chỉ hỏi các row mới hơn giá trị đó:
-- Watermark từ lần chạy thành công trước đó được lưu trong metadata orchestration
-- của bạn (ví dụ: một Airflow Variable, một control table, một file trên S3).
SELECT id, customer_id, total_cents, status, updated_at
FROM orders
WHERE updated_at > :last_watermark
ORDER BY updated_at
LIMIT 10000; -- phân trang theo batch; query lại với max(updated_at) mới khi chạy tiếp
Truy vấn này chỉ rẻ nếu updated_at được index:
CREATE INDEX idx_orders_updated_at ON orders (updated_at);
Không có index đó, WHERE updated_at > :last_watermark buộc phải sequential scan toàn bộ table mỗi lần extraction — đọc từng page của orders từ disk (hoặc đẩy buffer cache ra) chỉ để loại bỏ tất cả trừ một nhúm row vừa thay đổi gần đây. Trên một table orders 200 triệu row chạy hàng giờ, đó là 24 lần full scan mỗi ngày cạnh tranh với production traffic vì đúng những lý do resource-contention đã nêu ở trên. Có index, cùng truy vấn đó trở thành một index range scan chỉ chạm vào số page tương ứng với số row mới/thay đổi kể từ lần chạy trước — rẻ hơn nhiều bậc, và chi phí của nó giữ nguyên ngay cả khi table lớn lên.
Một vài điểm thực tế đáng biết:
ORDER BY updated_atcũng quan trọng, không chỉ mệnh đề filter. Bạn muốn phân trang xác định (deterministic pagination) và có một điểm an toàn để resume nếu job fail giữa chừng; sort theo một column không được index phá hỏng toàn bộ ý nghĩa dù bản thân filter có dùng index, vì engine vẫn có thể phải sort toàn bộ tập kết quả khớp trong memory hoặc trên disk.- Trùng giá trị tại ranh giới watermark là một nguồn bug thật sự. Nếu hai row có cùng giá trị
updated_atchính xác, một câuWHERE updated_at > :last_watermark LIMIT Nngây thơ có thể bỏ sót một row nếu nó rơi ngoài trang hiện tại. Cách khắc phục phổ biến: dùng composite watermark(updated_at, id)vớiWHERE (updated_at, id) > (:last_ts, :last_id), hoặc chấp nhận một cửa sổ chồng lấp nhỏ và de-duplicate ở downstream theo primary key. updated_atphải thực sự được duy trì trên mọi write path, kể cả bulk update, admin script, và migration. Một câuUPDATE orders SET status = 'refunded'chạy tay mà không đụng vàoupdated_atsẽ vô hình với pipeline của bạn mãi mãi. Đây là một cam kết ở tầng application-schema, không phải điều đội data có thể tự quyết một mình — cần nêu ra khi onboard một nguồn mới.- Hard delete vô hình với watermark scan. Nếu một row bị xóa vật lý thay vì soft-delete (cờ
is_deleted) hay tombstone, một truy vấn dựa trênupdated_atsẽ không bao giờ báo cho bạn biết nó đã biến mất. Log-based CDC (bên dưới) là cách duy nhất đáng tin cậy để nắm bắt delete.
Normalization vs. denormalization: sự căng thẳng tạo ra ETL
Backend — Relational Databases giải thích 1NF/2NF/3NF chi tiết; điểm quan trọng dưới góc nhìn data engineering là normalization để làm gì và tại sao warehouse cố tình vứt bỏ nó.
Schema OLTP được normalize vì normalization bảo vệ write integrity: địa chỉ của một khách hàng chỉ tồn tại đúng một row, nên cập nhật nó cập nhật ở mọi nơi cùng lúc, và không có update nào có thể để lại hai bản sao của cùng một fact mâu thuẫn với nhau. Cái giá của sự bảo vệ đó là việc đọc một “business object hoàn chỉnh” — một order cùng khách hàng, các line item, sản phẩm, địa chỉ giao hàng — đòi hỏi join năm sáu table. Với một order đơn lẻ, không sao. Với một aggregation trên mười triệu order, năm sáu join qua các table normalized chính là kiểu truy vấn vật vã trên engine OLTP mà warehouse được xây để tránh.
Data warehouse đảo ngược sự đánh đổi này: chúng chấp nhận dư thừa (cùng tên khách hàng được copy vào mọi row fact order) để đổi lấy tốc độ đọc, dùng một star schema cố tình denormalize — một fact table trung tâm (ví dụ fact_orders) được bao quanh bởi các dimension table rộng, được làm phẳng (dim_customer, dim_product, dim_date) hầu như không cần join cho các truy vấn aggregation thông thường. Cơ chế đầy đủ của thiết kế star/snowflake schema nằm trong Data Modeling & Warehousing; điều cần khắc sâu ở đây là chính sự căng thẳng này — normalized-để-ghi vs. denormalized-để-đọc — là lý do vì sao một bước transformation buộc phải tồn tại giữa nguồn và warehouse. Bạn không thể trỏ một BI tool vào một OLTP replica normalized và mong có hiệu năng aggregation cỡ warehouse; ai đó phải làm công việc join-và-flatten, một lần, trước, và đó chính là “T” (Transform) trong ETL và lớp modeling trong ELT (dbt, v.v.) thực sự làm.
Khái niệm chính
Extract từ replica thay vì primary
Pattern production phổ biến nhất để bảo vệ hiệu năng OLTP khỏi workload data engineering rất đơn giản: không bao giờ chạy extraction query lên primary; chạy lên read replica.
Hầu hết RDBMS engine hỗ trợ replication bất đồng bộ (hoặc, hiếm hơn, đồng bộ): một read replica liên tục apply write-ahead log (hoặc binlog) của primary và chậm hơn primary vài mili giây đến vài giây. Trỏ extraction job, CDC log reader, và truy vấn ad-hoc của analyst vào replica có nghĩa là:
- Query load, table scan, và transaction chạy lâu trên replica có không tác động trực tiếp nào tới write throughput hay latency của primary.
- Bạn có thể “kém cẩn thận” hơn một chút về isolation và indexing trên replica (trong giới hạn hợp lý) vì blast radius của một truy vấn tồi bị giới hạn trong một instance không critical.
- Nếu replica chậm quá xa (replication lag), bạn có rủi ro extract dữ liệu cũ, nên bản thân lag trở thành thứ cần monitor và alert (xem Monitoring & Observability) — một batch job đọc từ một replica chậm 40 phút âm thầm tạo ra một warehouse cũ hơn 40 phút so với bạn nghĩ.
- Một số engine cho phép cấu hình một replica analytics/reporting chuyên dụng tách biệt với replica dùng cho failover/HA, để một extraction query chạy hoảng loạn không đe dọa luôn cả tư thế disaster-recovery của bạn.
Sự đánh đổi là read-after-write consistency: nếu ứng dụng ghi một row và extraction job của bạn đọc từ một replica đang lag chỉ vài mili giây sau đó, row đó có thể chưa xuất hiện. Với hầu hết use case analytical (doanh thu hôm qua, số signup tuần này), điều này không quan trọng; với bất cứ điều gì đòi hỏi tính đúng “tính đến hiện tại” nghiêm ngặt, bạn hoặc đọc từ primary cho truy vấn cụ thể đó hoặc tính toán rõ ràng cho độ lag.
Log-based CDC như một lựa chọn thay thế cho việc query hoàn toàn
Polling dựa trên watermark (pattern updated_at ở trên) đơn giản và hoạt động với mọi RDBMS, nhưng có giới hạn thật sự: nó bỏ sót hard delete, có thể bỏ sót các update trung gian nhanh giữa các lần polling (bạn chỉ bao giờ thấy trạng thái mới nhất, không thấy lịch sử thay đổi), và vẫn chạy một câu SELECT lên database mỗi lần lịch chạy, dù có rẻ đến đâu.
Change Data Capture (CDC) đi theo hướng khác: thay vì query table, bạn đọc log replication nội bộ của database — cùng stream mà một read replica tiêu thụ để giữ đồng bộ — và biến mỗi thay đổi ở mức row (insert/update/delete) đã được ghi log thành một event. Cách này đầy đủ hơn hẳn (delete và mọi trạng thái trung gian đều được nắm bắt) và áp gần như zero query load lên nguồn, vì bạn đang “tail” một log mà database đã ghi sẵn cho mục đích replication của chính nó, chứ không chạy SELECT lên table đang sống. Các công cụ như Debezium, AWS DMS, và connector CDC của Fivetran hoạt động theo cách này. Cơ chế CDC và việc truyền streaming được trình bày chi tiết trong Streaming & Real-Time Data; điều quan trọng ở đây là mỗi engine RDBMS bên dưới expose định dạng log nào, vì đó là thứ quyết định công cụ CDC có thể kết nối vào được hay không.
So sánh các RDBMS engine dưới góc nhìn data engineering
Bốn engine chủ đạo bạn sẽ gặp với vai trò hệ thống nguồn khác nhau đáng kể ở đúng những khả năng quan trọng cho extraction: cơ chế replication/logging chúng expose, cơ chế đó tốt cỡ nào cho CDC, và chúng hành xử ra sao dưới một truy vấn analytical chạy lâu.
| Engine | Cơ chế replication / log | Hỗ trợ CDC | Vai trò điển hình trong data stack | Đặc điểm đáng chú ý cho data engineering |
|---|---|---|---|---|
| PostgreSQL | Write-Ahead Log (WAL); logical replication và logical decoding (pgoutput, wal2json) native từ v10 | Xuất sắc — logical decoding được xây riêng cho CDC; connector Postgres của Debezium trưởng thành và dùng rộng rãi | Nguồn OLTP phổ biến cho startup/công ty vừa; cũng phổ biến như engine OLAP-adjacent qua extension | MVCC cho reader snapshot nhất quán mà không block writer; extensible (foreign data wrappers, pg_partman, TimescaleDB); transaction extraction REPEATABLE READ rẻ |
| MySQL / MariaDB | Binary log (binlog), định dạng row-based (ROW binlog format bắt buộc cho CDC đáng tin cậy) | Rất tốt — binlog CDC là pattern được kiểm chứng nhiều nhất trong hệ sinh thái (Debezium, Maxwell, Canal đều bắt nguồn từ đây) | Nguồn OLTP cực kỳ phổ biến, đặc biệt ở các công ty internet/consumer-scale (lịch sử: Facebook, Twitter, hầu hết stack e-commerce) | Phải xác nhận binlog_format=ROW và binlog_row_image (FULL) đầy đủ để CDC thấy giá trị before/after; MVCC của InnoDB tốt nhưng table MyISAM cũ hơn (vẫn thấy trong hệ thống legacy) không có row-level locking hay MVCC gì cả |
| Microsoft SQL Server | Transaction log; tính năng Change Data Capture và Change Tracking built-in; cũng hỗ trợ transactional replication cổ điển | Tốt — CDC là tính năng first-party của SQL Server (bật theo từng database, từng table), không nhất thiết cần công cụ tail log ngoài | Phổ biến ở doanh nghiệp chuẩn hóa trên stack Microsoft; nguồn thường gặp cho pipeline on-prem-sang-Azure | CDC native ghi change table trực tiếp bên trong SQL Server, đơn giản hóa extraction nhưng thêm overhead storage/bảo trì ngay trên nguồn; Always On Availability Groups cung cấp readable secondary cho extraction |
| Oracle Database | Redo log; Oracle GoldenGate và LogMiner cho CDC dựa trên log | Tốt nhưng thường cần công cụ trả phí (GoldenGate) hoặc connector mã nguồn mở dựa trên LogMiner; độ phức tạp vận hành cao hơn | Phổ biến ở doanh nghiệp lớn, legacy (ngân hàng, viễn thông, chính phủ); thường là nguồn khó nhất để có được self-serve CDC access | Đảm bảo isolation/consistency mạnh; extraction thường qua trung gian đội DBA và quy trình cấp quyền dữ liệu chính thức do licensing và governance quanh instance Oracle; tính năng flashback query hỗ trợ extract nhất quán tại một thời điểm |
Kết luận thực tế khi scope một tích hợp hệ thống nguồn mới: hỏi sớm xem CDC dựa trên logical/binlog có được bật hay không (nhiều database production chạy với nó tắt mặc định vì lý do hiệu năng hoặc licensing), liệu bạn có được cấp một read replica hay không, và extraction job được phép chạy ở isolation level nào. Ba câu trả lời đó quyết định toàn bộ kiến trúc extraction của bạn nhiều hơn hẳn việc bạn chọn ETL tool cụ thể nào.
Best Practices
Luôn extract từ replica khi có sẵn, và coi “không có replica” là một cờ đỏ đáng để escalate chứ không phải lý do để query thẳng primary — ngay cả một batch job hàng ngày duy nhất trên một table primary lớn cũng đủ để kích alert về p99 application latency.
Index column watermark trước lần chạy production đầu tiên, không phải sau khi bạn nhận ra pipeline chậm. CREATE INDEX ... CONCURRENTLY (PostgreSQL) hoặc online index build (SQL Server, MySQL 8+ với ALGORITHM=INPLACE) cho phép bạn thêm index mà không lock table, nhưng nó nên là một phần của checklist onboarding hệ thống nguồn, không phải một việc nghĩ ra sau.
Bọc extraction đa table trong một transaction duy nhất ở isolation level phù hợp (REPEATABLE READ hoặc tương đương snapshot-isolation của engine) bất cứ khi nào logic downstream phụ thuộc vào tính nhất quán giữa các table — ví dụ, extract một order cùng line item của nó trong cùng một lần chạy.
Ưu tiên log-based CDC thay vì polling khi một nguồn đã trưởng thành, đặc biệt với các table mà delete quan trọng, update xảy ra nhiều lần giữa các khoảng polling, hoặc table quá lớn để full-scan check delete bằng cách khác. Dành watermark polling updated_at cho các table nhỏ hơn, các nguồn bạn không thể có log access, hoặc một tích hợp đầu tiên nhanh gọn.
Phân trang các batch extraction lớn và làm cho job có thể resume. Không bao giờ pull “cả table” trong một câu query khi table có hàng triệu row; keyset pagination trên watermark đã index (WHERE (updated_at, id) > (:last_ts, :last_id) ORDER BY updated_at, id LIMIT N) tránh được vách hiệu năng cổ điển của pagination dựa trên OFFSET và cho phép một job fail restart từ trang commit cuối cùng thay vì từ đầu.
Đặt query timeout và giới hạn tài nguyên trên các connection extraction. Một extraction query hoạt động bất thường nên fail rõ ràng và bị kill, chứ không phải chạy âm thầm sáu tiếng làm đói primary. Hầu hết engine cho phép giới hạn thời gian chạy statement theo role/session (statement_timeout trong PostgreSQL, hint MAX_EXECUTION_TIME trong MySQL, Resource Governor trong SQL Server) — áp giới hạn đó lên credential mà pipeline của bạn dùng.
Xác nhận updated_at (hoặc một audit column tương đương) thực sự được duy trì trên mọi write path, kể cả bulk job, admin tool, và migration, trước khi dựa vào nó làm watermark — một write path duy nhất không được duy trì sẽ âm thầm tạo ra khoảng trống trong mọi extraction tương lai.
Coi hard delete là một khoảng trống đã biết của extraction dựa trên watermark và hoặc chuyển sang CDC, thêm cờ soft-delete vào schema nguồn (một cuộc trao đổi với đội ứng dụng, không phải điều bạn có thể tự quyết một mình), hoặc chạy quét reconciliation đầy đủ định kỳ trong khung giờ ít traffic để bắt những gì watermark polling bỏ sót.
Dùng một database role chuyên dụng, least-privilege cho extraction job, giới hạn ở SELECT trên các schema/table cụ thể cần thiết, tách biệt khỏi bất kỳ service account ứng dụng nào — điều này giới hạn blast radius và giúp việc audit hay throttle traffic data engineering độc lập với traffic ứng dụng trở nên đơn giản.
Tài liệu tham khảo
- Backend — Relational Databases — SQL fundamentals, ACID, isolation level, normalization, và indexing chi tiết
- PostgreSQL Documentation — Write-Ahead Logging
- PostgreSQL Documentation — Logical Replication
- MySQL Reference Manual — The Binary Log
- Microsoft Learn — Change Data Capture (SQL Server)
- Debezium Documentation
- Martin Kleppmann — Designing Data-Intensive Applications, Ch. 5 & 11 — nền tảng về replication và CDC/derived-data
- Use The Index, Luke — hướng dẫn indexing cho SQL
Part of the Data Engineer Roadmap knowledge base.
Overview
Most data pipelines start their life at a relational database. Somewhere behind the application your company runs — the checkout service, the CRM, the billing system — sits a PostgreSQL, MySQL, or SQL Server instance quietly doing the thing relational databases are best at: recording individual business events, one row at a time, with strict correctness guarantees. As a data engineer, that database is not “your” database. You do not own its schema, you did not design its indexes, and you are, from its point of view, an uninvited guest. Your job is to get data out of it — reliably, incrementally, and without breaking the application that depends on it.
This note assumes you already know what a relational database is: tables, rows, SQL, ACID transactions, normal forms, indexes. That ground is covered in depth in Backend — Relational Databases, and this note leans on it rather than repeating it. What it adds is the data engineer’s angle on the same technology: why the database that is perfect for the application is hostile to the analytical queries you want to run against it, how transaction isolation and indexing change the way you write extraction queries, why replicas exist, and how the major RDBMS engines differ in the one capability data engineers care about most — how cheaply and reliably you can get a stream of changes out of them. Everything here feeds directly into Data Sources & Ingestion (how you actually pull the data) and Data Modeling & Warehousing (what shape the data takes once it lands).
Fundamentals
OLTP vs. OLAP: source vs. destination
The single most important mental model in this note is the split between OLTP (Online Transaction Processing) and OLAP (Online Analytical Processing) systems. It is not just a technical distinction — it is the reason the entire discipline of data engineering exists. If one database could serve both workloads equally well, there would be no need to extract, transform, and load data anywhere; applications would just query production directly for their dashboards. They can’t, so we build pipelines.
An OLTP system is optimized for many small, fast, concurrent transactions: INSERT INTO orders ..., UPDATE inventory SET qty = qty - 1 WHERE sku = ..., one row (or a handful of related rows) at a time, thousands of times per second, from thousands of concurrent users. Its schema is normalized (see below) to make single-row writes cheap and safe. Its indexes are built to make point lookups (WHERE id = ?) and small range scans fast.
An OLAP system is optimized for the opposite workload: few, large, read-heavy queries that scan and aggregate millions or billions of rows — “total revenue by region by month for the last three years.” Its schema is denormalized (star/snowflake) to minimize joins, its storage is often columnar, and it is built to scan huge swaths of data efficiently rather than to update single rows quickly.
| Aspect | OLTP (source) | OLAP (destination) |
|---|---|---|
| Purpose | Run the business (record transactions) | Analyze the business (report on transactions) |
| Typical role in a data stack | Source system data engineers extract from | Destination (data warehouse) data engineers load into |
| Schema design | Normalized (3NF), many narrow tables | Denormalized (star/snowflake schema), few wide tables |
| Query pattern | Point lookups, small range scans, single-row writes | Large aggregations, full or near-full table/partition scans |
| Concurrency | Thousands of short-lived read/write transactions per second | A handful of long-running, read-only queries at a time |
| Storage layout | Row-oriented (efficient to read/write one full row) | Column-oriented (efficient to read one column across many rows) |
| Data volume per query | A few rows | Millions to billions of rows |
| Data freshness | Real-time, always current | Often batch/near-real-time (minutes to hours behind) |
| Example engines | PostgreSQL, MySQL, SQL Server, Oracle | Snowflake, BigQuery, Redshift, ClickHouse |
Why you don’t run analytics directly on production OLTP
Given this split, an obvious question is: why not just run your GROUP BY reporting query straight against the production Postgres database and skip the pipeline entirely? For a small app, people do exactly that for a while — and then they stop, for a few concrete reasons.
Lock contention and blocking. A long-running analytical SELECT that scans a large table can, depending on isolation level and engine, hold read locks or force the storage engine to keep old row versions around (see MVCC below) for the duration of the query. Meanwhile the application is trying to UPDATE and INSERT into that same table. In the worst case (older MySQL/InnoDB configurations, or databases without proper MVCC) you get genuine lock waits: the application’s write blocks behind your report. Even with MVCC engines that avoid classic reader/writer blocking, a long-running transaction can still block DDL, prevent vacuum/cleanup from reclaiming space, and hold back checkpoint or WAL truncation.
Resource contention. Even without locking, an analytical scan competes for the same finite resources as production traffic: CPU, disk I/O, the buffer cache/shared buffer pool, and network bandwidth. A SELECT SUM(total_cents) FROM orders over 50 million rows will happily evict the application’s hot working set out of the cache, causing every subsequent OLTP query to fall back to disk. Your checkout page gets slower not because of a bug, but because someone ran a report.
The optimizer picks the wrong plan for the job. An OLTP-tuned engine and its statistics are optimized for the queries the application runs — index lookups on a handful of rows. An ad-hoc analytical query with several joins and aggregations over most of the table can trigger a full table scan, or a bad join plan, that a warehouse engine (built and tuned for exactly that workload) would handle far more gracefully.
Blast radius and access control. Production databases hold PII, payment data, and other sensitive fields under strict access controls. Running arbitrary analytical queries against production forces you to either grant broad read access to analysts (a security/compliance problem — see Data Security & Compliance) or funnel every question through the data team as a bottleneck.
This is the core motivating argument for the entire ETL/ELT discipline (see ETL/ELT & Data Pipelines): copy the data, on your own schedule, using a method that minimizes impact on the source, into a system built for the analytical workload, and let people query that as hard as they want.
Transactions and isolation: a data engineer’s recap
Backend — Relational Databases covers ACID and the four SQL isolation levels (Read Uncommitted, Read Committed, Repeatable Read, Serializable) in full; the short version for a data engineer is this: the isolation level of the transaction your extraction job runs under determines what “consistent” means for the snapshot you just pulled.
If your extraction query runs at READ COMMITTED (the default in PostgreSQL and SQL Server) and you extract from multiple tables in the same logical batch — say, orders and order_items — without wrapping the whole extraction in a single transaction, it is possible for a concurrent write to insert a new order and its items between your two SELECTs, or worse, for you to see the order but not yet its items (because the application’s transaction that inserts both hasn’t committed the items row yet, or committed it a moment after your read). The result is a set of extracted files that are individually valid rows but jointly inconsistent — an order with no items, or a foreign key in your extract that points to a row you haven’t extracted yet.
Two practical mitigations:
- Run the whole extraction as a single transaction at
REPEATABLE READ(PostgreSQL calls this “repeatable read”; it gives you a consistent snapshot as of the transaction’s start via MVCC, at no extra locking cost for readers). This guarantees every table you read within that transaction reflects the same point in time. - Prefer log-based/CDC extraction (binlog, WAL) over ad-hoc snapshot queries for anything that needs strict cross-table consistency, because the replication log preserves transaction boundaries — you can group changes by the source transaction ID and apply them atomically downstream.
The practical failure mode to remember: READ UNCOMMITTED (dirty reads) has no business anywhere near a production extraction job — you could pull rows from a transaction that later rolls back, silently corrupting your warehouse with data that never actually happened.
Indexing for extraction performance
The backend note explains what a B-tree index is and why it speeds up point lookups; the data engineering angle is narrower and very concrete: your extraction query’s WHERE and ORDER BY clauses must line up with an existing index, or you will full-table-scan a production table on every run.
The dominant extraction pattern is incremental extraction using a watermark column — typically an updated_at timestamp or a monotonically increasing id/version column that the application already maintains (often for its own auditing purposes, which is convenient). Each run of the pipeline records the maximum watermark value it saw, and the next run asks only for rows newer than that:
-- The watermark from the previous successful run is stored in your
-- orchestration metadata (e.g. an Airflow Variable, a control table, a file in S3).
SELECT id, customer_id, total_cents, status, updated_at
FROM orders
WHERE updated_at > :last_watermark
ORDER BY updated_at
LIMIT 10000; -- paginate in batches; re-query with the new max(updated_at) as you go
This query is cheap only if updated_at is indexed:
CREATE INDEX idx_orders_updated_at ON orders (updated_at);
Without that index, WHERE updated_at > :last_watermark forces a sequential scan of the entire table on every extraction run — reading every page of orders from disk (or evicting the buffer cache) just to throw away all but a handful of recently changed rows. On a 200-million-row orders table running hourly, that is 24 full scans a day competing with production traffic for exactly the resource-contention reasons described above. With the index, the same query is an index range scan that touches roughly as many pages as there are new/changed rows since the last run — orders of magnitude cheaper, and its cost stays flat as the table grows.
A few practical wrinkles worth knowing:
ORDER BY updated_atmatters, not just the filter. You want deterministic pagination and a safe place to resume if the job fails mid-batch; sorting by an unindexed column defeats the whole point even if the filter itself uses an index, because the engine may still need to sort the full matching set in memory or on disk.- Ties at the watermark boundary are a real bug source. If two rows share the exact same
updated_at, a naiveWHERE updated_at > :last_watermark LIMIT Ncan skip one of them if it falls outside the current page. Common fixes: use a composite watermark(updated_at, id)withWHERE (updated_at, id) > (:last_ts, :last_id), or accept a small overlap window and de-duplicate downstream on the primary key. updated_atmust actually be maintained on every write path, including bulk updates, admin scripts, and migrations. A singleUPDATE orders SET status = 'refunded'run by hand without touchingupdated_atis invisible to your pipeline forever. This is an application-schema contract, not something the data team controls alone — flag it during onboarding of a new source.- Hard deletes are invisible to a watermark scan. If a row is physically deleted rather than soft-deleted (
is_deletedflag) or tombstoned, anupdated_at-based query will never tell you it’s gone. Log-based CDC (below) is the only reliable way to capture deletes.
Normalization vs. denormalization: the tension that creates ETL
Backend — Relational Databases explains 1NF/2NF/3NF in detail; the data engineering point is what normalization is for and why the warehouse deliberately throws it away.
OLTP schemas are normalized because normalization protects write integrity: a customer’s address lives in exactly one row, so updating it updates it everywhere at once, and no update can leave two copies of the same fact disagreeing with each other. The cost of that protection is that reading a “complete” business object — an order with its customer, its line items, its products, its shipping address — requires joining five or six tables. For a single order, that’s fine. For an aggregation over ten million orders, five or six joins across normalized tables is exactly the query pattern that struggles on an OLTP engine and that a warehouse is built to avoid.
Data warehouses invert the trade-off: they accept redundancy (the same customer name copied into every order-fact row) in exchange for read speed, using a deliberately denormalized star schema — one central fact table (e.g., fact_orders) surrounded by wide, flattened dimension tables (dim_customer, dim_product, dim_date) that require few or no joins for typical aggregation queries. The full mechanics of star/snowflake schema design live in Data Modeling & Warehousing; the point to internalize here is that this exact tension — normalized-for-writes vs. denormalized-for-reads — is why a transformation step has to exist between source and warehouse at all. You cannot point a BI tool at a normalized OLTP replica and get warehouse-grade aggregation performance; someone has to do the join-and-flatten work, once, ahead of time, which is what “T” (Transform) in ETL and the modeling layer in ELT (dbt, etc.) actually do.
Key Concepts
Extracting from a replica instead of the primary
The single most common production pattern for protecting OLTP performance from data engineering workloads is simple: never run extraction queries against the primary; run them against a read replica.
Most RDBMS engines support asynchronous (or, more rarely, synchronous) replication: a read replica continuously applies the primary’s write-ahead log (or binlog) and stays a few milliseconds to a few seconds behind. Pointing extraction jobs, CDC log readers, and ad-hoc analyst queries at a replica means:
- Query load, table scans, and long-running transactions on the replica have zero direct impact on the primary’s write throughput or latency.
- You can afford to be less careful about isolation and indexing on the replica (within reason) because the blast radius of a bad query is contained to a non-critical instance.
- If the replica falls too far behind (replication lag), you risk extracting stale data, so lag itself becomes something to monitor and alert on (see Monitoring & Observability) — a batch job that reads from a replica lagging by 40 minutes silently produces a warehouse that’s 40 minutes staler than you think.
- Some engines let you configure a dedicated analytics/reporting replica distinct from the replica used for failover/HA, so a runaway extraction query doesn’t threaten your disaster-recovery posture either.
The trade-off is read-after-write consistency: if the application writes a row and your extraction job reads from a lagging replica milliseconds later, that row may not be visible yet. For most analytical use cases (yesterday’s revenue, this week’s signups) this is irrelevant; for anything requiring strict “as of now” correctness, you either read from the primary for that specific query or account for the lag explicitly.
Log-based CDC as the alternative to querying at all
Watermark-based polling (the updated_at pattern above) is simple and works with any RDBMS, but it has real limits: it misses hard deletes, it can miss rapid intermediate updates between polling intervals (you only ever see the latest state, not the history of changes), and it still runs a SELECT against the database on every schedule tick, however cheap.
Change Data Capture (CDC) takes a different approach: instead of querying tables, you read the database’s internal replication log — the same stream a read replica consumes to stay in sync — and turn each logged row-level change (insert/update/delete) into an event. This is strictly more complete (deletes and every intermediate state are captured) and imposes near-zero query load on the source, because you’re tailing a log the database already writes for its own replication purposes, not running SELECTs against live tables. Tools like Debezium, AWS DMS, and Fivetran’s CDC connectors work this way. CDC mechanics and streaming delivery are covered in depth in Streaming & Real-Time Data; what matters here is which underlying RDBMS log format each engine exposes, since that dictates what CDC tooling can even connect to.
RDBMS engines compared for data engineering
The four mainstream engines you’ll meet as source systems differ meaningfully in exactly the capabilities that matter for extraction: what replication/logging mechanism they expose, how good that mechanism is for CDC, and how they behave under a long-running analytical query.
| Engine | Replication / log mechanism | CDC support | Typical role in a data stack | Notable data-engineering traits |
|---|---|---|---|---|
| PostgreSQL | Write-Ahead Log (WAL); native logical replication and logical decoding (pgoutput, wal2json) since v10 | Excellent — logical decoding is purpose-built for CDC; Debezium’s Postgres connector is mature and widely used | Common OLTP source for startups/mid-size companies; also popular as an OLAP-adjacent engine via extensions | MVCC gives readers a consistent snapshot without blocking writers; extensible (foreign data wrappers, pg_partman, TimescaleDB); REPEATABLE READ extraction transactions are cheap |
| MySQL / MariaDB | Binary log (binlog), row-based format (ROW binlog format required for reliable CDC) | Very good — binlog CDC is the most battle-tested pattern in the ecosystem (Debezium, Maxwell, Canal all originated here) | Extremely common OLTP source, especially at internet/consumer-scale companies (historically Facebook, Twitter, most e-commerce stacks) | Must confirm binlog_format=ROW and adequate binlog_row_image (FULL) for CDC to see before/after values; InnoDB’s MVCC is good but older MyISAM tables (still seen in legacy systems) have no row-level locking or MVCC at all |
| Microsoft SQL Server | Transaction log; built-in Change Data Capture feature and Change Tracking feature; also supports classical transactional replication | Good — CDC is a first-party SQL Server feature (enable per-database, per-table), no external log-tailing tool strictly required | Common in enterprises standardized on the Microsoft stack; frequent source for on-prem-to-Azure pipelines | Native CDC writes change tables directly inside SQL Server, simplifying extraction but adding storage/maintenance overhead on the source itself; Always On Availability Groups provide readable secondaries for extraction |
| Oracle Database | Redo log; Oracle GoldenGate and LogMiner for log-based CDC | Good but typically requires paid tooling (GoldenGate) or LogMiner-based open-source connectors; higher operational complexity | Common in large, legacy enterprises (banking, telco, government); often the hardest source to get self-serve CDC access to | Strong isolation/consistency guarantees; extraction usually mediated by DBA teams and formal data-access processes given licensing and governance around Oracle instances; flashback query features help with point-in-time consistent extracts |
The practical takeaway when scoping a new source-system integration: ask early whether logical/binlog-based CDC is even switched on (many production databases run with it disabled by default for performance or licensing reasons), whether you’ll be granted a read replica, and what isolation level the extraction jobs are permitted to run at. Those three answers determine your entire extraction architecture far more than which specific ETL tool you pick.
Best Practices
Always extract against a replica when one exists, and treat “no replica available” as a red flag worth escalating rather than a reason to query the primary directly — even a single daily batch job against a large primary table can be enough to trip an alert on p99 application latency.
Index the watermark column before the first production run, not after you notice the pipeline is slow. CREATE INDEX ... CONCURRENTLY (PostgreSQL) or an online index build (SQL Server, MySQL 8+ with ALGORITHM=INPLACE) lets you add it without locking the table, but it should be part of the source-system onboarding checklist, not an afterthought.
Wrap multi-table extraction in a single transaction at an appropriate isolation level (REPEATABLE READ or the engine’s snapshot-isolation equivalent) whenever downstream logic depends on cross-table consistency — for example, extracting an order and its line items in the same run.
Prefer log-based CDC over polling once a source matures, especially for tables where deletes matter, updates happen many times between your polling interval, or the table is too large to full-scan-check for deletes some other way. Reserve updated_at-watermark polling for smaller tables, sources where you can’t get log access, or a fast first integration.
Paginate large extraction batches and make the job resumable. Never pull “the whole table” in one query when a table has millions of rows; keyset pagination on the indexed watermark (WHERE (updated_at, id) > (:last_ts, :last_id) ORDER BY updated_at, id LIMIT N) avoids the classic OFFSET-based pagination performance cliff and lets a failed job restart from its last committed page instead of from zero.
Set query timeouts and resource limits on extraction connections. A misbehaving extraction query should fail loudly and get killed, not run for six hours quietly starving the primary. Most engines let you cap statement runtime per role/session (statement_timeout in PostgreSQL, MAX_EXECUTION_TIME hint in MySQL, Resource Governor in SQL Server) — apply that limit to the credentials your pipeline uses.
Confirm updated_at (or an equivalent audit column) is actually maintained on every write path, including bulk jobs, admin tools, and migrations, before relying on it as a watermark — a single unmaintained code path silently produces gaps in every future extraction.
Treat hard deletes as a known gap in watermark-based extraction and either move to CDC, add soft-delete flags to the source schema (a conversation with the application team, not something you can do unilaterally), or run periodic full reconciliation scans during low-traffic windows to catch what watermark polling missed.
Use a dedicated, least-privilege database role for extraction jobs, scoped to SELECT on the specific schemas/tables needed, separate from any application service account — this limits blast radius and makes it trivial to audit or throttle data engineering traffic independently of application traffic.
References
- Backend — Relational Databases — SQL fundamentals, ACID, isolation levels, normalization, and indexing in depth
- PostgreSQL Documentation — Write-Ahead Logging
- PostgreSQL Documentation — Logical Replication
- MySQL Reference Manual — The Binary Log
- Microsoft Learn — Change Data Capture (SQL Server)
- Debezium Documentation
- Martin Kleppmann — Designing Data-Intensive Applications, Ch. 5 & 11 — replication and CDC/derived-data fundamentals
- Use The Index, Luke — SQL indexing guide