หากสิ่งใดถูกกำหนดให้มีไว้เพื่อสิ่งอื่นเป็นเป้าหมาย สิ่งนั้นก็ไม่อาจมีเป้าหมายสูงสุดอยู่ที่การรักษาตัวมันเอง ดังเช่นกัปตันเรือที่ไม่ได้มีเป้าหมายสูงสุดคือการรักษาเรือที่ได้รับมอบหมายไว้ เพราะเรือถูกสร้างขึ้นเพื่อสิ่งอื่น นั่นคือการเดินเรือ

(ที่มักถูกอ้างถึงว่า: หากเป้าหมายสูงสุดของกัปตันคือการรักษาเรือไว้ เขาก็จอดเรือไว้ที่ท่าเรือ ตลอดไป)

St. Thomas Aquinas, Summa Theologica (1265–1274)

ใน บทที่ 2 เราได้พูดถึงเป้าหมายของการสร้างแอปพลิเคชันและระบบที่มี ความน่าเชื่อถือ , ความสามารถในการขยายขนาด , และ ความสามารถในการบำรุงรักษา . ธีมเหล่านี้แทรกอยู่ในทุกบท ตัวอย่างเช่น เราพูดถึงอัลกอริทึม fault-tolerance (การทนต่อความผิดพลาด) หลายแบบที่ช่วยให้ระบบน่าเชื่อถือขึ้น การทำ sharding เพื่อให้ขยายขนาดได้ และกลไกด้าน evolvability กับ abstraction ที่ช่วยให้บำรุงรักษาระบบได้ง่ายขึ้น

ในบทนี้เราจะนำแนวคิดทั้งหมดมารวมกัน และต่อยอดจากแนวคิดสถาปัตยกรรม streaming/event-driven จาก บทที่ 12 เพื่อพัฒนาปรัชญาการสร้างแอปพลิเคชันที่ตอบโจทย์เป้าหมายเหล่านั้น บทนี้มีความเห็นส่วนตัวของผู้เขียนมากกว่าบทก่อนๆ เพราะเจาะลึกปรัชญาแนวเดียวแทนที่จะเปรียบเทียบหลายแนวทาง

Data Integration (การรวมข้อมูล)

ธีมที่วนกลับมาตลอดทั้งเล่มคือ ปัญหาหนึ่งมักมีหลายวิธีแก้ และแต่ละวิธีก็มีข้อดี ข้อเสีย และ trade-off ของตัวเอง ตัวอย่างเช่น เรื่อง storage engine ใน บทที่ 4 เราดู log-structured storage, B-tree และ column-oriented storage ส่วนเรื่องการ replicate ข้อมูลใน บทที่ 6 เราดูแนวทาง single-leader, multi-leader และ leaderless

ถ้าคุณมีปัญหาเช่น "ฉันต้องการเก็บข้อมูลบางอย่างแล้วค้นหามันอีกครั้งในภายหลัง" ไม่มีคำตอบที่ถูกต้องเพียงหนึ่งเดียว แต่มีหลายแนวทางที่แต่ละแบบเหมาะกับสถานการณ์ต่างกัน ซอฟต์แวร์ทั่วไปมักต้องเลือกแนวทางใดแนวทางหนึ่ง เพราะการทำให้โค้ดแค่เส้นทางเดียวทนทานและทำงานได้ดีก็ยากพออยู่แล้ว และถ้าพยายามรองรับกรณีใช้งานมากเกินไปด้วยฟีเจอร์เยอะๆ ผลที่ได้มักแย่กว่าเครื่องมือที่เชี่ยวชาญเฉพาะด้าน

ดังนั้นเครื่องมือที่เหมาะที่สุดจึงขึ้นอยู่กับสถานการณ์เช่นกัน ซอฟต์แวร์ทุกตัว แม้แต่ database ที่เรียกว่า "อเนกประสงค์" ก็ถูกออกแบบมาสำหรับรูปแบบการใช้งานเฉพาะแบบใดแบบหนึ่ง

เมื่อมีตัวเลือกมากขนาดนี้ ความท้าทายแรกคือการจับคู่ผลิตภัณฑ์กับสถานการณ์ที่เหมาะกัน ผู้ขายมักไม่อยากบอกว่า workload แบบไหนที่ซอฟต์แวร์ของตัวเองไม่เหมาะ แต่หวังว่าบทก่อนๆ จะให้คำถามที่ช่วยอ่านระหว่างบรรทัดและเข้าใจ trade-off ได้ดีขึ้น

อย่างไรก็ตาม ต่อให้คุณเข้าใจว่าเครื่องมือแต่ละตัวเหมาะกับสถานการณ์แบบไหนอย่างถ่องแท้ ก็ยังมีความท้าทายอีกข้อ ในแอปพลิเคชันที่ซับซ้อน ข้อมูลมักถูกใช้ในหลายรูปแบบ และซอฟต์แวร์ตัวเดียวไม่น่าจะเหมาะกับ ทุก รูปแบบ ดังนั้นคุณจึงเลี่ยงไม่ได้ที่จะต้องนำซอฟต์แวร์หลายตัวมาประกอบกันเพื่อให้แอปพลิเคชันทำงานได้ครบ

Combining Specialized Tools by Deriving Data (การรวมเครื่องมือเฉพาะทางด้วยการ derive ข้อมูล)

ตัวอย่างหนึ่งคือ เรามักต้องใช้ OLTP database คู่กับ full-text search index เพื่อรองรับ query ด้วยคำค้นแบบกำหนดเอง แม้ database บางตัว (เช่น PostgreSQL) จะมีฟีเจอร์ full-text indexing ซึ่งอาจพอสำหรับแอปพลิเคชันง่ายๆ [ 1 ] แต่ความสามารถในการค้นหาที่ซับซ้อนกว่านั้นต้องใช้เครื่องมือ information-retrieval เฉพาะทาง ในทางกลับกัน search index มักไม่เหมาะเป็น system of record (ระบบบันทึกข้อมูลหลัก) ที่ทนทาน แอปพลิเคชันจำนวนมากจึงต้องใช้ทั้งสองเครื่องมือร่วมกันเพื่อตอบโจทย์ให้ครบ

เราพูดถึงประเด็นการผสานระบบข้อมูลไว้ใน "การทำให้ระบบอยู่ใน Sync กัน" . ยิ่งข้อมูลมีสำเนาในรูปแบบต่างๆ มากขึ้น การผสานก็ยิ่งยาก นอกจาก database และ search index แล้ว คุณอาจต้องเก็บสำเนาข้อมูลไว้ในระบบวิเคราะห์ (data warehouse หรือระบบ batch และ stream processing) ดูแล cache หรือ denormalized version ของ object ที่ derive มาจากข้อมูลต้นฉบับ ส่งข้อมูลผ่านระบบ machine learning, classification, ranking หรือ recommendation หรือส่งการแจ้งเตือนตามการเปลี่ยนแปลงของข้อมูล

Reasoning about dataflows (การวิเคราะห์ dataflow)

เมื่อต้องเก็บข้อมูลชุดเดียวกันไว้ในระบบจัดเก็บหลายระบบเพื่อรองรับรูปแบบการเข้าถึงที่ต่างกัน คุณต้องรู้ให้ชัดว่า input และ output คืออะไร ข้อมูลถูกเขียนที่ไหนก่อน ข้อมูลรูปแบบไหน derive มาจากแหล่งใด และจะส่งข้อมูลไปให้ถึงทุกที่ที่ต้องใช้ในรูปแบบที่ถูกต้องได้อย่างไร

ตัวอย่างเช่น คุณอาจให้ข้อมูลถูกเขียนลง system-of-record database ก่อน จากนั้นการเปลี่ยนแปลงใน database นั้นจะถูก capture (ดู "Change Data Capture" ) แล้วนำไปใช้กับ search index ตามลำดับเดียวกัน ถ้า CDC เป็นวิธีเดียวที่ใช้อัปเดต index คุณก็มั่นใจได้ว่า index derive มาจาก system of record ทั้งหมด จึงสอดคล้องกับมันเสมอ (ยกเว้นกรณีมี bug ในซอฟต์แวร์) และการเขียนลง database ก็เป็นทางเดียวที่ input ใหม่จะเข้ามาในระบบนี้

ถ้าให้แอปพลิเคชันเขียนตรงไปยังทั้ง search index และ database จะเกิดปัญหาตามที่แสดงใน รูปที่ 12-4 ที่ client สองตัวส่ง write ที่ขัดแย้งกันมาพร้อมกัน และระบบจัดเก็บทั้งสองประมวลผลตามลำดับที่ต่างกัน กรณีนี้ทั้ง database และ search index ต่างไม่ได้ "รับผิดชอบ" การกำหนดลำดับของ write จึงอาจตัดสินใจขัดแย้งกันและไม่สอดคล้องกันถาวร

ถ้าส่ง input ของผู้ใช้ทั้งหมดผ่านระบบเดียวที่เป็นผู้กำหนดลำดับของ write ทุกตัว ก็จะ derive ข้อมูลรูปแบบอื่นได้ง่ายขึ้นมาก เพียงประมวลผล write ตามลำดับเดียวกัน นี่คือการประยุกต์ใช้แนวทาง state machine replication ที่เราเห็นใน "Consensus ในทางปฏิบัติ" . การเลือกใช้ CDC หรือ event sourcing log สำคัญน้อยกว่าหลักการที่ให้ตัวกลางตัวเดียวกำหนด total order

การอัปเดต derived data system (ระบบข้อมูลที่ derive มา) ด้วย event log มักทำให้เป็น deterministic และ idempotent ได้ (ดู "Idempotence" ) ซึ่งช่วยให้กู้คืนจากความผิดพลาดได้ง่าย

Derived data versus distributed transactions (Derived data เทียบกับ distributed transactions)

แนวทางคลาสสิกในการทำให้ระบบข้อมูลสอดคล้องกันคือ distributed transaction ตามที่เราพูดถึงใน "Two-Phase Commit" . แล้วการใช้ derived data system เทียบกับ distributed transaction เป็นอย่างไร

ในระดับแนวคิด ทั้งสองแบบมีเป้าหมายคล้ายกันแต่ใช้วิธีต่างกัน Distributed transaction ใช้ atomic commit protocol เพื่อให้การเปลี่ยนแปลงถูกนำไปใช้แบบ atomic ส่วนระบบที่ใช้ log ได้ความถูกต้องจาก deterministic retry และ idempotence

ความแตกต่างที่ใหญ่ที่สุดคือ ระบบ transaction มักรับประกันว่าเขียนค่าเสร็จแล้วจะอ่านค่าปัจจุบันได้ทันที (ดู "การอ่าน writes ของตัวเอง" ). ในทางกลับกัน derived data system มักอัปเดตแบบ async จึงไม่รับประกันโดยค่าเริ่มต้นว่าการอ่านจะได้ข้อมูลล่าสุด

Distributed transaction ใช้งานได้ผลดีในสภาพแวดล้อมที่ยอมรับต้นทุนด้านประสิทธิภาพและการดูแลระบบได้ แต่ XA มี fault tolerance และประสิทธิภาพที่ไม่ดี (ดู "Distributed Transactions ข้ามระบบที่แตกต่างกัน" ) ซึ่งลดประโยชน์ของมันลงอย่างมาก อาจเป็นไปได้ที่จะออกแบบ protocol ที่ดีกว่าสำหรับ distributed transaction แต่การทำให้ protocol นั้นถูกยอมรับอย่างกว้างขวางและเข้ากับเครื่องมือที่มีอยู่ได้ จะเป็นเรื่องท้าทาย และคงไม่เกิดขึ้นในเร็วๆ นี้

เมื่อยังไม่มี distributed transaction protocol ที่ดีและแพร่หลาย การ derive ข้อมูลจาก log จึงเป็นแนวทางที่น่าจะไปต่อได้ที่สุดในการผสานระบบข้อมูลต่างๆ อย่างไรก็ตาม การรับประกันอย่าง read-your-own-writes ก็ยังมีประโยชน์ และการบอกทุกคนว่า "eventual consistency เป็นสิ่งที่หลีกเลี่ยงไม่ได้ ทำใจแล้วเรียนรู้ที่จะอยู่กับมัน" ก็ไม่เกิดประโยชน์ (อย่างน้อยก็เมื่อไม่มีคำแนะนำดีๆ เกี่ยวกับ วิธีการ จัดการกับมัน)

ต่อไปในบทนี้ เราจะดูแนวทางในการ implement การรับประกันที่เข้มขึ้นบนระบบ derive แบบ async เพื่อหาจุดกึ่งกลางระหว่าง distributed transaction กับระบบที่ใช้ log แบบ async

The limits of total ordering (ข้อจำกัดของการจัดลำดับแบบ total order)

สำหรับระบบที่มีขนาดเล็กพอ การสร้าง totally ordered event log เป็นไปได้อย่างสมบูรณ์ (ดังที่ แสดงให้เห็นโดยความนิยมของฐานข้อมูลที่มี single-leader replication ซึ่งสร้าง log ดังกล่าวอย่างแม่นยำ) อย่างไรก็ตาม เมื่อระบบถูกขยายขนาดไปสู่ workloads ที่ใหญ่ขึ้นและซับซ้อนมากขึ้น ข้อจำกัด ก็เริ่มปรากฏให้เห็น:

  • ในกรณีส่วนใหญ่ การสร้าง totally ordered log ต้องการให้ events ทั้งหมดผ่าน single leader node ที่ตัดสินใจเกี่ยวกับลำดับ ถ้า throughput ของ events มากกว่าที่เครื่องเดียว จะจัดการได้ คุณต้องแยกส่วน (shard) log ออกเป็นหลายเครื่อง ลำดับของ events ในสอง shards จะคลุมเครือ

  • ถ้าเซิร์ฟเวอร์กระจายอยู่หลาย ภูมิภาคทางภูมิศาสตร์ —ตัวอย่างเช่น เพื่อทนต่อ datacenter ทั้งหมดที่ออฟไลน์—โดยทั่วไปคุณจะมี leader แยกต่างหากในแต่ละ datacenter เพราะ network delays ทำให้การประสานงานแบบ synchronous ข้าม datacenter ไม่มีประสิทธิภาพ ซึ่งหมายถึงลำดับของ events ที่ไม่ได้กำหนดไว้สำหรับ events ที่มาจากสอง datacenter ที่แตกต่างกัน

  • เมื่อแอปพลิเคชันถูก deploy เป็น microservices ทางเลือกการออกแบบทั่วไปคือการ deploy แต่ละ service และ state ที่ทนทานของมันเป็นหน่วยอิสระ โดยไม่มี state ที่ทนทานที่ใช้ร่วมกันระหว่าง services เมื่อสอง events มาจาก services ที่แตกต่างกัน events เหล่านั้นไม่มีลำดับที่กำหนด

  • แอปพลิเคชันบางตัวรักษา client-side state ที่ถูกอัปเดตทันทีเมื่อมี user input (โดยไม่ต้อง รอการยืนยันจากเซิร์ฟเวอร์) และแม้แต่ยังคงทำงานแบบออฟไลน์ ด้วย แอปพลิเคชันดังกล่าว ไคลเอนต์และเซิร์ฟเวอร์มีแนวโน้มสูงที่จะเห็น events ในลำดับที่ต่างกัน

ในทางทฤษฎี การตัดสินใจ total order ของ events เรียกว่า total order broadcast ซึ่งดังที่เราเห็นใน "The Many Faces of Consensus" นั้นเทียบเท่ากับ consensus อัลกอริธึม consensus ส่วนใหญ่ถูกออกแบบมาสำหรับสถานการณ์ที่ throughput ของโหนดเดียวเพียงพอที่จะประมวลผลสตรีม events ทั้งหมด และอัลกอริธึมเหล่านี้ ไม่ได้มีกลไกให้หลายโหนดแบ่งปันงานในการจัดลำดับ events

Ordering events to capture causality (การจัดลำดับ events เพื่อจับความสัมพันธ์เชิงสาเหตุ)

ถ้าไม่มีความเชื่อมโยงเชิงสาเหตุระหว่าง events การไม่มี total order ก็ไม่ใช่ปัญหาใหญ่ เนื่องจาก events ที่เกิดขึ้นพร้อมกันสามารถถูกจัดลำดับตามอำเภอใจได้ บางกรณีอื่นๆ จัดการได้ง่าย— ตัวอย่างเช่น การอัปเดตหลายครั้งของ object เดียวกันสามารถถูกจัดลำดับแบบ total order โดยการ routing การอัปเดตทั้งหมดสำหรับ object ID เฉพาะไปยัง shard ของ log เดียวกัน อย่างไรก็ตาม การพึ่งพาเชิงสาเหตุบางครั้งเกิดขึ้นในวิธีที่ละเอียดอ่อนกว่า

ตัวอย่างเช่น พิจารณาบริการเครือข่ายสังคมและผู้ใช้สองคนที่เคยมีความสัมพันธ์กันแต่เพิ่ง เลิกกัน ผู้ใช้คนหนึ่งลบอีกคนออกจากเพื่อน จากนั้นส่งข้อความถึง เพื่อนที่เหลือเพื่อบ่นเกี่ยวกับแฟนเก่า ความตั้งใจของผู้ใช้คือให้แฟนเก่า ไม่เห็นข้อความหยาบคายนั้น เนื่องจากข้อความถูกส่งหลังจากที่สถานะเพื่อนของบุคคลนั้นถูกเพิกถอน

อย่างไรก็ตาม ในระบบที่เก็บสถานะความเป็นเพื่อนในที่หนึ่งและข้อความในอีกที่หนึ่ง การพึ่งพาลำดับ ระหว่าง event เลิกเป็นเพื่อน และ event ส่งข้อความ อาจสูญหายไป ถ้าการพึ่งพาเชิงสาเหตุไม่ถูกจับไว้ service ที่ส่งการแจ้งเตือนเกี่ยวกับ ข้อความใหม่อาจประมวลผล event ส่งข้อความ ก่อน event เลิกเป็นเพื่อน และส่งการแจ้งเตือนไปยังแฟนเก่าอย่างไม่ถูกต้อง

ในตัวอย่างนี้ การแจ้งเตือนเป็นเหมือนการ join ระหว่างข้อความและรายการเพื่อน ทำให้เกี่ยวข้องกับปัญหาเรื่องเวลาของ joins ที่เราได้พูดถึงก่อนหน้านี้ (ดู "Time dependence of joins" ). น่าเสียดายที่ปัญหานี้ดูเหมือนจะไม่มีคำตอบง่ายๆ [ 2 , 3 ]. จุดเริ่มต้นรวมถึงสิ่งต่อไปนี้:

  • Logical timestamps สามารถให้ total ordering ได้โดยไม่ต้องมีการประสานงาน (ดู "ID Generators และ Logical Clocks" ) ดังนั้นพวกมันอาจช่วยได้เมื่อ total order broadcast เป็นไปไม่ได้ อย่างไรก็ตาม พวกมันยังคงต้องการให้ ผู้รับจัดการ events ที่ถูกส่งไม่เรียงลำดับ และพวกมันต้องการ metadata เพิ่มเติม ที่ต้องถูกส่งต่อ

  • ถ้าคุณสามารถ log event เพื่อบันทึกสถานะของระบบที่ผู้ใช้เห็นก่อนตัดสินใจ และกำหนด identifier ที่ไม่ซ้ำให้กับ event นั้น event ใดๆ ในภายหลังสามารถอ้างอิงถึง identifier ของ event นั้นเพื่อบันทึกการพึ่งพาเชิงสาเหตุ [ 4 ]

  • อัลกอริธึมการแก้ไขความขัดแย้ง (ดู "การแก้ไขความขัดแย้งอัตโนมัติ" ) ช่วยในการประมวลผล events ที่ถูกส่งในลำดับที่ไม่คาดคิด พวกมันมีประโยชน์สำหรับ การรักษาสถานะ แต่พวกมันไม่ช่วยถ้าการกระทำมีผลข้างเคียงภายนอก (เช่นการส่ง การแจ้งเตือนไปยังผู้ใช้)

รูปแบบสำหรับการพัฒนาแอปพลิเคชันอาจเกิดขึ้นในอนาคตที่ช่วยให้การพึ่งพาเชิงสาเหตุ ถูกจับได้อย่างมีประสิทธิภาพ และ state ที่ derive ไว้ถูกรักษาไว้อย่างถูกต้อง โดยไม่ต้องบังคับให้ events ทั้งหมด ผ่านคอขวดของ total order broadcast

Batch and Stream Processing (Batch และ Stream Processing)

เป้าหมายของการรวมข้อมูลคือการทำให้แน่ใจว่าข้อมูลอยู่ในรูปแบบที่ถูกต้องในที่ที่ถูกต้องทั้งหมด การทำเช่นนี้ต้องอาศัยการ consume inputs, การแปลง, การ join, การกรอง, การรวม, การฝึกโมเดล, การประเมินผล, และในที่สุดการเขียนไปยัง outputs ที่เหมาะสม Batch และ stream processors เป็นเครื่องมือ สำหรับบรรลุเป้าหมายนี้ outputs ของ batch และ stream processes คือ datasets ที่ derive ไว้ เช่น search indexes, materialized views, คำแนะนำที่จะแสดงให้ผู้ใช้, ตัววัดรวม, และอื่นๆ

ดังที่เราเห็นในบท 11 และ 12 , batch และ stream processing มีหลักการร่วมกันหลายอย่าง ความแตกต่างพื้นฐานหลักคือ stream processors ดำเนินการกับ datasets ที่ไม่มีขอบเขต (unbounded) ในขณะที่ batch process inputs มีขนาดที่ทราบและจำกัด

Maintaining derived state (การรักษา state ที่ derive ไว้)

Batch processing มีลักษณะ functional ที่ค่อนข้างชัดเจน (แม้ว่าโค้ดจะไม่ได้เขียนด้วยภาษา functional programming) มันส่งเสริม deterministic, pure functions ที่ output ขึ้นอยู่กับ input เท่านั้นและไม่มี side effects นอกเหนือจาก outputs ที่ชัดเจน โดยปฏิบัติต่อ inputs เป็น immutable และ outputs เป็น append-only Stream processing ก็คล้ายกัน แต่มันขยาย operators เพื่อให้มี state ที่จัดการได้และทนต่อความผิดพลาด

หลักการของ deterministic functions ที่มี inputs และ outputs ที่กำหนดไว้อย่างดีนั้นไม่เพียงดี สำหรับการทนต่อความผิดพลาด แต่ยังทำให้การใช้เหตุผลเกี่ยวกับ dataflows ในองค์กรง่ายขึ้น [ 5 ]. ไม่ว่าข้อมูลที่ derive ไว้จะเป็น search index, statistical model, หรือ cache มันก็ มีประโยชน์ในการคิดในแง่ของ data pipelines ที่ derive สิ่งหนึ่งจากอีกสิ่งหนึ่ง ผลักดันการเปลี่ยนแปลงสถานะ ในระบบหนึ่งผ่าน functional application code และนำผลลัพธ์ไปใช้กับระบบที่ derive ไว้

ในหลักการแล้ว ระบบข้อมูลที่ derive ไว้สามารถรักษาได้แบบ synchronous เช่นเดียวกับที่ relational database อัปเดต secondary indexes แบบ synchronous ภายใน transaction เดียวกับ writes ไปยังตารางที่ถูก index อย่างไรก็ตาม asynchrony คือสิ่งที่ทำให้ระบบที่ใช้ event logs มีความทนทาน มันช่วยให้ความผิดพลาด ในส่วนหนึ่งของระบบถูกจำกัดอยู่เฉพาะที่ ในขณะที่ distributed transactions จะ abort ถ้ามี participant ใดล้มเหลว ดังนั้นพวกมันมีแนวโน้มที่จะขยายความผิดพลาดโดยแพร่กระจายไปยังส่วนอื่นของระบบ

เราเห็นใน "Sharding และ Secondary Indexes" ว่า secondary indexes มักข้ามขอบเขตของ shards ระบบที่ถูก shard พร้อม secondary indexes ต้อง ส่ง writes ไปยังหลาย shards (ถ้า index ถูก partitioned แบบ term) หรือส่ง reads ไปยังทุก shards (ถ้า index ถูก partitioned แบบ document) การสื่อสารข้าม shard ดังกล่าวก็มีความน่าเชื่อถือและ สามารถขยายขนาดได้มากที่สุดถ้า index ถูกรักษาแบบ asynchronous [ 6 ]

Reprocessing data for application evolution (การประมวลผลข้อมูลซ้ำเพื่อวิวัฒนาการของแอปพลิเคชัน)

เมื่อรักษาข้อมูลที่ derive ไว้ batch และ stream processing ต่างก็มีประโยชน์ Stream processing ช่วยให้ การเปลี่ยนแปลงใน input สะท้อนใน derived views ด้วยความล่าช้าต่ำ ในขณะที่ batch processing ช่วยให้ ข้อมูลประวัติที่สะสมเป็นจำนวนมากถูกประมวลผลซ้ำเพื่อ derive views ใหม่บน dataset ที่มีอยู่

โดยเฉพาะอย่างยิ่ง การประมวลผลข้อมูลที่มีอยู่ซ้ำเป็นกลไกที่ดีสำหรับการบำรุงรักษาระบบ การพัฒนาให้รองรับฟีเจอร์ใหม่และความต้องการที่เปลี่ยนไป หากไม่มีการประมวลผลซ้ำ วิวัฒนาการของ schema จะถูกจำกัดอยู่ที่การเปลี่ยนแปลงง่ายๆ เช่นการเพิ่มฟิลด์ optional ใหม่ให้กับ record หรือเพิ่ม record ชนิดใหม่ ในทางกลับกัน ด้วยการประมวลผลซ้ำ มันเป็นไปได้ที่จะปรับโครงสร้าง dataset ให้เป็นโมเดลที่แตกต่างอย่างสิ้นเชิงเพื่อรองรับความต้องการใหม่ได้ดีขึ้น

Schema Migrations on Railways (การโยกย้าย Schema บนรางรถไฟ)

"การโยกย้าย schema" ขนาดใหญ่เกิดขึ้นในระบบที่ไม่ใช่คอมพิวเตอร์เช่นกัน ตัวอย่างเช่น ในช่วงต้น ของการสร้างทางรถไฟในอังกฤษศตวรรษที่ 19 มีมาตรฐานที่แข่งขันกันหลายมาตรฐานสำหรับ gauge (ระยะห่างระหว่างรางสองเส้น) รถไฟที่สร้างสำหรับ gauge หนึ่งไม่สามารถวิ่งบนรางของ อีก gauge หนึ่ง ซึ่งจำกัดการเชื่อมต่อที่อาจเป็นไปได้ในเครือข่ายรถไฟ [ 7 ]

หลังจากที่มาตรฐาน gauge เดียวถูกตัดสินใจในปี 1846 รางที่มี gauge อื่นต้องถูก เปลี่ยน—แต่จะทำอย่างไรโดยไม่ต้องปิดเส้นทางรถไฟเป็นเวลาหลายเดือนหรือหลายปี? วิธีแก้ไขคือการแปลงรางเป็น dual gauge หรือ mixed gauge โดยการเพิ่มรางที่สาม การแปลงนี้สามารถทำได้ทีละน้อย และเมื่อเสร็จสิ้น รถไฟของทั้งสอง gauges สามารถวิ่งบนเส้นทาง โดยใช้สองในสามราง ในที่สุด เมื่อรถไฟทั้งหมดถูก แปลงเป็น standard gauge แล้ว รางที่ให้ nonstandard gauge ก็สามารถถูกเอาออกได้

"การประมวลผลซ้ำ" รางที่มีอยู่ด้วยวิธีนี้ และอนุญาตให้เวอร์ชันเก่าและใหม่ดำรงอยู่ เคียงข้างกัน ทำให้สามารถเปลี่ยน gauge ได้ทีละน้อยตลอดระยะเวลาหลายปี อย่างไรก็ตาม การดำเนินการนี้มีราคาแพง ซึ่งเป็นสาเหตุที่ nonstandard gauges ยังคงมีอยู่ในปัจจุบัน ตัวอย่างเช่น ระบบ BART ในเขตอ่าวซานฟรานซิสโกใช้ gauge ที่แตกต่างจาก ส่วนใหญ่ของสหรัฐอเมริกา

Derived views อนุญาตให้มีการวิวัฒนาการแบบ ค่อยเป็นค่อยไป . ถ้าคุณต้องการปรับโครงสร้าง dataset คุณไม่จำเป็นต้องทำการโยกย้ายเป็นการสลับอย่างกระทันหัน แต่คุณสามารถรักษา schema เก่าและ schema ใหม่ไว้เคียงข้างกันเป็นสอง views ที่ derive อย่างอิสระจากข้อมูลพื้นฐานเดียวกัน จากนั้นคุณสามารถเริ่มย้ายผู้ใช้จำนวนเล็กน้อยไปยัง view ใหม่เพื่อทดสอบประสิทธิภาพและค้นหา bugs ในขณะที่ผู้ใช้ส่วนใหญ่ยังคงถูก route ไปยัง view เก่า คุณสามารถค่อยๆ เพิ่มสัดส่วนของผู้ใช้ที่เข้าถึง view ใหม่ และในที่สุดคุณก็สามารถละทิ้ง view เก่าได้ [ 8 , 9 ]

ความสวยงามของการโยกย้ายแบบค่อยเป็นค่อยไปคือทุกขั้นตอนของกระบวนการสามารถย้อนกลับได้ง่าย ถ้ามีอะไรผิดพลาด คุณจะมีระบบที่ทำงานได้ให้กลับไปใช้เสมอ การลดความเสี่ยงของ ความเสียหายที่แก้ไขไม่ได้ช่วยให้คุณมั่นใจมากขึ้นในการดำเนินการต่อไป และทำให้คุณเคลื่อนที่เร็วขึ้น เพื่อปรับปรุงระบบของคุณ [ 10 ]

Unifying batch and stream processing (การรวม batch และ stream processing)

ข้อเสนอแรกเริ่มสำหรับการรวม batch และ stream processing คือ lambda architecture [ 11 ] ซึ่งมีปัญหาหลายประการ [ 12 ] และเลิกใช้ไปแล้ว ระบบที่ใหม่กว่าช่วยให้ batch computations (การประมวลผลข้อมูลประวัติซ้ำ) และ stream computations (การประมวลผล events เมื่อมาถึง) ถูก implement ในระบบเดียวกัน [ 13 ]—แนวทางที่บางครั้งรู้จักกันในชื่อ kappa architecture [ 12 ]

การรวม batch และ stream processing ในระบบเดียวต้องการคุณสมบัติต่อไปนี้:

  • ความสามารถในการเล่นซ้ำ events ประวัติผ่าน processing engine เดียวกับที่จัดการสตรีม ของ events ล่าสุด ตัวอย่างเช่น log-based message brokers มีความสามารถในการเล่นซ้ำ messages และ stream processors บางตัวสามารถอ่าน input จาก distributed filesystem หรือ object storage

  • Exactly-once semantics สำหรับ stream processors—นั่นคือ การรับประกันว่า output จะเหมือนกับ ถ้าไม่มีความผิดพลาดเกิดขึ้น แม้ว่าความผิดพลาดจะเกิดขึ้นจริงก็ตาม เช่นเดียวกับ batch processing สิ่งนี้ต้องการ การละทิ้ง partial outputs ของ failed tasks ใดๆ

  • เครื่องมือสำหรับการ windowing โดย event time ไม่ใช่ processing time เนื่องจาก processing time ไม่มีความหมาย เมื่อประมวลผล events ประวัติซ้ำ ตัวอย่างเช่น Apache Beam มี API สำหรับการแสดง การคำนวณดังกล่าว ซึ่งสามารถรันได้โดยใช้ Apache Flink หรือ Google Cloud Dataflow

Unbundling Databases (การแยกส่วนประกอบฐานข้อมูล)

ในระดับนามธรรม ฐานข้อมูล, batch/stream processors, และระบบปฏิบัติการล้วนทำหน้าที่ เดียวกัน: พวกมันเก็บข้อมูลบางอย่าง และพวกมันอนุญาตให้คุณประมวลผลและสอบถามข้อมูลนั้น [ 14 , 15 ] ฐานข้อมูลเก็บข้อมูลใน records ของ data model (แถวในตาราง, documents, vertices ใน graph, ฯลฯ) ในขณะที่ filesystem ของระบบปฏิบัติการเก็บข้อมูลในไฟล์—แต่ในแกนกลางแล้ว ทั้งคู่เป็นระบบ "การจัดการข้อมูล" [ 16 ] ดังที่เราเห็นใน บทที่ 11 , batch processors ก็เหมือน distributed version ของ Unix

ในความเป็นจริง มีความแตกต่างในทางปฏิบัติมากมาย ตัวอย่างเช่น filesystems จำนวนมากไม่จัดการกับ ไดเรกทอรีที่มีไฟล์ขนาดเล็ก 10 ล้านไฟล์ได้ดีนัก ในขณะที่ฐานข้อมูลที่มี records ขนาดเล็ก 10 ล้าน record เป็นเรื่องปกติธรรมดา อย่างไรก็ตาม ความคล้ายคลึงและความแตกต่างระหว่างระบบปฏิบัติการ และฐานข้อมูลก็คุ้มค่าที่จะสำรวจ

Unix และ relational databases ได้เข้าถึงปัญหาการจัดการข้อมูลด้วยปรัชญาที่แตกต่างกันมาก Unix มองว่าจุดประสงค์ของมันคือการนำเสนอ abstraction ฮาร์ดแวร์ที่มีเหตุผลแต่ค่อนข้างระดับต่ำให้กับโปรแกรมเมอร์ ในขณะที่ relational databases ต้องการให้ abstraction ระดับสูงแก่โปรแกรมเมอร์แอปพลิเคชันที่จะ ซ่อนความซับซ้อนของโครงสร้างข้อมูลบนดิสก์, concurrency, การกู้คืนจาก crash, และอื่นๆ Unix พัฒนา pipes และ files ที่เป็นแค่ลำดับของไบต์ ในขณะที่ฐานข้อมูลพัฒนา SQL และ transactions

แนวทางไหนดีกว่ากัน? มันขึ้นอยู่กับสิ่งที่คุณต้องการ Unix "ง่ายกว่า" ในแง่ที่ว่ามันเป็น wrapper ที่ค่อนข้างบาง รอบทรัพยากรฮาร์ดแวร์; relational databases "ง่ายกว่า" ในแง่ที่ว่า short declarative query สามารถใช้โครงสร้างพื้นฐานที่ทรงพลังมากมาย (query optimization, indexes, join methods, concurrency control, replication, ฯลฯ) โดยที่ผู้เขียน query ไม่จำเป็นต้องเข้าใจรายละเอียดการ implement

ความตึงเครียดระหว่างปรัชญาเหล่านี้ดำรงอยู่มานานหลายทศวรรษ (ทั้ง Unix และ relational model เกิดขึ้นใน ช่วงต้นทศวรรษ 1970) และยังไม่ได้รับการแก้ไข ตัวอย่างเช่น การเคลื่อนไหว NoSQL อาจถูกตีความได้ว่าต้องการ ใช้แนวทางแบบ Unix ของ low-level abstractions ในโดเมนของการจัดเก็บข้อมูล OLTP แบบกระจาย

ส่วนนี้พยายามที่จะประนีประนอมทั้งสองแนวทาง ด้วยความหวังว่าเราจะสามารถรวมสิ่งที่ดีที่สุดของทั้งสอง โลกเข้าด้วยกัน

Composing Data Storage Technologies (การประกอบเทคโนโลยีการจัดเก็บข้อมูล)

ตลอดทั้งเล่มนี้ เราพูดถึงฟีเจอร์ต่างๆ ของ database และวิธีที่มันทำงาน เช่น

  • Secondary index ที่ช่วยให้ค้นหา record ตามค่าของ field ได้อย่างมีประสิทธิภาพ

  • Materialized view ซึ่งเป็น cache ของผลลัพธ์ query ที่คำนวณไว้ล่วงหน้า

  • Replication log ที่ทำให้สำเนาข้อมูลบน node อื่นเป็นปัจจุบันอยู่เสมอ

  • Full-text search index ที่ช่วยค้นหาคำในข้อความ และมีอยู่ใน relational database บางตัว [ 1 ]

ในบท 11 และ 12 เราก็เจอธีมคล้ายกัน คือการสร้าง full-text search index การดูแล materialized view และการ replicate การเปลี่ยนแปลงจาก database ไปยังระบบข้อมูลที่ derive ด้วย CDC

ฟีเจอร์ที่ database มีมาในตัวกับระบบข้อมูลที่ derive ซึ่งคนสร้างขึ้นด้วย batch และ stream processor ดูคล้ายกันมาก

Creating an index (การสร้าง index)

ลองดูว่าเกิดอะไรขึ้นเมื่อคุณรัน CREATE INDEX เพื่อสร้าง index ใหม่ใน relational database ตัว database ต้องสแกน consistent snapshot ของตาราง เลือกค่า field ทั้งหมดที่จะ index เรียงลำดับ แล้วเขียน index ออกมา จากนั้นต้องประมวลผล backlog ของ write ที่เกิดขึ้นตั้งแต่ถ่าย snapshot (โดยสมมติว่าตารางไม่ถูกล็อกขณะสร้าง index จึงยังเขียนต่อได้) เมื่อเสร็จแล้ว database ยังต้องอัปเดต index ให้เป็นปัจจุบันทุกครั้งที่ transaction เขียนลงตาราง

กระบวนการนี้คล้ายกับการตั้งค่า follower replica ใหม่มาก (ดู "การตั้งค่า Followers ใหม่" ) และคล้ายกับการ bootstrap CDC ในระบบ streaming (ดู "Initial snapshot" )

ทุกครั้งที่คุณรัน CREATE INDEX database ก็ประมวลผล dataset ที่มีอยู่ซ้ำโดยพื้นฐาน แล้ว derive index ออกมาเป็น view ใหม่ของข้อมูลนั้น ข้อมูลที่มีอยู่อาจเป็น snapshot ของ state แทนที่จะเป็น log ของการเปลี่ยนแปลงทั้งหมดที่เคยเกิดขึ้น แต่ทั้งสองอย่างเกี่ยวข้องกันอย่างใกล้ชิด

The meta-database of everything (meta-database ของทุกสิ่ง)

ในแง่นี้ dataflow ทั่วทั้งองค์กรเริ่มดูเหมือน database ขนาดใหญ่ตัวเดียว [ 5 ]. ทุกครั้งที่ batch, stream, หรือ ETL process ย้ายข้อมูลจากที่หนึ่งในรูปแบบหนึ่งไปยังอีกที่ในอีกรูปแบบหนึ่ง มันก็ทำหน้าที่เหมือน subsystem ของ database ที่ดูแลให้ index หรือ materialized view เป็นปัจจุบัน

เมื่อมองแบบนี้ batch และ stream processor ก็เหมือนการ implement trigger, stored procedure และอัลกอริทึมดูแล materialized view ในระดับละเอียด ส่วนระบบข้อมูลที่ derive ซึ่งพวกมันดูแลอยู่ก็เหมือน index คนละชนิด ตัวอย่างเช่น relational database อาจรองรับ B-tree index, hash index, spatial index และ index ชนิดอื่นๆ ในสถาปัตยกรรมระบบข้อมูลที่ derive ซึ่งกำลังเกิดขึ้น ความสามารถเหล่านี้ไม่ได้เป็นฟีเจอร์ของผลิตภัณฑ์ database แบบรวมศูนย์ตัวเดียว แต่ถูกให้บริการโดยซอฟต์แวร์หลายตัวที่รันบนเครื่องต่างกันและดูแลโดยทีมต่างกัน

แนวโน้มเหล่านี้จะพาเราไปทางไหนในอนาคต? ถ้าเริ่มจากข้อสมมติว่าไม่มี data model หรือรูปแบบการจัดเก็บแบบเดียวที่เหมาะกับทุกรูปแบบการเข้าถึง ก็มีสองทางที่จะนำเครื่องมือจัดเก็บและประมวลผลที่ต่างกันมาประกอบเป็นระบบที่ทำงานสอดคล้องกันได้:

Federated databases (การรวมฝั่ง read)

เราสามารถมี query interface เดียวที่ครอบ storage engine และวิธีประมวลผลหลากหลายแบบ ซึ่งเรียกว่า federated database หรือ polystore [ 17 , 18 ] ตัวอย่างเช่น foreign data wrapper ของ PostgreSQL และ federated query engines เช่น Trino, Hoptimator, และ Xorq แอปพลิเคชันที่ต้องการ data model หรือ query interface เฉพาะยังเข้าถึง storage engine ข้างใต้ได้โดยตรง ส่วนผู้ใช้ที่ต้องการรวมข้อมูลจากหลายแหล่งก็ทำผ่าน federated interface ได้ง่ายๆ

Federated query interface สืบทอดแนวคิดแบบ relational ที่เป็นระบบรวมศูนย์ตัวเดียว มีภาษา query ระดับสูงและ semantics ที่สวยงาม แต่ implementation ซับซ้อน

Unbundled databases (การรวมฝั่ง write)

federation จัดการ query แบบอ่านอย่างเดียวข้ามหลายระบบได้ แต่ยังไม่มีคำตอบที่ดีสำหรับการทำให้ write sync กันข้ามระบบเหล่านั้น เราบอกไปแล้วว่าใน database ตัวเดียว การสร้าง index ที่สอดคล้องกันเป็นฟีเจอร์ที่มีมาในตัว แต่เมื่อเรานำระบบจัดเก็บหลายระบบมาประกอบกัน เราต้องมั่นใจว่าการเปลี่ยนแปลงข้อมูลทั้งหมดไปถึงทุกที่ที่ควรไป แม้เกิดความผิดพลาดขึ้น การทำให้เชื่อมระบบจัดเก็บเข้าด้วยกันได้อย่างน่าเชื่อถือ (เช่นผ่าน CDC และ event log) จึงเปรียบเหมือนการ unbundling ฟีเจอร์ดูแล index ของ database ให้ sync write ข้ามเทคโนโลยีต่างๆ ได้ [ 5 , 19 ]

แนวทาง unbundled เป็นไปตามแนวคิดแบบ Unix คือเครื่องมือเล็กๆ ที่ทำสิ่งเดียวให้ดี [ 20 ] สื่อสารกันผ่าน low-level API ที่เหมือนกันทุกตัว (pipe) และนำมาประกอบกันด้วยภาษาระดับสูง (shell) [ 14 ]

Making unbundling work (ทำให้ unbundling ใช้งานได้จริง)

Federation และ unbundling เป็นสองด้านของเหรียญเดียวกัน คือการประกอบระบบที่เชื่อถือได้ ขยายขนาดได้ และบำรุงรักษาได้จากส่วนประกอบหลากหลาย การ query แบบ read-only ผ่าน federation ต้องแมป data model หนึ่งไปอีกอันหนึ่ง ซึ่งต้องคิดหนัก แต่สุดท้ายก็จัดการได้ ส่วนการทำให้ write sync กันข้ามระบบจัดเก็บหลายระบบเป็นปัญหาวิศวกรรมที่ยากกว่า เราจึงจะเน้นเรื่องนี้ตรงนี้

แนวทางดั้งเดิมในการทำให้ write sync กันคือใช้ distributed transaction ข้ามระบบจัดเก็บที่ต่างกัน [ 17 ] ซึ่งมีปัญหาดังที่กล่าวไปก่อนหน้า transaction ภายในระบบจัดเก็บหรือ stream processor ตัวเดียวทำได้ แต่เมื่อข้อมูลข้ามขอบเขตระหว่างเทคโนโลยีต่างกัน asynchronous event log ที่มี idempotent write เป็นแนวทางที่ทนทานและใช้ได้จริงกว่า

ตัวอย่างเช่น stream processor บางตัวใช้ distributed transaction เพื่อให้ได้ exactly-once semantics และใช้งานได้ค่อนข้างดี แต่เมื่อ transaction ต้องครอบคลุมระบบที่คนหลายกลุ่มเป็นผู้สร้าง (เช่นเมื่อ stream processor เขียนข้อมูลไปยัง distributed key-value store หรือ search index) การไม่มี transaction protocol มาตรฐานทำให้การเชื่อมระบบยากขึ้นมาก ส่วน ordered log ของ event ที่มี idempotent consumer เป็น abstraction ที่ง่ายกว่ามากและ implement ได้จริงกว่าเมื่อต้องข้าม ระบบ ที่ต่างกัน [ 5 ]

ข้อได้เปรียบใหญ่ของการผสานระบบด้วย log คือ loose coupling ระหว่างส่วนประกอบต่างๆ ซึ่งเห็นได้สองระดับ:

  • ในระดับระบบ asynchronous event stream ทำให้ระบบโดยรวมทนต่อการล่มหรือประสิทธิภาพตกของแต่ละส่วนได้ดีขึ้น ถ้า consumer ทำงานช้าหรือล้มเหลว event log จะ buffer message ไว้ให้ producer และ consumer ตัวอื่นทำงานต่อโดยไม่ได้รับผลกระทบ consumer ที่ล่มจะตามทันเมื่อได้รับการแก้ไข จึงไม่พลาดข้อมูลใดๆ และความผิดพลาดก็ถูกจำกัดอยู่เฉพาะที่ ในทางกลับกัน การโต้ตอบแบบ synchronous ของ distributed transaction มักทำให้ความผิดพลาดเฉพาะจุดลุกลามเป็นความล้มเหลวขนาดใหญ่

  • ในระดับคน การ unbundling ระบบข้อมูลทำให้แต่ละทีมพัฒนา ปรับปรุง และดูแลส่วนประกอบซอฟต์แวร์และ service ของตัวเองได้อย่างอิสระ การเชี่ยวชาญเฉพาะด้านช่วยให้แต่ละทีมโฟกัสทำสิ่งเดียวให้ดี โดยมี interface ที่นิยามชัดเจนกับระบบของทีมอื่น Event log ให้ interface ที่ทรงพอจะรับประกันความสอดคล้องได้ค่อนข้างเข้มงวด (เพราะ event ทนทานและมีลำดับ) แต่ก็ทั่วไปพอที่จะใช้กับข้อมูลแทบทุกชนิด

Unbundled versus integrated systems (ระบบ unbundled เทียบกับระบบ integrated)

ถ้า unbundling กลายเป็นแนวทางของอนาคตจริง มันก็ไม่ได้มาแทนที่ database ในรูปแบบปัจจุบัน database ยังจำเป็นเหมือนเดิม ทั้งสำหรับเก็บ state ใน stream processor และสำหรับให้บริการ query บน output ของ batch และ stream processor ส่วน specialized query engine ก็ยังสำคัญสำหรับ workload เฉพาะทาง เช่น query engine ใน data warehouse ถูกปรับมาสำหรับ exploratory analytical query และรองรับ workload แบบนี้ได้ดีมาก

ความซับซ้อนของการรันโครงสร้างพื้นฐานหลายตัวอาจเป็นปัญหา ซอฟต์แวร์แต่ละตัวมี learning curve ปัญหาเรื่อง configuration และลักษณะการดูแลระบบเป็นของตัวเอง จึงควร deploy ส่วนประกอบ (moving parts) ให้น้อยที่สุดเท่าที่ทำได้ ผลิตภัณฑ์ซอฟต์แวร์แบบ integrated ตัวเดียวอาจให้ประสิทธิภาพที่ดีกว่าและคาดเดาได้มากกว่าสำหรับ workload ที่มันถูกออกแบบมารองรับ เมื่อเทียบกับระบบที่เกิดจากเครื่องมือหลายตัวที่คุณต้องมาประกอบเข้ากับ application code เอง [ 21 ] การสร้างระบบเพื่อ scale ที่คุณไม่ได้ต้องการเป็นความพยายามที่สูญเปล่า และอาจทำให้คุณติดอยู่กับการออกแบบที่ปรับเปลี่ยนไม่ได้ จริงๆ แล้วมันก็คือ premature optimization รูปแบบหนึ่ง

เป้าหมายของ unbundling ไม่ใช่การเอาชนะ database แต่ละตัวในเรื่องประสิทธิภาพสำหรับ workload เฉพาะ แต่เพื่อให้คุณนำ database หลายตัวมาใช้ร่วมกัน และได้ประสิทธิภาพดีกับ workload ที่หลากหลายกว่าที่ซอฟต์แวร์ตัวเดียวทำได้ เป็นเรื่องของความกว้าง ไม่ใช่ความลึก

ดังนั้น ถ้าเทคโนโลยีตัวเดียวทำได้ทุกอย่างที่คุณต้องการ ก็น่าจะดีที่สุดที่จะใช้ผลิตภัณฑ์นั้น แทนที่จะพยายามสร้างใหม่เองจากส่วนประกอบระดับล่าง ข้อดีของ unbundling และ composition จะเกิดขึ้นก็ต่อเมื่อไม่มีซอฟต์แวร์ตัวเดียวที่ตอบโจทย์ได้ครบ

เครื่องมือสำหรับประกอบระบบข้อมูลดีขึ้นเรื่อยๆ Debezium ดึง change stream จาก database ได้หลายตัว protocol ของ Kafka กำลังกลายเป็นมาตรฐานโดยพฤตินัยสำหรับ event stream และ incremental view maintenance engine (ดู "Incremental View Maintenance" ) ช่วยให้คำนวณและอัปเดต cache ของ query ที่ซับซ้อนไว้ล่วงหน้าได้

Designing Applications Around Dataflow (การออกแบบแอปพลิเคชันโดยมี dataflow เป็นแกนกลาง)

แนวคิดการอัปเดตข้อมูลที่ derive เมื่อข้อมูลต้นทางเปลี่ยนไม่ใช่เรื่องใหม่ ตัวอย่างเช่น สเปรดชีตมีความสามารถด้านการเขียนโปรแกรมแบบ dataflow ที่ทรงพลัง [ 22 ]: คุณใส่สูตรในเซลล์หนึ่งได้ (เช่น ผลรวมของเซลล์ในอีกคอลัมน์) และเมื่อ input ตัวใดของสูตรเปลี่ยน ผลลัพธ์ของสูตรจะถูกคำนวณใหม่อัตโนมัติ นี่คือสิ่งที่เราอยากได้ในระดับระบบข้อมูล เมื่อ record ใน database เปลี่ยน เราอยากให้ index ของ record นั้นอัปเดตเอง และ cached view หรือ aggregation ที่ขึ้นกับ record นั้นก็ refresh เองโดยอัตโนมัติ เราไม่ควรต้องกังวลกับรายละเอียดทางเทคนิคของการ refresh แต่ควรไว้ใจได้ว่ามันทำงานถูกต้อง

ระบบข้อมูลส่วนใหญ่จึงยังมีอะไรต้องเรียนรู้จากสิ่งที่ VisiCalc ทำได้แล้วตั้งแต่ปี 1979 [ 23 ] สิ่งที่ต่างจากสเปรดชีตคือ ระบบข้อมูลทุกวันนี้ต้องทนต่อความผิดพลาด ขยายขนาดได้ และเก็บข้อมูลได้อย่างทนทาน ทั้งยังต้องผสานเทคโนโลยีต่างๆ ที่คนหลายกลุ่มพัฒนาขึ้นในช่วงเวลาต่างกัน และใช้ library กับ service ที่มีอยู่แล้ว การคาดหวังให้ซอฟต์แวร์ทั้งหมดพัฒนาด้วยภาษา framework หรือเครื่องมือเดียวจึงไม่สมจริง

ในส่วนนี้เราจะต่อยอดแนวคิดเหล่านี้ และดูวิธีสร้างแอปพลิเคชันรอบ unbundled database และ dataflow

Application code as a derivation function (Application code ในฐานะ derivation function)

เมื่อ dataset หนึ่ง derive มาจากอีก dataset หนึ่ง ข้อมูลจะผ่านฟังก์ชันแปลงบางอย่าง ตัวอย่างเช่น

  • Secondary index เป็น dataset ที่ derive แบบหนึ่งซึ่งมีฟังก์ชันแปลงตรงไปตรงมา คือสำหรับแต่ละแถวหรือ document ในตารางต้นทาง จะเลือกค่าที่ต้องการ index แล้วเรียงตามค่าเหล่านั้น (สมมติว่าเป็น SSTable หรือ B-tree index ซึ่งเรียงตาม key)

  • Full-text search index สร้างขึ้นโดยใช้ฟังก์ชัน natural language processing หลายอย่าง เช่น การตรวจจับภาษา การตัดคำ stemming หรือ lemmatization การแก้คำสะกดผิด และการระบุคำพ้องความหมาย จากนั้นจึงสร้างโครงสร้างข้อมูลสำหรับค้นหาอย่างมีประสิทธิภาพ (เช่น inverted index)

  • ในระบบ ML เรามองได้ว่า model derive มาจาก training data ผ่านฟังก์ชัน feature extraction และ statistical analysis ต่างๆ เมื่อนำ model ไปใช้กับ input ใหม่ output ของมันก็ derive มาจาก input นั้นและ parameter ที่เรียนรู้มา (จึงเป็นการ derive จาก training data ทางอ้อม)

  • Cache มักเป็นการรวมข้อมูลในรูปแบบที่จะแสดงบน UI การ populate cache จึงต้องรู้ว่า UI อ้างอิง field ไหนบ้าง และการเปลี่ยน UI อาจทำให้ต้องแก้นิยามวิธี populate cache แล้วสร้าง cache ใหม่

ฟังก์ชัน derive สำหรับ secondary index เป็นที่ต้องการกันทั่วไปจน database จำนวนมากมีให้เป็นฟีเจอร์หลัก และคุณเรียกใช้ได้ด้วยการรัน CREATE INDEX . สำหรับ full-text indexing ความสามารถด้านภาษาพื้นฐานของภาษาทั่วไปอาจมีอยู่ใน database แล้ว แต่ความสามารถที่ซับซ้อนกว่ามักต้องปรับแต่งตามโดเมน ส่วนใน machine learning นั้น feature engineering ขึ้นชื่อว่าขึ้นกับแอปพลิเคชันมาก และมักต้องอาศัยความรู้เชิงลึกเกี่ยวกับการโต้ตอบของผู้ใช้และการ deploy แอปพลิเคชัน [ 24 ]

เมื่อฟังก์ชันที่สร้าง dataset ที่ derive ไม่ใช่ฟังก์ชันมาตรฐานแบบการสร้าง secondary index ก็ต้องใช้ custom code เพื่อจัดการส่วนที่เฉพาะกับแอปพลิเคชัน และ custom code นี้เองที่ database หลายตัวทำได้ไม่ดี แม้ relational database มักรองรับ trigger, stored procedure และ user-defined function ที่ใช้รัน application code ภายใน database ได้ แต่สิ่งเหล่านี้ค่อนข้างเป็นส่วนที่ถูกต่อเติมเข้ามาทีหลังในการออกแบบ database

Separation of application code and state (การแยก application code ออกจาก state)

ในทางทฤษฎี database น่าจะเป็นสภาพแวดล้อมสำหรับ deploy application code อะไรก็ได้ เหมือนระบบปฏิบัติการ แต่ในทางปฏิบัติกลับไม่เหมาะกับงานนี้ เพราะไม่ตอบโจทย์การพัฒนาแอปพลิเคชันสมัยใหม่ เช่น การจัดการ dependency และ package, version control, rolling upgrade, evolvability, monitoring, metrics, การเรียก network service และการเชื่อมกับระบบภายนอก

ในทางกลับกัน เครื่องมือ deploy และ cluster management อย่าง Kubernetes, Docker, Mesos, YARN ถูกออกแบบมาเพื่อรัน application code โดยเฉพาะ เพราะโฟกัสทำสิ่งเดียวให้ดี จึงทำได้ดีกว่า database ที่มีการรัน user-defined function เป็นเพียงหนึ่งในฟีเจอร์มากมาย

เว็บแอปพลิเคชันส่วนใหญ่ทุกวันนี้ deploy เป็น stateless service ที่ request ของผู้ใช้จะถูก route ไปยัง application server ตัวไหนก็ได้ และเซิร์ฟเวอร์จะลืมทุกอย่างเกี่ยวกับ request หลังส่ง response แล้ว รูปแบบนี้ทำให้เพิ่มหรือลบเซิร์ฟเวอร์ได้ตามต้องการ ซึ่งสะดวก แต่ state ก็ต้องอยู่ที่ไหนสักแห่ง (โดยทั่วไปคือ database) แนวโน้มจึงเป็นการแยก stateless application logic ออกจากการจัดการ state (database) คือไม่ใส่ application logic ไว้ใน database และไม่เก็บ persistent state ไว้ในแอปพลิเคชัน [ 25 ] ดังที่ชาว functional programming ชอบพูดเล่นกันว่า "เราเชื่อในการแยก Church และ State" [ 26 ]

Note (หมายเหตุ)

การอธิบายมุกมักทำให้ไม่ตลก แต่ขออธิบายไว้ เพื่อไม่ให้ใครรู้สึกตกขบวน Church หมายถึงนักคณิตศาสตร์ Alonzo Church ผู้สร้าง lambda calculus ซึ่งเป็นรูปแบบแรกเริ่มของการคำนวณที่เป็นพื้นฐานของภาษา functional programming ส่วนใหญ่ lambda calculus ไม่มี mutable state (เช่นไม่มีตัวแปรที่สามารถถูกเขียนทับ) ดังนั้นอาจกล่าวได้ว่า mutable state แยกจากงานของ Church

ในโมเดลเว็บแอปพลิเคชันทั่วไปนี้ database ทำหน้าที่เป็น mutable shared variable (ตัวแปรร่วมที่เปลี่ยนค่าได้) ที่เข้าถึงแบบ synchronous ผ่านเครือข่าย แอปพลิเคชันอ่านและอัปเดตตัวแปรนี้ได้ ส่วน database ดูแลให้มันทนทาน พร้อมให้ concurrency control และ fault tolerance ในระดับหนึ่ง

อย่างไรก็ตาม ในภาษาโปรแกรมส่วนใหญ่ คุณ subscribe การเปลี่ยนแปลงของ mutable variable ไม่ได้ ทำได้แค่อ่านค่าเป็นระยะ ต่างจากสเปรดชีตตรงที่ผู้อ่านตัวแปรจะไม่ได้รับแจ้งเมื่อค่าเปลี่ยน (คุณจะ implement การแจ้งเตือนแบบนี้ในโค้ดเองก็ได้ ซึ่งเรียกว่า observer pattern แต่ภาษาส่วนใหญ่ไม่มี pattern นี้เป็นฟีเจอร์ built-in)

database สืบทอดแนวทางแบบ passive ต่อข้อมูลที่เปลี่ยนแปลงได้นี้มาด้วย ถ้าอยากรู้ว่าข้อมูลใน database เปลี่ยนไปหรือยัง ทางเลือกเดียวมักคือ poll (query ซ้ำเป็นระยะ) ส่วนการ subscribe การเปลี่ยนแปลงเพิ่งเริ่มมีให้ใช้เป็นฟีเจอร์

Dataflow: Interplay between state changes and application code (Dataflow: ความสัมพันธ์ระหว่างการเปลี่ยนแปลง state กับ application code)

การคิดถึงแอปพลิเคชันในแง่ของ dataflow หมายถึงการทบทวนความสัมพันธ์ระหว่าง application code กับการจัดการ state ใหม่ แทนที่จะมอง database เป็นตัวแปร passive ที่แอปพลิเคชันเป็นฝ่ายจัดการ เราให้ความสำคัญกับปฏิสัมพันธ์และการทำงานร่วมกันระหว่าง state, การเปลี่ยนแปลงของ state และโค้ดที่ประมวลผลมันมากขึ้น Application code ตอบสนองต่อการเปลี่ยนแปลง state ในที่หนึ่งด้วยการ trigger การเปลี่ยนแปลง state ในอีกที่หนึ่ง

เราเห็นแนวคิดนี้มาแล้วใน CDC, actor model, trigger และ incremental view maintenance การ unbundle database คือการนำแนวคิดนี้ไปใช้สร้าง dataset ที่ derive อยู่นอก database หลัก เช่น cache, full-text search index, ระบบ machine learning หรือระบบวิเคราะห์ โดยเราใช้ stream processing และ messaging system เพื่อจุดประสงค์นี้ได้

การดูแลข้อมูลที่ derive ต้องมีคุณสมบัติต่อไปนี้ ซึ่ง log-based message broker ให้ได้:

  • เมื่อดูแลข้อมูลที่ derive ลำดับของการเปลี่ยนแปลง state มักสำคัญ (ถ้า view หลายตัว derive มาจาก event log เดียวกัน ทุกตัวต้องประมวลผล event ตามลำดับเดียวกันจึงจะสอดคล้องกัน)

  • ต้องมี fault tolerance เพราะแค่สูญเสีย message เดียวก็ทำให้ dataset ที่ derive ไม่ sync กับแหล่งข้อมูลไปอย่างถาวร ทั้งการส่ง message และการอัปเดต state ที่ derive ต้องเชื่อถือได้

การจัดลำดับ message ที่คงที่และการประมวลผล message แบบ fault-tolerant เป็นข้อกำหนดที่ค่อนข้างเข้มงวด แต่ต้นทุนต่ำกว่าและทนทานในการใช้งานจริงมากกว่า distributed transaction Stream processor สมัยใหม่ให้การรับประกันเรื่องลำดับและความน่าเชื่อถือเหล่านี้ได้ในระดับ scale ใหญ่ และยังให้รัน application code เป็น stream operator ได้ด้วย

Application code นี้ประมวลผลอะไรก็ได้ที่ฟังก์ชัน derive ซึ่งมีมาในตัว database มักทำไม่ได้ เหมือนเครื่องมือ Unix ที่ต่อกันด้วย pipe เราประกอบ stream operator เป็นระบบขนาดใหญ่ที่มี dataflow เป็นแกนกลางได้ โดยแต่ละ operator รับ stream ของการเปลี่ยนแปลง state เป็น input และสร้าง stream ของการเปลี่ยนแปลง state อีกชุดเป็น output

Stream processors and services (Stream processor กับ service)

รูปแบบการพัฒนาแอปพลิเคชันที่นิยมที่สุดในปัจจุบันแบ่งฟังก์ชันการทำงานออกเป็นชุดของ services ที่สื่อสารกันผ่าน synchronous network request เช่น REST API ข้อดีหลักของ service-oriented architecture แบบนี้เมื่อเทียบกับ monolithic application ตัวเดียวคือ organizational scalability จาก loose coupling กล่าวคือแต่ละทีมทำงานกับ service คนละตัว ลดภาระการประสานงานข้ามทีม (ตราบใดที่ service deploy และอัปเดตได้อย่างอิสระ)

การประกอบ stream operator เป็นระบบ dataflow คล้ายกับแนวทาง microservices หลายประการ [ 27 , 28 ] แต่กลไกการสื่อสารข้างใต้ต่างกันมาก คือเป็นการสตรีม message แบบทิศทางเดียวและ asynchronous แทนที่จะเป็นการโต้ตอบแบบ request/response ที่เป็น synchronous

นอกจากข้อดีที่ระบุไว้ใน "Event-Driven Architectures" , เช่น fault tolerance ที่ดีขึ้น ระบบ dataflow ยังให้ประสิทธิภาพที่ดีกว่า REST API หรือ RPC แบบดั้งเดิมได้ ตัวอย่างเช่น สมมติลูกค้าซื้อสินค้าที่ตั้งราคาเป็นสกุลเงินหนึ่งแต่จ่ายเป็นอีกสกุลเงินหนึ่ง การแปลงสกุลเงินต้องรู้อัตราแลกเปลี่ยนปัจจุบัน ซึ่ง implement ได้สองวิธี [ 27 , 29 ]:

  • ในแนวทาง microservices โค้ดที่ประมวลผลการซื้อน่าจะ query exchange rate service หรือ database เพื่อขอดูอัตราปัจจุบันของสกุลเงินนั้น

  • ในแนวทาง dataflow โค้ดที่ประมวลผลการซื้อจะ subscribe stream ของการอัปเดตอัตราแลกเปลี่ยนไว้ล่วงหน้า และบันทึกอัตราปัจจุบันลงใน local database ทุกครั้งที่อัตราเปลี่ยน พอถึงเวลาประมวลผลการซื้อ ก็ query local database นี้ได้โดยตรง

แนวทางที่สองเปลี่ยน synchronous network request ไปยัง service อื่นให้เป็น query ไปยัง local database (ซึ่งอาจอยู่บนเครื่องเดียวกัน หรือแม้แต่ใน process เดียวกัน) ในแนวทาง microservices คุณก็เลี่ยง synchronous network request ได้ด้วยการ cache อัตราแลกเปลี่ยนไว้ใน service ที่ประมวลผลการซื้อ แต่เพื่อให้ cache นั้นสดใหม่ คุณต้อง poll หาอัตราแลกเปลี่ยนที่อัปเดตเป็นระยะ หรือ subscribe stream ของการเปลี่ยนแปลง ซึ่งก็คือสิ่งที่แนวทาง dataflow ทำอยู่แล้ว

แนวทาง dataflow ไม่เพียงเร็วกว่า แต่ยังทนต่อความล้มเหลวของ service อื่นได้ดีกว่าด้วย network request ที่เร็วและเชื่อถือได้ที่สุดคือการไม่มี network request เลย! แทนที่จะเรียก RPC ตอนนี้เรามี stream join ระหว่าง purchase event กับ exchange rate update event

join นี้ขึ้นกับเวลา คือถ้าประมวลผล purchase event ซ้ำในภายหลัง อัตราแลกเปลี่ยนจะเปลี่ยนไปแล้ว และถ้าต้องการสร้าง output เดิมขึ้นมาใหม่ คุณต้องหาอัตราแลกเปลี่ยนในอดีต ณ เวลาที่ซื้อจริง ไม่ว่าจะ query service หรือ subscribe stream ของการอัปเดตอัตราแลกเปลี่ยน คุณก็ต้องจัดการกับ time dependence นี้ (ดู "Time dependence of joins" )

การ subscribe stream ของการเปลี่ยนแปลง แทนที่จะ query state ปัจจุบันเมื่อต้องใช้ ทำให้เราเข้าใกล้โมเดลการคำนวณแบบสเปรดชีตมากขึ้น เมื่อข้อมูลชิ้นหนึ่งเปลี่ยน ข้อมูลที่ derive จากมันก็อัปเดตได้อย่างรวดเร็ว ยังมีคำถามเปิดอีกมาก เช่นเรื่อง time-dependent join แต่การสร้างแอปพลิเคชันรอบแนวคิด dataflow เป็นทิศทางที่น่าสำรวจ

Observing Derived State (การสังเกต derived state)

ในระดับแนวคิด ระบบ dataflow ที่พูดถึงในส่วนก่อนหน้าคือกระบวนการสร้าง dataset ที่ derive (เช่น search index, materialized view และ predictive model) และดูแลให้เป็นปัจจุบัน เราเรียกกระบวนการนั้นว่า write path . เมื่อมีการเขียนข้อมูลเข้าระบบ ข้อมูลอาจผ่าน batch และ stream processing หลายขั้นตอน และในที่สุดทุก dataset ที่ derive ก็จะถูกอัปเดตให้รวมข้อมูลที่เขียนนั้น รูปที่ 13-1 แสดงตัวอย่างการอัปเดต search index

Diagram illustrating the write and read paths in a search index, showing document updates processed through linguistic analysis to update the index, and queries accessing the index for search results.

Figure 13-1. Writes (การอัปเดตเอกสาร) พบกับ reads (query) ใน search index

แต่ทำไมถึงต้องสร้าง dataset ที่ derive ตั้งแต่แรก? ส่วนใหญ่ก็เพราะคุณอยาก query มันในภายหลัง นี่คือ read path : เมื่อให้บริการ request ของผู้ใช้ คุณอ่านจาก dataset ที่ derive อาจประมวลผลผลลัพธ์เพิ่มเติม แล้วสร้าง response ส่งกลับไปยังผู้ใช้

write path กับ read path รวมกันครอบคลุมเส้นทางทั้งหมดของข้อมูล ตั้งแต่จุดที่เก็บรวบรวมมาจนถึงจุดที่ถูกนำไปใช้ (ซึ่งน่าจะเป็นคนอีกคนหนึ่ง) Write path คือส่วนของเส้นทางที่คำนวณล่วงหน้า ทำแบบ eager ทันทีที่ข้อมูลเข้ามา ไม่ว่าจะมีใครขอดูหรือไม่ ส่วน read path คือส่วนที่ทำก็ต่อเมื่อมีคนขอเท่านั้น ถ้าคุณคุ้นกับภาษา functional programming ก็อาจสังเกตได้ว่า write path คล้าย eager evaluation และ read path คล้าย lazy evaluation

Dataset ที่ derive คือจุดที่ write path กับ read path มาพบกัน ดังที่แสดงใน รูปที่ 13-1 . มันสะท้อน trade-off ระหว่างงานที่ต้องทำตอนเขียนกับงานที่ต้องทำตอนอ่าน

Materialized views and caching (Materialized view และ caching)

Full-text search index เป็นตัวอย่างที่ดี write path อัปเดต index ส่วน read path ค้นหาคำใน index ทั้งสองฝั่งต้องทำงานบางส่วน ฝั่ง write ต้องอัปเดต index entry ของทุกคำที่ปรากฏในเอกสาร ฝั่ง read ต้องค้นหาแต่ละคำใน query และใช้ Boolean logic เพื่อหาเอกสารที่มี ทั้งหมด ทุกคำใน query (operator AND ) หรือ ใดๆ คำพ้องความหมายของแต่ละคำ (operator OR )

ถ้าไม่มี index การค้นหาต้องสแกนเอกสารทั้งหมด (เหมือน grep ) ซึ่งจะแพงมากถ้ามีเอกสารจำนวนมาก การไม่มี index หมายถึง write path ทำงานน้อยลง (ไม่มี index ให้อัปเดต) แต่ read path ทำงานมากขึ้น

ในทางกลับกัน เราลองนึกภาพการคำนวณผลลัพธ์การค้นหาล่วงหน้าสำหรับทุก query ที่เป็นไปได้ก็ได้ กรณีนั้น read path จะทำงานน้อยลง คือไม่ต้องใช้ Boolean logic เพียงแค่ค้นผลลัพธ์ของ query นั้นแล้วส่งกลับ แต่ write path จะแพงกว่ามาก เพราะชุดของ search query ที่เป็นไปได้ไม่มีที่สิ้นสุด (หรืออย่างน้อยก็เพิ่มแบบ exponential ตามจำนวนคำในคลังข้อมูล) การคำนวณผลลัพธ์ทั้งหมดไว้ล่วงหน้าจึงทำไม่ได้

อีกทางเลือกคือคำนวณผลลัพธ์ล่วงหน้าเฉพาะ query ที่พบบ่อยที่สุดชุดหนึ่ง เพื่อให้ตอบได้เร็วโดยไม่ต้องไปที่ index ส่วน query ที่ไม่ค่อยพบก็ยังตอบจาก index ได้ สิ่งนี้มักเรียกว่า cache ของ query ที่พบบ่อย แต่จะเรียกว่า materialized view ก็ได้ เพราะต้องอัปเดตมันเมื่อมีเอกสารใหม่ที่ควรอยู่ในผลลัพธ์ของ query เหล่านั้น

จากตัวอย่างนี้ จะเห็นว่า index ไม่ใช่เส้นแบ่งเดียวที่เป็นไปได้ระหว่าง write path กับ read path เราจะ cache ผลลัพธ์การค้นหาที่พบบ่อยก็ได้ และ grep โดยไม่มี index ก็ทำได้ถ้ามีเอกสารไม่มาก เมื่อมองแบบนี้ บทบาทของ cache, index และ materialized view ก็เรียบง่าย คือเลื่อนเส้นแบ่งระหว่าง read path กับ write path ให้เราทำงานที่ write path มากขึ้นด้วยการคำนวณผลลัพธ์ล่วงหน้า เพื่อลดงานที่ read path

การเลื่อนเส้นแบ่งระหว่างงานบน write path และ read path คือหัวใจของตัวอย่างเครือข่ายสังคมใน "กรณีศึกษา: Home Timelines ของเครือข่ายสังคม" . ในตัวอย่างนั้น เส้นแบ่งระหว่าง write path กับ read path อาจวางต่างกันสำหรับคนดังและผู้ใช้ทั่วไป หลังผ่านมา 500 หน้า เรากลับมาที่จุดเริ่มต้นแล้ว!

Stateful, offline-capable clients (client ที่มี state และทำงานออฟไลน์ได้)

แนวคิดเรื่องเส้นแบ่งระหว่าง write path กับ read path น่าสนใจ เพราะเราคุยเรื่องการเลื่อนเส้นแบ่งนั้นได้ และดูได้ว่าในทางปฏิบัติการเลื่อนหมายถึงอะไร ลองดูในบริบทที่ต่างออกไป

ในอดีต เว็บเบราว์เซอร์เป็น stateless client ที่ใช้งานได้ก็ต่อเมื่อมีอินเทอร์เน็ตเท่านั้น (สิ่งเดียวที่ทำได้แบบออฟไลน์คือเลื่อนขึ้นลงในหน้าที่โหลดไว้ตอนออนไลน์) แต่ปัจจุบัน single-page JavaScript app มีความสามารถแบบ stateful มากมาย ทั้งการโต้ตอบกับ UI ฝั่ง client และ persistent local storage ในเว็บเบราว์เซอร์ ส่วนแอปมือถือก็เก็บ state จำนวนมากไว้บนอุปกรณ์ได้ และไม่ต้องไปกลับเซิร์ฟเวอร์สำหรับการโต้ตอบของผู้ใช้ส่วนใหญ่

ใน "Sync Engines และ Local-First Software" เราเห็นว่า persistent local state ทำให้แอปพลิเคชันกลุ่มหนึ่งให้ผู้ใช้ทำงานออฟไลน์ได้โดยไม่ต้องมีอินเทอร์เน็ต แล้ว sync กับเซิร์ฟเวอร์ระยะไกลเบื้องหลังเมื่อเครือข่ายพร้อมใช้งาน [ 30 ] เพราะอุปกรณ์มือถือบางครั้งมีอินเทอร์เน็ตเซลลูลาร์ที่ช้าและไม่เสถียร จึงเป็นข้อได้เปรียบใหญ่ของผู้ใช้ ถ้า UI ไม่ต้องรอ synchronous network request และแอปส่วนใหญ่ทำงานออฟไลน์ได้

เมื่อเราเลิกสมมติว่า client เป็น stateless ที่คุยกับ database กลาง แล้วหันมาเก็บ state ไว้บนอุปกรณ์ของผู้ใช้ ก็เกิดโอกาสใหม่ๆ มากมาย โดยเฉพาะอย่างยิ่ง เรามอง state บนอุปกรณ์ได้ว่าเป็น cache ของ state บนเซิร์ฟเวอร์ . พิกเซลบนหน้าจอเป็น materialized view ของ model object ใน client app และ model object ก็เป็น local replica ของ state ใน datacenter ระยะไกล [ 31 ]

Pushing state changes to clients (การ push การเปลี่ยนแปลง state ไปยัง client)

ถ้าคุณโหลดหน้าเว็บทั่วไปในเบราว์เซอร์ แล้วข้อมูลบนเซิร์ฟเวอร์เปลี่ยนทีหลัง เบราว์เซอร์จะไม่รู้เรื่องจนกว่าคุณจะโหลดหน้าใหม่ เบราว์เซอร์อ่านข้อมูล ณ จุดเวลาเดียวและสมมติว่ามันไม่เปลี่ยน มันไม่ได้ subscribe การอัปเดตจากเซิร์ฟเวอร์ state ในเบราว์เซอร์จึงเป็น cache ที่ล้าสมัยและไม่ถูกอัปเดต เว้นแต่คุณจะ poll หาการเปลี่ยนแปลงเอง (โปรโตคอล subscribe feed ที่ใช้ HTTP อย่าง RSS ก็เป็นรูปแบบพื้นฐานของการ polling นั่นเอง)

โปรโตคอลใหม่ๆ ก้าวไปไกลกว่ารูปแบบ request/response พื้นฐานของ HTTP Server-sent events (EventSource API) และ WebSocket เป็นช่องทางสื่อสารที่ให้เบราว์เซอร์เปิด TCP connection ค้างไว้กับเซิร์ฟเวอร์ และเซิร์ฟเวอร์ก็ push message ไปหาเบราว์เซอร์ได้เองตราบที่ยังเชื่อมต่ออยู่ ทำให้เซิร์ฟเวอร์แจ้ง client ของผู้ใช้ปลายทางได้ทันทีเมื่อ state ที่ client เก็บไว้ในเครื่องมีการเปลี่ยนแปลง ซึ่งช่วยลดความล้าสมัยของ client-side state

ในแง่ของโมเดล write path และ read path การ push การเปลี่ยนแปลง state ไปยังอุปกรณ์ client หมายถึงการขยาย write path ไปจนถึงผู้ใช้ปลายทาง ตอน client เริ่มทำงานครั้งแรกยังต้องใช้ read path เพื่อรับ state เริ่มต้น แต่หลังจากนั้นก็พึ่ง stream ของการเปลี่ยนแปลง state ที่เซิร์ฟเวอร์ส่งมาได้ แนวคิดเรื่อง stream processing และ messaging ที่เราคุยกันจึงไม่ได้จำกัดอยู่แค่ใน datacenter เราขยายไปถึงอุปกรณ์ของผู้ใช้ปลายทางได้ด้วย [ 32 ]

อุปกรณ์บางครั้งก็ออฟไลน์ และจะรับการแจ้งเตือนการเปลี่ยนแปลง state จากเซิร์ฟเวอร์ไม่ได้ในช่วงนั้น แต่เราแก้ปัญหานี้ไปแล้ว ใน "Consumer offsets" เราพูดถึงว่า consumer ของ log-based message broker สามารถ reconnect หลังล้มเหลวหรือหลุดการเชื่อมต่อ และมั่นใจได้ว่าไม่พลาด message ที่เข้ามาระหว่างที่หลุดไป เทคนิคเดียวกันนี้ใช้ได้กับผู้ใช้แต่ละคน โดยอุปกรณ์แต่ละเครื่องเป็น subscriber ของ event stream ขนาดเล็กๆ

End-to-end event streams (event stream แบบ end-to-end)

เครื่องมือสำหรับพัฒนา stateful client และ UI เช่น React และ Elm [ 33 ] สามารถอัปเดต UI ที่แสดงผลอยู่ตามการเปลี่ยนแปลงของ state เบื้องหลังได้ จึงเป็นเรื่องธรรมชาติที่จะขยายโมเดลการเขียนโปรแกรมนี้ ให้เซิร์ฟเวอร์ push event การเปลี่ยนแปลง state เข้าสู่ client-side event pipeline ได้

การเปลี่ยนแปลง state ไหลผ่าน write path แบบ end-to-end ได้ ตั้งแต่การโต้ตอบบนอุปกรณ์เครื่องหนึ่งที่ trigger การเปลี่ยนแปลง ผ่าน event log ระบบข้อมูลที่ derive และ stream processor ไปจนถึง user interface บนอุปกรณ์อีกเครื่อง การเปลี่ยนแปลงเหล่านี้กระจายไปได้ด้วย latency ค่อนข้างต่ำ เช่น ต่ำกว่าหนึ่งวินาทีแบบ end-to-end

แอปพลิเคชันบางประเภท เช่น instant messaging และเกมออนไลน์ ใช้สถาปัตยกรรมแบบ "real-time" นี้อยู่แล้ว (ในความหมายของการโต้ตอบที่ latency ต่ำ ไม่ใช่การรับประกันเวลาตอบสนอง) แล้วทำไมเราไม่สร้างแอปพลิเคชันทั้งหมดแบบนี้ล่ะ?

ความท้าทายคือสมมติฐานเรื่อง stateless client และการโต้ตอบแบบ request/response ฝังลึกอยู่ใน database, library, framework และโปรโตคอลของเรา data store จำนวนมากรองรับ read และ write operation ที่ request หนึ่งได้ response เดียว แต่รองรับ operation ที่ request หนึ่งได้ stream ของ response ต่อเนื่องตามเวลา (เช่นความสามารถในการ subscribe การเปลี่ยนแปลง) น้อยกว่ามาก

การจะขยาย write path ไปถึงผู้ใช้ปลายทางได้ เราต้องคิดใหม่ตั้งแต่พื้นฐานว่าจะสร้างระบบเหล่านี้อย่างไร คือเปลี่ยนจากการโต้ตอบแบบ request/response ไปเป็น publish/subscribe dataflow [ 31 ] ต้องลงแรงพอสมควร แต่ผลที่ได้คือ UI ตอบสนองไวขึ้นและรองรับการทำงานออฟไลน์ได้ดีขึ้น

Reads are events too (Read ก็เป็น event เหมือนกัน)

เราคุยกันไปแล้วว่าเมื่อ stream processor เขียนข้อมูลที่ derive ลง store (database, cache หรือ index) และมีการ query store นั้น store จะเป็นเส้นแบ่งระหว่าง write path กับ read path มันทำให้ query แบบ random-access กับข้อมูลได้ ซึ่งถ้าไม่มี store ก็ต้องสแกน event log ทั้งหมด

ในหลายกรณี data store แยกออกจากระบบ streaming แต่อย่าลืมว่า stream processor ก็ต้องเก็บ state เพื่อทำ aggregation และ join เช่นกัน state นี้โดยปกติซ่อนอยู่ภายใน stream processor แต่บาง framework ก็เปิดให้ client ภายนอก query ได้ [ 34 ] ทำให้ stream processor กลายเป็น database แบบง่ายๆ ตัวหนึ่ง

ลองขยายแนวคิดนั้นต่อ ในโมเดลที่เราคุยกันมาจนถึงตอนนี้ write ไปยัง store ผ่าน event log ส่วน read เป็น network request ชั่วคราวที่ไปยัง node ซึ่งเก็บข้อมูลที่ถูก query โดยตรง นี่เป็นการออกแบบที่สมเหตุสมผล แต่ไม่ใช่ทางเดียว เรายังแทน read request เป็น stream ของ event และส่งทั้ง read event และ write event ผ่าน stream processor ได้ด้วย โดย processor จะตอบ read event ด้วยการ emit ผลลัพธ์การอ่านไปยัง output stream [ 35 ]

เมื่อทั้ง write และ read ถูกแทนเป็น event และ route ไปยัง stream operator ตัวเดียวกัน เรากำลังทำ stream-table join ระหว่าง stream ของ read query กับ database โดยแต่ละ read event ต้องถูกส่งไปยัง database shard ที่เก็บข้อมูลที่เกี่ยวข้อง เหมือนที่ batch และ stream processor ทำ copartition ของ input ด้วย key เดียวกันเมื่อทำ join

ความเชื่อมโยงระหว่างการให้บริการ request กับการทำ join นี้ค่อนข้างเป็นพื้นฐาน [ 36 ]. read request แบบครั้งเดียวผ่าน join operator ซึ่งจะลืม request นั้นทันที ส่วน subscribe request เป็น persistent join กับ event ทั้งในอดีตและอนาคตที่อยู่อีกฝั่งหนึ่งของ join

การ log read event อาจมีประโยชน์ในการติดตามความสัมพันธ์เชิงสาเหตุและ data provenance (ที่มาของข้อมูล) ข้ามระบบ เพราะ log จะช่วยให้สร้างสิ่งที่ผู้ใช้เห็นก่อนตัดสินใจบางอย่างขึ้นมาใหม่ได้ ตัวอย่างเช่น ในร้านค้าออนไลน์ วันส่งสินค้าโดยประมาณและสถานะสต็อกที่แสดงให้ลูกค้าเห็นน่าจะมีผลต่อการตัดสินใจซื้อ [ 4 ] การจะวิเคราะห์ความเชื่อมโยงนี้ได้ ต้องบันทึกผลลัพธ์ของ query ที่ผู้ใช้ทำเพื่อดูสถานะการจัดส่งและสต็อกสินค้า

การเขียน read request ลง storage ที่ทนทานจึงช่วยให้ติดตามความสัมพันธ์เชิงสาเหตุได้ดีขึ้น แต่ก็ทำให้มีต้นทุน storage และ I/O เพิ่ม การปรับระบบแบบนี้เพื่อลด overhead ยังเป็นปัญหาวิจัยที่เปิดอยู่ [ 2 ] แต่ถ้าคุณ log read request เพื่อวัตถุประสงค์ด้านการดูแลระบบอยู่แล้วเป็นผลข้างเคียงของการประมวลผล request ก็ไม่ใช่การเปลี่ยนแปลงใหญ่อะไรที่จะให้ log นั้นเป็นต้นทางของ request แทน

Multishard data processing (การประมวลผลข้อมูลข้ามหลาย shard)

สำหรับ query ที่แตะ shard เดียว การส่งผ่าน stream แล้วรวบรวม stream ของ response อาจเป็นภาระเกินจำเป็น แต่แนวคิดนี้เปิดทางให้รัน complex query ที่ต้องรวมข้อมูลจากหลาย shard แบบกระจาย โดยใช้โครงสร้างพื้นฐานด้าน message routing, sharding และ join ที่ stream processor มีอยู่แล้ว

ฟีเจอร์ distributed RPC ของ Storm รองรับรูปแบบการใช้งานนี้ เช่นใช้คำนวณจำนวนคนที่เห็น URL บนเครือข่ายสังคม คือการรวม follower set ของทุกคนที่โพสต์ URL นั้น [ 37 ] เมื่อชุดผู้ใช้ถูก shard การคำนวณนี้ต้องรวมผลลัพธ์จากหลาย shard

อีกตัวอย่างของ pattern นี้อยู่ในการป้องกันการฉ้อโกง การประเมินความเสี่ยงว่า purchase event หนึ่งเป็นการฉ้อโกงหรือไม่ ทำได้โดยตรวจสอบคะแนนความน่าเชื่อถือ (reputation score) ของที่อยู่ IP, อีเมล, ที่อยู่เรียกเก็บเงิน, ที่อยู่จัดส่ง และอื่นๆ ของผู้ใช้ reputation database แต่ละตัวถูก shard เอง การรวบรวมคะแนนสำหรับ purchase event หนึ่งจึงต้อง join กับ dataset ที่ shard ต่างกันไปตามลำดับ [ 38 ]

กราฟการรัน query ภายในของ query engine ใน data warehouse ก็มีลักษณะคล้ายกัน ถ้าต้องทำ multishard join แบบนี้ การใช้ database ที่มีฟีเจอร์นี้อาจง่ายกว่าการ implement เองด้วย stream processor แต่การมอง query เป็น stream ก็เปิดทางเลือกในการ implement แอปพลิเคชันขนาดใหญ่ที่ติดข้อจำกัดของโซลูชันสำเร็จรูปทั่วไป

Aiming for Correctness (การมุ่งสู่ความถูกต้อง)

กับ stateless service ที่อ่านข้อมูลอย่างเดียว ถ้าเกิดอะไรผิดพลาดก็ไม่ใช่เรื่องใหญ่ แก้ bug แล้ว restart service ทุกอย่างก็กลับมาเป็นปกติ แต่ระบบที่มี state อย่าง database ไม่ง่ายขนาดนั้น มันถูกออกแบบมาให้จำสิ่งต่างๆ ไว้ตลอดไป (ไม่มากก็น้อย) ถ้าเกิดอะไรผิดพลาด ผลกระทบก็อาจอยู่ไปตลอดเช่นกัน ซึ่งหมายความว่าต้องคิดอย่างรอบคอบกว่า [ 39 ]

เราต้องการสร้างแอปพลิเคชันที่น่าเชื่อถือและ ถูกต้อง (คือโปรแกรมที่มี semantics ชัดเจนและเข้าใจได้ แม้เกิดความผิดพลาดต่างๆ) มาราวสี่ทศวรรษแล้วที่คุณสมบัติของ transaction อย่าง atomicity, isolation และ durability เป็นเครื่องมือหลักในการสร้างแอปพลิเคชันที่ถูกต้อง แต่รากฐานเหล่านั้นอ่อนแอกว่าที่คิด เช่น ความสับสนของ weak isolation level (ดู "Weak Isolation Levels" )

ในบางวงการ มีการเลิกใช้ transaction ไปเลยและหันไปใช้โมเดลที่ให้ประสิทธิภาพและ scalability ดีกว่า แต่ semantics ยุ่งยากกว่า Consistency ถูกพูดถึงบ่อยแต่นิยามไม่ชัดเจน บางคนยืนยันว่าเราควร "ยอมรับ weak consistency" เพื่อให้ availability ดีขึ้น ทั้งที่ยังไม่มีความเข้าใจชัดเจนว่าในทางปฏิบัติมันหมายถึงอะไร

สำหรับหัวข้อที่สำคัญขนาดนี้ ความเข้าใจและวิธีการทางวิศวกรรมของเรากลับไม่แน่นอนอย่างน่าประหลาดใจ เช่น ยากมากที่จะบอกว่าการรันแอปพลิเคชันภายใต้ transaction isolation level หรือ replication configuration ระดับใดระดับหนึ่งนั้นปลอดภัยหรือไม่ [ 40 , 41 ] บ่อยครั้งวิธีแก้ง่ายๆ ดูเหมือนทำงานถูกต้องตอน concurrency ต่ำและไม่มีข้อผิดพลาด แต่กลับมี bug แฝงอยู่มากมายเมื่อเจอสถานการณ์ที่ท้าทายกว่า

ตัวอย่างเช่น การทดลอง Jepsen ของ Kyle Kingsbury [ 42 ] ได้ชี้ให้เห็นช่องว่างขนาดใหญ่ระหว่างการรับประกันด้านความปลอดภัยที่ผลิตภัณฑ์บางตัวอ้างไว้ กับพฤติกรรมจริงเมื่อเจอปัญหาเครือข่ายและ crash ต่อให้ผลิตภัณฑ์โครงสร้างพื้นฐานอย่าง database ไม่มีปัญหา โค้ดแอปพลิเคชันก็ยังต้องใช้ฟีเจอร์ของมันให้ถูกต้อง ซึ่งพลาดได้ง่ายถ้า configuration เข้าใจยาก (เช่น weak isolation level, quorum configuration และอื่นๆ)

ถ้าแอปพลิเคชันของคุณทนต่อข้อมูลที่ถูกทำให้เสียหายหรือสูญหายเป็นครั้งคราวแบบคาดเดาไม่ได้ ชีวิตก็จะง่ายขึ้นมาก และอาจรอดไปได้ด้วยการหวังว่าจะไม่เป็นอะไร แต่ถ้าต้องการการรับประกันความถูกต้องที่เข้มงวดกว่านั้น serializability และ atomic commit เป็นแนวทางที่ยอมรับกันมานาน แต่ก็มีต้นทุน คือโดยทั่วไปทำงานได้เฉพาะใน datacenter เดียว (ตัดสถาปัตยกรรมแบบกระจายตามภูมิศาสตร์ออกไป) และจำกัด scale กับ fault tolerance ที่ทำได้

แนวทาง transaction แบบดั้งเดิมไม่ได้หายไปไหน แต่ก็ไม่ใช่คำตอบสุดท้ายของการทำให้แอปพลิเคชันถูกต้องและทนต่อความผิดพลาด ในส่วนนี้เราจะดูวิธีคิดแบบอื่นเกี่ยวกับความถูกต้องในบริบทของสถาปัตยกรรม dataflow

The End-to-End Argument for Databases (หลักการ end-to-end สำหรับ database)

การที่แอปพลิเคชันใช้ระบบข้อมูลที่ให้การรับประกันด้านความปลอดภัยค่อนข้างเข้มงวด เช่น serializable transaction ไม่ได้แปลว่าแอปพลิเคชันจะปลอดภัยจากการสูญเสียหรือความเสียหายของข้อมูล ตัวอย่างเช่น ถ้าแอปพลิเคชันมี bug ที่ทำให้เขียนข้อมูลผิดหรือลบข้อมูลออกจาก database serializable transaction ก็ช่วยอะไรไม่ได้ นี่คือเหตุผลที่สนับสนุนข้อมูลแบบ immutable และ append-only เพราะกู้คืนจากความผิดพลาดแบบนั้นได้ง่ายกว่า ถ้าตัดความสามารถของโค้ดที่ผิดพลาดในการทำลายข้อมูลดีๆ ออกไป

แม้ immutability จะมีประโยชน์ แต่ก็ไม่ใช่ยาวิเศษ ลองดูตัวอย่างที่ละเอียดอ่อนกว่าของการที่ข้อมูลอาจเสียหายได้

Exactly-once execution of an operation (การทำ operation แบบ exactly-once)

ใน "Distributed Transactions ข้ามระบบที่แตกต่างกัน" เราแนะนำแนวคิดของ exactly-once (หรือ effectively-once ) semantics ในบริบทของการประมวลผล message แนวคิดคือ ถ้ามีอะไรผิดพลาดระหว่างประมวลผล message คุณเลือกได้ว่าจะยอมแพ้ (ทิ้ง message ซึ่งทำให้ข้อมูลสูญหาย) หรือลองใหม่ ถ้าลองใหม่ ก็มีความเสี่ยงที่ครั้งแรกประมวลผลสำเร็จไปแล้ว แต่คุณไม่ได้รับการยืนยัน ทำให้ message ถูกประมวลผลสองครั้ง

การประมวลผลสองครั้งเป็นข้อมูลเสียหายรูปแบบหนึ่ง เราคงไม่อยากเรียกเก็บเงินลูกค้าสองครั้งสำหรับบริการเดียวกัน (เก็บเงินเกิน) หรือเพิ่ม counter สองครั้ง (ทำให้ metric สูงเกินจริง) ในบริบทนี้ exactly once หมายถึงการจัดการการคำนวณให้ผลลัพธ์สุดท้ายเหมือนกับกรณีที่ไม่มีความผิดพลาดเกิดขึ้น แม้ operation จะถูก retry เพราะความผิดพลาดก็ตาม เราได้พูดถึงแนวทางบางอย่างในการทำเช่นนี้ไปแล้ว

หนึ่งในแนวทางที่ได้ผลที่สุดคือการทำให้ operation เป็น idempotent คือทำให้แน่ใจว่าผลลัพธ์เหมือนกันไม่ว่าจะรันครั้งเดียวหรือหลายครั้ง อย่างไรก็ตาม การทำให้ operation ที่ไม่ได้ idempotent โดยธรรมชาติเป็น idempotent ต้องใช้ความพยายามและความระมัดระวัง คุณอาจต้องเก็บ metadata เพิ่ม (เช่นชุดของ operation ID ที่เคยอัปเดตค่าแล้ว) และต้องมี fencing เมื่อทำ failover จาก node หนึ่งไปอีก node หนึ่ง (ดู "Distributed Locks และ Leases" )

Duplicate suppression (การกำจัดข้อมูลซ้ำ)

ความจำเป็นในการกำจัด duplicate แบบเดียวกันนี้เกิดขึ้นในที่อื่นอีกมากนอกจาก stream processing ตัวอย่างเช่น TCP ใช้ sequence number บน packet เพื่อเรียงลำดับให้ถูกต้องฝั่งผู้รับ และตรวจว่า packet ไหนสูญหายหรือซ้ำบนเครือข่าย packet ที่หายจะถูกส่งใหม่ และ TCP stack จะลบ duplicate ออกก่อนส่งข้อมูลให้แอปพลิเคชัน

อย่างไรก็ตาม การกำจัด duplicate นี้ใช้ได้เฉพาะภายใน TCP connection เดียว ลองนึกว่า TCP connection คือการเชื่อมต่อจาก client ไปยัง database ที่กำลังรัน transaction ใน ตัวอย่างที่ 13-1 . ใน database หลายตัว transaction ผูกอยู่กับ client connection (ถ้า client ส่ง query หลายตัว database รู้ว่าอยู่ใน transaction เดียวกันเพราะส่งมาบน TCP connection เดียวกัน) ถ้า client เจอเครือข่ายขัดข้องและ connection timeout หลังส่ง COMMIT แต่ก่อนได้รับ response จาก database server มันจะไม่รู้ว่า transaction ถูก commit หรือ abort ไปแล้ว (เราเห็นสถานการณ์นี้ใน รูปที่ 9-1 )

Example 13-1. การโอนเงินแบบไม่ idempotent จากบัญชีหนึ่งไปอีกบัญชีหนึ่ง
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance + 11.00 WHERE account_id = 1234;
UPDATE accounts SET balance = balance - 11.00 WHERE account_id = 4321;
COMMIT;

Client จะ reconnect ไปยัง database แล้วลองทำ transaction ใหม่ก็ได้ แต่ตอนนี้อยู่นอกขอบเขตการกำจัด duplicate ของ TCP แล้ว เพราะ transaction ใน ตัวอย่างที่ 13-1 ไม่ได้ idempotent จึงอาจโอนไป $22 แทนที่จะเป็น $11 ตามที่ตั้งใจ ดังนั้นแม้โค้ดแบบนี้จะเป็นตัวอย่างมาตรฐานของ atomicity ใน transaction แต่ก็ไม่ถูกต้อง และธนาคารจริงไม่ได้ทำงานแบบนี้ [ 3 ]

โปรโตคอล 2PC (ดู "Two-Phase Commit" ) ทำลายการแมปแบบ one-to-one ระหว่าง TCP connection กับ transaction เพราะต้องให้ transaction coordinator reconnect ไปยัง database หลังเครือข่ายขัดข้อง แล้วสั่งให้ commit หรือ abort transaction ที่ค้างอยู่ แล้วแค่นี้พอจะรับประกันว่า transaction จะรันเพียงครั้งเดียวหรือไม่? น่าเสียดาย ไม่พอ

ต่อให้กำจัด duplicate transaction ระหว่าง database client กับ server ได้ เราก็ยังต้องกังวลเรื่องเครือข่ายระหว่างอุปกรณ์ของผู้ใช้ปลายทางกับ application server ตัวอย่างเช่น ถ้า client ของผู้ใช้ปลายทางเป็นเว็บเบราว์เซอร์ มันอาจใช้ HTTP POST request เพื่อส่งคำสั่งไปยังเซิร์ฟเวอร์ สมมติว่าผู้ใช้มีสัญญาณเซลลูลาร์อ่อน และส่ง POST request สำเร็จ แต่สัญญาณหายก่อนจะได้รับ response จากเซิร์ฟเวอร์

กรณีนี้ผู้ใช้น่าจะเห็นข้อความแสดงข้อผิดพลาดและอาจลองใหม่เอง เว็บเบราว์เซอร์จะเตือนว่า "คุณแน่ใจหรือว่าต้องการส่งฟอร์มนี้อีกครั้ง?" และผู้ใช้ก็ตอบว่าใช่ เพราะอยากให้ operation เกิดขึ้น (pattern Post/Redirect/Get [ 43 ] ช่วยเลี่ยงข้อความเตือนนี้ในการทำงานปกติ แต่ไม่ช่วยถ้า POST request timeout) จากมุมมองของ web server การลองใหม่เป็น request แยกต่างหาก และจากมุมมองของ database ก็เป็น transaction แยกต่างหาก กลไก deduplication ปกติจึงไม่ช่วย

Uniquely identifying requests (การระบุ request ด้วย identifier ที่ไม่ซ้ำ)

การจะทำให้ request เป็น idempotent ข้ามการสื่อสารเครือข่ายหลาย hop ได้ การพึ่งกลไก transaction ที่ database ให้มาไม่เพียงพอ คุณต้องพิจารณาการไหล end-to-end ของ request

ตัวอย่างเช่น คุณสร้าง unique identifier สำหรับแต่ละ request (เช่น UUID) แล้วใส่เป็น hidden form field ใน client application หรือคำนวณ hash ของ form field ที่เกี่ยวข้องทั้งหมดเพื่อ derive request ID [ 3 ] ถ้าเบราว์เซอร์ส่ง POST request สองครั้ง ทั้งสอง request จะมี request ID เดียวกัน จากนั้นส่ง request ID นั้นต่อไปจนถึง database แล้วตรวจสอบให้แน่ใจว่าประมวลผล request ที่มี ID นั้นเพียงครั้งเดียว ดังที่แสดงใน ตัวอย่างที่ 13-2 .

Example 13-2. การกำจัด request ซ้ำด้วย unique ID
ALTER TABLE requests ADD UNIQUE (request_id);

BEGIN TRANSACTION;

INSERT INTO requests
  (request_id, from_account, to_account, amount)
  VALUES('0286FDB8-D7E1-423F-B40B-792B3608036C', 4321, 1234, 11.00);

UPDATE accounts SET balance = balance + 11.00 WHERE account_id = 1234;
UPDATE accounts SET balance = balance - 11.00 WHERE account_id = 4321;

COMMIT;

โค้ดนี้พึ่ง uniqueness constraint บนคอลัมน์ request_id . ถ้า transaction พยายาม insert ID ที่มีอยู่แล้ว INSERT จะล้มเหลวและ transaction จะถูก abort เพื่อไม่ให้มีผลสองครั้ง โดยทั่วไป relational database บังคับ uniqueness constraint ได้ถูกต้องแม้ใน weak isolation level (ส่วน check-then-insert ในระดับแอปพลิเคชันอาจล้มเหลวภายใต้ nonserializable isolation ดังที่พูดถึงใน "Write Skew และ Phantoms" )

นอกจากกำจัด request ซ้ำแล้ว ตาราง requests ใน ตัวอย่างที่ 13-2 ยังทำหน้าที่เป็น event log แบบหนึ่ง ซึ่งมีประโยชน์กับ event sourcing หรือ CDC การอัปเดตยอดคงเหลือในบัญชีไม่จำเป็นต้องอยู่ใน transaction เดียวกับการ insert event เพราะข้อมูลนั้นซ้ำซ้อนและ derive จาก request event ได้ใน downstream consumer ตราบใดที่ event ถูกประมวลผลแบบ exactly once ซึ่งบังคับใช้ได้อีกครั้งด้วย request ID

The end-to-end argument (หลักการ end-to-end)

สถานการณ์การกำจัด duplicate transaction นี้เป็นเพียงตัวอย่างหนึ่งของหลักการทั่วไปที่เรียกว่า end-to-end argument ซึ่ง Saltzer, Reed และ Clark อธิบายไว้ในปี 1984 [ 44 ]:

ฟังก์ชันที่เป็นปัญหาจะ implement ได้อย่างสมบูรณ์และถูกต้องก็ต่อเมื่อมีความรู้และความช่วยเหลือจากแอปพลิเคชันที่ปลายทางของระบบสื่อสาร ดังนั้นการให้ฟังก์ชันนั้นเป็นฟีเจอร์ของระบบสื่อสารเองจึงเป็นไปไม่ได้ (แต่บางครั้งฟังก์ชันเวอร์ชันไม่สมบูรณ์ที่ระบบสื่อสารให้มาก็อาจมีประโยชน์ในฐานะการเพิ่มประสิทธิภาพ)

ในตัวอย่างของเรา ฟังก์ชันที่เป็นปัญหา คือการกำจัด duplicate เราเห็นว่า TCP กำจัด duplicate packet ในระดับ TCP connection และ stream processor บางตัวให้สิ่งที่เรียกว่า exactly-once semantics ในระดับการประมวลผล message แต่ก็ไม่พอที่จะป้องกันไม่ให้ผู้ใช้ส่ง duplicate request ถ้า request แรก timeout ไปเอง TCP, database transaction และ stream processor ลำพังกำจัด duplicate เหล่านี้ได้ไม่หมด การแก้ปัญหาต้องใช้วิธีแบบ end-to-end คือ transaction identifier ที่ส่งตั้งแต่ client ของผู้ใช้ปลายทางไปจนถึง database

หลักการ end-to-end ยังใช้กับการตรวจสอบ data integrity ด้วย Checksum ที่มีใน Ethernet, TCP และ TLS ตรวจจับ packet ที่เสียหายในเครือข่ายได้ แต่ตรวจจับความเสียหายที่เกิดจาก bug ในซอฟต์แวร์ฝั่งส่งและฝั่งรับของการเชื่อมต่อ หรือความเสียหายบนดิสก์ที่เก็บข้อมูลไม่ได้ ถ้าต้องการจับความเสียหายของข้อมูลให้ได้ทุกแหล่งที่เป็นไปได้ คุณต้องมี end-to-end checksum

หลักการคล้ายกันนี้ใช้กับการเข้ารหัสด้วย [ 44 ] รหัสผ่านของ WiFi ที่บ้านป้องกันคนแอบดักข้อมูล WiFi ของคุณได้ แต่ป้องกันผู้โจมตีที่อื่นบนอินเทอร์เน็ตไม่ได้ ส่วน TLS/SSL ระหว่าง client กับเซิร์ฟเวอร์ป้องกันผู้โจมตีบนเครือข่ายได้ แต่ป้องกันการเจาะเซิร์ฟเวอร์ไม่ได้ มีเพียง end-to-end encryption และ authentication เท่านั้นที่ป้องกันได้ครบทุกกรณี

แม้ฟีเจอร์ระดับต่ำ (TCP duplicate suppression, Ethernet checksum, WiFi encryption) จะให้คุณสมบัติ end-to-end ที่ต้องการด้วยตัวเองไม่ได้ แต่ก็ยังมีประโยชน์ เพราะลดโอกาสเกิดปัญหาในระดับที่สูงกว่า ตัวอย่างเช่น HTTP request มักจะเสียหายถ้าไม่มี TCP จัด packet กลับเข้าลำดับที่ถูกต้อง เราแค่ต้องจำไว้ว่า ความน่าเชื่อถือระดับต่ำลำพังไม่พอจะรับประกันความถูกต้องแบบ end-to-end

Applying end-to-end thinking in data systems (การคิดแบบ end-to-end ในระบบข้อมูล)

สิ่งนี้พาเรากลับมาที่ประเด็นตั้งต้น การที่แอปพลิเคชันใช้ระบบข้อมูลที่ให้การรับประกันด้านความปลอดภัยค่อนข้างเข้มงวด เช่น serializable transaction ไม่ได้แปลว่าแอปพลิเคชันจะปลอดภัยจากข้อมูลสูญหายหรือเสียหาย ตัวแอปพลิเคชันเองต้องมีมาตรการ end-to-end เช่นการกำจัด duplicate ด้วย

น่าเสียดาย เพราะกลไก fault-tolerance นั้นทำให้ถูกต้องได้ยาก กลไกความน่าเชื่อถือระดับต่ำอย่างที่อยู่ใน TCP ทำงานได้ค่อนข้างดี ความผิดพลาดระดับสูงที่หลงเหลือจึงเกิดขึ้นไม่บ่อยนัก คงจะดีมากถ้าเราห่อหุ้มกลไก fault-tolerance ระดับสูงไว้ใน abstraction ที่โค้ดแอปพลิเคชันไม่ต้องกังวลเรื่องนี้ แต่ดูเหมือนเรายังหา abstraction ที่ใช่ไม่เจอ

Transaction ถูกมองว่าเป็น abstraction ที่มีประโยชน์มานาน ดังที่พูดถึงใน บทที่ 8 มันรวบปัญหาที่เป็นไปได้หลากหลายแบบ (การเขียนพร้อมกัน, การละเมิด constraint, crash, เครือข่ายขัดข้อง, ดิสก์เสีย) ให้เหลือแค่สองผลลัพธ์ คือ commit หรือ abort ซึ่งลดความซับซ้อนของโมเดลการเขียนโปรแกรมลงมหาศาล แต่ก็ยังไม่พอ

Transaction มีต้นทุนสูง โดยเฉพาะเมื่อเกี่ยวข้องกับเทคโนโลยีจัดเก็บข้อมูลที่ต่างกัน (ดู "Distributed Transactions ข้ามระบบที่แตกต่างกัน" ) เมื่อเราไม่ยอมใช้ distributed transaction เพราะแพงเกินไป เราก็ต้อง implement กลไก fault-tolerance ใน application code ขึ้นมาเอง ซึ่งตัวอย่างมากมายในหนังสือเล่มนี้แสดงให้เห็นแล้วว่า การคิดเรื่อง concurrency และ partial failure นั้นยากและขัดกับสัญชาตญาณ กลไกระดับแอปพลิเคชันส่วนใหญ่จึงมักทำงานไม่ถูกต้อง ผลคือข้อมูลสูญหายหรือเสียหาย

ด้วยเหตุผลเหล่านี้ จึงคุ้มค่าที่จะหา abstraction ด้าน fault-tolerance ที่ทำให้การสร้างคุณสมบัติความถูกต้องแบบ end-to-end เฉพาะของแอปพลิเคชันง่ายขึ้น โดยยังคงประสิทธิภาพที่ดีและลักษณะการทำงานที่ดีในสภาพแวดล้อมแบบกระจายขนาดใหญ่

Enforcing Constraints (การบังคับใช้ constraint)

ลองคิดเรื่องความถูกต้องในบริบทของแนวคิด unbundling database เราเห็นแล้วว่าการกำจัด duplicate แบบ end-to-end ทำได้ด้วย request ID ที่ส่งจาก client ไปจนถึง database ที่บันทึก write แล้ว constraint ประเภทอื่นล่ะ?

เรามาดู uniqueness constraint เป็นพิเศษ เหมือนที่เราพึ่งใน ตัวอย่างที่ 13-2 . ใน "ข้อจำกัดและการรับประกันความเป็นเอกลักษณ์" เราเห็นตัวอย่างอื่นๆ ของฟีเจอร์แอปพลิเคชันที่ต้องบังคับให้ค่าไม่ซ้ำกัน เช่น username หรืออีเมลต้องระบุผู้ใช้ได้เพียงคนเดียว บริการเก็บไฟล์จะมีไฟล์ชื่อเดียวกันเกินหนึ่งไฟล์ไม่ได้ และคนสองคนจองที่นั่งเดียวกันบนเที่ยวบินหรือในโรงละครไม่ได้

constraint ประเภทอื่นก็คล้ายกันมาก เช่น การรับประกันว่ายอดเงินในบัญชีไม่ติดลบ การไม่ขายสินค้าเกินจำนวนที่มีในสต็อกของคลัง หรือห้องประชุมไม่มีการจองซ้อนกัน เทคนิคที่ใช้บังคับความไม่ซ้ำกันมักใช้กับ constraint ประเภทนี้ได้เช่นกัน

Uniqueness constraints require consensus (Uniqueness constraint ต้องใช้ consensus)

ใน บทที่ 10 เราเห็นว่าในสภาพแวดล้อมแบบกระจาย การบังคับ uniqueness constraint ต้องใช้ consensus ถ้ามี request พร้อมกันหลายตัวที่ใช้ค่าเดียวกัน ระบบต้องตัดสินว่า operation ที่ขัดแย้งกันตัวไหนจะได้รับการยอมรับ และปฏิเสธที่เหลือว่าละเมิด constraint

วิธีที่พบบ่อยที่สุดในการทำ consensus นี้คือให้ node เดียวเป็น leader และรับผิดชอบการตัดสินใจทั้งหมด ซึ่งใช้ได้ดีตราบใดที่คุณไม่ว่าอะไรที่จะส่ง request ทั้งหมดผ่าน node เดียว (แม้ client จะอยู่อีกซีกโลก) และตราบใดที่ node นั้นไม่ล้มเหลว อัลกอริทึม consensus อย่าง Raft จัดการเรื่องการเลือก leader ใหม่อย่างปลอดภัยเมื่อ leader ปัจจุบันล้มเหลว (หรือเชื่อว่าล้มเหลวเพราะปัญหาเครือข่าย) และป้องกัน split brain ได้

การตรวจความไม่ซ้ำกันขยายขนาดได้ด้วยการ shard ตามค่าที่ต้องไม่ซ้ำ ตัวอย่างเช่น ถ้าต้องรับประกันว่า request ID ไม่ซ้ำ ดังใน ตัวอย่างที่ 13-2 , คุณก็ทำให้ request ทั้งหมดที่มี request ID เดียวกันถูก route ไปยัง shard เดียวกันได้ ถ้าต้องการให้ username ไม่ซ้ำ ก็ shard ด้วย hash ของ username

อย่างไรก็ตาม asynchronous multi-leader replication ใช้ไม่ได้ เพราะ leader ต่างตัวอาจรับ write ที่ขัดแย้งกันพร้อมกัน ทำให้ค่าไม่ไม่ซ้ำอีกต่อไป ถ้าต้องการปฏิเสธ write ที่ละเมิด constraint ทันที ก็เลี่ยงการประสานงานแบบ synchronous ไม่ได้ [ 45 ]

Uniqueness in log-based messaging (ความไม่ซ้ำกันใน messaging ที่ใช้ log)

shared log ทำให้ consumer ทั้งหมดเห็น message ในลำดับเดียวกัน (คือการรับประกันแบบ total order broadcast ซึ่งตามที่เรานิยามไว้ใน "The Many Faces of Consensus" , เทียบเท่ากับ consensus) ในแนวทาง unbundled database ที่ใช้ log-based messaging เราใช้แนวทางที่คล้ายกันมากเพื่อบังคับ uniqueness constraint ได้

Stream processor consume message ทั้งหมดใน log shard ตามลำดับบน thread เดียว ดังนั้นถ้า shard log ตามค่าที่ต้องไม่ซ้ำ stream processor ก็ตัดสินได้อย่างชัดเจนและ deterministic ว่า operation ที่ขัดแย้งกันตัวไหนมาก่อนใน log ตัวอย่างเช่น กรณีที่ผู้ใช้หลายคนพยายามขอ username เดียวกัน [ 46 ]:

  1. ทุก request ขอ username จะถูกเข้ารหัสเป็น message และ append ลง shard ที่กำหนดด้วย hash ของ username

  2. Stream processor อ่าน request ใน log ตามลำดับ โดยใช้ local database ติดตามว่า username ไหนถูกใช้ไปแล้ว สำหรับทุก request ที่ขอ username ซึ่งยังว่าง มันจะบันทึกว่า username นั้นถูกใช้แล้ว และ emit message แจ้งว่าสำเร็จไปยัง output stream ส่วน request ที่ขอ username ซึ่งถูกใช้ไปแล้ว มันจะ emit message ปฏิเสธไปยัง output stream

  3. Client ที่ขอ username จะดู output stream และรอ message สำเร็จหรือปฏิเสธที่ตรงกับ request ของตัวเอง

อัลกอริทึมนี้เหมือนกับการสร้าง consensus ด้วย shared log ที่เราเห็นใน บทที่ 10 . มันขยายรองรับ request ปริมาณมากได้ง่ายด้วยการเพิ่มจำนวน shard เพราะแต่ละ shard ประมวลผลได้อย่างอิสระ

แนวทางนี้ใช้ได้ไม่เฉพาะกับ uniqueness constraint แต่ยังรวมถึง constraint ประเภทอื่นๆ อีกมาก หลักการพื้นฐานคือ write ที่อาจขัดแย้งกันจะถูก route ไปยัง shard เดียวกันและประมวลผลตามลำดับ นิยามของความขัดแย้งอาจขึ้นกับแอปพลิเคชัน แต่ stream processor ใช้ logic อะไรก็ได้ในการตรวจสอบความถูกต้องของ request

Multishard request processing (การประมวลผล request ข้ามหลาย shard)

การรับประกันว่า operation ทำงานแบบ atomic พร้อมกับเป็นไปตาม constraint จะน่าสนใจขึ้นเมื่อมีหลาย shard เกี่ยวข้อง ใน ตัวอย่างที่ 13-2 , อาจมีสาม shard คือ shard ที่เก็บ request ID, shard ที่เก็บบัญชีผู้รับ และ shard ที่เก็บบัญชีผู้จ่าย ไม่มีเหตุผลที่สามส่วนนี้ต้องอยู่ใน shard เดียวกัน เพราะเป็นอิสระต่อกัน

ในแนวทางดั้งเดิมของ database การรัน transaction นี้ต้องใช้ atomic commit ข้ามทั้งสาม shard ซึ่งโดยเนื้อแท้บังคับให้มันอยู่ใน total order เมื่อเทียบกับ transaction อื่นทั้งหมดบน shard เหล่านั้น และเมื่อมีการประสานงานข้าม shard แล้ว shard ต่างๆ ก็ประมวลผลอย่างอิสระไม่ได้อีก throughput จึงมักลดลง

อย่างไรก็ตาม เราได้ความถูกต้องในระดับเดียวกันโดยไม่ต้องมี cross-shard transaction ได้ ด้วย sharded log และ stream processor รูปที่ 13-2 แสดงตัวอย่าง payment transaction ที่ต้องตรวจว่าบัญชีต้นทางมีเงินเพียงพอ และถ้าพอ ก็โอนเงินไปยังบัญชีปลายทางแบบ atomic พร้อมกับหักค่าธรรมเนียม

Diagram illustrating the process of transferring money from a source account to a destination and fees account using event logs and stream processors.

Figure 13-2. การตรวจว่าบัญชีต้นทางมีเงินเพียงพอ และการโอนเงินแบบ atomic ไปยังบัญชีปลายทางและบัญชีค่าธรรมเนียม โดยใช้ event log และ stream processor

กระบวนการทำงานดังนี้ [ 47 ]:

  1. Request ขอโอนเงินจากบัญชีต้นทางไปยังบัญชีปลายทางจะได้ unique request ID จาก client ของผู้ใช้ และถูก append ลง log shard ตาม ID ของบัญชีต้นทาง

  2. Stream processor อ่าน log ของ request และดูแล database ที่เก็บ state ของบัญชีต้นทางกับ ID ของ request ที่ประมวลผลไปแล้ว เนื้อหาของ database นี้ derive มาจาก log ทั้งหมด เมื่อ stream processor เจอ request ที่มี ID ซึ่งไม่เคยเห็นมาก่อน ก็ตรวจใน local database ว่าบัญชีต้นทางมีเงินพอสำหรับการโอนหรือไม่

    ถ้าพอ มันจะอัปเดต local database เพื่อกันเงินจำนวนที่จะจ่ายไว้ในบัญชีต้นทาง และ emit event ไปยัง log อื่นอีกหลายตัว ได้แก่ outgoing payment event ไปยัง log shard ของบัญชีต้นทาง (input log ของมันเอง), incoming payment event ไปยัง log shard ของบัญชีปลายทาง และ incoming payment event ไปยัง log shard ของบัญชีค่าธรรมเนียม โดย request ID เดิมจะรวมอยู่ใน event ที่ emit ทั้งหมด

  3. สุดท้าย outgoing payment event จะถูกส่งกลับมายัง source account processor (ซึ่งระหว่างนั้นอาจได้รับ event อื่นที่ไม่เกี่ยวข้องด้วย) stream processor จะจำได้จาก request ID ว่านี่คือ payment ที่มันกันเงินไว้ก่อนหน้า และดำเนินการ payment ด้วยการอัปเดต local state ของบัญชีต้นทาง โดยจะข้าม duplicate ตาม request ID

  4. Log shard ของบัญชีปลายทางและค่าธรรมเนียมถูก consume โดย stream processing task ที่เป็นอิสระต่อกัน เมื่อได้รับ incoming payment event ก็อัปเดต local state ให้ตรงกับ payment และ deduplicate event ตาม request ID

รูปที่ 13-2 แสดงสามบัญชีใน shard แยกกันสามตัว แต่จะอยู่ใน shard เดียวกันก็ได้ ไม่สำคัญ ข้อกำหนดเดียวคือ event ของบัญชีใดๆ ต้องถูกประมวลผลตามลำดับใน log อย่างเคร่งครัดด้วย at-least-once semantics และ stream processor ต้องเป็น deterministic

ตัวอย่างเช่น ลองดูว่าจะเกิดอะไรขึ้นถ้า source account processor crash ขณะประมวลผล payment request message output อาจถูก emit หรือยังไม่ถูก emit ก่อนที่จะ crash ก็ได้ หลังกู้คืนจาก crash processor จะประมวลผล request เดิมอีกครั้ง (เพราะ at-least-once semantics) และตัดสินใจเหมือนเดิมว่าจะอนุญาต payment หรือไม่ (เพราะเป็น deterministic) แล้ว emit output message เดิมด้วย request ID เดิมไปยัง shard ของ outgoing, incoming และบัญชีค่าธรรมเนียม ถ้า message ซ้ำ consumer ปลายทางก็จะข้ามไปตาม request ID

Atomicity ในระบบนี้ไม่ได้มาจาก transaction แต่มาจากข้อเท็จจริงที่ว่าการเขียน request event เริ่มต้นลง source account log เป็น atomic action เมื่อ event นั้นอยู่ใน log แล้ว event ปลายทางทั้งหมดจะถูกเขียนในที่สุด อาจหลังจากที่ stream processor กู้คืนจาก crash และอาจมี duplicate แต่ท้ายที่สุดมันจะปรากฏ

ถ้ามี exactly-once semantics ตัวอย่างนี้จะ implement ง่ายขึ้น เพราะ semantics นี้รับประกันว่า local state ของ stream processor สอดคล้องกับชุด message ที่มันประมวลผล ดังนั้นถ้า crash แล้วประมวลผล message บางส่วนซ้ำ local state ก็จะถูกรีเซ็ตกลับเป็นสภาพก่อนประมวลผล message เหล่านั้น

ถ้าผู้ใช้ใน รูปที่ 13-2 ต้องการรู้ว่าการโอนของตัวเองได้รับอนุมัติหรือไม่ ก็ subscribe source account log shard แล้วรอ outgoing payment event ได้ และเพื่อแจ้งให้ผู้ใช้ทราบอย่างชัดเจนว่ายอดเงินไม่พอ stream processor จะ emit event "payment declined" ไปยัง log shard นั้นก็ได้

ด้วยการแบ่ง multishard transaction ออกเป็นหลายขั้นตอนที่ shard ต่างกัน และใช้ end-to-end request ID เราได้คุณสมบัติความถูกต้องแบบเดียวกัน (ทุก request ถูกนำไปใช้ exactly once กับทั้งบัญชีผู้จ่ายและผู้รับ) แม้เกิดความผิดพลาด โดยไม่ต้องใช้ atomic commit protocol

Timeliness and Integrity (timeliness กับ integrity)

ระบบ transactional หลายระบบมีคุณสมบัติที่สะดวกอย่างหนึ่ง คือทันทีที่ transaction commit แล้ว write ของมันจะปรากฏให้ transaction อื่นเห็นทันที คุณสมบัตินี้ถูกนิยามอย่างเป็นทางการว่า strict serializability (พูดถึงใน "Linearizability กับ Serializability" )

แต่กรณีนี้ไม่เป็นจริงเมื่อ unbundle operation ออกเป็นหลายขั้นตอนของ stream processor Consumer ของ log เป็น asynchronous โดยการออกแบบ producer จึงไม่ต้องรอจน consumer ประมวลผล message ของมันเสร็จ อย่างไรก็ตาม client สามารถรอให้ message ปรากฏบน output stream ได้ เช่น ผู้ใช้ที่รอ outgoing payment หรือ payment declined event ใน รูปที่ 13-2 ซึ่งขึ้นอยู่กับว่าบัญชีต้นทางมีเงินเพียงพอหรือไม่

ในตัวอย่างนี้ ความถูกต้องของการตรวจยอดเงินในบัญชีต้นทางไม่ได้ขึ้นกับว่าผู้ใช้ที่ส่ง request รอผลหรือไม่ การรอมีจุดประสงค์เพียงเพื่อแจ้งผู้ใช้แบบ synchronous ว่า payment สำเร็จหรือไม่ การแจ้งเตือนนี้ถูกแยกออกจากผลของการประมวลผล request

โดยทั่วไป คำว่า consistency รวมข้อกำหนดสองอย่างที่ควรพิจารณาแยกกัน

Timeliness (ความทันเวลา)

Timeliness หมายถึงการทำให้แน่ใจว่าผู้ใช้เห็นระบบในสถานะที่เป็นปัจจุบัน เราเห็นมาก่อนแล้วว่าถ้าผู้ใช้อ่านจากสำเนาข้อมูลที่ล้าสมัย พวกเขาอาจเห็นระบบอยู่ในสถานะที่ไม่สอดคล้องกัน (ดู "ปัญหากับ Replication Lag" ) อย่างไรก็ตาม ความไม่สอดคล้องนั้นเป็นเพียงชั่วคราว และสุดท้ายจะหายไปเองเมื่อรอแล้วลองใหม่

CAP theorem ใช้คำว่า "consistency" ในความหมายของ linearizability ซึ่งเป็นวิธีที่เข้มงวดในการให้ timeliness ส่วนคุณสมบัติ timeliness ที่อ่อนกว่า เช่น read-after-write consistency ก็มีประโยชน์เช่นกัน

Integrity (ความสมบูรณ์ของข้อมูล)

Integrity หมายถึงการไม่มีข้อมูลเสียหาย คือไม่มีข้อมูลสูญหาย และไม่มีข้อมูลที่ขัดแย้งกันหรือเป็นเท็จ โดยเฉพาะอย่างยิ่ง ถ้า dataset ที่ derive ถูกดูแลเป็น view ของข้อมูลต้นทาง การ derive ก็ต้องถูกต้อง ตัวอย่างเช่น database index ต้องสะท้อนเนื้อหาของ database อย่างถูกต้อง เพราะ index ที่มี record หายไปไม่ค่อยมีประโยชน์

ถ้า integrity ถูกละเมิด ความไม่สอดคล้องนั้นจะคงอยู่ถาวร การรอแล้วลองใหม่ไม่ช่วยแก้ database ที่เสียหายในกรณีส่วนใหญ่ แต่ต้องตรวจสอบและซ่อมแซมอย่างชัดเจน ในบริบทของ ACID transaction คำว่า "consistency" มักถูกเข้าใจว่าเป็นแนวคิด integrity ที่เฉพาะกับแอปพลิเคชัน Atomicity และ durability เป็นเครื่องมือสำคัญในการรักษา integrity

พูดให้เป็นสโลแกน การละเมิด timeliness ยอมรับได้ภายใต้ eventual consistency ส่วนการละเมิด integrity ทำให้เกิดความไม่สอดคล้องถาวร (perpetual inconsistency)

ในแอปพลิเคชันส่วนใหญ่ integrity สำคัญกว่า timeliness มาก การละเมิด timeliness อาจน่ารำคาญและสร้างความสับสน แต่การละเมิด integrity อาจเป็นหายนะ

ตัวอย่างเช่น ในใบแจ้งยอดบัตรเครดิตของคุณ ไม่น่าแปลกใจถ้า transaction ที่ทำภายใน 24 ชั่วโมงที่ผ่านมายังไม่ปรากฏ เป็นเรื่องปกติที่ระบบเหล่านี้จะมีความล่าช้าอยู่บ้าง เรารู้ว่าธนาคาร reconcile และ settle transaction แบบ asynchronous และ timeliness ไม่ได้สำคัญมากตรงนี้ [ 3 ] แต่จะแย่มากถ้ายอดคงเหลือในใบแจ้งยอดไม่เท่ากับผลรวมของ transaction บวกยอดคงเหลือในใบแจ้งยอดก่อนหน้า (ข้อผิดพลาดในการรวมยอด) หรือถ้า transaction ถูกเรียกเก็บเงินจากคุณแต่ไม่ได้จ่ายให้ร้านค้า (เงินหายไป) ปัญหาแบบนี้เป็นการละเมิด integrity ของระบบ

Correctness of dataflow systems (ความถูกต้องของระบบ dataflow)

โดยทั่วไป ACID transaction ให้ทั้ง timeliness (เช่น linearizability) และ integrity (เช่น atomic commit) ดังนั้นถ้าคุณมองความถูกต้องของแอปพลิเคชันจากมุมของ ACID transaction ความแตกต่างระหว่าง timeliness กับ integrity ก็แทบไม่สำคัญ

ในทางกลับกัน คุณสมบัติที่น่าสนใจอย่างหนึ่งของระบบ dataflow แบบ event-based ที่เราพูดถึงในบทนี้คือมันแยก timeliness กับ integrity ออกจากกัน เมื่อประมวลผล event stream แบบ asynchronous จะไม่มีการรับประกัน timeliness เว้นแต่คุณจะสร้าง consumer ที่รอ message มาถึงก่อนจึงจะ return ตัวอย่างเช่น ผู้ใช้อาจขอ payment แล้วอ่านสถานะบัญชีก่อนที่ stream processor จะประมวลผล request นั้น และจะไม่เห็น payment ที่เพิ่งขอไป

แต่ integrity เป็นหัวใจของระบบ streaming ดังที่เราเห็นแล้ว exactly-once หรือ effectively-once semantics เป็นกลไกรักษา integrity ถ้า event สูญหายหรือมีผลสองครั้ง integrity ของระบบข้อมูลอาจถูกละเมิด การส่ง message แบบ fault-tolerant และการกำจัด duplicate (เช่น idempotent operation) จึงสำคัญต่อการรักษา integrity ของระบบข้อมูลเมื่อเกิดความผิดพลาด

ดังที่เห็นในส่วนก่อนหน้า ระบบ stream processing ที่เชื่อถือได้สามารถรักษา integrity ได้โดยไม่ต้องใช้ distributed transaction และ atomic commit protocol ซึ่งหมายความว่ามันอาจได้ความถูกต้องในระดับเทียบเคียงกัน ด้วยประสิทธิภาพและความทนทานในการใช้งานจริงที่ดีกว่ามาก เราได้ integrity นี้จากกลไกหลายอย่างรวมกัน:

  • การแทนเนื้อหาของ write operation เป็น message เดียว ซึ่งเขียนแบบ atomic ได้ง่าย ซึ่งเป็นแนวทางที่เข้ากันได้ดีมากกับ event sourcing

  • การ derive การอัปเดต state อื่นๆ ทั้งหมดจาก message เดียวนั้นด้วย deterministic derivation function ซึ่งคล้ายกับ stored procedure

  • การส่ง request ID ที่ client สร้างขึ้นผ่านทุกระดับของการประมวลผล ทำให้กำจัด duplicate และทำ idempotence แบบ end-to-end ได้

  • การทำให้ message เป็น immutable และให้ประมวลผลข้อมูลที่ derive ซ้ำได้เป็นครั้งคราว ซึ่งทำให้กู้คืนจาก bug ได้ง่าย

Loosely interpreted constraints (constraint ที่ตีความแบบหลวมๆ)

ดังที่กล่าวไปก่อนหน้า การบังคับ uniqueness constraint ต้องใช้ consensus ซึ่งโดยทั่วไป implement ด้วยการส่ง event ทั้งหมดของ shard หนึ่งผ่าน node เดียว ข้อจำกัดนี้เลี่ยงไม่ได้ถ้าเราต้องการ uniqueness constraint ในรูปแบบดั้งเดิม และ stream processing ก็เลี่ยงไม่ได้เช่นกัน

อย่างไรก็ตาม แอปพลิเคชันจริงจำนวนมากมีความต้องการทางธุรกิจที่ยอมให้ละเมิดสิ่งที่คุณอาจคิดว่าเป็น hard constraint ได้:

  • ถ้าลูกค้าสั่งสินค้ามากกว่าที่คุณมีในคลัง คุณก็สั่งสต็อกเพิ่ม ขออภัยในความล่าช้า และเสนอส่วนลดให้ ซึ่งก็เหมือนสิ่งที่ต้องทำถ้ารถ forklift วิ่งทับสินค้าบางส่วนในคลังจนสต็อกจริงน้อยกว่าที่คุณคิด [ 3 ] ดังนั้นการขอโทษและชดเชยจึงต้องเป็นส่วนหนึ่งของกระบวนการทางธุรกิจของคุณอยู่แล้วเพื่อรับมือกับเหตุการณ์แบบนี้ และ hard constraint ต่อจำนวนสินค้าในสต็อกอาจไม่จำเป็น

  • ในทำนองเดียวกัน สายการบินหลายแห่งขายตั๋วเกินจำนวนที่นั่ง (overbook) โดยคาดว่าผู้โดยสารบางคนจะพลาดเที่ยวบิน และโรงแรมหลายแห่งก็รับจองห้องเกิน โดยคาดว่าแขกบางคนจะยกเลิก ในกรณีเหล่านี้ constraint "หนึ่งคนต่อหนึ่งที่นั่ง" ถูกละเมิดโดยเจตนาด้วยเหตุผลทางธุรกิจ และมีกระบวนการชดเชย (การคืนเงิน, การอัปเกรด, การจัดห้องพักฟรีที่โรงแรมใกล้เคียง) ไว้รับมือกับกรณีที่ความต้องการเกินจำนวนที่มี แม้ไม่มีการ overbook ก็ยังต้องมีกระบวนการขอโทษและชดเชยสำหรับเหตุการณ์อย่างเที่ยวบินที่ถูกยกเลิกเพราะสภาพอากาศเลวร้ายหรือพนักงานนัดหยุดงาน —การกู้คืนจากปัญหาเหล่านี้เป็นเพียงส่วนหนึ่งของการทำธุรกิจตามปกติ [ 3 ]

  • ถ้ามีคนถอนเงินมากกว่าที่มีในบัญชี ธนาคารเรียกเก็บค่าธรรมเนียม overdraft และให้เขาชำระยอดที่ค้างได้ และด้วยการจำกัดยอดถอนรวมต่อวัน ความเสี่ยงของธนาคารก็ถูกจำกัดไว้

  • ในระบบที่ผสานข้อมูลข้ามองค์กร ความไม่สอดคล้องเลี่ยงไม่ได้ และต้องมีกลไกชดเชยไว้จัดการ ดังที่ระบุไว้ใน "กรณีการใช้งาน Batch" , การ settle การชำระเงินระหว่างธนาคารเป็นตัวอย่างหนึ่งของเรื่องนี้

ในบริบททางธุรกิจหลายแห่ง จึงยอมรับได้ที่จะละเมิด constraint ชั่วคราวแล้วแก้ไขทีหลังด้วยการขอโทษ การเปลี่ยนแปลงเพื่อแก้ความผิดพลาดนี้เรียกว่า compensating transaction [ 48 , 49 ] ต้นทุนของการขอโทษ (ทั้งเงินและชื่อเสียง) ต่างกันไป แต่มักค่อนข้างต่ำ คุณยกเลิกอีเมลที่ส่งไปแล้วไม่ได้ แต่ส่งอีเมลติดตามพร้อมการแก้ไขได้ ถ้าคุณเรียกเก็บเงินบัตรเครดิตซ้ำสองครั้งโดยไม่ตั้งใจ ก็คืนเงินรายการหนึ่งได้ และต้นทุนของคุณก็เป็นแค่ค่าธรรมเนียมการประมวลผล และอาจมีลูกค้าร้องเรียน ส่วนเงินที่ออกจากตู้ ATM ไปแล้วคุณเอากลับมาตรงๆ ไม่ได้ แต่ในหลักการ คุณส่งเจ้าหนี้ไปทวงเงินคืนได้ ถ้าบัญชี overdraft แล้วลูกค้าไม่ยอมคืน

ต้นทุนของการขอโทษยอมรับได้หรือไม่เป็นการตัดสินใจทางธุรกิจ ถ้ายอมรับได้ โมเดลดั้งเดิมที่ตรวจ constraint ทั้งหมดก่อนเขียนข้อมูลก็เข้มงวดเกินจำเป็น และอาจสมเหตุสมผลกว่าที่จะเขียนไปก่อนอย่างมองโลกในแง่ดี แล้วตรวจ constraint ทีหลัง คุณยังรับประกันได้ว่าการตรวจเกิดขึ้นก่อน operation ที่อาจกู้คืนได้ยากหรือมีต้นทุนสูง แต่ไม่ได้หมายความว่าต้องตรวจก่อนเขียนข้อมูล

แอปพลิเคชันเหล่านี้ ต้องการ integrity คุณไม่อยากให้การจองหายหรือเงินหายไปเพราะเครดิตกับเดบิตไม่ตรงกัน แต่พวกมัน ไม่ต้องการ timeliness ในการบังคับ constraint ถ้าคุณขายสินค้าเกินจำนวนที่มีในคลัง ก็แก้ปัญหาทีหลังได้ ซึ่งคล้ายกับแนวทาง conflict resolution ที่เราพูดถึงใน "การจัดการกับ Writes ที่ขัดแย้งกัน" .

Coordination-avoiding data systems (ระบบข้อมูลที่เลี่ยงการทำ coordination)

ตอนนี้เราสังเกตเห็นสองอย่างที่น่าสนใจ:

  • ระบบ dataflow รักษาการรับประกัน integrity บนข้อมูลที่ derive ได้โดยไม่ต้องใช้ atomic commit, linearizability หรือ synchronous cross-shard coordination

  • แม้ strict uniqueness constraint ต้องใช้ timeliness และ coordination แต่แอปพลิเคชันจำนวนมากก็ยอมรับ loose constraint ที่อาจถูกละเมิดชั่วคราวแล้วแก้ทีหลังได้ ตราบใดที่ยังรักษา integrity ไว้ตลอด

เมื่อรวมสองข้อนี้เข้าด้วยกัน หมายความว่าระบบ dataflow สามารถจัดการข้อมูลให้แอปพลิเคชันจำนวนมากได้โดยไม่ต้องทำ coordination และยังให้การรับประกัน integrity ที่เข้มแข็ง ระบบข้อมูลแบบ หลีกเลี่ยงการประสานงาน นี้น่าสนใจมาก เพราะให้ประสิทธิภาพและ fault tolerance ที่ดีกว่าระบบที่ต้องทำ synchronous coordination [ 45 ]

ตัวอย่างเช่น ระบบแบบนี้ทำงานแบบกระจายข้ามหลาย datacenter ใน multi-leader configuration ได้ โดย replicate แบบ asynchronous ระหว่าง region แต่ละ datacenter ทำงานได้อย่างอิสระจากที่อื่น เพราะไม่ต้องทำ synchronous cross-region coordination ระบบแบบนี้จะมีการรับประกัน timeliness ที่อ่อน คือเป็น linearizable ไม่ได้ถ้าไม่ทำ coordination แต่ยังมีการรับประกัน integrity ที่เข้มแข็ง

ในบริบทนี้ serializable transaction ยังมีประโยชน์ในการดูแล state ที่ derive แต่รันได้ในขอบเขตเล็กๆ ที่มันทำงานได้ดี [ 6 ] ไม่จำเป็นต้องใช้ heterogeneous distributed transaction อย่าง XA ยังใช้ synchronous coordination ได้ในจุดที่จำเป็น (เช่น เพื่อบังคับ constraint ที่เข้มงวดก่อน operation ที่กู้คืนไม่ได้) แต่ไม่จำเป็นที่ทุกอย่างต้องแบกต้นทุนของ coordination ถ้ามีแอปพลิเคชันส่วนเล็กๆ เท่านั้นที่ต้องการมัน [ 32 ]

อีกมุมหนึ่งในการมอง coordination และ constraint คือ มันลดจำนวนครั้งที่คุณต้องขอโทษเรื่องความไม่สอดคล้อง แต่ก็อาจลดประสิทธิภาพและ availability ของระบบ จึงอาจเพิ่มจำนวนครั้งที่ต้องขอโทษเรื่องระบบล่ม คุณลดจำนวนการขอโทษให้เป็นศูนย์ไม่ได้ แต่หา trade-off ที่ดีที่สุดสำหรับความต้องการของคุณได้ คือจุดที่ไม่มีทั้งความไม่สอดคล้องมากเกินไปและปัญหา availability มากเกินไป

Trust, but Verify (เชื่อใจ แต่ต้องตรวจสอบ)

การพูดถึงความถูกต้อง, integrity และ fault tolerance ทั้งหมดของเราตั้งอยู่บนสมมติฐานว่าบางอย่างอาจผิดพลาดได้ แต่บางอย่างจะไม่ผิดพลาด เราเรียกสมมติฐานแบบนี้ว่า system model (ดู "System Model และ Reality" ) ตัวอย่างเช่น เราควรสมมติว่า process crash ได้ เครื่องไฟดับกะทันหันได้ และเครือข่ายอาจหน่วงหรือทิ้ง message ได้ตามอำเภอใจ ส่วนที่เราอาจสมมติว่าจะไม่เกิดคือ ข้อมูลที่เขียนลงดิสก์ไม่สูญหายหลัง fsync , ข้อมูลในหน่วยความจำไม่เสียหาย และคำสั่งคูณของ CPU คืนผลลัพธ์ที่ถูกต้องเสมอ

สมมติฐานเหล่านี้ค่อนข้างสมเหตุสมผล เพราะเป็นจริงเกือบตลอดเวลา และจะทำอะไรสำเร็จได้ยากถ้าต้องกังวลตลอดว่าคอมพิวเตอร์จะทำผิดพลาด โดยทั่วไป system model มองความผิดพลาดแบบสองขั้ว คือสมมติว่าบางอย่างเกิดขึ้นได้ ส่วนบางอย่างเกิดขึ้นไม่ได้เลย แต่ในความเป็นจริงมันเป็นเรื่องของความน่าจะเป็น บางอย่างมีโอกาสมากกว่า บางอย่างน้อยกว่า คำถามคือสมมติฐานของเราถูกละเมิดบ่อยพอที่เราอาจเจอในทางปฏิบัติหรือไม่

เราเห็นมาแล้วว่าข้อมูลเสียหายได้ในหน่วยความจำ (ดู "ความผิดพลาดของฮาร์ดแวร์และซอฟต์แวร์" ), บนดิสก์ (ดู "การจำลองและความทนทาน" ), และบนเครือข่าย (ดู "รูปแบบที่อ่อนแอของการโกหก" ) บางทีเรื่องนี้ควรได้รับความสนใจมากขึ้น? ถ้าระบบของคุณใหญ่พอ แม้แต่เหตุการณ์ที่แทบเป็นไปไม่ได้ก็เกิดขึ้นได้

Maintaining integrity in the face of software bugs (การรักษา integrity ท่ามกลาง software bug)

นอกจากปัญหาฮาร์ดแวร์เหล่านี้ ยังมีความเสี่ยงจาก software bug เสมอ ซึ่ง checksum ระดับต่ำของเครือข่าย หน่วยความจำ หรือ filesystem จะตรวจไม่เจอ แม้แต่ซอฟต์แวร์ database ที่ใช้กันแพร่หลายก็มี bug เช่น MySQL บางเวอร์ชันในอดีตรักษา uniqueness constraint ไม่ถูกต้อง [ 50 ] และ serializable isolation level ของ PostgreSQL ก็เคยมี write skew anomaly [ 51 ] MySQL และ PostgreSQL เป็น database ที่แข็งแกร่งและเป็นที่ยอมรับ ผ่านการใช้งานจริงโดยคนจำนวนมากมาหลายปี ส่วนซอฟต์แวร์ที่ยังไม่บรรลุนิติภาวะเท่านี้ สถานการณ์มีแนวโน้มแย่กว่ามาก

แม้จะพยายามออกแบบ ทดสอบ และตรวจสอบอย่างหนัก bug ก็ยังหลุดเข้ามา และแม้จะหายากและสุดท้ายถูกพบและแก้ไข ก็ยังมีช่วงเวลาหนึ่งที่ bug แบบนี้อาจทำให้ข้อมูลเสียหายได้

พอมาถึง application code เราต้องสมมติว่ามี bug มากขึ้น เพราะแอปพลิเคชันส่วนใหญ่ไม่ได้รับการตรวจสอบและทดสอบละเอียดเท่าโค้ดของ database แอปพลิเคชันจำนวนมากไม่ได้ใช้ฟีเจอร์ด้าน integrity ที่ database มีให้อย่างถูกต้องด้วยซ้ำ เช่น foreign-key หรือ uniqueness constraint [ 25 ]

Consistency ในความหมายของ ACID ขึ้นอยู่กับแนวคิดที่ว่า database เริ่มต้นในสถานะที่สอดคล้องกัน และ transaction เปลี่ยนมันจากสถานะที่สอดคล้องหนึ่งไปอีกสถานะที่สอดคล้องหนึ่ง เราจึงคาดหวังว่า database จะอยู่ในสถานะที่สอดคล้องเสมอ แต่แนวคิดนี้สมเหตุสมผลก็ต่อเมื่อสมมติว่า transaction ไม่มี bug ถ้าแอปพลิเคชันใช้ database ผิดวิธีบางอย่าง เช่น ใช้ weak isolation level อย่างไม่ปลอดภัย ก็ไม่อาจรับประกัน integrity ของ database ได้

Don't just blindly trust what they promise (อย่าเชื่อคำสัญญาแบบหลับหูหลับตา)

เมื่อทั้งฮาร์ดแวร์และซอฟต์แวร์ไม่เป็นไปตามอุดมคติ ข้อมูลเสียหายก็ดูเหมือนจะเลี่ยงไม่ได้ไม่ช้าก็เร็ว เราจึงควรมีวิธีตรวจให้ได้อย่างน้อยว่าข้อมูลเสียหายหรือไม่ เพื่อจะได้แก้ไขและตามหาต้นตอของข้อผิดพลาด การตรวจสอบ integrity ของข้อมูลเรียกว่า auditing .

ดังที่พูดถึงใน "ข้อดีของ immutable events" , auditing ไม่ได้มีไว้เฉพาะแอปพลิเคชันทางการเงิน แต่ auditability สำคัญมากในแวดวงการเงิน เพราะทุกคนรู้ว่าความผิดพลาดเกิดขึ้นได้ และเราต่างยอมรับว่าต้องตรวจจับและแก้ไขปัญหาได้

ระบบที่เติบโตเต็มที่แล้วก็มีแนวโน้มจะคำนึงถึงความเป็นไปได้ที่จะมีอะไรผิดพลาดและจัดการกับความเสี่ยงนั้นเช่นกัน ตัวอย่างเช่น ระบบจัดเก็บข้อมูลขนาดใหญ่อย่าง HDFS และ Amazon S3 ไม่ได้เชื่อใจดิสก์เต็มร้อย ระบบเหล่านี้รัน background process ที่คอยอ่านไฟล์ซ้ำ เปรียบเทียบกับ replica อื่น และย้ายไฟล์จากดิสก์หนึ่งไปอีกดิสก์หนึ่ง เพื่อลดความเสี่ยงของ silent corruption [ 52 , 53 ]

ถ้าอยากแน่ใจว่าข้อมูลของคุณยังอยู่ ก็ต้องอ่านและตรวจสอบมัน ส่วนใหญ่ข้อมูลก็ยังอยู่ แต่ถ้าไม่อยู่ คุณอยากรู้ให้เร็วที่สุดมากกว่ารู้ช้า ด้วยเหตุผลเดียวกัน การลองกู้คืนจาก backup เป็นครั้งคราวก็สำคัญ ไม่งั้นคุณอาจพบว่า backup เสียหายตอนที่สายเกินไปและข้อมูลหายไปแล้ว อย่าเชื่อแบบหลับหูหลับตาว่าทุกอย่างจะทำงานได้

ระบบอย่าง HDFS และ S3 ก็ยังต้องสมมติว่าดิสก์ทำงานถูกต้องเป็นส่วนใหญ่ ซึ่งเป็นสมมติฐานที่สมเหตุสมผล แต่ไม่เหมือนกับการสมมติว่ามันทำงานถูกต้อง เสมอ . ปัจจุบันมีระบบไม่มากนักที่ใช้แนวทาง "เชื่อใจ แต่ตรวจสอบ" แบบนี้ โดยตรวจสอบตัวเองอย่างต่อเนื่อง หลายระบบสมมติว่าการรับประกันความถูกต้องเป็นแบบสัมบูรณ์ และไม่ได้เผื่อไว้สำหรับกรณีข้อมูลเสียหายที่เกิดขึ้นนานๆ ครั้ง ในอนาคตเราอาจได้เห็นระบบแบบ self-validating หรือ self-auditing มากขึ้น ซึ่งตรวจสอบ integrity ของตัวเองอย่างต่อเนื่อง แทนที่จะพึ่งความเชื่อใจแบบหลับหูหลับตา [ 54 ]

Designing for auditability (การออกแบบเพื่อให้ตรวจสอบย้อนหลังได้)

ถ้า transaction เปลี่ยน object หลายตัวใน database ภายหลังอาจบอกได้ยากว่าทำไม ต่อให้คุณเก็บ transaction log ไว้ การ insert, update และ delete ในตารางต่างๆ ก็ไม่จำเป็นต้องให้ภาพชัดเจนว่า ทำไม การเปลี่ยนแปลงเหล่านั้นถูกทำ เพราะการเรียกใช้ application logic ที่ตัดสินใจเรื่องการเปลี่ยนแปลงนั้นเป็นเพียงชั่วคราว และทำซ้ำไม่ได้

ในทางตรงกันข้าม ระบบที่ใช้ event ให้ auditability ได้ดีกว่า ในแนวทาง event sourcing input ของผู้ใช้ที่เข้ามายังระบบจะถูกแทนเป็น immutable event เดียว และการอัปเดต state ทั้งหมดที่ตามมา derive จาก event นั้น การ derive ทำให้เป็น deterministic และทำซ้ำได้ ดังนั้นการรัน log ของ event ชุดเดิมผ่านโค้ด derive เวอร์ชันเดิมจะให้การอัปเดต state แบบเดิม

การทำให้ dataflow ชัดเจนช่วยให้เห็นที่มาของข้อมูลชัดขึ้น ซึ่งทำให้ตรวจสอบ integrity ได้ง่ายขึ้น สำหรับ event log เราใช้ hash ตรวจว่า event storage ไม่เสียหายได้ ส่วน state ที่ derive ใดๆ เราก็รัน batch และ stream processor ที่ derive มันจาก event log ซ้ำเพื่อดูว่าได้ผลเหมือนเดิมหรือไม่ หรือแม้แต่รัน derivation ซ้ำซ้อนแบบขนานกันไปเลยก็ได้

dataflow ที่ deterministic และนิยามชัดเจนยังช่วยให้ debug และตามรอยการทำงานของระบบเพื่อหาสาเหตุว่าทำไมมันทำบางอย่างได้ง่ายขึ้นด้วย [ 4 , 55 ] ถ้ามีเหตุการณ์ที่ไม่คาดคิดเกิดขึ้น การมีความสามารถในการวินิจฉัยเพื่อจำลองสถานการณ์ที่นำไปสู่เหตุการณ์นั้นได้อย่างแม่นยำ ซึ่งก็คือ time-travel debugging จึงมีค่ามาก

The end-to-end argument again (หลักการ end-to-end อีกครั้ง)

ถ้าเราไว้ใจไม่ได้เต็มที่ว่าทุกส่วนของระบบจะไม่ทำให้ข้อมูลเสียหาย คือฮาร์ดแวร์ทุกชิ้นไม่มีข้อผิดพลาดและซอฟต์แวร์ทุกตัวไม่มี bug เราก็ต้องตรวจสอบ integrity ของข้อมูลเป็นระยะอย่างน้อย ถ้าไม่ตรวจ เราจะไม่รู้ว่าข้อมูลเสียหายจนสายเกินไป หลังจากที่มันสร้างความเสียหายต่อระบบปลายน้ำไปแล้ว ซึ่งตอนนั้นการตามหาต้นตอจะยากและแพงกว่ามาก

การตรวจสอบ integrity ของระบบข้อมูลทำได้ดีที่สุดแบบ end-to-end ยิ่งรวมส่วนต่างๆ ของระบบไว้ใน integrity check มากเท่าไร โอกาสที่ความเสียหายจะหลุดรอดโดยไม่มีใครสังเกตในบางขั้นตอนก็ยิ่งน้อยลง ถ้าตรวจสอบได้ว่า data pipeline ที่ derive ทั้งเส้นถูกต้องแบบ end-to-end แล้ว ดิสก์ เครือข่าย service และอัลกอริทึมทุกส่วนตลอดเส้นทางก็ถูกรวมอยู่ในการตรวจสอบนั้นโดยปริยาย

การมี integrity check แบบ end-to-end อย่างต่อเนื่องทำให้คุณมั่นใจในความถูกต้องของระบบมากขึ้น ซึ่งช่วยให้ทำงานได้เร็วขึ้น [ 56 ] เหมือนการทดสอบอัตโนมัติ auditing ช่วยให้เจอ bug ได้เร็วขึ้น และลดความเสี่ยงที่การเปลี่ยนแปลงระบบหรือเทคโนโลยีจัดเก็บข้อมูลใหม่จะก่อความเสียหาย ถ้าคุณไม่กลัวที่จะเปลี่ยนแปลง คุณก็พัฒนาแอปพลิเคชันให้ตอบรับความต้องการที่เปลี่ยนไปได้ดีขึ้นมาก

Tools for auditable data systems (เครื่องมือสำหรับระบบข้อมูลที่ตรวจสอบย้อนหลังได้)

ปัจจุบันมีระบบข้อมูลไม่มากที่ให้ความสำคัญกับ auditability ในระดับสูง แอปพลิเคชันบางตัว implement กลไก audit ของตัวเอง เช่น log การเปลี่ยนแปลงทั้งหมดลงตาราง audit แยกต่างหาก แต่การรับประกัน integrity ของ audit log และ state ของ database ยังคงทำได้ยาก transaction log ทำให้ป้องกันการปลอมแปลงได้โดยเซ็นเป็นระยะด้วย hardware security module แต่ก็ไม่ได้รับประกันว่า transaction ที่ถูกต้องเข้าไปอยู่ใน log ตั้งแต่แรก

Blockchain อย่าง Bitcoin และ Ethereum เป็น shared append-only log ที่ตรวจสอบความสอดคล้องด้วย cryptography transaction ที่เก็บอยู่คือ event และ smart contract ก็คือ stream processor โดยพื้นฐาน โปรโตคอล consensus ที่มันใช้รับประกันว่าทุก node เห็นตรงกันเรื่องลำดับของ event ความต่างจากโปรโตคอล consensus ใน บทที่ 10 คือ blockchain เป็น Byzantine fault-tolerant กล่าวคือยังทำงานได้แม้ node ที่เข้าร่วมบางส่วนมีข้อมูลเสียหาย เพราะ replica คอยตรวจสอบ integrity ของกันและกันอย่างต่อเนื่อง

สำหรับแอปพลิเคชันส่วนใหญ่ blockchain มี overhead สูงเกินกว่าจะใช้ประโยชน์ได้ แต่เครื่องมือ cryptographic บางอย่างของมันใช้ในบริบทที่เบากว่าได้ ตัวอย่างเช่น Merkle trees [ 57 ] คือ tree ของ hash ที่ใช้พิสูจน์อย่างมีประสิทธิภาพว่า record หนึ่งอยู่ใน dataset (และอื่นๆ อีกเล็กน้อย) Certificate transparency ใช้ append-only log ที่ตรวจสอบด้วย cryptography และ Merkle tree เพื่อตรวจสอบความถูกต้องของ TLS/SSL certificate [ 58 , 59 ] โดยเลี่ยงการต้องใช้โปรโตคอล consensus ด้วยการมี leader เดียวต่อ log

อัลกอริทึมตรวจสอบ integrity และ auditing อย่างของ certificate transparency และ distributed ledger อาจถูกใช้แพร่หลายขึ้นในระบบข้อมูลทั่วไปในอนาคต แต่ยังต้องทำงานอีกบ้างเพื่อให้ขยายขนาดได้เท่าระบบที่ไม่มี cryptographic auditing และเพื่อให้ผลเสียต่อประสิทธิภาพน้อยที่สุด แต่ก็ยังน่าสนใจ

Summary (สรุป)

ในบทนี้เราพูดถึงแนวทางใหม่ในการออกแบบระบบข้อมูลโดยใช้แนวคิดจาก stream processing เราเริ่มจากการสังเกตว่าไม่มีเครื่องมือตัวเดียวที่รองรับทุก use case ได้อย่างมีประสิทธิภาพ แอปพลิเคชันจึงต้องประกอบซอฟต์แวร์หลายตัวเข้าด้วยกันเพื่อให้ได้ตามต้องการ เราพูดถึงวิธีแก้ปัญหา data integration นี้ด้วย batch processing และ event stream เพื่อให้การเปลี่ยนแปลงข้อมูลไหลไปมาระหว่างระบบ

ในแนวทางนี้ ระบบบางตัวถูกกำหนดให้เป็น system of record และข้อมูลอื่นๆ derive จากมันผ่านการแปลง เราดูแล index, materialized view, machine learning model, สถิติสรุป และอื่นๆ ได้ด้วยวิธีนี้ การทำให้ derivation และ transformation เหล่านี้เป็น asynchronous และ loosely coupled ช่วยกันไม่ให้ปัญหาในส่วนหนึ่งลามไปยังส่วนที่ไม่เกี่ยวข้อง ทำให้ระบบโดยรวมทนทานและ fault-tolerant มากขึ้น

การแสดง dataflow เป็นการแปลงจาก dataset หนึ่งไปอีก dataset หนึ่งยังช่วยให้แอปพลิเคชันพัฒนาต่อได้ ถ้าต้องการเปลี่ยนขั้นตอนประมวลผลขั้นหนึ่ง เช่น เปลี่ยนโครงสร้างของ index หรือ cache ก็รันโค้ดแปลงตัวใหม่บน input dataset ทั้งหมดเพื่อ derive output ใหม่ได้ เช่นเดียวกัน ถ้ามีอะไรผิดพลาด คุณแก้โค้ดแล้วประมวลผลข้อมูลซ้ำเพื่อกู้คืนได้

กระบวนการเหล่านี้ค่อนข้างคล้ายกับสิ่งที่ database ทำภายในอยู่แล้ว เราจึงมองแนวคิดแอปพลิเคชัน dataflow ใหม่ว่าเป็นการ unbundling ส่วนประกอบของ database แล้วสร้างแอปพลิเคชันด้วยการประกอบส่วนประกอบที่ loosely coupled เหล่านี้

State ที่ derive อัปเดตได้โดยการสังเกตการเปลี่ยนแปลงของข้อมูลต้นทาง และ consumer ปลายทางก็สังเกต state นั้นต่อได้อีกทอดหนึ่ง เรานำ dataflow นี้ไปต่อได้จนถึงอุปกรณ์ของผู้ใช้ปลายทางที่แสดงข้อมูล และสร้าง UI ที่อัปเดตแบบไดนามิกตามการเปลี่ยนแปลงของข้อมูลและยังทำงานออฟไลน์ได้

จากนั้นเราพูดถึงวิธีทำให้แน่ใจว่าการประมวลผลทั้งหมดนี้ยังถูกต้องเมื่อเกิดความผิดพลาด เราเห็นว่าการรับประกัน integrity ที่เข้มแข็ง implement แบบ scalable ได้ด้วย asynchronous event processing โดยใช้ end-to-end request identifier เพื่อทำให้ operation เป็น idempotent และตรวจ constraint แบบ asynchronous Client จะรอจนการตรวจผ่านก่อน หรือจะทำงานต่อโดยไม่รอก็ได้ แต่เสี่ยงที่จะต้องขอโทษหากละเมิด constraint แนวทางนี้ scalable และทนทานกว่าแนวทางดั้งเดิมที่ใช้ distributed transaction มาก และเข้ากับวิธีทำงานของกระบวนการทางธุรกิจจำนวนมากในทางปฏิบัติ

ด้วยการจัดโครงสร้างแอปพลิเคชันรอบ dataflow และตรวจ constraint แบบ asynchronous เราหลีกเลี่ยง coordination ส่วนใหญ่ได้ และสร้างระบบที่รักษา integrity แต่ยังทำงานได้ดีแม้ในสถานการณ์ที่กระจายตามภูมิศาสตร์และเมื่อเกิดความผิดพลาด สุดท้ายเราพูดถึงการใช้ audit เล็กน้อย เพื่อตรวจสอบ integrity ของข้อมูลและตรวจจับข้อมูลเสียหายเล็กน้อย และสังเกตว่าเทคนิคที่ blockchain ใช้ก็คล้ายกับระบบที่ใช้ event