Cách thay đổi mức độ cô lập (isolation levels) mà không bị mất kiểm soát.
Một khách hàng đã nói với tôi một điều rất thú vị vào ngày hôm nọ. Anh ấy nói: “Chúng ta đã thảo luận trước đây về lý do tại sao chúng ta muốn bắt đầu sử dụng optimistic locking trong mã nguồn của mình. Làm thế nào để chúng ta đạt được điều đó?”
Nếu bạn không phải là một chuyên gia về SQL Server, nhận xét đó có lẽ thậm chí không có ý nghĩa gì. Nhưng đối với một số người trong chúng ta, khi bạn đưa một môi trường đến mức có thể xử lý optimistic locking VÀ bạn có những lập trình viên sẵn sàng thực hiện một vài thay đổi mã nguồn để tận dụng lợi thế của nó, thì điều đó thật thú vị!
Tôi là một người hâm mộ cuồng nhiệt của optimistic locking trong SQL Server và đã chứng kiến những cải thiện đáng kể về hiệu suất khi nó được triển khai tốt. Đối với đúng cơ sở dữ liệu và đúng đội ngũ, một chút nỗ lực làm việc chăm chỉ sẽ giúp việc mở rộng ứng dụng trở nên dễ dàng hơn về lâu dài.
Optimistic Locking 101: Ôn tập nhanh
Nếu bạn mới làm quen với Isolation Levels trong SQL Server, bài viết này có thể khó theo dõi. Hãy cân nhắc dành một chút thời gian để xem qua danh sách tài liệu tham khảo về Isolation Levels trong SQL Server trên web của chúng tôi.
Đây là phần ôn tập rất đơn giản hóa dành cho những ai đã biết về các mức độ cô lập của mình, nhưng cần làm sạch những mạng nhện kiến thức:
SQL Server sử dụng khóa “pessimistic” trong các cơ sở dữ liệu người dùng trừ khi bạn yêu cầu nó làm khác đi. Mức độ cô lập mặc định của bạn trong SQL Server là READ COMMITTED. Mặc dù nghe có vẻ yên tâm – ai mà không muốn một chút cam kết từ cơ sở dữ liệu của mình chứ? – đó là một dạng khóa pessimistic. Theo mức độ cô lập READ COMMITTED, người đọc có thể chặn người ghi, và người ghi có thể chặn người đọc. Đôi khi mức độ cô lập mặc định không đủ mạnh để cung cấp mức độ nhất quán phù hợp, vì vậy mọi người cần sử dụng các mức độ cô lập cao hơn (“pessimistic hơn”, nếu bạn muốn gọi như vậy) để ngăn chặn các hiện tượng như non-repeatable reads và ghost records.
Giả sử chúng ta có một bảng Orders, và chúng ta sẽ thêm một hàng vào đó:
1234567 | CREATE TABLE dbo.Orders (Id INT IDENTITY(1,1) PRIMARY KEY CLUSTERED, CreationDate DATETIME2 NOT NULL, ItemOrdered NVARCHAR(100) NOT NULL); INSERT INTO dbo.Orders(CreationDate, ItemOrdered) VALUES (GETDATE(), N'Jaguar XKR-S'); |
Khi cơ sở dữ liệu của chúng ta không chịu nhiều tải, việc truy vấn các đơn hàng đang mở rất dễ dàng – chỉ cần SELECT * FROM dbo.Orders, và bạn ngay lập tức thấy đơn hàng duy nhất mà chúng ta có.
Tuy nhiên, khi tải tăng lên trong cơ sở dữ liệu, sự căng thẳng giữa việc đảm bảo sự cô lập giao dịch / kết quả chính xác và việc hỗ trợ đồng thời cũng tăng lên. Bạn bắt đầu phải chiến đấu với việc chặn (blocking) và bế tắc (deadlocks).
Trong khi ai đó đang cố gắng tải một hàng mới vào bảng Orders:
1234 | BEGIN TRANINSERT INTO dbo.Orders (CreationDate, ItemOrdered) VALUES (GETDATE(), N'Ferrari 328'); |
Nếu ai đó cố gắng kiểm tra các Orders của chúng ta cùng lúc, họ sẽ bị chặn:

Trong ảnh chụp màn hình ở trên, cửa sổ bên trái đang chèn một hàng, và cửa sổ bên phải bị chặn. Lệnh select đó sẽ chỉ treo ở đó mãi mãi cho đến khi truy vấn bên trái cam kết giao dịch của họ, hoặc hoàn tác nó.
Để giải quyết vấn đề đó, mọi người thường sử dụng NOLOCK – điều này hoạt động theo nghĩa là truy vấn ở bên phải bây giờ có thể hoàn thành ngay lập tức:

Nhưng điều đó mang lại kết quả tai hại vì NOLOCK sẽ hiển thị cho bạn các hàng chưa bao giờ được cam kết, nó sẽ bỏ qua các hàng, nó sẽ đọc các hàng hai lần, và truy vấn của bạn có thể thất bại hoàn toàn. Tôi giải thích và minh họa điều đó ở đây.
Bước vào optimistic locking.
Nhiều thập kỷ trước, SQL Server 2005 đã giới thiệu hai mức độ cô lập bổ sung để giúp bạn trong sứ mệnh hướng tới sự đồng thời ngày càng lớn hơn: SNAPSHOT và READ COMMITTED SNAPSHOT isolation (tùy chọn thứ hai này thường được gọi là “RCSI”). Hai mức độ cô lập này sử dụng phiên bản để cho phép một số hoặc tất cả các truy vấn của bạn tăng tốc vượt qua các giao dịch “đang thực hiện” và đọc một phiên bản dữ liệu đã được cam kết trước đó.
Tôi chỉ cần làm hai việc: bật optimistic concurrency (sẽ nói thêm về điều đó trong giây lát), và thêm một dòng duy nhất vào các truy vấn chỉ đọc của tôi muốn bỏ qua các vấn đề chặn trong khi vẫn thấy kết quả chính xác:
123 | SET TRANSACTION ISOLATION LEVEL SNAPSHOT; SELECT * FROM dbo.Orders; |
Kết quả là, chà, kết quả! Tôi có thể nhận được kết quả ngay lập tức thay vì bị chặn:

Truy vấn select chỉ thấy dữ liệu thực – như thể lệnh insert ở bên trái chưa cam kết, điều đó là đúng!
Người đọc không chặn người ghi, người ghi không chặn người đọc

<
Bạn không chắc liệu máy chủ production của mình có chịu được tải hay không
Cần có tài nguyên kiểm thử để đảm bảo thay đổi sẽ không tạo ra dữ liệu xấu
Không ai muốn đầu tư vào việc kiểm thử nếu môi trường không thể xử lý được tải. Và mọi người không biết cách kiểm thử tác động của tính năng một cách an toàn nếu nó chưa được chứng minh là không tạo ra dữ liệu xấu. Điều này có vẻ như là một bế tắc trong quản lý dự án.

Những viên bi lạc quan
Đừng lo lắng– có một phương pháp để kiểm thử tải trước! Chìa khóa nằm ở việc hiểu sự khác biệt giữa hai mức độ cô lập (isolation level). Nếu bạn bật SNAPSHOT isolation trên một cơ sở dữ liệu, SQL Server bắt đầu triển khai các công nghệ tạo phiên bản (versioning) để các truy vấn có thể đặt mức độ cô lập của chúng thành snapshot. (Các truy vấn phải thực hiện việc này một cách rõ ràng– như chạy lệnh ‘SET TRANSACTION ISOLATION LEVEL SNAPSHOT’.)
Đây là phần dễ bị bỏ sót. Ngay khi bạn bật SNAPSHOT isolation trên một cơ sở dữ liệu, SQL Server sẽ đợi các giao dịch đang chạy hoàn tất, sau đó ngay lập tức bắt đầu sử dụng tính năng tạo phiên bản cho các sửa đổi dữ liệu. Bạn bắt đầu sử dụng thêm 14 byte cho mỗi hàng trên các bảng trong chính cơ sở dữ liệu đó. Ngoài ra, các phiên bản được tạo trong tempdb version store để lưu giữ giá trị dữ liệu trước đó cho các thao tác cập nhật, xóa và một số thao tác chèn. Điều này xảy ra ngay cả khi không có truy vấn nào được chạy bằng SNAPSHOT isolation. Thật đấy.
Điều này có vẻ hơi thiệt thòi. Phải, bạn phải trả giá cho hiệu năng ngay cả khi bạn không sử dụng SNAPSHOT! Nhưng đó là vì một lý do chính đáng– SQL Server của bạn chưa có khả năng ngoại cảm. Nó không thể dự đoán liệu bất cứ lúc nào bạn có bắt đầu một truy vấn sử dụng SNAPSHOT hay không, và nó phải đảm bảo rằng cơ sở dữ liệu đã sẵn sàng nếu bạn làm điều đó bất cứ lúc nào.
Nhưng đây cũng có thể là một tính năng hữu ích. Bạn có thể kiểm thử tác động hiệu năng của việc tạo phiên bản cho một cơ sở dữ liệu chỉ bằng cách đặt ALLOW_SNAPSHOT_ISOLATION thành ON. Khi bạn đưa ra lệnh thay đổi này, SQL Server sẽ đợi tất cả các giao dịch đang hoạt động hoàn tất trước khi trả về trạng thái cho bạn (Books Online giải thích lý do và cách xử lý tại đây – hãy tìm kiếm ALLOW_SNAPSHOT_ISOLATION trên trang đó.) Sau đó, bạn có thể giám sát hiệu năng trong thời gian bạn đã lên kế hoạch và sau đó đảo ngược quy trình.
Miễn là bạn chưa thay đổi bất kỳ mã nguồn nào và đang sử dụng tùy chọn ALLOW_SNAPSHOT_ISOLATION, sẽ không có truy vấn nào thực sự SỬ DỤNG mức độ cô lập đã thay đổi và bạn có thể chỉ cần giám sát tác động hiệu năng của khóa lạc quan (optimistic locking).
Một lưu ý: Tôi đã cố tình đề cập đến một cài đặt cụ thể. Tùy chọn READ_COMMITTED_SNAPSHOT có tên tương tự, nhưng lại rất khác biệt! Cài đặt đó thay đổi mức độ cô lập mặc định của bạn để sử dụng khóa lạc quan mà không cần thay đổi mã nguồn trong các truy vấn của bạn. Ngay khi bạn bật nó lên, một số truy vấn có thể bắt đầu nhận được kết quả không mong đợi hoặc ghi dữ liệu không chính xác. (Một ví dụ về điều này nằm ở mục #3 bên dưới.) Quy trình bật READ_COMMITTED_SNAPSHOT cũng hoạt động khác. Trong một khoảnh khắc duy nhất, kết nối thực hiện lệnh bật RCSI phải là lệnh duy nhất đang hoạt động trong cơ sở dữ liệu. (Tôi đi sâu hơn vào chi tiết này trong phần “các lưu ý” ở cuối hướng dẫn này.)
Vì cả hai lý do này, ALLOW_SNAPSHOT_ISOLATION phù hợp hơn nhiều để bạn thử nghiệm và tìm hiểu xem việc bật khóa lạc quan ảnh hưởng như thế nào đến khối lượng công việc của bạn. Tuy nhiên, hãy tuân theo lẽ thường: kiểm thử trước vào những thời điểm lưu lượng thấp và tăng dần lên những thời điểm bận rộn hơn. Hãy biết rằng việc vô hiệu hóa và quay trở lại trạng thái “bình thường” có thể không phải lúc nào cũng diễn ra nhanh như bạn muốn nếu bạn có các giao dịch chạy dài hoặc khối lượng công việc rất nặng.
2. Lập kế hoạch ngăn chặn thảm họa với Snapshot Isolation
Bước thứ hai để xác định điều gì có thể vượt khỏi tầm kiểm soát nếu có điều bất thường xảy ra– ví dụ như một phiên làm việc (session) bất thường để mở một giao dịch. Đây không phải là một kịch bản xa vời. Tôi đã từng gặp trường hợp này do cả lỗi con người cũng như lỗi trong các framework ứng dụng. Nó thực sự không quá hiếm và có thể xảy ra với bạn.
Kế hoạch của bạn cần xác định những rủi ro lớn nhất từ thay đổi này đối với doanh nghiệp của bạn và những gì có thể gây khó khăn cho đội ngũ phản ứng sự cố. Sau đó, hãy thiết lập mọi thứ để nếu có điều kỳ lạ xảy ra, mọi người sẽ được thông báo và biết cách phân loại vấn đề.
Dưới đây là ví dụ về hai điều bạn có thể xác định là mối quan tâm lớn nhất nếu bạn triển khai khóa lạc quan trong SQL Server:
Tempdb có thể bị đầy
Các chuỗi phiên bản dài có thể được tạo ra, khiến hiệu năng truy vấn trở nên cực kỳ chậm
Cách bạn xử lý những vấn đề này sẽ khác nhau tùy thuộc vào hệ thống giám sát, các công cụ sẵn có và tài liệu của bạn. Ngay cả khi bạn không có các công cụ cao cấp, bạn có thể sử dụng các Cảnh báo bộ đếm hiệu năng (performance counter Alerts) của SQL Server để thông báo cho bạn nếu dung lượng đã sử dụng trong Tempdb vượt quá một ngưỡng nhất định. Bạn cũng có thể đặt cảnh báo trên các bộ đếm hiệu năng cho thời gian giao dịch chạy lâu nhất, hoặc sử dụng các DMV để giám sát version store một cách chi tiết. (Lưu ý rằng không phải tất cả các DMV này đều nhẹ khi sử dụng lúc version store trở nên lớn.)
Nhiệm vụ của bạn rất đơn giản: xác định cách đơn giản nhất để giám sát các điều kiện mà bạn quan tâm. Kiểm thử xem nó có hoạt động trên hệ thống không phải production hay không. Quan trọng nhất, hãy viết tài liệu cho cảnh báo đó để giúp ai đó giải mã vấn đề nếu bạn không có mặt để xử lý mọi thứ và thực hiện các hành động cơ bản.
3. Chọn lựa cẩn thận giữa Snapshot và Read Committed Snapshot Isolation (RCSI)
Được rồi, bây giờ là lúc các lập trình viên vào cuộc. Bạn có thể sử dụng khóa lạc quan trong SQL Server bằng cách sử dụng một trong hai tùy chọn cơ sở dữ liệu này– hoặc cả hai. Cái nào phù hợp với bạn?
Cân nhắc lớn nhất với một cơ sở mã hiện có là mức độ kiểm thử sẵn có cho mã đó. Tôi nghĩ mức độ cô lập READ COMMITTED SNAPSHOT rất tuyệt, nhưng nó sẽ thay đổi mức độ cô lập mặc định cho mọi truy vấn ngay lập tức. Điều này có thể dẫn đến kết quả không chính xác, tùy thuộc vào cách mã của bạn được viết. Ví dụ trường hợp xấu nhất là nếu mã của bạn:
Đ
Để triển khai SNAPSHOT isolation trên một số câu lệnh, trước tiên bạn cần kích hoạt nó bằng tùy chọn cơ sở dữ liệu ALLOW_SNAPSHOT_ISOLATION. Sau đó, bạn có thể triển khai SNAPSHOT isolation cho một số truy vấn – bạn thực hiện việc này bằng cách thiết lập mức độ cô lập với ‘SET TRANSACTION ISOLATION LEVEL SNAPSHOT’. Ban đầu, việc triển khai cách này cho các truy vấn đọc mà bạn đã kiểm thử và biết rằng không dễ xảy ra các vấn đề về dữ liệu tương tự như những gì chúng tôi đã trình bày ở trên sẽ dễ dàng hơn. Điều này có nghĩa là bạn có thể cần sử dụng các chuỗi kết nối riêng biệt cho các truy vấn đó, hoặc đảm bảo rằng bạn đặt lại và kiểm tra mức độ cô lập mà bạn đang vận hành sau khi các câu lệnh hoàn tất.
Nói cách khác, mức độ cô lập là một thiết lập ở cấp độ phiên. Bạn cần đảm bảo rằng mình không vô tình sử dụng SNAPSHOT isolation cho nhiều truy vấn hơn mức dự định.
Những lưu ý khi triển khai các mức độ cô lập Snapshot và Read Committed Snapshot
Có một vài sự thật nhỏ mà tôi đã phát hiện ra qua nhiều năm mà tôi không ngờ tới. Chúng hoàn toàn hợp lý, chỉ là tôi đã không nghĩ đến chúng cho đến khi làm việc với khóa lạc quan (optimistic locking) trong SQL Server.
Các gợi ý (hints) trong mã của bạn vẫn được áp dụng. Giả sử bạn gặp vấn đề với việc khóa. Qua nhiều năm, các gợi ý NOLOCK được thêm vào nhiều nơi để giúp cải thiện điều này. Cuối cùng, bạn nhận được xác nhận từ nhóm phát triển của mình rằng READ_COMMITTED_SNAPSHOT là an toàn cho các ứng dụng của bạn và thay đổi của bạn được phê duyệt, vì vậy bạn bật nó lên. Bạn đang dành tất cả các tài nguyên hiệu năng đó cho việc tạo phiên bản, nhưng đoán xem? Những gợi ý NOLOCK đó vẫn khiến các truy vấn thực hiện đọc bẩn (dirty reads) thay vì sử dụng phiên bản dữ liệu! Các gợi ý NOLOCK đó phải bị loại bỏ.
Các tác vụ ghi vẫn chặn các tác vụ ghi trong Read Committed Snapshot Isolation (RCSI), và tùy thuộc vào cách bạn đã viết các giao dịch của mình, điều này có thể làm thay đổi kết quả trong ứng dụng của bạn. Đọc thêm tại đây.
Xung đột cập nhật không giống như bế tắc (deadlocks). Xung đột cập nhật chỉ có thể xảy ra khi bạn sử dụng SNAPSHOT isolation cho các câu lệnh sửa đổi dữ liệu – bạn không cần phải lo lắng về điều này với READ_COMMITTED_SNAPSHOT. Tuy nhiên, việc mọi người triển khai SNAPSHOT thường thực tế hơn vì các vấn đề kiểm thử mà tôi đã nêu ở trên. Ngay cả khi bạn chỉ triển khai SNAPSHOT cho các giao dịch đọc, hãy tự làm quen với các mã lỗi và thông báo cho xung đột cập nhật và đảm bảo mã của bạn xử lý lỗi 3960 (“Snapshot isolation transaction aborted due to update conflict…”).
Việc kích hoạt READ_COMMITTED_SNAPSHOT trên một hệ thống bận rộn khó hơn vẻ ngoài của nó. Như tôi đã đề cập trước đó, việc bật hoặc tắt READ_COMMITTED_SNAPSHOT là hơi bất thường. Về mặt kỹ thuật, bạn không bắt buộc phải đưa cơ sở dữ liệu vào chế độ người dùng đơn (single user mode), nhưng để lệnh hoàn tất, bạn cần phải là lệnh đang hoạt động duy nhất tại thời điểm đó. Cách đơn giản nhất để thực hiện việc này là sử dụng mệnh đề ‘WITH ROLLBACK IMMEDIATE’ của lệnh ALTER DATABASE (tìm kiếm nó trên trang đó). Tuy nhiên, tôi thấy cách này không chạy một cách dễ dự đoán hoặc dễ dàng trên các hệ thống có giao dịch rất cao. Tôi khuyên bạn nên lên kế hoạch thay đổi để bật hoặc tắt thiết lập READ_COMMITTED_SNAPSHOT trong cơ sở dữ liệu vào thời điểm có lưu lượng truy cập rất thấp nếu bạn cần giữ mọi thứ ổn định.
Việc hoàn tác (rollback) và vô hiệu hóa SNAPSHOT đòi hỏi nhiều thay đổi mã hơn. Để dừng việc tạo phiên bản hàng (row versioning), bạn cần vô hiệu hóa SNAPSHOT — và ngay khi bạn làm điều đó, các truy vấn thiết lập mức độ cô lập thành SNAPSHOT và cố gắng chạy sẽ thất bại với Lỗi 3292: “Snapshot isolation transaction failed accessing database ‘dbname’ because snapshot isolation is not allowed in this database”