← Kỹ sư dữ liệu← Data Engineer
Kỹ sư dữ liệuData Engineer19 Th7, 2026Jul 19, 202625 phút đọc21 min read

Big Data & Distributed ComputingBig Data & Distributed Computing

Thuộc bộ kiến thức Data Engineer Roadmap.

Tổng quan

Mọi data platform sớm muộn cũng chạm phải cùng một bức tường: một table, một join, hay một job aggregation chạy đêm trước đây chỉ mất vài phút, giờ mất hàng giờ, hoặc đơn giản là không còn vừa trong memory của máy đang chạy nó. Đến lúc đó có hai hướng đi. Vertical scaling (scale theo chiều dọc) là mua một máy to hơn — nhiều RAM hơn, nhiều CPU core hơn, disk nhanh hơn — đơn giản nhưng có trần cứng và trở nên đắt đỏ không cân xứng khi đã gần đỉnh của thị trường phần cứng. Horizontal scaling (scale theo chiều ngang) thay vào đó chia nhỏ dữ liệu và công việc ra nhiều máy bình thường, cùng nhau phối hợp như một hệ thống duy nhất. Đây chính là bước chuyển nền tảng mà các công nghệ “big data” được xây dựng xoay quanh, và cũng là đánh đổi tương tự các backend engineer thực hiện khi scale một stateless web service (xem ../backend/README.md để có vocabulary scaling tổng quát) — chỉ khác là một hệ thống distributed xử lý dữ liệu còn phải di chuyển và shuffle một lượng lớn state, chứ không chỉ các request không trạng thái, và điều này tạo ra những vấn đề không tồn tại khi scale web server phía sau load balancer.

Note này cover những gì một data engineer thực sự cần để suy luận về distributed computing: partitioning, shuffling, và fault tolerance như là cơ chế cốt lõi; cluster và resource manager (YARN, Kubernetes) như là bộ máy chạy công việc đó; hệ sinh thái Hadoop (HDFS, MapReduce) như nền tảng lịch sử mà gần như mọi hệ thống hiện đại hoặc kế thừa hoặc phản ứng lại; và Apache Spark như công cụ mà một data engineer thực sự dùng hàng ngày. Kết thúc bằng ghi chú vì sao cloud object storage phần lớn đã thay thế HDFS tự quản lý trong các stack hiện đại, và cách chủ đề này kết nối với data lake (./07-data-lakes-and-modern-architectures.md), streaming (./11-streaming-and-realtime-data.md), container orchestration (./12-containers-and-orchestration.md), và các cloud data platform managed (./13-cloud-data-platforms.md).

Kiến thức nền tảng

Vì sao cần distributed computing

Một máy đơn có ba tài nguyên hữu hạn mà một workload dữ liệu có thể vắt kiệt: memory, disk, và CPU throughput. Một dataset vài gigabyte thì thoải mái nằm gọn trong RAM của một laptop; một dataset vài chục terabyte thì không, dù bạn mua bao nhiêu RAM đi nữa, vì dung lượng RAM trên mỗi máy có giới hạn thực tế và kinh tế. Ngay cả khi dữ liệu vừa trên disk, một CPU đơn (hoặc thậm chí một máy đa nhân duy nhất) xử lý tuần tự cũng đặt ra trần cứng cho throughput — đọc, biến đổi, và aggregate một petabyte dữ liệu trên một máy có thể mất nhiều ngày hoặc nhiều tuần dù disk có chứa đủ đi nữa.

Distributed computing giải quyết cả hai giới hạn này bằng cách chia một bài toán lớn thành nhiều mảnh nhỏ hơn, gửi mỗi mảnh đến một máy khác nhau trong một cluster, rồi gộp các kết quả bộ phận lại. Nếu việc chia công việc thành N phần scale hoàn hảo, N máy sẽ hoàn thành trong 1/N thời gian. Trong thực tế nó không bao giờ scale hoàn hảo — việc phối hợp giữa các máy, di chuyển dữ liệu giữa chúng, và gộp kết quả đều tốn thời gian — nhưng đi từ một máy sang cluster hàng chục hoặc hàng trăm máy biến những job bất khả thi trên một node đơn thành những job hoàn thành trong vài phút.

Cần thành thật về việc khi nào điều này thực sự quan trọng. Phần lớn workload phân tích — kể cả ở những công ty tự coi mình là “big data” — vẫn chạy thoải mái trên một máy đơn được cấp đủ tài nguyên với các công cụ như DuckDB, Polars, hay một job Spark/pandas single-node. Distributed computing thêm vào một lượng phức tạp vận hành thực sự (provision cluster, network partition, partial failure, debug qua hàng chục máy) chỉ đáng đánh đổi khi khối lượng dữ liệu hoặc yêu cầu throughput xử lý thực sự vượt quá những gì một máy mạnh đơn lẻ có thể làm. Một data engineer giỏi chọn cluster vì con số buộc phải vậy, chứ không phải vì mặc định.

Data partitioning

Partitioning là cách một hệ thống distributed quyết định hàng/record nào nằm trên node nào (hoặc, trong Spark, partition nào trong memory của một executor). Một dataset được chia thành các partition — các khối logic — và mỗi partition được xử lý độc lập và song song bởi một task chạy trên một node nào đó trong cluster. Partitioning chính là điều làm cho horizontal scaling khả thi: không có nó, sẽ không có cách nào để chia công việc.

Cách dữ liệu được partition ảnh hưởng cực lớn đến hiệu năng. Nếu một dataset các event của user được partition theo hash của user_id, thì mọi event của một user nhất định sẽ nằm cùng một partition — chính xác là điều bạn muốn cho một phép toán cần tất cả event của một user nằm cùng nhau (ví dụ groupBy(user_id)). Nếu cùng dataset đó được partition round-robin (row 1 vào partition 1, row 2 vào partition 2, cứ thế mà không quan tâm nội dung), không có đảm bảo nào như vậy, và một phép toán cần group theo user_id sẽ phải di chuyển dữ liệu giữa các partition để đưa các key trùng khớp lại gần nhau. Chọn đúng partitioning key — và tránh partition skew, khi một key phổ biến hơn hẳn các key khác và một partition phải làm phần lớn công việc trong khi các partition còn lại rảnh rỗi — là một trong những quyết định quan trọng nhất khi tune một job distributed.

Vấn đề shuffle

Shuffle là điều xảy ra khi một phép toán cần dữ liệu hiện đang nằm trên các node khác nhau phải được đưa về cùng một node — phổ biến nhất là với JOIN, GROUP BY, DISTINCT, và các phép toán repartition. Cụ thể: nếu orders được partition theo order_idcustomers được partition theo customer_id, join chúng theo customer_id đòi hỏi phải di chuyển vật lý các hàng qua network để các giá trị customer_id trùng khớp cùng nằm trên một executor. Việc này bao gồm serialize dữ liệu, ghi xuống disk, truyền qua network, và deserialize ở node nhận — tất cả đều chậm hơn hàng bậc độ lớn so với một phép tính in-memory, cục bộ.

Đây chính là thứ khiến distributed processing chậm hơn nhiều so với những gì các engineer mới vào nghề kỳ vọng. Một job trông nhanh trên giấy (aggregate một tỷ hàng, dễ thôi, chỉ cần chia làm 1.000 phần) có thể bị chi phối hoàn toàn bởi thời gian shuffle nếu phép aggregation đòi hỏi di chuyển phần lớn dữ liệu qua network. Các triệu chứng của job nặng shuffle bao gồm thời gian “stage” dài trong Spark UI không tương ứng với khối lượng tính toán rõ ràng, disk spill (khi dữ liệu shuffle không vừa memory và phải ghi xuống rồi đọc lại từ disk cục bộ), và mạng bị bão hòa toàn cluster.

Cách xử lý thực tế là thiết kế job để giảm thiểu shuffle: filter và project (chỉ chọn column/row cần thiết) trước khi join hay group-by thay vì sau, để ít dữ liệu phải di chuyển hơn; pre-partition dữ liệu trên disk theo key mà bạn thường xuyên join hoặc group, để nó đã sẵn cùng vị trí; dùng broadcast join khi một bên của join đủ nhỏ để vừa memory trên mọi node (tránh hoàn toàn việc shuffle bên lớn); và cẩn trọng với các lời gọi repartition()/coalesce(), vì cả hai đều kích hoạt shuffle (repartition phân phối lại dữ liệu qua một số partition mới; coalesce gộp partition mà không cần shuffle đầy đủ khi giảm số partition).

Fault tolerance

Ở quy mô hàng trăm máy chạy trong nhiều giờ, lỗi phần cứng và mạng không phải là trường hợp hiếm gặp — mà là chắc chắn về mặt thống kê. Một disk hỏng, một node mất kết nối mạng, một process bị OS kill vì dùng quá nhiều memory. Một hệ thống xử lý distributed phải được xây dựng với giả định điều này sẽ xảy ra giữa job, và thiết kế sao cho một lỗi node không đòi hỏi phải chạy lại toàn bộ tính toán từ đầu.

Hai ý tưởng làm điều này khả thi. Thứ nhất, replication: dữ liệu được lưu với nhiều bản sao (HDFS mặc định replication 3 lần) trên các node và rack khác nhau, nên mất disk của một node không làm mất dữ liệu. Thứ hai, lineage và recomputation: các engine hiện đại như Spark theo dõi chuỗi các phép biến đổi đã tạo ra mỗi partition dữ liệu (đây chính xác là điều RDD lineage graph hoặc DataFrame query plan ghi lại). Nếu một node đang giữ một partition chết giữa job, Spark không cần một checkpoint của đúng partition đó — nó có thể tính lại chỉ partition đó từ lineage, dùng dữ liệu input gốc và các phép biến đổi lẽ ra phải tạo ra nó, và chỉ chạy lại phần công việc bị mất thay vì toàn bộ job. Sự kết hợp này — input bền vững qua replication, intermediate state có thể phục hồi qua lineage — chính là điều cho phép một job trải rộng trên cluster 500 node sống sót qua một vài lỗi node mà không cần operator can thiệp.

Khái niệm chính

Cluster computing và cluster management

Một cluster là một nhóm máy (vật lý hoặc ảo) phối hợp để hoạt động như một hệ thống duy nhất đối với các ứng dụng chạy trên nó. Từ góc nhìn của data engineer, một cluster cung cấp CPU, memory được gộp chung (và đôi khi cả storage), và một scheduler quyết định máy nào chạy phần công việc nào.

Ai đó phải quyết định, tại mỗi thời điểm, job nào nhận phần CPU và memory nào của cluster — đây là công việc của một resource manager. Trong hệ sinh thái Hadoop, vai trò này thuộc về YARN (Yet Another Resource Negotiator): một process ResourceManager theo dõi tài nguyên khả dụng trên toàn cluster và đàm phán với các agent NodeManager trên từng máy để khởi chạy và giám sát các “container” (đơn vị CPU/memory được cấp phát, đừng nhầm với Docker container) nơi các task ứng dụng thực sự chạy. YARN tách rời resource management của Hadoop khỏi MapReduce chính là để các framework khác — bao gồm Spark — có thể chạy trên cùng cluster và chia sẻ tài nguyên với nó, thay vì mỗi engine xử lý cần một fleet máy riêng.

Ngày càng nhiều, Kubernetes đảm nhận vai trò tương tự cho workload dữ liệu, không chỉ cho stateless microservice. Spark, Flink, và các engine khác có thể chạy native trên Kubernetes, dùng nó như một scheduler tổng quát phân bổ pod (chứ không phải YARN container) trên cluster, xử lý việc restart các executor bị lỗi, và để workload dữ liệu chia sẻ hạ tầng với các ứng dụng containerized khác của tổ chức thay vì đòi hỏi một cluster Hadoop/YARN riêng biệt. Sự hội tụ này được nói kỹ hơn ở ./12-containers-and-orchestration.md; tóm gọn là với các triển khai mới, “Spark on Kubernetes” đã trở thành một target phổ biến ít nhất ngang bằng “Spark on YARN,” đặc biệt với những tổ chức đã chạy Kubernetes cho mọi thứ khác.

Hệ sinh thái Hadoop

Hadoop là dự án đã phổ biến hóa thế hệ hiện tại của distributed data processing mã nguồn mở, được xây dựng từ hai thành phần cốt lõi cộng với một lớp resource manager được thêm vào sau.

HDFS (Hadoop Distributed File System) giải quyết bài toán lưu trữ file lớn hơn nhiều so với bất kỳ disk đơn nào. Một file được chia thành các block kích thước cố định (128 MB mặc định trong Hadoop hiện đại), và mỗi block được lưu — và quan trọng là replicate — trên nhiều máy (mặc định 3 bản, thường trải trên các rack khác nhau để một rack hỏng không gây mất dữ liệu). Hai loại node phối hợp việc này:

Thành phầnVai trò
NameNodeNode chủ; giữ metadata của filesystem trong memory — cây thư mục, block nào tạo nên file nào, và DataNode nào giữ mỗi block. Không lưu dữ liệu file thực tế
DataNodeCác worker; lưu các block dữ liệu thực tế trên disk cục bộ, và báo cáo trạng thái block/heartbeat về NameNode
Secondary NameNodeĐịnh kỳ gộp edit log của NameNode thành một checkpoint image để giữ thời gian restart NameNode ở mức quản lý được — dù tên gọi vậy, đây không phải hot standby

NameNode là single point of failure được biết rõ trong các triển khai Hadoop cổ điển (được giảm thiểu trong cluster production với HDFS High Availability, chạy một NameNode standby với edit log dùng chung qua quorum journal). Nếu một DataNode gặp sự cố, NameNode nhận thấy heartbeat bị thiếu và tự động re-replicate các block mà node đó đang giữ sang các node khỏe mạnh khác, khôi phục hệ số replication một cách tự động.

MapReduce là mô hình xử lý gốc của Hadoop, và vẫn đáng để hiểu vì cấu trúc hai pha của nó (map, rồi reduce) là tổ tiên khái niệm của gần như mọi API xử lý distributed ra đời sau đó, bao gồm cả Spark. Một job MapReduce có hai pha do người dùng định nghĩa:

Một ví dụ word-count kinh điển: pha map phát ra (word, 1) cho mỗi từ trong mỗi document; shuffle nhóm tất cả các 1 theo từ trên toàn cluster; pha reduce cộng dồn số đếm cho mỗi từ. Pattern này tổng quát hóa cho filtering, joining, và aggregating các dataset lớn hơn word count nhiều, nhưng cơ chế shuffle giống hệt như mô tả ở trên vẫn áp dụng — và trong MapReduce cổ điển, mọi kết quả trung gian giữa map và reduce, và giữa các job MapReduce nối tiếp nhau, đều được ghi xuống disk. Đây là lý do chính khiến MapReduce phần lớn đã bị Spark thay thế cho hầu hết workload: một pipeline MapReduce nhiều bước phải trả giá cho một lượt ghi và đọc disk đầy đủ cho mỗi stage, trong khi Spark giữ dữ liệu trung gian trong memory qua các stage bất cứ khi nào có thể, chỉ spill xuống disk khi bị áp lực memory. Với một chuỗi 10 phép biến đổi, sự khác biệt đó không hề nhỏ — có thể chênh nhau cả bậc độ lớn về thời gian thực tế. Dù vậy MapReduce vẫn nền tảng để hiểu: HDFS, YARN, và mô hình tư duy map/shuffle/reduce là nền móng cho cách bản thân Spark thực thi công việc bên trong, và một số tổ chức vẫn còn chạy các job MapReduce legacy chưa migrate.

Apache Spark chuyên sâu

Spark được xây dựng trực tiếp như phản ứng lại mô hình thực thi bị ràng buộc bởi disk của MapReduce, với xử lý in-memory xuyên suốt các bước như cải tiến trung tâm: kết quả trung gian từ một phép biến đổi có thể ở lại trong RAM chung của cluster và nạp trực tiếp vào phép biến đổi tiếp theo, thay vì phải đi vòng qua disk. Với các workload lặp lại (huấn luyện machine learning, thuật toán đồ thị lặp) và pipeline nhiều bước, điều này thường khiến Spark nhanh hơn 10–100 lần so với job MapReduce tương đương.

Abstraction cốt lõi của Spark là RDD (Resilient Distributed Dataset): một tập hợp object bất biến, được partition, trải rộng trên cluster, cùng với lineage (chuỗi các phép biến đổi) cần thiết để tính lại bất kỳ partition nào bị mất — lineage này chính xác là cơ chế fault-tolerance đã mô tả ở trên. RDD ở mức thấp và phần lớn code data engineering ngày nay được viết dựa trên DataFrame API ở mức cao hơn thay vì thế — một table distributed với các column có tên và kiểu, về mặt khái niệm tương tự DataFrame của pandas hay R nhưng được partition trên cluster, và được xây dựng trên nền RDD bên dưới. DataFrame hưởng lợi từ Catalyst, query optimizer của Spark, và Tungsten, engine memory/execution off-heap của nó, cùng nhau giúp Spark lập kế hoạch và thực thi các phép toán DataFrame hiệu quả hơn nhiều so với code RDD viết tay tương đương — vì lý do này, DataFrame (và Spark SQL, bên dưới) là điểm bắt đầu được khuyến nghị cho gần như mọi code Spark mới, RDD thô chỉ dành cho các nhu cầu mức thấp bất thường.

Trung tâm của cách Spark thực thi tất cả những điều này là lazy evaluation. Gọi .filter(), .select(), hoặc .groupBy() trên một DataFrame không tính toán gì ngay lập tức — nó chỉ thêm một bước vào logical query plan. Chỉ một action.collect(), .count(), .write(), .show() — mới thực sự kích hoạt việc thực thi, lúc đó Catalyst optimizer của Spark nhìn vào toàn bộ chuỗi phép biến đổi cùng một lúc (không phải từng cái một), sắp xếp lại và gộp các phép toán khi có lợi (ví dụ đẩy filter lên sớm nhất có thể, để ít dữ liệu chảy vào join sau đó hơn), và chỉ lúc đó mới tạo ra và chạy physical execution plan trên toàn cluster. Đây là lý do gọi .explain() trên một DataFrame trước khi chạy nó là một trong những thói quen debug hữu ích nhất trong Spark: nó cho thấy chính xác physical plan nào — bao gồm chỗ nào xảy ra shuffle — mà Catalyst đã quyết định.

Spark SQL cho phép bạn diễn đạt cùng các phép toán DataFrame dưới dạng câu SQL trên các table/view đã đăng ký, mà Catalyst biên dịch xuống thành physical plan giống hệt code DataFrame tương đương — SQL và DataFrame API là hai cú pháp cho cùng một engine bên dưới, và có thể trộn lẫn tự do trong cùng một job.

Một ví dụ PySpark thực tế

Đoạn code sau đọc một CSV các đơn hàng thương mại điện tử, lọc lấy đơn hoàn tất, tính doanh thu theo quốc gia, và ghi kết quả ra dưới dạng Parquet đã partition — một bước ETL nhỏ nhưng đầy đủ, thực tế.

from pyspark.sql import SparkSession
from pyspark.sql import functions as F

spark = (
    SparkSession.builder
    .appName("daily-revenue-by-country")
    .config("spark.sql.shuffle.partitions", "200")  # tune down from default 200 for smaller clusters
    .getOrCreate()
)

# Read: schema inference is convenient for exploration, but an explicit
# schema avoids a full extra read pass and surprising type inference in production.
orders = (
    spark.read
    .option("header", "true")
    .schema(
        "order_id STRING, customer_id STRING, country STRING, "
        "status STRING, amount DOUBLE, order_ts TIMESTAMP"
    )
    .csv("s3://raw-bucket/orders/2026-07-18/*.csv")
)

# Transformations are lazy: nothing runs yet, Spark is just building a plan.
completed = orders.filter(F.col("status") == "completed")

revenue_by_country = (
    completed
    .groupBy("country")                     # triggers a shuffle: rows for the
                                             # same country must land on one executor
    .agg(
        F.sum("amount").alias("total_revenue"),
        F.count("order_id").alias("order_count"),
        F.countDistinct("customer_id").alias("unique_customers"),
    )
    .orderBy(F.desc("total_revenue"))
)

# Inspect the physical plan before running anything — see where the shuffle happens.
revenue_by_country.explain()

# .write() is an action: this is what actually triggers execution across the cluster.
(
    revenue_by_country
    .write
    .mode("overwrite")
    .partitionBy("country")
    .parquet("s3://curated-bucket/revenue_by_country/2026-07-18/")
)

# Equivalent logic expressed as Spark SQL against the same DataFrame,
# compiled by Catalyst to the same physical plan:
completed.createOrReplaceTempView("completed_orders")
spark.sql("""
    SELECT country,
           SUM(amount)              AS total_revenue,
           COUNT(order_id)          AS order_count,
           COUNT(DISTINCT customer_id) AS unique_customers
    FROM completed_orders
    GROUP BY country
    ORDER BY total_revenue DESC
""").show(10)

spark.stop()

Vài điều đáng chú ý trong ví dụ này: groupBy("country") là điểm trong job xảy ra shuffle — các hàng cục bộ của mỗi executor cho một quốc gia nhất định cần phải nằm cùng nhau trước khi phép aggregation có thể chạy, nên đây chính xác là kiểu phép toán mà phần “vấn đề shuffle” ở trên cảnh báo, và spark.sql.shuffle.partitions kiểm soát shuffle đó tạo ra bao nhiêu partition (mặc định 200 thường quá cao cho job nhỏ và quá thấp cho job rất lớn — tune tham số này là một trong những đòn bẩy hiệu năng Spark có tác động lớn nhất). Ghi với .partitionBy("country") bố trí output thành một thư mục con cho mỗi quốc gia trong path đích, cho phép các reader downstream prune partition và chỉ đọc các quốc gia cần thiết thay vì scan toàn bộ. Và không có gì ở trên thực sự thực thi cho đến khi .write() hoặc .show() được gọi — đây là lazy evaluation trong thực tế, không chỉ trên lý thuyết.

Distributed file system ngoài HDFS

Trong một cluster Hadoop on-premises, HDFS là nơi tự nhiên để lưu dữ liệu mà cluster tính toán trên đó — storage và compute nằm trên cùng máy. Trong các data platform cloud-native hiện đại, sự sắp xếp đó phần lớn đã bị thay thế bởi cloud object storage — Amazon S3, Google Cloud Storage (GCS), Azure Data Lake Storage (ADLS) — như lớp lưu trữ distributed chính, với compute (ví dụ Spark trên EMR/Dataproc/Databricks) đọc và ghi vào nó qua network thay vì từ disk cục bộ.

Có vài lý do sự chuyển dịch này xảy ra và duy trì: object storage không cần cluster để quản lý — không cần giữ NameNode high-availability, không cần provision hay thay disk DataNode, không cần lên kế hoạch dung lượng cho tăng trưởng storage, vì nó gần như vô hạn và co giãn tự động. Nó được tính phí pay-per-use (theo GB lưu trữ, theo request, theo GB truyền tải) thay vì đòi hỏi phần cứng trả trước, luôn bật, được sizing cho tải đỉnh. Và nó tích hợp native với các managed compute platform — Databricks, AWS EMR, Google Dataproc — có thể bật/tắt cluster compute tạm thời trên dữ liệu tồn tại độc lập trong object storage, tách rời vòng đời (và chi phí) của storage khỏi vòng đời của compute, phù hợp hơn nhiều với các workload không cần chạy 24/7. Đánh đổi là object storage không phải một filesystem POSIX thực sự — không có append file tại chỗ hay rename như các phép toán metadata nguyên tử theo cách HDFS cung cấp — đây chính xác là khoảng trống mà các table format như Delta Lake, Iceberg, và Hudi tồn tại để lấp đầy; xem ./07-data-lakes-and-modern-architectures.md cho lớp đó.

MapReduce vs. Spark

MapReduceSpark
Execution modelMap → shuffle → reduce; mỗi stage/job đi vòng qua diskDAG các phép biến đổi; dữ liệu trung gian giữ trong memory qua các stage khi vừa
Tốc độBaseline; chậm với job lặp/nhiều bước do disk I/O giữa mỗi stageThường nhanh hơn 10–100 lần với workload lặp và nhiều bước
Programming modelMức thấp: hàm map/reduce viết tay bằng Java (hoặc qua streaming API ở ngôn ngữ khác)API DataFrame/SQL mức cao (cùng với RDD mức thấp), API có Scala, Java, Python, R, SQL
Fault toleranceChạy lại các task map/reduce lỗi; dựa vào replication của HDFS cho độ bền inputLineage RDD/DataFrame chỉ tính lại partition bị mất; cùng dùng replication HDFS/object-storage cho độ bền input
Phù hợp nhất hiện nayJob batch legacy đã viết và ổn định; hiếm khi được chọn cho công việc mớiBatch ETL, phân tích tương tác, streaming (Structured Streaming), pipeline ML (MLlib) — lựa chọn mặc định cho distributed processing mới
Độ trưởng thành hệ sinh tháiRất trưởng thành nhưng phần lớn đã đóng băng; phần lớn phát triển tích cực đã chuyển sang Spark và các engine mới hơn (Flink, Trino)Rất trưởng thành, phát triển tích cực, hệ sinh thái rộng (Spark SQL, MLlib, Structured Streaming, GraphX), tiêu chuẩn thực tế cho công việc distributed data engineering mới

Hiểu bảng này không phải để chọn phe trong một cuộc tranh luận đã ngã ngũ — Spark đã thắng cho tuyệt đại đa số công việc mới từ nhiều năm trước — mà là để nhận ra vì sao: vấn đề shuffle và chi phí disk I/O là chung cho cả hai engine, nhưng việc thực thi in-memory và lập kế hoạch lazy, dựa trên optimizer của Spark tấn công thẳng vào chính điểm nghẽn khiến MapReduce chậm, mà không từ bỏ mô hình cluster/partitioning/fault-tolerance cơ bản mà MapReduce đã thiết lập.

Best Practices

Tài liệu tham khảo

Part of the Data Engineer Roadmap knowledge base.

Overview

Every data platform eventually hits the same wall: a table, a join, or a nightly aggregation job that used to finish in minutes now takes hours, or simply doesn’t fit in the memory of the machine running it. At that point there are two ways forward. Vertical scaling buys a bigger machine — more RAM, more CPU cores, faster disks — which is simple but has a hard ceiling and gets disproportionately expensive near the top of the hardware market. Horizontal scaling instead splits the data and the work across many ordinary machines that cooperate as a single system. This is the fundamental shift that “big data” technologies are built around, and it is the same trade-off backend engineers make when scaling a stateless web service (see ../backend/README.md for the general scaling vocabulary) — except a distributed data processing system also has to move and shuffle large volumes of state, not just stateless requests, which introduces problems that don’t exist when scaling web servers behind a load balancer.

This note covers what a data engineer needs to actually reason about distributed computing: partitioning, shuffling, and fault tolerance as the core mechanics; clusters and resource managers (YARN, Kubernetes) as the machinery that runs the work; the Hadoop ecosystem (HDFS, MapReduce) as the historical foundation nearly every modern system either descends from or reacted against; and Apache Spark as the tool a data engineer will actually use day to day. It closes with a note on why cloud object storage has largely replaced self-managed HDFS in modern stacks, and how this topic connects to data lakes (./07-data-lakes-and-modern-architectures.md), streaming (./11-streaming-and-realtime-data.md), container orchestration (./12-containers-and-orchestration.md), and managed cloud platforms (./13-cloud-data-platforms.md).

Fundamentals

Why distributed computing

A single machine has three finite resources that a data workload can exhaust: memory, disk, and CPU throughput. A dataset that is a few gigabytes fits comfortably in RAM on a laptop; a dataset that is tens of terabytes does not, no matter how much RAM you buy, because RAM capacity per machine has practical and economic limits. Even when data does fit on disk, a single CPU (or even a multi-core single machine) processing it serially puts a hard ceiling on throughput — reading, transforming, and aggregating a petabyte of data on one machine could take days or weeks even if the disk could hold it.

Distributed computing addresses both constraints by splitting a large problem into many smaller pieces, sending each piece to a different machine in a cluster, and combining the partial results. If splitting the work N ways scaled perfectly, N machines would finish in 1/N of the time. In practice it never scales perfectly — coordinating machines, moving data between them, and combining results all cost time — but going from one machine to a cluster of dozens or hundreds turns jobs that are impossible on a single node into jobs that finish in minutes.

It’s worth being honest about when this matters. Most analytical workloads — even at companies that consider themselves “big data” shops — fit comfortably on a single well-resourced machine using tools like DuckDB, Polars, or a single-node Spark/pandas job. Distributed computing adds real operational complexity (cluster provisioning, network partitions, partial failures, debugging across dozens of machines) that is only worth paying for once data volume or processing throughput genuinely exceeds what a single powerful machine can do. A good data engineer reaches for a cluster because the numbers demand it, not by default.

Data partitioning

Partitioning is how a distributed system decides which rows or records live on which node (or, within Spark, which partition of an executor’s memory). A dataset is split into partitions — logical chunks — and each partition is processed independently and in parallel by a task running on some node in the cluster. Partitioning is what makes horizontal scaling possible at all: without it, there would be no way to divide the work.

How data is partitioned matters enormously for performance. If a dataset of user events is partitioned by a hash of user_id, then all events for a given user land in the same partition — which is exactly what you want for an operation that needs all of one user’s events together (a groupBy(user_id), for instance). If the same dataset is partitioned round-robin (row 1 to partition 1, row 2 to partition 2, and so on with no regard to content), no such guarantee holds, and an operation that needs to group by user_id will have to move data between partitions to bring matching keys together. Choosing the right partitioning key — and avoiding partition skew, where one key is far more common than others and one partition ends up doing most of the work while the rest sit idle — is one of the most consequential decisions in tuning a distributed job.

The shuffle problem

A shuffle is what happens when an operation needs data that currently lives on different nodes to be brought together onto the same node — most commonly for JOIN, GROUP BY, DISTINCT, and repartitioning operations. Concretely: if orders is partitioned by order_id and customers is partitioned by customer_id, joining them on customer_id requires physically moving rows across the network so that matching customer_id values end up co-located on the same executor. This involves serializing data, writing it to disk, transferring it over the network, and deserializing it on the receiving node — all of which is orders of magnitude slower than an in-memory, local computation.

This is the thing that makes distributed processing slower than engineers new to the field expect. A job that looks fast on paper (aggregate a billion rows, easy, just split it 1,000 ways) can be dominated end to end by shuffle time if the aggregation requires moving most of the data across the network. Symptoms of shuffle-heavy jobs include long “stage” times in the Spark UI that don’t correspond to obvious computation, disk spill (when shuffle data doesn’t fit in memory and has to be written to and read back from local disk), and network saturation across the cluster.

The practical response is to design jobs to minimize shuffling: filter and project (select only needed columns/rows) before a join or group-by rather than after, so less data needs to move; pre-partition data on disk by the key you’ll frequently join or group on, so it’s already co-located; use broadcast joins when one side of a join is small enough to fit in memory on every node (avoiding a shuffle of the large side entirely); and be deliberate about repartition()/coalesce() calls, since both trigger a shuffle (repartition redistributes data across a new number of partitions; coalesce merges partitions without a full shuffle when reducing partition count).

Fault tolerance

At the scale of hundreds of machines running for hours, hardware and network failures are not an edge case — they are a statistical certainty. A disk fails, a node loses network connectivity, a process gets killed by the OS for using too much memory. A distributed processing system has to be built assuming this will happen mid-job, and designed so a single node failure doesn’t require restarting the entire computation from scratch.

Two ideas make this possible. First, replication: data is stored with multiple copies (HDFS defaults to 3x replication) across different nodes and racks, so losing one node’s disk doesn’t lose the data. Second, lineage and recomputation: modern engines like Spark track the sequence of transformations that produced each partition of data (this is exactly what the RDD lineage graph or DataFrame query plan records). If a node holding a partition dies mid-job, Spark doesn’t need a checkpoint of that exact partition — it can recompute just that partition from its lineage, using the original input data and the transformations that were supposed to produce it, and re-run only the lost work rather than the whole job. This combination — durable input via replication, recoverable intermediate state via lineage — is what lets a job spanning a 500-node cluster survive a handful of node failures without an operator needing to intervene.

Key Concepts

Cluster computing and cluster management

A cluster is a group of machines (physical or virtual) that cooperate to behave as a single system to the applications running on it. From a data engineer’s point of view, a cluster provides pooled CPU, memory, and (sometimes) storage, and a scheduler decides which machine runs which piece of work.

Someone has to decide, at any given moment, which jobs get which slice of the cluster’s CPU and memory — this is the job of a resource manager. In the Hadoop ecosystem this role belongs to YARN (Yet Another Resource Negotiator): a ResourceManager process tracks available resources across the cluster and negotiates with NodeManager agents on each machine to launch and monitor “containers” (units of allocated CPU/memory, not to be confused with Docker containers) in which application tasks actually run. YARN decoupled Hadoop’s resource management from MapReduce specifically so other frameworks — Spark included — could run on the same cluster and share resources with it, rather than each processing engine needing its own dedicated fleet of machines.

Increasingly, Kubernetes fills this same role for data workloads, not just for stateless microservices. Spark, Flink, and other engines can run natively on Kubernetes, using it as a general-purpose scheduler that allocates pods (not YARN containers) across a cluster, handles restarts of failed executors, and lets data workloads share infrastructure with the rest of an organization’s containerized applications rather than requiring a separate Hadoop/YARN cluster. This convergence is covered in more depth in ./12-containers-and-orchestration.md; the short version is that for new deployments, “Spark on Kubernetes” has become at least as common a target as “Spark on YARN,” particularly for organizations that already run Kubernetes for everything else.

The Hadoop ecosystem

Hadoop is the project that popularized the current generation of open-source distributed data processing, built out of two core components plus a resource manager layer added later.

HDFS (Hadoop Distributed File System) solves the problem of storing files far larger than any single disk. A file is split into fixed-size blocks (128 MB by default in modern Hadoop), and each block is stored — and, crucially, replicated — across multiple machines (3 copies by default, typically spread across different racks so a single rack failure doesn’t cause data loss). Two kinds of nodes coordinate this:

ComponentRole
NameNodeThe master; holds the filesystem’s metadata in memory — the directory tree, which blocks make up which file, and which DataNodes hold each block. Does not store actual file data
DataNodeThe workers; store the actual data blocks on local disk, and report block status/heartbeats back to the NameNode
Secondary NameNodePeriodically merges the NameNode’s edit log into a checkpoint image to keep NameNode restart times manageable — despite the name, it is not a hot standby

The NameNode is a well-known single point of failure in classic Hadoop deployments (mitigated in production clusters with HDFS High Availability, running a standby NameNode with shared edit logs via a quorum journal). If a DataNode goes down, the NameNode notices missing heartbeats and re-replicates the blocks that node was holding onto other healthy nodes, restoring the replication factor automatically.

MapReduce was Hadoop’s original processing model, and it’s still worth understanding because its two-phase structure (map, then reduce) is the conceptual ancestor of virtually every distributed processing API that followed, including Spark’s. A MapReduce job has two user-defined phases:

A classic word-count example: the map phase emits (word, 1) for every word in every document; the shuffle groups all 1s by word across the cluster; the reduce phase sums the counts per word. The pattern generalizes to filtering, joining, and aggregating far larger datasets than word counts, but the same shuffle mechanics described above apply — and in classic MapReduce, every intermediate result between map and reduce, and between chained MapReduce jobs, is written to disk. This is the main reason MapReduce has been largely superseded by Spark for most workloads: a multi-step MapReduce pipeline pays a full disk write and read for every stage, whereas Spark keeps intermediate data in memory across steps whenever it fits, only spilling to disk under memory pressure. For a chain of 10 transformations, that difference is not incremental — it can be an order of magnitude in wall-clock time. MapReduce is nonetheless still foundational to understand: HDFS, YARN, and the map/shuffle/reduce mental model underpin how Spark itself executes work internally, and some organizations still run legacy MapReduce jobs they haven’t migrated.

Apache Spark in depth

Spark was built directly in response to MapReduce’s disk-bound execution model, with in-memory processing across steps as its central improvement: intermediate results from one transformation can stay in a cluster’s collective RAM and feed directly into the next transformation, rather than round-tripping through disk. For iterative workloads (machine learning training, iterative graph algorithms) and multi-step pipelines, this typically makes Spark 10–100x faster than equivalent MapReduce jobs.

Spark’s core abstraction is the RDD (Resilient Distributed Dataset): an immutable, partitioned collection of objects spread across the cluster, along with the lineage (the sequence of transformations) needed to recompute any lost partition — this lineage is exactly the fault-tolerance mechanism described earlier. RDDs are low-level and most data engineering code today is written against the higher-level DataFrame API instead — a distributed table with named, typed columns, conceptually similar to a pandas or R DataFrame but partitioned across a cluster, and built on top of RDDs under the hood. DataFrames benefit from Catalyst, Spark’s query optimizer, and Tungsten, its off-heap memory/execution engine, which together let Spark plan and execute DataFrame operations far more efficiently than equivalent hand-written RDD code — for this reason, DataFrames (and Spark SQL, below) are the recommended entry point for nearly all new Spark code, with raw RDDs reserved for unusual low-level needs.

Central to how Spark executes any of this is lazy evaluation. Calling .filter(), .select(), or .groupBy() on a DataFrame does not compute anything immediately — it appends a step to a logical query plan. Only an action.collect(), .count(), .write(), .show() — actually triggers execution, at which point Spark’s Catalyst optimizer looks at the entire chain of transformations at once (not one at a time), reorders and merges operations where it’s profitable (pushing filters as early as possible, for instance, so less data flows into a later join), and only then generates and runs the physical execution plan across the cluster. This is why calling .explain() on a DataFrame before running it is one of the most useful debugging habits in Spark: it shows exactly what physical plan — including where shuffles occur — Catalyst decided on.

Spark SQL lets you express the same DataFrame operations as SQL queries against registered tables/views, which Catalyst compiles down to the identical physical plan as the equivalent DataFrame code — SQL and the DataFrame API are two syntaxes for the same underlying engine, and can be freely mixed in the same job.

A real PySpark example

The following reads a CSV of e-commerce orders, filters to completed orders, computes per-country revenue, and writes the result out as partitioned Parquet — a small but complete, realistic ETL step.

from pyspark.sql import SparkSession
from pyspark.sql import functions as F

spark = (
    SparkSession.builder
    .appName("daily-revenue-by-country")
    .config("spark.sql.shuffle.partitions", "200")  # tune down from default 200 for smaller clusters
    .getOrCreate()
)

# Read: schema inference is convenient for exploration, but an explicit
# schema avoids a full extra read pass and surprising type inference in production.
orders = (
    spark.read
    .option("header", "true")
    .schema(
        "order_id STRING, customer_id STRING, country STRING, "
        "status STRING, amount DOUBLE, order_ts TIMESTAMP"
    )
    .csv("s3://raw-bucket/orders/2026-07-18/*.csv")
)

# Transformations are lazy: nothing runs yet, Spark is just building a plan.
completed = orders.filter(F.col("status") == "completed")

revenue_by_country = (
    completed
    .groupBy("country")                     # triggers a shuffle: rows for the
                                             # same country must land on one executor
    .agg(
        F.sum("amount").alias("total_revenue"),
        F.count("order_id").alias("order_count"),
        F.countDistinct("customer_id").alias("unique_customers"),
    )
    .orderBy(F.desc("total_revenue"))
)

# Inspect the physical plan before running anything — see where the shuffle happens.
revenue_by_country.explain()

# .write() is an action: this is what actually triggers execution across the cluster.
(
    revenue_by_country
    .write
    .mode("overwrite")
    .partitionBy("country")
    .parquet("s3://curated-bucket/revenue_by_country/2026-07-18/")
)

# Equivalent logic expressed as Spark SQL against the same DataFrame,
# compiled by Catalyst to the same physical plan:
completed.createOrReplaceTempView("completed_orders")
spark.sql("""
    SELECT country,
           SUM(amount)              AS total_revenue,
           COUNT(order_id)          AS order_count,
           COUNT(DISTINCT customer_id) AS unique_customers
    FROM completed_orders
    GROUP BY country
    ORDER BY total_revenue DESC
""").show(10)

spark.stop()

A few things worth calling out in this example: the groupBy("country") is the point in the job where a shuffle happens — every executor’s local rows for a given country need to end up together before the aggregation can run, so this is exactly the kind of operation the “shuffle problem” section above warns about, and spark.sql.shuffle.partitions controls how many partitions that shuffle produces (the default of 200 is often too high for small jobs and too low for very large ones — tuning it is one of the highest-leverage Spark performance knobs). Writing with .partitionBy("country") lays the output out as one subdirectory per country in the target path, which lets downstream readers prune partitions and read only the countries they need instead of scanning everything. And nothing above actually executes until .write() or .show() is called — this is lazy evaluation in practice, not just in theory.

Distributed file systems beyond HDFS

In an on-premises Hadoop cluster, HDFS is the natural place to store the data a cluster computes over — the storage and compute live on the same machines. In modern cloud-native data platforms, that arrangement has largely been replaced by cloud object storage — Amazon S3, Google Cloud Storage (GCS), Azure Data Lake Storage (ADLS) — as the primary distributed storage layer, with compute (Spark on EMR/Dataproc/Databricks, for instance) reading and writing to it over the network rather than from local disk.

A few reasons this shift happened and stuck: object storage requires no cluster to manage — no NameNode to keep highly available, no DataNode disks to provision or replace, no capacity planning for storage growth, since it’s effectively unlimited and elastic. It is billed pay-per-use (per GB stored, per request, per GB transferred) rather than requiring upfront, always-on hardware sized for peak load. And it integrates natively with managed compute platforms — Databricks, AWS EMR, Google Dataproc — that spin ephemeral compute clusters up and down against data that persists independently in object storage, decoupling the lifecycle (and cost) of storage from the lifecycle of compute, which is a much better fit for workloads that don’t need to run 24/7. The trade-off is that object storage is not a true POSIX filesystem — no in-place file appends or renames as atomic metadata operations the way HDFS provides — which is exactly the gap that table formats like Delta Lake, Iceberg, and Hudi exist to fill; see ./07-data-lakes-and-modern-architectures.md for that layer.

MapReduce vs. Spark

MapReduceSpark
Execution modelMap → shuffle → reduce; every stage/job round-trips through diskDAG of transformations; intermediate data kept in memory across stages when it fits
SpeedBaseline; slow for iterative/multi-step jobs due to disk I/O between every stageTypically 10–100x faster for iterative and multi-step workloads
Programming modelLow-level: hand-written map/reduce functions in Java (or via streaming APIs in other languages)High-level DataFrame/SQL API (plus lower-level RDDs), APIs in Scala, Java, Python, R, SQL
Fault toleranceRe-runs failed map/reduce tasks; relies on HDFS replication for input durabilityRDD/DataFrame lineage recomputes only lost partitions; same underlying HDFS/object-storage replication for input durability
Best fit todayLegacy batch jobs already written and stable; rarely chosen for new workBatch ETL, interactive analytics, streaming (Structured Streaming), ML pipelines (MLlib) — the default choice for new distributed processing
Ecosystem maturityVery mature but largely frozen; most active development has moved to Spark and newer engines (Flink, Trino)Very mature, actively developed, broad ecosystem (Spark SQL, MLlib, Structured Streaming, GraphX), the de facto standard for new distributed data engineering work

Understanding this table is less about picking a side in a settled argument — Spark won for the overwhelming majority of new work years ago — and more about recognizing why: the shuffle problem and disk I/O costs are shared by both engines, but Spark’s in-memory execution and lazy, optimizer-driven planning attack exactly the bottleneck that made MapReduce slow, without abandoning the same underlying cluster/partitioning/fault-tolerance model MapReduce established.

Best Practices

References