ผู้เขียนบางคนอ้างว่า two-phase commit แบบทั่วไปมีต้นทุนสูงเกินไป เพราะก่อให้เกิดปัญหาด้านประสิทธิภาพและ availability แต่เราเชื่อว่าควรให้โปรแกรมเมอร์แอปพลิเคชันไปแก้ปัญหาประสิทธิภาพที่เกิดจากการใช้ transaction มากเกินไปเมื่อเจอคอขวดจริง ดีกว่าต้องคอยเขียนโค้ดเลี่ยงปัญหาเพราะไม่มี transaction ให้ใช้
James Corbett et al., "Spanner: Google's Globally-Distributed Database" (2012)
ในโลกจริงอันโหดร้ายของระบบข้อมูล มีหลายอย่างที่อาจผิดพลาดได้:
-
ซอฟต์แวร์หรือฮาร์ดแวร์ของฐานข้อมูลอาจล้มเหลวได้ทุกเมื่อ (รวมถึงกลางคันตอนกำลังเขียนข้อมูล)
-
แอปพลิเคชันอาจ crash ได้ทุกเมื่อ (รวมถึงตอนทำงานไปได้แค่ครึ่งหนึ่งของชุดการดำเนินการ)
-
เครือข่ายอาจมีปัญหาจนตัดการเชื่อมต่อระหว่างแอปพลิเคชันกับฐานข้อมูล หรือระหว่างโหนดของฐานข้อมูลด้วยกันเองโดยไม่ทันตั้งตัว
-
ไคลเอ็นต์หลายตัวอาจเขียนลงฐานข้อมูลพร้อมกันจนเขียนทับการเปลี่ยนแปลงของกันและกัน
-
ไคลเอ็นต์อาจอ่านเจอข้อมูลที่ไม่สมเหตุสมผล เพราะถูกอัปเดตไปแค่บางส่วน
-
Race condition ระหว่างไคลเอ็นต์อาจทำให้เกิดบั๊กที่คาดไม่ถึง
ระบบที่เชื่อถือได้ต้องรับมือกับข้อผิดพลาดทุกแบบเหล่านี้ และต้องแน่ใจว่าไม่มีข้อผิดพลาดใดลุกลามเป็นความล้มเหลวร้ายแรง แต่การทำ fault tolerance (ความทนต่อความผิดพลาด) เป็นงานที่หนักมาก ต้องคิดอย่างรอบคอบว่าอะไรผิดพลาดได้บ้าง และต้องทดสอบอย่างเข้มงวดเพื่อให้แน่ใจว่าวิธีที่ใช้ได้ผลจริง
มาหลายทศวรรษแล้วที่ transaction เป็นกลไกหลักที่ใช้ทำให้ปัญหาเหล่านี้ง่ายลง Transaction คือวิธีที่ให้แอปพลิเคชันรวมการอ่านและเขียนหลายครั้งเป็นหน่วยเชิงตรรกะหน่วยเดียว ในเชิงแนวคิด การอ่านและเขียนทั้งหมดใน transaction จะถูกทำเป็นการดำเนินการเดียว คือ transaction ทั้งก้อนจะสำเร็จ ซึ่งเรียกว่า commit หรือไม่ก็ล้มเหลว ซึ่งเรียกว่า abort หรือ rollback ถ้าล้มเหลว แอปพลิเคชันก็ retry ได้อย่างปลอดภัย transaction ทำให้การจัดการข้อผิดพลาดง่ายขึ้นมาก เพราะแอปพลิเคชันไม่ต้องห่วงเรื่องความล้มเหลวบางส่วน (กรณีที่การดำเนินการบางอย่างสำเร็จ แต่บางอย่างล้มเหลวด้วยเหตุผลใดก็ตาม)
ถ้าคุณใช้ transaction จนคุ้นเคย อาจรู้สึกว่ามันเป็นเรื่องธรรมดา แต่เราไม่ควรมองข้ามมัน Transaction ไม่ใช่กฎของธรรมชาติ มันถูกสร้างขึ้นมาเพื่อจุดประสงค์อย่างหนึ่ง นั่นคือเพื่อ ทำให้โมเดลการเขียนโปรแกรมง่ายขึ้น เมื่อแอปพลิเคชันเข้าถึงฐานข้อมูล transaction ช่วยให้แอปพลิเคชันไม่ต้องสนใจข้อผิดพลาดและปัญหา concurrency บางอย่างที่อาจเกิดขึ้น เพราะฐานข้อมูลจัดการเรื่องเหล่านั้นให้แทน (เราเรียกสิ่งเหล่านี้ว่า safety guarantees (การรับประกันด้านความปลอดภัย) )
ไม่ใช่ทุกแอปพลิเคชันที่ต้องใช้ transaction และบางครั้งการลดระดับการรับประกันของ transaction หรือเลิกใช้ไปเลยก็มีข้อดี (เช่น ได้ประสิทธิภาพดีขึ้นหรือ availability สูงขึ้น) คุณสมบัติด้านความปลอดภัยบางอย่างทำได้โดยไม่ต้องมี transaction แต่ในทางกลับกัน transaction ก็ช่วยป้องกันความทุกข์ใจได้มากมาย เช่น สาเหตุทางเทคนิคเบื้องหลังเรื่องอื้อฉาว Post Office Horizon (ดู "ความน่าเชื่อถือสำคัญแค่ไหน?" ) น่าจะมาจากการที่ระบบบัญชีเบื้องหลังไม่มี ACID transaction [ 1 ]
แล้วจะรู้ได้อย่างไรว่าต้องใช้ transaction หรือไม่? ก่อนจะตอบได้ เราต้องเข้าใจก่อนว่า transaction รับประกันความปลอดภัยอะไรได้บ้างอย่างแน่ชัด และมีต้นทุนอย่างไร transaction ดูตรงไปตรงมาในแวบแรก แต่จริง ๆ มีรายละเอียดปลีกย่อยสำคัญอยู่มาก
concurrency control (การควบคุมการทำงานพร้อมกัน) สำคัญทั้งกับฐานข้อมูลแบบโหนดเดียวและแบบกระจาย ในบทนี้เราจะลงลึกเรื่องนี้ โดยดู race condition ชนิดต่าง ๆ ที่เกิดขึ้นได้ และวิธีที่ฐานข้อมูล implement isolation level อย่าง read committed, snapshot isolation และ serializability นอกจากนี้เราจะดู two-phase commit protocol และความท้าทายในการทำ atomicity ให้ได้ใน distributed transaction ด้วย
What Exactly Is a Transaction? (ธุรกรรมคืออะไรกันแน่)
relational database (ฐานข้อมูลเชิงสัมพันธ์) เกือบทั้งหมดในปัจจุบัน รวมถึงฐานข้อมูลที่ไม่ใช่ relational บางตัว รองรับ transaction และส่วนใหญ่ทำตามรูปแบบที่ IBM System R ซึ่งเป็นฐานข้อมูล SQL ตัวแรกวางไว้ในปี 1975 [ 2 , 3 , 4 ] แม้รายละเอียดการ implement บางอย่างจะเปลี่ยนไป แต่แนวคิดโดยรวมแทบไม่เปลี่ยนมาเกือบ 50 ปีแล้ว: transaction ใน MySQL, PostgreSQL, Oracle, SQL Server ฯลฯ คล้ายกับของ System R อย่างน่าแปลกใจ
ช่วงปลายทศวรรษ 2000 ฐานข้อมูลที่ไม่ใช่ relational (NoSQL) เริ่มได้รับความนิยม โดยตั้งใจปรับปรุงสถานะเดิมของฐานข้อมูล relational ด้วยการเสนอ data model แบบใหม่เป็นทางเลือก (ดู บทที่ 3 ) และมี replication กับ sharding (ที่กล่าวถึงในบทที่ 6 และ 7 ) เป็นค่าเริ่มต้น Transaction เป็นเหยื่อหลักของกระแสนี้: ฐานข้อมูลยุคนั้นหลายตัวตัด transaction ทิ้งไปเลย หรือไปนิยามคำนี้ใหม่ให้หมายถึงการรับประกันที่อ่อนกว่าที่เคยเข้าใจกันมา
กระแส NoSQL และฐานข้อมูลแบบกระจายทำให้เกิดความเชื่อแพร่หลายว่า transaction scale ไม่ได้โดยธรรมชาติ และระบบขนาดใหญ่ต้องเลิกใช้มันเพื่อให้ได้ประสิทธิภาพดีและ availability สูง แต่ช่วงหลังมานี้ความเชื่อนั้นถูกพิสูจน์แล้วว่าผิด ฐานข้อมูลที่เรียกว่า "NewSQL" อย่าง CockroachDB [ 5 ], TiDB [ 6 ], Spanner [ 7 ], FoundationDB [ 8 ], และ YugabyteDB แสดงให้เห็นว่าระบบที่มี transaction สามารถ scale รองรับข้อมูลปริมาณมากและ throughput สูงได้ ระบบเหล่านี้ผสม sharding เข้ากับ consensus protocol ซึ่งเราจะพูดถึงใน บทที่ 10 เพื่อให้การรับประกัน ACID ที่แข็งแกร่งได้แม้ในระดับ scale ใหญ่
แต่นั่นไม่ได้แปลว่าทุกระบบต้องมี transaction เหมือนกัน transaction ก็เหมือนตัวเลือกการออกแบบอื่น ๆ ที่มีทั้งข้อดีและข้อจำกัด เพื่อให้เข้าใจ trade-off เหล่านั้น ในบทนี้เราจะลงรายละเอียดว่า transaction รับประกันอะไรได้บ้าง ทั้งในการทำงานปกติและในสถานการณ์สุดโต่งแต่เกิดขึ้นได้จริงต่าง ๆ
The Meaning of ACID (ความหมายของ ACID)
การรับประกันด้านความปลอดภัยที่ transaction ให้มักอธิบายด้วยตัวย่อที่รู้จักกันดีว่า ACID ซึ่งย่อมาจาก atomicity (ความเป็นอะตอม), consistency (ความสอดคล้อง), isolation (การแยกออกจากกัน), และ durability (ความคงทนถาวร) คำนี้ Theo Härder และ Andreas Reuter บัญญัติขึ้นในปี 1983 [ 9 ] เพื่อสร้างคำศัพท์ที่แม่นยำสำหรับกลไก fault tolerance ในฐานข้อมูล
แต่ในทางปฏิบัติ การ implement ACID ของแต่ละฐานข้อมูลไม่เหมือนกัน ตัวอย่างเช่น อย่างที่เราจะได้เห็นกัน มีความคลุมเครืออยู่มากเกี่ยวกับความหมาย ของ isolation [ 10 ] แนวคิดระดับสูงนั้นถูกต้อง แต่รายละเอียดปลีกย่อยต่างหากที่สำคัญ ทุกวันนี้เมื่อระบบไหนอ้างว่า "เป็นไปตาม ACID" ก็ไม่ชัดเจนว่าจะคาดหวังการรับประกันอะไรได้บ้าง น่าเสียดายที่ "ACID" กลายเป็นคำทางการตลาดไปเป็นส่วนใหญ่
Note (หมายเหตุ)
ระบบที่ไม่เป็นไปตาม ACID บางครั้งถูกเรียกว่า BASE ซึ่งย่อมาจาก basically available (พร้อมใช้งานโดยพื้นฐาน), soft state (สถานะยืดหยุ่น), และ eventual consistency (ความสอดคล้องในที่สุด) [ 11 ] ซึ่งคลุมเครือยิ่งกว่านิยามของ ACID เสียอีก ดูเหมือนว่านิยามเดียวของ BASE ที่สมเหตุสมผลคือ "ไม่ใช่ ACID" (คือหมายถึงเกือบอะไรก็ได้ที่คุณต้องการ)
มาดูนิยามของ atomicity, consistency, isolation และ durability ให้ละเอียดขึ้น เพราะจะช่วยให้เรากลั่นกรองแนวคิดเรื่อง transaction ได้ชัดเจนขึ้น
Atomicity (ความเป็นอะตอม)
โดยทั่วไปแล้ว atomic หมายถึงสิ่งที่แบ่งเป็นส่วนย่อยไม่ได้ คำนี้มีความหมายคล้ายกันแต่ต่างกันเล็กน้อยในสาขาต่าง ๆ ของวิทยาการคอมพิวเตอร์ เช่น ในการเขียนโปรแกรมแบบ multithreaded ถ้า thread หนึ่งทำ atomic operation แปลว่า thread อื่นไม่มีทางเห็นผลลัพธ์ที่ operation นั้นทำไปแค่ครึ่งเดียว ระบบจะอยู่ในสถานะก่อนหรือหลัง operation เท่านั้น ไม่มีสถานะกึ่งกลาง
ในทางกลับกัน ในบริบทของ ACID atomicity ไม่ใช่ เกี่ยวกับ concurrency มันไม่ได้บอกว่าจะเกิดอะไรขึ้นเมื่อหลาย process พยายามเข้าถึงข้อมูลเดียวกันพร้อมกัน เพราะเรื่องนั้นเป็นหน้าที่ของตัวอักษร I ซึ่งย่อมาจาก isolation (ดู "Isolation" )
แต่ ACID atomicity อธิบายว่าเกิดอะไรขึ้นเมื่อไคลเอ็นต์ต้องการเขียนหลายครั้ง แล้วเกิดข้อผิดพลาดขึ้นหลังจากเขียนไปแล้วบางส่วน เช่น process crash, การเชื่อมต่อเครือข่ายขาด, ดิสก์เต็ม หรือ integrity constraint ถูกละเมิด ถ้า write เหล่านั้นถูกจัดกลุ่มเป็น atomic transaction แล้ว transaction ทำให้เสร็จ (commit) ไม่ได้เพราะเกิดข้อผิดพลาด transaction จะถูก abort และฐานข้อมูลต้องทิ้งหรือยกเลิก write ทั้งหมดที่ทำไปแล้วใน transaction นั้น
ถ้าไม่มี atomicity แล้วเกิดข้อผิดพลาดกลางคันระหว่างการเปลี่ยนแปลงหลายอย่าง จะรู้ยากว่าการเปลี่ยนแปลงไหนเกิดขึ้นแล้วและไหนยังไม่เกิด แอปพลิเคชันอาจลองใหม่ แต่ก็เสี่ยงที่การเปลี่ยนแปลงบางอย่างจะเกิดขึ้นสองครั้ง ทำให้ข้อมูลซ้ำหรือผิดพลาด Atomicity ทำให้ปัญหานี้ง่ายลง: ถ้า transaction ถูก abort แอปพลิเคชันมั่นใจได้ว่ามันไม่ได้เปลี่ยนอะไรเลย จึง retry ได้อย่างปลอดภัย
ความสามารถในการ abort transaction เมื่อเกิดข้อผิดพลาด และทิ้ง write ทั้งหมดของ transaction นั้น คือสิ่งที่เป็นหัวใจของ ACID atomicity บางที abortability (ความสามารถในการยกเลิก) น่าจะเป็นคำที่ดีกว่า atomicity แต่เราจะใช้ atomicity ต่อไปเพราะเป็นคำที่ใช้กันทั่วไป
Consistency (ความสอดคล้อง)
คำว่า consistency ถูกใช้ในความหมายต่าง ๆ มากเกินไป:
-
ใน บทที่ 6 เราได้พูดถึง replica consistency (ความสอดคล้องของสำเนา) และปัญหาของ eventual consistency ที่เกิดขึ้นในระบบที่ทำ replication แบบอะซิงโครนัส (ดู "Problems with Replication Lag" )
-
consistent snapshot (snapshot ที่สอดคล้อง) ของฐานข้อมูล เช่น เพื่อใช้สำรองข้อมูล คือ snapshot ของฐานข้อมูลทั้งหมด ณ เวลาใดเวลาหนึ่ง และเจาะจงลงไปอีกว่า consistent snapshot จะสอดคล้องกับความสัมพันธ์ happens-before (ดู "The happens-before relation and concurrency" ): ถ้า snapshot มีค่าที่ถูกเขียน ณ เวลาหนึ่ง snapshot นั้นก็จะสะท้อนทุก writes ที่เกิดขึ้นก่อนที่ค่านั้นจะถูกเขียนด้วย
-
Consistent hashing (การแฮชแบบสอดคล้อง) เป็นแนวทาง sharding ที่บางระบบใช้ทำ rebalancing (ดู "Consistent hashing" )
-
ใน CAP theorem (ที่กล่าวถึงใน บทที่ 10 ) คำว่า consistency ถูกใช้ในความหมายว่า linearizability (ดู "Linearizability" )
-
ในบริบทของ ACID consistency หมายถึงแนวคิดเฉพาะของแอปพลิเคชันว่าฐานข้อมูลอยู่ใน "สถานะที่ดี"
น่าเสียดายที่คำเดียวกันนี้มีความหมายอย่างน้อยห้าอย่าง
แนวคิดของ ACID consistency คือคุณมีเงื่อนไขบางอย่างเกี่ยวกับข้อมูล ( invariants ) ที่ต้องเป็นจริงอยู่เสมอ เช่น ในระบบบัญชี เครดิตและเดบิตของทุกบัญชีรวมกันต้องสมดุลเสมอ ถ้า transaction เริ่มจากฐานข้อมูลที่ถูกต้องตาม invariant เหล่านี้ และทุก write ใน transaction รักษาความถูกต้องนั้นไว้ ก็มั่นใจได้ว่า invariant จะยังเป็นจริงอยู่เสมอ (invariant อาจถูกละเมิดชั่วคราวระหว่างที่ transaction ทำงาน แต่ควรกลับมาเป็นจริงอีกครั้งเมื่อ transaction commit)
ถ้าอยากให้ฐานข้อมูลบังคับ invariant ให้ คุณต้องประกาศมันเป็น constraints (ข้อจำกัด) ซึ่งเป็นส่วนหนึ่งของ schema เช่น foreign-key constraint, uniqueness constraint และ check constraint (ซึ่งจำกัดค่าที่ปรากฏได้ในแต่ละแถว) มักใช้แทน invariant บางประเภท ข้อกำหนดความสอดคล้องที่ซับซ้อนกว่านี้บางครั้งก็ทำได้ด้วย trigger หรือ materialized view [ 12 ]
แต่ invariant ที่ซับซ้อนอาจแสดงด้วย constraint ที่ฐานข้อมูลมักมีให้ได้ยากหรือเป็นไปไม่ได้ ในกรณีนั้นแอปพลิเคชันต้องรับผิดชอบกำหนด transaction ให้ถูกต้องเพื่อรักษาความสอดคล้องไว้เอง ถ้าคุณเขียนข้อมูลเสียที่ละเมิด invariant แต่ไม่ได้ประกาศ invariant นั้นไว้ ฐานข้อมูลก็ห้ามคุณไม่ได้ ดังนั้น C ใน ACID จึงมักขึ้นอยู่กับวิธีที่แอปพลิเคชันใช้ฐานข้อมูล ไม่ใช่คุณสมบัติของฐานข้อมูลเพียงอย่างเดียว
Isolation (การแยกออกจากกัน)
ฐานข้อมูลส่วนใหญ่มีไคลเอ็นต์หลายตัวเข้าถึงพร้อมกัน ซึ่งไม่มีปัญหาถ้าแต่ละตัวอ่านและเขียนคนละส่วนของฐานข้อมูล แต่ถ้าเข้าถึงเรคคอร์ดเดียวกัน คุณอาจเจอปัญหา concurrency (race condition)
รูปที่ 8-1 เป็นตัวอย่างง่าย ๆ ของปัญหาแบบนี้ สมมติว่ามีไคลเอ็นต์สองตัวกำลังเพิ่มค่า counter ที่เก็บในฐานข้อมูลพร้อมกัน แต่ละตัวต้องอ่านค่าปัจจุบัน บวก 1 แล้วเขียนค่าใหม่กลับไป (สมมติว่าฐานข้อมูลไม่มี increment operation ในตัว) ใน รูปที่ 8-1 counter ควรเพิ่มจาก 42 เป็น 44 เพราะมีการ increment สองครั้ง แต่กลับได้แค่ 43 เพราะ race condition
Figure 8-1. Race condition ระหว่างไคลเอ็นต์สองตัวที่เพิ่มค่า counter พร้อมกัน
Isolation ในความหมายของ ACID คือ transaction ที่ทำงานพร้อมกันถูกแยกออกจากกัน ไม่ก้าวก่ายกัน ตำราฐานข้อมูลคลาสสิกนิยาม isolation ว่าเป็น serializability ซึ่งหมายความว่าแต่ละ transaction แสร้งทำเป็นได้ว่าตัวเองเป็น transaction เดียวที่รันอยู่บนฐานข้อมูลทั้งก้อน ฐานข้อมูลรับประกันว่าเมื่อ transaction commit แล้ว ผลลัพธ์จะเหมือนกับว่ามันถูกรัน แบบ serial (ทีละตัว) แม้ในความเป็นจริงอาจรันพร้อมกันก็ตาม [ 13 ]
แต่ serializability มีต้นทุนด้านประสิทธิภาพ ในทางปฏิบัติฐานข้อมูลหลายตัวจึงใช้ isolation ในรูปแบบที่อ่อนกว่า serializability คือยอมให้ concurrent transaction รบกวนกันได้ในขอบเขตจำกัด ฐานข้อมูลยอดนิยมบางตัว เช่น Oracle ไม่ได้ implement serializability เลย (Oracle มี isolation level ที่เรียกว่า "serializable" แต่จริง ๆ แล้วมัน implement snapshot isolation ซึ่งเป็นการรับประกันที่อ่อนแอกว่า serializability [ 10 , 14 ]) ซึ่งหมายความว่า race condition บางชนิดยังเกิดขึ้นได้ เราจะสำรวจ snapshot isolation และรูปแบบอื่น ๆ ของ isolation ใน "Weak Isolation Levels"
Durability (ความคงทนถาวร)
จุดประสงค์ของระบบฐานข้อมูลคือเป็นที่เก็บข้อมูลที่ปลอดภัย โดยไม่ต้องกลัวว่าข้อมูลจะหาย Durability คือคำสัญญาว่าเมื่อ transaction commit สำเร็จแล้ว ข้อมูลที่มันเขียนไว้จะไม่หายไป แม้ฮาร์ดแวร์จะเกิดข้อผิดพลาดหรือฐานข้อมูล crash ก็ตาม
ในฐานข้อมูลแบบโหนดเดียว durability โดยทั่วไปหมายถึงข้อมูลถูกเขียนลง nonvolatile storage (หน่วยความจำถาวร) เช่น hard drive หรือ SSD การเขียนไฟล์ปกติมักถูก buffer ไว้ในหน่วยความจำก่อน แล้วค่อยส่งไปเขียนลงดิสก์ทีหลัง ซึ่งแปลว่าข้อมูลอาจหายถ้าไฟดับกะทันหัน ฐานข้อมูลจำนวนมากจึงใช้ fsync system call เพื่อให้แน่ใจว่าข้อมูลถูกเขียนลงดิสก์จริง ๆ ฐานข้อมูลมักมี write-ahead log หรือกลไกที่คล้ายกัน (ดู "Making B-trees reliable" ) ซึ่งช่วยให้กู้คืนได้ถ้า crash กลางคันระหว่างที่กำลังเขียน ฐานข้อมูลหลายตัว (เช่น MySQL, MongoDB และ PostgreSQL) เก็บข้อมูลพร้อม checksum ซึ่งช่วยตรวจจับ log entry ที่เสียหายหรือไม่สมบูรณ์ และช่วยกู้คืนฐานข้อมูลให้เป็น consistent snapshot หลัง crash
ในฐานข้อมูลแบบ replicated durability อาจหมายถึงข้อมูลถูกคัดลอกไปยังโหนดจำนวนหนึ่งสำเร็จแล้ว เพื่อรับประกัน durability ฐานข้อมูลต้องรอจนกว่า write หรือ replication เหล่านี้จะเสร็จสมบูรณ์ ก่อนจะรายงานว่า transaction commit สำเร็จ แต่อย่างที่กล่าวไว้ใน "Reliability and Fault Tolerance" durability ที่สมบูรณ์แบบไม่มีอยู่จริง ถ้าฮาร์ดดิสก์และข้อมูลสำรองทั้งหมดของคุณถูกทำลายพร้อมกัน ฐานข้อมูลก็ช่วยอะไรคุณไม่ได้อย่างแน่นอน
Replication and Durability (Replication และ Durability)
ในอดีต durability หมายถึงการเขียนลงเทป archive จากนั้นก็เข้าใจกันว่าเป็นการเขียนลงดิสก์หรือ SSD ปัจจุบันมันถูกปรับให้หมายถึง replication แล้ว implementation แบบไหนดีกว่ากัน?
ความจริงก็คือ ไม่มีอะไรที่สมบูรณ์แบบ:
-
ถ้าคุณเขียนลงดิสก์แล้วเครื่องเสีย ข้อมูลของคุณอาจไม่หาย แต่ก็เข้าถึงไม่ได้จนกว่าจะซ่อมเครื่องหรือย้ายดิสก์ไปเครื่องอื่น ส่วนระบบแบบ replicated ยังคงใช้งานได้อยู่
-
ข้อผิดพลาดที่สัมพันธ์กัน เช่น ไฟดับ หรือบั๊กที่ทำให้ทุก node ล้มเหลวเมื่อได้รับ input บางอย่าง อาจทำให้ replica ทั้งหมดล้มเหลวพร้อมกัน (ดู "Reliability and Fault Tolerance" ) ซึ่งทำให้ข้อมูลที่อยู่ในหน่วยความจำอย่างเดียวหายไป ดังนั้นการเขียนลงดิสก์จึงยังสำคัญสำหรับฐานข้อมูลแบบ replicated
-
ในระบบที่ทำ replication แบบอะซิงโครนัส write ล่าสุดอาจหายไปเมื่อ leader ใช้งานไม่ได้ (ดู "Handling Node Outages" )
-
มีการพบว่าเมื่อไฟดับกะทันหัน SSD บางครั้งละเมิดการรับประกันที่ควรจะให้ แม้แต่
fsyncก็ไม่รับประกันว่าจะทำงานถูกต้อง [ 15 ] เฟิร์มแวร์ดิสก์ก็มีบั๊กได้เหมือนซอฟต์แวร์อื่น ๆ [ 16 , 17 ]—เช่น บั๊กที่ทำให้ไดรฟ์พังหลังทำงานครบ 32,768 ชั่วโมง [ 18 ] และfsyncก็ใช้ให้ถูกยาก แม้แต่ PostgreSQL ก็ใช้มันผิดมานานกว่า 20 ปี [ 19 , 20 , 21 ] -
ปฏิสัมพันธ์ที่ซับซ้อนระหว่าง storage engine กับการ implement ระบบไฟล์อาจก่อให้เกิดบั๊กที่หาเจอยาก และอาจทำให้ไฟล์บนดิสก์เสียหายหลัง crash [ 22 , 23 ] ข้อผิดพลาดของระบบไฟล์บน replica หนึ่งบางครั้งก็แพร่ไปยัง replica อื่นได้เช่นกัน [ 24 ]
-
ข้อมูลบนดิสก์อาจค่อย ๆ เสียหายโดยไม่มีใครตรวจพบ [ 25 , 26 ] ถ้าข้อมูลเสียหายมาสักพักแล้ว replica และ backup ล่าสุดก็อาจเสียหายไปด้วย ในกรณีนี้คุณต้องพยายามกู้ข้อมูลจาก backup เก่า
-
งานวิจัยชิ้นหนึ่งเกี่ยวกับ SSD พบว่าไดรฟ์ 30% ถึง 80% เกิด bad block อย่างน้อยหนึ่งครั้งในสี่ปีแรกของการใช้งาน และมีเพียงบางส่วนเท่านั้นที่เฟิร์มแวร์แก้ไขได้ [ 27 ] ฮาร์ดไดรฟ์แบบแม่เหล็กมีอัตรา bad sector ต่ำกว่า แต่มีอัตราที่ไดรฟ์พังทั้งลูกสูงกว่า SSD
-
เมื่อ SSD ที่หมดอายุการใช้งาน (ผ่านรอบ write/erase มามาก) ถูกถอดออกจากไฟ มันอาจเริ่มสูญเสียข้อมูลภายในไม่กี่สัปดาห์ถึงไม่กี่เดือน ขึ้นอยู่กับอุณหภูมิ [ 28 ] ปัญหานี้พบได้น้อยกว่าในไดรฟ์ที่สึกหรอน้อย [ 29 ]
ในทางปฏิบัติ ไม่มีเทคนิคใดเทคนิคเดียวที่ให้การรับประกันแบบสมบูรณ์แบบได้ มีแต่เทคนิคลดความเสี่ยงต่าง ๆ ซึ่งรวมถึงการเขียนลงดิสก์ การทำ replication ไปยังเครื่องที่อยู่ไกลออกไป และ backup และเทคนิคเหล่านี้ใช้ร่วมกันได้และควรใช้ร่วมกัน โดยทั่วไปแล้ว ควรรับ "การรับประกัน" ทางทฤษฎีใด ๆ ด้วยความระมัดระวัง
Single-Object and Multi-Object Operations (การดำเนินการบนวัตถุเดียวและหลายวัตถุ)
สรุปแล้ว ใน ACID, atomicity และ isolation อธิบายว่าฐานข้อมูลควรทำอะไรเมื่อไคลเอ็นต์เขียนหลายครั้งภายใน transaction เดียวกัน:
Atomicity
ถ้าเกิดข้อผิดพลาดกลางคันระหว่างที่กำลังเขียนหลายครั้ง transaction ควรถูก abort และ write ที่ทำไปจนถึงจุดนั้นควรถูกทิ้ง พูดอีกอย่างคือ ฐานข้อมูลช่วยให้คุณไม่ต้องกังวลเรื่องความล้มเหลวบางส่วน ด้วยการรับประกันแบบ all-or-nothing
Isolation
Transaction ที่ทำงานพร้อมกันไม่ควรรบกวนกัน เช่น ถ้า transaction หนึ่งเขียนหลายครั้ง transaction อื่นควรเห็น write ทั้งหมดหรือไม่เห็นเลย ไม่ใช่เห็นแค่บางส่วน
นิยามเหล่านี้สมมติว่าคุณต้องการแก้ไขหลาย object (row, document, record) ในครั้งเดียว multi-object transaction แบบนี้มักจำเป็นเมื่อข้อมูลหลายชิ้นต้องสอดคล้องกัน รูปที่ 8-2 แสดงตัวอย่างจากแอปพลิเคชันอีเมล ถ้าจะแสดงจำนวนอีเมลที่ผู้ใช้ยังไม่ได้อ่าน คุณอาจ query แบบนี้:
SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
แต่ query นี้อาจช้าเกินไปถ้ามีอีเมลจำนวนมาก คุณจึงอาจตัดสินใจเก็บจำนวนอีเมลที่ยังไม่ได้อ่านไว้ในฟิลด์แยกต่างหาก (เป็นการ denormalization ชนิดหนึ่ง ซึ่งเรากล่าวถึงใน "Normalization, Denormalization, and Joins" ) ทีนี้ เมื่อมีอีเมลใหม่เข้ามา คุณต้อง increment unread counter ด้วย และเมื่ออีเมลถูก mark ว่าอ่านแล้ว ก็ต้อง decrement unread counter เช่นกัน
ใน รูปที่ 8-2 ผู้ใช้ 2 เจอความผิดปกติ: รายการกล่องจดหมายแสดงอีเมลที่ยังไม่ได้อ่าน แต่ counter แสดงเป็นศูนย์เพราะการ increment counter ยังไม่เกิดขึ้น (ถ้าคิดว่า counter ที่ผิดในแอปพลิเคชันอีเมลเป็นเรื่องเล็กน้อยเกินไป ลองนึกถึงยอดเงินในบัญชีลูกค้าแทน unread counter และรายการชำระเงินแทนอีเมล) Isolation ป้องกันปัญหานี้โดยรับประกันว่าผู้ใช้ 2 จะเห็นทั้งอีเมลที่เพิ่ม และ counter ที่อัปเดตแล้ว หรือไม่เห็นทั้งสองอย่างเลย จะไม่เห็นจุดกึ่งกลางที่ไม่สอดคล้องกัน
Figure 8-2. การละเมิด isolation: transaction หนึ่งอ่าน writes ที่ยังไม่ได้ commit ของอีก transaction หนึ่ง ("dirty read")
รูปที่ 8-3 แสดงให้เห็นว่าทำไมต้องมี atomicity: ถ้าเกิดข้อผิดพลาดที่ไหนสักแห่งระหว่างที่ transaction ทำงาน เนื้อหาของกล่องจดหมายกับ unread counter อาจไม่สอดคล้องกัน ใน atomic transaction ถ้าการอัปเดต counter ล้มเหลว transaction จะถูก abort และการเพิ่มอีเมลจะถูก roll back
Figure 8-3. Atomicity รับประกันว่าถ้าเกิดข้อผิดพลาด write ก่อนหน้าทั้งหมดของ transaction นั้นจะถูกยกเลิก เพื่อหลีกเลี่ยงสถานะที่ไม่สอดคล้องกัน
Multi-object transaction ต้องมีวิธีบอกว่าการอ่านและเขียนไหนอยู่ใน transaction เดียวกัน ในฐานข้อมูล relational โดยทั่วไปทำได้ผ่านการเชื่อมต่อ TCP จากไคลเอ็นต์ไปยังเซิร์ฟเวอร์ฐานข้อมูล บนการเชื่อมต่อหนึ่ง ๆ ทุกอย่างระหว่าง BEGIN TRANSACTION และ COMMIT จะถือว่าเป็นส่วนหนึ่งของ transaction เดียวกัน ถ้าการเชื่อมต่อ TCP ถูกตัดขาด transaction ต้องถูก abort
ในทางกลับกัน ฐานข้อมูลที่ไม่ใช่ relational หลายตัวไม่มีวิธีจัดกลุ่ม operation เข้าด้วยกันแบบนี้ แม้จะมี multi-object API (เช่น key-value store อาจมี multi-put operation ที่อัปเดตหลาย key ในครั้งเดียว) ก็ไม่ได้แปลว่ามี transaction semantics: คำสั่งอาจสำเร็จกับบาง key และล้มเหลวกับ key อื่น ทำให้ฐานข้อมูลอยู่ในสถานะที่ถูกอัปเดตแค่บางส่วน
Single-object writes (การเขียนบนวัตถุเดียว)
Atomicity และ isolation ก็เกี่ยวข้องเมื่อมีการเปลี่ยนแปลง object เดียวเช่นกัน เช่น ลองนึกว่าคุณกำลังเขียนเอกสาร JSON ขนาด 20 kB ลงฐานข้อมูล:
-
ถ้าการเชื่อมต่อเครือข่ายถูกตัดขาดหลังจากส่งไป 10 kB แรก ฐานข้อมูลจะเก็บ JSON fragment ขนาด 10 kB ที่ parse ไม่ได้นั้นไว้หรือไม่?
-
ถ้าไฟดับขณะที่ฐานข้อมูลกำลังเขียนทับค่าเดิมบนดิสก์ คุณจะได้ค่าเก่ากับค่าใหม่ที่ถูกต่อปนกันหรือไม่?
-
ถ้าไคลเอ็นต์อื่นอ่านเอกสารนั้นขณะที่กำลังเขียนอยู่ มันจะเห็นค่าที่ถูกอัปเดตแค่บางส่วนหรือไม่?
ผลลัพธ์แต่ละแบบเหล่านั้นจะสร้างความสับสนอย่างมาก storage engine เกือบทั้งหมดจึงพยายามให้ atomicity และ isolation ในระดับ object เดียว (เช่น key-value pair) บนโหนดเดียว โดย implement atomicity ได้ด้วย log สำหรับ crash recovery (ดู "Making B-trees reliable" ) และ implement isolation ได้ด้วย lock บนแต่ละ object (ให้ thread เดียวเข้าถึง object ได้ในแต่ละครั้ง)
บาง ฐานข้อมูลยังมี atomic operation ที่ซับซ้อนกว่านั้น เช่น increment operation ซึ่งทำให้ไม่ต้องมี read-modify-write cycle แบบใน รูปที่ 8-1 อีกแบบที่เป็นที่นิยมคือ conditional write operation ซึ่งอนุญาตให้เขียนได้ก็ต่อเมื่อไม่มีใครเปลี่ยนค่านั้นพร้อมกัน (ดู "Conditional writes (compare-and-set)" ) คล้ายกับ compare-and-set หรือ compare-and-swap (CAS) operation ใน shared-memory concurrency
Note (หมายเหตุ)
พูดให้เคร่งครัด คำว่า atomic increment ใช้คำว่า atomic ในความหมายของการเขียนโปรแกรมแบบ multithreaded แต่ในบริบทของ ACID ควรเรียกว่า isolated หรือ serializable increment แต่คนไม่ค่อยใช้คำนี้กัน
Single-object operation เหล่านี้มีประโยชน์ เพราะช่วยป้องกัน lost update เมื่อไคลเอ็นต์หลายตัวพยายามเขียน object เดียวกันพร้อมกัน (ดู "Preventing Lost Updates" ) แต่มันไม่ใช่ transaction ในความหมายปกติของคำ เช่น โหมด "strong consistency" ของ Aerospike และฟีเจอร์ "lightweight transactions" ของ Cassandra และ ScyllaDB ให้ linearizable (ดู "Linearizability" ) read และ conditional write บน object เดียว แต่ไม่มีการรับประกันข้ามหลาย object
The need for multi-object transactions (ความจำเป็นของ multi-object transactions)
เราจำเป็นต้องมี multi-object transaction หรือไม่? แอปพลิเคชันทุกตัวจะ implement ได้ไหมด้วย key-value data model และ single-object operation เพียงอย่างเดียว?
ในบาง use case การ insert, update และ delete บน object เดียวก็เพียงพอ แต่ในอีกหลายกรณี การเขียนไปยังหลาย object ต้องถูกประสานกัน:
-
ใน relational data model row ในตารางหนึ่งมักมี foreign-key reference ไปยัง row ในอีกตารางหนึ่ง ในทำนองเดียวกัน ใน graph-like data model vertex จะมี edge ไปยัง vertex อื่น Multi-object transaction ช่วยให้มั่นใจว่า reference เหล่านี้ยังใช้ได้อยู่ เมื่อ insert หลาย record ที่อ้างถึงกัน foreign key ต้องถูกต้องและเป็นปัจจุบัน ไม่เช่นนั้นข้อมูลจะไร้ความหมาย
-
ใน document data model ฟิลด์ที่ต้องอัปเดตพร้อมกันมักอยู่ในเอกสารเดียวกัน ซึ่งถือเป็น object เดียว จึงไม่ต้องใช้ multi-object transaction เมื่ออัปเดตเอกสารเดียว แต่ document database ที่ไม่มีฟังก์ชัน join ก็ทำให้คนหันมาใช้ denormalization เหมือนกัน (ดู "When to Use Which Model" ) เมื่อข้อมูลที่ denormalize ไว้ต้องอัปเดต ดังในตัวอย่างของ รูปที่ 8-2 คุณต้องอัปเดตหลายเอกสารในครั้งเดียว Transaction มีประโยชน์มากในสถานการณ์นี้ เพื่อป้องกันไม่ให้ข้อมูลที่ denormalize ไว้ไม่สอดคล้องกัน
-
ในฐานข้อมูลที่มี secondary index (เกือบทุกตัวยกเว้น pure key-value store) index ต้องถูกอัปเดตทุกครั้งที่คุณเปลี่ยนค่า และในมุมมองของ transaction แล้ว index เหล่านี้เป็น database object คนละตัวกัน เช่น ถ้าไม่มี transaction isolation record อาจปรากฏใน index หนึ่งแต่ไม่ปรากฏในอีก index หนึ่ง เพราะการอัปเดต index ที่สองยังไม่เกิดขึ้น (ดู "Sharding and Secondary Indexes" )
แอปพลิเคชันแบบนี้ยัง implement ได้โดยไม่มี transaction แต่การจัดการข้อผิดพลาดจะซับซ้อนขึ้นมากถ้าไม่มี atomicity และการขาด isolation อาจทำให้เกิดปัญหา concurrency เราจะพูดถึงปัญหาเหล่านั้นใน "Weak Isolation Levels" และสำรวจแนวทางอื่นใน บทที่ 13
Handling errors and aborts (การจัดการข้อผิดพลาดและการยกเลิก)
คุณสมบัติสำคัญของ transaction คือ abort แล้ว retry ได้อย่างปลอดภัยถ้าเกิดข้อผิดพลาด ฐานข้อมูล ACID ยึดปรัชญานี้: ถ้าฐานข้อมูลเสี่ยงจะละเมิดการรับประกัน atomicity, isolation หรือ durability มันจะทิ้ง transaction ทั้งหมด แทนที่จะปล่อยให้ค้างอยู่ครึ่งทาง
แต่ไม่ใช่ทุกระบบที่ทำตามปรัชญานั้น โดยเฉพาะ datastore ที่มี leaderless replication (ดู "Leaderless Replication" ) ทำงานแบบ "best effort" มากกว่า ซึ่งสรุปได้ว่า "ฐานข้อมูลจะทำเท่าที่ทำได้ และถ้าเจอข้อผิดพลาด มันจะไม่ยกเลิกสิ่งที่ทำไปแล้ว" ดังนั้นแอปพลิเคชันต้องรับผิดชอบในการกู้คืนจากข้อผิดพลาดเอง
ข้อผิดพลาด เป็นสิ่งที่หลีกเลี่ยงไม่ได้ แต่นักพัฒนาจำนวนมากชอบคิดแค่ happy path มากกว่าจะยุ่งกับความซับซ้อนของการจัดการข้อผิดพลาด เช่น ORM framework ยอดนิยมอย่าง Rails ActiveRecord และ Django ไม่ retry transaction ที่ถูก abort ข้อผิดพลาดมักทำให้เกิด exception ที่ลอยขึ้นไปตาม stack ดังนั้น input ของผู้ใช้จะถูกทิ้ง และผู้ใช้ได้รับข้อความแสดงข้อผิดพลาด น่าเสียดายมาก เพราะประเด็นของการ roll back transaction ก็คือเพื่อให้ retry ได้อย่างปลอดภัย
การ retry transaction ที่ถูก abort เป็นกลไกจัดการข้อผิดพลาดที่เรียบง่ายและได้ผล แต่ก็ไม่สมบูรณ์แบบ:
-
ถ้า transaction สำเร็จจริง แต่เครือข่ายหลุดขณะที่เซิร์ฟเวอร์พยายามแจ้งไคลเอ็นต์ว่า commit สำเร็จ (ทำให้ไคลเอ็นต์เห็นว่า timeout) การ retry transaction จะทำให้มันถูกรันสองครั้ง เว้นแต่คุณจะมีกลไก deduplication เพิ่มเติมในระดับแอปพลิเคชัน
-
ถ้าข้อผิดพลาดเกิดจาก overload หรือการแย่งกันสูงระหว่าง transaction ที่ทำงานพร้อมกัน การ retry จะทำให้ปัญหาแย่ลง ไม่ใช่ดีขึ้น เพื่อหลีกเลี่ยง feedback cycle แบบนี้ คุณอาจจำกัดจำนวนครั้งที่ retry ใช้ exponential backoff และจัดการข้อผิดพลาดที่เกี่ยวกับ overload แยกจากข้อผิดพลาดอื่น (ดู "When an Overloaded System Won't Recover" )
-
ควร retry เฉพาะหลังเกิด transient error (เช่น จาก deadlock, isolation violation, เครือข่ายหลุดชั่วคราว หรือ failover) เท่านั้น หลัง permanent error (เช่น constraint violation) การ retry ไม่มีประโยชน์
-
ถ้า transaction มี side effect นอกฐานข้อมูล side effect เหล่านั้นอาจยังเกิดขึ้นแม้ transaction จะถูก abort เช่น ถ้าคุณกำลังส่งอีเมล คงไม่อยากส่งอีเมลซ้ำทุกครั้งที่ retry transaction ถ้าต้องการให้แน่ใจว่าหลายระบบ commit หรือ abort พร้อมกัน two-phase commit ช่วยได้ (เราจะพูดถึงใน "Two-Phase Commit" )
-
ถ้า client process crash ระหว่างที่ retry ข้อมูลที่มันพยายามเขียนลงฐานข้อมูลจะหายไป
Weak Isolation Levels (ระดับการแยกตัวที่อ่อนแอ)
ถ้าสอง transaction ไม่ได้แตะข้อมูลเดียวกัน หรือทั้งคู่เป็น read-only ก็ทำงานพร้อมกันได้อย่างปลอดภัย เพราะไม่มี transaction ใดขึ้นอยู่กับอีกตัว ปัญหา concurrency (race condition) จะเกิดขึ้นก็ต่อเมื่อ transaction หนึ่งอ่านข้อมูลที่อีก transaction หนึ่งกำลังแก้ไขพร้อมกัน หรือเมื่อสอง transaction พยายามแก้ไขข้อมูลเดียวกัน
บั๊กเกี่ยวกับ concurrency หาเจอยากด้วยการทดสอบ เพราะจะเกิดขึ้นก็ต่อเมื่อคุณโชคร้ายเจอจังหวะเวลาที่พอดี ปัญหาด้านจังหวะแบบนี้อาจเกิดขึ้นได้ยากและมักจำลองซ้ำได้ยาก Concurrency ยังยากที่จะคิดให้ออก โดยเฉพาะในแอปพลิเคชันขนาดใหญ่ที่คุณไม่จำเป็นต้องรู้ว่าโค้ดส่วนอื่นกำลังเข้าถึงฐานข้อมูลอยู่หรือไม่ การพัฒนาแอปพลิเคชันก็ยากพออยู่แล้วเมื่อมีผู้ใช้ทีละคน การมีผู้ใช้พร้อมกันจำนวนมากยิ่งยากขึ้น เพราะข้อมูลชิ้นไหนก็อาจเปลี่ยนไปอย่างไม่คาดคิดได้ทุกเมื่อ
ด้วยเหตุนี้ ฐานข้อมูลจึงพยายามซ่อนปัญหา concurrency จากนักพัฒนาแอปพลิเคชันมานาน ด้วยการให้ transaction isolation ในทางทฤษฎี isolation ควรทำให้ชีวิตคุณง่ายขึ้น โดยให้คุณแสร้งทำเหมือนว่าไม่มี concurrency เกิดขึ้น Serializable isolation หมายความว่าฐานข้อมูลรับประกันว่า transaction จะให้ผลลัพธ์เหมือนกับว่ามันทำงาน แบบ serial (คือทีละตัว โดยไม่มี concurrency)
น่าเสียดายที่ในทางปฏิบัติ isolation ไม่ง่ายอย่างนั้น Serializable isolation มีต้นทุนด้านประสิทธิภาพ และฐานข้อมูลจำนวนมากไม่ต้องการจ่ายราคานั้น [ 10 ] ระบบจึงมักใช้ isolation level ที่อ่อนกว่า ซึ่งป้องกันปัญหา concurrency บางอย่าง แต่ไม่ทั้งหมด isolation level เหล่านั้นเข้าใจยากกว่ามากและอาจนำไปสู่บั๊กที่ซับซ้อน แต่ก็ยังถูกใช้กันในทางปฏิบัติ [ 30 ]
บั๊กเกี่ยวกับ concurrency ที่เกิดจาก weak transaction isolation และ race condition ไม่ได้เป็นแค่ปัญหาเชิงทฤษฎี มันสร้างความเสียหายทางการเงินอย่างมาก รวมถึงทำให้ Bitcoin exchange ล้มละลาย [ 31 , 32 , 33 , 34 ] นำไปสู่การตรวจสอบโดยผู้สอบบัญชี [ 35 ] และทำให้ข้อมูลลูกค้าเสียหาย [ 36 ] ความเห็นยอดนิยมเมื่อมีการเปิดเผยปัญหาแบบนี้คือ "ใช้ฐานข้อมูล ACID ถ้าคุณจัดการกับข้อมูลทางการเงิน!" แต่นั่นพลาดประเด็น เพราะฐานข้อมูล relational ยอดนิยมหลายระบบ (ซึ่งโดยทั่วไปถือว่าเป็น ACID) ก็ใช้ weak isolation จึงไม่ได้หมายความว่าจะป้องกันบั๊กเหล่านี้ไม่ให้เกิดขึ้นได้เสมอไป
Note (หมายเหตุ)
ว่าไปแล้ว ระบบธนาคารส่วนใหญ่พึ่งพา text file ที่แลกเปลี่ยนกันผ่าน secure FTP [ 37 ] ในบริบทนี้ การมี audit trail และมาตรการป้องกันการฉ้อโกงในระดับคนบางอย่าง สำคัญกว่าคุณสมบัติ ACID เสียอีก
ตัวอย่างเหล่านั้นยังชี้ให้เห็นประเด็นสำคัญ: แม้ปัญหา concurrency จะเกิดขึ้นได้ยากในการทำงานปกติ คุณก็ต้องคิดถึงความเป็นไปได้ที่ผู้โจมตีอาจจงใจส่ง request ที่ concurrent สูงเป็นชุดมาที่ API ของคุณ เพื่อพยายามใช้ประโยชน์จากบั๊ก concurrency [ 32 ] ดังนั้น เพื่อสร้างแอปพลิเคชันที่เชื่อถือได้และปลอดภัย คุณต้องมั่นใจว่ามีการป้องกันบั๊กแบบนี้อย่างเป็นระบบ
ในส่วนนี้เราจะดู weak (nonserializable) isolation level หลายแบบที่ใช้ในทางปฏิบัติ และลงรายละเอียดว่า race condition ชนิดไหนเกิดขึ้นได้และเกิดไม่ได้กับแต่ละแบบ เพื่อให้คุณตัดสินใจได้ว่าระดับไหนเหมาะกับแอปพลิเคชันของคุณ จากนั้นเราจะพูดถึง serializability โดยละเอียด (ดู "Serializability" ) เราจะอธิบาย isolation level แบบไม่เป็นทางการโดยใช้ตัวอย่าง ถ้าต้องการนิยามที่เข้มงวดและการวิเคราะห์คุณสมบัติของมัน ดูได้ในเอกสารวิชาการ [ 38 , 39 , 40 , 41 ]
Read Committed (การอ่านที่ commit แล้ว)
ระดับพื้นฐานที่สุดของ transaction isolation คือ read committed โดยให้การรับประกันสองอย่าง
-
เมื่ออ่านจากฐานข้อมูล คุณจะเห็นเฉพาะข้อมูลที่ commit แล้วเท่านั้น (ไม่มี dirty read)
-
เมื่อเขียนลงฐานข้อมูล คุณจะเขียนทับได้เฉพาะข้อมูลที่ commit แล้วเท่านั้น (ไม่มี dirty write)
มาดูการรับประกันสองข้อนี้โดยละเอียดกัน
No dirty reads (ไม่มีการอ่านข้อมูลที่ยังไม่ commit)
ลองนึกว่า transaction หนึ่งเขียนข้อมูลบางอย่างลงฐานข้อมูลแล้ว แต่ยังไม่ commit หรือ abort transaction อื่นจะเห็นข้อมูลที่ยังไม่ commit นั้นได้ไหม? ถ้าเห็นได้ เรียกว่า dirty read [ 3 ]
Transaction ที่ทำงานในระดับ read-committed isolation ต้องป้องกัน dirty read นั่นหมายความว่า write ของ transaction ใด ๆ จะถูกคนอื่นมองเห็นก็ต่อเมื่อ transaction นั้น commit (และ write ทั้งหมดของมันจะมองเห็นพร้อมกัน) ดังแสดงใน รูปที่ 8-4 โดยที่ผู้ใช้ 1 ตั้งค่า x = 3 แต่การเรียก get x ของผู้ใช้ 2 ยังคงคืนค่าเก่าคือ 2 ตราบที่ผู้ใช้ 1 ยังไม่ commit
Figure 8-4. No dirty reads: ผู้ใช้ 2 จะเห็นค่าใหม่ของ x ก็ต่อเมื่อ transaction ของผู้ใช้ 1 ถูก commit แล้วเท่านั้น
การป้องกัน dirty read มีประโยชน์ด้วยเหตุผลหลายอย่าง:
-
ถ้า transaction ต้องอัปเดตหลายแถว dirty read หมายความว่า transaction อื่นอาจเห็นแค่บางอัปเดตแต่ไม่เห็นทั้งหมด เช่น ใน รูปที่ 8-2 ผู้ใช้เห็นอีเมลใหม่ที่ยังไม่ได้อ่าน แต่ไม่เห็น counter ที่อัปเดตแล้ว นี่คือ dirty read ของอีเมล การเห็นฐานข้อมูลในสถานะที่ถูกอัปเดตแค่บางส่วนทำให้ผู้ใช้สับสน และอาจทำให้ transaction อื่นตัดสินใจผิดพลาด
-
ถ้า transaction ถูก abort write ทุกตัวที่มันทำไว้ต้องถูก roll back (ดังใน รูปที่ 8-3 ) ถ้าฐานข้อมูลอนุญาต dirty read transaction อาจเห็นข้อมูลที่ภายหลังถูก roll back ซึ่งก็คือข้อมูลที่ไม่เคยถูก commit ลงฐานข้อมูลจริง ๆ transaction ที่อ่านข้อมูลที่ยังไม่ commit ก็ต้องถูก abort ด้วยเช่นกัน ทำให้เกิดปัญหาที่เรียกว่า cascading aborts
No dirty writes (ไม่มีการเขียนทับข้อมูลที่ยังไม่ commit)
จะเกิดอะไรขึ้นถ้าสอง transaction พยายามอัปเดตแถวเดียวกันในฐานข้อมูลพร้อมกัน? เราไม่รู้ว่า write จะเกิดขึ้นในลำดับไหน แต่ปกติเราถือว่า write ทีหลังจะเขียนทับ write ก่อนหน้า
แล้วถ้า write แรกเป็นส่วนหนึ่งของ transaction ที่ยังไม่ commit ทำให้ write ทีหลังไปเขียนทับค่าที่ยังไม่ commit ล่ะ? แบบนี้เรียกว่า dirty write [ 38 ] Transaction ที่ทำงานในระดับ read-committed isolation ต้องป้องกัน dirty write โดยปกติทำได้ด้วยการหน่วง write ที่สองไว้จนกว่า transaction แรกจะ commit หรือ abort
การป้องกัน dirty write ช่วยหลีกเลี่ยงปัญหา concurrency บางอย่าง:
-
ถ้า transaction อัปเดตหลายแถว dirty write อาจนำไปสู่ผลลัพธ์ที่ไม่ดี เช่น รูปที่ 8-5 ซึ่งแสดงเว็บไซต์ขายรถมือสองที่มีสองคนคือ Aaliyah และ Bryce พยายามซื้อรถคันเดียวกันพร้อมกัน การซื้อรถต้องใช้ database write สองครั้ง: ต้องอัปเดตรายการบนเว็บไซต์ให้สะท้อนผู้ซื้อ และต้องส่งใบแจ้งหนี้การขายไปยังผู้ซื้อ ในกรณีของ รูปที่ 8-5 การขายตกเป็นของ Bryce (เพราะการอัปเดตตาราง
listings) ของเขาชนะ) แต่ใบแจ้งหนี้ถูกส่งไปยัง Aaliyah (เพราะการอัปเดตตารางinvoices) ของเธอชนะ) Read-committed isolation ป้องกันความผิดพลาดแบบนี้ได้ -
อย่างไรก็ตาม read-committed isolation ไม่ได้ ป้องกัน race condition ระหว่างการ increment counter สองครั้งใน รูปที่ 8-1 ในกรณีนี้ write ที่สองเกิดขึ้นหลังจาก transaction แรก commit แล้ว จึงไม่ใช่ dirty write แต่ก็ยังผิดอยู่ เพียงด้วยเหตุผลคนละอย่าง ใน "Preventing Lost Updates" เราจะพูดถึงวิธีทำให้การ increment counter แบบนี้ปลอดภัย
Figure 8-5. เมื่อมี dirty write การเขียนที่ขัดแย้งกันจากคนละ transaction อาจปนกันได้
Implementing read-committed (การ implement read-committed)
Read-committed เป็น isolation level ที่นิยมมาก และเป็นค่าเริ่มต้นใน Oracle Database, PostgreSQL, SQL Server และฐานข้อมูลอื่น ๆ อีกมาก [ 10 ]
โดยทั่วไป ฐานข้อมูลป้องกัน dirty write ด้วย row-level lock เมื่อ transaction ต้องการแก้ไขแถวใดแถวหนึ่ง (หรือ document หรือ object อื่น) มันต้องได้ lock ของแถวนั้นก่อน แล้วถือ lock นั้นไว้จนกว่า transaction จะ commit หรือ abort แถวหนึ่งมี transaction เดียวเท่านั้นที่ถือ lock ได้ ถ้า transaction อื่นต้องการเขียนแถวเดียวกัน ต้องรอจนกว่า transaction แรกจะ commit หรือ abort จึงจะได้ lock และทำงานต่อได้ ฐานข้อมูลจะล็อกให้โดยอัตโนมัติในโหมด read-committed (หรือใน isolation level ที่เข้มงวดกว่า)
แล้วจะป้องกัน dirty read ได้อย่างไร? ทางเลือกหนึ่งคือใช้ lock เดียวกัน และให้ transaction ที่ต้องการอ่านแถวหนึ่งขอ lock ชั่วครู่แล้วปล่อยทันทีหลังอ่านเสร็จ ซึ่งทำให้แน่ใจว่าการอ่านจะไม่เกิดขึ้นในขณะที่แถวมีค่าที่ยังไม่ commit (เพราะช่วงนั้น lock ถูกถือโดย transaction ที่กำลังเขียนอยู่)
แต่วิธีที่ใช้ read lock ใช้ไม่ค่อยได้ผลในทางปฏิบัติ เพราะ write transaction ที่รันนาน ๆ เพียงตัวเดียวสามารถบังคับให้ transaction อื่นจำนวนมากต้องรอจนกว่ามันจะเสร็จ แม้ transaction อื่นเหล่านั้นจะอ่านอย่างเดียวและไม่ได้เขียนอะไรลงฐานข้อมูลเลยก็ตาม สิ่งนี้ทำให้ response time ของ read-only transaction แย่ลง และไม่ดีต่อการ operate ระบบ: ความช้าในส่วนหนึ่งของแอปพลิเคชันอาจลามไปกระทบส่วนอื่นที่ไม่เกี่ยวกันเลย เพราะต้องรอ lock
แต่ก็มีฐานข้อมูลบางตัวที่ใช้ lock ป้องกัน dirty read เช่น IBM Db2 และ Microsoft SQL Server เมื่อตั้งค่า read_committed_snapshot=off [ 30 ]
วิธีที่ใช้กันทั่วไปมากกว่าในการป้องกัน dirty read คือวิธีที่แสดงใน รูปที่ 8-4 สำหรับทุกแถวที่ถูกเขียน ฐานข้อมูลจะจำทั้งค่าเก่าที่ commit แล้ว และค่าใหม่ที่ถูกตั้งโดย transaction ที่กำลังถือ write lock อยู่ ขณะที่ transaction นั้นยังทำงานอยู่ transaction อื่นที่อ่านแถวนั้นจะได้ค่าเก่า ต่อเมื่อค่าใหม่ถูก commit แล้วเท่านั้น transaction อื่นจึงจะเปลี่ยนไปอ่านค่าใหม่ (ดู "Multiversion concurrency control" สำหรับรายละเอียดเพิ่มเติม)
ฐานข้อมูลบางตัวรองรับ isolation level ที่อ่อนกว่า เรียกว่า read uncommitted มันป้องกัน dirty write แต่ไม่ป้องกัน dirty read พูดอีกอย่างคือ มันคืนค่าที่ถูกเขียนล่าสุดทันที แม้ transaction ที่เขียนจะยังไม่ commit ก็ตาม วิธีนี้ให้ประสิทธิภาพที่ดีกว่าได้ เพราะฐานข้อมูลไม่ต้องเก็บแถวไว้สองเวอร์ชัน และยังช่วยลดโอกาสเกิด (แต่ไม่ได้ป้องกัน) lost update ซึ่งเราจะพูดถึงใน "Preventing Lost Updates"
Snapshot Isolation and Repeatable Read (Snapshot Isolation และ Repeatable Read)
ถ้ามอง read-committed isolation แค่ผิวเผิน ก็คงไม่แปลกที่จะคิดว่ามันทำทุกอย่างที่ transaction ต้องการแล้ว: มันให้ abort ได้ (จำเป็นสำหรับ atomicity) ป้องกันการอ่านผลลัพธ์ที่ transaction ทำยังไม่เสร็จ และป้องกัน write ที่เกิดพร้อมกันไม่ให้ปนกัน จริง ๆ แล้วสิ่งเหล่านี้เป็นคุณสมบัติที่มีประโยชน์ และเป็นการรับประกันที่แข็งแกร่งกว่าที่คุณจะได้จากระบบที่ไม่รองรับ transaction มาก
แต่ก็ยังมีบั๊ก concurrency อีกหลายแบบที่เกิดได้เมื่อใช้ isolation level นี้ เช่น รูปที่ 8-6 แสดงปัญหาที่เกิดขึ้นได้กับ read-committed isolation
สมมติว่า Aaliyah มีเงินออม $1,000 ในธนาคาร แบ่งเป็นสองบัญชี บัญชีละ $500 transaction หนึ่งโอนเงิน $100 จากบัญชีหนึ่งไปอีกบัญชีหนึ่ง ถ้าเธอโชคร้ายไปดูยอดคงเหลือในบัญชีตอนที่ transaction นั้นกำลังทำงานอยู่พอดี เธออาจเห็นยอดของบัญชีหนึ่งก่อนที่เงินโอนเข้าจะมาถึง (ยังเป็น $500) และเห็นอีกบัญชีหนึ่งหลังจากที่เงินโอนออกไปแล้ว (ยอดใหม่คือ $400) สำหรับ Aaliyah แล้วดูเหมือนเธอมีเงินรวมแค่ $900 ในบัญชี เหมือนกับว่า $100 หายไปอย่างไร้ร่องรอย
ความผิดปกตินี้เรียกว่า read skew และเป็นตัวอย่างของ nonrepeatable read : ถ้า Aaliyah อ่านยอดคงเหลือของบัญชี 1 อีกครั้งตอนท้าย transaction เธอจะเห็นค่าที่ต่างจาก query ก่อนหน้า ($600) Read skew ถือว่ายอมรับได้ภายใต้ read-committed isolation: ยอดคงเหลือที่ Aaliyah เห็นนั้น commit แล้วในตอนที่เธออ่าน
Figure 8-6. Read skew: Aaliyah เห็นฐานข้อมูลในสถานะที่ไม่สอดคล้องกัน
Note (หมายเหตุ)
คำว่า skew น่าเสียดายที่ถูกใช้ในความหมายหลายอย่างเกินไป ก่อนหน้านี้เราใช้มันในความหมายของ unbalanced workload with hot spots (ดู "Skewed Workloads and Relieving Hot Spots" ) แต่ที่นี่หมายถึง timing anomaly (ความผิดปกติด้านจังหวะเวลา)
ในกรณีของ Aaliyah นี่ไม่ใช่ปัญหาถาวร เพราะเธอน่าจะเห็นยอดคงเหลือที่สอดคล้องกันถ้าโหลดเว็บไซต์ธนาคารออนไลน์ใหม่อีกครั้งในอีกไม่กี่วินาที แต่ความไม่สอดคล้องชั่วคราวแบบนี้ยอมรับไม่ได้ในกรณีต่อไปนี้เป็นต้น:
Backups (การสำรองข้อมูล)
การทำ backup ต้องคัดลอกฐานข้อมูลทั้งหมด ซึ่งอาจใช้เวลาหลายชั่วโมงสำหรับฐานข้อมูลขนาดใหญ่ ระหว่างที่ backup กำลังทำงานอยู่ ฐานข้อมูลก็ยังมี write เกิดขึ้นต่อไป คุณจึงอาจได้ backup ที่บางส่วนเป็นข้อมูลเวอร์ชันเก่า และบางส่วนเป็นข้อมูลเวอร์ชันใหม่ ถ้าต้องกู้คืนจาก backup แบบนั้น ความไม่สอดคล้องกัน (เช่น เงินที่หายไป) จะกลายเป็นถาวร
Analytical queries และ integrity checks
บางครั้งคุณอาจต้องรัน query ที่สแกนฐานข้อมูลเกือบทั้งก้อน query แบบนี้พบได้ทั่วไปใน analytics (ดู "Operational Versus Analytical Systems" ) หรืออาจเป็นส่วนหนึ่งของการตรวจสอบ integrity เป็นระยะเพื่อดูว่าทุกอย่างปกติดี (เช่น ตรวจหาข้อมูลเสียหาย) query เหล่านี้มีแนวโน้มจะคืนผลลัพธ์ที่ไร้ความหมายถ้ามันเห็นส่วนต่าง ๆ ของฐานข้อมูลที่จุดเวลาต่างกัน
Snapshot isolation [ 38 ] เป็นวิธีแก้ปัญหาที่พบบ่อยที่สุด แนวคิดคือให้แต่ละ transaction อ่านจาก consistent snapshot ของฐานข้อมูล คือเห็นข้อมูลทั้งหมดที่ commit แล้วในฐานข้อมูล ณ จุดเริ่มต้นของ transaction นั้น แม้ภายหลังข้อมูลจะถูก transaction อื่นเปลี่ยนไปแล้ว แต่ละ transaction ก็จะเห็นเฉพาะข้อมูลเก่า ณ จุดเวลานั้นเท่านั้น
Snapshot isolation มีประโยชน์มากสำหรับ long-running read-only query เช่น backup และ analytics เพราะยากมากที่จะคิดให้ออกว่า query หมายถึงอะไร ถ้าข้อมูลที่มันทำงานด้วยเปลี่ยนไปพร้อมกันขณะที่ query กำลังรัน เมื่อ transaction เห็น consistent snapshot ของฐานข้อมูลที่ถูกแช่แข็งไว้ ณ จุดเวลาใดจุดหนึ่ง ก็จะเข้าใจได้ง่ายกว่ามาก
Snapshot isolation เป็นฟีเจอร์ยอดนิยม: PostgreSQL, MySQL ที่ใช้ storage engine InnoDB, Oracle, SQL Server และอื่น ๆ รองรับมันในรูปแบบต่าง ๆ แม้พฤติกรรมโดยละเอียดจะต่างกันไปในแต่ละระบบ [ 30 , 42 , 43 ] ฐานข้อมูลบางตัว เช่น Oracle, TiDB และ Aurora DSQL ถึงขั้นให้ snapshot isolation เป็น isolation level สูงสุด Cloud data warehouse อย่าง BigQuery ก็มักใช้ snapshot isolation เช่นกัน เพราะให้ภาพของฐานข้อมูล ณ จุดเวลาหนึ่งสำหรับ analytical query
Multiversion concurrency control (การควบคุม concurrency แบบหลายเวอร์ชัน)
เช่นเดียวกับ read-committed isolation การ implement snapshot isolation โดยทั่วไปใช้ write lock เพื่อป้องกัน dirty write (ดู "Implementing read-committed" ) ซึ่งหมายความว่า transaction ที่เขียนสามารถขัดขวางความคืบหน้าของ transaction อื่นที่เขียนแถวเดียวกันได้ แต่การอ่านไม่ต้องใช้ lock เลย ในแง่ประสิทธิภาพ หลักการสำคัญของ snapshot isolation คือ readers ไม่เคย block writers และ writers ไม่เคย block readers ทำให้ฐานข้อมูลรัน read query ที่ใช้เวลานานบน consistent snapshot ไปพร้อมกับการประมวลผล write ตามปกติได้ โดยไม่ต้องแย่ง lock กัน
เพื่อ implement snapshot isolation ฐานข้อมูลขยายกลไกที่เราเห็นสำหรับป้องกัน dirty read ใน รูปที่ 8-4 แทนที่จะมีสองเวอร์ชันของแต่ละแถว (เวอร์ชันที่ commit แล้วกับเวอร์ชันที่ถูกเขียนทับแต่ยังไม่ commit) ฐานข้อมูลอาจต้องเก็บหลายเวอร์ชันที่ commit แล้วของแถวหนึ่ง ๆ เพราะ transaction ที่กำลังทำงานอยู่หลายตัวอาจต้องเห็นสถานะของฐานข้อมูล ณ จุดเวลาที่ต่างกัน เนื่องจากมันเก็บหลายเวอร์ชันของแถวไว้เคียงกัน เทคนิคนี้จึงเรียกว่า multiversion concurrency control (MVCC)
รูปที่ 8-7 แสดงว่า snapshot isolation ที่ใช้ MVCC ถูก implement ใน PostgreSQL อย่างไร [ 42 , 44 , 45 ] (implementation อื่นก็คล้ายกัน) เมื่อ transaction เริ่มต้น มันจะได้รับ transaction ID ที่ไม่ซ้ำกันและเพิ่มขึ้นเสมอ ( txid ) เมื่อ transaction เขียนอะไรลงฐานข้อมูล ข้อมูลที่เขียนจะถูกติดแท็กด้วย transaction ID ของ transaction ที่เขียน (พูดให้แม่นยำ transaction ID ใน PostgreSQL เป็น 32-bit integer จึงจะ overflow หลังจากผ่านไปประมาณ 4 พันล้าน transaction กระบวนการ vacuum จะ cleanup เพื่อให้แน่ใจว่า overflow นั้นไม่กระทบข้อมูล)
Figure 8-7. การ implement snapshot isolation ด้วย multiversion concurrency control
แต่ละแถวในตารางมีฟิลด์ inserted_by ซึ่งเก็บ ID ของ transaction ที่ insert แถวนั้นลงตาราง แต่ละแถวยังมีฟิลด์ deleted_by ซึ่งเริ่มต้นเป็นค่าว่าง ถ้า transaction ลบแถว แถวนั้นจะยังไม่ถูกลบออกจากฐานข้อมูลจริง แต่จะถูกทำเครื่องหมายว่าถูกลบ โดยตั้งฟิลด์ deleted_by เป็น ID ของ transaction ที่ขอลบ ต่อมาเมื่อแน่ใจแล้วว่าไม่มี transaction ใดเข้าถึงข้อมูลที่ถูกลบหรือถูกเขียนทับได้อีก กระบวนการ garbage collection (GC) ของฐานข้อมูลจะลบแถวที่ถูกทำเครื่องหมายว่าลบไว้ และคืนพื้นที่ว่าง
ภายในแล้ว การอัปเดตจะถูกแปลงเป็นการลบตามด้วยการ insert [ 46 ] ตัวอย่างเช่น ใน รูปที่ 8-7 transaction 13 หักเงิน $100 จากบัญชี 2 ทำให้ยอดคงเหลือเปลี่ยนจาก $500 เป็น $400 ตาราง accounts ตอนนี้มีสองแถวสำหรับบัญชี 2: แถวที่มีเงิน $500 ซึ่งถูกทำเครื่องหมายว่าถูกลบโดย transaction 13 และแถวที่มีเงิน $400 ซึ่ง transaction 13 insert เข้าไป
ทุกเวอร์ชันของแถวจะถูกเก็บไว้ใน database heap เดียวกัน (ดู "Storing Values Within the Index" ) ไม่ว่า transaction ที่เขียนมันจะ commit หรือไม่ก็ตาม เวอร์ชันต่าง ๆ ของแถวเดียวกันจะเชื่อมกันเป็น linked list จากเวอร์ชันล่าสุดไปเก่าสุดหรือกลับกัน เพื่อให้ query สามารถ iterate ผ่านทุกเวอร์ชันของแถวนั้นภายในได้ [ 47 , 48 ]
Visibility rules for observing a consistent snapshot (กฎการมองเห็นสำหรับการสังเกต consistent snapshot)
เมื่อ transaction อ่านจากฐานข้อมูล จะใช้ transaction ID ตัดสินว่าแถวเวอร์ชันไหนที่มันเห็นได้และเวอร์ชันไหนที่มองไม่เห็น เมื่อกำหนด visibility rule (กฎการมองเห็น) อย่างรอบคอบ ฐานข้อมูลก็สามารถให้ consistent snapshot ของข้อมูลแก่แอปพลิเคชันได้ โดยคร่าว ๆ มีวิธีทำงานดังนี้ [ 45 ]:
-
เมื่อเริ่มแต่ละ transaction ฐานข้อมูลจะสร้างรายการของ transaction อื่นทั้งหมดที่กำลังทำงานอยู่ (ยังไม่ commit หรือ abort) ในขณะนั้น write ใด ๆ ที่ transaction เหล่านั้นทำจะถูกเมินเสีย แม้ภายหลังพวกมันจะ commit ก็ตาม ซึ่งรับประกันว่าแอปพลิเคชันเห็น consistent snapshot ที่ไม่ได้รับผลกระทบจากการ commit ของ transaction อื่น
-
write ใด ๆ ที่ทำโดย transaction ที่มี transaction ID ทีหลัง (คือเริ่มหลังจาก transaction ปัจจุบันเริ่ม จึงไม่อยู่ในรายการ transaction ที่กำลังทำงานอยู่) จะถูกเมิน ไม่ว่า transaction เหล่านั้นจะ commit หรือไม่
-
write ใด ๆ ที่ทำโดย transaction ที่ถูก abort จะถูกเมิน ไม่ว่า abort จะเกิดขึ้นเมื่อไร ข้อดีคือเมื่อ transaction ถูก abort เราไม่ต้องรีบลบแถวที่มันเขียนออกจาก storage ทันที เพราะกฎการมองเห็นจะกรองมันออกให้ แล้วกระบวนการ GC ค่อยลบทีหลังได้
-
write อื่นทั้งหมดจะถูก query ของแอปพลิเคชันมองเห็น
กฎเหล่านี้ใช้กับทั้งการ insert และการลบแถว ใน รูปที่ 8-7 เมื่อ transaction 12 อ่านจากบัญชี 2 มันเห็นยอดคงเหลือ $500 เพราะการลบยอด $500 นั้นทำโดย transaction 13 (ตามกฎข้อ 2 transaction 12 มองไม่เห็นการลบที่ transaction 13 ทำ) และยอด $400 ที่ถูก insert เข้ามาก็ยังมองไม่เห็นเช่นกัน (ตามกฎเดียวกัน)
พูดอีกอย่างคือ แถวจะมองเห็นได้ถ้าเงื่อนไขทั้งสองข้อต่อไปนี้เป็นจริง:
-
ณ เวลาที่ transaction ของผู้อ่านเริ่มต้น transaction ที่ insert แถวนั้นได้ commit แล้ว
-
แถวไม่ได้ถูกทำเครื่องหมายว่าถูกลบ หรือถ้าถูกทำเครื่องหมายไว้ transaction ที่ขอลบก็ยังไม่ได้ commit ณ เวลาที่ transaction ของผู้อ่านเริ่มต้น
Transaction ที่ทำงานนานอาจใช้ snapshot เดิมไปเรื่อย ๆ เป็นเวลานาน และอ่านค่าที่ (ในมุมมองของ transaction อื่น) ถูกเขียนทับหรือลบไปนานแล้ว เพราะฐานข้อมูลไม่เคยอัปเดตค่าในตำแหน่งเดิม แต่ insert เวอร์ชันใหม่ทุกครั้งที่ค่าเปลี่ยน จึงให้ consistent snapshot ได้โดยมี overhead เพียงเล็กน้อย
Indexes and snapshot isolation (Indexes และ snapshot isolation)
index ทำงานอย่างไรในฐานข้อมูลแบบ multiversion? วิธีที่พบบ่อยที่สุดคือให้แต่ละ index entry ชี้ไปที่เวอร์ชันใดเวอร์ชันหนึ่งของแถวที่ตรงกับ entry นั้น (จะเป็นเวอร์ชันเก่าที่สุดหรือใหม่ที่สุดก็ได้) แต่ละ row version อาจมี reference ไปยังเวอร์ชันที่เก่ากว่าถัดไปหรือใหม่กว่าถัดไป query ที่ใช้ index จึงต้อง iterate ผ่านแถวเหล่านั้นเพื่อหาแถวที่มองเห็นได้ และมีค่าตรงกับที่ query กำลังหา เมื่อ GC ลบ row version เก่าที่ไม่มี transaction ใดมองเห็นอีกแล้ว ก็ลบ index entry ที่เกี่ยวข้องออกได้เช่นกัน
รายละเอียดการ implement จำนวนมากส่งผลต่อประสิทธิภาพของ multiversion concurrency control [ 47 , 48 ] เช่น PostgreSQL มี optimization ที่ช่วยเลี่ยงการอัปเดต index ถ้าเวอร์ชันต่าง ๆ ของแถวเดียวกันพอดีอยู่ใน page เดียวกันได้ [ 42 ] ฐานข้อมูลอื่นบางตัวไม่เก็บสำเนาเต็มของแถวที่ถูกแก้ไข แต่เก็บเฉพาะส่วนต่างระหว่างเวอร์ชันเพื่อประหยัดพื้นที่
CouchDB, Datomic และ LMDB ใช้อีกแนวทางหนึ่ง แม้พวกมันจะใช้ B-trees (ดู "B-Trees" ) เหมือนกัน แต่ใช้ immutable (copy-on-write) ซึ่งไม่เขียนทับ page ของ tree เมื่อมีการอัปเดต แต่จะสร้างสำเนาใหม่ของแต่ละ page ที่ถูกแก้ไขแทน parent page ขึ้นไปจนถึง root ของ tree จะถูกคัดลอกและอัปเดตให้ชี้ไปยัง child page เวอร์ชันใหม่ ส่วน page ที่ไม่ได้รับผลกระทบจากการเขียนไม่ต้องคัดลอก และใช้ร่วมกับ tree ใหม่ได้ [ 49 ]
กับ immutable B-tree ทุก write transaction (หรือ batch ของ transaction) จะสร้าง B-tree root ใหม่ และ root ใด root หนึ่งก็คือ consistent snapshot ของฐานข้อมูล ณ จุดเวลาที่มันถูกสร้างขึ้น ไม่จำเป็นต้องกรองแถวตาม transaction ID เพราะ write ที่ตามมาแก้ไข B-tree ที่มีอยู่ไม่ได้ ทำได้แค่สร้าง tree root ใหม่ แนวทางนี้ยังต้องมี background process สำหรับ compaction และ GC ด้วย
Snapshot isolation, repeatable read, and naming confusion (Snapshot isolation, repeatable read, และความสับสนในการตั้งชื่อ)
MVCC เป็นเทคนิคการ implement ที่ฐานข้อมูลใช้กันทั่วไป และมักใช้ implement snapshot isolation แต่ฐานข้อมูลแต่ละตัวบางครั้งใช้คำศัพท์ต่างกันเรียกสิ่งเดียวกัน เช่น snapshot isolation ถูกเรียกว่า "repeatable read" ใน PostgreSQL และ "serializable" ใน Oracle [ 30 ] นอกจากนี้ บางครั้งระบบต่างกันก็ใช้คำเดียวกันแต่ความหมายต่างกัน เช่น ใน PostgreSQL "repeatable read" หมายถึง snapshot isolation แต่ใน MySQL หมายถึงการ implement MVCC ที่ให้ความสอดคล้องอ่อนกว่า snapshot isolation [ 43 ] และ IBM Db2 ใช้ "repeatable read" เรียก serializability [ 10 ]
ที่มาของความสับสนในการตั้งชื่อคือ SQL standard ไม่มีแนวคิดเรื่อง snapshot isolation เพราะมาตรฐานอิงจากนิยาม isolation level ของ System R ในปี 1975 [ 3 ] และตอนนั้นยังไม่มีการคิดค้น snapshot isolation มาตรฐานจึงกำหนด repeatable read isolation แทน ซึ่งดูผิวเผินแล้วคล้ายกับ snapshot isolation PostgreSQL เรียก snapshot isolation level ของตัวเองว่า repeatable read เพราะมันเป็นไปตามข้อกำหนดของมาตรฐาน จึงอ้างได้ว่าเป็นไปตามมาตรฐาน
น่าเสียดายที่นิยาม isolation level ใน SQL standard มีข้อบกพร่อง คือคลุมเครือ ไม่แม่นยำ และไม่เป็นอิสระจากการ implement เท่าที่มาตรฐานควรจะเป็น [ 38 ] แม้ฐานข้อมูลหลายตัวจะ implement repeatable read isolation แต่การรับประกันที่ให้ก็ต่างกันมาก ทั้งที่ในนามแล้วการรับประกันเหล่านั้นถูกกำหนดเป็นมาตรฐาน [ 30 ] isolation level นี้ถูกนิยามอย่างเป็นทางการไว้ในงานวิจัย [ 39 , 40 ] แต่ implementation ส่วนใหญ่ไม่เป็นไปตามนิยามทางการนั้น ผลก็คือไม่มีใครรู้จริง ๆ ว่า repeatable read isolation หมายถึงอะไร
Preventing Lost Updates (การป้องกัน Lost Updates)
ที่ผ่านมา เราพูดถึง read-committed และ snapshot isolation level โดยเน้นที่การรับประกันว่า read-only transaction จะเห็นอะไรได้บ้างเมื่อมี concurrent write เกิดขึ้น ส่วนประเด็นที่สอง transaction เขียนพร้อมกันนั้นเราแทบไม่ได้พูดถึง มีแค่ dirty write (ดู "No dirty writes" ) ซึ่งเป็น write-write conflict ชนิดหนึ่งที่เกิดขึ้นได้
ยังมี conflict ที่น่าสนใจอีกหลายชนิดที่เกิดขึ้นได้ระหว่าง transaction ที่เขียนพร้อมกัน ชนิดที่รู้จักกันดีที่สุดคือปัญหา lost update ที่แสดงใน รูปที่ 8-1 ด้วยตัวอย่างของการ increment counter พร้อมกันสองครั้ง
ปัญหา lost update เกิดขึ้นได้ถ้าแอปพลิเคชันอ่านค่าจากฐานข้อมูล แก้ไข แล้วเขียนค่าที่แก้ไขแล้วกลับไป (read-modify-write cycle ที่กล่าวถึงก่อนหน้านี้) ถ้าสอง transaction ทำแบบนี้พร้อมกัน การแก้ไขหนึ่งอาจหายไปได้ เพราะ write ที่สองไม่ได้รวมการแก้ไขครั้งแรกไว้ด้วย (บางครั้งเราเรียกว่า write ทีหลัง clobbers write ก่อนหน้า) รูปแบบนี้เกิดขึ้นได้ในหลายสถานการณ์ เช่น:
-
การ increment counter หรืออัปเดตยอดคงเหลือในบัญชี (ต้องอ่านค่าปัจจุบัน คำนวณค่าใหม่ แล้วเขียนค่าที่อัปเดตแล้วกลับไป)
-
การแก้ไขบางส่วนของค่าที่ซับซ้อน เช่น การเพิ่ม element เข้าไปใน list ภายใน JSON document (ต้อง parse เอกสาร แก้ไข แล้วเขียนเอกสารที่แก้ไขแล้วกลับไป)
-
ผู้ใช้สองคนแก้ไขหน้า wiki พร้อมกัน โดยแต่ละคนบันทึกการเปลี่ยนแปลงด้วยการส่งเนื้อหาทั้งหน้าไปยังเซิร์ฟเวอร์ ซึ่งเขียนทับสิ่งที่อยู่ในฐานข้อมูลในขณะนั้น
เนื่องจากนี่เป็นปัญหาที่พบบ่อยมาก จึงมีวิธีแก้ที่หลากหลายถูกพัฒนาขึ้น [ 50 ] เราจะดูวิธีที่พบบ่อยที่สุดในที่นี้
Atomic write operations (การดำเนินการเขียนแบบอะตอม)
ฐานข้อมูลจำนวนมากมี atomic update operation ซึ่งทำให้ไม่ต้อง implement read-modify-write cycle ในโค้ดแอปพลิเคชัน โดยปกติแล้วเป็นวิธีแก้ที่ดีที่สุดถ้าโค้ดของคุณเขียนอยู่ในรูปของ operation เหล่านั้นได้ ตัวอย่างเช่น คำสั่งต่อไปนี้ปลอดภัยต่อ concurrency ในฐานข้อมูล relational ส่วนใหญ่:
UPDATE counters SET value = value + 1 WHERE key = 'foo';
ในทำนองเดียวกัน document database อย่าง MongoDB มี atomic operation สำหรับแก้ไขบางส่วนของ JSON document และ Redis มี atomic operation สำหรับแก้ไข data structure เช่น priority queue แต่ write ไม่ได้ทุกแบบที่จะเขียนในรูป atomic operation ได้ง่าย เช่น การอัปเดตหน้า wiki เกี่ยวข้องกับการแก้ไขข้อความแบบใดก็ได้ ซึ่งจัดการได้ด้วยอัลกอริทึมที่กล่าวถึงใน "Conflict-free replicated datatypes and operational transformation" แต่ในสถานการณ์ที่ใช้ operation เหล่านี้ได้ โดยปกติมันคือตัวเลือกที่ดีที่สุด
Atomic operation มักถูก implement ด้วยการล็อก object แบบ exclusive ตอนที่อ่านมัน เพื่อไม่ให้ transaction อื่นอ่านได้จนกว่าการอัปเดตจะเสร็จ อีกทางเลือกหนึ่งคือบังคับให้ atomic operation ทั้งหมดรันบน thread เดียว
น่าเสียดายที่ ORM framework ทำให้เขียนโค้ดที่ทำ read-modify-write cycle แบบไม่ปลอดภัยได้ง่ายโดยไม่ตั้งใจ แทนที่จะใช้ atomic operation ที่ฐานข้อมูลมีให้ [ 51 , 52 , 53 ] นี่อาจเป็นต้นเหตุของบั๊กที่ซับซ้อนและหาเจอยากด้วยการทดสอบ
Explicit locking (การล็อกอย่างชัดเจน)
อีกทางเลือกหนึ่งในการป้องกัน lost update ถ้า atomic operation ในตัวของฐานข้อมูลให้ฟังก์ชันที่ต้องการไม่ได้ คือให้แอปพลิเคชันล็อก object ที่กำลังจะถูกอัปเดตอย่างชัดเจน แล้วแอปพลิเคชันก็ทำ read-modify-write cycle ได้ และถ้า transaction อื่นพยายามอัปเดตหรือล็อก object เดียวกันพร้อมกัน ก็จะถูกบังคับให้รอจนกว่า read-modify-write cycle แรกจะเสร็จ
ตัวอย่างเช่น เกมผู้เล่นหลายคนที่ผู้เล่นหลายคนย้ายตัวหมากตัวเดียวกันพร้อมกันได้ ในกรณีนี้ atomic operation อาจไม่เพียงพอ เพราะแอปพลิเคชันยังต้องมั่นใจว่าการเดินของผู้เล่นเป็นไปตามกติกาของเกม ซึ่งเกี่ยวข้องกับตรรกะบางอย่างที่คุณไม่สามารถ implement เป็น database query ได้อย่างสมเหตุสมผล แต่คุณอาจใช้ lock เพื่อป้องกันไม่ให้ผู้เล่นสองคนย้ายตัวหมากตัวเดียวกันพร้อมกัน ดังที่แสดงใน ตัวอย่าง 8-1
Example 8-1. การล็อกแถวอย่างชัดเจนเพื่อป้องกัน lost update
BEGIN TRANSACTION;
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE;
-- Check whether move is valid, then update the position
-- of the piece that was returned by the previous SELECT.
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;
คำสั่ง FOR UPDATE ระบุว่าฐานข้อมูลควรล็อกทุกแถวที่คืนโดย query นี้
วิธีนี้ใช้ได้ แต่จะให้ถูกต้องคุณต้องคิดเรื่องตรรกะของแอปพลิเคชันอย่างรอบคอบ เพราะลืมเพิ่ม lock ที่จำเป็นไว้ตรงไหนสักแห่งในโค้ดได้ง่าย และทำให้เกิด race condition
นอกจากนี้ การล็อกหลาย object ยังเสี่ยงต่อ deadlock คือสถานการณ์ที่สอง transaction ขึ้นไปรอให้อีกฝ่ายปล่อย lock ฐานข้อมูลจำนวนมากตรวจจับ deadlock ได้เองและ abort transaction ใดตัวหนึ่งที่เกี่ยวข้อง เพื่อให้ระบบเดินหน้าต่อได้ คุณจัดการสถานการณ์นี้ในระดับแอปพลิเคชันได้ด้วยการ retry transaction ที่ถูก abort
Automatically detecting lost updates (การตรวจจับ lost updates โดยอัตโนมัติ)
Atomic operation และ lock เป็นวิธีป้องกัน lost update ด้วยการบังคับให้ read-modify-write cycle เกิดขึ้นทีละอัน อีกทางเลือกหนึ่งคืออนุญาตให้มันรันขนานกัน และถ้า transaction manager ตรวจพบ lost update ก็ abort transaction นั้นแล้วบังคับให้ทำ read-modify-write cycle ใหม่
ข้อดีของแนวทางนี้คือฐานข้อมูลตรวจสอบได้อย่างมีประสิทธิภาพควบคู่ไปกับ snapshot isolation จริง ๆ แล้ว repeatable read ของ PostgreSQL, serializable ของ Oracle และ snapshot isolation ของ SQL Server จะตรวจจับโดยอัตโนมัติเมื่อเกิด lost update และ abort transaction ที่เป็นต้นเหตุ แต่ repeatable read isolation level ของ MySQL/InnoDB ไม่ตรวจจับ lost update [ 30 , 43 ] ผู้เขียนบางคน [ 38 , 40 ] โต้แย้งว่าฐานข้อมูลต้องป้องกัน lost update ได้จึงจะถือว่าให้ snapshot isolation ดังนั้นภายใต้นิยามนี้ MySQL จึงไม่ได้ให้ snapshot isolation
ข้อดีใหญ่ของ lost update detection คือโค้ดแอปพลิเคชันไม่ต้องใช้ฟีเจอร์พิเศษใด ๆ ของฐานข้อมูล คุณอาจลืมใช้ lock หรือ atomic operation จนเกิดบั๊กได้ แต่ lost update detection ทำงานโดยอัตโนมัติ จึงมีโอกาสผิดพลาดน้อยกว่า อย่างไรก็ตาม คุณยังต้อง retry transaction ที่ถูก abort ในระดับแอปพลิเคชัน
Conditional writes (compare-and-set) (การเขียนแบบมีเงื่อนไข)
ในฐานข้อมูลที่ไม่มี transaction บางครั้งคุณจะพบ conditional write operation ที่ป้องกัน lost update ได้ โดยอนุญาตให้อัปเดตก็ต่อเมื่อค่าไม่เปลี่ยนไปตั้งแต่ที่คุณอ่านครั้งล่าสุด (กล่าวถึงไปแล้วใน "Single-object writes" ) ถ้าค่าปัจจุบันไม่ตรงกับที่คุณอ่านไว้ก่อนหน้า การอัปเดตจะไม่มีผล และต้องทำ read-modify-write cycle ใหม่ มันเทียบได้กับ atomic CAS instruction ที่ CPU จำนวนมากรองรับ ในระดับฐานข้อมูล
ตัวอย่างเช่น เพื่อป้องกันไม่ให้ผู้ใช้สองคนอัปเดตหน้า wiki เดียวกันพร้อมกัน คุณอาจลองทำแบบนี้ โดยคาดหวังว่าการอัปเดตจะเกิดขึ้นก็ต่อเมื่อเนื้อหาของหน้าไม่เปลี่ยนไปตั้งแต่ที่ผู้ใช้เริ่มแก้ไข:
-- This may or may not be safe, depending on the database implementation
UPDATE wiki_pages SET content = 'new content'
WHERE id = 1234 AND content = 'old content';
ถ้าเนื้อหาเปลี่ยนไปและไม่ตรงกับ old content อีกต่อไป การอัปเดตนี้จะไม่มีผล คุณจึงต้องตรวจสอบว่าการอัปเดตมีผลหรือไม่ และ retry ถ้าจำเป็น แทนที่จะเปรียบเทียบเนื้อหาทั้งหมด คุณอาจใช้คอลัมน์ version number ที่ increment ทุกครั้งที่อัปเดต และอัปเดตก็ต่อเมื่อ version number ปัจจุบันไม่เปลี่ยน แนวทางนี้บางครั้งเรียกว่า optimistic locking [ 54 ]
โปรดทราบว่าถ้ามี transaction อื่นแก้ไข content พร้อมกัน เนื้อหาใหม่อาจมองไม่เห็นภายใต้กฎการมองเห็นของ MVCC (ดู "Visibility rules for observing a consistent snapshot" ) implementation ของ MVCC หลายตัวมีข้อยกเว้นของกฎการมองเห็นสำหรับสถานการณ์นี้ คือค่าที่ transaction อื่นเขียนจะมองเห็นได้ตอนประเมิน WHERE clause ของ UPDATE และ DELETE query แม้ว่า write เหล่านั้นจะมองไม่เห็นใน snapshot ตามปกติก็ตาม
Conflict resolution and replication (การแก้ไขความขัดแย้งและการ replication)
ในฐานข้อมูลแบบ replicated (ดู บทที่ 6 ) การป้องกัน lost update จะมีอีกมิติหนึ่งเพิ่มเข้ามา เพราะฐานข้อมูลเหล่านี้มีสำเนาของข้อมูลอยู่บนหลายโหนด และข้อมูลอาจถูกแก้ไขพร้อมกันบนโหนดต่างกัน จึงต้องมีขั้นตอนเพิ่มเติม
Lock และ conditional write operation สมมติว่ามีสำเนาข้อมูลล่าสุดเพียงชุดเดียว แต่ฐานข้อมูลที่มี multi-leader หรือ leaderless replication มักอนุญาตให้ write หลายตัวเกิดขึ้นพร้อมกันและ replicate แบบอะซิงโครนัส จึงรับประกันสำเนาล่าสุดเพียงชุดเดียวไม่ได้ ดังนั้นเทคนิคที่อาศัย lock หรือ conditional write จึงใช้ไม่ได้ในบริบทนี้ (เราจะกลับมาพูดเรื่องนี้ละเอียดขึ้นใน "Linearizability" )
แต่ตามที่กล่าวถึงใน "Dealing with Conflicting Writes" แนวทางทั่วไปในฐานข้อมูลแบบ replicated เช่นนี้คืออนุญาตให้ concurrent write สร้างค่าหลายเวอร์ชันที่ขัดแย้งกัน (หรือที่เรียกว่า siblings ) แล้วใช้โค้ดแอปพลิเคชันหรือ data structure พิเศษมาแก้ไขและรวมเวอร์ชันเหล่านี้ทีหลัง
การรวมค่าที่ขัดแย้งกันป้องกัน lost update ได้ถ้าการอัปเดตเป็น commutative (คือใช้ในลำดับต่างกันบน replica ต่างกันแล้วยังได้ผลลัพธ์เดียวกัน) เช่น การ increment counter และการเพิ่ม element เข้า set เป็น commutative operation นี่คือแนวคิดเบื้องหลัง CRDT ซึ่งเราพบใน "Conflict-free replicated datatypes and operational transformation" แต่ operation บางอย่าง เช่น conditional write ไม่สามารถทำให้เป็น commutative ได้
นอกจากนี้ วิธี LWW conflict resolution ซึ่งเป็นค่าเริ่มต้นในฐานข้อมูลแบบ replicated หลายตัว มีแนวโน้มเกิด lost update ดังที่กล่าวถึงใน "Last write wins (discarding concurrent writes)"
Write Skew and Phantoms (Write Skew และ Phantom)
ในหัวข้อก่อนหน้านี้เราได้ดู dirty writes และ lost updates ซึ่งเป็น race condition สองชนิดที่เกิดขึ้นได้เมื่อ transaction ต่างกันพยายามเขียน object เดียวกันพร้อมกัน เพื่อหลีกเลี่ยงข้อมูลเสียหาย race condition เหล่านี้ต้องถูกป้องกัน ไม่ว่าจะโดยฐานข้อมูลเองอัตโนมัติ หรือด้วยมาตรการที่ทำเองอย่างการใช้ lock หรือ atomic write operation
แต่นั่นยังไม่ใช่ race condition ทั้งหมดที่อาจเกิดขึ้นระหว่างการเขียนพร้อมกัน ในหัวข้อนี้เราจะเห็นตัวอย่างที่ละเอียดอ่อนกว่าของ ความขัดแย้ง
เริ่มจากลองนึกว่าคุณกำลังเขียนแอปพลิเคชันให้แพทย์ใช้จัดการเวร on-call ที่โรงพยาบาล โดยปกติโรงพยาบาลพยายามให้มีแพทย์ on-call หลายคนในเวลาเดียวกัน แต่อย่างน้อยต้องมีหนึ่งคนให้ได้ แพทย์ขอเลิกเวรได้ (เช่น ถ้าป่วย) โดยมีเงื่อนไขว่าต้องมีเพื่อนร่วมงานอย่างน้อยหนึ่งคนยัง on-call ในเวรนั้น [ 55 , 56 ]
ทีนี้ลองนึกว่า Aaliyah และ Bryce เป็นแพทย์ on-call สองคนในเวรหนึ่ง ทั้งคู่รู้สึกไม่สบาย จึงตัดสินใจขอลา แต่โชคร้ายที่ทั้งคู่กดปุ่มขอออกจากเวรในเวลาใกล้เคียงกัน สิ่งที่เกิดขึ้นต่อไปแสดงใน รูปที่ 8-8
ในแต่ละ transaction แอปพลิเคชันของคุณจะตรวจสอบก่อนว่าตอนนี้มีแพทย์ on-call สองคนขึ้นไปหรือไม่ ถ้ามี ก็สันนิษฐานว่าปลอดภัยที่แพทย์หนึ่งคนจะออกจากเวรได้ เนื่องจากฐานข้อมูลใช้ snapshot isolation การตรวจสอบทั้งสองครั้งจึงคืนค่า 2 ทั้งสอง transaction จึงเดินหน้าไปขั้นตอนถัดไป Aaliyah อัปเดตเรคคอร์ดของเธอเพื่อออกจากเวร และ Bryce ก็อัปเดตเรคคอร์ดของเขาเช่นกัน ทั้งสอง transaction commit และตอนนี้ไม่มีแพทย์ on-call เลย ข้อกำหนดของคุณที่ต้องมีแพทย์อย่างน้อยหนึ่งคนถูกละเมิด
Figure 8-8. Write skew ที่ทำให้แอปพลิเคชันเกิดบั๊ก
Characterizing write skew (การอธิบายลักษณะของ write skew)
ความผิดปกตินี้เรียกว่า write skew [ 38 ] มันไม่ใช่ทั้ง dirty write และ lost update เพราะสอง transaction กำลังอัปเดตคนละ object (เรคคอร์ด on-call ของ Aaliyah และของ Bryce ตามลำดับ) แม้จะเห็นได้ไม่ชัดนักว่าเกิด conflict ขึ้น แต่ก็เป็น race condition แน่นอน: ถ้าสอง transaction รันทีละอัน แพทย์คนที่สองจะถูกห้ามไม่ให้ออกจากเวร พฤติกรรมผิดปกตินี้เกิดขึ้นได้ก็เพราะ transaction รันพร้อมกัน
คุณอาจมอง write skew ว่าเป็นรูปแบบทั่วไปของปัญหา lost update Write skew เกิดขึ้นได้ถ้าสอง transaction อ่าน object ชุดเดียวกัน แล้วอัปเดตบางตัวในชุดนั้น (transaction ต่างกันอาจอัปเดต object ต่างกัน) ในกรณีพิเศษที่ transaction ต่างกันอัปเดต object ตัวเดียวกัน คุณจะได้ anomaly แบบ dirty write หรือ lost update (ขึ้นอยู่กับจังหวะเวลา)
เราเห็นแล้วว่ามีหลายวิธีป้องกัน lost update แต่สำหรับ write skew ตัวเลือกของเรามีจำกัดกว่า:
-
Atomic single-object operation ไม่ช่วย เพราะมีหลาย object เข้ามาเกี่ยวข้อง
-
การตรวจจับ lost update โดยอัตโนมัติที่พบใน snapshot isolation บาง implementation น่าเสียดายที่ก็ไม่ช่วยเช่นกัน write skew ไม่ถูกตรวจจับ โดยอัตโนมัติ ใน repeatable read ของ PostgreSQL, repeatable read ของ MySQL/InnoDB, serializable ของ Oracle หรือ snapshot isolation ของ SQL Server [ 30 ] การป้องกัน write skew โดยอัตโนมัติต้องใช้ serializable isolation ที่แท้จริง (ดู "Serializability" )
-
ฐานข้อมูลบางตัวให้คุณกำหนด constraint ซึ่งฐานข้อมูลจะบังคับใช้ให้ (เช่น uniqueness, foreign-key constraint หรือข้อจำกัดบนค่าใดค่าหนึ่ง) แต่การระบุว่าต้องมีแพทย์ on-call อย่างน้อยหนึ่งคน ต้องใช้ constraint ที่เกี่ยวข้องกับหลาย object ฐานข้อมูลส่วนใหญ่ไม่มีการรองรับ constraint แบบนี้ในตัว แม้คุณอาจ implement ได้ด้วย trigger หรือ materialized view ดังที่กล่าวถึงใน "Consistency" [ 12 ]
-
ถ้าใช้ serializable isolation level ไม่ได้ ตัวเลือกที่ดีที่สุดรองลงมาในกรณีนี้น่าจะเป็นการล็อกแถวที่ transaction ขึ้นอยู่กับอย่างชัดเจน ในตัวอย่างแพทย์ คุณอาจเขียนแบบนี้:
BEGIN TRANSACTION; SELECT * FROM doctors WHERE on_call = true AND shift_id = 1234 FOR UPDATE; UPDATE doctors SET on_call = false WHERE name = 'Aaliyah' AND shift_id = 1234; COMMIT;
เหมือนเดิม
FOR UPDATEบอกให้ฐานข้อมูลล็อกทุกแถวที่ query นี้คืนมา
More examples of write skew (ตัวอย่างเพิ่มเติมของ write skew)
Write skew อาจดูเป็นปัญหาลึกลับในตอนแรก แต่เมื่อรู้จักมันแล้ว คุณอาจสังเกตเห็นสถานการณ์อื่นที่มันเกิดขึ้นได้ นี่คือตัวอย่างเพิ่มเติม:
ระบบจองห้องประชุม
สมมติว่าคุณต้องการบังคับว่าห้องประชุมห้องเดียวกันจะถูกจองซ้อนกันในเวลาเดียวกันสองรายการไม่ได้ [ 57 ] เมื่อมีคนต้องการจอง คุณจะตรวจสอบก่อนว่ามีการจองที่ขัดแย้งกันหรือไม่ (คือการจองห้องเดียวกันที่ช่วงเวลาทับซ้อนกัน) และถ้าไม่พบ ก็สร้างการประชุมขึ้นมา (ดู ตัวอย่าง 8-2 )
Example 8-2. ระบบจองห้องประชุมที่พยายามหลีกเลี่ยงการจองซ้ำซ้อน (ไม่ปลอดภัยภายใต้ snapshot isolation)
BEGIN TRANSACTION;
-- Check for any existing bookings that overlap with the period of noon-1pm
SELECT COUNT(*) FROM bookings
WHERE room_id = 123 AND
end_time > '2025-01-01 12:00' AND start_time < '2025-01-01 13:00';
-- If the previous query returned zero:
INSERT INTO bookings
(room_id, start_time, end_time, user_id)
VALUES (123, '2025-01-01 12:00', '2025-01-01 13:00', 666);
COMMIT;
น่าเสียดายที่ snapshot isolation ไม่ได้ป้องกันไม่ให้ผู้ใช้อีกคน insert การประชุมที่ขัดแย้งเข้ามาพร้อมกัน ถ้าจะรับประกันว่าตารางเวลาจะไม่ชนกัน คุณต้องใช้ serializable isolation อีกครั้ง
เกมผู้เล่นหลายคน
ใน ตัวอย่าง 8-1 เรา ใช้ lock เพื่อป้องกัน lost update (ให้แน่ใจว่าผู้เล่นสองคนย้ายตัวหมากตัวเดียวกันพร้อมกันไม่ได้) แต่ lock ไม่ได้ป้องกันไม่ให้ผู้เล่นย้ายตัวหมากสองตัวที่ต่างกันไปยังตำแหน่งเดียวกันบนกระดาน หรืออาจเดินที่ผิดกติกาในแบบอื่น ขึ้นอยู่กับชนิดของกติกาที่คุณต้องการบังคับ คุณอาจใช้ uniqueness constraint ได้ แต่ถ้าไม่ได้ ก็ยังเสี่ยงต่อ write skew
การอ้างชื่อผู้ใช้
บน บนเว็บไซต์ที่ผู้ใช้แต่ละคนต้องมีชื่อผู้ใช้ไม่ซ้ำกัน ผู้ใช้สองคนอาจพยายามสร้างบัญชีด้วยชื่อเดียวกันพร้อมกัน คุณใช้ transaction ตรวจสอบได้ว่าชื่อนั้นถูกใช้แล้วหรือยัง และถ้ายังไม่ถูกใช้ ก็สร้างบัญชีด้วยชื่อนั้น แต่เหมือนตัวอย่างก่อนหน้า วิธีนี้ไม่ปลอดภัยภายใต้ snapshot isolation โชคดีที่กรณีนี้ uniqueness constraint เป็นทางแก้ที่ง่าย (transaction ที่สองที่พยายามลงทะเบียนชื่อนั้นจะถูก abort เพราะละเมิด constraint)
การป้องกันการใช้จ่ายซ้ำซ้อน
บริการ ที่อนุญาตให้ผู้ใช้ใช้จ่ายเงินหรือแต้มสะสมต้องตรวจสอบว่าผู้ใช้ไม่ได้ใช้จ่ายเกินกว่าที่มี คุณอาจ implement ด้วยการ insert spending item ชั่วคราวลงในบัญชีของผู้ใช้ แล้วแสดงรายการทั้งหมดในบัญชีและตรวจสอบว่าผลรวมเป็นบวก แต่เมื่อมี write skew อาจเกิดกรณีที่ spending item สองรายการถูก insert พร้อมกันจนรวมกันแล้วทำให้ยอดคงเหลือติดลบ โดยที่ไม่มี transaction ใดสังเกตเห็นอีกตัวหนึ่ง
Phantoms causing write skew (Phantoms ที่ทำให้เกิด write skew)
ตัวอย่างก่อนหน้าทั้งหมดมีรูปแบบคล้ายกัน:
-
query
SELECTตรวจสอบว่าข้อกำหนดข้อหนึ่งเป็นจริงหรือไม่ โดยค้นหาแถวที่ตรงกับเงื่อนไขการค้นหา (เช่น มีแพทย์ on-call อย่างน้อยสองคน ไม่มีการจองห้องนั้นในเวลานั้น ตำแหน่งบนกระดานยังไม่มีตัวหมากอื่นอยู่ ชื่อผู้ใช้ยังไม่ถูกใช้ หรือยังมีเงินเหลืออยู่ในบัญชี) -
โค้ดแอปพลิเคชันตัดสินใจว่าจะทำอย่างไรต่อโดยดูจากผลของ query แรก (อาจทำ operation นั้นต่อ หรืออาจแจ้งข้อผิดพลาดให้ผู้ใช้และ abort)
-
ถ้าแอปพลิเคชันตัดสินใจทำต่อ มันจะเขียน (
INSERT,UPDATE, หรือDELETE) ลงฐานข้อมูลและ commit transactionผลของการเขียนนี้ทำให้ precondition ของการตัดสินใจในขั้นตอนที่ 2 เปลี่ยนไป พูดอีกอย่างคือ ถ้าคุณรัน query
SELECTซ้ำจากขั้นตอนที่ 1 หลัง commit การเขียนแล้ว คุณจะได้ผลต่างจากเดิม เพราะการเขียนเปลี่ยนชุดของแถวที่ตรงกับเงื่อนไขการค้นหา (ตอนนี้มีแพทย์ on-call น้อยลงหนึ่งคน ห้องประชุมถูกจองในเวลานั้นแล้ว ตำแหน่งบนกระดานถูกตัวหมากที่ย้ายมาครอบครองแล้ว ชื่อผู้ใช้ถูกใช้ไปแล้ว และมีเงินในบัญชีน้อยลง)
ขั้นตอนเหล่านี้อาจเกิดในลำดับต่างออกไป เช่น คุณอาจเขียนก่อน แล้วค่อยรัน query SELECT และสุดท้ายตัดสินใจว่าจะ abort หรือ commit จากผลของ query
ในตัวอย่างแพทย์ on-call แถวที่ถูกแก้ไขในขั้นตอนที่ 3 เป็นหนึ่งในแถวที่ขั้นตอนที่ 1 คืนมา คุณจึงทำให้ transaction ปลอดภัยและหลีกเลี่ยง write skew ได้ด้วยการล็อกแถวในขั้นตอนที่ 1 ( SELECT FOR UPDATE ) แต่อีกสี่ตัวอย่างต่างออกไป: พวกมันตรวจสอบการ ไม่มี ของแถวที่ตรงกับเงื่อนไขการค้นหา และการเขียน เพิ่ม แถวที่ตรงกับเงื่อนไขเดียวกัน ถ้า query ในขั้นตอนที่ 1 ไม่คืนแถวใด ๆ SELECT FOR UPDATE ก็ไม่มีอะไรให้ attach lock ได้ [ 58 ]
ผลกระทบนี้ ที่ write ใน transaction หนึ่งเปลี่ยนผลลัพธ์ของ search query ใน transaction อื่น เรียกว่า phantom [ 4 ] Snapshot isolation หลีกเลี่ยง phantom ใน read-only query ได้ แต่ใน read/write transaction อย่างตัวอย่างที่เราคุยกัน phantom อาจนำไปสู่ write skew ที่แก้ยากเป็นพิเศษ SQL ที่ ORM สร้างขึ้นก็มีแนวโน้มเกิด write skew เช่นกัน [ 52 , 53 ]
Materializing conflicts (การทำให้ความขัดแย้งเป็นรูปธรรม)
ถ้าปัญหาของ phantom คือไม่มี object ให้เรา attach lock ได้ บางทีเราอาจสร้าง lock object เทียมขึ้นมาในฐานข้อมูลเองก็ได้?
ตัวอย่างเช่น ในกรณีการจองห้องประชุม ลองนึกถึงตารางของ time slot กับห้อง แต่ละแถวในตารางนี้ตรงกับห้องหนึ่งในช่วงเวลาหนึ่ง (เช่น 15 นาที) คุณสร้างแถวสำหรับทุกชุดค่าผสมของห้องและช่วงเวลาที่เป็นไปได้ไว้ล่วงหน้า (เช่น สำหรับหกเดือนข้างหน้า)
ทีนี้ transaction ที่ต้องการสร้างการจองก็ล็อก ( SELECT FOR UPDATE ) แถวในตารางที่ตรงกับห้องและช่วงเวลาที่ต้องการได้ หลังจากได้ lock แล้ว transaction ก็ตรวจสอบการจองที่ทับซ้อนกัน และ insert การจองใหม่ได้เหมือนเดิม โปรดทราบว่าตารางเพิ่มเติมนี้ไม่ได้ใช้เก็บข้อมูลเกี่ยวกับการจอง แต่เป็นเพียงชุดของ lock ที่ใช้ป้องกันไม่ให้มีการจองห้องและช่วงเวลาเดียวกันพร้อมกัน
แนวทางนี้เรียกว่า materializing conflicts (การทำให้ความขัดแย้งเป็นรูปธรรม) เพราะมันเอา phantom มาเปลี่ยนเป็น lock conflict บนชุดของแถวที่มีอยู่จริงในฐานข้อมูล [ 14 ] น่าเสียดายที่การคิดหาวิธี materialize conflict อาจทำได้ยากและพลาดง่าย และก็ไม่สวยที่จะปล่อยให้กลไกควบคุม concurrency รั่วเข้ามาอยู่ใน data model ของแอปพลิเคชัน ด้วยเหตุผลเหล่านี้ materializing conflicts ควรถือเป็นทางเลือกสุดท้ายเมื่อไม่มีทางอื่น ส่วนใหญ่แล้ว serializable isolation level น่าใช้กว่า
Serializability (ความสามารถในการจัดลำดับ)
ในบทนี้เราเห็นตัวอย่างหลายอย่างของ transaction ที่เสี่ยงต่อ race condition บางอย่างถูกป้องกันโดย read-committed และ snapshot isolation level แต่บางอย่างไม่ถูกป้องกัน เราเจอตัวอย่างที่แก้ยากเป็นพิเศษคือ write skew และ phantom นับเป็นสถานการณ์ที่น่าเศร้า:
-
Isolation level เข้าใจยาก และแต่ละฐานข้อมูล implement ไม่เหมือนกัน (เช่น ความหมายของ "repeatable read" ต่างกันมาก)
-
ดูจากโค้ดแอปพลิเคชันแล้วบอกได้ยากว่ารันที่ isolation level หนึ่งแล้วปลอดภัยหรือไม่ โดยเฉพาะในแอปพลิเคชันขนาดใหญ่ ที่คุณอาจไม่รู้ว่ามีอะไรอีกบ้างที่อาจเกิดขึ้นพร้อมกัน
-
ไม่มีเครื่องมือดี ๆ ที่ช่วยเราตรวจจับ race condition ในหลักการ static analysis อาจช่วยได้ [ 35 ] แต่เทคนิคจากงานวิจัยยังไม่ถูกนำมาใช้จริง การทดสอบปัญหา concurrency ก็ยาก เพราะส่วนใหญ่ไม่ deterministic ปัญหาจะเกิดก็ต่อเมื่อคุณโชคร้ายเจอจังหวะเวลาที่พอดี
นี่ไม่ใช่ปัญหาใหม่ มันเป็นแบบนี้มาตั้งแต่ทศวรรษ 1970 ที่เริ่มมี weak isolation level [ 3 ] มาตลอด คำตอบของนักวิจัยก็เรียบง่าย: ใช้ serializable isolation!
Serializable isolation เป็น isolation level ที่แข็งแกร่งที่สุด มันรับประกันว่าแม้ transaction จะรันแบบขนานได้ ผลลัพธ์สุดท้ายจะเหมือนกับว่ามันถูกรันทีละตัว แบบ serial โดยไม่มี concurrency ฐานข้อมูลจึงรับประกันว่าถ้า transaction ทำงานถูกต้องเมื่อรันทีละตัว มันก็จะยังถูกต้องเมื่อรันพร้อมกัน พูดอีกอย่างคือ ฐานข้อมูลป้องกัน ทั้งหมด race condition ที่เป็นไปได้
แต่ถ้า serializable isolation ดีกว่า weak isolation level ที่ยุ่งเหยิงมากขนาดนั้น ทำไมไม่ทุกคนใช้ล่ะ? เพื่อตอบคำถามนี้ เราต้องดูตัวเลือกในการ implement serializability และประสิทธิภาพของแต่ละแบบ ฐานข้อมูลส่วนใหญ่ที่ให้ serializability ในปัจจุบันใช้หนึ่งในสามเทคนิคนี้ ซึ่งเราจะสำรวจในส่วนที่เหลือของบท:
-
รัน transaction ตามลำดับ serial จริง ๆ (ดูหัวข้อถัดไป)
-
Two-phase locking (ดู "Two-Phase Locking" ) ซึ่งเป็นตัวเลือกเดียวที่ใช้งานได้จริงมาหลายทศวรรษ
-
เทคนิค optimistic concurrency control เช่น serializable snapshot isolation (ดู "Serializable Snapshot Isolation" )
Actual Serial Execution (การดำเนินการแบบ Serial จริง)
วิธีที่ง่ายที่สุดในการหลีกเลี่ยงปัญหา concurrency คือเอา concurrency ออกให้หมด: รันทีละ transaction ตามลำดับ serial บน thread เดียว วิธีนี้ทำให้เราเลี่ยงปัญหาการตรวจจับและป้องกัน conflict ระหว่าง transaction ไปได้ทั้งหมด isolation ที่ได้จึง serializable ตามนิยาม
แม้นี่อาจดูเป็นแนวคิดที่ชัดเจน แต่กว่าที่นักออกแบบฐานข้อมูลจะเห็นว่า loop แบบ single-threaded สำหรับรัน transaction ใช้ได้จริง ก็ต้องรอถึงทศวรรษ 2000 [ 59 ] ในเมื่อ 30 ปีก่อนหน้านั้น concurrency แบบ multithreaded ถูกมองว่าจำเป็นต่อการได้ประสิทธิภาพที่ดี แล้วอะไรเปลี่ยนไปจนการรันแบบ single-threaded ทำได้จริง?
มีสองพัฒนาการที่ทำให้ต้องคิดใหม่:
-
RAM ถูกลงพอที่ใน use case จำนวนมาก เราเก็บ active dataset ทั้งหมดไว้ในหน่วยความจำได้แล้ว (ดู "Keeping Everything in Memory" ) เมื่อข้อมูลทั้งหมดที่ transaction ต้องเข้าถึงอยู่ในหน่วยความจำ transaction จะรันได้เร็วกว่าตอนที่ต้องรอโหลดข้อมูลจากดิสก์มาก
-
นักออกแบบฐานข้อมูลตระหนักว่า OLTP transaction มักสั้นและอ่านเขียนแค่ไม่กี่ครั้ง (ดู "Operational Versus Analytical Systems" ) ส่วน analytical query ที่รันนานมักเป็น read-only จึงรันบน consistent snapshot (ด้วย snapshot isolation) นอก loop ที่รัน transaction แบบ serial ได้
แนวทางการรัน transaction แบบ serial ถูก implement ใน VoltDB/H-Store, Redis และ Datomic เป็นต้น [ 60 , 61 , 62 ] ระบบที่ออกแบบมาให้รันแบบ single-threaded บางครั้งทำงานได้ดีกว่าระบบที่รองรับ concurrency เพราะเลี่ยง overhead ในการประสานงานของ lock ได้ แต่ throughput ของมันถูกจำกัดอยู่ที่ CPU core เดียว เพื่อใช้ thread เดียวนั้นให้คุ้มที่สุด transaction ต้องมีโครงสร้างต่างจากรูปแบบดั้งเดิม
Encapsulating transactions in stored procedures (การห่อหุ้ม transactions ใน stored procedures)
ในยุคแรกของฐานข้อมูล ความตั้งใจคือให้ database transaction ครอบคลุมกิจกรรมของผู้ใช้ทั้งกระบวนการ เช่น การจองตั๋วเครื่องบินเป็นกระบวนการหลายขั้นตอน (ค้นหาเส้นทาง ราคา และที่นั่งว่าง ตัดสินใจเลือกแผนการเดินทาง จองที่นั่งในแต่ละเที่ยวบินของแผนนั้น กรอกรายละเอียดผู้โดยสาร ชำระเงิน) นักออกแบบฐานข้อมูลคิดว่าคงดีถ้ากระบวนการทั้งหมดนั้นเป็น transaction เดียว เพื่อให้ commit แบบ atomic ได้
น่าเสียดายที่มนุษย์ตัดสินใจและตอบสนองช้ามาก ถ้า database transaction ต้องรอ input จากผู้ใช้ ฐานข้อมูลก็ต้องรองรับ transaction ที่ทำงานพร้อมกันได้จำนวนมหาศาล ซึ่งส่วนใหญ่ idle อยู่ ฐานข้อมูลส่วนใหญ่ทำแบบนั้นได้ไม่มีประสิทธิภาพ แอปพลิเคชัน OLTP เกือบทั้งหมดจึงทำให้ transaction สั้นเข้าไว้ โดยไม่รอผู้ใช้แบบ interactive อยู่ภายใน transaction บนเว็บ นั่นหมายความว่า transaction ถูก commit ภายใน HTTP request เดียวกัน คือ transaction ไม่ได้ครอบคลุมหลาย request และ HTTP request ใหม่จะเริ่ม transaction ใหม่
แม้จะเอามนุษย์ออกจาก critical path แล้ว transaction ก็ยังคงรันในสไตล์ client/server แบบ interactive ทีละ statement อยู่ แอปพลิเคชันส่ง query อ่านผลลัพธ์ อาจส่ง query อื่นต่อโดยขึ้นอยู่กับผลลัพธ์ของ query แรก และเป็นอย่างนี้ไปเรื่อย ๆ query และผลลัพธ์ถูกส่งไปมาระหว่างโค้ดแอปพลิเคชัน (ที่รันบนเครื่องหนึ่ง) กับเซิร์ฟเวอร์ฐานข้อมูล (อีกเครื่องหนึ่ง)
ใน transaction แบบ interactive นี้ เวลาส่วนใหญ่หมดไปกับการสื่อสารผ่านเครือข่ายระหว่างแอปพลิเคชันกับฐานข้อมูล ถ้าคุณไม่อนุญาต concurrency ในฐานข้อมูลและประมวลผลทีละ transaction throughput จะแย่มาก เพราะฐานข้อมูลจะใช้เวลาเกือบทั้งหมดรอให้แอปพลิเคชันส่ง query ถัดไปของ transaction ปัจจุบัน ในฐานข้อมูลแบบนี้ จำเป็นต้องประมวลผลหลาย transaction พร้อมกันเพื่อให้ได้ประสิทธิภาพที่สมเหตุสมผล
ด้วยเหตุนี้ ระบบที่ประมวลผล transaction แบบ single-threaded serial จึงไม่อนุญาต interactive multistatement transaction แอปพลิเคชันต้องจำกัดตัวเองให้ใช้ transaction ที่มี statement เดียว หรือไม่ก็ส่งโค้ดของทั้ง transaction ไปให้ฐานข้อมูลล่วงหน้าเป็น stored procedure [ 63 ]
ความแตกต่างระหว่าง interactive transaction กับ stored procedure แสดงใน รูปที่ 8-9 ถ้าข้อมูลทั้งหมดที่ transaction ต้องการอยู่ในหน่วยความจำ stored procedure จะรันได้เร็วมาก โดยไม่ต้องรอ network หรือ disk I/O
Figure 8-9. ความแตกต่างระหว่าง interactive transaction และ stored procedure (ใช้ตัวอย่าง transaction ของ รูปที่ 8-8 )
Pros and cons of stored procedures (ข้อดีและข้อเสียของ stored procedures)
Stored procedure มีอยู่ในฐานข้อมูล relational มานานแล้ว และเป็นส่วนหนึ่งของมาตรฐาน SQL (SQL/PSM) ตั้งแต่ปี 1999 แต่ค่อนข้างมีชื่อเสียงไม่ดีด้วยหลายเหตุผล:
-
ตามธรรมเนียม ผู้ผลิตฐานข้อมูลแต่ละรายมีภาษาของตัวเองสำหรับ stored procedure (Oracle มี PL/SQL, SQL Server มี T-SQL, PostgreSQL มี PL/pgSQL ฯลฯ) ภาษาเหล่านี้ตามพัฒนาการของ general-purpose programming language ไม่ทัน จึงดูค่อนข้างน่าเกลียดและล้าสมัยเมื่อมองจากปัจจุบัน และไม่มีระบบนิเวศของ library แบบที่ภาษาโปรแกรมสมัยใหม่ส่วนใหญ่มี
-
โค้ดที่รันอยู่ในฐานข้อมูลจัดการยาก เมื่อเทียบกับ application server แล้วมัน debug ยากกว่า เก็บใน version control และ deploy ลำบากกว่า ทดสอบยากกว่า และเชื่อมกับระบบเก็บ metrics เพื่อ monitor ได้ยาก
-
ฐานข้อมูลมักไวต่อเรื่องประสิทธิภาพมากกว่า application server มาก เพราะ database instance ตัวเดียวมักถูก application server หลายตัวใช้ร่วมกัน stored procedure ที่เขียนไม่ดีในฐานข้อมูล (เช่น กินหน่วยความจำหรือเวลา CPU มาก หรือแม้แต่ทำให้ crash) สร้างปัญหาได้มากกว่าโค้ดที่เขียนไม่ดีในลักษณะเดียวกันบน application server มาก
-
ในระบบ multitenant ที่อนุญาตให้ tenant เขียน stored procedure เอง การรันโค้ดที่ไม่น่าเชื่อถือใน process เดียวกับ database kernel เป็นความเสี่ยงด้านความปลอดภัย [ 64 ]
แต่ปัญหาเหล่านั้นแก้ได้ stored procedure สมัยใหม่เลิกใช้ PL/SQL และหันมาใช้ general-purpose programming language ที่มีอยู่แล้วแทน VoltDB ใช้ Java หรือ Groovy, Datomic ใช้ Java หรือ Clojure, Redis ใช้ Lua, และ MongoDB ใช้ JavaScript
Stored procedure ยังมีประโยชน์เมื่อฝังตรรกะของแอปพลิเคชันไว้ที่อื่นได้ยาก เช่น แอปพลิเคชันที่ใช้ GraphQL อาจเปิดฐานข้อมูลของตัวเองให้เรียกตรงผ่าน GraphQL proxy ถ้า proxy ไม่รองรับตรรกะ validation ที่ซับซ้อน คุณก็ฝังตรรกะนั้นลงในฐานข้อมูลโดยตรงด้วย stored procedure ได้ ถ้าฐานข้อมูลไม่รองรับ stored procedure คุณจะต้อง deploy validation service คั่นระหว่าง proxy กับฐานข้อมูลเพื่อทำ validation
เมื่อใช้ stored procedure กับข้อมูลใน memory การรัน transaction ทั้งหมดบน thread เดียวก็ทำได้จริง เมื่อ stored procedure ไม่ต้องรอ I/O และเลี่ยง overhead ของกลไก concurrency control อื่น ๆ ได้ มันก็ได้ throughput ค่อนข้างดีบน thread เดียว
VoltDB ยังใช้ stored procedure สำหรับ replication ด้วย แทนที่จะคัดลอก write ของ transaction จากโหนดหนึ่งไปอีกโหนดหนึ่ง มันรัน stored procedure เดียวกันบนแต่ละ replica VoltDB จึงกำหนดว่า stored procedure ต้องเป็น deterministic (เมื่อรันบนโหนดต่างกัน ต้องได้ผลลัพธ์เดียวกัน) เช่น ถ้า transaction ต้องใช้วันที่และเวลาปัจจุบัน ก็ต้องทำผ่าน deterministic API พิเศษ (ดู "Durable Execution and Workflows" สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับ deterministic operation) แนวทางนี้เรียกว่า state machine replication ซึ่งเราจะกลับมาพูดถึงอีกครั้งใน บทที่ 10
Sharding (การแบ่งส่วนข้อมูล)
การรัน transaction ทั้งหมดแบบ serial ทำให้ concurrency control ง่ายขึ้นมาก แต่จำกัด transaction throughput ของฐานข้อมูลไว้ที่ความเร็วของ CPU core เดียวบนเครื่องเดียว Read-only transaction อาจรันที่อื่นโดยใช้ snapshot isolation ได้ แต่สำหรับแอปพลิเคชันที่มี write throughput สูง single-threaded transaction processor อาจกลายเป็นคอขวดร้ายแรงได้
เพื่อ scale ไปใช้หลาย CPU core และหลายโหนด คุณ shard ข้อมูลได้ (ดู บทที่ 7 ) ซึ่ง VoltDB รองรับ ถ้าคุณหาวิธี shard dataset ได้ โดยให้แต่ละ transaction อ่านและเขียนข้อมูลภายใน shard เดียวเท่านั้น แต่ละ shard ก็มี thread ประมวลผล transaction ของตัวเองที่ทำงานอิสระจาก shard อื่นได้ ในกรณีนี้ คุณให้แต่ละ CPU core มี shard ของตัวเองได้ ทำให้ transaction throughput scale เป็นเส้นตรงตามจำนวน CPU core [ 61 ]
แต่สำหรับ transaction ที่ต้องเข้าถึงหลาย shard ฐานข้อมูลต้องประสานงาน transaction นั้นข้ามทุก shard ที่เกี่ยวข้อง และต้องรัน stored procedure แบบ lockstep ในทุก shard เพื่อรับประกัน serializability ทั่วทั้งระบบ
เนื่องจาก cross-shard transaction มี overhead ในการประสานงานเพิ่ม จึงช้ากว่า single-shard transaction มาก VoltDB รายงาน throughput ประมาณ 1,000 cross-shard write ต่อวินาที ซึ่งต่ำกว่า single-shard throughput ของมันหลาย order of magnitude และเพิ่มไม่ได้ด้วยการเพิ่มเครื่อง [ 63 ] งานวิจัยล่าสุดสำรวจวิธีทำให้ multishard transaction scale ได้ดีขึ้น [ 65 ]
ว่า transaction จะเป็น single-shard ได้หรือไม่ขึ้นอยู่กับโครงสร้างข้อมูลที่แอปพลิเคชันใช้เป็นอย่างมาก ข้อมูล key-value แบบง่ายมัก shard ได้ง่ายมาก แต่ข้อมูลที่มี secondary index หลายตัวมีแนวโน้มต้องประสานงานข้าม shard มาก (ดู "Sharding and Secondary Indexes" )
Summary of serial execution (สรุปการดำเนินการแบบ serial)
การรัน transaction แบบ serial กลายเป็นวิธีที่ใช้ได้จริงในการทำ serializable isolation ภายใต้ข้อจำกัดบางอย่าง:
-
ทุก transaction ต้องเล็กและเร็ว เพราะ transaction ที่ช้าเพียงตัวเดียวก็ทำให้การประมวลผล transaction ทั้งหมดหยุดชะงักได้
-
เหมาะที่สุดเมื่อ active dataset พอดีอยู่ในหน่วยความจำ ข้อมูลที่ไม่ค่อยถูกเข้าถึงอาจย้ายไปไว้บนดิสก์ได้ แต่ถ้าต้องเข้าถึงใน single-threaded transaction ระบบจะช้ามาก
-
Write throughput ต้องต่ำพอให้ CPU core เดียวรับไหว ไม่เช่นนั้นต้อง shard transaction โดยไม่ต้องมีการประสานงานข้าม shard
-
Cross-shard transaction ทำได้ แต่ throughput ของมัน scale ยาก
Two-Phase Locking (การล็อกสองเฟส)
เป็นเวลาราว 30 ปีที่มีอัลกอริทึมเพียงตัวเดียวที่ใช้กันอย่างแพร่หลายสำหรับ serializability ในฐานข้อมูล คือ two-phase locking (2PL) บางครั้งเรียกว่า strong strict two-phase locking (SS2PL) เพื่อ แยกมันออกจาก 2PL รูปแบบอื่น
2PL is not 2PC (หมายเหตุ)
2PL กับ 2PC ต่างกันมาก 2PL ให้ serializable isolation ส่วน 2PC ให้ atomic commit ในฐานข้อมูลแบบกระจาย (ดู "Two-Phase Commit" ) เพื่อหลีกเลี่ยงความสับสน ควรมองว่าเป็นคนละแนวคิดโดยสิ้นเชิง และไม่ต้องสนใจว่าชื่อบังเอิญคล้ายกัน
ก่อนหน้านี้เราเห็นว่ามักใช้ lock เพื่อป้องกัน dirty write (ดู "No dirty writes" ) ถ้าสอง transaction พยายามเขียน object เดียวกันพร้อมกัน lock จะรับประกันว่าผู้เขียนคนที่สองต้องรอจนกว่าคนแรกจะจบ transaction (abort หรือ commit) จึงจะทำต่อได้
2PL คล้ายกัน แต่เข้มงวดกับ lock มากกว่ามาก หลาย transaction อ่าน object เดียวกันพร้อมกันได้ตราบใดที่ไม่มีใครเขียนมัน แต่ทันทีที่มีใครต้องการเขียน (แก้ไขหรือลบ) object ก็ต้องเข้าถึงแบบ exclusive:
-
ถ้า transaction A อ่าน object ไปแล้ว และ transaction B ต้องการเขียน object นั้น B ต้องรอจนกว่า A จะ commit หรือ abort จึงจะทำต่อได้ (ซึ่งทำให้มั่นใจว่า B จะเปลี่ยน object โดยที่ A ไม่รู้ตัวไม่ได้)
-
ถ้า transaction A เขียน object ไปแล้ว และ transaction B ต้องการอ่าน object นั้น B ต้องรอจนกว่า A จะ commit หรือ abort จึงจะทำต่อได้ (การอ่าน object เวอร์ชันเก่า ดังใน รูปที่ 8-4 ใช้ไม่ได้ภายใต้ 2PL)
ใน 2PL writer ไม่ได้แค่บล็อก writer คนอื่น แต่ยังบล็อก reader ด้วย และกลับกันก็เช่นกัน คติพจน์ที่กล่าวไว้ก่อนหน้านี้ readers never block writers, and writers never block readers ของ snapshot isolation (ดู "Multiversion concurrency control" ) สรุปความแตกต่างสำคัญระหว่าง snapshot isolation กับ 2PL ได้ดี ในทางกลับกัน เพราะ 2PL ให้ serializability มันจึงป้องกัน race condition ทั้งหมดที่กล่าวถึงก่อนหน้านี้ รวมถึง lost update และ write skew
Implementation of 2PL (การ implement 2PL)
2PL ถูกใช้ใน serializable isolation level ของ MySQL/InnoDB และ SQL Server และใน repeatable-read isolation level ของ Db2 [ 30 ]
การบล็อก reader และ writer ถูก implement ด้วยการมี lock บนแต่ละ object ในฐานข้อมูล lock อาจอยู่ใน shared mode หรือ exclusive mode (หรือที่เรียกว่า multi-reader single-writer lock) โดยใช้งานดังนี้:
-
ถ้า transaction ต้องการอ่าน object ต้องขอ lock ใน shared mode ก่อน หลาย transaction ถือ lock ใน shared mode พร้อมกันได้ แต่ถ้ามี transaction อื่นถือ exclusive lock บน object นั้นอยู่แล้ว transaction เหล่านี้ต้องรอ
-
ถ้า transaction ต้องการเขียน object ต้องขอ lock ใน exclusive mode ก่อน จะมี transaction อื่นถือ lock พร้อมกันไม่ได้ (ไม่ว่าใน shared หรือ exclusive mode) ดังนั้นถ้า object นั้นมี lock อยู่แล้ว transaction ต้องรอ
-
ถ้า transaction อ่านแล้วจึงเขียน object เดียวกัน มันอาจ upgrade shared lock เป็น exclusive lock ได้ การ upgrade ทำงานเหมือนกับการขอ exclusive lock โดยตรง
-
หลังจาก transaction ได้ lock แล้ว ต้องถือ lock ไว้จนจบ transaction (commit หรือ abort) นี่คือที่มาของชื่อ "two-phase": เฟสแรก (ระยะ เติบโต ขณะที่ transaction กำลังทำงาน) คือช่วงที่ขอ lock และเฟสที่สอง (ระยะ หดตัว ตอนท้ายของ transaction) คือช่วงที่ปล่อย lock ทั้งหมด สองเฟสนี้ต้องไม่ทับซ้อนกัน เมื่อปล่อย lock แล้ว transaction จะขอ lock ใหม่ไม่ได้อีก
เมื่อมีการใช้ lock มากขนาดนี้ เป็นเรื่องง่ายที่ transaction A จะติดอยู่เพื่อรอให้ transaction B ปล่อย lock และในทางกลับกัน สถานการณ์นี้เรียกว่า deadlock ฐานข้อมูลตรวจจับ deadlock ระหว่าง transaction ได้เองและ abort หนึ่งในนั้นเพื่อให้ตัวอื่นเดินหน้าต่อได้ แอปพลิเคชันต้อง retry transaction ที่ถูก abort
Performance of 2PL (ประสิทธิภาพของ 2PL)
ข้อเสียใหญ่ของ 2PL และเหตุผลที่มันไม่ได้เป็นค่าเริ่มต้นของระบบส่วนใหญ่ตั้งแต่ทศวรรษ 1970 คือประสิทธิภาพ Transaction throughput และ response time ของ query แย่ลงมากภายใต้ 2PL เมื่อเทียบกับ weak isolation
ส่วนหนึ่งเป็นเพราะ overhead ของการขอและปล่อย lock ทั้งหมดนั้น แต่ที่สำคัญกว่าคือ concurrency ที่ลดลง โดยการออกแบบ ถ้าสอง concurrent transaction พยายามทำอะไรที่อาจก่อให้เกิด race condition ไม่ว่าด้วยทางใดก็ตาม ฝ่ายหนึ่งต้องรอให้อีกฝ่ายเสร็จ
ตัวอย่างเช่น ถ้ามี transaction ที่ต้องอ่านทั้งตาราง (เช่น backup, analytical query หรือ integrity check ตามที่กล่าวถึงใน "Snapshot Isolation and Repeatable Read" ) transaction นั้นต้องขอ shared lock บนทั้งตาราง ดังนั้น transaction ที่อ่านต้องรอก่อนจนกว่า transaction ที่กำลังเขียนตารางนั้นทั้งหมดจะเสร็จ จากนั้น ระหว่างที่กำลังอ่านทั้งตาราง (ซึ่งอาจใช้เวลานานสำหรับตารางใหญ่) transaction อื่นทั้งหมดที่ต้องการเขียนตารางนั้นจะถูกบล็อกจนกว่า read-only transaction ตัวใหญ่นั้นจะ commit ผลคือฐานข้อมูลเขียนไม่ได้เป็นเวลานาน
ด้วยเหตุนี้ ฐานข้อมูลที่ใช้ 2PL อาจมี latency ไม่เสถียร และอาจช้ามากที่ percentile สูง (ดู "Describing Performance" ) ถ้า workload มีการแย่งกัน เพียง transaction ที่ช้าตัวเดียว หรือ transaction ตัวเดียวที่เข้าถึงข้อมูลจำนวนมากและขอ lock จำนวนมาก ก็อาจทำให้ระบบที่เหลือหยุดชะงักได้ มีการใช้ transaction timeout และการ monitor slow query เพื่อตรวจจับและจำกัด query ที่มีพฤติกรรมไม่ดี
แม้ deadlock จะเกิดขึ้นได้กับ lock-based read-committed isolation level แต่เกิดบ่อยกว่ามากภายใต้ 2PL serializable isolation (ขึ้นอยู่กับรูปแบบการเข้าถึงของ transaction ของคุณ) นี่อาจเป็นปัญหาด้านประสิทธิภาพเพิ่มเติม: เมื่อ transaction ถูก abort เพราะ deadlock แล้ว retry มันต้องทำงานทั้งหมดใหม่ ถ้า deadlock เกิดบ่อย นี่อาจหมายถึงงานที่สูญเปล่าไปมาก
Predicate locks (การล็อกแบบเพรดิเคต)
ในคำอธิบายเรื่อง lock ก่อนหน้านี้ เราข้ามรายละเอียดที่ละเอียดอ่อนแต่สำคัญไป ใน "Phantoms causing write skew" เราได้พูดถึงปัญหาของ phantoms —นั่นคือ transaction หนึ่งเปลี่ยนผลลัพธ์ของ search query ของอีก transaction หนึ่ง ฐานข้อมูลที่มี serializable isolation ต้องป้องกัน phantom
ในตัวอย่างการจองห้องประชุม นี่หมายความว่าถ้า transaction หนึ่งค้นหาการจองที่มีอยู่ของห้องหนึ่งในช่วงเวลาหนึ่งไปแล้ว (ดู ตัวอย่าง 8-2 ) transaction อื่นจะ insert หรืออัปเดตการจองอื่นของห้องและช่วงเวลาเดียวกันพร้อมกันไม่ได้ (แต่ insert การจองของห้องอื่น หรือของห้องเดียวกันในเวลาอื่นที่ไม่กระทบการจองที่เสนอมา พร้อมกันได้)
เราจะ implement เรื่องนี้ได้อย่างไร? ในเชิงแนวคิด เราต้องใช้ predicate lock [ 4 ] มันทำงานคล้ายกับ shared/exclusive lock ที่อธิบายไว้ก่อนหน้านี้ แต่ไม่ได้เป็นของ object ใด object หนึ่ง (เช่นแถวเดียวในตาราง) แต่เป็นของ object ทั้งหมดที่ตรงกับเงื่อนไขการค้นหา เช่นนี้:
SELECT * FROM bookings
WHERE room_id = 123 AND
end_time > '2026-01-01 12:00' AND
start_time < '2026-01-01 13:00';
Predicate lock จำกัดการเข้าถึงดังนี้:
-
ถ้า transaction A ต้องการอ่าน object ที่ตรงกับเงื่อนไข อย่างใน query
SELECTนั้น ต้องขอ shared-mode predicate lock บนเงื่อนไขของ query ถ้า transaction B ถือ exclusive lock บน object ใดก็ตามที่ตรงกับเงื่อนไขเหล่านั้นอยู่ A ต้องรอจนกว่า B จะปล่อย lock จึงจะ query ได้ -
ถ้า transaction A ต้องการ insert, update หรือ delete object ใด ๆ ต้องตรวจสอบก่อนว่าค่าเก่าหรือค่าใหม่ตรงกับ predicate lock ที่มีอยู่หรือไม่ ถ้า transaction B ถือ predicate lock ที่ตรงกันอยู่ A ต้องรอจนกว่า B จะ commit หรือ abort จึงจะทำต่อได้
แนวคิดสำคัญคือ predicate lock ใช้ได้แม้กับ object ที่ยังไม่มีในฐานข้อมูลแต่อาจถูกเพิ่มเข้ามาในอนาคต (phantom) ถ้า 2PL รวม predicate lock ไว้ด้วย ฐานข้อมูลจะป้องกัน write skew และ race condition อื่น ๆ ทุกรูปแบบ และ isolation ของมันจึงเป็น serializable
Index-range locks (การล็อกช่วงดัชนี)
น่าเสียดายที่ predicate lock ทำงานได้ไม่ดี: ถ้ามี lock จำนวนมากจาก transaction ที่กำลังทำงานอยู่ การตรวจสอบหา lock ที่ตรงกันจะใช้เวลามาก ด้วยเหตุนี้ ฐานข้อมูลส่วนใหญ่ที่ใช้ 2PL จึง implement index-range locking (หรือที่เรียกว่า next-key locking ) ซึ่งเป็นการประมาณ predicate locking แบบง่าย [ 56 , 66 ]
การทำให้ predicate ง่ายขึ้นด้วยการให้มันตรงกับชุด object ที่ใหญ่ขึ้นนั้นปลอดภัย ตัวอย่างเช่น ถ้ามี predicate lock สำหรับการจองห้อง 123 ระหว่างเที่ยงถึงบ่ายโมง คุณอาจประมาณมันด้วยการล็อกการจองของห้อง 123 ในเวลาใดก็ได้ หรือประมาณด้วยการล็อกทุกห้อง (ไม่ใช่แค่ห้อง 123) ระหว่างเที่ยงถึงบ่ายโมง ซึ่งปลอดภัยเพราะ write ใดก็ตามที่ตรงกับ predicate เดิมย่อมตรงกับค่าประมาณเหล่านั้นด้วยอย่างแน่นอน
ในฐานข้อมูลการจองห้อง คุณน่าจะมี index บนคอลัมน์ room_id และ/หรือ index บน start_time และ end_time (มิฉะนั้น query ข้างต้นจะช้ามากบนฐานข้อมูลขนาดใหญ่):
-
สมมติว่า index ของคุณอยู่บน
room_idและฐานข้อมูลใช้ index นี้ค้นหาการจองที่มีอยู่ของห้อง 123 ทีนี้ฐานข้อมูลก็แค่ attach shared lock ไว้กับ index entry นี้ เพื่อบ่งบอกว่ามี transaction ค้นหาการจองของห้อง 123 ไปแล้ว -
หรือถ้าฐานข้อมูลใช้ time-based index ค้นหาการจองที่มีอยู่ ก็ attach shared lock ไว้กับช่วงของค่าใน index นั้นได้ เพื่อบ่งบอกว่ามี transaction ค้นหาการจองที่ทับซ้อนกับช่วงเวลาเที่ยงถึงบ่ายโมงของวันที่ระบุ
ไม่ว่าแบบไหน ค่าประมาณของเงื่อนไขการค้นหาจะถูก attach ไว้กับ index ใด index หนึ่ง ทีนี้ ถ้า transaction อื่นต้องการ insert, update หรือ delete การจองของห้องเดียวกันและ/หรือช่วงเวลาที่ทับซ้อนกัน มันต้องอัปเดตส่วนเดียวกันของ index และในระหว่างนั้นจะเจอ shared lock จึงถูกบังคับให้รอจนกว่า lock จะถูกปล่อย
วิธีนี้ป้องกัน phantom และ write skew ได้อย่างมีประสิทธิภาพ Index-range lock ไม่แม่นยำเท่า predicate lock (อาจล็อกช่วงของ object ที่กว้างกว่าที่จำเป็นอย่างเคร่งครัดเพื่อรักษา serializability) แต่เพราะมี overhead ต่ำกว่ามาก จึงเป็นการประนีประนอมที่ดี
ถ้าไม่มี index ที่เหมาะสมให้ range lock attach ได้ ฐานข้อมูลก็ fallback ไปใช้ shared lock บนทั้งตารางได้ ซึ่งไม่ดีต่อประสิทธิภาพ เพราะจะหยุด transaction อื่นทั้งหมดไม่ให้เขียนตารางนั้น แต่ก็เป็นทางถอยที่ปลอดภัย
Serializable Snapshot Isolation (SSI)
บทนี้ ได้วาดภาพ concurrency control ในฐานข้อมูลออกมาค่อนข้างมืดมน ด้านหนึ่ง เรามี implementation ของ serializability ที่ประสิทธิภาพไม่ดี (2PL) หรือ scale ไม่ดี (serial execution) อีกด้านหนึ่ง เรามี weak isolation level ที่ประสิทธิภาพดี แต่เสี่ยงต่อ race condition หลายแบบ (lost update, write skew, phantom ฯลฯ) แล้ว serializable isolation กับประสิทธิภาพที่ดีขัดกันโดยพื้นฐานหรือเปล่า?
ดูเหมือนจะไม่: มีอัลกอริทึมที่เรียกว่า serializable snapshot isolation (SSI) ให้ serializability เต็มรูปแบบโดยเสียประสิทธิภาพเพียงเล็กน้อยเมื่อเทียบกับ snapshot isolation SSI ค่อนข้างใหม่ มันถูกอธิบายครั้งแรกในปี 2008 [ 55 , 67 ]
ปัจจุบัน SSI และอัลกอริทึมที่คล้ายกันถูกใช้ใน single-node database (serializable isolation level ใน PostgreSQL [ 56 ], In-Memory OLTP/Hekaton ของ SQL Server [ 68 ], และ HyPer [ 69 ]), distributed database (CockroachDB [ 5 ] และ FoundationDB [ 8 ]) และ embedded storage engine เช่น BadgerDB
Pessimistic versus optimistic concurrency control (การควบคุม concurrency แบบ Pessimistic กับ Optimistic)
2PL เป็นกลไกการควบคุม concurrency แบบ pessimistic : ตั้งอยู่บนหลักการว่าถ้าอะไรก็ตามอาจผิดพลาดได้ (ดูจาก lock ที่ transaction อื่นถืออยู่) ก็ควรรอจนกว่าสถานการณ์จะปลอดภัยอีกครั้งก่อนจะทำอะไร เหมือนกับ mutual exclusion ที่ใช้ป้องกัน data structure ในการเขียนโปรแกรมแบบ multithreaded
การรันแบบ serial ในแง่หนึ่งคือ pessimistic สุดขั้ว โดยพื้นฐานแล้วเทียบเท่ากับการที่แต่ละ transaction ถือ exclusive lock บนฐานข้อมูลทั้งหมด (หรือหนึ่ง shard ของฐานข้อมูล) ตลอดช่วงที่ transaction ทำงาน เราชดเชย pessimism นี้ด้วยการทำให้แต่ละ transaction รันเร็วมาก มันจึงต้องถือ "lock" แค่ช่วงสั้น ๆ
ในทางตรงกันข้าม serializable snapshot isolation เป็นเทคนิค optimistic concurrency control Optimistic ในบริบทนี้หมายความว่า แทนที่จะบล็อกเมื่อมีสิ่งที่อาจเป็นอันตรายเกิดขึ้น transaction จะทำงานต่อไปอยู่ดี โดยหวังว่าทุกอย่างจะออกมาดี เมื่อ transaction ต้องการ commit ฐานข้อมูลจะตรวจสอบว่ามีสิ่งเลวร้ายเกิดขึ้นหรือไม่ (คือ isolation ถูกละเมิดหรือไม่) ถ้ามี transaction จะถูก abort และต้อง retry เฉพาะ transaction ที่ทำงานแบบ serializable เท่านั้นที่ได้รับอนุญาตให้ commit
Optimistic concurrency control เป็นแนวคิดเก่า [ 70 ] และมีการถกเถียงเรื่องข้อดีข้อเสียของมันมานาน [ 71 ] มันทำงานได้ไม่ดีถ้ามีการแย่งกันสูง (transaction จำนวนมากพยายามเข้าถึง object เดียวกัน) เพราะทำให้สัดส่วนของ transaction ที่ต้อง abort สูง ถ้าระบบใกล้ถึง throughput สูงสุดอยู่แล้ว โหลดเพิ่มจาก transaction ที่ retry อาจทำให้ประสิทธิภาพแย่ลงไปอีก
แต่ถ้ามีความจุสำรองเพียงพอ และการแย่งกันระหว่าง transaction ไม่สูงเกินไป เทคนิค optimistic concurrency control มักทำงานได้ดีกว่าแบบ pessimistic การแย่งกันลดลงได้ด้วย commutative atomic operation เช่น ถ้าหลาย transaction ต้องการ increment counter พร้อมกัน ลำดับที่ใช้ increment ไม่สำคัญ (ตราบใดที่ไม่ได้อ่าน counter ใน transaction เดียวกัน) ดังนั้น concurrent increment ทั้งหมดจึงทำได้โดยไม่ขัดแย้งกัน
ตามชื่อที่บอกไว้ SSI ตั้งอยู่บน snapshot isolation คือ read ทั้งหมดภายใน transaction อ่านจาก consistent snapshot ของฐานข้อมูล (ดู "Snapshot Isolation and Repeatable Read" ) SSI เพิ่มอัลกอริทึมบน snapshot isolation เพื่อตรวจจับ serialization conflict ระหว่าง read และ write และตัดสินว่าควร abort transaction ใด
Decisions based on an outdated premise (การตัดสินใจบนพื้นฐานของ premise ที่ล้าสมัย)
เมื่อก่อนหน้านี้เราพูดถึง write skew ใน snapshot isolation (ดู "Write Skew and Phantoms" ) เราสังเกตเห็นรูปแบบที่เกิดซ้ำ: transaction อ่านข้อมูลจากฐานข้อมูล ตรวจสอบผลลัพธ์ของ query และตัดสินใจลงมือทำ (เขียนลงฐานข้อมูล) ตามผลลัพธ์ที่เห็น แต่ภายใต้ snapshot isolation ผลลัพธ์ของ query เดิมอาจไม่เป็นปัจจุบันแล้วเมื่อ transaction commit เพราะข้อมูลอาจถูกแก้ไขไปในระหว่างนั้น
พูดอีกอย่างคือ transaction กำลังลงมือทำโดยอาศัย premise (ข้อเท็จจริงที่เป็นจริงตอนเริ่ม transaction เช่น "ตอนนี้มีแพทย์ on-call อยู่สองคน") ต่อมาเมื่อ transaction ต้องการ commit ข้อมูลเดิมอาจเปลี่ยนไปแล้ว premise จึงอาจไม่เป็นจริงอีกต่อไป
เมื่อแอปพลิเคชันส่ง query (เช่น "ตอนนี้มีแพทย์ on-call กี่คน?") ฐานข้อมูลไม่รู้ว่าตรรกะของแอปพลิเคชันนำผลของ query นั้นไปใช้อย่างไร เพื่อความปลอดภัย ฐานข้อมูลต้องสมมติว่าการเปลี่ยนแปลงใด ๆ ในผลลัพธ์ของ query (premise) หมายความว่า write ใน transaction นั้นอาจไม่ถูกต้อง พูดอีกอย่างคือ อาจมี causal dependency (ความสัมพันธ์เชิงเหตุและผล) ระหว่าง query กับ write ใน transaction เพื่อให้ได้ serializable isolation ฐานข้อมูลต้องตรวจจับสถานการณ์ที่ transaction อาจลงมือทำโดยอาศัย premise ที่ล้าสมัย และ abort transaction นั้น
ฐานข้อมูลรู้ได้อย่างไรว่าผลลัพธ์ของ query อาจเปลี่ยนไปแล้ว? ลองดูสองกรณี:
-
การตรวจจับการอ่าน MVCC object version ที่ล้าสมัย (มี uncommitted write เกิดขึ้นก่อนการอ่าน)
-
การตรวจจับ write ที่กระทบ read ก่อนหน้า (write เกิดขึ้นหลังการอ่าน)
Detection of stale MVCC reads (การตรวจจับการอ่าน MVCC ที่เก่า)
ขอให้นึกย้อนว่า snapshot isolation มักถูก implement ด้วย MVCC (ดู "Multiversion concurrency control" ) เมื่อ transaction อ่านจาก consistent snapshot ในฐานข้อมูล MVCC มันจะเมิน write ที่ transaction อื่นทำไว้ ถ้า transaction เหล่านั้นยังไม่ commit ณ เวลาที่ snapshot ถูกสร้าง
ใน รูปที่ 8-10 transaction 43 เห็น Aaliyah มี on_call = true เพราะ transaction 42 (ซึ่งแก้ไขสถานะ on-call ของ Aaliyah) ยังไม่ commit แต่เมื่อ transaction 43 ต้องการ commit นั้น transaction 42 commit ไปแล้ว นั่นหมายความว่า write ที่ถูกเมินตอนอ่านจาก consistent snapshot มีผลแล้ว และ premise ของ transaction 43 ไม่เป็นจริงอีกต่อไป เรื่องจะซับซ้อนยิ่งขึ้นเมื่อ writer insert ข้อมูลที่ไม่เคยมีอยู่ก่อน (ดู "Phantoms causing write skew" ) ต่อไปเราจะพูดถึงการตรวจจับ phantom write สำหรับ SSI
Figure 8-10. การตรวจจับเมื่อ transaction อ่านค่าที่ล้าสมัยจาก MVCC snapshot
เพื่อป้องกัน anomaly นี้ ฐานข้อมูลต้องติดตามว่าเมื่อไรที่ transaction เมิน write ของ transaction อื่นเพราะกฎการมองเห็นของ MVCC เมื่อ transaction ต้องการ commit ฐานข้อมูลจะตรวจสอบว่า write ที่ถูกเมินไปนั้นมีตัวไหน commit แล้วหรือไม่ ถ้ามี transaction ต้องถูก abort
ทำไมต้องรอจนถึงตอน commit? ทำไมไม่ abort transaction 43 ทันทีที่ตรวจพบ stale read? ก็เพราะถ้า transaction 43 เป็น read-only transaction ก็ไม่ต้อง abort เพราะไม่มีความเสี่ยงของ write skew ตอนที่ transaction 43 อ่าน ฐานข้อมูลยังไม่รู้ว่า transaction นั้นจะเขียนในภายหลังหรือไม่ นอกจากนี้ transaction 42 อาจ abort หรืออาจยังไม่ commit ตอนที่ transaction 43 commit ดังนั้นการอ่านนั้นอาจกลายเป็นไม่ stale ก็ได้ ด้วยการเลี่ยง abort ที่ไม่จำเป็น SSI จึงรักษาความสามารถของ snapshot isolation ในการรองรับ read ที่รันนานบน consistent snapshot ไว้ได้
Detection of writes that affect prior reads (การตรวจจับ writes ที่ส่งผลต่อ reads ก่อนหน้า)
กรณีที่สองที่ต้องพิจารณาคือ transaction อื่นแก้ไขข้อมูลหลังจากที่ข้อมูลนั้นถูกอ่านไปแล้ว กรณีนี้แสดงใน รูปที่ 8-11
ในบริบทของ 2PL เราพูดถึง index-range lock (ดู "Index-range locks" ) ซึ่งให้ฐานข้อมูลล็อกการเข้าถึงทุกแถวที่ตรงกับ search query ได้ เช่น WHERE shift_id = 1234 เราใช้เทคนิคคล้ายกันได้ที่นี่ ต่างกันแค่ lock ของ SSI ไม่ได้บล็อก transaction อื่น
ใน รูปที่ 8-11 transaction 42 และ 43 ต่างค้นหาแพทย์ on-call ในเวร 1234 ถ้ามี index บน shift_id ฐานข้อมูลสามารถใช้ index entry 1234 เพื่อบันทึกว่า transaction 42 และ 43 อ่านข้อมูลนี้ (ถ้าไม่มี index ก็ติดตามข้อมูลนี้ที่ระดับตารางได้) ข้อมูลนี้ต้องเก็บไว้แค่ช่วงหนึ่งเท่านั้น หลังจาก transaction จบ (commit หรือ abort) และ concurrent transaction ทั้งหมดจบแล้ว ฐานข้อมูลก็ลืมข้อมูลที่มันอ่านไปได้
Figure 8-11. ใน serializable snapshot isolation การตรวจจับเมื่อ transaction หนึ่งแก้ไขสิ่งที่อีก transaction หนึ่งอ่าน
เมื่อ transaction เขียนลงฐานข้อมูล มันต้องดูใน index เพื่อหา transaction อื่นที่เพิ่งอ่านข้อมูลที่ได้รับผลกระทบ กระบวนการนี้คล้ายกับการขอ write lock บน key range ที่ได้รับผลกระทบ แต่แทนที่จะบล็อกจนกว่า reader จะ commit lock นี้ทำหน้าที่เหมือน tripwire คือแค่แจ้ง transaction เหล่านั้นว่าข้อมูลที่อ่านไปอาจไม่เป็นปัจจุบันอีกแล้ว
ใน รูปที่ 8-11 transaction 43 แจ้ง transaction 42 ว่า read ก่อนหน้าของมันล้าสมัยแล้ว และกลับกันก็เช่นกัน Transaction 42 commit ก่อนและสำเร็จ แม้ write ของ transaction 43 จะกระทบ 42 แต่ 43 ยังไม่ได้ commit write นั้นจึงยังไม่มีผล แต่เมื่อ transaction 43 ต้องการ commit write ที่ขัดแย้งจาก 42 ถูก commit ไปแล้ว ดังนั้น 43 ต้อง abort
Performance of serializable snapshot isolation (ประสิทธิภาพของ SSI)
เช่นเคย รายละเอียดทางวิศวกรรมจำนวนมากส่งผลต่อประสิทธิภาพของอัลกอริทึมในทางปฏิบัติ ตัวอย่างเช่น trade-off อย่างหนึ่งคือ granularity ในการติดตาม read และ write ของ transaction ถ้าฐานข้อมูลติดตามกิจกรรมของแต่ละ transaction อย่างละเอียดมาก มันจะรู้ได้แม่นยำว่า transaction ไหนต้อง abort แต่ overhead ในการบันทึกอาจสูงมาก ส่วนการติดตามที่ละเอียดน้อยกว่านั้นเร็วกว่า แต่อาจทำให้ abort transaction มากกว่าที่จำเป็นอย่างเคร่งครัด
ในบางกรณี ไม่เป็นไรถ้า transaction จะอ่านข้อมูลที่ transaction อื่นเขียนทับไปแล้ว ขึ้นอยู่กับว่ามีอะไรเกิดขึ้นอีกบ้าง บางครั้งก็พิสูจน์ได้ว่าผลของการรันนั้นยัง serializable อยู่ดี PostgreSQL ใช้ทฤษฎีนี้เพื่อลดจำนวน abort ที่ไม่จำเป็น [ 14 , 56 ]
เมื่อเทียบกับ 2PL ข้อดีใหญ่ของ serializable snapshot isolation คือ transaction หนึ่งไม่ต้องบล็อกเพื่อรอ lock ที่ transaction อื่นถืออยู่ เหมือน snapshot isolation คือ writer ไม่บล็อก reader และกลับกัน หลักการออกแบบนี้ทำให้ query latency คาดเดาได้มากขึ้นและแปรปรวนน้อยลง โดยเฉพาะ read-only query รันบน consistent snapshot ได้โดยไม่ต้องใช้ lock เลย ซึ่งน่าสนใจมากสำหรับ workload ที่เน้นอ่านเป็นหลัก
เมื่อเทียบกับ serial execution serializable snapshot isolation ไม่ถูกจำกัดด้วย throughput ของ CPU core เดียว เช่น FoundationDB กระจายการตรวจจับ serialization conflict ไปยังหลายเครื่อง ทำให้ scale ไปถึง throughput สูงมากได้ แม้ข้อมูลอาจถูก shard ไว้บนหลายเครื่อง transaction ก็ยังอ่านและเขียนข้อมูลในหลาย shard ได้ พร้อมรับประกัน serializable isolation
เมื่อเทียบกับ nonserializable snapshot isolation การต้องตรวจสอบการละเมิด serializability ทำให้มี overhead ด้านประสิทธิภาพบ้าง overhead เหล่านี้มากน้อยแค่ไหนยังเป็นที่ถกเถียงกัน: บางคนเชื่อว่าการตรวจสอบ serializability ไม่คุ้ม [ 72 ] ขณะที่บางคนเชื่อว่าตอนนี้ serializability ประสิทธิภาพดีมากจนไม่จำเป็นต้องใช้ snapshot isolation ที่อ่อนกว่าอีกแล้ว [ 69 ]
อัตราการ abort ส่งผลอย่างมากต่อประสิทธิภาพโดยรวมของ SSI เช่น transaction ที่อ่านและเขียนข้อมูลเป็นเวลานานมีแนวโน้มจะเจอ conflict และ abort SSI จึงต้องการให้ read/write transaction ค่อนข้างสั้น (long-running read-only transaction ใช้ได้) อย่างไรก็ตาม SSI ไวต่อ slow transaction น้อยกว่า 2PL หรือ serial execution
Distributed Transactions (ธุรกรรมแบบกระจาย)
ใน single-node transaction คุณมีเครื่องเดียวที่รับผิดชอบการรันตรรกะของ transaction เช่น อัลกอริทึม concurrency control สำหรับ transaction isolation ถ้าฐานข้อมูลของคุณใช้ single-leader replication การรัน transaction จะเกิดขึ้นบน leader เท่านั้น ส่วน follower แค่นำ log ของ write ที่ transaction บน leader commit ไปแล้วมา apply
แล้วถ้ามีหลาย node เข้ามาเกี่ยวข้องกับ transaction ล่ะ? เช่น บางทีคุณมี transaction ที่ต้องแตะหลาย shard ของฐานข้อมูลแบบ sharded หรือ global secondary index (ซึ่ง index entry อาจอยู่บนคนละ node กับข้อมูลหลัก ดู "Sharding and Secondary Indexes" ) สิ่งนี้เรียกว่า distributed transaction
อัลกอริทึม concurrency control ใน distributed transaction โดยกว้าง ๆ แล้วคล้ายกับของ single-node เราพูดถึง serial execution บนฐานข้อมูลแบบ sharded ไปแล้ว 2PL ใช้ได้ในสภาพแวดล้อมแบบกระจาย และสำหรับ SSI ก็มี distributed serializability checker [ 8 ] เราจะไม่ลงรายละเอียดเรื่องเหล่านี้เพิ่ม
แต่การทำ atomicity ให้ได้ใน distributed transaction เป็นความท้าทายใหม่ทั้งหมด และเป็นสิ่งที่ส่วนที่เหลือของบทนี้จะเน้น
สำหรับ single-node transaction atomicity มักถูก implement โดย storage engine เมื่อไคลเอ็นต์ขอให้โหนดฐานข้อมูล commit transaction ฐานข้อมูลจะทำให้ write ของ transaction นั้น durable (โดยทั่วไปใน write-ahead log ดู "Making B-trees reliable" ) แล้วจึง append commit record ลงใน log บนดิสก์ ถ้าฐานข้อมูล crash กลางกระบวนการนี้ transaction จะถูกกู้คืนจาก log เมื่อ node restart ถ้า commit record ถูกเขียนลงดิสก์สำเร็จก่อน crash ก็ถือว่า transaction commit แล้ว ถ้าไม่ write ทั้งหมดของ transaction นั้นจะถูก roll back
ดังนั้น บน node เดียว สิ่งที่สำคัญยิ่งต่อการ commit transaction คือ ลำดับ ที่ข้อมูลถูกเขียนลงดิสก์อย่างถาวร: ข้อมูลก่อน แล้วจึง commit record [ 22 ] ช่วงเวลาตัดสินที่กำหนดว่า transaction จะ commit หรือ abort คือตอนที่ดิสก์เขียน commit record เสร็จ ก่อนจุดนั้นยัง abort ได้ (เพราะ crash) แต่หลังจุดนั้น transaction ถือว่า commit แล้ว (แม้ฐานข้อมูลจะ crash) ดังนั้น อุปกรณ์ชิ้นเดียว (controller ของดิสก์ไดรฟ์ตัวหนึ่งที่ต่ออยู่กับ node ตัวหนึ่ง) เป็นตัวทำให้การ commit เป็น atomic
ใน distributed transaction การตัดสินว่า transaction commit แล้วหรือยังไม่ตรงไปตรงมาขนาดนั้น เช่น เมื่อ transaction ต้องการ commit แค่ส่ง commit request ไปยังทุก node แล้วให้แต่ละ node commit transaction อย่างอิสระนั้นไม่พอ เพราะอาจเกิดขึ้นได้ง่าย ๆ ว่า commit สำเร็จบนบาง node แต่ล้มเหลวบน node อื่น (ดังแสดงใน รูปที่ 8-12 ) ด้วยเหตุผลต่าง ๆ เช่น:
-
บาง node อาจตรวจพบ constraint violation หรือ conflict จนต้อง abort ขณะที่ node อื่น commit ได้สำเร็จ
-
commit request บางตัวอาจหายไปในเครือข่ายจนสุดท้าย abort เพราะ timeout ขณะที่ commit request ตัวอื่นไปถึง
-
บาง node อาจ crash ก่อนที่ commit record จะถูกเขียนครบ แล้ว roll back transaction ตอน recover ขณะที่ node อื่น commit สำเร็จ
Figure 8-12. เมื่อ transaction เกี่ยวข้องกับ database node หลายตัว มันอาจ commit บางตัวและล้มเหลวบนตัวอื่น
ถ้าบาง node commit transaction แต่บาง node abort node เหล่านั้นจะไม่สอดคล้องกัน และเมื่อ transaction ถูก commit บน node หนึ่งแล้ว ก็ไม่สามารถถอนคืนได้อีกถ้าภายหลังพบว่ามันถูก abort บนอีก node หนึ่ง เพราะเมื่อข้อมูลถูก commit แล้ว transaction อื่นภายใต้ read-committed หรือ isolation ที่แข็งแกร่งกว่าจะมองเห็นข้อมูลนั้นได้ ตัวอย่างเช่น ใน รูปที่ 8-12 ตอนที่ผู้ใช้ 1 สังเกตเห็นว่า commit ล้มเหลวบน database 1 ผู้ใช้ 2 ก็อ่านข้อมูลจาก transaction เดียวกันบน database 2 ไปแล้ว ถ้า transaction ของผู้ใช้ 1 ถูก abort ในภายหลัง transaction ของผู้ใช้ 2 ก็ต้องถูกย้อนกลับด้วย เพราะมันอิงกับข้อมูลที่ถูกประกาศย้อนหลังว่าไม่เคยมีอยู่
วิธีที่ดีกว่าคือทำให้แน่ใจว่า node ทั้งหมดที่เกี่ยวข้องกับ transaction จะ commit ทั้งหมดหรือ abort ทั้งหมด และป้องกันไม่ให้เกิดการผสมกัน การทำเช่นนี้เรียกว่าปัญหา atomic commitment
Two-Phase Commit (การ commit สองเฟส)
Two-phase commit เป็นอัลกอริทึมสำหรับทำ atomic transaction commit ข้ามหลาย node เป็นอัลกอริทึมคลาสสิกในฐานข้อมูลแบบกระจาย [ 13 , 73 , 74 ] 2PC ถูกใช้ภายในบางฐานข้อมูล และยังเปิดให้แอปพลิเคชันใช้ในรูปแบบของ XA transactions [ 75 ] (ซึ่ง Java Transaction API เป็นต้นรองรับ) หรือผ่าน WS-AtomicTransaction สำหรับ SOAP web service [ 76 , 77 ]
ขั้นตอนพื้นฐานของ 2PC แสดงใน รูปที่ 8-13 แทนที่จะเป็น commit request เดียว ดังเช่นใน single-node transaction กระบวนการ commit/abort ใน 2PC ถูกแบ่งเป็นสองเฟส (จึงเป็นที่มาของชื่อ)
Figure 8-13. การดำเนินการ 2PC ที่สำเร็จ
2PC ใช้ component ใหม่ที่ปกติไม่ปรากฏใน single-node transaction: coordinator (หรือที่เรียกว่า transaction manager ) coordinator มักถูก implement เป็น library ภายใน process เดียวกับแอปพลิเคชันที่ขอ transaction (เช่น ฝังอยู่ใน Java EE container) แต่ก็เป็น process หรือ service แยกต่างหากได้เช่นกัน ตัวอย่างของ coordinator ดังกล่าว ได้แก่ Narayana, JOTM, BTM และ MSDTC
เมื่อใช้ 2PC distributed transaction จะเริ่มด้วยการที่แอปพลิเคชันอ่านและเขียนข้อมูลบน database node หลายตัวตามปกติ เราเรียก database node เหล่านี้ว่า participants ใน transaction เมื่อแอปพลิเคชันพร้อม commit coordinator จะเริ่มเฟส 1 โดยส่ง prepare request ไปยังแต่ละ node เพื่อถามว่า commit ได้หรือไม่ แล้ว coordinator ก็ติดตามการตอบกลับจาก participant:
-
ถ้า participant ทั้งหมดตอบตกลง ซึ่งแสดงว่าพร้อม commit coordinator จะส่ง commit request ในเฟส 2 และการ commit ก็เกิดขึ้น
-
ถ้า participant ใดตอบปฏิเสธ coordinator จะส่ง abort request ไปยังทุก node ในเฟส 2
กระบวนการนี้ค่อนข้างคล้ายพิธีแต่งงานแบบดั้งเดิมในวัฒนธรรมตะวันตก: ผู้ดำเนินพิธีถามคู่บ่าวสาวทีละคนว่าต้องการแต่งงานกับอีกฝ่ายหรือไม่ และโดยทั่วไปจะได้คำตอบว่า "ฉันยอมรับ" จากทั้งสองคน หลังจากได้รับการตอบรับจากทั้งสองฝ่าย ผู้ดำเนินพิธีก็ประกาศว่าทั้งคู่แต่งงานกันแล้ว (เทียบได้กับ transaction ถูก commit) และข่าวดีก็ถูกประกาศให้แขกทุกคนทราบ ถ้าฝ่ายใดฝ่ายหนึ่งไม่ตอบตกลง พิธีก็ถูกยกเลิก [ 78 ]
A system of promises (ระบบของคำสัญญา)
จากคำอธิบายสั้น ๆ นี้ อาจยังไม่ชัดว่าทำไม 2PC รับประกัน atomicity ได้ ขณะที่ one-phase commit ข้ามหลาย node ทำไม่ได้ แน่นอนว่า prepare และ commit request ก็หายไปในเครือข่ายได้ง่ายพอ ๆ กันในกรณีสองเฟส แล้ว 2PC ต่างออกไปตรงไหน?
เพื่อเข้าใจว่าทำไมมันได้ผล เราต้องแยกกระบวนการให้ละเอียดขึ้นอีกหน่อย:
-
เมื่อแอปพลิเคชันต้องการเริ่ม distributed transaction มันขอ transaction ID จาก coordinator โดย transaction ID นี้เป็น globally unique
-
แอปพลิเคชันเริ่ม single-node transaction บน participant แต่ละตัว และแนบ globally unique transaction ID ไปกับ single-node transaction นั้น read และ write ทั้งหมดทำภายใน single-node transaction เหล่านี้ ถ้ามีอะไรผิดพลาดในขั้นตอนนี้ (เช่น node crash หรือ request timeout) coordinator หรือ participant ตัวใดก็ได้ abort ได้
-
เมื่อแอปพลิเคชันพร้อม commit coordinator ส่ง prepare request ไปยัง participant ทั้งหมด โดยติดแท็ก global transaction ID ถ้า request ใดล้มเหลวหรือ timeout coordinator จะส่ง abort request สำหรับ transaction ID นั้นไปยัง participant ทั้งหมด
-
เมื่อ participant ได้รับ prepare request มันต้องแน่ใจว่าตัวเอง commit transaction ได้แน่นอนไม่ว่าจะเกิดอะไรขึ้น ซึ่งรวมถึงการเขียนข้อมูลทั้งหมดของ transaction ลงดิสก์ (crash ไฟดับ หรือดิสก์เต็ม ไม่ใช่ข้ออ้างที่ยอมรับได้สำหรับการปฏิเสธไม่ commit ทีหลัง) และตรวจสอบ conflict หรือ constraint violation ใด ๆ เมื่อตอบตกลงกับ coordinator แล้ว node ก็สัญญาว่าจะ commit transaction ได้โดยไม่มีข้อผิดพลาดหากถูกขอ พูดอีกอย่างคือ participant สละสิทธิ์ในการ abort transaction แต่ยังไม่ได้ commit มันจริง ๆ
-
เมื่อ coordinator ได้รับการตอบกลับของ prepare request ทั้งหมดแล้ว มันจะตัดสินใจอย่างเด็ดขาดว่าจะ commit หรือ abort transaction (commit ก็ต่อเมื่อ participant ทั้งหมดโหวตตกลง) coordinator ต้องเขียนการตัดสินใจนั้นลง transaction log บนดิสก์ เพื่อให้รู้ว่าตัดสินใจไปทางไหนในกรณีที่ crash ภายหลัง ซึ่งเรียกว่า commit point
-
เมื่อการตัดสินใจของ coordinator ถูกเขียนลงดิสก์แล้ว commit หรือ abort request จะถูกส่งไปยัง participant ทั้งหมด ถ้า request นี้ล้มเหลวหรือ timeout coordinator ต้อง retry ไปเรื่อย ๆ จนกว่าจะสำเร็จ จะย้อนกลับไม่ได้อีกแล้ว ถ้าตัดสินใจ commit การตัดสินใจนั้นต้องถูกบังคับใช้ ไม่ว่าจะต้อง retry กี่ครั้งก็ตาม ถ้า participant crash ไปในระหว่างนั้น transaction จะถูก commit เมื่อมัน recover เพราะ participant โหวตตกลงไปแล้ว จึงปฏิเสธไม่ commit ตอน recover ไม่ได้
ดังนั้น โปรโตคอลนี้มี "จุดที่ไม่มีทางหวนกลับ" ที่สำคัญสองจุด: เมื่อ participant โหวตตกลง มันสัญญาว่าจะ commit ได้อย่างแน่นอนในภายหลัง (แม้ coordinator อาจยังเลือก abort ได้) และเมื่อ coordinator ตัดสินใจแล้ว การตัดสินใจนั้นเพิกถอนไม่ได้ คำสัญญาเหล่านี้เองที่รับประกัน atomicity ของ 2PC (atomic commit บน node เดียวรวมสองเหตุการณ์นี้เป็นอันเดียว คือการเขียน commit record ลง transaction log)
กลับมาที่การเปรียบเทียบกับการแต่งงาน ก่อนจะพูดว่า "ฉันยอมรับ" คุณกับคู่ของคุณมีอิสระที่จะ abort transaction ด้วยการพูดว่า "ไม่มีทาง!" (หรืออะไรทำนองนั้น) แต่หลังจากพูดว่า "ฉันยอมรับ" แล้ว คุณถอนคำพูดนั้นไม่ได้ ถ้าคุณเป็นลมหลังพูดว่า "ฉันยอมรับ" และไม่ได้ยินผู้ดำเนินพิธีประกาศว่าคุณแต่งงานแล้ว ก็ไม่ได้เปลี่ยนความจริงที่ว่า transaction ถูก commit ไปแล้ว เมื่อคุณฟื้นสติ คุณสามารถรู้ได้ว่าคุณแต่งงานแล้วหรือยัง โดยสอบถามผู้ดำเนินพิธีถึงสถานะของ global transaction ID ของคุณ หรือรอให้ผู้ดำเนินพิธี retry commit request ครั้งถัดไป (เพราะการ retry จะดำเนินต่อไปตลอดช่วงที่คุณหมดสติ)
Coordinator failure (ความล้มเหลวของ Coordinator)
เราได้พูดถึงสิ่งที่เกิดขึ้นถ้า participant ตัวใดตัวหนึ่งหรือเครือข่ายล้มเหลวระหว่าง 2PC: ถ้า prepare request ใดล้มเหลวหรือ timeout coordinator จะ abort transaction ถ้า commit หรือ abort request ใดล้มเหลว coordinator จะ retry ไม่รู้จบ แต่ยังไม่ชัดเจนนักว่าจะเกิดอะไรขึ้นถ้า coordinator crash
ถ้า coordinator ล้มเหลวก่อนส่ง prepare request participant สามารถ abort transaction ได้อย่างปลอดภัย แต่เมื่อ participant ได้รับ prepare request และโหวตตกลงแล้ว มันจะ abort เองฝ่ายเดียวไม่ได้อีก มันต้องรอให้ coordinator บอกกลับมาว่า transaction ถูก commit หรือ abort ถ้า coordinator crash หรือเครือข่ายล้มเหลว ณ จุดนี้ participant ก็ไม่มีทางทำอะไรได้นอกจากรอ transaction ของ participant ในสถานะนี้เรียกว่า in doubt หรือ uncertain
สถานการณ์นี้แสดงใน รูปที่ 8-14 ในตัวอย่างนี้ coordinator ตัดสินใจ commit จริง ๆ และ database 2 ได้รับ commit request แล้ว แต่ coordinator crash ก่อนจะส่ง commit request ไปยัง database 1 database 1 จึงไม่รู้ว่าจะ commit หรือ abort แม้แต่ timeout ก็ไม่ช่วยในที่นี้: ถ้า database 1 abort เองฝ่ายเดียวหลัง timeout มันจะไม่สอดคล้องกับ database 2 ที่ commit ไปแล้ว ในทำนองเดียวกัน การ commit เองฝ่ายเดียวก็ไม่ปลอดภัย เพราะ participant อื่นอาจ abort ไปแล้ว
ถ้าไม่ได้ยินจาก coordinator participant ก็ไม่มีทางรู้ว่าจะ commit หรือ abort ในหลักการ participant อาจสื่อสารกันเองเพื่อดูว่าแต่ละตัวโหวตอย่างไรแล้วตกลงกันได้ แต่นั่นไม่ใช่ส่วนหนึ่งของ 2PC protocol
วิธีเดียวที่ 2PC จะเสร็จสมบูรณ์ได้คือรอให้ coordinator recover นี่คือเหตุผลที่ coordinator ต้องเขียนการตัดสินใจ commit หรือ abort ลง transaction log บนดิสก์ก่อนส่ง commit หรือ abort request ไปยัง participant: เมื่อ coordinator recover มันจะรู้สถานะของ transaction ที่ in doubt ทั้งหมดจากการอ่าน transaction log ของมัน transaction ใดที่ไม่มี commit record ใน log ของ coordinator จะถูก abort ดังนั้น commit point ของ 2PC จึงลงเอยที่ atomic commit แบบ single-node ธรรมดาบน coordinator
Figure 8-14. Coordinator crash หลังจาก participant โหวตตกลง Database 1 ไม่รู้ว่าจะ commit หรือ abort
นอกจากนี้ ถ้าดิสก์ของ coordinator เสียและ log หายไป ระบบก็ไม่มีทาง recover อัตโนมัติ ทางเลือกเดียวคือให้ผู้ดูแลระบบ commit หรือ abort transaction ที่ in doubt ด้วยมือ ถ้าหายไปเฉพาะส่วนล่าสุดของ transaction log coordinator ที่กำลัง recover อาจเชื่อว่า transaction ที่ commit ไปแล้วยังไม่ได้ commit และพยายาม abort มัน ซึ่งละเมิด atomicity
Three-phase commit (การ commit สามเฟส)
2PC ถูกเรียกว่า blocking atomic commit protocol เพราะ 2PC อาจติดค้างอยู่เพื่อรอให้ coordinator recover เป็นไปได้ที่จะทำให้ atomic commit protocol nonblocking เพื่อให้ไม่ติดค้างเมื่อ node ล้มเหลว แต่การทำให้ได้ผลจริงในทางปฏิบัตินั้นไม่ง่ายนัก
เพื่อเป็นทางเลือกแทน 2PC มีผู้เสนออัลกอริทึมที่เรียกว่า three-phase commit (3PC) [ 13 , 79 ] แต่ 3PC สมมติว่าเครือข่ายมี delay แบบมีขอบเขต และ node มีเวลาตอบสนองแบบมีขอบเขต ในระบบจริงส่วนใหญ่ที่ network delay ไม่มีขอบเขตและมี process pause (ดู บทที่ 9 ) 3PC ไม่สามารถรับประกัน atomicity ได้
ทางออกที่ดีกว่าในทางปฏิบัติคือแทนที่ single-node coordinator ด้วย fault-tolerant consensus protocol เราจะเห็นวิธีทำเช่นนี้ใน บทที่ 10
Distributed Transactions Across Different Systems (ธุรกรรมแบบกระจายข้ามระบบที่แตกต่างกัน)
Distributed transaction และ 2PC มีชื่อเสียงทั้งดีและไม่ดี ด้านหนึ่ง ถูกมองว่าให้การรับประกันความปลอดภัยที่สำคัญซึ่งยากจะทำได้ด้วยวิธีอื่น อีกด้านหนึ่ง ถูกวิจารณ์ว่าก่อปัญหาด้านการ operate ระบบ ทำลายประสิทธิภาพ และสัญญาไว้มากกว่าที่ส่งมอบได้จริง [ 80 , 81 , 82 , 83 ] cloud service หลายตัวเลือกที่จะไม่ implement distributed transaction เพราะปัญหาด้านการ operate ที่มันก่อขึ้น [ 84 ]
distributed transaction บาง implementation มีโทษด้านประสิทธิภาพหนักมาก ต้นทุนด้านประสิทธิภาพส่วนใหญ่ที่แฝงอยู่ใน 2PC มาจาก fsync operation เพิ่มเติมที่ต้องใช้เพื่อ crash recovery และ network round trip ที่เพิ่มขึ้น
แต่แทนที่จะปัดตก distributed transaction ไปเลย เราควรดูมันให้ละเอียดขึ้น เพราะมีบทเรียนสำคัญให้เรียนรู้จากมัน ก่อนอื่น เราควรระบุให้ชัดว่า "distributed transaction" หมายถึงอะไร เพราะมี distributed transaction สองประเภทที่ต่างกันมากแต่มักถูกปนกัน:
Database-internal distributed transactions
ฐานข้อมูลแบบกระจายบางตัว (คือฐานข้อมูลที่ใช้ replication และ sharding เป็นค่ามาตรฐาน) รองรับ internal transaction ระหว่าง node ของฐานข้อมูลนั้นเอง เช่น YugabyteDB, TiDB, FoundationDB, Spanner, VoltDB, Cassandra และ NDB storage engine ของ MySQL Cluster มี internal transaction แบบนี้ ในกรณีนี้ node ทั้งหมดที่ร่วมใน transaction รันซอฟต์แวร์ฐานข้อมูลตัวเดียวกัน
Heterogeneous distributed transactions
ใน heterogeneous transaction participant เป็นเทคโนโลยีตั้งแต่สองอย่างขึ้นไป เช่น ฐานข้อมูลสองตัวจากคนละผู้ผลิต หรือแม้แต่ระบบที่ไม่ใช่ฐานข้อมูลอย่าง message broker distributed transaction ข้ามระบบเหล่านี้ต้องรับประกัน atomic commit แม้ระบบเหล่านั้นอาจต่างกันโดยสิ้นเชิงเมื่อดูข้างใน
Database-internal transaction ไม่ต้องเข้ากันได้กับระบบอื่น จึงใช้ protocol อะไรก็ได้ และทำ optimization เฉพาะเทคโนโลยีนั้นได้ ด้วยเหตุนี้ database-internal distributed transaction มักทำงานได้ค่อนข้างดี ในทางกลับกัน transaction ที่ข้ามเทคโนโลยีต่างชนิดกันท้าทายกว่ามาก ที่นี่เราจะเน้นแบบหลัง และจะพูดถึง database-internal distributed transaction ในหัวข้อถัดไป
Exactly-once message processing (การประมวลผลข้อความแบบ Exactly-Once)
Heterogeneous distributed transaction ช่วยให้ผสานระบบที่หลากหลายเข้าด้วยกันได้อย่างทรงพลัง เช่น message จาก message queue จะถูก ack ว่าประมวลผลแล้วก็ต่อเมื่อ database transaction ที่ประมวลผล message นั้น commit สำเร็จเท่านั้น ทำได้โดย commit การ ack message และการเขียนฐานข้อมูลแบบ atomic ใน transaction เดียว เมื่อรองรับ distributed transaction แล้ว แม้ message broker กับฐานข้อมูลจะเป็นสองเทคโนโลยีที่ไม่เกี่ยวข้องกันและรันอยู่บนคนละเครื่อง ก็ทำเช่นนี้ได้
ถ้าการส่ง message หรือ database transaction ล้มเหลว ทั้งคู่จะถูก abort message broker จึง redeliver message ได้อย่างปลอดภัยในภายหลัง ดังนั้น ด้วยการ commit message และ side effect ของการประมวลผลแบบ atomic เราจึงมั่นใจได้ว่า message ถูกประมวลผล อย่างมีประสิทธิภาพ หนึ่งครั้งพอดี แม้อาจต้อง retry สองสามครั้งกว่าจะสำเร็จ เพราะการ abort จะทิ้ง side effect ของ transaction ที่ทำเสร็จแค่บางส่วน สิ่งนี้เรียกว่า exactly-once semantics
แต่ distributed transaction แบบนี้ทำได้ก็ต่อเมื่อทุกระบบที่ได้รับผลกระทบจาก transaction ใช้ atomic commit protocol เดียวกันได้เท่านั้น เช่น สมมติว่า side effect อย่างหนึ่งของการประมวลผล message คือการส่งอีเมล และ email server ไม่รองรับ 2PC ก็อาจเกิดกรณีที่อีเมลถูกส่งสองครั้งหรือมากกว่า ถ้าการประมวลผล message ล้มเหลวแล้ว retry แต่ถ้า side effect ทั้งหมดของการประมวลผล message ถูก roll back เมื่อ transaction abort ขั้นตอนการประมวลผลก็ retry ได้อย่างปลอดภัยเหมือนไม่มีอะไรเกิดขึ้น
เราจะกลับมาพูดเรื่อง exactly-once semantics อีกครั้งภายหลังในบทนี้ ตอนนี้มาดู atomic commit protocol ที่ทำให้ heterogeneous distributed transaction แบบนี้เป็นไปได้ก่อน
XA transactions
X/Open XA (ย่อมาจาก eXtended Architecture ) เป็นมาตรฐานสำหรับ implement 2PC ข้ามเทคโนโลยีต่างชนิดกัน [ 75 ] มันถูกเสนอขึ้นในปี 1991 และมีการ implement อย่างกว้างขวาง XA ได้รับการรองรับโดย relational database แบบดั้งเดิมหลายตัว (รวมถึง PostgreSQL, MySQL, Db2, SQL Server และ Oracle) และ message broker (รวมถึง ActiveMQ, HornetQ, MSMQ และ IBM MQ)
XA ไม่ใช่ network protocol แต่เป็นแค่ C API สำหรับเชื่อมต่อกับ transaction coordinator มี binding ของ API นี้ในภาษาอื่นด้วย เช่น ในโลก ของแอปพลิเคชัน Java EE XA transaction ถูก implement ด้วย Java Transaction API (JTA) ซึ่ง driver จำนวนมากรองรับ ทั้ง driver ของฐานข้อมูลที่ใช้ Java Database Connectivity (JDBC) และ driver ของ message broker ที่ใช้ Java Message Service (JMS) API
XA สมมติว่าแอปพลิเคชันของคุณใช้ network driver หรือ client library ในการสื่อสารกับ participant database หรือ messaging service ถ้า driver รองรับ XA แปลว่ามันเรียก XA API เพื่อดูว่า operation หนึ่งควรเป็นส่วนหนึ่งของ distributed transaction หรือไม่ และถ้าใช่ ก็ส่งข้อมูลที่จำเป็นไปยัง database server driver ยังเปิด callback ให้ coordinator ใช้ขอให้ participant prepare, commit หรือ abort ได้ด้วย
Transaction coordinator implement XA API มาตรฐานไม่ได้กำหนดว่าต้อง implement อย่างไร แต่ในทางปฏิบัติ coordinator มักเป็นเพียง library ที่ถูกโหลดเข้าไปใน process เดียวกับแอปพลิเคชันที่เริ่ม transaction (ไม่ใช่ service แยกต่างหาก) มันติดตามว่า transaction มี participant ตัวไหนบ้าง รวบรวมคำตอบของ participant หลังขอให้ prepare (ผ่าน callback ใน driver) และใช้ log บนดิสก์ในเครื่องเพื่อบันทึกการตัดสินใจ commit/abort ของแต่ละ transaction
ถ้า process ของแอปพลิเคชัน crash หรือเครื่องที่แอปพลิเคชันรันอยู่ล่ม coordinator ก็ล่มไปด้วย participant ที่มี transaction ที่ prepare แล้วแต่ยังไม่ commit จะติดอยู่ในสถานะ in doubt เมื่อ log ของ coordinator อยู่บนดิสก์ในเครื่องของ application server เซิร์ฟเวอร์นั้นต้องถูก restart และ coordinator library ต้องอ่าน log เพื่อกู้ผลลัพธ์ commit/abort ของแต่ละ transaction กลับมา จากนั้น coordinator จึงจะใช้ XA callback ของ database driver ขอให้ participant commit หรือ abort ตามความเหมาะสมได้ database server ติดต่อ coordinator โดยตรงไม่ได้ เพราะการสื่อสารทั้งหมดต้องผ่าน client library
Holding locks while in doubt (การถือ lock ในขณะที่สงสัย)
ทำไมเราถึงกังวลมากกับ transaction ที่ติดอยู่ในสถานะ in doubt? ระบบส่วนที่เหลือทำงานต่อไปโดยไม่สนใจ transaction in doubt ที่สุดท้ายก็จะถูก cleanup ไม่ได้หรือ?
ปัญหาอยู่ที่ การล็อก ดังที่กล่าวถึงใน "Read Committed" database transaction มักขอ row-level exclusive lock บนแถวใดก็ตามที่มันแก้ไข เพื่อป้องกัน dirty write ถ้าคุณต้องการ serializable isolation ฐานข้อมูลที่ใช้ 2PL ก็ต้องขอ shared lock บนแถวใดก็ตามที่ถูก อ่าน โดย transaction
ฐานข้อมูลปล่อย lock เหล่านั้นไม่ได้จนกว่า transaction จะ commit หรือ abort (แสดงเป็นพื้นที่แรเงาใน รูปที่ 8-13 ) ดังนั้น เมื่อใช้ 2PC transaction ต้องถือ lock ไว้ตลอดช่วงที่อยู่ในสถานะ in doubt ถ้า coordinator crash และใช้เวลา 20 นาทีกว่าจะ start ใหม่ lock เหล่านั้นจะถูกถือไว้ 20 นาที ถ้า log ของ coordinator หายไปทั้งหมดด้วยเหตุผลบางอย่าง lock เหล่านั้นจะถูกถือไว้ตลอดไป หรืออย่างน้อยก็จนกว่าผู้ดูแลระบบจะแก้สถานการณ์ด้วยมือ
ตราบที่ lock ยังถูกถืออยู่ ไม่มี transaction อื่นแก้ไขแถวเหล่านั้นได้ และขึ้นอยู่กับ isolation level transaction อื่นอาจถูกบล็อกไม่ให้อ่านแถวด้วยซ้ำ ดังนั้น transaction อื่นจึงทำงานต่อไปตามปกติไม่ได้ ถ้าต้องการเข้าถึงข้อมูลชุดเดียวกัน ก็จะถูกบล็อก ซึ่งอาจทำให้แอปพลิเคชันส่วนใหญ่ของคุณใช้งานไม่ได้จนกว่า transaction in doubt จะได้รับการแก้ไข
Recovering from coordinator failure (การกู้คืนจากความล้มเหลวของ coordinator)
ในทางทฤษฎี ถ้า coordinator crash แล้ว restart มันควร recover สถานะจาก log ได้อย่างสะอาดและแก้ transaction in doubt ทั้งหมด แต่ในทางปฏิบัติ orphaned in-doubt transaction ก็เกิดขึ้นได้ [ 85 , 86 ] คือ transaction ที่ coordinator ตัดสินผลลัพธ์ไม่ได้ด้วยเหตุผลใดก็ตาม (เช่น เพราะ transaction log หายหรือเสียหายจากบั๊กของซอฟต์แวร์) transaction เหล่านี้แก้อัตโนมัติไม่ได้ จึงค้างอยู่ในฐานข้อมูลตลอดไป ถือ lock และบล็อก transaction อื่น
แม้แต่การ reboot database server ก็แก้ปัญหานี้ไม่ได้ เพราะ implementation 2PC ที่ถูกต้องต้องรักษา lock ของ in-doubt transaction ไว้แม้ข้าม restart (ไม่เช่นนั้นจะเสี่ยงละเมิดการรับประกัน atomicity) มันเป็น สถานการณ์ที่ยุ่งยากหนักใจ
ทางออกเดียวคือให้ผู้ดูแลระบบตัดสินใจด้วยตัวเองว่าจะ commit หรือ roll back transaction เหล่านั้น ผู้ดูแลระบบต้องตรวจสอบ participant ของแต่ละ in-doubt transaction ดูว่ามี participant ตัวไหน commit หรือ abort ไปแล้วบ้าง แล้วใช้ผลลัพธ์เดียวกันกับ participant ตัวอื่น การแก้ปัญหานี้อาจต้องใช้แรงงานคนมาก และส่วนใหญ่น่าจะต้องทำภายใต้ความกดดันและเวลาที่จำกัดระหว่าง production outage ร้ายแรง (ไม่อย่างนั้น coordinator จะอยู่ในสภาพแย่ขนาดนั้นได้อย่างไร?)
XA implementation หลายตัวมีทางหนีฉุกเฉินที่เรียกว่า heuristic decisions : ให้ participant ตัดสินใจเองฝ่ายเดียวว่าจะ abort หรือ commit in-doubt transaction โดยไม่ต้องรอการตัดสินใจที่แน่ชัดจาก coordinator [ 75 ] ให้ชัดเจน heuristic ในที่นี้เป็นคำเลี่ยงสำหรับ น่าจะทำลาย atomicity เพราะการตัดสินใจแบบ heuristic ละเมิดระบบของคำสัญญาใน 2PC ดังนั้น heuristic decision จึงมีไว้ใช้เพื่อหลุดจากสถานการณ์หายนะเท่านั้น ไม่ได้มีไว้ใช้เป็นปกติ
Problems with XA transactions (ปัญหากับ XA transactions)
Single-node coordinator เป็น single point of failure ของทั้งระบบ และการทำให้มันเป็นส่วนหนึ่งของ application server ก็เป็นปัญหาเช่นกัน เพราะ log ของ coordinator บนดิสก์ในเครื่องกลายเป็นส่วนสำคัญของ durable state ของระบบ ซึ่งสำคัญพอ ๆ กับตัวฐานข้อมูลเอง
ในหลักการ coordinator ของ XA transaction ก็ทำให้ highly available และ replicate ได้ เหมือนที่เราคาดหวังจากฐานข้อมูลสำคัญอื่น ๆ แต่น่าเสียดายที่ยังไม่แก้ปัญหาพื้นฐานของ XA ซึ่งก็คือไม่มีทางให้ coordinator กับ participant ของ transaction สื่อสารกันโดยตรง พวกมันสื่อสารได้เฉพาะผ่านโค้ดแอปพลิเคชันที่เรียก transaction และ database driver ที่ใช้เรียก participant เท่านั้น
ดังนั้นแม้จะ replicate coordinator โค้ดแอปพลิเคชันก็ยังเป็น single point of failure อยู่ดี การแก้ปัญหานี้ต้องออกแบบวิธีรันโค้ดแอปพลิเคชันใหม่ทั้งหมดให้ replicate หรือ restart ได้ ซึ่งอาจหน้าตาคล้ายกับ durable execution (ดู "Durable Execution and Workflows" ) แต่ดูเหมือนยังไม่มีเครื่องมือใดใช้แนวทางนี้จริง
อีกปัญหาหนึ่งคือ เนื่องจาก XA ต้องเข้ากันได้กับระบบข้อมูลหลากหลายชนิด มันจึงต้องเป็น lowest common denominator (ตัวหารร่วมน้อยที่สุด) โดยหลีกเลี่ยงไม่ได้ เช่น มันตรวจจับ deadlock ข้ามระบบต่างชนิดไม่ได้ (เพราะต้องมี protocol มาตรฐานให้แต่ละระบบแลกเปลี่ยนข้อมูลเรื่อง lock ที่แต่ละ transaction กำลังรออยู่) และใช้กับ SSI ไม่ได้ (ดู "Serializable Snapshot Isolation" ) เพราะต้องมี protocol สำหรับระบุ conflict ข้ามระบบต่างชนิดกัน
ปัญหาเหล่านี้ค่อนข้างเป็นเรื่องที่มีอยู่โดยธรรมชาติของการทำ transaction ข้ามเทคโนโลยีต่างชนิดกัน แต่การทำให้ระบบข้อมูลต่างชนิดหลายระบบสอดคล้องกันก็ยังเป็นปัญหาจริงที่สำคัญ เราจึงต้องหาทางแก้แบบอื่น ซึ่งทำได้ ดังที่เราจะเห็นในหัวข้อถัดไปและใน บทที่ 12
Database-Internal Distributed Transactions (ธุรกรรมแบบกระจายภายในฐานข้อมูล)
ดังที่อธิบายไว้ก่อนหน้านี้ distributed transaction ที่ข้ามเทคโนโลยี storage ต่างชนิดกันกับที่อยู่ภายในระบบเดียวนั้นต่างกันมาก โดยกรณีหลังคือ node ที่ร่วมทั้งหมดเป็นส่วนหนึ่งของฐานข้อมูลเดียวกันที่รันซอฟต์แวร์เดียวกัน internal distributed transaction แบบนี้เป็นคุณสมบัติเด่นของฐานข้อมูล "NewSQL" เช่น CockroachDB [ 5 ], TiDB [ 6 ], Spanner [ 7 ], FoundationDB [ 8 ] และ YugabyteDB เป็นต้น message broker บางตัว เช่น Kafka ก็รองรับ internal distributed transaction [ 87 ]
หลายระบบเหล่านี้ใช้ 2PC เพื่อรับประกัน atomicity ของ transaction ที่เขียนหลาย shard แต่ไม่เจอปัญหาแบบเดียวกับ XA transaction เพราะ distributed transaction ของมันไม่ต้องเชื่อมต่อกับเทคโนโลยีอื่น จึงหลีกเลี่ยงกับดัก lowest-common-denominator ได้ ผู้ออกแบบระบบเหล่านี้ใช้ protocol ที่ดีกว่า ทั้งเชื่อถือได้และเร็วกว่าได้อย่างอิสระ
ปัญหาใหญ่ที่สุดของ XA แก้ได้ด้วยวิธีต่อไปนี้:
-
Replicate coordinator พร้อม automatic failover ไปยัง coordinator node อื่นถ้าตัวหลัก crash
-
ให้ coordinator กับ data shard สื่อสารกันโดยตรงโดยไม่ต้องผ่านโค้ดแอปพลิเคชันเป็นตัวกลาง
-
Replicate shard ที่ร่วมใน transaction เพื่อลดความเสี่ยงที่ต้อง abort transaction เพราะความผิดพลาดใน shard ใด shard หนึ่ง
-
ผูก atomic commitment protocol เข้ากับ distributed concurrency control protocol ที่รองรับการตรวจจับ deadlock และ consistent read ข้าม shard
Consensus algorithm มักถูกใช้เพื่อ replicate coordinator และ database shard เราจะเห็นใน บทที่ 10 ว่า atomic commitment สำหรับ distributed transaction implement ได้อย่างไรด้วย consensus algorithm อัลกอริทึมเหล่านี้ทนต่อความผิดพลาดด้วยการ fail over จาก node หนึ่งไปอีก node หนึ่งโดยอัตโนมัติ ไม่ต้องให้คนเข้าไปช่วย และยังรับประกันคุณสมบัติ consistency ที่แข็งแกร่งได้
Isolation level ที่ distributed transaction มีให้ขึ้นอยู่กับระบบ แต่ snapshot isolation [ 6 ] และ serializable snapshot isolation [ 5 , 8 ] ก็ทำได้ทั้งสองแบบข้าม shard
Exactly-Once Message Processing Revisited (การประมวลผลข้อความแบบ Exactly-Once ทบทวนอีกครั้ง)
เราเห็นใน "Exactly-once message processing" ว่า use case สำคัญอย่างหนึ่งของ distributed transaction คือการรับประกันว่า operation มีผลเพียงครั้งเดียว (exactly once) แม้ crash จะเกิดขึ้นระหว่างประมวลผลและต้อง retry ถ้าคุณ commit transaction แบบ atomic ข้าม message broker กับฐานข้อมูลได้ คุณก็ ack message ไปยัง broker ได้ก็ต่อเมื่อ message นั้นถูกประมวลผลสำเร็จ และ database write ที่เกิดจากการประมวลผลถูก commit แล้ว
แต่จริง ๆ แล้วคุณไม่จำเป็นต้องมี distributed transaction เพื่อให้ได้ exactly-once semantics ยังมีอีกแนวทางหนึ่ง ดังนี้ ซึ่งต้องใช้แค่ transaction ภายในฐานข้อมูล:
-
สมมติว่าทุก message มี ID ที่ไม่ซ้ำกัน และในฐานข้อมูลคุณมีตารางของ message ID ที่ประมวลผลไปแล้ว เมื่อเริ่มประมวลผล message จาก broker คุณเริ่ม transaction ใหม่บนฐานข้อมูลและตรวจสอบ message ID ถ้ามี message ID เดียวกันอยู่ในฐานข้อมูลแล้ว ก็รู้ได้ว่า message นั้นถูกประมวลผลไปแล้ว จึง ack message ไปยัง broker แล้วทิ้งมันไปได้
-
ถ้า message ID ยังไม่มีในฐานข้อมูล คุณเพิ่มมันลงในตาราง แล้วจึงประมวลผล message ซึ่งอาจทำให้มี write เพิ่มเติมลงฐานข้อมูลภายใน transaction เดียวกัน เมื่อประมวลผล message เสร็จ คุณ commit transaction บนฐานข้อมูล
-
เมื่อ database transaction commit สำเร็จแล้ว คุณ ack message ไปยัง broker ได้
-
เมื่อ ack message ไปยัง broker สำเร็จแล้ว คุณรู้ว่า broker จะไม่พยายามประมวลผล message เดิมอีก จึงลบ message ID ออกจากฐานข้อมูลได้ (ใน transaction แยกต่างหาก)
ถ้า message processor crash ก่อน commit database transaction transaction จะถูก abort และ message broker จะ retry การประมวลผล ถ้ามัน crash หลัง commit แต่ก่อน ack message ไปยัง broker ก็จะ retry เช่นกัน แต่ตอน retry จะเห็น message ID ในฐานข้อมูลแล้วทิ้ง message นั้นไป ถ้ามัน crash หลัง ack message แต่ก่อนลบ message ID ออกจากฐานข้อมูล คุณจะมี message ID เก่าค้างอยู่ ซึ่งไม่เป็นอันตรายอะไรนอกจากกินพื้นที่เก็บข้อมูลนิดหน่อย ถ้า retry เกิดขึ้นก่อนที่ database transaction จะถูก abort (ซึ่งเกิดได้ถ้าการสื่อสารระหว่าง message processor และฐานข้อมูลถูกขัดจังหวะ) uniqueness constraint บนตารางของ message ID ควรป้องกันไม่ให้ message ID เดียวกันถูก insert โดยสอง concurrent transaction
ดังนั้น การทำ exactly-once processing ต้องใช้แค่ transaction ภายในฐานข้อมูล ไม่จำเป็นต้องมี atomicity ข้ามฐานข้อมูลกับ message broker สำหรับ use case นี้ การบันทึก message ID ในฐานข้อมูลทำให้การประมวลผล message เป็น idempotent เพื่อให้ retry การประมวลผล message ได้อย่างปลอดภัยโดยไม่เกิด side effect ซ้ำ แนวทางที่คล้ายกันนี้ใช้ใน stream processing framework เช่น Kafka Streams เพื่อให้ได้ exactly-once semantics ดังที่เราจะเห็นใน บทที่ 12
อย่างไรก็ตาม internal distributed transaction ภายในฐานข้อมูลก็ยังมีประโยชน์ต่อความสามารถในการ scale ของรูปแบบแบบนี้ เช่น มันอนุญาตให้เก็บ message ID ไว้ใน shard หนึ่ง และเก็บข้อมูลหลักที่การประมวลผล message อัปเดตไว้ใน shard อื่น และรับประกัน atomicity ของการ commit transaction ข้าม shard เหล่านั้น
Summary (สรุป)
Transaction เป็น abstraction layer ที่ช่วยให้แอปพลิเคชันแสร้งทำเป็นว่าปัญหา concurrency บางอย่าง และความผิดพลาดของฮาร์ดแวร์และซอฟต์แวร์บางชนิด ไม่มีอยู่จริง ข้อผิดพลาดกลุ่มใหญ่ถูกลดเหลือเพียง transaction abort ธรรมดา ๆ และแอปพลิเคชันก็แค่ต้องลองอีกครั้ง
ในบทนี้เราเห็นตัวอย่างมากมายของปัญหาที่ transaction ช่วยป้องกัน แอปพลิเคชันไม่ใช่ทุกตัวที่เสี่ยงต่อปัญหาเหล่านี้ทั้งหมด แอปพลิเคชันที่มีรูปแบบการเข้าถึงง่ายมาก เช่น อ่านและเขียนแค่ record เดียว อาจจัดการได้โดยไม่ต้องมี transaction แต่สำหรับรูปแบบการเข้าถึงที่ซับซ้อนกว่า transaction ช่วยลดจำนวนกรณีข้อผิดพลาดที่คุณต้องคิดถึงลงได้มหาศาล
ถ้าไม่มี transaction สถานการณ์ข้อผิดพลาดต่าง ๆ (process crash, เครือข่ายหลุด, ไฟดับ, ดิสก์เต็ม, concurrency ที่คาดไม่ถึง ฯลฯ) ทำให้ข้อมูลไม่สอดคล้องกันได้หลายรูปแบบ เช่น ข้อมูลที่ denormalize ไว้อาจไม่ตรงกับข้อมูลต้นทางได้ง่าย ๆ และถ้าไม่มี transaction ก็ยากมากที่จะคิดให้ออกว่าการเข้าถึงที่ซับซ้อนและส่งผลต่อกันจะกระทบฐานข้อมูลอย่างไร
เราลงลึกเป็นพิเศษในเรื่อง concurrency control โดยพูดถึง isolation level ที่ใช้กันอย่างแพร่หลายหลายแบบ โดยเฉพาะ read-committed , snapshot (บางครั้งเรียกว่า repeatable read ), และ serializable เราอธิบายลักษณะของ isolation level เหล่านั้นผ่านตัวอย่าง race condition ต่าง ๆ ซึ่งสรุปไว้ใน ตาราง 8-1
Table 8-1. สรุป anomaly ที่เกิดขึ้นได้ใน isolation level ต่าง ๆ | Isolation level | Dirty reads | Read skew | Phantom reads | Lost updates | Write skew | | --- | --- | --- | --- | --- | --- | | Read uncommitted | ✗ Possible | ✗ Possible | ✗ Possible | ✗ Possible | ✗ Possible | | Read committed | ✓ Prevented | ✗ Possible | ✗ Possible | ✗ Possible | ✗ Possible | | Snapshot isolation | ✓ Prevented | ✓ Prevented | ✓ Prevented | ? Depends | ✗ Possible | | Serializable | ✓ Prevented | ✓ Prevented | ✓ Prevented | ✓ Prevented | ✓ Prevented |
สรุปสั้น ๆ อีกครั้ง:
Dirty reads
ไคลเอ็นต์หนึ่งอ่าน write ของอีกไคลเอ็นต์ก่อนที่ write เหล่านั้นจะถูก commit read-committed isolation level และ level ที่แข็งแกร่งกว่าป้องกัน dirty read ได้
Dirty writes
ไคลเอ็นต์หนึ่งเขียนทับข้อมูลที่อีกไคลเอ็นต์เขียนไว้แต่ยังไม่ได้ commit transaction implementation เกือบทั้งหมดป้องกัน dirty write ได้ (จึงไม่อยู่ในตาราง)
Read skew
ไคลเอ็นต์เห็นส่วนต่าง ๆ ของฐานข้อมูล ณ จุดเวลาที่ต่างกัน read skew บางกรณียังเป็นที่รู้จักในชื่อ nonrepeatable reads ปัญหานี้มักถูกป้องกันด้วย snapshot isolation ซึ่งให้ transaction อ่านจาก consistent snapshot ที่ตรงกับจุดเวลาหนึ่ง ๆ Snapshot isolation มักถูก implement ด้วย multiversion concurrency control
Phantom reads
Transaction อ่าน object ที่ตรงกับเงื่อนไขการค้นหา แล้วไคลเอ็นต์อื่นเขียนบางอย่างที่กระทบผลลัพธ์ของการค้นหานั้น Snapshot isolation ป้องกัน phantom read แบบตรงไปตรงมาได้ แต่ phantom ในบริบทของ write skew ต้องจัดการเป็นพิเศษ เช่น index-range lock
Lost updates
ไคลเอ็นต์สองตัวทำ read-modify-write cycle พร้อมกัน ตัวหนึ่งเขียนทับ write ของอีกตัวโดยไม่รวมการเปลี่ยนแปลงของมันไว้ด้วย ข้อมูลจึงหายไป snapshot isolation บาง implementation ป้องกัน anomaly นี้อัตโนมัติ ขณะที่บางตัวต้อง lock เอง ( SELECT FOR UPDATE )
Write skew
Transaction อ่านบางอย่าง ตัดสินใจจากค่าที่เห็น แล้วเขียนผลการตัดสินใจลงฐานข้อมูล แต่เมื่อถึงตอนที่ write เกิดขึ้น premise ของการตัดสินใจนั้นไม่เป็นจริงอีกแล้ว มีเพียง serializable isolation เท่านั้นที่ป้องกัน anomaly นี้ได้
Weak isolation level ป้องกัน anomaly เหล่านั้นได้บางอย่าง แต่ปล่อยให้คุณซึ่งเป็นนักพัฒนาแอปพลิเคชันต้องจัดการกับ anomaly ที่เหลือเอง (เช่น ด้วย explicit locking) มีเพียง serializable isolation เท่านั้นที่ป้องกันปัญหาเหล่านี้ได้ทั้งหมด เราพูดถึงสามแนวทางในการ implement serializable transaction:
รัน transaction ตามลำดับ serial จริง ๆ
ถ้าคุณทำให้แต่ละ transaction รันได้เร็วมาก (โดยทั่วไปด้วย stored procedure) และ transaction throughput ต่ำพอให้ CPU core เดียวประมวลผลไหว หรือ shard ได้ นี่เป็นทางเลือกที่เรียบง่ายและได้ผล
Two-phase locking
หลายทศวรรษที่ผ่านมา 2PL เป็นวิธีมาตรฐานในการ implement serializability แต่แอปพลิเคชันจำนวนมากเลี่ยงไม่ใช้เพราะประสิทธิภาพไม่ดี
Serializable snapshot isolation
SSI เป็นอัลกอริทึมที่ค่อนข้างใหม่ ซึ่งเลี่ยงข้อเสียส่วนใหญ่ของสองแนวทางก่อนหน้าได้ มันใช้แนวทาง optimistic โดยปล่อยให้ transaction ทำงานต่อไปโดยไม่บล็อก เมื่อ transaction ต้องการ commit ก็จะถูกตรวจสอบ และถูก abort ถ้าการรันนั้นไม่ serializable
สุดท้าย เราดูวิธีทำ atomicity เมื่อ transaction กระจายอยู่บนหลาย node โดยใช้ 2PC ถ้า node เหล่านั้นรันซอฟต์แวร์ฐานข้อมูลเดียวกัน distributed transaction ก็ทำงานได้ค่อนข้างดี แต่ข้ามเทคโนโลยี storage ต่างชนิด (ด้วย XA transaction) 2PC มีปัญหา คือไวต่อความผิดพลาดของ coordinator และของโค้ดแอปพลิเคชันที่ขับ transaction และทำงานร่วมกับกลไก concurrency control ได้ไม่ดี โชคดีที่ idempotence รับประกัน exactly-once semantics ได้โดยไม่ต้องทำ atomic commit ข้ามเทคโนโลยี storage ต่างชนิดกัน เราจะเห็นเรื่องนี้เพิ่มเติมในบทต่อ ๆ ไป
ตัวอย่างในบทนี้ใช้ relational data model แต่ดังที่กล่าวถึงใน "The need for multi-object transactions" transaction เป็นฟีเจอร์ของฐานข้อมูลที่มีคุณค่า ไม่ว่าจะใช้ data model แบบใดก็ตาม