นักเขียนบางคนอ้างว่า two-phase commit ทั่วไปนั้นมีค่าใช้จ่ายสูงเกินกว่าที่จะรองรับได้ เนื่องมาจากปัญหาด้านประสิทธิภาพหรือความพร้อมใช้งานที่เกิดขึ้น เราเชื่อว่ามันดีกว่าที่จะให้โปรแกรมเมอร์แอปพลิเคชันจัดการกับปัญหาด้านประสิทธิภาพจากการใช้งาน transaction มากเกินไปเมื่อเกิดคอขวดขึ้น มากกว่าที่จะต้องเขียนโค้ดเพื่อหลีกเลี่ยงการขาด transaction อยู่ตลอดเวลา
James Corbett et al., "Spanner: Google's Globally-Distributed Database" (2012)
ในโลกแห่งความจริงของระบบข้อมูล หลายสิ่งหลายอย่างสามารถผิดพลาดได้:
-
ซอฟต์แวร์หรือฮาร์ดแวร์ของฐานข้อมูลอาจล้มเหลวได้ทุกเมื่อ (รวมถึงในช่วงกลางของการดำเนินการเขียน)
-
แอปพลิเคชันอาจ crash ได้ทุกเมื่อ (รวมถึงครึ่งทางของชุดการดำเนินการ)
-
การหยุดชะงักของเครือข่ายสามารถตัดการเชื่อมต่อระหว่างแอปพลิเคชันกับฐานข้อมูล หรือระหว่างโหนดฐานข้อมูลด้วยกันโดยไม่คาดคิด
-
ไคลเอ็นต์หลายตัวอาจเขียนไปยังฐานข้อมูลพร้อมกัน ทำให้การเปลี่ยนแปลงของกันและกันถูกทับ
-
ไคลเอ็นต์อาจอ่านข้อมูลที่ไม่สมเหตุสมผลเนื่องจากถูกอัปเดตเพียงบางส่วน
-
Race conditions ระหว่างไคลเอ็นต์สามารถทำให้เกิดบั๊กที่น่าประหลาดใจได้
เพื่อให้มีความน่าเชื่อถือ ระบบจะต้องจัดการกับข้อบกพร่องทุกประเภทเหล่านี้ และมั่นใจว่าสิ่งเหล่านั้นไม่ก่อให้เกิดความล้มเหลวที่ร้ายแรง อย่างไรก็ตาม การใช้กลไกการทนต่อความผิดพลาด (fault-tolerance) นั้นเป็นงานที่หนักมาก มันต้องใช้ความคิดอย่างรอบคอบเกี่ยวกับทุกสิ่งที่อาจผิดพลาดได้ และการทดสอบอย่างเข้มงวดเพื่อให้แน่ใจว่าโซลูชันที่นำมาใช้นั้นใช้งานได้จริง
เป็นเวลาหลายทศวรรษที่ transaction เป็นกลไกที่ถูกเลือกใช้เพื่อทำให้ปัญหาเหล่านี้ง่ายขึ้น Transaction คือวิธีที่ให้แอปพลิเคชันรวมการอ่านและเขียนหลาย ๆ ครั้งเข้าด้วยกันเป็นหน่วยเชิงตรรกะ ในทางความคิด การอ่านและเขียนทั้งหมดใน transaction จะถูกดำเนินการเป็นการดำเนินการเดียว ไม่ว่า transaction ทั้งหมดจะสำเร็จ ซึ่งนำไปสู่การ commit หรือล้มเหลว ซึ่งนำไปสู่การ abort หรือ rollback หากล้มเหลว แอปพลิเคชันสามารถลองใหม่ได้อย่างปลอดภัย ด้วย transaction การจัดการข้อผิดพลาดจะง่ายขึ้นมากสำหรับแอปพลิเคชัน เพราะไม่ต้องกังวลเกี่ยวกับความล้มเหลวบางส่วน (ที่ซึ่งด้วยเหตุผลใดก็ตาม การดำเนินการบางอย่างสำเร็จและบางอย่างล้มเหลว)
หากคุณคุ้นเคยกับการทำงานกับ transaction มันอาจดูเหมือนเป็นเรื่องธรรมดา แต่เราไม่ควรมองข้ามมัน Transaction ไม่ใช่กฎของธรรมชาติ พวกมันถูกสร้างขึ้นโดยมีวัตถุประสงค์—นั่นคือเพื่อ ทำให้โมเดลการเขียนโปรแกรมง่ายขึ้น สำหรับแอปพลิเคชันที่เข้าถึงฐานข้อมูล การใช้ transaction ช่วยให้แอปพลิเคชันไม่ต้องสนใจสถานการณ์ข้อผิดพลาดและปัญหา concurrency ที่อาจเกิดขึ้นบางอย่าง เพราะฐานข้อมูลจัดการสิ่งเหล่านั้น แทนให้ (เราเรียกสิ่งเหล่านี้ว่า การรับประกันความปลอดภัย (safety guarantees) )
ไม่ใช่ทุกแอปพลิเคชันที่ต้องการ transaction และบางครั้งก็มีข้อดีในการลดทอนการรับประกันของ transaction หรือละทิ้งมันทั้งหมด (เช่น เพื่อให้ได้ประสิทธิภาพที่ดีขึ้นหรือความพร้อมใช้งานที่สูงขึ้น) คุณสมบัติด้านความปลอดภัยบางอย่างสามารถทำได้โดยไม่ต้องมี transaction ในทางกลับกัน transaction สามารถป้องกันความทุกข์ใจได้มากมาย ตัวอย่างเช่น สาเหตุทางเทคนิคเบื้องหลังเรื่องอื้อฉาว Post Office Horizon (ดู "ความน่าเชื่อถือสำคัญแค่ไหน?" ) น่าจะเกิดจากการขาด ACID transactions ในระบบบัญชีเบื้องหลัง [ 1 ]
คุณจะรู้ได้อย่างไรว่าคุณจำเป็นต้องใช้ transaction? เพื่อตอบคำถามนั้น อันดับแรกเราต้องทำความเข้าใจเกี่ยวกับการรับประกันความปลอดภัยที่แน่ชัดที่ transaction สามารถให้ได้ และต้นทุนที่เกี่ยวข้องกับมัน แม้ว่า transaction จะดูตรงไปตรงมาในแวบแรก แต่ก็มีรายละเอียดปลีกย่อยที่สำคัญมากมายที่เกี่ยวข้อง
การควบคุม concurrency (concurrency control) มีความสำคัญทั้งสำหรับฐานข้อมูลแบบโหนดเดียวและแบบกระจาย เราจะเจาะลึกในหัวข้อนี้ในบทนี้ พูดถึง race conditions ชนิดต่าง ๆ ที่สามารถเกิดขึ้นได้ และวิธีที่ฐานข้อมูล implement isolation levels เช่น read-committed, snapshot isolation และ serializability เราจะตรวจสอบ two-phase commit protocol และความท้าทายในการบรรลุ atomicity ใน distributed transaction ด้วย
What Exactly Is a Transaction? (ธุรกรรมคืออะไรกันแน่)
ฐานข้อมูลเชิงสัมพันธ์ (relational databases) เกือบทั้งหมดในปัจจุบัน รวมถึงฐานข้อมูลที่ไม่ใช่เชิงสัมพันธ์บางตัว ก็รองรับ transactions ส่วนใหญ่แล้วพวกมันทำตามสไตล์ที่ IBM System R ซึ่งเป็นฐานข้อมูล SQL ตัวแรกได้นำเสนอในปี 1975 [ 2 , 3 , 4 ] แม้ว่ารายละเอียดการ implement บางอย่างจะเปลี่ยนไป แต่แนวคิดโดยรวมยังคงเหมือนเดิมมาเกือบ 50 ปีแล้ว: การรองรับ transaction ใน MySQL, PostgreSQL, Oracle, SQL Server ฯลฯ มีความคล้ายคลึงกับของ System R อย่างน่าประหลาดใจ
ในช่วงปลายทศวรรษ 2000 ฐานข้อมูลที่ไม่ใช่เชิงสัมพันธ์ (NoSQL) เริ่มได้รับความนิยม พวกมันมุ่งหวังที่จะปรับปรุงสถานะเดิมของฐานข้อมูลเชิงสัมพันธ์โดยเสนอทางเลือกของโมเดลข้อมูลใหม่ (ดู บทที่ 3 ) และรวม replication กับ sharding (ที่กล่าวถึงในบทที่ 6 และ 7 ) เป็นค่าเริ่มต้น Transaction เป็นเหยื่อหลักของกระแสนี้: ฐานข้อมูลในยุคนั้นหลายตัวละทิ้ง transaction ไปโดยสิ้นเชิง หรือนิยามคำนี้ใหม่เพื่อหมายถึงชุดการรับประกันที่อ่อนแอกว่าที่เคยเข้าใจกันมาก่อน
กระแสความนิยมรอบ ๆ ฐานข้อมูลแบบกระจาย NoSQL ทำให้เกิดความเชื่อที่แพร่หลายว่า transaction ไม่สามารถ scale ได้โดยพื้นฐาน และระบบขนาดใหญ่ใด ๆ ก็ต้องละทิ้งมันเพื่อรักษาประสิทธิภาพที่ดีและความพร้อมใช้งานสูง ในช่วงไม่นานมานี้ ความเชื่อนั้นได้พิสูจน์แล้วว่าผิด ฐานข้อมูลที่เรียกว่า "NewSQL" อย่าง CockroachDB [ 5 ], TiDB [ 6 ], Spanner [ 7 ], FoundationDB [ 8 ], และ YugabyteDB ได้แสดงให้เห็นว่าระบบ transactional สามารถ scale ไปสู่ปริมาณข้อมูลขนาดใหญ่และ throughput สูงได้ ระบบเหล่านี้รวม sharding เข้ากับ consensus protocols ซึ่งเราจะสำรวจใน บทที่ 10 เพื่อให้การรับประกัน ACID ที่แข็งแกร่งในระดับ scale
อย่างไรก็ตาม นั่นไม่ได้หมายความว่าทุกระบบต้องมี transactional เช่นกัน เช่นเดียวกับตัวเลือกการออกแบบทางเทคนิคอื่น ๆ transaction มีข้อดีและข้อจำกัด เพื่อทำความเข้าใจ trade-offs เหล่านั้น ในบทนี้เราจะสำรวจรายละเอียดของการรับประกันที่ transaction สามารถให้ได้ ทั้งในการทำงานปกติและในสถานการณ์สุดขั้ว (แต่เป็นจริง) ต่าง ๆ
The Meaning of ACID (ความหมายของ ACID)
การรับประกันความปลอดภัยที่ transaction ให้ได้มักถูกอธิบายด้วยตัวย่อที่รู้จักกันดีคือ ACID ซึ่งย่อมาจาก atomicity (ความเป็นอะตอม), consistency (ความสอดคล้อง), isolation (การแยกออกจากกัน), และ durability (ความคงทนถาวร) คำนี้ถูกบัญญัติขึ้นในปี 1983 โดย Theo Härder และ Andreas Reuter [ 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 หรือหลัง operation เท่านั้น ไม่ใช่สิ่งที่อยู่กึ่งกลางระหว่างนั้น
ในทางกลับกัน ในบริบทของ ACID atomicity ไม่ใช่ เกี่ยวกับ concurrency มันไม่ได้อธิบายสิ่งที่เกิดขึ้นเมื่อหลาย process พยายามเข้าถึงข้อมูลเดียวกันพร้อมกัน เพราะสิ่งนั้นครอบคลุมอยู่ภายใต้ตัวอักษร I ซึ่งย่อมาจาก isolation (ดู "Isolation" )
แต่ ACID atomicity อธิบายสิ่งที่เกิดขึ้นเมื่อไคลเอ็นต์ต้องการทำการเขียนหลายครั้ง แต่เกิดข้อบกพร่องขึ้นหลังจากบาง writes ถูกประมวลผลไปแล้ว—ตัวอย่างเช่น process crash, การเชื่อมต่อเครือข่ายขาด, ดิสก์เต็ม, หรือ integrity constraint ถูกละเมิด ถ้า writes ถูกจัดกลุ่มเข้าด้วยกันเป็น atomic transaction และ transaction ไม่สามารถทำให้เสร็จ (commit) ได้เนื่องจากข้อบกพร่อง transaction จะถูก abort และฐานข้อมูลต้องทิ้งหรือยกเลิกทุก writes ที่ได้ทำไปแล้วใน transaction นั้น
หากไม่มี atomicity เมื่อเกิดข้อผิดพลาดขึ้นกลางทางขณะทำการเปลี่ยนแปลงหลายอย่าง มันยากที่จะรู้ว่าการเปลี่ยนแปลงใดเกิดขึ้นแล้วและใดยังไม่เกิดขึ้น แอปพลิเคชันอาจลองอีกครั้ง แต่นั่นเสี่ยงที่จะทำให้บางการเปลี่ยนแปลงเกิดขึ้นสองครั้ง นำไปสู่ข้อมูลที่ซ้ำซ้อนหรือไม่ถูกต้อง Atomicity ทำให้ปัญหานี้ง่ายขึ้น: ถ้า transaction ถูก abort แอปพลิเคชันสามารถมั่นใจได้ว่ามันไม่ได้เปลี่ยนแปลงอะไรเลย ดังนั้นจึงสามารถลองใหม่ได้อย่างปลอดภัย
ความสามารถในการ abort transaction เมื่อเกิดข้อผิดพลาดและทิ้งทุก writes จาก 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 เริ่มต้นด้วยฐานข้อมูลที่ถูกต้องตาม invariants เหล่านี้ และทุก writes ระหว่าง transaction รักษาความถูกต้องนั้นไว้ คุณก็มั่นใจได้ว่า invariants จะเป็นที่พอใจเสมอ (invariant อาจถูกละเมิดชั่วคราวระหว่างการดำเนินการ transaction แต่ควรกลับมาเป็นที่น่าพอใจอีกครั้งเมื่อ transaction commit)
ถ้าคุณต้องการให้ฐานข้อมูลบังคับใช้ invariants ของคุณ คุณต้องประกาศมันเป็น constraints (ข้อจำกัด) ซึ่งเป็นส่วนหนึ่งของ schema ตัวอย่างเช่น foreign-key constraints, uniqueness constraints, และ check constraints (ซึ่งจำกัดค่าที่สามารถปรากฏในแต่ละแถว) มักถูกใช้เพื่อจำลอง invariants เฉพาะประเภท ข้อกำหนดความสอดคล้องที่ซับซ้อนกว่านี้บางครั้งสามารถจำลองได้โดยใช้ triggers หรือ materialized views [ 12 ]
อย่างไรก็ตาม invariants ที่ซับซ้อนอาจยากหรือเป็นไปไม่ได้ที่จะจำลองโดยใช้ constraints ที่ฐานข้อมูลมักจะมีให้ ในกรณีนั้น เป็นความรับผิดชอบของแอปพลิเคชันที่จะกำหนด transactions ให้ถูกต้องเพื่อรักษาความสอดคล้องไว้ ถ้าคุณเขียนข้อมูลเสียที่ละเมิด invariants ของคุณ แต่คุณไม่ได้ประกาศ invariants เหล่านั้น ฐานข้อมูลก็ไม่สามารถหยุดคุณได้ ดังนั้น C ใน ACID มักขึ้นอยู่กับว่าแอปพลิเคชันใช้ฐานข้อมูลอย่างไร และไม่ใช่คุณสมบัติของฐานข้อมูลเพียงอย่างเดียว
Isolation (การแยกออกจากกัน)
ฐานข้อมูลส่วนใหญ่ถูกเข้าถึงโดยไคลเอ็นต์หลายตัวพร้อมกัน นั่นไม่มีปัญหาถ้าพวกมันอ่านและเขียนคนละส่วนของฐานข้อมูล แต่ถ้าพวกมันเข้าถึงเรคคอร์ดฐานข้อมูลเดียวกัน คุณอาจเจอปัญหา concurrency (race conditions)
รูปที่ 8-1 คือตัวอย่างง่าย ๆ ของปัญหาประเภทนี้ สมมติว่าคุณมีไคลเอ็นต์สองตัวกำลังเพิ่มค่า counter ที่เก็บในฐานข้อมูลพร้อมกัน ไคลเอ็นต์แต่ละตัวต้องอ่านค่าปัจจุบัน บวก 1 และเขียนค่าใหม่กลับไป (สมมติว่าไม่มี increment operation ในตัวของฐานข้อมูล) ใน รูปที่ 8-1 counter ควรจะเพิ่มขึ้นจาก 42 เป็น 44 เพราะมีการ increment สองครั้ง แต่มันกลับไปที่ 43 เท่านั้นเนื่องจาก race condition
Figure 8-1. Race condition ระหว่างไคลเอ็นต์สองตัวที่เพิ่มค่า counter พร้อมกัน
Isolation ในความหมายของ ACID หมายถึง transactions ที่ทำงานพร้อมกันถูกแยกออกจากกัน พวกมันไม่สามารถก้าวก่ายกันได้ หนังสือเรียนฐานข้อมูลคลาสสิกให้คำนิยามของ isolation ว่าเป็น serializability ซึ่งหมายความว่าแต่ละ transaction สามารถแสร้งทำเป็นว่ามันเป็น transaction เดียวที่ทำงานอยู่บนฐานข้อมูลทั้งหมด ฐานข้อมูลรับประกันว่าเมื่อ transactions ถูก commit แล้ว ผลลัพธ์จะเหมือนกับว่าพวกมันถูกดำเนินการ แบบ serial (ทีละตัว) แม้ว่าในความเป็นจริงพวกมันอาจทำงานพร้อมกัน [ 13 ]
อย่างไรก็ตาม serializability มีต้นทุนด้านประสิทธิภาพ ในทางปฏิบัติ ฐานข้อมูลหลายตัวใช้รูปแบบของ isolation ที่อ่อนแอกว่า serializability—นั่นคือพวกมันยอมให้ concurrent transactions รบกวนกันและกันในขอบเขตที่จำกัด ฐานข้อมูลยอดนิยมบางตัว เช่น Oracle ไม่ได้ implement serializability เลย (Oracle มี isolation level ที่เรียกว่า "serializable" แต่มัน implement snapshot isolation ซึ่งเป็นการรับประกันที่อ่อนแอกว่า serializability [ 10 , 14 ]) ซึ่งหมายความว่า race conditions บางชนิดยังคงเกิดขึ้นได้ เราจะสำรวจ snapshot isolation และรูปแบบอื่น ๆ ของ isolation ใน "Weak Isolation Levels"
Durability (ความคงทนถาวร)
จุดประสงค์ของระบบฐานข้อมูลคือการจัดหาสถานที่ปลอดภัยสำหรับเก็บข้อมูลโดยไม่ต้องกลัวว่าจะสูญเสียมัน Durability คือคำสัญญาว่าหลังจาก transaction ถูก commit สำเร็จแล้ว ข้อมูลใด ๆ ที่มันเขียนไว้จะไม่ถูกลืม แม้ว่าจะมีข้อบกพร่องของฮาร์ดแวร์หรือฐานข้อมูล crash ก็ตาม
ในฐานข้อมูลแบบโหนดเดียว durability โดยทั่วไปหมายถึงข้อมูลถูกเขียนไปยังหน่วยความจำถาวร (nonvolatile storage) เช่น hard drive หรือ SSD การเขียนไฟล์ปกติมักถูกบัฟเฟอร์ในหน่วยความจำก่อนที่จะส่งไปยังดิสก์ในภายหลัง ซึ่งหมายความว่าข้อมูลอาจสูญหายหากเกิดไฟดับกะทันหัน ฐานข้อมูลจำนวนมากจึงใช้ fsync system call เพื่อให้แน่ใจว่าข้อมูลถูกเขียนไปยังดิสก์จริง ๆ ฐานข้อมูลมักจะมี write-ahead log หรือคุณสมบัติที่คล้ายกัน (ดู "Making B-trees reliable" ) ซึ่งช่วยให้สามารถกู้คืนได้ในกรณีที่เกิด crash กลางทางระหว่างการเขียน ฐานข้อมูลหลายตัว (เช่น MySQL, MongoDB, และ PostgreSQL) เก็บข้อมูลของพวกเขาพร้อม checksum ซึ่งช่วยให้ตรวจจับ log entries ที่เสียหายหรือไม่สมบูรณ์ และช่วยกู้คืนฐานข้อมูลให้เป็น consistent snapshot หลังจาก crash
ในฐานข้อมูลแบบ replicated durability อาจหมายถึงข้อมูลถูกคัดลอกไปยังโหนดจำนวนหนึ่งสำเร็จแล้ว เพื่อให้การรับประกัน durability ฐานข้อมูลต้องรอจนกว่า writes หรือ replications เหล่านี้เสร็จสมบูรณ์ก่อนที่จะรายงานว่า transaction ถูก commit สำเร็จ อย่างไรก็ตาม ตามที่ได้กล่าวถึงใน "Reliability and Fault Tolerance" ความสมบูรณ์แบบของ durability นั้นไม่มีอยู่จริง ถ้าฮาร์ดดิสก์ทั้งหมดและการสำรองข้อมูลทั้งหมดของคุณถูกทำลายพร้อมกัน ฐานข้อมูลก็ไม่สามารถทำอะไรเพื่อช่วยคุณได้อย่างชัดเจน
Replication and Durability (Replication และ Durability)
ในอดีต durability หมายถึงการเขียนไปยังเทปarchive จากนั้นก็ถูกเข้าใจว่าเป็นการเขียนไปยังดิสก์หรือ SSD ในปัจจุบันมันถูกปรับให้หมายถึงการ replication แล้วการ implement แบบไหนดีกว่ากัน?
ความจริงก็คือ ไม่มีอะไรที่สมบูรณ์แบบ:
-
ถ้าคุณเขียนลงดิสก์และเครื่องเสีย แม้ว่าข้อมูลของคุณจะไม่สูญหาย แต่มันก็ไม่สามารถเข้าถึงได้จนกว่าคุณจะซ่อมเครื่องหรือย้ายดิสก์ไปยังเครื่องอื่น ระบบแบบ replicated ยังคงพร้อมใช้งานได้
-
ข้อบกพร่องที่มีความสัมพันธ์กัน—เช่น ไฟดับ หรือบั๊กที่ทำให้ทุกโ-node ล้มเหลวเมื่อได้รับ input บางอย่าง—สามารถทำให้ replicas ทั้งหมดล้มเหลวพร้อมกัน (ดู "Reliability and Fault Tolerance" ) ทำให้ข้อมูลที่อยู่ในหน่วยความจำเท่านั้นสูญหาย การเขียนลงดิสก์จึงยังคงมีความเกี่ยวข้องสำหรับฐานข้อมูลแบบ replicated
-
ในระบบที่ replication แบบอะซิงโครนัส writes ล่าสุดอาจสูญหายเมื่อ leader ไม่พร้อมใช้งาน (ดู "Handling Node Outages" )
-
เมื่อไฟดับกะทันหัน โดยเฉพาะ SSDs ได้ถูกพบว่าบางครั้งละเมิดการรับประกันที่พวกมันควรจะให้ แม้แต่
fsyncก็ไม่รับประกันว่าจะทำงานได้ถูกต้อง [ 15 ] เฟิร์มแวร์ดิสก์สามารถมีบั๊กได้ เช่นเดียวกับซอฟต์แวร์ประเภทอื่น [ 16 , 17 ]—ตัวอย่างเช่น ทำให้ไดรฟ์ล้มเหลวหลังจากทำงานครบ 32,768 ชั่วโมง [ 18 ] และfsyncก็ใช้งานยาก แม้แต่ PostgreSQL ก็ใช้มันผิดมานานกว่า 20 ปี [ 19 , 20 , 21 ] -
ปฏิสัมพันธ์ที่ซับซ้อนระหว่าง storage engine และการ implement ระบบไฟล์สามารถนำไปสู่บั๊กที่ได้ยาก และอาจทำให้ไฟล์บนดิสก์เสียหายหลังจาก crash [ 22 , 23 ] ข้อผิดพลาดของระบบไฟล์บน replica หนึ่งบางครั้งสามารถแพร่กระจายไปยัง replicas อื่นได้เช่นกัน [ 24 ]
-
ข้อมูลบนดิสก์สามารถเสียหายทีละน้อยโดยไม่ถูกตรวจพบ [ 25 , 26 ] ถ้าข้อมูลเสียหายมาระยะหนึ่งแล้ว replicas และ backups ล่าสุดก็อาจเสียหายไปด้วย ในกรณีนี้ คุณจะต้องพยายามกู้คืนข้อมูลจาก backup ประวัติ
-
งานวิจัยหนึ่งเกี่ยวกับ SSDs พบว่า 30% ถึง 80% ของไดรฟ์พัฒนา bad block อย่างน้อยหนึ่งครั้งในช่วงสี่ปีแรกของการทำงาน และมีเพียงบางส่วนเท่านั้นที่สามารถแก้ไขได้โดยเฟิร์มแวร์ [ 27 ] ฮาร์ดไดรฟ์แบบแม่เหล็กมีอัตรา bad sectors ที่ต่ำกว่า แต่อัตราความล้มเหลวโดยรวมสูงกว่า SSDs
-
เมื่อ SSD ที่หมดอายุการใช้งาน (ที่ผ่านรอบการ write/erase จำนวนมาก) ถูกถอดออกจากไฟ มันสามารถเริ่มสูญเสียข้อมูลในระยะเวลาหลายสัปดาห์ถึงหลายเดือน ขึ้นอยู่กับอุณหภูมิ [ 28 ] ปัญหานี้พบน้อยกว่าสำหรับไดรฟ์ที่มีระดับการสึกหรอต่ำ [ 29 ]
ในทางปฏิบัติ ไม่มีเทคนิคใดเทคนิคหนึ่งที่สามารถให้การรับประกันที่สมบูรณ์แบบได้ มีเพียงเทคนิคลดความเสี่ยงต่าง ๆ—รวมถึงการเขียนลงดิสก์, การ replication ไปยังเครื่องระยะไกล, และ backups—และเทคนิคเหล่านี้สามารถและควรใช้ร่วมกัน โดยปกติแล้ว เป็นการฉลาดที่จะรับ "การรับประกัน" ทางทฤษฎีใด ๆ ด้วยความระมัดระวัง
Single-Object and Multi-Object Operations (การดำเนินการบนวัตถุเดียวและหลายวัตถุ)
ในการสรุป ใน ACID, atomicity และ isolation อธิบายสิ่งที่ฐานข้อมูลควรทำเมื่อไคลเอ็นต์ทำการเขียนหลายครั้งภายใน transaction เดียวกัน:
Atomicity
ถ้าเกิดข้อผิดพลาดขึ้นกลางทางระหว่างลำดับของการ writes transaction ควรถูก abort และ writes ที่ทำไปจนถึงจุดนั้นควรถูกทิ้ง กล่าวอีกนัยหนึ่ง ฐานข้อมูลช่วยคุณไม่ต้องกังวลเกี่ยวกับความล้มเหลวบางส่วนโดยให้การรับประกันแบบ all-or-nothing
Isolation
Transactions ที่ทำงานพร้อมกันไม่ควรรบกวนกันและกัน ตัวอย่างเช่น ถ้า transaction หนึ่งทำการเขียนหลายครั้ง transaction อื่นควรเห็น writes ทั้งหมดหรือไม่เห็นเลย แต่ไม่ใช่เห็นเพียงบางส่วน
นิยามเหล่านี้สมมติว่าคุณต้องการแก้ไขหลาย ๆ objects (rows, documents, records) ในครั้งเดียว Multi-object transactions แบบนี้มักจำเป็นเมื่อข้อมูลหลายชิ้นต้องถูกทำให้สอดคล้องกัน รูปที่ 8-2 แสดงตัวอย่างจากแอปพลิเคชันอีเมล ในการแสดงจำนวนข้อความที่ยังไม่ได้อ่านสำหรับผู้ใช้ คุณสามารถสอบถามแบบนี้:
SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true
อย่างไรก็ตาม คุณอาจพบว่าคิวรีนี้ช้าเกินไปถ้ามีอีเมลจำนวนมาก และตัดสินใจเก็บจำนวนข้อความที่ยังไม่ได้อ่านในฟิลด์แยกต่างหาก (เป็นการ 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: ถ้าเกิดข้อผิดพลาดขึ้น somewhere ระหว่างการดำเนินการ transaction เนื้อหาของกล่องจดหมายและ unread counter อาจไม่สอดคล้องกัน ใน atomic transaction ถ้าการอัปเดต counter ล้มเหลว transaction จะถูก abort และการแทรกอีเมลจะถูก roll back
Figure 8-3. Atomicity รับประกันว่าถ้าเกิดข้อผิดพลาด ทุก writes ก่อนหน้าจาก transaction นั้นจะถูกยกเลิก เพื่อหลีกเลี่ยงสถานะที่ไม่สอดคล้องกัน
Multi-object transactions ต้องการวิธีการระบุว่าการอ่านและเขียนใดอยู่ใน transaction เดียวกัน ในฐานข้อมูลเชิงสัมพันธ์ โดยทั่วไปทำได้โดยอาศัยการเชื่อมต่อ TCP ของไคลเอ็นต์ไปยังเซิร์ฟเวอร์ฐานข้อมูล บนการเชื่อมต่อใด ๆ ทุกอย่างระหว่าง BEGIN TRANSACTION และ COMMIT จะถูกพิจารณาว่าเป็นส่วนหนึ่งของ transaction เดียวกัน ถ้าการเชื่อมต่อ TCP ถูกขัดจังหวะ transaction ต้องถูก abort
ในทางกลับกัน ฐานข้อมูลที่ไม่ใช่เชิงสัมพันธ์หลายตัวไม่มีวิธีการ grouping operations เข้าด้วยกันแบบนี้ แม้ว่าจะมี multi-object API (เช่น key-value store อาจมี multi-put operation ที่อัปเดตหลาย keys ในครั้งเดียว) ก็ไม่ได้หมายความว่ามันมี transaction semantics: คำสั่งอาจสำเร็จสำหรับบาง keys และล้มเหลวสำหรับ keys อื่น ทำให้ฐานข้อมูลอยู่ในสถานะที่ถูกอัปเดตเพียงบางส่วน
Single-object writes (การเขียนบนวัตถุเดียว)
Atomicity และ isolation ยังใช้เมื่อมีการเปลี่ยนแปลงวัตถุเดียว ตัวอย่างเช่น ลองนึกภาพว่าคุณกำลังเขียนเอกสาร JSON ขนาด 20 kB ไปยังฐานข้อมูล:
-
ถ้าการเชื่อมต่อเครือข่ายถูกขัดจังหวะหลังจากส่งไป 10 kB แรก ฐานข้อมูลจะเก็บ JSON fragment ที่ไม่สามารถ parse ได้ขนาด 10 kB นั้นหรือไม่?
-
ถ้าไฟดับขณะที่ฐานข้อมูลกำลังเขียนทับค่าก่อนหน้าบนดิสก์ คุณจะได้ค่าทั้งเก่าและใหม่ที่ถูก spliced เข้าด้วยกันหรือไม่?
-
ถ้าไคลเอ็นต์อื่นอ่านเอกสารนั้นขณะที่การเขียนกำลังดำเนินอยู่ มันจะเห็นค่าที่ถูกอัปเดตเพียงบางส่วนหรือไม่?
แต่ละผลลัพธ์เหล่านั้นจะทำให้สับสนอย่างมาก ดังนั้น storage engines เกือบทั้งหมดจึงมุ่งมั่นที่จะให้ atomicity และ isolation ในระดับของวัตถุเดียว (เช่น key-value pair) บนโหนดเดียว Atomicity สามารถ implement ได้โดยใช้ log สำหรับ crash recovery (ดู "Making B-trees reliable" ) และ isolation สามารถ implement ได้โดยใช้ lock บนแต่ละวัตถุ (อนุญาตให้ thread เดียวเข้าถึงวัตถุได้ในแต่ละครั้ง)
บาง ฐานข้อมูลยังมี atomic operations ที่ซับซ้อนกว่า เช่น 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 operations เหล่านี้มีประโยชน์ เพราะมันสามารถป้องกัน lost updates เมื่อไคลเอ็นต์หลายตัวพยายามเขียนไปยังวัตถุเดียวกันพร้อมกัน (ดู "Preventing Lost Updates" ) อย่างไรก็ตาม มันไม่ใช่ transaction ในความหมายปกติของคำ ตัวอย่างเช่น โหมด "strong consistency" ของ Aerospike และฟีเจอร์ "lightweight transactions" ของ Cassandra และ ScyllaDB นำเสนอ linearizable (ดู "Linearizability" ) reads และ conditional writes บนวัตถุเดียว แต่ไม่มีการรับประกันข้ามหลายวัตถุ
The need for multi-object transactions (ความจำเป็นของ multi-object transactions)
เราต้องการ multi-object transactions หรือไม่? เป็นไปได้ไหมที่จะ implement แอปพลิเคชันใด ๆ ด้วย key-value data model และ single-object operations เพียงอย่างเดียว?
ในบาง use cases การ insert, update, และ delete บนวัตถุเดียวนั้นเพียงพอ อย่างไรก็ตาม ในหลายกรณีอื่น ๆ การเขียนไปยังหลาย ๆ objects จำเป็นต้องถูกประสานงาน:
-
ใน relational data model row ในตารางหนึ่งมักมี foreign-key reference ไปยัง row ในอีกตารางหนึ่ง ในทำนองเดียวกัน ใน graph-like data model vertex มี edges ไปยัง vertices อื่น ๆ Multi-object transactions ช่วยให้คุณมั่นใจว่า reference เหล่านี้ยังคงใช้ได้ เมื่อ inserting หลาย ๆ records ที่อ้างถึงกันและกัน foreign keys ต้องถูกต้องและเป็นปัจจุบัน ไม่เช่นนั้นข้อมูลจะไร้ความหมาย
-
ใน document data model ฟิลด์ที่ต้องอัปเดตพร้อมกันมักอยู่ในเอกสารเดียวกัน ซึ่งถูกถือว่าเป็นวัตถุเดียว ไม่จำเป็นต้องมี multi-object transactions เมื่ออัปเดตเอกสารเดียว อย่างไรก็ตาม ฐานข้อมูลเอกสารที่ขาดฟังก์ชัน join ก็สนับสนุน denormalization เช่นกัน (ดู "When to Use Which Model" ) เมื่อข้อมูลที่ถูก denormalize ต้องการการอัปเดต ดังในตัวอย่างของ รูปที่ 8-2 คุณต้องอัปเดตหลายเอกสารในครั้งเดียว Transactions มีประโยชน์มากในสถานการณ์นี้เพื่อป้องกันไม่ให้ข้อมูลที่ denormalize ไปไม่สอดคล้องกัน
-
ในฐานข้อมูลที่มี secondary indexes (เกือบทุกอย่างยกเว้น pure key-value stores) indexes ก็ต้องถูกอัปเดตทุกครั้งที่คุณเปลี่ยนค่า indexes เหล่านี้เป็น database objects ที่แตกต่างจากมุมมองของ transaction—ตัวอย่างเช่น หากไม่มี transaction isolation อาจเป็นไปได้ที่ record จะปรากฏใน index หนึ่งแต่ไม่ปรากฏในอีก index หนึ่งเพราะการอัปเดตไปยัง index ที่สองยังไม่เกิดขึ้น (ดู "Sharding and Secondary Indexes" )
แอปพลิเคชันดังกล่าวยังคง implement ได้โดยไม่มี transactions อย่างไรก็ตาม การจัดการข้อผิดพลาดจะซับซ้อนมากขึ้นหากไม่มี atomicity และการขาด isolation อาจทำให้เกิดปัญหา concurrency เราจะพูดถึงปัญหาเหล่านั้นใน "Weak Isolation Levels" และสำรวจแนวทางอื่นใน บทที่ 13
Handling errors and aborts (การจัดการข้อผิดพลาดและการยกเลิก)
คุณสมบัติสำคัญของ transaction คือมันสามารถถูก abort และลองใหม่ได้อย่างปลอดภัยถ้าเกิดข้อผิดพลาด ฐานข้อมูล ACID ยึดตามปรัชญานี้: ถ้าฐานข้อมูลกำลังเสี่ยงที่จะละเมิดการรับประกัน atomicity, isolation, หรือ durability มันจะละทิ้ง transaction ทั้งหมดแทนที่จะปล่อยให้มันค้างอยู่ครึ่งทาง
อย่างไรก็ตาม ไม่ใช่ทุกระบบที่ทำตามปรัชญานั้น โดยเฉพาะ datastores ที่มี leaderless replication (ดู "Leaderless Replication" ) ทำงานบนพื้นฐาน "best effort" มากกว่า ซึ่งสามารถสรุปได้ว่า "ฐานข้อมูลจะทำเท่าที่มันทำได้ และถ้ามันเจอข้อผิดพลาด มันจะไม่ยกเลิกสิ่งที่มันทำไปแล้ว"—ดังนั้นจึงเป็นความรับผิดชอบของแอปพลิเคชันที่จะกู้คืนจากข้อผิดพลาด
ข้อผิดพลาด จะเกิดขึ้นอย่างหลีกเลี่ยงไม่ได้ แต่นักพัฒนาซอฟต์แวร์จำนวนมากชอบที่จะคิดแต่เรื่อง happy path มากกว่าความซับซ้อนของการจัดการข้อผิดพลาด ตัวอย่างเช่น ORM frameworks ยอดนิยมอย่าง Rails ActiveRecord และ Django ไม่ได้ลอง transaction ที่ถูก abort ใหม่—ข้อผิดพลาดมักส่งผลให้ exception ผุดขึ้นมาตาม stack ดังนั้น input ผู้ใช้ใด ๆ จะถูกทิ้ง และผู้ใช้ได้รับข้อความแสดงข้อผิดพลาด น่าเสียดาย เพราะประเด็นทั้งหมดของการ roll back transactions ก็เพื่อให้สามารถลองใหม่ได้อย่างปลอดภัย
แม้ว่าการลอง transaction ที่ถูก abort ใหม่เป็นกลไกการจัดการข้อผิดพลาดที่เรียบง่ายและมีประสิทธิภาพ แต่มันก็ไม่สมบูรณ์แบบ:
-
ถ้า transaction สำเร็จจริง แต่เครือข่ายถูกขัดจังหวะขณะที่เซิร์ฟเวอร์พยายามยืนยันความสำเร็จ (commit) ไปยังไคลเอ็นต์ (ทำให้มัน timeout จากมุมมองของไคลเอ็นต์) การลอง transaction ใหม่จะทำให้มันถูกดำเนินการสองครั้ง เว้นแต่คุณจะมีกลไก deduplication เพิ่มเติมในระดับแอปพลิเคชัน
-
ถ้าข้อผิดพลาดเกิดจาก overload หรือการแข่งขันสูงระหว่าง transactions ที่ทำงานพร้อมกัน การลอง transaction ใหม่จะทำให้ปัญหาแย่ลง ไม่ดีขึ้น เพื่อหลีกเลี่ยง feedback cycles ดังกล่าว คุณสามารถจำกัดจำนวนครั้งในการลองใหม่ ใช้ exponential backoff และจัดการข้อผิดพลาดที่เกี่ยวข้องกับ overload แตกต่างจากข้อผิดพลาดอื่น (ดู "When an Overloaded System Won't Recover" )
-
การลองใหม่ควรทำหลังจาก transient errors (เช่น เนื่องจาก deadlock, isolation violation, การขัดจังหวะเครือข่ายชั่วคราว, หรือ failover) เท่านั้น หลังจาก permanent error (เช่น constraint violation) การลองใหม่จะไร้ประโยชน์
-
ถ้า transaction มี side effects นอกฐานข้อมูล side effects เหล่านั้นอาจเกิดขึ้นแม้ว่า transaction จะถูก abort แล้วก็ตาม ตัวอย่างเช่น ถ้าคุณกำลังส่งอีเมล คุณคงไม่ต้องการส่งอีเมลซ้ำทุกครั้งที่ลอง transaction ใหม่ ถ้าคุณต้องการให้แน่ใจว่าหลายระบบ commit หรือ abort พร้อมกัน two-phase commit สามารถช่วยได้ (เราจะกล่าวถึงใน "Two-Phase Commit" )
-
ถ้า client process crash ระหว่างการลองใหม่ ข้อมูลใด ๆ ที่มันพยายามเขียนไปยังฐานข้อมูลจะสูญหาย
Weak Isolation Levels (ระดับการแยกตัวที่อ่อนแอ)
ถ้า two transactions ไม่ได้เข้าถึงข้อมูลเดียวกัน หรือทั้งคู่เป็น read-only พวกมันสามารถทำงานพร้อมกันได้อย่างปลอดภัย เพราะไม่มี transaction ใดขึ้นอยู่กับอีกอันหนึ่ง ปัญหา concurrency (race conditions) จะเกิดขึ้นก็ต่อเมื่อ transaction หนึ่งอ่านข้อมูลที่ถูกแก้ไขพร้อมกันโดยอีก transaction หนึ่ง หรือเมื่อสอง transactions พยายามแก้ไขข้อมูลเดียวกัน
บั๊กเกี่ยวกับ concurrency นั้นค้นหาได้ยากโดยการทดสอบ เพราะบั๊กเหล่านี้จะเกิดขึ้นก็ต่อเมื่อคุณโชคร้ายกับจังหวะเวลาเท่านั้น ปัญหาด้านจังหวะดังกล่าวอาจเกิดขึ้นได้ยากและมักจำลองได้ยาก Concurrency ยังยากต่อการให้เหตุผล โดยเฉพาะในแอปพลิเคชันขนาดใหญ่ที่คุณไม่จำเป็นต้องรู้ว่าโค้ดส่วนอื่นใดกำลังเข้าถึงฐานข้อมูล การพัฒนาแอปพลิเคชันก็ยากพออยู่แล้วถ้าคุณมีผู้ใช้แค่ครั้งละคน การมีผู้ใช้พร้อมกันจำนวนมากทำให้ยากยิ่งขึ้น เพราะข้อมูลชิ้นใด ๆ ก็สามารถเปลี่ยนแปลงอย่างไม่คาดคิดได้ทุกเมื่อ
ด้วยเหตุนั้น ฐานข้อมูลจึงพยายามซ่อนปัญหา concurrency จากนักพัฒนาแอปพลิเคชันมาเป็นเวลานานโดยการให้ transaction isolation ในทางทฤษฎี การ isolation ควรทำให้ชีวิตคุณง่ายขึ้นโดยให้คุณแสร้งทำเสมือนว่าไม่มี concurrency เกิดขึ้น Serializable isolation หมายความว่าฐานข้อมูลรับประกันว่า transactions มีผลลัพธ์เหมือนกับว่าพวกมันทำงาน แบบ serial (นั่นคือ ทีละครั้ง โดยไม่มี concurrency)
ในทางปฏิบัติ isolation นั้นไม่ง่ายอย่างนั้นน่าเสียดาย Serializable isolation มีต้นทุนด้านประสิทธิภาพ และฐานข้อมูลจำนวนมากไม่ต้องการจ่ายราคานั้น [ 10 ] ดังนั้น ระบบจึงมักใช้ระดับการ isolation ที่อ่อนแอกว่า ซึ่งป้องกันปัญหา concurrency บางอย่าง แต่ไม่ทั้งหมด ระดับการ isolation เหล่านั้นเข้าใจได้ยากกว่ามากและสามารถนำไปสู่บั๊กที่ซับซ้อน แต่กระนั้นก็ยังถูกใช้ในทางปฏิบัติ [ 30 ]
บั๊กเกี่ยวกับ concurrency ที่เกิดจาก weak transaction isolation และ race conditions ไม่ใช่แค่ปัญหาเชิงทฤษฎีเท่านั้น พวกมันก่อให้เกิดความเสียหายทางการเงินอย่างมาก รวมถึงการทำให้ Bitcoin exchange ล้มละลาย [ 31 , 32 , 33 , 34 ] นำไปสู่การสอบสวนโดยผู้สอบบัญชีทางการเงิน [ 35 ] และทำให้ข้อมูลลูกค้าเสียหาย [ 36 ] ความเห็นยอดนิยมต่อการเปิดเผยปัญหาดังกล่าวคือ "ใช้ฐานข้อมูล ACID ถ้าคุณจัดการกับข้อมูลทางการเงิน!"—แต่นั่นพลาดประเด็นไป แม้แต่ระบบฐานข้อมูลเชิงสัมพันธ์ยอดนิยมหลายระบบ (ซึ่งโดยทั่วไปถือว่าเป็น ACID) ก็ใช้ weak isolation ดังนั้นพวกมันจึงไม่จำเป็นต้องป้องกันบั๊กเหล่านี้ไม่ให้เกิดขึ้น
Note (หมายเหตุ)
อย่างไรก็ตาม ระบบธนาคารส่วนใหญ่พึ่งพา text files ที่ถูกแลกเปลี่ยนผ่าน secure FTP [ 37 ] ในบริบทนี้ การมี audit trail และมาตรการป้องกันการฉ้อโกงในระดับมนุษย์นั้นสำคัญกว่าคุณสมบัติ ACID เสียอีก
ตัวอย่างเหล่านั้นยังชี้ให้เห็นประเด็นสำคัญ: แม้ว่าปัญหา concurrency จะเกิดขึ้นได้ยากในการทำงานปกติ คุณก็ต้องพิจารณาความเป็นไปได้ที่ผู้โจมตีอาจจงใจส่งคำขอที่ concurrent สูงเป็นชุดไปยัง API ของคุณเพื่อพยายามใช้ประโยชน์จากบั๊ก concurrency [ 32 ] ดังนั้น เพื่อสร้างแอปพลิเคชันที่เชื่อถือได้และปลอดภัย คุณต้องมั่นใจว่าบั๊กดังกล่าวถูกป้องกันอย่างเป็นระบบ
ในส่วนนี้เราจะดู weak (nonserializable) isolation levels หลายแบบที่ใช้ในทางปฏิบัติ และพูดคุยในรายละเอียดเกี่ยวกับ race conditions ชนิดต่าง ๆ ที่สามารถและไม่สามารถเกิดขึ้นได้กับแต่ละแบบ เพื่อให้คุณตัดสินใจได้ว่าระดับใดเหมาะสมกับแอปพลิเคชันของคุณ เมื่อทำเสร็จแล้ว เราจะพูดถึง serializability ในรายละเอียด (ดู "Serializability" ) การอภิปรายของเราเกี่ยวกับ isolation levels จะเป็นแบบไม่เป็นทางการ โดยใช้ตัวอย่าง ถ้าคุณต้องการนิยามที่เข้มงวดและการวิเคราะห์คุณสมบัติของมัน คุณสามารถหาได้ในเอกสารวิชาการ [ 38 , 39 , 40 , 41 ]
Read Committed (การอ่านที่ commit แล้ว)
ระดับพื้นฐานที่สุดของ transaction isolation คือ read committed และมันให้การรับประกันสองประการ
-
เมื่ออ่านจากฐานข้อมูล คุณจะเห็นเฉพาะข้อมูลที่ถูก commit แล้วเท่านั้น (ไม่มี dirty reads)
-
เมื่อเขียนไปยังฐานข้อมูล คุณจะเขียนทับเฉพาะข้อมูลที่ถูก commit แล้วเท่านั้น (ไม่มี dirty writes)
มาพูดคุยเกี่ยวกับการรับประกันสองข้อนี้ในรายละเอียดกัน
No dirty reads (ไม่มีการอ่านข้อมูลที่ยังไม่ commit)
ลองนึกภาพว่า transaction ได้เขียนข้อมูลบางอย่างไปยังฐานข้อมูล แต่ transaction ยังไม่ commit หรือ abort ยัง transaction อื่นสามารถเห็นข้อมูลที่ยังไม่ได้ commit นั้นได้หรือไม่? ถ้าได้ นั่นเรียกว่า dirty read [ 3 ]
Transactions ที่ทำงานในระดับ read-committed isolation ต้องป้องกัน dirty reads นั่นหมายความว่า writes ใด ๆ โดย transaction จะถูกมองเห็นโดยผู้อื่นก็ต่อเมื่อ transaction นั้น commit (แล้ว writes ทั้งหมดของมันจะถูกมองเห็นพร้อมกัน) ดังแสดงใน รูปที่ 8-4 โดยที่ผู้ใช้ 1 ตั้งค่า x = 3 แต่การเรียก get x ของผู้ใช้ 2 ยังคงคืนค่าเก่า คือ 2 ในขณะที่ผู้ใช้ 1 ยังไม่ commit
Figure 8-4. No dirty reads: ผู้ใช้ 2 จะเห็นค่าใหม่ของ x ก็ต่อเมื่อ transaction ของผู้ใช้ 1 ถูก commit แล้วเท่านั้น
การป้องกัน dirty reads มีประโยชน์ด้วยเหตุผลหลายประการ:
-
ถ้า transaction ต้องอัปเดตหลายแถว dirty read หมายความว่าอีก transaction หนึ่งอาจเห็นบางอัปเดตแต่ไม่เห็นทั้งหมด ตัวอย่างเช่น ใน รูปที่ 8-2 ผู้ใช้เห็นอีเมลที่ยังไม่ได้อ่านใหม่ แต่ไม่เห็น counter ที่อัปเดตแล้ว นี่คือ dirty read ของอีเมล การเห็นฐานข้อมูลในสถานะที่ถูกอัปเดตเพียงบางส่วนทำให้ผู้ใช้สับสนและอาจทำให้ transactions อื่นตัดสินใจผิดพลาด
-
ถ้า transaction ถูก abort writes ใด ๆ ที่มันทำไว้จำเป็นต้องถูก roll back (ดังใน รูปที่ 8-3 ) ถ้าฐานข้อมูลอนุญาต dirty reads transaction อาจเห็นข้อมูลที่ถูก roll back ในภายหลัง—นั่นคือข้อมูลที่ไม่เคยถูก commit ไปยังฐานข้อมูลจริง ๆ transaction ใดที่อ่านข้อมูลที่ยังไม่ commit ก็จำเป็นต้องถูก abort เช่นกัน นำไปสู่ปัญหาที่เรียกว่า cascading aborts
No dirty writes (ไม่มีการเขียนทับข้อมูลที่ยังไม่ commit)
จะเกิดอะไรขึ้นถ้า two transactions พยายามอัปเดตแถวเดียวกันในฐานข้อมูลพร้อมกัน? เราไม่รู้ว่า writes จะเกิดขึ้นในลำดับใด แต่ปกติเราถือว่า write ทีหลังจะเขียนทับ write ก่อนหน้า
อย่างไรก็ตาม จะเกิดอะไรขึ้นถ้า write แรกเป็นส่วนหนึ่งของ transaction ที่ยังไม่ commit ดังนั้น write ทีหลังไปเขียนทับค่าที่ยังไม่ commit? นี่เรียกว่า dirty write [ 38 ] Transactions ที่ทำงานในระดับ read-committed isolation ต้องป้องกัน dirty writes โดยปกติโดยการหน่วง write ที่สองจนกว่า transaction แรกจะ commit หรือ abort
การป้องกัน dirty writes ช่วยหลีกเลี่ยงปัญหา concurrency บางอย่าง:
-
ถ้า transactions อัปเดตหลายแถว dirty writes สามารถนำไปสู่ผลลัพธ์ที่ไม่ดี ตัวอย่างเช่น รูปที่ 8-5 ซึ่งแสดงเว็บไซต์ขายรถมือสองที่มีคนสองคน Aaliyah และ Bryce พยายามซื้อรถคันเดียวกันพร้อมกัน การซื้อรถต้องการ database writes สองครั้ง: รายการบนเว็บไซต์ต้องถูกอัปเดตเพื่อสะท้อนผู้ซื้อ และใบแจ้งหนี้การขายต้องถูกส่งไปยังผู้ซื้อ ในกรณีของ รูปที่ 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" เราจะพูดถึงวิธีทำให้ counter increments ดังกล่าวปลอดภัย
Figure 8-5. ด้วย dirty writes writes ที่ขัดแย้งกันจาก transactions ต่างกันสามารถปนกันได้
Implementing read-committed (การ implement read-committed)
Read-committed เป็น isolation level ที่ได้รับความนิยมมาก มันเป็นค่าเริ่มต้นใน Oracle Database, PostgreSQL, SQL Server และฐานข้อมูลอื่น ๆ อีกมากมาย [ 10 ]
โดยทั่วไป ฐานข้อมูลป้องกัน dirty writes โดยใช้ row-level locks เมื่อ transaction ต้องการแก้ไขแถวใดแถวหนึ่ง (หรือเอกสาร หรือ object อื่น) มันต้องได้รับ lock บนแถวนั้นก่อน จากนั้นมันต้องถือ lock นั้นไว้จนกว่า transaction จะถูก commit หรือ abort มีเพียง transaction เดียวเท่านั้นที่สามารถถือ lock สำหรับแถวใด ๆ ได้ ถ้า transaction อื่นต้องการเขียนไปยังแถวเดียวกัน มันต้องรอจนกว่า transaction แรกจะ commit หรือ abort ก่อนที่มันจะได้รับ lock และดำเนินการต่อ การล็อกนี้ทำโดยอัตโนมัติโดยฐานข้อมูลในโหมด read-committed (หรือใน isolation levels ที่แข็งแกร่งกว่า)
เราจะป้องกัน dirty reads ได้อย่างไร? ทางเลือกหนึ่งคือการใช้ lock เดียวกัน และกำหนดให้ transaction ที่ต้องการอ่านแถวหนึ่งต้องได้รับ lock ชั่วครู่แล้วปล่อยมันทันทีหลังจากอ่าน ซึ่งจะทำให้แน่ใจว่าการอ่านไม่สามารถเกิดขึ้นได้ในขณะที่แถวมีค่าที่ยังไม่ commit (เพราะในระหว่างนั้น lock จะถูกถือโดย transaction ที่กำลังทำการเขียน)
อย่างไรก็ตาม วิธีการที่ต้องใช้ read locks ทำงานได้ไม่ดีในทางปฏิบัติ เพราะ transaction เขียนที่ใช้เวลานาน transaction หนึ่งสามารถบังคับให้ transactions อื่น ๆ จำนวนมากรอจนกว่า transaction ที่ใช้เวลานานจะเสร็จสมบูรณ์ แม้ว่า transactions อื่นจะอ่านอย่างเดียวและไม่ได้เขียนอะไรไปยังฐานข้อมูลก็ตาม สิ่งนี้ส่งผลเสียต่อเวลา response time ของ read-only transactions และไม่ดีต่อการปฏิบัติการ: การชะลอตัวในส่วนหนึ่งของแอปพลิเคชันสามารถส่งผลกระทบต่อเนื่องไปยังส่วนที่แตกต่างกันโดยสิ้นเชิงของแอปพลิเคชัน เนื่องจากการรอล็อก
อย่างไรก็ตาม locks ถูกใช้เพื่อป้องกัน dirty reads ในฐานข้อมูลบางตัว เช่น IBM Db2 และ Microsoft SQL Server ด้วยการตั้งค่า read_committed_snapshot=off [ 30 ]
วิธีการที่ใช้กันทั่วไปมากกว่าในการป้องกัน dirty reads คือวิธีที่แสดงใน รูปที่ 8-4 สำหรับทุกแถวที่เขียน ฐานข้อมูลจะจำทั้งค่าเก่าที่ commit แล้วและค่าใหม่ที่ถูกตั้งโดย transaction ที่กำลังถือ write lock อยู่ในขณะนั้น ขณะที่ transaction กำลังดำเนินอยู่ transactions อื่นที่อ่านแถวนั้นจะได้รับค่าเก่า เฉพาะเมื่อค่าใหม่ถูก commit แล้ว transactions จึงจะเปลี่ยนไปอ่านค่าใหม่ (ดู "Multiversion concurrency control" สำหรับรายละเอียดเพิ่มเติม)
ฐานข้อมูลบางตัวรองรับ isolation level ที่อ่อนแอกว่าเรียกว่า read uncommitted มันป้องกัน dirty writes แต่ไม่ได้ป้องกัน dirty reads กล่าวอีกนัยหนึ่ง มันคืนค่าที่ถูกเขียนล่าสุดทันที แม้ว่า transaction ที่เขียนจะยังไม่ commit ก็ตาม สิ่งนี้สามารถให้ประสิทธิภาพที่ดีกว่า เพราะฐานข้อมูลไม่จำเป็นต้องเก็บสองเวอร์ชันของแถว มันยังสามารถลดความน่าจะเป็นของ (แต่ไม่ป้องกัน) lost updates ซึ่งเราจะพูดถึงใน "Preventing Lost Updates"
Snapshot Isolation and Repeatable Read (Snapshot Isolation และ Repeatable Read)
ถ้าคุณมอง read-committed isolation แบบผิวเผิน คุณอาจให้อภัยที่คิดว่ามันทำทุกอย่างที่ transaction ต้องการ: มันอนุญาตการ abort (จำเป็นสำหรับ atomicity), ป้องกันการอ่านผลลัพธ์ที่ไม่สมบูรณ์ของ transactions, และป้องกัน writes ที่เกิดขึ้นพร้อมกันไม่ให้ปนกัน แท้จริงแล้ว สิ่งเหล่านี้เป็นคุณสมบัติที่มีประโยชน์ และมันเป็นการรับประกันที่แข็งแกร่งกว่าสิ่งที่คุณจะได้จากระบบที่ไม่รองรับ transactions มาก
อย่างไรก็ตาม ยังมีวิธีอีกมากมายที่จะเกิดบั๊ก 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 เธอจะเห็นค่าที่แตกต่าง ($600) จากที่เธอเห็นใน query ก่อนหน้า 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 (การสำรองข้อมูล)
การสำรองข้อมูลต้องทำสำเนาของฐานข้อมูลทั้งหมด ซึ่งอาจใช้เวลาหลายชั่วโมงสำหรับฐานข้อมูลขนาดใหญ่ ในระหว่างที่กระบวนการสำรองข้อมูลกำลังทำงาน writes จะยังคงเกิดขึ้นกับฐานข้อมูล ดังนั้น คุณอาจจบลงด้วยบางส่วนของ backup ที่มีข้อมูลเวอร์ชันเก่า และส่วนอื่นมีข้อมูลเวอร์ชันใหม่ ถ้าคุณต้องการกู้คืนจาก backup ดังกล่าว ความไม่สอดคล้องกัน (เช่นเงินที่หายไป) จะกลายเป็นถาวร
Analytical queries และ integrity checks
บางครั้งคุณอาจต้องการเรียกใช้ query ที่สแกนผ่านส่วนใหญ่ของฐานข้อมูล query ดังกล่าวพบได้ทั่วไปใน analytics (ดู "Operational Versus Analytical Systems" ) หรือมันอาจเป็นส่วนหนึ่งของการตรวจสอบความสมบูรณ์เป็นระยะว่าทุกอย่างอยู่ในระเบียบ (การตรวจสอบข้อมูลเสียหาย) query เหล่านี้มีแนวโน้มที่จะคืนผลลัพธ์ที่ไร้ความหมายถ้าพวกมันสังเกตเห็นส่วนต่าง ๆ ของฐานข้อมูล ณ จุดเวลาที่แตกต่างกัน
Snapshot isolation [ 38 ] เป็นวิธีแก้ปัญหาที่พบบ่อยที่สุด แนวคิดคือแต่ละ transaction อ่านจาก consistent snapshot ของฐานข้อมูล—นั่นคือ มันเห็นข้อมูลทั้งหมดที่ถูก commit ในฐานข้อมูล ณ จุดเริ่มต้นของ transaction นั้น แม้ว่าข้อมูลจะถูกเปลี่ยนแปลงในภายหลังโดย transaction อื่น แต่ละ transaction จะเห็นเฉพาะข้อมูลเก่าจากจุดเวลาเฉพาะนั้น
Snapshot isolation เป็นประโยชน์อย่างมากสำหรับ long-running read-only queries เช่น backups และ 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 warehouses เช่น BigQuery ก็ใช้ snapshot isolation บ่อยครั้งเช่นกัน เพราะมันให้มุมมอง ณ จุดเวลาหนึ่งของฐานข้อมูลสำหรับ analytical queries
Multiversion concurrency control (การควบคุม concurrency แบบหลายเวอร์ชัน)
เช่นเดียวกับ read-committed isolation การ implement snapshot isolation โดยทั่วไปใช้ write locks เพื่อป้องกัน dirty writes (ดู "Implementing read-committed" ) ซึ่งหมายความว่า transaction ที่เขียนสามารถขัดขวางความคืบหน้าของอีก transaction หนึ่งที่เขียนไปยังแถวเดียวกันได้ อย่างไรก็ตาม การอ่านไม่ต้องการ lock ใด ๆ จากมุมมองด้านประสิทธิภาพ หลักการสำคัญของ snapshot isolation คือ readers ไม่เคย block writers และ writers ไม่เคย block readers สิ่งนี้ช่วยให้ฐานข้อมูลจัดการ read queries ที่ใช้เวลานานบน consistent snapshot พร้อมกันกับการประมวลผล writes ตามปกติ โดยไม่มีการแย่งชิง lock ระหว่างทั้งสอง
เพื่อ implement snapshot isolation ฐานข้อมูลใช้การขยายกลไกที่เราเห็นสำหรับการป้องกัน dirty reads ใน รูปที่ 8-4 แทนที่จะมีสองเวอร์ชันของแต่ละแถว (เวอร์ชันที่ commit แล้วและเวอร์ชันที่ถูกเขียนทับแต่ยังไม่ commit) ฐานข้อมูลอาจต้องเก็บหลายเวอร์ชันที่ commit แล้วของแถวหนึ่ง ๆ เพราะ transactions ที่กำลังดำเนินอยู่ต่าง ๆ อาจต้องเห็นสถานะของฐานข้อมูล ณ จุดเวลาที่แตกต่างกัน เนื่องจากมันรักษาหลายเวอร์ชันของแถวไว้เคียงข้างกัน เทคนิคนี้จึงเรียกว่า multiversion concurrency control (MVCC)
รูปที่ 8-7 แสดงให้เห็นว่า MVCC-based snapshot isolation ถูก implement ใน PostgreSQL อย่างไร [ 42 , 44 , 45 ] (การ implement อื่นก็คล้ายกัน) เมื่อ transaction ถูกเริ่ม มันจะได้รับ transaction ID ที่ไม่ซ้ำกันและเพิ่มขึ้นเสมอ ( txid ) เมื่อใดก็ตามที่ transaction เขียนอะไรบางอย่างไปยังฐานข้อมูล ข้อมูลที่มันเขียนจะถูกติดแท็กด้วย transaction ID ของผู้เขียน (เพื่อให้แม่นยำ transaction IDs ใน PostgreSQL เป็น 32-bit integers ดังนั้นมันจะล้นหลังจากประมาณ 4 พันล้าน transactions กระบวนการ vacuum จะทำการ cleanup เพื่อให้แน่ใจว่าการล้นนั้นไม่ส่งผลกระทบต่อข้อมูล)
Figure 8-7. การ implement snapshot isolation โดยใช้ multiversion concurrency control
แต่ละแถวในตารางมีฟิลด์ inserted_by ซึ่งเก็บ ID ของ transaction ที่แทรกแถวนั้นลงในตาราง แต่ละแถวยังมีฟิลด์ deleted_by ซึ่งเริ่มต้นว่างเปล่า ถ้า transaction ลบแถว แถวนั้นจะไม่ถูกลบออกจากฐานข้อมูล แต่จะถูกทำเครื่องหมายว่าถูกลบโดยการตั้งค่าฟิลด์ deleted_by เป็น ID ของ transaction ที่ร้องขอการลบ ในภายหลัง เมื่อแน่ใจแล้วว่าไม่มี transaction ใดสามารถเข้าถึงข้อมูลที่ถูกลบหรือถูกเขียนทับได้อีก กระบวนการ garbage collection (GC) ในฐานข้อมูลจะลบแถวที่ถูกทำเครื่องหมายว่าถูกลบและเพิ่มพื้นที่ว่าง
การอัปเดตจะถูกแปลงภายในเป็นการลบและการแทรก [ 46 ] ตัวอย่างเช่น ใน รูปที่ 8-7 transaction 13 หัก $100 จากบัญชี 2 เปลี่ยนยอดคงเหลือจาก $500 เป็น $400 ตาราง accounts ตอนนี้มีสองแถวสำหรับบัญชี 2: แถวที่มีเงิน $500 ที่ถูกทำเครื่องหมายว่าถูกลบโดย transaction 13 และแถวที่มีเงิน $400 ที่ถูกแทรกโดย transaction 13
ทุกเวอร์ชันของแถวจะถูกเก็บไว้ใน database heap เดียวกัน (ดู "Storing Values Within the Index" ) โดยไม่คำนึงว่า transactions ที่เขียนพวกมันได้ commit หรือไม่ เวอร์ชันต่าง ๆ ของแถวเดียวกันสร้าง linked list ไปจากเวอร์ชันล่าสุดไปหาเก่าสุดหรือกลับกัน เพื่อให้ queries สามารถ iterate ผ่านทุกเวอร์ชันของแถวภายใน [ 47 , 48 ]
Visibility rules for observing a consistent snapshot (กฎการมองเห็นสำหรับการสังเกต consistent snapshot)
เมื่อ transaction อ่านจากฐานข้อมูล transaction IDs จะถูกใช้เพื่อตัดสินว่าแถวเวอร์ชันใดที่มันสามารถเห็นได้และเวอร์ชันใดที่มองไม่เห็น โดยการกำหนดกฎการมองเห็นอย่างรอบคอบ ฐานข้อมูลสามารถนำเสนอ consistent snapshot ของเนื้อหาไปยังแอปพลิเคชัน วิธีการทำงานโดยคร่าว ๆ ดังนี้ [ 45 ]:
-
เมื่อเริ่มแต่ละ transaction ฐานข้อมูลจะสร้างรายการของ transactions อื่นทั้งหมดที่กำลังดำเนินอยู่ (ยังไม่ commit หรือ abort) ในขณะนั้น writes ใด ๆ ที่ transactions เหล่านั้นทำจะถูกไม่สนใจ แม้ว่า transactions จะ commit ในภายหลังก็ตาม สิ่งนี้รับประกันว่าแอปพลิเคชันเห็น consistent snapshot ที่ไม่ได้รับผลกระทบจากการ commit ของ transaction อื่น
-
writes ใด ๆ ที่ทำโดย transactions ที่มี transaction ID ทีหลัง (นั่นคือซึ่งเริ่มหลังจาก transaction ปัจจุบันเริ่ม และดังนั้นจึงไม่ถูกรวมในรายการของ transactions ที่กำลังดำเนินอยู่) จะถูกไม่สนใจ โดยไม่คำนึงว่า transactions เหล่านั้นจะ commit หรือไม่
-
writes ใด ๆ ที่ทำโดย transactions ที่ถูก abort จะถูกไม่สนใจ โดยไม่คำนึงว่า abort เกิดขึ้นเมื่อใด สิ่งนี้มีข้อดีว่าเมื่อ transaction ถูก abort เราไม่จำเป็นต้องลบแถวที่มันเขียนออกจาก storage ทันที เนื่องจากกฎการมองเห็นจะกรองมันออก กระบวนการ GC สามารถลบมันในภายหลัง
-
writes อื่นทั้งหมดจะถูกมองเห็นโดย queries ของแอปพลิเคชัน
กฎเหล่านี้ใช้กับการแทรกและการลบแถว ใน รูปที่ 8-7 เมื่อ transaction 12 อ่านจากบัญชี 2 มันเห็นยอดคงเหลือ $500 เพราะการลบยอด $500 นั้นทำโดย transaction 13 (ตามกฎข้อ 2 transaction 12 ไม่สามารถเห็นการลบที่ทำโดย transaction 13) และการแทรกยอด $400 ก็ยังไม่สามารถมองเห็นได้ (โดยกฎเดียวกัน)
กล่าวอีกนัยหนึ่ง แถวจะถูกมองเห็นถ้าทั้งสองเงื่อนไขต่อไปนี้เป็นจริง:
-
ณ เวลาที่ transaction ของผู้อ่านเริ่มต้น transaction ที่แทรกแถวได้ commit แล้ว
-
แถวไม่ได้ถูกทำเครื่องหมายว่าถูกลบ หรือถ้าถูกทำเครื่องหมาย transaction ที่ร้องขอการลบยังไม่ได้ commit ณ เวลาที่ transaction ของผู้อ่านเริ่มต้น
Transaction ที่ทำงานนานอาจใช้ snapshot ต่อไปเป็นเวลานาน อ่านค่าที่ (จากมุมมองของ transactions อื่น) ถูกเขียนทับหรือลบไปนานแล้ว โดยการไม่อัปเดตค่าในตำแหน่งเดิม แต่แทรกเวอร์ชันใหม่ทุกครั้งที่มีการเปลี่ยนแปลงค่า ฐานข้อมูลสามารถให้ consistent snapshot โดยมี overhead เพียงเล็กน้อย
Indexes and snapshot isolation (Indexes และ snapshot isolation)
Indexes ทำงานอย่างไรในฐานข้อมูลแบบ multiversion? วิธีการทั่วไปที่สุดคือแต่ละ index entry ชี้ไปยังหนึ่งในเวอร์ชันของแถวที่ตรงกับ entry (ไม่ว่าจะเป็นเวอร์ชันเก่าที่สุดหรือใหม่ที่สุด) แต่ละ row version อาจมีการอ้างอิงไปยังเวอร์ชันที่เก่ากว่าถัดไปหรือใหม่กว่าถัดไป query ที่ใช้ index จะต้อง iterate ผ่านแถวเพื่อหาแถวที่มองเห็นได้และค่าที่ตรงกับสิ่งที่ query กำลังค้นหา เมื่อ GC ลบ row versions เก่าที่ไม่สามารถมองเห็นได้อีกต่อไปโดย transaction ใด ๆ index entries ที่เกี่ยวข้องก็สามารถถูกลบได้เช่นกัน
รายละเอียดการ implement มากมายส่งผลต่อประสิทธิภาพของ multiversion concurrency control [ 47 , 48 ] ตัวอย่างเช่น PostgreSQL มีการเพิ่มประสิทธิภาพสำหรับการหลีกเลี่ยงการอัปเดต index ถ้าเวอร์ชันที่แตกต่างกันของแถวเดียวกันสามารถอยู่ในหน้าเดียวกันได้ [ 42 ] ฐานข้อมูลอื่นบางตัวหลีกเลี่ยงการเก็บสำเนาเต็มของแถวที่ถูกแก้ไขและเก็บเฉพาะความแตกต่างระหว่างเวอร์ชัน เพื่อประหยัดพื้นที่
อีกแนวทางหนึ่งถูกใช้ใน CouchDB, Datomic, และ LMDB แม้ว่าพวกมันจะใช้ B-trees (ดู "B-Trees" ) เช่นกัน แต่พวกมันใช้ immutable (copy-on-write) variant ซึ่งไม่เขียนทับ pages ของ tree เมื่อพวกมันถูกอัปเดต แต่จะสร้างสำเนาใหม่ของแต่ละ page ที่ถูกแก้ไขแทน Parent pages ขึ้นไปจนถึง root ของ tree จะถูกคัดลอกและอัปเดตเพื่อชี้ไปยังเวอร์ชันใหม่ของ child pages page ใดที่ไม่ได้รับผลกระทบจากการเขียนไม่จำเป็นต้องถูกคัดลอกและสามารถแชร์กับ tree ใหม่ได้ [ 49 ]
ด้วย immutable B-trees ทุก write transaction (หรือ batch ของ transactions) สร้าง root B-tree ใหม่ และ root เฉพาะนั้นคือ consistent snapshot ของฐานข้อมูล ณ จุดเวลาที่มันถูกสร้างขึ้น ไม่จำเป็นต้องกรองแถวตาม transaction IDs เพราะ writes ที่ตามมาไม่สามารถแก้ไข B-tree ที่มีอยู่ได้ พวกมันสามารถสร้าง roots ใหม่ของ tree เท่านั้น แนวทางนี้ยังต้องการกระบวนการเบื้องหลังสำหรับ 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 เพราะมาตรฐานอ้างอิงจากนิยามของ System R ในปี 1975 เกี่ยวกับ isolation levels [ 3 ] และ snapshot isolation ยังไม่ถูกคิดค้นขึ้นในตอนนั้น แทนที่จะเป็นเช่นนั้น มันกำหนด repeatable read isolation ซึ่งดูผิวเผินแล้วคล้ายกับ snapshot isolation PostgreSQL เรียก snapshot isolation level ของมันว่า repeatable read เพราะมันตรงตามข้อกำหนดของมาตรฐานและดังนั้นมันจึงสามารถอ้างความสอดคล้องกับมาตรฐานได้
น่าเสียดายที่นิยามของ isolation levels ใน SQL standard นั้นมีข้อบกพร่อง—มันคลุมเครือ ไม่แม่นยำ และไม่เป็นอิสระจากการ implement อย่างที่มาตรฐานควรจะเป็น [ 38 ] ถึงแม้ว่าฐานข้อมูลหลายตัวจะ implement repeatable read isolation แต่ก็มีความแตกต่างอย่างมากในการรับประกันที่พวกมันให้ แม้ว่าการรับประกันเหล่านั้นจะถูกทำให้เป็นมาตรฐานในนาม [ 30 ] isolation level นี้ถูกนิยามอย่างเป็นทางการในเอกสารวิจัย [ 39 , 40 ] แต่การ implement ส่วนใหญ่ไม่เป็นไปตามนิยามทางการนั้น ดังนั้นจึงไม่มีใครรู้จริง ๆ ว่า repeatable read isolation หมายถึงอะไร
Preventing Lost Updates (การป้องกัน Lost Updates)
การอภิปรายของเราเกี่ยวกับ read-committed และ snapshot isolation levels ส่วนใหญ่เน้นที่การรับประกันเกี่ยวกับสิ่งที่ read-only transaction สามารถเห็นได้เมื่อมี concurrent writes เราได้ไม่สนใจประเด็นของ two transactions ที่เขียนพร้อมกัน—เราได้พูดถึงแค่ dirty writes (ดู "No dirty writes" ) ซึ่งเป็น write-write conflict ชนิดหนึ่งที่สามารถเกิดขึ้นได้
ยังมี conflicts ที่น่าสนใจอีกหลายชนิดที่สามารถเกิดขึ้นระหว่าง transactions ที่เขียนพร้อมกัน ที่รู้จักกันดีที่สุดคือปัญหา lost update ที่แสดงใน รูปที่ 8-1 ด้วยตัวอย่างของการ increment counter พร้อมกันสองครั้ง
ปัญหา lost update สามารถเกิดขึ้นได้ถ้าแอปพลิเคชันอ่านค่าจากฐานข้อมูล แก้ไขมัน และเขียนค่าที่แก้ไขแล้วกลับไป (read-modify-write cycle ที่กล่าวถึงก่อนหน้านี้) ถ้า two transactions ทำเช่นนี้พร้อมกัน การแก้ไขหนึ่งครั้งอาจสูญหายได้ เพราะ write ที่สองไม่ได้รวมการแก้ไขครั้งแรก (บางครั้งเราบอกว่า write ทีหลัง clobbers write ก่อนหน้า) รูปแบบนี้เกิดขึ้นในหลายสถานการณ์ เช่น:
-
การ increment counter หรืออัปเดตยอดคงเหลือในบัญชี (ต้องอ่านค่าปัจจุบัน คำนวณค่าใหม่ และเขียนค่าที่อัปเดตกลับไป)
-
การเปลี่ยนแปลงบางส่วนในค่าที่ซับซ้อน—ตัวอย่างเช่น การเพิ่ม element ไปยัง list ภายใน JSON document (ต้อง parse เอกสาร ทำการเปลี่ยนแปลง และเขียนเอกสารที่แก้ไขแล้วกลับไป)
-
ผู้ใช้สองคนแก้ไขหน้า wiki พร้อมกัน โดยแต่ละคนบันทึกการเปลี่ยนแปลงโดยส่งเนื้อหาทั้งหน้าของหน้าไปยังเซิร์ฟเวอร์ เขียนทับสิ่งที่อยู่ในฐานข้อมูลในขณะนั้น
เนื่องจากนี่เป็นปัญหาที่พบบ่อย จึงมีวิธีแก้ไขหลากหลายที่ถูกพัฒนา [ 50 ] เราจะดูวิธีที่พบบ่อยที่สุดที่นี่
Atomic write operations (การดำเนินการเขียนแบบอะตอม)
ฐานข้อมูลจำนวนมากมี atomic update operations ซึ่งขจัดความจำเป็นในการ implement read-modify-write cycles ในโค้ดแอปพลิเคชัน โดยปกติแล้วมันเป็นวิธีแก้ปัญหาที่ดีที่สุดถ้าโค้ดของคุณสามารถแสดงในรูปของการดำเนินการเหล่านั้น ตัวอย่างเช่น คำสั่งต่อไปนี้ปลอดภัยต่อ concurrency ในฐานข้อมูลเชิงสัมพันธ์ส่วนใหญ่:
UPDATE counters SET value = value + 1 WHERE key = 'foo';
ในทำนองเดียวกัน ฐานข้อมูลเอกสารเช่น MongoDB มี atomic operations สำหรับการแก้ไขบางส่วนของ JSON document และ Redis มี atomic operations สำหรับการแก้ไข data structures เช่น priority queues ไม่ใช่ทุก writes ที่สามารถแสดงในรูปของ atomic operations ได้ง่าย—ตัวอย่างเช่น การอัปเดตหน้า wiki เกี่ยวข้องกับการแก้ไขข้อความตามอำเภอใจ ซึ่งสามารถจัดการได้โดยใช้อัลกอริทึมที่กล่าวถึงใน "Conflict-free replicated datatypes and operational transformation" —แต่ในสถานการณ์ที่การดำเนินการเหล่านี้สามารถใช้ได้ โดยปกติแล้วมันคือตัวเลือกที่ดีที่สุด
Atomic operations โดยทั่วไปถูก implement โดยการล็อก object อย่างเฉพาะเมื่อมันถูกอ่าน เพื่อให้ไม่มี transaction อื่นสามารถอ่านมันได้จนกว่าการอัปเดตจะเสร็จสมบูรณ์ อีกทางเลือกหนึ่งคือบังคับให้ atomic operations ทั้งหมดถูกดำเนินการบน thread เดียว
น่าเสียดายที่ ORM frameworks ทำให้ง่ายต่อการเขียนโค้ดที่ทำ read-modify-write cycles ที่ไม่ปลอดภัยโดยไม่ได้ตั้งใจ แทนที่จะใช้ atomic operations ที่ฐานข้อมูลจัดให้ [ 51 , 52 , 53 ] นี่อาจเป็นแหล่งของบั๊กที่ซับซ้อนซึ่งยากต่อการค้นหาโดยการทดสอบ
Explicit locking (การล็อกอย่างชัดเจน)
อีกทางเลือกหนึ่งสำหรับการป้องกัน lost updates ถ้า atomic operations ในตัวของฐานข้อมูลไม่ได้ให้ฟังก์ชันที่จำเป็น คือให้แอปพลิเคชันล็อก objects ที่กำลังจะถูกอัปเดตอย่างชัดเจน จากนั้นแอปพลิเคชันสามารถดำเนินการ read-modify-write cycle และถ้า transaction อื่นพยายามอัปเดตหรือล็อก object เดียวกันพร้อมกัน มันจะถูกบังคับให้รอจนกว่า read-modify-write cycle แรกจะเสร็จสมบูรณ์
ตัวอย่างเช่น พิจารณาเกมผู้เล่นหลายคนที่ผู้เล่นหลายคนสามารถย้ายตัวละครเดียวกันพร้อมกันได้ ในกรณีนี้ atomic operation อาจไม่เพียงพอ เพราะแอปพลิเคชันยังต้องมั่นใจว่าการเคลื่อนที่ของผู้เล่นเป็นไปตามกฏของเกม ซึ่งเกี่ยวข้องกับตรรกะบางอย่างที่คุณไม่สามารถ implement เป็น database query ได้อย่างสมเหตุสมผล คุณอาจใช้ lock เพื่อป้องกันไม่ให้ผู้เล่นสองคนย้ายตัวหมากเดียวกันพร้อมกัน ดังที่แสดงใน ตัวอย่าง 8-1
Example 8-1. การล็อกแถวอย่างชัดเจนเพื่อป้องกัน lost updates
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
ยิ่งไปกว่านั้น การล็อกหลาย objects มีความเสี่ยงของ deadlock ซึ่ง two หรือมากกว่า transactions กำลังรอให้อีกฝ่ายปล่อย lock ฐานข้อมูลจำนวนมากตรวจจับ deadlock โดยอัตโนมัติและ abort หนึ่งใน transactions ที่เกี่ยวข้องเพื่อให้ระบบสามารถดำเนินการต่อได้ คุณสามารถจัดการสถานการณ์นี้ในระดับแอปพลิเคชันโดยการลอง transaction ที่ถูก abort ใหม่
Automatically detecting lost updates (การตรวจจับ lost updates โดยอัตโนมัติ)
Atomic operations และ locks เป็นวิธีการป้องกัน lost updates โดยการบังคับให้ read-modify-write cycles เกิดขึ้นตามลำดับ อีกทางเลือกหนึ่งคืออนุญาตให้พวกมันทำงานแบบขนาน และถ้า 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 updates [ 30 , 43 ] ผู้เขียนบางคน [ 38 , 40 ] โต้แย้งว่าฐานข้อมูลต้องป้องกัน lost updates เพื่อให้มีคุณสมบัติเป็น snapshot isolation ดังนั้น MySQL จึงไม่ได้ให้ snapshot isolation ภายใต้นิยามนี้
ข้อดีใหญ่ของ lost update detection คือมันไม่ต้องการให้โค้ดแอปพลิเคชันใช้คุณสมบัติพิเศษใด ๆ ของฐานข้อมูล คุณอาจลืมใช้ lock หรือ atomic operation และทำให้เกิดบั๊ก แต่ lost update detection เกิดขึ้นโดยอัตโนมัติและดังนั้นจึงมีโอกาสผิดพลาดน้อยกว่า อย่างไรก็ตาม คุณยังต้องลอง transactions ที่ถูก abort ใหม่ในระดับแอปพลิเคชัน
Conditional writes (compare-and-set) (การเขียนแบบมีเงื่อนไข)
ในฐานข้อมูลที่ไม่มี transactions บางครั้งคุณจะพบ conditional write operation ที่สามารถป้องกัน lost updates โดยอนุญาตให้อัปเดตเกิดขึ้นได้ก็ต่อเมื่อค่าไม่เปลี่ยนแปลงตั้งแต่คุณอ่านครั้งล่าสุด (กล่าวถึงก่อนหน้านี้ใน "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 อีกต่อไป การอัปเดตนี้จะไม่มีผล ดังนั้นคุณต้องตรวจสอบว่าการอัปเดตมีผลหรือไม่ และลองใหม่ถ้าจำเป็น แทนที่จะเปรียบเทียบเนื้อหาทั้งหมด คุณยังสามารถใช้คอลัมน์ version number ที่คุณ increment ทุกครั้งที่มีการอัปเดต และใช้การอัปเดตก็ต่อเมื่อหมายเลขเวอร์ชันปัจจุบันไม่เปลี่ยนแปลง แนวทางนี้บางครั้งเรียกว่า optimistic locking [ 54 ]
โปรดทราบว่าถ้า transaction อื่นแก้ไข content พร้อมกัน เนื้อหาใหม่อาจไม่สามารถมองเห็นได้ภายใต้กฎการมองเห็นของ MVCC (ดู "Visibility rules for observing a consistent snapshot" ) การ implement MVCC หลายตัวมีข้อยกเว้นต่อกฎการมองเห็นสำหรับสถานการณ์นี้ โดยที่ค่าที่เขียนโดย transactions อื่นสามารถมองเห็นได้ในการประเมิน WHERE clause ของ UPDATE และ DELETE queries แม้ว่า writes เหล่านั้นจะไม่สามารถมองเห็นได้ใน snapshot โดยปกติ
Conflict resolution and replication (การแก้ไขความขัดแย้งและการ replication)
ในฐานข้อมูลแบบ replicated (ดู บทที่ 6 ) การป้องกัน lost updates เกิดขึ้นในอีกมิติหนึ่ง เพราะฐานข้อมูลเหล่านี้มีสำเนาของข้อมูลบนหลายโหนด และข้อมูลสามารถถูกแก้ไขพร้อมกันบนโหนดที่แตกต่างกัน จึงต้องมีขั้นตอนเพิ่มเติม
Locks และ conditional write operations สมมติว่ามีสำเนาล่าสุดของข้อมูลเพียงชุดเดียว อย่างไรก็ตาม ฐานข้อมูลที่มี multi-leader หรือ leaderless replication มักอนุญาตให้ writes หลายครั้งเกิดขึ้นพร้อมกันและ replicate แบบอะซิงโครนัส ดังนั้นพวกมันจึงไม่สามารถรับประกันสำเนาล่าสุดของข้อมูลเพียงชุดเดียว ดังนั้น เทคนิคที่ใช้ locks หรือ conditional writes จึงใช้ไม่ได้ในบริบทนี้ (เราจะกลับมาที่ประเด็นนี้ในรายละเอียดเพิ่มเติมใน "Linearizability" )
แทนที่จะเป็นเช่นนั้น ตามที่กล่าวถึงใน "Dealing with Conflicting Writes" แนวทางทั่วไปในฐานข้อมูลแบบ replicated ดังกล่าวคืออนุญาตให้ concurrent writes สร้างเวอร์ชันที่ขัดแย้งกันหลายเวอร์ชันของค่า (หรือที่เรียกว่า siblings ) และใช้โค้ดแอปพลิเคชันหรือ data structures พิเศษเพื่อแก้ไขและรวมเวอร์ชันเหล่านี้ภายหลัง
การรวมค่าที่ขัดแย้งกันสามารถป้องกัน lost updates ได้ถ้าการอัปเดตเป็น commutative (นั่นคือ คุณสามารถใช้มันในลำดับที่แตกต่างกันบน replicas ที่แตกต่างกันและยังได้ผลลัพธ์เดียวกัน) ตัวอย่างเช่น การ increment counter และการเพิ่ม element ไปยัง set เป็น commutative operations นั่นคือแนวคิดเบื้องหลัง CRDTs ซึ่งเราพบใน "Conflict-free replicated datatypes and operational transformation" อย่างไรก็ตาม การดำเนินการบางอย่าง เช่น conditional writes ไม่สามารถทำให้เป็น commutative ได้
นอกจากนี้ วิธี LWW conflict resolution ซึ่งเป็นค่าเริ่มต้นในฐานข้อมูลแบบ replicated หลายตัว มีแนวโน้มที่จะเกิด lost updates ดังที่กล่าวถึงใน "Last write wins (discarding concurrent writes)"
Write Skew and Phantoms (Write Skew และ Phantom)
ในส่วนก่อนหน้านี้เราดู dirty writes และ lost updates ซึ่งเป็น race conditions สองชนิดที่สามารถเกิดขึ้นได้เมื่อ transactions ที่แตกต่างกันพยายามเขียนไปยัง objects เดียวกันพร้อมกัน เพื่อหลีกเลี่ยงความเสียหายของข้อมูล race conditions เหล่านั้นจำเป็นต้องถูกป้องกัน—ไม่ว่าจะโดยอัตโนมัติโดยฐานข้อมูล หรือโดยการป้องกันด้วยตนเองเช่นการใช้ locks หรือ atomic write operations
อย่างไรก็ตาม นั่นไม่ใช่จุดสิ้นสุดของรายการ race conditions ที่อาจเกิดขึ้นระหว่างการเขียนพร้อมกัน ในส่วนนี้เราจะเห็นตัวอย่างที่ละเอียดอ่อนกว่าของ ความขัดแย้ง
เริ่มต้นด้วย ลองนึกภาพว่าคุณกำลังเขียนแอปพลิเคชันสำหรับแพทย์เพื่อจัดการกะการเรียกตัว (on-call shifts) ที่โรงพยาบาล โดยปกติโรงพยาบาลพยายามให้มีแพทย์หลายคนในกะเดียว แต่ต้องมีอย่างน้อยหนึ่งคน แพทย์สามารถสละกะของพวกเขาได้ (เช่น ถ้าพวกเขาป่วย) โดยมีเงื่อนไขว่าต้องมีเพื่อนร่วมงานอย่างน้อยหนึ่งคนกะนั้น [ 55 , 56 ]
ทีนี้ลองนึกภาพว่า Aaliyah และ Bryce เป็นแพทย์สองคนที่อยู่ในกะหนึ่ง ทั้งคู่รู้สึกไม่สบาย ดังนั้นพวกเขาจึงตัดสินใจขอลาหยุด โชคร้ายที่พวกเขาคลิกปุ่มออกจากกะในช่วงเวลาใกล้เคียงกัน สิ่งที่เกิดขึ้นต่อไปแสดงใน รูปที่ 8-8
ในแต่ละ transaction แอปพลิเคชันของคุณตรวจสอบก่อนว่ามีแพทย์สองคนหรือมากกว่าในกะนี้หรือไม่ ถ้ามี มันถือว่าปลอดภัยที่แพทย์หนึ่งคนจะออกจากกะได้ เนื่องจากฐานข้อมูลใช้ snapshot isolation การตรวจสอบทั้งสองคืนค่า 2 ดังนั้นทั้งสอง transactions ดำเนินการไปยังขั้นตอนถัดไป Aaliyah อัปเดตเรคคอร์ดของเธอเพื่อออกจากกะ และ Bryce ก็อัปเดตเรคคอร์ดของเขาเช่นเดียวกัน ทั้งสอง transactions commit และตอนนี้ไม่มีแพทย์ในกะแล้ว ข้อกำหนดของคุณที่ต้องมีแพทย์อย่างน้อยหนึ่งคนในกะถูกละเมิด
Figure 8-8. Write skew ที่ทำให้เกิดบั๊กในแอปพลิเคชัน
Characterizing write skew (การอธิบายลักษณะของ write skew)
ความผิดปกตินี้เรียกว่า write skew [ 38 ] มันไม่ใช่ dirty write หรือ lost update เพราะ two transactions กำลังอัปเดตสอง objects (เรคคอร์ดการเรียกตัวของ Aaliyah และ Bryce ตามลำดับ) มันไม่ชัดเจนนักว่ามีความขัดแย้งเกิดขึ้นที่นี่ แต่มันเป็น race condition แน่นอน: ถ้า two transactions ทำงานทีละรายการ แพทย์คนที่สองจะถูกป้องกันไม่ให้ออกจากกะ พฤติกรรมผิดปกติเกิดขึ้นได้ก็เพราะ transactions ทำงานพร้อมกัน
คุณสามารถคิดถึง write skew ว่าเป็นภาพรวมของปัญหา lost update Write skew สามารถเกิดขึ้นได้ถ้า two transactions อ่าน objects เดียวกันแล้วอัปเดตบางส่วนของ objects เหล่านั้น (transactions ที่แตกต่างกันอาจอัปเดต objects ที่แตกต่างกัน) ในกรณีพิเศษที่ transactions ที่แตกต่างกันอัปเดต object เดียวกัน คุณจะได้ dirty write หรือ lost update anomaly (ขึ้นอยู่กับจังหวะเวลา)
เราเห็นแล้วว่ามีหลายวิธีในการป้องกัน lost updates สำหรับ write skew ตัวเลือกของเรามีข้อจำกัดมากกว่า:
-
Atomic single-object operations ไม่ช่วย เพราะเกี่ยวข้องกับหลาย objects
-
การตรวจจับ lost updates โดยอัตโนมัติที่คุณพบในการ implement snapshot isolation บางตัว ก็ไม่ช่วยเช่นกัน—write skew ไม่ถูกตรวจจับ โดยอัตโนมัติ ใน repeatable read ของ PostgreSQL, repeatable read ของ MySQL/InnoDB, serializable ของ Oracle, หรือ snapshot isolation ของ SQL Server [ 30 ] การป้องกัน write skew โดยอัตโนมัติต้องใช้ true serializable isolation (ดู "Serializability" )
-
ฐานข้อมูลบางตัวอนุญาตให้คุณกำหนดค่า constraints ซึ่งจะถูกบังคับใช้โดยฐานข้อมูล (เช่น uniqueness, foreign-key constraints, หรือข้อจำกัดบนค่าเฉพาะ) อย่างไรก็ตาม เพื่อระบุว่าต้องมีแพทย์อย่างน้อยหนึ่งคนในกะ คุณจะต้องมี constraint ที่เกี่ยวข้องกับหลาย objects ฐานข้อมูลส่วนใหญ่ไม่มีการรองรับในตัวสำหรับ constraints ดังกล่าว แม้ว่าคุณอาจสามารถ implement ได้ด้วย triggers หรือ materialized views ดังที่กล่าวถึงใน "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);
น่าเสียดายที่ snapshot isolation ไม่ได้ป้องกันผู้ใช้อื่นจากการ insert การประชุมที่ขัดแย้งพร้อมกัน เพื่อรับประกันว่าคุณจะไม่มีการจองซ้ำซ้อน คุณต้องใช้ serializable isolation อีกครั้ง
เกมผู้เล่นหลายคน
ใน ตัวอย่าง 8-1 เรา ใช้ lock เพื่อป้องกัน lost updates (เพื่อให้แน่ใจว่าผู้เล่นสองคนไม่สามารถย้ายตัวหมากเดียวกันพร้อมกัน) อย่างไรก็ตาม lock ไม่ได้ป้องกันผู้เล่นจากการย้ายตัวหมากสองตัวที่แตกต่างกันไปยังตำแหน่งเดียวกันบนกระดาน หรืออาจทำการเคลื่อนที่อื่นที่ละเมิดกฎของเกม ขึ้นอยู่กับชนิดของกฎที่คุณกำลังบังคับใช้ คุณอาจใช้ uniqueness constraint ได้ แต่อย่างอื่นคุณก็ยังเสี่ยงต่อ write skew
การอ้างชื่อผู้ใช้
บน เว็บไซต์ที่ผู้ใช้แต่ละคนต้องมีชื่อผู้ใช้ที่ไม่ซ้ำกัน ผู้ใช้สองคนอาจลองสร้างบัญชีด้วยชื่อผู้ใช้เดียวกันพร้อมกัน คุณสามารถใช้ transaction เพื่อตรวจสอบว่าชื่อถูกใช้หรือไม่ และถ้าไม่ ก็สร้างบัญชีด้วยชื่อนั้น อย่างไรก็ตาม เช่นเดียวกับตัวอย่างก่อนหน้านี้ มันไม่ปลอดภัยภายใต้ snapshot isolation โชคดีที่ uniqueness constraint เป็นวิธีแก้ไขที่ง่ายที่นี่ (transaction ที่สองที่พยายามลงทะเบียนชื่อผู้ใช้จะถูก abort เนื่องจากละเมิด constraint)
การป้องกันการใช้จ่ายซ้ำซ้อน
บริการ ที่อนุญาตให้ผู้ใช้ใช้จ่ายเงินหรือคะแนนต้องตรวจสอบว่าผู้ใช้ไม่ได้ใช้จ่ายเกินกว่าที่พวกเขามี คุณอาจ implement โดยการแทรก spending item ชั่วคราวลงในบัญชีผู้ใช้ แสดงรายการทั้งหมดในบัญชี และตรวจสอบว่าผลรวมเป็นบวก อย่างไรก็ตาม ด้วย write skew อาจเกิดขึ้นได้ว่า spending items สองรายการถูกแทรกพร้อมกันซึ่งรวมกันทำให้ยอดคงเหลือติดลบ แต่ไม่มี transaction ใดสังเกตเห็นอีก transaction หนึ่ง
Phantoms causing write skew (Phantoms ที่ทำให้เกิด write skew)
ตัวอย่างก่อนหน้านี้ทั้งหมดเป็นไปตามรูปแบบที่คล้ายกัน:
-
query
SELECTตรวจสอบว่าข้อกำหนดเป็นที่พอใจหรือไม่โดยการค้นหาแถวที่ตรงกับเงื่อนไขการค้นหา (เช่น มีแพทย์อย่างน้อยสองคนในกะ ไม่มีการจองห้องนั้นในเวลานั้น ตำแหน่งบนกระดานยังไม่มีตัวหมากอื่น ชื่อผู้ใช้ยังไม่ถูกใช้ เงินยังคงอยู่ในบัญชี) -
ขึ้นอยู่กับผลลัพธ์ของ query แรก โค้ดแอปพลิเคชันตัดสินใจว่าจะดำเนินการต่ออย่างไร (อาจจะดำเนินการต่อ หรือรายงานข้อผิดพลาดให้ผู้ใช้และ abort)
-
ถ้าแอปพลิเคชันตัดสินใจดำเนินการต่อ มันทำการเขียน (
INSERT,UPDATE, หรือDELETE) ไปยังฐานข้อมูลและ commit transactionผลของการเขียนนี้เปลี่ยน precondition ของการตัดสินใจในขั้นตอนที่ 2 กล่าวอีกนัยหนึ่ง ถ้าคุณทำ query
SELECTซ้ำจากขั้นตอนที่ 1 หลังจาก commit การเขียน คุณจะได้ผลลัพธ์ที่แตกต่าง เพราะการเขียนเปลี่ยนชุดของแถวที่ตรงกับเงื่อนไขการค้นหา (ตอนนี้มีแพทย์ในกะน้อยลงหนึ่งคน ห้องประชุมถูกจองแล้วสำหรับเวลานั้น ตำแหน่งบนกระดานถูกครอบครองโดยตัวหมากที่ถูกย้าย ชื่อผู้ใช้ถูกใช้แล้ว ตอนนี้มีเงินในบัญชีน้อยลง)
ขั้นตอนอาจเกิดขึ้นในลำดับที่แตกต่างกัน ตัวอย่างเช่น คุณสามารถเขียนก่อน จากนั้นทำ query SELECT และสุดท้ายตัดสินใจว่าจะ abort หรือ commit ตามผลลัพธ์ของ query
ในตัวอย่างแพทย์ในกะ แถวที่ถูกแก้ไขในขั้นตอนที่ 3 เป็นหนึ่งในแถวที่คืนในขั้นตอนที่ 1 ดังนั้นคุณสามารถทำให้ transaction ปลอดภัยและหลีกเลี่ยง write skew โดยการล็อกแถวในขั้นตอนที่ 1 ( SELECT FOR UPDATE ) อย่างไรก็ตาม สี่ตัวอย่างอื่นนั้นแตกต่าง: พวกมันตรวจสอบการ ไม่มี ของแถวที่ตรงกับเงื่อนไขการค้นหา และการเขียน เพิ่ม แถวที่ตรงกับเงื่อนไขเดียวกัน ถ้า query ในขั้นตอนที่ 1 ไม่คืนแถวใด ๆ SELECT FOR UPDATE ไม่สามารถ attach locks ไปยังอะไรได้ [ 58 ]
ผลกระทบนี้ ซึ่งการเขียนใน transaction หนึ่งเปลี่ยนผลลัพธ์ของ search query ในอีก transaction หนึ่ง เรียกว่า phantom [ 4 ] Snapshot isolation หลีกเลี่ยง phantoms ใน read-only queries แต่ใน read/write transactions เช่นตัวอย่างที่เรากล่าวถึง phantoms สามารถนำไปสู่กรณี write skew ที่ซับซ้อนเป็นพิเศษ SQL ที่สร้างโดย ORMs ก็มีแนวโน้มที่จะเกิด write skew เช่นกัน [ 52 , 53 ]
Materializing conflicts (การทำให้ความขัดแย้งเป็นรูปธรรม)
ถ้าปัญหาของ phantoms คือไม่มี object ที่เราสามารถ attach locks ได้ บางทีเราอาจสร้าง lock object ขึ้นมาในฐานข้อมูลเทียม?
ตัวอย่างเช่น ในกรณีการจองห้องประชุม คุณสามารถจินตนาการถึงการสร้างตารางของช่วงเวลาและห้อง แต่ละแถวในตารางนี้สอดคล้องกับห้องเฉพาะในช่วงเวลาเฉพาะ (เช่น 15 นาที) คุณสร้างแถวสำหรับทุกชุดค่าผสมที่เป็นไปได้ของห้องและช่วงเวลาล่วงหน้า (เช่นสำหรับหกเดือนข้างหน้า)
ตอนนี้ transaction ที่ต้องการสร้างการจองสามารถล็อก ( SELECT FOR UPDATE ) แถวในตารางที่สอดคล้องกับห้องและช่วงเวลาที่ต้องการ หลังจากได้รับ locks แล้ว transaction สามารถตรวจสอบการจองที่ทับซ้อนกันและแทรกการจองใหม่เช่นเดิม โปรดทราบว่าตารางเพิ่มเติมนี้ไม่ได้ใช้เพื่อเก็บข้อมูลเกี่ยวกับการจอง—มันเป็นเพียง collection ของ locks ที่ใช้ป้องกันการจองห้องและช่วงเวลาเดียวกันไม่ให้ถูกทำพร้อมกัน
แนวทางนี้เรียกว่า materializing conflicts (การทำให้ความขัดแย้งเป็นรูปธรรม) เพราะมันนำ phantom และเปลี่ยนมันเป็น lock conflict บนชุดของแถวที่มีอยู่ในฐานข้อมูล [ 14 ] น่าเสียดายที่มันอาจยากและมีโอกาสผิดพลาดในการคิดหาวิธีทำให้ความขัดแย้งเป็นรูปธรรม และมันไม่สวยงามที่จะปล่อยให้กลไกการควบคุม concurrency รั่วไหลเข้าไปในโมเดลข้อมูลของแอปพลิเคชัน ด้วยเหตุผลเหล่านั้น materializing conflicts ควรถูกพิจารณาเป็นทางเลือกสุดท้ายถ้าไม่มีทางเลือกอื่น serializable isolation level เป็นที่นิยมกว่าในกรณีส่วนใหญ่
Serializability (ความสามารถในการจัดลำดับ)
ในบทนี้เราได้เห็นตัวอย่างหลายอย่างของ transactions ที่มีแนวโน้มที่จะเกิด race conditions race conditions บางอย่างถูกป้องกันโดย read-committed และ snapshot isolation levels แต่บางอย่างไม่ถูกป้องกัน เราพบตัวอย่างที่ซับซ้อนเป็นพิเศษกับ write skew และ phantoms มันเป็นสถานการณ์ที่น่าเศร้า:
-
Isolation levels เข้าใจยากและถูก implement อย่างไม่สอดคล้องกันในฐานข้อมูลที่แตกต่างกัน (เช่น ความหมายของ "repeatable read" แตกต่างกันอย่างมีนัยสำคัญ)
-
เป็นการยากที่จะบอกจากการดูโค้ดแอปพลิเคชันว่ามันปลอดภัยที่จะทำงานใน isolation level หนึ่งหรือไม่—โดยเฉพาะในแอปพลิเคชันขนาดใหญ่ ที่คุณอาจไม่ทราบถึงทุกสิ่งที่อาจเกิดขึ้นพร้อมกัน
-
ไม่มีเครื่องมือที่ดีที่จะช่วยเราตรวจจับ race conditions ในหลักการ static analysis อาจช่วยได้ [ 35 ] แต่เทคนิคการวิจัยยังไม่ถูกนำมาใช้ในทางปฏิบัติ การทดสอบปัญหา concurrency เป็นเรื่องยาก เพราะโดยปกติแล้วมันไม่ deterministic—ปัญหาจะเกิดขึ้นก็ต่อเมื่อคุณโชคร้ายกับจังหวะเวลาเท่านั้น
นี่ไม่ใช่ปัญหาใหม่ มันเป็นแบบนี้มาตั้งแต่ยุค 1970 เมื่อ weak isolation levels ถูกนำมาใช้ครั้งแรก [ 3 ] ตลอดมา คำตอบจากนักวิจัยนั้นง่าย: ใช้ serializable isolation!
Serializable isolation เป็น isolation level ที่แข็งแกร่งที่สุด มันรับประกันว่าแม้ว่า transactions อาจดำเนินการแบบขนาน ผลลัพธ์สุดท้ายจะเหมือนกับว่าพวกมันถูกดำเนินการทีละครั้ง แบบ serial โดยไม่มี concurrency ดังนั้น ฐานข้อมูลรับประกันว่าถ้า transactions มีพฤติกรรมถูกต้องเมื่อทำงานเดี่ยว มันจะยังคงถูกต้องเมื่อทำงานพร้อมกัน—กล่าวอีกนัยหนึ่ง ฐานข้อมูลป้องกัน ทั้งหมด race conditions ที่เป็นไปได้
แต่ถ้า serializable isolation ดีกว่าความยุ่งเหยิงของ weak isolation levels มาก ทำไมไม่ทุกคนใช้มัน? เพื่อตอบคำถามนี้ เราต้องดูตัวเลือกสำหรับการ implement serializability และประสิทธิภาพของมัน ฐานข้อมูลส่วนใหญ่ที่ให้ serializability ในปัจจุบันใช้หนึ่งในสามเทคนิค ซึ่งเราจะสำรวจในส่วนที่เหลือของบทนี้:
-
การดำเนินการ transactions ตามลำดับ 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 เดียว การทำเช่นนี้ เราหลีกเลี่ยงปัญหาการตรวจจับและป้องกันความขัดแย้งระหว่าง transactions ได้อย่างสมบูรณ์ ผลลัพธ์ที่ได้คือ serializable ตามนิยาม
ถึงแม้ว่านี่อาจดูเหมือนเป็นแนวคิดที่ชัดเจน แต่ก็เพียงในยุค 2000 ที่นักออกแบบฐานข้อมูลตัดสินใจว่าการ loop แบบ single-threaded สำหรับการดำเนินการ transaction นั้นเป็นไปได้ [ 59 ] ถ้า concurrency แบบ multithreaded ถูกพิจารณาว่าจำเป็นสำหรับการได้ประสิทธิภาพที่ดีในช่วง 30 ปีก่อนหน้านั้น อะไรเปลี่ยนไปที่ทำให้การดำเนินการแบบ single-threaded เป็นไปได้?
การพัฒนาสองอย่างทำให้เกิดการคิดใหม่นี้:
-
RAM ราคาถูกพอที่สำหรับหลาย use cases ตอนนี้มันเป็นไปได้ที่จะเก็บชุดข้อมูลที่ใช้งานอยู่ทั้งหมดในหน่วยความจำ (ดู "Keeping Everything in Memory" ) เมื่อข้อมูลทั้งหมดที่ transaction ต้องการเข้าถึงอยู่ในหน่วยความจำ transactions สามารถดำเนินการได้เร็วกว่ามากเมื่อเทียบกับการต้องรอให้ข้อมูลถูกโหลดจากดิสก์
-
นักออกแบบฐานข้อมูลตระหนักว่า OLTP transactions มักจะสั้นและทำการอ่านและเขียนเพียงจำนวนน้อย (ดู "Operational Versus Analytical Systems" ) ในทางตรงกันข้าม analytical queries ที่ใช้เวลานานมักเป็น read-only ดังนั้นพวกมันสามารถทำงานบน consistent snapshot (โดยใช้ snapshot isolation) นอกลูปการดำเนินการ serial
แนวทางการดำเนินการ transactions แบบ serial ถูก implement ใน VoltDB/H-Store, Redis, และ Datomic เป็นต้น [ 60 , 61 , 62 ] ระบบที่ออกแบบมาสำหรับการดำเนินการแบบ single-threaded บางครั้งสามารถทำงานได้ดีกว่าระบบที่รองรับ concurrency เพราะมันสามารถหลีกเลี่ยง overhead ของการประสานงานการล็อกได้ อย่างไรก็ตาม throughput ของมันถูกจำกัดที่ single CPU core เพื่อให้ใช้ประโยชน์จาก thread เดียวได้สูงสุด transactions ต้องถูกจัดโครงสร้างแตกต่างจากรูปแบบดั้งเดิม
Encapsulating transactions in stored procedures (การห่อหุ้ม transactions ใน stored procedures)
ในยุคแรก ๆ ของฐานข้อมูล ความตั้งใจคือ transaction ในฐานข้อมูลสามารถครอบคลุมขั้นตอนกิจกรรมทั้งหมดของผู้ใช้ ตัวอย่างเช่น การจองตั๋วเครื่องบินเป็นกระบวนการหลายขั้นตอน (ค้นหาเส้นทาง ค่าโดยสาร และที่นั่งว่าง ตัดสินใจเกี่ยวกับแผนการเดินทาง จองที่นั่งในแต่ละเที่ยวบิน ป้อนรายละเอียดผู้ชม ชำระเงิน) นักออกแบบฐานข้อมูลคิดว่ามันจะดีถ้ากระบวนการทั้งหมดนั้นเป็น transaction เดียวเพื่อให้มันสามารถ commit แบบอะตอมได้
น่าเสียดายที่มนุษย์ใช้เวลาตัดสินใจและตอบสนองช้ามาก ถ้า transaction ในฐานข้อมูลจำเป็นต้องรอ input จากผู้ใช้ ฐานข้อมูลต้องรองรับ transactions ที่ทำงานพร้อมกันจำนวนมหาศาล ซึ่งส่วนใหญ่ไม่ได้ใช้งาน ฐานข้อมูลส่วนใหญ่ไม่สามารถทำเช่นนั้นได้อย่างมีประสิทธิภาพ ดังนั้นแอปพลิเคชัน OLTP เกือบทั้งหมดจึงทำให้ transactions สั้นโดยหลีกเลี่ยงการรอผู้ใช้แบบโต้ตอบภายใน transaction บนเว็บ นี่หมายความว่า transaction จะถูก commit ภายใน HTTP request เดียวกัน—transaction ไม่ได้ครอบคลุมหลาย requests HTTP request ใหม่เริ่ม transaction ใหม่
ถึงแม้ว่ามนุษย์จะถูกนำออกจาก critical path แล้ว transactions ยังคงถูกดำเนินการในสไตล์ client/server แบบโต้ตอบ ทีละ statement แอปพลิเคชันทำ query อ่านผลลัพธ์ อาจทำ query อื่นขึ้นอยู่กับผลลัพธ์ของ query แรก และอื่น ๆ queries และผลลัพธ์ถูกส่งกลับไปกลับมาระหว่างโค้ดแอปพลิเคชัน (ที่ทำงานบนเครื่องหนึ่ง) และเซิร์ฟเวอร์ฐานข้อมูล (บนอีกเครื่องหนึ่ง)
ในสไตล์การโต้ตอบของ transaction นี้ เวลาจำนวนมากถูกใช้ในการสื่อสารเครือข่ายระหว่างแอปพลิเคชันและฐานข้อมูล ถ้าคุณไม่ให้ concurrency ในฐานข้อมูลและประมวลผลทีละ transaction throughput จะแย่มากเพราะฐานข้อมูลจะใช้เวลาส่วนใหญ่รอให้แอปพลิเคชันส่ง query ถัดไปสำหรับ transaction ปัจจุบัน ในฐานข้อมูลประเภทนี้ จำเป็นต้องประมวลผลหลาย transactions พร้อมกันเพื่อให้ได้ประสิทธิภาพที่สมเหตุสมผล
ด้วยเหตุนี้ ระบบที่มีการประมวลผล transaction แบบ single-threaded serial จึงไม่อนุญาตให้มี multistatement transactions แบบโต้ตอบ แต่แอปพลิเคชันต้องจำกัดตัวเองให้อยู่กับ transactions ที่มี statement เดียว หรือส่งโค้ด transaction ทั้งหมดไปยังฐานข้อมูลล่วงหน้าเป็น stored procedure [ 63 ]
ความแตกต่างระหว่าง interactive transactions และ stored procedures แสดงใน รูปที่ 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 procedures มีมานานแล้วในฐานข้อมูลเชิงสัมพันธ์ และเป็นส่วนหนึ่งของมาตรฐาน SQL (SQL/PSM) ตั้งแต่ปี 1999 พวกมันมีชื่อเสียงที่ไม่ค่อยดีด้วยเหตุผลหลายประการ:
-
ตามธรรมเนียม ผู้ผลิตฐานข้อมูลแต่ละรายมีภาษาของตนเองสำหรับ stored procedures (Oracle มี PL/SQL, SQL Server มี T-SQL, PostgreSQL มี PL/pgSQL ฯลฯ) ภาษาเหล่านี้ไม่ตามทันการพัฒนาในภาษาการเขียนโปรแกรมทั่วไป ดังนั้นพวกมันจึงดูค่อนข้างน่าเกลียดและเก่าแก่จากมุมมองในปัจจุบัน และขาดระบบนิเวศของ libraries ที่คุณพบในภาษาการเขียนโปรแกรมสมัยใหม่ส่วนใหญ่
-
โค้ดที่ทำงานในฐานข้อมูลนั้นจัดการยาก เมื่อเทียบกับเซิร์ฟเวอร์แอปพลิเคชัน มันดีบักยากกว่า เก็บใน version control และ deploy ลำบากกว่า ทดสอบยากกว่า และยากที่จะรวมกับระบบรวบรวม metrics สำหรับการตรวจสอบ
-
ฐานข้อมูลมักจะไวต่อประสิทธิภาพมากกว่าเซิร์ฟเวอร์แอปพลิเคชันมาก เพราะ instance ฐานข้อมูลเดียวมักถูกแชร์โดยเซิร์ฟเวอร์แอปพลิเคชันหลายตัว stored procedure ที่เขียนไม่ดี (เช่น ใช้หน่วยความจำหรือ CPU เวลามาก หรือแม้แต่ทำให้ crash) ในฐานข้อมูลสามารถสร้างปัญหาได้มากกว่าโค้ดที่เขียนไม่ดีเทียบเท่าในเซิร์ฟเวอร์แอปพลิเคชัน
-
ในระบบ multitenant ที่อนุญาตให้ tenants เขียน stored procedures ของตัวเอง มันเป็นความเสี่ยงด้านความปลอดภัยที่จะ execute โค้ดที่ไม่น่าเชื่อถือใน process เดียวกับ database kernel [ 64 ]
อย่างไรก็ตาม ปัญหาเหล่านั้นสามารถเอาชนะได้ การ implement stored procedures สมัยใหม่ได้ละทิ้ง PL/SQL และใช้ภาษาการเขียนโปรแกรมทั่วไปที่มีอยู่แทน VoltDB ใช้ Java หรือ Groovy, Datomic ใช้ Java หรือ Clojure, Redis ใช้ Lua, และ MongoDB ใช้ JavaScript
Stored procedures ยังมีประโยชน์เมื่อตรรกะแอปพลิเคชันไม่สามารถฝังที่อื่นได้ง่าย แอปพลิเคชันที่ใช้ GraphQL ตัวอย่างเช่น อาจเปิดเผยฐานข้อมูลของพวกเขาโดยตรงผ่าน GraphQL proxy ถ้า proxy ไม่รองรับตรรกะการตรวจสอบที่ซับซ้อน คุณสามารถฝังตรรกะดังกล่าวลงในฐานข้อมูลโดยตรงโดยใช้ stored procedure ถ้าฐานข้อมูลไม่รองรับ stored procedures คุณจะต้อง deploy validation service ระหว่าง proxy และฐานข้อมูลเพื่อทำการตรวจสอบ
ด้วย stored procedures และ in-memory data การดำเนินการ transactions ทั้งหมดบน thread เดียวกลายเป็นไปได้ เมื่อ stored procedures ไม่ต้องรอ I/O และหลีกเลี่ยง overhead ของกลไก concurrency control อื่น พวกมันสามารถบรรลุ throughput ที่ค่อนข้างดีบน thread เดียว
VoltDB ยังใช้ stored procedures สำหรับ replication แทนที่จะคัดลอก writes ของ transaction จากโหนดหนึ่งไปยังอีกโหนดหนึ่ง มัน execute stored procedure เดียวกันบนแต่ละ replica ดังนั้น VoltDB จึงต้องการให้ stored procedures เป็น deterministic (เมื่อทำงานบนโหนดที่แตกต่างกัน พวกมันต้องให้ผลลัพธ์เดียวกัน) ถ้า transaction ต้องการใช้วันที่และเวลาปัจจุบัน ตัวอย่างเช่น มันต้องทำผ่าน deterministic APIs พิเศษ (ดู "Durable Execution and Workflows" สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับ deterministic operations) แนวทางนี้เรียกว่า state machine replication และเราจะกลับมาที่มันใน บทที่ 10
Sharding (การแบ่งส่วนข้อมูล)
การดำเนินการ transactions ทั้งหมดแบบ serial ทำให้ concurrency control ง่ายขึ้นมาก แต่มันจำกัด throughput ของ transaction ในฐานข้อมูลไว้ที่ความเร็วของ single CPU core บนเครื่องเดียว Read-only transactions อาจทำงานที่อื่น โดยใช้ snapshot isolation แต่สำหรับแอปพลิเคชันที่มี write throughput สูง single-threaded transaction processor สามารถเป็นคอขวดที่ร้ายแรงได้
เพื่อ scale ไปยังหลาย CPU cores และหลายโหนด คุณสามารถ shard ข้อมูลของคุณ (ดู บทที่ 7 ) ซึ่งรองรับใน VoltDB ถ้าคุณสามารถหาวิธี shard ชุดข้อมูลของคุณเพื่อให้แต่ละ transaction ต้องอ่านและเขียนข้อมูลภายใน shard เดียวเท่านั้น แต่ละ shard ก็สามารถมี thread การประมวลผล transaction ของตัวเองที่ทำงานแยกจาก shard อื่น ในกรณีนี้ คุณสามารถให้แต่ละ CPU core มี shard ของตัวเอง ซึ่งช่วยให้ throughput ของ transaction scale เป็นเชิงเส้นกับจำนวน CPU cores [ 61 ]
อย่างไรก็ตาม สำหรับ transaction ใด ๆ ที่ต้องเข้าถึงหลาย shards ฐานข้อมูลต้องประสานงาน transaction ข้ามทุก shards ที่มันเกี่ยวข้อง stored procedure ต้องถูกดำเนินการแบบ lockstep ข้ามทุก shards เพื่อรับประกัน serializability ทั่วทั้งระบบ
เนื่องจาก cross-shard transactions มี overhead การประสานงานเพิ่มเติม พวกมันช้ากว่า single-shard transactions มาก VoltDB รายงาน throughput ประมาณ 1,000 cross-shard writes ต่อวินาที ซึ่งต่ำกว่า single-shard throughput ของมันหลาย order of magnitude และไม่สามารถเพิ่มได้โดยการเพิ่มเครื่อง [ 63 ] งานวิจัยล่าสุดได้สำรวจวิธีการทำให้ multishard transactions สามารถ scale ได้มากขึ้น [ 65 ]
ว่า transaction จะเป็น single-shard ได้หรือไม่นั้นขึ้นอยู่กับโครงสร้างของข้อมูลที่แอปพลิเคชันใช้เป็นอย่างมาก Simple key-value data มักจะ shard ได้ง่ายมาก แต่ข้อมูลที่มี secondary indexes หลายตัวมีแนวโน้มที่จะต้องมีการประสานงานข้าม shard จำนวนมาก (ดู "Sharding and Secondary Indexes" )
Summary of serial execution (สรุปการดำเนินการแบบ serial)
การดำเนินการ transactions แบบ serial กลายเป็นวิธีที่ใช้ได้ในการบรรลุ serializable isolation ภายใต้ข้อจำกัดบางอย่าง:
-
ทุก transaction ต้องเล็กและเร็ว เพราะใช้ transaction ช้าเพียง transaction เดียวในการหยุดการประมวลผล transaction ทั้งหมด
-
เหมาะสมที่สุดเมื่อชุดข้อมูลที่ใช้งานอยู่สามารถ fit ในหน่วยความจำได้ ข้อมูลที่ไม่ค่อยได้เข้าถึงอาจถูกย้ายไปยังดิสก์ได้ แต่ถ้าจำเป็นต้องเข้าถึงใน single-threaded transaction ระบบจะช้ามาก
-
Write throughput ต้องต่ำพอที่จะจัดการบน single CPU core หรือไม่เช่นนั้น transactions ต้องถูก shard โดยไม่ต้องมีการประสานงานข้าม shard
-
Cross-shard transactions เป็นไปได้ แต่ 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" ) เพื่อหลีกเลี่ยงความสับสน ควรคิดถึงพวกมันเป็นแนวคิดที่แยกจากกันโดยสิ้นเชิงและไม่สนใจความคล้ายคลึงกันที่โชคร้ายในชื่อ
เราเห็นก่อนหน้านี้ว่า locks มักถูกใช้เพื่อป้องกัน dirty writes (ดู "No dirty writes" ) ถ้า two transactions พยายามเขียนไปยัง object เดียวกันพร้อมกัน lock รับประกันว่าผู้เขียนคนที่สองต้องรอจนกว่าคนแรกจะเสร็จสิ้น transaction (abort หรือ commit) ก่อนที่จะดำเนินการต่อ
2PL คล้ายกัน แต่มันทำให้ข้อกำหนดของ lock แข็งแกร่งขึ้นมาก หลาย transactions ได้รับอนุญาตให้อ่าน object เดียวกันพร้อมกันตราบเท่าที่ไม่มีใครเขียนถึงมัน แต่ทันทีที่มีใครต้องการเขียน (แก้ไขหรือลบ) object จะต้องมีการเข้าถึงแบบเอกสิทธิ์:
-
ถ้า 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 ผู้เขียนไม่เพียงแค่บล็อกผู้เขียนอื่น พวกมันยังบล็อกผู้อ่าน และในทางกลับกัน คติพจน์ที่กล่าวถึงก่อนหน้านี้ readers never block writers, and writers never block readers ของ snapshot isolation (ดู "Multiversion concurrency control" ) จับความแตกต่างสำคัญระหว่าง snapshot isolation และ 2PL ในทางกลับกัน เพราะ 2PL ให้ serializability มันป้องกัน race conditions ทั้งหมดที่กล่าวถึงก่อนหน้านี้ รวมถึง lost updates และ write skew
Implementation of 2PL (การ implement 2PL)
2PL ถูกใช้โดย serializable isolation level ใน MySQL/InnoDB และ SQL Server และโดย repeatable-read isolation level ใน Db2 [ 30 ]
การบล็อกของผู้อ่านและผู้เขียนถูก implement โดยการมี lock บนแต่ละ object ในฐานข้อมูล lock สามารถอยู่ใน shared mode หรือ exclusive mode (หรือที่เรียกว่า multi-reader single-writer lock) มันถูกใช้ดังนี้:
-
ถ้า transaction ต้องการอ่าน object มันต้องได้รับ lock ใน shared mode ก่อน หลาย transactions ได้รับอนุญาตให้ถือ lock ใน shared mode พร้อมกัน แต่ถ้า transaction อื่นมี exclusive lock บน object อยู่แล้ว transactions เหล่านี้ต้องรอ
-
ถ้า transaction ต้องการเขียนไปยัง object มันต้องได้รับ lock ใน exclusive mode ก่อน ไม่มี transaction อื่นใดที่สามารถถือ lock ในเวลาเดียวกัน (ไม่ว่าจะใน shared หรือ exclusive mode) ดังนั้นถ้ามี lock ใด ๆ บน object อยู่แล้ว transaction ต้องรอ
-
ถ้า transaction อ่านก่อนแล้วจึงเขียน object มันอาจ upgrade shared lock ของมันเป็น exclusive lock การ upgrade ทำงานในลักษณะเดียวกับการได้รับ exclusive lock โดยตรง
-
หลังจาก transaction ได้รับ lock แล้ว มันต้องถือ lock ต่อไปจนกว่าจะสิ้นสุด transaction (commit หรือ abort) นี่คือที่มาของชื่อ "two-phase": เฟสแรก (ระยะ เติบโต ขณะที่ transaction กำลังดำเนินการ) คือเมื่อ locks ถูกได้รับ และเฟสที่สอง (ระยะ หดตัว ตอนท้ายของ transaction) คือเมื่อ locks ทั้งหมดถูกปล่อย สองเฟสนี้ต้องไม่ทับซ้อนกัน เมื่อ lock ถูกปล่อยแล้ว ไม่สามารถรับ lock ใหม่ใน transaction ได้
เนื่องจากมี locks จำนวนมากที่ถูกใช้ มันสามารถเกิดขึ้นได้ง่ายทีเดียวที่ transaction A ติดอยู่รอให้ transaction B ปล่อย lock และในทางกลับกัน สถานการณ์นี้เรียกว่า deadlock ฐานข้อมูลตรวจจับ deadlocks ระหว่าง transactions โดยอัตโนมัติและ abort หนึ่งในนั้นเพื่อให้ตัวอื่นดำเนินการต่อได้ transaction ที่ถูก abort ต้องถูกลองใหม่โดยแอปพลิเคชัน
Performance of 2PL (ประสิทธิภาพของ 2PL)
ข้อเสียใหญ่ของ 2PL และเหตุผลที่มันไม่ได้เป็นค่าเริ่มต้นสำหรับระบบส่วนใหญ่ตั้งแต่ยุค 1970 คือประสิทธิภาพ Transaction throughput และ response times ของ queries แย่ลงอย่างมีนัยสำคัญภายใต้ 2PL เมื่อเทียบกับ weak isolation
นี่เป็นส่วนหนึ่งเนื่องจาก overhead ของการรับและปล่อย locks ทั้งหมดเหล่านั้น แต่ที่สำคัญกว่าคือเนื่องจากการลดลงของ concurrency โดยการออกแบบ ถ้า two concurrent transactions พยายามทำอะไรก็ตามที่อาจส่งผลให้เกิด race condition ฝ่ายหนึ่งต้องรอให้อีกฝ่ายเสร็จสิ้น
ตัวอย่างเช่น ถ้าคุณมี transaction ที่ต้องอ่านทั้งตาราง (เช่น backup, analytical query, หรือ integrity check ตามที่กล่าวถึงใน "Snapshot Isolation and Repeatable Read" ) transaction นั้นต้องใช้ shared lock บนทั้งตาราง ดังนั้น transaction ที่อ่านต้องรอจนกว่า transactions ทั้งหมดที่กำลังเขียนไปยังตารางนั้นจะเสร็จสิ้น จากนั้น ในขณะที่กำลังอ่านทั้งตาราง (ซึ่งอาจใช้เวลานานสำหรับตารางขนาดใหญ่) transactions อื่นทั้งหมดที่ต้องการเขียนไปยังตารางนั้นจะถูกบล็อกจนกว่า big read-only transaction จะ commit ในทางปฏิบัติ ฐานข้อมูลจะไม่พร้อมใช้งานสำหรับการเขียนเป็นเวลานาน
ด้วยเหตุนี้ ฐานข้อมูลที่ใช้ 2PL สามารถมี latencies ที่ไม่เสถียร และสามารถช้ามากที่ percentiles สูง (ดู "Describing Performance" ) ถ้ามีการแข่งขันใน workload เพียง transaction ช้า transaction เดียว หรือ transaction ที่เข้าถึงข้อมูลจำนวนมากและได้รับ locks จำนวนมาก ก็สามารถทำให้ระบบที่เหลือหยุดชะงัก Transaction timeouts และ slow query monitoring ถูกใช้เพื่อตรวจจับและจำกัด queries ที่มีพฤติกรรมไม่ดี
แม้ว่า deadlocks สามารถเกิดขึ้นกับ lock-based read-committed isolation level พวกมันเกิดขึ้นบ่อยกว่ามากภายใต้ 2PL serializable isolation (ขึ้นอยู่กับรูปแบบการเข้าถึงของ transaction ของคุณ) นี้อาจเป็นปัญหาเพิ่มเติมด้านประสิทธิภาพ: เมื่อ transaction ถูก abort เนื่องจาก deadlock และถูกลองใหม่ มันต้องทำงานทั้งหมดอีกครั้ง ถ้า deadlocks ถี่ นี่อาจหมายถึงความพยายามที่สูญเปล่าอย่างมีนัยสำคัญ
Predicate locks (การล็อกแบบเพรดิเคต)
ในคำอธิบายของ locks ก่อนหน้านี้ เรามองข้ามรายละเอียดที่ละเอียดอ่อนแต่สำคัญ ใน "Phantoms causing write skew" เราได้พูดถึงปัญหาของ phantoms —นั่นคือ transaction หนึ่งเปลี่ยนผลลัพธ์ของ search query ของอีก transaction หนึ่ง ฐานข้อมูลที่มี serializable isolation ต้องป้องกัน phantoms
ในตัวอย่างการจองห้องประชุม นี่หมายความว่าถ้า transaction หนึ่งได้ค้นหาการจองที่มีอยู่สำหรับห้องหนึ่งในช่วงเวลาหนึ่ง (ดู ตัวอย่าง 8-2 ), transaction อื่นไม่ได้รับอนุญาตให้ insert หรืออัปเดตการจองอื่นสำหรับห้องและช่วงเวลาเดียวกันพร้อมกัน (การ insert การจองสำหรับห้องอื่น หรือสำหรับห้องเดียวกันในเวลาที่แตกต่างซึ่งไม่กระทบการจองที่เสนอนั้นทำได้)
เราจะ implement สิ่งนี้ได้อย่างไร? ในทางแนวคิด เราต้องการ predicate lock [ 4 ] มันทำงานคล้ายกับ shared/exclusive lock ที่อธิบายไว้ก่อนหน้านี้ แต่มันไม่ได้เป็นของ object เฉพาะ (เช่น หนึ่งแถวในตาราง) แต่มันเป็นของ objects ทั้งหมดที่ตรงกับเงื่อนไขการค้นหา เช่นนี้:
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 ต้องการอ่าน objects ที่ตรงกับเงื่อนไข ดังใน query
SELECTนั้น มันต้องได้รับ shared-mode predicate lock บนเงื่อนไขของ query ถ้าอีก transaction B มี exclusive lock บน object ใด ๆ ที่ตรงกับเงื่อนไขเหล่านั้น A ต้องรอจนกว่า B จะปล่อย lock ก่อนที่จะได้รับอนุญาตให้ทำ query -
ถ้า transaction A ต้องการ insert, update, หรือ delete object ใด ๆ มันต้องตรวจสอบก่อนว่าค่าเก่าหรือค่าใหม่ตรงกับ predicate lock ที่มีอยู่หรือไม่ ถ้า predicate lock ที่ตรงกันถูกถือโดย transaction B แล้ว A ต้องรอจนกว่า B จะ commit หรือ abort ก่อนที่จะดำเนินการต่อ
แนวคิดสำคัญที่นี่คือ predicate lock ใช้แม้กับ objects ที่ยังไม่มีในฐานข้อมูล แต่อาจถูกเพิ่มในอนาคต (phantoms) ถ้า 2PL รวม predicate locks ฐานข้อมูลจะป้องกัน write skew และ race conditions อื่น ๆ ทุกรูปแบบ และ isolation ของมันจะกลายเป็น serializable
Index-range locks (การล็อกช่วงดัชนี)
น่าเสียดายที่ predicate locks ทำงานได้ไม่ดี: ถ้ามี locks จำนวนมากโดย transactions ที่กำลังดำเนินอยู่ การตรวจสอบ locks ที่ตรงกันจะใช้เวลามาก ด้วยเหตุนั้น ฐานข้อมูลส่วนใหญ่ที่มี 2PL จึง implement index-range locking (หรือที่เรียกว่า next-key locking ) ซึ่งเป็นการประมาณอย่างง่ายของ predicate locking [ 56 , 66 ]
ปลอดภัยที่จะทำให้ predicate ง่ายขึ้นโดยทำให้มันตรงกับชุด objects ที่ใหญ่ขึ้น ตัวอย่างเช่น ถ้าคุณมี predicate lock สำหรับการจองห้อง 123 ระหว่างเที่ยงถึงบ่ายโมง คุณสามารถประมาณมันโดยการล็อกการจองสำหรับห้อง 123 ในเวลาใดก็ได้ หรือคุณสามารถประมาณมันโดยการล็อกทุกห้อง (ไม่ใช่แค่ห้อง 123) ระหว่างเที่ยงถึงบ่ายโมง ปลอดภัยเพราะ write ใด ๆ ที่ตรงกับ predicate ดั้งเดิมจะตรงกับการประมาณนั้นอย่างแน่นอน
ในฐานข้อมูลการจองห้อง คุณอาจมี index บนคอลัมน์ room_id และ/หรือ indexes บน 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 จะถูกปล่อย
สิ่งนี้ให้การป้องกันที่มีประสิทธิภาพต่อ phantoms และ write skew Index-range locks ไม่แม่นยำเท่ากับ predicate locks (พวกมันอาจล็อกช่วงของ objects ที่ใหญ่กว่าที่จำเป็นอย่างเคร่งครัดเพื่อรักษา serializability) แต่เนื่องจากพวกมันมี overhead ต่ำกว่ามาก พวกมันจึงเป็นการประนีประนอมที่ดี
ถ้าไม่มี index ที่เหมาะสมที่ range lock สามารถ attach ได้ ฐานข้อมูลสามารถ fallback ไปยัง shared lock บนทั้งตาราง สิ่งนี้จะไม่ดีต่อประสิทธิภาพ เพราะมันจะหยุด transactions อื่นทั้งหมดไม่ให้เขียนไปยังตาราง แต่มันเป็นตำแหน่ง fallback ที่ปลอดภัย
Serializable Snapshot Isolation (SSI)
บทนี้ ได้วาดภาพที่มืดมนของการควบคุม concurrency ในฐานข้อมูล ในด้านหนึ่ง เรามีการ implement serializability ที่ทำงานได้ไม่ดี (2PL) หรือไม่ scale ได้ดี (serial execution) ในอีกด้านหนึ่ง เรามี weak isolation levels ที่มีประสิทธิภาพที่ดีแต่มักเกิด race conditions ต่าง ๆ (lost updates, write skew, phantoms ฯลฯ) serializable isolation และประสิทธิภาพที่ดีขัดแย้งกันโดยพื้นฐานหรือไม่?
ดูเหมือนจะไม่: อัลกอริทึมที่เรียกว่า serializable snapshot isolation (SSI) ให้ serializability อย่างสมบูรณ์โดยมีโทษด้านประสิทธิภาพเพียงเล็กน้อยเมื่อเทียบกับ snapshot isolation SSI ค่อนข้างใหม่ มันถูกอธิบายครั้งแรกในปี 2008 [ 55 , 67 ]
ปัจจุบัน SSI และอัลกอริทึมที่คล้ายกันถูกใช้ใน single-node databases (serializable isolation level ใน PostgreSQL [ 56 ], In-Memory OLTP/Hekaton ของ SQL Server [ 68 ], และ HyPer [ 69 ]), distributed databases (CockroachDB [ 5 ] และ FoundationDB [ 8 ]), และ embedded storage engines เช่น BadgerDB
Pessimistic versus optimistic concurrency control (การควบคุม concurrency แบบ Pessimistic กับ Optimistic)
2PL เป็นกลไกการควบคุม concurrency แบบ pessimistic : มันขึ้นอยู่กับหลักการที่ว่าถ้าอะไรก็ตามอาจผิดพลาด (ตามที่ระบุโดย lock ที่ถือโดย transaction อื่น) ก็ควรจะรอจนกว่าสถานการณ์จะปลอดภัยอีกครั้งก่อนที่จะทำอะไร มันเหมือนกับ mutual exclusion ที่ใช้เพื่อป้องกัน data structures ในการเขียนโปรแกรมแบบ multithreaded
การดำเนินการแบบ serial ในแง่หนึ่งคือ pessimistic อย่างที่สุด โดยพื้นฐานแล้วมันเทียบเท่ากับแต่ละ transaction มี exclusive lock บนฐานข้อมูลทั้งหมด (หรือหนึ่ง shard ของฐานข้อมูล) ตลอดระยะเวลาของ transaction เราชดเชย pessimism โดยทำให้แต่ละ transaction ดำเนินการเร็วมาก ดังนั้นมันต้องถือ "lock" เพียงช่วงสั้น ๆ
ในทางตรงกันข้าม serializable snapshot isolation เป็นเทคนิค optimistic concurrency control Optimistic ในบริบทนี้หมายความว่าแทนที่จะบล็อกถ้าสิ่งที่อาจเป็นอันตรายเกิดขึ้น transactions ยังคงดำเนินต่อไป ด้วยหวังว่าทุกอย่างจะดี เมื่อ transaction ต้องการ commit ฐานข้อมูลจะตรวจสอบว่ามีสิ่งเลวร้ายเกิดขึ้นหรือไม่ (นั่นคือ isolation ถูกละเมิดหรือไม่) ถ้าเป็นเช่นนั้น transaction จะถูก abort และต้องถูกลองใหม่ เฉพาะ transactions ที่ดำเนินการแบบ serializable เท่านั้นที่ได้รับอนุญาตให้ commit
Optimistic concurrency control เป็นแนวคิดเก่า [ 70 ] และข้อดีข้อเสียของมันถูกถกเถียงกันมานาน [ 71 ] มันทำงานได้ไม่ดีถ้ามีการแข่งขันสูง (transactions จำนวนมากพยายามเข้าถึง objects เดียวกัน) เพราะนี่นำไปสู่สัดส่วนที่สูงของ transactions ที่ต้อง abort ถ้าระบบใกล้ถึง throughput สูงสุดแล้ว โหลด transaction เพิ่มเติมจากการลองใหม่สามารถทำให้ประสิทธิภาพแย่ลง
อย่างไรก็ตาม ถ้ามีความจุสำรองเพียงพอ และถ้าการแข่งขันระหว่าง transactions ไม่สูงเกินไป เทคนิค optimistic concurrency control มักจะทำงานได้ดีกว่า pessimistic ones การแข่งขันสามารถลดลงได้ด้วย commutative atomic operations: ตัวอย่างเช่น ถ้าหลาย transactions ต้องการ increment counter พร้อมกัน มันไม่สำคัญว่าการ increment จะถูกใช้ในลำดับใด (ตราบใดที่ counter ไม่ถูกอ่านใน transaction เดียวกัน) ดังนั้น concurrent increments ทั้งหมดสามารถถูกใช้โดยไม่มีความขัดแย้ง
ตามชื่อที่แนะนำ SSI ขึ้นอยู่กับ snapshot isolation—นั่นคือ reads ทั้งหมดภายใน transaction ถูกสร้างจาก consistent snapshot ของฐานข้อมูล (ดู "Snapshot Isolation and Repeatable Read" ) บนพื้นฐานของ snapshot isolation SSI เพิ่มอัลกอริทึมสำหรับการตรวจจับ serialization conflicts ระหว่าง reads และ writes และกำหนดว่า transactions ใดควร abort
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 เช่น "ขณะนี้มีแพทย์สองคนในกะ") ต่อมา เมื่อ transaction ต้องการ commit ข้อมูลดั้งเดิมอาจเปลี่ยนไป—premise อาจไม่เป็นจริงอีกต่อไป
เมื่อแอปพลิเคชันทำ query (เช่น "ขณะนี้มีแพทย์ในกะกี่คน?") ฐานข้อมูลไม่ทราบว่าแอปพลิเคชันใช้ผลลัพธ์ของ query นั้นอย่างไร เพื่อความปลอดภัย ฐานข้อมูลต้องสมมติว่าการเปลี่ยนแปลงใด ๆ ในผลลัพธ์ของ query (premise) หมายความว่า writes ใน transaction นั้นอาจไม่ถูกต้อง กล่าวอีกนัยหนึ่ง อาจมีความสัมพันธ์เชิงสาเหตุระหว่าง queries และ writes ใน transaction เพื่อให้ serializable isolation ฐานข้อมูลต้องตรวจจับสถานการณ์ที่ transaction อาจดำเนินการบน premise ที่ล้าสมัยและ abort transaction ในกรณีนั้น
ฐานข้อมูลรู้ได้อย่างไรว่าผลลัพธ์ของ query อาจเปลี่ยนไป? พิจารณาสองกรณี:
-
การตรวจจับการอ่าน MVCC object version ที่เก่า (uncommitted write เกิดขึ้นก่อนการอ่าน)
-
การตรวจจับ writes ที่ส่งผลกระทบต่อ prior reads (write เกิดขึ้นหลังการอ่าน)
Detection of stale MVCC reads (การตรวจจับการอ่าน MVCC ที่เก่า)
โปรดจำไว้ว่า snapshot isolation มักถูก implement โดย MVCC (ดู "Multiversion concurrency control" ) เมื่อ transaction อ่านจาก consistent snapshot ในฐานข้อมูล MVCC มันไม่สนใจ writes ที่ทำโดย transactions อื่นที่ยังไม่ได้ 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 แทรกข้อมูลที่ไม่มีอยู่ก่อนหน้านี้ (ดู "Phantoms causing write skew" ) เราจะพูดถึงการตรวจจับ phantom writes สำหรับ SSI ต่อไป
Figure 8-10. การตรวจจับเมื่อ transaction อ่านค่าที่ล้าสมัยจาก MVCC snapshot
เพื่อป้องกันความผิดปกตินี้ ฐานข้อมูลต้องติดตามเมื่อ transaction ไม่สนใจ writes ของอีก transaction หนึ่งเนื่องจากกฎการมองเห็นของ MVCC เมื่อ transaction ต้องการ commit ฐานข้อมูลตรวจสอบว่า writes ที่ถูกไม่สนใจได้ถูก 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 ดังนั้นการอ่านอาจกลายเป็นว่าไม่เก่าเลย โดยการหลีกเลี่ยงการ abort ที่ไม่จำเป็น SSI รักษาการรองรับของ snapshot isolation สำหรับการอ่านที่ใช้เวลานานจาก consistent snapshot
Detection of writes that affect prior reads (การตรวจจับ writes ที่ส่งผลต่อ reads ก่อนหน้า)
กรณีที่สองที่ต้องพิจารณาคือ transaction อื่นแก้ไขข้อมูลหลังจากที่มันถูกอ่านแล้ว กรณีนี้แสดงใน รูปที่ 8-11
ในบริบทของ 2PL เราได้พูดถึง index-range locks (ดู "Index-range locks" ) ซึ่งอนุญาตให้ฐานข้อมูลล็อกการเข้าถึงทุกแถวที่ตรงกับ search query เช่น WHERE shift_id = 1234 เราสามารถใช้เทคนิคที่คล้ายกันที่นี่ ยกเว้นว่า SSI locks ไม่ได้บล็อก transactions อื่น
ใน รูปที่ 8-11 transactions 42 และ 43 ต่างค้นหาแพทย์ในกะระหว่าง shift 1234 ถ้ามี index บน shift_id ฐานข้อมูลสามารถใช้ index entry 1234 เพื่อบันทึกข้อเท็จจริงที่ว่า transactions 42 และ 43 อ่านข้อมูลนี้ (ถ้าไม่มี index ข้อมูลนี้สามารถติดตามได้ที่ระดับตาราง) ข้อมูลนี้ต้องถูกเก็บไว้เพียงชั่วคราว หลังจาก transaction เสร็จสิ้น (commit หรือ abort) และ concurrent transactions ทั้งหมดเสร็จสิ้น ฐานข้อมูลสามารถลืมข้อมูลที่มันอ่านได้
Figure 8-11. ใน serializable snapshot isolation การตรวจจับเมื่อ transaction หนึ่งแก้ไขสิ่งที่อีก transaction หนึ่งอ่าน
เมื่อ transaction เขียนไปยังฐานข้อมูล มันต้องตรวจสอบใน indexes สำหรับ transactions อื่นที่เพิ่งอ่านข้อมูลที่ได้รับผลกระทบ กระบวนการนี้คล้ายกับการได้รับ write lock บน affected key range แต่แทนที่จะบล็อกจนกว่าผู้อ่านจะ commit lock ทำหน้าที่เป็น tripwire มันเพียงแจ้ง transactions ว่าข้อมูลที่พวกเขาอ่านอาจไม่เป็นปัจจุบันอีกต่อไป
ใน รูปที่ 8-11 transaction 43 แจ้ง transaction 42 ว่าการอ่านก่อนหน้าของมันล้าสมัย และในทางกลับกัน Transaction 42 เป็นคนแรกที่ commit และมันสำเร็จ แม้ว่า write ของ transaction 43 จะส่งผลต่อ 42 แต่ 43 ยังไม่ commit ดังนั้นการเขียนยังไม่มีผล อย่างไรก็ตาม เมื่อ transaction 43 ต้องการ commit write ที่ขัดแย้งจาก 42 ได้ถูก commit แล้ว ดังนั้น 43 ต้อง abort
Performance of serializable snapshot isolation (ประสิทธิภาพของ SSI)
เช่นเคย รายละเอียดทางวิศวกรรมมากมายส่งผลต่อประสิทธิภาพของอัลกอริทึมในทางปฏิบัติ ตัวอย่างเช่น trade-off หนึ่งคือ granularity ที่ reads และ writes ของ transactions ถูกติดตาม ถ้าฐานข้อมูลติดตามกิจกรรมของแต่ละ transaction อย่างละเอียดมาก มันสามารถแม่นยำเกี่ยวกับ transactions ที่ต้อง abort แต่ overhead ของการเก็บบันทึกอาจมีนัยสำคัญ การติดตามที่ละเอียดน้อยกว่านั้นเร็วกว่า แต่อาจนำไปสู่การ abort transactions มากกว่าที่จำเป็นอย่างเคร่งครัด
ในบางกรณี มันโอเคที่ transaction จะอ่านข้อมูลที่ถูกเขียนทับโดย transaction อื่น ขึ้นอยู่กับสิ่งที่เกิดขึ้นอื่น ๆ บางครั้งมันเป็นไปได้ที่จะพิสูจน์ว่าผลลัพธ์ของการดำเนินการนั้น serializable อยู่ดี PostgreSQL ใช้ทฤษฎีนี้เพื่อลดจำนวนการ abort ที่ไม่จำเป็น [ 14 , 56 ]
เมื่อเทียบกับ 2PL ข้อดีใหญ่ของ serializable snapshot isolation คือ transaction หนึ่งไม่ต้องรอ locks ที่ถือโดย transaction อื่น เช่นเดียวกับ snapshot isolation ผู้เขียนไม่บล็อกผู้อ่าน และในทางกลับกัน หลักการออกแบบนี้ทำให้ query latency คาดเดาได้มากขึ้นและแปรปรวนน้อยลง โดยเฉพาะ read-only queries สามารถทำงานบน consistent snapshot โดยไม่ต้องใช้ locks ใด ๆ ซึ่งน่าดึงดูดมากสำหรับ read-heavy workloads
เมื่อเทียบกับ serial execution serializable snapshot isolation ไม่ถูกจำกัดด้วย throughput ของ single CPU core—ตัวอย่างเช่น FoundationDB กระจายการตรวจจับ serialization conflicts ข้ามหลายเครื่อง ทำให้มันสามารถ scale ไปยัง throughput ที่สูงมากได้ แม้ว่าข้อมูลอาจถูก sharded ข้ามหลายเครื่อง transactions สามารถอ่านและเขียนข้อมูลในหลาย shards ในขณะที่รับประกัน serializable isolation
เมื่อเทียบกับ nonserializable snapshot isolation ความจำเป็นในการตรวจสอบการละเมิด serializability ทำให้มี overhead ด้านประสิทธิภาพบ้าง overhead เหล่านี้มีนัยสำคัญแค่ไหนเป็นเรื่องที่ถกเถียงกัน: บางคนเชื่อว่าการตรวจสอบ serializability ไม่คุ้มค่า [ 72 ] ในขณะที่คนอื่นเชื่อว่าประสิทธิภาพของ serializability ตอนนี้ดีมากจนไม่จำเป็นต้องใช้ snapshot isolation ที่อ่อนแอกว่าอีกต่อไป [ 69 ]
อัตราการ abort ส่งผลกระทบอย่างมีนัยสำคัญต่อประสิทธิภาพโดยรวมของ SSI ตัวอย่างเช่น transaction ที่อ่านและเขียนข้อมูลเป็นระยะเวลานานมีแนวโน้มที่จะเจอ conflicts และ abort ดังนั้น SSI ต้องการให้ read/write transactions ค่อนข้างสั้น (long-running read-only transactions นั้นใช้ได้) อย่างไรก็ตาม SSI ไวต่อ slow transactions น้อยกว่า 2PL หรือ serial execution
Distributed Transactions (ธุรกรรมแบบกระจาย)
ใน single-node transaction คุณมีเครื่องเดียวที่รับผิดชอบในการดำเนินการตรรกะของ transaction เช่น อัลกอริทึมการควบคุม concurrency สำหรับ transaction isolation ถ้าฐานข้อมูลของคุณใช้ single-leader replication การดำเนินการ transaction เกิดขึ้นบน leader เท่านั้น และ followers เพียงแค่ใช้ log ของ writes ที่ถูก commit โดย transactions บน leader
อย่างไรก็ตาม จะเกิดอะไรขึ้นถ้าหลาย nodes เกี่ยวข้องใน transaction? ตัวอย่างเช่น บางทีคุณมี transaction ที่ต้องแตะหลาย shards ของฐานข้อมูลแบบ sharded หรือ global secondary index (ซึ่ง index entry อาจอยู่บน node ที่แตกต่างจากข้อมูลหลัก ดู "Sharding and Secondary Indexes" ) สิ่งนี้เรียกว่า distributed transaction
อัลกอริทึมสำหรับการควบคุม concurrency ใน distributed transactions โดยกว้าง ๆ แล้วคล้ายกับของ single-node concurrency control เราได้พูดถึง serial execution บนฐานข้อมูลแบบ sharded ก่อนหน้านี้ 2PL ทำงานในสภาพแวดล้อมแบบกระจาย และสำหรับ SSI มี distributed serializability checkers [ 8 ] เราจะไม่ลงรายละเอียดเพิ่มเติมเกี่ยวกับสิ่งเหล่านี้
การบรรลุ atomicity ใน distributed transaction เป็นความท้าทายใหม่ทั้งหมด และนั่นคือสิ่งที่ส่วนที่เหลือของบทนี้จะเน้น
สำหรับ single-node transactions atomicity มักถูก implement โดย storage engine เมื่อไคลเอ็นต์ขอให้โหนดฐานข้อมูล commit transaction ฐานข้อมูลทำให้ writes ของ transaction คงทน (โดยทั่วไปใน write-ahead log ดู "Making B-trees reliable" ) จากนั้น append commit record ไปยัง log บนดิสก์ ถ้าฐานข้อมูล crash กลางกระบวนการนี้ transaction จะถูกกู้คืนจาก log เมื่อ node รีสตาร์ท ถ้า commit record ถูกเขียนไปยังดิสก์สำเร็จก่อน crash transaction จะถูกพิจารณาว่า commit แล้ว ถ้าไม่ writes ใด ๆ จาก transaction นั้นจะถูก roll back
ดังนั้น บน node เดียว การ commit transaction ขึ้นอยู่กับ ลำดับ ที่ข้อมูลถูกเขียนไปยังดิสก์อย่างถาวร: ข้อมูลก่อน แล้วจึง commit record [ 22 ] ช่วงเวลาสำคัญที่ตัดสินว่า transaction commit หรือ abort เกิดขึ้นเมื่อดิสก์เขียน commit record เสร็จ—ก่อนหน้านั้น ยังสามารถ abort ได้ (เนื่องจาก crash) แต่หลังจากนั้น transaction จะถูก commit (แม้ว่าฐานข้อมูลจะ crash) ดังนั้น มันเป็นอุปกรณ์เดียว (controller ของ drive ดิสก์เฉพาะที่ติดอยู่กับ node เฉพาะ) ที่ทำให้ commit เป็นอะตอม
ใน distributed transaction การตัดสินว่า transaction ถูก commit หรือไม่นั้นไม่ง่ายอย่างนั้น ตัวอย่างเช่น เมื่อ transaction ต้องการ commit การส่ง commit request ไปยังทุก nodes และ commit transaction บนแต่ละ node อย่างอิสระนั้นไม่เพียงพอ มันอาจเกิดขึ้นได้ง่ายที่ commit สำเร็จบนบาง nodes และล้มเหลวบนบาง nodes (ดังแสดงใน รูปที่ 8-12 ) ด้วยเหตุผลต่าง ๆ:
-
บาง nodes อาจตรวจพบ constraint violation หรือ conflict ทำให้ต้อง abort ในขณะที่ nodes อื่นสามารถ commit ได้สำเร็จ
-
commit requests บางส่วนอาจสูญหายในเครือข่าย ทำให้ abort เนื่องจาก timeout ในขณะที่ commit requests อื่นไปถึง
-
บาง nodes อาจ crash ก่อนที่ commit record จะถูกเขียนเต็มที่ และ roll back transaction เมื่อกู้คืน ในขณะที่ nodes อื่น commit สำเร็จ
Figure 8-12. เมื่อ transaction เกี่ยวข้องกับ database nodes หลายตัว มันอาจ commit บางตัวและล้มเหลวบนบางตัว
ถ้าบาง nodes commit transaction แต่บาง nodes abort มัน nodes จะไม่สอดคล้องกัน และเมื่อ transaction ถูก commit บน node หนึ่งแล้ว มันไม่สามารถถูกยกเลิกได้ถ้าภายหลังปรากฏว่ามันถูก abort บนอีก node หนึ่ง เพราะเมื่อข้อมูลถูก commit แล้ว มันจะมองเห็นได้โดย transactions อื่นภายใต้ read-committed หรือ isolation ที่แข็งแกร่งกว่า ตัวอย่างเช่น ใน รูปที่ 8-12 เมื่อถึงเวลาที่ผู้ใช้ 1 สังเกตเห็นว่า commit ล้มเหลวบน database 1 ผู้ใช้ 2 ได้อ่านข้อมูลจาก transaction เดียวกันบน database 2 แล้ว ถ้า transaction ของผู้ใช้ 1 ถูก abort ในภายหลัง transaction ของผู้ใช้ 2 ก็จะต้องถูกย้อนกลับเช่นกัน เพราะมันขึ้นอยู่กับข้อมูลที่ถูกประกาศย้อนหลังว่าไม่มีอยู่
วิธีที่ดีกว่าคือรับประกันว่า nodes ที่เกี่ยวข้องใน transaction จะ commit ทั้งหมดหรือ abort ทั้งหมด และป้องกันการผสมกันของทั้งสอง การบรรลุสิ่งนี้เรียกว่าปัญหา atomic commitment
Two-Phase Commit (การ commit สองเฟส)
Two-phase commit เป็นอัลกอริทึมสำหรับบรรลุ atomic transaction commit ข้ามหลาย nodes มันเป็นอัลกอริทึมคลาสสิกในฐานข้อมูลแบบกระจาย [ 13 , 73 , 74 ] 2PC ถูกใช้ภายในในบางฐานข้อมูล และยังถูกทำให้พร้อมใช้งานสำหรับแอปพลิเคชันในรูปแบบของ XA transactions [ 75 ] (ซึ่งรองรับโดย Java Transaction API เป็นต้น) หรือผ่าน WS-AtomicTransaction สำหรับ SOAP web services [ 76 , 77 ]
ขั้นตอนพื้นฐานของ 2PC แสดงใน รูปที่ 8-13 แทนที่จะเป็น commit request เดียว ดังเช่นใน single-node transaction กระบวนการ commit/abort ใน 2PC ถูกแบ่งเป็นสองเฟส (จึงเป็นที่มาของชื่อ)
Figure 8-13. การดำเนินการ 2PC ที่สำเร็จ
2PC ใช้ component ใหม่ที่ไม่ปรากฏใน single-node transactions ปกติ: coordinator (หรือที่เรียกว่า transaction manager ) coordinator มักถูก implement เป็น library ภายใน process เดียวกับแอปพลิเคชันที่ร้องขอ transaction (เช่น ฝังใน Java EE container) แต่มัน process หรือ service แยกต่างหาก ตัวอย่างของ coordinators ดังกล่าวรวมถึง Narayana, JOTM, BTM, และ MSDTC
เมื่อใช้ 2PC distributed transaction เริ่มต้นด้วยการที่แอปพลิเคชันอ่านและเขียนข้อมูลบน database nodes หลายตัว ตามปกติ เราเรียก database nodes เหล่านี้ว่า participants ใน transaction เมื่อแอปพลิเคชันพร้อมที่จะ commit coordinator เริ่มเฟส 1 โดยส่ง prepare request ไปยังแต่ละ nodes ถามพวกมันว่าสามารถ commit ได้หรือไม่ จากนั้น coordinator ติดตามการตอบสนองจาก participants:
-
ถ้า participants ทั้งหมดตอบตกลง แสดงว่าพร้อมที่จะ commit coordinator ส่ง commit request ในเฟส 2 และ commit จะเกิดขึ้น
-
ถ้า participant ใดตอบปฏิเสธ coordinator ส่ง abort request ไปยังทุก nodes ในเฟส 2
กระบวนการนี้ค่อนข้างคล้ายกับพิธีแต่งงานแบบดั้งเดิมในวัฒนธรรมตะวันตก: ผู้ดำเนินพิธีถามคู่แต่ละคนว่าต้องการแต่งงานกับอีกคนหนึ่งหรือไม่ และโดยปกติได้รับคำตอบ "ฉันยอมรับ" จากทั้งสองคน หลังจากได้รับการตอบรับจากทั้งสองฝ่าย ผู้ดำเนินพิธีประกาศว่าทั้งคู่แต่งงานกัน transaction ถูก commit และข่าวดีถูกประกาศให้ทุกคนที่มาร่วมงานทราบ ถ้าฝ่ายใดฝ่ายหนึ่งไม่ตอบตกลง พิธีจะถูกยกเลิก [ 78 ]
A system of promises (ระบบของคำสัญญา)
จากคำอธิบายสั้น ๆ นี้อาจไม่ชัดเจนว่าทำไม 2PC ถึงรับประกัน atomicity ในขณะที่ one-phase commit ข้ามหลาย nodes ไม่ได้ แน่นอน prepare และ commit requests ก็สามารถสูญหายในเครือข่ายได้ง่ายในกรณีสองเฟสเช่นกัน อะไรที่ทำให้ 2PC แตกต่าง?
เพื่อเข้าใจว่าทำไมมันถึงทำงาน เราต้องแยกย่อยกระบวนการในรายละเอียดเพิ่มเติมอีกเล็กน้อย:
-
เมื่อแอปพลิเคชันต้องการเริ่ม distributed transaction มันขอ transaction ID จาก coordinator transaction ID นี้ไม่ซ้ำกันในระดับโลก
-
แอปพลิเคชันเริ่ม single-node transaction บน participant แต่ละตัวและแนบ transaction ID ที่ไม่ซ้ำกันในระดับโลกเข้ากับ single-node transaction นั้น reads และ writes ทั้งหมดถูกทำในหนึ่งใน single-node transactions เหล่านี้ ถ้ามีอะไรผิดพลาดในขั้นตอนนี้ (เช่น node crash หรือ request timeout) coordinator หรือ participant ใด ๆ สามารถ abort ได้
-
เมื่อแอปพลิเคชันพร้อมที่จะ commit coordinator ส่ง prepare request ไปยัง participants ทั้งหมด ติดแท็กด้วย global transaction ID ถ้า request ใดล้มเหลวหรือ timeout coordinator ส่ง abort request สำหรับ transaction ID นั้นไปยัง participants ทั้งหมด
-
เมื่อ participant ได้รับ prepare request มันต้องแน่ใจว่ามันสามารถ commit transaction ได้อย่างแน่นอนภายใต้ทุกสถานการณ์ ซึ่งรวมถึงการเขียนข้อมูล transaction ทั้งหมดไปยังดิสก์ (crash, ไฟดับ, หรือดิสก์เต็มไม่ใช่ข้อแก้ตัวที่ยอมรับได้สำหรับการปฏิเสธที่จะ commit ในภายหลัง) และตรวจสอบ conflicts หรือ constraint violations ใด ๆ โดยการตอบตกลงกับ coordinator node สัญญาว่าจะ commit transaction โดยไม่มีข้อผิดพลาดถ้าถูกขอ กล่าวอีกนัยหนึ่ง participant สละสิทธิ์ในการ abort transaction แต่ไม่ได้ commit มันจริง ๆ
-
เมื่อ coordinator ได้รับการตอบสนองต่อ prepare requests ทั้งหมดแล้ว มันทำการตัดสินใจที่แน่ชัดว่าจะ commit หรือ abort transaction (commit ก็ต่อเมื่อ participants ทั้งหมดโหวตตกลง) coordinator ต้องเขียนการตัดสินใจนั้นไปยัง transaction log บนดิสก์เพื่อให้มันรู้ว่ามันตัดสินใจทางไหนในกรณีที่มัน crash ในภายหลัง สิ่งนี้เรียกว่า commit point
-
เมื่อการตัดสินใจของ coordinator ถูกเขียนไปยังดิสก์แล้ว commit หรือ abort request จะถูกส่งไปยัง participants ทั้งหมด ถ้า request นี้ล้มเหลวหรือ timeout coordinator ต้องลองใหม่ตลอดไปจนกว่าจะสำเร็จ ไม่มีการหวนกลับอีกแล้ว ถ้าการตัดสินใจคือ commit การตัดสินใจนั้นต้องถูกบังคับใช้ ไม่ว่าจะต้องลองใหม่กี่ครั้งก็ตาม ถ้า participant crash ในระหว่างนั้น transaction จะถูก commit เมื่อมันกู้คืน—เนื่องจาก participant โหวตตกลง มันไม่สามารถปฏิเสธที่จะ commit เมื่อมันกู้คืน
ดังนั้น โปรโตคอลประกอบด้วย "จุดที่ไม่มีทางหันกลับ" ที่สำคัญสองจุด: เมื่อ participant โหวตตกลง มันสัญญาว่ามันจะสามารถ commit ในภายหลังได้อย่างแน่นอน (แม้ว่า coordinator อาจยังเลือกที่จะ abort); และเมื่อ coordinator ตัดสินใจแล้ว การตัดสินใจนั้นไม่สามารถเพิกถอนได้ คำสัญญาเหล่านั้นรับประกัน atomicity ของ 2PC (Single-node atomic commit รวมสองเหตุการณ์นี้เป็นหนึ่ง: การเขียน commit record ไปยัง transaction log)
กลับมาที่การเปรียบเทียบการแต่งงาน ก่อนที่จะพูดว่า "ฉันยอมรับ" คุณและคู่ของคุณมีอิสระที่จะ abort transaction โดยการพูดว่า "ไม่!" (หรืออะไรทำนองนั้น) อย่างไรก็ตาม หลังจากพูดว่า "ฉันยอมรับ" คุณไม่สามารถถอนคำพูดนั้นได้ ถ้าคุณเป็นลมหลังจากพูดว่า "ฉันยอมรับ" และคุณไม่ได้ยินผู้ดำเนินพิธีประกาศว่าคุณแต่งงานแล้ว นั่นไม่เปลี่ยนความจริงที่ว่า transaction ถูก commit แล้ว เมื่อคุณฟื้นสติในภายหลัง คุณสามารถค้นหาได้ว่าคุณแต่งงานแล้วหรือยังโดยการสอบถามผู้ดำเนินพิธีถึงสถานะของ global transaction ID ของคุณ หรือคุณสามารถรอการลอง commit request ครั้งถัดไปของผู้ดำเนินพิธี (เนื่องจากการลองใหม่จะดำเนินต่อไปตลอดช่วงที่คุณหมดสติ)
Coordinator failure (ความล้มเหลวของ Coordinator)
เราได้พูดถึงสิ่งที่เกิดขึ้นถ้า participant ใด participant หนึ่งหรือเครือข่ายล้มเหลวระหว่าง 2PC: ถ้า prepare request ใดล้มเหลวหรือ timeout coordinator จะ abort transaction; ถ้า commit หรือ abort request ใดล้มเหลว coordinator จะลองใหม่ไม่มีกำหนด อย่างไรก็ตาม มันชัดเจนน้อยกว่าว่าจะเกิดอะไรขึ้นถ้า coordinator crash
ถ้า coordinator ล้มเหลวก่อนที่จะส่ง prepare requests 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 ในหลักการ participants สามารถสื่อสารกันเพื่อค้นหาว่าแต่ละ participant โหวตอย่างไรและตกลงกัน แต่นั่นไม่ใช่ส่วนหนึ่งของโปรโตคอล 2PC
วิธีเดียวที่ 2PC จะเสร็จสมบูรณ์คือรอให้ coordinator กู้คืน นี่คือเหตุผลที่ coordinator ต้องเขียนการตัดสินใจ commit หรือ abort ไปยัง transaction log บนดิสก์ก่อนที่จะส่ง commit หรือ abort requests ไปยัง participants: เมื่อ coordinator กู้คืน มันกำหนดสถานะของ transactions ที่ in doubt ทั้งหมดโดยการอ่าน transaction log ของมัน transactions ใด ๆ ที่ไม่มี commit record ใน log ของ coordinator จะถูก abort ดังนั้น commit point ของ 2PC ก็คือ regular single-node atomic commit บน coordinator
Figure 8-14. Coordinator crash หลังจาก participants โหวตตกลง Database 1 ไม่รู้ว่าจะ commit หรือ abort
ยิ่งไปกว่านั้น ถ้าดิสก์ของ coordinator ล้มเหลวและ log ของมันสูญหาย ระบบไม่มีวิธีที่จะกู้คืนโดยอัตโนมัติ ทางเลือกเดียวคือให้ผู้ดูแลระบบ commit หรือ abort transactions ที่ in doubt ด้วยตนเอง ถ้าเฉพาะส่วนล่าสุดของ transaction log สูญหาย coordinator ที่กำลังกู้คืนอาจเชื่อว่า transactions ที่ commit แล้วยังไม่ได้ commit และพยายาม abort มัน ละเมิด atomicity
Three-phase commit (การ commit สามเฟส)
2PC ถูกเรียกว่า blocking atomic commit protocol เพราะ 2PC สามารถติดอยู่รอให้ coordinator กู้คืน มันเป็นไปได้ที่จะทำให้ atomic commit protocol nonblocking เพื่อให้มันไม่ติดขัดถ้า node ล้มเหลว อย่างไรก็ตาม การทำให้สิ่งนี้ทำงานในทางปฏิบัติไม่ง่ายนัก
เป็นทางเลือกแทน 2PC อัลกอริทึมที่เรียกว่า three-phase commit (3PC) ถูกเสนอ [ 13 , 79 ] อย่างไรก็ตาม 3PC สมมติเครือข่ายที่มีหน่วงเวลาแบบมีขอบเขตและ nodes ที่มีเวลาตอบสนองแบบมีขอบเขต ในระบบปฏิบัติจริงส่วนใหญ่ที่มีหน่วงเวลาเครือข่ายแบบไม่มีขอบเขตและ process pauses (ดู บทที่ 9 ) 3PC ไม่สามารถรับประกัน atomicity
วิธีแก้ปัญหาที่ดีกว่าในทางปฏิบัติคือการแทนที่ single-node coordinator ด้วย fault-tolerant consensus protocol เราจะเห็นวิธีการทำเช่นนี้ใน บทที่ 10
Distributed Transactions Across Different Systems (ธุรกรรมแบบกระจายข้ามระบบที่แตกต่างกัน)
Distributed transactions และ 2PC มีชื่อเสียงแบบผสม ในด้านหนึ่ง พวกมันถูกมองว่าให้การรับประกันความปลอดภัยที่สำคัญซึ่งยากที่จะบรรลุด้วยวิธีอื่น ในอีกด้านหนึ่ง พวกมันถูกวิจารณ์ว่าทำให้เกิดปัญหาการปฏิบัติการ ฆ่าประสิทธิภาพ และสัญญามากเกินกว่าที่จะส่งมอบได้ [ 80 , 81 , 82 , 83 ] บริการคลาวด์หลายแห่งเลือกที่จะไม่ implement distributed transactions เนื่องจากปัญหาการปฏิบัติการที่พวกมันก่อให้เกิด [ 84 ]
การ implement distributed transactions บางอย่างมีโทษด้านประสิทธิภาพที่หนักหน่วง ต้นทุนด้านประสิทธิภาพส่วนใหญ่ที่มีอยู่ใน 2PC มาจาก fsync operations เพิ่มเติมที่จำเป็นสำหรับการกู้คืนจาก crash และ network round trips เพิ่มเติม
อย่างไรก็ตาม แทนที่จะปฏิเสธ distributed transactions ไปเลย เราควรตรวจสอบพวกมันในรายละเอียดเพิ่มเติม เพราะมีบทเรียนสำคัญที่ต้องเรียนรู้จากพวกมัน ในการเริ่มต้น เราควรแม่นยำเกี่ยวกับสิ่งที่เราหมายถึง "distributed transactions" distributed transactions สองประเภทที่ค่อนข้างแตกต่างกันมักถูกปนกัน:
Database-internal distributed transactions
ฐานข้อมูลแบบกระจายบางตัว (นั่นคือ ฐานข้อมูลที่ใช้ replication และ sharding ในการกำหนดค่ามาตรฐานของพวกมัน) รองรับ internal transactions ระหว่าง nodes ของฐานข้อมูลนั้น ตัวอย่างเช่น YugabyteDB, TiDB, FoundationDB, Spanner, VoltDB, Cassandra, และ NDB storage engine ของ MySQL Cluster มีการรองรับ internal transaction ดังกล่าว ในกรณีนี้ nodes ทั้งหมดที่เข้าร่วมใน transaction กำลัง run ซอฟต์แวร์ฐานข้อมูลเดียวกัน
Heterogeneous distributed transactions
ใน heterogeneous transaction participants เป็นสองเทคโนโลยีขึ้นไป—ตัวอย่างเช่น ฐานข้อมูลสองตัวจากผู้ผลิตที่แตกต่างกัน หรือแม้แต่ระบบที่ไม่ใช่ฐานข้อมูล เช่น message brokers distributed transaction ข้ามระบบเหล่านี้ต้องรับประกัน atomic commit แม้ว่าระบบอาจแตกต่างกันโดยสิ้นเชิงภายใต้ hood
Database-internal transactions ไม่จำเป็นต้องเข้ากันได้กับระบบอื่น ดังนั้นพวกมันสามารถใช้โปรโตคอลใดก็ได้และใช้การเพิ่มประสิทธิภาพเฉพาะเทคโนโลยีนั้น ด้วยเหตุนั้น database-internal distributed transactions มักทำงานได้ค่อนข้างดี ในทางกลับกัน transactions ที่ครอบคลุมเทคโนโลยีที่แตกต่างกันนั้นท้าทายกว่ามาก เราจะเน้นที่สิ่งเหล่านั้นที่นี่และพูดถึง database-internal distributed transactions ในส่วนถัดไป
Exactly-once message processing (การประมวลผลข้อความแบบ Exactly-Once)
Heterogeneous distributed transactions อนุญาตให้ระบบที่หลากหลายถูกผสานรวมในวิธีที่มีประสิทธิภาพ ตัวอย่างเช่น ข้อความจาก message queue สามารถถูก acknowledge ว่าประมวลผลแล้วก็ต่อเมื่อ database transaction สำหรับการประมวลผลข้อความถูก commit สำเร็จ สิ่งนี้ถูก implement โดยการ commit การยืนยันข้อความและการเขียนฐานข้อมูลแบบอะตอมใน transaction เดียว ด้วยการรองรับ distributed transaction สิ่งนี้เป็นไปได้แม้ว่า message broker และฐานข้อมูลจะเป็นสองเทคโนโลยีที่ไม่เกี่ยวข้องกันทำงานบนเครื่องที่แตกต่างกัน
ถ้าการส่งข้อความหรือ database transaction ล้มเหลว ทั้งคู่จะถูก abort ดังนั้น message broker สามารถส่งข้อความซ้ำได้อย่างปลอดภัยในภายหลัง ดังนั้น โดยการ commit ข้อความและ side effects ของการประมวลผลแบบอะตอม เราสามารถรับประกันว่าข้อความถูกประมวลผล อย่างมีประสิทธิภาพ หนึ่งครั้ง แม้ว่ามันอาจต้องลองใหม่สองสามครั้งก่อนที่จะสำเร็จ การ abort ทิ้ง side effects ใด ๆ ของ transaction ที่ทำเสร็จเพียงบางส่วน สิ่งนี้เรียกว่า exactly-once semantics
อย่างไรก็ตาม distributed transaction ดังกล่าวเป็นไปได้ก็ต่อเมื่อทุกระบบที่ได้รับผลกระทบจาก transaction สามารถใช้โปรโตคอล atomic commit เดียวกันเท่านั้น ตัวอย่างเช่น สมมติว่า side effect ของการประมวลผลข้อความคือการส่งอีเมล และเซิร์ฟเวอร์อีเมลไม่รองรับ 2PC มันอาจเกิดขึ้นได้ว่าอีเมลถูกส่งสองครั้งหรือมากกว่าถ้าการประมวลผลข้อความล้มเหลวและถูกลองใหม่ แต่ถ้า side effects ทั้งหมดของการประมวลผลข้อความถูก roll back เมื่อ transaction abort ขั้นตอนการประมวลผลสามารถลองใหม่ได้อย่างปลอดภัยราวกับว่าไม่มีอะไรเกิดขึ้น
เราจะกลับมาที่หัวข้อของ exactly-once semantics ในภายหลังในบทนี้ มาดู atomic commit protocol ที่อนุญาตให้ heterogeneous distributed transactions ดังกล่าวก่อน
XA transactions
X/Open XA (ย่อมาจาก eXtended Architecture ) เป็นมาตรฐานสำหรับการ implement 2PC ข้ามเทคโนโลยีที่แตกต่างกัน [ 75 ] มันถูกนำเสนอในปี 1991 และถูก implement อย่างกว้างขวาง XA รองรับโดยฐานข้อมูลเชิงสัมพันธ์ดั้งเดิมหลายตัว (รวมถึง PostgreSQL, MySQL, Db2, SQL Server, และ Oracle) และ message brokers (รวมถึง ActiveMQ, HornetQ, MSMQ, และ IBM MQ)
XA ไม่ใช่ network protocol—มันเป็นแค่ C API สำหรับการเชื่อมต่อกับ transaction coordinator การผูกสำหรับ API นี้มีอยู่ในภาษาอื่น ตัวอย่างเช่น ในโลก ของแอปพลิเคชัน Java EE XA transactions ถูก implement โดยใช้ Java Transaction API (JTA) ซึ่งรองรับโดย drivers จำนวนมากสำหรับฐานข้อมูลที่ใช้ Java Database Connectivity (JDBC) และ drivers สำหรับ message brokers ที่ใช้ Java Message Service (JMS) APIs
XA สมมติว่าแอปพลิเคชันของคุณใช้ network driver หรือ client library เพื่อสื่อสารกับ participant databases หรือ messaging services ถ้า driver รองรับ XA นั่นหมายความว่ามันเรียก XA API เพื่อค้นหาว่า operation ควรเป็นส่วนหนึ่งของ distributed transaction หรือไม่—และถ้าใช่ มันส่งข้อมูลที่จำเป็นไปยังเซิร์ฟเวอร์ฐานข้อมูล driver ยังเปิดเผย callbacks ที่ coordinator สามารถใช้เพื่อขอให้ participant prepare, commit, หรือ abort
Transaction coordinator implement XA API มาตรฐานไม่ได้ระบุว่ามันควรถูก implement อย่างไร แต่ในทางปฏิบัติ coordinator มักเป็นเพียง library ที่ถูกโหลดใน process เดียวกับแอปพลิเคชันที่ออก transaction (ไม่ใช่ service แยกต่างหาก) มันติดตาม participants ใน transaction รวบรวมการตอบสนองของ participants หลังจากขอให้พวกเขา prepare (ผ่าน callback ใน driver) และใช้ log บนดิสก์ภายในเครื่องเพื่อติดตามการตัดสินใจ commit/abort สำหรับแต่ละ transaction
ถ้า process ของแอปพลิเคชัน crash หรือเครื่องที่แอปพลิเคชันทำงานเสีย coordinator ก็จะพังไปด้วย participants ใด ๆ ที่มี transactions ที่ prepare แต่ยังไม่ได้ commit จะติดอยู่ในสถานะ in doubt เนื่องจาก log ของ coordinator อยู่บนดิสก์ภายในเครื่องของเซิร์ฟเวอร์แอปพลิเคชัน เซิร์ฟเวอร์นั้นต้องถูกรีสตาร์ท และ coordinator library ต้องอ่าน log เพื่อกู้คืนผลลัพธ์ commit/abort ของแต่ละ transaction จากนั้น coordinator จึงสามารถใช้ XA callbacks ของ database driver เพื่อขอให้ participants commit หรือ abort ตามความเหมาะสม เซิร์ฟเวอร์ฐานข้อมูลไม่สามารถติดต่อ coordinator โดยตรง เนื่องจากการสื่อสารทั้งหมดต้องผ่าน client library
Holding locks while in doubt (การถือ lock ในขณะที่สงสัย)
ทำไมเราถึงกังวลมากเกี่ยวกับ transaction ที่ติดอยู่ในสถานะ in doubt? ระบบที่เหลือไม่สามารถทำงานต่อไปและไม่สนใจ transaction in doubt ที่จะถูก cleanup ในที่สุด?
ปัญหาอยู่ที่ การล็อก ดังที่กล่าวถึงใน "Read Committed" database transactions มักจะได้รับ row-level exclusive locks บนแถวใด ๆ ที่พวกมันแก้ไข เพื่อป้องกัน dirty writes ถ้าคุณต้องการ serializable isolation ฐานข้อมูลที่ใช้ 2PL ก็ต้องได้รับ shared locks บนแถวใด ๆ ที่ถูก อ่าน โดย transaction
ฐานข้อมูลไม่สามารถปล่อย locks เหล่านั้นได้จนกว่า transaction จะ commit หรือ abort (แสดงเป็นพื้นที่แรเงาใน รูปที่ 8-13 ) ดังนั้น เมื่อใช้ 2PC transaction ต้องถือ lock ตลอดเวลาที่มันอยู่ในสถานะ in doubt ถ้า coordinator crash และใช้เวลา 20 นาทีในการเริ่มต้นใหม่ locks เหล่านั้นจะถูกถือไว้ 20 นาที ถ้า log ของ coordinator สูญหายทั้งหมดด้วยเหตุผลบางอย่าง locks เหล่านั้นจะถูกถือไว้ตลอดไป—หรืออย่างน้อยจนกว่าสถานการณ์จะถูกแก้ไขด้วยตนเองโดยผู้ดูแลระบบ
ในขณะที่ locks ถูกถือไว้ ไม่มี transaction อื่นใดสามารถแก้ไขแถวเหล่านั้นได้ ขึ้นอยู่กับ isolation level transactions อื่นอาจถูกบล็อกไม่ให้อ่านแถวด้วย ดังนั้น transactions อื่นไม่สามารถดำเนินธุรกิจต่อไปได้—ถ้าพวกมันต้องการเข้าถึงข้อมูลเดียวกัน พวกมันจะถูกบล็อก สิ่งนี้สามารถทำให้ส่วนใหญ่ของแอปพลิเคชันของคุณไม่พร้อมใช้งานจนกว่า transaction in doubt จะได้รับการแก้ไข
Recovering from coordinator failure (การกู้คืนจากความล้มเหลวของ coordinator)
ในทางทฤษฎี ถ้า coordinator crash และถูกรีสตาร์ท มันควรกู้คืนสถานะจาก log อย่างสะอาดและแก้ไข transactions in doubt ใด ๆ อย่างไรก็ตาม ในทางปฏิบัติ orphaned in-doubt transactions เกิดขึ้นได้ [ 85 , 86 ]—นั่นคือ transactions ที่ coordinator ไม่สามารถตัดสินใจผลลัพธ์ได้ไม่ว่าด้วยเหตุผลใด (เช่น เพราะ transaction log สูญหายหรือเสียหายเนื่องจากบั๊กซอฟต์แวร์) transactions เหล่านี้ไม่สามารถแก้ไขได้โดยอัตโนมัติ ดังนั้นพวกมันจึงอยู่ในฐานข้อมูลตลอดไป ถือ locks และบล็อก transactions อื่น
แม้แต่การรีบูตเซิร์ฟเวอร์ฐานข้อมูลของคุณก็ไม่สามารถแก้ปัญหานี้ได้ เพราะการ implement 2PC ที่ถูกต้องต้องรักษา locks ของ in-doubt transaction แม้ข้ามการรีสตาร์ท (มิฉะนั้น มันจะเสี่ยงต่อการละเมิดการรับประกัน atomicity) มันเป็น สถานการณ์ที่เหนียวแน่น
ทางออกเดียวคือให้ผู้ดูแลระบบตัดสินใจด้วยตนเองว่าจะ commit หรือ roll back transactions ผู้ดูแลระบบต้องตรวจสอบ participants ของแต่ละ in-doubt transaction กำหนดว่า participant ใด commit หรือ abort แล้ว และใช้ผลลัพธ์เดียวกันกับ participants อื่น การแก้ปัญหาอาจต้องใช้ความพยายามด้วยตนเองจำนวนมาก และส่วนใหญ่มักต้องทำภายใต้ความกดดันสูงและเวลาเร่งด่วนระหว่างการหยุดชะงักของการผลิตที่ร้ายแรง (ไม่อย่างนั้น ทำไม coordinator ถึงอยู่ในสถานะที่แย่ขนาดนั้น?)
การ implement XA หลายตัวมีช่องทางหนีฉุกเฉินที่เรียกว่า heuristic decisions : อนุญาตให้ participant ตัดสินใจเพียงฝ่ายเดียวที่จะ abort หรือ commit in-doubt transaction โดยไม่ต้องมีการตัดสินใจที่แน่ชัดจาก coordinator [ 75 ] เพื่อความชัดเจน heuristic ในที่นี้เป็นคำสุภาพสำหรับ น่าจะทำลาย atomicity เนื่องจากการตัดสินใจแบบ heuristic ละเมิดระบบของคำสัญญาใน 2PC ดังนั้น heuristic decisions มีไว้สำหรับการออกจากสถานการณ์ภัยพิบัติเท่านั้น ไม่ใช่สำหรับการใช้ปกติ
Problems with XA transactions (ปัญหากับ XA transactions)
Single-node coordinator เป็นจุดเสียจุดเดียวสำหรับทั้งระบบ และการทำให้มันเป็นส่วนหนึ่งของเซิร์ฟเวอร์แอปพลิเคชันก็เป็นปัญหาเช่นกันเพราะ log ของ coordinator บนดิสก์ภายในเครื่องกลายเป็นส่วนสำคัญของสถานะระบบที่คงทน—สำคัญพอ ๆ กับตัวฐานข้อมูลเอง
ในหลักการ coordinator ของ XA transaction สามารถมีความพร้อมใช้งานสูงและถูก replicated ได้ เช่นเดียวกับที่เราคาดหวังจากฐานข้อมูลสำคัญอื่น ๆ น่าเสียดายที่สิ่งนี้ยังไม่แก้ปัญหาพื้นฐานของ XA ซึ่งก็คือมันไม่มีทางให้ coordinator และ participants ของ transaction สื่อสารกันโดยตรง พวกมันสามารถสื่อสารผ่านโค้ดแอปพลิเคชันที่เรียก transaction และ database drivers ที่มันใช้เรียก participants เท่านั้น
แม้ว่า coordinator จะถูก replicated โค้ดแอปพลิเคชันก็ยังเป็นจุดเสียจุดเดียว การแก้ปัญหานี้จะต้องออกแบบใหม่ทั้งหมดว่าโค้ดแอปพลิเคชันถูก run อย่างไรเพื่อให้มันสามารถ replicated หรือ restartable ซึ่งอาจดูคล้ายกับ durable execution (ดู "Durable Execution and Workflows" ) อย่างไรก็ตาม ไม่มีเครื่องมือใดที่ใช้แนวทางนี้ในทางปฏิบัติ
ปัญหาอีกประการคือเนื่องจาก XA ต้องเข้ากันได้กับระบบข้อมูลที่หลากหลาย มันจึงเป็นตัวหารร่วมที่ต่ำที่สุดอย่างหลีกเลี่ยงไม่ได้ ตัวอย่างเช่น มันไม่สามารถตรวจจับ deadlocks ข้ามระบบที่แตกต่างกัน (เพราะนั่นจะต้องมีโปรโตคอลมาตรฐานสำหรับระบบในการแลกเปลี่ยนข้อมูลเกี่ยวกับ locks ที่แต่ละ transaction กำลังรอ) และมันไม่ทำงานกับ SSI (ดู "Serializable Snapshot Isolation" ) เพราะนั่นจะต้องมีโปรโตคอลสำหรับการระบุ conflicts ข้ามระบบที่แตกต่างกัน
ปัญหาเหล่านี้มีอยู่โดยธรรมชาติในการทำ transactions ข้ามเทคโนโลยีที่แตกต่างกัน อย่างไรก็ตาม การทำให้ระบบข้อมูลที่แตกต่างกันหลายระบบสอดคล้องกันยังคงเป็นปัญหาจริงและสำคัญ ดังนั้นเราต้องหาวิธีแก้ไขที่แตกต่าง สิ่งนี้สามารถทำได้ ดังที่เราจะเห็นในส่วนถัดไปและใน บทที่ 12
Database-Internal Distributed Transactions (ธุรกรรมแบบกระจายภายในฐานข้อมูล)
ดังที่อธิบายไว้ก่อนหน้านี้ มีความแตกต่างใหญ่ระหว่าง distributed transactions ที่ครอบคลุมเทคโนโลยีการจัดเก็บที่แตกต่างกันหลายตัวกับที่อยู่ภายในระบบ—นั่นคือที่ nodes ที่เข้าร่วมทั้งหมดเป็นส่วนหนึ่งของฐานข้อมูลเดียวกันที่ run ซอฟต์แวร์เดียวกัน internal distributed transactions ดังกล่าวเป็นคุณลักษณะที่กำหนดของฐานข้อมูล "NewSQL" เช่น CockroachDB [ 5 ], TiDB [ 6 ], Spanner [ 7 ], FoundationDB [ 8 ], และ YugabyteDB ตัวอย่างเช่น message brokers บางตัว เช่น Kafka ก็รองรับ internal distributed transactions [ 87 ]
หลายระบบเหล่านี้ใช้ 2PC เพื่อรับประกัน atomicity ของ transactions ที่เขียนไปยังหลาย shards แต่พวกมันไม่ประสบปัญหาเดียวกับ XA transactions เพราะ distributed transactions ของพวกมันไม่จำเป็นต้องเชื่อมต่อกับเทคโนโลยีอื่น พวกมันหลีกเลี่ยงกับดักตัวหารร่วมที่ต่ำที่สุด—นักออกแบบของระบบเหล่านี้มีอิสระที่จะใช้โปรโตคอลที่ดีกว่าที่เชื่อถือได้และเร็วขึ้น
ปัญหาที่ใหญ่ที่สุดกับ XA สามารถแก้ไขได้โดย:
-
การ replicated coordinator ด้วย automatic failover ไปยัง coordinator node อื่นถ้า primary crash
-
อนุญาตให้ coordinator และ data shards สื่อสารโดยตรงโดยไม่มีโค้ดแอปพลิเคชันเป็นตัวกลาง
-
การ replicated shards ที่เข้าร่วมเพื่อให้ความเสี่ยงของการต้อง abort transaction เนื่องจากความผิดพลาดใน shard ใด shard หนึ่งลดลง
-
การเชื่อมโยง atomic commitment protocol กับ distributed concurrency control protocol ที่รองรับการตรวจจับ deadlock และการอ่านที่สอดคล้องกันข้าม shards
Consensus algorithms มักถูกใช้เพื่อ replicate coordinator และ database shards เราจะเห็นใน บทที่ 10 ว่า atomic commitment สำหรับ distributed transactions สามารถ implement ได้โดยใช้ consensus algorithm อัลกอริทึมเหล่านี้ทนต่อความผิดพลาดโดยการ fail over โดยอัตโนมัติจาก node หนึ่งไปยังอีก node หนึ่งโดยไม่ต้องมีการแทรกแซงของมนุษย์ ในขณะที่ยังคงรับประกันคุณสมบัติความสอดคล้องที่แข็งแกร่ง
Isolation levels ที่มีให้สำหรับ distributed transactions ขึ้นอยู่กับระบบ แต่ snapshot isolation [ 6 ] และ serializable snapshot isolation [ 5 , 8 ] เป็นไปได้ทั้งสองแบบข้าม shards
Exactly-Once Message Processing Revisited (การประมวลผลข้อความแบบ Exactly-Once ทบทวนอีกครั้ง)
เราเห็นใน "Exactly-once message processing" ว่า use case ที่สำคัญสำหรับ distributed transactions คือการรับประกันว่า operation มีผลหนึ่งครั้ง (exactly once) แม้ว่า crash จะเกิดขึ้นระหว่างการประมวลผลและจำเป็นต้องลองใหม่ ถ้าคุณสามารถ commit transaction แบบอะตอมข้าม message broker และฐานข้อมูล คุณสามารถยืนยันข้อความไปยัง broker ก็ต่อเมื่อมันถูกประมวลผลสำเร็จและ database writes ที่เกิดจากกระบวนการถูก commit
อย่างไรก็ตาม คุณไม่จำเป็นต้องมี distributed transactions จริง ๆ เพื่อบรรลุ exactly-once semantics วิธีการทางเลือกมีดังนี้ ซึ่งต้องการ transactions ภายในฐานข้อมูลเท่านั้น:
-
สมมติว่าทุกข้อความมี ID ที่ไม่ซ้ำกัน และในฐานข้อมูลคุณมีตารางของ message IDs ที่ถูกประมวลผลแล้ว เมื่อคุณเริ่มประมวลผลข้อความจาก broker คุณเริ่ม transaction ใหม่บนฐานข้อมูลและตรวจสอบ message ID ถ้า message ID เดียวกันมีอยู่แล้วในฐานข้อมูล คุณรู้ว่ามันถูกประมวลผลแล้ว ดังนั้นคุณสามารถยืนยันข้อความไปยัง broker และทิ้งมัน
-
ถ้า message ID ยังไม่มีในฐานข้อมูล คุณเพิ่มมันลงในตาราง จากนั้นคุณประมวลผลข้อความ ซึ่งอาจส่งผลให้มีการ writes เพิ่มเติมไปยังฐานข้อมูลภายใน transaction เดียวกัน เมื่อคุณประมวลผลข้อความเสร็จ คุณ commit transaction บนฐานข้อมูล
-
เมื่อ database transaction ถูก commit สำเร็จแล้ว คุณสามารถยืนยันข้อความไปยัง broker
-
เมื่อข้อความถูกยืนยันไปยัง broker สำเร็จแล้ว คุณรู้ว่ามันจะไม่พยายามประมวลผลข้อความเดียวกันอีก ดังนั้นคุณสามารถลบ message ID จากฐานข้อมูล (ใน transaction แยกต่างหาก)
ถ้า message processor crash ก่อนที่จะ commit database transaction transaction จะถูก abort และ message broker จะลองประมวลผลใหม่ ถ้ามัน crash หลังจาก commit แต่ก่อนที่จะยืนยันข้อความไปยัง broker มันก็จะลองประมวลผลใหม่เช่นกัน แต่การลองใหม่จะเห็น message ID ในฐานข้อมูลและทิ้งมัน ถ้ามัน crash หลังจากยืนยันข้อความแต่ก่อนที่จะลบ message ID จากฐานข้อมูล คุณจะมี message ID เก่าค้างอยู่ ซึ่งไม่ก่อให้เกิดอันตรายนอกจากใช้พื้นที่เก็บข้อมูลเล็กน้อย ถ้าการลองใหม่เกิดขึ้นก่อนที่ database transaction จะถูก abort (ซึ่งอาจเกิดขึ้นถ้าการสื่อสารระหว่าง message processor และฐานข้อมูลถูกขัดจังหวะ) uniqueness constraint บนตารางของ message IDs ควรป้องกันไม่ให้ message ID เดียวกันถูก insert โดย two concurrent transactions
ดังนั้น การบรรลุ exactly-once processing ต้องการเพียง transactions ภายในฐานข้อมูล—atomicity ข้ามฐานข้อมูลและ message broker ไม่จำเป็นสำหรับ use case นี้ การบันทึก message ID ในฐานข้อมูลทำให้การประมวลผลข้อความ idempotent เพื่อให้การประมวลผลข้อความสามารถลองใหม่ได้อย่างปลอดภัยโดยไม่ทำ side effects ซ้ำ วิธีการที่คล้ายกันถูกใช้ใน stream processing frameworks เช่น Kafka Streams เพื่อบรรลุ exactly-once semantics ดังที่เราจะเห็นใน บทที่ 12
อย่างไรก็ตาม internal distributed transactions ภายในฐานข้อมูลยังมีประโยชน์สำหรับความสามารถในการ scale ของรูปแบบดังกล่าว ตัวอย่างเช่น พวกมันจะอนุญาตให้ message IDs ถูกเก็บใน shard หนึ่งและข้อมูลหลักที่อัปเดตโดยการประมวลผลข้อความถูกเก็บใน shards อื่น และรับประกัน atomicity ของ transaction commit ข้าม shards เหล่านั้น
Summary (สรุป)
Transactions เป็นชั้นของการแยกส่วน (abstraction layer) ที่ช่วยให้แอปพลิเคชันแสร้งทำเสมือนว่าปัญหา concurrency บางอย่างและความผิดพลาดของฮาร์ดแวร์และซอฟต์แวร์บางชนิดไม่มีอยู่จริง คลาสของข้อผิดพลาดขนาดใหญ่ถูกลดทอนเหลือเพียง transaction abort ง่าย ๆ และแอปพลิเคชันก็แค่ต้องลองอีกครั้ง
ในบทนี้เราเห็นตัวอย่างมากมายของปัญหาที่ transactions ช่วยป้องกัน ไม่ใช่ทุกแอปพลิเคชันที่อ่อนไหวต่อปัญหาทั้งหมดเหล่านั้น แอปพลิเคชันที่มีรูปแบบการเข้าถึงที่ง่ายมาก เช่น การอ่านและเขียนเพียง record เดียว อาจจัดการได้โดยไม่มี transactions อย่างไรก็ตาม สำหรับรูปแบบการเข้าถึงที่ซับซ้อนกว่า transactions สามารถลดจำนวนกรณีข้อผิดพลาดที่คุณต้องคิดถึงได้อย่างมหาศาล
หากไม่มี transactions สถานการณ์ข้อผิดพลาดต่าง ๆ (process crash, การขัดจังหวะเครือข่าย, ไฟดับ, ดิสก์เต็ม, concurrency ที่ไม่คาดคิด ฯลฯ) หมายความว่าข้อมูลสามารถไม่สอดคล้องกันในหลายวิธี ตัวอย่างเช่น ข้อมูลที่ถูก denormalize สามารถไม่สอดคล้องกับข้อมูลต้นทางได้ง่าย หากไม่มี transactions มันยากมากที่จะให้เหตุผลเกี่ยวกับผลกระทบที่การเข้าถึงที่ซับซ้อนและมีปฏิสัมพันธ์กันสามารถมีต่อฐานข้อมูล
เราลงลึกเป็นพิเศษในหัวข้อการควบคุม concurrency โดยพูดถึง isolation levels ที่ใช้กันอย่างแพร่หลายหลายแบบ: โดยเฉพาะ read-committed , snapshot (บางครั้งเรียกว่า repeatable read ), และ serializable เราอธิบายลักษณะของ isolation levels เหล่านั้นโดยการพูดถึงตัวอย่างต่าง ๆ ของ race conditions ซึ่งสรุปใน ตาราง 8-1
Table 8-1. สรุป anomalies ที่สามารถเกิดขึ้นได้ใน isolation levels ต่าง ๆ | 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
ไคลเอ็นต์หนึ่งอ่าน writes ของอีกไคลเอ็นต์ก่อนที่พวกมันจะถูก commit read-committed isolation level และ levels ที่แข็งแกร่งกว่าป้องกัน dirty reads
Dirty writes
ไคลเอ็นต์หนึ่งเขียนทับข้อมูลที่อีกไคลเอ็นต์เขียนแต่ยังไม่ได้ commit การ implement transaction เกือบทั้งหมดป้องกัน dirty writes (ดังนั้นมันจึงไม่รวมอยู่ในตาราง)
Read skew
ไคลเอ็นต์เห็นส่วนต่าง ๆ ของฐานข้อมูล ณ จุดเวลาที่แตกต่างกัน บางกรณีของ read skew ยังเป็นที่รู้จักว่า nonrepeatable reads ปัญหานี้มักถูกป้องกันด้วย snapshot isolation ซึ่งอนุญาตให้ transaction อ่านจาก consistent snapshot ที่สอดคล้องกับจุดเวลาเฉพาะจุดหนึ่ง Snapshot isolation มักถูก implement ด้วย multiversion concurrency control
Phantom reads
Transaction อ่าน objects ที่ตรงกับเงื่อนไขการค้นหา ไคลเอ็นต์อื่นทำ write ที่ส่งผลต่อผลลัพธ์ของการค้นหานั้น Snapshot isolation ป้องกัน phantom reads ทั่วไป แต่ phantoms ในบริบทของ write skew ต้องการการจัดการพิเศษ เช่น index-range locks
Lost updates
ไคลเอ็นต์สองตัวดำเนินการ read-modify-write cycle พร้อมกัน ตัวหนึ่งเขียนทับ write ของอีกตัวโดยไม่รวมการเปลี่ยนแปลงของมัน ดังนั้นข้อมูลจึงสูญหาย การ implement snapshot isolation บางตัวป้องกัน anomaly นี้โดยอัตโนมัติ ในขณะที่บางตัวต้องการ lock ด้วยตนเอง ( SELECT FOR UPDATE )
Write skew
Transaction อ่านบางสิ่ง ตัดสินใจบนพื้นฐานของค่าที่มันเห็น และเขียนการตัดสินใจไปยังฐานข้อมูล อย่างไรก็ตาม เมื่อถึงเวลาที่ write ถูกทำ premise ของการตัดสินใจไม่เป็นจริงอีกต่อไป มีเพียง serializable isolation เท่านั้นที่ป้องกัน anomaly นี้
Weak isolation levels ป้องกัน anomalies บางอย่างเหล่านั้น แต่ปล่อยให้คุณ นักพัฒนาแอปพลิเคชัน จัดการกับ anomalies อื่นด้วยตนเอง (เช่น โดยใช้ explicit locking) มีเพียง serializable isolation เท่านั้นที่ป้องกันปัญหาทั้งหมดเหล่านี้ เราได้พูดถึงสามแนวทางในการ implement serializable transactions:
การดำเนินการ transactions ตามลำดับ serial จริง
ถ้าคุณสามารถทำให้แต่ละ transaction ดำเนินการได้เร็วมาก (โดยทั่วไปโดยใช้ stored procedures) และ transaction throughput ต่ำพอที่จะประมวลผลบน single CPU core หรือสามารถ shard ได้ นี่เป็นตัวเลือกที่ง่ายและมีประสิทธิภาพ
Two-phase locking
เป็นเวลาหลายทศวรรษที่ 2PL เป็นวิธีมาตรฐานในการ implement serializability แต่แอปพลิเคชันจำนวนมากหลีกเลี่ยงการใช้มันเนื่องจากประสิทธิภาพที่ไม่ดี
Serializable snapshot isolation
SSI เป็นอัลกอริทึมที่ค่อนข้างใหม่ที่หลีกเลี่ยงข้อเสียส่วนใหญ่ของแนวทางก่อนหน้านี้ มันใช้แนวทาง optimistic อนุญาตให้ transactions ดำเนินการต่อโดยไม่ต้องบล็อก เมื่อ transaction ต้องการ commit มันจะถูกตรวจสอบ และมันจะถูก abort ถ้าการดำเนินการไม่เป็น serializable
สุดท้าย เราตรวจสอบวิธีบรรลุ atomicity เมื่อ transaction ถูกกระจายข้ามหลาย nodes โดยใช้ 2PC ถ้า nodes เหล่านั้นทั้งหมด run ซอฟต์แวร์ฐานข้อมูลเดียวกัน distributed transactions สามารถทำงานได้ค่อนข้างดี อย่างไรก็ตาม ข้ามเทคโนโลยีการจัดเก็บที่แตกต่างกัน (โดยใช้ XA transactions) 2PC มีปัญหา มันไวต่อความผิดพลาดใน coordinator และโค้ดแอปพลิเคชันที่ขับเคลื่อน transaction และมันมีปฏิสัมพันธ์ที่ไม่ดีกับกลไกการควบคุม concurrency โชคดีที่ idempotence สามารถรับประกัน exactly-once semantics โดยไม่ต้อง atomic commit ข้ามเทคโนโลยีการจัดเก็บที่แตกต่างกัน เราจะเห็นเพิ่มเติมเกี่ยวกับเรื่องนี้ในบทต่อ ๆ ไป
ตัวอย่างในบทนี้ใช้ relational data model อย่างไรก็ตาม ดังที่กล่าวถึงใน "The need for multi-object transactions" transactions เป็นคุณสมบัติฐานข้อมูลที่มีค่า ไม่ว่าข้อมูลจะใช้ data model ใดก็ตาม