Spark Programming Model
Spark cho developer những abstraction nào để mô tả distributed computation, và những abstraction đó giúp framework hiểu được điều gì?
From MapReduce to Spark
Nếu từng tìm hiểu các distributed processing framework như MapReduce, Spark hay Flink, bạn sẽ nhận ra rằng chúng không cho developer mô tả computation theo một cách hoàn toàn tự do. Mỗi framework đều đưa ra một abstraction nhất định, tức một cái khuôn quy định user có thể biểu diễn data và processing logic như thế nào.
Sự giới hạn này không chỉ để API dễ sử dụng hơn. Framework cần nhìn thấy một execution structure đủ rõ và giữ được một số invariant nhất định thì mới có thể tự động phân phối computation lên nhiều máy. Nếu không có abstraction đó, mỗi lần xử lý data phân tán, developer sẽ phải tự trả lời hàng loạt câu hỏi: data được chia thành những phần nào, logic nào có thể chạy song song, máy nào chạy phần việc nào, intermediate data được chuyển qua network ra sao, nếu một worker chết thì phần việc nào cần chạy lại, progress/failure được theo dõi thế nào, và output cuối cùng được partition/ghi xuống storage ra sao.
Đây đều là những vấn đề thiết yếu của distributed execution, nhưng phần lớn developer không muốn tự giải quyết lại chúng trong từng bài toán business/data. Họ muốn mô tả computation ở mức gần với bài toán hơn, còn framework chịu trách nhiệm biến computation đó thành work có thể chạy trên cluster.
Vì vậy, mỗi distributed processing framework đều phải chọn một điểm đánh đổi. User chấp nhận mô tả computation trong một khuôn giới hạn hơn; đổi lại, framework nhìn thấy execution structure đủ rõ để tự động hóa phần execution phía sau.
Với MapReduce, khuôn đó là map → shuffle → reduce.
Ở đó, user sẽ cung cấp logic map và reduce.
Framework không nhất thiết hiểu business semantics bên trong các functions này, nhưng nó biết trước execution skeleton:
map tasks có thể chạy độc lập trên các input splits;
intermediate records được partition và shuffle theo key;
sau đó reduce tasks xử lý từng nhóm records và ghi output.
Khuôn này hoạt động tốt khi computation có thể được biểu diễn tự nhiên bằng một vòng xử lý như vậy. Vấn đề là nhiều data workflow thực tế không chỉ có một bước map rồi một bước reduce; chúng thường là một chuỗi operations phụ thuộc nhau: lọc data, join với dataset khác, aggregate, rồi tiếp tục join hoặc aggregate trên kết quả trung gian. Với MapReduce, user vẫn có thể xử lý những workflow như vậy, nhưng thường phải tự chia computation thành nhiều jobs nhỏ hơn. Mỗi job chỉ cho framework thấy một lát cắt của toàn bộ computation; output của job trước thường phải được materialize xuống distributed storage để job sau đọc lại, còn việc nối nhiều jobs thành một workflow lớn hơn nằm ngoài abstraction của một MapReduce job.
Vì vậy, giới hạn của MapReduce không phải là nó “không xử lý được” computation phức tạp. Điểm hạn chế nằm ở chỗ programming model của nó không biểu diễn toàn bộ computation nhiều bước như một structure thống nhất. User phải tự bẻ computation theo khuôn của từng MapReduce job và chịu thêm boundary giữa các jobs. Spark mở rộng đúng điểm này: nó vẫn giữ nguyên tắc framework cần một abstraction đủ rõ để hiểu computation, nhưng cho phép user compose nhiều operations trên distributed data thành một computation lớn hơn. Nhờ đó, framework có thể nhìn thấy dependency giữa nhiều bước xử lý, pipeline những bước có thể chạy liền nhau, và chỉ tạo data-movement boundary khi execution thật sự cần.
What Is Spark's Programming Model?
Từ góc nhìn của người dùng, Spark cung cấp một programming model để mô tả cách dữ liệu được biến đổi trong một distributed computation. Thông qua các API của Spark, user bắt đầu từ một hoặc nhiều nguồn dữ liệu, mô tả các bước biến đổi (operations) trên chúng, và cuối cùng xác định kết quả muốn tạo ra. Một Spark program vì thế có thể được nhìn như một data transformation pipeline: dữ liệu đi vào computation, lần lượt được biến đổi qua các bước, rồi tạo thành một dataset mới hoặc một output cuối cùng.
Từ mô tả này, Spark chịu trách nhiệm cho phần còn lại: tìm cách thực thi các bước biến đổi, tối ưu computation khi có thể, chia dữ liệu và công việc thành các đơn vị phù hợp để chạy song song trên cluster, di chuyển dữ liệu giữa các machine khi cần, và phục hồi những phần computation bị ảnh hưởng khi có failure.
Nói cách khác, Spark đặt một abstraction boundary giữa hai phía:
-
User-facing model: What transformations should happen to the data?
-
Spark execution: How can those transformations actually be executed on a distributed system?
Sự tách biệt này là nền tảng của programming model trong Spark. User có thể suy nghĩ về computation chủ yếu dưới dạng data và transformations, trong khi Spark giữ quyền quyết định cách computation đó được tổ chức và thực thi trên distributed system. Quan trọng hơn, description mà user tạo ra không nhất thiết phải được thực thi ngay lập tức theo đúng thứ tự từng dòng code. Spark có thể giữ lại structure của computation để phân tích và lập kế hoạch trước khi execution thực sự diễn ra.
Apache Spark's Data APIs
Spark có nhiều API, nhưng chúng đều là những cách khác nhau để user nhìn vào cùng một thứ: dữ liệu phân tán cần được transform. Khác biệt giữa các API nằm ở mức abstraction và mức Spark hiểu được structure của data, chứ không phải ở việc chúng chạy trên engine riêng.
Resilient Distributed Dataset (RDD)
Resilient Distributed Dataset (RDD) là abstraction cơ bản nhất trong Spark. Điều cần nhớ đầu tiên là RDD không phải một khối data vật lý nằm sẵn ở đâu đó — nó là một bản mô tả về cách tái tạo data, được cấu thành từ ba thứ:
- Dependencies: RDD biết nó được tạo ra từ RDD nào và bằng operation gì. Đây chính là lineage, thứ giúp Spark tái tạo lại data khi lỗi mà không cần chạy lại toàn bộ.
- Partitions (kèm locality information): RDD được chia thành nhiều partitions, mỗi partition mang thông tin nó thích nằm gần node nào. Partitions là đơn vị song song để Spark gửi task xuống.
- Compute function (
Partition => Iterator[T]): logic của RDD, nhận một partition và trả ra iterator duyệt qua các phần tử. Spark không lưu sẵn data trong RDD; nó chỉ lưu cách tính, và gọi hàm này khi cần đọc một partition.
Ba thứ này là nền tảng của RDD programming model, và mọi abstraction cao hơn đều được xây lên trên chúng: dependencies cho fault tolerance, partitions cho parallelism và data locality, còn compute function cho khả năng sinh data theo nhu cầu thay vì materialize trước.
Tuy nhiên, mặc dù RDD là description, nhưng khi Spark thật sự materialize description đó để tính toán, các phần tử mà compute function sinh ra lại là Scala/Java object nằm trên JVM heap. Chính đặc điểm này kéo theo một loạt vấn đề về memory và GC:
- Một job có thể sinh ra hàng triệu object nhỏ. Object Java/Scala mang overhead riêng như class pointer, object header và padding, nên tốn RAM hơn nhiều so với giá trị thật của data.
- JVM phải quét toàn bộ object graph để tìm "rác", gây GC overhead nghiêm trọng. Nếu app sinh nhiều object tạm (intermediate), dễ chạm full GC và pause toàn bộ thread — hiện tượng "stop-the-world". Dù các GC hiện đại như G1GC hay ZGC đã giảm thời gian dừng, vẫn luôn có pause dù ngắn.
- Dữ liệu được tổ chức theo kiểu row-based nên tính toán vectorized (SIMD1) không hiệu quả.
- JVM được thiết kế cho app long-running, ít churn object. Nhưng RDD API tạo, biến đổi và hủy hàng tỷ object theo từng job — đi ngược hoàn toàn với workload lý tưởng của GC, nên hiệu năng kém.
Chính những vấn đề GC/memory này là động lực để Spark phát triển các abstraction phía trên — DataFrame và Dataset — hướng tới columnar và off-heap memory. Đó cũng là chủ đề của phần kế tiếp.
Object Management in Python & Java
Java (JVM) quản lý heap bằng object graph: các object trên heap liên kết với nhau qua reference tạo thành một graph. GC bắt đầu từ GC Roots, lần theo các liên kết để xem object nào còn reachable; phần không reachable thì bị thu hồi.
Python (CPython) dùng chủ yếu reference counting: mỗi object có refcount, khi về 0 thì được giải phóng ngay. Do refcount không xử lý được vòng tham chiếu, Python có thêm cycle GC để phát hiện và dọn các cycle không còn được tham chiếu từ bên ngoài.
DataFrame
DataFrame là abstraction dạng bảng: data được nhìn như rows/columns có schema, gần với SQL hơn là một collection object tự do như RDD. Điểm quan trọng của schema là Spark hiểu được structure của data — cột nào tồn tại, kiểu dữ liệu là gì, expression nào đang được áp dụng — nên framework có nhiều thông tin hơn để lập plan và tối ưu computation.
Khi làm việc với DataFrame, Spark có nhiều cơ hội tổ chức data hiệu quả hơn RDD. Nếu data đến từ format dạng cột như Parquet/ORC, Spark có thể đọc theo columnar path; nếu DataFrame được cache, Spark có thể giữ nó trong memory dưới dạng columnar batches thay vì hàng triệu Java object rời rạc. Khi đi vào execution, Spark vẫn có thể chuyển giữa row-based internal representation và columnar/vectorized representation tùy operator, nhưng điểm chính là DataFrame không còn buộc mọi xử lý phải đi qua object model thô của JVM như RDD.
Row-based vs Column-based
Row-based lưu các field của cùng một record gần nhau. Nếu workload thường đọc hoặc cập nhật trọn một record — ví dụ lấy toàn bộ thông tin của một order/user — cách lưu này rất tự nhiên, nên hay gặp trong các hệ thống OLTP.
Column-based lưu các giá trị của cùng một cột gần nhau. Nếu workload thường scan rất nhiều rows nhưng chỉ cần một vài cột — ví dụ tính tổng doanh thu theo ngày, đếm số event theo city, hoặc aggregate trên một metric — cách lưu này hiệu quả hơn vì engine có thể đọc ít data hơn và dễ tận dụng vectorized execution.
Cụ thể hơn, schema cho Spark cơ sở để áp dụng các kỹ thuật tối ưu mà RDD khó làm được:
- Column pruning: nếu query chỉ cần vài cột, Spark có thể tránh đọc/giữ những cột không dùng.
- Predicate pushdown: điều kiện lọc có thể được đẩy xuống data source khi source hỗ trợ, giảm lượng data phải đọc lên.
- Expression optimization: Spark có thể phân tích expression ở mức plan, simplify/reorder một số bước thay vì coi mỗi function là hộp đen.
- Physical planning: Spark có thể chọn cách join, aggregate, sort, shuffle phù hợp hơn dựa trên shape của query.
- Efficient execution representation: trong execution, Spark có thể dùng internal binary format, code generation, vectorized/columnar path ở những chỗ phù hợp để giảm object overhead và GC pressure.
Vì vậy, điểm quan trọng của DataFrame không nằm ở chuyện nó dùng một storage format cố định nào, mà ở việc Spark hiểu được nhiều hơn về dữ liệu và biểu thức đang được áp dụng lên dữ liệu đó. Thay vì chỉ thấy một chuỗi function gần như opaque như với RDD, Spark có thể nhìn computation ở dạng có structure hơn: cột nào được đọc, điều kiện nào được filter, expression nào được tính, phép join hoặc aggregate nào đang diễn ra. Chính structure này là thứ cho phép Spark phân tích và tối ưu computation trước khi chạy.
Dataset
Trong Scala, DataFrame thực chất có thể hiểu là Dataset[Row].
Row ở đây là một generic, untyped object của Spark: nó đại diện cho một record trong bảng, có thể chứa nhiều field khác kiểu nhau, và user truy cập field qua tên cột, index hoặc getter. Spark vẫn biết schema của DataFrame, nhưng phía code của user không có một domain type cụ thể như UserEvent hay Order.
Dataset thêm lớp type đó vào API. Thay vì làm việc với Dataset[Row], user có thể định nghĩa một class/case class như UserEvent, rồi làm việc với Dataset[UserEvent]. Khi đó mỗi element trong Dataset được nhìn ở phía code như một object có type cụ thể, nên compiler có thể bắt một số lỗi sớm hơn và code gần với domain model hơn.
Điểm dễ nhầm là typed object trong Dataset nằm ở lớp API cho developer, không phải lời hứa rằng Spark sẽ hiểu và tối ưu mọi method bên trong object đó. Spark tối ưu tốt nhất khi computation vẫn được viết bằng các structured expressions mà framework nhìn thấy được. Với các operation như filter, select, groupBy, Spark có thể phân tích expression và lập plan. Còn nếu logic được giấu trong một function hoặc method opaque trên object, Spark vẫn chạy được, nhưng phần logic đó trở thành hộp đen nên khó tối ưu hơn.
Dataset API boundary
Đừng hiểu Dataset như một API cross-language giống DataFrame. Dataset typed chủ yếu tồn tại ở Scala/Java vì nó dựa vào compile-time type system và Encoder của JVM API. Spark cần biết type của từng record là gì — ví dụ mỗi record là một UserEvent — để map giữa object typed trong code và representation nội bộ mà engine dùng để chạy.
Python/R không expose typed Dataset theo nghĩa này vì user code không nằm trong cùng JVM compile-time type system như Scala/Java. Spark vẫn biết schema của DataFrame, nhưng không có một record type được compiler kiểm tra trước để tạo Encoder cho từng domain object. Structured API chính trong PySpark/R vì vậy là DataFrame.
Vì vậy, nên hiểu Dataset chủ yếu như một lớp type-safety trên Structured APIs cho Scala/Java. Nó giúp user viết code an toàn hơn và gần domain model hơn, nhưng không tự động làm execution nhanh hơn DataFrame. Phần tối ưu execution vẫn đến từ việc Spark nhìn thấy schema, expressions và plan; nếu logic bị giấu trong object methods, optimizer còn khó can thiệp hơn.
Spark SQL
Spark SQL tồn tại vì DataFrame/Dataset API dù mạnh vẫn không phải cách diễn đạt thân thiện với tất cả user. Với analyst hoặc những người đã quen làm việc bằng SQL, việc viết Scala/Python method chain để mô tả một phép lọc, join hay aggregate thường không tự nhiên bằng viết trực tiếp một câu query trên table/view. SQL cũng là ngôn ngữ chung của warehouse-style workflow, BI tool, JDBC/ODBC connector, và nhiều team data muốn mô tả logic xử lý bằng một format quen thuộc, ít phụ thuộc vào ngôn ngữ lập trình hơn.
Vì vậy, Spark SQL cho user viết cùng một kiểu computation trên structured data, nhưng bằng SQL trên table/view thay vì bằng DataFrame/Dataset API. Ví dụ:
events.createOrReplaceTempView("events")
result = spark.sql("""
SELECT city, count(*) AS n
FROM events
WHERE country = 'VN'
GROUP BY city
""")Tuy nhiên, cần lưu ý rằng SQL không phải một đường execution tách biệt khỏi DataFrame/Dataset. spark.sql(...) trả về một DataFrame; ngược lại, DataFrame cũng có thể được đăng ký thành temporary view để query bằng SQL. Dù user viết method chain hay SQL string, Spark vẫn cố đưa computation về cùng một nhóm representation có schema, expressions và query plan để phân tích/tối ưu trước khi chạy.
Khác biệt lớn của SQL nằm ở boundary xung quanh nó. Khi viết FROM events, Spark phải biết events là temporary view, table trong catalog, hay dữ liệu đến từ metastore nào. Vì vậy Spark SQL không chỉ là syntax thay cho DataFrame; nó còn là lớp nối Spark với catalog/metastore, structured file formats, interactive SQL shell, và các external tools qua JDBC/ODBC.
Choosing Between RDD, DataFrame, Dataset, and SQL
Không nên nhìn các API này như bốn engine khác nhau. Chúng là bốn cách để user biểu đạt computation trên distributed data, khác nhau chủ yếu ở mức Spark hiểu được structure của data và mức type-safety mà API cho developer.
| API | Data view | Spark can see | Good fit | Main trade-off |
|---|---|---|---|---|
| RDD | Distributed collection của object | Dependency, partition, compute function; ít schema/expression structure | Logic custom, dữ liệu rất linh hoạt, cần hiểu/control Spark ở mức thấp | Ít tối ưu tự động hơn; dễ sinh nhiều JVM objects và GC pressure |
| DataFrame | Bảng có schema: rows/columns | Schema, columns, expressions, relational operations | ETL, analytics, data engineering workflow thông thường | Ít type-safety ở mức domain object trong code |
| Dataset | Typed domain object trong Scala/Java | Schema/expression nếu dùng structured operations; type ở API level | Scala/Java code muốn type-safety và domain model | Không tự động nhanh hơn DataFrame; object/function opaque vẫn khó tối ưu |
| Spark SQL | Query trên table/view | SQL expressions, schema, catalog/table references | Analyst/BI/JDBC/ODBC, transformation viết theo SQL, workflow gần warehouse | Phụ thuộc nhiều hơn vào catalog/view/table boundary |
Rule of thumb: nếu data có schema rõ và logic có thể diễn đạt bằng relational operations, ưu tiên DataFrame hoặc Spark SQL. Nếu viết Scala/Java và cần type-safety ở API level, Dataset là lựa chọn hợp lý. RDD nên được xem như lower-level API cho những case cần control/custom logic đặc biệt, hoặc để hiểu cơ chế nền của Spark, chứ không phải entry point mặc định cho data engineering workflow.
Transformations, Actions, and Lazy Evaluation
Spark chia operations trên distributed data thành hai nhóm: transformations và actions . Hai nhóm này tạo ra một cách nghĩ rất đặc trưng của Spark: code của user trước hết xây lên một description của computation, còn execution chỉ thật sự bắt đầu khi có action yêu cầu kết quả.
Transformation mô tả data sẽ được biến đổi như thế nào. Khi gọi select() hay filter(), Spark không sửa DataFrame ban đầu; nó trả về một DataFrame mới đại diện cho bước transform tiếp theo. Vì vậy các dataset trong Spark có tính immutable: mỗi bước tạo ra một dataset mới, còn dataset cũ vẫn giữ nguyên.
Điểm quan trọng là transformations được đánh giá lazily . Spark không tính kết quả ngay tại dòng filter() hay select(). Nó chỉ ghi nhớ rằng DataFrame hiện tại được tạo ra từ DataFrame trước bằng những transformation nào. Chuỗi ghi nhớ đó gọi là lineage. Nhờ giữ lại lineage, Spark có thể nhìn toàn bộ chuỗi computation trước khi chạy, rồi ở bước lập plan sau đó mới quyết định nên gộp, sắp xếp hoặc tối ưu các bước như thế nào.
Action là lúc user thật sự yêu cầu một kết quả: đếm số dòng, lấy vài record ra driver, hiển thị data, hoặc ghi data ra storage. Khi action được gọi, Spark mới đánh giá toàn bộ chuỗi transformations đã được ghi nhớ trước đó.
Ví dụ dưới đây tạo ra một DataFrame từ file text, lọc các dòng chứa Spark, rồi chỉ đến count() mới trigger execution:
strings = spark.read.text("../README.md")
filtered = strings.filter(strings.value.contains("Spark"))
filtered.count() # action — trigger executionLazy evaluation không chỉ giúp Spark có cơ hội tối ưu. Vì mỗi transformation tạo ra dataset mới và lineage ghi lại cách dataset đó được tạo ra, Spark cũng có thể replay lại một phần lineage khi cần tái tạo data bị mất. Đây là một phần cơ sở cho fault tolerance trong Spark.
Common operations
Một vài operation quen thuộc của mỗi loại:
| Transformations | Actions |
|---|---|
orderBy() | show() |
groupBy() | take() |
filter() | count() |
select() | collect() |
join() | save() |
Narrow and Wide Transformations
Không phải transformation nào cũng giống nhau ở góc nhìn distributed data. Có transformation chỉ cần xử lý từng partition độc lập, nhưng cũng có transformation buộc Spark phải gom data từ nhiều partition khác nhau lại trước khi tạo ra kết quả đúng.
Narrow transformation là loại transformation mà mỗi output partition có thể được tính từ một input partition tương ứng. Những operation như filter(), select() hoặc map() thường thuộc nhóm này: Spark có thể xử lý data ngay trong từng partition mà không cần trao đổi data giữa các node.
Wide transformation thì khác. Kết quả của một output partition có thể phụ thuộc vào data nằm ở nhiều input partitions. Những operation như groupBy(), orderBy() hoặc join() thường cần gom các record có cùng key, sắp xếp lại data, hoặc đưa những phần data liên quan về cùng chỗ. Đây là lúc computation không còn chỉ là xử lý cục bộ trong từng partition nữa; Spark phải tạo ra một boundary nơi data được di chuyển và phân phối lại trên cluster.
Ở mức programming model, chỉ cần nhớ rằng cùng là transformation nhưng chúng tạo ra dependency shape khác nhau. Shape đó là một phần thông tin Spark giữ lại từ code của user, rồi dùng ở bước execution planning phía sau.
My Summary
Spark programming model bắt đầu từ data abstraction. User không mô tả máy nào chạy phần nào, partition nào được đặt ở đâu, hay stage nào sẽ được tạo ra. User mô tả computation bằng cách chọn một API để nhìn data — RDD, DataFrame, Dataset hoặc SQL — rồi nối các operations lại để nói data cần được transform như thế nào.
Những API này khác nhau ở mức Spark nhìn thấy structure của computation. Với RDD, Spark thấy dependency, partitions và compute function. Với DataFrame, Dataset và SQL, Spark thấy thêm schema, columns, expressions và query/table boundary. Càng nhiều structure được biểu diễn rõ trong API, Spark càng có nhiều cơ sở để phân tích và tối ưu trước khi chạy.
Transformations, actions và lazy evaluation là phần nối giữa user-facing code và execution phía sau. Transformations xây lên description của computation; actions là điểm user thật sự yêu cầu kết quả; lazy evaluation cho Spark cơ hội giữ lại toàn bộ chuỗi operations trước khi quyết định chạy nó như thế nào.
Ở mức này, Spark chưa cần hiểu business semantics của logic. Nó không cần biết đây là fraud scoring, revenue aggregation hay feature engineering. Điều framework cần thấy là computation shape: data bắt đầu từ đâu, đi qua những transformation nào, dependency giữa các bước có hình dạng ra sao, và output/result được yêu cầu ở điểm nào.
Footnotes
-
SIMD (Single Instruction, Multiple Data) là một dạng tối ưu phần cứng trong CPU.
Hiểu đơn giản: một lệnh CPU duy nhất có thể xử lý nhiều dữ liệu cùng lúc. ↩