5.1 Vấn đề đồng thời

Hãy bắt đầu với một lý thuyết xa vời.

Bất kỳ hệ thống thông tin nào (hay đơn giản là một ứng dụng) mà các lập trình viên tạo ra đều bao gồm một số khối điển hình, mỗi khối cung cấp một phần chức năng cần thiết. Ví dụ: bộ đệm được sử dụng để ghi nhớ kết quả của hoạt động sử dụng nhiều tài nguyên nhằm đảm bảo máy khách đọc dữ liệu nhanh hơn, các công cụ xử lý luồng cho phép bạn gửi tin nhắn đến các thành phần khác để xử lý không đồng bộ và các công cụ xử lý hàng loạt được sử dụng để " cào" khối lượng dữ liệu tích lũy với một số định kỳ. .

Và trong hầu hết mọi ứng dụng, cơ sở dữ liệu (DB) đều liên quan theo cách này hay cách khác, thường thực hiện hai chức năng: lưu trữ dữ liệu khi nhận được từ bạn và sau đó cung cấp chúng cho bạn theo yêu cầu. Hiếm ai nghĩ đến việc tạo cơ sở dữ liệu của riêng mình, vì đã có nhiều giải pháp làm sẵn. Nhưng làm thế nào để bạn chọn đúng cho ứng dụng của mình?

Vì vậy, hãy tưởng tượng rằng bạn đã viết một ứng dụng có giao diện di động cho phép bạn tải danh sách các nhiệm vụ đã lưu trước đó quanh nhà - nghĩa là đọc từ cơ sở dữ liệu và bổ sung cho nó các nhiệm vụ mới, cũng như ưu tiên từng nhiệm vụ cụ thể. nhiệm vụ - từ 1 (cao nhất) đến 3 (thấp nhất). Giả sử ứng dụng dành cho thiết bị di động của bạn chỉ được sử dụng bởi một người tại một thời điểm. Nhưng bây giờ bạn đã dám nói với mẹ về sáng tạo của mình và bây giờ mẹ đã trở thành người dùng thường xuyên thứ hai. Điều gì sẽ xảy ra nếu bạn quyết định đồng thời, ngay trong cùng một phần nghìn giây, đặt một số tác vụ - "rửa cửa sổ" - thành một mức độ ưu tiên khác?

Theo thuật ngữ chuyên môn, có thể coi truy vấn cơ sở dữ liệu của bạn và mẹ là 2 quá trình thực hiện một truy vấn đến cơ sở dữ liệu. Tiến trình là một thực thể trong chương trình máy tính có thể chạy trên một hoặc nhiều luồng. Thông thường, một quy trình có hình ảnh mã máy, bộ nhớ, ngữ cảnh và các tài nguyên khác. Nói cách khác, quy trình có thể được mô tả như là việc thực hiện các lệnh của chương trình trên bộ xử lý. Khi ứng dụng của bạn đưa ra yêu cầu tới cơ sở dữ liệu, chúng ta đang nói về thực tế là cơ sở dữ liệu của bạn xử lý yêu cầu nhận được qua mạng từ một quy trình. Nếu có hai người dùng đang ngồi trong ứng dụng cùng một lúc, thì có thể có hai quy trình tại bất kỳ thời điểm cụ thể nào.

Khi một số quy trình đưa ra yêu cầu đối với cơ sở dữ liệu, nó sẽ tìm thấy nó ở một trạng thái nhất định. Một hệ thống trạng thái là một hệ thống ghi nhớ các sự kiện trước đó và lưu trữ một số thông tin, được gọi là "trạng thái". Một biến được khai báo là integercó thể có trạng thái 0, 1, 2 hoặc giả sử là 42. Mutex (loại trừ lẫn nhau) có hai trạng thái: bị khóa hoặc được mở khóa , giống như một semaphore nhị phân ("bắt buộc" so với "đã phát hành") và nói chung là nhị phân (nhị phân) kiểu dữ liệu và biến chỉ có thể có hai trạng thái - 1 hoặc 0.

Dựa trên khái niệm về trạng thái, một số cấu trúc toán học và kỹ thuật được dựa trên, chẳng hạn như máy tự động hữu hạn - một mô hình có một đầu vào và một đầu ra và ở một trong các tập hợp trạng thái hữu hạn tại mỗi thời điểm - và “trạng thái ” mẫu thiết kế, trong đó một đối tượng thay đổi hành vi tùy thuộc vào trạng thái bên trong (ví dụ: tùy thuộc vào giá trị được gán cho biến này hay biến khác).

Vì vậy, hầu hết các đối tượng trong thế giới máy đều có một số trạng thái có thể thay đổi theo thời gian: đường ống của chúng tôi, xử lý một gói dữ liệu lớn, gây ra lỗi và trở nên không thành công hoặc thuộc tính đối tượng Wallet lưu trữ số tiền còn lại trong tài khoản của người dùng tài khoản, những thay đổi sau khi biên nhận bảng lương.

Quá trình chuyển đổi (“chuyển đổi”) từ trạng thái này sang trạng thái khác—ví dụ: từ đang tiến hành sang thất bại —được gọi là một thao tác. Chắc hẳn ai cũng biết các thao tác CRUD - create, read, update, deletehoặc các phương thức HTTP tương tự - POST, GET, PUT, DELETE. Nhưng các lập trình viên thường đặt tên khác cho các thao tác trong mã của họ, vì thao tác này có thể phức tạp hơn việc chỉ đọc một giá trị nhất định từ cơ sở dữ liệu - nó cũng có thể kiểm tra dữ liệu và sau đó là thao tác của chúng ta, có dạng một hàm, sẽ được gọi, chẳng hạn, Và validate()ai thực hiện các thao tác-chức năng này? các quá trình đã được mô tả.

Thêm một chút nữa, và bạn sẽ hiểu tại sao tôi lại mô tả chi tiết các thuật ngữ như vậy!

Bất kỳ hoạt động nào - có thể là một chức năng hoặc trong các hệ thống phân tán, gửi yêu cầu đến một máy chủ khác - đều có 2 thuộc tính: thời gian gọithời gian hoàn thành (thời gian hoàn thành) , sẽ lớn hơn thời gian gọi (các nhà nghiên cứu từ Jepsen tiến hành từ các giả định lý thuyết rằng cả hai dấu thời gian này sẽ được cung cấp các đồng hồ tưởng tượng, được đồng bộ hóa hoàn toàn, có sẵn trên toàn cầu).

Hãy tưởng tượng ứng dụng danh sách việc cần làm của chúng ta. Bạn đưa ra yêu cầu tới cơ sở dữ liệu thông qua giao diện di động trong 14:00:00.014và mẹ của bạn trong 13:59:59.678(tức là 336 mili giây trước đó) đã cập nhật danh sách việc cần làm thông qua cùng một giao diện, thêm việc rửa bát đĩa vào đó. Có tính đến độ trễ mạng và hàng đợi tác vụ có thể có đối với cơ sở dữ liệu của bạn, nếu ngoài bạn và mẹ bạn, tất cả bạn bè của mẹ bạn cũng sử dụng ứng dụng của bạn, thì cơ sở dữ liệu có thể thực hiện yêu cầu của mẹ sau khi xử lý yêu cầu của bạn. Nói cách khác, có khả năng hai yêu cầu của bạn, cũng như các yêu cầu từ bạn gái của mẹ bạn, sẽ được gửi đến cùng một dữ liệu tại cùng một thời điểm (đồng thời).

Vì vậy, chúng tôi đã đi đến thuật ngữ quan trọng nhất trong lĩnh vực cơ sở dữ liệu và ứng dụng phân tán - đồng thời. Chính xác thì tính đồng thời của hai hoạt động có nghĩa là gì? Nếu một số thao tác T1 và một số thao tác T2 được đưa ra, thì:

  • T1 có thể được bắt đầu trước thời điểm bắt đầu thực hiện T2 và kết thúc giữa thời điểm bắt đầu và kết thúc của T2
  • T2 có thể được bắt đầu trước thời gian bắt đầu của T1 và kết thúc giữa thời điểm bắt đầu và kết thúc T1
  • T1 có thể được bắt đầu và kết thúc giữa thời điểm bắt đầu và kết thúc thực hiện T1
  • và bất kỳ kịch bản nào khác mà T1 và T2 có một số thời gian thực hiện chung

Rõ ràng là trong khuôn khổ bài giảng này, chúng ta chủ yếu nói về các truy vấn đi vào cơ sở dữ liệu và cách hệ thống quản lý cơ sở dữ liệu nhận biết các truy vấn này, nhưng thuật ngữ đồng thời rất quan trọng, ví dụ, trong ngữ cảnh của các hệ điều hành. Tôi sẽ không đi quá xa chủ đề của bài viết này, nhưng tôi nghĩ điều quan trọng cần đề cập là tính đồng thời mà chúng ta đang nói đến ở đây không liên quan đến vấn đề nan giải về đồng thời và đồng thời cũng như sự khác biệt của chúng, được thảo luận trong ngữ cảnh của hệ điều hành và hiệu năng cao. Song song là một cách để đạt được đồng thời trong một môi trường có nhiều lõi, bộ xử lý hoặc máy tính. Chúng ta đang nói về đồng thời theo nghĩa truy cập đồng thời các quy trình khác nhau vào dữ liệu chung.

Và trên thực tế, điều gì có thể sai, thuần túy về mặt lý thuyết?

Khi làm việc trên dữ liệu được chia sẻ, có thể xảy ra nhiều vấn đề liên quan đến đồng thời, còn được gọi là "điều kiện chủng tộc". Vấn đề đầu tiên xảy ra khi một quá trình nhận được dữ liệu mà lẽ ra nó không nên nhận: dữ liệu không đầy đủ, tạm thời, bị hủy hoặc nói cách khác là dữ liệu "không chính xác". Vấn đề thứ hai là khi quy trình nhận được dữ liệu cũ, tức là dữ liệu không tương ứng với trạng thái được lưu cuối cùng của cơ sở dữ liệu. Giả sử một số ứng dụng đã rút tiền từ tài khoản của người dùng với số dư bằng không, bởi vì cơ sở dữ liệu trả lại trạng thái tài khoản cho ứng dụng, không tính đến lần rút tiền cuối cùng từ tài khoản đó, điều này chỉ xảy ra vài mili giây trước. Tình hình là như vậy, phải không?

5.2 Giao dịch đến để cứu chúng tôi

Để giải quyết những vấn đề như vậy, khái niệm về giao dịch đã xuất hiện - một nhóm nhất định các hoạt động tuần tự (thay đổi trạng thái) với cơ sở dữ liệu, đây là một hoạt động đơn lẻ về mặt logic. Tôi sẽ đưa ra một ví dụ về ngân hàng một lần nữa - và không phải ngẫu nhiên, bởi vì khái niệm giao dịch dường như xuất hiện chính xác trong bối cảnh làm việc với tiền. Ví dụ kinh điển về giao dịch là chuyển tiền từ tài khoản ngân hàng này sang tài khoản ngân hàng khác: trước tiên bạn cần rút số tiền từ tài khoản nguồn, sau đó gửi vào tài khoản đích.

Để giao dịch này được thực hiện, ứng dụng sẽ cần thực hiện một số hành động trong cơ sở dữ liệu: kiểm tra số dư của người gửi, chặn số tiền trên tài khoản của người gửi, thêm số tiền vào tài khoản của người nhận và khấu trừ số tiền từ người gửi. Sẽ có một số yêu cầu cho một giao dịch như vậy. Ví dụ: ứng dụng không thể nhận thông tin lỗi thời hoặc không chính xác về số dư - ví dụ: nếu đồng thời một giao dịch song song kết thúc do lỗi giữa chừng và tiền không được ghi nợ từ tài khoản - và ứng dụng của chúng tôi đã nhận được thông tin rằng các khoản tiền đã bị xóa.

Để giải quyết vấn đề này, một thuộc tính của giao dịch như “cô lập” đã được sử dụng: giao dịch của chúng tôi được thực hiện như thể không có giao dịch nào khác được thực hiện tại cùng một thời điểm. Cơ sở dữ liệu của chúng tôi thực hiện các hoạt động đồng thời như thể nó đang thực hiện chúng lần lượt, tuần tự - trên thực tế, mức cô lập cao nhất được gọi là Strict Serializable . Vâng, cao nhất, có nghĩa là có nhiều cấp độ.

"Dừng lại," bạn nói. Giữ ngựa của bạn, thưa ông.

Hãy nhớ lại cách tôi đã mô tả rằng mỗi thao tác có thời gian gọi và thời gian thực hiện. Để thuận tiện, bạn có thể coi việc gọi và thực thi là 2 hành động. Sau đó, danh sách được sắp xếp của tất cả các lệnh gọi và hành động thực thi có thể được gọi là lịch sử của cơ sở dữ liệu. Sau đó, mức cô lập giao dịch là một tập hợp các lịch sử. Chúng tôi sử dụng các mức cô lập để xác định câu chuyện nào là "hay". Khi chúng ta nói rằng một câu chuyện "không thể nối tiếp" hoặc "không thể nối tiếp", chúng tôi muốn nói rằng câu chuyện đó không nằm trong tập hợp các câu chuyện có thể nối tiếp.

Để làm rõ chúng ta đang nói về thể loại truyện nào, tôi sẽ đưa ra các ví dụ. Ví dụ, có một loại lịch sử - đọc trung gian . Nó xảy ra khi giao dịch A được phép đọc dữ liệu từ một hàng đã được sửa đổi bởi một giao dịch B đang chạy khác và chưa được cam kết ("không cam kết") - trên thực tế, những thay đổi cuối cùng vẫn chưa được cam kết bởi giao dịch B và nó có thể hủy bỏ chúng bất cứ lúc nào. Và, ví dụ, đọc bị hủy chỉ là ví dụ của chúng tôi với giao dịch rút tiền bị hủy

Có một số bất thường có thể xảy ra. Nghĩa là, sự bất thường là một số loại trạng thái dữ liệu không mong muốn có thể xảy ra trong quá trình truy cập cạnh tranh vào cơ sở dữ liệu. Và để tránh một số trạng thái không mong muốn nhất định, cơ sở dữ liệu sử dụng các mức cô lập khác nhau - nghĩa là các mức bảo vệ dữ liệu khác nhau khỏi các trạng thái không mong muốn. Các mức này (4 phần) đã được liệt kê trong tiêu chuẩn ANSI SQL-92.

Việc mô tả các cấp độ này có vẻ mơ hồ đối với một số nhà nghiên cứu và họ đưa ra các phân loại của riêng mình, chi tiết hơn. Tôi khuyên bạn nên chú ý đến dự án Jepsen đã được đề cập, cũng như dự án Hermitage, nhằm mục đích làm rõ chính xác mức độ cô lập nào được cung cấp bởi DBMS cụ thể, chẳng hạn như MySQL hoặc PostgreSQL. Nếu bạn mở các tệp từ kho lưu trữ này, bạn có thể xem trình tự các lệnh SQL mà họ sử dụng để kiểm tra cơ sở dữ liệu để tìm các điểm bất thường nhất định và bạn có thể thực hiện điều gì đó tương tự đối với cơ sở dữ liệu mà bạn quan tâm). Đây là một ví dụ từ kho lưu trữ để thu hút sự quan tâm của bạn:

-- Database: MySQL

-- Setup before test
create table test (id int primary key, value int) engine=innodb;
insert into test (id, value) values (1, 10), (2, 20);

-- Test the "read uncommited" isolation level on the "Intermediate Reads" (G1b) anomaly
set session transaction isolation level read uncommitted; begin; -- T1
set session transaction isolation level read uncommitted; begin; -- T2
update test set value = 101 where id = 1; -- T1
select * from test; -- T2. Shows 1 => 101
update test set value = 11 where id = 1; -- T1
commit; -- T1
select * from test; -- T2. Now shows 1 => 11
commit; -- T2

-- Result: doesn't prevent G1b

Điều quan trọng là phải hiểu rằng đối với cùng một cơ sở dữ liệu, theo quy định, bạn có thể chọn một trong một số kiểu cách ly. Tại sao không chọn vật liệu cách nhiệt mạnh nhất? Bởi vì, giống như mọi thứ trong khoa học máy tính, mức cô lập được chọn phải tương ứng với sự đánh đổi mà chúng ta sẵn sàng thực hiện - trong trường hợp này là sự đánh đổi về tốc độ thực thi: mức cô lập càng mạnh thì yêu cầu càng chậm xử lý. Để hiểu mức độ cô lập bạn cần, bạn cần hiểu các yêu cầu đối với ứng dụng của mình và để hiểu liệu cơ sở dữ liệu bạn đã chọn có cung cấp mức này hay không, bạn sẽ phải xem tài liệu - đối với hầu hết các ứng dụng, điều này là đủ, nhưng nếu bạn có một số yêu cầu đặc biệt chặt chẽ, tốt hơn là nên sắp xếp một bài kiểm tra giống như những gì những người từ dự án Hermitage làm.

5.3 Chữ "I" và các chữ cái khác trong ACID

Cô lập về cơ bản là những gì mọi người muốn nói khi họ nói về ACID nói chung. Và chính vì lý do này mà tôi đã bắt đầu phân tích từ viết tắt này một cách tách biệt và không theo thứ tự như những người cố gắng giải thích khái niệm này thường làm. Bây giờ chúng ta hãy nhìn vào ba chữ cái còn lại.

Nhớ lại ví dụ của chúng tôi với chuyển khoản ngân hàng. Giao dịch chuyển tiền từ tài khoản này sang tài khoản khác bao gồm thao tác rút tiền từ tài khoản thứ nhất và thao tác nạp tiền vào tài khoản thứ hai. Nếu hoạt động bổ sung của tài khoản thứ hai không thành công, có thể bạn không muốn hoạt động rút tiền từ tài khoản đầu tiên xảy ra. Nói cách khác, giao dịch thành công hoàn toàn hoặc hoàn toàn không xảy ra, nhưng nó không thể chỉ được thực hiện cho một số phần. Thuộc tính này được gọi là "tính nguyên tử" và là chữ "A" trong ACID.

Khi giao dịch của chúng ta được thực thi, giống như bất kỳ hoạt động nào, nó sẽ chuyển cơ sở dữ liệu từ trạng thái hợp lệ này sang trạng thái hợp lệ khác. Một số cơ sở dữ liệu cung cấp cái gọi là ràng buộc - nghĩa là các quy tắc áp dụng cho dữ liệu được lưu trữ, chẳng hạn như liên quan đến khóa chính hoặc khóa phụ, chỉ mục, giá trị mặc định, loại cột, v.v. Vì vậy, khi thực hiện một giao dịch, chúng ta phải chắc chắn rằng tất cả các ràng buộc này sẽ được đáp ứng.

Sự đảm bảo này được gọi là "tính nhất quán" và một chữ cái Ctrong ACID (đừng nhầm với tính nhất quán từ thế giới của các ứng dụng phân tán mà chúng ta sẽ nói sau). Tôi sẽ đưa ra một ví dụ rõ ràng về tính nhất quán theo nghĩa ACID: một ứng dụng cho cửa hàng trực tuyến muốn thêm ordersmột hàng vào bảng và ID từ bảng product_idsẽ được chỉ định trong cột - điển hình .productsforeign key

Giả sử, nếu sản phẩm đã bị xóa khỏi danh mục và theo đó, khỏi cơ sở dữ liệu, thì thao tác chèn hàng sẽ không xảy ra và chúng tôi sẽ gặp lỗi. Theo ý kiến ​​​​của tôi, sự đảm bảo này hơi xa vời - nếu chỉ vì việc sử dụng tích cực các ràng buộc từ cơ sở dữ liệu có nghĩa là chuyển trách nhiệm đối với dữ liệu (cũng như chuyển một phần logic kinh doanh, nếu chúng ta đang nói về một ràng buộc như CHECK ) từ ứng dụng đến cơ sở dữ liệu, như người ta nói bây giờ, chỉ là như vậy.

Và cuối cùng, nó vẫn còn D- "sức đề kháng" (độ bền). Lỗi hệ thống hoặc bất kỳ lỗi nào khác không được dẫn đến mất kết quả giao dịch hoặc nội dung cơ sở dữ liệu. Tức là, nếu cơ sở dữ liệu trả lời rằng giao dịch đã thành công, thì điều này có nghĩa là dữ liệu đã được ghi vào bộ nhớ cố định - chẳng hạn như trên đĩa cứng. Nhân tiện, điều này không có nghĩa là bạn sẽ thấy ngay dữ liệu trong yêu cầu đọc tiếp theo.

Mới hôm trước, tôi đang làm việc với DynamoDB từ AWS (Dịch vụ web của Amazon) và đã gửi một số dữ liệu để lưu và sau khi nhận được câu trả lời HTTP 200(OK) hoặc đại loại như vậy, tôi đã quyết định kiểm tra nó - và không thấy điều này dữ liệu trong cơ sở dữ liệu trong 10 giây tiếp theo. Tức là DynamoDB đã cam kết dữ liệu của tôi, nhưng không phải tất cả các nút đều được đồng bộ hóa ngay lập tức để nhận bản sao dữ liệu mới nhất (mặc dù dữ liệu đó có thể nằm trong bộ nhớ đệm). Ở đây chúng ta lại đi vào lãnh thổ của tính nhất quán trong bối cảnh của các hệ thống phân tán, nhưng vẫn chưa đến lúc để nói về nó.

Vì vậy, bây giờ chúng tôi biết ACID đảm bảo là gì. Và chúng tôi thậm chí còn biết tại sao chúng hữu ích. Nhưng chúng ta có thực sự cần chúng trong mọi ứng dụng không? Và nếu không, khi nào chính xác? Có phải tất cả các DB đều cung cấp những đảm bảo này không và nếu không, họ sẽ cung cấp những gì thay thế?