Trang này được dịch bằng máy. Đọc bản gốc tiếng Anh. English

Giao dịch trong Firebird: ACID, mức cô lập, Deadlock và giải quyết xung đột cập nhật

Alexey Kovyazin, với sự hỗ trợ của Vlad Khorsun và Dmitry Kuzmenko, 08-T04-2019

Nội dung:

Có cần thiết phải hiểu cách giao dịch hoạt động không?

Có lẽ vì khái niệm giao dịch khá đơn giản, nhiều lập trình viên đánh giá thấp tầm quan trọng của việc sử dụng giao dịch đúng cách trong Firebird. Tuy nhiên, chỉ sau khi bạn hiểu thấu đáo cách giao dịch hoạt động, bạn mới có thể hiểu được nhiều điều bí ẩn liên quan đến hiệu suất, chẳng hạn như hiện tượng cơ sở dữ liệu chậm đột ngột (liên quan đến việc quét các phiên bản bản ghi dư thừa phát sinh do quản lý giao dịch kém).

Nói chung, khái niệm giao dịch được áp dụng cho bất kỳ hệ thống động nào chuyển từ trạng thái này sang trạng thái khác. Ví dụ, một ví dụ kinh điển về giao dịch là chuyển tiền từ tài khoản này sang tài khoản khác. Thông thường, nó trông giống như thế này:

Code

Begin   --- chuyển tiền từ tài khoản 1 sang tài khoản 2
 --giảm số dư tài khoản 1
 --tăng số dư tài khoản 2
End - cam kết giao dịch

Ví dụ này cho thấy rằng tiền phải biến mất khỏi tài khoản 1 và xuất hiện trong tài khoản 2 cùng một lúc, nếu không sẽ có tiền dư thừa hoặc thiếu hụt không giải thích được trong hệ thống tại một thời điểm nào đó.

Từ góc độ cơ sở dữ liệu, giao dịch thường được định nghĩa là một nhóm các thao tác được thực hiện trên cơ sở dữ liệu và được xem là độc lập với các giao dịch khác. Theo quan điểm của tôi, định nghĩa này không tốt hơn cũng không tệ hơn các định nghĩa khác nhưng, như mọi định nghĩa, nó ít ý nghĩa nếu không hiểu được hoạt động bên trong và logic thực tế của một hệ quản trị cơ sở dữ liệu (DBMS).

Người ta tin rằng một giao dịch trong cơ sở dữ liệu phải đáp ứng các yêu cầu ACID

Code
A - Tính nguyên tử (Atomicity)
С - Tính nhất quán (Consistency)
I - Tính cô lập (Isolation)
D - Tính bền vững (Durability)

Nhiều lập trình viên ứng dụng cơ sở dữ liệu bị ấn tượng bởi từ viết tắt này đến mức họ thường sử dụng những lập luận như “bạn không có chữ D trong ACID” khi so sánh các DBMS khác nhau (thường ngay sau đó là “tôi không quan tâm bạn nghĩ gì”).

Thực ra, mọi thứ khá đơn giản - ACID là một tập hợp các yêu cầu về việc triển khai giao dịch trong một DBMS cụ thể, một số yêu cầu rất nghiêm ngặt (ví dụ, D - tất nhiên, tính bền vững rất quan trọng!) trong khi một số yêu cầu khác ít nghiêm ngặt hơn - khi chúng ta xem xét các mức cô lập giao dịch, chúng ta sẽ thấy rằng tính cô lập có thể thay đổi.

Vì vậy, không nên cố gắng hiểu ngay ý nghĩa đen của từ viết tắt này. Thay vào đó, chúng ta sẽ xem xét logic hoạt động của các DBMS (và đặc biệt là giao dịch) và xem ACID từ góc độ “nó được tạo ra như thế nào” thay vì “nó có nghĩa là gì”.

Vì các khía cạnh của giao dịch khá phức tạp, chúng ta cần một biểu diễn trực quan - một loại biểu đồ - để minh họa hoạt động và sự tương tác của các giao dịch. Sử dụng các biểu đồ này, chúng ta có thể xây dựng một câu chuyện logic và đi sâu vào chi tiết cách giao dịch hoạt động.

Trước hết, chúng ta sẽ giới thiệu một dòng thời gian vì các giao dịch phát triển theo thời gian. Dòng thời gian sẽ được đánh dấu theo cách chúng ta cần - chúng ta không cần giây hay phút, mà cần các bước chính trong sự tương tác giữa các giao dịch:

Sau đó, chúng ta sẽ thêm một giao dịch vào dòng thời gian này - hãy vẽ nó dưới dạng một hình chữ nhật có các cạnh tương ứng với thời điểm bắt đầu và kết thúc của giao dịch. Vì tất cả các giao dịch trong Firebird đều được đánh số, chúng ta cũng sẽ chỉ định số của giao dịch.

Như vậy, biểu đồ cho thấy giao dịch số 11 bắt đầu tại thời điểm t3 và kết thúc tại thời điểm t10. Có hai cách để một giao dịch kết thúc - COMMIT, tức là áp dụng tất cả các thay đổi được thực hiện trong giao dịch, và ROLLBACK, tức là hủy bỏ tất cả các thay đổi được thực hiện trong giao dịch. Chúng ta sẽ thể hiện cách giao dịch kết thúc như sau:

Để tiếp tục, chúng ta sẽ phải chỉ định các tham số khác nhau của giao dịch trên các biểu đồ này và chúng ta sẽ chỉ định chúng ở góc dưới bên trái của hình chữ nhật đại diện cho giao dịch tương ứng - ví dụ này cho thấy giao dịch #11 có mức cô lập snapshot.

Khi chúng ta nói “giao dịch X chèn dữ liệu” hoặc “giao dịch Y đọc dữ liệu này và dữ liệu kia” - điều đó không chính xác về mặt hình thức, vì chúng ta nên nói “các thay đổi đã được thực hiện trong giao dịch X”. Chỉ các câu lệnh SQL mới có thể đọc hoặc chèn dữ liệu, vì vậy nếu điều này quan trọng cho câu chuyện, chúng ta sẽ hiển thị các câu lệnh này bên trong hình chữ nhật giao dịch:

Trong ví dụ này, chúng ta có thao tác INSERT vào bảng T1, trường i1, giá trị 100 - thao tác này được thực hiện trong giao dịch #11 và đã được cam kết.

Ngoài ra, đôi khi chúng ta sẽ phải hiển thị kết quả của một thao tác, ví dụ như trong ví dụ sau:

Ví dụ này cho thấy những điều sau:

  1. Giao dịch #11 với tham số mức cô lập được đặt là snapshot (các mức cô lập sẽ được thảo luận sau, ở đây nó chỉ được hiển thị để vẽ bức tranh hoàn chỉnh) được bắt đầu tại thời điểm t3
  2. Thao tác INSERT INTO T1(i1) values (100) chèn giá trị 100 vào trường i1 của bảng t1 bắt đầu tại thời điểm t5 VÀ kết thúc tại thời điểm t7
  3. Thao tác SELECT i1 from T1 trả về giá trị i1 bằng 100 bắt đầu tại thời điểm t8
  4. Giao dịch #11 kết thúc bằng câu lệnh COMMIT, tức là các thay đổi do giao dịch #11 thực hiện được cam kết vào cơ sở dữ liệu

Như vậy, với sự trợ giúp của các biểu đồ giao dịch, chúng ta có thể mô tả chi tiết những gì diễn ra trong cơ sở dữ liệu và hiểu được cách giao dịch hoạt động.

Bây giờ khi chúng ta đã có các biểu đồ giao dịch, hãy xem từ viết tắt ACID thực sự có nghĩa là gì.

Tính nguyên tử (Atomicity)

Tính nguyên tử có nghĩa là tất cả các thao tác tạo nên một giao dịch đều được thực hiện hoặc không thao tác nào được thực hiện: “tất cả hoặc không gì cả”. Nghe có vẻ đơn giản, nhưng sau đó các chi tiết mới dần lộ ra.

Đầu tiên, DBMS (không chỉ Firebird mà gần như tất cả) có 2 loại tính nguyên tử: tính nguyên tử ở cấp độ câu lệnh và tính nguyên tử ở cấp độ nhóm câu lệnh trong một giao dịch.

Tính nguyên tử ở cấp độ câu lệnh có nghĩa là câu lệnh UPDATET1 SETX=1 WHEREY=2 luôn được thực hiện thành công hoặc không được thực hiện.

Tính nguyên tử ở cấp độ nhóm câu lệnh hoạt động khác (chúng ta sẽ sử dụng mã giả ở đây để đánh dấu thời điểm giao dịch được bắt đầu và cam kết):

Code
Start transaction 11
INSERT ..100
INSERT ..200
INSERT ..300
Commit 11

Đây là những gì nó sẽ trông gần đúng trên biểu đồ:

Điều đó có nghĩa là cả ba câu lệnh INSERT đều được thực hiện thành công và các thay đổi do chúng tạo ra được cam kết tại thời điểm giao dịch #11 được cam kết.

Câu hỏi mà tôi thường hỏi tại các buổi hội thảo khi nói về giao dịch - liệu câu lệnh COMMIT có được thực hiện thành công cho giao dịch 11 nếu INSERT INTO..300 gây ra một ngoại lệ không:

Một phần đáng kể khán giả luôn trả lời rằng câu lệnh COMMIT sẽ không được thực hiện thành công! (Điều thú vị là, trong một số DBMS khác, điều này sẽ khiến giao dịch bị rollback!)

Tuy nhiên, điều đó không đúng - chỉ cần chạy isql và thực hiện một thí nghiệm với bất kỳ cơ sở dữ liệu nào (isql có cách triển khai các thao tác đơn giản và trực tiếp, không “đoán” cho người dùng).

Vấn đề là tính nguyên tử ở cấp độ nhóm câu lệnh được đảm bảo bởi việc cam kết giao dịch là một vấn đề của logic nghiệp vụ. Lập trình viên của ứng dụng phải quyết định liệu một giao dịch có nên được cam kết trong trường hợp có ngoại lệ trong câu lệnh INSERT thứ ba hay không. Nếu logic nghiệp vụ cho phép cam kết kết quả, câu lệnh COMMIT có thể dễ dàng được thực hiện.

Như vậy, yêu cầu tính nguyên tử trong ACID là yêu cầu DBMS có thể cam kết hoặc rollback kết quả của một nhóm câu lệnh được thực hiện trong một giao dịch. Quyết định cam kết hay rollback phụ thuộc vào logic nghiệp vụ mà bạn cần triển khai.

Và chúng ta hãy nhấn mạnh lại một lần nữa - mặc dù tính nguyên tử của một giao dịch đối với một nhóm câu lệnh có nghĩa là khả năng cam kết hoặc rollback toàn bộ nhóm bất kể kết quả (và sự lựa chọn phụ thuộc vào logic nghiệp vụ), nhưng tính nguyên tử của một câu lệnh được đảm bảo bởi việc triển khai của DBMS, tức là không thể thực hiện một câu lệnh (ví dụ, UPDATE) “một cách không đầy đủ” (không nguyên tử).

Tính nhất quán (Consistency)

Tính nhất quán có nghĩa là dữ liệu bên trong cơ sở dữ liệu không có mâu thuẫn. Tất nhiên, ở đây chúng ta có thể thấy một phạm vi rộng để suy đoán vì “không có mâu thuẫn” nghĩa là gì?

Hai mức nhất quán thường được phân biệt:

  1. Mức cơ sở dữ liệu, nơi tính nhất quán có nghĩa là dữ liệu tương ứng với các ràng buộc của cơ sở dữ liệu, chẳng hạn như khóa Primary, Unique và Foreign, các ràng buộc Check. Mức nhất quán này được đảm bảo bởi thực tế là các ràng buộc của cơ sở dữ liệu sẽ không cho phép chèn dữ liệu không tương ứng với các ràng buộc: ví dụ: CHECK(x>0) sẽ không cho phép một số âm được chèn vào trường tương ứng.
  2. Mức logic nghiệp vụ, nơi tính nhất quán được đảm bảo bởi lập trình viên của ứng dụng với sự trợ giúp của các công cụ do DBMS cung cấp, chẳng hạn như giao dịch.

Làm thế nào các giao dịch giúp đảm bảo tính nhất quán ở mức logic nghiệp vụ? Khá đơn giản - nếu chúng ta lấy ví dụ chuyển tiền, lập trình viên phải đảm bảo rằng tất cả các thay đổi được rollback trong trường hợp có ngoại lệ và việc sử dụng giao dịch giúp họ làm điều đó.

Code
Starttransaction
Giảm số tiền trong tài khoản 1…. Thành công
Tăng số tiền trong tài khoản 2… Thất bại
Rollback ---- trong trường hợp có ngoại lệ!

Nói cách khác, lập trình viên phải viết mã sao cho dữ liệu được rollback trong trường hợp có ngoại lệ và do đó tính nhất quán của dữ liệu được duy trì từ góc độ logic nghiệp vụ.

Theo cách này, yêu cầu tính nhất quán trong từ viết tắt ACID có nghĩa là DBMS cần có khả năng duy trì tính nhất quán của dữ liệu với sự trợ giúp của cơ chế giao dịch.

Tính cô lập (Isolation)

Yêu cầu về tính cô lập của giao dịch phát sinh từ sự cần thiết phải đảm bảo kết quả của một tập hợp các thao tác bất kể thứ tự chúng được thực hiện.

Nói một cách đơn giản, mỗi giao dịch phải được thực hiện với cùng một kết quả bất kể các giao dịch khác có đang hoạt động đồng thời hay không.

Cơ chế giao dịch được cho là để đảm bảo tính nhất quán ở mức logic nghiệp vụ, nhưng nó cũng được cho là để bảo vệ các giao dịch khỏi dữ liệu tạm thời chưa được xác nhận có thể xuất hiện trong quá trình thực hiện các giao dịch đồng thời.

Trong thực tế, nó trông như thế này:

Chúng ta thấy giao dịch #11 bắt đầu tại thời điểm t2, trong đó một thao tác chèn được thực hiện vào bảng tại thời điểm t3-t5. Giao dịch #11 không được cam kết ngay sau khi chèn mà tiếp tục hoạt động cho đến thời điểm t8.

Đồng thời, giao dịch #12 được bắt đầu và nó thực hiện câu lệnh SELECT cho các bản ghi trong bảng mà giao dịch #11 đã chèn vào. Câu lệnh SELECT đầu tiên được thực hiện tại thời điểm t6, thời điểm thao tác chèn đã kết thúc, nhưng câu lệnh này trả về kết quả rỗng vì giao dịch #12 không thể thấy dữ liệu chưa được cam kết từ các giao dịch khác.

Giao dịch #11 được cam kết tại thời điểm t8 và câu lệnh SELECT trong giao dịch #12 được thực hiện tại thời điểm t9. Nó trả về kết quả bằng 100 vì dữ liệu được tạo trong giao dịch #11 giờ đã được cam kết (và vì mức cô lập của giao dịch #12 là read committed, nhưng chúng ta sẽ nói về điều đó sau).

Ví dụ này là khá đủ để minh họa yêu cầu về tính cô lập - không giống như tính nguyên tử và tính nhất quán, tính cô lập được triển khai dưới dạng các quy tắc nghiêm ngặt được gọi là các mức cô lập và mỗi giao dịch phải có một tham số thiết lập mức cô lập mà nó hoạt động.

Độ bền

Khái niệm độ bền cho phép nhà phát triển hoàn toàn tin tưởng vào thực tế rằng dữ liệu được tạo ra trong một giao dịch đã cam kết sẽ ngay lập tức xuất hiện trong cơ sở dữ liệu và sẽ không biến mất khỏi nó (tất nhiên, trừ khi có các câu lệnh rõ ràng xóa hoặc thay đổi nó) bất kể điều gì xảy ra tiếp theo.

Như bạn có thể thấy, yêu cầu về độ bền chỉ là lẽ thường tình - khó có ai đồng ý sử dụng một hệ thống mà dữ liệu của nó có thể biến mất đột ngột.

ACID: tóm tắt

ACID có nghĩa là các yêu cầu về cách các giao dịch phải hoạt động:

  • Tính nguyên tử
    • Các câu lệnh luôn mang tính nguyên tử
    • Các nhóm câu lệnh có thể được làm cho mang tính nguyên tử với sự trợ giúp của các giao dịch
  • Tính nhất quán
    • Hai mức nhất quán: ràng buộc cơ sở dữ liệu và logic kinh doanh
  • Tính cô lập
    • Được đảm bảo bởi cơ chế giao dịch với sự trợ giúp của các mức cô lập được thiết lập cho chúng
  • Độ bền
    • Tất cả dữ liệu đã cam kết trở thành vĩnh viễn

Như bạn thấy, mọi thứ khá logic. Trong thực tế, khó khăn chính nằm ở các mức cô lập, vì vậy chúng ta hãy xem chi tiết cách chúng hoạt động.

Mức cô lập của một giao dịch xác định dữ liệu đã cam kết nào mà giao dịch này có thể nhìn thấy.

Có các mức cô lập thường được gọi là tiêu chuẩn. Chúng được mô tả trong tiêu chuẩn ANSI SQL (các bản sửa đổi khác nhau). Theo như tôi biết, không có một DBMS nào triển khai chúng chính xác theo cách được mô tả trong tiêu chuẩn, nhưng không ai lo lắng về điều đó vì các cơ chế giao dịch thực tế trong các DBMS cụ thể có tất cả các tùy chọn cần thiết để triển khai logic kinh doanh.

Bạn có thể tìm thấy định nghĩa cổ điển về các mức cô lập trong “A Critique of ANSI SQL Isolation Levels

Đối với những người đã đọc bài viết này, đây là bảng so sánh các mức cô lập cổ điển với các mức tương tự trong Firebird. Tất nhiên, sự tương ứng không trực tiếp vì các mức cô lập trong Firebird, cũng như trong các DBMS khác, không tuân thủ 100% các định nghĩa ANSI SQL, nhưng chúng rất giống với chúng.

Các mức cô lập ANSI Mức cô lập trong Firebird
Read Uncommitted n/a
Read Committed Read Committed
Repeatable Read Snapshot
Serializable Snapshot table stability

Giống như bất kỳ DBMS nào khác, Firebird có những đặc thù riêng trong việc triển khai tính cô lập. Bây giờ chúng ta sẽ tập trung vào cách các mức cô lập hoạt động trong Firebird, thay vì mức độ tuân thủ của chúng với tiêu chuẩn.

Mức cô lập Snapshot

Mức cô lập Snapshot là mức đầu tiên trong mã gốc của InterBase và vẫn là mức mặc định cho API và các tiện ích lõi của Firebird (ví dụ: isql.exe). Đây có thể là lý do tại sao nó là mức dễ hiểu nhất.

Snapshot cô lập giao dịch khỏi mọi thay đổi được thực hiện từ thời điểm nó bắt đầu.

Chúng ta hãy xem biểu đồ giao dịch bên dưới: nó cho thấy giao dịch #10 bắt đầu với mức cô lập snapshot. Trong giao dịch này, một số câu lệnh SELECT được thực hiện trên bảng T1 không có bản ghi nào trong ví dụ này.

Giao dịch đồng thời #15 bắt đầu sau khi giao dịch #10 bắt đầu chèn dữ liệu vào bảng T1 và giao dịch này kết thúc bằng câu lệnh COMMIT tại thời điểm t9, tức là dữ liệu được cam kết vào cơ sở dữ liệu tại thời điểm này và có sẵn cho các câu lệnh từ các giao dịch khác.

Tuy nhiên, câu lệnh trong giao dịch #10 được thực thi tại thời điểm t10 (tức là sau khi giao dịch #15 được cam kết) không thấy dữ liệu được chèn vì mức cô lập snapshot cho phép nó chỉ thấy dữ liệu đã cam kết được chèn hoặc thay đổi TRƯỚC KHI BẮT ĐẦU giao dịch #10.

Do đó, mức cô lập Snapshot cho phép bạn làm việc với cơ sở dữ liệu như thể nó bị đóng băng tại thời điểm giao dịch bắt đầu. Nó thường cần thiết để xây dựng các báo cáo phức tạp dựa trên dữ liệu thay đổi nhanh chóng: snapshot được sử dụng để tránh tình huống phần đầu của báo cáo dựa trên một số dữ liệu và phần cuối dựa trên dữ liệu khác.

Tuy nhiên, tính năng tuyệt vời này có cái giá của nó - khi chúng ta xem xét cách tính cô lập được triển khai trong Firebird sau này, bạn sẽ thấy rằng việc bắt đầu các giao dịch rất dài với mức cô lập snapshot dẫn đến quá nhiều phiên bản bản ghi và hiệu suất thấp hơn.

Mức cô lập Read Committed

Một giao dịch với mức cô lập read committed có thể thấy dữ liệu đã cam kết của các giao dịch khác được cam kết trong khi nó đang hoạt động (không giống như trường hợp với mức snapshot, khi bạn chỉ có thể thấy dữ liệu được cam kết trước thời điểm giao dịch bắt đầu).

Chúng ta hãy minh họa cách mức cô lập read committed hoạt động bằng biểu đồ sau:

Nó cho thấy một ví dụ gần như giống hệt với ví dụ trước: hai giao dịch đồng thời, một trong số đó thường xuyên đọc dữ liệu từ bảng T1 trong khi giao dịch thứ hai chèn và cam kết dữ liệu.

Không giống như trường hợp với mức cô lập snapshot, giao dịch #10 trong ví dụ này thấy dữ liệu được chèn và cam kết bởi giao dịch #15.

Ví dụ này cho chúng ta ý tưởng về tác động của mức cô lập read committed: các câu lệnh trong một giao dịch với mức cô lập này có thể thấy dữ liệu được cam kết trước thời điểm câu lệnh tương ứng được thực thi.

Biểu đồ tiếp theo cho thấy một ví dụ trong đó hai giao dịch đồng thời #11 và #18 thay đổi dữ liệu.

Lưu ý rằng giao dịch #11 bắt đầu trước khi giao dịch #14 bắt đầu đọc dữ liệu trong khi giao dịch #18 bắt đầu sau nó, nhưng điều này không ảnh hưởng đến kết quả: nếu dữ liệu được cam kết, nó có thể được nhìn thấy bởi giao dịch đồng thời với mức cô lập read committed.

Khả năng này làm cho mức cô lập read committed trở thành lựa chọn tự nhiên cho các câu lệnh SQL thường xuyên được thực thi để hiển thị trạng thái mới nhất của cơ sở dữ liệu (ví dụ: để hiển thị các đơn hàng mới nhất).

Phần dành cho việc dọn dẹp rác sẽ cho thấy rằng các giao dịch read committed với bổ ngữ read-only trong Firebird đến phiên bản 4 là lựa chọn tốt nhất cho các giao dịch đọc “vô hạn” vì chúng được bắt đầu dưới dạng pre-committed.

Mức cô lập Snapshot table stability

Có thể làm cho câu chuyện về mức cô lập snapshot table stability, vốn là đối tác của mức Serializable tiêu chuẩn, trở nên rất ngắn hoặc khá dài và chi tiết.

Phiên bản ngắn của câu chuyện như sau: mức này hoàn toàn giống với mức snapshot nhưng có thêm khóa bảng (bảng phải được chỉ định rõ ràng trong các tham số giao dịch) cho việc ghi và đọc. Điều đó có nghĩa là có thể bắt đầu một giao dịch sẽ chiếm hoàn toàn bảng được chỉ định và bất kỳ giao dịch nào khác sẽ nhận được lỗi truy cập.

Nói cách khác, một giao dịch với mức cô lập snapshot table stability sẽ thực sự đưa tất cả các truy vấn đến bảng được chỉ định vào hàng đợi. Trên thực tế, chỉ các thao tác đọc trong các giao dịch thông thường được thực hiện ngoài lượt của chúng (như thường lệ) trong khi tất cả các chế độ khác sẽ tạo thành một hàng đợi (tất nhiên, điều này phụ thuộc vào sự tương tác).

Nếu được triển khai mà không cẩn thận, nó có thể gây ra khóa và không thể làm việc với cơ sở dữ liệu, đó là lý do tại sao các nhà phát triển ứng dụng cơ sở dữ liệu Firebird có thể sợ sử dụng mức cô lập này.

Tuy nhiên, nếu được triển khai đúng cách, mức cô lập Serializable cho phép dễ dàng tạo thành các hàng đợi và thực hiện các thay đổi tuần tự trong các bản ghi cơ sở dữ liệu, điều này có thể rất hữu ích để triển khai bộ đếm, số tài liệu tuần tự và các đối tượng tương tự khác.

Để mô tả chính xác cách tạo thành một hàng đợi với sự trợ giúp của một giao dịch với mức cô lập snapshot table stability, chúng ta sẽ phải xem xét thêm một tham số giao dịch nữa: wait/nowait - và sau đó quay lại ví dụ về hàng đợi.

Trước đó, chúng ta đã xem xét một cách tương tác giữa các giao dịch trong đó dữ liệu được thay đổi trong một giao dịch và được đọc trong một giao dịch khác.

Tuy nhiên, trong thực tế thường xảy ra trường hợp các giao dịch khác nhau cố gắng thay đổi cùng một dữ liệu và vì chỉ có một kết quả được lưu trong cơ sở dữ liệu, giao dịch đồng thời sẽ nhận được thông báo xung đột - trên thực tế, một ngoại lệ sẽ làm gián đoạn (và hủy bỏ) việc thực thi câu lệnh cụ thể đang cố gắng thay đổi dữ liệu đã được thay đổi.

Tùy chọn wait xác định cách một giao dịch nên phản ứng với xung đột cập nhật. Có ba cách để cấu hình tùy chọn này:

  1. Wait (không có tham số) = chờ cho đến khi giao dịch đồng thời kết thúc
  2. Wait Timeout N giây = chờ cho đến khi giao dịch đồng thời kết thúc, nhưng không quá N giây
  3. Nowait - không chờ cho đến khi giao dịch đồng thời kết thúc

Lưu ý rằng tùy chọn wait được chỉ định bằng mã giả ở đây trong khi tên có thể khác nhau trong API và trong các thành phần cụ thể mặc dù ý nghĩa vẫn giữ nguyên.

Chúng ta hãy xem chi tiết điều gì xảy ra trong trường hợp xung đột cập nhật với các biến thể khác nhau của tùy chọn wait.

Wait

Vì vậy, chúng ta hãy tưởng tượng hai giao dịch đang hoạt động đồng thời (#11 và #14) trong đó câu lệnh UPDATE được thực thi phải thay đổi cùng một bản ghi trong cùng một bảng T1.

Giao dịch #14 chạy với tùy chọn wait (nếu bạn sử dụng isql để tái tạo các ví dụ, wait được đặt theo mặc định).

Câu lệnh UPDATE trong giao dịch #11 bắt đầu tại thời điểm t3 và kết thúc tại thời điểm t5, nhưng giao dịch chưa được cam kết - tức là câu lệnh COMMIT chưa có cho đến thời điểm t6.

Biểu đồ dưới đây cho thấy tình huống này:

Câu lệnh UPDATE cũng được thực thi trong giao dịch #14 và nó cố gắng cập nhật cùng một bản ghi trong cùng một bảng, nhưng nó bắt đầu muộn hơn - khoảng tại thời điểm t4.

Vì có xung đột cập nhật với bản cập nhật từ giao dịch #11 và wait được chỉ định trong giao dịch #14, câu lệnh UPDATE sẽ chờ cho đến khi giao dịch xung đột #11 kết thúc.

Nếu giao dịch #11 kéo dài đủ lâu, câu lệnh UPDATE trong giao dịch #14 sẽ có vẻ như bị đóng băng từ quan điểm của người dùng đang theo dõi việc thực thi câu lệnh này.

Nếu bạn tái tạo tình huống này với sự trợ giúp của hai isql.exe, hình ảnh tiếp theo cho thấy thời điểm giao dịch thứ hai (chính xác hơn là giao dịch trong đó câu lệnh UPDATE đồng thời bắt đầu muộn hơn - đó là giao dịch #14 trong ví dụ của chúng ta) chờ cho đến khi giao dịch đầu tiên kết thúc (đó là giao dịch #11 trong ví dụ của chúng ta).

Sau khi câu lệnh COMMIT được thực thi trong giao dịch #11, giao dịch #14 đang chờ nó sẽ được thông báo ngay lập tức và bản cập nhật xung đột sẽ kết thúc bằng một ngoại lệ.

Dưới đây bạn có thể thấy một ví dụ về thông báo lỗi như vậy (số của giao dịch đồng thời không trùng với ví dụ của chúng ta vì số giao dịch bắt đầu từ đầu trong mỗi cơ sở dữ liệu và sau đó chỉ tăng lên trong khi được đặt lại chỉ sau khi sao lưu/khôi phục):

Code
SQL> update T1 set i1 = 2 where i1=1;
Statement failed, SQLSTATE = 40001
deadlock
-update conflicts with concurrent update
-concurrent transaction number is 19
SQL>

Hãy chú ý đến từ “deadlock” trong thông báo lỗi - hiện tại thực sự không có deadlock theo định nghĩa cổ điển của nó. Thay vào đó, có một xung đột cập nhật, nhưng các nhà phát triển Firebird không thay đổi thông báo lỗi vì nó đã được sử dụng hơn 35 năm. Chúng ta sẽ xử lý deadlock “cổ điển” thực sự sau.

Vì vậy, chúng ta đã xem xét tình huống khi giao dịch với câu lệnh UPDATE đồng thời kết thúc bằng câu lệnh COMMIT. Bây giờ chúng ta hãy xem một tình huống tương tự, nhưng khi nó bị rollback - bạn có thể thấy nó trên biểu đồ dưới đây:

Tình huống hoàn toàn tương tự với tình huống trước - hai câu lệnh UPDATE cố gắng cập nhật cùng một bản ghi, nhưng giao dịch đồng thời #20 lần này kết thúc bằng việc bị rollback và kết quả là các thay đổi trong giao dịch #15 được lưu trong cơ sở dữ liệu mà không có lỗi.

Do đó, tùy chọn wait giúp tổ chức logic nghiệp vụ của các thao tác cập nhật theo cách mà các cập nhật xung đột sẽ chờ vô thời hạn trong hàng đợi, hy vọng đến giây phút cuối cùng rằng giao dịch xung đột với chúng sẽ kết thúc bằng câu lệnh ROLLBACK.

Chiến thuật này có phải lúc nào cũng hợp lý không? Tất nhiên, điều đó phụ thuộc vào cách triển khai logic nghiệp vụ, nhưng Firebird cũng cung cấp các tùy chọn khác để giải quyết xung đột cập nhật với sự trợ giúp của tùy chọn wait.

Wait với thời gian chờ (timeout)

Trước hết, có thể là một ý tưởng hay để giới hạn thời gian chờ - thay vì chờ vô thời hạn trong trường hợp xung đột, bạn có thể giới hạn thời gian chờ bằng cách chỉ định một khoảng thời gian chờ (timeout) cho tùy chọn wait.

Trong isql.exe, tham số như vậy được chỉ định bằng câu lệnh sau:

Code
SET TRANSACTION WAIT LOCK TIMEOUT N;

Trong đó N là thời gian (tính bằng giây) mà giao dịch đồng thời sẽ chờ để xung đột được giải quyết.

Bạn có thể tìm thấy thêm chi tiết về các câu lệnh điều khiển giao dịch trong Firebird Language Reference. Lưu ý rằng có thể có nhiều cách khác nhau để chỉ định thời gian chờ trong các trình điều khiển hoặc thành phần truy cập cụ thể (thường là với sự trợ giúp của tham số API).

Bạn có thể xem một ví dụ trong isql trong hình dưới đây:

Chúng ta hãy nghiên cứu với sự trợ giúp của các biểu đồ giao dịch cách các giao dịch tương tác với nhau nếu bạn chỉ định thời gian chờ cho tùy chọn wait.

Vì vậy, tình huống vẫn như cũ - hai giao dịch đồng thời #11 và #14 trong đó câu lệnh UPDATE cố gắng cập nhật cùng một bản ghi trong bảng T1 được thực thi.

Tuy nhiên, trong trường hợp này, câu lệnh trong giao dịch #14 chờ cho đến khi giao dịch #11 kết thúc hoặc cho đến khi thời gian chờ được chỉ định (3 giây) hết hạn - tùy điều kiện nào đến trước.

Trong ví dụ này, thời gian chờ hết hạn sớm hơn, câu lệnh kết thúc với một ngoại lệ:

Code
SQL> update T1 set i1=6 where i1=1;
Statement failed, SQLSTATE = 40001
lock time-out on wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 40

Lưu ý rằng “deadlock” lại xuất hiện trong thông báo lỗi, nhưng nó vẫn không phải là một “deadlock” thực sự.

Tương ứng, tình huống này tương tự như với tùy chọn wait, nhưng nó bị giới hạn bởi thời gian chờ - nếu thời gian chờ được chỉ định hết hạn sớm hơn so với thời điểm giao dịch đồng thời kết thúc.

Việc chỉ định tùy chọn wait với thời gian chờ có thể là một giải pháp tốt để triển khai logic nghiệp vụ nếu bạn biết chắc chắn rằng tất cả các giao dịch ghi khá ngắn (ví dụ: không dài hơn 1-2 giây).

Nowait

Rất dễ dàng để giải thích Nowait là gì từ quan điểm chính thức - nó là wait với thời gian chờ bằng không. Nếu bạn chỉ định nowait trong các giao dịch, các cập nhật xung đột sẽ đưa ra một ngoại lệ ngay lập tức.

Trong trường hợp này, chúng ta lại có các giao dịch đồng thời #11 và #14 (nowait) trong đó các câu lệnh UPDATE đồng thời được thực thi. Câu lệnh trong giao dịch với tùy chọn nowait không chờ khi nó thấy một cập nhật đồng thời, mà đưa ra ngoại lệ sau ngay lập tức tại thời điểm cập nhật của nó (chỉ khác số giao dịch):

Code
SQL> update T1 set i1=5 where i1=1;
Statement failed, SQLSTATE = 40001
lock conflict on no wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 50
SQL>

Đây là những gì nó trông như thế nào trong một ví dụ với hai công cụ isql:

Lưu ý rằng giao dịch nowait không quan tâm khi nào và bằng cách nào giao dịch với câu lệnh UPDATE đồng thời kết thúc - cho dù đó là câu lệnh COMMIT hay ROLLBACK, ngoại lệ vẫn được đưa ra.

Từ quan điểm của logic nghiệp vụ, giao dịch nowait có thể thuận tiện nếu bạn biết chắc chắn rằng cập nhật đồng thời phải dẫn đến việc chắc chắn hủy bỏ các hành động của câu lệnh hiện tại.

Nhiều trình điều khiển Firebird sử dụng tùy chọn nowait làm giá trị mặc định và, miễn là nhiều nhà phát triển không biết rằng có thể đặt mức độ ít nghiêm ngặt hơn để giải quyết xung đột cập nhật (ví dụ: wait lock timeout 1), các ứng dụng của họ (và đôi khi cả người dùng) phải chịu đựng các lỗi không cần thiết do xung đột.

Vì từ khóa “deadlock” xuất hiện trong mỗi ngoại lệ liên quan đến xung đột cập nhật, nhiều nhà phát triển ứng dụng chắc chắn rằng đây chính là một deadlock thực sự (một số thậm chí nghĩ rằng một Dead rất cụ thể nào đó có vai trò trong lỗi này).

Đồng thời, nếu chúng ta xem xét tệp cấu hình firebird.conf, chúng ta sẽ thấy tham số DeadlockTimeout ở đó (mặc định là 10 giây), và nếu chúng ta nhìn vào tiêu đề đầu ra của tiện ích fb_lock_print, chúng ta cũng sẽ thấy tham số “Deadlock scans”.

Vấn đề là một “deadlock thực sự” có thể xảy ra trong Firebird và từ khóa “deadlock” xuất hiện trong tất cả các ngoại lệ liên quan đến xung đột cập nhật không có mối liên hệ trực tiếp với nó. May mắn thay, deadlock thực sự xảy ra khá hiếm.

Chúng ta hãy xem “deadlock thực sự” này là gì. Để làm điều đó, hãy nhìn vào biểu đồ tương tác giao dịch sau:

Chúng ta có hai giao dịch đồng thời với tùy chọn wait trong đó câu lệnh UPDATE được thực thi. Không giống như trong trường hợp xung đột cập nhật đơn giản, ở đây chúng ta có thể thấy một xung đột cập nhật phụ thuộc lẫn nhau:

  • Giao dịch #11 cập nhật bản ghi có khóa = 20, và giao dịch #12 cập nhật bản ghi có khóa = 10;
  • Sau đó, giao dịch #11 cập nhật bản ghi có khóa = 10, và giao dịch #12 cập nhật bản ghi có khóa = 20;

Kết quả là, chúng ta có một tình huống khi mỗi giao dịch phải chờ giao dịch kia kết thúc và cả hai có thể chờ vô thời hạn vì cả hai đều được chỉ định tùy chọn wait. Tất nhiên, máy chủ không thể cho phép điều đó xảy ra, vì vậy một trong các giao dịch sẽ bị buộc phải rollback sau thời gian chờ được chỉ định trong tham số DeadlockTimeout được đặt ở giá trị mặc định là 10 giây.

Chúng ta có thể tái tạo tình huống này với sự trợ giúp của hai isql:

Sau khi giao dịch thứ hai được bắt đầu, một tình huống deadlock thực sự xảy ra. Để phát hiện ra điều đó một cách chắc chắn, máy chủ bắt đầu một thủ tục gọi là Deadlock scan - nó được bắt đầu ở các khoảng thời gian bằng DeadlockTimeout, mặc định là 10 giây.

Lưu ý rằng máy khách (isql trong trường hợp này) nhận được một thông báo xung đột cập nhật thông thường, nhưng nó được khởi tạo sau 10 giây ngay cả khi giao dịch được bắt đầu với tùy chọn wait.

Sau khi máy chủ phát hiện khóa phụ thuộc lẫn nhau của hai giao dịch, nó cũng sẽ tăng bộ đếm deadlock nội bộ (bạn có thể thấy nó trong đầu ra của fb_lock_print).

Sử dụng thực tế của Snapshot Table Stability

Bây giờ chúng ta đã biết cách các giao dịch hoạt động với các câu lệnh UPDATE xung đột, chúng ta có thể quay lại mức cô lập Snapshot Table Stability và tìm một ứng dụng thực tế cho nó.

Vì vậy, khi mức cô lập này được chỉ định, bảng sẽ bị khóa để ghi và thậm chí để đọc.

Lưu ý rằng nếu bảng không được chỉ định rõ ràng trong các tham số giao dịch, tất cả các bảng mà các câu lệnh truy cập trong giao dịch này sẽ bị khóa và điều này xảy ra trong lần truy cập đầu tiên vào một bảng. Rõ ràng, nếu mức cô lập này được sử dụng một cách thiếu thận trọng, nó sẽ dễ dàng dẫn đến một số lượng lớn các xung đột cập nhật.

Mệnh đề Reserving TableNN cho phép bạn chỉ định một bảng cụ thể (hoặc một số bảng) sẽ bị khóa khi bắt đầu giao dịch (cũng có thể chỉ định chế độ reserving).

Tính năng tuyệt vời này cùng với tùy chọn wait cho phép bạn triển khai một hàng đợi tuần tự rất hiệu quả để thay đổi một bảng cụ thể.

Trong thực tế, nó trông như thế này - những máy khách cần tạo một hàng đợi đến một bảng cụ thể bắt đầu giao dịch SNAPSHOT TABLE STABILITY chỉ định bảng này và sau đó chúng cố gắng thực hiện một thao tác trong giao dịch này và kết thúc nó ngay lập tức.

Ví dụ: chúng ta muốn tạo một bộ đếm tăng dần tuần tự trong một bảng chỉ có một bản ghi thuộc loại CREATE TABLE Table1(i1 integer not null), nhưng vì lý do nào đó chúng ta không thể sử dụng một generator.

Mã giả trông gần giống như thế này:

Code
set transaction snapshot table stability reserving TABLE1 for protected write
UPDATE Table1 Set i1 = i1+1;
SELECT i1 from Table1;
COMMIT;

Nếu chúng ta chạy mã này không phải với mức cô lập Snapshot Table Stability (Table1), mà với mức cô lập thấp hơn, thì có thể một câu lệnh UPDATE đồng thời sẽ can thiệp giữa thời điểm bắt đầu giao dịch và trước câu lệnh UPDATE của nó. Kết quả là, chúng ta sẽ nhận được một ngoại lệ cập nhật ngay lập tức (nowait) hoặc câu lệnh sẽ đóng băng cho đến khi giao dịch đồng thời kết thúc (wait) hoặc hết thời gian chờ (wait interval) - nói cách khác, xung đột sẽ được giải quyết bằng cách nào đó ở mức câu lệnh.

Với mức cô lập snapshot table stability, chúng ta được đảm bảo chống lại điều đó vì bảng được reserving khi bắt đầu giao dịch - nó hoàn toàn là của chúng ta hoặc hoàn toàn không phải của chúng ta. Nếu chúng ta chỉ định tùy chọn wait để giải quyết xung đột, các kết nối song song sẽ tự động tạo thành một hàng đợi mà không xử lý bất kỳ lỗi nào.

Tất nhiên, cách tiếp cận này chỉ có thể được áp dụng cho các giao dịch ngắn (như trong ví dụ của chúng ta).

Trong thực tế, mức cô lập Snapshot table stability được sử dụng để tạo hàng đợi và tính toán lại logic phức tạp ở chế độ độc quyền (trong các bảng tương đối nhỏ hoặc khi không có người dùng khác).

Bên trong engine, Firebird sử dụng mức cô lập Snapshot Table Stability để tạo chỉ mục - tức là khi bạn thực thi câu lệnh ALTER INDEX indexname ACTIVE;, Firebird sẽ chiếm hoàn toàn bảng mà chỉ mục đang được xây dựng cho nó.

Tiếp theo là gì?

Bài viết này chỉ giới thiệu các khái niệm về giao dịch Firebird. Để hiểu hoàn toàn cách các giao dịch hoạt động trong Firebird, cần phải xem xét kiến trúc đa thế hệ (các khái niệm về phiên bản bản ghi và thu gom rác), xem xét các điểm đánh dấu giao dịch (Oldest Interesting, Oldest Active, Oldest Snapshot, next) và những thứ khác.

Bài viết dựa trên tài liệu của hội thảo/tọa đàm “All About Transactions”, được giới thiệu lần đầu tiên vào năm 2013 trong các buổi hội thảo Firebird Tour, và dựa trên khóa đào tạo của IBSurgeon " Firebird Transaction in details".

Liên hệ

[email protected] Vui lòng liên hệ với chúng tôi nếu có bất kỳ câu hỏi hoặc đề xuất nào: [email protected]