<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Ryan Fu on Medium]]></title>
        <description><![CDATA[Stories by Ryan Fu on Medium]]></description>
        <link>https://medium.com/@0050211?source=rss-663daee8909b------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/0*Ps5oicPKapB8u21G</url>
            <title>Stories by Ryan Fu on Medium</title>
            <link>https://medium.com/@0050211?source=rss-663daee8909b------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Wed, 04 Nov 2026 20:32:40 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@0050211/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Chapter 12. A Taxonomy of Data Systems Evolution]]></title>
            <link>https://medium.com/@0050211/from-database-to-data-lakehouse-part-14-final-chapter-6924169483ab?source=rss-663daee8909b------2</link>
            <guid isPermaLink="false">https://medium.com/p/6924169483ab</guid>
            <dc:creator><![CDATA[Ryan Fu]]></dc:creator>
            <pubDate>Sun, 14 Jun 2026 17:45:35 GMT</pubDate>
            <atom:updated>2026-06-29T05:24:47.604Z</atom:updated>
            <content:encoded><![CDATA[<p>《<a href="https://medium.com/@adrianhsu/%E7%B5%A6%E5%AD%B8%E5%BC%9F%E5%A6%B9%E7%9A%84%E7%9F%BD%E8%B0%B7-ai-software-%E6%8C%87%E5%8D%97-da054b7a8dcb">給學弟妹的矽谷 AI / Software 指南</a>》是一個矽谷工程師自發的眾籌寫作計劃，由<a href="https://www.facebook.com/opensource4you">源來適你社群</a>推動。這將是一個在台灣軟體學生圈內很有影響力的文章系列。已有 &gt;10 篇準備發布，目標 100 篇並出書，歡迎 tag 強者大神來寫！</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yvb6VCG80RIyZx3q598ynA.png" /></figure><p>如果你已經讀到這裡，恭喜你，我們終於一起走到了《From Database to Data Lakehouse》Volume 1 的 Grand Finale。</p><p>首先，想誠摯地感謝一路陪伴這個系列至今的每一位讀者。從最初幾篇零散的文章開始，許多內容都是在與讀者的討論、交流與反饋之中，不斷修正、補充與完善。沒有大家長期的支持，這個系列不可能走到今天。</p><p>這一章被命名為《A Taxonomy of Data Systems Evolution》。</p><p>Taxonomy 中文通常翻譯為「分類學」。在生物學中，它負責整理萬千物種，將生命依照共同特徵分成界、門、綱、目、科、屬、種；例如人類屬於動物界、脊索動物門、哺乳綱、靈長目、人科、人屬、智人種，也就是 Homo sapiens。而在 Computer Science 中，Taxonomy 也經常被用來整理研究領域、系統架構或技術類型。愈深入思考，我愈覺得它正適合用來描述這一章。</p><p>我想做的，是替六十多年來的 Data Systems 建立一套理解其演化脈絡的分類框架。我希望像一位歷史學家一樣，站在更高的視角，將這些看似毫不相關的故事重新放進同一幅歷史地圖之中，理解它們如何彼此交會、彼此影響。</p><p>最近我也開始把過去散落在 Medium 上的連載重新整理、補充與重寫，以逐步收斂成一本完整的書。目前預計的章節如下：</p><ul><li>Chapter 0. Preface: <a href="https://medium.com/@0050211/from-database-to-data-lakehouse-part-1-22a822faf9bb">https://medium.com/@0050211/from-database-to-data-lakehouse-part-1-22a822faf9bb</a></li><li>Chapter 1. Dawn of Database Systems (1960–1980): <a href="https://medium.com/@0050211/from-database-to-data-lakehouse-part-2-d9bd1a383a06?sharedUserId=0050211">https://medium.com/@0050211/from-database-to-data-lakehouse-part-2-d9bd1a383a06</a></li><li>Chapter 2. The Rise of Data Warehouse (1980–2000): <a href="https://medium.com/@0050211/from-database-to-data-lakehouse-part-3-9457004341be?sharedUserId=0050211">https://medium.com/@0050211/from-database-to-data-lakehouse-part-3-9457004341be</a></li><li>Chapter 3. A Brief History of Storage Devices<br>Part I: <a href="https://medium.com/@0050211/from-database-to-data-lakehouse-part-5-4fabddec10f4?sharedUserId=0050211">https://medium.com/@0050211/from-database-to-data-lakehouse-part-5-4fabddec10f4</a><br>Part II: <a href="https://medium.com/@0050211/from-database-to-data-lakehouse-part-6-%E5%AD%98%E5%84%B2%E8%A3%9D%E7%BD%AE%E7%B0%A1%E5%8F%B2-volume-2-925dd6ffbeab?sharedUserId=0050211">https://medium.com/@0050211/from-database-to-data-lakehouse-part-6-%E5%AD%98%E5%84%B2%E8%A3%9D%E7%BD%AE%E7%B0%A1%E5%8F%B2-volume-2-925dd6ffbeab</a></li><li>Chapter 4. A Brief History of Storage Systems: <a href="https://medium.com/@0050211/from-database-to-data-lakehouse-part-7-c5eeb497fb44">https://medium.com/@0050211/from-database-to-data-lakehouse-part-7-c5eeb497fb44</a></li><li>Chapter 5. The Immutable Files in Databases: <a href="https://medium.com/@0050211/from-database-to-data-lakehouse-part-8-32bbdd46b5b1">https://medium.com/@0050211/from-database-to-data-lakehouse-part-8-32bbdd46b5b1</a></li><li>Chapter 6. The RUM Conjecture (出版時預計這會獨立成一個 Chapter)</li><li>Chapter 7. Immutable Files Meet Open Source: <a href="https://medium.com/@0050211/from-database-to-data-lakehouse-part-9-2a5b4baa9c8f">https://medium.com/@0050211/from-database-to-data-lakehouse-part-9-2a5b4baa9c8f</a></li><li>Chapter 8. The Age of Big Data and Data Lake (2000–2017): <a href="https://medium.com/@0050211/from-database-to-data-lakehouse-part-10-7801261db693">https://medium.com/@0050211/from-database-to-data-lakehouse-part-10-7801261db693</a></li><li>Chapter 9. Kafka: From Distributed Log to Event Streaming Platform: <a href="https://medium.com/@0050211/from-database-to-data-lakehouse-part-11-kafka-a-distributed-message-store-cd3462097cf3">https://medium.com/@0050211/from-database-to-data-lakehouse-part-11-kafka-a-distributed-message-store-cd3462097cf3</a></li><li>Chapter 10. Stream Processing Engine (Part I): <a href="https://medium.com/@0050211/from-database-to-data-lakehouse-part-12-8734fc1a7c59">https://medium.com/@0050211/from-database-to-data-lakehouse-part-12-8734fc1a7c59</a></li><li>Chapter 11. Stream Processing Engine (Part II): <a href="https://medium.com/@0050211/from-database-to-data-lakehouse-part-13-ef379f250027">https://medium.com/@0050211/from-database-to-data-lakehouse-part-13-ef379f250027</a></li><li>Chapter 12. A Taxonomy of Data Systems Evolution: <a href="https://medium.com/@0050211/from-database-to-data-lakehouse-part-14-final-chapter-6924169483ab?sharedUserId=0050211">https://medium.com/@0050211/from-database-to-data-lakehouse-part-14-final-chapter-6924169483ab</a></li></ul><p>由於不少章節原本就是以 Medium 連載的形式撰寫，因此未來每一章也都會附上對應的 Medium 文章連結。一方面方便讀者閱讀最新版本，另一方面也方便我持續更新內容，讓這本書能隨著技術演進而不斷成長。</p><p>我的小孩會陪我一起追 Marvel 的《What If…?》。不知不覺間，三季已經全部追完。</p><p>在這部作品裡，最有趣的角色或許不是鋼鐵人，也不是奇異博士，而是那位始終站在宇宙之外的 Watcher。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sM-YXJq_aSiKhOVETZ3agg.jpeg" /></figure><p>在 Marvel 漫畫的設定中，Watcher 是一個古老種族的一員。他們擁有近乎無限的知識與壽命，默默觀察宇宙中無數條時間線的發展，記錄每一個文明的興衰與每一次歷史的分歧。他們知道過去，也看見未來，卻始終遵守一條原則：只觀察，不干預。</p><p>於是，在某個宇宙裡，Peggy Carter 成為了 Captain Carter；另一個宇宙裡，Ultron 取得了無限寶石；再另一個宇宙裡，復仇者聯盟從未成立。Watcher 帶著我們穿梭於不同時間線之間，看見各種可能發生的歷史。</p><p>然而，歷史學家做的事情卻有些不同。</p><p>如果說 Watcher 告訴我們發生了什麼事，那麼像 Yuval Noah Harari 這樣的歷史學家，則試圖回答另一個問題：為什麼這些事情會在同一個時代發生？</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*luL9h1DES4iLl5qUJpHIaA.png" /></figure><p>《人類大歷史》最迷人的地方，並不在於逐年記錄歷史事件，而在於它嘗試站在更高的視角，將看似毫不相關的事件串聯起來。農業、宗教、帝國、貨幣、科學與工業革命，這些原本分散於不同領域的故事，最終被放進同一幅更大的歷史圖景之中。</p><p>於是，我忍不住做了一個有趣的思想實驗。</p><p>假設 Harari 也成為了一位 Watcher。他站在資料世界之外，面前有一座擁有近乎無限容量的歷史資料庫，完整記錄了從第一個 Database 誕生，到 Data Warehouse、Big Data、Data Lakehouse，甚至 Agent 時代的所有故事。</p><p>如果這座資料庫真的存在，它會長成什麼樣子？</p><p>Harari Watcher 並不會按照公司名稱建立索引，也不會依照產品名稱分類。在他的眼中，Google、Microsoft、Databricks 都只是歷史中的一個事件，而不是故事的主角。</p><p>時間，才是這座歷史資料庫唯一的 Primary Key。站在這樣的視角回望過去，歷史便不再是一條單純的時間線，而更像是一張巨大的多維資料表。</p><p>Harari Watcher 可以沿著 Database 這條時間序列，觀察資料模型如何從 Hierarchical Database 演化到 Relational Database，再走向 NoSQL、HTAP 與 AI-Native Database；也可以沿著 Compute Engine 這條時間序列，觀察計算模型如何從 Volcano、MapReduce、Spark、Flink，一路演化到 Photon、Velox 與 DataFusion。</p><p>又或者，他也可以將時間固定在某一年，同時展開所有技術維度，觀察 Database、Storage、Compute、Catalog、AI 等不同領域在同一個時代如何彼此影響、共同演化，最終匯聚成下一波技術浪潮。</p><p>於是，他將整段資料系統的歷史切分成五個 Time Slice。</p><ul><li>1960s–1980：Dawn of Database Systems</li><li>1980–2000：The Rise of Data Warehouse</li><li>2000–2017：The Age of Big Data and Data Lake</li><li>2017–2025：The Rise of Data Lakehouse</li><li>2025+：The Age of Agents</li></ul><p>在每一個 Time Slice 中，Harari Watcher 都會先提出當時最重要的 Dominant Question，接著依照時間線，觀察 Hardware、Operating System、Networking、Storage System、Database、Data Warehouse、Data Lakehouse、Query Language、Catalog、Storage Engine、Compute Engine、Format / Protocol、Workload、Consumer 與 AI Development 等不同技術維度的演化。</p><p>最後，他會透過 Technology Lineage，重新理解這些技術維度之間的因果關係：哪些技術回應了上一個時代留下的問題，哪些突破催生了新的架構，又有哪些看似獨立的發明，其實是在相同的歷史背景下共同誕生。</p><p>現在，就讓我們跟著 Harari Watcher，一起查詢這座跨越六十多年的歷史資料庫。</p><h3>1960s–1980: Dawn of Database System</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eEzvkaHlg9BjyHGpSq2-5A.png" /></figure><h4>Dominant Question</h4><p>如何有效地儲存、管理與查詢資料？</p><h4>Hardware</h4><ul><li>IBM System/360 (1964): 第一個成功的大型通用 Mainframe 家族。</li><li>Magnetic Tape (1960s): 當時最主要的大規模資料存儲媒介。</li><li>Disk Storage (1960s): 開始普及，使資料能夠被隨機存取。</li></ul><h4>Operating System</h4><ul><li>OS/360 (1964): IBM 為 System/360 開發的大型作業系統。</li><li>UNIX (1969): Ken Thompson 與 Dennis Ritchie 於 Bell Labs 開發。</li></ul><h4>Networking</h4><ul><li>ARPANET (1969): 現代 Internet 的起點，首次驗證了 Packet Switching 與分散式網路架構的可行性。</li></ul><h4>Storage System</h4><ul><li>File System (1960s): 資料主要以檔案形式儲存與管理。</li><li>Direct Access Storage Device, DASD (1960s): IBM Mainframe 常見的磁碟存儲架構。</li></ul><h4>Database</h4><ul><li>Integrated Data Store (IDS) (1964): Charles Bachman 開發的早期 Network Database 系統，後來成為 CODASYL 模型的重要來源。</li><li>IBM IMS (1966): 最具代表性的 Hierarchical Database。</li><li>CODASYL Data Model (1969): Network Database 的代表性模型。</li><li>“A Relational Model of Data for Large Shared Data Banks” — Edgar F. Codd (1970): 提出 Relational Model。</li><li>“The Design and Implementation of INGRES” — Michael Stonebraker, Eugene Wong, Lawrence Rowe, Michael Held (1976): Berkeley 團隊對 Relational Database 的重要實作。</li><li>“Access Path Selection in a Relational Database Management System” — Patricia G. Selinger et al. (1979): 首次提出 Cost-Based Query Optimizer。</li></ul><h4>Query Language</h4><ul><li>SEQUEL (1974): IBM System R 團隊提出的查詢語言。</li><li>SQL (1978): SEQUEL 後來演變為 SQL，逐漸成為 Relational Database 的標準查詢語言。</li></ul><h4>Catalog</h4><ul><li>System Catalog (1970s): Relational Database 開始引入 Metadata 管理概念。</li></ul><h4>Storage Engine</h4><ul><li>Indexed Sequential Access Method, ISAM (1970s): 早期廣泛使用的索引存取方法。</li><li>Record-Oriented Storage (1970s): 資料以 Record 為中心進行儲存與存取。</li></ul><h4>Format / Protocol</h4><ul><li>ASCII (1963): 後來成為最廣泛使用的字元編碼標準之一。</li><li>EBCDIC (1964): IBM Mainframe 主要使用的字元編碼。</li></ul><h4>Workload</h4><ul><li>Transaction Processing (1960s): 銀行、保險與政府資訊系統的主要需求。</li><li>Online Transaction Processing, OLTP (1970s): 開始形成現代交易系統的雛形。</li></ul><h4>Consumer</h4><ul><li>Government (1960s): 大型資訊系統的重要使用者。</li><li>Banking Industry (1960s): 最早的大規模資料管理需求來源之一。</li><li>Large Enterprises (1970s): Database 開始成為企業核心資訊系統的一部分。</li></ul><h4>AI Development</h4><ul><li>Dartmouth Workshop (1956): John McCarthy 等人正式提出 Artificial Intelligence 一詞。</li><li>First AI Boom (1956–1973): Symbolic AI 成為主流研究方向。</li><li>Lighthill Report (1973): 英國政府重新評估 AI 研究成果。</li><li>First AI Winter (1974–1980): AI 研究經費與產業熱度明顯下降。</li></ul><h4>Technology Lineage</h4><p>Database 的演化是從檔案管理開始。</p><p>在 1960 年代，大多數應用程式直接操作 File System，資料結構與存取邏輯緊密耦合在程式碼之中。隨著企業資料量快速增加，IBM 在 1966 年推出 IMS，以樹狀結構管理資料，開啟了 Hierarchical Database 時代。然而，現實世界中的資料關係往往並非單純的父子關係，於是 CODASYL 在 1969 年提出 Network Database 模型，嘗試以網狀結構描述更複雜的資料關聯。</p><p>儘管 Hierarchical Database 與 Network Database 在工程上都取得了一定成功，但它們都有相同的問題：應用程式必須知道資料的實際存放方式，才能正確存取資料。這使得系統維護與演進變得十分困難。</p><p>1970 年，Edgar F. Codd 發表〈A Relational Model of Data for Large Shared Data Banks〉，提出 Relational Model。這項突破最重要的地方並不在於新的資料結構，而是在於首次將資料的邏輯模型與物理存儲分離。使用者只需要描述資料之間的關係，而不必關心資料究竟存放在哪裡。</p><p>接下來數年間，IBM 的 System R 與 Berkeley 的 Ingres 分別驗證了這套理論的可行性。而到了 1979 年，Patricia Selinger 團隊發表〈Access Path Selection in a Relational Database Management System〉，進一步建立 Cost-Based Query Optimization 的基礎。從此之後，決定如何存取資料的責任開始從程式設計師轉移到 Database 系統本身。</p><p>同一時期，查詢方式也經歷了類似的轉變。早期系統通常必須透過程式碼逐步操作資料，而 System R 團隊提出的 SEQUEL 則首次嘗試以宣告式語言描述需求。這套想法後來演化為 SQL，並逐漸成為 Relational Database 的共同語言。</p><p>在這個時代，資料管理的思維開始從「如何取得資料」轉向「想取得什麼資料」。</p><h3>1980–2000: The Rise of Data Warehouse</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FPEdOZtFv2tf8e7f1H8U1w.png" /></figure><h4>Dominant Question</h4><p>如何將分散於各個業務系統的資料整合起來，轉化為決策所需的資訊？</p><h4>Hardware</h4><ul><li>IBM 3380 (1980): 首款容量超過 1GB 的商用磁碟系統。</li><li>“A Case for Redundant Arrays of Inexpensive Disks (RAID)” — David A. Patterson, Garth Gibson, Randy H. Katz (1988): 提出 RAID 架構，以多顆廉價磁碟提供更高容量、效能與可靠性。</li></ul><h4>Operating System</h4><ul><li>UNIX Workstations (1980s): 開始廣泛應用於企業資料處理環境。</li><li>Linux (1991): 開源作業系統，於 2000 年代成為 Hadoop 與 Big Data 平台的重要基礎。</li><li>Windows NT (1993): Microsoft 進軍企業級伺服器市場的重要產品。</li></ul><h4>Networking</h4><ul><li>TCP/IP Standardization (1983): ARPANET 正式切換至 TCP/IP。</li><li>Ethernet (1980s): 成為企業網路的主流標準。</li></ul><h4>Storage System</h4><ul><li>Network Attached Storage, NAS (1980s): File Store 開始進入企業環境。</li><li>Storage Area Network, SAN (1990s): Block Store 開始支援大型企業資料中心。</li></ul><h4>Database</h4><ul><li>Oracle Database (1980s): 最成功的商業 Relational Database 之一。</li><li>IBM DB2 (1983): IBM 的企業級 Relational Database。</li><li>Sybase (1984): Client/Server Database 架構代表。</li><li>Microsoft SQL Server (1989): Microsoft Database 產品線起點。</li><li>PostgreSQL (1996): 由 Ingres 衍生出的開源 Relational Database。</li></ul><h4>Data Warehouse</h4><ul><li>Teradata (1983): 最早成功商業化的 MPP Data Warehouse 之一。</li><li>“Building the Data Warehouse” — Bill Inmon (1992): 首次系統化定義 Enterprise Data Warehouse。</li><li>“The Data Warehouse Toolkit” — Ralph Kimball (1996): 提出 Dimensional Modeling 與 Star Schema。</li><li>Enterprise Data Warehouse, EDW (1990s): 成為大型企業分析平台的主流架構。</li><li>MPP Architecture (1990s): 大規模平行處理成為 Data Warehouse 核心技術。</li></ul><h4>Query Language</h4><ul><li>SQL-86 (1986): ANSI 第一版 SQL 標準。</li><li>SQL-92 (1992): 首次完整標準化現代 SQL 的核心語法，包括 JOIN、Subquery 與 Integrity Constraints。</li><li>OLAP Query (1990s): 開始出現需要 Join、Aggregation 與多維度分析的複雜商業查詢。</li></ul><h4>Storage Engine</h4><ul><li>B-Tree (1980s): 成為 Relational Database 最重要的索引結構。</li><li>Bitmap Index (1990s): 專為分析型查詢最佳化。</li><li>Materialized View (1990s): 提升大型聚合查詢效能的重要技術。</li><li>“The Log-Structured Merge-Tree (LSM-Tree)” — Patrick O’Neil et al. (1996): 以批次合併改善高寫入工作負載，後來成為多數 NoSQL Database 的核心結構。</li></ul><h4>Compute Engine</h4><ul><li>“Volcano — An Extensible and Parallel Query Evaluation System” — Goetz Graefe (1993): 現代 Query Executor 的重要基礎。</li></ul><h4>Format / Protocol</h4><ul><li>ODBC (1992): 微軟推動的統一 Database 存取標準。</li><li>JDBC (1997): Java 生態系的 Database 存取標準。</li><li>CSV (1990s): 成為企業資料交換的重要格式。</li></ul><h4>Workload</h4><ul><li>OLTP (1980s): 成為企業資訊系統的主流工作負載。</li><li>TPC-A (1989): 第一代標準化 OLTP Benchmark，以銀行交易為主要測試場景。</li><li>TPC-C (1992): 模擬訂單與倉儲管理系統，成為企業交易系統的重要 Benchmark。</li><li>OLAP (1993): Edgar F. Codd 正式提出 Online Analytical Processing。</li><li>Business Intelligence, BI (1990s): 企業開始利用資料支援決策分析。</li><li>TPC-H (1999): 模擬商業分析查詢，成為 Data Warehouse 的重要 Benchmark。</li></ul><h4>Consumer</h4><ul><li>Executive (1980s): Data Warehouse 最早服務的核心對象。</li><li>Business Analyst (1990s): 開始成為企業資料分析的主要使用者。</li><li>Decision Support Systems, DSS (1990s): 決策支援系統逐漸普及。</li></ul><h4>AI Development</h4><ul><li>Expert Systems Boom (1980s): 專家系統開始進入商業應用，掀起第二次 AI 熱潮。</li><li>AI Winter (1987–1993): Lisp Machine 市場崩潰與 Expert Systems 退潮，AI 產業進入低潮期。</li><li>Statistical Machine Learning Era (1990s): 統計方法逐漸取代 Symbolic AI，Machine Learning 開始崛起。</li></ul><h4>Technology Lineage</h4><p>1980 年代以前，大部分 Database 的主要任務都是支援日常營運。銀行轉帳、訂單處理、庫存管理與航空訂位系統，都屬於典型的 OLTP 工作負載。這些系統非常擅長記錄交易，但並不擅長回答管理者的問題。</p><p>隨著企業累積的資料越來越多，人們開始希望利用資料理解企業本身。例如哪些產品最賺錢、哪些客戶最有價值、哪個地區的銷售正在下滑。這類問題通常需要跨越大量資料進行聚合與分析，而不是單筆交易處理。</p><p>正是在這樣的背景下，Data Warehouse 開始興起。1983 年 Teradata 將 MPP 架構帶入分析領域，嘗試利用多台機器共同處理大型查詢。到了 1992 年，Bill Inmon 在《Building the Data Warehouse》中首次系統化定義 Enterprise Data Warehouse；而 Ralph Kimball 則在 1996 年透過《The Data Warehouse Toolkit》推廣 Dimensional Modeling 與 Star Schema。雖然兩人的方法論有所不同，但目標一致：讓企業能夠從資料中獲得洞察。</p><p>同一時期，查詢工作負載也逐漸從 OLTP 走向 OLAP。1993 年，Edgar F. Codd 正式提出 Online Analytical Processing 的概念，標誌著分析型工作負載開始成為獨立領域。TPC-A、TPC-C 與 TPC-H 等 Benchmark 的出現，也反映出產業關注點正在從交易處理逐步轉向分析查詢。</p><p>因此，如果說前一個時代解決的是「如何管理資料」，那麼這個關心的則是「如何利用資料進行決策」。</p><h3>2000–2017: The Age of Big Data and Data Lake</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FcgeIJXjyTy9rd0Lr7YQaA.png" /></figure><h4>Dominant Question</h4><p>如何處理單機與傳統資料平台無法承載的資料規模？</p><h4>Hardware</h4><ul><li>Commodity Servers (2000s): 大量廉價 x86 Server 成為 Scale-Out 與 Big Data 系統的基礎。</li><li>Multi-Core CPU (2005–2010): 多核心處理器逐漸普及，軟體開始重新思考平行計算模型。</li></ul><h4>Operating System</h4><ul><li>Kubernetes (2014): Container Orchestration 系統，於 2017–2025 成為雲原生資料平台的重要基礎。</li></ul><h4>Networking</h4><ul><li>Gigabit Ethernet (2000s): 成為資料中心主流網路。</li><li>10 Gigabit Ethernet (2010s): 支撐大型分散式系統。</li></ul><h4>Storage System</h4><ul><li>“The Google File System” — Sanjay Ghemawat, Howard Gobioff, Shun-Tak Leung (2003): 提出分散式檔案系統設計。</li><li>HDFS (2004): Hadoop Distributed File System。</li><li>Amazon S3 (2006): Object Storage 開始進入主流視野。</li></ul><h4>Database</h4><ul><li>“Bigtable: A Distributed Storage System for Structured Data” — Fay Chang et al. (2006): 提出 Column-Family Database 設計。</li><li>“Amazon Dynamo: Amazon’s Highly Available Key-value Store” — Giuseppe DeCandia et al. (2007): 提出高可用分散式 Key-Value Database 設計。</li><li>Apache Cassandra (2008): 結合 Bigtable 與 Dynamo 思想的分散式 NoSQL Database。</li><li>MongoDB (2009): 最具代表性的 Document Database 之一。</li><li>“Spanner: Google’s Globally Distributed Database” — James C. Corbett et al. (2012): 首次大規模實現全球一致性的分散式 Database。</li><li>TiDB (2015): 嘗試統一 OLTP 與 OLAP 工作負載的 HTAP Database。</li></ul><h4>Data Warehouse</h4><ul><li>Google BigQuery (2011): 首個大規模 Serverless Data Warehouse 服務之一。</li><li>Amazon Redshift (2012): 將 MPP Data Warehouse 帶入公有雲。</li><li>Snowflake (2012): 推動存算分離架構的 Cloud Data Warehouse。</li><li>ClickHouse (2016): 專為即時分析設計的 Column-Oriented Analytical Database。</li></ul><h4>Query Language</h4><ul><li>Pig Latin (2006): Apache Pig 的資料處理語言。</li><li>HiveQL (2008): Hive 的 SQL 方言。</li><li>SQL-on-Hadoop (2010s): SQL 成為 Big Data 分析主流介面。</li></ul><h4>Catalog</h4><ul><li>Hive Metastore (2008): Hadoop 生態系事實上的 Metadata 標準。</li><li>Apache ZooKeeper (2008): 提供分散式協調能力。</li></ul><h4>Storage Engine</h4><ul><li>“C-Store: A Column-oriented DBMS” — Michael Stonebraker et al. (2005): 現代 Columnar Database 與分析型存儲引擎的重要基礎。</li><li>Apache Kafka (2011): 以 Distributed Log 為核心的 Streaming Storage 系統。</li></ul><h4>Compute Engine</h4><ul><li>“MapReduce: Simplified Data Processing on Large Clusters” — Jeffrey Dean, Sanjay Ghemawat (2004): 提出大規模分散式 Batch Processing 模型。</li><li>Apache Hadoop (2004): 將 MapReduce 與 HDFS 開源化，建立 Big Data 生態系。</li><li>“One Size Fits All? An Idea Whose Time Has Come and Gone” — Michael Stonebraker et al. (2005): 指出不同工作負載需要不同專用 Engine。</li><li>Apache Hive (2008): 首次將 SQL 引入 Hadoop 生態系。</li><li>Apache Storm (2011): 大規模 Stream Processing Engine。</li><li>Presto (2013): Facebook 發起的 Distributed SQL Query Engine，可直接查詢多種資料來源。</li><li>Apache Spark (2014): 以記憶體運算改善 Batch Processing 效率。</li><li>Apache Flink (2015): 將 State 與 Event Time 納入計算模型的新一代 Streaming Engine。</li></ul><h4>Format / Protocol</h4><ul><li>“Data Page Layouts for Relational Databases on Deep Memory Hierarchies” — Anastassia Ailamaki, David J. DeWitt, Mark D. Hill, Marios Skounakis (2001): 提出 PAX Page Layout。</li><li>Apache Avro (2009): Hadoop 生態系的重要 Row-Oriented Serialization Format。</li><li>Apache ORC (2013): 基於 PAX 思想設計 Storage Format，最初由 Hive 生態系推動。</li><li>Apache Parquet (2013): 基於 PAX 思想設計的 Storage Format，後來成為 Data Lake 的主流格式。</li><li>Apache Arrow (2016): In-Memory Columnar Format，為跨系統資料交換建立共同基礎。</li></ul><h4>Workload</h4><ul><li>Web Scale Data Processing (2000s): Google、Yahoo! 等網際網路公司開始處理 PB 級資料。</li><li>Batch Analytics (2000s): 大規模離線資料分析成為 Hadoop 的主要工作負載。</li><li>YCSB (2010): Yahoo! Cloud Serving Benchmark，成為 NoSQL Database 的重要 Benchmark。</li><li>Big Data Analytics (2010s): Spark 等系統推動大規模資料分析普及。</li><li>Event Streaming (2010s): Log、Clickstream 與 IoT 資料推動即時處理需求成長。</li><li>TPC-DS (2012): Data Warehouse 與 Lakehouse 最重要的分析型 Benchmark。</li></ul><h4>Consumer</h4><ul><li>Data Engineer (2010s): 負責建設與維護 Hadoop、Spark 等大規模資料平台。</li><li>Data Scientist (2010s): 利用統計學與 Machine Learning 從海量資料中提取洞察。</li></ul><h4>AI Development</h4><ul><li>Deep Learning Revival (2006): Geoffrey Hinton 等人重新點燃 Neural Network 研究熱潮。</li><li>ImageNet (2009): 大規模影像資料集發布，建立統一的 Computer Vision Benchmark。</li><li>AlexNet (2012): 在 ImageNet 競賽大幅領先傳統方法，開啟 Deep Learning 時代。</li><li>TensorFlow (2015): Google 開源 Machine Learning Framework，加速 Deep Learning 普及。</li><li>Deep Learning Boom (2012–2017): GPU、大數據與神經網路共同推動 AI 再次進入高速發展期。</li></ul><h4>Technology Lineage</h4><p>對 Harari Watcher 而言，2000–2017 無疑是最令人頭痛的一個 Time Slice。前兩個時代還勉強能畫出一棵清楚的家族樹；到了 Big Data 時代，卻像有人把整盒義大利麵打翻在地上 — — Database、Data Warehouse、Data Lake、Compute、Storage、Batch、Streaming、Row Store、Column Store、Wide-Column Store、PAX Format、OLTP、OLAP、HTAP 各種概念同時朝不同方向快速演化，又彼此交錯融合。即使擁有近乎無限的知識，Harari Watcher 恐怕也得泡上一壺咖啡，才能慢慢理清這段錯綜複雜的技術演化史。</p><p>2000 年代初期，Google、Yahoo! 等網際網路公司開始面臨前所未有的資料規模。每天產生的搜尋紀錄、網頁資料與使用者行為資料已經超出傳統 Database 與 Data Warehouse 的能力範圍。</p><p>2004 年，Google 發表《MapReduce: Simplified Data Processing on Large Clusters》，首次系統化展示如何利用大量 Commodity Servers 進行大規模資料處理。同一年，Hadoop 將這套思想帶入開源世界，開啟 Big Data 時代。</p><p>然而，MapReduce 很快暴露出新的問題。雖然它成功解決了 Scale-Out 計算，但每個階段都需要大量磁碟 I/O，開發模型也過於低階。Michael Stonebraker 在《MapReduce: A Major Step Backwards》中直接批評 MapReduce 在 Schema、Query Optimization 與執行效率上的退步。</p><p>有趣的是，Big Data 後來十多年的演化，竟有些像《奧德賽》。為了突破傳統 Database 的限制，產業離開了熟悉的 SQL 世界，踏上一場漫長的旅程；Hive、Impala、Presto、Spark SQL、Trino……繞了一大圈之後，最終又回到了 SQL。</p><p>同一時期，Database 世界也開始分化。2006 年的 Bigtable 與 2007 年的 Dynamo 分別探索不同的 Scale-Out Database 設計方向。隨後 Cassandra、MongoDB 等 NoSQL Database 陸續出現，嘗試突破傳統 Relational Database 的擴展性限制。</p><p>Cassandra 選擇了高可用、去中心化與水平擴展；而後來的 ScyllaDB 則沿著 Cassandra 的資料模型與 API 繼續前進，但在底層實作上走向另一條路。ScyllaDB 採用 Seastar 的 shard-per-core 架構，讓每個 CPU core 擁有自己的資料分片與執行邏輯，盡量避免傳統多執行緒系統中的 lock contention 與 context switch。換句話說，它繼承了 Cassandra 的分散式模型，卻試圖用更貼近現代硬體的方式重新實作。</p><p>到了 2012 年，Google 發表 Spanner，首次在全球分散式 Database 中同時提供 Serializable 與 Linearizability，至今仍是業界極少數能同時達成這兩種強一致性保證的大規模資料庫系統。</p><p>2015 年，TiDB 則開始探索 HTAP（Hybrid Transactional and Analytical Processing），試圖在同一套系統中同時支援 OLTP 與 OLAP，重新思考交易與分析是否一定需要分屬不同的平台。</p><p>在分析領域，Cloud Data Warehouse 也逐漸成形。Google BigQuery（2011）、Amazon Redshift（2012）與 Snowflake（2012）相繼問世，Cloud Data Warehouse 開始成為新的發展方向。資料平台逐漸從昂貴的 MPP Appliance，轉向建立於公有雲之上的存算分離架構。</p><p>這場典範轉移的影響，甚至反映在資本市場上。長年主導 Enterprise Data Warehouse 市場的 Teradata，一度被視為分析型資料庫的代名詞；然而隨著 Snowflake 快速崛起並成功上市，市場開始重新評價雲端原生資料平台的價值，Teradata 的市值與影響力逐漸被新一代 Cloud Data Warehouse 取代，也象徵著資料平台正式進入雲端時代。</p><p>與此同時，人們也開始重新思考 Storage 與 Compute 的角色分工。HDFS 與 Object Storage 等分散式存儲系統開始負責資料持久化，而 Parquet、ORC 等 PAX Format 則逐漸成為分析型資料的共同表示方式；另一方面，Presto、Spark 等 Compute Engine 則專注於資料計算與查詢執行。</p><p>另一個重要變化來自 Streaming。</p><p>最早的 Big Data 系統主要專注於 Batch Processing，但隨著網站、廣告系統與行動應用快速成長，企業開始希望即時處理持續流入的資料。2011 年 Kafka 將 Distributed Log 推向主流，使 Streaming Storage 首次成為獨立領域。</p><p>更重要的是，Kafka 的演化逐漸超越了單純的 Log Collector。它透過 Consumer Group 將資料傳輸與資料消費解耦；在 KRaft 中，又將原本依賴 ZooKeeper 的 Metadata Consensus 收回系統內部；近年的 Consumer Rebalance Protocol 與 Share Groups，則進一步將 Consumer Coordination 與 Queue Semantics 提升為平台原生能力。</p><p>Kafka 不斷將原本分散在外部系統、client 或使用者慣例中的控制邏輯，逐步收斂到平台本身，使 Streaming Storage 不再只是資料管道，而開始成為現代資料平台的重要基礎設施。</p><p>後來的 Redpanda 則繼承了 Kafka 的 API，底層卻試圖用更貼近現代硬體的方式 — Seastar — 重新實作 Streaming Storage。</p><p>Streaming Compute 也快速演化。S4 首先嘗試將 Streaming 視為一等公民；Storm 則首次解決了大規模 Stream Processing 的可靠性問題；MillWheel 則首次系統化解決 State、Exactly-Once 與 Event Time 等問題。Google 隨後在 Dataflow 中提出「Unifying Batch and Streaming」的理念，認為兩者只是同一份資料的不同觀察方式。Beam 將這套模型抽象成統一程式設計框架，而 Flink 則進一步將 Dynamic Tables 與 Changelog Streams 帶入實務系統。</p><p>有趣的是，這個時代同時出現了兩種看似相反的趨勢。</p><p>在系統底層，Database、Storage Engine 與 Compute Engine 持續分化。OLTP、OLAP 與 HTAP 逐漸走向不同方向；Batch 與 Streaming 也各自發展出專門化系統；Storage 與 Compute 則開始獨立演化，形成不同的最佳化策略。</p><p>然而在系統上層，抽象卻逐漸走向統一。Dataflow 嘗試統一 Batch 與 Streaming；Dynamic Tables 嘗試統一 Stream 與 Table；TVR 進一步統一不同時間維度下的資料表示；而《One SQL to Rule Them All》則開始嘗試用單一 SQL 模型同時描述 Stream、Table、Batch 與 Materialized View。</p><p>換句話說，底層系統百花齊放，上層介面則逐漸收斂。工程師希望在內部保有足夠的自由度來最佳化不同工作負載，同時也希望使用者面對的是一套簡單且一致的抽象模型。</p><p>這種「底層分化、上層統一」的趨勢，或許正是 2000–2017 這個時代最值得玩味的現象之一。</p><h3>2017–2025: The Rise of Data Lakehouse</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ShhYH4tc9ahkntHwgIY-IA.png" /></figure><h4>Dominant Question</h4><p>如何在開放存儲之上，同時獲得 Data Lake 的彈性與 Data Warehouse 的可靠性？</p><h4>Hardware</h4><ul><li>NVMe SSD Adoption (2017–2025): 高頻 I/O 工作負載開始廣泛受益於 NVMe 的低延遲與高並行能力。</li><li>GPU Computing Boom (2020s): Foundation Models 與 Generative AI 推動 GPU 成為現代 AI 基礎設施核心。</li></ul><h4>Networking</h4><ul><li>Cloud-Native Networking (2017–2025): Kubernetes 與 Service Mesh 生態成熟，成為雲原生資料平台的重要基礎。</li><li>RDMA Adoption (2020s): 高效能資料系統與 AI Infrastructure 開始利用 RDMA 降低網路延遲與 CPU 開銷。</li></ul><h4>Storage System</h4><ul><li>Amazon S3 Strong Consistency (2020): Object Storage 正式具備強一致性語意。</li><li>S3 Express One Zone (2023): 透過低延遲架構改善 Object Storage 存取效能。</li></ul><h4>Database</h4><ul><li>CockroachDB (2017): 將 Spanner 類型的分散式 SQL Database 帶入開源世界。</li><li>Milvus (2019): 開源 Vector Database，推動向量檢索技術普及。</li><li>Neon (2022): 將 Storage-Compute Separation 引入 Postgres 生態系。</li><li>LanceDB (2023): AI Native Multimodal Database，探索向量、檔案與 AI Workload 的統一模型。</li></ul><h4>Data Lakehouse</h4><ul><li>Apache Hudi (2017): 最早期的 Open Table Format 之一，強調 Upsert 與 Incremental Processing。</li><li>Delta Lake (2019): 將 single statement ACID Transaction 引入 Data Lake。</li><li>Apache Iceberg V1 (2020): 建立 Snapshot、Manifest 與 Schema Evolution 等核心 Table Format 能力。</li><li>Apache Iceberg V2 (2022): 增加 Row-Level Delete、MERGE 與 CDC 支援。</li><li>Apache Paimon (2023): 專為 Streaming Lakehouse 設計的 Open Table Format。</li><li>Lance Table (2023): 專為 AI 與 Multimodal Workload 設計的 Table Format。</li><li>Apache Iceberg V3 (2024): 強化 Streaming、Variant Types 與 AI Workload 支援。</li><li>Apache Fluss (2024): Streaming-First Lakehouse Table Format，嘗試統一 Streaming 與 Lakehouse。</li></ul><h4>Query Language</h4><ul><li>Streaming SQL (2020s): Flink 等系統推動 SQL 與 Streaming 的整合。</li><li>SQL/PGQ (2023): ISO SQL 正式納入 Property Graph Query，擴展 SQL 對 Graph Data 的支援能力。</li><li>“One SQL to Rule Them All” (2019): 嘗試以單一 SQL 模型統一 Stream、Table、Batch 與 Materialized View。</li></ul><h4>Catalog</h4><ul><li>AWS Glue (2017): 將 Catalog 作為雲端託管服務推向主流。</li><li>Unity Catalog (2022): Databricks 推動跨 Workspace 與 Lakehouse 的統一 Governance Layer。</li><li>REST Catalog (2020s): Iceberg 推動 Catalog 標準化介面，降低 Engine 與 Catalog 的耦合。</li><li>Apache Gravitino (2024): 嘗試建立跨 Catalog、跨資料來源的統一 Metadata 與 Governance 平台。</li><li>Apache Polaris (2024): Apache Iceberg 生態的重要 Open Catalog 與 Governance 專案。</li><li>Lakekeeper (2024): 專注於 Iceberg Catalog 與 Governance 的開源方案。</li></ul><h4>Compute Engine</h4><ul><li>Apache DataFusion (2018): Rust 生態的重要 Query Engine，推動可組合資料系統發展</li><li>DuckDB (2019): 推動 Single-Node Analytics 復興，重新挑戰分散式系統的必要性。</li><li>Materialize (2020): 基於 Incremental View Maintenance 的 Streaming SQL Engine。</li><li>Trino (2020): Presto 社群分支後持續發展，成為重要的 Distributed SQL Engine。</li><li>Photon (2022): Databricks 新一代 Vectorized Query Engine，大幅提升 Lakehouse 查詢效能。</li><li>Velox (2022): Meta 開源的 Unified Execution Engine，成為新一代分析引擎的重要基礎。</li><li>RisingWave (2022): 基於 Streaming Database 理念的 Distributed Streaming SQL Engine。</li><li>Apache Gluten (2023): 將 Velox 引入 Spark 生態，推動 Lakehouse 執行引擎現代化。</li><li>Arroyo (2023): 基於 Rust 與 Arrow 生態的新一代 Streaming SQL Engine。</li><li>DataFusion Comet (2024): 將 DataFusion 執行引擎引入 Spark 生態，探索 Spark 與 Rust Engine 的整合。</li></ul><h4>Format / Protocol</h4><ul><li>Lance File Format (2023): 專為 Vector Search、Multimodal Data 與 AI Workload 設計的新一代 Columnar File Format。</li><li>Apache Vortex (2024): 下一代 Columnar Format，嘗試在壓縮率、查詢效能與雲端存儲成本之間取得更佳平衡。</li></ul><h4>Workload</h4><ul><li>Nexmark (2020s 廣泛採用): Streaming SQL 與 Stream Processing Engine 的重要 Benchmark。</li><li>Lakehouse Analytics (2020s): Iceberg、Delta 與 Hudi 推動 Open Data Lake 上的大規模分析工作負載。</li><li>Real-Time Analytics (2020s): Streaming 與 OLAP 持續融合，即時分析逐漸成為主流需求。</li><li>Feature Engineering &amp; ML Pipelines (2020s): 資料平台開始直接支援 Machine Learning 訓練與特徵工程。</li><li>VectorDBBench (2023): 向量檢索系統的重要 Benchmark，反映 AI Era 新型工作負載需求。</li><li>RAG Workload (2023): Retrieval-Augmented Generation 推動向量檢索與知識庫查詢成為新型工作負載。Consumer</li></ul><h4>Consumer</h4><ul><li>Data Engineer (2020s): 負責建設 Lakehouse、Streaming 與 Data Platform。</li><li>Data Scientist (2020s): 利用 Lakehouse 與 AI 平台進行模型訓練與分析。</li><li>Machine Learning Engineer (2020s): 負責模型部署、特徵工程與 AI Production 系統。</li><li>AI Application Developer (2023–2025): 開始利用 Foundation Models 建構 AI 應用。</li></ul><h4>AI Development</h4><ul><li>“Attention Is All You Need” — Ashish Vaswani et al. (2017): 提出 Transformer Architecture，開啟 Foundation Model 時代。</li><li>GPT-3 (2020): 大型語言模型開始進入主流視野。</li><li>ChatGPT (2022): Generative AI 普及的重要轉折點。</li><li>Multimodal Foundation Models (2024): AI 開始同時處理文字、影像、語音與影片。</li></ul><h4>Technology Lineage</h4><p>2017 年前後，Big Data 時代留下的許多問題開始逐漸浮現。</p><p>過去十多年來，Hadoop、MapReduce、NoSQL 與各種 Scale-Out 技術成功解決了資料規模快速成長的挑戰。然而，隨著這些系統被廣泛採用，新的問題也開始出現。資料散落於不同平台之間，Metadata 缺乏統一管理，資料複製與同步成本不斷增加，而 Data Lake 則經常演變成難以治理的 Data Swamp。</p><p>因此，產業開始重新思考資料平台的設計方式。</p><p>首先發生變化的是存儲層。Object Storage 逐漸取代 HDFS 成為新的基礎設施，而 Amazon S3 的 Strong Consistency 則進一步消除了許多過去的限制。資料平台不再圍繞特定計算框架建構，而開始圍繞共享存儲建構。</p><p>接著，Hudi、Delta Lake 與 Iceberg 等 Open Table Format 相繼成熟。它們並沒有試圖重新發明存儲系統，而是在 Object Storage 之上重新建立 Table、Schema、Transaction、Time Travel 與 Schema Evolution 等能力，使資料第一次能夠以開放格式被多個 Compute Engine 共同存取。</p><p>Table Format 的競爭並未因此停止。隨著 Delta Uniform、Apache XTable 等跨格式互通方案陸續出現，產業開始嘗試打破 Iceberg、Delta 與 Hudi 之間的邊界。競爭的焦點，也逐漸從「誰擁有最好的 Table Format」，轉向「如何讓不同 Table Format 能夠共同協作」。</p><p>然而，Open Table Format 解決了「資料如何共享」的問題，卻也讓另一個問題浮上檯面：當所有 Engine 都開始共享同一份資料時，誰來管理這份共同的 Metadata？於是，Catalog 開始成為新的競爭焦點。從 Hive Metastore 到 AWS Glue，再到 REST Catalog、Polaris、Lakekeeper、Unity Catalog 與 Gravitino，Catalog 逐漸從單純的 Metadata Repository 演變為整個資料平台的控制平面。</p><p>與此同時，計算層也開始出現新的變化。過去十多年來，產業不斷追求更大的叢集與更強的 Scale-Out 能力；但到了 2020 年代，越來越多人開始重新檢視這個前提。Jordan Tigani 在《Big Data is Dead》中指出，真正重要的往往不是資料總量，而是實際參與查詢的 Working Set。DuckDB、DataFusion 與 Polars 等系統的興起，也反映出一種新的思考：不是所有問題都需要分散式系統，而是應該根據工作負載選擇最合適的 Engine。</p><p>同樣的反思也出現在 HTAP 領域。過去十多年來，人們一直希望用同一套系統同時處理 OLTP 與 OLAP。然而《HTAP is Dead, Long Live HTAP》指出，HTAP 遲遲未能成為主流，原因並不完全是技術，而是組織分工。大多數企業更在意 OLTP 是否穩定，而不是 OLAP 是否方便，因此分析工作最終仍然傾向獨立到專門的分析平台。</p><p>回頭來看，2017–2025 的重點是在整合 Big Data 時代留下的碎片。Data Lakehouse 透過 Open Table Format、Catalog 與 Object Storage，讓不同 Engine 能夠共享與治理同一份資料，也為下一個 AI 與 Agent 時代奠定共同的資料基礎。</p><h3>2025+: The Age of Agents</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5-dE1kaPKlLae3lHWY92KA.png" /></figure><p>過去六十多年來，資料平台的演化始終圍繞著人類使用者展開。Database 時代服務的是應用程式開發者；Data Warehouse 時代服務的是 Analyst；Big Data 時代服務的是 Data Engineer；Lakehouse 時代則試圖讓不同系統共享同一份資料。</p><p>然而到了 2025 年之後，兩條新的演化路線開始逐漸浮現。</p><p>第一條路線，是 Agent 成為新的資料消費者。它們更傾向透過 MCP、Tool Calling、API 與各種結構化介面存取資料平台，並將資料視為推理、規劃與執行任務的基礎。資料平台開始從「回答問題」轉向「協助 Agent 完成工作」。</p><p>如果回顧這條演化路線，可以發現它也有自己的技術譜系：2017 年 Transformer 奠定了現代 AI 的基礎；隨後 Large Language Model 興起，使自然語言逐漸成為新的人機交互介面；Prompt Engineering 讓人類開始以語言驅動模型；Function Calling 則讓模型首次具備操作外部工具的能力；Agent 將推理與工具使用整合成完整工作流程；MCP 嘗試標準化 Agent 與工具之間的互動方式，而 A2A 則進一步探索 Agent 與 Agent 之間的協作機制。AI 系統的能力，也從「回答問題」逐漸演化為「完成任務」。</p><p>另一條路線，則是資料本身開始走向 Multimodal。過去的資料平台主要處理 Table 與 Structured Data；如今，Text、Image、Audio、Video、Embedding、Knowledge Graph 甚至 Agent Memory，都開始成為資料平台的一部分。資料平台不再只是管理 Table，而開始管理各種不同形式的 Knowledge。</p><p>因此，資料平台開始向 AI Infrastructure 演化。這也帶來了 2025 年之後的新問題：如何讓資料平台同時服務 humankind 與 Agent？又如何讓資料平台成為 AI 時代共同的知識基礎？</p><p>如果是早已看遍無數時間線的 Harari Watcher，或許早已知道這條道路最終將通往何方；但對於只能感知線性時間的人類而言，未來仍是一片等待探索的可能性空間。</p><p>巧合的是，我也有幸在 2026 年二月加入微軟的 Agent 團隊，親身參與這場新的技術浪潮。Harari 已經替人類寫下《Sapiens: A Brief History of Humankind》，而當 2025 年這個問題的答案在未來逐漸清晰，也許我也能盡棉薄之力，將這段歷史書寫成另一個故事──《A Brief History of Agents》。</p><h3>Future Work</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bO9qr4sH0ZUg4F9V5JxXVA.png" /></figure><p>當細心的讀者回頭翻閱本書的目錄，或許會發現一件有趣的事情。</p><p>雖然本書名為《From Database to Data Lakehouse》，但在 Volume 1 中，卻始終沒有一個 Chapter 專門介紹 2017–2025：The Rise of Data Lakehouse 這個 Time Slice。</p><p>這其實是我刻意留下的一個伏筆，我計劃將該主題留待 Volume 2 探討。</p><p>為了跟目前在微軟的工作接軌，我想優先投入 Agent 這個主題。因此，在完成《From Database to Data Lakehouse》Volume 1 之後，我預計會先撰寫一本全新的書：《A Brief History of Agents》。</p><p>而之後的《From Database to Data Lakehouse》Volume 2，則將回到本書的主題，完整補上 2017–2025：The Rise of Data Lakehouse 這個 Time Slice。</p><p>Volume 2 預計將深入探討 Cloud Data Warehouse（Redshift、Snowflake、BigQuery）、Open Table Format（Delta Lake、Hudi、Iceberg）、Iceberg v1–v4、Lance File Format、Lance Table Format、DuckLake、Apache Fluss，以及 Lakehouse 生態系近年的重要發展。</p><p>此外，我也計劃用一整章的篇幅，重新整理 ETL／ELT、Lambda Architecture、Kappa Architecture、Medallion Architecture 與 shift-left 的演化關係。它們是在不同時代回應不同問題，當這些概念重新回到同一條歷史時間線，資料工程的演化方向也將變得清晰：從資料搬運與計算能力，逐步走向語義治理與責任前移。</p><p>期待在不久的將來，能與大家在《A Brief History of Agents》以及《From Database to Data Lakehouse》Volume 2 再次相見！</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/630/1*kxHTp0krMqytNFxOUXydOA.jpeg" /></figure><h3>Appendix</h3><ol><li>人柱力和佩恩六道，谁才是湖仓一体的终极形态？<a href="https://mp.weixin.qq.com/s/6Zz0SXhuKDp3aUWcutUeNg">https://mp.weixin.qq.com/s/6Zz0SXhuKDp3aUWcutUeNg</a></li><li>被骂“在乱讲”的专家，这次可能说对了：传统数据仓库正在被 Agentic AI 吞噬: <a href="https://mp.weixin.qq.com/s/aO736vLWPfhQ3VIUJGlzEQ">https://mp.weixin.qq.com/s/aO736vLWPfhQ3VIUJGlzEQ</a></li><li>Big Data is Dead: <a href="https://motherduck.com/blog/big-data-is-dead/">https://motherduck.com/blog/big-data-is-dead/</a></li><li>HTAP is dead, long live HTAP: <a href="https://medium.com/@streamkap/htap-is-dead-long-live-htap-8972f6dc93ca">https://medium.com/@streamkap/htap-is-dead-long-live-htap-8972f6dc93ca</a></li><li>Catalogs in Apache Iceberg: <a href="https://www.linkedin.com/posts/risingwave_iceberg-catalogs-choosing-the-right-one-activity-7350919660810256385--grO/">https://www.linkedin.com/posts/risingwave_iceberg-catalogs-choosing-the-right-one-activity-7350919660810256385--grO/</a></li><li>Iceberg Catalogs in 2025:Deep Dive into Polaris, LakeKeeper, Glue &amp; Nessie| Greybeam’s Arsham Eslami: <a href="https://www.youtube.com/watch?v=0MejdgCax3w">https://www.youtube.com/watch?v=0MejdgCax3w</a></li><li>S3 二十年编年史：如何成为现代数据系统的默认持久层: <a href="https://mp.weixin.qq.com/s/7dxFzsGJ2DlxJ9luW_Uasw">https://mp.weixin.qq.com/s/7dxFzsGJ2DlxJ9luW_Uasw</a></li><li>What Goes Around Comes Around… And Around…: <a href="https://db.cs.cmu.edu/papers/2024/whatgoesaround-sigmodrec2024.pdf">https://db.cs.cmu.edu/papers/2024/whatgoesaround-sigmodrec2024.pdf</a></li><li>Scaling Multimodal AI Lakehouse with Lance &amp; LanceDB: <a href="https://www.youtube.com/watch?v=HPFzAjHog6M">https://www.youtube.com/watch?v=HPFzAjHog6M</a></li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*n4DUHcnVs8hoT5iJTAF_vg.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6924169483ab" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Chapter 11. Stream Processing Engine: Toward Unified Data Processing]]></title>
            <link>https://medium.com/@0050211/from-database-to-data-lakehouse-part-13-ef379f250027?source=rss-663daee8909b------2</link>
            <guid isPermaLink="false">https://medium.com/p/ef379f250027</guid>
            <dc:creator><![CDATA[Ryan Fu]]></dc:creator>
            <pubDate>Fri, 29 May 2026 14:28:44 GMT</pubDate>
            <atom:updated>2026-06-14T18:26:46.827Z</atom:updated>
            <content:encoded><![CDATA[<p>《<a href="https://medium.com/@adrianhsu/%E7%B5%A6%E5%AD%B8%E5%BC%9F%E5%A6%B9%E7%9A%84%E7%9F%BD%E8%B0%B7-ai-software-%E6%8C%87%E5%8D%97-da054b7a8dcb">給學弟妹的矽谷 AI / Software 指南</a>》是一個矽谷工程師自發的眾籌寫作計劃，由<a href="https://www.facebook.com/opensource4you">源來適你社群</a>推動。這將是一個在台灣軟體學生圈內很有影響力的文章系列。已有 &gt;10 篇準備發布，目標 100 篇並出書，歡迎 tag 強者大神來寫！</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YQh-VCf1eUx74x34zbr0tw.png" /></figure><p>在 Stream Processing Engine 的世界裡，當資料以流的形式不斷湧入，系統必須在毫秒之間做出決策。任何一次重試，都可能造成重複計算；任何一次延遲，都可能改變結果的正確性。於是，三個看似獨立的問題 — — 精確一次（exactly-once）、狀態（state），以及時間（time） — — 開始交織在一起。</p><p>如果沒有狀態，我們無法記住過去；<br>如果沒有時間，我們無法判斷順序；<br>而如果兩者都不穩定，精確一次便無從談起。</p><p>於是，一個更根本的問題浮現：當我們同時面對這三個核心挑戰時，一個完整的 Stream Processing Engine，究竟是如何被建構出來的？</p><p>三個核心挑戰，並不是在同一時間被解決的。它們在不同的技術節點之間反覆拉扯，答案最終在 Apache Flink 成形。</p><p>2013 年，Google MillWheel 嘗試從工程層面出發，第一次正面迎戰精確一次與狀態的問題，並開始處理時間所帶來的實務挑戰。然而，在這個階段，時間仍然只是被使用的工具，而不是被理解的概念。MillWheel 已經具備 stateful processing 與 exactly-once 的雛形，並引入 low watermark 等機制，使系統能夠在現實環境中運作。這些設計，為後來 Flink 在 checkpoint、state 管理，以及容錯機制上的實作奠定了工程基礎，但仍缺乏一套完整的時間語意，使系統難以精確推理結果的正確性。</p><p>2015 年，Google Dataflow 提出了一個截然不同的觀點：時間不再只是輔助計算的參數，而是整個系統的核心語意。Event Time、Watermark 等概念，使我們第一次能夠在理論上定義「正確的結果」。Flink 後來的 event-time processing、watermark 機制與 window semantics，正是在這樣的思想下逐步建立起來，使 streaming 計算不再只是即時處理資料，而是能夠對結果的正確性進行推理與保證。</p><p>2016 年，Apache Beam 則將 Dataflow 轉化為一種可被撰寫、可被移植的程式模型，讓這些原本抽象的概念，成為工程師可以直接操作的語言。</p><p>而故事並沒有停在這裡。</p><p>受 Dataflow 啟發，接下來的 Flink Dynamic Tables 和《One SQL to Rule Them All》這篇論文讓整個資料世界開始重新理解：</p><ul><li>stream</li><li>batch</li><li>table</li><li>SQL</li></ul><p>這些抽象概念彼此之間的關係。</p><p>這段發生於 2013 到 2019 年之間的歷史，交織著工程底層實作、理論、模型與落地等不同面向，是一段人類逐漸學會在「不確定的時間流」中建立確定性的過程。</p><p>而在這段歷史之中，三次極其重要的「抽象融合」也悄悄發生。我將它們作為彩蛋，散落於整個篇章之中，歡迎讀者自行尋找。</p><h3>MillWheel: The Battle for Correctness (2013)</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/569/1*xTfIX5K63MfHfmMHSWeHwA.png" /></figure><h4>Introduction</h4><p>在 2010 年代初期，streaming 系統已經開始被廣泛應用。從 Apache S4 到 Apache Storm，工程師已經可以將資料建模為持續流動的事件，並透過分散式系統即時地進行處理。</p><p>然而，這些系統所解決的，多半是「如何讓資料持續流動」的問題。當資料可以被即時處理之後，一個更根本的挑戰開始浮現：在面對機器失敗、網路延遲與資料亂序的現實環境下，我們是否仍然能夠得到正確的結果？</p><p>MillWheel 正是在這樣的背景下誕生。作為 Google 內部用於大規模流式資料處理的系統，MillWheel 並不只是關注吞吐量或延遲，而是將焦點放在一致性（consistency）與正確性（correctness）上。</p><p>在其設計中，資料被建模為持續流動的 record，系統以一張分散式的資料流圖來組織計算，並支援 per-key 的狀態管理、細粒度的 checkpoint，以及對使用者透明的 exactly-once 處理語意。這些能力，使得 streaming 系統第一次能夠在實務上同時兼顧即時性與正確性。</p><p>也正因如此，MillWheel 並不是另一個「更快的 streaming 系統」，而是第一次嚴肅地回答了一個問題：當資料持續流動、且世界充滿不確定性時，我們如何仍然建立一個可被信任的計算系統？</p><h4>Components</h4><p><strong>Computation</strong></p><p>在 MillWheel 中，整個系統被建模為一張持續運作的資料流圖，而 Computation，便是這張圖中的基本節點。每一個 Computation 代表一段使用者定義的處理邏輯。它從一個或多個 stream 接收資料，經過內部的運算後，再將結果輸出到其他 stream，形成一條不斷流動的處理管線。</p><p>從結構上來看，一個 Computation 包含三個核心要素：輸入的資料流、輸出的資料流，以及節點內部的處理邏輯。這樣的設計，使 Computation 在概念上接近於 Apache Storm 中的 Bolt，或 Apache S4 中的 Processing Element。</p><p>然而，MillWheel 並不只是重現這些早期系統的設計，而是進一步將 state、fault tolerance 與一致性語意納入 Computation 的執行模型之中，使其成為一個可以支撐正確性保證的運算單位。</p><p><strong>Stream</strong></p><p>在這張資料流圖中，Stream 扮演的是節點之間的連結。它描述了資料從一個 Computation 流向另一個 Computation 的方向，也構成了整個系統的運作結構。一個 Computation 可以訂閱多個 stream，並同時將結果輸出到不同的 downstream 節點。這些 stream 並不是靜態的資料集合，而是持續流動的 record 序列，代表著系統中不斷發生的事件。</p><p>與傳統批次系統中「先儲存、再處理」的模式不同，Stream 中的資料在產生的同時就被消費與轉換。資料不再等待，而是持續流動。也正因如此，Stream 不只是資料的載體，它同時定義了整個系統的時間特性與處理節奏。</p><p><strong>Key</strong></p><p>如果說 Stream 定義了資料如何流動，那麼 Key 則決定了資料如何被理解與計算。在 MillWheel 中，每一筆資料都被表示為一個三元組：（key, value, timestamp）。其中，key 是用來分群與定位資料的核心維度。</p><p>與 Apache Storm 或 Apache S4 不同，MillWheel 將 key 的決定權交給資料的消費端。每一個 Computation 都可以針對輸入的 stream，自行定義 key 的萃取方式。這意味著，同一條資料流，可以被不同的 Computation 以不同的方式解讀與聚合，而不需要複製成多條幾乎相同的 stream。資料本身保持不變，但其意義會隨著不同的 key 被重新詮釋。</p><p>更重要的是，Key 同時也是計算的基本單位。系統會針對每一個 key 維護對應的 state，並保證同一個 key 的資料會依序處理，而不同 key 之間則可以平行執行。</p><p>在這樣的模型下，stateful computation 不再是額外的機制，而是自然地融入資料流之中。</p><p><strong>Persistent State</strong></p><p>如果說 Computation、Stream 與 Key 定義了資料如何流動與被分群，那麼 Persistent State，則決定了系統是否能夠「記住」過去。在 MillWheel 中，每一個 Computation 都可以為每一個 key 維護對應的狀態。這些狀態並不是暫存在記憶體中的中間結果，而是被持久化儲存（persistent），並與系統的容錯機制緊密結合。</p><p>具體來說，當一筆資料被處理時，系統會同時更新對應 key 的 state，並將這些變更納入 checkpoint。這些 checkpoint 是細粒度的，意味著系統不需要定期暫停整個資料流來進行快照，而是能夠在資料持續流動的過程中，逐步地記錄狀態的變化。</p><p>這樣的設計，使得在機器失敗或任務重啟時，系統可以從最近的狀態恢復，並避免重複計算所帶來的不一致問題。對使用者而言，這提供了一種近似 exactly-once 的處理語意 — — 即使底層發生重試，計算結果仍然保持一致。</p><p>更重要的是，state 並不是附加在系統之上的輔助功能，而是整個計算模型的一部分。每一個 Computation 的邏輯，都可以自然地建立在 state 之上，使得像是計數、聚合、去重等操作，成為 streaming 計算的基本能力，而不再需要額外的外部系統來支援。</p><p><strong>Low Watermarks</strong></p><p>然而，光有 state 並不足以解決 streaming 中的所有問題。當資料以非同步的方式到達系統時，一個更困難的問題隨之出現：我們如何知道「某個時間點之前的資料已經到齊」？</p><p>在 MillWheel 論文中，這個概念被稱為 Low Watermark，用來強調它代表的是 event time progress 的「下界估計（lower bound estimate）」。後來在 Dataflow 與 Flink 等系統中，社群逐漸簡稱為 watermark。</p><p>low watermark 可以被理解為系統對資料進度的一種估計：當 watermark 推進到某個 timestamp 時，系統便認為所有早於這個時間點的資料，已經被接收並處理完成。</p><p>這樣的機制，使得系統即使在面對 out-of-order data 時，仍然可以進行基於時間的推理。例如，一個 Computation 可以在 watermark 超過某個時間點後，對該時間區間的資料進行聚合，或輸出最終結果，而不需要等待所有資料嚴格依序到達。</p><p>然而，在 MillWheel 中，watermark 仍然是一種工程上的進度指標，而不是一套完整的時間語意模型。它提供了一種實務上可行的方式，讓系統能夠在不確定的資料到達順序中繼續前進，但並未嚴格定義在何種情況下結果應被視為最終且正確。</p><p>這也意味著，雖然 MillWheel 已經開始觸及「時間」這個問題，但對於時間如何影響計算結果，仍缺乏一套可以被形式化推理的框架。</p><p><strong>Timers</strong></p><p>如果說 Persistent State 讓系統能夠記住過去，Low Watermark 讓系統能夠推進時間，那麼 Timers，則讓系統開始對時間做出回應。</p><p>在 MillWheel 中，每一個 Computation 都可以為特定的 key 設定 timer。這些 timer 與 key 的 state 綁定，並以 timestamp 作為觸發條件。當系統的時間進度推進到指定的時間點時，對應的 timer 便會被觸發，進而執行使用者定義的邏輯。</p><p>這樣的機制，使得 Computation 不再只是被動地等待資料到達，而是可以在「某個時間點」主動執行操作。例如，在進行時間區間的聚合時，系統可以在某個時間點觸發計算，輸出該時間範圍內的統計結果，而不需要等待一個明確的結束訊號。</p><p>更重要的是，timer 的觸發與 state 的更新，是在同一個一致性模型之中運作的。也就是說，timer 所觸發的操作，會與資料處理一樣，被納入系統的容錯與重試機制中，使其在面對失敗時，仍然能夠保持結果的一致性。</p><p>然而，在 MillWheel 中，timer 仍然是一種以工程為導向的機制。它提供了一種讓系統在特定時間點執行邏輯的能力，但並未與 watermark、window 等概念形成一套完整的時間語意模型。換句話說，系統可以在「某個時間點」做出反應，但對於「這個時間點代表什麼」，以及「結果何時算是最終」，仍缺乏一套可以被清楚推理的框架。</p><p>為了理解 MillWheel 中資料究竟是如何流動的，我們可以用一個簡單的廣告點擊統計系統作為例子。</p><h4>An Example: Counting Ad Clicks in Real Time</h4><p>假設我們希望即時統計每一則廣告在每分鐘內的點擊次數，並在每分鐘結束後，將結果輸出到下游系統。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FVDX5fPycE39cVnO5lywQg.png" /></figure><p><strong>Step 1: Event Enters the Stream</strong></p><p>現在，系統收到一筆事件：</p><ul><li>(AdID = “AD_42, “UserID = “U1007”, EventTime = 2026–05–23 10:00:12)</li></ul><p>這筆資料首先進入一條 Stream：</p><ul><li>click_stream</li></ul><p>在 MillWheel 中，Stream 是 Computation 與 Computation 之間的資料流向。</p><p><strong>Step 2: Computation Extracts the Key</strong></p><p>接著，ClickCounter 這個 Computation 訂閱了這條 stream。Computation 定義為：</p><ul><li>key_extractor = AdID</li></ul><p>因此，剛剛那筆事件會被解析為：</p><ul><li>Key = “AD_42”</li><li>Value = click_event</li><li>Timestamp = 2026–05–23 10:00:12</li></ul><p>這意味著，所有屬於 AD_42 的事件，都會被路由到相同的邏輯 state 中。</p><p><strong>Step 3: Persistent State Is Updated</strong></p><p>此時，ClickCounter 會讀取對應 key 的 Persistent State：</p><ul><li>state[“AD_42”][“10:00”]</li></ul><p>假設目前 state 中的值是：</p><ul><li>count = 57</li></ul><p>收到新事件後：</p><ul><li>count = 58</li></ul><p>這個更新不會只存在記憶體中，而是會被持久化儲存。即使 worker 在下一秒失敗，系統仍然可以從 persistent state 恢復計算。論文中提到，MillWheel 的持久化能力建立在 Google 內部的 distributed storage infrastructure 之上，其中包含 Bigtable 等系統。</p><p><strong>Step 4: Low Watermark Advances</strong></p><p>隨著更多事件持續到達，這條 stream 在這個 Computation 中的 Low Watermark，也會逐漸向前推進。</p><p>假設目前這個 Computation 已經觀察到以下事件：</p><ul><li>10:01:05</li><li>10:01:08</li><li>10:01:10</li></ul><p>系統目前所觀察到的最大 event timestamp 為：</p><ul><li>10:01:10</li></ul><p>然而，MillWheel 並不會立刻將 watermark 推進到 10:01:10。因為系統知道，在分散式環境中，資料可能仍然因為網路延遲、partition skew，或 upstream backpressure 而晚到。</p><p>假設系統根據歷史統計，認為：</p><ul><li>大部分事件的延遲不會超過 10 秒</li></ul><p>那麼它可能會保留：</p><ul><li>10 秒 safety margin</li></ul><p>於是，這個 Computation 的 Low Watermark 便會被推進到：</p><ul><li>2026–05–23 10:01:00</li></ul><p>這代表系統認為：</p><ul><li>未來已經不太可能再收到 timestamp 小於 10:01:00 的事件。</li></ul><p>注意，Low Watermark 並不是資料本身攜帶的欄位，也不是整個系統共享的一個全域時間。它更像是系統對 event-time progress 的動態估計。</p><p>在 MillWheel 中，watermark 會隨著 stream 在不同 Computation 之間逐步傳遞。每個 Computation 都會根據自己所接收到的 input stream，維護對應的 watermark 狀態。</p><p>如果某個 Computation 同時依賴兩條 input streams：</p><ul><li>stream_A watermark = 10:01:00</li><li>stream_B watermark = 10:00:40</li></ul><p>那麼實際的 input watermark，為了等待最慢的那條 stream，通常會取兩者中的較小值：</p><ul><li>min(10:01:00, 10:00:40) = 10:00:40</li></ul><p>而 watermark 的推進速度，本質上是一種 correctness 與 latency 之間的 trade-off。例如：</p><ul><li>safety margin = 2 秒，watermark = 10:01:08，結果 latency 很低，但 late data 風險高</li><li>safety margin = 30 秒，watermark = 10:00:40，結果 correctness 較高，但 latency 增加</li></ul><p>除了 safety margin 之外，watermark 的行為也會受到其他因素影響。例如：</p><ul><li>allowed lateness estimate：系統若允許 late data &lt;= 1 minute，則 watermark 通常會更保守。</li><li>source delay model：若 source 位於跨洲資料中心，系統可能預期 network delay ≈ 15 秒，則 watermark 推進也會較慢。</li><li>out-of-order 程度：若事件經常亂序，則系統通常需要保留更大的 safety margin。</li><li>propagation policy：某些系統可能每 100 ms 傳播一次 watermark；有些則可能每 5 秒才更新一次 downstream watermark。這會影響 timer 觸發與 window completion 的即時性。</li><li>watermark 更新頻率：即使 watermark estimate 本身相同，若系統每 10 秒才更新一次 watermark，下游仍然會感受到額外延遲。</li></ul><p>總結來說，Low Watermark 是系統對時間的信心。</p><p><strong>Step 5: Timer Fires</strong></p><p>然而，僅僅有 state 與 watermark，系統仍然不知道「什麼時候該輸出結果」。因此，在一開始處理資料時，這個 Computation 便已經為對應的時間窗口註冊了一個 timer：</p><ul><li>window = [10:00:00, 10:01:00)</li><li>fire_time = 10:01:00</li></ul><p>隨著前一節中的 Low Watermark 持續向前推進：</p><ul><li>watermark = 10:01:00</li></ul><p>這個 timer 終於被觸發（fire）。此時，Computation 會讀取對應 key 的 Persistent State：</p><ul><li>state[“AD_42”][“10:00”] = 58</li></ul><p>並產生一筆輸出 record：</p><ul><li>(“AD_42”, “10:00”, 58)</li></ul><p>這筆 output record 會被寫入 downstream stream，並傳遞給下一個 Computation。後續系統可能再將其聚合、寫入分析儲存，或更新即時 dashboard。</p><p>在這個例子中，它代表：</p><p>廣告 AD_42 在 10:00 ~ 10:01 之間，共收到 58 次點擊。</p><p>在 MillWheel 中，Timer 並不是單純依賴 wall clock 的鬧鐘，而是與 event-time progress 緊密耦合。真正決定 timer 是否觸發的，不是機器現在幾點，而是 watermark 是否已經推進到對應時間。</p><p>這種設計非常關鍵。因為在 streaming 世界裡，資料往往會亂序到達。如果系統只依賴 processing time，許多較晚到達、但 event time 屬於舊 window 的資料，便可能被錯誤地排除。</p><p>透過 watermark 與 timer 的結合，MillWheel 第一次讓系統能夠：</p><ul><li>等待「大部分應該到達的資料」</li><li>同時避免無限期等待</li><li>並在 correctness 與 latency 之間取得平衡</li></ul><p><strong>Step 6: Delivery Guarantees and Strong Productions</strong></p><p>當 timer 被觸發後，系統終於準備將結果送往下游。然而，在 MillWheel 中，「計算完成」並不代表「結果已經安全」。</p><p>假設此時系統產生了以下 output record：</p><ul><li>(“AD_42”, “10:00”, 58)</li></ul><p>這筆資料接著準備被寫入 downstream stream。但如果 worker 在送出過程中突然失敗，例如：</p><ul><li>at 10:01:02, worker crash</li></ul><p>此時，系統可能陷入一個危險狀態：</p><ul><li>output 已經送出</li><li>但 upstream 尚未收到 ACK</li><li>recovery node 不知道 downstream 是否已經收到資料</li></ul><p>如果系統直接重新計算並再次送出 output，就可能產生 duplicate result；但如果選擇不重送，又可能造成資料遺失。</p><p>為了解決這個問題，MillWheel 論文引入了 <em>Strong Productions</em>。在這個模型中，output 並不會在計算完成後立刻送出，而是會先被 checkpoint 到 persistent backing store。</p><ul><li>checkpoint: (“AD_42”, “10:00”, 58)</li></ul><p>只有當 checkpoint 完成之後，系統才真正將 output 發送到 downstream stream。這意味著 downstream output 本身，也成為系統 state 的一部分。因此，即使 worker 在 transmission 過程中失敗，新的 worker 也不需要重新執行 window aggregation，而是可以直接 replay 已經 checkpoint 的 output。這正是 Strong Productions 的核心：系統保證「輸出的事實」可以被重建。</p><p>相對地，如果系統沒有先 checkpoint output，而只是直接將資料送往下游，那便屬於一種較弱的 delivery model，可以被視為 Weak Productions。</p><p>在 Weak Productions 中：</p><ul><li>output 不一定可 replay</li><li>failure 後可能需要重新計算</li><li>downstream 可能看到 duplicate outputs</li><li>correctness 更依賴 application-level deduplication</li></ul><p>這也是為什麼 MillWheel 能夠逐步逼近 exactly-once semantics，而早期 stream processing engine 往往只能做到 at-least-once。</p><p>而 checkpoint 本身，也與 time window 有著微妙但重要的關係。例如可以這樣設置：</p><ul><li>window = 1 minute</li><li>checkpoint interval = 5 seconds</li></ul><p>window 決定的是「哪些事件屬於同一組計算」，而 checkpoint interval 決定的則是「系統多久保存一次目前進度」</p><p>較短的 checkpoint interval：</p><ul><li>recovery 更快</li><li>duplicate risk 更低</li><li>但 storage 與 I/O 成本更高</li></ul><p>較長的 checkpoint interval：</p><ul><li>overhead 較低</li><li>但 failure 後可能需要 replay 更多 state changes</li></ul><p>因此，checkpoint 本質上並不是時間語意的一部分，而是 fault tolerance 的工程機制。</p><h3>Dataflow: Rethinking Time in Data (2015)</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/656/1*v8PZhtUcwJIQfE_k8YvreA.png" /></figure><h4>Introduction</h4><p>MillWheel 幾乎解決了 streaming 系統中所有工程層面的困難。</p><p>資料可以被可靠地處理，狀態可以被持久化，失敗可以被重試，輸出也可以透過 Strong Productions 保證一致性。Low Watermark 與 event-time-aware timer 的引入，更讓系統第一次真正開始理解 event time，而不只是單純依賴 processing time。</p><p>從工程的角度來看，一個完整的 stream processing engine，似乎終於開始成形。</p><p>然而，Dataflow 所做的，並不是再發明另一個新的 streaming system，而是進一步將這些原本分散在系統中的概念，抽象成一套完整的時間語意模型（Time Semantics）。此後，時間不再只是 timer 背後的隱藏機制，也不只是系統內部對 progress 的估計，而成為開發者可以直接描述與操作的計算語意。</p><h4>Time Semantics</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lhAROWDS8QOEeD7vyX-3hA.png" /></figure><p>Dataflow 的 Time Semantics，將原本隱含在系統中的「時間」，拆解成一組可以被明確描述的語意，可以同時回答四個問題：</p><ul><li>我們在計算什麼（What）</li><li>在哪個時間範圍內計算（Where）</li><li>什麼時候輸出結果（When）</li><li>結果如何隨時間修正（How）</li></ul><p>這四個維度，構成了 Google Dataflow 對 streaming 計算的完整描述。</p><p>過去的 streaming 系統，往往將時間視為系統內部的實作細節；而 Dataflow，則第一次將時間提升為一級語意（first-class semantics）。時間不再只是 timer 或 scheduler 背後的隱藏機制，而成為開發者可以直接描述與操作的計算模型。</p><p>值得注意的是，Dataflow paper 本身仍然偏向概念與語意模型的建立；真正將這些抽象概念轉化為工程 API 的，則是後來的 Apache Beam。</p><p>因此，接下來在介紹 What、Where、When 與 How 這四個維度時，我除了會說明 Dataflow paper 中的核心思想，也會同時搭配 Beam 的 API 與 programming model，讓這些原本抽象的時間語意，能夠更具體地被理解。</p><p><strong>Transform: Defining What</strong></p><p>在 Dataflow 中，所有資料處理都建立在兩個核心 primitive 之上：</p><ul><li>ParDo</li><li>GroupByKey</li></ul><p>而這些 primitive，在 Dataflow 裡被統稱為 Transform，是一種描述「資料應該如何被轉換」的計算步驟。它可以是：</p><ul><li>對每筆事件進行處理</li><li>對資料重新分組</li><li>對多筆資料進行 aggregation</li><li>對資料進行 filter、join 或 combine</li></ul><p>整個 Pipeline，本質上就是由一連串 Transform 所構成的計算圖（computation graph）。其中，ParDo 負責描述逐筆事件的轉換邏輯，例如：</p><ul><li>parse event</li><li>extract fields</li><li>filter invalid records</li></ul><p>而 GroupByKey，則負責將具有相同 key 的資料聚合在一起。例如：</p><ul><li>group clicks by ad_id</li></ul><p>以下就是在 Apache Beam API 的體現：</p><pre># ParDo<br>events | beam.Map(parse_event)<br><br># GroupByKey<br>events<br>| beam.Map(lambda x: (x.ad_id, 1))<br>| beam.GroupByKey()</pre><p>這些操作並不是新的概念，MapReduce 裡就有。現在我們要處理的棘手問題在於：當資料是無界、亂序且持續到達時，GroupByKey 應該在什麼時候被視為完成？</p><p>在 batch 世界裡，這個問題並不存在。系統只需要等待所有資料到齊，再進行 aggregation 即可。但在 streaming 世界中，「所有資料」本身可能永遠不存在。</p><p>正因如此，我們需要定義 Where。</p><p><strong>Windowing: Defining Where</strong></p><p>Windowing 回答的是：我們應該在哪個時間範圍內進行計算？</p><p>在無界資料流中，我們無法等待「所有資料」到齊，因此必須主動切分資料。Windowing 提供了一種方式，將連續不斷的事件，劃分為一段一段有限的時間區間。</p><p>這些區間可以是固定大小的時間窗口（Fixed Window），例如：</p><ul><li>每 1 分鐘統計一次廣告點擊數</li></ul><p>也可以是滑動窗口（Sliding Window）：</p><ul><li>每 5 分鐘統計一次，但每 1 分鐘更新結果</li></ul><p>甚至可以是依據事件分佈動態形成的 Session Window。例如：</p><ul><li>若使用者 30 分鐘內沒有新的操作，則視為 session 結束</li></ul><p>透過這樣的切分，原本無界的資料流，得以在邏輯上被轉換為一系列有限集合，使得像 GroupByKey 這樣原本只能在 batch 系統中完成的操作，也能重新在 streaming 世界中成立。</p><p>以下是三種 windows 在 Beam API 的實現：</p><pre># 60 sec Fixed Window<br>beam.WindowInto(<br>   beam.window.FixedWindows(60)<br>)<br><br># Sliding Window<br># 300 sec window, slide every 60 sec<br>beam.WindowInto(<br>   beam.window.SlidingWindows(<br>       size=300,<br>       period=60<br>   )<br>)<br><br># Session Window<br># 1800 sec inactivity gap<br>beam.WindowInto(<br>   beam.window.Sessions(1800)<br>)</pre><p>除此之外，Beam 也允許開發者自訂 WindowFn，建立自己的 window assignment logic。</p><p>值得注意的是，Window 並不一定只能建立在 event time 之上。在 Dataflow 與 Beam 的模型中，Windowing strategy 與 time domain，其實是兩個彼此獨立的維度。最常見的時間語意包括：</p><ul><li>Event Time</li><li>Processing Time</li><li>Ingestion Time</li></ul><p>其中：</p><ul><li>Event Time 指事件真正發生的時間</li><li>Processing Time 指 worker 實際觀察到事件的時間</li><li>Ingestion Time 則是資料進入 streaming infrastructure 的時間</li></ul><p>但更深一層來看，Dataflow 的時間模型其實並不限於 wall clock。理論上，任何能夠描述「系統進度（progress）」的 logical coordinate，都可以成為時間語意的一部分。例如：</p><ul><li>Database CDC 的 SCN / LSN / binlog offset</li><li>ML training 中的 epoch / global step</li><li>Trading system 中的 market tick sequence</li><li>distributed system 中的 logical timestamp</li></ul><p>Dataflow 真正革命性的地方在於，它開始暗示「時間」不一定來自時鐘，也可以源於任何描述系統進度的座標。</p><p>而當系統開始採用 event time 時，便需要 Watermark 這種 heuristic progress estimation，也就是系統對於「資料完整度」的推測。在 Beam 中，開發者可以直接定義 watermark strategy。例如：</p><pre># watermark = max_event_time_seen - 30 sec<br>WatermarkStrategy<br>    .forBoundedOutOfOrderness(<br>        Duration.ofSeconds(30)<br>)</pre><p>而更進一步地，Beam 與 Flink 也允許開發者自訂 watermark generator。從此，watermark 不再只是 runtime 內部的隱藏機制，而開始成為可被工程師直接操作的 programming abstraction。</p><p>理論上，不同的：</p><ul><li>window strategy</li><li>time domain</li><li>watermark policy</li></ul><p>可以形成大量組合。例如：</p><ul><li>Fixed Window + Event Time + (max_event_time — delay)</li><li>Sliding Window + Event Time + bounded lateness</li><li>Session Window + Event Time + heuristic watermark</li><li>Fixed Window + Processing Time + system clock</li></ul><p>但並不是所有組合都有道理，例如以下設置或許無法跟老闆交代：</p><ul><li>Session Window + Processing Time</li></ul><p>因為 Session 本質上是在描述 user behavioral continuity，所以更適合建立在 Event Time 之上。</p><p>決定資料該如何分組計算後，下一步須要定義 When。</p><p><strong>Triggering: Defining When</strong></p><p>Trigger 回答的是：我們應該在什麼時候輸出結果？</p><p>在現實世界中，資料可能延遲、亂序，甚至在很久之後才到達。Trigger 的引入，讓輸出從一次性的事件，轉變為一個可被控制的過程。</p><p>在 Beam 中，default trigger 會在 watermark 判斷某個 window 的資料大致到齊時，輸出該 window 的結果；如果沒有額外設定 late trigger，後續 late data 可能被丟棄。Beam 文件也明確說明，trigger 的作用就是決定每個 window 何時 emit aggregation result，這些被輸出的結果稱為 pane。</p><p>最基本的，是 Event-Time Trigger。當 watermark 超過 window 結束時間時，系統便認為大部分屬於這個 window 的資料，應該都已經到達。</p><p>例如：</p><ul><li>window = [10:00, 10:01)</li><li>watermark = 10:01</li></ul><p>此時，系統便可以輸出：</p><ul><li>AD_42 = 58 clicks</li></ul><p>在 Beam Python API 中，可以寫成：</p><pre>from apache_beam import window<br>from apache_beam.transforms import trigger<br><br>events | beam.WindowInto(<br>    window.FixedWindows(60),<br>    trigger=trigger.AfterWatermark()<br>)</pre><p>然而，watermark 永遠只是估計，而不是絕對保證。因此，Dataflow 也允許 Late Trigger 的存在。假設在 watermark 已經推進之後，又有一筆較晚到達的事件：</p><ul><li>Another event with timestamp = 10:00:42</li><li>arrival time = 10:03:15</li></ul><p>系統仍然可以重新觸發 trigger，輸出更新後的結果：</p><ul><li>AD_42 = 59 clicks</li></ul><p>在 Beam 中，可以把 late trigger 接在 AfterWatermark 後面：</p><pre># watermark 到達後先輸出一次；若 5 分鐘內還有 late data 到達，每來一筆 late data，就可以再次輸出更新結果。<br>events | beam.WindowInto(<br>    window.FixedWindows(60),<br>    trigger=trigger.AfterWatermark(<br>        late=trigger.AfterCount(1)<br>    ),<br>    allowed_lateness=300<br>)</pre><p>除了 event-time-based trigger 之外，Dataflow 論文也提到 Processing-Time Trigger。它關心的不是 event time 是否完整，而是希望降低使用者看到結果的 latency。例如：</p><ul><li>每 10 秒先輸出一次目前結果</li></ul><p>在 Beam 中，可以寫成：</p><pre>events | beam.WindowInto(<br>    window.FixedWindows(60),<br>    trigger=trigger.AfterProcessingTime(10)<br>)</pre><p>此外，系統也可以使用 Data-Driven Trigger，在高流量系統中，這種方式可以避免長時間沒有輸出。例如：</p><ul><li>每累積 1000 筆事件就輸出一次</li></ul><p>在 Beam 中，可以寫成：</p><pre>events | beam.WindowInto(<br>    window.FixedWindows(60),<br>    trigger=trigger.AfterCount(1000)<br>)</pre><p>更重要的是，這些 Trigger 並不是互斥的。Beam 支援把 event-time、processing-time、data-driven trigger 組合起來。AfterWatermark 本身也支援 early 與 late trigger 參數：early 用來在 watermark 到達前提早輸出，late 用來在 watermark 到達後處理 late data。例如：</p><pre># 先每 10 秒輸出一次近似值, watermark 到達後輸出正式結果, late data 到達時再重新修正<br>events | beam.WindowInto(<br>    window.FixedWindows(60),<br>    trigger=trigger.AfterWatermark(<br>        early=trigger.AfterProcessingTime(10),<br>        late=trigger.AfterCount(1)<br>    ),<br>    allowed_lateness=300<br>)</pre><p>不同 trigger 的選擇，本質上是在 trade-off：</p><ul><li>越早輸出：latency 越低，但結果越可能不完整</li><li>越晚輸出：correctness 越高，但使用者等待越久</li><li>越常輸出：互動性越好，但 cost 與 downstream 負擔越高</li></ul><p>這也代表我們不再需要等待一個「完美時刻」才輸出結果；而是可以先提供較快但較不完整的答案，再逐步逼近更正確的結果。而 correctness、latency 與 cost，也第一次被放到同一個模型中共同權衡。</p><p>決定好該在何時輸出結果後，下一步必須決定 How。</p><p><strong>Accumulation: Defining How</strong></p><p>在傳統 batch 系統中，結果通常只有一次輸出，因此「修正結果」這件事幾乎不存在。但在 Dataflow 中，Trigger 可以讓同一個 window 被多次觸發，系統可能產生：58 clicks, 59 clicks, 61 clicks … 這些同屬一個 window 的 pane。</p><p>因此，系統必須進一步定義：</p><ul><li>新結果應該覆蓋舊結果？還是累積在舊結果之上？是否要撤回舊結果？</li></ul><p>論文中，主要描述了三種核心 accumulation mode。</p><p><strong><em>Discarding Mode</em></strong></p><p>每次 trigger 之後，系統都會丟棄先前已經輸出的 accumulation state。例如：</p><p>第一次 trigger：</p><ul><li>58 clicks</li></ul><p>之後又有 late data 到達：</p><ul><li>+1 click</li></ul><p>下一次 trigger 時，系統只輸出：</p><ul><li>1 click</li></ul><p>每次 output，只代表「新增變化（delta）」。在 Beam 中，可以寫成：</p><pre>beam.WindowInto(<br>    beam.window.FixedWindows(60),<br>    trigger=trigger.AfterWatermark(<br>        late=trigger.AfterCount(1)<br>    ),<br>    accumulation_mode=<br>        trigger.AccumulationMode.DISCARDING<br>)</pre><p>在 Discarding Mode 中，trigger fire 之後，先前 pane 的 accumulation state 可以被丟棄。系統不需要長期保留 58 clicks 這類舊 aggregation state，而只需要處理新的變化量。因此：</p><ul><li>retained state 較小</li><li>recomputation 成本較低</li><li>network traffic 較低</li></ul><p>這種模式通常較適合：</p><ul><li>high-throughput pipeline</li><li>append-only downstream</li><li>monitoring system</li><li>對 storage cost 敏感的場景</li></ul><p>但相對地：</p><ul><li>downstream 必須自行累積結果</li><li>consumer 必須理解 pane 是 delta</li><li>downstream logic 會變得較複雜</li></ul><p><strong><em>Accumulating Mode</em></strong></p><p>而在 Accumulating Mode 中，系統則會保留先前的 accumulation state。</p><p>第一次 trigger：</p><ul><li>58 clicks</li></ul><p>late data 到達後：</p><ul><li>+1 click</li></ul><p>下一次 trigger：</p><ul><li>59 clicks</li></ul><p>每一次 output，都代表目前系統所相信的「最新真相」。在 Beam 中，可以寫成：</p><pre>beam.WindowInto(<br>    beam.window.FixedWindows(60),<br>    trigger=trigger.AfterWatermark(<br>        late=trigger.AfterCount(1)<br>    ),<br>    accumulation_mode=<br>        trigger.AccumulationMode.ACCUMULATING<br>)</pre><p>真正重要的是，系統必須保留完整 accumulation state。因為 59 clicks 並不是憑空產生的。系統必須記得 previous aggregation = 58，才能重新計算新的結果。而在其他 streaming 用例中，保留的 state 可能包括：</p><ul><li>Top-K aggregation</li><li>Join state</li><li>distinct set</li><li>session state</li><li>partial aggregation hash table</li></ul><p>因此，Accumulating Mode 往往意味著：</p><ul><li>更大的 retained state</li><li>更高的 recomputation cost</li><li>更大的 output volume</li><li>更高的 network traffic</li></ul><p>但相對地，它對 downstream system 也更加直觀。因為 downstream 不需要自行維護 state，每一次 output，都可以直接被視為 current truth so far。因此，Accumulating Mode 通常較適合：</p><ul><li>dashboard</li><li>BI system</li><li>streaming SQL</li><li>materialized view</li><li>user-facing analytics</li></ul><p>總結來說，Discarding 與 Accumulating 的選擇一種 trade-off：</p><ul><li>Discarding：較低 cost，但對 downstream 來說較複雜</li><li>Accumulating：較高 cost，但對 downstream 來說較直觀</li></ul><p><strong><em>Accumulating and Retracting Mode</em></strong></p><p>前面兩種模式，對於像 click count 這種單純累加的 aggregation 已經足夠。但有些 query 的結果並不是「只會增加」的，最典型的例子是 Top-K。</p><p>假設我們正在計算：</p><ul><li>每分鐘點擊數最高的前 3 個廣告</li></ul><p>一開始，window [10:00, 10:01) 的結果是：</p><ul><li>Top 3 = A, B, C</li></ul><p>如果後來一筆 late data 到達，使得廣告 D 的點擊數超過 C，新的結果就變成：</p><ul><li>Top 3 = A, B, D</li></ul><p>這時候，問題不只是「多了一個 D」，而是「C 必須被移出結果」。</p><p>Discarding Mode 只能輸出新的 delta，但無法清楚表達 C 已經失效。Accumulating Mode 可以輸出新的完整結果 A, B, D，但如果 downstream 不是覆蓋式儲存，而是把每次輸出都當成新的 row，就可能同時保留 A, B, C 與 A, B, D，造成語意混亂。</p><p>因此，這類 query 需要第三種模式：Accumulating and Retracting。它不只輸出新的 refinement，也明確撤回先前已經不再正確的結果。</p><p>需要注意的是，Beam 還沒實作 Retraction API，因此這次我們以 Flink SQL 來舉例 explicit retraction。假設我們寫：</p><pre>SELECT *<br>FROM (<br> SELECT<br>   ad_id,<br>   COUNT(*) AS clicks,<br>   ROW_NUMBER() OVER (<br>     ORDER BY COUNT(*) DESC<br>   ) AS rank_num<br> FROM clicks<br> GROUP BY ad_id<br>)<br>WHERE rank_num &lt;= 3;</pre><p>一開始，系統得到：</p><pre>A = 100 clicks<br>B = 90 clicks<br>C = 80 clicks</pre><p>因此輸出這些 changelog：</p><pre>INSERT (rank=1, A, 100)<br>INSERT (rank=2, B, 90)<br>INSERT (rank=3, C, 80)</pre><p>接著，一筆 late data 讓 D 的點擊數變成 85：</p><pre>D = 85 clicks</pre><p>新的 Top 3 變成：</p><pre>A = 100<br>B = 90<br>D = 85</pre><p>此時，Flink 不能只是輸出 D。它還必須告訴 downstream：原本排名第三的 C 已經不屬於結果。因此 changelog 就會變成：</p><pre>DELETE / RETRACT (rank=3, C, 80)<br>INSERT (rank=3, D, 85)</pre><p>如果排名也發生變動，例如 D 超過 B，則系統還需要撤回舊排名，再輸出新排名：</p><pre>UPDATE_BEFORE (rank=2, B, 90)<br>UPDATE_BEFORE (rank=3, C, 80)<br>UPDATE_AFTER (rank=2, D, 95)<br>UPDATE_AFTER (rank=3, B, 90)</pre><p>這就是 Accumulating and Retracting 的核心精神：系統一方面保留並更新目前最完整的結果，另一方面也明確撤回那些已經不再成立的舊結果。也因此，Top-K、Rank、Join、Distinct 這類 non-monotonic query，往往不能只靠單純累加來處理。它們需要一種能夠表達「舊結果失效」的語意，而這正是 Retraction 存在的理由。</p><h4>Unifying Batch and Streaming</h4><p>在傳統觀點中，batch 處理的是一個已經完成的資料集合，而 streaming 則處理持續到達的資料流。兩者在系統設計與實作上，看似有著本質上的差異。</p><p>如果我們將資料視為一個無界的資料流，那麼所謂的 batch，其實只是這個資料流在某一個時間範圍內的切片。換句話說，batch 並不是一種不同的資料型態，而是 streaming 在特定條件下的一種觀察方式。</p><p>而這樣的統一，正是透過：</p><ul><li>Window</li><li>Trigger</li><li>Accumulation</li></ul><p>這三個時間語意所完成的。從這個角度來看，streaming 與 batch 的差異，在於對這三種語意採取了不同策略。</p><p>在典型的 batch 系統中：</p><ul><li>Window 通常是一個具有明確邊界的完整資料集，例如：<br>2026–05–01 ~ 2026–05–31 的所有資料</li><li>Trigger 通常只會觸發一次，也就是：<br>等待所有資料完成後才輸出</li><li>Accumulation 則通常採取：<br>final immutable result，一旦輸出，結果便不再改變。</li></ul><p>因此，在 batch 的世界裡，時間被隱藏了，系統假設資料終究會完整到齊。而在 streaming 系統中，這三者則採取完全不同的策略。</p><ul><li>Window 變成持續移動、持續切分的時間範圍。</li><li>Trigger 不再只有一次，而可能：提前輸出近似值、watermark 到達時輸出正式結果、late data 到達後再次修正</li><li>Accumulation 也不再是 immutable final answer，而是一個 continuously refining result</li></ul><p>在這樣的模型裡，streaming 不再只是「即時版 batch」，反而成為更一般化、更接近真實世界的資料模型；而 batch，則只是 streaming 在資料已經靜止之後的一種觀察方式。這也意味著，我們不再需要為 batch 與 streaming 分別設計兩套完全不同的系統，而是可以在同一個時間語意模型之下，描述所有資料處理行為。</p><p>中國宋代詩人蘇軾曾寫下：『橫看成嶺側成峰，遠近高低各不同。』</p><p>而在西方物理學中，Quantum Mechanics 則揭示了另一種雙重性：粒子與波，並不是兩種不同的存在，而是同一個系統在不同尺度下的兩種表現。</p><p>batch 與 streaming 的關係，亦是如此。</p><h3>Apache Beam: A Unified Programming Model (2016)</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/482/1*Ias2on08WZzI3nThge33Pw.png" /></figure><h4>From Model to Language</h4><p>在 Dataflow 中，我們已經看見了一個關鍵轉變：資料處理不再只是操作資料，而是操作「時間中的資料」。Window、Trigger 與 Accumulation，讓我們第一次能夠精確描述在不完整世界中如何逐步逼近正確結果。</p><p>然而，這些概念該如何被工程師真正使用？</p><p>Apache Beam 的出現，正是為了回答這個問題。但要理解 Beam，我們必須先理解它的來源。</p><p>Beam 並不是一個憑空誕生的新系統，而是 Google 在建立 Dataflow 模型與 Cloud Dataflow 服務過程中的自然延伸。Dataflow 論文的作者群，同時也是這套系統的主要設計者。他們在內部實踐了這套模型之後，進一步將其抽象為一個通用的程式模型，並透過 Apache 基金會開源，最終形成了 Beam。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BFEskdPwMMFaD_t_BvfARg.png" /></figure><p>換句話說，Beam 並不是另一種資料處理理論，而是 Dataflow 的語言化版本。在這個語言之中，資料被抽象為 PCollection — — 一個可能是有限（bounded），也可能是無限（unbounded）的資料集合。所有的操作，則被表達為一系列 Transform，從最基本的 ParDo，到涉及跨資料關聯的 GroupByKey，再到描述時間語意的 Window 與 Trigger。</p><p>這樣的設計，帶來了一個深遠的改變：時間語意不再隱藏在系統內部，而是成為程式的一部分。工程師不再只是撰寫「要做什麼」，而是同時描述「在哪個時間範圍內做」、「什麼時候輸出」，以及「結果如何隨時間演化」。</p><p>當這些元素被串接在一起時，便形成了一條 Pipeline。</p><p>這樣的 Pipeline 並不是單純的執行流程，而是一種宣告式的描述。開發者所撰寫的，並不是「如何逐步執行」，而是「資料應該如何流動與被轉換」。實際的執行方式，則被延後到更底層的系統中決定。</p><p>這個設計帶來了一個關鍵改變：資料處理程式，第一次可以被視為一個與執行引擎解耦的邏輯結構。Pipeline 描述的是「做什麼」，而不是「怎麼做」。</p><h4>Write Once, Run Anywhere</h4><p>在 Apache Beam 中，Pipeline 並不是直接被執行的，而是交由一個稱為 Runner 的元件來運行。不同的 Runner，對應不同的執行引擎，例如：</p><ul><li>Flink</li><li>Spark</li><li>Google Cloud Dataflow</li></ul><p>理論上，同一份 Pipeline 可以在不同 Runner 上執行，而不需要修改程式碼。這樣的設計，提出了一個極具吸引力的承諾：Write Once, Run Anywhere。開發者不再需要為不同系統撰寫不同版本的資料處理邏輯，而是可以專注於描述：</p><ul><li>資料如何被轉換</li><li>時間如何被定義</li><li>結果如何被演化</li></ul><p>至於實際的執行方式，則交由底層 engine 負責。而這背後，其實隱含著更大的野心。Beam 試圖將資料處理本身，抽象成一個獨立於執行引擎之外的通用層：</p><ul><li>PCollection 描述資料</li><li>Transform 描述運算</li><li>Window 與 Trigger 描述時間語意</li></ul><p>某種程度上，這與編譯器世界中的 IR（Intermediate Representation）非常相似。程式不再直接綁定某一個 runtime，而是先被轉化為一種更高層的通用表示，再交由不同 backend 執行。</p><h4>Did Beam Win?</h4><p>Apache Beam 描繪出了 streaming 世界最完整的藍圖。然而，Beam 有像 Kubernetes 那樣統治世界嗎？答案其實非常耐人尋味。</p><p><strong>AWS (~30% Market Share)</strong></p><p>目前全球雲端市場市佔率最高的，仍然是 Amazon Web Services，長期維持在約 30% 左右。然而，在 AWS 的 streaming ecosystem 中，Beam 從未成為主流 abstraction。</p><p>雖然理論上 AWS 可以透過 Runner 支援 Beam，但實務上，多數團隊仍然直接使用：</p><ul><li>Apache Flink</li><li>Apache Spark</li></ul><p>工程師通常直接撰寫：</p><ul><li>Flink API</li><li>Spark Structured Streaming API</li></ul><p>而不是先透過 Beam abstraction，再交由 Runner 執行。原因其實非常現實。在 production 環境中，開發者最終仍然需要理解：</p><ul><li>checkpointing</li><li>backpressure</li><li>state backend</li><li>task scheduling</li><li>memory tuning</li></ul><p>這些 execution engine 的細節。而 Beam 的 abstraction，並沒有真正消除這些複雜度，只是將它們部分隱藏起來。</p><p><strong>Azure (~20–25% Market Share)</strong></p><p>第二大的雲廠商，則是 Microsoft Azure，市佔率大約落在 20% 到 25% 之間。Azure 同樣支援 Beam Runner，但 Beam 也從未成為其主要 streaming API。在 Azure ecosystem 中，更主流的仍然是：</p><ul><li>Spark Structured Streaming</li><li>Databricks</li><li>Apache Flink</li></ul><p>特別是在 Databricks 與 Spark 生態逐漸壯大之後，Spark Structured Streaming 幾乎成為 Azure data platform 的事實標準。因此，大部分工程團隊仍然傾向直接操作 execution engine，而不是建立在 Beam abstraction 之上。</p><p><strong>Google Cloud (~10–12% Market Share)</strong></p><p>真正將 Beam 視為核心 abstraction 的，其實只有市佔率大約為 10% 到 12% 的 Google Cloud。Google Cloud Dataflow 幾乎就是 Beam 的原生執行平台。Beam 中的：</p><ul><li>Pipeline</li><li>PCollection</li><li>Window</li><li>Trigger</li></ul><p>都能直接映射到 Dataflow runtime。</p><p>某種程度上，Beam 可以被視為 Google 將 Dataflow paper 工程化後對外公開的 programming model。在 Google Cloud 中，Beam 並不是附屬 API，而是整個 streaming abstraction 的核心。</p><h4>Abstraction vs Execution</h4><p>Beam 至今仍沒能成為主流，有兩個深層原因。</p><p>首先，Beam 出現得太早。在 2016 年，大部分工程團隊甚至還沒有真正理解 event time、watermark 與 late data 的重要性。相比之下，直接操作 Spark 或 Flink，往往更加直觀，也更容易最佳化。</p><p>更根本的是，開發者最終選擇的，往往不是最優雅的 abstraction，而是最可控的 execution engine。Beam 所描繪的 abstraction，或許是 streaming 世界中最優雅的一套理論模型之一；但真實世界的資料系統，最終仍然充滿了 backpressure、state migration、checkpoint tuning、memory management、skew、deployment 與 failure recovery 這些無法被完全抽象化的工程細節。</p><p>當不同 Runner 的 semantics、performance、checkpoint behavior 與 state management 開始出現差異時，”Write Once, Run Anywhere” 便逐漸變成 “Write Once, Debug Everywhere”。</p><p>於是，當今世界採納的並不是 Beam 的 abstraction layer 本身，而是 Dataflow 所提出的 What、Where、When、How 這套對時間、狀態與結果演化方式的理解。</p><h3>Flink Dynamic Tables: Relationalizing Streaming</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GP9pVyDxcGdbWk2b3JgWvw.png" /></figure><h4>Apache Flink</h4><p>Apache Flink 最早起源於德國柏林 Technical University of Berlin 的研究計畫 Stratosphere。2014 年，Stratosphere 正式捐贈給 Apache 基金會，並改名為 Apache Flink。與當時許多仍然建立在 batch 思維上的系統不同，Flink 從一開始就選擇了 streaming-first architecture，視 batch 為 bounded stream。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/313/1*4gwm5kVoxUOqSyEzRKIB_A.jpeg" /></figure><p>因此，Flink 早期的發展重心，主要集中在 event-time processing、watermark、stateful streaming，以及 exactly-once checkpoint 等 execution engine 層面的問題。</p><p>Dataflow 對 refinement semantics 的重新定義，已經開始動搖傳統 batch result 的靜態觀點。而當 streaming system 逐漸需要面對 SQL、Materialized View 與 incremental maintenance 等 relational world 的需求時，streaming 與 relational algebra 的融合，幾乎成為一種必然。</p><p>於是，大約從 2017 年開始，Flink 逐漸將重心擴展到 Table API、Streaming SQL 與 Dynamic Tables 等 higher-level abstraction。而這段演化過程中，Blink 原本是一個由中國團隊建立的 Flink fork，主要專注於 Streaming SQL、query optimizer、relational execution，以及更完整的 Table API。相比當時較偏 execution engine 的原始 Flink，Blink 更加重視 relational abstraction on streaming systems。後來，Blink 的大量成果逐漸被合併回 Apache Flink 主線之中。</p><p>到了 2019 年前後，特別是 Flink 1.9，Blink planner 開始成為官方的重要方向，而 Dynamic Tables 與 Streaming SQL 的整體模型，也在這段期間逐漸成熟。這也標誌著一個重要轉變：streaming system 不再只是處理 event 的 engine，而開始成為一個 continuously evolving relational system。</p><h4>Materialized View and Incremental Maintenance</h4><p>Dynamic Tables 並不是全新的東西，它更像是將傳統資料庫中的 Materialized View，推進到 unbounded streaming world 後的結果。</p><p>在傳統資料庫中，Materialized View（MV）本質上是一種預先計算並持久化儲存的 query result。例如：</p><pre>SELECT ad_id, COUNT(*)<br>FROM clicks<br>GROUP BY ad_id;</pre><p>系統不需要每次 query 都重新掃描整張 table，而是可以直接讀取已經預先計算好的結果。</p><p>然而，當 underlying table 發生變化時，系統是否需要重新計算整張 view？於是，資料庫世界開始發展出 Incremental View Maintenance (IVM)，也就是只根據資料的變化量（delta），更新 view 的結果。</p><p>例如，一筆新的 click event 到達後，系統不需要重新計算所有廣告的點擊數，而只需要將對應 ad_id 的 count 加一即可。</p><p>而當 streaming system 開始引入：</p><ul><li>stateful processing</li><li>event time</li><li>continuous aggregation</li><li>refinement semantics</li></ul><p>時，事情開始變得非常有趣。因為 streaming system 其實就是一個永遠不會停止的 incremental update system。在這個觀點下，Dynamic Tables 可以被理解為 continuously maintained materialized views。table 不再是靜態資料集，而是一個持續變化的 relational state；query result 也不再是一次性計算完成的 final answer，而是一個會隨時間不斷被更新的 dynamic view。</p><p>Flink Dynamic Tables 所做的事情，其實就是將傳統資料庫中的 Materialized View 與 Incremental View Maintenance，推進到 unbounded streaming world。</p><h4>Changelog Stream</h4><p>在 Dynamic Tables 的世界中，stream 被重新理解為 changelog of a table。每一筆 stream event，不再只是 moving data，而是：</p><ul><li>INSERT</li><li>UPDATE_BEFORE</li><li>UPDATE_AFTER</li><li>DELETE</li></ul><p>而這也形成了一個後來非常重要的觀點：<strong>Stream-Table Duality</strong>。在這個觀點下：</p><ul><li>stream 可以被視為 table 的 changelog</li><li>table 則可以被視為 stream 在某個時間點的 materialization</li></ul><p>值得注意的是，changelog stream 並不等於完整 relational algebra 本身，它更像是 relational result state 的變化紀錄。這些 relational algebra</p><ul><li>projection</li><li>selection</li><li>join</li><li>aggregation</li><li>ranking</li></ul><p>仍然存在於 query execution 與 optimizer 層中。</p><p>changelog 跟 Write-Ahead Log (WAL) 兩者都在記錄 state change，但主要差異在於：</p><ul><li>WAL 描述的是 storage state 的變化</li><li>changelog stream 描述的則是 relational state 的變化</li></ul><h3>One SQL to Rule Them All (2019)</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/664/1*wSziOQLVyaGSBc9_1p__SQ.png" /></figure><h4>Introduction</h4><p>承接前一節 Flink Dynamic Tables 的發展，下一個自然浮現的問題是：如果 stream 與 table 已經可以被統一理解，那麼 SQL 本身，是否也能同時描述 batch 與 streaming？這正是《One SQL to Rule Them All》試圖回答的問題。</p><p>這篇 paper 的核心觀點其實非常大膽：如果 SQL 已經是資料世界最通用的語言，那麼 streaming 為什麼還需要另一套語言？作者的答案是：不需要。</p><p>而這篇 paper 的野心，也並不是發明一個新的 Streaming SQL dialect，而是試圖將 streaming，重新放回標準 SQL 與 relational algebra 的世界裡。只要加入少量 extension，SQL 就可以同時描述：</p><ul><li>bounded batch</li><li>unbounded stream</li><li>changelog</li><li>time-varying relations</li></ul><p>這篇論文之所以特別值得閱讀，不只是因為它提出了許多後來深遠影響業界的概念，更因為它其實是一篇建立在多年工業實踐之上的統整性論文。</p><p>作者群長期參與 Google Dataflow、Apache Beam、Apache Flink、Apache Calcite 等系統的設計與演化，因此 paper 中許多提案，並非憑空構想，而是來自真實 streaming SQL 系統所遭遇的問題與經驗總結。</p><p>有趣的是，並非所有提案最後都被主流系統完整採納。有些概念，例如 Time-Varying Relations、event time、watermark、window table-valued functions，以及 changelog stream，後來幾乎成為整個 streaming SQL 世界的共同語言；而另一些更具野心的提案，則至今仍未成為主流 SQL 標準的一部分。</p><p>接下來，我們將依序介紹這篇論文提出的七個核心 proposal。其中，Proposal 1 奠定了整篇論文最重要的理論基礎；而 Proposal 2 至 Proposal 7，則是在此基礎之上，逐步建立完整的 Streaming SQL 語法與語意模型。</p><p>在介紹每個 proposal 的同時，我們也會觀察當今主流系統的採納情況，並以 Apache Flink SQL 作為主要範例，看看哪些構想已經成為產業標準，又有哪些構想至今仍停留在論文之中。</p><h4>Proposal 1: Time-Varying Relations（TVR）</h4><p>TVR 是這篇論文的核心概念。</p><p>在傳統 relational model 中，table 通常被視為某個靜態資料集。query 的輸入是 table，輸出也是 table；而 query result，通常被假設是一個相對穩定、不再變動的結果。</p><p>但在 streaming system 裡：</p><ul><li>資料會持續到達</li><li>state 會持續變化</li><li>query result 也會不斷被修正</li></ul><p>於是，paper 提出了一個更根本的觀點：relation 並不是靜態存在，而是一個隨時間持續演化的 semantic object。這個 semantic object，便是 Time-Varying Relation。</p><p>而 stream 與 table，則只是 TVR 的不同 representation。</p><p>這比先前提到的 stream-table duality 更加激進。它並不是在說 stream 與 table 很像，而是在說 stream 與 table 根本就是同一個東西。</p><p>而這個觀點，也徹底改變了 SQL 在 streaming world 中的定位。因為一旦：</p><ul><li>stream = representation of TVR</li><li>table = representation of TVR</li></ul><p>那麼 SQL 便不再只是查詢靜態 table 的語言，它開始變成一種描述 TVR 如何隨時間演化的語言。</p><h4>Proposal 2: Event Time Columns and Watermarks</h4><p>在此提案，event time 被正式帶入 SQL。</p><p>在傳統 SQL 裡，時間通常只是普通欄位；但在 streaming SQL 裡，時間不只是資料的一部分，而是系統判斷「資料是否大致到齊」的依據。paper 因此主張：relation 中應該可以有明確的 event-time column，而這個 column 需要搭配 watermark，讓系統能推理 event-time progress。</p><p>paper 對 watermark 的定義很關鍵：watermark 是一個從 processing time 到 event time 的 monotonic function。換句話說，在某個 processing time，系統宣告 event time 已經推進到某個時間點；也就是說，未來應該不會再看到 timestamp 小於或等於該 watermark 的資料。</p><p>這也是為什麼 watermark 對 streaming SQL 很重要。沒有 watermark，系統永遠無法知道某個 window 或 GROUP BY 是否可以被視為「足夠完整」；有了 watermark，系統才有機會在 correctness、latency 與 resource cleanup 之間做取捨。</p><p>Flink SQL 已經相當完整地支援這個提案。Flink 的 WATERMARK clause 可以在 CREATE TABLE 中宣告 event-time attribute，形式是 WATERMARK FOR rowtime_column AS watermark_strategy_expression；官方文件也說，這個 clause 會把指定 column 標記為 event-time attribute。</p><p>例如：</p><pre>CREATE TABLE orders (<br>  order_id STRING,<br>  user_id STRING,<br>  amount DECIMAL(10, 2),<br>  -- event-time column<br>  order_time TIMESTAMP(3),<br>  -- 表示系統假設資料最多可能晚到 5 秒。<br>  -- 當目前看到的最大 event time 是 10:00:30 時，watermark 大約會推進到 10:00:25。<br>  WATERMARK FOR order_time AS order_time - INTERVAL &#39;5&#39; SECOND<br>) WITH (<br>  &#39;connector&#39; = &#39;kafka&#39;,<br>  &#39;topic&#39; = &#39;orders&#39;,<br>  &#39;format&#39; = &#39;json&#39;<br>);</pre><p>這正是 paper 所說的方向：event time 不再是隱藏在 stream metadata 裡的東西，而是 SQL schema 中可以被明確宣告、操作，並與 watermark 一起參與 query semantics 的一等公民。</p><h4>Proposal 3: Grouping on Event Timestamps</h4><p>在 batch SQL 裡，GROUP BY 是一件非常自然的事情。因為資料是有限的，系統只需要等所有資料讀完，再做 aggregation 即可。系統知道 input 何時結束，因此也知道 aggregation 何時完成。例如：</p><pre>SELECT user_id, COUNT(*)<br>FROM clicks<br>GROUP BY user_id;</pre><p>但對 unbounded stream 而言，input 是源源不絕的。這意味著如果沒有額外資訊，GROUP BY 永遠不會完成。</p><p>因此論文特別強調，對 unbounded relation 做 grouping 時，必須依賴 event-time column 與 watermark。因為只有 watermark，才能讓系統推理某個 event-time interval 是否已經 sufficiently complete。</p><p>例如使用 Flink SQL 對同一個 orders table 進行 event-time grouping：</p><pre>SELECT<br>  window_start,<br>  window_end,<br>  COUNT(*) AS order_count,<br>  SUM(amount) AS total_sales<br>FROM TABLE(<br>  TUMBLE(<br>    TABLE orders,<br>    DESCRIPTOR(order_time),<br>    INTERVAL &#39;1&#39; MINUTE<br>  )<br>)<br>GROUP BY window_start, window_end;</pre><p>在這個 query 裡，Flink 會根據 watermark 的推進，決定某個 event-time window 是否可以被視為 sufficiently complete，並觸發 aggregation result。</p><h4>Proposal 4: Event-Time Windowing Functions</h4><p>在早期 streaming SQL 系統裡，window 通常被當成特殊版本的 GROUP BY。例如：</p><p>GROUP BY TUMBLE(…)</p><p>這類語法雖然實用，但 paper 認為 window 被硬塞進 GROUP BY，破壞了 relational semantics。因為在 relational algebra 的世界裡 GROUP BY 本來應該只是對 relation 做 grouping。而 window 其實是在對 relation 進行時間切分（partitioning over time）。</p><p>因此，論文提出 windowing 應該被視為 table-valued function（TVF）：接收一個 relation，<br>並回傳另一個 relation 的 function。這樣一來 windowing 就重新回到 relational model 裡。</p><p>Flink SQL 在這部分完成度非常高。目前 Flink 已經完整支援：</p><ul><li>TUMBLE</li><li>HOP</li><li>CUMULATE</li><li>SESSION</li></ul><p>等 Windowing TVF。</p><p><strong>TUMBLE</strong></p><pre>SELECT<br>  window_start,<br>  window_end,<br>  COUNT(*) AS order_count<br>FROM TABLE(<br>-- TUMBLE(...) 會先產生：帶有 window_start / window_end metadata 的 relation。之後 GROUP BY 才對這個 relation 做 aggregation。<br>  TUMBLE(<br>    TABLE orders,<br>    DESCRIPTOR(order_time),<br>    INTERVAL &#39;1&#39; MINUTE<br>  )<br>)<br>GROUP BY window_start, window_end;</pre><p><strong>HOP</strong></p><pre>SELECT<br>  window_start,<br>  window_end,<br>  SUM(amount) AS total_sales<br>FROM TABLE(<br>-- 每 1 分鐘滑動一次，計算最近 5 分鐘的 aggregation。<br>  HOP(<br>    TABLE orders,<br>      DESCRIPTOR(order_time),<br>      INTERVAL &#39;1&#39; MINUTE,<br>      INTERVAL &#39;5&#39; MINUTE<br>  )<br>)<br>GROUP BY window_start, window_end;</pre><p><strong>SESSION</strong></p><pre>SELECT<br>  window_start,<br>  window_end,<br>  user_id,<br>  COUNT(*) AS click_count<br>FROM TABLE(<br>-- 若 30 分鐘內沒有新 event，則 session 結束。<br>  SESSION(<br>    TABLE clicks,<br>    DESCRIPTOR(click_time),<br>    INTERVAL &#39;30&#39; MINUTE<br>  )<br>)<br>GROUP BY window_start, window_end, user_id;</pre><p><strong>CUMULATE</strong></p><p>這類 cumulative window，常用於：</p><ul><li>dashboard incremental refresh</li><li>partial aggregation</li><li>progressive reporting</li></ul><pre>SELECT<br>  window_start,<br>  window_end,<br>  SUM(amount) AS revenue<br>FROM TABLE(<br>  CUMULATE(<br>    TABLE orders,<br>    DESCRIPTOR(order_time),<br>    INTERVAL &#39;1&#39; MINUTE,<br>    INTERVAL &#39;1&#39; HOUR<br>  )<br>)<br>GROUP BY window_start, window_end;</pre><h4>Proposal 5: EMIT STREAM (How)</h4><p>在 paper 的模型裡，query result 並不一定只是某個時間點的 snapshot table。很多時候，使用者真正想要的是 result 的持續變化過程。例如：</p><pre>-- 只是 proposal<br>SELECT<br>  product_id,<br>  COUNT(*) AS order_count<br>FROM orders<br>GROUP BY product_id<br>EMIT STREAM;</pre><p>代表當 aggregation state 發生變化時，持續輸出 refinement。例如：</p><pre>product_42 → 58<br>product_42 → 59<br>product_42 → 61</pre><p>這也是 paper 對：</p><ul><li>stream-table duality</li><li>TVR</li><li>refinement semantics</li></ul><p>的一次具體化。在 Dynamic Tables 的觀點下，這等價於：</p><ul><li>將 continuously evolving relation，materialize 成 changelog stream。</li></ul><h4>Proposal 6: EMIT AFTER WATERMARK (When)</h4><p>在 dashboard, billing 或 reporting 的用例中，使用者並不希望每一次 refinement 都立刻被 downstream 看見。例如：</p><pre>-- 只是 proposal<br>SELECT<br>  window_start,<br>  window_end,<br>  COUNT(*) AS order_count<br>FROM TABLE(<br>  TUMBLE(<br>    TABLE orders,<br>    DESCRIPTOR(order_time),<br>    INTERVAL &#39;1&#39; MINUTE<br>  )<br>)<br>GROUP BY window_start, window_end<br>-- 這代表等 watermark 超過 window_end，才 materialize result。<br>EMIT AFTER WATERMARK;</pre><p>EMIT AFTER WATERMARK 試圖將 watermark-driven materialization 提升到 SQL 層級，使使用者能夠顯式控制 query result 何時被 materialize，而不是將這個決策隱含在 runtime 或 window operator 中。</p><p>雖然 Flink 沒採用這個語法，但其在 event-time window query 中的預設的 trigger 就是 watermark-based emission。</p><h4>Proposal 7: EMIT AFTER DELAY (When)</h4><p>如果每次 state change 都 emit， 高流量 query 可能產生大量 updates。對 downstream system 而言，這可能：</p><ul><li>太頻繁</li><li>太昂貴</li><li>太 noisy</li></ul><p>因此，我們可以這樣用：</p><pre>-- 只是 proposal<br>SELECT<br>  product_id,<br>  SUM(amount) AS total_sales<br>FROM orders<br>GROUP BY product_id<br>EMIT STREAM<br>-- 這代表即使 underlying relation 持續變化，系統也只會每 10 秒 materialize 一次。<br>AFTER DELAY INTERVAL &#39;10&#39; SECOND;</pre><p>例如輸出可能是這樣：</p><pre>10:00:00 → product_42 = 58<br>10:00:10 → product_42 = 73<br>10:00:20 → product_42 = 91</pre><p>paper 更進一步提出：EMIT STREAM, AFTER DELAY, AFTER WATERMARK</p><p>可以彼此組合。例如：</p><ul><li>平時每 10 秒輸出一次 approximate result</li><li>watermark 到達後輸出 on-time result</li><li>late data 再觸發 refinement</li></ul><p>這其實已經非常接近 Beam trigger model 與 refinement semantics 的 SQL 化版本。</p><h4>The Unfinished Proposals of Streaming SQL</h4><p>回頭來看，《One SQL to Rule Them All》所提出的七個 proposal 中，大部分都已經以不同形式被主流 streaming SQL 系統吸收。然而，仍有三個與 materialization control 相關的 proposal，至今尚未成為主流 SQL 標準的一部分：</p><ul><li>EMIT STREAM</li><li>EMIT AFTER WATERMARK</li><li>EMIT AFTER DELAY</li></ul><p>有趣的是，這並不代表它們背後的思想失敗了。事實上，這些 proposal 所試圖解決的問題，正是 Dataflow 模型中的 When 與 How：</p><ul><li>結果應該在什麼時候被輸出？</li><li>多次輸出的結果應該如何呈現？</li></ul><p>真正困難的地方在於，當這些概念被推進 SQL 之後，它們已經不再只是 query semantics，而開始涉及 execution engine 的設計。例如：</p><pre>EMIT AFTER DELAY INTERVAL &#39;10&#39; SECOND</pre><p>這句話究竟代表：</p><ul><li>operator 每 10 秒輸出一次？</li><li>sink 每 10 秒 flush 一次？</li><li>downstream 每 10 秒看到一次更新？</li><li>mini-batch 每 10 秒觸發一次？</li></ul><p>不同系統可能有完全不同的解釋，因此很難形成跨引擎的一致標準。</p><p>這也是為什麼今天的 Flink 雖然已經實作了大量 Dataflow 的核心思想，但 Flink SQL 對於 When 與 How 的支援，仍然不是完全 declarative 的。</p><p>所謂 declarative，指的是使用者只需要描述「想要什麼結果」，而不需要關心系統如何實作。例如：</p><pre>SELECT …<br>EMIT AFTER DELAY INTERVAL &#39;10&#39; SECOND</pre><p>光從 query 本身，我們便能清楚知道：</p><ul><li>這個查詢的結果，應該每 10 秒被 materialize 一次。</li></ul><p>然而在今天的 Flink 中，許多與 When 與 How 相關的行為，並不是直接寫在 SQL 裡，而是分散在 runtime configuration、Trigger、Timer、State 與 sink implementation 等不同層次之中。例如：</p><pre>SET &#39;table.exec.mini-batch.allow-latency&#39; = &#39;10 s&#39;;</pre><p>或是在 Java 程式中自行實作 Trigger 與 Timer。</p><p>換句話說，系統雖然具備這些能力，但使用者往往需要透過額外設定或程式碼組合，才能表達完整的行為。這種方式更接近 imperative programming：開發者必須告訴系統應該如何執行，而不只是描述想要的語意。</p><p>若從 API 的角度來看，Flink 大致可以分成三個層次：</p><pre>Flink SQL<br>↓<br>Table API<br>↓<br>DataStream API</pre><p>最上層的 Flink SQL，主要提供：</p><ul><li>Dynamic Tables</li><li>event time</li><li>watermark</li><li>Window TVF</li><li>changelog semantics</li></ul><p>以及各種 SQL 查詢能力。</p><p>中間的 Table API 則與 Flink SQL 共用同一套 planner 與 runtime。許多設定，例如：</p><pre>SET &#39;table.exec.mini-batch.enabled&#39; = &#39;true&#39;;<br>SET &#39;table.exec.mini-batch.allow-latency&#39; = &#39;5 s&#39;;</pre><p>本質上都屬於 Table Layer 的 runtime configuration。</p><p>然而，這些設定仍然是在調整系統的執行方式，而不是定義查詢本身的語意。本質上，它們回答的是系統應該如何執行？而不是查詢結果應該如何被 materialize？</p><p>真正接近 Dataflow 的 When 與 How 的能力，則位於最底層的 DataStream API。在 DataStream API 中，Flink 提供了這些 execution primitives：</p><ul><li>Trigger</li><li>Timer</li><li>State</li><li>ProcessFunction</li></ul><p>開發者可以自行定義：</p><ul><li>watermark 到達時觸發</li><li>processing time 到達時觸發</li><li>count-based trigger</li><li>custom trigger</li><li>timer-based logic</li></ul><p>並透過：</p><ul><li>FIRE</li><li>PURGE</li><li>FIRE_AND_PURGE</li></ul><p>等 Trigger behavior，組合出各種不同的輸出模式。</p><p>因此，discarding mode、accumulating mode，甚至類似 retract/update 的行為，其實都能夠在 DataStream API 中被實作出來。</p><p>換句話說，Flink runtime 本身，並不缺乏實現 Dataflow When 與 How 的能力。真正缺少的是將這些能力提升成 SQL 語言的一部分。</p><p>因此，今天的 Flink 呈現出一個相當有趣的局面：What（計算什麼）和 Where（在哪個時間範圍計算）已經完整進入 SQL；When（何時輸出）和 How（如何輸出）則仍然有一部分停留在 runtime 與 API 層。</p><p>從這個角度來看，《One SQL to Rule Them All》並沒有完全失敗。相反地，它提出的大部分語意模型都已深深影響後來的 Flink、Materialize 與整個 streaming SQL 生態系。真正尚未完成的，只是如何將 Dataflow 的 When 與 How，完整提升為 declarative SQL language 的一部分。</p><h4>Elaboration for Flink Job</h4><p>不論你撰寫的是 SQL、Table API，還是 DataStream API，最終都會被編譯成一個或多個 Flink Job，並由 Flink Runtime 負責執行。</p><p>因此，當我們討論 Event Time、Watermark、Dynamic Tables、Trigger 或 Changelog 時，它們並不是停留在理論上的抽象概念，而是最終會被轉化為一連串實際運作的 operator、state 與 network shuffle。</p><p>現在，讓我們透過一個稍早出現過的 SQL 範例，看看一個 Flink Job 究竟是如何被建構與執行的。首先，我們定義一個 source table：</p><pre>CREATE TABLE orders (<br>  order_id STRING,<br>  user_id STRING,<br>  amount DECIMAL(10, 2),<br>  order_time TIMESTAMP(3),<br>  WATERMARK FOR order_time AS order_time - INTERVAL &#39;5&#39; SECOND<br>) WITH (<br>  &#39;connector&#39; = &#39;kafka&#39;,<br>  &#39;topic&#39; = &#39;orders&#39;,<br>  &#39;format&#39; = &#39;json&#39;<br>);</pre><p>這裡的 CREATE TABLE orders 並不是在 Flink 內真正建立一張 physical table，而是在 Flink Catalog 中註冊一個對應到 Kafka topic 的 logical table。接著，我們可以撰寫 streaming aggregation：</p><pre>INSERT INTO minute_sales<br>SELECT<br>  window_start,<br>  window_end,<br>  COUNT(*) AS order_count,<br>  SUM(amount) AS total_sales<br>FROM TABLE(<br>  TUMBLE(<br>    TABLE orders,<br>    DESCRIPTOR(order_time),<br>    INTERVAL &#39;1&#39; MINUTE<br>  )<br>)<br>GROUP BY window_start, window_end;</pre><p>這段 SQL 本質上代表持續從 Kafka topic orders 讀取 stream，並不斷計算每分鐘 aggregation。INSERT INTO minute_sales 則表示將這個 continuously evolving result，持續寫入另一個 sink。例如：</p><pre>CREATE TABLE minute_sales (<br>  window_start TIMESTAMP(3),<br>  window_end TIMESTAMP(3),<br>  order_count BIGINT,<br>  total_sales DECIMAL(10,2)<br>) WITH (<br>  &#39;connector&#39; = &#39;kafka&#39;,<br>  &#39;topic&#39; = &#39;minute_sales&#39;,<br>  &#39;format&#39; = &#39;json&#39;<br>);</pre><p>因此，整個 topology 實際上是：</p><pre>Kafka topic: orders<br>↓<br>Flink SQL Job<br>↓<br>Kafka topic: minute_sales</pre><p>這裡有一個非常重要的觀念：source 與 sink，通常都必須是 persistent storage。例如：</p><ul><li>Kafka</li><li>Iceberg</li><li>JDBC database</li><li>filesystem</li></ul><p>原因是 Flink 本身並不是 database，而更像是一個 continuous query execution engine。而 Flink SQL 中的 CREATE TABLE 很多時候也並不是建立真正的 physical table，而只是在 Catalog 中，<br>註冊 external system 的 metadata。</p><p>另外，一個 query 通常代表一個 Flink job。例如：</p><pre>INSERT INTO minute_sales<br>SELECT …</pre><p>在 production environment 中，通常會被編譯成一個持續執行（continuously running）的 Flink job。只要 Kafka 中還有新的 event 持續進來，這個 job 就會：</p><ul><li>持續 consume stream</li><li>持續更新 state</li><li>持續計算 aggregation</li><li>持續 output changelog</li></ul><p>而這也正是 Dynamic Tables 最重要的思想之一：query result 不再是一次性產生的 static table，而是一個 continuously evolving relational state。</p><p>然而，在 query result 持續演化的當下，如果機器故障了怎麼辦？事實上，這正是 Streaming System 早期最困難的問題之一。</p><p>2013 年，Google MillWheel 首次系統性地將 persistent state 與 checkpoint 機制引入大型 stream processing system，並藉此實現 exactly-once processing semantics。MillWheel 的許多設計，後來也深深影響了 Dataflow、Beam 與 Flink 等系統。</p><p>在 Flink 中，state 會定期被 snapshot 到 checkpoint storage。當機器故障時，Flink 可以從最近一次 checkpoint 恢復 operator state、watermark progress 與 processing progress，並繼續執行 job。例如，在 DataStream API 中，可以透過下列設定開啟 checkpoint：</p><pre>StreamExecutionEnvironment env =<br>    StreamExecutionEnvironment.getExecutionEnvironment();<br><br>env.enableCheckpointing(10000); // every 10 seconds</pre><p>或是在 Flink configuration 中，以下代表系統每 10 秒建立一次 checkpoint：</p><pre>execution.checkpointing.interval: 10s</pre><p>目前常見的 checkpoint storage 例子包括：</p><pre>execution.checkpointing.storage: filesystem<br>execution.checkpointing.dir: s3://flink-checkpoints/<br>execution.checkpointing.dir: hdfs:///flink/checkpoints</pre><p>在大型 production environment 中，S3 往往是最常見的選擇。因為 checkpoint 本質上就是 state snapshot，而 object storage 具備高耐久性、低成本以及良好的水平擴展能力。</p><h3>Summary: From MillWheel to Flink</h3><p>回顧這段歷史，Google 的兩篇經典論文其實分工非常明確。</p><p>2013 年的 MillWheel，主要關注的是 execution correctness。它試圖回答的是：</p><ul><li>系統如何在機器故障後恢復？</li><li>如何保存 state？</li><li>如何實現 exactly-once？</li></ul><p>因此，MillWheel 花了大量篇幅討論 state management、checkpoint、low watermark、fault tolerance 與 recovery 等工程問題。</p><p>而 2015 年的 Dataflow 則選擇站在另一個高度思考問題。它預設底層 execution engine 已經存在，並將注意力集中在時間語意（time semantics）本身。</p><p>換句話說，MillWheel 關心的是「如何保存正確的 state？」而 Dataflow 關心的則是：「什麼才是正確的結果？」從今天回頭看，這兩個問題其實密不可分。Trigger、Accumulation 與 Refinement 都依賴 state 的持久化；而 state 的恢復方式，又會直接影響結果的正確性。</p><p>於是，一個自然的問題浮現：「如果能夠同時擁有 MillWheel 的 state 與 fault tolerance，再加上 Dataflow 的時間語意，會得到什麼樣的系統？」</p><p>Apache Flink 可以說是這個問題最具代表性的答案之一。它不僅吸收了 MillWheel 在 state、checkpoint 與 exactly-once 上的工程經驗，也採納了 Dataflow 的 Event Time、Watermark、Trigger 與 Accumulation 等時間語意模型，將 execution correctness 與 semantic correctness 統一到同一個系統之中。</p><p>而本章開頭曾經提到：在 2013 到 2019 這段歷史中，發生了三次極其重要的抽象融合。</p><p>第一個融合，是 Google Dataflow 的 Unifying Batch and Streaming。</p><p>在此之前，batch 與 streaming 長期被視為兩種截然不同的計算模式。然而 Dataflow 透過 What、Where、When 與 How 這套統一的模型，重新定義了兩者之間的關係。Batch 不再是一套獨立的系統，而可以被理解為 streaming 的特殊情況；streaming 則成為更一般化的資料處理模型。</p><p>第二個融合，是 Flink Dynamic Tables 的 Stream-Table Duality。</p><p>在此之前，stream 屬於事件與消息的世界；table 則屬於資料庫與 SQL 的世界。兩者雖然經常共存，卻很少被放進同一套理論框架之中。</p><p>Dynamic Tables 第一次讓資料庫世界與 streaming 世界開始共用同一套語意：stream 是 changelog；table 是 materialized state。兩者不再是完全不同的資料型態，而是同一個現象的兩種觀察方式。</p><p>而第三個融合，至今仍在持續發展之中。</p><p>《One SQL to Rule Them All》與 TVR（Time-Varying Relations）更進一步提出了一個比 Stream-Table Duality 更激進的觀點。在 TVR 的世界裡，stream 與 table 是同一個 semantic object 在不同時間尺度下的呈現方式。</p><p>換句話說，它不再主張：</p><ul><li>stream 與 table 很像；</li></ul><p>而是主張：</p><ul><li>stream 與 table 本質上就是同一件事。</li></ul><p>這正是第三次抽象融合：Unifying Stream, Table and SQL</p><p>從 MillWheel 到 Dataflow，從 Dynamic Tables 到 TVR，下層有新系統的誕生，上層也有一連串抽象層次的提升。</p><p>一旦 stream 與 table 成了 TVR，這趟乘載抽象與想像的 streaming 之旅又往前推進了一大步。stream、batch、table 與 SQL 這些原本彼此分離的概念，也開始逐漸被統一進同一套 worldview 之中。</p><p>(To be continued …)</p><h3>Appendix</h3><ol><li>極客 29 | Dataflow（一）：正确性、容错和时间窗口</li><li>極客 30 | Dataflow（二）：MillWheel，一个早期实现</li><li>MillWheel: Fault-Tolerant Stream Processing at Internet Scale: <a href="https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/41378.pdf">https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/41378.pdf</a></li><li>The Dataflow Model: A Practical Approach to Balancing Correctness, Latency, and Cost in Massive-Scale, Unbounded, Out-of-Order Data Processing: <a href="https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/43864.pdf">https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/43864.pdf</a></li><li>Apache Beam: <a href="https://beam.apache.org/about/">https://beam.apache.org/about/</a></li><li>Dynamic Tables: <a href="https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/table/concepts/dynamic_tables/">https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/table/concepts/dynamic_tables/</a></li><li>One SQL to Rule Them All: An Efficient and Syntactically Idiomatic Approach to Management of Streams and Tables: <a href="https://arxiv.org/pdf/1905.12133">https://arxiv.org/pdf/1905.12133</a></li><li>One SQL to Rule Them All — Fabian Hueske: <a href="https://www.youtube.com/watch?v=uJtqGkIxGhc">https://www.youtube.com/watch?v=uJtqGkIxGhc</a></li><li>Apache Flink™ vs Apache Kafka™ Streams vs Apache Spark™ Structured Streaming — Comparing Stream Processing Engines: <a href="https://www.onehouse.ai/blog/apache-spark-structured-streaming-vs-apache-flink-vs-apache-kafka-streams-comparing-stream-processing-engines">https://www.onehouse.ai/blog/apache-spark-structured-streaming-vs-apache-flink-vs-apache-kafka-streams-comparing-stream-processing-engines</a></li><li>Arroyo: <a href="https://www.arroyo.dev/blog/why-not-flink/">https://www.arroyo.dev/blog/why-not-flink/</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ef379f250027" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[2026.05.13 [優文賞析] Why We Need Continual Learning]]></title>
            <link>https://medium.com/@0050211/2026-05-13-%E5%84%AA%E6%96%87%E8%B3%9E%E6%9E%90-why-we-need-continual-learning-49486a1aa5cc?source=rss-663daee8909b------2</link>
            <guid isPermaLink="false">https://medium.com/p/49486a1aa5cc</guid>
            <dc:creator><![CDATA[Ryan Fu]]></dc:creator>
            <pubDate>Sat, 16 May 2026 06:20:51 GMT</pubDate>
            <atom:updated>2026-05-16T07:00:34.705Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4Hmz3t7GatzwM_4xWzjLCQ.png" /></figure><p>在導演克里斯多福·諾蘭（Christopher Nolan）執導的第二部劇情長片《記憶拼圖》（Memento）裡，主角 Leonard Shelby 因為腦部受傷，失去了形成新記憶的能力。</p><p>他的世界，每幾分鐘就會重置一次。他記不得剛剛發生了什麼，也無法把新的經驗真正變成自己的一部分。為了活下去，他只能依靠外部工具：把資訊刺在身上、拍立可照片、紙條、便籤。這些東西像是他大腦的延伸記憶體，幫助他推理、行動，甚至完成複雜任務。</p><p>但問題是：他從來沒有真正「成長」。因為所有經驗都停留在外部。它們沒有沉澱進他的腦中，沒有形成真正的理解，也沒有產生複利。而這正是今天大型語言模型最真實的寫照。</p><p>這幾年，人類第一次感受到 AI 如此接近「智慧」。模型能寫程式、翻譯、做研究、寫論文、規劃旅行、陪人聊天，甚至能表現出某種令人不安的「理解感」。但如果你仔細觀察，就會發現一件奇怪的事情：今天的大模型，其實幾乎不會學習。更精確地說，它們在「訓練完成之後」，就不再真正學習了。</p><p>模型在預訓練階段，確實會從整個網際網路中吸收知識。數十億篇文章、圖片、程式碼、討論，被壓縮進數千億個參數裡。這是一種驚人的壓縮工程。但一旦模型部署上線，它的參數通常就被凍結。</p><p>它可以回答新的問題，卻不會因為新的經驗而真正改變自己，也就是說。它沒有「經驗複利」，無法像人類一樣：</p><ul><li>今天學到一件事</li><li>明天因此變得更聰明</li><li>後天再用這些知識理解更複雜的世界</li></ul><p>於是，整個 AI 產業開始瘋狂替模型搭建外部記憶系統。我們發明：</p><ul><li>長上下文窗口（Long Context）</li><li>檢索增強生成（RAG）</li><li>向量資料庫（Vector Database）</li><li>Agent Framework</li><li>Memory System</li><li>Multi-Agent Collaboration</li></ul><p>聊天紀錄成了便利貼。RAG 成了外部筆記本。System Prompt 成了刻在身上的刺青。模型於是看起來越來越聰明，但模型真的學會了嗎？還是它只是越來越會「查資料」？</p><p>這，正是 Continual Learning 想解決的終極問題。</p><p>事實上，「持續學習（Continual Learning）」並不是今天才出現的新概念。早在 1989 年，研究者 McCloskey 與 Cohen 就已經提出相關理論，開始討論神經網路如何在學習新知識時，避免破壞舊有知識。</p><p>但在 1980、1990 年代，這個問題始終沒有真正受到重視。原因很簡單：當時的 AI，根本還沒有強大到足以讓人認真思考「部署後還能不能繼續學習」這件事。那個年代的神經網路規模極小，訓練資料有限，算力也遠遠不足。大多數模型甚至連穩定辨識手寫數字、語音、簡單圖像都很困難，更別說理解世界、長時間推理，或與人類持續互動。換句話說，當年的 AI 連「學會基本能力」都還做不到，人們自然不會去思考：如果模型未來還要持續成長，該怎麼辦？直到今天，大模型能力突然爆炸式成長，人們才第一次真正意識到：模型「已經知道的東西」，和模型「未來還能學會什麼」，其實是兩件完全不同的事情。而兩者之間，存在一道巨大的鴻溝。</p><p>也因此在最近，Continual Learning 突然從一個冷門研究方向，變成整個 AI 領域最關鍵、也最迫切的問題之一。a16z 這篇長文的目的，正是試圖重新整理整個領域的地圖。</p><h3>上下文學習（In-Context Learning）：AI 為什麼只靠 Prompt 就能突然變聰明？</h3><p>在討論 Continual Learning 之前，我們必須先承認一件事：上下文學習（In-Context Learning，ICL）其實非常成功。甚至可以說，過去幾年整個生成式 AI 的爆發，很大一部分都建立在它之上。</p><p>Transformer 的本質，是一種基於序列的條件式預測系統。模型會根據你給它的上下文，推測下一個最合理的 token。而神奇的地方就在這裡：你不需要重新訓練模型，只需要改變輸入內容，模型就能突然表現出完全不同的能力。</p><p>你給它幾段翻譯範例，它就突然會翻譯。你給它程式碼風格，它就開始模仿。你給它人格設定，它甚至會表現出某種穩定的角色感。這也是為什麼 Prompt Engineering 曾經一度成為熱門職業。</p><p>人們發現：真正影響模型行為的，不一定是權重本身，而是你如何組織上下文。過去很多研究者其實瞧不起 Prompt Engineering，認為這只是某種「投機取巧的小技巧」，不像真正的機器學習。但後來大家才發現：這種方法之所以強大，恰恰是因為它與 Transformer 架構高度原生相容。</p><p>ICL 不需要重新訓練，不需要昂貴 GPU，不需要重新部署模型，且會隨著模型能力一起縮放。模型越強，Prompt 的威力就越大。也因此，過去幾年的 AI 應用開發，逐漸從「訓練模型」，轉向「編排上下文」，焦點開始變成：</p><ul><li>哪些資訊該放進 context？</li><li>哪些資訊該被摘要？</li><li>哪些內容應該被長期保存？</li><li>Agent 如何在數小時的任務中維持一致性？</li><li>多個工具與記憶系統如何協作？</li></ul><p>像 Cursor、OpenClaw 這類系統，其實都在做同一件事：重新設計「上下文如何流動」。但 context architecture 儼然成為一門新的工程學。</p><p>然而，當 AI 的工作模式，從「一次性問答」變成「長時間自主運作的 Agent」後，整個系統對上下文的依賴突然被推到極限。</p><p>以前的 ChatBot，大多只是短對話，Context Window 很少被塞滿。即使快滿了，也只需要簡單裁剪聊天紀錄即可。但 Agent 不一樣，一個複雜任務，可能需要：</p><ul><li>搜尋資料</li><li>呼叫工具</li><li>規劃步驟</li><li>修改策略</li><li>長時間推理</li><li>多輪自我反思</li></ul><p>而 Agent 的每一步，又都依賴前一步留下來的上下文。於是，Context Window 開始以前所未有的速度膨脹。</p><p>這也是為什麼，最近整個 AI 產業突然開始瘋狂競賽「超大上下文窗口」。從 8K、32K、128K，到如今動輒數百萬 token 的 context window，本質上，其實都是在試圖延長模型的「工作記憶」。因為大家逐漸發現：如果 Agent 想真正長時間運作，它就必須記得自己之前做過什麼。</p><p>而目前最直接的方法，就是把更多歷史塞進 context。這條路之所以吸引人，是因為它不需要真正改變模型，不需要讓模型學習，也不需要冒著破壞權重的風險。它只是單純把「外部記憶」做得越來越大。某種程度上，這很像替《記憶拼圖》的主角準備一本更厚、更完整的筆記本。</p><h3>Context Window 的極限：為什麼 Agent 跑久了會「失憶」？</h3><p>更大的 Context Window，真的等於真正的記憶嗎？答案可能是否定的。因為 Agent 很快就暴露出一個問題：它們會「斷片」。很多研究者發現，一個 Agent 在連續運行 20 到 100 步之後，系統開始出現明顯退化：</p><ul><li>上下文充滿冗餘資訊</li><li>早期重要訊息被稀釋</li><li>推理鏈逐漸崩壞</li><li>模型失去方向感</li><li>任務開始發散</li></ul><p>即使 context window 還沒真正滿掉，模型也可能已經無法有效利用其中資訊。這暴露出一件很重要的事：Transformer 雖然能「看到」很長的上下文，但不代表它真的能長期維持穩定狀態。</p><p>今天的 Transformer，本質上仍然是一種 stateless-ish architecture，它不像人類大腦那樣，會主動把重要經驗壓縮成長期表徵，它只是每次重新閱讀整份上下文。換句話說，模型其實一直都在「重看筆記」，而不是把經驗真正內化。</p><p>整個 AI 系統的問題，開始越來越像作業系統，甚至像分散式系統。（當然，這並不是 a16z 原文直接提出的觀點，而是從今天 Agent 系統的演化方向所延伸出的另一種理解方式。）</p><p>因為真正困難的地方，不再是：「模型夠不夠聰明？」而是：如何在有限記憶、有限頻寬、有限上下文下，維持長時間一致性的智能行為。</p><p>如果你仔細觀察，現在很多 Agent Framework 面對的問題，其實已經和作業系統極為相似。例如：</p><ul><li>哪些資訊該保留在 Context Window？</li><li>哪些資訊應該被壓縮？</li><li>哪些記憶需要長期保存？</li><li>哪些狀態應該被丟棄？</li><li>如何避免 Context 被垃圾資訊污染？</li><li>如何在有限 Token Budget 下維持系統穩定？</li></ul><p>這些問題，本質上都很像作業系統中的：</p><ul><li>Memory Management</li><li>Cache Eviction</li><li>Scheduling</li><li>Paging</li><li>State Management</li></ul><p>Transformer 的 Context Window，某種程度上甚至很像一塊昂貴但高速的 RAM。而 RAG、Vector Database、External Memory，則開始像是外部儲存系統。今天很多 Agent 的「記憶摘要（Summarization）」機制，也越來越像：</p><ul><li>Log Compaction</li><li>Hierarchical Storage</li><li>Compression Pipeline</li></ul><p>而 Multi-Agent 系統，則更明顯帶有分散式系統的影子。因為一旦系統開始拆成多個 Agent：</p><ul><li>每個 Agent 擁有自己的局部上下文</li><li>各自負責不同子任務</li><li>彼此透過訊息交換協作</li><li>共享部分狀態</li><li>聚合最終結果</li></ul><p>問題就變成分散式系統最核心的問題：</p><ul><li>如何同步狀態？</li><li>如何避免資訊衝突？</li><li>如何降低 communication cost？</li><li>如何維持長時間一致性？</li><li>如何在部分 Agent 出錯時維持整體穩定？</li></ul><p>某種程度上，AI 正在重新走過電腦科學歷史上的一條老路：</p><ul><li>當單一程序不夠時，人類發明了作業系統。</li><li>當單一機器不夠時，人類發明了分散式系統。</li></ul><p>而現在，當單一 Context Window 不夠時，我們開始發明：</p><ul><li>Multi-Agent Architecture</li><li>External Memory</li><li>Hierarchical Context</li><li>Persistent State</li><li>Long-running Cognitive Systems</li></ul><p>也因此，未來的 AI Engineering，很可能不再只是 Machine Learning。它會越來越像：「認知系統工程（Cognitive Systems Engineering）」。而其中很多核心思想，可能會重新回到這些過去幾十年電腦科學最底層、也最成熟的學科之中。：</p><ul><li>Operating Systems</li><li>Distributed Systems</li><li>Database Systems</li><li>Networking</li></ul><h3>超長記憶的競賽：State Space Model（SSM）與新架構的崛起</h3><p>當 Agent 開始需要長時間運作後，整個 AI 產業很快就碰到了一個極為現實的問題：Transformer 的記憶成本，正在失控。問題的根源，來自 Transformer 最核心的機制：Attention。</p><p>每一個 token 都會和其他 token 計算關聯性，正是 Transformer 強大的原因。一句話中的每個詞，都需要「看向」整段上下文，讓模型可以自由地回頭檢索任何位置的資訊。它能直接建立：</p><ul><li>遠距依賴（Long-range Dependency）</li><li>跨段落關聯</li><li>多步推理鏈</li></ul><p>這種能力，本質上是一種近乎「無損」的上下文召回（Recall），只要資訊還在 Context Window 裡，模型理論上都能重新注意到它，但代價也極其巨大。</p><p><strong>Transformer Attention 有一個著名問題</strong>：計算量會隨序列長度平方成長。也就是當 token 數量從 n 增加到 2n 時，Attention 計算量不是增加兩倍，而是四倍。因此它的時間與記憶體複雜度是：</p><p>O(n²)</p><p>這也是為什麼 Context Window 越大，GPU 消耗就越驚人。</p><p><strong>而另一個問題，則是 KV Cache</strong>。在推理階段，Transformer 會把之前所有 token 的：</p><ul><li>Key（K）</li><li>Value（V）</li></ul><p>暫存下來。這就是所謂的 KV Cache。因為模型每生成一個新 token，都需要重新參考過去所有 token。</p><p>所以 Context 越長，KV Cache 就越大。而且是線性持續增長。對長時間運作的 Agent 而言，這會變成極其昂貴的記憶體負擔。你可以把它想像成：模型每次對話，都把整本歷史紀錄完整攤在桌上，不斷增加，從不真正忘記。</p><p>這也是為什麼，最近幾年 AI 研究界開始大量投入另一條路線：State Space Model（SSM）。</p><p>從架構上看，SSM 其實是在嘗試替換 Transformer 最核心的部分：Attention Layer。也就是原本 Transformer 裡：</p><ul><li>token 與 token 全局互看</li></ul><p>現在改成：</p><ul><li>用一個持續演化的 state 傳遞資訊</li></ul><p>因此很多 SSM 架構，看起來會更像：</p><ul><li>recurrent system</li><li>continuous dynamical system</li><li>streaming state update</li></ul><p>而不是傳統的 full attention matrix。這也是為什麼很多人會說：SSM 更像一種「流式記憶（streaming memory）」。</p><p>於是，整個問題開始變成一種新的取捨。</p><p>Transformer 提供：</p><ul><li>強召回</li><li>高精度注意力</li><li>幾乎無損的上下文存取</li></ul><p>但代價是：</p><ul><li>O(n²) 計算</li><li>巨大的 KV Cache</li><li>長序列成本爆炸</li></ul><p>而 SSM 提供：</p><ul><li>O(n) 線性縮放</li><li>固定大小 state</li><li>更適合超長序列</li><li>更接近長時間 Agent</li></ul><p>但代價是：</p><ul><li>有損記憶</li><li>早期資訊可能退化</li><li>精確召回能力下降</li></ul><p>這裡有一個非常重要的地方必須釐清：即使是 SSM、Linear Attention、超長 Context Window，它們本質上依然屬於 In-Context Learning，因為模型權重並沒有真正更新，模型並沒有因為經驗而永久改變自己。它只是：把「工作記憶」做得更長、更便宜、更有效率。</p><p>換句話說，這些技術雖然正在重新定義「記憶」，但還沒有真正跨進 Continual Learning 最核心的領域 — — 讓模型在部署後，真正改變自己的參數與表徵。</p><h3>Filing Cabinet Fallacy：檢索不等於學習</h3><p>當整個 AI 產業都在瘋狂擴大 Context Window、建構 Memory System、設計 Agent Framework 時，a16z 在文章中提出了一個非常重要的概念：</p><p>The Filing Cabinet Fallacy。</p><p>它其實是在逼整個 AI 圈重新捫心自問：如果 AI 只是越來越會「查資料」，那它真的算是在學習嗎？</p><p>這個觀點，來自 Ilya Sutskever 對 AGI 的一個核心思考：人類其實並不是一種「預先裝滿知識的系統」。人類出生時，知道的事情非常少。一個十五歲的少年，也許根本不懂醫學、法律、程式設計，甚至對世界幾乎一無所知。但人類擁有一種更重要的能力：持續學習。</p><p>人類會：</p><ul><li>工作</li><li>犯錯</li><li>修正</li><li>累積經驗</li><li>抽象化規律</li><li>建立新的理解框架</li></ul><p>智慧因此被詮釋為一種會隨時間複利成長的過程，而不是一個出廠時就完美無瑕的產品。</p><p>接著，文章提出了一個很有意思的思想實驗：想像你建造了一個系統，它擁有全世界最大的文件櫃，裡面儲存了所有人類知識、歷史文件、網頁與論文，而且索引極度完美。無論你問什麼，它都能瞬間找到答案。</p><p>問題來了：這樣的系統，真的算「學會了」嗎？</p><p>答案其實未必。因為它只是擁有了近乎無限的檢索能力。它可以查到任何東西，但它從來沒有被迫真正理解世界。</p><p>這也是整篇文章最核心的一個觀點：真正的學習，本質上是一種壓縮。大型語言模型之所以強大，並不是因為它把整個網際網路逐字背下來。恰恰相反，它真正厲害的地方，在於它把龐大的世界知識，壓縮進有限的參數空間裡。而這種壓縮，還是有損的（Lossy Compression）。模型不可能記住所有細節，因此它只能提煉規律、發現結構、建立抽象表徵，最後形成泛化能力。也正因如此，它才能舉一反三。</p><p>從某種程度上，人類大腦其實也是如此。我們不會逐字記住人生中的每一秒。我們會遺忘、模糊、壓縮經驗，但也正因如此，我們才能建立概念、形成直覺，甚至產生創造力。換句話說，智能的核心，也許從來不是「完整保存資訊」，而是「有效壓縮經驗」。</p><p>而這也正是今天 AI 最諷刺的地方。在預訓練時，我們拼命逼模型壓縮世界；但在部署之後，我們卻停止讓它繼續壓縮。模型不能真正更新自己的內部表徵，也無法把新的經驗內化進參數。</p><p>於是，我們開始替它加上 RAG、Memory Layer、Context Window、External Storage 與各種 Agent Workflow，讓模型越來越像一個巨大的文件櫃系統。它能查詢、能檢索、能回憶，但它是否真的還在「成長」？</p><p>這，正是 Continual Learning 真正想回答的問題。</p><h3>為什麼有些知識無法被塞進 Prompt？</h3><p>支持「超大 Context Window 就足夠」的人，常有一個很自然的想法：「既然模型可以透過 Prompt 學習，那只要把更多資訊塞進上下文，不就好了嗎？」但問題是，有些知識根本無法被完整寫進 Prompt。</p><p>只因那些知識本質上就無法被「直接描述」。例如：</p><ul><li>一位資深醫師，看一眼 MRI 掃描，就能直覺感受到某個陰影「不太對勁」。</li><li>一位資深工程師，看到 distributed system 的 log，幾秒內就能察覺異常。</li><li>一位圍棋高手，看到棋盤的瞬間，就能感受到局勢的氣。</li></ul><p>這些能力，很難被完整拆解成語言規則。因為它們往往不是單一知識點，而是大量經驗被長時間壓縮後，形成的高維表徵（representation）。這也是為什麼，a16z 文章特別提到：有些能力，可能只能存在於模型參數裡。</p><p>大型語言模型在訓練時，其實不只是記住知識。它更像是在建立一個巨大的 Hidden Space。這個空間裡，會逐漸形成：</p><ul><li>概念之間的距離</li><li>模式之間的相似性</li><li>隱性的結構關聯</li><li>高維抽象表徵</li></ul><p>例如「醫療」、「風險」、「腫瘤」、「異常組織」，可能並不是孤立詞彙，而是在 hidden space 裡形成某種複雜幾何結構。很多這種結構，人類其實無法完整用語言描述。Prompt 可以提供資訊，但它不一定能直接建立這些內部表徵。這也是文章為什麼提到數學史。數學，是人類最接近「純抽象結構」的知識領域。</p><p>而許多數學上的重大突破之所以曠日費時，往往不是因為資料不足，也不是因為人們缺少計算能力。真正需要時間的，是人類必須經過一代又一代的累積，才逐漸發現新的抽象表徵（representation），並建立原本看不見的結構連結。很多時候，答案其實早就隱藏在既有知識之中，只是人類還沒有足夠好的「理解框架」，去看見它們彼此之間的關係。</p><h4>Fermat’s Last Theorem</h4><p>最著名的例子之一，就是 Fermat’s Last Theorem。1637 年，Pierre de Fermat 在書頁空白處留下了猜想和一句著名註記：</p><p>我已找到一個真正美妙的證明，但這裡空間太小寫不下。</p><p>學家不斷接力推進。從 Leonhard Euler、Sophie Germain，到 Kummer、Hilbert 等人，無數成果逐漸累積，卻始終無法真正完成最後一步。</p><p>直到 20 世紀後半，人們才慢慢意識到：費馬大定理真正困難的地方，其實不在原方程本身，而在於它背後隱藏著不同數學領域之間極深的結構連結，包括橢圓曲線（Elliptic Curves）、模形式（Modular Forms）與 Galois Representation。</p><p>1980 年代，Gerhard Frey 與 Ken Ribet 陸續建立起關鍵橋梁，最終讓問題被轉化成另一個更深層的模性猜想。最後，1994 年，Andrew Wiles 經過七年近乎與世隔絕的研究，終於完成證明。</p><h4>Poincaré conjecture</h4><p>Poincaré conjecture 的歷史也非常類似。1904 年，Henri Poincaré 提出一個看似直觀、卻困擾數學界近百年的問題：如果一個三維空間中的所有閉合曲線，都能收縮成一個點，那這個空間是否一定等價於三維球體？</p><p>接下來近百年間，拓樸學（Topology）、微分幾何（Differential Geometry）與幾何分析（Geometric Analysis）不斷發展，人類也逐漸建立起更深的空間理解框架。</p><p>1980 年代，Richard Hamilton 提出 Ricci Flow，開始用「幾何流」的方式研究空間如何隨時間演化，這成為後來突破的關鍵起點。</p><p>直到 2002–2003 年，Grigori Perelman 發表三篇論文，最終完成證明。而 Perelman 不只解出了問題，也驚人地重新定義空間如何演化。</p><h4>Open Question</h4><p>這些突破，究竟只是「更多資料的重組」？還是某種更深層的內部表徵重建（representation restructuring）？如果是後者，那麼今天只依賴 Prompt、RAG 與 Context Window 的系統，可能永遠無法真正做到這種程度的創造性學習。</p><p>因為有些知識，並不是「寫下來」就能學會。它們必須經過長時間壓縮與內化，最後在 hidden space 裡形成新的結構連結。</p><p>而這正是 Continual Learning 最迷人的地方。</p><h3>上下文學習的四大缺陷</h3><p>雖然 In-Context Learning 已經非常成功，但 a16z 認為，它終究存在一些無法被單純「加大 Context Window」解決的根本限制。以下這四個問題，和 Transformer 的本質有關。</p><h4>推理時干擾（Inference-time Interference）</h4><p>模型在預訓練階段，已經從大量資料中學會了某些非常強的行為模式。例如，它可能早已深度內化：「只要使用者給出一段指令，就應該盡可能配合完成。」</p><p>而當模型部署後，我們再透過 System Prompt 告訴它某些事情不能做、某些內容不能輸出、某些請求必須拒絕時，本質上，其實是在用新的上下文規則，去壓制模型原本已經存在於參數中的行為傾向。問題在於，那些舊模式，往往比 Prompt 更深、更強。</p><p>因此，一旦使用者透過某些特殊技巧重新包裝請求，例如 Base64 編碼、Prompt Injection、偽裝成翻譯任務，或把惡意指令藏進文件內容，模型內部原本「看到指令就配合執行」的模式，可能又會重新被激活。</p><p>這也是為什麼很多 Jailbreak，本質上並不是「騙過 Prompt」，而是重新喚醒模型在預訓練時，早已學會的既有行為模式。換句話說，Prompt 並不能真正改寫模型。它只是暫時在推理階段，和模型參數裡既有的強模式競爭。</p><h4>強先驗覆蓋（Strong Priors）</h4><p>大型語言模型在預訓練時，已經吸收了大量既有知識，而這些知識有時會強大到連 Prompt 都很難覆蓋。例如，如果模型在 React 17 上訓練了海量資料，那麼即使你不斷告訴它「現在請使用 React 19 的最佳實踐」，模型仍然可能不自覺回到舊模式。因為那些模式，早已深深壓進參數之中。這也是很多人會發現，小眾框架、新語言、新 API，往往很難只靠 Prompt 就讓模型真正「轉換世界觀」。</p><h4>Reversal Curse</h4><p>這是一個非常能暴露 Transformer 本質的現象。例如，你告訴模型：「Olaf Scholz 是德國第九任總理。」模型通常可以回答「Olaf Scholz 是誰？」；但如果反過來問「德國第九任總理是誰？」，模型卻可能答錯。</p><p>原因在於，Transformer 裡很多知識關聯，本質上並不是對稱的。模型可能學會了 A → B，卻沒有真正建立 B → A。這代表模型很多時候並不是在操作真正抽象的概念，而更像是在延續某種高機率的 token pattern。</p><h4>有些知識根本無法被語言完整描述</h4><p>第四個問題，則是最深層、也最難解決的一個。</p><p>例如，一位醫師辨識腫瘤的直覺、一位交易員察覺市場異常的感覺，或是一位母語者對語氣細微差異的敏感度，往往都來自大量高維模式（high-dimensional patterns）的長期壓縮。這些能力可能存在於 hidden space 的幾何結構裡，但人類很難把它們完整翻譯成文字。</p><p>也因此，即使 Context Window 無限大，有些知識依然無法單純透過 Prompt 傳遞。因為 Prompt 本質上仍然是語言，而語言並不足以完整承載所有表徵。單純記住歷史資料，並不等於真正理解一個人。如果模型只是回憶你過去說過的話、重複你的偏好、檢索舊對話，那它其實更像一個高級記事本。</p><p>真正的人類理解，往往不是「記住內容」，而是把長期互動逐漸壓縮成內部表徵。很多時候，你甚至沒有明確說出口，但對方已經能預判你的習慣、價值觀與思考方式。這種能力，本質上更接近學習，而不是檢索。</p><p>也因此，a16z 才會認為：如果 AI 最終想真正從工具走向智能，那麼光靠 Context Window 與外部記憶，也許仍然不夠。</p><h3>Continual Learning 的完整光譜</h3><p>當人們第一次接觸 Continual Learning 時，很容易把問題理解成：「模型到底有沒有記憶？」但 a16z 認為，真正重要的問題是：模型究竟有沒有把新的經驗「壓縮進自己內部」。換句話說，關鍵不在於系統有沒有保存資訊，而在於壓縮（compression）到底發生在哪裡。</p><p>因為從 Continual Learning 的角度來看，並不是所有「記住資訊」的系統，都代表真正的學習。有些系統，只是把資訊存放在外部；有些系統，則開始把新的知識逐漸內化進模型本身。而這兩者之間，其實存在非常大的本質差異。</p><p>a16z 將目前整個領域，大致分成三條主要路線。</p><h4>非參數化（Non-parametric）</h4><p>這也是今天最成熟、最主流的方向，包括 Context Window、RAG、Vector Database、Agent Memory 與 Multi-Agent System。這類方法的共同特徵是：模型權重完全不變。</p><p>新的資訊，只是被暫時放進上下文，或儲存在外部記憶系統裡。模型會「看到」這些資訊，但不會真正改變自己。因此，這類系統更接近「檢索」。它們可以快速找到資料、重新組合資訊、延長工作記憶，但模型本身並沒有因為這些經驗而產生永久變化。</p><p>這條路最大的優點，是安全、穩定，而且幾乎不會出現 catastrophic forgetting。因為模型本身從來沒有被修改。也因此，今天絕大多數 AI 產品，其實都仍然屬於這個範疇。</p><h4>半參數化（Semi-parametric）</h4><p>這條路介於「完全外部記憶」與「真正修改模型」之間。代表性的做法，包括 Adapter、External Memory Module、KV Compression 與各種知識插件（Knowledge Plugin）。</p><p>它們通常不會直接修改整個 foundation model，而是額外增加某種局部模組，讓模型能針對特定領域快速適應，同時避免破壞原本的大模型能力。因此，新的知識雖然開始被整理、壓縮，甚至局部內化，但仍然沒有真正進入模型最核心的參數空間。</p><p>正因如此，這條路線在工程上具有很強的吸引力。它一方面保留了 foundation model 的穩定性，另一方面又能讓系統對特定任務產生某種「學習效果」，而且部署成本與風險，都遠低於真正的全量參數更新。</p><p>因此很多人認為，這可能會是未來幾年最務實、也最容易落地的 Continual Learning 方向之一。</p><h4>參數化（Parametric）</h4><p>這條路的核心目標，是讓模型在部署之後，依然能持續更新自己的內部表徵。也就是說：新的經驗，不再只是被檢索，而是開始真正改變模型。這包括 Continual Fine-tuning、Test-time Training（TTT）、Reinforcement Feedback Loop、Sparse Memory Layer 等方法。</p><p>在這種情況下，模型不只是「記得」某件事，它會因為新的經驗，而永久改變自己的 hidden space。這才是真正意義上的學習，但問題也隨之而來。</p><p>一旦模型開始修改自己的參數，就可能出現 catastrophic forgetting、alignment drift、data poisoning，甚至各種難以預測的不穩定行為。也因此，這條路雖然最接近真正的 Continual Learning，但同時也是最危險、最難控制的一條路。</p><p>從某種程度上，這三條路線，其實也代表了三種不同層次的智能系統。非參數化系統，本質上仍然是高度依賴外部檢索的工具；半參數化系統，開始出現局部內化與任務適應能力；而真正的參數化 Continual Learning，則開始接近一種能隨經驗持續演化的智能體。</p><h3>持續學習最殘酷的現實：為什麼直接更新權重目前行不通？</h3><p>當人們第一次聽到 Continual Learning 時，很容易產生一種直覺：「既然模型不會持續學習，那讓模型直接更新權重不就好了嗎？」但事情其實沒有那麼簡單。</p><p>a16z 在文章裡特別強調，真正困難的地方，是如何讓模型在持續學習的同時，不把自己學壞。因為一旦模型開始在部署後持續更新參數，就會出現一連串非常棘手的連鎖失效（failure modes）。而四個問題，即使到今天，也還沒有在大規模生產環境中被真正解決。</p><h4>災難性遺忘（Catastrophic Forgetting）</h4><p>新的學習，可能直接摧毀舊的表徵。這是 Continual Learning 幾十年來最核心的難題之一。</p><p>例如，你拿一個多語言模型去做程式碼微調。結果模型的 coding 能力確實變強了，但原本流利的法語能力卻突然大幅退化。因為神經網路的參數空間，本來就是高度共享的。當模型為了適應新任務而調整參數時，舊能力可能也會一起被改寫。</p><p>這其實反映了一個更深層的矛盾：模型既需要穩定性（stability），保留舊知識；又需要可塑性（plasticity），學習新知識。而這兩者，往往彼此衝突。</p><h4>Temporal Disentanglement</h4><p>中文很難完全翻譯，大致可以理解成：「不變規則」與「可變狀態」被混在同一組參數裡。</p><p>例如，一家餐廳「有五號桌」，這是一種長期穩定的規則；但「今天五號桌的客人對堅果過敏」，則只是暫時狀態。模型原本只需要記住：「五號桌今天需要避免堅果。」</p><p>但在參數更新之後，模型可能開始產生各種意料之外的偏移，它也許會錯誤地推論：「五號桌永遠不能提供堅果」</p><p>因為在當今的 neural network 裡，很多不同層次的知識，其實被壓縮在同一片參數空間中。模型很難真正分清：哪些是永久規律， 哪些只是暫時資訊。</p><h4>邏輯整合失敗（Logical Integration Failure）</h4><p>這也是現今 language model 很有趣的一個現象：即使你更新了一個事實，模型也不一定能同步更新所有相關推論。</p><p>例如，你告訴模型：「現在的美國總統已經換人了。」但當你再問：「誰住在白宮？」它卻可能仍然回答舊總統。</p><p>原因在於，模型的更新，很多時候只是局部 token pattern 的調整，而不是整個語義結構的同步重建。也就是說，模型未必真正「理解」了概念之間的關聯。</p><h4>Unlearning</h4><p>模型其實非常難「精準忘記」某些知識。</p><p>這聽起來有點反直覺。因為人類會以為：既然模型能學習，那應該也能反向刪除知識。</p><p>但問題在於，神經網路裡並不存在一種簡單的「減法操作」。模型學到的知識，通常是分散式地儲存在大量參數之中。一段錯誤知識、有毒內容，甚至某個人的隱私資訊，很可能早已擴散進整個 hidden space，你很難只刪掉其中一小部分，而不影響其他能力。</p><p>這也是為什麼 Machine Learning 裡的「忘記」，很多時候其實比「學習」更困難。</p><h3>當模型開始持續更新：安全邊界為何瞬間崩塌？</h3><p>今天 AI 世界裡「訓練（training）」與「部署（deployment）」之所以被嚴格分開，其實不只是工程便利。它同時也是安全（security）、審計（audit）、治理（governance）最重要的邊界。</p><p>在 today’s LLM architecture 裡，模型一旦完成訓練、通過 alignment 與 safety evaluation，權重通常就會被凍結下來。這代表模型行為是相對固定、可測試、可驗證的，可以：</p><ul><li>做 regression test</li><li>做 safety benchmark</li><li>做紅隊測試（red teaming）</li><li>做 compliance certification</li></ul><p>但 Continual Learning 一旦真正實現，這條邊界可能會瞬間崩塌，因為模型開始變成一個持續演化的 moving target，會有以下三個問題。</p><h4>Alignment Drift</h4><p>即使只是做某個看似無害的窄域微調（domain-specific fine-tuning），模型的安全行為也可能開始出現難以預測的退化。</p><p>例如，你原本只是想讓模型更擅長醫療問答，結果模型卻在其他領域突然變得：</p><ul><li>更容易 hallucinate</li><li>更容易被 jailbreak</li><li>更容易產生危險建議</li></ul><p>原因在於，神經網路裡的能力往往高度共享，局部更新可能會影響整個 hidden space 的幾何結構。</p><h4>Data Poisoning</h4><p>如果模型會持續從外部世界學習，那麼攻擊者也可能開始「餵養」模型。而這種攻擊，比 today’s prompt injection 更危險。因為 prompt injection 至少只存在於當前 context 裡；但 Continual Learning 下的惡意資料，可能會被永久壓縮進模型權重之中。</p><p>這很像慢性、持久化的 prompt injection。只是這次，惡意內容不再停留在對話上下文，而是直接刻進模型的大腦裡。</p><h4>審計能力（Auditability）的崩潰</h4><p>today’s software engineering 有一個非常重要的前提：系統版本是固定的。因此你可以：</p><ul><li>rollback</li><li>compare versions</li><li>reproduce bugs</li><li>做 regression analysis</li></ul><p>但如果模型每一天、甚至每一小時都在持續更新，那麼很多傳統軟體工程的方法都會開始失效。因為你很難再回答：「這個模型現在到底是哪個版本？」</p><p>模型可能在不同時間、不同使用者、不同地區，都已經學到不同東西。</p><h4>隱私（Privacy）</h4><p>如果模型開始把使用者互動直接壓縮進 weights，那麼敏感資訊也可能一起進入 hidden representation。而這會比 today’s context-based memory 更危險。</p><p>context window 至少還能刪除、過濾、檢索、清空。但一旦資訊被壓縮進參數空間裡，就很難再精準移除。這也是為什麼 Machine Unlearning 會變成近年非常重要的研究方向。</p><p>a16z 強調，這些問題並不代表 Continual Learning 本質上不可行。但它揭示了一件事：Continual Learning 的挑戰，並不只是模型架構，它同時也是：</p><ul><li>security problem</li><li>systems problem</li><li>governance problem</li><li>epistemology problem</li></ul><p>甚至某種程度上，也是文明層級的問題。</p><h3>當今 ChatGPT 是如何部署的？</h3><p>當理解 Continual Learning 之後，一個很自然的問題便會浮現：「今天的 ChatGPT，到底是如何部署的？」</p><p>很多人第一次使用大型語言模型時，都會產生一種直覺：模型似乎正在「學習」。它會記得對話、模仿使用者語氣、引用前文，甚至表現出某種逐漸理解你的感覺。但事實上，今日 GPT 類模型，大多並不是以「持續學習」的方式部署的。</p><p>當今 GPT 通常會先經過一整套離線訓練流程（offline training pipeline），包括：</p><ul><li>大規模預訓練（pretraining）</li><li>指令微調（supervised fine-tuning, SFT）</li><li>alignment 與 RLHF（reinforcement learning from human feedback）</li></ul><p>在這些訓練階段完成後，模型的核心權重（weights）通常就會被凍結（freeze），接著才正式部署到 production environment。部署後的模型，通常不會因為每天與使用者互動，而持續更新自己的 foundation model weights。</p><p>某種程度上，today’s LLM 更像是一個被凍結的世界快照（frozen snapshot）。它擁有龐大的知識、強大的推理能力，以及驚人的語言泛化能力；但它對世界的理解，本質上仍然停留在某個訓練時間點。</p><p>而這也正是為什麼模型公司必須不斷發布新版本。因為世界本身是持續變動的。新的新聞會出現、新的 API 會發布、法律會更新、科學會進步，人類語言與文化也一直在演化。如果模型不重新訓練，它與現實世界之間的落差就會越來越大。</p><p>因此，GPT-3、GPT-4、GPT-4.5、GPT-5 的差異，並不只是「更聰明」。在某種程度上，其實也是不同時間點的世界模型（world model）。</p><p>不過，這並不代表 today’s ChatGPT 完全沒有「記憶」。只是這些記憶，大多並不存在於模型參數裡。例如 Context Window、Chat History、RAG、Vector Database、Memory Feature 與 External Storage，本質上都屬於模型外部的記憶系統。它們讓模型看起來像是在「記住事情」，但很多時候，其實只是把資訊重新注入上下文，再讓模型進行推理。</p><p>當今的 AI architecture 已經開始越來越像電腦科學中的記憶體階層（memory hierarchy）。不同類型的知識，被放在不同層級：數學與語言能力，存在 weights 裡；即時新聞，交給 search 與 retrieval；使用者偏好，放進 memory layer；長任務狀態，保存在 context window；即時工具結果，則透過 agent workflow 動態取得。</p><p>因此，today’s AI 已經不再只是單一模型。它更像是一整套由權重、上下文、檢索、工具與外部記憶共同組成的認知系統（cognitive system）。</p><p>這也讓我想起自己一個很有趣的親身經歷。</p><p>幾個月前，我看完一部甫播畢的美劇《Dexter: Resurrection》。當時我很自然地跑去問模型某段劇情、角色動機，甚至想和它討論一些伏筆。結果 GPT 無論如何努力都答不出來。</p><p>當時我很困惑，因為在我的直覺裡，ChatGPT 應該要「什麼都知道」。直到剛剛看完 a16z 的文章，我才恍然大悟：其實模型不是「不夠聰明」，而是那部劇，很可能根本沒有被訓練進它的世界模型裡。</p><p>畢竟，today’s GPT 並不是一個會即時觀看 Netflix、追新劇、同步更新世界知識的系統。它也不會像人類一樣，在昨晚看完一部影集後，今天就把新的角色關係與劇情發展內化進自己的長期記憶。它知道的世界，只停留在某個訓練時間點。如果新的作品、新聞、迷因、事件沒有進入下一輪訓練資料，那麼對模型而言，那些東西幾乎就像不存在一樣。</p><p>today’s LLM 並不是「正在活著的大腦」，它更像是一份被凍結的世界快照（frozen snapshot）。而這也讓 Continual Learning 這件事變得格外迷人 — — 讓 AI 持續把新的世界，慢慢內化進自己。</p><h3>Appendix</h3><ol><li>Why We Need Continual Learning: <a href="https://a16z.com/why-we-need-continual-learning/">https://a16z.com/why-we-need-continual-learning/</a></li><li><a href="https://www.youtube.com/watch?v=zGpQ4WAGMMI">https://www.youtube.com/watch?v=zGpQ4WAGMMI</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=49486a1aa5cc" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Chapter 10. Stream Processing Engine: The Birth of Stream Processing]]></title>
            <link>https://medium.com/@0050211/from-database-to-data-lakehouse-part-12-8734fc1a7c59?source=rss-663daee8909b------2</link>
            <guid isPermaLink="false">https://medium.com/p/8734fc1a7c59</guid>
            <dc:creator><![CDATA[Ryan Fu]]></dc:creator>
            <pubDate>Mon, 27 Apr 2026 07:35:23 GMT</pubDate>
            <atom:updated>2026-06-14T18:21:55.250Z</atom:updated>
            <content:encoded><![CDATA[<p>《<a href="https://medium.com/@adrianhsu/%E7%B5%A6%E5%AD%B8%E5%BC%9F%E5%A6%B9%E7%9A%84%E7%9F%BD%E8%B0%B7-ai-software-%E6%8C%87%E5%8D%97-da054b7a8dcb">給學弟妹的矽谷 AI / Software 指南</a>》是一個矽谷工程師自發的眾籌寫作計劃，由<a href="https://www.facebook.com/opensource4you">源來適你社群</a>推動。這將是一個在台灣軟體學生圈內很有影響力的文章系列。已有 &gt;10 篇準備發布，目標 100 篇並出書，歡迎 tag 強者大神來寫！</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vcanG3MJN_B6ZPXPxkWnfA.jpeg" /></figure><p>上一章開始，我們正式進入了 Stream 的世界。</p><p>我曾這樣介紹：</p><p>進入 Data Lake 時代後，可透過 streaming 的方式即時傳輸資料。不再等待深夜批次處理，而是讓資料持續流動，即時同步到目標系統。若採用這種方式，Kafka 就成為不可或缺的工具 — — 它為即時資料傳輸搭起橋樑。</p><p>Kafka 解決的是一個關鍵問題：<strong>資料如何持續流動。</strong>但資料只是流動，本身並沒有意義。</p><p>Store 的下游，必須接上 Processing Engine，才有意思。Kafka 作為一個 Stream Store，它的下游可以有兩種典型使用方式：</p><ul><li>每小時讀取一批資料，交由 Batch Job 分析；</li><li>或交由 Stream Processing Engine，即時處理。</li></ul><p>前者仍然停留在「資料累積後再處理」的邏輯；後者則嘗試處理「正在發生的事情」。這兩者看似只是延遲差異，實則代表兩種完全不同的計算哲學。</p><p>在本書鋪陳的結構中：</p><ul><li>在 Data Source 端，我已詳細介紹了 Database；</li><li>在 Data Destination 端，我介紹了 Data Warehouse、Data Lake，並初步窺探了 Lakehouse；</li><li>在中間件層，我介紹了 Kafka，這個承載資料流動的橋樑。</li></ul><p>熟悉我鋪排的讀者應該不難猜到 — — 既然我們已經理解了資料如何產生、如何儲存、如何流動，那麼接下來自然要問：</p><p><strong>如何計算正在流動的資料？</strong></p><p>本章，我們將正式進入 Kafka 下游的核心 — — <strong>Stream Processing Engine。</strong></p><p>我們會從 2010 年 Yahoo 推出的 S4 開始。那是一個仍然粗糙、卻首次將 streaming 作為一級公民的系統原型。緊接著是 Twitter 的 Storm，它將 streaming 帶入 production，證明這種模式可以在現實世界運作。</p><p>在這一章，讀者可以用一種相對輕鬆的心情來閱讀。S4 與 Storm 開啟了一條新的方向，是過渡階段的探索，而非最終答案。這條路最終會引領到集大成的系統 — — Flink，因此下一章更適合在精神飽滿時細細閱讀。</p><p>到這裡讀者或許有疑問：既然 S4 與 Storm 都不是最終解，那為何還需要花篇幅理解它們？</p><p>一個技術，從來都不是憑空出現的。它背後總有一些人，在某個時間點、某個環境裡，面對具體的問題與限制，做出了一些選擇。也許是某個工程師在深夜 debug 系統時發現的瓶頸，也許是一家公司在規模成長後無法再承受既有架構的壓力。</p><p>當我們試著把這些「人、事、時、地、物」拼湊起來時，S4、Storm，甚至後來的 Flink，就不再只是幾個系統名稱，而是同一條演化路徑上的不同節點。S4 回應了 batch 無法即時處理的問題；Storm 則進一步回應了可靠傳遞的需求；而 Flink，則是在三個懸而未解的問題之上，試圖給出更完整的答案。</p><p>也正是在這樣的演進脈絡中，我們才開始真正理解，Kafka 與 Streaming Processing Engine 各自扮演的角色 — — Kafka 讓資料流動起來；Streaming Processing Engine 則讓這條河五顏六色。</p><p>之所以五顏六色，不是因為工業污染，而是因為 <strong>Stream Processing Engine</strong> 能在資料流動的瞬間，重新塑形每一筆資料。它可以即時聚合、即時過濾、即時轉換，讓資料在尚未沉澱為靜態儲存之前，就已經呈現出新的樣貌。</p><p>在這個世界裡，資料在流動之中持續被理解、被修正、被更新。河流不再單調，而是一條會隨時間改變顏色的動態結構。</p><h3>S4（2010）</h3><h4>Introduction</h4><p>2010 年，Yahoo 推出了 S4（Simple Scalable Streaming System）。在 MapReduce 仍然主導資料處理思維的時代，S4 嘗試回答一個不同的問題：如果資料不是靜態地躺在儲存系統裡，而是持續流入系統，我們是否能以長期運行的方式，即時處理這些資料，而不再等待批次作業？</p><p>S4 是最早公開的分散式 Stream Processing Engine 之一。它的核心思想很簡單卻具有前瞻性：資料以事件（event）的形式持續流動，而系統中的處理單元則長期存在，隨著事件到來即時更新內部狀態並產生新的事件。</p><p>在這個設計中，資料不再是等待被讀取的檔案，而是觸發計算的動態訊號。</p><h4>Definition</h4><p><strong>Event</strong></p><p>在 S4 中，系統的基本資料單位稱為 <strong>event</strong>。官方將其定義為不可變（immutable）的資料記錄。Event 在系統中流動，並可能包含一個 key，是整個擴展模型的核心：系統透過對 key 進行雜湊（hash），決定該 event 應被送往哪個節點。</p><p>因此，key-based routing 是 S4 水平擴展能力的基礎。</p><p><strong>Processing Element (PE)</strong></p><p>S4 的核心抽象是 <strong>Processing Element（PE）</strong>。PE 是一個可維持內部狀態的處理單元。它長期存在，持續接收 event，更新自身狀態，並可能產生新的 event。與批次系統不同，PE 並不是一次性執行，而是常駐於系統中的狀態機。</p><p><strong>Per-Key Stateful Computation</strong></p><p>在 S4 的模型中，每一個 key 對應一個 PE instance。相同 key 的 event 會被路由到同一個 PE，使得該 PE 可以維護與該 key 相關的內部狀態。</p><p>這種設計等同於一種 per-key stateful computation。每個 key 都擁有自己的狀態空間，形成一個小型的獨立狀態機。這種 per-key 分割機制，使得系統既可擴展，又能維持狀態一致性（在單一節點範圍內）。</p><p><strong>Stream</strong></p><p>Event 在 PE 之間以 <strong>stream</strong> 的形式傳遞。官方將 stream 定義為 event 的序列。然而，S4 並未建立嚴格的時間語意模型。Event 可以攜帶時間資訊，但系統本身並未制度化 event time、watermark 或 window 等概念。</p><p>這意味著時間相關的邏輯必須由應用程式自行實作。</p><p><strong>Application Graph</strong></p><p>一個 S4 應用由多個 Processing Element（PE）所組成，這些 PE 透過 stream 相互連接，形成一個持續運行的處理圖。與後來更形式化的拓撲模型相比，S4 的應用圖相對簡單，但其核心思想已十分清晰：計算不再是一次性的批次執行，而是一個長期存在的狀態轉移過程。</p><p>從結構上來看，這樣的處理圖在實務上常被畫成 DAG（Directed Acyclic Graph），資料沿著節點之間的邊持續流動。然而，將 S4 僅僅理解為一個 DAG，容易忽略它更本質的設計。</p><p>S4 的官方模型是一種 event-driven graph。每個 PE 接收 event、更新內部狀態，並產生新的 event，再透過 key-based routing 傳遞給下一個 PE。這種運作方式，使得整個系統更接近 Actor model 與 message passing 的組合，而非一個靜態的計算圖。</p><p>因此，S4 的重點在於兩個核心機制：</p><ol><li>以 key 為中心的 state 管理，讓每個 PE 都能持續記住並更新自己的狀態。</li><li>同時透過 event 在系統中流動，讓計算一步一步往前推進。</li></ol><h4>Use Case: Top K Word Count in S4</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1Vc48VpcYzbrZcM9QSjiCA.png" /></figure><p>這張 Application Graph 展示了一個典型的 S4 應用：在整個系統中持續計算出現次數最多的前 K 個單詞。這不是一次性的批次運算，而是一個隨資料流動而不斷更新的狀態問題。資料持續進來，計數持續改變，Top K 也隨時間演化。整個流程被組織為一個由 Processing Element（PE）構成的有向圖。</p><p>在圖中，我們看到 PE1 到 PE8 這樣的標記，它們只是用來表示在分散式環境中被複製與部署的多個 PE instance。讀者真正必須關心的是<strong>每一層 PE 的角色與它們之間的連接關係</strong>。</p><p>例如，最上層的 QuoteSplitterPE 負責接收原始句子，將其拆分成單詞；中間一層的 WordCountPE 依據 key（單詞）維護各自的計數；再往下的 SortPE 則對局部資料進行排序；最後由 MergePE 將多個局部結果整合為全域的 Top K。這些邏輯上的角色，才是應用圖的核心。</p><p>至於像 PE2、PE3、PE4 這樣的節點，代表的是同一種類型的 PE 被平行部署多個 instance。這種 fan-out（平行度）通常是由開發者在設定中決定的，用來分散負載。在 S4 的模型中，這個數量並不會隨系統自動調整，而是需要在部署時事先配置，因此某種程度上是「寫死」的。系統會根據 event 的 key 做 hash，將資料路由到對應的 PE instance，確保相同 key 的資料會被送到同一個節點。</p><p>如果從實務角度來看，使用 S4 開發一個應用，大致可以分為三個步驟。</p><p>第一步，定義資料的流動方式，也就是設計 event 的結構與 key。key 的選擇會直接影響狀態如何被切分與分散。</p><p>第二步，要在各個 Processing Element 裡定義兩件事情：一是「我會接收哪些 event」，二是「我處理完之後會發出哪些 event」。</p><p>乍看之下，當開發者在定義「某種 event 應該被哪個 PE 接收」時，似乎已經在描述一張計算圖。從這個角度來看，將 S4 的應用畫成 DAG，並沒有錯。但關鍵差異在於：這張圖並不是一個被明確宣告、也不是一個會被編譯（compile）與最佳化的拓撲（topology）。</p><p>在 S4 中，並不存在一個集中式的 compile 階段，去驗證整個應用是否構成一個嚴格的 DAG。開發者也不需要先定義完整的圖結構。相反地，系統只關心 event 如何在不同的 PE 之間流動。至於這些 event 最終會串成什麼樣的結構，是在系統運行過程中，由 event 的流動與 key-based routing 自然形成的。</p><p>換句話說，圖是執行的結果，而不是設計的起點。這個結構通常可以被觀察為一個 DAG，但它並不是由系統在執行前所明確建構與驗證的。</p><p>第三步，設定系統的平行度與部署方式，包括每種 PE 要啟動多少個 instance，以及它們如何分布在不同節點上。</p><p>整體而言，開發者需要思考的並不是「資料在哪裡被處理」，而是「資料應該如何流動，以及每一個節點應該做什麼」。至於資料如何被分發、節點如何被調度，則交由系統來負責。</p><h4>Summary of S4</h4><p>S4 在 2014 年從 Apache 的孵化計畫中正式退場（retired）。至此，讓我們來檢視它的歷史地位。</p><p>當大數據的價值開始被企業真正重視，MapReduce 那種定期、批次式的處理模式逐漸顯得遲緩。業務需求開始要求更低延遲的回應，資料不再只是用來做歷史分析，而是要反映「正在發生的事情」。在這樣的背景下，流式計算首次走上舞台中央。</p><p>S4 正是這個轉折點的產物。Yahoo 嘗試用它來實現低延遲的即時資料處理，例如廣告與搜尋資料的即時回饋，以及線上 A/B 測試。S4 的設計理念與 MapReduce 在精神上有某種延續：開發者只需實作 Processing Element 的業務邏輯，而無需關心分散式系統如何運行。資料分發、節點調度與基本容錯機制由框架負責。</p><p>在架構上，S4 將業務邏輯與底層的網路協定、資料路由與負載分散機制拆分開來，形成可插拔（pluggable）的系統設計。整體流程被建構為一個有向圖，每個節點是一個 Processing Element，每條邊是一條事件流。每個 PE 被部署在某個處理節點上，負責執行業務邏輯並維護記憶體中的狀態。計算結果可以定期發布到外部儲存系統，使其對外可用。</p><p>然而，S4 的設計也顯得相當原始。它採用完全對稱、去中心化的分散式架構，試圖避免單點故障，但也因此缺乏靈活的動態擴展能力。當流量激增時，系統更傾向於採取服務降級，而非動態擴容。</p><p>在容錯層面，S4 僅處理節點層級的失效。當節點故障時，系統可以在其他硬體上重新啟動對應的處理元素，但已存在於記憶體中的狀態可能已經遺失。對於節點之間的資料傳輸，系統也未提供端到端的傳遞保證。</p><p>換句話說，S4 成功證明了一件事：流式計算可以被建構成一個可擴展的分散式系統。但它尚未回答更深層的問題：當狀態與資料同時流動時，如何在故障發生之後仍然確保計算結果的正確性？</p><p>S4 的種種限制，都成為 Storm 和之後系統的改進方向。</p><h3>Storm（2011）</h3><h4>Introduction</h4><p>如果說 S4 證明了流式計算是可能的，那麼 Storm 則試圖讓它變得可靠。</p><p>2011 年，Twitter 公開了 Storm。當時 Twitter 面臨的問題十分具體：使用者產生的訊息流量龐大，熱門話題、推薦系統、廣告排序等應用都需要在極低延遲下持續更新結果。單純依賴批次系統已無法滿足這樣的需求。</p><p>Storm 的出現，並不是要重新定義計算模型，而是要解決一個更迫切的問題 — — 如何在分散式環境下，可靠地處理無界資料流。</p><h4>Definition</h4><p>Storm 由以下幾個核心組件構成。</p><p><strong>Spout</strong></p><p>Spout 是資料來源。它負責從外部系統讀取或接收資料，並將資料轉化為 Tuple 持續發送進系統。Spout 本身不負責計算邏輯，而是扮演輸入端角色。與 S4 將所有節點統一為 Processing Element 不同，Storm 明確將資料來源抽象為獨立組件。在概念上，Spout 可以對應到 S4 中的無鍵 PE，但在模型層面，它被清楚區分為「產生資料」而非「處理資料」的單元。</p><p><strong>Tuple</strong></p><p>Tuple 是 Storm 中最小的資料單位。官方定義為一個帶有命名欄位的值列表。開發者在宣告時為欄位命名，但在實際傳輸過程中，系統只需傳輸欄位的位置與值。這種設計使得資料結構定義與傳輸過程分離。在概念上，Tuple 對應於 S4 的 Event。</p><p><strong>Stream</strong></p><p>Stream 是無界的 Tuple 序列。資料在 Topology 中透過 Stream 傳遞，並由系統分散到多個節點並行處理。Stream 並不代表一個有限集合，而是一個持續存在的資料流。</p><p><strong>Bolt</strong></p><p>Bolt 是執行計算邏輯的節點。它可以接收一個或多個輸入 Stream，並產生一個或多個輸出 Stream。所有業務邏輯 — — 例如轉換、過濾、聚合或將結果寫入外部系統 — — 都在 Bolt 中實作。</p><p>乍看之下，Bolt 與 S4 的 Processing Element（PE）非常相似。然而，兩者在抽象層面存在關鍵差異。</p><p>S4 在模型中明確定義 per-key Processing Element。每個 key 對應一個 PE instance，狀態與 key 綁定。而在 Storm 中，Bolt 並不天然具備 per-key 語意。若需要將相同 key 的 Tuple 發送至同一個 Bolt 任務，必須透過 grouping 機制（例如 fields grouping）來實現。換言之，per-key 行為在 Storm 中是一種配置結果，而不是模型內建抽象。</p><p>此外，PE 在 S4 中被視為 stateful 的核心單元；而 Bolt 是否維護狀態完全取決於開發者實作。Storm 並未在模型層制度化狀態管理。</p><p><strong>Topology</strong></p><p>Topology 是 Storm 中最關鍵的抽象。與 S4 不同，這張圖不再是執行過程中逐漸形成的結構，而是由開發者在系統啟動前明確定義。</p><p>開發者需要清楚描述：</p><ul><li>有哪些 Spout 與 Bolt</li><li>它們之間如何連接</li><li>資料應該如何在不同節點之間流動（透過 grouping）</li></ul><p>在這個過程中，Topology 會在提交（submit）到叢集前經過基本的驗證，確保整體結構與設定是合法且可執行的。換句話說，這張圖不只是設計概念，而是一個會被系統接受並執行的計算描述。</p><p>在大多數情況下，Topology 被設計為一個有向無環圖（DAG）。這樣的限制，使資料能夠沿著單一方向流動，也讓系統在追蹤資料處理狀態與進行錯誤恢復時，能維持相對簡單的模型。這一點與 Storm 的 <strong>ack 機制</strong>與 <strong>tuple tree</strong> 設計密切相關，後續會再詳細說明。</p><p>不過，這並不代表 Storm 完全無法表達具有回饋（feedback）的計算。透過較高階的 API（例如 Trident）或結合外部系統，開發者仍然可以實現帶有「環」的邏輯流程。但在核心模型中，Storm 仍然傾向將計算限制在 DAG 之內，以換取更可控的可靠性與系統行為。</p><p>想進一步思考的讀者或許會問：什麼樣的情況，會需要一個帶有 feedback 的計算圖？</p><p>答案通常出現在那些「結果會影響下一步計算」的場景。例如圖演算法中反覆更新節點權重、機器學習中持續調整模型參數，或是即時推薦系統中，根據使用者最新行為不斷修正排序結果。這類問題的共通點在於：計算並不是單向推進的，而是需要將上一輪的輸出重新納入下一輪的輸入，形成一種持續迭代的過程。</p><p>簡而言之，DAG 所描述的是一種「資料一路往前流動」的世界；而一旦引入 feedback，計算就不再只是流動，而是開始「回頭修正自己」。這種模型在表達能力上更為強大，但同時也會大幅增加系統在狀態管理與錯誤恢復上的複雜度。</p><p>而 Word Count 這個用例的圖是一個典型的 DAG。</p><h4>Use Case: Word Count</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*J9p_SFvXtwVuOqRXiwv5NQ.png" /></figure><p>Storm 官方文件中最常見的示例，是對 Tweet 進行即時單詞統計。我們可以從這個例子，理解 Storm 的可靠傳遞機制。</p><p>整個 Topology 的起點是一個 <strong>TweetSpout</strong>。它從外部資料流中讀取 Tweet，將每一條 Tweet 作為一個 Tuple 發送進系統。這個最初由 Spout 發出的 Tuple，被視為整棵計算樹的根。</p><p>接著，Tuple 被隨機分派到 <strong>ParseTweetBolt</strong>。這個 Bolt 將 Tweet 解析為單詞，並為每個單詞產生新的 Tuple，發送給下游的 <strong>WordCountBolt</strong>。不同的單詞會透過 fields grouping 被送往不同的 WordCountBolt 任務，因此整個計算過程呈現分散式並行。</p><p>在這個過程中，任何一個下游 Tuple 若未成功處理，都意味著整體處理失敗。Storm 的設計並不是局部補救，而是從 Spout 重新發送整條根 Tuple。這正是 Storm 所謂的 <strong>tuple tree</strong> 模型：所有由 Spout 發出的 Tuple，與其衍生出的所有子 Tuple，共同構成一棵邏輯上的樹。</p><p>為了追蹤這棵樹是否處理完成，Storm 引入了 <strong>Acker</strong> 機制。</p><h4>The XOR-Based Ack Mechanism</h4><p>在 Storm 中，每一個由 Spout 發出的 Tuple，都會被分配一個 64 位元的 message-id。我們可以想像系統中有一個 Spout_1，它從外部資料來源讀取一筆資料，例如一條 Tweet，並將其封裝成一個 Tuple 發送出去。假設這筆資料的 message-id 是 M。</p><p>當 Spout_1 發送這個 Tuple 的同時，系統會通知 Acker 開始追蹤這筆資料。此時，Acker 會建立一筆紀錄：</p><p>M → checksum = M</p><p>這裡可以把 checksum 想成一個「尚未完成工作的總和」。一開始只有這一筆資料，因此 checksum 就等於 M。本質上，Acker 的任務就是持續更新這個值，直到所有相關工作都完成為止。</p><p>接下來，這個 Tuple 進入第一個 Bolt，例如 ParseTweetBolt_1。這個 Bolt 將一句話拆解成多個單詞，並產生多個新的 Tuple，例如 [“the”], [“fox”], [“jumps”] 等。這些新 Tuple 都會帶著同一個 root message-id M。</p><p>當 ParseTweetBolt_1 發送這些新 Tuple 時，它會通知 Acker：「我產生了新的工作」。同時，當它完成對原始 Tuple 的處理時，也會再通知 Acker：「我已經處理完了這一段」。</p><p>後續的 Bolt，例如 WordCountBolt_1、WordCountBolt_2，也會做同樣的事情：<br> 每當它們</p><ul><li>發送新的 Tuple（代表新增工作）</li><li>或完成一個 Tuple 的處理（代表完成工作）</li></ul><p>都會通知 Acker。</p><p>Acker 並不儲存整棵 Tuple tree，也不記錄每個節點之間的關係。它只做一件事情：用 XOR（異或）運算，把這些「新增工作」與「完成工作」累積到 checksum 上。</p><p>這個機制之所以成立，關鍵在於 XOR 的一個性質：</p><p>A XOR A = 0</p><p>也就是說，一個工作被「加入」一次、再被「完成」一次，對 checksum 的影響會互相抵消。因此，你可以把 checksum 理解為系統中「還沒完成的工作」的總體表示。只要還有任何一個衍生出的 Tuple 尚未完成處理，checksum 就不會是 0。一旦整棵 Tuple tree 上的所有 Tuple 都完成處理，所有的 XOR 最終會抵消為 0。此時，Acker 會通知 Spout_1：</p><p>message-id = M 的這筆資料，已經被完整處理。</p><p>這樣的設計還有一個重要的工程優勢。對於每一筆 root message-id，Acker 並不需要儲存整棵計算樹。它只需要記錄兩個 8 bytes 的數值，就可以代表整棵分散式計算流程的狀態：</p><ul><li>message-id（8 bytes）</li><li>checksum（8 bytes）</li></ul><p>換句話說，Acker 是用一個非常小的固定大小狀態，來追蹤一個可能非常龐大、跨多個節點的計算過程。</p><p>最後，Storm 會為每一筆 root Tuple 設定一個 timeout（預設約數十秒）。如果在這段時間內，checksum 一直沒有回到 0，代表仍然有某些工作沒有完成 — — 可能是某個 Bolt 處理失敗，或資料在系統中遺失。在這種情況下，Storm 會判定這筆資料處理失敗，並通知 Spout_1 重新發送這筆資料，讓整個流程重新執行一次。</p><p>整體而言，這個機制的本質可以用一句話概括：Storm 用一個簡單的 checksum，來代表整棵計算樹「還沒完成的部分」，當它變成 0，就代表整筆資料處理完成。</p><p>這是一種極為工程化、同時也相當優雅的設計：不用保存複雜的資料結構，只透過位元運算，就能追蹤整個分散式系統中的計算進度。</p><h4>Delivery Guarantees</h4><p>然而，這套 Ack 機制所提供的保證是 <strong>at-least-once</strong>。</p><p>若任何一個下游 Tuple 未成功處理，Storm 會重新發送整條根 Tuple。這意味著其他已成功處理的分支也可能被重複執行。以 Top K 的例子來說，若某個單詞處理失敗，整句 Tweet 可能被重新統計，造成重複計數。</p><p>若關閉 ack 機制，Storm 便成為 <strong>at-most-once</strong> 系統。上游發送後不再追蹤是否成功處理，可能導致資料遺失。</p><h3>What are Still Missing in Kafka + Storm</h3><p>如果我們將 Kafka 與 Storm 組合在一起，一個負責資料的持續流動，一個負責資料的即時處理，乍看之下，似乎已經具備了一個完整的流式資料處理系統。然而，當這樣的組合真正進入業務場景時，很快就會遇到一個根本性的問題：資料是否被正確地處理。</p><p>所謂正確，指的是<strong>精確一次</strong>、<strong>狀態</strong>與<strong>時間</strong>這三個面向。</p><h4>Exactly Once</h4><p>Kafka 確保資料能夠被持續傳遞，而 Storm 負責即時處理這些資料。Storm 透過 ack 機制提供了 at-least-once 的處理保證，確保從 Kafka 讀取的資料最終會被處理。然而，這並不保證資料只被處理一次。一旦發生失敗與重試，整條 Tuple chain 可能被重新執行，導致計算結果被重複更新。</p><p>這正是 Exactly Once 困難之處。它不僅要求資料不遺失，還要求整個計算過程在面對失敗時，對外呈現的結果等價於只執行一次。這意味著系統必須同時處理訊息重送、狀態更新與外部副作用的一致性，而這在分散式環境中並沒有簡單解法。</p><p>這個問題在計費相關的應用中尤為關鍵。例如廣告點擊計費、雲端服務用量計費或金融交易處理，每一筆事件都直接對應金額的增減。一旦某筆資料被<strong>重複處理</strong>，就可能造成重複扣款或錯誤計費。相較之下，<strong>資料遺失</strong>在這類場景中也同樣不可接受，系統必須同時避免兩者發生。這也使得 exactly-once 不再只是理論上的理想，而是實務上不可妥協的需求。</p><h4>Persistent State Management</h4><p>這個問題在 Kafka + Storm 的組合中會更加明顯。</p><p>Kafka 負責保存資料流，讓資料可以被重放（replay）；Storm 則負責即時處理這些資料。然而，處理過程中的狀態，卻只存在於 Storm 的計算節點之中，通常保存在記憶體裡。</p><p>這與 MapReduce 的世界形成鮮明對比。MapReduce 建立在 batch 模式之上，輸入資料固定，計算有明確邊界。當任務失敗時，只需重新執行整個 job，即可得到一致結果，因為狀態本質上是可重建的。</p><p>但在 Kafka + Storm 的架構下，資料是無界的，計算是長期存在的，狀態持續演化。一旦某個 Bolt 所在的節點失效，該節點上的狀態可能直接遺失。即使 Kafka 能夠重新提供資料，系統也無法準確恢復先前的計算狀態。</p><p>因此，僅僅依靠 Kafka 的資料重放與 Storm 的訊息重送是不夠的。我們需要將計算節點所依賴的狀態資訊持久化下來，使系統在故障後能夠恢復到一致狀態，而不是重新開始。這不僅是容錯的需求，同時也是支援線上擴容與狀態遷移的基礎。</p><p>換句話說，在 Kafka + Storm 的架構中，<strong>state 並沒有被視為一等公民</strong>。</p><h4>Time Window</h4><p>然而，即使 Kafka 解決了資料流動，Storm 解決了資料處理與可靠傳遞，仍然存在一個更根本的問題：</p><p>在一條無界的資料流中，我們如何界定「一次計算應該涵蓋的範圍」？</p><p>Kafka 持續提供資料流，而 Storm 持續處理資料，但兩者都沒有回答這個問題。</p><p>在 batch 系統中，這個問題並不明顯，因為整個資料集本身就隱含了一個時間邊界。但在 Kafka + Storm 的 streaming 架構中，資料沒有開始，也沒有結束。任何聚合操作，都必須先定義其觀察範圍。</p><p>這正是時間窗口（time window）的概念。時間窗口將無界的資料流切分為一段一段有界的區間，使得統計與聚合具有明確語意。</p><p>進一步地，我們還必須決定時間的定義方式：是以資料產生的時間（event time）為準，還是以系統接收到資料的時間（processing time）為準。</p><p>event time 通常用於對「真實世界行為」敏感的業務場景，例如廣告點擊、交易紀錄或使用者行為分析。在這些情境中，資料可能延遲到達，但仍需依照實際發生時間進行統計。</p><p>processing time 則適用於對即時反應要求較高、但允許一定誤差的場景，例如即時監控、系統告警或 dashboard 顯示。這類應用更關心的是「系統現在看到什麼」，而不是「事件真正發生在什麼時候」。資料一旦被系統接收到，就立即參與計算，而不會等待可能延遲到達的歷史資料來修正結果。</p><p>例如，在監控系統中，我們希望即時知道目前每秒的請求數是否過高。如果某些資料因為網路延遲晚了幾秒才到達，通常不會回頭修正已經顯示的監控數據，而是直接忽略這些延遲事件。這種情況下，使用 processing time 能夠提供更低延遲、也更簡單的實作。</p><p>不同的窗口定義，如固定窗口（tumbling window）、滑動窗口（sliding window）或會話窗口（session window），各自對應不同的業務需求。</p><p>tumbling window 將時間切分為不重疊的區間，例如每五分鐘統計一次請求數，常用於報表與計費場景。sliding window 允許窗口之間重疊，例如每分鐘更新過去五分鐘的統計結果，適用於即時趨勢分析與監控系統。session window 則根據事件之間的間隔動態決定邊界，例如將一段時間內連續的使用者行為視為同一個 session，常用於使用者行為分析。</p><p>在 S4 與 Storm 時代，這三種窗口已經以各種形式存在於工程實作中，只是尚未被統一命名，也缺乏明確的模型。直到 Google 在 2015 年提出 Dataflow 模型，這些分散的實務經驗才首次被系統化整理，並被納入一個統一的計算框架之中。Dataflow 明確定義了 window 作為切分無界資料流的核心抽象，並將不同類型的 window 視為計算語意的一部分，而不再只是應用程式中的實作細節。</p><p>綜合來看，即使我們擁有 Kafka 與 Storm，一個能夠傳遞資料，一個能夠處理資料，我們仍然缺少三個關鍵要素：Exactly Once 的處理語意、狀態的持久化管理，以及時間窗口的內建模型。</p><h3>Summary</h3><p>從 at-least-once 到 exactly-once，是 streaming 領域的關鍵里程碑。而通往這個里程碑的路徑，在 storage 與 processing 兩個層面上分別展開。</p><p>在 Stream Storage 的層面，這條路徑由 Early Kafka 演進到 Modern Kafka，逐步建立起可靠的資料持久化與重放機制。</p><p>而在 Stream Processing Engine 的層面，這個里程碑則由 Flink 跨越。Flink 不僅補上了 Storm 所缺乏的 exactly-once 語意，還進一步將 state 視為一等公民，建立了完整的 Persistent State Management，並將 Time Window 納入計算模型之中。</p><p>換句話說，Kafka 解決了「資料如何可靠地流動」，而 Flink 則開始回答「資料應該如何被正確地計算」。</p><p>因此，在下一章，我們自然要問：當我們同時面對<strong>精確一次</strong>、<strong>狀態</strong>與<strong>時間</strong>這三個核心挑戰時，一個完整的 Stream Processing Engine，究竟是如何被建構出來的？</p><p>(To be continued …)</p><h3>Appendix</h3><ol><li>[極客時間] 大数据经典论文解读 25 | 从S4到Storm（一）：当分布式遇上实时计算</li><li>[極客時間] 大数据经典论文解读 26 | 从S4到Storm（二）：位运算是个好东西</li><li>[極客時間] 大数据经典论文解读 29 | Dataflow（一）：正确性、容错和时间窗口</li><li>Streaming 101: The world beyond batch: <a href="https://www.oreilly.com/radar/the-world-beyond-batch-streaming-101/">https://www.oreilly.com/radar/the-world-beyond-batch-streaming-101/</a></li><li>Streaming 102: The world beyond batch: <a href="https://www.oreilly.com/radar/the-world-beyond-batch-streaming-102/">https://www.oreilly.com/radar/the-world-beyond-batch-streaming-102/</a></li><li>Storm @ Twitter（Nathan Marz, 2014）:<a href="https://classpages.cselabs.umn.edu/Spring-2018/csci8980/Papers/StreamProcessing/Storm.pdf">https://classpages.cselabs.umn.edu/Spring-2018/csci8980/Papers/StreamProcessing/Storm.pdf</a></li><li>Apache Storm 官方文件：Guaranteeing message processing: <a href="https://storm.apache.org/releases/2.6.0/Guaranteeing-message-processing.html">https://storm.apache.org/releases/2.6.0/Guaranteeing-message-processing.html</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8734fc1a7c59" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[2026.04.23 DataFusion MeetUp Prep]]></title>
            <link>https://medium.com/@0050211/2026-04-23-datafusion-meetup-prep-5233e9907151?source=rss-663daee8909b------2</link>
            <guid isPermaLink="false">https://medium.com/p/5233e9907151</guid>
            <dc:creator><![CDATA[Ryan Fu]]></dc:creator>
            <pubDate>Thu, 23 Apr 2026 07:55:01 GMT</pubDate>
            <atom:updated>2026-04-23T07:55:01.350Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZLfLnQFJZmMvYeEhlhM0cQ.png" /></figure><p>明天，我準備前往 Bellevue 的 Databricks，參加一場由 Andrew Lamb 等人主持的 Apache DataFusion meetup。我計劃從 Redmond 搭上剛開通的 link light rail 東西線，一路前往活動現場，享用美味的晚餐，配上幾場引人入勝的 technical talks。</p><p>就像一場精心安排的餐會，每一道菜都有其節奏與層次；這場 meetup 的議程也同樣如此。表面上看，各個分享來自不同領域、不同團隊，但細看之下，卻有一條清晰的主線貫穿其中：幾乎所有系統，都圍繞著同一個 execution engine 展開。</p><p>這些系統橫跨不同領域。</p><p>有的專注於時間序列（time-series），例如 InfluxDB，長期聚焦於高頻 metrics 資料的寫入與查詢，常見於監控與 IoT 場景；</p><p>也有系統進一步擴展這個範疇，例如 Greptime，其設計目標不僅限於傳統時間序列，而是嘗試整合 OpenTelemetry 所定義的三種核心資料型態：metrics、logs 與 traces，將 observability data 統一在同一個查詢模型之下。</p><p>有的處理地理空間資料（geospatial），如 Apache Sedona；</p><p>有的面向向量搜尋（vector search）與 AI 應用，例如 LanceDB；</p><p>也有像 SpiceAI 這樣的新型系統，試圖將資料查詢與模型推論整合在同一個平台之中。</p><p>它們處理的資料型態不同，面對的場景也彼此分殊，卻在一個關鍵點上逐漸匯流：它們開始共用同一套查詢執行能力，也就是 Apache DataFusion。</p><p>於是，一種新的分工模式正在成形。資料格式可以共享，由 Apache Arrow 承擔；查詢執行可以共享，由 Apache DataFusion 提供；而查詢計畫，甚至也開始朝標準化的方向推進，例如 Substrait。</p><p>在這樣的背景下，一個更基本的問題，也值得再次被拿回來反覆咀嚼：當一條 SQL 被送進資料系統時，究竟發生了什麼？</p><h3><strong>Classical DB Query Pipeline</strong></h3><p>在我們討論 DataFusion 或 Calcite 之前，可以先回到一個更根本的問題：當一條 SQL 被送進資料庫時，究竟發生了什麼？</p><p>在傳統 Database（例如 PostgreSQL 所代表的 System R lineage）中，SQL 並不會直接被執行。它會經過一連串轉換，逐步被轉化為一段可以在機器上運作的計算流程。</p><p>這個過程大致可以描述為：</p><p>SQL → AST → Logical Plan → Optimizer → Physical Plan → Execution</p><p>首先，SQL 會經過 parser，被轉換成 AST（Abstract Syntax Tree）。這一層描述的是語法結構，重點在於句子的組成方式，尚未觸及資料如何被處理。</p><p>接著，AST 會被轉換成 Logical Plan。此時查詢會被表示為 relational algebra，例如 projection、filter、join 等操作。這一步的轉換，讓查詢從語法結構進入語意層，開始描述資料之間的運算關係。</p><p>接下來，optimizer 會對 Logical Plan 進行改寫。透過 rule-based 或 cost-based 方法，系統會嘗試調整 join 順序、下推 filter，甚至重組整個查詢，以降低執行成本。</p><p>在完成最佳化之後，系統會產生 Physical Plan，決定具體的執行策略，例如使用 hash join 或 nested loop，是否走 index scan，或如何分配資源。</p><p>最後，execution engine 會依據這個 Physical Plan，逐步執行各個 operator，並產生最終結果。</p><p>這整個流程揭示了一件重要的事情：SQL 在資料庫中的角色，更接近一段需要被編譯的程式，而不是直接被執行的指令。</p><h3><strong>Calcite: Planning Without Execution</strong></h3><p>如果將傳統資料庫視為一個完整的「編譯加執行」系統，那麼 Apache Calcite 的切入點就顯得相當獨特。</p><p>Calcite 的 pipeline 與傳統資料庫在前半段十分相似：</p><p>SQL → AST → RelNode → Optimizer → Optimized Plan</p><p>差異出現在流程的終點。當 Calcite 產生最佳化後的計畫時，它的工作也隨之結束。</p><h4>SQL Dialect as a First-Class Concern</h4><p>在 Calcite 中，查詢的處理從 SQL parsing 開始，而這一層本身就具備高度的可擴展性。</p><p>Calcite 能夠解析多種 SQL dialect，並允許開發者定義或擴展語法、運算子與查詢規則。這代表 SQL 在進入 optimizer 之前，就已經可以被調整與改寫，以符合不同系統的語意需求。對於需要建立自訂查詢語言，或在多種資料來源之間統一語意的場景，這一層提供了關鍵的彈性。</p><p>這裡可以與 ANTLR 做一個對比。ANTLR 同樣可以用來定義語法並生成 parser，將輸入的程式轉換為一棵 parse tree（語法樹），並在此基礎上由開發者進一步建構 AST 或後續處理流程。這一層的關注點停留在語法結構本身：它能描述「這句話長什麼樣子」，但不直接定義這句話對應的資料運算。</p><p>Calcite 則在 parser 之上更進一步，將 SQL 映射為 relational algebra（RelNode），並允許在這個語意層上進行轉換與重寫。查詢在這個階段已經被理解為一組資料操作，而不只是語法結構。這也讓 SQL 成為一個可以被設計與演化的語言層，而不僅僅是輸入格式。</p><h4><strong>Optimizer Framework: Hep and Volcano</strong></h4><p>當 SQL 被轉換為 RelNode 之後，optimizer 才開始發揮作用。</p><p>這裡需要釐清兩個經常被混用的概念：RBO（rule-based optimization）與 CBO（cost-based optimization）描述的是「如何判斷哪個計畫更好」，而 optimizer framework 則關心「系統如何產生與探索這些候選計畫」。</p><p>在 Calcite 中，這個 distinction 被具體實現為兩種不同的 optimizer framework：</p><p>Hep（Heuristic Planner）採用規則序列的方式運作。系統依照預先定義的順序套用 transformation rules，每一步都產生一個新的查詢版本。這種模式結構簡單，適合用於早期階段的改寫，例如 predicate pushdown 或 expression simplification，也與 RBO 的思路相當接近。</p><p>Volcano 則採取搜尋導向的方式。系統會同時維護多個候選計畫，並透過成本模型（statistics）來評估不同方案的優劣。這使得查詢優化可以被視為一個搜尋問題，最終由成本函數決定哪一條路徑被採用。在這個 framework 中，CBO 成為核心能力，而 rule 則負責擴展搜尋空間。</p><p>透過 Hep 與 Volcano 的搭配，Calcite 能夠在同一個系統中支援不同層次的最佳化策略，從輕量的規則改寫到較完整的成本導向搜尋。</p><h4>Plan Without Execution</h4><p>經過 optimizer 之後，Calcite 會產生一個最佳化後的計畫。然而，這個計畫仍停留在抽象層次。</p><p>即使在文件中會看到「physical plan」這個詞，它所代表的仍是策略上的選擇，而非可以直接執行的 operator。這些計畫通常以 RelNode 的形式存在，描述了 join 的順序、運算的結構，以及各種最佳化決策。</p><p>當這個計畫被交給像 Apache Hive、Apache Flink 或 Apache Drill 這類系統時，仍需要再經過一個轉換階段：</p><p>RelNode → Engine-specific Plan → Execution</p><p>這個過程通常被稱為 lowering。系統會將抽象的 relational operator（例如 Join、Aggregate），轉換成對應執行引擎的具體單元，例如 MapReduce job、streaming operator，或分散式 execution fragment。</p><p>這個轉換並不會重新做完整的最佳化，而是將既有的決策，具體化為可以被執行的形式。</p><h4>A Planner in the Ecosystem</h4><p>這樣的設計，使 Calcite 在整個資料系統生態中扮演一個清晰的角色。</p><p>它負責將 SQL 轉換為經過最佳化的查詢計畫，同時提供一個可擴展的語言與語意層。execution 則被留給下游系統實現，讓不同引擎可以在共享同一套 planner 的同時，維持各自的執行模型與性能特性。</p><p>在這個分工之下，Calcite 更像是一個通用的 query planning infrastructure，為各種資料系統提供一致的查詢理解與最佳化能力。</p><h3>DataFusion: Execution as a Library</h3><p>與 Calcite 相比，Apache DataFusion 的設計選擇走向另一條路徑。</p><p>DataFusion 同樣具備完整的查詢流程：</p><p>SQL → AST → Logical Plan → Logical Optimizer → Physical Plan → Execution</p><p>表面上看起來與傳統資料庫相似，但在 optimizer 的設計上，取徑明顯更為簡潔。DataFusion 採用的是一條線性的 rule-based pipeline：一組 transformation rules 依序作用於 Logical Plan，逐步完成 predicate pushdown、projection pruning 等改寫。這種模式在結構上更接近 Calcite 的 Hep planner，屬於 RBO 的範疇；相較之下，完整的 cost-based search framework 目前仍不是其核心設計重點。</p><p>在語言層的設計上，DataFusion 的定位也有所不同。它提供 SQL parser 與基本的語法支援，並允許透過 UDF（user-defined function）與 UDAF（user-defined aggregate function）擴展計算能力。這種擴展方式讓開發者可以在既有語法下加入新的運算邏輯。然而，對於 SQL grammar 本身的修改或 dialect 的深度定義，DataFusion 並沒有提供像 Calcite 那樣完整的機制。</p><p>換個角度來看，DataFusion 對 SQL 的支援更偏向「可擴展的運算能力」，而不是「可設計的語言系統」。</p><p>在 Physical Plan 之後，語意發生了關鍵變化。</p><p>在 DataFusion 中，Physical Plan 由一組具體的 operator 組成，例如 ParquetExec、HashJoinExec、AggregateExec 等。這些 operator 實作了 ExecutionPlan 介面，可以直接被執行，並產生 Arrow 的 RecordBatch。</p><p>也因此，當 Physical Plan 被建立完成時，整個查詢已經具備可執行的形態，不需要再轉交給其他引擎進行翻譯。換句話說，執行能力已經內建在這個系統之中。</p><p>這裡有一個容易混淆的地方：DataFusion 並沒有省略 execution layer，而是將 execution layer 本身做成一個可以被嵌入的元件。這樣的設計，讓 Physical Plan 更接近一段可運行的程式，而不僅僅是一張描述執行策略的藍圖。</p><p>這也解釋了為什麼常會用 LLVM 來類比 DataFusion。兩者在角色上的相似之處，在於都提供了一個可以被重用的執行核心：上層可以自由定義語言或系統，下層則負責將這些描述轉換為實際運算。</p><p>在 DataFusion 的情境中，SQL 可以視為輸入語言，Logical Plan 與 Physical Plan 則逐步將其轉換為可執行的資料流，最終由 Arrow-based execution model 完成運算。</p><p>這種分層方式，也反映了現代資料系統的一個趨勢：不同能力開始被拆分為獨立元件，例如 Storage（Iceberg）、Memory Format（Arrow）、Execution（DataFusion）。系統的建構方式，從單一整合產品，逐漸轉向可組合的架構。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/782/1*VQCKOUal4dW7HS_kxfH4Iw.png" /></figure><h3>Wherobots / Apache Sedona (≈ 2018, originally GeoSpark)</h3><p>Wherobots 與 Apache Sedona 的出現，源自一個長期存在但經常被忽略的問題：傳統資料系統擅長處理數值與結構化資料，但對於空間資料（geospatial data）的支援，始終顯得笨重而低效。</p><p>Apache Sedona（前身 GeoSpark）最初建立在 Spark 之上，目標是讓使用者可以用類似 SQL 的方式進行空間查詢，例如：</p><ul><li>計算兩點之間的距離</li><li>判斷一個 polygon 是否包含某個點</li><li>進行 spatial join（例如找出某區域內的所有事件）</li></ul><p>這些操作在 GIS 領域十分常見，但對於一般的 query engine 來說，卻涉及複雜的幾何運算與特殊資料結構（例如 R-tree）。</p><p>隨著 DataFusion 與 Arrow 生態的成熟，Sedona 開始將這些能力帶入新的 execution backend。這讓 spatial analytics 不再依賴單一系統（如 Spark），而可以嵌入到更輕量的 query engine 中。</p><p>在這個架構中，DataFusion 承擔的是 execution layer 的角色：</p><p>→ SQL（含 spatial function）<br> → Logical Plan（含 geometry 操作）<br> → DataFusion Physical Plan（執行 filter / join / aggregation）</p><p>Sedona 則在其上提供 domain-specific function 與運算邏輯。換句話說，DataFusion 在這裡位於：</p><p>👉 <strong>Execution Layer（執行層）<br></strong> 負責一般 relational operator，而空間邏輯由 Sedona 擴展。</p><h3>Greptime (2022)</h3><p>Greptime 是一個專為 time-series data 設計的資料庫，其設計核心圍繞在高頻寫入、時間排序，以及時間區間查詢。</p><p>在監控系統、IoT、金融交易等場景中，資料通常以時間為主軸，例如：</p><ul><li>CPU 使用率隨時間變化</li><li>感測器每秒回報的數值</li><li>股票價格的即時更新</li></ul><p>這類資料的特性，使得傳統 OLAP 或 OLTP 系統難以同時兼顧寫入效率與查詢性能。然而，Greptime 並不僅僅停留在傳統 time-series database 的範疇。它的設計逐步對齊 OpenTelemetry 所定義的三種核心資料型態：</p><ul><li><strong>metrics</strong>：數值型時間序列，例如 CPU、記憶體、QPS</li><li><strong>logs</strong>：離散事件記錄，通常帶有時間戳與結構化欄位</li><li><strong>traces</strong>：跨服務請求的呼叫鏈，用於描述系統內部的因果關係</li></ul><p>長期以來，這三種資料型態各自發展，形成彼此分離的系統版圖：metrics 通常儲存在 time-series database 中，logs 則流入以 Logstash、Elasticsearch、Kibana 為代表的 log system，而 traces 則由 Jaeger 或 Zipkin 等 tracing system 負責。</p><p>這樣的分工在早期是自然的：不同資料型態對應不同的儲存結構與查詢需求。然而，隨著系統規模與複雜度增加，問題也逐漸浮現 — — 當使用者想要回答一個實際問題時，往往需要同時結合三種資料。例如，一個 API 延遲升高的問題，可能需要從 metrics 發現異常、從 logs 找出錯誤訊息，再透過 traces 還原整個請求的呼叫路徑。資料分散在不同系統中，使得分析過程變得零碎且昂貴。</p><p>正是在這樣的背景下，像 Greptime 這樣的系統開始嘗試改變這種分裂的局面：不再將 metrics、logs 與 traces 視為彼此獨立的資料，而是將它們納入同一個查詢模型之中，讓 observability 從多個系統的拼接，轉變為一個可被整體理解的資料層。</p><p>Greptime 的策略，是將 storage 與 query engine 分離，並採用 Arrow-based execution 來提升查詢效率。在這個架構中，Apache DataFusion 被引入作為 SQL execution 的核心。</p><p>使用情境包括：</p><ul><li>時間範圍查詢（time range scan）</li><li>aggregation（例如 rolling average）</li><li>多維度分析（time + tag）</li><li>跨資料型態分析（例如 metrics 與 logs 的關聯查詢）</li></ul><p>在整體 pipeline 中：</p><p>→ SQL</p><p>→ Logical Plan</p><p>→ DataFusion Execution（scan / filter / aggregation）</p><p>→ time-series / observability specific optimization（例如時間裁剪、trace 過濾）</p><p>因此 DataFusion 在這裡主要位於：</p><p>👉 <strong>Execution Layer（執行層）</strong></p><p>並負責一般 SQL 運算，而 time-series 與 observability 特有語意（例如時間語意、trace 關聯）則由 Greptime 系統補足。</p><h3>InfluxDB 3 (2023)</h3><p>InfluxDB 的第三代版本，代表了一次明顯的架構轉向。</p><p>在早期版本中，InfluxDB 採用自建的 query engine，並使用 Flux 作為查詢語言。這種設計提供了高度控制力，但也帶來維護成本與生態整合上的限制。</p><p>到了 InfluxDB 3，系統開始全面採用現代資料堆疊的組件：</p><ul><li>Apache Arrow（記憶體格式）</li><li>Parquet（儲存）</li><li>👉 Apache DataFusion（執行）</li><li>SQL（取代 Flux）</li></ul><p>這樣的轉變，使 InfluxDB 從「自建整套系統」，逐步轉向「組合式架構」。</p><p>在實際使用中，InfluxDB 3 支援：</p><ul><li>時間序列查詢</li><li>高效 aggregation</li><li>多維度分析</li></ul><p>而 DataFusion 負責將 SQL 轉換為實際的 execution pipeline。</p><p>在 flow 中的位置為：</p><p>👉 <strong>Execution Layer（執行層）</strong></p><p>與 Greptime 類似，但 InfluxDB 的轉變更具有代表性：它顯示即使是成熟產品，也會選擇將 execution 外包給共用的 engine。</p><h3>LanceDB (2023)</h3><p>LanceDB 所關注的問題，來自於 AI 時代的新需求：如何在資料庫中處理向量（embedding）與相似度搜尋。</p><p>在語意搜尋、推薦系統、RAG（retrieval-augmented generation）等場景中，資料不再只是數值或字串，而是高維向量。例如：</p><ul><li>一段文字的 embedding</li><li>一張圖片的特徵向量</li></ul><p>這些資料的查詢方式，與傳統 SQL 有本質差異，通常依賴 approximate nearest neighbor（ANN）等演算法。</p><p>LanceDB 的設計將 storage（Lance format）與 vector index 作為核心，同時引入 SQL 作為查詢接口。這裡 DataFusion 的角色，是處理非向量部分的查詢邏輯，例如：</p><ul><li>filter（metadata 條件）</li><li>projection</li><li>部分 aggregation</li></ul><p>查詢流程通常呈現為：</p><p>→ vector search（ANN index）<br>→ relational filter（DataFusion）<br>→ result merge</p><p>因此 DataFusion 在這裡位於：</p><p>👉 <strong>Execution Layer（部分執行層）</strong></p><p>更精確地說，它負責 relational 部分，而向量搜尋由 LanceDB 自身處理。</p><h3>SpiceAI (2023)</h3><p>SpiceAI 嘗試解決的是另一個問題：如何讓資料系統更貼近 AI 應用，並支援即時推論與資料查詢的整合。</p><p>SpiceAI 的設計包含：</p><ul><li>Data layer（資料來源）</li><li>Model layer（AI 模型）</li><li>Serving layer（API / 查詢接口）</li></ul><p>在這個架構中，DataFusion 扮演核心角色，用來執行 SQL 查詢，並與其他元件（例如模型推論）結合。</p><p>典型 use case 包括：</p><ul><li>將資料查詢與模型推論結合</li><li>即時 feature computation</li><li>AI-driven analytics</li></ul><p>在 execution flow 中：</p><p>→ SQL / API request<br> → DataFusion execution<br> → model inference（可選）<br> → response</p><p>因此 DataFusion 在這裡位於：</p><p>👉 <strong>Execution Layer（核心執行層）</strong></p><p>並作為整個系統的計算引擎，連接資料與 AI 模型。</p><h3>Comet</h3><p>在前面的幾個系統中，我們看到一個共同模式：它們大多在設計之初，就選擇將 Apache DataFusion 作為核心 execution engine，並在其之上構建各自的 domain-specific 能力。</p><p>這些系統的共同特徵，在於查詢執行能力並非來自外部服務，而是直接嵌入在應用程式內部。實作上，開發者會在程式碼中呼叫 DataFusion 的 library（例如 Rust crate），建立查詢上下文，產生 Logical Plan 與 Physical Plan，並直接觸發 execution。查詢不需要透過獨立的資料庫進程來執行，而是作為應用程式的一部分運行。</p><p>以這種方式構建的系統，可以視為：</p><p><strong>1》以 DataFusion 為 execution core 的新系統<br></strong> 系統從一開始就圍繞 DataFusion 設計，例如前面提到的 Sedona、Greptime、InfluxDB 3、LanceDB 與 SpiceAI。DataFusion 在這些架構中扮演的是核心引擎，負責查詢的實際執行，並與資料模型與 domain logic 緊密整合。</p><p>隨著生態逐漸成熟，另一種使用方式開始出現。</p><p><strong>2》在既有系統中導入 DataFusion 的系統<br></strong> 這類系統本身已經具備完整的 query pipeline，包括 parser、optimizer 與 execution engine。引入 DataFusion 的目的，不在於重建整個系統，而是針對特定執行階段進行優化。</p><p>接下來要介紹的 Comet，屬於第二種類型。</p><h4>Comet in the Query Flow</h4><p>Comet 的背景來自 Apache Spark。Spark 本身已經具備完整的查詢流程：</p><p>SQL → Catalyst Optimizer → Physical Plan → Execution（JVM）</p><p>其中 execution layer 主要依賴 JVM-based operator（Tungsten），並以 row-based 或部分 columnar 的方式進行計算。Comet 並沒有改動這條 pipeline 的前半段。SQL parsing 與 Catalyst optimizer 仍由 Spark 負責，整體查詢流程的控制權也維持不變。</p><p>變化發生在 execution 的實作層。</p><p>當 Spark 建立 Physical Plan（execution tree）時，其中部分 operator（例如 scan、filter、aggregation），會被導向 Comet 所提供的 native runtime。這些 operator 不再使用原本的 JVM 實作，而是透過 Comet 呼叫 Rust-based execution engine。</p><p>這個過程可以表示為：</p><p>Spark Physical Plan</p><p>→（部分 operator）</p><p>→ Comet runtime</p><p>→ DataFusion execution</p><p>→ 回傳結果至 Spark</p><p>需要注意的是，這裡並不存在「將 Java operator 直接轉換為 Rust operator」的過程。實際發生的是：Spark 在執行過程中，將資料與計算需求交給另一個 runtime，待計算完成後再將結果取回。</p><h4>Arrow as the Common Language</h4><p>這樣的跨 runtime 執行之所以可行，關鍵在於 Apache Arrow。</p><p>Arrow 定義了一種跨語言共享的記憶體格式，使 JVM 與 Rust 可以在不進行資料複製的情況下交換資料。Spark 可以將中間結果轉為 Arrow format，交由 Comet 傳遞給 DataFusion；後者則直接在這些 columnar 資料上進行 vectorized 計算。</p><p>完成運算後，結果再以相同格式回傳給 Spark，繼續後續的 execution flow。因此，這裡的關鍵並不在語言轉換，而在於資料格式的統一。</p><h4>Execution Layer Placement</h4><p>從整體 flow 來看，Comet 並沒有改變 query planning 的邏輯，而是重新定義了 execution 的實作方式。</p><p>在這個架構中：</p><ul><li>Spark 負責 query planning 與 execution orchestration</li><li>Comet 負責 runtime bridging（JVM ↔ Arrow ↔ Rust）</li><li>DataFusion 負責實際的 operator execution</li></ul><p>DataFusion 在這裡的位置，仍然是：</p><p>👉 <strong>Execution Layer（執行層）</strong></p><p>不同之處在於，它並未成為整個系統的核心，而是被嵌入在既有 execution pipeline 中的一部分。</p><h3>Substrait</h3><p>在前一節中，我們看到透過 Comet，execution layer 已經可以在不同系統之間被重用。當資料可以透過 Arrow 共享，計算可以透過 DataFusion 被重用，一個更進一步的問題自然浮現：</p><p>如果 execution 可以共享，那麼查詢計畫本身，是否也能在不同系統之間流動？</p><p>這正是 Substrait 所關注的問題。</p><h4>From System-specific Plan to a Shared Representation</h4><p>在現有系統中，每個 query engine 都維護著自己的內部表示：</p><ul><li>Apache Calcite 使用 RelNode</li><li>Apache DataFusion 使用 Logical Plan / Physical Plan</li><li>Apache Spark 使用 Catalyst plan</li></ul><p>這些表示方式都能描述查詢邏輯，但彼此之間並不相容。一個在 Spark 中產生的 plan，無法直接交給 DataFusion 執行；不同系統之間，仍然存在語意上的斷層。</p><p>Substrait 的目標，是提供一個跨系統共通的查詢表示格式。</p><h4>What Substrait Actually Is</h4><p>Substrait 並不是一個服務，也不是一個 query engine。<br> 它更接近一種標準：定義查詢計畫應該如何被表示。</p><p>在實作上，Substrait 通常以 protobuf 的形式存在，描述一棵由 relational operators 組成的計畫樹，例如 filter、join、aggregate 等。</p><p>因此，Substrait 在整體流程中的角色，可以理解為：</p><p>SQL</p><p>→ AST</p><p>→ Logical Plan</p><p>→ Substrait Plan</p><p>→ Engine-specific Plan</p><p>→ Execution</p><p>這裡有兩個關鍵步驟：</p><ol><li>某個 planner（例如 Spark 或 Calcite）負責將查詢轉換為 Substrait Plan</li><li>目標 execution engine（例如 DataFusion 或 DuckDB）負責將 Substrait Plan 轉換為自己的內部表示，並執行</li></ol><p>Substrait 本身並不參與 execution，也不負責 optimizer 的具體實作。</p><h4>A Concrete Flow</h4><p>以一個簡單查詢為例：</p><p>SELECT SUM(x) FROM t WHERE y &gt; 10</p><p>一個可能的流程如下：</p><p>Spark / Calcite</p><p>→ 產生 Logical Plan</p><p>→ 輸出 Substrait Plan（標準格式）</p><p>→ DataFusion 接收 Substrait Plan</p><p>→ 轉換為 DataFusion Logical / Physical Plan</p><p>→ Execution（Arrow-based）</p><p>在這個過程中，查詢不需要重新解析 SQL，也不需要重寫 optimizer 邏輯。Substrait 作為中介表示，使不同系統之間可以直接交換查詢計畫。</p><h3>Summary</h3><p>如果將前面介紹的元件放在同一個框架中，可以看到一個逐漸成形的分層：</p><ul><li>Arrow → 定義資料如何在系統間流動</li><li>DataFusion → 提供可重用的 execution engine</li><li>Substrait → 定義可共享的 query plan</li></ul><p>這三者共同構成了一個新的資料系統基礎：資料、計算與語意，都可以被模組化與重用。</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5233e9907151" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From Database to Data Lakehouse — Part 11. Kafka: a Distributed Message Store]]></title>
            <link>https://medium.com/@0050211/from-database-to-data-lakehouse-part-11-kafka-a-distributed-message-store-cd3462097cf3?source=rss-663daee8909b------2</link>
            <guid isPermaLink="false">https://medium.com/p/cd3462097cf3</guid>
            <dc:creator><![CDATA[Ryan Fu]]></dc:creator>
            <pubDate>Wed, 21 Jan 2026 06:57:03 GMT</pubDate>
            <atom:updated>2026-06-14T18:30:26.915Z</atom:updated>
            <content:encoded><![CDATA[<h3>Chapter 9. Kafka: From Distributed Log to Event Streaming Platform</h3><p>《<a href="https://medium.com/@adrianhsu/%E7%B5%A6%E5%AD%B8%E5%BC%9F%E5%A6%B9%E7%9A%84%E7%9F%BD%E8%B0%B7-ai-software-%E6%8C%87%E5%8D%97-da054b7a8dcb">給學弟妹的矽谷 AI / Software 指南</a>》是一個矽谷工程師自發的眾籌寫作計劃，由<a href="https://www.facebook.com/opensource4you">源來適你社群</a>推動。這將是一個在台灣軟體學生圈內很有影響力的文章系列。已有 &gt;10 篇準備發布，目標 100 篇並出書，歡迎 tag 強者大神來寫！</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*U3LwkOsrYK7YL6PP3GcsWg.jpeg" /></figure><p>OLTP 和 OLAP 兩種系統分工明確，兩個系統中間需要一座<strong>橋樑</strong>，<strong>將 OLTP 資料庫中的資料轉送到 OLAP 系統中</strong>。這個過程往往不只是搬運，而是一次轉化：欄位需對齊，格式要清洗，不同系統之間的語意差異也必須整合。這套資料流通與轉換的流程，被稱為 <strong>ETL：Extract、Transform、Load</strong>。</p><p>在 1980–2000 這個年代，<strong>夜間批次任務（batch job）</strong>搭起這座橋樑。每天深夜，資料會從來源資料庫中抽取（Extract），進入中繼區（staging area），接著進行格式轉換、缺值補齊與商業邏輯處理（Transform），最後再將清洗完的資料載入（Load）至 Data Warehouse。這整套流程通常交由 ETL 工具（如 Informatica、DataStage）執行，由工程師設計並排程自動化運行。</p><p>然而，進入 Data Lake 時代後，另一種選擇逐漸興起：透過 streaming 的方式即時傳輸資料。不再等待深夜批次處理，而是讓資料持續流動，即時同步到目標系統。若採用這種方式，Kafka 就成為不可或缺的工具 — — 它不僅是資料傳輸的管道，更是整個即時資料架構的核心。這一章，就讓我們來深入探究 Kafka。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*IQ62w4z2l_KfyBAmaRtGCg.gif" /></figure><p>本章的架構如下：</p><p>首先，我們會在 <strong>Some House-Keeping Definitions</strong> 中釐清一些基本概念，確保我們在同一個語境下討論。接著，<strong>The Issue of Big Data Transmission</strong> 會探討大數據傳輸面臨的核心挑戰 — — 當資料量從 GB 成長到 TB、PB 時，傳統的點對點傳輸模式為何開始失效。</p><p>在 <strong>Scribe: A Multi-Tier Aggregation Tree</strong> 中，我們會回顧 Facebook 在 2008 年提出的 Scribe 系統，看看業界最早是如何用分層聚合的方式解決大規模日誌收集問題。這為理解 Kafka 的設計提供了重要的歷史背景。</p><p>從 2026 年回頭看，Kafka 早已從 2011 年論文中的初始版本，演進成一個功能完備的分散式串流平台。為了區分不同時期的 Kafka，接下來的小節標題會明確標示為「Early Kafka」或「Modern Kafka」 — — 「Early」指 2011 年論文中描述的版本，「Modern」則是後來逐步演進、添加了各種新能力的版本。</p><p><strong>Early Kafka’s Mission: Bridging to Near Real-Time</strong> 會帶我們回到 LinkedIn 在 2010 年開發 Kafka 的初衷 — — 他們需要一個系統能夠以接近即時的方式，將活動日誌從前端網站傳送到後端的資料倉儲和分析系統，而當時的消息隊列都無法滿足這個需求。</p><p>在 <strong>Early Kafka: The Architecture</strong> 中，我們會深入 2011 年論文中描述的 Kafka 核心設計：基於日誌的儲存模型、partition 的概念、以及 consumer group 的機制。這些設計至今仍是 Kafka 的基石。</p><p>隨後，<strong>Modern Kafka: The Improvements</strong> 會介紹 Kafka 從最初版本到現代的重要演進，包括 replication 機制的加入、exactly-once 語意的實現，以及 WarpStream 這類雲原生架構的出現。</p><p>最後，<strong>Kafka wire protocol: The De Facto Standard</strong> 會展示 Kafka 如何從一個專用的日誌傳輸系統，演變成為整個業界的事實標準 — — 幾乎所有的串流處理框架、資料整合工具都選擇與 Kafka API 相容。</p><p>讓我們開始吧！</p><h3>Some House-Keeping Definitions</h3><p>在深入探討 Kafka 的技術細節之前，讓我們先釐清一些容易混淆的定義，確保我們對關鍵概念有共同的理解。</p><h4>In-Memory vs Persistent</h4><p><strong>In-Memory</strong>（記憶體中）意味著資料存儲在 RAM 中，特點是存取速度極快（納秒級），但系統重啟或斷電後資料會丟失。<strong>Persistent</strong>（持久化）則意味著資料存儲在持久化存儲介質上（硬碟、SSD），即使系統重啟，資料依然存在。在分散式系統和資料庫領域，In-Memory 和 Persistent 的區別至關重要，因為它直接影響到資料的可靠性、性能和系統設計。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*JUrUCBXtrUwRBI6vTijsHA.png" /></figure><p>當我們在大學的資料結構課程中學習 Array、Linked List、Hash Table、Binary Tree 時，教科書通常不會特別強調這些是「In-Memory Data Structure」。但事實上，它們確實都是駐留在記憶體中的。同樣地，當我們在 LeetCode 或其他平台刷題時，使用的資料結構也都是 In-Memory 的，程式執行結束後，資料就消失了。</p><p>值得一提的是，Computer Science 中確實存在 Persistent Data Structure 的概念，但它的意思完全不同。它指的是修改操作會產生新版本而不改變舊版本的資料結構，比如 Clojure 中的 Persistent Vector 或函數式程式設計中的 Immutable Data Structure。這個概念最早由 Chris Okasaki 在 1996 年的博士論文中系統性地研究，並在 1998 年出版成《Purely Functional Data Structures》一書。</p><p>有趣的是，Persistent Data Structure 的「多版本」概念其實借鑒自資料庫的 MVCC（Multi-Version Concurrency Control）。MVCC 早在 1978 年就由 MIT 的 David P. Reed 在博士論文中提出，用於解決資料庫的並發控制問題。但兩者有個關鍵差異：<strong>MVCC 的多個版本是可以持久化到磁碟的</strong>，系統重啟後資料依然存在；而 <strong>Persistent Data Structure 仍然是駐留在記憶體中的</strong>，斷電後資料依然會丟失，只是在程式執行期間保留了歷史版本而已。</p><p><strong>Examples Across Different Levels<br></strong>讓我們從不同層級來看 In-Memory 和 Persistent 的例子：</p><p>檔案格式層級：</p><ul><li>Apache Arrow 是 In-Memory 的 column format，設計用於記憶體中的高效資料處理和零拷貝共享。</li><li>Apache Parquet 是 Persistent 的 PAX format，設計用於長期存儲在硬碟上。</li></ul><p>工具層級：</p><ul><li>Redis 本質上是 In-Memory Database，所有資料主要存儲在記憶體中以提供極低延遲。但 Redis 也提供 RDB（快照）和 AOF（Append-Only File）等機制來支援持久化模式，讓資料可以在重啟後恢復。</li><li>Memcached 則是純粹的 In-Memory Cache，沒有任何持久化機制。</li></ul><p>RDBMS 內部：</p><ul><li>Buffer Pool 是 In-Memory 的，用於緩存熱點數據頁，加速查詢。</li><li>WAL（Write-Ahead Log）、B-tree 索引、Data File 都是 Persistent 的，確保資料不會因為斷電而丟失。</li></ul><p>Cassandra 內部：</p><ul><li>Memtable 是 In-Memory 的寫入緩衝區。</li><li>CommitLog（WAL）和 SSTable 是 Persistent 的，提供資料的持久化保證。</li></ul><p><strong>Storage Implies Persistence</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/504/1*hK3KlvV85a09Lk5_offiwg.png" /></figure><p>值得注意的是，Storage 這個詞本身就隱含了 Persistent 的意義。當我們談論 Storage Systems 時，預設就是在討論持久化的存儲系統：</p><ul><li>Block Storage、File Storage、Object Storage 都是在強調它們的持久化性質</li><li>在資料庫領域，Row Store、Column Store、Wide-Column Store、Document Store 同樣都是在描述資料如何被持久化存儲</li></ul><p>Kafka 明顯是一種 Store，但在界定它的分類時花了我一些力氣。Kafka 在<strong>單一 partition 內</strong>保證嚴格的 FIFO 順序，但<strong>跨 partition</strong> 則沒有順序保證。它的消息確認機制也與傳統 Message Queue 不同 — — Consumer 透過提交 offset 來批次確認消費進度，而非逐條確認。更重要的是，Kafka 也截然不同於上述的每一種資料庫，它不是為了複雜查詢或事務處理而設計的。</p><p>就在山窮水盡之際，”Kafka: a Distributed Messaging System for Log Processing” 這篇開山鼻祖般的論文給了我靈感。雖然不是業界的正式類名，從此我就把它歸為 Message Store。這個分類強調了 Kafka 的核心特性：它是一個專門用來存儲和傳遞消息的持久化系統。下一章會介紹的 Kafka 的前輩（如 RabbitMQ）和後輩（如 Apache Pulsar, Apache Fluss），都屬於 Message Store。</p><h4>Storage vs Compute, Batch vs Stream</h4><p>在資料系統的演進歷史中，Storage 和 Compute 的關係經歷了多次重大變革，這些變革深刻影響了系統的可擴展性、性能和成本結構。</p><p>在 Compute 和 Storage 的分工中，Storage 負責資料的持久化存儲，而 Compute 則負責資料的處理和轉換。在資料庫系統中，Compute Engine 的主要職責包括 Query Optimization（查詢優化）、Query Execution（查詢執行）、Join 操作、Aggregation 聚合等。它需要決定如何最有效地讀取資料、用什麼順序執行操作、是否使用索引，以及如何分配記憶體和 CPU 資源。</p><p><strong>The Evolution of Storage and Compute Architecture</strong></p><p>關於 Compute Engine 如何共享 Memory 和 Disk 等資源，是個亙古談不膩的話題。在 1986 年，Michael Stonebraker 發表了影響深遠的論文 “The Case for Shared Nothing”，系統性地比較了三種多處理器資料庫架構。這三種架構按照耦合的緊密程度，由緊到鬆可以分為：</p><p><strong><em>Shared Memory (SM)</em></strong></p><p>在這種架構中，多個 CPU 透過匯流排（bus）或交換機（switch）存取同一個中央記憶體。論文中提到的例子包括 Synapse 和 IBM/AP 配置。現代的例子則是單機多核心系統上的資料庫，比如在一台配備 128 核心的伺服器上運行的 PostgreSQL（值得注意的是，1986 年論文中的 Shared Memory 指的是「多個 CPU 晶片」共享記憶體，而非現代的多核 CPU — — 多核要到 2000 年代才普及）。所有 CPU 核心共享同一個記憶體空間，可以直接存取相同的資料結構。</p><p>這種架構的優點是程式設計簡單，不需要考慮資料分佈。但致命的缺陷是無法擴展到大量處理器 — — 當處理器數量增加時，記憶體匯流排會成為瓶頸，多個處理器競爭存取共享記憶體會導致嚴重的爭用（contention）問題。</p><p><strong><em>Shared Disk (SD)</em></strong></p><p>每個處理器擁有自己的記憶體，但所有處理器透過高速網路存取同一組磁碟陣列。論文中提到的例子是 VAX/cluster 和多埠磁碟系統。現代最典型的例子是 Oracle RAC (Real Application Clusters)，多個 Oracle 實例運行在不同節點上，但共享同一個儲存系統（通常是 SAN）。</p><p>這種架構的問題在於需要協調多個 lock table 副本，同步寫入共享日誌，導致複雜的 transaction 管理。當多個節點試圖修改同一筆資料時，必須透過分散式鎖機制來保證一致性，這會帶來顯著的效能開銷。</p><p><strong><em>Shared Nothing (SN)</em></strong></p><p>每個處理器擁有完全獨立的記憶體和磁碟，節點之間只透過網路通訊。論文中提到的例子是 Tandem 和 Tolerant 系統。現代的例子包括 Teradata、Greenplum。每個節點負責一部分資料，處理針對其資料分片（data partition）的查詢。</p><p>這種架構的優勢在於線性擴展能力 — — 增加節點就能幾乎等比例增加處理能力，不會像 Shared Memory 那樣受限於記憶體匯流排的瓶頸。</p><p><strong><em>Stonebraker’s Conclusion</em></strong></p><p>Stonebraker 的結論很明確：對於「scalable、tunable、almost nice」的資料庫 — — 即大部分交易都是本地自足（locally sufficient）的資料庫 — — Shared Nothing 架構是最具成本效益的架構選擇。</p><p>但有個重要的背景：<strong>當時 Stonebraker 討論的完全是 on-premises 環境</strong>。1986 年雲端運算還不存在，所有系統都是企業自己購買硬體、建置資料中心。在那個年代，儲存是昂貴且難以擴展的資源，網路頻寬也很有限。</p><p><strong>Cloud Era: Compute-Storage Separation</strong></p><p>然而，進入雲端時代後，遊戲規則完全改變了。</p><p>首先是<strong>物件儲存的崛起</strong>。Amazon S3 在 2006 年推出，提供了幾乎無限可擴展、高度耐用、且成本極低的儲存服務。相比之下，本地磁碟（block storage）不僅貴得多，還需要手動管理容量。這個經濟學的轉變是根本性的：在 on-premises 時代，儲存是稀缺資源；在雲端時代，儲存變得幾乎是免費的。</p><p>其次是<strong>彈性需求的普遍性</strong>。雲端應用的工作負載往往有明顯的高峰和低谷 — — 白天查詢量大，晚上幾乎沒有；月底報表跑批次，平時只有零星查詢。在 Shared Nothing 架構中，計算和儲存是綁定的，你無法單獨擴展其中一個。但在 compute-storage separation 架構中，你可以在需要時快速增加計算節點，用完就釋放，而資料始終安全地存放在共享儲存層。</p><p>第三是<strong>資料共享的需求</strong>。現代資料平台需要支援多種工作負載：BI 分析、機器學習訓練、即時查詢、批次處理。在 Shared Nothing 架構中，每個系統都需要維護自己的資料副本。但在 compute-storage separation 架構中，所有計算引擎都可以直接存取同一份資料 — — Spark 可以讀取，Presto 也可以讀取，不需要複製。</p><p>Snowflake 在 2012 年創立時，徹底擁抱了這個新典範。他們將計算層（虛擬倉庫）完全與儲存層（S3）分離。Snowflake 官方文件將自己的架構描述為「shared-disk 和 shared-nothing 資料庫架構的混合體」 — — 提供了 shared-disk 架構的資料管理簡便性，同時具備 shared-nothing 架構的性能和擴展優勢，也稱之為 “multi-cluster, shared data architecture”。這不是回到傳統的 Shared Disk，而是一種全新的架構 — — 儲存是雲原生的物件儲存，而非傳統的共享磁碟陣列；計算節點是短暫的、無狀態的，可以隨時啟動和關閉。</p><p>Databricks 的 Delta Lake、AWS 的 Athena、Google 的 BigQuery、ClickHouse Cloud，幾乎所有現代雲端資料倉儲都採用了類似的 <strong>Compute-Storage Separation</strong> 架構。這不是因為 Stonebraker 錯了，而是因為雲端改變了底層假設：</p><ul><li>儲存從稀缺變成豐富</li><li>網路頻寬大幅提升（從 Mbps 到 Gbps 甚至 100 Gbps）</li><li>工作負載從穩定變成突發性的</li><li>資料共享從例外變成常態</li></ul><p>在這樣的背景下，這股「<strong>Compute-Storage Separation</strong>」的浪潮，也開始向訊息與串流的領域擴散，而不同系統選擇了不同的切入方式。</p><p><strong><em>WarpStream</em></strong></p><p>從零開始重新設計 Kafka 的雲端架構，直接以 S3 為主要儲存層，徹底移除對本地磁碟的依賴。這是一條最激進、也最徹底的 compute-storage separation 路線，目標是讓 Kafka broker 成為可隨時啟停的無狀態計算資源。</p><p><strong><em>AutoMQ</em></strong></p><p>採取類似的思路，主打 Kafka-on-object-storage，原生支援 S3-compatible 儲存（如 S3、OSS、OBS），並在架構層面弱化本地磁碟的重要性，試圖以雲原生方式重新詮釋 Kafka。</p><p><strong><em>Aiven</em></strong></p><p>作為託管服務提供者，Aiven 透過 tiered storage 與雲端儲存後端的整合，將 Kafka 的資料逐步延伸到 object storage，在維持既有 Kafka 架構的前提下，降低儲存成本並提升彈性。</p><p><strong><em>Confluent</em></strong></p><p>一方面在 Confluent Platform 與 Confluent Cloud 中提供 Tiered Storage，將冷資料下沉至 object storage；另一方面透過收購 WarpStream，補齊了以 BYOC 模式全面擁抱 S3-first Kafka 的產品線，形成從傳統 Kafka 到雲原生 Kafka 的完整光譜。</p><p><strong><em>Huawei Cloud</em></strong></p><p>在其 Kafka 服務中深度整合 OBS（S3-compatible 物件儲存），以雲服務的方式推動 Kafka 去本地磁碟化，滿足大型企業與政府場景對成本、擴展性與運維簡化的需求。</p><p><strong><em>Apache Pulsar</em></strong></p><p>值得一提的是，Pulsar 並非在 Kafka 架構上「事後補上」物件儲存，而是從一開始就將 broker 與儲存層解耦，將資料持久化交由 BookKeeper 負責，broker 本身則專注於流量調度與協議處理。從設計哲學來看，Pulsar 其實更早體現了 compute-storage separation 的精神，只是選擇了一條與 Kafka 生態平行、而非演進自 Kafka 的路線。</p><p>這些實作在技術細節與成熟度上各不相同：有些選擇以 tiered storage 漸進演進，有些則直接重構儲存模型，甚至從一開始就假設計算與儲存是分離的。但它們共同指向一個明確趨勢：即便是誕生於 shared-nothing 時代，compute-storage separation 也正在成為雲端時代下<strong>串流與訊息系統</strong>的新常態。</p><p><strong>Cloud Era: Compute-Compute Separation</strong></p><p>更進一步，現代雲端資料倉庫如 Snowflake 和 BigQuery 還實現了 <strong>Compute-Compute Separation</strong>。在這種架構下，不僅 Storage 是共享的，多個獨立的 Compute Cluster 也可以同時存取同一份資料。這意味著不同的團隊或不同的工作負載（如 ETL、即時分析、機器學習訓練）可以使用各自獨立的 Compute 資源，互不干擾。當某個團隊的查詢負載突然增加時，只需要擴展該團隊的 Compute Cluster，而不會影響其他團隊。這種架構將資源隔離和彈性擴展推向了極致。</p><p><strong>Stream come into play</strong></p><p>在 Lakehouse 架構下，每個階層的職掌分明。在 Storage 層，資料必須是可被計算的，而 Storage 本身也分為不同的類型以適應不同的處理模式。</p><p>傳統的資料庫本質上是 Batch Store，它們將資料以靜態的方式存儲起來，等待 Batch 處理作業來讀取和計算。這些 Batch Store 只能被 ETL Batch Job 或分析引擎（如 Spark、Presto、Trino）以批次模式處理。資料通常是在某個時間點的快照，處理的是「已經發生的事情」。</p><p>而 Kafka 則是 Stream Store，它的定位截然不同。<strong>Stream Store 不僅可以被 Batch Job 處理</strong>（例如每小時從 Kafka 讀取一批資料進行分析），<strong>更重要的是它也能被 Stream Compute Engine</strong>（如 Apache Flink、Kafka Streams）<strong>處理</strong>。這意味著資料可以在產生的瞬間就被處理，處理的是「正在發生的事情」。</p><p>這個區別至關重要。Batch Store 假設資料是完整的、不可變的，適合處理歷史分析、報表生成這類場景。Stream Store 則假設資料是持續流入的、永無止境的，適合處理即時監控、即時推薦、即時風控這類場景。</p><p>在現代 Lakehouse 架構中，兩者並非對立，而是互補的。資料通常先流經 Stream Store（如 Kafka），被 Stream Compute Engine 處理後產生即時結果，同時也會被定期批次寫入 Batch Store（如 Object Storage 上的 Parquet 文件），供後續的深度分析使用。這正是 Lambda 架構和 Kappa 架構試圖解決的問題：如何在同一個系統中同時支援 Stream 和 Batch 兩種處理模式。</p><p>下表整理了 Storage 和 Compute 在 Batch 和 Stream 兩種處理模式下的代表性工具：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dgo519QfZsyHUFNuxjtbrw.png" /></figure><p>這個分類幫助我們理解不同工具在資料處理管道中的定位。值得注意的是，現代系統的界線越來越模糊：Spark 既可以做 Batch 也可以做 Stream，Kafka 既可以被 Batch Engine 讀取也可以被 Stream Engine 處理。</p><p>（傳統上，Spark 的 stream processing 是以 micro-batch 為實作基礎的「Near-Real Time」處理模型，而非 record-by-record 的連續流處理。Spark 4.1 開始支持 Real-Time Mode。）</p><h4>Delivery Guarantees in RPC</h4><p>在分散式消息系統中，當 Client 傳訊息給 Server，兩者之間能提供什麼樣的 Delivery Guarantees？</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/501/1*mUoVv_D5U33iqtq-Ycm2xQ.png" /></figure><p>在理想情況下，我們希望遠端程序呼叫能維持與本地程序呼叫相同的語義 — — 也就是「exactly once」，當 Client 的呼叫返回時，程序確實<strong>執行</strong>了恰好一次。但在分散式環境中，訊息可能遺失、Server 可能當機，要維持這種語義非常困難。因此，根據 RPC 系統願意付出多少代價， Bruce Nelson 提出了三種不同的語義：At-Least-Once、At-Most-Once、Exactly-Once。</p><p><strong>這三種語意規範的是 Server 實際執行了幾次，而不是 Server 收到請求幾次。</strong>理解這一點對接下來的討論至關重要！</p><p><strong>At-Least-Once</strong></p><p>只要伺服器機器沒有故障，呼叫至少會執行一次。這種語義的實作非常簡單，開銷很小。客戶端機器會持續向伺服器發送呼叫請求，直到收到確認訊息。如果一個或多個確認訊息遺失，伺服器可能會執行多次呼叫。</p><p>這種方法只適用於「冪等操作」（idempotent operation），也就是多次呼叫會返回相同結果的操作。實作 at-least-once 的 Server 必須是無狀態的（stateless），不會因為 Client 的請求而改變全域狀態。</p><p><strong>At-Most-Once: Three Implementation Approaches</strong></p><p>At-Most-Once 保證操作最多執行一次，有三種主要的實作方式。</p><p>第一種是利用 TCP 層面的保證。如果 RPC 建立在 TCP 之上，可以直接利用 TCP 本身的特性來實現 At-Most-Once。TCP 透過 sequence number 保證封包不重複、不亂序，因此只要 Client 發送一次請求後不重試，Server 最多只會收到一次請求，自然也就最多執行一次操作。這種情況下不需要在應用層實作任何去重機制，因為 TCP 已經在網路層面處理了重複問題。</p><p>第二種是 Fire-and-Forget 加上不重試的策略。這是最簡單的實作方式：Client 發送請求後就放棄，既不等待回應也不重試。由於 Client 只發送一次，Server 自然不會收到重複請求。這種方式常見於使用 UDP 的場景，如日誌記錄或監控指標上報 — — UDP 本身不保證可靠傳輸，但換來的是極低的延遲和開銷。操作可能完全沒執行（封包在網路中丟失），也可能執行了一次，但絕不會執行多次。對於可以容忍少量數據遺失的應用來說，這種簡單的策略往往就足夠了。</p><p>第三種是 Request ID 加上去重表的機制，這是最通用也最常見的實作方式，特別適合不可靠的網路環境。在這種機制中，Client 為每個請求分配唯一的 Request ID，並在沒收到回應時重試（可能重試多次）。Server 端維護一個「已處理請求」的記錄表。當 Server 收到請求時，會先檢查 Request ID 是否已存在於去重表中。如果存在，就識別為重複請求，不再執行操作，直接返回成功（或之前的結果）；如果不存在，則執行操作、記錄 Request ID，然後返回結果。這種機制確保了即使 Client 因為網路問題多次重試，Server 端的操作也只會執行一次。</p><p>然而，這種看似完美的「Client retry + Server 去重」組合，<strong>仍然無法達到 Exactly-Once 的保證。問題的關鍵在於一個微妙但致命的時序窗口。</strong></p><p>想像這樣的場景：Client 發送一個轉帳請求（Request ID: 12345），Server 收到後記錄了這個 Request ID 到去重表，並執行了轉帳操作 — — 錢已經轉出去了。但就在 Server 準備回傳結果的時候，它突然崩潰了。Client 那邊因為超時沒收到回應，不知道操作到底成功了沒有，於是重試了相同的請求。新的 Server（或重啟後的 Server）接手後，檢查去重表發現 Request ID: 12345 確實存在，於是判斷這是重複請求，不再執行轉帳。</p><p>但問題來了：之前的執行結果在哪裡？</p><p>若 Server 只記錄了 Request ID，知道「這個請求我處理過」，但不知道當時的執行結果是什麼。它只能回傳一個模糊的「成功」或「我見過這個請求」。Client 收到回應，但仍然不知道具體結果 — — 轉帳真的成功了嗎？餘額變成多少了？這就是 At-Most-Once 的本質：保證不重複執行，但不保證 Client 一定能知道結果。</p><p><strong>Exactly-Once: The Holy Grail</strong></p><p>要實現真正的 Exactly-Once，需要更進一步。首先要持久化 Request ID，不能只存在記憶體的去重表中；其次要持久化執行結果，不只是記錄「處理過」，還要記錄「結果是什麼」；最後需要原子性保證，執行操作和記錄結果必須是一個原子操作，不能被中斷。只有這樣，當 Client 重試時，Server 才能識別出「這個請求我執行過」，並且返回當時的執行結果。</p><p>這就是為什麼 Exactly-Once 如此難以實現 — — 它需要複雜的狀態管理、持久化機制，以及類似兩階段提交的協議。正如 RPC 文獻中提到的，Bruce Nelson 在其博士論文中提出了 Exactly-Once 的實作方案，但「因為實作的複雜性，據我所知從未被實際部署過」。大多數系統最終都選擇了在 At-Least-Once 或 At-Most-Once 之間權衡，而將 Exactly-Once 的保證留給應用層去處理。</p><p><strong>The Reality: Choose Your Trade-off</strong></p><p>在實務中，選擇哪種語義取決於具體場景。</p><p>對於非關鍵數據（如用戶行為日誌、監控指標），At Most Once 或 At Least Once 通常就足夠了。對於關鍵業務數據（如訂單、交易），需要 Exactly Once，但也要評估實現成本。</p><p>很多時候，At Least Once 加上下游的冪等性處理，是性價比最高的方案。比如使用唯一的 message-id 進行去重，或者設計天然冪等的操作（如「將庫存設定為 10」而不是「庫存減 1」）。</p><p>理解這三種語義的權衡，是設計可靠的分散式系統的基礎。Kafka 提供了靈活的配置選項，讓用戶可以根據需求選擇合適的語義保證，這也是它能夠適應各種場景的原因之一。</p><h4>Throughput, Latency, Freshness</h4><p>在評估分散式系統和資料處理系統的性能時，有三個關鍵指標經常被提及：Throughput、Latency 和 Freshness。理解這三者的定義和權衡，對於設計和優化系統至關重要。</p><p><strong>Throughput: How Much Can We Process?</strong></p><p>Throughput（吞吐量）指的是系統在單位時間內能夠處理的數據量或請求數量。常見的度量單位包括：每秒處理的消息數（messages per second, msg/s）、每秒處理的請求數（requests per second, req/s）、每秒傳輸的數據量（MB/s 或 GB/s）。</p><p>在 Kafka 的場景中，吞吐量通常指每秒能夠寫入或讀取多少條消息。高吞吐量意味著系統能夠處理大規模的數據流，這對於處理海量日誌、用戶行為數據等場景至關重要。</p><p>Kafka 的設計重點就是追求高吞吐量，透過順序寫入、批次處理、零拷貝等技術，Kafka 能在普通硬件上達到每秒數百萬條消息的吞吐量。</p><p><strong>Latency: How Fast Can We Respond?</strong></p><p>Latency（延遲）指的是從請求發出到收到響應的時間間隔。在不同的系統中，延遲的定義可能略有不同。在消息系統中，延遲通常指：</p><ul><li>端到端延遲（End-to-End Latency）：從 Producer 發送消息，到 Consumer 接收並處理該消息的總時間。</li><li>寫入延遲（Write Latency）：Producer 發送消息到收到 Broker 確認的時間。</li><li>讀取延遲（Read Latency）：Consumer 請求數據到收到數據的時間。</li></ul><p>延遲通常以毫秒（ms）或微秒（μs）為單位。低延遲系統能夠快速響應請求，這對於即時交互、即時告警等場景非常重要。</p><p>需要注意的是，Throughput 和 Latency 往往存在權衡。為了提高吞吐量，系統通常會採用批次處理（batching），將多個請求打包一起處理，這會增加單個請求的延遲。</p><p>Kafka 在這個權衡上的選擇很有意思。在 Kafka 4.0 之前，預設的策略是「盡快發送」 — — Producer 一有消息就立刻送出去，linger.ms（等待時間）預設為 0。這個設計優先考慮低延遲，適合需要即時響應的場景。但從 Kafka 4.0 開始，預設值改為 5ms，讓 Producer 稍微等一下，看看能不能湊齊更多消息一起打包發送。這個看似微小的改變，反映了 Kafka 設計理念的演進：從「預設追求最低延遲」轉向「預設追求更好的吞吐量」。</p><p>這個轉變背後的邏輯很實際：在大多數生產環境中，增加幾毫秒的延遲通常是可以接受的，但透過 batching 提升 30–50% 的吞吐量，卻能顯著降低網路開銷和 Broker 負載。當然，對於確實需要極低延遲的場景（如高頻交易、即時告警），用戶仍然可以手動將 linger.ms 設為 0。</p><p>這也體現了 Kafka 一貫的哲學：提供合理的預設值（針對大多數場景優化），同時保留完整的配置彈性，讓用戶根據自己的需求做出權衡。</p><p><strong>Freshness: How Current Is Our Data?</strong></p><p>Freshness（新鮮度）指的是數據的時效性，也就是「數據有多新」。這個概念在資料分析和決策系統中尤為重要，因為過時的數據可能導致錯誤的決策。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*4wnvfMZtXnnOL46WFba_qQ.png" /></figure><p>Freshness 通常與這三種處理模式對應：</p><ul><li>Offline/ Batch Analysis（離線分析）的數據新鮮度為小時級到天級。數據通常是昨天或更早的快照，適合歷史分析。典型場景包括每日業務報表、月度趨勢分析、離線機器學習訓練。</li><li>Near Real-Time（準即時）的數據新鮮度為分鐘級到秒級。數據有一定延遲但仍然相對新鮮，適合運營監控。典型場景包括每小時的銷售統計、運營儀表板、A/B 測試結果。</li><li>Real-Time/ Streaming（即時）的數據新鮮度為毫秒級到秒級。數據幾乎是即時產生即時處理，適合需要立即響應的場景。典型場景包括金融交易反欺詐、即時推薦、系統異常告警。</li></ul><p><strong>The Trade-offs</strong></p><p>這三個指標之間存在複雜的權衡關係。</p><p>Throughput vs Latency：提高吞吐量通常需要批次處理，這會增加延遲。Kafka 允許配置 linger.ms 和 batch.size 來調整這個權衡。</p><p>Throughput vs Freshness：批次處理可以提高吞吐量，但會降低數據新鮮度。如果要求 Real-Time 的 Freshness，就需要犧牲一些吞吐量。</p><p>Latency vs Reliability：如前面討論的 At-Least-Once vs Exactly-Once，更強的可靠性保證通常意味著更高的延遲。</p><p>Kafka 的設計哲學是：優先保證高吞吐量，然後透過配置讓用戶根據需求調整延遲和新鮮度。對於需要低延遲的場景，可以減小 batch size 和 linger time；對於需要高新鮮度的場景，可以讓 Consumer 更頻繁地拉取數據。</p><p>理解這三個指標及其權衡，是選擇和優化消息系統的基礎。不同的業務場景對這三個指標的要求不同，沒有一個「完美」的配置，關鍵是找到適合自己場景的平衡點。</p><h4>SLA: Service Level Agreement</h4><p><strong>What is SLA?</strong></p><p>SLA（Service Level Agreement，服務水準協議）是服務提供者與客戶之間就服務品質達成的正式協議。在雲端服務和資料系統領域，SLA 定義了服務提供者承諾提供的最低服務水準，以及未達標時的補償措施。</p><p>SLA 通常包含多個關鍵指標，每個指標都有明確的數值承諾。這些指標讓客戶能夠量化評估服務品質，也為服務提供者設定了明確的目標。</p><p><strong>Common SLA Metrics in the Industry</strong></p><p>業界常見的 SLA 指標包括：</p><p><strong>Availability</strong>（可用性）是最常見的 SLA 指標，通常以「幾個九」來表示。99.9%（Three Nines）意味著服務每年最多只能停機 8.76 小時。99.99%（Four Nines）意味著每年最多停機 52.56 分鐘。99.999%（Five Nines）意味著每年最多停機 5.26 分鐘。</p><p>舉例來說，AWS S3 承諾 99.99% 的可用性，Google Cloud Storage 承諾 99.95% 的可用性。Confluent Cloud 根據不同的方案，提供從 99.9% 到 99.99% 的可用性保證。</p><p><strong>Durability（持久性）</strong> 規定數據丟失的最大機率。AWS S3 承諾 99.999999999%（11 個九）的年度持久性，這意味著如果你存儲 10,000,000 個物件，平均每 10,000 年才會丟失一個。這是針對正常運作期間，由於硬體故障、位元翻轉等隨機因素造成的資料損壞或遺失。</p><p><strong>Latency</strong>（延遲）規定了請求響應時間的上限。例如，某些資料庫服務可能承諾 99% 的查詢在 100ms 內完成，99.9% 的查詢在 1 秒內完成。</p><p><strong>Throughput</strong>（吞吐量）保證系統能夠處理的最小流量。例如，Kafka 的雲端服務可能承諾每秒至少能處理 X MB 的數據。</p><p>而以下兩個承諾跟災難發生有關。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*fuHX2CS5a9po_rEqLwt4Kw.png" /></figure><p><strong>Recovery Point Objective (RPO)</strong> 定義了災難發生時可接受的最大數據遺失量。例如，RPO=0 意味著零數據遺失，RPO=1 小時意味著災難發生時最多可能遺失 1 小時的數據。RPO 關注的是「能承受丟失多少資料」。</p><p><strong>Recovery Time Objective (RTO)</strong> 定義了系統故障後恢復服務的最長時間。例如，承諾在 4 小時內恢復服務。RTO 關注的是「能承受停機多久」。</p><p>值得注意的是，Durability 和 RPO 看似相似但其實不同：Durability 描述的是平時運作中資料自然損壞的機率；RPO 描述的是當災難（如資料中心被摧毀、人為誤刪）發生時，你最多能接受丟失多少資料。</p><p><strong>SLA for Data Freshness</strong></p><p><strong>Data Freshness</strong>（數據新鮮度）的 SLA 在即時數據處理系統中越來越重要。這類 SLA 通常規定數據從產生到可被查詢的最大延遲時間。例如，某個即時分析平台可能承諾：所有事件在產生後 5 秒內可被查詢（P99），或者所有事件在產生後 10 秒內必定可被查詢（P100）。</p><p>在 Kafka 的場景中，雖然 Kafka 本身不直接提供 Freshness 的 SLA，但整個資料管道可能會有這樣的承諾。例如，從用戶點擊事件發生，到該事件出現在即時儀表板上，承諾不超過 30 秒。</p><p>Data Pipeline SLA 可能包括多個階段：</p><ul><li>數據從 Source 到 Kafka 的延遲不超過 X 秒。</li><li>數據在 Kafka 中的保留時間至少為 Y 天。</li><li>從 Kafka 到下游系統（如資料倉庫）的同步延遲不超過 Z 分鐘。</li></ul><p><strong>SLA Violations and Compensation</strong></p><p>當服務提供者未能達到 SLA 承諾時，通常會提供補償。常見的補償方式包括：</p><ul><li>服務抵免（Service Credits）：按照停機時間比例退還費用。例如，如果月度可用性低於 99.9%，退還 10% 的月費；低於 99.0%，退還 25% 的月費。</li><li>優先支援（Priority Support）：在發生問題時提供更快的響應時間。</li><li>合約終止權（Right to Terminate）：如果 SLA 持續未達標，客戶有權提前終止合約而不受罰。</li></ul><p><strong>The Reality of SLA</strong></p><p>需要注意的是，SLA 通常會包含一些免責條款（Exclusions）。例如：</p><ul><li>計劃性維護期間不計入停機時間。</li><li>因客戶自身配置錯誤導致的問題不算違反 SLA。</li><li>因不可抗力（如自然災害、戰爭）導致的服務中斷可能不受 SLA 約束。</li></ul><p>此外，高階的 SLA（如 99.99% 或更高）通常需要額外付費。這反映了提供高可用性服務的成本：需要更多的冗餘、更頻繁的監控、更快的故障恢復能力。</p><p>對於設計資料系統的工程師來說，理解 SLA 很重要，因為：</p><ul><li>你需要設計系統以滿足對下游的 SLA 承諾。</li><li>你需要評估上游服務的 SLA 是否能支撐你的需求。</li><li>你需要在成本和服務品質之間做出權衡。</li></ul><p>Kafka 作為資料管道的核心組件，其配置和部署方式直接影響整個系統的 SLA。選擇合適的 replication factor、acks 設定、Consumer Group 配置等，都是為了在成本和 SLA 之間找到平衡點。</p><h4>Lambda Architecture vs Kappa Architecture</h4><p>現代業務有時必須一口氣滿足 Offline, Near Real-Time, Real-Time 這三種需求。舉個實際的例子：一筆銀行轉帳發生時，交易的同時需要進行反欺詐檢測（real-time），交易結束後需要立即反映在即時報表中（near real-time），而在較長時間內，這筆交易還會被用於報表統計、數據挖掘和機器學習（offline）。</p><p>因此，該如何權衡批處理（Batch Processing）和流處理（Stream Processing）的運用，這正是 <strong>Lambda 架構</strong>和 <strong>Kappa 架構</strong>試圖解決的核心問題。</p><p><strong>Lambda Architecture: The Dual-Track Solution</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*HdhPq6ObmldOAaOAeyem6g.png" /></figure><p>2011 年，Nathan Marz（Apache Storm 的創建者）在他的部落格中提出了 Lambda Architecture<a href="https://www.dremio.com/wiki/lambda-architecture/"> </a>，嘗試透過「雙軌並行」的方式同時支持批處理和即時處理。這個架構的核心思想是讓數據同時流入批處理層和速度層，分別處理後再合併結果，從而兼顧準確性和即時性。</p><p><strong>Kappa Architecture: Simplifying the Complexity</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*aFS8KsQYn4iZYtzrXX7bvw.png" /></figure><p>2014 年 7 月，LinkedIn 的 Jay Kreps（Apache Kafka 的共同創建者）在文章 “Questioning the Lambda Architecture” 中提出了 Kappa Architecture，提供了一個更簡潔的解決方案。Kappa 架構的核心理念是：如果流處理引擎足夠強大，為什麼不用它來處理所有數據？透過移除批處理層，只保留流處理層和服務層，Kappa 架構大幅降低了系統的複雜度。</p><p>按照我的規劃，之後會有一整章的篇幅，將這些看似零散、實則高度相關的名詞 — — ETL／ELT、Lambda Architecture、Kappa Architecture、Medallion Architecture 與 shift-left — — 用層次分明的方式串接起來。在此我先給出一個整體輪廓，作為後續討論的<strong>引子</strong>：</p><p>從更高的抽象層次來看，ETL／ELT、Lambda Architecture、Kappa Architecture、Medallion Architecture 與 shift-left，其實並非彼此競爭的名詞，而是資料工程在不同發展階段，針對不同核心問題所提出的回應。ETL 與 ELT 解決的是「資料怎麼進來、什麼時候轉換」：ETL 假設轉換應該發生在載入前，使資料一進入系統就符合既定 schema；而 ELT 則傾向先將 raw data 可靠地落地，再在下游用更彈性的計算引擎完成轉換，這也更貼近 data lake 與 lakehouse 的運作方式。Lambda 與 Kappa 進一步聚焦於「資料能不能同時即時且正確地算出來」：前者以雙軌架構在批次準確性與即時性之間折衷，後者則承認事件流才是主體，試圖用單一串流模型消除兩套邏輯所帶來的系統複雜度。隨著資料規模與使用場景不斷擴張，工程挑戰逐漸從「算不算得出來」轉向「算出來的結果能不能信」，這正是 Medallion Architecture 透過 Bronze／Silver／Gold 分層來表達資料成熟度的出發點；而 shift-left 則更進一步，將焦點從技術架構推向組織與責任分工，主張在資料產生的最上游就定義 schema、品質與契約（例如 data contract、schema registry 與前置驗證），避免把風險與不確定性一路傳遞到管線末端。整體而言，這些概念共同勾勒出一條清晰的演進路線：資料工程的重心，正不斷從資料搬運與計算能力，走向語義治理與責任前移。</p><p>如果你對這條演進路線背後的真理感到好奇，那麼之後的某一章會為此而寫！</p><p><strong>Kafka: The Streaming Bridge</strong></p><p>無論是 Lambda 還是 Kappa 架構，<strong>Kafka 都是不可或缺的核心組件</strong>。</p><p>在 Lambda 架構中，Kafka 扮演著統一數據入口的角色，負責將數據分發到批處理和流處理兩個系統。它就像一個交通樞紐，確保相同的數據能夠同時送達兩條不同的處理管道。</p><p>而在 Kappa 架構中，Kafka 的角色更加關鍵 — — 它不僅是數據的傳輸管道，更是<strong>唯一的數據來源（Single Source of Truth）</strong>。透過 Kafka 的數據持久化和重放（Replay）能力，流處理引擎既可以處理即時進來的數據，也可以重新處理歷史數據。這種設計讓 Kappa 架構能夠用單一的處理引擎同時滿足批處理和流處理的需求。</p><p>因此，在這兩個架構中，<strong>Data Source 的下一個站點都是 Kafka</strong>，而 Kafka 之所以能在 Data Source 的下游站穩腳跟，只因它當初解決了日誌大數據的傳輸問題。</p><h3>The Issue of Big Data Transmission</h3><p>我們先來了解一個根本問題：<strong>大數據系統的數據究竟從哪裡來？</strong></p><p>在 Data Lake Era，大數據的源頭，其實就是應用系統或業務系統產生的<strong>日誌（Log）</strong>。最早用上這些大數據技術的，往往都是互聯網公司的<strong>廣告</strong>和<strong>搜索</strong>業務。你每瀏覽一次網頁、每點擊一次廣告、每進行一次搜索，這些操作都會在服務器上記錄下對應的訪問日誌。</p><p>在一開始，對於這些數據處理的需求還不是流式處理，而是透過 <strong>MapReduce</strong> 來進行批處理的。所以，我們就有了一個最簡單的需求：<strong>把每台應用服務器上的日誌，放到 Hadoop 集群的 HDFS 上去。</strong></p><h4>The Naive Approaches and Their Failures</h4><p><strong>Approach 1: Scheduled Upload with Cronjob</strong></p><p>最直接的方法是把日志先寫到本地硬盤，按時間分割文件（例如每小時一個），再透過 Linux 的 cronjob 定時上傳到 HDFS。這個方式簡單粗暴，但問題顯而易見：<strong>在日誌上傳到 HDFS 之前，整個系統是沒有災難復原機制的</strong>。如果某台服務器出現硬件故障，那段時間的日誌就丟了。</p><p><strong>Approach 2: Direct Write to HDFS</strong></p><p>那能不能直接透過 HDFS 客戶端寫入日誌呢？這樣一旦寫入就有三份副本，不會丟數據。但這會帶來兩個致命問題。</p><p>首先是<strong>並發壓力</strong>。如果有 100 台應用服務器，就會有 100 個客戶端 7×24 小時不斷往 HDFS 寫入，HDFS 並不是為這種高並發、持續寫入的場景設計的。</p><p>其次是<strong>文件組織的兩難</strong>。如果各自寫各自的文件，每小時會產生 100 個小文件，造成大量存儲浪費和 MapReduce 處理開銷。如果都往同一個文件寫，所有客戶端會在 <strong>chunkserver</strong>（HDFS 中負責實際存儲數據的服務器）的主副本上排隊競爭，形成串行化的瓶頸。</p><p>因此，我們需要的是一個專門設計的中間層，能夠接受數百個客戶端的高並發寫入、提供數據的持久化和容錯，並高效地將數據傳遞給 HDFS 和其他處理系統。</p><h3>Scribe: A Multi-Tier Aggregation Tree</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Srq1_njgs22bjWEwFss4cQ.jpeg" /></figure><p>為了更適合 HDFS（GFS）這樣的特性，我們需要一個中間層來幫助解決問題。這個中間層，通常被稱為<strong>日誌收集器（Log Collector）</strong>。其中，Facebook 於 2008 年推出了開源的 <strong>Scribe</strong>，Cloudera 於 2010 年推出了 <strong>Flume</strong>。</p><p>這些日誌收集系統的架構並不難理解。簡單來說，各個應用服務器上都有一個日誌收集器的客戶端，多個客戶端會把日誌發送到一個<strong>日誌匯集（Log Aggregator）服務器</strong>，而多個日誌匯集服務器還可以再次用類似的方式進行匯聚。透過這樣一個類似於<strong>多層樹狀的結構</strong>，最終只有幾個日誌匯聚服務器會向 HDFS 寫入數據。</p><p>這種方式的優勢很明顯：既不會有太多的並發寫入請求直接打到 HDFS 上，同時又盡可能發揮了 HDFS 順序寫入數據高吞吐量的優勢。這些系統本身也已經設計了各種容錯機制。比如，在網絡傳輸中斷的情況下，Scribe 會先把數據寫入到本地磁盤，等待網絡恢復的時候再做「斷點續傳」，這樣就不會因為暫時的網絡問題而丟失數據。</p><p>實際上，它並不是即時不斷地向 HDFS 寫入數據，而是<strong>定時</strong>地向 HDFS 上<strong>轉存（Dump）文件</strong>。只不過，這個「定時」很頻繁，比如每 5 分鐘，甚至每 1 分鐘就向 HDFS 上寫入一個日誌文件。這樣做的好處是，因為我們已經做了所有應用服務器的日誌匯聚，即使有 1000 台服務器，這一分鐘也只有<strong>一個日誌文件</strong>，並不會遇到大量碎片化的小文件的問題。</p><p>然而，這種架構仍有其局限性：它仍然是批處理模式，只是批次變小了（從小時級降到分鐘級），無法做到秒級或毫秒級的即時處理，而且每次都需要啟動完整的 MapReduce 任務，overhead 仍然存在。</p><h3>Early Kafka’s Mission: Bridging to Near Real-Time</h3><p>根據 Kafka 的原始論文 “Kafka: a Distributed Messaging System for Log Processing”（2011）和當時的技術背景，我們需要理解一個重要的事實：<strong>Kafka 最初的設計目標並不是為了支持 Real-Time 的即時查詢，而是為了解決更實際的日誌傳輸問題。</strong></p><p>在 2008–2010 年的 Scribe/Flume 時代，系統的主要需求是將日誌收集到 HDFS，延遲通常介於 Near Real-Time 到 Offline 之間，分析方式是定期的批處理 MapReduce。這個時期的挑戰是「如何高效地把數據送進去」，而不是「如何即時處理數據」。隨著技術的進步，Scribe 和 Flume 已經能夠將延遲壓縮到 Near Real-Time 的範圍，但仍然無法滿足 Real-Time 的需求。</p><p>2011 年 Kafka 誕生時，LinkedIn 的工程師們看到了三個核心需求。首先是<strong>高效的日誌收集和傳輸</strong> — — 需要將數百台服務器的日誌快速、可靠地收集起來，並傳遞到 HDFS 進行批處理分析。其次是 <strong>Near Real-Time 分析</strong> — — 將數據分析的延遲從 Offline 提升到 Near Real-Time，支持更頻繁的 MapReduce 任務執行。第三，雖然當時主流還是批處理，但 LinkedIn 已經看到流處理的需求，<strong>為未來的 Real-Time 處理做準備</strong>。值得注意的是，Storm 也是在 2011 年開源，流處理的概念正在興起。</p><p>因此，2011 年的 Kafka 定位在 Near Real-Time，<strong>並非因為技術能力的限制，而是因為整個數據生態系統還以批處理為主</strong>。當時的分析工具（HDFS、MapReduce、Hive）都是批處理導向，業務場景也不需要秒級響應。Kafka 的核心價值是提供更高效、更可靠的日誌傳輸，並為未來的流處理需求做好準備。</p><p>真正的 Real-Time <strong>需求爆發</strong>是在 2014–2015 年之後。隨著 Spark Streaming、Flink 等流處理引擎的成熟，以及即時推薦、風控、監控等業務場景的興起，Real-Time 處理才成為主流需求。Kafka 也在這個過程中水到渠成地演化，它的底層架構從一開始就是 Stream Store，當生態系統（如 Stream Compute Engine）和業務需求成熟時，Kafka 自然能夠支援 Real-Time Processing 場景。</p><p><strong>好的系統往往從解決眼前的實際問題開始，但要為未來的需求留下足夠的設計空間。</strong></p><h3>Early Kafka: The Architecture</h3><h4>Core Entities</h4><p>Kafka 的架構由幾個核心實體所構成。理解這些實體各自的角色，以及它們之間如何協作，是掌握 Kafka 系統行為與設計取捨的關鍵。本節將以一則訊息（message）的生命週期為主線，從它被寫入系統、儲存、再到被讀取與處理的過程，逐步說明 Kafka 的核心組件，以及它們在整體架構中所扮演的角色。</p><p>從系統視角來看，這條生命週期描述的是一條完整的 <strong>Data Plane 路徑</strong>：Producer 將資料寫入、Broker 負責儲存與複寫、Consumer 從中讀取並處理事件。這樣的敘述方式，與在 relational database 中討論一條 DML 指令（INSERT / UPDATE / SELECT）的執行流程十分相似 — — 關注的是資料如何流動、如何被存取，而不是系統如何做出全域決策。</p><p>正因如此，現在先將焦點放在 Data Plane 的角色與互動上，而暫時不深入討論 Control Plane 中關於 metadata、選舉與 consensus 的細節。那些問題，將在 ”Kafka 4.0 (2024): From ZooKeeper Era to KRaft Era” 中被單獨拆解與對照。</p><p>千里之行，始於足下。讓我們開始吧！</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*8i3IicWKWdu6GjtVQg2Eog.png" /></figure><p>一則訊息的旅程始於 <strong>Producer（生產者）</strong>。Producer 是資料的來源，負責將訊息發送到 Kafka。每一筆訊息在送出時，都必須指定要寫入哪一個 Topic，而在實作層面，Producer 也會決定該訊息最終要被寫入哪一個 Partition。實務上，Producer 可能是一個應用伺服器、日誌收集器，或任何需要對外發送事件或資料的系統。</p><p>需要特別釐清的是，Kafka 所謂的「Producer」是一種<strong>邏輯角色（client）</strong>，而不是程式檔案或程式碼數量的概念。一個應用程式通常會建立一個 Producer client 實例來對 Kafka 發送訊息，但在需要時，也可以在同一個應用中建立多個 Producer client。Kafka 並不關心這些 Producer 來自同一個程式檔案、同一個 process，或是不同的服務；在系統眼中，它們都是獨立的 Producer client。</p><p>同樣需要釐清的是：訊息要進入哪個 Partition，是 Producer 的行為選擇，而不是 Kafka 協定層的強制規則。雖然最常見、也最被建議的做法，是根據訊息的 key 進行分配，以確保相同 key 的訊息落在同一條序列中，但 Kafka 並不要求「相同 key 必須進入相同 Partition」。Producer 完全可以選擇將相同 key 的訊息分別送到不同的 Partition；只是這樣一來，原本依賴 Partition 所提供的區域順序語義就會被破壞。換言之，順序並不是 Kafka 自動替你維護的性質，而是使用者在設計 Producer 行為時所做出的選擇。</p><p>當訊息被送出後，它會被寫入 <strong>Broker</strong>。Broker 是 Kafka 叢集中的服務節點，負責實際儲存資料，並處理 Producer 與 Consumer 的讀寫請求。一個 Kafka 叢集通常由多個 Broker 組成，每個 Broker 可以同時承載多個 Topic 的多個 Partition。為了在多節點環境下協調叢集狀態與角色分工，Kafka 必須仰賴某種共識（Consensus）機制，而這套機制在 ZooKeeper 時代與 KRaft 時代，採取了截然不同的設計。</p><p>在 Broker 內部，訊息首先被歸類到對應的 <strong>Topic（主題）</strong>。Topic 是 Kafka 中用來對訊息進行邏輯分類的單位，可以理解為一個事件或資料的命名空間。例如「使用者行為日誌」、「訂單事件」、「系統監控指標」都可以是不同的 Topic。Producer 在發送訊息時必須指定 Topic，Consumer 則透過訂閱 Topic 來接收資料。若以資料庫做類比，Topic 類似於 PostgreSQL 中的 table — — 都是用來組織與描述同一類型資料的邏輯單位。</p><p>每一個 Topic 在實體上會被切分為多個 <strong>Partition（分區）</strong>。Partition 是 Topic 的物理切分單位，分散儲存在不同的 Broker 上。這個設計是 Kafka 能夠實現高吞吐量與平行處理的核心：多個 Producer 可以同時向不同的 Partition 寫入資料，多個 Consumer 也可以同時從不同的 Partition 讀取資料。Kafka 僅在 Partition 內提供區域順序保證；每一個 Partition 本身都是一條獨立的 append-only log。</p><p>這個概念與 PostgreSQL 的 partition 有相似之處：兩者都是將大型資料集切分為較小的單元，以提升效能與可管理性，而且都可以依照 key 來決定資料應該落在哪個 partition。不同之處在於，Kafka 的 Partition 更強調順序性與串流語義，每個 Partition 都擁有獨立的 offset 序列；而資料庫的 partition 則主要用於查詢最佳化，在邏輯上仍屬於同一張 table。</p><p>在每一條 Partition 之中，Kafka 透過 <strong>Offset（偏移量）</strong> 來標識訊息的位置。Offset 是一個在單一 Partition 內單調遞增的數字，用來表示每一筆訊息在該 Partition 中的順序位置。由於每個 Partition 都有自己獨立的 Offset 序列，Offset 的語義只在 Partition 內成立，而不存在跨 Partition 的全域 Offset。Consumer 正是透過維護 Offset，來追蹤自己已經處理到哪一個位置；這也是 Kafka 能夠支援資料重播、回溯與精確控制消費進度的基礎。</p><p>當訊息準備被讀取時，輪到 <strong>Consumer（消費者）</strong> 登場。Consumer 從 Kafka 拉取訊息並進行處理，它可以訂閱一個或多個 Topic，並從其所分配到的 Partition 中讀取資料。與 Producer 一樣，Consumer 也是一種邏輯上的 client，而不是程式檔案的概念。一個應用程式通常會建立一個 Consumer client 實例，但也可以在同一個應用中建立多個 Consumer client，視需求而定。</p><p>與許多傳統 message queue 不同，Kafka 並不由 Broker 代為追蹤「訊息是否已被消費完成」，而是將消費進度的管理權交給 Consumer。Consumer 可以透過設定 `enable.auto.commit = true`，啟用自動提交 Offset 的機制，由 Kafka client 依照 `auto.commit.interval.ms` 所指定的時間間隔，定期提交目前消費到的位置；也可以將 `enable.auto.commit` 設為 `false`，關閉自動提交，改由應用程式在確認業務邏輯完成後，主動提交 Offset。這使得「何時算處理完成」成為應用語義的一部分，而不再是系統隱含的行為，進而影響錯誤恢復、重試策略與整體處理語義。</p><p>當多個 Consumer 需要共同處理同一批資料時，Kafka 透過 <strong>Consumer Group（消費者組）</strong> 來進行協調。Consumer Group 並不是一個需要額外建立的實體，而是一種執行時的邏輯關係：只要多個 Consumer client 使用相同的 group.id，Kafka 就會將它們視為同一個 Consumer Group。Group 的責任在於協調 Partition 的分配與重新平衡，而不是定義 Offset 的語義。</p><p>在同一個 Consumer Group 中，每一個 Partition 在任一時間點，只會被分配給其中一個 Consumer，以確保 Partition 內的區域順序不被破壞；而當某個 Consumer 發生故障或離線時，Group 會觸發 rebalancing，將其負責的 Partition 重新分配給其他仍然存活的 Consumer。由於消費進度是以 Offset 形式被記錄下來，新接手的 Consumer 可以從正確的位置繼續處理，確保資料不會遺失，最多只會出現重複處理的情況。</p><p>從 client 的角色分工來看，Kafka 的設計其實相當直白：Producer 本質上是一個 Writer client，負責將資料寫入系統；Consumer 則是一個 Reader client，負責從系統中讀取資料並進行處理。這種讀寫角色在 client 層級上的明確分離，是 Kafka 架構的重要特徵之一。</p><p>相較之下，在 PostgreSQL 等傳統關聯式資料庫中，讀與寫並不是透過不同種類的 client 來區分，而是由同一種 client 透過 SQL 語義來表達 — — 同一個連線既可以執行 SELECT 進行讀取，也可以執行 INSERT、UPDATE 或 DELETE 進行寫入。換言之，資料庫系統將讀寫角色視為操作語義的一部分，而 Kafka 則選擇在 client 類型與系統互動模型上，直接將讀與寫拆分為不同的角色。</p><p>這種在 client 層級就明確區分讀寫角色的做法，也讓 Kafka 更自然地成為一個以資料流動為中心，而非以複雜查詢為中心的系統。</p><p>最後值得一提的是，Producer 與 Consumer 並不是 Kafka 唯一的 client 類型。Kafka 後續也提供 <strong>Admin client</strong>，用來建立與管理 Topic、查詢叢集狀態與設定；在後續的發展中，還出現了 Kafka Streams 與 Kafka Connect 等更高階的 client，將 Producer 與 Consumer 的能力進一步封裝，用於串流處理與資料整合。不過在 Early Kafka 的架構中，Producer、Consumer 與 Consumer Group，已經構成了訊息從寫入到被處理的完整生命週期。</p><h4>Pull Model: Consumers Pull Data</h4><p>Kafka 採用了一個與許多傳統消息隊列不同的設計：<strong>Consumer 主動拉取（Pull）數據，而不是 Broker 推送（Push）數據</strong>。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*uItIvrcjpOUKhJwGBKqWHg.jpeg" /></figure><p>這個設計選擇看似簡單，但帶來了深遠的影響。在傳統的 Push 模型中，Broker 需要追蹤每個 Consumer 的消費速度，決定何時推送多少數據。如果 Consumer 處理速度慢，Broker 需要緩存數據或限流；如果 Consumer 處理速度快，Broker 又可能成為瓶頸。</p><p>而在 Pull 模型中，<strong>Consumer 完全掌控消費速度</strong>。處理能力強的 Consumer 可以快速拉取大批數據，處理能力弱的 Consumer 可以慢慢拉取。Broker 不需要關心 Consumer 的狀態，只需要簡單地響應數據請求即可。這大幅簡化了 Broker 的設計，也讓系統更容易擴展。</p><p>此外，Pull 模型還讓 <strong>Consumer 可以自由控制消費位置</strong>。想要重新處理昨天的數據？只需要將 Offset 重置到昨天的位置再拉取即可。想要跳過某些數據？直接調整 Offset。這種靈活性在 Push 模型中很難實現。</p><p>當然，Pull 模型也有代價。如果沒有新數據，Consumer 可能會不斷發起空請求，造成資源浪費。Kafka 通過「長輪詢（Long Polling）」機制解決這個問題 — — 如果沒有數據，Broker 會保持連接一段時間，等待新數據到達後再響應，而不是立即返回空結果。</p><h4>How Early Kafka Implements Reading and Writing in a Single Partition</h4><p>在實際的實現上，Kafka 是這麼做的。每一個 Topic 會有很多個 Partition，分布到不同的物理機器上。一個物理機上，可能會分配到多個 Partition。</p><p>實際存儲的時候，我們的一個 Partition 是一個<strong>邏輯上的日誌文件</strong>。在物理上，這個日誌文件會實現成一組大小基本相同的 <strong>Segment 文件</strong>，比如每個 Segment 是 1GB 大小。每當有新消息從 Producer 發過來的時候，Broker 就會把消息<strong>追加寫入</strong>到最後那個 Segment 文件裡。</p><p>而為了性能考慮，Kafka 支持我們自己設置，是每次寫入都把數據刷新到硬盤裡，還是在寫入了一定數量的日誌或者經過一個固定的時間的時候，才把文件刷新到硬盤裡。如果選擇後者，概念上確實類似於 Cassandra 從 MemTable flush 到 SSTable：資料先累積在記憶體中，達到一定條件後才批次寫入磁碟。這讓我們可以在數據可靠性和寫入性能之間做權衡。</p><p>Broker 會在內存裡維護一個簡單的<strong>索引</strong>，這個索引其實就是通過一個虛擬的偏移量，指向一個具體的 Segment 文件。那麼在 Consumer 要消費數據的時候，就是根據 Consumer 本地維護的已經處理完的偏移量，在索引裡找到實際的 Segment 文件，然後去讀取數據就好了。</p><p>這個設計的精妙之處在於：<strong>寫入是順序追加，讀取是順序掃描</strong>。這兩種操作在機械硬盤上都非常高效，即使在 SSD 上也能發揮最佳性能。Kafka 正是通過這種「化隨機為順序」的設計，在使用普通硬件的情況下，達到了極高的吞吐量。</p><h4>Leveraging the Linux File System</h4><p>Kafka 的一個關鍵設計決策是：完全依賴 Linux 文件系統，而不是自己實現任何緩存機制。</p><p>Kafka 直接使用本地文件系統承擔消息持久化的功能，沒有實現任何緩存機制，而是直接依賴 Linux 文件系統的 Page Cache。Kafka 寫入的數據本質上都在 Page Cache 裡。</p><p>這個設計特別適合串流數據處理的場景。因為讀寫的數據有很強的時間局部性（Temporal Locality） — — Broker 剛剛寫入的數據，幾乎立刻會被下游的 Consumer 讀取，所以大量的讀寫都會命中緩存。Kafka 透過 mmap（Memory Mapped Files）來寫入數據，使得讀寫性能優異。</p><p>這個設計避免了兩個重要問題。第一是 JVM 的 GC 開銷 — — 如果大量消息緩存在 JVM 記憶體裡，GC 可能會短時間大幅影響 Broker 性能，造成延遲峰值。第二是緩存的「冷啟動問題」 — — 如果 Broker 進程重啟，內存中的緩存會消失，讀取性能會大幅下降。透過利用文件系統的 Page Cache，即使進程重啟，只要 OS 沒有重啟，Page Cache 仍然存在，性能依然穩定。</p><h4>Zero-Copy with sendfile</h4><p>除了利用文件系統，Kafka 還利用了 Linux 的 sendfile API，透過 DMA（Direct Memory Access）直接將數據從文件系統傳輸到網絡通道，實現了零拷貝（Zero-Copy）。</p><p>在傳統的數據傳輸中，數據需要經過多次複製：從磁碟讀到內核緩衝區、從內核複製到用戶空間、從用戶空間複製回內核、最後從內核發送到網絡。每次複製都會消耗 CPU 和記憶體頻寬。</p><p>sendfile 讓數據直接在內核空間內從文件系統傳輸到網絡 socket，完全繞過用戶空間，避免了多次複製。搭配 DMA，數據傳輸甚至不需要 CPU 參與，CPU 可以專注於處理其他任務。這大幅降低了網絡數據傳輸的開銷。</p><p>這些設計選擇加在一起，造就了 Kafka 驚人的性能表現：在普通硬件上就能達到每秒數百萬條消息的吞吐量，而且延遲穩定可預測。Kafka 的速度，很大程度上來自於它不做多餘的事情 — — 讓 OS 做它最擅長的事（文件系統和網絡傳輸），自己只專注於消息路由和協調。</p><h4>ZooKeeper: The Coordination Backbone in Kafka Cluster</h4><p>在 Kafka 的原始設計中，ZooKeeper 扮演著至關重要的協調角色。它就像是整個 Kafka cluster 的「中央控制台」，負責管理所有的 metadata 和協調工作。</p><p><strong>Broker Registration and Management</strong></p><p>每個 Broker 啟動時，都會在 ZooKeeper 中註冊自己。具體來說，Broker 會在 /brokers/ids/[broker_id] 路徑下創建一個 ephemeral node（臨時節點），包含該 Broker 的 host 和 port 資訊。這個 ephemeral node 的特性很重要：只要 Broker 保持與 ZooKeeper 的連接，這個節點就存在；一旦 Broker 掛掉或失去連接，這個節點就會自動消失，通知整個 cluster 這個 Broker 已經不可用了。</p><p>此外，Broker 還會在 /brokers/topics/[topic] 下註冊它所管理的 topics 和 partitions 資訊。這樣整個 cluster 就能知道哪些 topics 分布在哪些 Brokers 上。</p><p><strong>Consumer Registration and Coordination</strong></p><p>Consumer 同樣需要在 ZooKeeper 中註冊。每個 Consumer 會在 /consumers/[group_id]/ids 路徑下註冊自己，同樣使用 ephemeral node。Consumer 還會在 ZooKeeper 中追蹤自己消費到哪個 offset 了，這些資訊存儲在 /consumers/[group_id]/offsets/[topic]/[partition] 路徑下。</p><p>更重要的是，Consumer 會在 ZooKeeper 中註冊 watches（監聽器），用來監聽：</p><ul><li>Consumer Group 的變化（有新 Consumer 加入或離開）</li><li>Broker 的變化（有 Broker 加入或離開）</li><li>Topic 的變化（有新 Topic 被創建）</li></ul><p>當這些變化發生時，ZooKeeper 會通知相關的 Consumers，觸發 rebalancing 過程，重新分配 partitions。</p><p><strong>Core Functions of ZooKeeper</strong></p><p>總結來說，在早期 Kafka 架構中，ZooKeeper 負責：</p><ul><li>Broker 註冊與健康檢查</li><li>Consumer 註冊與 offset 管理</li><li>Topic 和 partition 的 metadata 儲存</li><li>Controller election（選擇哪個 Broker 作為 Controller）</li><li>Consumer Group 的協調與 rebalancing 觸發</li></ul><p>值得注意的是，從 Kafka 0.9 開始，Consumer offset 的存儲逐漸從 ZooKeeper 遷移到 Kafka 內部的 __consumer_offsets topic。但在 2011 年的論文和早期版本中，ZooKeeper 確實是 Broker 和 Consumer 都必須連接的中心協調服務。</p><h4>Delivery Order/Guarantees in Early Kafka</h4><p>Kafka 保證來自同一個 partition 的消息會按順序傳遞給 consumer，但不同 partition 之間的消息順序就沒有保證了。<strong>這其實是 Kafka 和傳統 Message Queue 最大的不同之一</strong> — — 傳統的 Message Queue（如 RabbitMQ、ActiveMQ）通常維護一個全域的消息順序，所有消息在單一隊列中按照抵達順序排列。</p><p>須特別注意的是，Kafka 論文中將傳統 Message Queue 描述為具備全域順序，指的是它常見的使用方式：</p><ul><li>假設單一的 queue</li><li>假設 consumer 行為不破壞 FIFO</li></ul><p>但在更嚴格的語義層級上，Message Queue 並未將全域順序建模為不可破壞的一級契約，multi-consumer、retry、requeue 都會破壞全域順序。</p><p>而 <strong>Kafka 透過 partition 實現水平擴展，直接犧牲了全域順序性，卻換來了更高的吞吐量和平行處理能力</strong>，這樣的選擇也導致 Kafka 只在 partition 內提供可預期的區域順序。如果用戶須保證相關消息的順序，就得先確保相關消息都發送到同一個 partition。</p><p>原則上，每個 consumer group 都能恰好收到一次消息。但有個例外：當 consumer process 異常崩潰（沒有乾淨地關閉）時，接手這些 partition 的新 consumer 可能會收到一些重複的消息 — — 那些在最後一次成功 commit offset 到 ZooKeeper 之後的消息。因此，論文中只提到 <strong>Broker-Comsumer</strong> 的訊息傳遞是 At-Least-Once。</p><p>事實上，如果要完整地討論 Delivery Guarantee，應該是要將視野擴大到 <strong>Producer-Broker-Consumer</strong> 這整條 End-to-End 路徑的訊息傳遞，而 Modern Kafka 也真的在 End-to-End 上做到了 Exactly-Once！</p><h3>Modern Kafka: The Improvements</h3><p>在 2011 年的原始論文中，Kafka 的作者們在結論部分描述了系統當時的狀態，並給出了 Future Works。究竟這些願景後來如何一步步實現？</p><h4>The Original Vision: What They Planned</h4><p>在論文的 Conclusion and Future Works 章節中，Kafka 的創建者們明確指出了兩個主要的發展方向。</p><p>第一個方向是增加內建的跨 Broker 複製機制（Built-in Replication）。在 2011 年的版本中，Kafka 還沒有內建的高可用機制。論文中提到：「We plan to add built-in replication of messages across multiple brokers to allow durability and data availability guarantees even in the case of unrecoverable machine failures.」</p><p>他們計劃同時支援非同步複製（Asynchronous Replication）和同步複製（Synchronous Replication）兩種模式，讓應用程式可以根據對持久性、可用性和吞吐量的需求，選擇適當的冗餘級別。這個設計理念體現了 Kafka 一貫的哲學：不是提供一個「萬能」的解決方案，而是提供靈活的配置選項，讓用戶根據場景自行權衡。</p><p>第二個方向是增加串流處理能力（Stream Processing Capability）。論文中觀察到：「After retrieving messages from Kafka, real time applications often perform similar operations such as window-based counting and joining each message with records in a secondary store or with messages in another stream.」</p><p>他們認為，在最底層，這可以透過在發布時對消息進行語義分區（Semantic Partitioning）來支援，確保具有相同 key 的所有消息都進入同一個 Partition，從而到達同一個 Consumer 進程。這為跨 Consumer 機器集群處理分散式串流提供了基礎。在此之上，他們希望提供一個實用的串流工具庫，包含不同的窗口函數（Windowing Functions）或 Join 技術，這將對這類應用非常有幫助。</p><h4>What Changed From 2011 Till 2026</h4><p>從 2011 年的原始論文到 2026 年，Kafka 經歷了 15 年的持續演進。讓我們按照時間軸來看看主要的里程碑版本和關鍵功能。</p><p><strong>Kafka 0.8 (2013): The Foundation of High Availability</strong></p><p>Kafka 0.8 是一個里程碑版本，它實現了論文中提出的第一個 Future Work：Built-in Replication。這個版本引入的 replication 機制，讓 Kafka 從「日誌收集工具」真正邁向「企業級消息系統」。</p><p><strong><em>Replication: Leader and Followers</em></strong></p><p>為了實現高可用性，Kafka 0.8 為每個 Partition 引入了多副本機制，分散存儲在不同的 Broker 上。這些副本採用 Leader-Follower 模型：其中一個副本擔任 Leader，其餘的是 Followers。Producer 寫入數據時，只需要寫入 Leader，Leader 會將數據寫入本地日誌文件。接著，每個 Follower 會主動從 Leader 拉取最新數據，拉取成功後向 Leader 發送 Ack 確認。</p><p><strong><em>ISR (In-Sync Replica)</em></strong></p><p>Kafka 引入了 ISR（In-Sync Replica）的概念來管理副本同步狀態。只有與 Leader 保持同步的 Followers 才會被納入 ISR 集合中。當 Leader 失效時，只有 ISR 中的 Followers 才有資格被選為新的 Leader。這個機制確保了數據的一致性，同時也提供了靈活的容錯能力。</p><p><strong><em>Tuning Durability with acks</em></strong></p><p>最關鍵的創新是 acks 配置，它讓用戶可以在性能和可靠性之間自由權衡：</p><ul><li><strong>acks=0</strong>：Producer 發送消息後立即返回，不等待任何確認。性能最高，但可能丟失數據。</li><li><strong>acks=1</strong>：等待 Leader 寫入成功即返回。如果 Leader 在數據複製到 Followers 之前掛掉，可能會丟失少量數據。</li><li><strong>acks=all</strong>：等待所有 ISR 中的副本都寫入成功才返回。最安全，但延遲較高。</li></ul><p>對於廣告點擊日誌這類可容忍少量丟失的場景，可以選擇 acks=1 獲得更好的性能；對於金融交易這類關鍵數據，則可以選擇 acks=all 確保不丟失任何消息。</p><p>這裡有個重要的細節：acks=all 等待的是 ISR 中的副本，而不是所有 Follower 副本。舉例來說，假設某個 Partition 配置了 3 個副本：</p><ul><li>1 個 Leader: partition_1</li><li>2 個 Followers: partition_2 &amp; partition_3</li></ul><p>如果 partition_3 因為網路問題或處理速度過慢而掉隊，它會被移出 ISR。此時，即使設定 acks=all，Producer 也只需等待 partition_1（Leader）和 partition_2（仍在 ISR 中的 Follower）寫入成功即可，不會無限期等待已掉隊的 partition_3。這個設計在保證數據安全性的同時，也避免了單一慢節點拖累整個系統的問題。</p><p>那麼，掉隊的 partition_3 如何歸隊呢？答案是：自動且持續進行。</p><p>partition_3 不會被替換或刪除，而是會持續嘗試從 partition_1（Leader）拉取數據。一旦它解決了導致落後的問題（例如網路恢復或處理速度提升），就會開始追趕 partition_1 的進度。當 partition_3 完全追上 partition_1 的日誌末端（log end offset）時，partition_1 會自動將它重新加入 ISR 列表。</p><p>整個過程完全自動化，不需要人工干預。partition_1（Leader）會持續監控 partition_2 和 partition_3 的同步狀態：如果某個 Follower 的延遲時間超過設定的閾值（replica.lag.time.max.ms），就會被移出 ISR；一旦追上進度，又會自動歸隊。這個機制讓 Kafka 既能保持高效能，又具備自我修復的能力。</p><p>這個版本奠定了 Kafka 高可用性的基礎，使其能夠支撐生產級別的應用場景。</p><p><strong>Kafka 0.9 (2015)</strong></p><p>如果說早期的 Kafka 主要是一個高吞吐量的分散式 log 系統，那麼 Kafka 0.9 可以被視為它正式邁向「平台化」的起點。從這個版本開始，Kafka 不再只關心資料如何被寫入與讀取，而是開始正面回應「資料如何在系統之間流動」與「平台該承擔哪些通用責任」這兩個更高層次的問題。</p><p><strong><em>Kafka Connect</em></strong></p><p>Kafka Connect 提供了一個標準化的資料整合框架，用來連接 Kafka 與各種外部系統。它將過去必須自行撰寫 Producer／Consumer 程式碼、處理錯誤與重試邏輯的工程問題，提升為以設定檔驅動、可重複部署的系統能力，使資料整合成為 Kafka 平台的一部分，而非零散的應用實作。</p><p>在設計上，Kafka Connect 明確區分為兩種角色：Source Connector 與 Sink Connector，分別對應資料流動的兩個方向。</p><p>Source Connector 的任務，是將外部系統中的資料導入 Kafka，轉換為持續產生的事件流。這些資料來源涵蓋了多種類型：最典型的是關聯式資料庫，透過 JDBC 方式定期拉取資料表或查詢結果；更進一步的，則是以資料庫交易日誌為基礎的 Change Data Capture（CDC），例如 MySQL 的 binlog、PostgreSQL 的 logical decoding、Oracle 的 redo log、或 SQL Server 的 transaction log。CDC 類型的 Source Connector 將資料的每一次變化視為事件，這使得資料庫正式成為事件來源，而 Kafka 則成為承載這些變更歷史的事件骨幹。</p><p>除了資料庫之外，Source Connector 也常被用來整合檔案與日誌系統，例如持續監控檔案新增內容、收集應用程式日誌；在雲端環境中，也可以從物件儲存（如 S3、GCS、Blob Storage）將 batch 或半結構化資料導入 Kafka。某些情境下，Source Connector 甚至被用來橋接既有的訊息系統、SaaS 平台或業務 API，作為系統現代化或事件化過程中的過渡層。</p><p>相對地，Sink Connector 的角色，則是將 Kafka 中的事件流可靠地輸出到各種下游系統。最常見的目的地包括關聯式資料庫與資料倉儲，用來將事件寫回 OLTP 系統或分析平台；在資料湖與雲端分析場景中，事件也常被持續寫入 HDFS 或物件儲存，作為長期保存與批次分析的基礎。對於即時查詢與搜尋需求，Kafka 事件則可以透過 Sink Connector 寫入 Elasticsearch 或類似系統；而在狀態同步與低延遲查詢的場景中，NoSQL 或 Key-Value Store（如 Cassandra、HBase、Redis）也成為常見的下游。</p><p>透過 Kafka Connect，Kafka 不再只是資料流經的一個中繼點，而是成為系統之間資料流動的中樞。無論是傳統的 batch ingestion、即時事件串流，或後來逐漸成為主流的 CDC 模式，都可以被納入同一套抽象之中。</p><p><strong><em>Other Updates</em></strong></p><p>除了 Connect 之外，Kafka 0.9 也在企業級可用性上邁出了關鍵一步。這個版本正式引入安全機制，包括 SSL/TLS 加密傳輸、SASL 身份驗證（支援 Kerberos、PLAIN、SCRAM 等），以及以 ACL（Access Control List）為基礎的授權管理，使 Kafka 能夠更安全地運行在多租戶與受管控的環境中。</p><p>此外，Kafka 0.9 也將 Consumer offset 的管理，從 ZooKeeper 遷移至 Kafka 內部的 __consumer_offsets topic。這個改變不僅大幅減輕了 ZooKeeper 的負擔，也象徵著 Kafka 開始將更多關鍵的系統狀態內聚回自身，為後續去 ZooKeeper 化（KRaft）的演進奠定了基礎。</p><p><strong>Kafka 0.10 (2016): Kafka Streams</strong></p><p>Kafka 0.10 版本推出了 Kafka Streams，這是實現論文中第二個 Future Work 的重要一步。</p><p>Kafka Streams 是一個輕量級的串流處理 library，可以直接嵌入 Java 應用程式中，無需部署獨立的處理 cluster。它提供了 Java API 來進行串流處理操作，包括 map、filter、join、aggregate、windowing 等。採用 one-record-at-a-time 的處理模型，延遲可以達到毫秒級。</p><p>後來，Confluent 為了降低使用門檻，推出了 ksqlDB（最早稱為 KSQL），讓不熟悉程式設計的使用者也能用 SQL 語法來做串流處理。ksqlDB 底層其實是用 Kafka Streams 實作的。</p><p>不過，對於更複雜的串流處理需求，業界普遍認為 Apache Flink 是更成熟、功能更強大的選擇。Confluent 也意識到這點，在 2023 年 1 月收購了 Immerok，一家由 Apache Flink 核心貢獻者創立的公司，開始在 Confluent Cloud 中提供 Flink 作為串流處理選項。</p><p>值得注意的是，Confluent 採取的是多工具並存的策略。Kafka Streams、ksqlDB 和 Flink 各有其適用場景：Kafka Streams 適合嵌入式應用，ksqlDB 提供簡單的 SQL 介面，而 Flink 則處理更複雜的分析需求。這三個工具目前都持續在 Confluent 平台中獲得支援和更新。</p><p><strong>Kafka 0.11 (2017): Exactly-Once Semantics</strong></p><p>對我而言，這個版本是一個別具匠心的里程碑，因為它實現了分散式系統的聖杯：Exactly-Once Semantics（EOS）。EOS 的實現讓 Kafka 能夠支援金融交易、訂單處理等對數據完整性要求極高的應用。</p><p>回想一下我們在討論 RPC 時提到的傳遞語意。在 RPC 的世界裡，討論的是兩台機器之間單次呼叫的執行次數：At-least-once 意味著函數可能執行多次，At-most-once 意味著函數最多執行一次，而 Exactly-once 則是理想狀態 — — 函數恰好執行一次。這些概念源於 1980 年代的 Remote Procedure Call 研究，關注的是點對點的呼叫語意。</p><p>但 Kafka 作為一個消息中間件，面臨的挑戰更為複雜。資料流經三個階段：Producer → Broker → Consumer。每個階段都可能失敗，每個階段都需要重試機制，而重試就可能導致重複。這不是單純的「函數執行幾次」問題，而是「消息在整個生命週期中被傳遞和處理了幾次」的問題。</p><p><strong><em>Producer to Broker: Idempotent Producer</em></strong></p><p>第一個挑戰是 Producer 到 Broker 的階段。當 Producer 發送消息後，Broker 成功寫入並回傳確認，但確認在網路中遺失了。Producer 看不到確認，只能假設失敗並重試。這時 Broker 會再次收到相同的消息，導致重複寫入。</p><p>Kafka 0.11 引入了 Idempotent Producer 來解決這個問題。每個 Producer 在初始化時會獲得一個唯一的 Producer ID（PID），並為每個消息分配一個單調遞增的 sequence number。Broker 會追蹤每個 PID 和 partition 的最大 sequence number。當收到重複的消息（sequence number 較小或相同）時，Broker 會識別出這是重試，直接回傳成功但不再寫入，從而避免重複。</p><p>啟用方式很簡單：設定 enable.idempotence=true。從 Kafka 3.0 開始，這已經成為預設行為。Idempotent Producer 保證了單個 partition 內的 exactly-once 語意。</p><p><strong><em>Broker to Consumer: Transactional Read</em></strong></p><p>第二個挑戰是 Consumer 端的處理。傳統上，Consumer 需要自己實作去重邏輯 — — 記住已處理過的消息 ID，過濾掉重複的消息。這把複雜度推給了應用程式開發者。</p><p>Kafka 0.11 提供了另一個選擇：Transactional Read（isolation.level=read_committed）。但在理解這個機制之前，我們需要先區分兩種類型的 Producer：</p><ul><li><strong>非事務性 Producer</strong>：這是最常見的模式。Producer 發送消息後，消息立即對所有 Consumer 可見。無論是否啟用 idempotence，這些都是非事務性消息。</li><li><strong>事務性 Producer</strong>：使用 Transactional API（需設定 transactional.id）的 Producer。它可以將多個消息的發送包裝成一個原子性事務。這些消息在事務提交之前會被標記為「未提交」，只有當 Producer 呼叫 commitTransaction() 後才會被標記為「已提交」；如果呼叫 abortTransaction()，則會被標記為「已中止」。</li></ul><p>值得注意的是，事務性 Producer 會自動啟用 idempotence — — 你不能使用事務而不啟用冪等性。</p><p>當 Consumer 設定 isolation.level=read_committed 時，它的讀取行為是：</p><ul><li><strong>讀取所有非事務性消息</strong>（無論 Producer 是否啟用 idempotence）</li><li><strong>讀取所有已提交的事務性消息</strong></li><li><strong>跳過所有未提交或已中止的事務性消息</strong></li></ul><p>舉個例子，假設一個 topic 按順序收到：</p><ol><li>非事務性 Producer 發送消息 A</li><li>事務性 Producer 開始事務，發送消息 B、C</li><li>非事務性 Producer 發送消息 D</li><li>事務性 Producer 提交事務</li><li>另一個事務性 Producer 發送消息 E，但事務被中止</li></ol><p>read_uncommitted Consumer 會看到：<strong>A, B, C, D, E</strong>（全部消息）；read_committed Consumer 會看到：<strong>A, B, C, D</strong>（跳過被中止的消息 E）</p><p>這種設計的巧妙之處在於：read_committed 不是「只讀取事務性消息」，而是「只讀取已確定的消息」。這確保了向後相容性 — — 即使在啟用事務功能後，舊的非事務性 Producer 發送的消息仍然可以被正常讀取。</p><p>更巧妙的是，read_committed Consumer 實際上仍然會從 Broker 讀到那些已中止的記錄（如消息 E），只是在 client 端選擇過濾掉，不交給應用程式處理。這意味著該付出的 I/O 成本還是省不掉。不過理論上，事務中止不應該是常態，所以這個開銷在正常情況下是可以接受的。</p><p><strong><em>End-to-End: Transactional API</em></strong></p><p>最強大的是 Transactional API，它能夠將多個操作包裝成一個原子性事務。例如，一個串流處理應用可能需要：從 Topic A 讀取消息、處理、寫入 Topic B、提交 offset。如果中間任何步驟失敗，整個操作都應該回滾。</p><p>Transactional API 讓這一切成為可能。Producer 可以使用 beginTransaction()、commitTransaction() 和 abortTransaction() 來控制事務邊界。Kafka 會確保事務內的所有寫入操作要麼全部成功，要麼全部失敗，而且 Consumer 使用 read_committed 時只會看到已提交的資料。</p><p>這個設計的精妙之處在於：它不是靠昂貴的兩階段提交（Two-Phase Commit）來實現，而是利用 Kafka 自身的日誌機制 — — 將事務狀態本身也寫入特殊的 Topic（__transaction_state），用日誌的順序性和持久性來保證事務的正確性。（但 2PC 有在未來的規劃之中）</p><p><strong><em>Three Layers of Guarantees</em></strong></p><p>Kafka 0.11 提供了三種不同層次的 exactly-once 保證：</p><ol><li><strong>Idempotent Producer</strong>：單個 partition 內的 exactly-once，防止 Producer 重試導致的重複</li><li><strong>Transactional Read</strong>：Consumer 端的 exactly-once，確保只讀取已確定的消息</li><li><strong>Transactional API</strong>：端到端的 exactly-once，將讀取、處理、寫入包裝成原子操作</li></ol><p>值得一提的是，Kafka 的 isolation.level（還記得上一章 RDBMS 的 <strong>Transaction Consistency</strong> 嗎？那其實就是 <strong>Isolation level</strong>。）只提供兩個選項：read_uncommitted 和 read_committed。這比傳統資料庫的四個隔離級別簡單得多，但這正反映了 Kafka 的本質 — — 它是一個 append-only 的日誌系統，而非資料庫。Kafka 的消息不會被修改，Consumer 按順序讀取，因此不需要 Repeatable Read 或 Serializable 這類複雜的隔離級別。兩個選項已經足夠：要麼看到所有消息（包括未確定的），要麼只看到已確定的消息。</p><p>正如論文中所說，Kafka 的哲學是提供靈活的配置選項。對於可以容忍少量重複的應用（如日誌收集），可以使用 At-least-once。對於需要嚴格保證的應用（如金融交易），可以使用 Exactly-once。選擇權在用戶手中。</p><p><strong>Kafka 2.8 (2021): KRaft Mode (Experimental)</strong></p><p>Kafka 2.8.0 透過 KIP-500 引入了 KRaft 模式作為實驗性功能，這是 Kafka 架構的重大轉變。</p><p>KRaft（Kafka Raft）使用 Raft consensus 算法來管理 metadata，不再依賴 ZooKeeper。Controller quorum 由多個 Kafka broker 組成，使用 Raft 協議選舉 leader 和複製 metadata。所有 cluster metadata（topics、partitions、configurations、ACLs 等）都存儲在內部的 __cluster_metadata topic 中。</p><p>這個改變簡化了部署（不需要額外維護 ZooKeeper 集群）、提高了擴展性（metadata 更新更快）、加快了故障恢復速度。</p><p>不過，在 2.8 版本中，KRaft 還只是實驗性功能，不建議用於生產環境。</p><p><strong>Kafka 3.0 (2021): Breaking Changes and Modernization</strong></p><p>Kafka 3.0 包含了一些重大變更：棄用對 Java 8 和 Scala 2.12 的支援、Kafka Raft 支援 metadata topic 的 snapshots 和其他自管理 quorum 的改進、預設啟用更強的 Producer delivery guarantees。</p><p>這個版本移除了許多已經被棄用超過 12 個月的 API，簡化了平台。雖然版本號跳到了 3.0，但實際上沒有單一的革命性功能，而是許多漸進式的改進和清理工作。</p><p><strong>Kafka 3.3 (2022): KRaft Production-Ready</strong></p><p>KRaft 模式在 Kafka 3.3 版本中正式成為 production-ready。</p><p>從這個版本開始，用戶可以在生產環境中使用 KRaft 模式，徹底擺脫對 ZooKeeper 的依賴。不過，ZooKeeper 模式仍然被支援，讓用戶有時間遷移。</p><p><strong>Kafka 3.6 (2023): Tiered Storage</strong></p><p>Kafka 3.6.0 透過 KIP-405 實現了 tiered storage 支援，從根本上改變了組織可以如何架構其存儲策略。</p><p>Tiered Storage 允許將舊的日誌資料移動到更便宜的 Object Storage（如 S3、Azure Blob Storage、Google Cloud Storage），而不是全部存儲在昂貴的本地 SSD 上。這大幅降低了存儲成本，同時仍然保留了數據的可訪問性。Hot data 保留在本地 SSD 以提供低延遲訪問，Cold data 可以移到 Object Storage 以降低成本。</p><p>這個功能對於需要長時間保留數據的場景（如合規要求、歷史數據分析）非常有價值。</p><p><strong>Kafka 4.0 (2024): From ZooKeeper Era to KRaft Era</strong></p><p>Kafka 4.0 標誌著一個長達十餘年的架構時代正式落幕。從這個版本開始，Apache Kafka 成為第一個完全不依賴 ZooKeeper 即可運行的主要版本，Control Plane 的共識機制全面轉向 KRaft（Kafka Raft）。這個改變不只是移除一個外部元件，而是 Kafka 對自身架構邊界的一次重新收斂：從「資料在 Kafka、共識在 ZooKeeper」，走向「資料與共識都在 Kafka 之內」。</p><p>要真正理解這個轉換的意義，單靠靜態的架構圖其實並不夠清楚。更直觀的方式，是沿著<strong>一個具體 request 的生命週期</strong>，觀察它在系統中如何流動、在哪裡被決策，以及最終是如何被提交為全叢集一致的狀態。這種觀察方式，某種程度上就像是在理解 relational database 時，實際追蹤一條 DDL 指令在系統內部所經歷的完整路徑。</p><p>因此，在本節中，我們將以「create topic」這個最基本、卻同時最關鍵的操作作為切入點。這個操作本質上是一個 <strong>Control Plane request</strong>，語義上也非常接近 relational database 中的 DDL（例如 CREATE TABLE）：它不直接處理資料內容，卻會永久改變系統的結構與後續所有 Data Plane 行為。</p><p>以下，我們將分別以 ZooKeeper Era 與 KRaft Era 為背景，沿著這個 request 的生命週期，逐步拆解 Kafka 在兩個時代中是如何取得 consensus、如何劃分 Control Plane 與 Data Plane，以及這些設計差異又是如何影響整個系統的複雜度。</p><p>千里之行，始於足下。讓我們開始吧！</p><p><strong><em>ZooKeeper Era: A ZooKeeper-Centric Control Plane</em></strong></p><p><strong>以建立一個名為 orders 的 topic 為例</strong>，假設 Kafka 叢集的設定如下：</p><ul><li>Data Plane 由三個 Broker（B1、B2、B3）組成，B1 為 controller。</li><li>Control Plane 是一個由三個節點（Z1、Z2、Z3）組成的 ZooKeeper ensemble，Z1 為 leader。</li></ul><p><em>Step 1: An Admin Request Enters the Cluster</em></p><p><strong>「建立一個名為 orders 的 topic」的請求</strong>會由一個 Admin Client 發起，可能是命令列工具，也可能是應用程式中的管理 API。這個請求會先被送到 Kafka 叢集中任意一個 Broker，例如 B2。</p><p>需要特別強調的是，這個請求一開始進入的 Broker，並不代表它就有權限或責任來決定叢集的結構性變更。對 Kafka 而言，這仍然只是一個入口節點；真正的決策不會在這裡發生。</p><p><em>Step 2: Distinguishing Control Plane from Data Plane</em></p><p>當 B2 收到「建立 topic」的請求時，它會立即判斷這是一個 <strong>Control Plane 操作</strong>，而不是屬於 Data Plane 的資料讀寫行為。Broker 在 Data Plane 中負責的是 partition 的實際讀寫與複寫，而 topic 的建立、partition 的配置，屬於叢集層級的 metadata 變更。</p><p>因此，B2 不會嘗試自行處理這個請求，而是將它轉交給當前的 Controller。這個轉送行為本身，就反映了 ZooKeeper 時代 Kafka 的一個核心設計原則：<strong>Broker 不擁有最終的控制權，Control Plane 必須集中處理。</strong></p><p><em>Step 3: Controller as a Logical Role</em></p><p>在 Kafka 叢集中，Controller 並不是一個獨立部署的服務，而是一個由某一台 Broker 擔任的<strong>邏輯角色（logical role）</strong>。假設此時 B1 是 Controller，那麼 B2 便會將建立 topic 的請求轉送給 B1。現在，B1 就是 Control Plane 的協調者，負責序列化所有 metadata 的變更請求，確保叢集狀態不會出現競爭條件或分歧。</p><p><em>Step 4: Delegating Consensus to ZooKeeper</em></p><p>然而，即便 B1 是 Controller，也不擁有最終裁決權。在 ZooKeeper Era 中，Kafka 的設計選擇是將 <strong>Control Plane 的共識問題外包給 ZooKeeper</strong>。因此，B1 不會直接在 Kafka 內部宣告「topic 已建立」，而是必須將這次 metadata 變更寫入 ZooKeeper。</p><p>Controller B1 作為 ZooKeeper 的 client，可以連線到 ZooKeeper ensemble 中的任一節點，例如 Z2。即便 Z2 本身是 follower，只要這是一個寫入請求，它就會將請求轉發給當前的 ZooKeeper leader Z1。這一步，正式將 Kafka 的 Control Plane 操作，交由 ZooKeeper 的 consensus 來處理。</p><p><em>Step 5: ZooKeeper Leader Commits Metadata Changes</em></p><p>真正執行寫入的是 ZooKeeper leader Z1。它會將「建立 orders topic」這個操作，轉換為對 ZooKeeper data tree 的一系列修改，例如建立 /brokers/topics/orders 這個 znode。</p><p>在這個節點之下，ZooKeeper 會儲存該 topic 的 <strong>partition assignment、replica list，以及每個 partition 的初始 leader（例如 leader = B1）</strong>。這些資料全部都是 Kafka 的 Control Plane metadata，而非任何實際的 message 資料。</p><p><em>Step 6: Replication Inside the ZooKeeper Ensemble</em></p><p>這些 metadata 並不是只存在於 ZooKeeper leader 上。ZooKeeper 會透過自身的共識協議（Zab），將這些狀態變更以 log 的形式複寫到所有 follower 節點（例如 Z2、Z3）。只有當多數 ZooKeeper 節點確認這次變更後，這個 metadata 才會被視為正式提交。</p><p>此時，Control Plane 狀態，已經在 ZooKeeper ensemble 中達成全域一致。ZooKeeper 成為這個 topic metadata 的 <strong>single source of truth</strong>。</p><p><em>Step 7: Control Plane State Propagates Back to Brokers</em></p><p>一旦 ZooKeeper 中的 metadata 發生變化，Controller 會透過 watch 機制接收到通知。Controller 隨即將新的 topic 與 partition 配置資訊，分發給所有相關的 Broker。</p><p>各個 Broker 依據這些 Control Plane 指示，在自己的 <strong>Data Plane</strong> 中建立對應的 partition replica、初始化 log 結構，並準備開始接受 Producer 與 Consumer 的請求。</p><p><strong><em>KRaft Era: An Internalized Control Plane</em></strong></p><p>KRaft Era 的關鍵改變，是 Kafka 不再把 Control Plane 的共識外包給 ZooKeeper，而是把「共識、持久化與一致性」收回到 Kafka 自己體內。換言之，Kafka 不只是移除了一個外部依賴，而是重新定義了 Control Plane 的事實來源：在 KRaft 模式下，叢集 metadata 的 single source of truth 不再是一棵 ZooKeeper znode tree，而是一條由 Raft 複寫保護的 metadata log。</p><p>為了延續前一節的敘事，我們仍然以建立 orders topic 的操作為例，來走一遍 KRaft Era 中 create topic 的完整生命週期。現在，Kafka 叢集的設定如下：</p><ul><li>Data Plane 由三個 Broker（B1、B2、B3）組成。</li><li>Control Plane 是一組 Controller quorum（C1、C2、C3），C1 為 leader controller。所有 metadata 變更都必須經過這個 quorum 的同意才能生效。</li></ul><p><em>Step 1: An Admin Request Enters the Cluster</em></p><p>如同 ZooKeeper Era，create topic 的請求依然可能先進入任意一個 Broker，例如 B2。這個入口節點的存在，主要是為了提供叢集對外的管理介面，而不是意味著 B2 擁有 Control Plane 的決策權。</p><p><em>Step 2: Routing to the Active Controller</em></p><p>B2 會辨識這是一個 Control Plane 操作，並將請求路由到目前的 active controller。不同於 ZooKeeper Era 中「Controller 是某個 Broker 暫時擔任的角色」，在 KRaft 模式下，Controller 由一組專門的節點形成 quorum；因此，路由的目標通常是目前的 leader controller（C1）。若 B2 先送到某個 follower controller（例如 C2），C2 會像典型 Raft 系統一樣將請求導向 leader controller，確保 metadata 變更被序列化處理。</p><p><em>Step 3: Metadata as a Raft-Replicated Log Entry</em></p><p>當 leader controller（C1）接到 create topic 請求後，它不會「直接在記憶體裡改狀態」或「直接對所有 Broker 廣播結論」。相反地，它會把這次變更轉成一筆或多筆 append-only 的 metadata log entry。這些 log entry 會描述 orders topic 的結構與配置，例如 partitions 的數量、replication factor、partition assignment（各 partition 對應哪些 Broker 做 replicas）、以及每個 partition 的 initial leader 指派與相關狀態（例如 ISR 的初始構成）。</p><p>在 KRaft Era，這一步就是 Control Plane 的核心：<strong>不是「我決定」而是「我寫入一筆可被共識保護的 log」</strong>。只有 log 被 quorum commit 之後，這個狀態才被承認為叢集的一致事實。</p><p><em>Step 4: Raft Replication and Quorum Commit</em></p><p>接下來，C1 會將這些 metadata log entry 複寫到其他 Controller（C2、C3）。每個 follower controller 會將 entry 先寫入自己的 log，再回 ACK 給 C1。當 C1 收到多數節點（quorum）的確認後，便宣告這些 entry commit。commit 的意義與 ZooKeeper Era 非常類似：它代表這次 metadata 變更已經成為 Control Plane 的 single source of truth，只是此時「一致事實」不再由 ZooKeeper ensemble 的 data tree 表達，而是由 Controller quorum 的 metadata log 表達。</p><p>這也是 KRaft 與 ZooKeeper Era 最本質的差異：ZooKeeper 以 znode tree 作為狀態表達，Kafka 以 watch 取得變更；KRaft 則直接以 log 為狀態表達，並且在 Kafka 系統內完成複寫與提交。</p><p><em>Step 5: Brokers Learn Metadata via the Metadata Log</em></p><p>一旦 metadata log entry commit，Kafka 的各個 Broker（B1、B2、B3）就需要得知新的叢集狀態，才能在 Data Plane 中落實它。KRaft 模式下，Broker 不再透過 ZooKeeper watch 來感知變更，而是透過 Kafka 內建的 metadata propagation 機制，從 Controller quorum 取得最新的 metadata。你可以把它理解為：Broker 會持續跟上這條 metadata log 的進度，確保自己看到的 Control Plane 狀態與 quorum commit 的狀態一致。</p><p>因此，當 orders topic 被建立後，Broker 會看到一個已提交的 metadata 狀態：這個 topic 存在、它有哪些 partitions、每個 partition 的 replicas 分佈在哪些 brokers、以及每個 partition 的 leader replica 是誰。</p><p><em>Step 6: Data Plane Materialization on Each Broker</em></p><p>當 Broker 得到一致的 Control Plane metadata 後，才會在 Data Plane 真正「把它變成可用的資料結構」。這包含在本地建立對應 partition replica 的 log 目錄與檔案結構、初始化 replica state、準備與 leader/follower 角色相關的複寫鏈路，並使得 partition leader 能夠開始接受 Producer 的寫入與 Consumer 的讀取。</p><p>值得注意的是，這一步和 ZooKeeper Era 並沒有本質不同：Data Plane 仍然由 Broker、Partition、Replica 構成，資料仍然以 append-only log 的形式存放在 Broker 的儲存層。不同之處在於，Broker 所依賴的 Control Plane 狀態來源已經內建化，不再透過外部系統提供。</p><p><em>Step 7: Service Becomes Visible — Reads/Writes Follow the Leader Replica</em></p><p>當 orders topic 的 leader replica 完成初始化並對外宣告可服務後，Producer 的寫入流量會被導向該 leader 所在的 Broker（例如 B1），資料會被 append 到 leader replica 的 log 中；其他 Broker 上的 follower replicas（例如 B2、B3）則會從 leader 複寫資料，維持同步狀態。Consumer 的讀取同樣依賴 Partition 的 leader/follower 模型與 offset 序列，但這整條 Data Plane 路徑，從始至終都不再需要任何 ZooKeeper 參與。</p><p><strong><em>Summary</em></strong></p><p>從 ZooKeeper Era 到 KRaft Era，可以清楚看出 Kafka 在 Control Plane consensus 上的根本轉變。</p><p>在 ZooKeeper Era，Kafka 將所有關於叢集狀態的 consensus 外包給 ZooKeeper ensemble。ZooKeeper 本身就是一個共識系統，透過 Zab 協議在多個節點之間維持一致的 metadata 狀態，並以 quorum 為基礎來提交變更。Kafka 的 Controller 雖然負責發起與協調控制操作，但並不持有最終決策權；真正被視為「事實來源（source of truth）」的，是 ZooKeeper 中被一致提交的資料。換言之，Kafka 的 Control Plane 是「依附在 ZooKeeper 之上」的。</p><p>為了理解 ZooKeeper 在 Kafka Control Plane 中所扮演的角色，有必要先簡單說明 ZooKeeper 的 metadata model。ZooKeeper 內部並不是以表格或物件形式儲存資料，而是維護一棵<strong>階層式的 metadata tree</strong>，結構上非常類似檔案系統。每一個節點稱為一個 <em>znode</em>，以路徑（例如 /brokers/topics/orders）作為唯一識別。Kafka 會將叢集相關的控制資訊 — — 例如 Broker 註冊狀態、Topic 定義、Partition assignment、Replica 與 leader 資訊 — — 映射成這棵樹上的不同節點，並透過 ZooKeeper 的 consensus 機制來確保這些節點內容在 ensemble 中保持一致。</p><p>在眾多 znode 類型中，<strong>ephemeral znode</strong> 是 Kafka 特別依賴的一種。Ephemeral znode 的生命週期與建立它的 client session 綁定：只要該 client 與 ZooKeeper 的 session 中斷，對應的 ephemeral znode 就會自動被刪除。Kafka 正是利用這個特性來實作「活著的角色」判定，例如 Broker 是否仍在線、目前哪一個 Broker 擔任 Controller。當某個 Broker 異常死亡時，ZooKeeper 會因為 session 消失而自動移除對應的 ephemeral znode，進而觸發其他元件重新進行角色選舉。</p><p>除了 ephemeral znode 之外，ZooKeeper 還有其他類型的節點，例如 <strong>persistent znode</strong>，其內容在 client 離線後仍會保留，常用來儲存需要長期存在的 metadata（如 Topic 與 Partition 結構）；以及 <strong>sequential znode</strong>，會在建立時自動附加單調遞增的序號，常被用來實作排序或選舉原語。Kafka 雖然不是 ZooKeeper 的全部功能使用者，但正是透過這些 znode 語義的組合，將 Control Plane 的狀態外包給 ZooKeeper 來進行一致性維護。</p><p>在分布式系統中，任何叢集節點都有掛掉的可能。在 ZooKeeper Era 中，Kafka 正是依賴 ZooKeeper，讓關鍵角色的失效成為可被吸收的事件，而非系統性災難。這一點，可以從三種最關鍵的故障情境中看得特別清楚。</p><p>第一種情況是 ZooKeeper leader 發生故障。這對 Kafka 的影響其實相對有限，因為 ZooKeeper ensemble 本身就是一個 quorum-based consensus 系統。Leader 在 Zab 協議中扮演的是協調者角色，而不是 metadata 的唯一持有者。當 leader 節點失效時，其餘 ZooKeeper 節點會自動重新選舉新的 leader，只要仍有多數節點存活，所有已經被 quorum 提交的 metadata（例如 topic 定義、partition assignment、replica list）都不會遺失。對 Kafka 而言，Control Plane 的不變性完全由 ZooKeeper ensemble 所保證，Kafka 只需要在新 leader 穩定後重新連線即可。</p><p>第二種情況是 Kafka Controller 發生故障。Controller 在 ZooKeeper Era 中是一個邏輯角色，由某一個 Broker 暫時擔任，負責執行 Control Plane 的操作，例如 leader 選舉與 metadata 更新。但 Controller 本身並不是 consensus 的一部分，也不是事實來源。當 Controller 所在的 Broker 掛掉時，ZooKeeper 會因為對應的 ephemeral znode 消失而察覺此事，其餘 Broker 便會重新競選新的 Controller。新的 Controller 並不是接手前任的記憶狀態，而是重新從 ZooKeeper 中讀取已被一致提交的 metadata，並以此為基礎繼續運作。正因如此，Controller 在 ZooKeeper Era 被刻意設計成「可重建角色」，其失效不會破壞 Control Plane 的不變性。</p><p>第三種情況則發生在 Data Plane：Partition leader 發生故障。Partition leader 負責實際的讀寫請求，是資料流的關鍵節點。當 leader replica 所在的 Broker 掛掉時，Kafka 並不立即做出不可逆的決策，而是由 Controller 透過 ZooKeeper 中記錄的 metadata 與 ISR（in-sync replicas）資訊，選出新的 leader replica。這個 leader 變更本身，仍然必須被寫入 ZooKeeper，並經過 quorum 提交後，才會被視為正式生效。換言之，即使是 Data Plane 的角色切換，最終仍需回到 Control Plane 的 consensus 層，才能成為系統可依賴的事實。</p><p>綜合這三種情境可以看出，在 ZooKeeper Era 中，Kafka 的所有關鍵不變性都錨定在 ZooKeeper 的 consensus 之上。ZooKeeper leader、Kafka Controller、甚至 Partition leader 都可能失效，但只要 ZooKeeper ensemble 能維持 quorum，Kafka 就能透過重新指派角色、重建狀態，持續前進而不破壞整體一致性。</p><p>進入 KRaft Era 之後，Kafka 將 consensus 能力完整收回系統內部。KRaft 使用 Raft 協議，在一組 Controller 節點之間形成 controller quorum，所有 metadata 變更都被序列化為 append-only 的 metadata log，並且必須經過 quorum 同意後才能生效。這使得 Kafka 的 Control Plane 不再仰賴外部系統，而是以與 Data Plane 相同的 log-based 複寫模型來維持一致性。兩個時代其實都使用 quorum-based consensus，但差別在於：ZooKeeper Era 是「Kafka 依賴 ZooKeeper 的 quorum」，而 KRaft Era 則是「Kafka 自己成為擁有 quorum 的系統」。</p><p>在 KRaft Era 中，當 Controller leader 發生故障時，系統的行為與 ZooKeeper Era 有本質上的不同。Controller 不再是一個「由某個 Broker 暫時擔任的角色」，而是存在於一組專責的 Controller 節點之中。這些節點共同維護一條 metadata log，並透過 Raft 協議選出 leader controller。當 leader controller 掛掉時，其餘 Controller 節點會立即透過 Raft 重新進行選舉；只要仍有過半節點存活，新的 leader controller 就能被選出，並從已經複寫完成的 metadata log 位置繼續服務。所有在故障發生前已經被 quorum 提交的 metadata 變更，都不會回滾或遺失；而尚未完成提交的操作，則會被自然地丟棄。對 Kafka 而言，Control Plane 的不變性不再依賴「某個外部系統是否仍然存在」，而是由 Kafka 自己的 controller quorum 直接保證。</p><p>另一方面，當 Data Plane 中的 Partition leader 發生故障時，處理流程在概念上仍然熟悉，但其背後的 Control Plane 路徑已經完全不同。當某個負責讀寫的 leader replica 所在的 Broker 掛掉，Broker 層會偵測到副本狀態的變化，並向 Controller 回報。Controller 並不立即做出本地決策，而是將「新的 leader 選舉結果」視為一個 metadata 變更事件，寫入 metadata log。這個 leader 變更只有在被 controller quorum 一致提交後，才會正式生效並被所有 Broker 觀察到。換言之，即便是 Data Plane 的角色切換，其「是否成立」的判準，仍然完全取決於 Control Plane 的 consensus。</p><p>這裡有一個非常關鍵、但常被忽略的差異：在 KRaft Era 中，Controller leader 的選舉、Topic 與 Partition 的結構變更、Replica 與 ISR 的調整、以及 Partition leader 的轉移，全部都被納入同一條 metadata log 中，使用同一套 quorum-based consensus 規則來處理。Kafka 不再需要在「ZooKeeper 的世界」與「Kafka 自己的世界」之間來回切換心智模型；Control Plane 與 Data Plane 雖然角色不同，但在一致性語義上已經完全對齊。</p><p>另一個容易混淆、但必須釐清的重點，是 Kafka 架構中其實<strong>同時存在多層 leader / follower 關係</strong>，而且這些層級彼此獨立、服務於不同目的。在 ZooKeeper Era 中，ZooKeeper ensemble 本身就有 leader 與 follower，用來維持 Control Plane metadata 的 consensus；Kafka 叢集內則有一個 Controller（由某個 Broker 擔任），負責協調控制決策，但它並不是共識 leader，而是 ZooKeeper 共識結果的執行者。到了 KRaft Era，Controller 本身形成一個 Raft quorum，其中同樣有 leader controller 與 follower controllers，直接承擔 Control Plane 的 consensus 職責。</p><p>在 Data Plane 中，Partition 也一直存在 leader 與 follower replica 的概念。Partition leader 負責實際的讀寫請求，follower replicas 則負責資料複寫與容錯，確保資料在 Broker 故障時仍可恢復。這一層的 leader / follower，關注的是資料一致性與可用性，與 Control Plane 的 metadata consensus 是不同維度的問題。</p><p>總結來看，不論是 ZooKeeper Era 還是 KRaft Era，Kafka 都不是「只有一個 leader / follower 架構」的系統，而是同時在 <strong>Control Plane 與 Data Plane</strong> 各自運作著不同層級、不同目的的 leader / follower 與 quorum 機制。KRaft 的關鍵突破，不在於引入 leader 或 quorum 本身，而在於讓 Kafka 從資料到控制，全面使用一致、內聚的 consensus 模型，完成了一次架構層級的自我收斂。</p><p><strong>Kafka 4.0 (2024): Next Generation Consumer Rebalance Protocol</strong></p><p>Kafka 4.0 也引入了 Next Generation Consumer Rebalance Protocol（KIP-848），大幅改善 rebalance 的效能與可預期性，並消除了過去「stop-the-world」式的重平衡行為。</p><p>在討論 Kafka 的 consumer rebalance 之前，必須先釐清一個最基本的問題：<strong>rebalance 到底是在平衡什麼？</strong></p><p>在 Kafka 的語義中，rebalance 指的並不是資料本身的重新分佈，也不是系統資源的重新調度，而是<strong>Partition 與 Consumer 之間的對應關係發生改變</strong>。換言之，rebalance 發生的時刻，是當一個 consumer group 內的成員變動，或是 topic 的 partition 結構改變時，Kafka 需要重新決定「哪一個 consumer 負責處理哪一些 partition」。Rebalance 的核心，是維持一個不變的約束條件：在同一個 consumer group 中，每一個 partition 在任一時間點只能由一個 consumer 消費。</p><p>這也意味著，rebalance 是一個<strong>純粹發生在 Consumer 與 Partition 之間的控制流程</strong>。它不涉及資料重寫、不涉及 partition 的移動，更不會影響 Producer 的寫入路徑。Partition 的資料仍然固定存在於 Broker 上，rebalance 改變的，只是「誰來讀」。</p><p>也正因為如此，Kafka 並不存在「Producer rebalance」這個概念。Producer 與 Partition 的關係，在設計上是鬆耦合的：Producer 在送出訊息時，可以根據 key、partitioner、或自訂邏輯選擇目標 partition，但這個選擇是一次性的、訊息層級的行為，而不是一個需要在系統中被維持的一致性狀態。Producer 不會「擁有」某個 partition，也不需要在其他 Producer 加入或離開時重新協調責任範圍。即便 Producer 的數量改變，Kafka 也不需要啟動任何形式的 rebalance；唯一可能發生變化的，是訊息被寫入哪個 partition，但那完全屬於 Producer 自身的行為選擇。</p><p>因此，rebalance 是 Kafka 為了解決 Consumer 並行處理與容錯而引入的機制，而不是一個通用的系統調度概念。它的存在前提，是「一組 Consumer 需要共同消費一組 Partition，且必須確保 partition 不被重複消費」。在這個基礎之上，才能理解為什麼 consumer rebalance 會成為 Kafka 架構演進中一個如此關鍵的議題。</p><p>早期的 Kafka consumer 設計，誕生於雲端運算尚未普及的年代。當時的系統假設是：Consumer 多半是長時間存活的進程，數量相對穩定，rebalance 屬於低頻事件。在這樣的前提下，將 partition 分配的計算邏輯放在 client 端是一個合理的選擇。Kafka 讓 consumer group 中的某一個 consumer 擔任 leader，負責計算 partition 與 consumer 之間的對應關係，其餘 consumer 則被動接受結果。這種設計降低了 server 端的複雜度，也符合當時分散式系統偏好「client 智慧化」的設計風格。</p><p>然而，雲端環境的成熟徹底改變了這些假設。在容器化與自動擴展成為常態後，consumer 的生命週期變得短暫且高度動態。Consumer 可能因為 autoscaling、rolling update、或基礎設施調度而頻繁啟停；consumer 數量不再穩定，而是隨著負載持續波動；不同語言與不同版本的 client 同時存在於同一個 consumer group 中。在這樣的環境下，rebalance 不再是偶發事件，而是系統日常運作的一部分。</p><p>舊有的 consumer rebalance 模型，在這種高 churn 環境中逐漸顯露限制。每一次 consumer 的進出，都可能觸發一次 stop-the-world 式的 rebalance，迫使整個 consumer group 暫停處理資料；由 client 擔任的 leader consumer 成為隱性的協調瓶頸；複雜的分區分配邏輯分散在各種 client 實作中，使得行為難以一致，除錯與觀察成本居高不下。這些問題不只是效能上的摩擦，而是系統可預期性與可運維性的根本挑戰。</p><p>Kafka 4.0 所引入的 Next Generation Consumer Rebalance Protocol，正是對這種雲端現實的回應。新的設計承認 consumer 的 churn 是常態，而非異常，並據此重新分配系統責任。Partition 與 consumer 的對應關係，不再由 client 端計算，而是交由 server 端的 group coordinator 負責，讓協調邏輯集中化、可觀察。Rebalance 流程也從過去的全域同步事件，轉為增量式調整，避免因小規模變動而讓整個 consumer group 停擺。Consumer 仍然維持 pull model，自行向 Broker 拉取資料，但不再承擔跨成員協調的責任。</p><p>從 client 邏輯轉向 server 邏輯的這個轉變，並不是否定早期設計，而是反映了底層運算環境的改變。在雲端時代，consumer 被視為可隨時替換的運算資源，而不是系統中的穩定中樞；而協調與一致性，則必須由平台本身承擔。</p><p><strong>Kafka 4.0 (2024): Other Updates</strong></p><p>Kafka 4.0 同時也代表了一次全面的技術現代化。Consumer 正式升級至 Java 11，Broker 升級至 Java 17，並完成從 Log4j 到 Log4j2 的遷移；此外，這個版本也正式移除了對 Scala 2.12 的支援，進一步收斂語言與執行環境的複雜度，為後續的維護與演進鋪平道路。</p><p>另一項值得關注的變化，是 Queues for Kafka（KIP-932）首次以 early access 形式登場，透過引入 share group 的概念，讓 Kafka 開始能夠覆蓋更多傳統 message queue 的使用場景。</p><p><strong>Kafka 4.2: Queues for Kafka GA</strong></p><p>在討論 Kafka 4.2 所引入的 Share Groups（Queues for Kafka）之前，必須先釐清一個關鍵問題：<strong>全域順序究竟是不是 message queue 與 Kafka 之間的核心差異？</strong></p><p>在多數工程師的直覺印象中，傳統 message queue（例如 <strong>RabbitMQ</strong>、ActiveMQ）似乎天然具備全域順序：訊息進入 queue 後排成一列，consumer 依序處理。這樣的描述在單一 queue、單一 consumer 的典型使用場景下確實成立，因此也經常被簡化為「message queue 提供全域順序」。然而，從更嚴格的語義角度來看，message queue 真正保證的是每個 message 的<strong>工作分派與完成</strong>，而不是全域順序本身。一旦引入多個 consumer、不同的處理時間、或 retry 與 requeue 機制，原本看似存在的全域順序便可能被打破。換言之，在 message queue 中，全域順序多半是常見使用方式下的結果，而非系統不可破壞的一級設計契約。</p><p>回到 <strong>Apache Kafka</strong>，Kafka 從設計之初就明確沒有承諾全域順序，而是只在 partition 內提供嚴格的區域順序。不過，這並不代表 Kafka 永遠無法呈現全域順序的行為。當一個 topic 只有單一 partition 時，Kafka 所提供的區域順序在實際效果上就等同於全域順序：所有訊息進入同一條 append-only log，由單一 offset 序列管理，consumer 所觀察到的就是一條嚴格排序的事件序列。</p><p>這樣的使用方式在實務中確實存在。例如在某些 Kafka Connect 的內部實作中，為了確保狀態更新、配置變更或控制類訊息具有明確且不可歧義的處理順序，便會刻意使用單一 partition 的 topic，藉此獲得全域順序等價的效果。這也再次凸顯 Kafka 的一個關鍵設計理念 — — Kafka 並非「不能有全域順序」，而是將是否需要全域順序的選擇權，明確地交回給使用者。</p><p>一旦系統需求成長，topic 被切分為多個 partition，Kafka 就明確選擇犧牲全域順序，以換取水平擴展與平行處理能力。跨 partition 的資料從設計上就不存在任何全域順序，這並不是妥協，而是 Kafka 架構的核心取捨。但與此同時，Kafka 對區域順序的保障依然是嚴格且可推理的：只要相關訊息被路由到同一個 partition，consumer 所看到的事件順序就會與寫入順序一致。這種區域順序是一級語義，構成了 Kafka offset 模型、重播能力與事件處理語義的基礎。</p><p>Kafka 4.2 所引入的 Share Groups，則是在這條演進路線上再往前一步。透過 Share Groups，Kafka 允許多個 consumer 協作式地消費 topic，而不再將 consumer 綁定到特定 partition。在這個消費模式下，Kafka 不再嘗試維持任何形式的區域順序；每一筆訊息都被視為獨立的工作項目（work item），只要求最終能被處理完成即可。這樣的語義，正好對應了傳統 message queue 中常見的工作負載，例如背景任務、非同步處理或高度彈性的 worker pool，而不再受限於 partition 的數量。</p><p>實作上來看，Share Groups 並不是在 Kafka 中引入一個全新的 queue 系統。Kafka 的底層依然是 topic、partition 與 append-only log，資料仍然是持久化、可重播，並且可以被不同的 consumer group 以不同方式觀察。Share Groups 所做的，是在既有 Kafka 架構之上，提供一種刻意放棄區域順序的消費語義，讓使用者可以在事件串流與工作分派之間自由選擇，而不必離開 Kafka 的整體模型與生態系。</p><p>從 single partition 到 multi-partition，再到 Share Groups，Kafka 的演進並不是在不同系統模型之間來回擺盪，而是逐步把「順序邊界」攤開來，讓使用者清楚決定：何時需要全域順序，何時只需要區域順序，以及何時可以完全不需要順序。這正是 Kafka 4.2「Queues for Kafka」的真諦。</p><p><strong>Unparalleled Contributions from opensource4you</strong></p><p>在 Kafka 4.2 邁向「Queues for Kafka」正式 GA 的過程中，除了核心設計方向的成熟，更值得一提的是開源社群在這段時間所投入的巨大工程能量。其中，<strong>opensource4you</strong> 在近兩年間，已成為 Apache Kafka 專案中最活躍、也最具影響力的貢獻團隊之一。</p><p>在短短兩年內，opensource4you 向 <strong>Apache Kafka</strong> 專案貢獻了數千筆 commits，實際推動了多項關鍵且結構性的演進，包含但不限於：</p><ul><li><strong>新一代 Consumer 架構的演進<br></strong> 針對 Kafka 在高併發、多租戶場景下的消費模型限制，持續推進更具彈性與可擴展性的 consumer 設計，為 Share Groups 等新能力奠定基礎。</li><li><strong>日誌系統現代化（Log4j → Log4j2）與全面重構<br></strong> 不僅完成框架升級，更同步進行大規模重構，改善效能、可觀測性與維護性，降低長期技術負債。</li><li><strong>大規模 Scala → Java 的系統性改寫<br></strong> 這並非單純語言轉換，而是為了提升可讀性、降低貢獻門檻，並讓 Kafka 核心程式碼更容易被更廣泛的工程師社群理解與維護。</li><li><strong>Quorum Controller APIs 的補齊與完善<br></strong> 持續強化 KRaft 架構下的控制平面能力，使 Kafka 能逐步擺脫對 ZooKeeper 的結構性依賴。</li></ul><p>除了程式碼貢獻本身，opensource4you 在 <strong>code review 與社群治理</strong> 上的投入同樣關鍵。其創辦人長期扮演 Kafka 社群中最核心的 reviewer 角色之一，累積的審查數量在整個專案中名列前茅，甚至超過多家主要商業公司團隊的總和。這種高密度、長時間的 review 參與，對於維持 Kafka 在快速演進下仍能保持工程品質，具有不可替代的價值。</p><p>值得注意的是，在 Kafka 仍需同時維護 <strong>legacy ZooKeeper 架構</strong>與<strong>新一代 KRaft 架構</strong>的過渡階段，opensource4you 也是目前承擔 ZooKeeper 相關維護工作的主要團隊之一，確保既有大型叢集能在演進過程中保持穩定運作。</p><p>總結來看，opensource4you 是實質參與並推動 Kafka 架構轉型的重要力量之一。Kafka 今日能以相對穩健的姿態，從事件串流平台邁向更通用的資料基礎設施，背後正是這類長期、低調、但高度工程密集的社群投入所累積的成果。</p><p><strong>Notable Features from Confluent Platform</strong></p><p>值得注意的是，有些功能是 Confluent Platform（Kafka 的商業版本）獨有的，並未包含在開源 Apache Kafka 中。</p><p><strong><em>Schema Registry</em></strong></p><p><strong>Schema Registry</strong> 是 Confluent 提供的重要組件（雖然是開源的，但不是 Apache Kafka 的一部分），用於管理 Avro、Protobuf、JSON Schema 等格式的 schema，提供 schema evolution 和相容性檢查，確保 Producer 和 Consumer 使用一致的 data schema。</p><p><strong><em>Kafka and Flink</em></strong></p><p>即便 Kafka 有了 Streams API，當遇到更複雜、更大規模的串流處理需求，業界普遍認為 Flink 是更成熟的選擇。這些串流處理需求包括：</p><ol><li>需要複雜的 window 操作：像是要計算「過去 24 小時內每 5 分鐘的平均交易金額，但每分鐘更新一次」這種滑動視窗（sliding window）加上跳躍更新的場景</li><li>多層次的狀態管理：例如同時追蹤每個用戶的短期行為（最近 1 小時）、中期趨勢（最近 7 天）、以及長期偏好（最近 90 天），並且這些狀態之間還需要互相關聯</li><li>需要處理 TB 級的即時數據：像是大型電商在雙 11 期間，每秒需要處理數百萬筆交易事件，並即時計算全站商品排行、庫存預警、以及個人化推薦</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/438/1*JXADu9N233KMQzULzNujJg.png" /></figure><p>Confluent Platform 的一大優勢是能在單一平台上整合 Kafka 和 Flink。透過 2023 年所收購 Immerok，Confluent 將 Flink 深度整合到其雲端服務中，讓使用者可以：用 Kafka 處理 event streaming 和消息傳遞、用 Flink 進行複雜的串流處理和分析、在同一個管理介面中操作兩者、無縫地在 Kafka topics 和 Flink jobs 之間傳遞數據。</p><p>值得一提的是，Apache Flink 從誕生之初（2015 年正式發布）就將 exactly-once 語意作為核心設計目標，透過分散式快照（distributed snapshot）機制實現了狀態的精確一次處理；而 Kafka 在 2017 年推出 exactly-once 支援。當我們將視野放得更大之後，一個有趣的問題浮現了：兩個都支援 exactly-once 的系統該如何搭配使用？</p><p>讓我們看一個典型的場景：</p><p>Kafka Topic A → Flink 處理 → Kafka Topic B</p><p>這種架構模式在實務中非常常見。舉例來說，原始的用戶行為日誌從 Topic A 進來，Flink 進行數據清洗（去除異常值、填補缺失欄位）和 enrichment（關聯資料庫補充用戶屬性），然後將處理好的數據寫回 Topic B。清洗後的數據可能被多個下游系統使用：推薦系統、報表系統、機器學習訓練系統都訂閱 Topic B。又或者在事件驅動架構中，Topic A 是原始業務事件（如「訂單創建」），Flink 進行複雜的事件關聯（如檢測「5 分鐘內同一用戶創建了 10 筆小額訂單」這種異常模式），識別出的異常事件寫入 Topic B，觸發風控服務、告警系統等下游動作。</p><p>為什麼要寫回 Kafka，而不是直接寫入最終目的地？因為 Flink 處理結果往往需要被多個系統消費，Kafka 作為中介提供了解耦、可重放、背壓處理等優勢。這種模式本質上是「流式 ETL」 — — 一切都在即時發生。</p><p>在這個資料流中，其實有<strong>兩層</strong> exactly-once 保證在運作：</p><p><strong>第一層是 Flink 內部的 exactly-once</strong>：透過 checkpoint 機制，Flink 保證即使 job 失敗重啟，內部狀態（如聚合計算、窗口結果）也能恢復到一致的狀態，不會重複計算。</p><p><strong>第二層是 Flink 寫入 Kafka 的 exactly-once</strong>：Flink 使用 Kafka 的 Transactional Producer API，將處理結果的寫入和 Kafka offset 的提交包裝在同一個事務中。要麼全部成功（處理完成 + offset 提交 + 寫入 Topic B），要麼全部失敗回滾。</p><p>這兩層保證是<strong>互補而非重複</strong>的。Kafka 的 exactly-once 確保消息在 Kafka 生態內不重複寫入；Flink 的 exactly-once 確保處理邏輯和狀態管理不會因為失敗而重複執行。只有同時配置兩邊的 exactly-once，才能達到真正的端到端保證。</p><p>配置方式也很直觀。在 Flink 端，啟用 checkpoint 並設定 exactly-once 模式；在 Kafka Sink 端，設定 DeliveryGuarantee.EXACTLY_ONCE 並指定 transactional ID prefix。這樣的組合讓 Flink + Kafka 成為目前業界處理關鍵業務串流資料最可靠的架構之一。</p><p>當然，exactly-once 並非沒有代價 — — Kafka 事務會增加幾毫秒到幾十毫秒的寫入延遲，Flink checkpoint 也會增加狀態儲存成本。但對於金融交易、訂單處理等不容許資料重複的場景，這個代價是完全值得的。而對於日誌收集、監控指標等場景，你仍然可以選擇 at-least-once 來換取更高的吞吐量。正如 Kafka 一貫的哲學：選擇權在用戶手中。</p><p><strong>Notable Features from WarpStream</strong></p><p>2024 年 9 月，Confluent 收購了 WarpStream，一家成立僅 13 個月的新創公司。這次收購補足了 Confluent 產品線的最後一塊拼圖 — — BYOC（Bring Your Own Cloud）部署模式。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/508/1*MopzPUcs2tg62ajsrZ-PFQ.png" /></figure><p>WarpStream 的核心創新在於徹底重新思考 Kafka 在雲端的架構設計。傳統 Kafka 是為本地磁碟設計的，即使搬到雲上，仍然需要管理大量的 EBS volume 和處理跨可用區的網路成本。WarpStream 的創辦人 Richard Artoul 和 Ryan Worl 意識到，既然資料庫層可以透過分離計算和儲存來降低運維負擔，資料串流層也應該可以做到同樣的事。</p><p>WarpStream 從零開始設計，直接將資料寫入物件儲存（如 S3），而不是依賴本地磁碟。這種架構帶來了幾個關鍵優勢：</p><p>首先是成本優化。物件儲存比 block storage 便宜得多，而且不需要為跨可用區的資料複製付出高額的網路傳輸費用。對於大規模的日誌記錄、可觀測性資料、或是填充 data lake 這類場景，成本節省相當可觀。</p><p>其次是部署靈活性。WarpStream 採用 BYOC 模式，資料和計算都在客戶自己的雲端帳號中，滿足了那些因為法規或合約限制而無法使用完全託管服務的組織。同時，透過 Confluent 的管理平面，又能享受到類似託管服務的便利性 — — 這是一種「共同責任框架」，客戶和供應商共同負責系統的運作和健康狀態。</p><p>第三是運維簡化。傳統 Kafka 需要精心調校磁碟、記憶體、網路等各種參數，還要處理 rebalancing、leader election 等複雜的運維任務。WarpStream 的雲原生設計大幅降低了這些負擔。</p><p>WarpStream 也保留了 Kafka 生態系統的相容性，支援標準的 Kafka protocol，並提供一些獨特功能，像是 offset-preserving replication（類似 Confluent Cluster Linking）、Cluster Quotas（用於多租戶環境的配額管理）、以及即將推出的 Kafka transactions 支援。</p><p>值得一提的是，WarpStream 在被收購後並沒有停止發展，反而加速了產品路線圖。他們推出了 Orbit 產品，能夠完美鏡像任何 Kafka 相容的來源，實現災難恢復、本地快取、分層儲存等功能。</p><p>Jay Kreps 在宣布收購時表示，WarpStream 展示了「BYOC 的正確做法」。透過這次收購，Confluent 現在擁有完整的產品組合：想要完全託管？用 Confluent Cloud。想要自己管理？用 Confluent Platform。想要在自己雲端帳號中運行但又不想處理所有運維細節？那就用 WarpStream。</p><p>這也反映了一個更大的趨勢：資料基礎設施正在全面擁抱物件儲存。從 Snowflake 到 Databricks，從 ClickHouse 到現在的 Kafka，幾乎所有現代資料平台都在朝這個方向演進。物件儲存不再只是冷資料的歸檔地，而是成為了高性能、即時資料處理的基礎。</p><p><strong>Notable Acquisition: When Confluent Joined IBM</strong></p><p>IBM 和 Confluent 在 2025 年 12 月 8 日宣布，IBM 將以每股 31 美元、總價值 110 億美元收購 Confluent，交易預計在 2026 年中完成。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/367/1*Gw9psFt4eoa6NWdiKKZCUQ.png" /></figure><p>事實上，Confluent 的總可達市場（TAM）在過去四年從 500 億美元翻倍成長到 2025 年的 1000 億美元，<strong>市場本身確實在擴張</strong>。真正的問題是 Confluent 能獲得的市場份額比預期少。市場很大，但 Confluent 作為獨立上市公司卻面臨成長瓶頸，難以將市場規模轉化為自己的營收。</p><p>這背後有幾個關鍵因素。首先是市場現實：Kafka 已經成熟為一種商品（commodity）。任何人都可以使用開源版本的 Kafka，AWS 提供 MSK、Azure 有 Event Hubs、Google Cloud 也有自己的託管 Kafka 服務。這些雲端廠商不僅提供了便利的託管服務，還能與各自的雲端生態系統深度整合，直接搶走了 Confluent 的潛在客戶。Confluent 雖然是 Kafka 的原創者，卻在商業化上面臨激烈競爭。</p><p>其次是 IBM 包裝這次收購的 AI 敘事。IBM 強調這次收購是為了打造「為 AI 而生的智能數據平台」，聲稱 AI agents 需要即時數據流。但這更像是一個行銷包裝 — — Kafka 本身的價值主張並沒有改變，只是現在被重新定位在 AI 浪潮之下。即時數據處理一直是 Kafka 的核心能力，無論有沒有 AI。</p><p>從更宏觀的角度看，這次收購是 IBM 多年策略佈局的一部分。透過收購 Red Hat（2019 年，340 億美元）、HashiCorp（2024 年，65 億美元）、以及現在的 Confluent（2025 年，110 億美元），IBM 創造了一個完整的企業基礎設施「三位一體」：作業系統層（Red Hat OpenShift）、基礎設施佈建層（HashiCorp）、以及數據傳輸層（Confluent）。這讓 IBM 能夠提供從底層到數據流的完整解決方案，在與 AWS、Azure、Google Cloud 等雲端巨頭的競爭中找到差異化定位。</p><p>Confluent 的 CEO 兼 Kafka 共同創造者 <strong>Jay Kreps</strong> 在官方部落格中表示，成為 IBM 的一部分不會改變 Confluent 的使命，反而會放大它。這是標準的收購後公關話術，但字裡行間也透露出一些無奈 — — 如果 Confluent 有足夠的成長空間和競爭優勢，或許不會選擇被收購。</p><p>這是一個有點悲傷但也很真實的科技業故事。即使你創造了改變世界的開源技術，建立了一個市值數十億美元的公司，最終還是可能因為市場動態、競爭壓力、或商業模式的侷限而被更大的企業收購。Kafka 改變了整個數據處理產業，但 Confluent 能從中獲得的商業回報，卻遠不如當初想像的那麼豐厚。開源的理想與商業的現實，在這個案例中形成了鮮明的對比。</p><h3>Kafka wire protocol: The De Facto Standard</h3><p>Kafka 定義了事件串流的標準。隨著 Kafka 在網路公司中的廣泛採用，<strong>Kafka wire protocol </strong>逐漸成為事件串流領域的事實標準（de facto standard）。它包含兩個緊密關聯的層級：</p><p><strong>1. 通信協議層</strong>：Kafka 定義了一套完整的二進位協議，規範了 client 與 broker 之間的通信方式。這套協議涵蓋了 produce request、fetch request、metadata request 等各種操作，採用請求-回應（request-response）模式，並經過精心設計以支援高效能的批次處理和零拷貝傳輸。</p><p><strong>2. 訊息格式層</strong>：Kafka 定義了訊息的儲存與傳輸格式，包括如何組織 batch、如何編碼 offset、如何處理壓縮等。巧妙的是，Kafka 讓磁碟儲存格式與線路傳輸格式保持一致，這帶來了零拷貝傳輸的效能優勢。這套格式經過了 LinkedIn、Netflix、Uber 等公司在極端規模下的實戰驗證。</p><p>值得注意的是，Kafka 並未使用 gRPC、Protocol Buffers 或 Thrift 等現成的 RPC 框架，而是基於 TCP 自行設計了這套二進位協議。這個決策讓 Kafka 能夠精確控制通信格式與儲存格式之間的映射關係，實現最佳效能。</p><p>至於 Producer API、Consumer API 和 Streams API，這些其實只是 Kafka 官方提供的 <strong>Java/Scala 實作</strong>。它們展示了如何使用底層 wire protocol 來實現事件串流的程式設計模型，但並非標準本身。其他語言的開發者可以基於相同的 Kafka wire protocol，實作出符合各自語言慣例的 client library — — 事實上，社群已經為 Python、Go、C/C++、.NET 等語言提供了各種實作，它們遵循相同的底層協議，但 API 設計各有不同。</p><p>當 Redpanda 或 Pulsar（透過 KoP）宣稱「Kafka API 兼容」時，它們指的就是實作了這套完整的 Kafka wire protocol。這讓任何為 Kafka 開發的 client（無論用什麼語言）都能與它們通信，而無需修改程式碼。</p><p>這種分層的標準化設計，讓 Kafka 既保持了協議層的統一性，又給予了各語言生態系統足夠的靈活性。</p><h3>Summary</h3><p>像 Scribe 這樣的日誌收集系統，專注於極致的資料攝取與傳輸效率，卻刻意不介入下游如何消費與處理資料。這種設計在當時非常合理，也極其成功。即使到了今天，Meta 仍然持續演進其內部的 Scribe 系統，支撐超過 15 TB/s 的資料攝取量。然而，2008 年開源的 Scribe 專案早已停止維護；相對地，Apache Kafka 則在開源世界中接下了這條路線，並將它推向更遠的地方。</p><p>傳統 message queue 關注的是「每一筆訊息都必須被完整處理」，這些訊息往往直接對應業務交易，因此需要嚴格的確認與回覆機制。但這樣的設計，通常以犧牲吞吐量與可擴展性為代價。Kafka 從一開始就選擇了不同的取捨：讓 Consumer 主動拉取資料並自行維護進度，將上游資料傳輸與下游業務處理解耦；充分利用 Linux 檔案系統與磁碟順序 I/O 的特性；再透過 replication 與可調整的 acks 設定，讓使用者能在效能與可靠性之間自行決定平衡點。這些選擇，使 Kafka 在本質上成為一個可與 Scribe 並列的大規模 Log Collector。</p><p>隨著 Kafka Streams 與 ksqlDB 的加入，Kafka 開始具備基本的串流運算能力；而當 Apache Flink、Apache Storm、Spark Streaming 等成熟的 Stream Compute Engine 成為其下游核心，Kafka 也正式完成了從「Log Collector」到「Event Streaming Platform」的角色轉換。</p><p>耐人尋味的，不只是功能的堆疊。</p><p>在 <strong>KRaft</strong> 中，Kafka 將 Control Plane 的 consensus 從外部的 ZooKeeper 收回到系統內部，讓 metadata 與 data 都遵循同一套 log-based、quorum-driven 的模型；在 <strong>新的 consumer rebalance protocol</strong> 中，Kafka 將 group control 的決策權，從 client 端的 leader consumer 收斂回 server 端的 group coordinator；而在 <strong>Share Groups / Queues for Kafka</strong> 中，Kafka 進一步把「如何消費訊息」這件事，從過去隱含在 consumer 假設中的語義，提升為平台本身可以明確提供的能力。</p><p>這三條演進線索，其實彼此高度呼應：Kafka 不斷把關鍵決策，從分散、不穩定、難以觀測的邊緣角色，收斂到系統核心；不斷將原本依賴使用者默契與慣例的行為，轉化為平台可被明確建模與保證的能力。</p><p>回頭看，Kafka 並不是突然「加上 Queue 功能」，也不是偶然「移除 ZooKeeper」。它只是一步一步，補回自己早年為了極致吞吐量而刻意放下的東西。當 Share Groups 正式落地，Kafka 將同時支援事件串流與工作分派這兩種原本分屬不同系統的負載模型。</p><p>一個「<strong>Unified Queuing and Streaming Platform</strong>」，逐漸浮現。</p><p>(To be continued …)</p><h3>Appendix</h3><ol><li>Kafka: a Distributed Messaging System for Log Processing: <a href="https://notes.stephenholiday.com/Kafka.pdf">https://notes.stephenholiday.com/Kafka.pdf</a></li><li>Persistent data structures: <a href="https://www.geeksforgeeks.org/dsa/persistent-data-structures/">https://www.geeksforgeeks.org/dsa/persistent-data-structures/</a></li><li>Introducing Warehouses: Compute-compute separation in ClickHouse Cloud: <a href="https://clickhouse.com/blog/introducing-warehouses-compute-compute-separation-in-clickhouse-cloud">https://clickhouse.com/blog/introducing-warehouses-compute-compute-separation-in-clickhouse-cloud</a></li><li>Delta vs. Lambda: Why Simplicity Trumps Complexity for Data Pipelines: <br><a href="https://www.databricks.com/blog/2020/11/20/delta-vs-lambda-why-simplicity-trumps-complexity-for-data-pipelines.html">https://www.databricks.com/blog/2020/11/20/delta-vs-lambda-why-simplicity-trumps-complexity-for-data-pipelines.html</a></li><li>Lambda Architecture: <a href="https://www.databricks.com/glossary/lambda-architecture">https://www.databricks.com/glossary/lambda-architecture</a></li><li>Questioning the Lambda Architecture: <a href="https://www.oreilly.com/radar/questioning-the-lambda-architecture/">https://www.oreilly.com/radar/questioning-the-lambda-architecture/</a></li><li>The Case for Shared Nothing: <a href="https://dsf.berkeley.edu/papers/hpts85-nothing.pdf">https://dsf.berkeley.edu/papers/hpts85-nothing.pdf</a></li><li>Semantics: <a href="https://www.cs.unc.edu/~dewan/242/f97/notes/ipc/node27.html">https://www.cs.unc.edu/~dewan/242/f97/notes/ipc/node27.html</a></li><li>Realtime Data Processing at Facebook: <a href="https://scontent-sjc3-1.xx.fbcdn.net/v/t39.8562-6/240826476_579340586413244_313948440943029532_n.pdf?_nc_cat=100&amp;ccb=1-7&amp;_nc_sid=e280be&amp;_nc_ohc=spm591wIEm0Q7kNvwHZclZb&amp;_nc_oc=AdmMo0iPlLPY6gIxP35MTmG7yEOieGh50iGTQueUiAwxPpQHLrdvMdP2WvUtoMe_oOo&amp;_nc_zt=14&amp;_nc_ht=scontent-sjc3-1.xx&amp;_nc_gid=fIbwYC6pUAcV3yYENOjLzQ&amp;oh=00_AfrXC8XfYLXNUixlvm6oxULXtK2FY6M_ZVSls-GzWZgelg&amp;oe=6969AEBE">https://scontent-sjc3-1.xx.fbcdn.net/v/t39.8562-6/240826476_579340586413244_313948440943029532_n.pdf?_nc_cat=100&amp;ccb=1-7&amp;_nc_sid=e280be&amp;_nc_ohc=spm591wIEm0Q7kNvwHZclZb&amp;_nc_oc=AdmMo0iPlLPY6gIxP35MTmG7yEOieGh50iGTQueUiAwxPpQHLrdvMdP2WvUtoMe_oOo&amp;_nc_zt=14&amp;_nc_ht=scontent-sjc3-1.xx&amp;_nc_gid=fIbwYC6pUAcV3yYENOjLzQ&amp;oh=00_AfrXC8XfYLXNUixlvm6oxULXtK2FY6M_ZVSls-GzWZgelg&amp;oe=6969AEBE</a></li><li>Facebook Open Sources LogDevice — a Distributed Data Store for Log Storage: <a href="https://www.infoq.com/news/2018/09/logdevice-distributed-logstorage/">https://www.infoq.com/news/2018/09/logdevice-distributed-logstorage/</a></li><li>Scribe: Transporting petabytes per hour via a distributed, buffered queueing system: <a href="https://engineering.fb.com/2019/10/07/core-infra/scribe/">https://engineering.fb.com/2019/10/07/core-infra/scribe/</a></li><li>Scribe、chukwa、kafka、flume日志系统对比: <a href="https://cloud.tencent.com/developer/article/1084773">https://cloud.tencent.com/developer/article/1084773</a></li><li>Open Source Log system comparison: Scribe, Chukwa, Kafka, flume__ message log system Kafka/flume, etc: <a href="https://topic.alibabacloud.com/a/open-source-log-system-comparison-scribe-chukwa-kafka-flume__-message-log-system-kafkaflume-etc_8_8_20290153.html">https://topic.alibabacloud.com/a/open-source-log-system-comparison-scribe-chukwa-kafka-flume__-message-log-system-kafkaflume-etc_8_8_20290153.html</a></li><li>[極客時間] 大数据经典论文解读 27 | Kafka（一）：消息队列的新标准</li><li>[極客時間] 大数据经典论文解读 28 | Kafka（二）：从Lambda到Kappa，流批一体计算的起源</li><li>Kafka Version History: A Comprehensive Guide: <a href="https://www.codestudy.net/blog/kafka-version-history/">https://www.codestudy.net/blog/kafka-version-history/</a></li><li>Keeping Up With the Kafka Lifecycle: <a href="https://www.openlogic.com/blog/keeping-up-with-kafka-lifecycle">https://www.openlogic.com/blog/keeping-up-with-kafka-lifecycle</a></li><li>Introducing Apache Kafka® 4.0: <a href="https://www.confluent.io/blog/latest-apache-kafka-release/">https://www.confluent.io/blog/latest-apache-kafka-release/</a></li><li>[WarpStream Blog] Hacking the Kafka PRoTocOL: <a href="https://www.warpstream.com/blog/hacking-the-kafka-protocol">https://www.warpstream.com/blog/hacking-the-kafka-protocol</a></li><li>Diskless Kafka on top of PostgreSQL, S3 or SQLite: <a href="https://tansu.io/">https://tansu.io/</a></li><li>[Ververica] A World Without Kafka: <a href="https://www.ververica.com/blog/a-world-without-kafka">https://www.ververica.com/blog/a-world-without-kafka</a></li><li>When Tables Became the Language of Time: <a href="https://ipolyzos.substack.com/p/when-tables-became-the-language-of">https://ipolyzos.substack.com/p/when-tables-became-the-language-of</a></li><li>Why “Streaming vs Batch” Is the Wrong Question in Modern Data Architectures: <a href="https://ipolyzos.substack.com/p/the-streaming-vs-batch-debate-is?r=anqog&amp;utm_campaign=post&amp;utm_medium=web&amp;triedRedirect=true">https://ipolyzos.substack.com/p/the-streaming-vs-batch-debate-is?r=anqog&amp;utm_campaign=post&amp;utm_medium=web&amp;triedRedirect=true</a></li><li>The Past, Present and Future of Stream Processing: <a href="https://www.kai-waehner.de/blog/2024/03/20/the-past-present-and-future-of-stream-processing/">https://www.kai-waehner.de/blog/2024/03/20/the-past-present-and-future-of-stream-processing/</a></li></ol><h3>Notes: Data Plane vs Control Plane, Consistency vs Consensus</h3><p>關於 Data Plane, Control Plane, Consensus 的論述也極為有趣，但我目前想不到合適的擺放而不搶走 Kafka 的丰采，故在此補上。</p><h4>Paxos：以提案為中心的最小共識單元</h4><p>Paxos 是由 Leslie Lamport 在 1990 年代提出的經典共識演算法，目標是在不可靠的分散式環境中，讓多個節點對「某一個值」達成一致。Paxos 的設計非常純粹：它不關心資料內容本身，只關心是否能對某個提案（proposal）在多數節點間取得同意。</p><p>在架構層次上，Paxos 並沒有明確區分 Data Plane 與 Control Plane。它更像是一個原子化的一致性原語（consensus primitive），可被用來保護任何需要共識的狀態。換言之，Paxos 本身並不是一個系統架構，而是一個可被嵌入、反覆使用的理論基礎。</p><p>也正因如此，原始 Paxos 每一次決議都需要完整跑一輪協議，在實務上並不適合用於高頻率、連續的 metadata 更新。它更多扮演的是理論起點的角色，而非直接部署於大型系統中的解法。</p><h4>Multi-Paxos：將共識延伸為持續運作的 Control Plane</h4><p>Multi-Paxos 是 Paxos 在工程實務中的自然延伸，用來處理「連續多個值」的共識問題。它的關鍵假設是：在多次決策過程中，leader 通常是穩定的，因此可以在建立 leader 之後，省略後續提案中重複的協商步驟，將 Paxos 攤平成一條持續成長的 log。</p><p>這使 Multi-Paxos 非常適合作為 Control Plane 的共識核心，用來序列化 metadata 變更，例如配置更新、節點成員調整、partition 重新分配等。此時，Data Plane 仍然是各節點各自處理請求；真正需要 consensus 的，是對「系統狀態變更順序」達成一致。</p><p>在實際系統中，幾乎所有「宣稱使用 Paxos」的平台，實際上運行的都是 Multi-Paxos 或其變形。以 Amazon DynamoDB 為代表的多個 AWS 內部系統，正是利用 Paxos / Multi-Paxos 家族來保護關鍵 metadata（例如 partition ownership、membership 與 reconfiguration）。因此，在討論工程實作時，直接以 Multi-Paxos 作為代表，反而更貼近真實運作情況。</p><h4>Zab（2008）：以樹狀結構管理 Control Plane metadata</h4><p>Zab（ZooKeeper Atomic Broadcast）是 ZooKeeper 所使用的共識協議，設計目標非常明確：維護一棵一致的 metadata tree。Zab 採用 leader / follower 架構，確保所有寫入操作都以相同順序被廣播並套用到每個節點。</p><p>在 Zab 的世界中，Control Plane 的 metadata 被建模為一個階層式結構（znode tree），並支援 ephemeral znode，用來表示「與 client session 綁定的暫態狀態」。ZooKeeper 本身不承載 Data Plane 的實際資料，而是作為純粹的 Control Plane 系統，為其他分散式系統提供一致性服務。</p><p>Apache Kafka（ZooKeeper Era）、HBase、Hadoop 等系統，都是將 Control Plane 的 consensus 外包給 Zab / ZooKeeper 的典型代表。</p><h4>Spanner（2012）：以時間為核心的 metadata 排序</h4><p>Google Spanner 採用的是基於 Multi-Paxos 的複合設計，但它的真正創新，在於引入 TrueTime，將時間納入一致性模型之中。</p><p>在 Spanner 中，Data Plane 的資料（table rows）與 Control Plane 的 metadata（schema、replica placement、transaction state）都被統一放在同一套共識架構下，但透過時間戳與版本化機制來管理順序。這使得 metadata 不只是「被排序」，而是被放進一條可驗證的時間軸中。</p><p>Spanner 是第一批清楚展示「metadata 本身也需要版本與時間語義」的系統，對後來分散式資料庫與交易系統的設計影響深遠。</p><h4>Raft（2014）：將 consensus 變成可理解的系統元件</h4><p>Raft 的誕生，很大程度上是為了降低 Multi-Paxos 類協議在理解與實作上的門檻。它明確定義了 leader、log replication 與 state machine 的角色分工，使共識機制不再只是理論模型，而是可以直接構成一個系統的 Control Plane。</p><p>在 Raft 架構中，Control Plane 的 metadata 被建模為 append-only log，所有狀態變更都必須先寫入 log，經過 quorum 複寫後，才能套用到狀態機；Data Plane 則依賴這些已提交狀態來運作。</p><p>etcd、Consul，以及 Kafka 在 KRaft Era 中的 Controller quorum，都是 Raft 作為 Control Plane 共識核心的代表案例。</p><h4>Accord（2021）：為交易而生的 leaderless consensus</h4><p>Accord 是 Cassandra 為支援跨 partition ACID 交易所提出的新一代共識協議。與前述以 leader 與 log 為中心的協議不同，Accord 刻意採用 leaderless 的設計，直接以交易為單位進行協調。</p><p>Accord 的核心任務不是維護全域系統 metadata，而是對「交易之間的依賴關係與提交順序」達成一致。因此，它處理的是位於 Data Plane 之上的交易協調狀態，而非傳統意義上的 Control Plane。</p><p>在這樣的模型中，共識不再圍繞「下一個值是什麼」，而是圍繞「哪些 transaction 可以安全地一起提交」。</p><h4>Summary of Consensus</h4><p>回顧從 Paxos、Zab、Spanner、Raft 到 Accord，可以發現分散式系統並不是在追求「某一種正確的共識演算法」，而是在不斷重新界定<strong>哪些狀態需要被一致地決定、以及這些狀態屬於系統的哪一個層次</strong>。</p><p>有些系統從誕生之初，就清楚劃分了 Control Plane 與 Data Plane。以 Spanner 為例，系統一開始便假設：資料的讀寫、交易執行屬於 Data Plane，而 schema、replica placement、transaction coordination 等關鍵狀態，必須由一個具備強一致性的 Control Plane 來統一管理。TrueTime 與 Paxos 的結合，正是為了讓這條控制路徑能夠穩定、可推理地存在。</p><p>也有一些系統是在演進過程中，才逐步將 Control Plane「收回」到自身之內。Kafka 正是最具代表性的例子。在 ZooKeeper Era，Kafka 將 Control Plane 的 consensus 外包給 ZooKeeper；而進入 KRaft Era 後，Kafka 選擇自行承擔 metadata 的一致性責任，讓資料與控制都落在同一個 log-based 的架構之中。這個轉變，並不是引入了新的需求，而是讓原本分散在外部的共識邏輯，重新成為系統的內在能力。</p><p>對於這類<strong>明確區分 Control Plane 與 Data Plane 的系統</strong>而言，只要是在 Control Plane 上，為了維護強一致的 metadata 而採用的協議，不論其具體形式是 Multi-Paxos、Zab、Raft，或 Spanner 所採用的變體，都可以被合理地歸類為 <strong>consensus</strong> algorithm。它們的共同目的，不在於處理使用者資料本身，而在於確保「系統如何運作」這件事本身是可被一致理解的。</p><p>相對地，在 Data Plane 中，討論的焦點則完全不同。這裡關心的是資料讀寫之間的語義關係，使用的語言也隨之改變：<strong>transaction consistency</strong> 對應的是隔離層級與交易語義，而 <strong>data consistency</strong> 則通常落在 CAP 的框架下，描述資料如何被「觀察與體驗」。</p><p>然而，並非所有系統都一開始就擁有清楚的 Control Plane。Cassandra 代表的是另一條路徑：一個從設計之初便高度去中心化、幾乎只有 Data Plane 的系統。在這樣的架構下，節點之間對等、沒有全域協調者，原本也刻意避免強一致的集中決策。因此，當 Cassandra 試圖引入跨 partition 的 ACID 交易時，便無法簡單地「加上一個 Control Plane」來解決問題。</p><p>Accord 正是在這個背景下誕生的。它不是替 Cassandra 補上一個傳統意義的控制層，而是選擇在 Data Plane 內部，直接對交易之間的依賴關係與提交條件進行 leaderless 的共識協調。換言之，Accord 並沒有改變 Cassandra 去中心化的本質，而是重新定義了「在沒有 Control Plane 的世界裡，consensus 能為誰服務」。</p><p>從這個角度來看，consensus 並不是 Control Plane 的專利。它真正回答的問題始終只有一個：<strong>在分布式系統裡，哪些狀態須被一致地決定，以及為了這個目標，我們願意付出多少代價。</strong></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=cd3462097cf3" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From Database to Data Lakehouse — Part 10]]></title>
            <link>https://medium.com/@0050211/from-database-to-data-lakehouse-part-10-7801261db693?source=rss-663daee8909b------2</link>
            <guid isPermaLink="false">https://medium.com/p/7801261db693</guid>
            <dc:creator><![CDATA[Ryan Fu]]></dc:creator>
            <pubDate>Fri, 02 Jan 2026 01:59:20 GMT</pubDate>
            <atom:updated>2026-06-15T01:28:42.108Z</atom:updated>
            <content:encoded><![CDATA[<h3>Chapter 8. The Age of Big Data and Data Lake (2000–2017)</h3><p>《<a href="https://medium.com/@adrianhsu/%E7%B5%A6%E5%AD%B8%E5%BC%9F%E5%A6%B9%E7%9A%84%E7%9F%BD%E8%B0%B7-ai-software-%E6%8C%87%E5%8D%97-da054b7a8dcb">給學弟妹的矽谷 AI / Software 指南</a>》是一個矽谷工程師自發的眾籌寫作計劃，由<a href="https://www.facebook.com/opensource4you">源來適你社群</a>推動。這將是一個在台灣軟體學生圈內很有影響力的文章系列。已有 &gt;10 篇準備發布，目標 100 篇並出書，歡迎 tag 強者大神來寫！</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*6r7aH3gboZ9zaJ_I" /></figure><p>在 2001 年，Gartner 分析師 Doug Laney 首次以三個 V — — <em>Volume、Velocity、Variety</em> — — 描述企業資料正在以從未見過的速度爆炸式成長。這是「Big Data」一詞首次具有清晰技術內涵的時刻。</p><p>真正讓 Big Data 從概念走向現實的，是 Google 在 2003–2006 年期間連續發布的三篇論文，被後世稱為 <strong>Google 三駕馬車（GFS、MapReduce、Bigtable）</strong>。它們並非純研究，而是支撐 Google 搜尋、廣告、地圖等超大規模業務的分散式基礎設施。這三篇論文共同向世界證明：「資料太大、太快、太雜」不再是問題，只要分散式系統設計得足夠優雅。</p><p>開源界受到巨大啟發：GFS 被重寫為 <strong>HDFS</strong>；MapReduce 被重寫為 <strong>Hadoop MapReduce</strong>；Bigtable 的模型則催生了 <strong>HBase</strong>。</p><p>而隨著這些基礎架構落地，大數據生態開始進入一種近乎<strong>寒武紀大爆發</strong>的階段：Hive、Pig、Avro、Zookeeper、Storm、Tez、Presto…… 在短短數年間接連出現，為當時的資料工程師帶來了宇宙般廣大的工具選擇，也帶來了同等規模的複雜性。</p><p>其中值得一提的是 <strong>Hive（2008）</strong>，它為大數據世界帶來了 SQL，讓工程師第一次能用熟悉的語法查詢儲存在 HDFS 上的資料。而 <strong>Hive Metastore</strong> 則以「表的 metadata」為中心，使 Spark、Presto、Trino 等後續引擎能共享同一套 schema 定義。不過 Hive 的「表」本質上仍是「檔案 + 資料夾」的包裝（metadata over directories），並不是現代意義上的 table format — — 缺乏 ACID、版本、索引與 schema 管理。資料一旦規模變大，很容易失控。</p><p>就在 Hadoop 生態蓬勃發展的 2010 年，Pentaho 的 CTO <strong>James Dixon</strong> 首次提出 <strong>Data Lake</strong> 概念。他將其比喻為一座能接收所有原始資料的大湖，對比 Data Warehouse 嚴格 ETL 後的瓶裝水。這個願景很美好：所有資料都自然流入湖中，各團隊按需取用。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*tOerPa1Nv1BK6_w0UOLpZw.png" /><figcaption>Data Lake architecture</figcaption></figure><p>然而沒有治理的湖，最終只會變得混濁。混雜的格式、缺乏 schema、缺乏 ACID、路徑命名不一致、無 metadata 管控等等缺陷，Data Lake 很容易淪為 <strong>Data Swamp（資料沼澤）</strong>。</p><p>從現在回頭看，這段歷史大致可以切成兩個時代：</p><ul><li><strong>Data Lake Era (2001–2016)<br></strong>從 Big Data 理論的出現，到 Hadoop 生態、SQL-on-Hadoop、Spark 的成熟，這段時期的資料雖已能被大量收集，卻仍舊以「檔案」為主 — — 無 ACID、無 schema 演進、無統一 metadata、無真正的 table abstraction。<strong>這是一個可以「存下所有資料」，卻無法「像資料庫般管理資料」的時代。</strong></li><li><strong>Data Lakehouse Era (2017</strong>–)<br>直到 <strong>2017 年，第一個 Lakehouse Table Format — — Apache Hudi — — 開源。</strong>緊接著 Iceberg（2018）與 Delta Lake（2019）相繼成熟，帶來 ACID、schema evolution、time travel、統一 metadata 層、compaction、文件管理等能力，使 Data Lake 首次具備「資料庫級的可靠性」。分析、流處理、ML pipeline 從此能在 object storage 上穩定運行。至此，<strong>人類正式從 Data Lake 時代跨入 Data Lakehouse Era。</strong></li></ul><p>本章將依序分成兩部分來介紹：</p><ul><li><strong>Google’s Distributed Systems Trilogy</strong></li><li><strong>Challenges to MapReduce, and the Technologies That Would Fix Them</strong></li></ul><p>在此先列出本章會探討的資料庫術語：</p><p><strong>ACID</strong>：Atomicity、Consistency、Isolation、Durability，描述 transaction 應具備的四個核心支柱。</p><p><strong>Two-Phase Commit（2PC）</strong>：一種分散式 transaction 的提交協議，用以保證 Atomicity。透過「準備階段（prepare）」與「提交階段（commit）」協調多個節點，確保所有參與者最終具有一致的提交結果，避免部分提交或部分回滾的情況發生。</p><p><strong>Two-Phase Locking（2PL）</strong>：一種並發控制協議。transaction 在成長階段只加鎖、不解鎖，在收縮階段只解鎖、不再加鎖，用以保證所有可觀測結果等價於某個序列化順序，從而達到 Serializable 級別。</p><p><strong>Serializable</strong>：最強的 Transactional Consistency 級別，要求所有併發 transaction 的執行結果，必須等價於某個依序執行（serial execution）的結果。此性質讓應用程式能以單一 transaction 的直覺來推理整體正確性。</p><p><strong>Linearizable</strong>：最強的 Data Consistency 模型之一，要求所有操作看起來都在某個全域時間軸上即時生效。一旦寫入完成，任何後續讀取都必須觀察到最新結果，提供接近單機系統的可觀測語意。</p><p><strong>Pessimistic Concurrency Control（PCC）</strong>：悲觀並發控制，假設衝突隨時可能發生。transaction 中的 SQL statements 在執行過程中即透過驗證機制檢查是否滿足隔離條件，若不滿足則等待或中止。</p><p><strong>Optimistic Concurrency Control（OCC）</strong>：樂觀並發控制，假設衝突較少。transaction 中的 SQL statements 可先自由執行，直到提交時再進行整體衝突驗證；若驗證失敗，transaction 會被回滾並重新嘗試執行。</p><p><strong>Multi-Version Concurrency Control（MVCC）</strong>：透過維護多個資料版本，使讀寫 transaction 彼此不阻塞，常用於實作 Snapshot Isolation，並支撐高併發讀取場景。</p><p><strong>Rule-Based Optimization（RBO）</strong>：以固定規則進行查詢最佳化，例如 predicate pushdown、projection pruning。</p><p><strong>Cost-Based Optimization（CBO）</strong>：根據統計資訊與成本模型，評估不同執行計畫的代價，選擇預期成本最低的查詢計畫。</p><h3>Google’s Distributed Systems Trilogy</h3><p>每當回想我心目中最具代表性的電影三部曲，1999–2010 無疑是個輝煌的年代。對我來說，一個成功的三部曲必須有清楚的起承轉合、完整的結構與首尾呼應的故事弧線。一些經典的例子如下：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*PshC6xOhMe-u-pHs.jpg" /></figure><p>The Matrix Trilogy（導演：Lana &amp; Lilly Wachowski）：<br>- The Matrix (1999)：駭客 Neo 意外踏入真實世界，啟動命運的開端。<br>- The Matrix Reloaded (2003)：世界觀急遽擴張，Architect 與 Oracle 的對話揭露整個系統背後的運作邏輯，戰爭壓力也全面逼近。<br>- The Matrix Revolutions (2003)：人類與機器的終局之戰迎來收束。</p><p>相比之下，2020 的第四集顯得有些狗尾續貂。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*p0ixkchC5FAlis0m.jpg" /></figure><p>The Lord of the Rings Trilogy（導演：Peter Jackson）：<br>- The Fellowship of the Ring (2001)：一位哈比人被迫踏上背負世界命運的旅途。<br>- The Two Towers (2002)：同伴分離，戰火蔓延，故事走向更深與更廣。<br>- The Return of the King (2003)：所有伏筆回收，史詩級的最終對決登場。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*VpA2kk2IwBx-gNNz.jpg" /></figure><p>Sam Raimi’s Spider-Man Trilogy（導演：Sam Raimi）：<br>Spider-Man (2002)：Peter Parker 接受能力與責任的召喚。<br>Spider-Man 2 (2004)：英雄必須在個人生活與使命間做出艱難抉擇。<br>Spider-Man 3 (2007)：內外敵人同時來襲，情感與力量雙重爆發。</p><p>相比之下，後兩代的 Spider-Man 雖然娛樂性十足，但似乎少了這種濃烈的戲劇張力。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*mpJZ2aBQKitYsVM3" /></figure><p>The Pirates of the Caribbean Trilogy（導演：Gore Verbinski）：<br>- The Curse of the Black Pearl (2003)：Jack Sparrow 以不可預測的魅力登場。<br>- Dead Man’s Chest (2006)：舊債追上門，劇情轉向更黑暗的深海。<br>- At World’s End (2007)：權力角逐、海上聯盟與最終大決戰同時爆發。</p><p>相比之下，後續兩集則較難融入前三集宏大的敘事架構。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/728/0*rlmipHVZEMKDIo4c.jpg" /></figure><p>而就在差不多的時空背景下，距離好萊塢數百公里遠的北加州，也誕生了另一套讓後人嘖嘖稱道的「三部曲」。創作者並非電影公司，而是一間科技公司，它發表的三篇分布式系統論文足以撼動未來二十年科技版圖。</p><h4>第一部曲：The Rise of the Distributed Storage</h4><p>在理解 GFS 的誕生之前，我們必須先回到 Google 當年的戰場。那是一個全球網頁數量正以倍速增長的年代，搜尋、廣告、爬蟲與排名系統每天都在產生難以想像的海量資料。然而，市場上現成的選項，無論在能力或成本上，都不足以支撐這樣的規模。</p><p>商用資料庫價格高昂、擴展性有限（Oracle、IBM DB2 等商用資料庫在當時採用昂貴的專用儲存設備，單機容量有限，擴容依賴 scale-up 而非 scale-out，成本高得難以維持）；傳統的分散式檔案系統最多管理到幾十台伺服器（NFS、AFS 都以中小規模集群為主，並非為上千台伺服器的水平擴張而設計）；而 Google 當時能採買的大多是便宜的 PC 級硬體 — — 每天都有機器在壞、硬碟在報錯、網線在鬆脫。</p><p>在這樣的環境下，他們需要的不只是一個能跑的儲存系統，而是一個能自癒、能承受海量錯誤、能提供高吞吐、並且能以極低成本水平擴張的分散式檔案系統。然而，當時世界上沒有任何商用方案能撐起這種規模，也沒有任何既有技術能滿足需求。GFS 的發明因此不是炫技，而是被迫創新。</p><p>當《The Google File System》在 2003 年發表時，整個分散式系統領域的規模想像仍停留在「數十台伺服器」的世界（MPI 叢集、Beowulf cluster，多數研究型或產業系統仍以 16～64 台規模為主，超大規模水平擴張尚未普及）。Google 卻直接端出一個足以支撐一千台以上伺服器、並同時服務數百個客戶端併發讀寫的分散式檔案系統。即便放到今天回頭看，GFS 仍然是那個時代最具前瞻性的架構之一 — — 技術上大膽、工程上務實，以取捨與簡化的方式取得最大化的可靠性。</p><p>GFS 最核心的哲學，是以「簡單」作為設計原則，有三個特性：</p><p>1. 直接使用 Linux 檔案系統，並採取極度簡潔的單一 Master 架構。這種設計避免了多 Master 帶來的複雜一致性問題，也讓整個系統的行為可預期、易診斷、好維護。然而，簡潔的代價同樣明顯：只要 Master 故障，整個集群便無法寫入；恢復過程也需要人工介入。儘管如此，Google 仍透過 checkpoint、operation log、shadow master 等手段，使系統在意外發生時能迅速恢復，保持在「壞了能修、修了能跑」的狀態。</p><p>2. 更重要的是，GFS 願意根據當時的硬體條件做取捨。2003 年仍是機械硬碟的時代，隨機讀寫的性能極差，因此 GFS 完全不追求隨機寫入的一致性，而是將整個架構設計為偏向順序寫入與 append 操作。這正好契合 Google 當時的核心工作負載：網頁爬蟲輸出、索引生成、搜尋排名中介資料，全部都是大型檔案與高吞吐量的處理流程。對這類任務而言，順序 I/O 的效能遠比傳統資料庫式的一致性來得重要。</p><p>3. 正因如此，GFS 也放寬了對一致性的要求。對於隨機寫入，它根本不提供一致性保障；至於 append，它採用「至少一次（at least once）」的寬鬆模型，也就是說可能重複寫入，但不會漏寫。重複資料交由應用層處理，而這種取捨不但合理，更是大規模資料處理中最有效的策略。Google 的目標不是打造一個完美一致的資料庫，而是建構一個能承受巨量資料與巨量錯誤、並且永不停機的儲存底座。</p><p>用一句話來總結 GFS 的本質，那就是：它把上千台伺服器、上萬顆硬碟的混亂，封裝成一顆「巨大的、永遠可用的硬碟」。開發者使用 GFS 客戶端時，不需要知道資料存在哪台機器、哪顆硬碟快壞掉、哪個 rack 網路塞住，甚至不需要知道底層是否正在發生大量硬體故障。所有這些複雜性都被系統吸收了。</p><p>正是這種簡潔而強大的抽象，使得 Google 能在 2000 年代初期率先把分散式儲存推向網際網路級規模。</p><h4>第二部曲：Large Clusters, Simplified Computation</h4><p>如果說 GFS 為 Google 解決了「資料放哪裡」的問題，那麼 MapReduce 的誕生，則是解決了另一個根本的痛點──<strong>資料那麼大，到底要怎麼算？</strong></p><p>在 GFS 建好之後，Google 很快發現，計算是隨後的挑戰：每天被爬蟲掃回來的網頁、被索引器拆成的詞彙、為排序模型生成的特徵，全都是 TB 級、甚至 PB 級的海量資料。這些資料要被分析、清洗、統計、轉換、排序，而傳統的分散式程式設計太複雜。工程師得自己處理分片、節點協調、錯誤恢復、資料轉移……稍有不慎就會出現死鎖、資料不一致、節點失效導致程序全毀。</p><p>在《MapReduce: Simplified Data Processing on Large Clusters》(2004) 之前，分散式運算更像是在荒野求生 — — 全靠經驗、直覺與一點運氣，才能撐過每一次運算流程。MapReduce 的出現，就是要把這種痛苦徹底抹除。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*22fczR6kfD0NMfRdXmu8Qg.png" /><figcaption>MapReduce flow</figcaption></figure><p>Google 的想法很簡單──<strong>讓工程師寫單機程式的方式，卻能跑在上千台機器上。</strong>而他們達成這件事的方式，優雅到近乎反直覺：把所有分散式計算，濃縮成兩個動作 — — map 和 reduce。map 負責拆；reduce 負責合，其餘所有分散式系統的細節──排程、並行、資料重新分配、錯誤恢復、重試、跨機器 shuffle──全部交給框架處理。</p><p>這個精神與 Unix 的哲學如出一轍：Do one thing, and do it well</p><p>MapReduce 的程序本質上像是 Unix 管線的跨機器版本。在 Unix 裡你用 grep | sort | uniq；在 MapReduce 裡，你用 map → shuffle → reduce。同樣的思想，只是規模被放大到數千台伺服器。</p><p>如果你熟悉設計模式，你會一眼看出 MapReduce 的精髓其實就是 Template Method Pattern。Google 在框架裡預先定義了整個分散式計算的生命週期，你只需要實作 map() 與 reduce() 兩個方法，框架就會自動負責剩下所有工作。因此，與其說 MapReduce 是一套分散式運算系統，不如說它是一套<strong>把分散式封裝起來、讓所有人都能使用的分散式運算框架</strong>。</p><p>這種極致簡化帶來了驚人的威力。在 MapReduce 之下，Google 能夠輕鬆實作出各種看似龐大的任務：分散式 grep、URL 訪問次數統計、倒排索引構建、網頁連結圖反轉（reverse web link graph）、域名詞向量生成、甚至全網級分布式排序。論文裡展示的範例幾乎都是網際網路級規模的工作，但工程師實際需要寫的程式碼常常只有幾十行。</p><p>而這，正是 MapReduce 的真正革命：<strong>它把千台機器的複雜度，隱藏在兩個簡單的函數背後。</strong>工程師第一次能用單機的方式思考，卻在多機環境運行；第一次不必理解分散式系統，就能享受分散式系統的力量。這種抽象化在當時是前所未有的突破，也讓大規模資料處理第一次成為所有工程師都能參與的領域，而不是高牆內的魔法技藝。</p><p>如果 GFS 是 Google 分散式儲存的起點，那麼 MapReduce 就是 Google 分散式計算的覺醒：把海量資料拆開、把混亂計算重組，把「大數據」從難以駕馭的怪物，變成人人都能向其發號施令的巨型工具。</p><h4>第三部曲：The Return of the Structured Data</h4><p>如果說 GFS 解決的是「如何可靠地儲存資料」，MapReduce 解決的是「如何在大量節點上做批次計算」，那麼 Bigtable 的登場，正是 Google 三部曲中的第三塊拼圖：<strong>如何在分散式環境中，高效地隨機讀寫結構化的海量資料？</strong></p><p>在 2000 年代中期，Google 的服務規模正急速膨脹，而支撐這些服務的資料型態，開始呈現出一個非常明確的共通特徵：<strong>資料高度結構化、以 key 精準定位、且讀寫延遲極度敏感</strong>。搜尋索引中，每個字詞都對應到一組明確的文件清單，並且在排名模型更新時需要即時改寫倒排索引；Google Maps 的 tiles 以座標（x, y, zoom）作為 key，使用者每一次拖動地圖，都必須即時載入任意位置的資料；廣告點擊紀錄則是一筆筆帶有 timestamp、user-id、ad-id 的事件，廣告系統必須在毫秒級完成寫入與查詢；而 Gmail 的 metadata 以 user-id 為主鍵，並需要支援使用者在開啟信件、標記已讀、移動資料夾時的即時更新。</p><p>這些資料雖然規模龐大，卻並不適合以「批次掃描」的方式處理。相反地，它們要求系統能夠<strong>即時定位到某一行資料，進行頻繁且高併發的 random read / write</strong>。也正是在這一點上，以批次處理見長的 MapReduce 開始顯得力不從心。</p><p>Google 當時真正需要的，是一個能在上千台伺服器上運行、支撐百萬級隨機 IOPS、具備自動故障恢復能力，並且能以簡單資料模型承載上述工作負載的分散式資料庫。然而，在那個時間點，世界上並不存在任何現成系統能同時滿足這些需求。《Bigtable: A Distributed Storage System for Structured Data》（2006）正是在這樣的背景下誕生，成為 Google 對「分散式結構化資料存取」問題的直接回應。</p><p>Bigtable 的影響並未止步於 Google 內部。它開啟了一條後來被稱為 <strong>Wide-Column Store</strong> 的設計路線，這類系統普遍採用 <strong>LSM Tree（Log-Structured Merge Tree）</strong> 作為核心儲存結構，以換取高寫入吞吐與可擴展性。承接這條脈絡的開源實作，包括 HBase（2008）、Cassandra（2008）、LevelDB（2011）與 RocksDB（2013）。其中，Cassandra 尤其受到矚目，它在 Bigtable 與 Dynamo 的思想基礎上，結合了 Gossip 協定、Ring 架構與虛擬節點、Anti-Entropy Repair、Read Repair，以及支援 Paxos 的 Lightweight Transaction，成為去中心化分散式資料庫設計的重要代表。</p><p>至此，Google 的三篇經典論文 — — GFS、MapReduce 與 Bigtable — — 具備層次分明、峰迴路轉與結構完整的故事弧線：從儲存、到計算、再到結構化資料存取，為大規模資料處理奠定了一套堅實的基礎。這套框架迅速被產業與開源社群擁抱，成為後來整個 Big Data 生態的思想原型。</p><p>然而，這套看似無懈可擊的組合技，仍被一位來自資料庫世界的大師揭示出其計算模型的結構性侷限，進而讓整個開源生態踏上了一段長達十多年的自我修補與重構之路。</p><h3><strong>Challenges to MapReduce, and the Technologies That Would Fix Them</strong></h3><p>1970 年代，Edgar F. Codd 提出了劃時代的 <strong>Relational Model</strong>，並因此於 1981 年獲頒圖靈獎。相較於過去仰賴指標與固定結構的 Network 與 Hierarchical 模型，關聯模型以集合論與代數為基礎，用「表格」抽象化資料結構，大幅降低使用者理解資料的門檻。更重要的是，Codd 引入了 <strong>聲明式查詢語言</strong> 的思想 — — 使用者只需描述「想要什麼」，而不是「要怎麼做」。這不僅重塑了資料庫的思維，也奠定 SQL 與後續 RDBMS 架構的根基。若以 1970 作為分界線，把資料庫史分成「Codd Era」之前與之後，絕不為過。</p><p>在此之後的四十年間，另一位關鍵人物 <strong>Michael Stonebraker</strong> 延續並拓展了這條道路，他的研究貢獻幾乎扭轉了資料庫世界的每一個十年：</p><ul><li><strong>Ingres（1970s）</strong>：早期 RDBMS 之一，推動 SQL 與查詢優化器研究。</li><li><strong>Postgres（1980s）</strong>：開啟 extensible database 的新時代，後成為今日 PostgreSQL 的基礎。</li><li><strong>Illustra（1990s）</strong>：開拓面向物件資料庫（OODBMS）。</li><li><strong>C-Store（2005）</strong>：Columnar DB 奠基作品，啟發後來的 Vertica。</li><li><strong>H-Store（2007）</strong>：主記憶體 OLTP 先驅，啟發 VoltDB。</li></ul><p>Stonebraker 的研究橫跨 OLTP、OLAP、Column Store、主記憶體資料庫、並行資料庫等關鍵領域，被譽為繼 Codd 之後的 <strong>「RDBMS 守護神」</strong>。</p><p>在 Google 三駕馬車橫空出世後，整個產業一度沉浸在分散式計算的狂熱之中。MapReduce 被視為大數據處理的革命，人們甚至大聲宣稱：「傳統資料庫已死」、「未來將完全屬於平行計算架構」。就在這股聲浪最盛之時，圖靈獎得主 <strong>Michael Stonebraker</strong> 在 2008 年發表一篇震撼性的評論文章<strong>《MapReduce: A Major Step Backwards》</strong>，以 RDBMS 為黃金標竿，列出了多項 RDBMS 擁有、MapReduce 卻明顯缺乏的功能 — — 而這些能力，正是任何資料平台能否從青澀工具走向成熟系統的分野。</p><p>稍後我將逐條列出 MapReduce 相比於 RDBMS 的缺陷 — — 包含 Stonebraker 在文章中提出的，以及我與 GPT 參考 2008 年資料庫實作所歸納出的 — — 並將其分成三大類展開討論：</p><ul><li><strong>RDBMS 功能相關：<br></strong>包括 SQL, Schema, Integrity Constraints, Updates, Index, Views &amp; Materialized Views, Bulk Loader</li><li><strong>RDBMS 組件相關：<br></strong>包括 Catalog, WAL, Buffer Pool, Storage Engine, Transaction, Concurrency Control, Query Optimizer, Admission Control</li><li><strong>RDBMS 工具鏈與生態相關：<br></strong>包括 Report Writers, Business Intelligence Tools, Data Mining Tools, Replication Tools, Database Design Tools</li></ul><p>在《MapReduce: A Major Step Backwards》發表後，開源社群開始大規模補強這些缺口。MapReduce 被重寫成 Hadoop MapReduce；Spark、Flink、Presto、Trino、ClickHouse、DuckDB 逐步完善了查詢引擎層；Parquet、ORC、Avro 走向檔案格式標準化；GFS 啟發了 HDFS，並出現了 S3 與 MinIO；Hudi、Iceberg、Delta Lake 將 Table Format 帶入 Lakehouse；Hive Metastore、AWS Glue、Unity Catalog 則補齊了 metadata 與治理能力。大數據生態進入近乎「寒武紀大爆發」的階段，從 Data Lake Era 正式邁向 Data Lakehouse Era。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*BHk1egIC1JSIajKl.png" /></figure><p>2025 的今天，讓我們替由 MapReduce 演化而成的 Lakehouse 進行一場 「RDBMS 版圖靈測試」，檢驗 Lakehouse 是否已功能健全。若一台機器上跑的究竟是 PostgreSQL，還是 Lakehouse Query Engine + Table Format + Object Storage，而使用者在敲下 SQL 查詢時無法分辨兩者，我們就說 Lakehouse 通過了這場「RDBMS 版圖靈測試」，與 RDBMS 並駕齊驅。</p><h4>1) RDBMS 功能相關</h4><h4>SQL</h4><p>SQL 是一種聲明式語言，讓使用者描述「想要什麼」，而不是指定「要怎麼做」。沒有 SQL 的系統會迫使開發者用程式碼描述整個資料處理流程，使複雜度與錯誤率急速上升。</p><p><strong>開源社群的改進：</strong><br>在 MapReduce 問世的那一刻，大數據世界雖然獲得了「能處理 PB 級資料」的能力，卻失去了資料庫世界最珍貴的東西 — — SQL。使用者必須用 Java 或寫滿 mapper / reducer 的程式碼才能完成最基本的資料整理；任何看似簡單的查詢，都得拆成多個 job、經歷 shuffle、等待 minutes 到 hours 才能取得結果。這種模式對工程師來說非常痛苦，也阻礙了大數據技術的普及。</p><p>於是，整個 Hadoop 生態開始了一場漫長的「尋回 SQL」之旅。</p><p>最早的嘗試是 2008 年的 <strong>Pig Latin</strong>。Pig 不是 SQL，而是一種專門描述資料流的 DSL。它讓工程師能像堆積木般組合 ETL pipeline，Pig 會負責把這些步驟編譯成 MapReduce job。雖然 Pig 不適合作為查詢語言，但它最先把「高階語言」引入 Hadoop，替後續 SQL 回歸鋪好了道路。</p><p>同樣在 2008 年，<strong>Hive SQL</strong> 出現，正式讓 Hadoop 世界理解到 SQL 的力量。Hive 做了一個大膽的決定：使用者寫 SQL，底層自動翻譯成 MapReduce。這個設計粗糙、效能不佳，但卻徹底降低了 Hadoop 的採用門檻。分析師不再需要寫 Java，資料工程師能用熟悉的語法操作 HDFS 上的資料。可以說，Hive 為「SQL on Hadoop」點燃了第一道火炬。</p><p>然而，真正讓 SQL 回到主舞台的，是 2014 年 <strong>Spark SQL</strong> 的誕生。Spark 不再依賴 MapReduce，而是使用 in-memory 計算與 Catalyst Optimizer。Catalyst 讓 SQL 查詢具備 CBO（Cost-Based Optimization）、向量化執行等能力，使資料湖查詢速度提升到過去難以想像的程度。從這一刻起，SQL 不只是 Hadoop 的附屬語言，而是 Data Lake 的核心 API。</p><p>緊接著，各式 SQL 引擎開始百花齊放。Cloudera 的 <strong>Impala（2012）</strong> 將 MPP 架構帶入 Hadoop，讓 SQL 查詢具備近乎資料庫級別的互動式延遲。Facebook 則在 2013 年推出 <strong>Presto</strong>，專門用於跨資料源查詢，使 SQL 能同時查 HDFS、MySQL、Kafka 等多種系統；它後來分支成 <strong>Trino（2020）</strong>，成為今日最活躍的分散式 SQL 引擎之一。<strong>DuckDB（2018）</strong>是一個崛起於資料科學圈的單機向量化資料庫，它把「分析引擎」放進筆電裡，掃描 Parquet 的速度卻能媲美大型系統。<strong>DataFusion（2018 起）</strong>則以 Rust 實作，擁有成熟的 SQL parser、query optimizer 與 execution engine，是現代許多查詢引擎（Ballista、Influx、Polars SQL）的底層核心。</p><p>在 2025 的今天，Data Lakehouse 已經能以 SQL 完成查詢、更新、ACID 交易、時間旅行、schema evolution、索引化 skipping、甚至跨系統 join。（跨系統 join 在語法與執行模型上已成立，但其效能與一致性語意，仍高度依賴 query engine、connector 與資料分佈特性。）</p><h4>Schema</h4><p>RDBMS 使用 Schema 來明確定義資料結構，包括 column 名稱、資料型態（INTEGER、TEXT、TIMESTAMP…）、constraints（NOT NULL、DEFAULT、CHECK）、索引與關聯等。有了 Schema，資料表的內容具有可預測性、可驗證性，也能支援強型別語意、查詢優化以及一致性維護。沒有 Schema 的系統（例如原始 Hadoop）通常只能依賴「檔案本身的格式」或「使用者的自律」來維持欄位意義。</p><p><strong>PostgreSQL 的例子：</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/804/1*W_qDuQMxvu-22nx5wNKRZg.png" /></figure><p><strong>開源社群的改進：<br></strong>在 MapReduce / HDFS 的年代，資料湖是一片沒有結構、沒有強制約束、沒有治理能力的荒野。Hadoop 的世界最初缺乏任何資料庫層級的 schema 管理，因此社群開始一步步補洞，試圖讓資料湖從「目錄 + 檔案」進化到真正可被管理的結構化平台。</p><p>第一個重要的里程碑是 2008 年出現的 Hive。Hive 的核心貢獻在於它首次把「表」的概念帶進資料湖：使用者可以為 HDFS 上的目錄建立 schema，並把該目錄視作一張邏輯表。然而 schema 僅存在 Hive Metastore 裡，真正的檔案仍然是任意格式，例如 text、CSV、JSON、ORC。Hive 並不檢查資料是否符合 schema，也不拒收無效資料；這使它本質上仍是 schema-on-read — — 讀取時套 schema，而不是寫入時 enforce schema。它提供了語意上的「表結構」，卻沒有資料庫式的治理能力。</p><p>接著在 2009 年，Avro 嘗試進一步往前推進，導入 schema 描述與自帶 schema 的檔案格式設計。Avro 支援欄位新增與移除，因此非常適合 Kafka、Hadoop pipeline 等需要流水式演進的場景。但 Avro 的 schema 仍然附著在單一檔案內，並沒有形成 table-level 的統一治理，因此仍無法算是資料庫式的表格式。</p><p>2013 年，Parquet 的出現讓資料湖正式進入 columnar schema 時代。Parquet 帶來了 nested 型別、column metadata、statistics 等資料庫級結構化能力，使查詢引擎可以做 predicate pushdown 與 row group skipping。然而 Parquet 依然缺乏 table-level 的 schema evolution：schema 是檔案層級的，整張表的 schema 管理仍然必須依賴外部 metastore 或人工約定。</p><p>真正的革命出現在 2017–2019 年之間。Hudi、Iceberg、Delta Lake 相繼誕生，這三個 table format 首次讓 Data Lakehouse 具備「像 RDBMS 一樣管理 schema」的能力。它們不但支援 ADD COLUMN、RENAME COLUMN、DROP COLUMN，甚至能執行 TYPE PROMOTION（例如 int → long）。更重要的是，它們會在寫入時 enforce schema，拒絕寫入不符合結構的資料，並提供 read/write schema evolution 的完整治理流程。</p><p>尤其 Iceberg 與 Delta 更進一步引入 column ID，使 schema 與 column 位置解耦；這意味著欄位順序變動不會破壞資料檔案，徹底解決了早期 Parquet/Hive 在 schema 演進時的脆弱性。再加上 metadata 層治理與 time travel 機制，Iceberg 與 Delta 能在不同版本 schema 之間維持一致性，支援長期的 schema 變遷歷史，這是過往 Data Lake 無法想像的能力。</p><p>從 Hive 的 metadata overlay，到 Avro 的 schema-aware 檔案格式；從 Parquet 的 column metadata，到 Iceberg / Delta / Hudi 的 table-level schema governance；這段漫長的進化軸線讓 Data Lakehouse 終於拿回資料庫世界早已證明必要的能力 — — 結構化資料的生命週期管理。如今，schema 不再是「讀取時才想到的註腳」，而是 Lakehouse 的核心治理基石，使資料湖真正具備資料庫式的可靠性、可預測性與長期演進能力。</p><h4>Integrity Constraints</h4><p>Integrity Constraints 是 RDBMS 用來維護資料品質的關鍵機制。它們確保資料表中的內容必須符合「商業規則」與「資料邏輯」，避免資料髒化、欄位錯位、主鍵重複、參照錯誤、非法值等問題。這些約束讓資料庫能有效：</p><ul><li>防止不合法資料寫入</li><li>系統化維護資料一致性</li><li>確保應用端與資料端共享相同的邏輯</li><li>幫助查詢規劃（因為 DB 了解資料邏輯）</li></ul><p>沒有 Integrity Constraints 的系統（例如原始 Hadoop / MapReduce）<strong>完全無法保證資料的正確性</strong>。</p><p><strong>PostgreSQL 的例子：</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WrbGdeIw7eJVTl22K80Z6w.png" /></figure><p><strong>開源社群的改進：<br></strong>即便 Hadoop 生態系逐步補齊查詢、格式、索引等能力，但在 <strong>Integrity Constraints（完整性約束）</strong> 這個領域，開源世界的腳步始終非常緩慢。原因很簡單：分散式檔案系統天生不具備「拒收髒資料」的能力，而早期的 Spark 與 Hive 也完全沒有像傳統資料庫那樣的結構約束機制。</p><p>在 Hive 裡，你可以宣告 NOT NULL，但系統並不會阻擋寫入 NULL 值；那條 constraint 更像註解，而非真正 enforce 的規則。Spark SQL 的情況也類似：它會愉快地接受任何髒資料，錯誤資料被寫到 Parquet 裡也不會被擋下。底層的 HDFS 或 S3 更是完全無法阻止 bad records，因為這整個架構本質上是 schema-on-read，而非 schema-on-write。</p><p>直到 Lakehouse Era，這個領域才迎來些許突破。</p><p>最早動手的是 <strong>Delta Lake（2019）</strong>。它重新思考資料湖的寫入語意，在 ACID transaction 的架構上加入實際可執行的約束檢查。Delta Lake 能真正 enforce NOT NULL，也支援 CHECK constraint，這使得資料品質第一次在資料湖中具備“阻擋功能”而非事後補救。不過 UNIQUE 與 FOREIGN KEY 仍不在支援範圍內，需要依賴應用層自行維護。</p><p><strong>Iceberg（2018）</strong>並不提供 UNIQUE、FK、或 CHECK constraint。它選擇把這些責任留給 compute engine（Spark、Trino、Flink）自行保證，因此整體仍屬於「弱約束」。至於 <strong>Hudi（2017）</strong>，它的核心關注點是 ingestion pipeline、upsert、incremental processing 與 CDC，因此在 Integrity Constraints 上沒有進行強化，無法阻擋 bad records。</p><p>在 2025 年，大部分 Integrity Constraints 仍未在分散式表格格式上真正落地。Delta Lake 是目前唯一提供 enforce 部分 Integrity Constraints 的 Lakehouse 表格式，但距離傳統 RDBMS 的約束能力仍有巨大落差。</p><h4>Updates</h4><p>在 RDBMS 中，<strong>UPDATE</strong> 代表「對既有資料進行修改」 — — 這是一個看似基本，但對資料平台成熟度極其關鍵的能力。一個具備更新能力的資料庫必須同步支援：</p><ul><li><strong>定位目標 row（index + predicate）</strong></li><li><strong>修改欄位（write path）</strong></li><li><strong>維持一致性（transaction）</strong></li><li><strong>維護索引、constraint、trigger 等附帶結構</strong></li></ul><p>這些都需要 Transaction Manager、Lock Manager、WAL、Buffer Pool 等內核組件協同運作。沒有更新能力的系統，本質上就是 <strong>append-only log</strong>；一旦資料變大或 schema 複雜，就會陷入需要不斷重寫全表、處理資料版本混亂、無法維持一致性的窘境。</p><p><strong>PostgreSQL 的例子：</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/265/1*DCZi4M2WG6oAUjdGvwlQ6g.png" /></figure><p><strong>開源社群的改進：<br></strong>在 MapReduce 與早期 Hadoop 的世界裡，<strong>更新資料</strong>是一件完全不存在的事。整個架構建立在「不可變檔案（immutable files）」之上：資料一旦寫入 HDFS，就不能更動，唯一的方式是重新寫一份新的檔案。因此，不論是 UPDATE、DELETE、MERGE，甚至是一筆筆地 upsert，都只能靠「整批重跑、整批覆蓋」的方式模擬。這正是 MapReduce 猶如「回到 1970 年代之前」的核心理由之一。但開源社群沒有放棄。為了讓資料湖真正能像資料庫一樣被管理，整個生態系花了十多年時間，逐步補齊更新語意。</p><p>2014 年，Hive 首次嘗試改變現狀。透過 ORC、delta files 與 compaction 機制，Hive ACID 為 Hadoop 帶來了 INSERT、UPDATE 與 DELETE 的能力。然而，它仍然受到批次壓縮（compaction）週期的限制，延遲高、即時性差，更像是在 immutable 世界上硬加出的 patch，而非真正的 row-level update。</p><p>直到 2017 年，Apache Hudi 出現，才真正讓資料湖具備「可更新」的靈魂。Hudi 借鏡 LSM-tree 的理念，將更新拆成 log 與 base file 的合併：upsert 成為一等公民，record-level index 允許在資料湖上以 key 搜尋與改寫記錄，incremental pulls 讓上下游只需處理變更過的資料。這正是 CDC 與流式 ingestion 能在資料湖落地的關鍵。</p><p>2019 年，Delta Lake 進一步推動更新能力向資料庫靠攏。它用 _delta_log transaction log 讓 UPDATE、DELETE、MERGE INTO 都能以 ACID 語意執行。底層仍然是 parquet files，但透過原子性 commit 與快照管理，Delta 為 lakehouse 帶來了真正的 row-level modification。</p><p>Iceberg 原本只支援 append 與 overwrite，不打算碰更新問題。但從 2022 年開始，它重新設計 metadata layout，將 <strong>equality delete</strong> 與 <strong>position delete</strong> 納入標準，成功解構 row-level update 的兩大難題：誰該被刪？以及刪除後如何保持 metadata 可控？Iceberg 的 MERGE 與 row-level UPDATE/DELETE 是三者中最晚出現的，卻在 metadata 管理上最優雅，也最適合大規模資料表的更新與維護。</p><p>走過十多年曲折的道路，如今的 Lakehouse 生態已經徹底不同於 MapReduce 初期的樣貌。到了 2025 年，我們終於能正式地說：<strong>Update 不再是 RDBMS 的專屬能力 — — 它如今已是 Data Lakehouse 的標準配備。</strong></p><h4>Index</h4><p>在 RDBMS 裡，Index 是讓查詢「不用每次都全表掃描」的關鍵結構。它的主要作用包括：</p><ul><li>依照某個欄位（或多個欄位）建立有序或特殊結構（B-tree、Hash、GiST、GIN、BRIN、SP-GiST）</li><li>加速 點查詢（=WHERE id = …）、範圍查詢（BETWEEN / &lt; / &gt;）、關聯查詢（JOIN）、全文搜尋 / JSON / array 包含等</li><li>幫助查詢優化器挑選最便宜的執行計畫</li><li>有時也用來實作 UNIQUE / EXCLUDE 等約束的底層結構</li></ul><p>沒有 Index 的系統，面對任何條件查詢都只能「sequential scan」，在資料量來到 TB、PB 級時，效能會直接崩潰。這正是 Stonebraker 文中說 MapReduce 是「brute force only」的原因之一。</p><p><strong>PostgreSQL 的例子：<br></strong>下面這段 SQL 同時示範 PostgreSQL 的主要索引型別，包括 B-tree, Hash, GiST, GIN, BRIN, SP-GiST。實務上 PostgreSQL 還支援 expression index、partial index、multi-column index 等變化，但這些是 index 定義方式，不是新的 index method，就不再細拆。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/667/1*k9RBIu_fL6wYg-nJVoa9QQ.png" /></figure><p><strong>開源社群的改進：<br></strong>MapReduce 與 HDFS 時代並沒有任何資料庫式的索引，所有查詢本質上都是全表掃描。要找某個 user 的紀錄，仍必須把所有 HDFS block 一一讀過，這也是 Stonebraker 形容 MapReduce 為「brute force instead of indexing」的背景。早期 Hadoop 生態雖然嘗試過補強，例如 Hive Index（bitmap/compact index），但因需要額外維護同步與查詢整合不自然，最終大多專案放棄使用，仍以 partition + 全表掃為主。</p><p>真正讓「索引」進入 Data Lake 的是 columnar file format 與分散式查詢引擎。Parquet 與 ORC（2013）在每個 row group / stripe 中儲存 min/max、null count 等統計，使 Spark SQL、Impala、Presto 能透過 predicate pushdown 與 row group skipping 避免大量不必要的 I/O，形成粗粒度的 file-level / block-level 索引。</p><p>進入 Data Lakehouse 時代後，索引開始以更完整的層級現身。Delta Lake 利用 transaction log 與 data skipping stats，結合 Z-ORDER 提升多欄位 filter 的 locality；Hudi 則針對 record-level access 引入 Bloom Index、HBase Index 與 simple index，使 lake 上的 key-based lookup 成為可能；Iceberg 更是將索引拆成 metadata-based 的多層級：partition spec（含 hidden partitioning）、sort order、manifest/manifest list（記錄每個 file 的 partition、min/max、null/NaN count 等）。查詢引擎得以先掃 metadata、再決定讀哪些檔案，並在 file 與 row group 內，再使用 Parquet/ORC stats 進行 finer-grained skipping。</p><p>這些 metadata index 成為可擴展又具成本效益的關鍵技術，使 Data Lakehouse 在 2025 年能支撐大部分 OLAP 工作負載的低延遲查詢需求。</p><h4><strong>Views &amp; Materialized Views</strong></h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*213bcNi6Q5IO7kQQ-yeTSg.jpeg" /></figure><p>在傳統 RDBMS 裡，<strong>Views</strong> 是資料邏輯抽象的代表性工具。它們讓資料庫能在底層 schema 不斷演進的情況下，仍維持一個「不變、可依賴」的邏輯介面。工程師只需面對 View，看起來像是一張穩定的表，而不必知道後端是否已經把資料從一張表拆成三張、是否新增了欄位、是否重新整理了索引。這種能力正是 Codd 所定義的 logical data independence 的具體落實。</p><p>為了具體展示這個抽象力量，我們定義一張共同的例子表：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/372/1*bDq0Hdb6LljFanFuWYE-_w.png" /></figure><p>在 PostgreSQL 裡，我們可以用 View 建立穩定的邏輯介面：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/422/1*qEXwgHmq_9zf5rs6EuqGLQ.png" /></figure><p>不論底層的 schema 之後如何重構，應用程式永遠只需要查詢：</p><p>SELECT * FROM active_orders;</p><p>這就是 Views 的本質：<strong>把瞬息萬變的物理世界，以穩定不變的邏輯面向呈現。</strong></p><p>而 <strong>Materialized Views</strong> 則是另一種抽象：不是邏輯，而是效能。它將查詢結果預先算出並實體化儲存，讓之後的查詢不必再掃描所有明細紀錄。例如針對上述 orders 表，我們可以建立每日彙總的 MV：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/396/1*pGjPa__WX68YGJJvtoHVGA.png" /></figure><p>之後系統只需定期刷新：</p><p>REFRESH MATERIALIZED VIEW daily_order_stats;</p><p>原本需要掃描數億筆訂單的計算，可以瞬間縮小到只掃描幾百筆彙總紀錄。這是資料庫世界最經典的「用空間換時間」工程策略。</p><p><strong>開源社群的改進：<br></strong>反觀早期的 MapReduce / Hadoop 世界，這兩樣東西是完全不存在的：沒有 view、沒有 materialized view，只有「再寫一個 mapper / reducer」這條路。每個團隊都要自己維護大段邏輯雷同的 MapReduce 程式碼，沒有任何邏輯抽象層來共享查詢意圖，更遑論 schema 演進時的邏輯穩定介面。這正是 Stonebraker 所評論的「沒有 logical data independence」 — — 資料與程式碼緊緊綁死，任何 schema 變動都要全線改程式。</p><p>開源社群後來慢慢補起這塊。Hive 一開始就引入了 view 的概念，讓你可以在 Hive Metastore 裡定義邏輯 view，之後在 HiveQL 裡以 view 當作表來查詢，這是 Hadoop 世界裡第一個比較像 RDBMS 的「邏輯抽象」。隨著 CBO 與 cost-based rewrite 出現，Hive 之後版本也加入了 materialized view 支援，允許系統在某些情況下自動用實體化結果替代原始 query，雖然行為和商業資料庫相比還不算完整，但已經是往「查詢重寫 + 實體快取」邁進的一大步。</p><p>在 Spark SQL、Presto、Trino 世界裡，view 的角色則更偏向「儲存查詢定義」：Spark 提供 permanent view、temporary view、global temp view；Presto/Trino 也支援在 catalog/schema 裡建立 view，讓多個使用者共享同一段 query 邏輯。至於 materialized view，商業發行版和雲端服務（例如 Databricks、某些雲上 Presto/Trino 發行）開始支援自動 rewrite 到事先建立好的 aggregate table 或 materialized view；在純開源環境中，則常見用「派生表 / summary table + 排程 refresh」的方式模擬，也就是自己管理一組 mv_* Delta / Iceberg / Hudi 表，然後定期用 Spark 或 Flink 刷新。</p><p>到了 Lakehouse Era，table format 本身（Hudi、Iceberg、Delta Lake）沒有直接提供「view」或「materialized view」語法，但它們成為 view/MV 的底層存儲標準。你可以把一張 Iceberg / Delta 的 derived table 視為一種「engine-agnostic 的 materialized view」：Trino、Spark、Flink 都能讀同一張表，query engine 再透過 catalog 層（Hive Metastore、Glue、Unity Catalog、Iceberg Catalog）與 CBO 決定要不要 rewrite 到這張預算好的表。</p><p><strong>截至 2025，Iceberg / Delta / Hudi 三大表格式「尚未打算」直接提供 View / Materialized View 語法</strong>，它們暫且將該語法留給 compute engine（Spark/Trino/Flink/BigQuery）實現。</p><h4>Bulk Loader</h4><p>在傳統 RDBMS 裡，Bulk Loader 是「把一大批資料有效率塞進資料庫」的專用通道。如果沒有 Bulk Loader，你就只能：</p><p>每次 INSERT 一個 row → 每個 row append 一個 WAL log、一次索引維護、一次 constraint 檢查</p><p>在幾百條 row、幾千條 row 時還勉強可以，一旦是幾百萬、幾億條 row，這種 row-by-row insert 幾乎一定會被 I/O、日誌與索引更新拖垮。Bulk Loader 的存在，就是要在不犧牲資料一致性與可恢復性的前提下，最大化吞吐量，常見優化手段包括：</p><ul><li>批次寫入（batch write），把多條 row 一起處理</li><li>使用更輕量的 logging 模式（例如 minimal logging），降低 WAL 負擔</li><li>以「頁」或「區段」為單位建構資料與索引，而不是逐 row 更新索引</li><li>允許在載入期間暫時關閉或延後某些昂貴檢查（例如先載入、後建索引）</li></ul><p><strong>PostgreSQL 的例子：</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/511/1*J6HxuNf2puIi5SwwTeKiPw.png" /></figure><p><strong>開源社群的改進：<br></strong>在 MapReduce 時代，資料的匯入是一件格外笨重的事。HDFS 缺乏像 RDBMS 那樣的 bulk loader — — 你不能像在 PostgreSQL 裡用一個 COPY 指令就在幾秒內把百萬筆資料高效匯入資料庫；相反地，你必須手動把檔案推進 HDFS，再讓後續的 MapReduce job 自行解析。這不只是繁瑣，更意味著資料湖缺乏「統一的入口」，沒有辦法以資料庫等級的方式管理 ingestion 的一致性、錯誤邏輯與格式標準。</p><p>開源社群為這個缺陷花了十多年時間補洞。最早的 Hadoop 工具如 DistCp、Flume、Sqoop 只是逐步把資料從 RDBMS、log 或外部系統搬進 HDFS，但這些工具仍然只是「把檔案丟進來」，不負責 schema、不追蹤 metadata，也不提供 ACID 語意的 checkpoint。</p><p>Spark 的 DataFrame API 首次為大規模 ingestion 帶來「schema-first」思維。你不再只是把檔案放進資料湖，而是用 spark.read 方式明確宣告欄位結構、型別與清洗邏輯，並且可以在寫入時觸發 partitioning、sorting、compression 與檔案大小控制。</p><p>Hudi（2017）、Iceberg（2018） 與 Delta Lake（2019）首次引入「資料庫級別的 bulk load」，從此 ingest 行為正式分成兩種模式：</p><ol><li><strong>Streaming / Incremental Ingest — — 類似 RDBMS 的 INSERT<br></strong>資料像 log 一樣持續流入表格，可能每 5 秒、每一分鐘、每一千筆資料就形成一個 commit。這種模式支援小批量、高頻率的寫入，也支援 CDC、upsert、MERGE 這類更新語意。Hudi 以 record-level index 做到真正的 upsert；Delta Lake 用 _delta_log 的 version 機制管理每一次 micro-batch；Iceberg 則透過 equality delete 與 position delete，讓流式寫入也能擁有 row-level semantics。</li><li><strong>Bulk Ingest — — 類似 RDBMS 的 COPY / Bulk Loader<br></strong>一次性匯入大量資料，例如一天的 log dump、DW 匯出的歷史資料、或大型批次 ETL。這種模式強調 throughput，而不是細粒度更新：一次批次寫入就形成一次 snapshot（Iceberg）／version（Delta）／commit（Hudi），行為與傳統 RDBMS 的 bulk load 十分相似。Hudi 的 bulk_insert 模式甚至刻意不建立索引，以換取最高的吞吐量；Iceberg 與 Delta Lake 則透過 metadata 層優化大檔案寫入與檔案併合。</li></ol><h4>2) RDBMS 組件相關</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*P93ocFAxSWVeEluQXsEaVQ.png" /></figure><h4>Catalog</h4><p>在傳統 RDBMS 裡，Catalog（又稱 system catalog 或 data dictionary）是一切資料管理能力的根基。無論資料實際存放在什麼資料檔、經歷過多少次 vacuum、index rebuild 或 schema 修改，Catalog 都負責唯一且權威地描述：</p><ul><li>這些 table 叫什麼名字</li><li>每張 table 的 columns 與 type</li><li>哪些 index、constraints、triggers、views 存在</li><li>權限、擁有者、統計資訊（statistics）</li><li>甚至 optimizer 執行計畫要依賴的 metadata</li></ul><p>換句話說，<strong>Catalog 是資料庫用來理解自己世界的「中央神經系統」</strong>。使用者可以完全不需要知道資料存在哪個檔案、是否被分割、複製或重寫，只要能 query Catalog，整個資料庫的狀態便一目了然。RDBMS 能提供 ACID、transactional DDL、index、optimizer、view/mview，都仰賴 Catalog 才能成立。</p><p><strong>PostgreSQL 的例子：<br></strong>PostgreSQL 的 Catalog 分散在一系列以 pg_ 為前綴的系統表中，每個表負責管理不同層次的 metadata：</p><ul><li><strong>pg_class</strong>：記錄所有 tables、indexes、views</li><li><strong>pg_attribute</strong>：記錄每一個欄位（column attributes）</li><li><strong>pg_type</strong>：記錄資料型別</li><li><strong>pg_constraint</strong>：記錄 PK、FK、CHECK、UNIQUE 等 constraints</li><li><strong>pg_index</strong>：記錄 index 結構</li><li><strong>pg_namespace</strong>：schema / namespace 資訊</li><li><strong>pg_statistic</strong>：optimizer 用來做 CBO（cost-based optimization）的統計</li><li><strong>pg_depend</strong>：追蹤物件間依賴關係</li></ul><p>例如以下指令，可以查到一張表在 PostgreSQL 內部的一切結構性資訊：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dvgrgbo0DLdysECEe0jM_Q.png" /></figure><p>PostgreSQL 的 Catalog 具備強一致性（transactional metadata）。DDL 語句會直接觸發 Catalog 中的狀態變動，而這些變動與一般資料操作一樣，受到同一個 transaction 的 ACID 保障。也正因如此，傳統資料庫才能提供真正「原子性」的 ALTER TABLE。</p><p><strong>開源社群的改進：<br></strong>在早期的 Hadoop 與 Data Lake 時代，系統並不存在一個真正意義上的「Catalog」。HDFS 只是檔案系統，Hive Metastore 雖然引入了資料表的概念，但本質上仍只是記錄路徑與欄位資訊的外部資料字典，缺乏強制 schema、缺乏演進機制、沒有 transaction、也無法追蹤歷史版本。甚至不同引擎之間（Hive、Spark、Presto、Impala）常因 Metastore metadata 不一致而產生正規化衝突，導致 schema 同步混亂，形成了所謂的 “schema-on-read fragmentation”。也因此，在 Data Lake Era 裡，「catalog」並不是一個具備一致語意的系統元件，而是分散在各家引擎中的「半產品」。</p><p>真正的突破出現在 Data Lakehouse Era。從 2017 年開始，Hudi、Iceberg、Delta Lake 這三大 table formats 同時意識到：要讓 Lakehouse 成為可靠得 OLAP 平台，唯有將 catalog 從「附屬品」拔升為「第一公民（first-class citizen）」。於是它們開始採用 table-level metadata + snapshot + schema evolution + commit log 的方式，讓 catalog 成為 ACID、time travel、partition spec、sort order、file lists、schema versioning 的統一入口。此時的 metadata 不再是 Hive Metastore 裡的一張薄表，而是完整的、多層級的 table description structure。換句話說，Metadata 構成了 Lakehouse 的「作業系統」。</p><p>Hudi 採取的是「內建 metadata table」的路線 — — 所有 file list、timeline、commit、rollback、索引等資訊，都存放在 Hudi table 自身的 metadata 層內，而非外部系統。這讓 Hudi 可以獨立於 Hive Metastore 運行，對 streaming ingest、incremental pull、log-structured table 特別友好。</p><p>Iceberg 則重新定義了 metadata 的標準，提出了 manifest、manifest list、partition spec、sorting spec、table metadata.json 等結構，使得 metadata 成為「不需要 Hive 也能完整描述 table 的自包含系統」。它最重要的貢獻在於「Catalog 抽象層」：Iceberg 可以掛在 Hive Metastore、AWS Glue、Nessie、原生 JDBC Catalog（JDBC-backed catalog）、甚至 Spark 自己的 catalog 之上，形成真正意義上的「pluggable metastore」。這也讓後續一整個 catalog 生態系得以爆發。</p><p>Delta Lake 的 catalog 則更往 SQL 方向前進。Databricks 引入了 Unity Catalog，正式將治理（governance）、permission、schema、lineage、table format metadata 整合成單一 API，讓 Lakehouse 上的所有 table（不論是 Delta / Iceberg / Hudi）都能以統一規則被管理。這是 Data Lakehouse 史上第一次將「metadata 管理」視為企業級產品，而非引擎附帶功能。</p><p>現代 Lakehouse 架構已經全面接受「Catalog 即資料庫的系統表（system catalog tables）」這個觀念。無論是 Iceberg、Delta、Hudi，或 Spark、Trino、Databricks、DuckDB 等 compute engine，都提供 SQL 介面查詢 catalog 與 metadata。這使得 Lakehouse 的 metadata layer 從「輔助工具」提升到「資料管理的第一公民」。</p><h4><strong>WAL（Write-Ahead Log）/ Log Manager</strong></h4><p>在任何成熟的 RDBMS 中，<strong>WAL（Write-Ahead Log）是一條生命線</strong>。它的核心原則是簡單卻強大 — — 在資料真正寫入 data files 之前，<strong>所有修改都必須先寫入 log</strong>。這使資料庫能在系統崩潰時「依照日誌復原」，保證資料的持久性（Durability）與一致性（Consistency）。換言之，WAL 是資料庫世界的黑盒子：即便整個機器瞬間熄火，重啟後仍能找回最後一刻的狀態。</p><p>在 <strong>PostgreSQL</strong> 中，WAL 的角色更是中央神經系統般的重要。所有修改都會先寫入 WAL 檔案，其後進行：</p><ul><li><strong>crash recovery（崩潰復原）</strong>：重播 WAL，使資料回到一致狀態</li><li><strong>replication（複寫）</strong>：利用 WAL streaming 將更動同步到備援節點</li><li><strong>point-in-time recovery（PITR）</strong>：透過 WAL 進行時間點還原</li></ul><p>這種「先寫日誌、後寫資料」的模式，使 PostgreSQL 天然具備強一致性（strong consistency）與高可靠度。</p><p><strong>PostgreSQL 的例子：<br></strong>以下是跟 WAL 相關的目錄/工具一覽表</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UB0gpunKM9qkfR8tboE_tg.png" /></figure><p><strong>開源社群的改進：<br></strong>當我們把視角從 PostgreSQL 轉向 Hadoop 與早期 Data Lake，不難發現一個巨大的落差：整個 MapReduce + HDFS 生態在設計之初，根本沒有思考過「需要一個 Logs 層來保證資料庫式的可靠性」。HDFS 是 append-only 的檔案系統，MapReduce 是 batch job 執行框架，所有錯誤恢復都依賴「把整個 job 重跑」 — — 而不是使用 redo / undo log 恢復中斷的寫入。這種做法雖然簡單，但在現代資料處理需求下顯得極其粗糙。</p><p>真正的轉折要等到 Lakehouse Table Formats 出現後才發生。從 2017 年開始，開源社群第一次認真地替 Data Lake 建立「日誌層」，試圖把 HDFS/S3 這種弱語意的儲存系統包裝成具備資料庫級別語意的表格。</p><p>Hudi 是第一個將 WAL 思維引入 Data Lake 的系統。它的 timeline（commit、delta commit、clean、compaction 等事件）實際上就是一種 Lakehouse 版的 write-ahead log；每一次 upsert 都先寫入 log file，再由 compaction 將 log 與 base file 合併。這讓 Data Lake 首次具備類似「redo log + background merge」的語意，也讓 incremental pulls 成為可能，等同讓 object storage 具備 CDC（Change Data Capture）的能力。</p><p>Iceberg 採取的是另一種方向。它不直接模仿 WAL，而是把「所有 metadata 的變化」寫成一串不可變的 manifest log。每一次 commit 都產生新的 metadata snapshot，所有表格操作（schema evolution、partition spec 變更、位置刪除、相等刪除）都被記錄在連續的 metadata log 中。這種模式本質上更接近「MVCC + snapshot log」，等同把「查詢」和「寫入」明確分離，使 Data Lake 能以 ACID 的方式並行處理大量讀寫。</p><p>Delta Lake 則採取更貼近資料庫的方式。它的 _delta_log 就像 Lakehouse 的 WAL，每個 commit 都是一份 JSON 或 Parquet 格式的 commit log，描述這次操作新增或刪除了哪些檔案。與 Hudi 和 Iceberg 不同，Delta Lake 的 transaction log 完全以序列化事件的方式呈現，更容易對接 Spark、Databricks runtime 與其他 streaming 引擎。當查詢引擎載入 Delta 表時，實際上是「replay」這些 log，構建出當前表格狀態，與 PostgreSQL 在重啟時重播 WAL 的概念極為相似。</p><p>儘管三者的架構各有取捨 — — Hudi 偏向 ingestion、Iceberg 偏向 metadata correctness、Delta 偏向 compute 統合 — — 但它們共同完成了一件事：在 object storage 上補上了 RDBMS 最核心的可靠性基石。2025 年的現在，我們終於可以說：Lakehouse 也有了屬於自己的 WAL。</p><h4>Buffer Pool / Buffer Manager</h4><p>在傳統 RDBMS 裡，Buffer Pool（或 Buffer Manager）扮演著資料庫的「短期記憶」。所有即將被查詢、剛被讀取、或準備被更新的資料頁（data pages）都會先載入 Buffer Pool。這讓資料庫能以記憶體速度回應查詢，而不需要每次都從磁碟讀取。Buffer Manager 的核心能力包含：</p><ul><li><strong>快取熱資料頁</strong>（降低 disk I/O）</li><li><strong>管理 dirty pages</strong>（尚未寫回磁碟的修改）</li><li><strong>搭配 WAL 保證 durability</strong></li><li><strong>負責 LRU / clock-sweep / eviction policy</strong></li><li><strong>維持 page 對齊、pin/unpin、page-level concurrency</strong></li></ul><p>如果把 RDBMS 比喻成人的大腦：</p><ul><li><strong>Buffer Pool = 短期記憶（working memory）</strong></li><li><strong>WAL + data files = 長期記憶（permanent storage）</strong></li></ul><p>這種短期記憶模型是 RDBMS 高效的基礎。</p><p><strong>PostgreSQL 的例子：<br></strong>PostgreSQL 透過 shared_buffers 參數定義 Buffer Pool 大小，所有資料頁都會存在 shared buffer 之中。當查詢讀取資料時：</p><ol><li>PostgreSQL 先檢查 Buffer Pool 是否已有該 page。</li><li>若有 → 直接從記憶體讀取。</li><li>若無 → 從 data files 載入到 Buffer Pool。</li><li>修改資料 → 標記為 dirty page，WAL 先寫盤，再由背景 writer 逐步刷回資料檔案。</li></ol><p>你可以用下面的 SQL 來查出哪些資料頁正在 Buffer Pool 中：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HqubnyMY7oFmBRKeGAXGuQ.png" /></figure><p><strong>開源社群的改進：<br></strong>在 Hadoop 與早期 Data Lake 的世界裡，幾乎不存在與資料庫等級 Buffer Manager 相對應的概念。HDFS 的核心理念是：「儲存由檔案負責，計算交給外部引擎。」因此整個系統不具備資料庫慣用的 page cache、eviction policy，也沒有 working set 的概念。所謂 working set，指的是一組查詢在短時間內反覆存取的熱資料，在 RDBMS 中，這些資料會被留在 buffer pool 裡，使後續查詢能直接從記憶體命中。但在 MapReduce 與 Hive 的時代，每一次 job 的啟動，都像重新開了一台機器：所有 I/O 再度從最底層的檔案開始掃描，毫無重用性可言。這使得查詢延遲始終居高不下，也讓整個生態被迫依賴「靠加機器堆性能」的途徑前進。</p><p>真正的轉捩點來自 Spark。Spark 引入 in-memory RDD 與 DataFrame cache，使 Big Data 世界首次具備近似資料庫 buffer pool 的能力：熱資料可以駐留在記憶體、多段查詢可以共享 cache、shuffle 結果能跨階段重用。雖然 Spark 的記憶體管理不是 page-based，也沒有像 PostgreSQL 那樣精細的 working set 控制，但效能的躍進已足以讓整個產業開始重新思考：「分散式查詢是否也應該擁有自己的 buffer 層？」</p><p>隨著 Lakehouse 架構興起，Iceberg、Delta Lake 與 Hudi 將 metadata 層與查詢引擎融合，使 cache 的概念逐漸細緻化：不再只是 cache 整份資料，而是 cache file、row group、column chunk，甚至是 manifest-level metadata。Presto、Trino、Spark SQL、DuckDB 也紛紛引入 file-level cache、columnar cache 以及 locality-aware scan，讓查詢引擎能主動避開不必要的 I/O。這些能力雖然尚未等同於傳統 RDBMS 的 buffer pool，但已展現同樣的精神：<strong>把熱門資料留在越靠近 CPU 的位置，讓反覆查詢的成本逐漸接近記憶體速度。</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/493/1*uBB_zJfnbFCj_sqIwhSu1Q.jpeg" /></figure><p>值得特別關注的是 Alluxio。在 compute–storage 分離的雲端環境裡，locality 徹底消失：Spark 與 Trino 在 Kubernetes 上跑，而資料卻遠在 S3 或 GCS。Alluxio 的任務，就是把 locality 找回來。它站在 object storage 與引擎之間，提供一個分散式、分層式的記憶體快取層，使 Spark、Presto、Hive、甚至 ML pipeline 都能以接近記憶體的速度讀取資料。某種意義上，Alluxio 是第一個成功將「buffer pool」擴展到整個集群層級的系統，為 Lakehouse 帶來真正跨 workload 的熱資料重用能力。</p><p>回顧整段演進，可以看到一條非常清楚的軌跡：Buffer Pool 不再是 RDBMS 的專利，而是以不同形式滲入 Lakehouse 架構的每一層<strong>，</strong>它將越來越像一個「記憶體先行」的系統，並逐步逼近 <strong>working set</strong> 效能模型。（working set 指的是在某一段時間內，被系統反覆存取、對效能最關鍵的那一小部分資料集合）</p><h4>Storage Engine</h4><p>在傳統 RDBMS 裡，Storage Engine 是系統的「物理核心」，負責資料頁面（pages）的布局、磁碟組織方式、record 格式、tuple header、壓縮策略、可變長度欄位管理、索引結構、惰性清除（vacuum/GC）、同時也承擔跨 WAL 與 data files 的一致性保證。換言之，它負責所有「資料如何真正寫進磁碟並再被取回」的細節。</p><p>更重要的是，Storage Engine 提供了 資料庫語意（database semantics） — — 例如：</p><ul><li>row-level CRUD</li><li>clustering index</li><li>MVCC 可見性</li><li>vacuum / compaction</li><li>tuple 與 page 的生命週期</li><li>buffer pool 與 WAL 之間的交互</li></ul><p>這些能力讓開發者無須關心磁碟格式與檔案組織，只需用 SQL 就能取得正確資料。</p><p><strong>PostgreSQL 的例子：<br></strong>PostgreSQL 具備成熟的儲存引擎架構，其核心包含：</p><ul><li>Heap Table：PostgreSQL 預設的資料表格式，以頁（page）為單位儲存 tuples。支援 MVCC，因此更新會產生新版本，舊版本透過 vacuum 清理。</li><li>TOAST（The Oversized-Attribute Storage Technique）：用於儲存大欄位（如 JSON、TEXT、BYTEA），自動 chunking、壓縮、外部儲存。使用者完全無須干預，資料庫自動管理大物件生命週期。</li><li>索引與儲存層整合：B-tree / Hash / GiST / SP-GiST / GIN / BRIN 都與 heap table 整合，使查詢可直接定位 tuple。</li><li>Table Access Methods (Table AM, PG12+)：讓 PostgreSQL 能使用替代儲存引擎，例如 <strong>ZHeap</strong>（支援更好的更新語意）。（90% 以上的使用者仍使用預設引擎 Heap AM，替代引擎（如 ZHeap）不是正式產品，也不會像 MySQL 那樣自由切換）</li></ul><p>這些組件構成 PostgreSQL 之所以能提供 ACID、MVCC、長期一致性與高效查詢的基礎。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/768/1*OyaehI9SDsLS3jPHIZQNww.png" /></figure><p><strong>MySQL 的例子：<br></strong>在 MySQL 的世界裡，Storage Engine 決定了一個資料表在底層以何種結構儲存、如何讀寫、如何維持一致性、是否能承受交易量，以及能否在意外發生時安全恢復。不同於 Oracle 採用單一儲存引擎的架構，MySQL 的 SQL layer 可以搭配多種 Storage Engine，而這個彈性也讓它成為理解「什麼叫做儲存引擎語意」最好的觀察樣本。</p><p>在 MySQL 的早期年代，站在主舞台中央的是 <strong>MyISAM</strong>。這個引擎以極簡與高速著稱：資料與索引分別儲存在 .MYD 與 .MYI 檔案中，格式輕量，啟動迅速，也因沒有 transaction、沒有 MVCC、沒有 foreign key checks，而在讀多寫少的工作負載下展現出驚人的存取速度。許多早期的網站與論壇引擎，都靠著 MyISAM 的「快，但沒有太多保證」存活了下來。然而，這個引擎最大的弱點也同樣明顯──沒有任何 crash recovery 機制。一旦伺服器意外關機或檔案損毀，MyISAM 可能直接壞表，成為資料庫工程師永遠記得的夢魘。它像一台跑得很快、沒有安全氣囊的跑車：適合某些特定場景，但絕不是日常依賴的主力引擎。</p><p>真正重新定義 MySQL 的，是後來成為預設引擎的 <strong>InnoDB</strong>。它帶來了完整的 ACID transactions、MVCC、row-level locking、foreign key constraints，以及支援 crash recovery 的 redo/undo log 機制，讓 MySQL 首次具備企業級資料庫該有的韌性。InnoDB 採用 clustered index 的儲存結構，將資料依照主鍵直接儲存在 B+tree 的 leaf node 裡，使主鍵查詢格外快速；同時，它的 buffer pool 也支援 page cache、dirty page flushing 與 eviction policy，使 InnoDB 能在高併發讀寫下仍保持穩定。若說 MyISAM 是輕巧的跑車，那麼 InnoDB 就是標準的商用級房車：安全、耐用、功能完善，是今日幾乎所有 MySQL 生產系統的基石。</p><p>然而，單靠 B+tree 的寫入模式在 SSD 時代開始顯露瓶頸。當寫入量以數百萬、數千萬筆事件計算時，隨機寫入的成本遠超過一般系統能承受。為了解決這個難題，Meta（Facebook）開發了 <strong>MyRocks</strong> ──一個以 RocksDB（LSM-tree）為核心的 MySQL Storage Engine。與 InnoDB 的 page-based 隨機寫入相比，LSM-tree 將所有寫入先放在記憶體（memtable），再批次以排序後的方式 flush 成 immutable SSTable，這讓寫入過程幾乎變成純追加（append），非常適合寫密集（write-heavy）的場景，例如事件紀錄、使用者 activity log、廣告系統 metadata 等。更重要的是，MyRocks 的壓縮率遠高於 InnoDB，往往能將 SSD 成本降低一半以上，在大規模部署下節省驚人的硬體費用。它仍然保留 MySQL SQL layer 的 transaction 與 replication 能力，彷彿在傳統 RDBMS 的身體裡植入了 HBase / Cassandra 的靈魂。</p><p>總結來看，MyISAM、InnoDB 與 MyRocks 代表了三種截然不同的儲存哲學：MyISAM 追求速度與輕量；InnoDB 追求完整的交易語意與可靠性；MyRocks 則在大規模、高寫入、SSD 成本敏感的場景下展現現代 LSM-tree 的優勢。這三者也恰恰是 OLTP Storage Engine 演化史的完美縮影。</p><p><strong>Clickhouse 的例子：<br></strong>相較於 PostgreSQL 與 MySQL 的儲存引擎演進路線，ClickHouse 採用了另一種完全不同的思維：將儲存引擎視為查詢模型的一部分，而非資料庫內部的可替換模組。對 ClickHouse 而言，選擇儲存引擎不是「用哪種 backend 儲存資料」，而是「指定這張表應該如何被排序、如何被併檔、如何被 TTL 處理、如何被即時查詢」。</p><p>這種理念最具代表性的體現，就是 ClickHouse 家族中最重要的儲存引擎──MergeTree。MergeTree 並不是某一個引擎，而是一整個「引擎族」，它們共同遵循 LSM-like 的合併邏輯（將小檔合併成大檔）、排序鍵（primary key）帶來的 locality、分區（partition）帶來的 time-based 管理能力，並在不同工作負載下延伸出不同的專用變體。不同的 MergeTree 變體，為不同類型的分析場景，提供不同的資料生命週期管理策略。</p><p>在一般情況下，使用者會以 MergeTree 或 ReplacingMergeTree 作為 OLAP 工作負載的主力。前者提供高效的 append-only 分析能力，而後者可在合併過程中保留最新版本的資料，適合需要覆寫語意（如 CDC）但又不需 OLTP 等級即時一致性的場景。若需要支援去重邏輯，則會使用 SummingMergeTree 或 AggregatingMergeTree，讓聚合運算在背景合併中自動完成，事實上形成一種「持久化的物化聚合」。更進一步，如果是時序資料分析，可使用 GraphiteMergeTree 或 TinyLog + MergeTree，以適應高頻、連續寫入且讀多於寫的模式。</p><p>有趣的是，ClickHouse 的儲存引擎選擇並非像 MySQL 一樣抽換整個 storage engine，而是透過「為每張表指定一種 MergeTree 變體」的方式，直接把資料存取邏輯寫入表的定義中。換言之，選擇 MergeTree 的哪一個成員，實際上就是選擇「這張表的生命週期、排序策略以及併檔規則」。</p><p><strong>開源社群的改進：<br></strong>在 Hadoop 與早期 Data Lake 的世界裡，所謂的 “storage engine” 幾乎不存在，或更準確地說，它僅止於「檔案格式 + 檔案系統」的組合。HDFS 可以儲存 PB 等級的資料，但它從未被設計成一個能理解 row、column、transaction、index 或更新語意的儲存層。對 MapReduce、Hive、Pig 那個年代的使用者而言，資料只是一堆躺在 object storage 或 HDFS 上的 不可變檔案，操作方式永遠是 append 與 rewrite，而非資料庫語意下的「更新」、「刪除」或「合併」。與 PostgreSQL、MySQL、Oracle 這些真正的儲存引擎相比，Hadoop 時代缺乏 row-level metadata、缺乏 buffer pool、缺乏 transaction manager，也缺乏任何形式的 storage-level index。Stonebraker 評論 MapReduce 是「brute force computing」並非危言聳聽；它本質上不是 storage engine，而只是大型文件處理框架。</p><p>真正的突破始於 Parquet（2013）。這個 columnar 格式第一次讓存放在 Data Lake 裡的檔案具有「結構」與「欄位統計 metadata」：row group、column chunk、min/max、null count 等資訊使查詢引擎得以跳過大量不相關的資料。這是一個關鍵轉折，因為它為後續的「Lakehouse 儲存引擎」奠定了最基本的資料組織單位。然而 Parquet 本身仍然是一種格式，而不是 storage engine — — 它沒有 transaction log、沒有 ACID、沒有更新語意、沒有 table abstraction。</p><p>2017 之後，真正意義上的「開源儲存引擎」才正式出現在 Data Lake 世界。Hudi（2017）、Iceberg（2018）與 Delta Lake（2019）三者共同把 object storage 推向資料庫等級的儲存層，並以各自的方式建立了屬於 Lakehouse 的 storage engine。</p><p>Hudi 引入了 log-structured 儲存模型，在 HDFS / S3 上模擬類似 LSM-tree 的更新語意，使 upsert、incremental pull、record-level index 成為可能。這是 Data Lake 世界第一次能做近似 OLTP 的 row-level 更新。</p><p>Delta Lake 以 transaction log（_delta_log）為核心，提供 MERGE、UPDATE、DELETE，並讓儲存在 Parquet 裡的 immutable file 世界終於能擁有「一致性的表格語意」。Delta 的儲存引擎是基於可重放的 log + metadata checkpoints，讓 object storage 行為與傳統資料庫愈加接近。</p><p>Iceberg 則採取了高度結構化的 metadata 設計，把 storage engine 拆成幾個層次：manifest、manifest list、partition spec、sort order、delete files（equality/position delete）。這種分層 metadata 模型為查詢引擎提供細緻的 pruning 能力，使儲存引擎不僅能更新，更能管理百萬級檔案並維持一致性。</p><p>這些 table formats 的出現，讓 Data Lake 不再只是 file system，而是具備「資料庫式儲存語意」的引擎：ACID、index-like skipping、schema evolution、compaction、transaction log、delete semantics、table abstraction、time travel、metadata pruning。到了 2025 年，Lakehouse 儲存層逐漸具備了與 RDBMS storage engine 可相比的完整度，只是實作方式更適合雲端、分散式、與 object storage 的特性。</p><h4>Transaction</h4><p>Lakehouse 本質上是一個以 OLAP 為核心的分布式資料系統，因此在討論 Transaction 時，更合適的參照對象並非單機資料庫（如 PostgreSQL 或 DuckDB），而是具備完整分布式語意的資料庫系統，例如 Spanner、CockroachDB 與 TiDB。</p><p>讓我們從一個思想實驗談起：想像有一個足夠 <strong>Big</strong> 的燒杯，裡面裝著 pH = 7 的 <strong>Water</strong>。現在，我們把一台 RDBMS 溶進去後量測 pH 值，讀數會是多少？</p><p>只要談到 RDBMS，就無法不談 Transaction；而一旦談到 Transaction，ACID 便隨之而來。這正是讓 <strong>Big Water</strong> 測出 pH &lt; 7 的根本原因。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*naPwm_OA23m0YNOjaHOKFQ.png" /><figcaption>讓我們用銀行帳戶轉帳的例子，來了解 transaction 的本質。首先，先初始化資料。</figcaption></figure><p>其中，<strong>A </strong>與<strong> D</strong> 相對容易理解，這兩個字母保證的是單一 transaction 的正確性。</p><p><strong>A</strong>tomicity：一個 transaction 中的所有操作必須全部完成，或全部不完成，不允許停留在任何部分完成的中間狀態。若在執行過程中發生錯誤，系統必須將資料狀態回滾（rollback）至 transaction 開始之前，使其效果如同該 transaction 從未執行過一樣。因此，transaction 被視為不可分割的最小執行單位。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xU8DGVE3-fDU8E8S93mjnw.png" /><figcaption>在 session #1，有一個 transaction 要從 account_id#1 匯款 $10 給 account_id#2。</figcaption></figure><p>在分散式環境中，Atomicity 的挑戰會被放大。假設 account_id#1 要匯款 $10 給 account_id#2 ，但兩個 account 分別位於不同的節點上：account_id#1 的扣款由 Node#1 負責，account_id#2 的入帳由 Node#2 負責。此時，一個單一的轉帳 transaction，實際上跨越了兩個獨立的儲存節點。</p><p>為了確保「要不是兩邊都成功，就是兩邊都不生效」，分散式資料庫通常會引入 <strong>Two-Phase Commit（2PC）</strong>協議。transaction coordinator 會先進入 <em>prepare phase</em>，要求 Node#1 與 Node#2 各自檢查本地操作是否可以提交，並將變更暫存在可回滾的狀態中；只有在所有參與節點都回覆「準備完成」後，才會進入 <em>commit phase</em>，正式通知所有節點提交變更。若任一節點在準備階段失敗，整個 transaction 便會被中止，所有節點一致回滾。</p><p>因此，即使一個 transaction 橫跨多個節點，2PC 仍能保證其 Atomicity。</p><p><strong>D</strong>urability：一旦 transaction 成功提交，對資料所做的修改就必須被永久保存。即便隨後發生系統當機、電源中斷或程序重啟，這些變更也不能遺失。</p><p>相對地，<strong>C</strong> 與 <strong>I</strong> 所保證的，則是多個 transactions 之間的整體正確性。這兩個字母所涵蓋的意涵明顯更為複雜，極易產生混淆。在撰寫本章的過程中，我反覆對照相關文獻，才逐步梳理出一條較為完整且一致的論述脈絡。</p><p><strong>C</strong>onsistency 這個詞，在資料系統世界裡是一個極容易引起誤解的概念，因為它在「分散式系統」與「資料庫」這兩個領域中，指涉的其實並不是同一件事。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hm6qqYdsVip_fr1-j-aGiQ.png" /><figcaption>CAP theorem</figcaption></figure><p>在<strong>分散式系統的語境</strong>中，Consistency 關注的是：當同一份邏輯資料同時存在於多個實體副本時，系統在面對讀寫操作時，所呈現出的可觀測行為。這正是 CAP 理論中對一致性的核心定義 — — 讀到的是不是最新的值？不同節點所觀察到的結果是否一致？本質上，它討論的是多個副本之間如何對齊。為了避免混淆，本文將此一脈絡明確稱為 <strong>Data Consistency</strong>，其代表性的模型與級別包括 Linearizable、Sequential，以及 Causal 等。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aspeoU9XO2VemCS8LkNgvg.png" /><figcaption>當 session #1 和 session #2 同時發生時，若缺乏適當的 transaction 的隔離，對 account_id#1 與 account_id#2 的資料存取將產生 <strong>類似 race condition 的並發問題</strong>，使帳戶餘額的最終狀態依賴於交易交錯的時序。</figcaption></figure><p>而<strong>在資料庫的語境</strong>中，Consistency 則與 Transaction 緊密綁定，討論的是：當多個 transactions 同時存在時，系統如何避免它們彼此干擾，並正確協調其執行結果。例如，在 account_id#1 轉帳 $10 給 account_id#2 的 transaction 尚未完成時，系統中可能同時出現另一筆 account_id#1 轉帳 $5 元給 account_id#2 的 transaction。這兩筆 transactions 各自都包含對同一組帳戶資料的讀取與更新，如果缺乏適當的隔離機制，可能導致其中一筆交易讀到對方尚未提交的中間狀態，或因更新順序交錯而產生非預期的最終結果，使帳戶餘額違反原本應成立的商業不變量。</p><p>因此，這種 Consistency 也可稱為 <strong>Transactional Consistency</strong>，亦即 ACID 中的 <strong>I（Isolation）</strong>。透過定義不同 transactions 之間可以「看到」彼此結果的程度，資料庫系統衍生出一系列 <strong>Transactional Consistency</strong> 級別（亦即 <strong>Isolation Levels</strong>），例如 Read Uncommitted、Read Committed、Repeatable Read 與 Serializable。</p><p>為了避免在後續討論中混用語言、混淆層次，本文將嚴格區分這兩種一致性：Data Consistency 用來描述分散式副本的可觀測行為；Transactional Consistency 則專指 transaction 之間的隔離與正確性問題。<strong>（以下我刻意不使用 Isolation 這個字，但請注意：Isolation 和 Transactional Consistency 是等義的。）</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Hyao_KV8LikcP0ETDhlxiQ.png" /><figcaption>Consistency Models in JEPSEN</figcaption></figure><p>當我們將這張圖放在眼前，可以清楚看到兩條原本來自不同研究脈絡的一致性體系 — — 資料一致性（Data Consistency）與事務一致性（Transactional Consistency） — — 在分布式資料庫中被同時整合。這種「雙一致性視角」正是評估一個分布式資料庫成熟度的黃金標準。</p><p>先看左側。這一整條階梯，描述的是<strong>多個 transaction 同時存在時，系統能保證到什麼程度的隔離與正確性</strong>。最底層是 <strong>Read Uncommitted</strong>，幾乎沒有任何隔離可言；往上到 <strong>Read Committed</strong>，避免讀到未提交的資料；再到 <strong>Repeatable Read</strong> 與 <strong>Snapshot Isolation</strong>，開始引入 snapshot 與版本概念，確保同一個 transaction 內看到的世界是穩定的；再往上，來到經典的 <strong>Serializable</strong> — — 所有 transaction 的結果，必須等價於某個「依序執行」的結果。</p><p>Serializable 這個性質帶來的好處，是讓應用程式可以用單執行緒、單交易的直覺來思考正確性：只要每個 transaction 在單獨執行時是正確的，那麼在併發環境下，系統也能保證整體結果不會出現 write skew、不可預期的交錯效果。因此，Serializable 被視為 Transaction Consistency 的頂點。</p><p>再把視線移到右半邊，討論的是<strong>副本之間的可觀測行為</strong>。最底層，是一些 session-level 的保證：<strong>Read Your Writes、Monotonic Reads、Monotonic Writes、Writes Follow Reads</strong>，這些模型關注的是「同一個 client 自己看到的世界是否合理」，而不是系統的全序。往上，是 <strong>PRAM</strong>、<strong>Causal Consistency</strong>，開始要求具備因果關係的操作必須被正確觀察；再往上，來到 <strong>Sequential Consistency</strong>；而接近頂端的 <strong>Linearizable</strong>，則是分散式系統中最強的一致性模型之一 — — <strong>所有操作必須看起來像是在某個全域時間軸上即時生效</strong>。</p><p>Linearizable 這個性質帶來的好處，是讓分散式系統能呈現出與單機系統幾乎一致的語意 — — 一旦寫入完成，任何後續的讀取都必須看到最新結果，使得使用者與應用程式可以不必理解副本、延遲與同步細節。因此，Linearizable 被視為 Data Consistency 的頂點。</p><p>現在，令人熱血沸騰的時刻來了。</p><p>從這兩個頂點各自搭起天梯，交會處就是 <strong>Strict Serializable，</strong>站在此處能同時俯瞰兩個世界。</p><p><strong>Strict Serializable = Serializable + Linearizable</strong></p><p>這正是為什麼它被視為「理論與工程上都極端困難」的聖杯，目前世界上只有一款分布式資料庫達到這種等級。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/976/1*naY9vT5L-H9wpi_xCMuF6w.png" /></figure><p>Spanner 做了關鍵兩件事：</p><ul><li><strong>Serializable Snapshot Isolation（SSI）:</strong>在交易層面，確保 transaction 之間不會產生 write skew，達到真正的 <strong>Serializable</strong>。</li><li><strong>TrueTime API: </strong>它引入物理時間 + 誤差界線，讓系統能在分散式環境中實作 <strong>Linearizable</strong> 的資料一致性。</li></ul><p>因此，Spanner 是唯一被業界所公認，在實務上<strong>讓 Transactional Consistency 與 Data Consistency 在最高等級交會的系統</strong>。</p><p>有趣的是，屬於 Wide-Column Store 的 Cassandra 在 5.0 迎來重要變革，藉由 Accord 協議實現出 ACID transaction，目標是達到 Strict Serializable。讓我們拭目以待。</p><p><strong>開源社群的改進：<br></strong>當我仔細梳理 Lakehouse 的 transaction 演進過程之際，一個長期被忽略卻極為關鍵的事實逐漸浮現！</p><p>從 Data Lake Era 邁向 Data Lakehouse Era，「支援 ACID transaction」經常被視為兩者之間最重要的分水嶺之一。然而，若將 Lakehouse 的 ACID 與 RDBMS 的 ACID 進行嚴格對照，就會發現，兩者在 transaction 的表現能力上，實屬天壤之別。</p><p>以 PostgreSQL、Oracle 等傳統資料庫為例，transaction 自設計之初便以 <strong>multi-statement transaction</strong> 為核心假設。一個 transaction block 可以包含多條 SQL statements，這些 statements 共享同一個隔離邊界，並在提交時以整體為單位決定是否生效（不久前，session #1 中的 transaction block 便是 multi-SQL-statements）。正是在這樣的模型下，ACID 的四個性質 — — 尤其是 Transactional Consistency（即 ACID 中的 Isolation） — — 才能成為資料庫用來避免併發錯誤與邏輯破壞的實質保證。</p><p>相對地，Lakehouse 世界中的早期實作，例如 Delta Lake 與 Apache Iceberg，其 transaction 語意最初主要圍繞在 <strong>single-statement atomic commit</strong> 上，而非由多條 SQL statements 所構成的 transaction block。換言之，每一次寫入操作（如 INSERT、MERGE、DELETE）本身具備原子性，但系統並未對跨 statements 的讀寫衝突提供完整的隔離語意。Read Committed、Snapshot Isolation、乃至 Serializable 等 Transactional Consistency levels，在這樣的模型下都缺乏成立的前提，因為它們本質上假設的是 transaction block，而非單一 statement。</p><p>Lakehouse 最初所宣稱的「ACID」，實際上是成立於 <strong>statement 粒度</strong>；而傳統 RDBMS 長期所保證的「ACID」，則是成立於 <strong>transaction block 粒度</strong>。會形成這種<strong>「一個 ACID，各自表述」</strong>的情況，可能的原因有二。首先，在 Lakehouse 誕生之初，statement-level ACID 已足以支撐當時最主要的應用場景 — — 以批次寫入、ETL、MERGE 與資料重整為核心的分析型工作負載。其次，在以 object store、分散式 metadata 與高吞吐為設計前提的架構下，若在一開始就實現 transaction-block-level ACID，其複雜度與成本都遠高於可接受範圍。</p><p>因此，Lakehouse 選擇先從「單一 statement 的原子提交」著手，其實是一條務實且符合業界最佳實踐的工程策略。</p><p>直到 2025 年，這一落差才開始出現實質性的收斂。Databricks 宣布其 Runtime 已能提供 Snapshot Isolation，並在 OCC 的 validation 階段處理細至 row-level 的衝突；Iceberg 也透過擴充 REST Catalog API，明確將 Snapshot Isolation 與 Serializable 納入設計目標。這些進展是否能在 Lakehouse 世界中建立起真正以 transaction blcok 為單位的隔離語意，仍有待時間與實務驗證。</p><h4>Concurrency Control</h4><p>為了達到特定的 <strong>Transaction Consistency level</strong>，資料庫系統必須妥善處理多個 transaction 同時對同一份資料進行讀寫所產生的衝突。這一類問題在學界統稱為 <strong>Concurrency Control</strong>，而其核心手段，長久以來可分為兩大類：<strong>悲觀式並發控制（Pessimistic Concurrency Control, PCC）</strong>與 <strong>樂觀式並發控制（Optimistic Concurrency Control, OCC）</strong>。</p><p>在經典教材 <em>Principles of Distributed Database Systems</em> 中，作者將悲觀與樂觀協議的執行流程，統一抽象為四個邏輯階段：<strong>讀（Read, R）</strong>、<strong>計算（Compute, C）</strong>、<strong>有效性驗證（Validation, V）</strong>與<strong>寫入（Write, W）</strong>。兩者的差異在於它們的<strong>執行順序不同</strong>。</p><p>在悲觀協議中，操作順序為 <strong>V → R → C → W</strong>；而在樂觀協議中，則為 <strong>R → C → V → W</strong>。由於計算階段（C）僅涉及本地邏輯處理，對並發行為本身並無實質影響，通常可在比較時省略。於是，悲觀與樂觀協議的核心差異便可簡化為：</p><ul><li><strong>PCC：V → R → W</strong></li><li><strong>OCC：R → V → W</strong></li></ul><p>這三個階段的含義如下。</p><p><strong>讀階段（Read Phase）</strong>指的是 transaction 將資料讀入自己的私有工作區，並在此基礎上進行邏輯更新。需要特別注意的是，雖然這個階段可能已經產生「更新後的值」，但這些結果對其他 transaction 仍然不可見。這個階段的命名容易引起誤解，因為實際上它同時包含了讀與暫時性的寫；更精確地說，它描述的是「將即將寫入的內容，先準備在一個僅自己可見的臨時空間中」。這個過程很像抄寫：先閱讀原文並在腦中整理，再準備誊寫成最終版本。</p><p><strong>有效性驗證階段（Validation Phase）</strong>是檢查這筆 transaction 的執行結果是否仍能維持資料庫的一致性。若驗證通過，transaction 便可進入寫入階段；否則，必須回滾並放棄本次執行。這裡其實隱含了兩層重要意涵：第一，驗證條件直接對應到系統所選擇的 Transaction Consistency 級別；第二，驗證本身可以透過不同機制實現，例如鎖檢查、時間戳排序、或版本衝突檢測，這也正是不同並發控制技術的分野所在。</p><p><strong>寫階段（Write Phase）</strong>則是將讀階段中準備好的更新結果，正式寫入共享資料結構，並對外宣告 transaction 成功提交。此時，更新才會對其他 transaction 可見，並成為資料庫狀態的一部分。</p><p>另一種更貼近直覺的說法是：PCC 與 OCC 的差別，在於系統用什麼時機點去檢查並確保 transaction 內每一條 SQL statement 的讀寫行為，都能「滿足目標 Transactional Consistency 對可見性與衝突次序的要求」。</p><p><strong>PCC 強調事前預防：</strong>在 transaction 執行期間，系統會在每一條 SQL statement 讀取或修改共享資料時，立刻施加控制（例如加鎖或阻擋衝突），確保這條 statement 的效果不會讓 transaction 違反目標 Transactional Consistency；一旦可能違反，就讓其中一方等待或中止。</p><p><strong>OCC 則著重事後檢查：</strong>transaction 內的 SQL statements 可以先在快照／版本上執行並累積變更，直到 commit 時才統一驗證「整個 transaction（由多條 statements 組成）的合併效果」是否仍滿足目標 Transactional Consistency；若不滿足，就回滾並通常以重試重新跑過這些 statements。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*8KggKl7ZhbE7MXkF.jpg" /></figure><p>若一個系統嚴格遵循 <strong>V → R → W</strong> 的執行順序，並在資料存取前即透過鎖或類似機制排除衝突，毫無疑問屬於典型的 <strong>PCC</strong>，例如以二階段鎖（2PL）為核心的傳統 RDBMS。相對地，在產業實作中，真正嚴格遵循 <strong>R → V → W</strong> 的<strong>狹義 OCC</strong> 系統其實非常少見，目前公認最接近教科書定義的代表是 <strong>FoundationDB</strong>。其餘多數系統雖然自稱採用樂觀並發控制，實際上仍混合了版本管理、鎖、或提前衝突檢測等機制，較適合被歸類為<strong>廣義 OCC</strong>，例如 TiDB 等現代分散式資料庫。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/364/0*XTGxb0yM4pYZ00FO" /></figure><p><strong>嚴格二階段鎖（Strict Two-Phase Locking, Strict 2PL）</strong>是 PCC 的典型代表，透過在 transaction 存續期間持有必要的鎖，能夠保證所有可觀測的執行結果等價於某個序列化順序，因此可達到 <strong>Serializable</strong> 級別。</p><p>相對地，<strong>FoundationDB</strong> 採用的是<strong>狹義 OCC</strong>，透過版本化資料與提交時的全域衝突驗證，同樣能夠保證 <strong>Serializable</strong>。這也說明了：Serializable 並非悲觀或樂觀策略的專利，而是並發控制機制是否嚴謹設計的結果。</p><p><strong>開源社群的改進：<br></strong>在 Lakehouse 架構中，並發控制不約而同地選擇了 Optimistic Concurrency Control（OCC），這絕非偶然，而是一個由底層物理條件所強烈決定的結果。</p><p>首先，Lakehouse 建立在 object store 之上，而 object store 的核心特性，與傳統資料庫所假設的執行環境截然不同。它缺乏低延遲的隨機寫入、缺乏 row-level lock、也缺乏長時間維持連線與狀態的能力。這使得任何以「鎖」為中心的悲觀並發控制（PCC），無論是 2PL 或其變形，都難以成立。</p><p>相反地，OCC 將衝突檢測延後到提交階段，只在 metadata 層進行驗證，避免在執行期間維持鎖與共享狀態，恰好契合了 object store「高延遲、不可變、append-oriented」的物理現實。這種「先寫、後驗證」的模式將每一個寫入操作都在本地或分散式計算引擎中獨立完成，直到準備提交時，才回到一個集中或半集中化的 metadata 層，檢查是否與其他 concurrent writer 產生衝突。</p><p>Apache Hudi 是最早明確將 OCC 引入 Lakehouse 的系統之一。Hudi 的核心抽象是 timeline：每一次寫入都對應一個 commit instant，而並發控制則圍繞這條時間線進行。在 OCC 模式下，writer 在執行期間不與其他 writer 互相阻塞，而是在提交時檢查是否有其他 commit 已經修改了相同的 file group 或 record key。若衝突被偵測到，該次提交會失敗並回滾，由上層重新嘗試。這種衝突檢測主要發生在 metadata 與索引層，而非實際資料檔案本身，也反映了 Hudi 對 record-level 更新需求的重視。</p><p>Iceberg 的 OCC 則體現在其 snapshot 與 metadata tree 的設計中。Iceberg 的每一次提交，本質上都是嘗試將 table 的 current snapshot pointer，從舊版本原子性地更新為新版本。提交是否成功，取決於「當前 snapshot 是否仍然是我在規劃時所觀察到的那一個」。若在此期間有其他 writer 已經成功提交，導致 snapshot pointer 前移，該次提交便會被拒絕。這是一種典型的 compare-and-swap（CAS）式 OCC：衝突不是檢查 row，而是檢查 table state 是否仍然一致。發控制的粒度，主要落在 metadata 層與 file set 層級。</p><p>Delta Lake 的 OCC 模型則圍繞其 transaction log（_delta_log）展開。每一個寫入操作，都會先根據某個 log version 規劃出一組 add/remove file actions，並在提交時嘗試將這些 actions 追加到 log 中。提交是否成功，取決於在此期間是否有其他 transaction 已經修改了會影響當前操作正確性的檔案集合。</p><p>總結來看，OCC 是在 Lakehouse 架構中唯一可行、可擴展、且能在工程上落地的並發控制模型。不同的 table format，僅是在這個共同前提下，選擇了不同的衝突偵測粒度與驗證策略。</p><h4>Query Optimizer</h4><p>SQL 用來描述「我要什麼資料」，而不是「要怎麼取得資料」。查詢首先會被解析成一棵 <strong>AST（Abstract Syntax Tree）</strong>，用來表達語法結構；接著，系統會將 AST 轉換為 <strong>Logical Plan</strong>，此時關注的是語意是否正確，例如資料來源、關聯關係與過濾條件是否成立，而不涉及任何實際的執行方式。</p><p>在此之後，<strong>Query Optimizer</strong> 登場。它會在龐大的搜尋空間中，透過 <strong>Rule-Based Optimization（RBO）</strong>（例如 predicate pushdown）與 <strong>Cost-Based Optimization（CBO）</strong>（例如 join order 選擇、join 演算法挑選）等策略，將一個語意等價的 logical plan 映射成一個具體可執行的 <strong>Physical Plan</strong>。</p><p>Physical plan 由一組實際可執行的 <strong>Query Execution Operators</strong> 所組成（如 scan、join、aggregate），最終交由 execution engine 依序驅動，真正完成資料的讀取與計算。</p><p><strong>PostgreSQL 的例子：<br></strong>在 PostgreSQL 中，一條 SQL 查詢從輸入到真正執行，會完整走過「語法解析 → 邏輯規劃 → 查詢最佳化 → 實體執行」這條經典路徑，這也是傳統 RDBMS 查詢處理流程的標準範本。</p><p>首先，SQL 會由 parser 解析成 AST（parse tree）。這個階段只負責語法與結構是否正確，例如關鍵字、括號、欄位數量是否匹配，並不涉及任何資料存取或效能考量。接著進入 rewrite 與 planning 階段，PostgreSQL 會將 parse tree 轉換為 Logical Plan：展開 view、套用 rewrite rules、解析 table 與 column 的對應關係，確保整個查詢在語意上是正確且可執行的。</p><p>之後，查詢進入 Query Optimizer。PostgreSQL 是一個以 CBO（Cost-Based Optimization）為核心的最佳化器，它會根據 catalog 中的統計資訊 — — 例如 row count、distinct count、histogram — — 評估不同執行策略的成本，包含 join order、join method、scan method 等。在龐大的等價計畫空間中，optimizer 會選出成本最低的一個，並將 Logical Plan 映射為一個具體的 Physical Plan。</p><p>這個 Physical Plan 是一棵由 Query Execution Operators 組成的執行樹，清楚描述資料要如何被讀取、如何 join、如何 aggregate，以及結果如何一層一層往上產生。PostgreSQL 支援的 execution operators 涵蓋資料存取（如 Seq Scan、Index Scan、Bitmap Scan）、Join（Nested Loop、Hash Join、Merge Join）、聚合與視窗計算（HashAggregate、GroupAggregate、WindowAgg），以及排序、限制、平行化等控制節點（Sort、Limit、Gather、Parallel Scan）。</p><p>所有這些 operators 都遵循 iterator model（又稱 Volcano-style execution）：上層節點向下拉取資料（pull-based），下層節點逐筆產生 tuple，資料像流水線一樣在 operator tree 中向上流動。這正是 PostgreSQL execution engine 的核心執行模型。</p><p>在這樣的架構下，<strong>EXPLAIN</strong> 與 <strong>EXPLAIN ANALYZE</strong> 成為理解查詢行為的關鍵工具。<strong>EXPLAIN</strong> 會顯示 Query Optimizer 所選擇的 Physical Plan，包含預估成本與預估資料量；而 <strong>EXPLAIN ANALYZE</strong> 則會實際執行查詢，展示 execution engine 真正跑過的 operator pipeline，並在相同的 plan 上標註真實的執行時間、實際 row 數與 loop 次數。</p><p><strong>開源社群的改進：<br></strong>在 MapReduce 的年代，查詢最佳化幾乎不存在。使用者撰寫的是「怎麼算」，而不是「要什麼」，因此也談不上 Query Optimizer 的角色。真正的轉折點，來自開源社群重新擁抱「宣告式查詢」之後，Optimizer 才重新回到資料系統的核心位置。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Hqbm9iTwvnEjk3-V9hhJoQ.png" /></figure><p>第一個關鍵角色，是 <strong>Apache Calcite</strong>（最早可追溯至 2012 年，2014 年進入 Apache 頂級專案）。Calcite 並不是一個執行引擎，而是一個刻意「不負責執行」的純 Query Optimizer 框架。它的責任範圍非常清楚：先基於定義好的 SQL dialect grammar 將查詢解析為 AST，進而產生 logical plan；接著套用一系列 rule-based optimization（如 predicate pushdown、expression rewrite）；最後再結合 catalog 與統計資訊，透過 cost-based optimization（CBO）推導出最佳的執行策略，輸出 logical 或 physical plan 供下游引擎使用。Hive、Flink、Druid，以及 Presto 的早期版本，都選擇以 Calcite 作為 optimizer。這標誌著一個關鍵轉變：<strong>Query Optimizer 首次被系統性地抽象為可重用元件，而不再必須與 execution engine 綁在一起</strong>。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/506/1*wZiuNS5ciIZYhCjUDWSsKg.png" /><figcaption>Starburst: fully-managed platform built on open-source Trino + Iceberg</figcaption></figure><p>Presto 在後期逐步移除對 Calcite 的依賴，改為自行維護 parser、analyzer 與 optimizer；而 2020 年之後由社群主導分支而出的 <strong>Trino</strong>，則在此基礎上持續強化 optimizer 與 connector 架構。Presto / Trino 的核心差異在於社群治理與發展方向：Trino 更積極投入成本式最佳化、dynamic filtering、與 connector pushdown。兩者之所以能實現 query federation 是因其 <strong>connector abstraction</strong> — — optimizer 會先在 logical plan 層嘗試將 filter、projection、aggregation 下推到各資料來源，再由 execution engine 將跨來源結果進行 join 與整合。</p><p>接著，<strong>Spark 在 2014 年推出 Spark SQL 與 Catalyst Optimizer</strong>。Catalyst 並非獨立框架，而是深度嵌入 Spark execution engine 的 optimizer。它最大的突破，在於將 logical plan 表示成可程式化的樹狀結構（Scala case class），使最佳化規則本身也成為可組合、可反覆套用的程式。這讓 Spark SQL 能同時結合 RBO（如 predicate pushdown、constant folding）與 CBO（如 join reordering），並與 execution engine 緊密協作，最終演進出 AQE（Adaptive Query Execution）。在 Catalyst 架構下，SQL、DataFrame、Dataset 三種 API 共享同一套 logical plan 與 optimizer，而常見的 physical operators 則包括 <strong>HashAggregate、SortAggregate、BroadcastHashJoin、ShuffleHashJoin、SortMergeJoin、ColumnarScan</strong> 等，完整對應 Spark 的分散式 execution model。</p><p>隨著 Lakehouse 架構逐漸成熟，「記憶體內、向量化、低延遲」成為新一代引擎的設計目標，Apache DataFusion（2020 年左右開始活躍）代表了第三條演進路線。DataFusion 是以 Rust 與 Apache Arrow 為核心的 query engine，透過明確定義的 SQL grammar 與 logical plan abstraction 支援 SQL。它的 optimizer 能充分利用 Arrow 的 columnar memory layout，在 logical 與 physical plan 轉換時更貼近實際的執行成本。DataFusion 明確區分 logical optimizer、physical optimizer 與 execution engine，並以高度模組化的方式實作，成為 InfluxDB IOx、Ballista 等新系統的基礎，象徵 optimizer 開始為「向量化、記憶體導向」的執行模型量身打造，而不再只是服務於傳統 iterator model。</p><p>然而，隨著引擎與 optimizer 百花齊放，一個新的瓶頸逐漸浮現：<strong>每個系統都有自己的 plan 表示法，最佳化成果難以跨系統重用</strong>。這正是 <strong>Substrait（約 2021 年提出）</strong> 出現的背景。Substrait 並不是一個 optimizer，而是一個中立的查詢計畫交換格式，嘗試標準化 logical 與 physical plan 的語意描述，使 Calcite、Spark、DataFusion、DuckDB 等系統能在 optimizer 與 execution engine 之間交換查詢計畫。這意味著，未來 Query Optimizer 不一定要與 execution engine 同源實作，最佳化本身也可能成為一種可跨系統共享、組裝的能力。</p><h4>Admission Control</h4><p>Admission Control 是資料庫內核中負責「控制誰可以進來執行、什麼時候進來執行」的關鍵機制。它的核心目的不是提升單一查詢的速度，而是保護整個系統在高負載或多租戶環境下仍能維持穩定與可預期的效能。透過限制同時執行的查詢數量、CPU／記憶體／I/O 的使用比例，Admission Control 能避免「少數昂貴查詢拖垮整個系統」的情況發生。在傳統資料庫中，它是確保 QoS（Quality of Service）、多租戶公平性，以及系統不進入雪崩狀態的重要防線。</p><p><strong>PostgreSQL 的例子：<br></strong>PostgreSQL 本身並沒有像商業資料庫那樣內建完整的 Admission Control 模組，但仍透過多個層面間接達成類似效果。</p><p>最基礎的是 max_connections，限制同時連線數，避免系統在瞬間被大量連線耗盡資源；搭配 connection pool（如 pgbouncer）可進一步平滑負載。在查詢層面，PostgreSQL 提供 statement timeout、lock timeout、idle_in_transaction_session_timeout 等機制，防止查詢長時間佔用資源。此外，透過 background worker 與 extension（如 pg_stat_statements），管理者能觀測並人工介入異常查詢。</p><p>雖然 PostgreSQL 的 Admission Control 偏向「靜態限制＋事後觀測」，但其核心精神仍是：避免系統被不可控的工作負載擊穿。</p><p><strong>開源社群的改進：<br>AC in Query Engine</strong></p><p>在 MapReduce 與早期 Hadoop 生態中，Admission Control 幾乎不存在。<strong>JobTracker</strong> 或 <strong>YARN</strong> 的設計目標是「把工作丟進來跑完」，而不是精細管理查詢之間的資源競爭，結果常見的情況是，一個昂貴的 batch job 就能讓整個叢集的延遲全面失控。</p><p>隨著查詢型工作（interactive query）進入 Data Lake 世界，Admission Control 才重新變得不可忽視。Spark 透過 fair scheduler 與 capacity scheduler，開始在應用層管理資源配額；Presto／Trino 引入 query queue、resource group 與 per-query memory limit，能在查詢進入 execution engine 前就進行分流與限制。</p><p>在 Lakehouse Era，這些機制進一步與 catalog、table format 與統計資訊結合。例如，在以 <strong>Iceberg + Trino</strong> 為核心的 Lakehouse 架構中，Trino 會先透過 catalog 讀取 Iceberg table 的 metadata：包含 partition 規格、data file 數量、每個 file 的 row count、column-level 的 min/max statistics 等。Query Optimizer 會根據這些資訊推導出一個預估的 logical 與 physical plan，進而估算這條查詢可能掃描多少檔案、需要多少 I/O、記憶體與 CPU。</p><p>在這個階段，Admission Control（如 Trino 的 resource group 機制）就能依據「預估成本」做出決策：如果這是一條只會掃描少量 partition、預期延遲極低的查詢，它可以被立即放行；但如果查詢預估將掃描上萬個 data files、消耗大量 memory，系統可能選擇將它排入佇列，甚至拒絕執行，避免拖垮整個叢集。</p><p><strong>AC for Clusters</strong></p><p>叢集層面的 Admission Control，也有戲劇性的演進。早期的大數據叢集，資源管理多半由 <strong>Mesos</strong> 或 <strong>YARN</strong> 負責，強調的是「資源切割與排程」，而非服務生命週期與應用隔離；<strong>Docker Swarm</strong> 則嘗試以容器為單位提供簡化的叢集管理，但在可擴展性與生態整合上始終有限。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/661/1*m8i_Yv4Uz4EX5STZvrrB9g.png" /></figure><p>最終，<strong>Kubernetes</strong> 勝出，成為事實上的標準。它把 Admission Control 從單一系統能力，提升為一個可插拔、可治理的控制平面（control plane）：透過 namespace、resource quota、limit range、priority class 與 admission webhook，Kubernetes 能在工作進入系統之前，就完成資源配額、優先順序與政策驗證。</p><h4>3) RDBMS 工具鏈與生態相關</h4><h4>Report Writers</h4><p>在 2008 年，報表工具仍然高度依賴傳統 RDBMS。像 Crystal Reports 這類 report writer，假設資料存放在一個具備穩定 schema、低延遲查詢能力的資料庫中，並可直接以 SQL 取得結果，用於人類可讀的視覺化輸出。當時的 MapReduce 與 HDFS 完全無法支撐這樣的互動模式，因此在報表生成這個最貼近終端使用者的場景上顯得格格不入。</p><p>到了 2025 年，Lakehouse 生態中這一塊<strong>早已補齊</strong>。Tableau、Power BI、Looker、Superset 等工具，能直接透過 Trino、Spark SQL、DuckDB 或 ClickHouse 查詢 Iceberg、Delta Lake 或 Hudi 上的資料。底層雖然不再是單一資料庫，而是「Query Engine + Table Format + Object Storage」的組合，但對使用者而言，「寫 SQL、出報表」這件事已經與 RDBMS 時代無異。</p><h4>Business Intelligence Tools</h4><p>在 2008 年，BI 工具強調的是即席分析（ad-hoc query）以及與 Data Warehouse 的互動能力。使用者期望能隨時提出新的問題、快速反覆調整查詢，並即時看到結果。然而，當時的 MapReduce 採用純批次模型，意味著每一次查詢都像是一個小型離線作業，不但啟動成本高、延遲長，也完全不適合探索式分析的互動節奏。</p><p>這個缺口，後來反而成為推動整個 Data Lake 生態演進的核心力量。隨著 Hive SQL、Spark SQL、Presto / Trino 等 SQL-on-Lake 引擎逐漸成熟，Lake 上的資料開始具備可接受的查詢延遲，並逐步引入 CBO、統計資訊收集與 metadata pruning 等能力，使 ad-hoc query 不再只是理論上的可能。到了 Lakehouse Era，BI 工具已能穩定地直接連接 Lakehouse 架構，例如 Tableau、Power BI、Looker、Superset 等，都能透過 Trino、Spark SQL、DuckDB 或 ClickHouse 查詢 Iceberg、Delta Lake、Hudi 上的資料。</p><p>更關鍵的是，這些 BI 工具的實際使用需求，反過來促進了底層系統在 SQL 語意、查詢穩定性與效能上的不斷逼近傳統資料庫。從使用者體驗的角度來看，現代 BI 模式早已跨越當年 Stonebraker 所設下的門檻，即使資料實際存放於 object storage，互動式分析的流暢度已不再是決定性差異。</p><h4>Data Mining Tools</h4><p>在 2008 年，資料探勘（Data Mining）仍被視為資料庫內建能力的一部分，例如 Oracle Data Mining 或 IBM DB2 Intelligent Miner。這一代工具的核心目標，是讓使用者能在資料庫內直接進行結構探索與模式發現，典型任務包括 clustering、association rules、outlier detection，以及少量基於決策樹或貝氏模型的監督式分析。這些能力多半以算子或函式的形式，直接嵌入在 DBMS 的 execution engine 中，並由查詢最佳化器負責調度與執行。在這樣的背景下，MapReduce 世界缺乏任何對應的資料探勘工具，確實在當時可被視為一種顯著的倒退。</p><p>然而，歷史並沒有朝著「把資料探勘帶回資料庫內核」的方向前進。相反地，它逐漸發展出一條以資料為中心、以計算為外掛的全新設計哲學。資料探勘與機器學習被重新發明在 Data Lake 與分散式計算引擎之上，從早期的 Spark MLlib、Flink ML，到後來的 TensorFlow、PyTorch，再到 Feature Store、ML pipeline 與 model serving 系統，形成了一條獨立於傳統 DBMS 之外的完整生態。</p><p>到了 2025 年的今天，Lakehouse 已成為機器學習最自然的資料基座：原始資料、特徵工程、模型訓練與推論，都圍繞著同一份資料與統一的 metadata 層運作。從能力層面來看，現代系統早已全面涵蓋甚至超越 2008 年所謂的 Data Mining；但從架構觀點來看，這已不再是「資料庫內建智能」，而是一個以 Lake 為核心、以計算引擎與 ML 系統協作的全新資料智能體系。</p><h4>Replication Tools</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*g1vy9svEaFRGBKGwTTxveg.png" /></figure><p>在 2008 年的語境中，replication tools 指的是像 Oracle GoldenGate 這樣的 DB-to-DB 複製系統，其核心目標是在多個資料庫實例之間維持資料同步。這類複製通常服務於兩個目的：一是建立 read replica 以分擔查詢負載，二是做為災難復原與資料備援的一環。其前提非常明確 — — 資料存在於具有 WAL、transaction 與 schema 的資料庫中，複製的是一連串已提交的 row-level 變更。在這個前提下，以檔案為中心、缺乏更新與交易語意的 MapReduce 與 HDFS，自然無法支援即時、持續的資料複製。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/547/1*nlwqYxhoqsZlS8N19_EsHQ.png" /></figure><p>到了 2025 年，Replication 的概念已徹底轉化為「log-driven ingestion」。最具代表性的工具是 Debezium，它不再嘗試做 DB-to-DB 的同步，而是直接訂閱 OLTP 系統的 transaction log，將每一次資料變更以事件的形式輸出。Debezium 原生支援多種資料來源，包括 MySQL、PostgreSQL、Oracle、SQL Server 等主流資料庫；在資料輸出端，它通常會將變更事件送入 Kafka，但也可透過 Kafka Connect 生態系進一步寫入各種下游系統。</p><p>在這種架構下，Kafka 扮演的是「持久化變更日誌」與「解耦緩衝層」的角色。資料從 OLTP 系統被持續地以 CDC 形式推送出來，經由 Kafka 傳遞到 Lakehouse ingestion pipeline，再寫入 Hudi、Iceberg 或 Delta Lake 等 table format。這些表格式進一步提供 incremental read 與 time travel，使下游的分析、流處理與機器學習工作，能在一致且可重現的資料視圖上運行。</p><p>從功能面來看，資料複製早已補齊；但從語意上看，它已不再是「資料庫之間的同步」。Replication 在 Lakehouse 時代的本質，是以 log 為中心的資料流動：資料不再被視為靜態狀態的拷貝，而是一條持續演進、可被多個系統消費的變更序列。</p><h4>Database Design Tools</h4><p>在 2008 年，Embarcadero 以及同時代的 ERwin、PowerDesigner，提供的並不只是畫圖工具，而是一整套以資料模型為中心的設計流程。工程師可以透過 ER Diagram 視覺化設計 table、column、primary key 與 foreign key，並在同一套工具中同時維護 Conceptual、Logical 與 Physical Model，從高層語意一路推導到實際的資料庫 schema。Integrity Constraints — — 例如 NOT NULL、UNIQUE、CHECK、FOREIGN KEY — — 也是模型本身的一部分。</p><p>更重要的是，這些設計工具與資料庫的強一致性模型緊密綁定。由模型產生的 DDL（CREATE TABLE、ALTER TABLE）是 transactional 的：schema 的改變與資料的改變一樣，受到 ACID 的保護，可以一同提交或回滾。這使得「資料庫設計」在 RDBMS 世界裡成為一種可驗證、可版本化、可推導的工程行為，而不是僅存在於文件或團隊約定中的抽象規範。這正是 MapReduce 與早期 Data Lake 生態中最徹底缺席的一塊。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/349/1*3Xm7DFDOvSfKyRP-kAS3iQ.png" /></figure><p>進入 Lakehouse Era 之後，這個缺口被以不同層級、不同設計哲學的工具逐步補齊。以 dbt 為例，它並未試圖成為傳統意義上的 Database Design Tool，而是專注於 logical model 與轉換層的可治理性。在 dbt 的世界中，table 與 view 被視為 model，資料轉換關係以程式碼形式明確定義，並透過 Git 進行版本控管，使 schema 演進具備可重現性。同時，dbt 也引入 not_null、unique、relationships 等測試，提供一種「弱形式」的 integrity constraint 檢查，以及自動化產生的文件與血緣圖，讓資料模型更接近可閱讀、可審查的設計文件。這些約束本質上仍屬於事後驗證（assertions），而非由資料庫內核強制執行的約束。dbt 能揭露問題、標記違規，但無法阻止不符合約束的資料被寫入。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/864/1*JngNP6HVldNvWyi8IthqUQ.jpeg" /></figure><p>Fivetran 則位於另一條完全不同的軸線上。它關注的不是資料模型語意，而是 ingestion 階段的正確性與穩定性。透過自動擷取 OLTP 系統的 schema、處理欄位新增與型別變動，Fivetran 解決的是 schema drift 的工程問題 — — 避免「上游一變、下游全面爆炸」。它確保資料能穩定地進入 Data Lake 或 Warehouse，但並不試圖定義資料之間的關係、約束或高層語意。</p><p>從這個角度來看，dbt 與 Fivetran 的確在工程實務上補齊了 Lakehouse 世界中的多個關鍵缺口，但它們刻意著力的，是流程層與驗證層，而非傳統 RDBMS 中那種與 ACID、transactional metadata 深度綁定的設計期強制（design-time enforcement）。</p><p>這並非單純的能力不足，而是源自 Lakehouse 本身的資料主權模型：資料並不是在 Lakehouse 中被「設計」或「誕生」，而是由上游系統持續產生並匯入。對於這樣一個以外部資料為中心、具高度異質性與演進性的系統而言，事後驗證、血緣追蹤與可觀測性，往往比事前阻擋來得更現實，也更具可擴展性。</p><p>因此，Stonebraker 在 2008 年所指出的「Database Design Tools 缺失」，在 2025 年的 Lakehouse 架構中，確實尚未被等價取代；但這個缺口，更多反映的是架構哲學與治理位置的差異，而非單純的技術落後。換句話說：</p><ul><li>RDBMS 的設計工具，透過 design-time enforcement 來「創造一個正確的現實」。</li><li>而 Lakehouse 的治理工具，則透過 process、validation 與 observability，來「管理一個本就不完美的現實」。</li></ul><h3>Summary of Lakehouse Architecture</h3><p>到了 2026 年，Lakehouse 早已走出當年 MapReduce 初出茅廬的青澀，一路上吸收了大量來自 RDBMS 的精神與能力。然而，它依然處於<strong>未竟之途</strong>，距離「使用者在敲下 SQL 的瞬間，無法分辨自己使用的是 PostgreSQL 還是 Lakehouse」的奇異點，仍差幾塊<strong>關鍵拼圖</strong>。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/814/1*PDQSUZ08VKrvdo0OjvNO7w.png" /><figcaption>那場 meetup 的干貨：Jason Reid 的精美 slides</figcaption></figure><p>這樣的結論，與 Iceberg 共同作者在 2024 的一場 meetup 中的提醒不謀而合：</p><p><em>We all hope we can use Iceberg just like using PostgreSQL. But in reality, we are just not there yet.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*u8MnatmF4rw3Q5ttMT10hA.png" /><figcaption>那場 meetup 的干貨：Jason Reid 的精美 slides</figcaption></figure><p>期許未來有一天，會有另一個像 Google 證明 batch 與 stream 為本質一體那樣的高光時刻，替 Lakehouse 與 RDBMS 之間的關係，給出一個同樣優雅而有力的答案。</p><p>(關於 batch 與 stream 如何被證明為本質一體，將在之後揭曉！)</p><h3>Appendix</h3><ol><li>The Google File System: <a href="https://static.googleusercontent.com/media/research.google.com/en//archive/gfs-sosp2003.pdf">https://static.googleusercontent.com/media/research.google.com/en//archive/gfs-sosp2003.pdf</a></li><li>MapReduce: Simplified Data Processing on Large Clusters: <a href="https://static.googleusercontent.com/media/research.google.com/en//archive/mapreduce-osdi04.pdf">https://static.googleusercontent.com/media/research.google.com/en//archive/mapreduce-osdi04.pdf</a></li><li>Bigtable: A Distributed Storage System for Structured Data: <a href="https://static.googleusercontent.com/media/research.google.com/en//archive/bigtable-osdi06.pdf">https://static.googleusercontent.com/media/research.google.com/en//archive/bigtable-osdi06.pdf</a></li><li>《大数据经典论文解读》 三驾马车学习:<a href="https://qiankunli.github.io/2022/05/18/google_articles.html#gfs%E8%AE%BA%E6%96%87">https://qiankunli.github.io/2022/05/18/google_articles.html#gfs%E8%AE%BA%E6%96%87</a></li><li>数据湖系列之一 | 你一定爱读的极简数据平台史，从数据仓库、数据湖到湖仓一体: <a href="https://xie.infoq.cn/article/31e1dcad3edee4f4302f41ff9?fbclid=IwY2xjawJ-wnRleHRuA2FlbQIxMABicmlkETBEdWZneU8xTlQzR045RjJyAR7GXGXmnqGec5jLx7MGJHcrWU7oMJD1f5O-VwJGQQ5xceDI-3bfCAYJIJ3DAg_aem_wFZuKh-bt5lLr9fusg0G5A">https://xie.infoq.cn/article/31e1dcad3edee4f4302f41ff9?fbclid=IwY2xjawJ-wnRleHRuA2FlbQIxMABicmlkETBEdWZneU8xTlQzR045RjJyAR7GXGXmnqGec5jLx7MGJHcrWU7oMJD1f5O-VwJGQQ5xceDI-3bfCAYJIJ3DAg_aem_wFZuKh-bt5lLr9fusg0G5A</a></li><li>以 Hadoop 和 PostgreSQL 为例，探析数据库拆解的影响: <a href="https://mp.weixin.qq.com/s/GA9YGq8omRyl-o1PxfPEMA">https://mp.weixin.qq.com/s/GA9YGq8omRyl-o1PxfPEMA</a></li><li>MapReduce: A major step backwards: <a href="https://homes.cs.washington.edu/~billhowe/mapreduce_a_major_step_backwards.html">https://homes.cs.washington.edu/~billhowe/mapreduce_a_major_step_backwards.html</a></li><li>Let’s talk about Postgres views and materialized views: <a href="https://www.linkedin.com/posts/crunchy-data-solutions-inc-_lets-talk-about-postgres-views-and-materialized-activity-7354535499241308160-zwf_/">https://www.linkedin.com/posts/crunchy-data-solutions-inc-_lets-talk-about-postgres-views-and-materialized-activity-7354535499241308160-zwf_/</a></li><li>What is a Storage Engine in Data Lakehouse? <a href="https://www.linkedin.com/posts/dipankar-mazumdar_dataengineering-softwareengineering-activity-7324075784417988608--3Fq/">https://www.linkedin.com/posts/dipankar-mazumdar_dataengineering-softwareengineering-activity-7324075784417988608--3Fq/</a></li><li>The Hudi Stack: <a href="https://hudi.apache.org/docs/hudi_stack/#storage-engine">https://hudi.apache.org/docs/hudi_stack/#storage-engine</a></li><li>Unbundling the Data Warehouse: The Case for Independent Storage (by Jason Reid): <a href="https://www.youtube.com/watch?v=CmEIJ-lagVU&amp;t=116s">https://www.youtube.com/watch?v=CmEIJ-lagVU&amp;t=116s</a></li><li>The advent of the open data lake, AI By the Bay 2025 (by Julien Le Dem): <a href="https://www.youtube.com/watch?v=xHGVCVjA_hc&amp;t=1s">https://www.youtube.com/watch?v=xHGVCVjA_hc&amp;t=1s</a></li><li>Consistency Models in JEPSEN: <a href="https://jepsen.io/consistency/models">https://jepsen.io/consistency/models</a></li><li>Why you should pick strong consistency, whenever possible: <a href="https://cloud.google.com/blog/products/databases/why-you-should-pick-strong-consistency-whenever-possible">https://cloud.google.com/blog/products/databases/why-you-should-pick-strong-consistency-whenever-possible</a></li><li>CEP-15: General Purpose Transactions: <a href="https://cwiki.apache.org/confluence/display/CASSANDRA/CEP-15%3A+General+Purpose+Transactions">https://cwiki.apache.org/confluence/display/CASSANDRA/CEP-15%3A+General+Purpose+Transactions</a></li><li>Cassandra on ACID: This Changes Everything, DataStax (by Patrick McFadin): <a href="https://www.youtube.com/watch?v=7Nm8mEcKrRc&amp;t=1463s">https://www.youtube.com/watch?v=7Nm8mEcKrRc&amp;t=1463s</a></li><li>[Databricks] Multi-Format, Multi-Table, Multi-Statement Transactions on Unity Catalog: <a href="https://www.youtube.com/watch?v=I7bPyNP1OCs">https://www.youtube.com/watch?v=I7bPyNP1OCs</a></li><li>[Databricks] Multi-Statement Transactions: How to Improve Data Consistency and Performance: <a href="https://www.youtube.com/watch?v=aViXvTkiI1I">https://www.youtube.com/watch?v=aViXvTkiI1I</a></li><li>[Databricks] Multi-statement Transactions in the Databricks Lakehouse (by Ryan Johnson): <a href="https://www.youtube.com/watch?v=YmY_NwaoxNk">https://www.youtube.com/watch?v=YmY_NwaoxNk</a></li><li>[Iceberg] Transactions and Isolation in Apache Iceberg™: <a href="https://www.youtube.com/watch?v=zhxw3Rrslak">https://www.youtube.com/watch?v=zhxw3Rrslak</a></li><li>[Iceberg] Multi-Table Multi-Statement Transaction with Apache Iceberg: <a href="https://www.youtube.com/watch?v=Dsao9M6zfwM">https://www.youtube.com/watch?v=Dsao9M6zfwM</a></li><li>[Onehouse] ACID Transactions in an Open Data Lakehouse: <a href="https://www.onehouse.ai/blog/acid-transactions-in-an-open-data-lakehouse">https://www.onehouse.ai/blog/acid-transactions-in-an-open-data-lakehouse</a></li><li>[Firebolt] Implementing Explicit Multi-Statement Transactions in a Stateless, Cloud-Native Architecture: <a href="https://www.firebolt.io/blog/implementing-explicit-multi-statement-transactions-in-a-stateless-cloud-native-architecture?utm_campaign=Oktopost-2025-12+Advocate+Posts&amp;utm_date=2025-12-20+07%3A44%3A00&amp;utm_medium=social_org&amp;utm_source=LinkedIn#why-you-need-multi-statement-transactions">https://www.firebolt.io/blog/implementing-explicit-multi-statement-transactions-in-a-stateless-cloud-native-architecture?utm_campaign=Oktopost-2025-12+Advocate+Posts&amp;utm_date=2025-12-20+07%3A44%3A00&amp;utm_medium=social_org&amp;utm_source=LinkedIn#why-you-need-multi-statement-transactions</a></li><li>Principles of Distributed Database Systems: <a href="https://link.springer.com/content/pdf/bfm:978-1-4419-8834-8/1.pdf">https://link.springer.com/content/pdf/bfm:978-1-4419-8834-8/1.pdf</a></li><li>Concurrency Control in Open Data Lakehouse: <a href="https://hudi.apache.org/blog/2025/01/28/concurrency-control/">https://hudi.apache.org/blog/2025/01/28/concurrency-control/</a></li><li>Towards Query Optimizer as a Service (QOaaS) in a Unified LakeHouse Ecosystem: Can One QO Rule Them All?: <a href="https://arxiv.org/pdf/2411.13704">https://arxiv.org/pdf/2411.13704</a></li><li>Query Optimization in the Wild: Realities and Trends: <a href="https://arxiv.org/pdf/2510.20082">https://arxiv.org/pdf/2510.20082</a></li><li>[Firebolt] Unlocking Faster Iceberg Queries: The Writer Optimizations You’re Missing: <a href="https://www.firebolt.io/blog/unlocking-faster-iceberg-queries-the-writer-optimizations-you-are-missing?utm_campaign=Oktopost-2025-12+Advocate+Posts&amp;utm_date=2026-01-07+07%3A58%3A11&amp;utm_medium=social_org&amp;utm_source=LinkedIn">https://www.firebolt.io/blog/unlocking-faster-iceberg-queries-the-writer-optimizations-you-are-missing?utm_campaign=Oktopost-2025-12+Advocate+Posts&amp;utm_date=2026-01-07+07%3A58%3A11&amp;utm_medium=social_org&amp;utm_source=LinkedIn</a></li><li>dbt Labs + Fivetran: Open data infrastructure for analytics and AI: <a href="https://www.getdbt.com/blog/dbt-labs-and-fivetran-merge-announcement">https://www.getdbt.com/blog/dbt-labs-and-fivetran-merge-announcement</a></li><li>Fivetran and dbt Labs Unite to Set the Standard for Open Data Infrastructure: <a href="https://www.fivetran.com/press/fivetran-and-dbt-labs-unite-to-set-the-standard-for-open-data-infrastructure-2025">https://www.fivetran.com/press/fivetran-and-dbt-labs-unite-to-set-the-standard-for-open-data-infrastructure-2025</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7801261db693" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From Database to Data Lakehouse — Part 9]]></title>
            <link>https://medium.com/@0050211/from-database-to-data-lakehouse-part-9-2a5b4baa9c8f?source=rss-663daee8909b------2</link>
            <guid isPermaLink="false">https://medium.com/p/2a5b4baa9c8f</guid>
            <dc:creator><![CDATA[Ryan Fu]]></dc:creator>
            <pubDate>Sun, 23 Nov 2025 17:22:31 GMT</pubDate>
            <atom:updated>2026-06-15T03:16:15.527Z</atom:updated>
            <content:encoded><![CDATA[<h3>Chapter 7. Immutable Files Meet Open Source</h3><p>《<a href="https://medium.com/@adrianhsu/%E7%B5%A6%E5%AD%B8%E5%BC%9F%E5%A6%B9%E7%9A%84%E7%9F%BD%E8%B0%B7-ai-software-%E6%8C%87%E5%8D%97-da054b7a8dcb">給學弟妹的矽谷 AI / Software 指南</a>》是一個矽谷工程師自發的眾籌寫作計劃，由<a href="https://www.facebook.com/opensource4you">源來適你社群</a>推動。這將是一個在台灣軟體學生圈內很有影響力的文章系列。已有 &gt;10 篇準備發布，目標 100 篇並出書，歡迎 tag 強者大神來寫！</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/871/0*fEjZuiD4MvD8xQIn" /></figure><p>在開源生態中，不同專案存在於不同的抽象層級。</p><p>上一章第四幕的 <strong>ClickHouse</strong> 屬於 <strong>Column Store</strong>，它是一個完整的 OLAP 資料庫系統，提供查詢引擎與存儲引擎，是典型的<strong>「工具層級」</strong>開源專案。工程師社群共享的，是如何透過這個工具進行即時分析。</p><p>進入 Data Lake Era 後，<strong>Parquet</strong> 是一種不可變 <strong>PAX Format</strong>，一個跨平台的檔案格式標準，並不是獨立的資料庫。不同的 Query Engine — — 無論是 ClickHouse、Spark 還是 Flink — — 都能直接讀寫同一份 Parquet 檔案。因此，Parquet 代表的是<strong>「檔案層級」</strong>的開源，其價值在於促進資料交換與共享。</p><p>本章將依序分成三個部分，全方位地詮釋「不可變檔案」如何成為大數據查詢的基石：</p><ul><li>Data Layouts: Row-oriented, Column-oriented, and PAX</li><li>Parquet for Structured Queries</li><li>Arrow for High-Performance Data Transfer</li></ul><h3>Data Layouts: Row-oriented, Column-oriented, and PAX</h3><p>在資料工程的演進中，著名的<strong>開源</strong>序列化方式大致分為兩類：<strong>Row-based</strong> 與 <strong>PAX</strong>。前者多用於<strong>傳輸</strong>（data exchange/serialization），後者則主要用於<strong>落盤</strong>（on-disk storage/query）。在 Row-based 的方法中，CSV, XML, JSON 屬於純文字的序列化，Protobuf, Thrift, Avro 屬於二進制的序列化。</p><h4>Open Row-based Serialization</h4><p>在資料工程的早期，<strong>CSV (Comma-Separated Values, 1972)</strong> 是最簡單、也最廣泛使用的結構化資料格式。它的設計極為輕量：一行代表一筆記錄，以逗號分隔欄位。這讓 CSV 幾乎能在所有程式語言中被快速讀寫，並且適合儲存表格型資料。然而，CSV 缺乏嚴謹的 schema 定義，對於缺失值或巢狀結構的處理能力不足，雖然簡單易用，但在大規模數據交換與演算中逐漸顯露侷限。</p><p>隨著網路應用的興起，<strong>XML (eXtensible Markup Language, 1998)</strong> 成為第一個廣泛普及的結構化資料格式。XML 採用類似 HTML 的標籤方式，能以樹狀結構描述複雜的資料，並可透過 DTD 或 XSD 定義嚴格的 schema。它廣泛應用於 Web Service（如 SOAP、RSS）與企業系統整合。但由於過於冗長、解析成本高，XML 在資料交換與儲存的效率上逐漸被取代。</p><p>為了簡化資料交換，<strong>JSON (JavaScript Object Notation, 2001)</strong> 應運而生。它以 key-value 與 array 組合來表達資料，天然支援巢狀結構，且人類可讀性高。因為簡單、靈活且易於與程式語言整合，JSON 很快成為 Web API 和資料交換的事實標準，尤其在 RESTful 服務盛行後，幾乎無所不在。</p><p>隨著跨語言系統的需求增加，文字格式在<strong>效能</strong>與<strong>型別安全性</strong>上逐漸成為瓶頸。<strong>Apache Thrift (2007, Facebook)</strong> 率先嘗試以二進位序列化（binary serialization）取代文字格式，它要求開發者事先定義 .thrift 檔案來描述欄位結構，並由編譯器自動產生跨語言的程式碼。在傳輸時，Thrift 會依據不同的 wire protocol（例如 Binary、Compact、JSON 等）攜帶欄位編號與型別標籤（type tag），使接收端能夠部分理解資料結構。這種設計讓 Thrift 在跨語言 <strong>RPC</strong>（Remote Procedure Call）環境中大放異彩，成為早期微服務架構的基石之一，特別是在 Facebook 的分散式系統內部通訊中。</p><p>一年後，<strong>Protocol Buffers (2008, Google)</strong> 延續相似理念，但將重點放在極致的效能與輕量化上。Protobuf 同樣需要事先定義 .proto 檔案，並在編譯階段產生對應的類別或結構體。不同於 Thrift，Protobuf 傳輸時不包含任何 schema 資訊，只依靠欄位編號（tag number）進行解碼。這讓資料封包極為精簡、序列化速度極快，特別適合 <strong>gRPC</strong> 或<strong>內部微服務之間的高頻通訊</strong>。然而，這種設計也意味著接收端必須與發送端共享相同版本的 schema，否則就可能出現欄位遺失或型別錯誤的情況。</p><p>到了 2009 年，Apache Avro 的出現，進一步改變了資料交換的思維。Avro 同樣是 row-based 的二進位序列化格式，但它選擇「讓資料自己帶著 schema」。每個 Avro 檔案或訊息都同時包含兩部分：一份以 JSON 描述的 schema，以及實際的二進位資料。這樣的設計讓 Avro 天然支援 <strong>schema evolution</strong> — — 當欄位名稱、順序或型別改變時，仍能在不同版本的系統之間保持相容性。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*UHG5JJKsOqbMUmC9" /><figcaption>With Confluent Cloud, it’s easier than ever to deploy Kafka cluster</figcaption></figure><p>需要注意的是，<strong>Apache Kafka 原生的訊息序列化機制是自行定義的，並不直接使用 Avro</strong>。後來由 Confluent 所推出的 <strong>Schema Registry</strong> 才採用了 Avro 作為標準的序列化格式，讓生產者與消費者都能共用 schema，確保資料流的一致性與版本控制。</p><p>因此，Avro 成為了大數據生態中「資料流動」的核心格式，尤其在 <strong>Kafka + Schema Registry</strong>、<strong>Hadoop</strong>、<strong>Hive</strong> 等系統中，扮演了流式傳輸與批次處理之間的橋樑角色。</p><h4>Open PAX Serialization</h4><p>當大數據分析的需求日益增長，單純的 row-based 格式已無法滿足查詢效率。<strong>Parquet (2013, Twitter + Cloudera)</strong> 採用了 <strong>PAX Format</strong>，讓查詢僅需讀取必要的欄位，大幅減少 I/O 並提升壓縮率。Parquet 的跨平台特性，使它迅速成為 Spark、Hive、Flink 等大數據系統的標準格式，並奠定了 Data Lake 的核心基礎。</p><p>同年，<strong>ORC (Optimized Row Columnar, 2013, Hortonworks)</strong> 也在 Hadoop 社群中誕生，最初專為 Hive 優化。它內建索引與高效壓縮，對 Hive 的批次查詢效能有顯著提升。雖然 ORC 與 Parquet 都是 PAX Format，但 ORC 更偏向 Hadoop/Hive 場景，而 Parquet 因其跨系統支援廣泛，逐漸成為 Data Lake 與雲端環境的事實標準。</p><h4><strong>Column-based Serialization in ClickHouse</strong></h4><p>ClickHouse 的 on-disk layout 是真正的 <strong>column-based</strong>，每個 column 都以獨立檔案儲存（.bin + .mrk 索引），沒有像 PAX 那樣的 row group 或 stripe 邏輯。這種設計讓查詢時能直接對單一 column 進行順序讀取與壓縮解壓，大幅提升 I/O 效率。然而，ClickHouse Native Format 並不是一種 open format，它屬於 ClickHouse 引擎內部使用的系統級存儲格式，主要服務於自身的 MergeTree 架構，而非作為跨平台的交換標準。</p><h4>From Avro to Parquet</h4><p>在實際的資料工程場景中，<strong>Row-based</strong> 與 <strong>PAX</strong> 格式往往需要搭配使用。例如，在一個 Kafka 驅動的即時資料管道裡，資料通常先以 <strong>Avro</strong> 格式寫入，因為 Avro 的 row-based schema 適合高效傳輸與跨語言序列化；而當這些資料進入 Hadoop 或 Spark 環境並需要落盤進行批次分析時，就必須轉換成 <strong>Parquet</strong>，才能發揮 columnar 的查詢與壓縮優勢。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*P7F11TIHK_DU25gp1bptBg.png" /></figure><p>在 Data Lake 時代，這樣的轉換需要工程師透過繁瑣的 ETL 流程來實作，手動撰寫 Spark 或 Flink 任務，並自行處理 schema 演進、檔案小碎片合併以及目錄管理等細節。</p><p>在 Data Lakehouse 時代，<strong>Tableflow</strong> 的出現，將這一切自動化：它能直接從 Kafka topics 轉換並 materialize 成 <strong>Iceberg 或 Delta Lake</strong> 表，內部完成格式轉換、壓縮最佳化與一致性提交，並自動同步到資料目錄（如 Glue、Unity Catalog）。</p><p>換句話說，<strong>Avro 解決的是「資料如何流動」的問題，而 Parquet 解決的是「資料如何靜態查詢」的問題</strong>；這種從 row 到 column 的轉換，不僅是現代大數據處理中最典型的工作流之一，也因 Tableflow 的自動化而變得更加簡潔高效。</p><p>然而，Parquet 這樣的 PAX 格式最初又是如何被發明出來的？</p><h3>Parquet for Structured Queries</h3><h4>Dremel</h4><p>2010 年，Google 在一篇經典論文 <strong>《Dremel: Interactive Analysis of Web-Scale Datasets》</strong> 中，首次公開了 Dremel 系統。這是一個能在 PB 級、數兆列資料上進行秒級互動式查詢的引擎，主要針對 <strong>read-only 的 nested data</strong>（例如 JSON、Protobuf）設計。傳統的 MapReduce 雖然能處理大資料，但查詢延遲往往以分鐘為單位，不適合快速探索。Dremel 的貢獻，就是讓工程師能在幾秒鐘內就完成 ad-hoc 分析，大幅改變了資料分析的工作方式。</p><p>Dremel 的核心創新有三點：</p><p>第一，它引入 <strong>multi-level execution tree</strong>，借鏡搜尋引擎的分層聚合架構，讓查詢請求能向下分派、結果向上聚合，支援大規模並行並容忍 straggler。</p><p>第二，它設計了一種 <strong>嵌套資料的列式存儲格式</strong>，透過 definition level 與 repetition level，能將 nested record 拆解為 column stripes，查詢時只需讀取相關欄位，既減少 IO，又提升壓縮效率。</p><p>第三，它提供了 <strong>SQL-like 語言</strong>，能直接對 GFS 或 Bigtable 上的資料進行互動查詢，而不需先進行耗時的 load data，也不必將查詢轉換成 MapReduce job。</p><p>這些創新，使得 Dremel 能夠支援 Google 內部從爬蟲數據分析、Android Market 安裝追蹤，到 Google Maps 與 Books 的 debug 場景，應用範圍非常廣。Dremel 論文不僅奠定了 Google 內部分析基礎，也直接啟發了後來的開源與商業系統。Google 自家推出的 <strong>BigQuery</strong>，就是以 Dremel 為核心架構發展而來。</p><p>Dremel 也是 <strong>Apache Parquet</strong> 的直接靈感來源，Parquet 官方文檔的原文是這麼描述的：</p><p>Parquet is built from the ground up with <strong>complex nested data structures</strong> in mind, and uses the <strong>record shredding</strong> and <strong>assembly algorithm</strong> described in the <strong>Dremel paper</strong>. We believe this approach is superior to simple flattening of nested name spaces.</p><h4>Parquet File Layout</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/601/1*L9O-Lj4V-QcsMAUpvs4tcA.gif" /><figcaption>Parquet file layout (出自 Parquet doc)</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bCnVW99_w7efs7uYRXOnJg.png" /><figcaption>Parquet file layout (出自 Julien Le Dem 的 slide)</figcaption></figure><p>Parquet 是一種 <strong>自我描述的列式檔案格式（self-describing columnar format）</strong>，透過層級化的結構設計，達到高壓縮比、高查詢效率與跨系統互通性。整個檔案的邏輯層級如下：</p><p><strong>1) Row Group<br></strong> Spark、Trino、Presto 的 scan parallelism 是以「Parquet 檔案數量」決定，一個檔案對應到一個 scan task。一個 Parquet 檔案可包含多個 Row Groups，每個 Row Group 代表資料集的一部分 rows（水平分片），其大小決定「單一 task 內部」的 scan 效率。Row Group 太小，I/O 呼叫變多、scan loop overhead 增加； Row Group 太大，predicate pushdown 無法細粒度跳讀。Row Group 的典型建議大小落在 128 MB 到 1 GB 之間，這是針對 I/O 效率與 predicate pushdown 的最佳折衷。</p><p><strong>2) Column Chunk<br></strong> 在每個 Row Group 之中，資料會再依 column（垂直方向）分開儲存，形成 <em>Column Chunk</em>。每個 column 都可以使用自己的壓縮與編碼方式（例如 Dictionary Encoding、Run-Length Encoding、Delta Encoding）。<br> 查詢引擎在讀取時，會根據 Column Chunk 的中繼資訊（metadata）進行 <strong>predicate pushdown</strong>：</p><ul><li><strong>Statistics</strong>（統計摘要）會記錄最小值、最大值與 null 數量，讓引擎能快速判斷該 chunk 是否可能符合查詢條件。</li><li><strong>Dictionary Page</strong> 儲存唯一值表，若查詢值不在字典中，整個 chunk 可直接跳過。</li><li><strong>Bloom Filter</strong>（可選）則是附屬於 Column Chunk 的機率性索引結構，用於快速判斷某個值是否「可能存在」於該 chunk 中，進一步加速查詢篩選。<br> 這些結構共同構成了 Parquet 在 column 層級的查詢加速機制，使系統能只讀取真正相關的資料。</li></ul><p><strong>3) Page<br></strong> 每個 Column Chunk 又會切分成多個 <em>Page</em>，是 Parquet 中最小的 I/O 單位（通常 8–64 KB）。Page 有數種型別：</p><ul><li><strong>Data Page</strong>：儲存實際的資料值。</li><li><strong>Dictionary Page</strong>：儲存唯一值表，用於字典編碼與查詢篩選。</li><li><strong>Index Page</strong>（可選）：提供定位資訊以加速查詢。<br> 每個 Page 都有 <strong>Page Header</strong>，以 <em>Thrift Compact Protocol</em> 序列化，記錄 repetition level、definition level、encoding 方式等中繼資訊，用來支援 nested structure 與 null 值。</li></ul><p><strong>4) Footer<br></strong> 檔案結尾的 <strong>Footer</strong> 是整個 Parquet 的「目錄」，同樣以 Thrift 格式儲存。它包含：</p><ul><li>全檔案的中繼資訊（FileMetaData），包括格式版本、Schema、key-value pairs。</li><li>每個 Row Group 與 Column Chunk 的偏移位置、壓縮方式與大小。</li><li><strong>Footer length（4 bytes）</strong> 與 <strong>Magic number “PAR1”</strong>，標示檔案結構邊界。</li></ul><p><strong>5) Magic Number 與自我描述性<br></strong> 每個 Parquet 檔案的開頭與結尾都包含 4 bytes 的 Magic number “PAR1”，用於快速識別檔案格式。由於所有 Schema 與 Metadata 都內嵌於 Footer，Parquet 是一種 <strong>完全自我描述（self-describing）</strong> 的格式 — — 即使沒有外部 schema registry，也能被任何支援 Parquet 的系統正確解讀。</p><h4>Predicate Pushdown</h4><p>為了在讀檔前就盡量「篩掉不相關資料」，Parquet 會把可判斷線索放在不同層級的中繼資訊裡，讓查詢引擎能在 <strong>row group / column chunk / page</strong> 等層級做 <em>pushdown</em>。以下以兩種粒度與四種主要機制來說明：</p><ul><li><strong>Row-level pushdown</strong>：利用 <strong>statistics / dictionary / bloom filter</strong>，在 <strong>row group / column chunk</strong> 粒度整塊跳過不相關資料。</li><li><strong>Page-level pushdown</strong>：利用 <strong>Page Index</strong>，在 <strong>page</strong> 粒度再進一步裁剪 I/O。</li></ul><h4>1️⃣ Statistics（統計摘要）</h4><ul><li><strong>層級</strong>：Footer → Column Chunk metadata</li><li><strong>功能</strong>：每個 column chunk 都包含最小值、最大值與 null 數量等統計資訊。</li><li><strong>用途</strong>：若整個 chunk 的 max &lt; 條件下界 或 min &gt; 條件上界，系統可直接略過該 chunk。</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/362/1*mdNQBcoFgtrYL4h2W9XJBg.png" /></figure><ul><li>若某 column chunk 的 max_amount &lt; 100，該 chunk 不讀取。</li></ul><h4>2️⃣ Dictionary（字典頁）</h4><ul><li><strong>層級</strong>：Column Chunk → Dictionary Page</li><li><strong>功能</strong>：收錄該 chunk 的唯一值，提供低基數欄位的等值查詢快速過濾。</li><li><strong>用途</strong>：查詢條件若等於某值（=），可先掃字典；若目標值不在字典中，整個 chunk 可跳過。</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/382/1*RGolJmH-v4PcCEpmKJubBA.png" /></figure><ul><li>若該 chunk 的 dictionary 無 ‘SF’，整個 chunk 不讀取。</li><li><strong>適用場景</strong>：低基數（low cardinality）欄位，如地區、狀態、分類等。</li></ul><h4>3️⃣ Bloom Filter（可選）</h4><ul><li><strong>層級</strong>：Row Group → Column Chunk → Bloom Filter Metadata（optional）</li><li><strong>功能</strong>：高基數欄位的機率性索引結構，可快速判斷「不可能存在」。</li><li><strong>用途</strong>：若 Bloom filter 判定該值不存在，整個 chunk 可直接略過。</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/500/1*3eN3S3Otyh88wESrEfC-4g.png" /></figure><ul><li>若 Bloom filter 判定 user_id 不存在於該 chunk，則略過。</li><li><strong>適用場景</strong>：高基數（high cardinality）欄位，如 user_id、訂單號碼等。</li></ul><h4>4️⃣ Index Page</h4><ul><li><strong>層級</strong>：Column Chunk → Index Page（可選）</li><li><strong>功能</strong>：記錄每個 page 的 min/max 值與偏移量，可在 chunk 內做更細的篩選。</li><li><strong>用途</strong>：能在同一 chunk 內僅讀取符合條件範圍的 pages。</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/952/1*jkcCD0iWVw7WVJPUjqfsEQ.png" /></figure><ul><li>查詢引擎僅讀取含 80–90 分的 pages，其餘 pages 直接略過。</li></ul><h4>Parquet vs ORC</h4><p>在 2010 年代初期，<strong>Parquet</strong> 與 <strong>ORC</strong> 相繼誕生於 Hadoop 生態系。它們都是開放式的 <strong>PAX format</strong>，旨在讓不同查詢引擎（Hive、Spark、Impala、Presto 等）共享相同的底層儲存結構。雖然兩者都採用 column-oriented 壓縮與編碼，但設計哲學上存在顯著差異。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*x7vNuym09BdWG_c8Kqubew.png" /></figure><p>這篇論文〈<em>An Empirical Evaluation of Columnar Storage Formats</em>〉（VLDB 2023）重新檢視了這兩種格式在現代硬體與機器學習工作負載下的行為，深入比較它們在壓縮策略、解碼效能與 metadata 設計上的取捨。</p><h4>Parquet 的設計取向：跨平台與通用性</h4><p>Parquet 的核心理念是 <strong>cross-platform compatibility</strong>。它的格式結構簡潔，metadata 集中且自我描述。Parquet 預設啟用 <strong>Dictionary Encoding</strong> 來壓縮重複值，並以 <em>Thrift</em>-based metadata 方式儲存 schema，使不同語言與系統都能輕鬆解析。</p><p>這種編碼在大多數低基數（low cardinality）資料中表現極佳，甚至在 floating-point 欄位中也有良好壓縮效果。Parquet 在壓縮比與解碼速度間採取平衡策略 — — 寧可略降壓縮比，也要保證高速解碼，讓它在多引擎環境與 ad-hoc 查詢中仍具高適應性。</p><h4>ORC 的設計取向：針對 Hive 的深度優化</h4><p><strong>ORC（Optimized Row Columnar）</strong> 則以 Hive 為主要應用場景，強調高壓縮比與批次查詢效能。它採用更緊密的 metadata 結構，支援 <strong>stripe-level indexing</strong> 與更豐富的 encoding 演算法（例如 RLE、Delta、Bit Packing）。這些策略能顯著節省儲存空間，但代價是增加 <strong>CPU-bound decoding</strong>。研究指出，在現代硬體環境中，效能瓶頸已逐漸從 I/O 移向 CPU 和記憶體運算，因此過度壓縮可能反而拖慢查詢速度。</p><h4>實驗觀察：新硬體環境下的取捨</h4><p>Andy Pavlo 的研究團隊設計了一套基準測試，模擬不同資料分佈與查詢模式。主要觀察結果如下：</p><ul><li><strong>Dictionary Encoding</strong> 在 Parquet 與 ORC 中皆普遍有效，建議持續強化此機制。</li><li><strong>簡化的 encoding 機制</strong> 可提升解碼速度，避免在查詢時因過多 codec 選擇造成延遲。</li><li><strong>Block compression</strong> 應設為可選功能（optional），除非能帶來明顯 I/O 效益。</li><li><strong>集中式 metadata layout</strong> 更有利於 wide-table 工作負載與高延遲雲端儲存環境。</li><li><strong>更細粒度的索引與過濾結構</strong>（如 page-level statistics 或 bloom filter）能顯著改善查詢延遲。</li><li><strong>Nested data models</strong> 應貼近 in-memory 格式（例如 Apache Arrow）以降低序列化開銷。</li></ul><h4>此論文給出的八項未來設計建議</h4><p>研究最後總結了八項具體教訓與設計方向，可作為未來 Column Format 的指引：</p><ol><li><strong>Aggressive Dictionary Encoding<br></strong>Dictionary encoding 對各種資料型態（甚至浮點數）都有效，因為多數真實世界資料的 NDV（Number of Distinct Values）比率偏低。未來格式應如 Parquet 一樣積極採用。</li><li><strong>Simplicity in Encoding Schemes<br></strong> 編碼機制應保持簡單一致，以確保穩定的解碼效能。過度複雜的 codec 選擇會增加 CPU 成本並影響查詢延遲。</li><li><strong>Computation-bound Optimization<br></strong> 隨著硬體發展，查詢瓶頸已從 I/O 轉向 CPU。未來格式應限制過重的壓縮與編碼，僅在收益明確時才使用。</li><li><strong>Centralized Metadata Layout<br></strong> Metadata 結構應集中化，並支援 random access，以便處理機器學習中常見的 wide tables。I/O block 大小亦應優化以匹配高延遲雲端儲存。</li><li><strong>Richer Auxiliary Index Structures<br></strong> 儲存成本日益降低，未來格式可內嵌更多索引與過濾結構（如 page-level filters、bloom filters）來加速查詢。</li><li><strong>Nested Data Affinity with In-memory Formats<br></strong> Nested data model 應設計得更貼近 in-memory 結構（如 Apache Arrow），以減少在 query engine 間的轉換負擔。</li><li><strong>ML-oriented Layout and Type Support<br></strong> 針對機器學習的 workload，未來格式需同時支援 wide-table 投影與低選擇性查詢，並為大型 binary objects 與浮點數設計專用壓縮機制。</li><li><strong>GPU-friendly Decoding<br></strong> 為了支援 GPU 加速，格式設計應提供足夠的平行 data blocks，並採用可在 GPU thread block 間並行運作的編碼演算法。</li></ol><h4>此論文的小結：不同時代的最適解</h4><p>整體而言，<strong>Parquet</strong> 代表跨平台、開放與高通用性的設計思維，而 <strong>ORC</strong> 則體現針對 Hive 的高壓縮與批次優化哲學。前者在多引擎與雲端環境中已成事實標準；後者在 Hadoop 時代達到效能巔峰。</p><p>然而，隨著硬體架構演進與機器學習負載興起，未來的 Column Format 必須重新定義其底層假設，更貼近 <strong>CPU / GPU 平行運算、wide-table 結構與 cloud-native 儲存</strong> 的實際需求。</p><h4>Small Files, Big Problem</h4><p>大珠小珠落玉盤，本用以形容琵琶音色之清脆悅耳。然而，當無數細碎的 Parquet 小檔案落在儲存裝置上時，這份清脆便瞬間走調，成了資料工程師所認證的不協和音。這樣的失衡並非美感問題，而是實實在在影響效能與成本的技術痛點。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/312/0*4myDhsIxiAZsSO7t" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/1*iFGmDW3V-GUShsxY9i_XZA.png" /></figure><p>在討論這些效能問題之前，我們先簡單理解 Spark 的執行架構。Spark 的查詢由 <strong>Spark Driver</strong> 負責調度與計畫，並將工作拆成一個個 <strong>job</strong>；每個 job 再被 Driver 分解成多個 <strong>tasks</strong>，交給叢集上的 <strong>Spark Executors</strong> 實際執行。Executors 會根據讀到的檔案數量啟動對應的 tasks 來掃描資料；因此，檔案越多、task 越多，整個查詢的調度成本與執行負擔就越高。</p><p>於是，想像同樣是 60 MB 的資料，如果把它存成一個 60 MB 的檔案，只需要一次 S3 GET 呼叫；但如果資料被切成一百萬個只有 60 bytes 的小檔案，那就需要一百萬次 GET。在 S3 的收費模型中，儲存空間費用反而不是主角，真正昂貴的是 API 呼叫次數：每一次 GET、LIST、PUT 都會依操作次數計費，而小檔案會讓這些操作暴增到難以想像的規模。</p><p>儘管兩者的資料量相同，成本卻因操作次數而暴增，形成典型的「作業成本高於資料本身」的現象。更糟的是，當 Spark Driver 要處理這些一百萬個小檔案時，會將它們拆成同等數量的 scan tasks，由 Executors 排隊執行。這會導致 Driver 的 scheduler 壓力驟增，Executors 間 context switching 次數暴增，而整體查詢延遲也成倍放大。這種 overhead 並非來自資料本身，而是來自小檔案所引發的巨量作業管理成本。</p><p>以下這三種寫入場景，容易造就小檔案，分別是：</p><p>1）Event stream</p><p>在 IoT、使用者行為分析或 app logs 這類即時資料管線中，資料透過 Kafka 或 Kinesis 以高頻率不斷抵達。這些事件多半只有幾百 bytes 到幾 KB 大小，每一個 micro-batch 或每次 flush 都會落成一個 Parquet 小檔案。以每秒一個 batch 為例，一個小時就會產生 3600 個檔案。</p><p>2）Over-parallel Spark jobs</p><p>Spark 的高平行度本來是優點，但如果平行度設定過高，就會把原本應該整合的寫入資料切成許多細小的碎片。例如，一個作業設定 200 個 Executors、每個 Executor 起 4 個 tasks，那麼僅一次 job 就可能產生 800 份 Parquet 寫入。</p><p>3）Over-partitioned Delta table</p><p>Delta 表格的 partition 設計原意是為了提升查詢效率，但若 partition key 分得過細，例如：</p><p>country=US/state=CA/city=SanJose/hour=13/minute=05/second=32</p><p>那麼資料便會被切割到每個 partition 僅剩幾筆 rows。這使得 delta table 內部充斥著大量極小的 Parquet 檔案。</p><h4>Databricks’ Solution to Small Files</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*bHL-gOukkTEIC0kC.png" /></figure><p>要解決這個問題，核心思路只有一個 — — <strong>減少檔案數量、增大單一檔案的大小（file consolidation）</strong>。在現代 Databricks 生態中，Spark、Databricks Runtime、以及 Delta Lake 分別提供不同層級的解法，從手工到半自動，再到完全結構化的表格管理，各自對應不同來源的小檔案問題。</p><p>在 Spark 層級，我們可以透過 repartition() 或 coalesce() 明確控制寫入檔案的數量。repartition() 會重新 shuffle 資料，使寫入檔案的分布更平均；coalesce() 則能在不進行 shuffle 的情況下減少 partitions。這兩者雖然簡單實用，但仍屬於手工調整，需要工程師在每一個 job 中精心設定，無法真正達到持續且自動化的最佳化。</p><p>Databricks Runtime 提供更進一步的 <strong>Auto Optimize</strong>。在寫入 Delta 表格時啟用 optimizeWrite 與 autoCompact，系統便能在寫入過程中自動調整檔案大小，並在背景完成檔案的合併。這種行為對於高頻率 ingestion（例如 streaming 或 CDC 管線）特別有效，可大幅降低小檔案的生成機率。</p><p>最後，Delta Lake 能以「表格」為單位進行更進階的結構化調整。首先，<strong>OPTIMIZE + ZORDER</strong> 透過 bin-packing 合併小檔案，並使用多維度 clustering 讓查詢時的 predicate pushdown 更有效率。例如，以下 SQL 會將指定表格的小檔案重新打包，並同時使用 ZORDER 讓相關 column 更緊密地排列在物理空間中：</p><p>OPTIMIZE user_events<br>WHERE event_date &gt;= ‘2024–01–01’<br>ZORDER BY (user_id, event_type);</p><p>其次，<strong>delta.targetFileSize</strong> 則讓 Delta 能依據工作負載，自動調整 rewrites 的目標檔案大小，使表格在長期運行下維持健康且穩定的檔案分布。</p><p>透過這兩種方式，Delta Lake 能在維持資料可用性的同時，大幅降低因小檔案過多而引起的額外費用、延遲與查詢效率問題。</p><h4>Solution to Small Files in Lakehouse Era</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*vhGypRu0bdP6KJeF.png" /></figure><p>進入 Lakehouse 時代後，Table Format 已具備更完整的資料生命週期管理能力，小檔案的處理不再只是 Spark 的參數調校，而成為整個表格語意中的一環。Apache Hudi、Apache Iceberg 與 Delta Lake 這三大開源表格式──以及 2022 年誕生的後起之秀 Apache Paimon──各自提供不同的小檔案治理與檔案重寫機制，能在不中斷資料管線的情況下持續維護表格健康，避免大量小檔案累積造成的成本與效能惡化。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/930/1*4dRFnEHglAvkVtljINaY2g.png" /></figure><p>在 Apache Hudi 中，小檔案治理奠基於兩條主要技術路線：Compaction 與 Clustering。對於 Copy-on-Write（COW）與 Merge-on-Read（MOR）兩種儲存策略，Hudi 以 Compaction 將累積的 log files 合併為 columnar 檔案；而 Clustering 則透過重新排序（sort）與 bin-packing，把碎片化的資料重新寫成較大、較均勻的 Parquet 檔案。更重要的是，Hudi 具備內建的 scheduling 機制，能在 ingestion pipeline 背後自動安排 compaction，使表格能在高吞吐寫入下仍然維持健康的檔案大小與排序特性。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/728/0*EeG7_vsQvx62x8WM.png" /></figure><p>與 Hudi 類似，Apache Paimon 也同時具備 <strong>same-job compaction</strong> 與 <strong>dedicated compaction job</strong>，但採用完全不同的底層設計。Paimon 的架構建立在 Log-Structured Merge (LSM) tree 之上，這是一種專為高頻率寫入而設計的儲存技術。Paimon 不會直接修改既有檔案，而是將變更寫入 immutable segments，並定期進行合併以優化查詢效能。透過結合 LSM 的寫入特性與 ORC/Parquet 的 columnar 優勢，Paimon 兼具即時更新能力與高效分析能力，非常適合用於 CDC（Change Data Capture）、即時數據管線、dashboard、以及低延遲 OLAP 查詢等場景。</p><p>Apache Iceberg 則採用了截然不同的理念。Iceberg 將 rewrite 作業視為表格語意的一部分，透過 RewriteDataFiles API 重組檔案，對小檔案進行 bin-packing，或依照 Sort Order 進行 clustering。此外，Iceberg 能重寫 manifest files，避免 metadata 自身碎片化，使 query planning 不會因 manifest 膨脹而延遲。Iceberg 的 rewrite 依賴 Spark 或 Flink 來執行，通常以批次（batch）方式手動觸發，不像 Hudi 具備強制性的 background scheduling，但它在 metadata 與 snapshot 層面的語義完整性更嚴謹。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*B5WpAQur4sdqaIqkQhkRXg.jpeg" /></figure><p>如果將 Iceberg 部署在 S3 + AWS Glue 之上，Iceberg 的 rewrite data files（compaction、bin-packing）並不會自動觸發。由於 Spark 本身沒有內建類似 cron 的排程能力，工程師必須使用外部 orchestrator 來定期觸發 rewrite job，例如透過 Airflow、AWS Step Functions 或 Glue Workflow 排程 Spark（或 EMR Serverless）執行 Iceberg Rewrite。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*AUHAAs3ldfKCt6l4" /></figure><p>如果將 Iceberg 部署在 Ryft 服務中，其 Adaptive Optimization 會根據 workload 動態管理 table files：針對過小、過舊或 delete-heavy 的 partitions 執行 targeted compaction；針對熱門資料進行 streaming-aware tiered compaction，使 micro-merge 不阻塞 SLA；同時合併 manifest files 以減少 metadata planning 的開銷。這種方式將 compaction 提升到「工作負載感知」的層級，而不只是簡單的 bin-packing。</p><h3>Arrow for High-Performance Data Transfer</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*46a_JRL3Pxl4YWCp79s3wQ.png" /></figure><p>使用 SQL 或 dataframe 下達一個查詢之後，將查詢結果從來源端傳送到目的端，通常需要經過三個步驟：</p><p>1) <strong>在來源端進行序列化（serialize）</strong> — — 將查詢結果從內部記憶體結構轉換成可傳輸的格式。</p><p>2) <strong>透過網路傳輸資料</strong> — — 以該傳輸格式發送資料流。</p><p>3) <strong>在目的端進行反序列化（deserialize）</strong> — — 將傳輸格式轉回目的系統可用的內部結構。</p><p>在過去網路速度較慢的年代，第二步「傳輸」常是主要瓶頸，因此設計重點在於縮小資料體積，通常透過壓縮來減少傳輸時間。這也是許多傳統資料庫協定（如 ODBC、JDBC、MySQL client/server protocol、PostgreSQL frontend/backend protocol）誕生的背景。</p><p>但隨著網路頻寬提升，瓶頸逐漸轉移到第一步和第三步的 <strong>ser/de </strong>本身：資料在查詢後與分析前不斷被重複編碼與解碼，造成 CPU 大量浪費與延遲。</p><p>Apache Arrow 的出現，改寫了這個遊戲規則。它是一個專為加速（甚至在許多情況下<strong>消除</strong>）ser/de 而設計的開源資料格式。自 2016 年問世以來，Arrow 及其跨語言工具生態迅速普及。</p><h4>Arrow 的三大特性</h4><p><strong>1) Arrow 格式是 Columnar</strong></p><p>所謂 <strong>columnar（column-oriented）資料格式</strong>，是指同一個 column 的所有值會被存放在連續的記憶體區塊中。這與 row-oriented 資料格式相反，後者是將每一個 row 的所有值存放在連續區塊中。</p><p>高效能的分析型資料庫、資料倉庫、查詢引擎與儲存系統幾乎都採用 columnar 架構，因為這樣能加速最常見的分析查詢操作。現代的 columnar 系統包括：Amazon Redshift、Apache DataFusion、ClickHouse、Databricks Photon Engine、DuckDB、Google BigQuery、Microsoft Azure Synapse Analytics、Vertica、Snowflake，以及 Voltron Data Theseus。</p><p>同樣地，許多分析結果的目的地（例如 BI 工具、資料應用平台、dataframe 函式庫與機器學習平台）也採用 columnar 架構。常見的 columnar BI 工具有 Amazon QuickSight、Domo、GoodData、Power BI、Qlik Sense、Spotfire、Tableau；常見的 columnar dataframe 函式庫則有 cuDF、pandas、Polars。</p><p>如今，查詢結果的來源與目的地往往都是 columnar 格式。因此，最有效率的資料傳輸方式就是使用 <strong>columnar transfer format</strong>，避免在來源端 ser/de 時花時間將資料從 column 轉成 row，再在目的端將 row 轉回 column。</p><p><strong>Arrow 本身就是 columnar 資料格式。</strong>它的記憶體布局與多數常見的 columnar 系統相似，甚至在許多情況下完全一致，這使得系統之間能直接交換資料而不需轉換結構。</p><p><strong>2) Arrow 格式支援 Zero-copy</strong></p><p>Zero-copy 指的是資料從一個媒介傳輸到另一個媒介時，不需要產生任何中間複本（intermediate copies）。要做到這件事，資料在記憶體、磁碟與網路上的 binary layout 必須完全一致。</p><p>Arrow 的設計完全支援 zero-copy。它將資料儲存在一組 columnar 的連續記憶體區塊中，其 layout 本質上是：</p><p>[ validity bitmap ][ offsets buffer ][ values buffer ]</p><p>這三塊 buffer：</p><ul><li>在磁碟 (Arrow IPC file format)</li><li>在網路（Arrow Flight）</li><li>在記憶體（in-memory Arrow arrays）</li></ul><p>的二進位結構是<strong>一模一樣的</strong>。</p><p>因此，Arrow 與 JSON、CSV 這類文字格式或 Protocol Buffers、Thrift 這類序列化格式不同 — — 後者在載入時必須經過 parsing 與 decoding。Arrow 也不同於 Parquet、ORC 這類使用壓縮與編碼以減少磁碟空間的格式 — — 它們在讀取時也需要解壓與解碼。</p><p>換句話說，如果來源系統中的資料已經是 Arrow 格式，那麼它可以直接以 Arrow 格式透過網路傳輸，不需任何 ser/de；目的系統接收到之後，也能直接讀入記憶體或磁碟中的 Arrow File，不需任何反向 ser/de。</p><p>由於 Arrow 的核心設計目標就是高效的 in-memory 分析操作，許多現代的 columnar 系統直接以 Arrow 作為記憶體格式，包括 Apache DataFusion、cuDF、Dremio、InfluxDB、Polars、Velox、Voltron Data Theseus。當這些系統作為資料傳輸的來源或目的地時，ser/de 的開銷幾乎可以完全消除。</p><p>而對於其他採用相似記憶體布局的系統（例如 Spark、Trino、ClickHouse、DuckDB，請參考附錄），Arrow 與它們之間的 ser/de 幾乎可以視為「輕量轉換」，成本遠低於傳統 row-based 系統。</p><p>3) Arrow 格式支援 Streaming</p><p>所謂 <strong>streamable data format</strong>，是指資料能夠被「逐塊（chunk-by-chunk）」處理，無需等到整個資料集載入完成才能開始分析。當資料以 streamable 格式傳輸時，接收端能在第一個 chunk 抵達時就開始處理。這帶來多重效益：</p><ul><li>傳輸與處理時間可以重疊。</li><li>記憶體使用更有效率。</li><li>多個 stream 可同時傳輸，加速整體 throughput。</li></ul><p>CSV 就是一個可 streaming 的格式，因為它在檔案開頭就包含欄位名稱（header），後續每一行都可依序處理。相對地，Parquet 與 ORC 則無法 streaming，因為它們的 schema 與 metadata 位於檔案尾端（footer），處理前必須先讀完整個檔案或至少下載 footer。</p><p><strong>Arrow 則是一種可 streaming 的格式。</strong>一個 dataset 可以表示成多個 <strong>RecordBatch</strong> 序列，所有 RecordBatch 共用相同的 schema。Arrow 定義了一種 streaming 結構：由 schema 開始，接著是一個或多個 RecordBatch。接收端可以隨著每個 batch 到達即時處理，而不需等待整個 dataset 傳完。</p><p>換言之，Arrow 的 streaming 特性讓資料傳輸與分析能同時進行，這也是它能在即時分析與高吞吐管線中表現卓越的原因之一。</p><h4>以 Arrow 在 Spark Executor 與 Spark Driver 之間進行資料串流傳輸為例</h4><p>在 Spark 的架構中，Spark Driver 是整個查詢的「指揮者」，負責解析 SQL、產生 logical plan 與 physical plan，並將計算任務拆解成多個 task 分派給叢集中的 Spark Executors。Executor 則是負責實際讀取資料、執行運算、產生中間結果，並在需要時回傳給 Driver。兩者之間的資料交換十分頻繁，是典型的分散式 compute-to-compute 通訊場景。</p><p>資料通常先以 Parquet 格式儲存在 Object Store（例如 S3）。當 Spark Executor 讀取這些資料時，它會先從 S3 取得 Parquet 的 raw bytes，接著依序解析每個 <strong>Row Group</strong>。對每個 Row Group 而言，Executor 會將其中所有 column chunk 展開成連續的 columnar buffer，並根據相同的 row 邊界重新組合，最終轉換成 <strong>一個或多個 Arrow RecordBatch</strong>。這樣的轉換方式使得資料能立即進入 Arrow 的 columnar in-memory 格式，並啟用 Spark Tungsten 引擎的 vectorized execution，以提升 CPU 使用效率。</p><p>在完成部分計算後，Executor 需要將中間結果傳回 Spark Driver。這是 Arrow 展現其價值的關鍵時刻：Executor 不需要把 columnar 資料重新序列化成 row-based 格式，也不需要透過傳統 JDBC/ODBC 等 legacy protocol，而是直接以 Arrow 的記憶體結構進行傳輸。由於 Arrow 的 in-memory 佈局同時就是 on-wire 格式，資料傳輸時不需要轉置、不需重新編碼，也不需重建 schema metadata，避免了昂貴的 row-to-column 或資料重建程序。這就是 Arrow 的 <strong>zero-copy</strong> 特性，使節點之間的資料交換不必建立任何中間複本。</p><p>更重要的是，Arrow 支援 <strong>streaming</strong> 格式，允許資料以逐批（batch-by-batch）的方式傳送至 Driver。Arrow streaming 的傳輸順序如下：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/856/1*g5xp2iRBZUPk4LkEZlN5tA.png" /></figure><p>其中，<strong>StreamHeader</strong> 只會在串流開始時出現一次，用來傳遞整份資料的 schema，包括欄位名稱、資料型態、nullable 規則等。此後所有 RecordBatch 都必須遵守這個 schema，因此在傳輸過程中不需反覆送出 metadata，大幅降低了 ser/de 負擔。</p><p>相比之下，每個 RecordBatch 內部也會有一個 <strong>batch header</strong>，但它只描述本批資料的：</p><ul><li>row 數</li><li>各 column buffer 的大小與位置</li><li>validity bitmap、offset buffer 的索引</li></ul><p>換言之，<strong>Batch header 管理的是「資料切片資訊」，而 StreamHeader 管理的是「資料結構資訊」。兩者互不重疊。</strong></p><p>透過這樣的設計，Spark Driver 不必等到整個結果集完成，即可在第一個 RecordBatch 抵達時立刻展開處理。這種「邊傳輸、邊處理」的特性，讓傳輸與計算可以重疊執行，顯著降低 end-to-end latency；同時 Driver 不需要一次載入全部結果，也使記憶體使用更加高效。</p><p>總結來說，Spark Executor 能以極高效率將資料傳回 Spark Driver，除了 Spark 本身的引擎優化之外，真正讓 compute-to-compute 資料交換順暢的，是 Arrow 提供的這三種特性：</p><ul><li>columnar memory layout</li><li>zero-copy 傳輸能力</li><li>streaming 的批次傳輸格式</li></ul><h4>OLTP vs OLAP</h4><p>儘管 Arrow 幾乎是理想的分析型（OLAP）資料交換格式，但它並不適合 OLTP 類型的工作負載。原因在於 OLTP 與 Arrow 在資料模型、傳輸單位與操作方式上有根本性的差異。</p><p>首先，OLTP 查詢往往是 <strong>row-based</strong>，例如「查一筆」、「插入一筆」、「更新一筆」。Arrow 的 columnar 記憶體布局並不是為 per-row 操作設計的，因此對單筆資料進行更新、覆寫或 partial update 並不自然。Arrow array 本身偏向 <strong>immutable、append-only</strong>，更新其中某個值通常需要重建該 column 的 buffer，而不像 row-based protocol 可以直接覆寫一筆 row。</p><p>其次，OLTP 訊息通常非常小、需要低延遲，適合使用精簡、明確的 RPC 格式，例如 Protobuf、JSON 或 Thrift。這些格式天生支援以結構化封包描述「一次一個操作」的請求，例如：</p><ul><li>update user where id = ?</li><li>insert record with fields X, Y, Z</li></ul><p>Arrow 則是為 <strong>批次傳輸（batches of rows）</strong> 設計，動輒使用成千上萬筆 row 的 columnar buffer 作為傳輸單位。若用 Arrow 傳小訊息，反而會比 Protobuf/Thrift 更沒效率，也沒有必要。</p><p>總結而言：<strong>Arrow 是為高吞吐、批次資料、分析型（OLAP）工作負載而設計。OLTP 的 row-by-row、低延遲、頻繁 mutation 特性，與 Arrow 的 columnar 模型并不契合。</strong></p><p>因此在現代系統中，<strong>OLTP </strong>多採用 Protobuf、JSON、Thrift 等 row-based RPC 格式。<strong>OLAP </strong>則多採用 Arrow、Arrow Flight、Arrow Flight SQL 進行高速資料交換。兩者服務於不同的操作模型，各自最佳化在完全不同的使用場景。</p><h4>Parquet vs Arrow</h4><p>Parquet 是為 persistent storage I/O 最佳化的 PAX format，強調壓縮率與長期持久化存儲。它在 persistent storage 上採用 row group、page、footer 等結構，能有效支援 predicate pushdown、索引、與高效壓縮。Arrow 則是為記憶體 I/O 最佳化的 columnar format，追求的是低延遲、高頻隨機存取，以及零拷貝（zero-copy）運算。</p><p>換句話說，Parquet 是資料「靜態存在」的形態，Arrow 是資料「動態運算」的形態。</p><p>在現代查詢引擎中，兩者幾乎總是前後搭配：資料長期以 Parquet 儲存在 persistent storage 上，當查詢引擎（例如 Spark、ClickHouse、DuckDB、DataFusion）需要讀取時，會將必要的 row group 或 column chunk 讀入記憶體並轉換為 Arrow 格式，以便進行 vectorized execution。這樣的設計讓 I/O 壓縮與 CPU 運算各自達到極致。</p><p>值得注意的是，Arrow 其實也有 persistent format（Arrow IPC file、Arrow Feather format），可直接將 Arrow RecordBatch 寫入 persistent storage，甚至支援零拷貝讀回記憶體。然而，這些格式仍以暫存或中間資料交換為主，並不如 Parquet 在<strong>壓縮</strong>與 <strong>predicate pushdown</strong> 上高效。換言之，Arrow persistent format 強調「快速載入與暫存」，Parquet persistent format 則強調「持久化與查詢優化」。</p><p>當資料在查詢引擎中被載入記憶體後，實際的資料生命週期由記憶體管理機制掌控。大多數 query engine 會採用 <strong>LRU（Least Recently Used）</strong>或<strong>基於引用計數的 page replacement 策略</strong>，來管理 Arrow RecordBatch 或 ColumnBuffer 的釋放。例如，在 Spark 或 DuckDB 中，當系統的 memory pool 接近上限時，會將最久未使用的 Arrow page 釋出，必要時再從 Parquet 重新載入。這使得整個系統能在 persistent storage（Parquet）與記憶體（Arrow）之間形成高效率的冷熱資料分層（cold-hot tier）。</p><p>因此，Parquet 與 Arrow 並非競爭關係，而是一種分工：<strong>Parquet 是資料的長期記憶，Arrow 是資料的即時意識。</strong>兩者透過統一的格式與 schema 定義，使資料能在 persistent storage 與記憶體之間自由切換而不失語意，最終讓查詢真正達到「從不可變檔案到零拷貝運算」的理想。</p><h4>Arrow Stack</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/936/1*NFUGOGhxMdwfvtVzVFz8tA.png" /></figure><p>這張圖展示了 Apache Arrow 的協定堆疊。整個架構從最底層的網路傳輸開始，逐層向上構成一條能在現代資料分析環境中高速交換查詢結果的資料通道。從 <strong>Network</strong>，到 <strong>gRPC</strong>、<strong>Arrow</strong>、<strong>Arrow Flight</strong>，再到 <strong>Arrow Flight SQL</strong>，各層的責任範圍明確，分工互不重疊。</p><p>最底層的 <strong>Network</strong>（TCP/IP）專注於封包能否成功送達，它只處理位元流，不理解 schema、格式或查詢語意。其上是 <strong>gRPC</strong>，它是一種高效能傳輸協定，而不是資料格式。gRPC 提供 HTTP/2 的雙向串流、flow control、錯誤處理與穩定連線能力，使分散式系統能以低延遲、高吞吐交換資料。然而，gRPC 並不理解 Arrow，它只負責傳送 protobuf message；至於 message 裡面要放什麼，則是更上層的責任。</p><p>再往上是 <strong>Arrow</strong>，本質是一套 columnar 的記憶體格式。Arrow 解決的是「資料長什麼樣」的問題──包括 buffers、offsets、null bitmaps 與 schema metadata。因為 Arrow 定義了一種跨語言統一的二進位資料布局，不同系統可以用 zero-copy 的方式共享資料，而不必重新序列化。</p><p><strong>Arrow Flight</strong> 位於資料格式之上，它是一套「高速傳輸協定」，解決的是「Arrow 資料如何傳」的問題。傳統 JDBC/ODBC 會把每一列轉成文字（CSV / JSON），造成巨大的序列化成本；而 Arrow Flight 能直接在網路上傳 Arrow 的記憶體 buffer，使 row-oriented ser/de 幾乎完全被消除。格式（Arrow）與傳輸（Flight）因此被明確拆分，兩者共同提升效能。</p><p><strong>Arrow Flight SQL</strong> 在此基礎上提供 SQL 層的抽象。它支援 prepare、execute、fetch 與 metadata operations，讓客戶端以 SQL 操作資料，而底層依然以 Arrow RecordBatch 為傳輸單位。這使得 SQL 查詢能以 Arrow Flight 的速度移動資料，不再受限於傳統 row-based 的序列化開銷。</p><p>最上層是 Client，分成三類族群：</p><ol><li><strong>Legacy Clients</strong>：現有應用若已採 JDBC/ODBC 寫成，可透過 Arrow Flight SQL 的 JDBC/ODBC driver 無痛遷移到 Arrow-based 的高速管道上。</li><li><strong>SQL Native Clients</strong>：例如 DataGrip、DBeaver、Snowflake Flight SQL Connector，可直接使用 Arrow Flight SQL，以 columnar 方式收發資料。</li><li><strong>Non-SQL Clients</strong>：如 Pandas、Polars、cuDF、TensorFlow，它們不執行 SQL，但能直接消費 Arrow 的 columnar buffer，或透過 Arrow Flight 接收資料。</li></ol><p>總結來說：Arrow 決定資料的格式，Arrow Flight 決定資料的傳輸方式，而 Arrow Flight SQL 在此基礎上提供 SQL 語意。透過這種分層協作，查詢結果能以最少複本、最低 CPU 成本，以及真正 columnar 的方式在系統之間高速移動。</p><h4>與 Arrow 相關的 Projects</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/996/1*85GeqcVDHk2cowFAQWZGHA.png" /></figure><p>Arrow 的成功並未止步於記憶體格式本身。在 Arrow 生態的延伸中，<strong>Rust 社群</strong>開發了 <strong>DataFusion</strong> — — 一個以 Arrow 為核心的 query engine，完整實作了 SQL parsing、logical plan、physical plan、optimizer 與 execution engine。它的設計理念是：「用 Arrow 統一資料格式，用 Rust 提升執行效能」。</p><p>DataFusion 在整個查詢堆疊中的角色，類似於 <strong>LLVM</strong> 之於編譯器生態。LLVM 的 IR（Intermediate Representation）是跨 programming language 的中介層，讓不同的語言前端（如 Clang、Rustc、Swiftc）共用同一套後端優化與 codegen。同樣地，DataFusion 以 Arrow 為底層記憶體格式，負責把不同的 SQL dialect 轉換成兩種統一的 Query Plan：先生成 <strong>Logical Plan</strong>，再經過優化轉成 <strong>Physical Plan</strong>，最後交由執行引擎執行。</p><p><strong>DataFusion Logical Plan</strong> 代表 SQL 經語法分析（parsing）後的抽象語意樹（AST），與特定 query engine 的邏輯結構綁定；<strong>DataFusion Physical Plan</strong> 則是根據 optimizer 決定具體執行路徑（例如選擇 Hash Join 或 Sort Merge Join）。即便 DataFusion 能解析多種 SQL dialect，其 Logical Plan 仍只是 DataFusion 自身的內部格式。</p><p>為了讓查詢語意能在不同系統之間流通，Arrow 社群進一步提出了 <strong>Substrait</strong> — — 一種可將 Logical Plan 轉換成 <strong>Substrait Query Plan</strong> 的開放標準。Substrait 是 execution-agnostic 的交換格式，使不同查詢引擎（如 Spark、Trino、DuckDB、DataFusion）能共享查詢語意。整個轉換流程如下：</p><p><strong>SQL → DataFusion Logical Plan → Substrait Query Plan → DataFusion Physical Plan → Execution</strong></p><p>舉例來說，假設使用者執行以下 SQL：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/566/1*_fXyg1NO7YJh8KEnsZFLnw.png" /></figure><p>在 DataFusion 內部，這段查詢首先被解析成 Logical Plan，形成一棵語意樹（AST），包含節點：Filter(region = ‘US’)、Aggregate(SUM(amount) BY customer_id)、Projection(customer_id, SUM(amount))。這些節點描述了「查詢邏輯」，但尚未決定具體執行方式。</p><p>經過優化器後，DataFusion 會生成 Physical Plan，例如：採用 HashAggregateExec（而非 Sort-based aggregate）；採用 ParquetExec 作為掃描節點（直接從磁碟讀取 Parquet 檔）；採用 FilterExec 實作 predicate pushdown。</p><p>若 Logical Plan 被導出為 Substrait Query Plan，它會被序列化成跨系統可理解的 <strong>protobuf</strong> 結構，記錄「Filter」「Aggregate」「Projection」等運算節點的語意，而不依賴任何特定引擎實作。因此，這份 Substrait Plan 可以被 Spark、Trino、DuckDB 等引擎重新載入，並依照各自的優化策略生成 Physical Plan，最終執行。</p><p>Substrait 的出現，讓查詢的可攜性不再受限於特定執行引擎。它使得「查詢語意（computation）」像資料一樣，能被不同系統交換與重組。這條演化路線，代表從「不可變檔案格式」（Parquet）逐步走向「可交換的計算格式」（Arrow, DataFusion, and Substrait）。前者標準化資料，後者標準化運算。</p><h3>Summary</h3><p>回顧這幾章，我們由下往上，一層一層追溯 Lakehouse 組件。</p><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/0*l0q5NLTW2YtElA_L" /><figcaption>Lakehouse stack by Dipankar Mazumdar</figcaption></figure><ul><li><strong>Part 5</strong>：從 Alan Turing 思想實驗中的「無限磁帶」談起，揭開了存儲裝置作為計算基礎的序幕。</li><li><strong>Part 6</strong>：<strong>Hardware Layer</strong>：固態硬碟（SSD）的普及，讓隨機讀寫成為常態，儲存不再只是序列化的媒介，而是演變為可高速並發操作的資料場域。</li><li><strong>Part 7</strong>：<strong>Data Lake Storage Layer</strong>：Object Store 帶來平坦命名空間與橫向擴展能力，並透過 API 操作物件，成為雲端時代資料儲存的主流架構。</li><li><strong>Part 8</strong>：介紹了四種資料庫中的不可變檔案，它們是 Avro 與 Parquet 的前身。</li><li><strong>Part 9</strong>：<strong>File Formats Layer</strong>：補足了 Parquet 和 Arrow 的論述，凸顯它作為 Data Lake Era 資料流通基石的地位。</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*pIMMgU3s4r8a9-Jv.png" /><figcaption>Lakehouse stack by LanceDB</figcaption></figure><p>現在，我們已具備必要的知識儲備，得以讓我接續 Part 3 結尾的鋪陳：</p><p>『當人類進入 21 世紀，一種完全不同於傳統 Massively Parallel Processing（MPP）的 OLAP 架構，登上了歷史舞台。它能包容從結構化（Structured）到非結構化（Unstructured）的各類資料型態。』</p><p>Data Lake 的誕生，源自 Google 發表的三篇開創性論文。</p><p>(To be continued …)</p><h3><strong>附錄</strong></h3><ol><li>Parquet official doc:<strong> </strong><a href="https://parquet.apache.org/docs/overview/">https://parquet.apache.org/docs/overview/</a></li><li>Column Storage For The AI Era (by Julien Le Dem): <a href="https://www.youtube.com/watch?v=S_aoc9dExqA">https://www.youtube.com/watch?v=S_aoc9dExqA</a></li><li>[DataFusion Blog] Embedding User-Defined Indexes in Apache Parquet Files: <a href="https://datafusion.apache.org/blog/2025/07/14/user-defined-parquet-indexes/?fbclid=IwY2xjawPJiSxleHRuA2FlbQIxMABicmlkETFwOGt4bHdUOHlxdXZURUpkc3J0YwZhcHBfaWQQMjIyMDM5MTc4ODIwMDg5MgABHsyQopygy05T_ER786vC80k_tssiP7p2x3a-_dQLrnvEevEu_QzAc8Ynz0_r_aem_ql6HHrttfRcuJtvvKKa36A">https://datafusion.apache.org/blog/2025/07/14/user-defined-parquet-indexes/?fbclid=IwY2xjawPJiSxleHRuA2FlbQIxMABicmlkETFwOGt4bHdUOHlxdXZURUpkc3J0YwZhcHBfaWQQMjIyMDM5MTc4ODIwMDg5MgABHsyQopygy05T_ER786vC80k_tssiP7p2x3a-_dQLrnvEevEu_QzAc8Ynz0_r_aem_ql6HHrttfRcuJtvvKKa36A</a></li><li>[DataFusion Blog] Using External Indexes, Metadata Stores, Catalogs and Caches to Accelerate Queries on Apache Parquet: <a href="https://datafusion.apache.org/blog/2025/08/15/external-parquet-indexes/">https://datafusion.apache.org/blog/2025/08/15/external-parquet-indexes/</a></li><li>[Spice AI] How we use Apache DataFusion at Spice AI: <a href="https://spice.ai/blog/how-we-use-apache-datafusion-at-spice-ai">https://spice.ai/blog/how-we-use-apache-datafusion-at-spice-ai</a></li><li>[Databricks] The Parquet Format and Performance Optimization Opportunities Boudewijn Braams: <a href="https://www.youtube.com/watch?v=1j8SdS7s_NY&amp;ab_channel=Databricks">https://www.youtube.com/watch?v=1j8SdS7s_NY&amp;ab_channel=Databricks</a></li><li>[Databricks] Recent Parquet Improvements in Apache Spark: <a href="https://www.youtube.com/watch?v=lrw44LlSp0s&amp;ab_channel=Databricks">https://www.youtube.com/watch?v=lrw44LlSp0s&amp;ab_channel=Databricks</a></li><li>Antoine Pitrou — Apache Parquet : the standard, efficient file format for tabular data: <a href="https://www.youtube.com/watch?v=EsoOGanShQ0&amp;ab_channel=%C3%98redevConference">https://www.youtube.com/watch?v=EsoOGanShQ0&amp;ab_channel=%C3%98redevConference</a></li><li>An Empirical Evaluation of Columnar Storage Formats</li><li>[Databricks] Degrading Performance? You Might be Suffering From the Small Files Syndrome: <a href="https://www.youtube.com/watch?v=L-x7UaWE5dk">https://www.youtube.com/watch?v=L-x7UaWE5dk</a></li><li>Polars Meetup #3 — Vectorized Parquet by Gijs Burghoorn: <a href="https://www.youtube.com/watch?v=xc5IsfwKRKE&amp;t=1159s">https://www.youtube.com/watch?v=xc5IsfwKRKE&amp;t=1159s</a></li><li>Apache Paimon Explained: An In-Depth Guide: <a href="https://celerdata.com/glossary/apache-paimon">https://celerdata.com/glossary/apache-paimon</a></li><li>Low latency Change Data Capture (CDC) to your data lake, using Apache Flink and Apache Paimon: <a href="https://www.youtube.com/watch?v=X3Dk07_i-7c&amp;t=931s">https://www.youtube.com/watch?v=X3Dk07_i-7c&amp;t=931s</a></li><li>Apache Paimon: Unified Data Lake for Stream &amp; Batch &amp; OLAP: <a href="https://www.youtube.com/watch?v=ZLdiJND1r9U">https://www.youtube.com/watch?v=ZLdiJND1r9U</a></li><li>Announcing Ryft Adaptive Optimization: <a href="https://www.ryft.io/blog/announcing-ryft-adaptive-optimization">https://www.ryft.io/blog/announcing-ryft-adaptive-optimization</a></li><li>Optimizing Iceberg Compaction: Why We Built an Embedded Engine in Rust: <a href="https://risingwave.com/blog/implementing-iceberg-compaction-rust/">https://risingwave.com/blog/implementing-iceberg-compaction-rust/</a></li><li>Parquet is getting old: <a href="https://www.linkedin.com/posts/dmitryanoshin_%F0%9D%97%A3%F0%9D%97%AE%F0%9D%97%BF%F0%9D%97%BE%F0%9D%98%82%F0%9D%97%B2%F0%9D%98%81-%F0%9D%97%9C%F0%9D%98%80-%F0%9D%97%9A%F0%9D%97%B2%F0%9D%98%81%F0%9D%98%81%F0%9D%97%B6%F0%9D%97%BB%F0%9D%97%B4-%F0%9D%97%A2%F0%9D%97%B9%F0%9D%97%B1-activity-7387622236339249152-U3GV?utm_source=share&amp;utm_medium=member_desktop&amp;rcm=ACoAAByiH74BNF1eQZrK1fR_YsBhHXwF_FuSboY">https://www.linkedin.com/posts/dmitryanoshin_%F0%9D%97%A3%F0%9D%97%AE%F0%9D%97%BF%F0%9D%97%BE%F0%9D%98%82%F0%9D%97%B2%F0%9D%98%81-%F0%9D%97%9C%F0%9D%98%80-%F0%9D%97%9A%F0%9D%97%B2%F0%9D%98%81%F0%9D%98%81%F0%9D%97%B6%F0%9D%97%BB%F0%9D%97%B4-%F0%9D%97%A2%F0%9D%97%B9%F0%9D%97%B1-activity-7387622236339249152-U3GV?utm_source=share&amp;utm_medium=member_desktop&amp;rcm=ACoAAByiH74BNF1eQZrK1fR_YsBhHXwF_FuSboY</a></li><li>What are Sketches or Stochastic Streaming Algorithms? <a href="https://www.linkedin.com/posts/dipankar-mazumdar_lakehouses-dataengineering-softwareengineering-activity-7374093393200762880-bGEc?utm_source=share&amp;utm_medium=member_desktop&amp;rcm=ACoAAByiH74BNF1eQZrK1fR_YsBhHXwF_FuSboY">https://www.linkedin.com/posts/dipankar-mazumdar_lakehouses-dataengineering-softwareengineering-activity-7374093393200762880-bGEc?utm_source=share&amp;utm_medium=member_desktop&amp;rcm=ACoAAByiH74BNF1eQZrK1fR_YsBhHXwF_FuSboY</a></li><li>Arrow official doc: <a href="https://arrow.apache.org/">https://arrow.apache.org/</a></li><li>Apache Arrow: High-Performance Columnar Data Framework (Wes McKinney): <a href="https://www.youtube.com/watch?v=YhF8YR0OEFk">https://www.youtube.com/watch?v=YhF8YR0OEFk</a></li><li>Apache Arrow Flight SQL: High-Performance Data Transfer Standard for Databases: <a href="https://www.youtube.com/watch?v=6q8AMrQV3vE">https://www.youtube.com/watch?v=6q8AMrQV3vE</a></li><li>Apache Arrow Flight SQL: High Performance, Simplicity, and Interoperability for Data Transfers: <a href="https://www.youtube.com/watch?v=OLsXlKb_XRQ&amp;t=1542s">https://www.youtube.com/watch?v=OLsXlKb_XRQ&amp;t=1542s</a></li><li>Where We’re Going, We Don’t Need Rows: <a href="https://www.youtube.com/watch?v=TjlmNGNx77E&amp;list=PLSE8ODhjZXjbEeW_bOCZ8c_nx_Jhoz-GW&amp;index=6">https://www.youtube.com/watch?v=TjlmNGNx77E&amp;list=PLSE8ODhjZXjbEeW_bOCZ8c_nx_Jhoz-GW&amp;index=6</a></li><li>How the Apache Arrow Format Accelerates Query Result Transfer: <a href="https://arrow.apache.org/blog/2025/01/10/arrow-result-transfer/">https://arrow.apache.org/blog/2025/01/10/arrow-result-transfer/</a></li><li>开源表格式的未来：Apache Iceberg 和 Lance: <a href="https://mp.weixin.qq.com/s/Px8ZSkO1KDsZYBJF1Zojsw">https://mp.weixin.qq.com/s/Px8ZSkO1KDsZYBJF1Zojsw</a></li><li>Lance: A Modern Columnar Data Format (Chang She): <a href="https://www.youtube.com/watch?v=ixpbVyrsuL8&amp;t=2s&amp;ab_channel=CMUDatabaseGroup">https://www.youtube.com/watch?v=ixpbVyrsuL8&amp;t=2s&amp;ab_channel=CMUDatabaseGroup</a></li><li>與 Arrow 類似的 memory format：</li></ol><ul><li><strong>Apache Spark</strong>：Spark SQL 的 Tungsten 引擎（Catalyst 優化器之後）使用 <strong>UnsafeRow</strong> 作為內部記憶體布局。UnsafeRow 是緊湊的二進位結構、固定長度欄位靠齊、欄位指標表在 header — — 和 Arrow 的 memory layout（連續 column buffer + bitmap validity）非常接近。因此，Spark 在與 Arrow 交互時（例如 toPandas()、ArrowColumnarBatch）能幾乎零拷貝轉換。</li><li><strong>Trino / Presto</strong>：它們的 Block 結構和 Arrow column buffer 也高度相似，都是 column-oriented、支援 dictionary encoding、null bitmap。Trino 在查詢結果導出時甚至能直接輸出為 Arrow stream。</li><li><strong>ClickHouse</strong>：雖然是 C++ 自行實作的 column store，但其每個 column segment（ColumnVector&lt;T&gt;）實際上就是一段連續記憶體 buffer。這樣的設計使它能快速轉換為 Arrow Table 或 Parquet，而不需複雜重組。</li><li><strong>DuckDB</strong>：DuckDB 的 vectorized execution 以 <strong>DataChunk</strong> 為單位，每個 column 都是 contiguous buffer，幾乎可直接映射到 Arrow 的 RecordBatch 結構。</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2a5b4baa9c8f" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From Database to Data Lakehouse — Part 8]]></title>
            <link>https://medium.com/@0050211/from-database-to-data-lakehouse-part-8-32bbdd46b5b1?source=rss-663daee8909b------2</link>
            <guid isPermaLink="false">https://medium.com/p/32bbdd46b5b1</guid>
            <dc:creator><![CDATA[Ryan Fu]]></dc:creator>
            <pubDate>Tue, 05 Aug 2025 08:08:12 GMT</pubDate>
            <atom:updated>2026-06-16T05:05:50.103Z</atom:updated>
            <content:encoded><![CDATA[<h3>Chapter 5. The Immutable Files in Databases</h3><p>《<a href="https://medium.com/@adrianhsu/%E7%B5%A6%E5%AD%B8%E5%BC%9F%E5%A6%B9%E7%9A%84%E7%9F%BD%E8%B0%B7-ai-software-%E6%8C%87%E5%8D%97-da054b7a8dcb">給學弟妹的矽谷 AI / Software 指南</a>》是一個矽谷工程師自發的眾籌寫作計劃，由<a href="https://www.facebook.com/opensource4you">源來適你社群</a>推動。這將是一個在台灣軟體學生圈內很有影響力的文章系列。已有 &gt;10 篇準備發布，目標 100 篇並出書，歡迎 tag 強者大神來寫！</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*OjSWdHEBQoUd1qT6" /></figure><p>業界對於 ORC、Avro 這類檔案格式，通常不會直接稱作「Immutable File」。比較常見的說法是 <strong>append-only file</strong>，在某些場景下則是 <strong>Copy-on-Write</strong>。</p><p>為了方便說明，我將這類檔案統稱為 <strong>「Immutable File」</strong>，強調它們的核心特性：<strong>檔案寫完之後就固定不變，所有資料的更新，都是透過新增檔案、切換 metadata 來完成的。</strong></p><p>「Immutable」這個詞的靈感，來自程式語言裡的資料型別設計。例如：</p><ul><li><strong>Python 的 string </strong>被視為 immutable，因其創建後就不能修改其中的 character。</li><li><strong>C++ 的 string</strong> 則是 mutable，因其可以原地變更 character。</li></ul><p>本文將邀請四款風格迥異的資料庫輪番登場，分為四幕展開：</p><p><strong>第一幕</strong> — — 從 OLTP 世界的經典不可變檔案 <strong>Write-Ahead Log（WAL）</strong>談起，揭示它如何以「先寫後改」的設計哲學，確保資料庫即使在當機後，也能優雅而完整地復原。</p><p><strong>第二幕</strong> — — 走進 <strong>Column Store</strong> 的國度，解讀為何在讀優化場景中，不可變檔案幾乎是必然選擇，並回顧一款由圖靈獎大師親自操刀的系統，如何奠定 Column Store 的技術里程碑。</p><p><strong>第三幕</strong> — — 揭開另一種不可變檔案架構 <strong>SSTable</strong> 的面紗。它誕生於網路服務與大規模分散式儲存的需求，推動資料庫從「寫即查」的同步模式，進化為「先寫後整併」的非同步處理邏輯。</p><p><strong>第四幕</strong> — — 在流量爆炸的時代，一款來自東歐的開源資料庫悄然現身。它將 <strong>MergeTree</strong> 的巧思與 Column Store 融合，讓即時分析在結構化資料的戰場上，展現出前所未有的速度與效率。</p><p>四位主角，四種風格，共同詮釋「不可變」在資料庫世界中的多元哲學。</p><h3><strong>A Brief History of Immutable Files — Volume 1</strong></h3><h4>第一幕：Immutable File for Write-Ahead Log</h4><p>今天要介紹的第一種不可變檔案是 Write-Ahead Log。</p><p>OLTP 資料庫的設計上，必須處理一個棘手卻至關重要的問題 — — <strong>故障復原（Recovery）</strong>。如果資料庫系統在運作過程中突然當機，該怎麼保證資料的正確性？</p><p>為了解決這個問題，資料庫的持久存儲層特別規劃了一塊區域，稱作 <strong>Write-Ahead Log（WAL，預寫日誌）</strong>。這塊區域可以是<strong>一組連續的磁碟檔案</strong>，也可以是<strong>獨立配置的 SSD</strong>。每當執行與寫入有關的 SQL（例如 insert、update、delete），Log Manager 就會產生一筆 <strong>append-only log</strong>，將操作寫入 WAL。相反地，簡單的讀取語句（select）並不會產生 log，因為查詢不會改變資料的狀態。</p><p>只要一筆 log 成功寫入 WAL，該筆記錄就不再被修改，形成了一個可重播的操作序列。資料庫的復原機制會根據這串 log，將資料恢復到一致的狀態。</p><p>undo record 紀錄修改前的資料，redo record 紀錄修改後的資料，那麼，一筆 append-only log 應該紀錄 undo record 或是 redo record？</p><p>答案並不只是簡單的操作記錄，這背後牽涉到資料庫的內部結構，特別是 <strong>Buffer Manager</strong> 的設計。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*tpkdKmJtZjlvQHox" /><figcaption>relational database components</figcaption></figure><h4>Buffer Manager, WAL, and Recovery</h4><p>資料庫在運作時，並不會每次讀寫都直接操作磁碟，因為磁碟 IO 的速度太慢。為了提升效能，資料庫會把磁碟上的資料單位 — — <strong>資料頁（page）</strong> — — 先載入到記憶體的 <strong>Buffer Pool</strong>。在資料庫的語境裡，<strong>page 是資料庫管理的最小 IO 單位</strong>，通常大小是 4KB、8KB 或 16KB，視實作而定。這裡的 page，在本質上對應到底層儲存裝置的 <strong>block（區塊）</strong>。換句話說，磁碟層面稱作 block，資料庫層面稱作 page，但實際上兩者是一對一映射，都是固定大小的資料單位，用來做 IO 操作。</p><p><strong>Buffer Manager</strong> 掌管 Buffer Pool。當 Buffer Manager 需要讀寫資料時，會先檢查 page 是否已在 Buffer Pool。若不在，才從磁碟（Block Storage）載入對應的 page。資料修改時，Buffer Manager 並不會立即把這些改動寫回磁碟，而是先在 Buffer Pool 裡修改 page，等到 page 被釋放或替換出 Buffer Pool 時，才同步寫回磁碟。這樣可以極大減少磁碟 IO，但也引入了一個問題：<strong>如果系統當機，記憶體裡的修改會全部消失</strong>。</p><p>這就是為什麼需要 WAL，來確保資料的持久性與復原能力。</p><h4>Steal / No-Steal 與 Force / No-Force</h4><p>想像你是 Buffer Manager，必須選擇管理策略。</p><p>第一道選擇題：Steal vs No-Steal</p><ul><li><strong>Steal<br></strong>允許將未完成的 transaction 的資料頁寫回磁碟。這樣可以節省 Buffer Pool 空間，但若系統當機，磁碟上就可能殘留尚未 commit 的變更，必須依靠 undo log 來回滾，維持 ACID 的 Atomicity（原子性）。</li><li><strong>No-Steal<br></strong>嚴格禁止將未完成的 transaction 資料寫回磁碟。這樣雖然保證了 Atomicity，但會增加 Buffer Pool 的壓力，降低效能。</li></ul><p>第二道選擇題：Force vs No-Force</p><ul><li><strong>Force<br></strong>每當一個 transaction commit 時，強制將所有相關資料頁寫回磁碟。這樣保證了 Durability（持久性），但會增加延遲和磁碟 IO。</li><li><strong>No-Force<br></strong>transaction commit 後，資料可以暫存在 Buffer Pool，延後寫入磁碟。效能較好，但如果當機，尚未寫入的部分就需要 redo record 來重做，才能保證 Durability。</li></ul><p>現代資料庫為了兼顧效能與可靠性，<strong>一律選擇 Steal + No-Force</strong>。這種策略帶來一個必然的結論：<strong>append-only log 必須同時包含 undo record 與 redo record，缺一不可。</strong></p><p>此外，每筆 log 還需記錄：</p><ul><li>Transaction ID</li><li>被修改的 page 位址（或物件 ID）</li></ul><h4>ARIES 的登場</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*zngH4yVcuRWdwO-a" /><figcaption>3 phases in ARIES</figcaption></figure><p>Steal + No-Force 策略帶來一個挑戰：當系統當機，磁碟上的資料可能同時存在兩種狀態 — — 一是<strong>已提交（committed）的 transaction，但資料尚未寫入磁碟</strong>；二是<strong>未完成的 transaction，卻已經部分寫入了資料頁</strong>。處理這種混合狀況，最具代表性的就是 <strong>ARIES（Algorithm for Recovery and Isolation Exploiting Semantics）</strong>。這個演算法首次發表於 1992 年，論文題目是：<strong>”ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging”</strong>，是為了 IBM DB2 而設計。後來，ARIES 的思想成為業界普遍共識，影響了 SQL Server、Oracle，PostgreSQL 也有部分概念受到啟發。</p><p>ARIES 的核心流程可拆解成：<strong>Analysis、Redo、Undo</strong>。因篇幅限制，請讀者參閱這 16 講視頻演示：<a href="https://www.youtube.com/watch?v=kFvujZ7MXVY&amp;list=PLzzVuDSjP25SYn9hULcyF4FnKe8mIBfjm&amp;index=1&amp;ab_channel=CS186Berkeley">https://www.youtube.com/watch?v=kFvujZ7MXVY&amp;list=PLzzVuDSjP25SYn9hULcyF4FnKe8mIBfjm&amp;index=1&amp;ab_channel=CS186Berkeley</a></p><h4>第二幕：Immutable File for Column Store</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/0*BNubKHKEYE1BJM5g" /></figure><p>在進入欄式系統中的 immutable file 設計之前，我們需要先理解 Row Store 和 Column Store 這兩種資料儲存架構的本質差異。</p><p>傳統的大多數關聯式資料庫系統（如 Oracle、SQL Server）採用 Row Store（列式儲存）：每筆記錄的所有屬性會<strong>連續地存放在磁碟上</strong>。這種設計的最大優點，是<strong>寫入效率高</strong> — — 只要一次磁碟寫入，就可以把一整筆資料打包存下來。這對於大量小型交易、頻繁更新的情境最為合適，例如 OLTP（線上交易處理）系統，因此 Row Store 本質上是一種「<strong>寫入最佳化</strong>」的架構。</p><p>相對地，在許多需要處理大規模查詢的場景，例如資料倉儲（Data Warehouse）、CRM 系統、電子圖書館目錄等，系統的瓶頸往往在於讀取效率。這類應用大多是<strong>批次載入資料後，進行大量查詢操作</strong>，更新頻率不高。這就是 Column Store（欄式儲存）發揮優勢的地方：每個欄位的值會<strong>連續儲存在一起</strong>，查詢時可以<strong>只讀取需要的欄位</strong>，不必載入整筆資料。這種設計特別適合大量聚合查詢（如 SUM、AVG），能顯著降低 I/O 與記憶體壓力，屬於「<strong>讀取最佳化</strong>」系統的代表。</p><p>2005 年由 Michael Stonebraker 等人發表的論文〈<em>C-Store: A Column-oriented DBMS</em>〉讓人眼睛一亮。它並不是在傳統 Row Store 架構上做微調，而是大膽提出了一種<strong>為「讀多寫少」（read-mostly）場景量身打造的全新資料庫架構</strong>。這種設計跳脫了以 OLTP 為核心的傳統邏輯，轉而聚焦在資料倉儲、CRM、即時查詢等現代分析場景，反映出對資料密集應用型態的深刻洞察。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/282/1*cbOecNrCrINHGU7Eomnl1Q.png" /></figure><p>為因應查詢與更新的雙重需求，C-Store 採用一種<strong>雙層架構設計</strong>：上層的 Writable Store（WS）專門處理即時寫入與交易更新，而底層的 Read-optimized Store（RS）則負責維持查詢效能。兩者之間透過一個背景任務 — — <strong>Tuple Mover</strong> — — 將資料從 WS 批次遷移至 RS，以達成查詢效率與寫入彈性的兼顧。值得注意的是，WS 與 RS 都採用欄式儲存（Column Store）架構，因此整個查詢優化器（optimizer）也是<strong>以欄式邏輯為基礎進行優化</strong>。</p><h4>為什麼 RS 是 Immutable 的？</h4><p>C-Store 的 Read-optimized Store（RS）設計是查詢導向的極致展現。為了讓資料可以高壓縮、高排序、高效查詢，C-Store 採用了一種極端但有效的策略：<strong>RS 是 immutable 的。</strong></p><p>這代表一旦資料被寫入 RS，就不會再被修改或原地更新（no in-place update）。這樣的設計看似激進，實際上是解決了以下幾個核心問題：</p><p>1️⃣ 查詢最佳化的前提：穩定資料結構</p><p>RS 採欄式儲存、依欄位排序並壓縮。如果允許即時寫入或修改，就會打亂排序與壓縮的連續性，進而降低查詢效率。因此，RS 完全避免這類操作，只接受資料的「批次導入」，讓儲存格式保持緊湊、一致。</p><p>2️⃣ 更新策略：Append + Merge</p><p>所有即時的插入與更新操作，會<strong>先寫入 WS（Writable Store）</strong>。WS 是一個短期的、可變的暫存區，資料會在那裡累積並持續接受變動。直到一段時間後，系統會透過背景任務 <strong>Tuple Mover</strong>，將 WS 中的資料<strong>批次合併</strong>到 RS 中，產生一個全新的 segment（RS′），並以新版本取代舊的 RS segment。整段重寫，不改舊檔，這正是 immutable 設計的精髓。</p><p>3️⃣ 刪除也是「邏輯刪除」</p><p>RS 不會馬上物理刪除資料。當使用者刪除某筆資料時，系統只會在 metadata 或刪除向量（Delete Record Vector, DRV）中註記該筆資料為「已刪除」。實際刪除的動作，會延後到下次 Tuple Mover 合併時才執行。這種方式確保在任一時刻，RS 的資料內容都是穩定、可預測的。</p><p>4️⃣ 支援 Snapshot Isolation</p><p>正因為 RS 是 immutable，系統可以同時保有多個時間點的資料版本（例如「昨天 8 點的快照」、「目前最新快照」），讓查詢能針對特定的時間點進行一致性讀取。這正是實現 snapshot isolation 的基礎。如果改採 in-place update，就無法保證歷史一致性。</p><h4>C-Store 的里程碑</h4><p>C-Store 的出現，標誌著關聯式資料庫設計邏輯的一次重大轉向。</p><p>需要先說明的是：<strong>C-Store 並不是第一個欄式資料庫（column store）</strong>。在它之前，像 <strong>Sybase IQ</strong>、<strong>KDB</strong>、<strong>Bubba</strong>、<strong>MonetDB</strong> 等系統早已提出了以欄為單位儲存資料的概念，甚至在某些場景中已有商業應用。然而，這些早期系統多半仍停留在單一技術實驗或特定場景下的應用，例如只處理批次查詢、僅支援 append-only 的儲存模型，或是缺乏更新與一致性控制的能力。</p><p><strong>C-Store 的真正突破，在於它不是單點創新，而是首次將多種過去分散的資料庫設計理念整合為一套可運作的完整系統。</strong>它提出了以下幾個具里程碑意義的核心概念：</p><ul><li>查詢導向的欄式儲存（RS）+ 更新導向的寫入區（WS）→ 支援查詢效能與線上更新並存</li><li>Projections 取代傳統表格與索引，支援多種排序與欄位組合的查詢路徑</li><li>Tuple Mover 實現 append + merge 的 immutable 架構</li><li>K-safety、snapshot isolation、無需 redo record 或 2PC 的交易支持</li><li>對 shared-nothing 分散式環境的天然支援與資料水平分區設計</li></ul><p>這些設計在當時是前所未見的系統性組合，讓 C-Store 成為日後多個資料庫系統設計的原型，也為欄式系統的現代化鋪下基礎。在一個查詢不可預測、更新不頻繁的世界裡，C-Store 所樹立的系統哲學與設計方法，成為資料庫走向 read-optimized 架構的重要里程碑。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/399/0*SJ6Uy_ixa2fcNIVQ" /></figure><p>後來，Stonebraker 團隊將 C-Store 商業化，於 2005 年創辦了 Vertica Systems，並在 2007 年正式推出產品。2011 年，Vertica 被 HP（Hewlett-Packard）收購，納入其企業資料平台。之後隨著 HP 的企業業務重整，Vertica 又被納入 Micro Focus，而在 2023 年，<strong>Micro Focus 全體併入 OpenText</strong>，因此 Vertica 現今正式隸屬於 OpenText。</p><p>即使歷經多次轉手，Vertica 至今仍是企業級欄式資料庫的代表之一，也留下了 Stonebraker 團隊將學術原型推向產業實用的經典案例。</p><p><strong>那麼，為什麼 Column Store 能夠在 OLAP 世界裡實現</strong>「<strong>讀取最佳化</strong>」<strong>？</strong>它到底解決了什麼根本問題？我們可以從 OLAP 系統的常見使用案例，來理解為什麼大家都說：</p><h4>Column Store 是為了 OLAP 而生</h4><p>現代分析型資料庫（OLAP）有三個典型使用案例，正好對應到欄式儲存（Column Store）的優勢。</p><p>1️⃣ OLAP 查詢通常只讀取特定欄位</p><p>不像 OLTP 需要查詢完整的 row，OLAP 的主要目的是<strong>分析與統計</strong>，例如財務報表、銷售統計、用戶行為分析等，查詢的常見模式是：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/224/0*UEiJWo9qGLUaLbV2" /></figure><p>只查 region 和 sales 兩個欄位，不關心其他欄位如 product_id、customer_id、order_status 等。</p><p>這種查詢特性，使得傳統 row store 會面臨 IO 浪費 — — 因為即使只需要讀一兩個欄位，仍必須將整條 row 從磁碟讀出。</p><p><strong>Column Store 的優點：</strong>資料是<strong>欄為單位獨立存放</strong>的，查詢時只需載入目標欄位，<strong>極大減少了 IO 讀取量</strong>。</p><p>2️⃣ OLAP Table 的欄位值會高度重複</p><p>Data Warehouse 中的表，常常是事實表（Fact Table）+ 維度表（Dimension Table）的組合。事實表會有大量重複的維度欄位，例如：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/401/0*GxdBLPDVPayBbssd" /></figure><p>product_category 這欄位可能只有幾十種值，卻重複出現在數億筆交易中。</p><p><strong>Column Store 的優點：</strong>欄式存儲讓相同欄位的值緊密排列，能有效發揮壓縮演算法的威力，例如字典編碼、Run-Length Encoding（RLE）、差分編碼等技術，尤其當同一欄位內的資料型別一致，能進一步提升 compression ratio。這樣不僅節省磁碟空間，也加速查詢效率。</p><p>3️⃣ OLAP 查詢常有大規模掃描與聚合</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/425/0*9H6GYcAvgXcl-VQ5" /></figure><p>這類查詢會掃整個事實表，聚合數百萬筆訂單，再排序出花最多錢的前十名顧客。</p><p><strong>Column Store 的優點：</strong>欄式資料的<strong>緊密排列與連續存取</strong>，剛好符合這種大規模運算的特性，可以充分發揮 CPU Cache、SIMD（單指令多資料）、向量化執行等現代硬體優勢，<strong>極大提升聚合與掃描的效能</strong>。</p><h4>第三幕：Immutable File for SSTable</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/0*ZmqudgbY_QltXd_5" /></figure><p>從 PostgreSQL 的 WAL 到 C-Store 的 RS，不可變檔案（Immutable File）的概念，不僅深植於 OLTP 的傳統關聯式資料庫中的復原機制（如 WAL），也在 OLAP 世界的 Column Store 架構中扮演關鍵角色。</p><p>無獨有偶，這套設計哲學也延伸到了 OLTP 的另一支流 — — Wide-Column Store。</p><p>2006 年，<strong>Bigtable</strong> 的問世，開啟以「高寫入吞吐量」與「低延遲查詢」為核心目標的資料庫設計風潮。這類系統普遍採用 LSM Tree（Log-Structured Merge Tree）架構，其典型代表包括 HBase（2008）、Cassandra（2008）、LevelDB（2011）與 RocksDB（2013）。LSM Tree 的關鍵在於 append-only 寫入與分層合併，造就了遠高於傳統關聯式資料庫的寫入性能。</p><p>其中，Cassandra 是分散式 OLTP 的經典代表。它如同 C-Store 般，將眾多成熟技術揉合為一體，包括 Gossip 協定、Ring 架構與虛擬節點、壓縮式 GC、Anti-Entropy Repair、Read Repair，以及支援 Paxos 的 Lightweight Transaction。這些技術背後的工程思維與設計取捨，我們將在後續章節深入探討。</p><p>但在此之前，讓我們先釐清一個核心問題：</p><h4>什麼是 Wide-Column Store？</h4><p>Wide-Column Store（又稱 Column Family）與傳統 Column Store 與 Row Store 均有所不同。它本質上是一種 row-based 模型，但引入了兩層索引機制：</p><ul><li><strong>Partition Key</strong>：決定資料儲存在哪個 partition，可視為水平切分的邊界。</li><li><strong>Clustering Columns</strong>：定義一個 partition 內的資料排序方式，可支援範圍查詢與複合索引。</li></ul><p>這種設計讓 Wide-Column Store 既能享有 row-based 更新彈性，又能在查詢特定欄位時獲得類似 Column Store 的效益。</p><p>若以資料嵌套能力劃分：</p><ul><li>傳統 KV Store 僅支援一級 key-value 結構。</li><li>Wide-Column Store 則支援「二級嵌套」，即 key → column family → column values。</li><li>若需更高層級嵌套（如陣列、Map 結構），則需仰賴如 MongoDB 這類 Document Store。</li></ul><p>若使用 CQL (Cassandra Query Language) 執行如下語句：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*4d9r_D7IqZUexJIj" /></figure><p>Partition key 是 state，clustering column 是 id，兩者構成 primary key。最終在持久層中，這個 ‘TX’ 的 partition 會隸屬於一個 SSTable，落盤的資料是這樣排列的。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/548/0*6t-UPQze-mCoXlfq" /><figcaption>持久層中，SSTable 內 ‘TX’ partition 的資料排列。(*) 的 * 是指 timestamp</figcaption></figure><p>熟悉 Relational DB 的讀者都知道，資料寫入磁碟前的過程，其實遠比表面複雜得多 — — 從記憶體快取到寫前日誌，每個環節都攸關性能與可靠性。而在 NoSQL 陣營中，Cassandra 的寫入路徑同樣精彩，絲毫不遜於傳統資料庫的波瀾起伏。</p><h4>Cassandra Write Path</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/453/0*tCq8C6v9zZXqcWM9" /><figcaption>Cassandra’s Write Path</figcaption></figure><p>Cassandra 的寫入流程可分為三大步驟：</p><p>1. 寫入 WAL 與 Memtable</p><p>當有資料寫入 Cassandra 時，系統會<strong>同時將資料寫入記憶體中的 Memtable</strong>，以及<strong>追加（append）到磁碟上的 WAL</strong>。</p><ul><li><strong>Memtable</strong> 是一種記憶體中的資料結構，可看作是每個資料表對應的一個寫入快取區，按照 partition key 排序儲存，故一個 Memtable 中可能會有許多 partitions。</li><li><strong>WAL</strong> 負責記錄所有寫入操作，確保即使發生斷電或系統崩潰，也能進行資料復原。</li></ul><p>當 Memtable 的大小超過設定閾值，或 WAL 的空間用滿時，系統會將該 Memtable 放入待處理佇列，開始 Flush 動作。</p><p>2. Flush：將 Memtable 資料寫入 SSTable</p><p>Flush 時會將 Memtable 內的資料<strong>以排序好的方式寫入磁碟，形成不可變的 SSTable 檔案</strong>。換言之，SSTable 就是 Memtable 在持久層中的體現。一旦 Flush 成功，對應的 WAL 內容就會被安全地刪除（purge），避免不必要的儲存空間浪費。</p><p>3. 永久儲存於 SSTable</p><p>每張資料表會有自己獨立的 Memtable 與 SSTable，但 WAL 則是多表共用。SSTable 是一種「一次寫入、永不修改」的不可變檔案（Immutable File）。因此，當發生 <strong>update</strong> 或 <strong>delete</strong> 操作時，實際上不會修改原有的 SSTable，而是<strong>新增一筆帶有覆蓋或標記刪除（tombstone）資訊的紀錄</strong>。這導致同一筆資料可能會散落於多個不同的 SSTable 中。</p><h4>Cassandra Compaction Strategies</h4><p>為了避免讀取時出現不一致或過多重複資料，Cassandra 必須依賴 <strong>Compaction 機制</strong>，將多個 SSTable 整合為一個、並移除過時或重複的紀錄，以確保查詢時讀者能讀到正確資料。</p><p>承接之前的 CQL 執行環境，我們再執行這 5 條語句</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*zKqHr-6fPw2Joc_S" /></figure><p>此時，又有一個新的 SSTable 誕生了，其內的 ‘TX’ partition 如下：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/546/0*bbwVUAsGnKpNo4hW" /><figcaption>持久層中，誕生一個新的 SSTable，其內也有 ‘TX’ partition。(*) 的 * 代表 timestamp。X 代表 tombstone</figcaption></figure><p>當 Compaction 的背景程序發動時，它以 SSTable 為單位，將相同 partition key 的 partition 兩兩合併。因為 Cassandra 是分散式系統，節點之間資料可能不同步，保留 tombstone 是為了讓刪除能在 cluster 各節點間「傳播完成」，避免資料不一致。gc_grace_seconds 是每個 table 的一個設定值，用來指定 <strong>tombstone（墓碑）保留多久後可以被清除</strong>，預設為 <strong>10 天（864000 秒）</strong>。</p><p>設想在 timestamp 864180 做 compaction，要合併兩個 ‘TX’ partitions。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ujT6Dc8EJ71gcC7_" /><figcaption>合併兩個 ‘TX’ partitions</figcaption></figure><p>合併 id = 1，產生 (1, ‘Johnny’)<br>合併 id = 2，timestamp 864176 &lt; timestamp 864180，將 tombstone 清除<br>合併 id = 3，產生 (3, ‘Norman’)<br>合併 id = 4，產生 (4, ‘Sue’)<br>合併 id = 5，timestamp 864184 &gt; timestamp 864180，仍保留 tombstone<br>合併 id = 6，產生 (6, ‘Henrie’)</p><p>當只有兩個 partitions 要合併時，推導過程不啻為紙上談兵。但在真實世界裡，等待合併的 partitions 卻多如過江之鯽。為了應對這種規模與複雜性，Cassandra 提供了三種主流的 Compaction 策略：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*3dNC17zfWdOfNuIX" /><figcaption>Size-Tiered Compaction Strategy</figcaption></figure><p>✅ 1. Size-Tiered Compaction Strategy</p><ul><li><strong>Cassandra 預設策略</strong></li><li>將大小相近的 SSTable（通常為 4 個）合併成一個較大的檔案</li><li>適用場景：<strong>高寫入、低查詢頻率</strong>（如寫入導向的監控系統）</li><li>缺點：會產生大量檔案，查詢時需掃描多個檔案，造成 <strong>Read Amplification</strong></li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*WSil8T6Kf0ppuZfz" /><figcaption>Leveled Compaction Strategy</figcaption></figure><p>✅ 2. Leveled Compaction Strategy</p><ul><li>將 SSTables 分層管理（Level 0、1、2…）</li><li>每層容量約為前一層的 10 倍，合併過程受控、碎片較少</li><li>適用場景：<strong>低寫入、高查詢頻率</strong></li><li>優點：查詢路徑短，<strong>每筆資料只存在一個檔案中</strong></li><li>缺點：寫入放大嚴重，需要高 I/O 資源</li></ul><p>✅ 3. Time-Window Compaction Strategy</p><ul><li>特別設計給 <strong>時序資料（Time Series）</strong>，如 logs、metrics、IoT 資料</li><li>依時間區段（如每小時、每日）將資料分組合併</li><li>舊時間窗口資料不再參與合併，避免重複 I/O</li><li>適合搭配 <strong>TTL（Time-to-Live）</strong>，自動淘汰過期資料</li></ul><p>Cassandra 的誕生是為了解決巨量資料寫入的挑戰，因此在設計上優先確保「寫入成功」的彈性與可靠性。基於這樣的初衷，它預設採用 <strong>Size-Tiered Compaction Strategy</strong>，以追求寫入吞吐最大化。</p><p>然而，當使用場景從「寫多讀少」轉向「寫少讀多」時，Size-Tiered 所造成的大量重複查詢與高 Read Amplification，就可能成為效能瓶頸。幸好，<strong>LSM Tree 架構本身具有高度彈性</strong>，Cassandra 也因此允許使用者針對資料型態與查詢特性，自行選擇最合適的 Compaction 策略，以在寫入效率與查詢效能之間取得平衡。</p><p>LSM Tree 也衍生出許多變體：InfluxDB 為時序資料打造了 Time-Structured Merge Tree（TSM Tree），Apache Paimon 則將 LSM Tree 與表格式設計融合，以支援 Lakehouse 的實時更新。而當我們去除其中的 Log 特性，就誕生了 MergeTree — — 這正是 ClickHouse 的預設引擎。</p><h4>第四幕：當 Column Store 遇上 MergeTree</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/512/0*IqESpADehfyHcQhW" /></figure><p>Column Store 向來是 OLAP 系統的首選架構，但這種設計對於高頻寫入並不友善，傳統做法多半是降低寫入頻率，以換取查詢速度最大化。</p><p>但在現實世界裡，越來越多系統面臨新的挑戰：如何在大量即時寫入的同時，依然支援 sub-second 的分析查詢？這類場景包括：</p><ul><li>即時 dashboard（如 Grafana）「點一下就出圖」的使用者期待</li><li>廣告技術（ad-tech）、log 分析、A/B testing、詐欺偵測（fraud detection）等需毫秒回應的場景</li></ul><p>為了解決內部 clickstream 數據規模爆炸的挑戰，俄羅斯搜尋引擎 Yandex 在 2016 年開源了一款資料庫 — — <strong>ClickHouse（Clickstream Data Warehouse）</strong>。它是當今極少數能在 TB 等級資料下，仍提供 <em>sub-second latency</em> 的開源分析系統，地位彷彿查詢界的 quick sort。</p><p>ClickHouse 的飛快，並非只靠單一技術，而是多項優化交織而成，包括：</p><ul><li><strong>Column Store 架構</strong>：每欄獨立儲存，壓縮率高，讀取效率佳</li><li><strong>Codecs</strong>：支援多種壓縮編碼（如 LZ4、ZSTD、Delta、Gorilla），依欄位型態自動選擇最適方式</li><li><strong>Compression</strong>：與 Codecs 搭配，有效減少 I/O 負載與儲存空間</li><li><strong>Data Partitioning</strong>：支援按時間戳、地區等條件切分資料，加速範圍查詢與刪除</li><li><strong>Vectorized Execution</strong>：向量化處理，每次處理一批資料而非逐筆，充分發揮 CPU cache</li><li><strong>In-RAM Dictionaries</strong>：以字典結構緩存常用欄位（如國家代碼、裝置類型），查詢效率大增</li><li><strong>Skip Indexes</strong>：儲存欄位值範圍，快速跳過不相關資料區塊</li><li><strong>Optimized Algorithms</strong>：針對排序、聚合、JOIN 等常用操作設計高效演算法</li><li><strong>Adaptive Hash Structures</strong>：靈活應對不同資料分布與大小，提升查詢穩定性</li><li><strong>Projections</strong>：類似物化檢視（Materialized Views），預先儲存部分查詢結果</li><li><strong>Distributed Query Execution</strong>：支援跨節點分散式查詢與 fault-tolerant merge</li><li><strong>Primary Key Indexes</strong>：透過主鍵維持資料排序，加快資料定位與合併（compaction）</li><li><strong>Merge Tree family</strong></li></ul><p>以下是一個盡可能體現 ClickHouse 加速技巧的 CREATE TABLE 範例，並附上每個設計對應的技術點註解。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/928/0*GhtmtfceMoB2egr5" /></figure><h4>Data Part &amp; Merge Tree</h4><p>在眾多加速技巧之中，<strong>最關鍵的元件，非 MergeTree 架構莫屬</strong>，它讓 Column Store 能在實時分析中脫胎換骨。讓我們從 data part 談起，假設我們有這個表：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/304/0*tPnbAnWqI4ZMoYmR" /></figure><p>每處理一筆 INSERT INTO 指令，背後是一連串的流程──最終的成果叫作 <strong>data part</strong>，一個自足的不可變資料單元。它的誕生過程大致如下：</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*17N5_tIpJ_lACSZ0" /><figcaption>CH’s Write Path</figcaption></figure><p>① <strong>Sorting 排序</strong>：<br> 所有寫入的 row 會先依據 ORDER BY 的排序鍵（如 (town, street)）進行排序。這一步驟會一併產出稀疏的 primary index，讓之後能快速定位範圍查詢所屬的位置。</p><p>② <strong>Splitting 切欄</strong>：<br> 接著將排序後的資料依據欄位切分，這是 column store 最基礎的處理邏輯，讓同一欄位的值可以彼此緊鄰儲存，以便向量化與壓縮。</p><p>③ <strong>Compression 壓縮</strong>：<br> 切出的每一個欄位會單獨使用指定的編碼器（如 ZSTD、Delta、Gorilla）進行壓縮，以充分利用資料的重複性與分佈特性。</p><p>④ <strong>Writing to Disk 寫入磁碟</strong>：<br> 壓縮後的每一欄會被存成獨立檔案，而這一批欄位檔案會統一存放在一個新建的目錄下，這個目錄就是 <strong>data part</strong>。它內含了資料本身，也包含了 compressed primary index 與所有必要的 metadata，使其完全自足。</p><h4>Merge in Merge Tree</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*HAAoFoR1v3ZzpAFD" /></figure><p>如果沒有後續處理，這些數量龐大又分散的 parts 將導致查詢時需開啟大量檔案、掃描眾多 index，嚴重拖累效能。為此，ClickHouse 採用了一種類似 LSM Tree 的策略：<strong>背景合併（background merge）</strong>。這個流程如下：</p><p>① <strong>週期性觸發<br></strong>背景 merge 任務會定期掃描每張表的 data parts，依據預設的合併規則，挑選數個小的 parts。</p><p>② <strong>合併排序 &amp; 重組索引<br></strong> 被選中的 parts 會以排序鍵為基礎進行 merge sort，產出一個更大的、排序後的資料集，並重新生成新的稀疏 primary index。</p><p>③ <strong>重新壓縮 &amp; 寫入<br></strong> 新的合併結果再次進行欄位壓縮，並寫入成一個全新的、self-contained 的大型 data part（通常上限約 150 GB，可設定）。</p><p>④ <strong>淘汰舊 part<br></strong>舊的多個小 part 會被標記為 inactive，並在設定的保留期後從磁碟刪除，讓整體儲存架構趨向有序與扁平。</p><p>這樣的合併會不斷進行，讓資料從一開始的眾多小 part，隨時間逐步匯整為更大的、查詢更高效的資料區塊。最終，形成一種<strong>樹狀但非平衡的資料層次結構</strong>，這也正是「MergeTree」名字的由來。</p><p>透過這樣的架構，ClickHouse 在保有 Column Store 優勢的同時，也成功克服了傳統 Column Store 不善於高頻寫入的弱點。這套從 write 到 merge 的流程，正是它能支撐即時分析查詢、實現 sub-second latency 的根本原因。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*2eMaQt-c_VLLXWKD" /><figcaption>How to merge in MergeTree</figcaption></figure><p>此時，讀者不妨暫停一下，將 <strong>ClickHouse 的 MergeTree</strong> 與 <strong>Cassandra 的 LSM Tree </strong>做個比較。兩者都具備將多個小資料單元合併為較大單元的機制，但細節卻大不相同：</p><ul><li><strong>ClickHouse</strong> 是將多個 <em>part</em> 合併，每個 part 內部是以欄（column）為單位進行合併；</li><li><strong>Cassandra</strong> 則是將多個 SSTable 合併，SSTable 裡則是將多個 <em>partition</em>（每個可能包含多欄）進行合併。</li></ul><p>這樣的差異，最終仍回到兩者的根本設計哲學：<br><strong>ClickHouse 是純正的 Column Store，適用於 OLAP；而 Cassandra 是 Wide-Column Store，適用於 OLTP。</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*fv6hdvn_AhNc3Mqq" /><figcaption>回顧 Cassandra 如何將兩個 partitions 合併</figcaption></figure><p>細心的讀者或許會好奇：既然 Cassandra 的 LSM Tree 有 Size-Tiered、Leveled、Time-Window 等多種 compaction 策略讓使用者可依場景選用，那麼 ClickHouse 的 MergeTree 是否也是如此？</p><p>事實上，ClickHouse 並<strong>不</strong>像 Cassandra 那樣開放多種 compaction 策略讓使用者指定。相反地，它採取一套內建的自動合併演算法，由系統根據條件動態決定何時、如何進行 parts 合併。這些策略雖不可自訂，但設計精細，考慮了多重面向：</p><ul><li>✅ <strong>大小相近</strong>：優先合併大小類似的 parts（類似 Size-Tiered，但更細緻），避免出現一個大檔與一堆小檔混合的情況。</li><li>✅ <strong>時間接近</strong>：避免跨時間區間的大合併，有助於時序資料的有效分層。</li><li>✅ <strong>讀取效益</strong>：會考慮資料的查詢模式，盡量避免破壞有利於跳躍搜尋（index granularity）的結構。</li><li>✅ <strong>避免合併風暴（merge storm）</strong>：內建「合併 budget」與資源控管，確保不會為了合併而拖垮系統效能。</li></ul><p>ClickHouse 將策略內嵌於系統預設行為中，由系統依 workload 動態調節，使用者不必顧慮太多參數設定。與之對照，<strong>Cassandra 為了適應各種 OLTP 的讀寫比例，才提供多種可顯式選擇的 compaction 策略，讓使用者根據讀寫比例自行取捨。</strong></p><p>然而，ClickHouse 也並非僅依賴單一的 MergeTree 實作。<strong>它提供了一整個 MergeTree 家族，讓使用者得以在不同的 OLAP 讀取場景中做出更精細的優化與取捨。</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/742/0*CtavgmBaLL9xsiL7" /><figcaption>CH 的 MergeTree family</figcaption></figure><h4>第五幕：Recovery in these Databases</h4><p>還記得第一幕登場的那位老朋友 — — <strong>WAL（Write-Ahead Log）</strong> 嗎？在關聯式資料庫的世界裡，WAL 是復原的靈魂，確保系統在崩潰後依然能完整恢復資料、一筆不漏。</p><p>如今，我們走完了第四幕，正好來一場呼應：比較四種資料庫的復原機制 — — 從以 WAL 為核心、擁有嚴謹 ARIES 流程的 Relational DB，到分散式架構、搭配 Commit Log 的 Cassandra；再到僅需簡化日誌與檢查點的 C-Store；最後，是不依賴 WAL，而是透過 <strong>data part 與 metadata 掃描</strong>來確認一致性的 ClickHouse。</p><p>這樣的首尾對照，為全篇帶來一種令人滿意的<strong>情節對稱之美</strong>。</p><h4>Recovery in Relational DB (use ARIES)</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ENhCdZG1i6mz2pE2" /></figure><p><strong>三個關鍵時間點：</strong></p><ul><li><strong>T1</strong>：Oldest Uncommitted Transaction 的開始時間</li><li><strong>T2</strong>：Last Checkpoint 的時間</li><li><strong>T3</strong>：Log 的最後時間</li></ul><p><strong>Recovery 步驟：</strong></p><ol><li><strong>Analysis Phase: </strong>從 <strong>T2</strong> 開始掃描 WAL，重建 Transaction Table 與 Dirty Page Table，並標記出 <strong>T1</strong>。</li><li><strong>Redo Phase: </strong>從 <strong>T1</strong> 之前第一個需要重播的動作開始，依序將 <strong>T3</strong> 時點前已提交的變更重做（REDO），將資料恢復到最新一致狀態。</li><li><strong>Undo Phase: </strong>從 <strong>T3</strong> 向回掃描至 <strong>T1</strong>，逆序回滾（UNDO）所有未提交的交易。</li></ol><h4>Recovery in C-Store</h4><p><strong>兩個關鍵時間點：</strong></p><ul><li><strong>T1</strong>：上次 Checkpoint 的時間</li><li><strong>T2</strong>：WAL 的最後時間</li></ul><p><strong>Recovery 步驟：</strong></p><ol><li><strong>Load Last Checkpoint</strong>：從儲存中載入最近一次的 Checkpoint，包括所有已持久化的 Read-Optimized Store（ROS）與 Write-Optimized Store（WOS）狀態。</li><li><strong>Replay WAL</strong>：從 <strong>T1</strong> 開始依序重播 WAL 中的操作，將所有新寫入的資料套用到 WOS，或將已完成移動的資料同步到 ROS。</li></ol><p>C-Store 採用 WOS（可更新、在記憶體中）+ ROS（不可變、在磁碟上）的雙層架構，因此復原的重點在於先還原 ROS，再依 WAL 回補 WOS 的變更，最後重新建立必要的索引與元資料。</p><h4>Recovery in Cassandra</h4><p><strong>兩個關鍵時間點：</strong></p><ul><li><strong>T1</strong>：上次檢查點（Flush）的時間</li><li><strong>T2</strong>：WAL 的最後時間</li></ul><p><strong>Recovery 步驟：</strong></p><ol><li><strong>Load SSTables</strong>：從磁碟載入已存在的 SSTables（不可變檔案），它們是上次 Flush 時已經落盤的資料。</li><li><strong>Replay WAL</strong>：從 <strong>T1</strong> 開始依序重播 WAL 中的變更，將尚未落盤的資料重新套用到 Memtable 中，然後 Flush 成新的 SSTable。</li></ol><p>Cassandra 採用 <strong>append-only 的 WAL</strong> + <strong>Memtable</strong> + <strong>SSTable</strong> 架構。復原時，SSTable 不需修改，只需依 WAL 回補未持久化的變更。墓碑（Tombstone）紀錄也會在重播時套用，以保證刪除狀態正確。</p><h4>Recovery in ClickHouse</h4><p><strong>Recovery 步驟：</strong></p><ol><li><strong>Scan Data Parts &amp; Metadata</strong>：啟動時掃描檔案系統中的所有 Data Parts 及其 metadata，確認哪些 parts 是完整有效的。</li><li><strong>Discard Corrupted Parts</strong>：若發現損壞或不完整的 parts，直接刪除，<strong>不嘗試修復</strong>。</li><li><strong>Load Valid Parts</strong>：將有效的 Data Parts 載入系統，即完成恢復。</li></ol><p>由於 ClickHouse 沒有 WAL，無法像 OLTP 資料庫（例如 PostgreSQL）那樣，透過重播日誌來補上未完成的 INSERT 等操作。因此，在崩潰前尚未完成落盤的資料，對 ClickHouse 而言等同於「從未寫入」。</p><p>這種情況下，資料的補送責任會交回給上游系統（通常是 OLTP、Kafka、ETL 等），由它們檢測這批資料未成功消費，並重新推送。若上游是 Kafka Connect、Flink、Debezium 等資料管道，則會依據 offset 或 checkpoint，將未確認的批次再次發送到 ClickHouse。</p><h4>本章小結</h4><p>至此，華麗的走秀落幕。讓我們用不同的面向，來回顧今天站上舞台的四位主角 — — <strong>Relational DB、C-Store、Cassandra、ClickHouse</strong> — — 來一場快問快答、現學現賣：</p><ul><li>哪兩款是 <strong>OLTP</strong>？哪兩款是 <strong>OLAP</strong>？</li><li>哪三款有 <strong>WAL</strong>？</li><li>哪一款的 WAL 同時包含 <strong>redo record</strong> 與 <strong>undo record</strong>？哪兩款的 WAL 只有 <strong>redo record</strong>？</li><li>哪一款是 <strong>Row Store</strong>？哪兩款是 <strong>Column Store</strong>？哪一款是 <strong>Wide-Column Store</strong>？</li><li>哪三款的查詢語言是 <strong>SQL</strong>？</li></ul><p>資料庫的迷人之處在於 — — 只要換一個觀察角度，就能激盪出完全不同的思辨火花。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/840/1*2Ls1ceJbge6Oi-SS4INS7A.png" /><figcaption>本章提到的四種風格迴異，適用場景各不相同的 databases</figcaption></figure><p>若以時間軸來看，這一切的脈絡始於 <strong>Database</strong> 的時代。那時，<strong>WAL</strong>（Write-Ahead Log） 已深植於關聯式資料庫的實作之中，肩負著復原（Recovery）的關鍵任務。</p><p>隨著讀寫負載需求日益分化，<strong>OLTP</strong> 與 <strong>OLAP</strong> 分道揚鑣：</p><ul><li><strong>OLTP</strong> 作為 <strong><em>primary storage</em></strong>，承載即時交易，強調每筆資料在發生當下就被妥善記錄，確保高度的一致性與完整性；</li><li><strong>OLAP</strong> 則擔任 <strong><em>secondary storage</em></strong>，將來自不同系統、橫跨時間的資料加以整合，讓企業得以回顧歷史，發掘橫向關聯，支撐商業智慧（BI）分析。</li></ul><p>在 OLTP 的世界中，SSTable 搭配 LSM Tree 的設計，有效應對高寫入吞吐與分散式儲存的挑戰。資料得以以 append-only 的方式分散寫入各節點，而後透過 compaction 機制讀取正確結果。這種架構賦予了比傳統 PostgreSQL 更高的寫入吞吐效能。</p><p>而在 OLAP 世界裡，Column Store 是設計上的首選。C-Store 的出現，更進一步確立了 Column Store 在「寫少讀多」場景下的技術典範。接著，ClickHouse 將 MergeTree 的哲學與 Column Store 架構融合，為 OLAP 帶來一場革新：<strong>即使面對高頻寫入，也能近乎即時的查詢結構化資料</strong>，讓<strong>「即時分析」</strong>從夢想走向實現。</p><p>然而，即便貴為查詢界的 QuickSort，ClickHouse 在 <strong>JSON、Array、Nested 等半結構化資料的即時查詢上</strong>，仍有掣肘。就在這塊曾被視為瓶頸的領域，一款實時分析型資料庫以精妙的設計突破了極限 — — <strong>2024 年的舊金山</strong>，一次商業上的戲劇性轉折，讓它成為全球的焦點。</p><p>關於它的故事──我們擇日再續。</p><p>(to be continued …)</p><h3>附錄</h3><p>4 ~ 6: 如何寫資料，如何讀資料，如何維護資料，這三講視頻構成堅實的三部曲。正文省略掉 Cassandra Read Path 的介紹，在此補充。Cassandra 的讀取會依序經過很多資料結構，依序是：<br><strong>Memtable → Row Cache → Bloom Filter → Partition Key Cache → Partition Summary → Partition Index → Compression Offset Map → Data Row</strong></p><ul><li>有些是「可能經過」（Row Cache, Partition Key Cache）</li><li>有些是「一定經過」（Bloom Filter, Partition Index）</li></ul><p>9 ~ 11: 3 種 Cassandra Compaction Strategy 精講構成另一個堅實的三部曲。</p><p>16 ~17: 長年以來，UPDATE 語句一直被視為 ClickHouse 的短板，原因來自其底層架構 — — 不可變的 data part 與 MergeTree 儲存設計。然而，這項限制也激發出一連串工程上的創新與重塑，使得在 2025 年的今天，ClickHouse 的更新能力已大幅躍進，逐步擺脫過去「不擅長修改」的印象。</p><ol><li>Algorithm for Recovery and Isolation Exploiting Semantics: (ARIES)<a href="https://www.geeksforgeeks.org/dbms/algorithm-for-recovery-and-isolation-exploiting-semantics-aries/">https://www.geeksforgeeks.org/dbms/algorithm-for-recovery-and-isolation-exploiting-semantics-aries/</a></li><li>RPO and RTO: getting to zero downtime and zero data : loss<a href="https://www.cockroachlabs.com/blog/demand-zero-rpo/">https://www.cockroachlabs.com/blog/demand-zero-rpo/</a></li><li>Row-Based Storage vs Column-Based Storage: A Beginner’s Guide: <a href="https://medium.com/@DataWithSantosh/row-based-storage-vs-column-based-storage-a-beginners-guide-6e91dbadb181">https://medium.com/@DataWithSantosh/row-based-storage-vs-column-based-storage-a-beginners-guide-6e91dbadb181</a></li><li>DS201.17 Write Path | Foundations of Apache Cassandra: <a href="https://www.youtube.com/watch?v=mDd4I-isodE&amp;list=PL2g2h-wyI4Spf5rzSmesewHpXYVnyQ2TS&amp;index=18&amp;ab_channel=DataStaxDevelopers">https://www.youtube.com/watch?v=mDd4I-isodE&amp;list=PL2g2h-wyI4Spf5rzSmesewHpXYVnyQ2TS&amp;index=18&amp;ab_channel=DataStaxDevelopers</a></li><li>DS201.18 Read Path | Foundations of Apache Cassandra:<br><a href="https://www.youtube.com/watch?v=x6g0sUi-5tw&amp;list=PL2g2h-wyI4Spf5rzSmesewHpXYVnyQ2TS&amp;index=23&amp;ab_channel=DataStaxDevelopers">https://www.youtube.com/watch?v=x6g0sUi-5tw&amp;list=PL2g2h-wyI4Spf5rzSmesewHpXYVnyQ2TS&amp;index=23&amp;ab_channel=DataStaxDevelopers</a></li><li>DS201.19 Compaction | Foundations of Apache Cassandra: <a href="https://www.youtube.com/watch?v=69sHSF0iUqg&amp;list=PL2g2h-wyI4Spf5rzSmesewHpXYVnyQ2TS&amp;index=19&amp;ab_channel=DataStaxDevelopers">https://www.youtube.com/watch?v=69sHSF0iUqg&amp;list=PL2g2h-wyI4Spf5rzSmesewHpXYVnyQ2TS&amp;index=19&amp;ab_channel=DataStaxDevelopers</a></li><li>Cassandra: How is data written? <a href="https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/dml/dmlHowDataWritten.html">https://docs.datastax.com/en/cassandra-oss/3.x/cassandra/dml/dmlHowDataWritten.html</a></li><li>Cassandra Compaction Definition: <a href="https://www.scylladb.com/glossary/cassandra-compaction/">https://www.scylladb.com/glossary/cassandra-compaction/</a></li><li>DS210.16 Size-Tiered Compaction: <a href="https://www.youtube.com/watch?v=TyTXOjFMi7k&amp;ab_channel=DataStaxDevelopers">https://www.youtube.com/watch?v=TyTXOjFMi7k&amp;ab_channel=DataStaxDevelopers</a></li><li>DS210.15 Leveled Compaction: <a href="https://www.youtube.com/watch?v=6yJEwqseMY4&amp;ab_channel=DataStaxDevelopers">https://www.youtube.com/watch?v=6yJEwqseMY4&amp;ab_channel=DataStaxDevelopers</a></li><li>DS210.17 Time Window Compaction: <a href="https://www.youtube.com/watch?v=P8jPlppa_8I&amp;ab_channel=DataStaxDevelopers">https://www.youtube.com/watch?v=P8jPlppa_8I&amp;ab_channel=DataStaxDevelopers</a></li><li>LSM Tree Database Compaction Strategies: When to Use Size-Tiered, Leveled, or Time-Windowed: <a href="https://medium.com/@rastogi.shivank16/lsm-tree-database-compaction-strategies-when-to-use-size-tiered-leveled-or-time-windowed-f40b5f839e3c">https://medium.com/@rastogi.shivank16/lsm-tree-database-compaction-strategies-when-to-use-size-tiered-leveled-or-time-windowed-f40b5f839e3c</a></li><li>An In-depth Discussion on the LSM Compaction Mechanism: <a href="https://www.alibabacloud.com/blog/an-in-depth-discussion-on-the-lsm-compaction-mechanism_596780">https://www.alibabacloud.com/blog/an-in-depth-discussion-on-the-lsm-compaction-mechanism_596780</a></li><li>ClickBench — a Benchmark For Analytical DBMS: <a href="https://benchmark.clickhouse.com/">https://benchmark.clickhouse.com/</a></li><li>ClickHouse Table parts: <a href="https://clickhouse.com/docs/parts">https://clickhouse.com/docs/parts</a>:</li><li>How we built fast UPDATEs for the ClickHouse column store — Part 1: Purpose-built engines: <a href="https://clickhouse.com/blog/updates-in-clickhouse-1-purpose-built-engines">https://clickhouse.com/blog/updates-in-clickhouse-1-purpose-built-engines</a></li><li>How we built fast UPDATEs for the ClickHouse column store — Part 2: SQL-style UPDATEs: <a href="https://clickhouse.com/blog/updates-in-clickhouse-2-sql-style-updates">https://clickhouse.com/blog/updates-in-clickhouse-2-sql-style-updates</a></li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=32bbdd46b5b1" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[From Database to Data Lakehouse — Part 7]]></title>
            <link>https://medium.com/@0050211/from-database-to-data-lakehouse-part-7-c5eeb497fb44?source=rss-663daee8909b------2</link>
            <guid isPermaLink="false">https://medium.com/p/c5eeb497fb44</guid>
            <dc:creator><![CDATA[Ryan Fu]]></dc:creator>
            <pubDate>Fri, 18 Jul 2025 20:07:01 GMT</pubDate>
            <atom:updated>2026-06-16T05:06:36.010Z</atom:updated>
            <content:encoded><![CDATA[<h3>Chapter 4. A Brief History of Storage Systems</h3><p>《<a href="https://medium.com/@adrianhsu/%E7%B5%A6%E5%AD%B8%E5%BC%9F%E5%A6%B9%E7%9A%84%E7%9F%BD%E8%B0%B7-ai-software-%E6%8C%87%E5%8D%97-da054b7a8dcb">給學弟妹的矽谷 AI / Software 指南</a>》是一個矽谷工程師自發的眾籌寫作計劃，由<a href="https://www.facebook.com/opensource4you">源來適你社群</a>推動。這將是一個在台灣軟體學生圈內很有影響力的文章系列。已有 &gt;10 篇準備發布，目標 100 篇並出書，歡迎 tag 強者大神來寫！</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*3XArX6kknOfrwe--.png" /></figure><p>儲存裝置的物理特性，決定了我們可以怎麼與資料互動。磁帶鼓勵線性讀寫，HDD 提供隨機存取，SSD 則強調高速與並發。這些裝置，如同資訊世界的畫布，作為之後抽象、邏輯與符號的載體。隨著「畫布」的質地改變，我們對筆法與佈局的要求也開始升級：從 sector 為單位的 Block Store、到目錄結構清晰的 File Store、再到分散式架構下以 metadata 為核心的 Object Store。</p><p>本章將聚焦於現代資料架構中的三種核心儲存系統：<strong>Block Store、File Store、Object Store</strong>。其中，Block Store 與 File Store 將分為 <strong>Single Node 與 Multi-Node</strong> 兩個階段來介紹，反映它們從本地部署到分散式架構的演進。至於 <strong>Object Store</strong>，則是一種天生具備<strong>雲端橫向擴展（Horizontal Scaling）</strong>能力的儲存模式，其設計初衷即是為了大規模資料與多節點環境而生。</p><h3>存儲系統簡史</h3><h4>Block Store in Single Node：從隨機存取開始</h4><p>早期的磁帶只能線性讀寫，但隨著磁碟（disk）技術的出現，我們第一次可以「隨機存取」資料。這意味著資料不必一口氣全讀，而是可以從任意位置開始。為了支援這種「位置導向」的存取邏輯，作業系統開始以 fixed-size 的區塊為單位，直接控制磁碟的讀寫頭：這就是 Block-based I/O 的誕生。</p><p>所謂 Block Store，又稱為區塊層級儲存（block-level storage），是指將資料切割成一個個長度固定的區塊（blocks），每次存取的單位就是這些區塊，而不是整個檔案。這些區塊會被暫存於資料緩衝區（data buffer）中，讀寫時以區塊為單位進行，可以有效降低 overhead 並提升資料流處理效率。這個封鎖（blocking）與解鎖（deblocking）的過程，是所有底層儲存系統的基礎。</p><p>事實上，<strong>所有儲存最終都建立在 Block Store 之上</strong>。不同的儲存系統，只是選擇用不同方式去組織這些區塊、管理它們的存取邏輯而已。Block Store 允許使用者指定 offset 和 block size，對任意 sector 進行快速讀寫。它不記錄「檔名」，不管「目錄結構」，更不關心「誰擁有這段資料」 — — 這些功能，是後來的 File Store 才會提供。</p><p>區塊儲存系統常搭配一種稱為「日誌紀錄（journaling）」的機制，用來記錄所有資料的變更操作。具體而言，journaling 並不直接修改資料本體，而是先在一塊預留區域（通常稱為 journal 或 write-ahead log）中紀錄「將對哪個區塊進行什麼操作」。只有當這筆操作在 journal 中被完整寫入後，系統才會實際修改對應區塊。</p><p>這種設計有兩個好處：</p><ol><li><strong>快速復原</strong>：如果系統發生斷電或錯誤，只要重播 journal，就能快速還原系統狀態，而不需掃描整個磁碟。</li><li><strong>定位明確</strong>：每筆寫入操作都與特定 block 編號對應，讓系統能立即知道某筆資料位於哪個位置、需從哪些區塊讀回。</li></ol><p>然而，Block Store 的強項在於處理結構化資料，對於需要大量 metadata 的非結構化應用（例如搜尋與標籤）則顯得力不從心。因為 Block Store 並不提供原生的 metadata 結構，導致以非結構化資料為核心的應用，在執行搜尋與索引時會遇到限制。</p><p>以下是 Block Store 的應用場景。</p><ul><li><strong>資料庫系統<br></strong> 資料庫通常不希望被 OS 的 File System 干擾，例如 Oracle、PostgreSQL 等，都會實作自己的 buffer pool 與 block manager。<br> Oracle 的 ASM（Automatic Storage Management）就是一種 Block Store，它讓 DBMS 直接控制資料塊的排列與 I/O 策略。</li><li><strong>虛擬機映像檔（本機磁碟）<br></strong> 像 QEMU、VMware、KVM 等使用 qcow2、vmdk 檔案時，會將磁碟模擬成一個 Block Store，供虛擬機系統讀寫。</li><li><strong>RAID 系統（本機磁碟條帶與鏡像）<br></strong> RAID 的條帶化（striping）與鏡像（mirroring）邏輯，是直接針對 block 而非檔案來實作的，屬於本機單節點的磁碟管理方式。</li></ul><h4>Block Store in Multi-Node：從單節點到分散式部署的演進</h4><p>隨著資料中心規模的擴大，單一機器的硬碟或 RAID 系統逐漸無法滿足容量與可靠度的需求。為了因應這種變化，Block Store 開始走向多節點部署的架構。然而，即使底層儲存設備已經分散到多個節點，Block Store 的存取方式仍分為兩大類：<strong>多節點部署但單節點存取</strong>，以及<strong>多節點部署、多節點同時存取</strong>。</p><p>第一種情況是最常見的，也是目前企業與雲端的主流做法。儲存設備雖然部署在多個節點，例如橫跨資料中心、AZ 或機房，但實際上，每個區塊裝置（volume）仍然只會被單一主機掛載與寫入。這種模式的代表例子就是 <strong>Amazon Elastic Block Store (EBS)</strong>。EBS 的資料分散存放於 AWS 的多個儲存節點中，並具備高可用的副本機制，但一個 EBS volume 同一時間只能掛載到一台 EC2 主機上（除非啟用 Multi-Attach）。這樣的設計避免了多台主機同時對同一區塊進行寫入時可能產生的資料衝突。</p><p>另一種更進階的情況是<strong>多節點部署，並允許多節點同時存取同一個 Block Store</strong>。這種模式較少見，因為它需要特殊的鎖定協議與一致性管理，來解決寫入衝突與資料同步問題。</p><h4>File Store in Single Node：讓資料有了名字與結構</h4><p>Block Store 提供了最底層的隨機讀寫能力，但對使用者來說，一堆 sector 或 block 編號並沒有意義。我們真正需要的，是一種能夠<strong>命名資料、建立目錄、設定權限</strong>的結構，也就是 File Store 的出現理由。</p><p>File Store 建立在 Block Store 之上，將資料區塊（blocks）抽象為可命名的「檔案」，並以層級式目錄（hierarchical directory）組織這些檔案。每個檔案都擁有一個唯一的檔案路徑（file path），這個路徑不僅標明資料的物理位置，也承載著語意資訊：檔案名稱、副檔名、所屬資料夾、最後修改時間與存取者權限等 metadata。</p><p>舉例來說：</p><ul><li>/project/report/summary.txt 就是典型的檔案路徑，其中 summary 是檔名，.txt 是副檔名，/project/report/ 是所在目錄。</li><li>透過這樣的路徑，使用者與作業系統都可以快速定位、讀寫、呈現資料。</li></ul><p>這種「可命名、可組織、可查找」的能力，讓 File Store 成為現代作業系統與應用程式之間最主要的資料接口。</p><p>File Store 的歷史，幾乎就是 UNIX 與 Windows 作業系統演化的縮影，如下圖。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZLMbuMyIxSn9XgGiAoHAug.png" /></figure><p>其中，UNIX 的 inode-based 架構是一個代表性設計：每個檔案都有唯一 inode 編號，記錄其 metadata 與實際 block pointer。這讓 File Store 得以用極高效率管理數十萬、甚至上億筆檔案，並支援硬連結、軟連結等功能。inode 的出現，使資料從「純位址存取」轉向「以 metadata 為查詢基礎」的架構設計，也成為日後檔案系統演進的核心基礎。</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*-lcVbmoQ42DpPYil" /><figcaption>a tree-based inode structure</figcaption></figure><p>File Store 系統本質上仍依賴 Block Store，但其資料組織更抽象、更人性化。在實作上，File Store 通常會定義自己的邏輯區塊大小（例如 4 KB、8 KB），這些邏輯區塊的大小可能會大於硬體實體的物理區塊大小（例如 512 bytes），這麼做能簡化管理與對齊操作。</p><p>然而，也因此產生了一個典型的技術問題：<strong>內部碎片（internal fragmentation）</strong>。因為檔案長度往往不是區塊大小的整數倍，導致每個檔案的最後一個區塊可能有部分空間閒置。久而久之，整體儲存空間的使用效率下降，甚至影響系統效能與擴展性。</p><p>此外，傳統 File Store 在檔案數量極大時（例如百萬級以上）也容易遇到 metadata 管理瓶頸，例如：</p><ul><li>inode 編號耗盡</li><li>目錄查找速度降低</li></ul><p>值得一提的是，<strong>POSIX（Portable Operating System Interface）</strong> 是由 IEEE 制定的一系列作業系統標準，旨在讓軟體能夠跨不同 UNIX 系統之間移植與運作。POSIX 最早於 <strong>1988 </strong>年正式發布，主要定義了作業系統應提供的<strong>API、檔案操作、進程管理與基本 I/O 行為規範</strong>。在隨後的版本中，POSIX 持續擴充，涵蓋了執行緒（<strong>Pthreads</strong>）、實時系統（<strong>Realtime</strong>）、網路協定等範疇，逐步建立了完整的 UNIX 相容標準。</p><p>特別是在儲存領域，<strong>POSIX 檔案語意（POSIX File Semantics）</strong> 成為傳統 File Store 的基礎。它規定了檔案與目錄的層級結構、鎖定機制、同步操作，以及一致性保證。這種語意雖然適合本地作業，但在雲端與分散式環境下，卻逐漸暴露出擴展性與靈活度的限制。</p><h4>File Store in Multi-node：從本機檔案系統到分散式檔案服務</h4><p>單機的 File Store 解決了本地資料管理的需求，但當多台伺服器需要同時讀寫、共享檔案時，單一節點的檔案系統就無法滿足需求了。這時，「<strong>多節點共享的 File Store</strong>」應運而生。</p><p>多節點 File Store 的設計目標，是讓多台主機可以<strong>同時存取相同的檔案系統</strong>，彼此共享資料，無需各自管理本地磁碟。這類架構的應用場景非常廣泛，例如企業內部的檔案伺服器、雲端平台的資料共享、叢集運算環境中的共同檔案存取等。</p><p>最早期的多節點檔案共享，從 1980s 的網路檔案系統（NFS）開始。NFS 是一種透過網路讓多台主機掛載同一個遠端目錄的協定。後來 Windows 發展出 SMB/CIFS，提供了類似的網路檔案共享機制。這類系統讓多台主機可以像存取本機檔案一樣操作遠端檔案，形成了最初的「分散式檔案存取」雛型。</p><p>進入 2000 年代後，資料量開始爆炸式成長。單純的目錄共享已經無法應對大數據處理的需求。2006 年，<strong>HDFS（Hadoop Distributed File System）</strong> 誕生，成為第一個被大規模採用的雲端時代分散式檔案系統。HDFS 的設計理念來自 Google GFS 論文，主要目標是支援巨量資料的批次處理，將單一檔案切成多個區塊，分散存放於多台伺服器上，並自動管理副本、容錯與節點失效處理。</p><p>HDFS 的核心特性是「<strong>Write Once, Read Many（一次寫入，多次讀取）</strong>」，適合大數據分析場景，但不適合頻繁隨機寫入或即時修改檔案。它支援大規模橫向擴展，透過 NameNode 管理 metadata，DataNode 儲存實際資料，成為 Spark、MapReduce、Hive 等大數據系統的基礎設施。</p><p>到了雲端時代，企業開始需要更一般化的共享檔案系統，而不只是大數據場景。2016 年，AWS 推出了 <strong>Elastic File System（EFS）</strong>，將傳統的 NFS 檔案共享雲端化，提供多節點掛載的服務。EFS 讓數十台甚至數百台 EC2 主機可以同時掛載同一個目錄，並具備自動擴展與高可用性。與 HDFS 不同，EFS 並不強調大數據運算，而是針對日常應用中的檔案共享需求設計，例如雲端網站主機、開發環境、備份與媒體檔案儲存等。</p><h4>Object Store：從檔案語意走向物件語意的儲存革命</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/196/0*hy07xiGWcXATnWni" /></figure><p>多節點 File Store 解決了基本的檔案共享問題，但當資料規模持續成長，特別是進入非結構化資料的時代，傳統檔案系統的限制也逐漸浮現。以 <strong>AWS Elastic File System（EFS）</strong> 為例，雖然它成功將 NFS 雲端化，支援多節點同時掛載與自動擴展容量，但本質上仍是建立在「檔案與目錄」的邏輯之上。這樣的結構，在雲端與大規模資料場景中，逐步暴露出五大核心問題：</p><p>1️⃣ 目錄結構的擴展極限</p><p>EFS 仍保留傳統的階層式目錄（hierarchical directory）。檔案是<strong>樹狀結構（Tree-based）管理</strong>，這種設計會在大規模時產生效能瓶頸：</p><ul><li>當目錄層級過深，查詢與遍歷速度顯著降低。</li><li>單一目錄內的檔案數量過多時，檔案查找會變得緩慢。</li><li>當系統規模擴展到數十億筆檔案時，目錄索引與 metadata 維護成為明顯瓶頸。</li></ul><p><strong>Object Store 的解法：</strong></p><p>Object Store 完全取消了樹狀目錄結構，採用<strong>平坦命名空間（Flat Namespace）</strong>。每個物件都有唯一的 key（如 URL 或物件 ID），資料會根據前綴或 hash 自動分散到不同儲存節點。這種設計消除了單一目錄的鎖與索引瓶頸，讓系統可以橫向擴展至數十億個物件而不會因目錄結構影響效能。</p><p>2️⃣ metadata 表達力不足</p><p>EFS 只支援傳統的檔名、副檔名、檔案路徑與基本權限，<strong>無法儲存豐富的自定義 metadata</strong>。這對於非結構化資料來說，形成明顯障礙：</p><ul><li>你無法直接在檔案層級加上分類標籤（tag）、版本號、資料來源等屬性。</li><li>查詢檔案只能靠檔名或目錄，無法用 metadata 條件來篩選。</li></ul><p>當應用場景轉向媒體管理、機器學習模型版本控制、IoT 訊號儲存，這種 metadata 表達力的缺陷變成極大的管理負擔。</p><p><strong>Object Store 的解法：</strong></p><p>Object Store 每個物件除了資料本體，還可以攜帶<strong>自定義 metadata</strong>，例如分類標籤（Tag）、版本編號、加密資訊、生命週期設定、業務屬性等等。這些 metadata 與物件本身一起儲存，成為物件不可分割的一部分。</p><p>3️⃣ 一致性與鎖定問題</p><p>檔案系統必須維持全域一致性（Global Consistency）。多台主機同時存取同一個檔案時，必須使用檔案鎖（file lock）來協調，否則會發生寫入衝突。但鎖的管理會造成效能衝突：</p><ul><li>多節點寫入同一檔案時，鎖定衝突會導致延遲。</li><li>Metadata 的操作（如建立目錄、修改權限）也容易成為同步瓶頸。</li><li>鎖的機制會限制分散式擴展的效率。</li></ul><p><strong>Object Store 的解法：</strong></p><p>Object Store 採用<strong>物件級存取（Object-level Access）</strong>，每個物件都是獨立的資料單位，沒有共享的 inode 或目錄鎖。每次 PUT/GET 操作都是原子性的，無需鎖定整個檔案系統，天然支援多節點並行存取，避免了鎖競爭與一致性瓶頸。</p><p>4️⃣ I/O 模型受限於傳統檔案語意</p><p>EFS 遵循 POSIX 檔案系統規範，必須符合傳統的 open、read、write、close 操作流程。這種模型：</p><ul><li>不適合處理高併發的物件存取或事件資料流。</li><li>無法針對每筆資料做到獨立物件級存取與管理。</li><li>每次存取都需維持檔案狀態（stateful），導致複雜度增加。</li></ul><p><strong>Object Store 的解法：</strong></p><p>Object Store 採用<strong>無狀態的 RESTful API（PUT/ GET/ DELETE）</strong>，每筆資料獨立操作，無需持續開檔、關檔，也不需維持 session 狀態。這使得物件存取可以高併發進行，適合事件資料流、IoT、影像上傳等場景。</p><p>5️⃣ 成本與效能的矛盾</p><p>EFS 具備自動擴展能力，但隨著資料量成長，儲存成本快速上升。以 PB 級資料規模計算，EFS 的儲存成本通常遠高於物件儲存（如 S3）。此外，EFS 的延遲主要設計給一般檔案共享，<strong>不適合用於需要海量物件快速並行寫入的場景</strong>。</p><p><strong>Object Store 的解法：<br></strong> Object Store 的儲存架構是<strong>Append-Only + Immutable Files</strong>，優化了寫入成本，並支援分層儲存（如熱存、冷存、封存）。同時，Object Store 的分散式架構允許海量物件的平行寫入，提升併發效能，並將總體儲存成本控制在大數據規模可接受的範圍內。</p><h4>Object Store 是為了規模化與非結構化資料而生</h4><p>正因為 EFS 與傳統檔案系統的結構性限制，當資料規模突破到數十億筆非結構化物件時，<strong>Object Store</strong> 被提出來作為全新的儲存抽象。它徹底拋棄了「檔案與目錄」的邏輯，改用「物件（Object）」作為儲存單位。每個物件由三個部分組成：</p><ol><li><strong>資料本體（Data）<br></strong> 儲存的內容，例如圖片、影片、Log 檔、模型檔案等。</li><li><strong>metadata（自定義描述）<br></strong> 允許自定義多維度欄位，如資料版本、標籤、時間戳、來源分類，甚至加密與存取權限，都可以直接存放在 metadata 裡。</li><li><strong>唯一識別碼（Object ID 或 URI）<br></strong> 不再有檔案路徑，物件用 ID 或 URL 作為索引，例如：<br> s3://my-bucket/user123/photo.png<br> 這種識別方式讓資料可以跨地理節點存取，完全解耦物理位置與命名規則。</li></ol><p>Object Store 在 2006 年隨著 <strong>Amazon S3</strong> 出現，成為雲端儲存的標準。此後，其他主流服務陸續推出：</p><ul><li><strong>Google Cloud Storage</strong></li><li><strong>Azure Blob Storage</strong></li><li><strong>Ceph Object Gateway（私有雲）</strong></li><li><strong>MinIO（開源物件存儲，適合自建）</strong></li></ul><p>Object Store 並不是單純的「更大的檔案系統」，而是<strong>資料語意的轉變</strong>。它將資料從層級結構中解放，轉為以物件 ID 與 metadata 管理，讓資料儲存真正具備了橫向擴展、全球存取、彈性分類與版本控制的能力。</p><p>Object Store 的核心優勢在於：</p><ul><li><strong>平坦命名空間（Flat Namespace）<br></strong> 不再有目錄結構，物件之間沒有層級，天然支援數十億筆物件的存取。</li><li><strong>API 式存取<br></strong> 不需要 mount 操作，直接透過 HTTP API（PUT/ GET/ DELETE）存取，適合自動化與微服務架構。</li><li><strong>橫向擴展與分散式特性<br></strong> 天然支援多節點寫入，無需鎖定。系統會自動處理副本、容錯與資料分佈。</li><li><strong>metadata 驅動管理<br></strong> 透過 metadata 直接管理版本、標籤、分類與生命週期（如自動轉冷存）。</li></ul><p>Object Store 相對於 inode 的劣勢與限制：</p><p>1️⃣ 不支援細粒度區塊更新（No Partial Update）</p><ul><li><strong>inode 系統</strong>：支援隨機寫入，可以修改檔案的一部分（例如 append 或修改第 N 個 byte）。</li><li><strong>Object Store</strong>：每次修改都必須<strong>整個物件重寫（PUT 覆蓋）</strong>，不支援部分更新。<br> 適合 WORM（Write Once Read Many）場景，但不適合頻繁小修改。</li></ul><p>2️⃣ 不支援原地 rename / move</p><ul><li><strong>inode 系統</strong>：rename 或 move 是 metadata 操作，O(1) 內完成，不需移動實體資料。</li><li><strong>Object Store</strong>：沒有「目錄」或 inode table，rename 實際上是「新建一個物件，再刪除舊的」，需要完整複製，成本高。</li></ul><p>3️⃣ 一致性模型較弱</p><ul><li><strong>inode 系統</strong>：本地檔案系統通常支援強一致性（Strong Consistency），例如 fsync 後立即可見。</li><li><strong>Object Store</strong>：大多數物件存儲（如 S3）採<strong>最終一致性（Eventually Consistent）</strong>，尤其是 List / Delete 操作，容易發生短時間內查不到剛寫入的物件或看見已刪除物件。</li></ul><p>4️⃣ 缺乏檔案權限與 ACL 細粒度管理</p><ul><li><strong>inode 系統</strong>：支援 per-file 權限（例如 UNIX 的 rwxr-xr-x）、群組控制、硬連結、軟連結。</li><li><strong>Object Store</strong>：權限控制通常在 bucket 或物件層級，細節依賴 IAM 或 Bucket Policy，粒度較粗，無法模擬完整的 POSIX 權限。</li></ul><p>5️⃣ 操作延遲較高（High Latency per Operation）</p><ul><li><strong>inode 系統</strong>：本地磁碟延遲低，單檔案操作通常在微秒～毫秒等級。</li><li><strong>Object Store</strong>：每次 PUT/ GET 都是完整 HTTP 請求，延遲較高（通常數十毫秒以上）。</li></ul><p>6️⃣ 無法直接支援本地掛載與 POSIX API</p><ul><li><strong>inode 系統</strong>：可以直接用 open()、read()、write() 等系統呼叫，檔案可掛載至目錄樹。</li><li><strong>Object Store</strong>：需透過 API（RESTful、SDK），不能直接當作本地檔案操作（除非用 FUSE 類橋接，但效能會大幅降低）。</li></ul><p>7️⃣ 無法做「零拷貝」（Zero-Copy）</p><ul><li><strong>inode 系統</strong>：支援 mmap、sendfile 等零拷貝技術，適合高效能場景。</li><li><strong>Object Store</strong>：每次讀寫都需透過網路傳輸，無法避免資料複製。</li></ul><p>所有存儲裝置 — — 無論是 HDD、SSD、NVMe — — 最終都以 <strong>block 為最小單位運作</strong>。現代雲端的 Object Store，其本質是在 Block Store 之上，建立了一層更高階的抽象，負責<strong>對外提供物件級 API（PUT/GET/DELETE）、管理 metadata（如物件名稱、標籤、版本），以及決定物件如何切分、加密與分佈到底層區塊</strong>。</p><p>即便底層存儲裝置採用的是 HDD，<strong>Object Store 依然不支援 in-place update（局部覆寫）</strong>。這一限制並非來自硬體層面的物理限制，而是源於<strong>分散式儲存系統在一致性、容錯與擴展性上的設計取捨</strong>。Object Store 採用 <strong>不可變資料（Immutable Data）模型</strong>，每個物件一旦寫入後即被視為完整且不可修改的單位。所有的資料更新行為，都是透過<strong>新增新物件（PUT）與刪除舊物件（DELETE）來實現版本替換</strong>，並不允許直接對現有物件內容進行部分修改。這樣的設計可以有效避免多節點間的鎖定問題，簡化一致性協議，確保每次存取都是獨立且原子的操作。</p><p>此外，Object Store 的物件資料通常會被<strong>切分成多個 chunk，分散存放在不同節點上，並具備多副本冗餘或糾刪碼（Erasure Coding）容錯機制</strong>。若允許 in-place update，不僅需要在多台伺服器之間同步鎖定特定區塊，還會導致資料與 metadata 之間的一致性問題。相較之下，採用不可變資料結構，可以讓物件的 metadata 與實體資料一同以「版本為單位」管理，天然支援時間旅行（Time Travel）、快照（Snapshot）與版本控制（Versioning），大幅降低分散式寫入的協調成本。</p><p>總結來說，<strong>「不可變物件」並非技術上的硬體限制，而是分散式存儲架構在一致性、容錯與擴展性之間權衡後的必然選擇</strong>。這種設計讓 Object Store 能在大規模、多副本的雲端環境中維持高效穩定的操作，雖然犧牲了局部更新的能力，但換來了簡化的併發控制與高度的系統彈性。</p><h4>SSD, Object Store, Parquet, and Lakehouse</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*l0q5NLTW2YtElA_L" /><figcaption>Lakehouse is an “Unbundled Database”</figcaption></figure><p>現代資料基礎建設的演進，從來不是單線性的過程。它是技術與需求在不同層次上彼此作用、互相推動的結果。各個層級的組件各自演化，最終在 2025 年，拼湊出看似完美的 OLAP 終極形態 — — <strong>Data Lakehouse</strong>。</p><p><strong>固態硬碟（SSD）</strong> 的普及，首先解決了資料存取的速度瓶頸。自此，隨機讀寫變成常態，儲存不再只是單純的序列化媒介，而是成為可以高速並發操作的資料場域。上個章節已經詳細介紹了 SSD 的技術演進。</p><p>然而，速度的提升並不足以應對資料規模的爆炸。當組織開始面對數十億筆非結構化資料時，傳統檔案系統的層級結構與 metadata 管理逐漸顯得捉襟見肘。這時，<strong>Object Store</strong> 應運而生。它提供了<strong>平坦命名空間（Flat Namespace）</strong>、<strong>橫向擴展能力</strong>，並透過 API 直接操作物件，成為雲端時代資料儲存的主流架構。今天，上圖的 Data Lake Storage，在多數場景中幾乎等同於 Object Store。</p><p>但僅有儲存，仍不足以支援後續的運算與分析。資料若無法被高效查詢與結構化解析，只是堆積在雲端的死資料。於是，<strong>Parquet</strong> 等欄式檔案格式被提出，專為分析場景設計。Parquet 將資料從「原始物件」轉換為「分析友善」的 colume format，極大降低了 I/O 成本，並為後續的資料處理系統建立了標準介面。Parquet 是一種<strong>不可變檔案格式</strong>，寫入後的檔案<strong>不</strong>支援原地修改。</p><p>Lakehouse 將資料儲存在 Object Store 上，卻同時具備資料倉儲的管理與查詢能力。透過表格層（如 <strong>Iceberg、Delta Lake、Hudi</strong>），物件儲存也能支援版本控制、事務一致性與 SQL 查詢，打破了 Data Warehouse 與 Data Lake 的界線。</p><p>然而，這一切的關鍵，其實還有一個尚未被充分揭示的核心問題：<strong>當底層的檔案是不可變的，我們又是如何在上面模擬出可變的資料表？</strong></p><p>這正是接下來要討論的主題 — — <strong>不可變檔案簡史</strong>。</p><p>(To be continued …)</p><h3>附錄</h3><ol><li>Block Storage vs. Object Storage vs. File Storage: What’s the Difference?<a href="https://qumulo.com/blog/block-storage-vs-object-storage-vs-file-storage/">https://qumulo.com/blog/block-storage-vs-object-storage-vs-file-storage/</a></li><li>File Storage vs. Block Storage vs. Object Storage: <a href="https://rajkumaraug20.medium.com/file-storage-vs-block-storage-vs-object-storage-2519031a2646">https://rajkumaraug20.medium.com/file-storage-vs-block-storage-vs-object-storage-2519031a2646</a></li><li>What’s the difference between block, object, and file storage?<a href="https://aws.amazon.com/compare/the-difference-between-block-file-object-storage/">https://aws.amazon.com/compare/the-difference-between-block-file-object-storage/</a></li><li>DeepSeek 3FS vs. JuiceFS: Architectures, Features, and Innovations in AI Storage: <a href="https://juicefs.com/en/blog/engineering/deepseek-3fs-vs-juicefs-architecture-feature">https://juicefs.com/en/blog/engineering/deepseek-3fs-vs-juicefs-architecture-feature</a></li><li>Exploring storage and computing separation for ClickHouse: <a href="https://juicefs.com/en/blog/solutions/clickhouse-disaggregated-storage-and-compute-practice">https://juicefs.com/en/blog/solutions/clickhouse-disaggregated-storage-and-compute-practice</a></li></ol><h3>那些有趣的文字小遊戲</h3><p>Q1: 請問在 cs 領域和業界中，s1, s2, s3 分別代表什麼？</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c5eeb497fb44" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>