Điều gì thực sự xảy ra khi bạn INSERT một hàng?
Một dòng code đơn giản. Bảy lớp phối hợp trong vài mili giây. Hãy cùng vén màn bí mật.
Một dòng code. Cả một thế giới ẩn bên dưới.
Bạn viết dòng này. Trông nó thật vô hại:
db.Insert("Users", row);
"Này cơ sở dữ liệu, chèn hàng này vào bảng Users nhé. Cảm ơn."
Và đó là tất cả những gì bạn nói. Dễ dàng. Thậm chí có vẻ nhàm chán.
Nhưng bên dưới lớp vỏ ấy, mọi thứ không hề nhàm chán. Hãy tưởng tượng một engine cơ sở dữ liệu giống như một phòng cấp cứu bệnh viện bận rộn. Hàng dữ liệu của bạn là một bệnh nhân mới. Nó phải qua khâu phân loại, được ghi vào sổ tiếp nhận chính thức trước khi bất kỳ ai chạm vào bất cứ thứ gì vĩnh viễn, và chỉ sau đó mới được xếp vào danh sách khoa nội. Nếu điện bị chớp tắt giữa chừng, sổ tiếp nhận sẽ cho chúng ta biết chính xác ai đã được làm thủ tục nhập viện chính thức.
Khi bạn yêu cầu AI "thêm một cột" hoặc "làm cho truy vấn này nhanh hơn", AI cần biết thay đổi của bạn thuộc về lớp nào của cơ sở dữ liệu. Nếu bạn có thể gọi tên các lớp, bạn có thể điều hướng AI — thay vì nhận được mã code chung chung, chỉ đúng một nửa.
Cái nhìn tổng quan về 5 lớp
Cơ sở dữ liệu không phải là một khối duy nhất. Đó là một chồng các lớp chuyên biệt, mỗi lớp giải quyết một vấn đề cụ thể. Hãy cùng làm quen với các nhân vật:
Cửa trước. db.Insert(...), db.CreateTable(...) — một vài phương thức mà bạn thực sự gọi với tư cách là lập trình viên.
Biết hình dạng dữ liệu của bạn (gọi là schema) và thực thi các quy tắc như "không được trùng ID" và "cột này không được để trống".
Sổ nhật ký tiếp nhận. Một WAL ghi lại mọi thay đổi vào đĩa trước khi dữ liệu thực sự bị sửa đổi — vì vậy một sự cố sập nguồn không bao giờ làm mất các bản ghi đã xác nhận (committed).
Một tủ hồ sơ được sắp xếp tinh vi. Cây B+ là thứ giúp bạn tìm thấy hàng thứ #4.829.117 trong vài mili giây thay vì đọc hàng triệu hàng một cách tuần tự.
Các byte thực tế trên đĩa, được tổ chức thành các khối có kích thước cố định gọi là trang. Đây là nơi dữ liệu của bạn thực sự trú ngụ về mặt vật lý.
Mỗi lớp có một nhiệm vụ. Tính bền vững là việc của WAL. Thứ tự sắp xếp là việc của chỉ mục. I/O đĩa là việc của engine lưu trữ. Nhầm lẫn các nhiệm vụ này là lỗi phổ biến nhất trong code cơ sở dữ liệu nghiệp dư — và đó là lý do bạn nên quan tâm mình đang chạm vào lớp nào.
Làm quen với API công khai
Đây là toàn bộ cuộc hội thoại mà bạn thực hiện với cơ sở dữ liệu từ mã ứng dụng của mình. Mọi thứ khác trong khóa học này chính là những gì xảy ra đằng sau những dòng code này.
using var db = new Database("mydata.mde", cacheSize: 100, useMemoryMappedFile: false);
var columns = new List<ColumnDefinition>
{
new ColumnDefinition("Id", DataType.Int, false),
new ColumnDefinition("Name", DataType.String),
new ColumnDefinition("Age", DataType.Int)
};
var table = db.CreateTable("Users", columns, primaryKeyColumn: "Id");
var row = new DataRow(table.Schema);
row["Id"] = 1;
row["Name"] = "Alice";
row["Age"] = 30;
db.Insert("Users", row);
Mở (hoặc tạo) một tệp cơ sở dữ liệu tên là mydata.mde. Lưu đệm 100 trang trong bộ nhớ để tăng tốc. Lệnh using đảm bảo tệp được đóng sạch sẽ khi chúng ta hoàn tất.
Phác thảo hình dạng của một "Người dùng": gồm ba trường.
Id là số nguyên, và false nghĩa là nó không được để trống.
Name là văn bản. Age là số nguyên. Những thứ cơ bản.
Tạo bảng Users với Id làm khóa chính — định danh duy nhất cho mỗi hàng.
Tạo một hàng trống khớp với hình dạng của bảng.
Điền thông tin: Alice, ID 1, 30 tuổi.
Giao nó cho cơ sở dữ liệu. Đây chính là dòng code "vô hại" mà chúng ta đã bắt đầu.
Lưu ý rằng ở cấp độ này thực sự chỉ có bốn động từ bạn cần biết: mở cơ sở dữ liệu, định nghĩa cột, tạo hàng, chèn nó. Mọi thứ khác mà khóa học này dạy là những gì cơ sở dữ liệu thực hiện cho bạn đằng sau bốn động từ đó.
Hành trình của một hàng dữ liệu
Nhấn Bước tiếp theo để xem hàng dữ liệu của bạn đi qua các lớp. Mỗi điểm dừng có đúng một nhiệm vụ duy nhất.
Bạn thích đọc theo tốc độ của mình? Đây là bảy bước tương tự dưới dạng thẻ:
db.Insert
Một lệnh gọi API từ ứng dụng của bạn, truyền vào tên bảng và một đối tượng hàng dữ liệu.
Đối tượng Database giữ một từ điển các bảng theo tên. Nó bàn giao quyền kiểm soát cho đúng thực thể Table (Bảng) đó.
Mỗi cột có khớp với kiểu dữ liệu mong đợi không? Các trường bắt buộc (non-null) có thực sự hiện diện không? Khóa chính có là duy nhất không?
Tuần tự hóa biến đối tượng hàng thành một mảng byte gọn nhẹ — dạng duy nhất mà ổ đĩa và nhật ký thực sự hiểu.
Đây là điểm đảm bảo tính bền vững. Một khi bản ghi WAL nằm trên đĩa, việc ghi dữ liệu chính thức "sống sót" — ngay cả khi mất điện ngay bây giờ, quá trình khôi phục có thể phát lại bản ghi đó.
Chỉ mục đặt bản ghi mới vào vị trí đã sắp xếp theo khóa chính, nhờ đó các lượt tra cứu theo ID trong tương lai sẽ rất nhanh.
Quyền kiểm soát chảy ngược lại chồng lớp. Dòng code đơn lẻ của bạn kết thúc. Alice hiện đã nằm an toàn trong cơ sở dữ liệu.
Cận cảnh: code Table.Insert thực tế
Đây là phương thức C# thực tế điều phối các bước từ 3 đến 6. Có khoảng 20 dòng C# từ tệp MiniDatabaseEngine/Table.cs. Đừng lo lắng — chúng ta sẽ dịch từng dòng.
public void Insert(DataRow row, Transaction.Transaction? transaction = null)
{
_lock.EnterWriteLock();
try
{
var key = GetPrimaryKey(row);
ValidateRowForWrite(row);
var keyExists = _index.Search(key) != null;
var keyPendingInTransaction = transaction?.HasBufferedValueForKey(_schema.TableName, key, GetPrimaryKeyDataType()) ?? false;
if (keyExists || keyPendingInTransaction)
throw new InvalidOperationException($"Duplicate primary key value '{key}'.");
var serialized = SerializeRow(row);
if (transaction != null)
{
transaction.LogInsert(_schema.TableName, key, serialized);
_nextRowId++;
return;
}
_index.Insert(key, serialized);
_nextRowId++;
}
finally
{
_lock.ExitWriteLock();
}
}
Phương thức công khai Insert nhận một hàng dữ liệu, và tùy chọn một giao dịch (nếu bạn đang gom nhiều lượt ghi vào một nhóm).
Chiếm một khóa ghi độc quyền. Không luồng nào khác có thể sửa đổi bảng này cho đến khi chúng ta hoàn tất.
Khối try bắt đầu một vùng code đảm bảo việc dọn dẹp (cleanup) luôn xảy ra ngay cả khi có lỗi.
Lấy giá trị khóa chính từ hàng dữ liệu (ví dụ: 1 cho Alice).
Xác thực: mọi trường có khớp với schema không? Có thiếu trường bắt buộc không? Sai kiểu dữ liệu? Nếu có sẽ báo lỗi ngay.
Khóa này đã có trong chỉ mục chưa? Hàm Search trả về giá trị nếu có, null nếu không.
Khóa này có đang chờ xử lý trong một giao dịch đang mở mà chưa được xác nhận (commit) không? Ký hiệu ?. giúp bỏ qua an toàn nếu không có giao dịch. Ký hiệu ?? false mặc định là "không" khi không có câu trả lời.
Trong cả hai trường hợp — đã xác nhận HOẶC đang chờ xử lý — đều từ chối vì trùng lặp.
Ném ra một lỗi rõ ràng kèm theo giá trị khóa vi phạm. Không có chuyện âm thầm bỏ qua lỗi.
Biến đối tượng hàng thành mảng byte, sẵn sàng để lưu trữ.
Nếu chúng ta đang ở trong một giao dịch, đừng chạm vào chỉ mục thật vội…
…thay vào đó, "tạm đỗ" thay đổi vào bộ đệm WAL của giao dịch và tăng bộ đếm ID hàng. Chúng ta sẽ áp dụng nó khi commit.
Nếu không — không có giao dịch — ghi thẳng nó vào chỉ mục Cây B+ ngay bây giờ.
Khối finally luôn chạy, dù thành công hay thất bại.
Giải phóng khóa ghi. Các luồng khác giờ có thể tiếp tục.
Lưu ý cách mà phương thức đơn lẻ này điều phối bao nhiêu lớp: trình quản lý khóa, trình xác thực schema, chỉ mục, bộ tuần tự hóa, bộ đệm giao dịch. Sự phối hợp đó chính là lý do khóa học này tồn tại.
Kiểm tra trực giác của bạn
Không cần học thuộc lòng. Mỗi câu hỏi là một tình huống thực tế bạn có thể gặp khi gỡ lỗi hoặc trò chuyện với AI về cơ sở dữ liệu. Hãy suy nghĩ kỹ, rồi chọn.
Người dùng báo cáo lỗi trùng khóa ngay cả sau khi đã rollback một giao dịch. Lớp kiểm tra của phần nào nhiều khả năng đang phát hỏa nhất?
Bạn muốn thêm tính năng nén cho các hàng đã lưu — tệp nhỏ hơn, đọc đĩa nhanh hơn. Lớp nào nên sở hữu logic nén?
Một lệnh ghi báo "thành công" (code của bạn nhận được kết quả thành công), nhưng hàng dữ liệu biến mất sau khi máy chủ bị sập. Đâu là nguyên nhân khả thi nhất?
Hãy sắp xếp bốn sự kiện sau theo thứ tự xảy ra trong một lượt chèn:
(a) các byte được lưu vĩnh viễn vào một trang trên đĩa (b) hàng được tuần tự hóa thành byte (c) khóa chính được trích xuất (d) bản ghi WAL được ghi nối vào
Tiếp theo: Module 2 — Trang & Vùng mở rộng, sơ đồ mặt bằng lưu trữ. Chúng ta sẽ phóng to xuống tận đáy của chồng lớp và xem cách cơ sở dữ liệu sắp xếp các byte trên đĩa một cách vật lý. Một khi bạn hiểu sơ đồ mặt bằng, phần còn lại của tòa nhà sẽ trở nên hợp lý.
Trang & Vùng mở rộng (Extents)
Sơ đồ mặt bằng lưu trữ — cách các byte trên đĩa trở thành thứ mà cơ sở dữ liệu có thể tìm thấy.
Ổ đĩa không quan tâm đến các hàng
Hãy tưởng tượng một kho chứa đồ tự quản khổng lồ. Mọi đơn vị kho đều có kích thước chính xác như nhau. Đó là cách một ổ đĩa nhìn thấy cơ sở dữ liệu của bạn — không phải dưới dạng các bảng và hàng, mà là một dãy dài các đơn vị lưu trữ giống hệt nhau gọi là trang.
Toàn bộ nhiệm vụ của engine lưu trữ là phiên dịch: lấy các hàng có kích thước thay đổi, lộn xộn của bạn và nhét chúng vào các đơn vị cố định, cứng nhắc này để ổ đĩa có thể di chuyển chúng một cách hiệu quả.
Trang — 4096 byte
Nguyên tử của lưu trữ. Mọi thứ trên đĩa đều là một trang.
Vùng mở rộng — 8 trang = 32 KB
Một "khu phố" gồm các trang. Được lấy theo nhóm để tăng tính cục bộ.
Cờ Dirty
Một bit đánh dấu rằng "trang này có thay đổi nhưng chưa được ghi xuống đĩa".
Bộ nhớ đệm LRU
Giữ các trang hay dùng trong RAM. Loại bỏ trang nào lâu nhất không có ai chạm vào.
Hầu hết các ổ SSD và bộ nhớ đệm trang của hệ điều hành hoạt động theo các khối 4KB. Việc khớp kích thước trang cơ sở dữ liệu với kích thước khối của hệ điều hành giúp: một trang cơ sở dữ liệu = một lần I/O đĩa — không lãng phí lượt đọc.
Giải phẫu một trang
Một trang có cấu tạo cực kỳ đơn giản: một ID, một mảng byte thô kích thước cố định, và một lá cờ. Chỉ có vậy thôi. ID là thứ làm cho phép toán hoạt động — nó cho engine biết chính xác đơn vị này nằm ở đâu trên đĩa.
public class Page
{
#if DATA_PAGE_SIZE
public const int PageSize = #DATA_PAGE_SIZE#;
#else
public const int PageSize = 4096; // Trang 4KB
#endif
public int PageId { get; set; }
public byte[] Data { get; set; }
public bool IsDirty { get; set; }
public Page(int pageId)
{
PageId = pageId;
Data = new byte[PageSize];
IsDirty = false;
}
Mỗi trang có kích thước chính xác 4096 byte — không ngoại lệ. Điều này khớp với kích thước mà ổ đĩa thích di chuyển nhất.
Mỗi trang có một số ID. ID này thực chất là một tọa độ bản đồ — nó cho chúng ta biết trang nằm ở đâu trên đĩa.
Mảng dữ liệu (Data array) là đơn vị lưu trữ 4096-byte thực tế. Bất cứ thứ gì cơ sở dữ liệu muốn ghi nhớ đều nằm ở đây.
IsDirty bắt đầu là false — trang khớp hoàn toàn với những gì đang có trên đĩa.
Hàm tạo (constructor) chỉ đơn giản là giao cho bạn một trang mới, trống rỗng với ID bạn yêu cầu — một đơn vị lưu trữ trống, sạch sẽ.
Vì mọi trang đều có cùng kích thước, việc tìm trang số N trên đĩa chỉ là một phép nhân duy nhất — không cần tìm kiếm, không cần tra cứu chỉ mục, chỉ là số học thuần túy.
Muốn trang 17? Bỏ qua 17 × 4096 = 69.632 byte tính từ đầu tệp. Xong.
Vùng mở rộng (Extents) — gom đơn cho xe tải
Việc điều động xe tải vận chuyển của kho hàng rất tốn kém. Chạy 8 chuyến riêng biệt cho 8 đơn vị kho liền kề là lãng phí — vì vậy chúng ta gửi xe đi một lần và bốc cả khối cùng lúc. Khối đó gọi là một vùng mở rộng (extent).
8 trang nằm cạnh nhau. Một chuyến xe. Đó là toàn bộ ý tưởng.
public class Extent
{
public const int PagesPerExtent = 8;
public int ExtentId { get; set; }
public Page[] Pages { get; set; }
public bool IsDirty => Pages.Any(p => p.IsDirty);
public Extent(int extentId)
{
ExtentId = extentId;
Pages = new Page[PagesPerExtent];
for (int i = 0; i < PagesPerExtent; i++)
{
int pageId = extentId * PagesPerExtent + i;
Pages[i] = new Page(pageId);
}
}
Mỗi vùng mở rộng chứa chính xác 8 trang. Luôn luôn như vậy. Không hơn, không kém.
Vùng mở rộng có ID riêng và một "kệ" nhỏ với 8 ngăn cho các trang.
"IsDirty" của một vùng mở rộng chỉ đơn giản là hỏi: có trang nào trong này cần lưu không?
Hàm tạo lấp đầy mỗi ngăn trong số 8 ngăn bằng một trang mới.
Đây là bí quyết: extentId × 8 + i gán cho mỗi trang ID toàn cục của nó. Vậy trang 17 nằm ở vùng mở rộng 2, ngăn 1 (vì 2 × 8 + 1 = 17). Không cần bảng tra cứu — chỉ là phép nhân.
Một trang có thể được sửa đổi trong bộ nhớ hàng chục lần và chỉ được ghi xuống đĩa một lần duy nhất khi có yêu cầu flush. Đó là lý do cơ sở dữ liệu chạy nhanh — các lượt ghi đĩa được gom lại thành đợt (batched).
Thử sức: thuộc vùng mở rộng nào?
Hãy nhớ công thức: extentId = pageId ÷ 8. Kéo mỗi trang vào vùng mở rộng mà nó thuộc về. Mấu chốt nằm ở phép toán này.
Vùng 0 — trang 0 đến 7
Vùng 1 — trang 8 đến 15
Vùng 2 — trang 16 đến 23
Bảng kẹp tại quầy lễ tân — Bộ nhớ đệm LRU
Việc đi bộ đến kho hàng cho mọi lần tra cứu là rất mệt mỏi. Vì vậy, quầy lễ tân giữ một bảng kẹp ghi lại những đơn vị kho được mở gần đây nhất — đó chính là bộ nhớ đệm (cache). Khi hết chỗ, nó sẽ loại bỏ kẻ nào lâu nhất không có ai động vào. Quy tắc đó có tên là: LRU.
public void Put(int pageId, Page page)
{
lock (_lockObject)
{
if (_cache.TryGetValue(pageId, out var node))
{
node.Page = page;
MoveToHead(node);
return;
}
var newNode = new CacheNode(page);
_cache[pageId] = newNode;
AddToHead(newNode);
if (_cache.Count > _capacity)
{
var removed = RemoveTail();
if (removed != null)
_cache.TryRemove(removed.Page.PageId, out _);
}
}
}
Khóa bảng kẹp lại trước — hai người cùng viết lên đó một lúc sẽ gây hỗn loạn.
Nếu trang này đã có trên bảng kẹp, chỉ cần làm mới nó và đưa mục đó lên trên cùng (mới nhất).
Nếu chưa có, hãy viết một mục mới toanh vào đầu bảng kẹp.
Nếu bảng kẹp bị tràn quá khả năng chứa...
...hãy gỡ bỏ mục ở dưới cùng — đó là cái mà lâu nhất chưa có ai chạm tới. Coi như nó không tồn tại.
Vị trí "đầu" và "cuối" của bảng kẹp được theo dõi bởi một danh sách liên kết đôi, đó là lý do tại sao việc đưa một thứ lên đầu gần như là miễn phí. Một biến thể xịn hơn — tệp ánh xạ bộ nhớ (memory-mapped file) — cho phép Hệ điều hành tự xử lý việc lưu đệm, nhưng đó là chủ đề cho một ngày khác.
Vận dụng vào thực tế
Ba câu hỏi để mở rộng các ý tưởng. Không có câu hỏi mẹo — chỉ là những tình huống bạn có thể gặp trong thực tế khi làm việc với AI hỗ trợ lập trình.
Nếu bộ nhớ đệm chứa 100 trang, mỗi trang 4 KB, thì nó sử dụng bao nhiêu RAM?
Bạn đã ghi 10.000 hàng nhưng chưa gọi Flush(). Điện bị mất. Cái gì còn sống sót?
Tại sao bộ nhớ đệm lại loại bỏ trang ít được dùng gần đây nhất, thay vì trang ít được dùng thường xuyên nhất?
Tiếp theo: module 3, Cây B+. Giờ đây các byte đã có nơi để ở, làm sao chúng ta tìm thấy một hàng cụ thể giữa hàng triệu hàng?
Cây B+ — Tìm cây kim trong đống cỏ một cách nhanh chóng
Cách cơ sở dữ liệu biến "tìm kiếm giữa 10 triệu hàng" thành khoảng 23 lần so sánh.
Danh bạ điện thoại với các tab chỉ mục
Việc quét 10 triệu hàng để tìm người dùng #47.392.185 sẽ mất rất nhiều thời gian. Một cây B+ biến cuộc tìm kiếm đó thành khoảng 23 lần so sánh — giống như việc tìm kiếm nhị phân trong một cuốn danh bạ điện thoại giấy, lật từng phần một.
Hãy hình dung một cuốn danh bạ điện thoại với các tab chỉ mục dày được dán vào mép các trang. Các tab này không chứa số điện thoại — chúng chỉ nói "lật vào đây để tìm vần S" hoặc "lật vào đây để tìm vần T". Các số điện thoại thực sự nằm trên các trang được sắp xếp theo bảng chữ cái ở cuối, và những trang đó được đóng ghim theo thứ tự — một khi bạn đã đến được mục "Smith", bạn chỉ cần đọc tiếp tới "Thompson, Turner, Vance".
Đó chính là một cây B+. Các tab là các nút nội bộ. Các trang là các nút lá. Và các ghim đóng chính là thứ làm cho các truy vấn dải (range queries) trở nên nhanh chóng.
Trong một cây B thông thường, các giá trị nằm ở mọi cấp độ. Trong cây B+, giá trị chỉ nằm ở các lá, và các lá được liên kết với nhau. Điều đó giúp các lần quét dải — WHERE age BETWEEN 20 AND 30 — trở thành một đường đi thẳng thay vì phải duyệt lại toàn bộ cây.
Hai loại nút, hai nhiệm vụ khác nhau
Mọi nút đều giữ các khóa (keys). Nhưng nút lá cũng giữ các giá trị (values) thực tế và có các con trỏ Next/Previous tới các nút anh em của chúng. Nút nội bộ chỉ giữ các khóa và con trỏ tới các nút con — chúng thuần túy là biển chỉ dẫn.
public abstract class BPlusTreeNode
{
public bool IsLeaf { get; protected set; }
public BPlusTreeNode? Parent { get; set; }
public List<object> Keys { get; protected set; }
protected BPlusTreeNode(bool isLeaf)
{
IsLeaf = isLeaf;
Keys = new List<object>();
}
public abstract int KeyCount { get; }
}
public class BPlusTreeInternalNode : BPlusTreeNode
{
public List<BPlusTreeNode> Children { get; private set; }
public BPlusTreeInternalNode() : base(false)
{
Children = new List<BPlusTreeNode>();
}
public override int KeyCount => Keys.Count;
}
public class BPlusTreeLeafNode : BPlusTreeNode
{
public List<object?> Values { get; private set; }
public BPlusTreeLeafNode? Next { get; set; }
public BPlusTreeLeafNode? Previous { get; set; }
public BPlusTreeLeafNode() : base( {
Values = new List<object?>();
}
public override int KeyCount => Keys.Count;
}
Bắt đầu với một bản thiết kế chung: mọi nút, dù là lá hay không, đều có một danh sách các khóa và biết nút cha của mình là ai.
KeyCount là abstract, nghĩa là mỗi loại nút sẽ triển khai nó theo cách riêng.
Các nút nội bộ thêm một thứ: một danh sách các nút con. Chúng là các biển chỉ dẫn — chúng biết nên gửi bạn đi đâu, chứ không biết câu trả lời là gì.
Các nút lá là nơi dữ liệu thực sự trú ngụ: một danh sách Values khớp tỉ lệ một-một với các khóa.
Cộng thêm các con trỏ Next và Previous tới các lá anh em — đây chính là bí quyết "các trang được đóng ghim". Một chuỗi các lá liên kết đôi.
Hai thao tác chứng minh giá trị của toàn bộ thiết kế
Một cây B+ chứng tỏ giá trị của mình qua hai chiêu thức: nhảy thẳng tới một khóa cụ thể (Search) hoặc thu thập mọi khóa trong một khoảng (Range). Cùng một cái cây, nhưng cơ chế rất khác nhau.
Search: leo xuống các tab chỉ mục để đến đúng trang
public object? Search(object key)
{
lock (_lockObject)
{
var leaf = FindLeafNode(key);
int index = FindKeyIndex(leaf.Keys, key);
if (index < leaf.Keys.Count && _comparer.Compare(leaf.Keys[index], key) == 0)
{
return ((BPlusTreeLeafNode)leaf).Values[index];
}
return null;
}
}
Chiếm khóa (lock) để hai luồng không cùng đọc cây trong khi ai đó đang sắp xếp lại nó.
Hàm FindLeafNode đi bộ từ gốc, chọn đúng nút con ở mỗi biển chỉ dẫn cho đến khi chạm tới một lá.
Bên trong lá đó, tìm kiếm nhị phân sẽ tìm ra vị trí mà khóa đó lẽ ra phải nằm trong số các khóa đã sắp xếp.
Hỏi bộ so sánh (comparer): khóa tại vị trí đó có thực sự bằng thứ chúng ta muốn không? Nếu có, trả về giá trị khớp tương ứng.
Nếu không, khóa này không có trong cây — trả về null.
Range: tìm điểm bắt đầu, rồi đi ngang
public IEnumerable<KeyValuePair<object, object?>> Range(object? minKey, object? maxKey)
{
lock (_lockObject)
{
var results = new List<KeyValuePair<object, object?>>();
var leaf = minKey != null ? FindLeafNode(minKey) : GetFirstLeaf();
while (leaf != null)
{
for (int i = 0; i < leaf.Keys.Count; i++)
{
var key = leaf.Keys[i];
if (minKey != null && _comparer.Compare(key, minKey) < 0)
continue;
if (maxKey != null && _comparer.Compare(key, maxKey) > 0)
return results;
results.Add(new KeyValuePair<object, object?>(key, leaf.Values[i]));
}
leaf = leaf.Next;
}
return results;
}
}
Trả về một IEnumerable gồm các cặp khóa-giá trị nằm giữa minKey và maxKey.
Tìm lá chứa minKey (hoặc lá ngoài cùng bên trái nếu không có giới hạn dưới). Đây là bước "lật trang đến vần S".
Bây giờ đi bộ tiến về phía trước qua các lá được liên kết — không cần quay lại gốc. Đây là toàn bộ lý do tại sao cây được liên kết.
Bỏ qua bất cứ thứ gì nằm dưới minKey. Nếu chúng ta vượt qua maxKey, nghĩa là xong — thoát ra.
Nếu không, thu thập cặp khóa-giá trị và tiếp tục.
Di chuyển sang lá tiếp theo thông qua con trỏ Next. Đó chính là tác dụng của việc "đóng ghim" giữa các trang.
Cây này sử dụng các nút bậc (order) là 4 (3 khóa, 4 nút con). Các cơ sở dữ liệu thực tế sử dụng bậc từ 100 trở lên. Fanout cao hơn nghĩa là cây nông hơn, giúp giảm số lần đọc đĩa cho mỗi lần tra cứu.
Ba thao tác, nhìn thoáng qua
Mọi truy vấn mà cơ sở dữ liệu từng chạy đối với một chỉ mục đều gói gọn trong một trong ba bước đi này.
Search — O(log n)
Leo xuống các tab chỉ mục để đến đúng trang, rồi tìm kiếm nhị phân bên trong lá. Một triệu hàng = khoảng 20 lần so sánh.
Insert + Split
Khi một lá bị tràn, hãy cắt đôi nó và đẩy (promote) khóa ở giữa lên nút cha. Một lượt tách (split) làm cây mọc cao lên từ gốc, chứ không phải mọc xuống dưới.
Quét dải (Range scan)
Tìm lá bắt đầu, sau đó đi dọc theo danh sách liên kết của các lá đã đóng ghim theo chiều ngang.
Xây dựng cây: chèn các giá trị 10, 20, 30, 40
Hãy xem một cái cây mọc lên từ con số không, với bậc (order) = 3 (nghĩa là một lá có thể giữ tối đa 2 khóa trước khi phải tách). Bốn lượt chèn, bốn bức ảnh chụp nhanh.
Cây bắt đầu như một lá đơn lẻ. Số 10 đi vào. Không có gì khác cần làm.
Vẫn vừa trong cùng một lá. Các khóa giữ đúng thứ tự sắp xếp.
Lá sẽ phải giữ 3 khóa (vượt quá khả năng). Tách nó ra: lá bên trái giữ [10], lá bên phải lấy [20, 30]. Chúng ta đẩy (promote) 20 lên một nút nội bộ mới toanh và nó trở thành gốc.
Duyệt từ gốc: 40 >= 20, vậy đi sang phải. Lá bên phải đã có [20, 30] và bây giờ cần thêm 40 — lại một lượt tách nữa! Bên trái giữ [20], bên phải trở thành [30, 40], và 30 được đẩy lên gốc. Gốc bây giờ giữ hai khóa.
Đây là code chèn dữ liệu điều phối toàn bộ quá trình đó. Nó ngắn gọn một cách đáng ngạc nhiên — phần đệ quy nằm bên trong SplitLeafNode, có thể khiến các lượt tách sủi bọt lên tận gốc.
public void Insert(object key, object? value)
{
lock (_lockObject)
{
var leaf = FindLeafNode(key);
InsertIntoLeaf(leaf, key, value);
if (leaf.KeyCount > _order - 1)
{
SplitLeafNode(leaf);
}
}
}
Chiếm khóa (lock) để không luồng nào khác có thể sửa đổi cây trong khi chúng ta đang sắp xếp lại nó.
Đi bộ từ gốc xuống dưới để tìm ra lá duy nhất mà khóa này thuộc về.
Thả khóa và giá trị vào lá đó, giữ cho danh sách luôn được sắp xếp.
Nếu lá hiện đang giữ nhiều khóa hơn mức cho phép, hãy tách nó ra — và lượt tách đó có thể lan truyền ngược lên trên.
Kiểm tra nhanh — áp dụng những gì bạn đã thấy
Bốn tình huống ngắn gọn. Không chấm điểm — mục tiêu là để mở rộng các ý tưởng chứ không phải học thuộc lòng chúng.
Truy vấn của bạn là WHERE user_id BETWEEN 1000 AND 2000. Thao tác cây B+ nào sẽ chạy, và tại sao nó lại rẻ?
Sau nhiều lượt chèn, cây của bạn cao lên trông thấy và việc tìm kiếm cảm giác chậm đi. Chuyện gì đã xảy ra?
Các lá có cả con trỏ Next VÀ Previous, nhưng phương thức Range hiện tại chỉ dùng Next. Tại sao lại giữ Previous?
Chèn các giá trị 5, 15, 25, 35 theo thứ tự vào một cây trống có bậc là 3 (tối đa 2 khóa mỗi lá). Nút gốc trông như thế nào sau lượt chèn cuối cùng?
Sắp tới: bây giờ bạn đã biết cái cây tồn tại, Module 4 sẽ cho thấy trình lập kế hoạch truy vấn LINQ quyết định khi nào nên sử dụng nó — và khi nào thì việc đọc từng hàng sẽ rẻ hơn.
Chỉ mục & Kế hoạch Truy vấn
Cách cơ sở dữ liệu biến LINQ của bạn thành một lộ trình — và tại sao cùng một câu hỏi có thể lúc nhanh lúc chậm.
Trình lập lộ trình GPS bên trong cơ sở dữ liệu của bạn
Hỏi điện thoại chỉ đường đến sân bay. Nó không thử đi qua mọi con phố — nó liếc qua yêu cầu của bạn, nhận thấy "chỉ đi đường cao tốc" và chọn một lộ trình hiệu quả. Cơ sở dữ liệu của bạn cũng làm điều tương tự với một truy vấn (query).
Bạn viết LINQ kiểu như .Where(r => r["Id"] == 42). Trước khi một hàng dữ liệu đơn lẻ bị chạm tới, một trình lập kế hoạch sẽ kiểm tra yêu cầu của bạn và chọn một chiến lược. Chiến lược đó chính là kế hoạch truy vấn, và nó quyết định liệu truy vấn của bạn chạy trong vài mili giây hay vài phút.
LINQ bạn đã viết
.Where(r => (int)r["Id"] == 42)
"Cho tôi người dùng có Id là 42."
Đường truy cập đã chọn
Chỉ mục tra cứu tại một điểm
SelectByKey(42)
Chi phí
O(log n)
Một lượt leo xuống cây. Nhanh như chớp, ngay cả với 10 triệu hàng.
Cùng một trình lập kế hoạch, ba lộ trình khác nhau
So khớp bằng trên Id
.Where(r => (int)r["Id"] == 42)
Chỉ mục tra cứu điểm — nhảy thẳng tới hàng dữ liệu. O(log n).
Khoảng dải trên Id
.Where(r => (int)r["Id"] > 100)
Quét dải chỉ mục — tìm điểm bắt đầu, rồi đi bộ qua các lá. O(log n + k).
Bộ lọc trên Tên
.Where(r => (string)r["Name"] == "Alice")
Quét toàn bộ bảng + bộ lọc. O(n). Không có chỉ mục trên cột Name, vì vậy mọi hàng phải bị kiểm tra.
LINQ mô tả những gì bạn muốn; trình lập kế hoạch tính toán làm thế nào. Đây là lý do lớn nhất khiến cơ sở dữ liệu giúp tăng năng suất — bạn không cần code tay các vòng lặp. Bạn chỉ cần nói "cho tôi những người dùng có Id = 42" và trình lập kế hoạch sẽ âm thầm chọn giữa việc nhảy một bước qua chỉ mục hay quét một triệu hàng.
Ba giai đoạn của hàm Execute
Khi bạn gọi .ToList() trên một truy vấn LINQ, engine sẽ chạy phương thức Execute. Hãy coi đó là trình GPS đang chạy qua ba bước: chọn cửa vào, lọc những gì đi qua, và sắp xếp những gì còn lại.
public TResult Execute<TResult>(Expression expression)
{
var plan = BuildExecutionPlan(expression);
IEnumerable<DataRow> rows = ExecuteAccessPath(plan);
foreach (var predicate in plan.Predicates)
{
rows = rows.Where(predicate);
}
if (plan.OrderByColumn != null)
{
if (plan.IsOrderByDescending)
rows = rows.OrderByDescending(r => r[plan.OrderByColumn]);
else
rows = rows.OrderBy(r => r[plan.OrderByColumn]);
}
return (TResult)(}
Đọc yêu cầu của người dùng và xây dựng kế hoạch — giống như đọc địa chỉ trước khi chọn lộ trình.
Giai đoạn 1: chọn cửa vào. Chạy đường truy cập đã chọn (nhảy qua chỉ mục, đi bộ theo dải, hoặc quét toàn bộ) để có được luồng hàng dữ liệu bắt đầu.
Giai đoạn 2: lọc những gì đi qua. Với bất kỳ điều kiện nào mà chỉ mục không thể xử lý, hãy kiểm tra từng hàng trong bộ nhớ.
Giai đoạn 3: sắp xếp những gì còn lại. Nếu người dùng yêu cầu thứ tự, hãy tráo đổi những hàng còn sống sót vào đúng trình tự.
Giao lại luồng các hàng dữ liệu kết quả cho bên gọi — lười biếng (lazy), sẵn sàng để lặp qua.
Lưu ý trình tự: đường truy cập trước, bộ lọc thứ hai, sắp xếp sau cùng. Việc sắp xếp luôn diễn ra trong bộ nhớ đối với bất kỳ hàng nào còn sống sót sau bộ lọc. Đó là lý do tại sao ORDER BY không bao giờ làm tăng tốc truy vấn — nó chỉ có thể làm chậm đi thôi.
Chọn cửa vào: ExecuteAccessPath
Đây là trái tim của trình lập kế hoạch. Một nấc thang kiểm tra vị ngữ (predicate) ngắn gọn sẽ quyết định xem truy vấn của bạn nhảy, đi bộ hay quét.
private IEnumerable<DataRow> ExecuteAccessPath(QueryExecutionPlan plan)
{
if (!string.IsNullOrEmpty(_primaryKeyColumn) && plan.IndexRange != null)
{
if (plan.IndexRange.ExactKey != null)
{
var single = _table.SelectByKey(plan.IndexRange.ExactKey);
return single != null ? new List<DataRow> { single } : new List<DataRow>();
}
if (plan.IndexRange.HasLowerBound || plan.IndexRange.HasUpperBound)
{
return _table.SelectByPrimaryKeyRange(plan.IndexRange.LowerBound, plan.IndexRange.UpperBound);
}
}
return _table.SelectAll();
}
Bắt đầu nấc thang if-ladder. Đầu tiên: chúng ta có chỉ mục trên khóa chính không, và người dùng có đưa ra một bộ lọc thân thiện với chỉ mục không?
Khóa chính xác? Trường hợp tốt nhất. Một lần nhảy vào chỉ mục là xong.
Trả về danh sách chính xác một hàng, hoặc danh sách trống nếu không có gì khớp. Không quét, không lọc, không tốn công vô ích.
Khoảng dải? Trường hợp tốt thứ nhì. Bộ lọc có dạng như Id > 100 hoặc Id BETWEEN 10 AND 20.
Đi bộ qua các lá đã sắp xếp của chỉ mục từ giới hạn dưới đến giới hạn trên. Vẫn nhanh hơn nhiều so với việc quét toàn bộ.
Không cái nào ở trên? Phương án dự phòng. Đọc mọi hàng trong bảng và để các giai đoạn sau lọc chúng trong bộ nhớ.
Đọc từ trên xuống dưới: khóa chính xác? nhảy một lần. khoảng dải? đi bộ qua lá. không cái nào cả? quét toàn bộ. Đó là toàn bộ trình lập kế hoạch — ba dòng quyết định thực sự ngồi trên đỉnh của một cây B+.
Nơi kế hoạch gặp cái cây
Khi kế hoạch chọn "khóa chính xác", quyền điều khiển chuyển sang SelectByKey — điểm mà lớp LINQ cuối cùng cũng gọi vào cây B+ mà bạn đã xây dựng ở Module 3.
public DataRow? SelectByKey(object key)
{
_lock.EnterReadLock();
try
{
var serialized = _index.Search(key);
if (serialized == null)
return null;
return DeserializeRow((byte[])serialized);
}
finally
{
_lock.ExitReadLock();
}
}
Chiếm một khóa đọc để các luồng đọc khác có thể cùng vào, nhưng luồng ghi thì phải chờ tới lượt.
Yêu cầu chỉ mục cây B+ tìm kiếm khóa này. Một lượt leo từ gốc xuống lá.
Không tìm thấy gì? Trả về null — bên gọi sẽ biến nó thành kết quả trống.
Tìm thấy rồi! Biến các byte đã lưu trữ ngược lại thành một hàng dữ liệu mà bên gọi có thể sử dụng.
Dù chuyện gì xảy ra, phải giải phóng khóa đọc trước khi rời đi.
Ba góc nhìn của cùng một truy vấn
Hãy xem cách những gì bạn viết trở thành một đối tượng kế hoạch rồi trở thành một lệnh gọi phương thức trên Bảng. Nhấp vào từng tab:
Tab 1: ý định khai báo của bạn. Chưa có gì thực thi cả — LINQ chỉ là một mô tả.
Hành trình ba bước này — ý định → kế hoạch → thao tác — chính là lý do SQL và LINQ mang lại cảm giác kỳ diệu. Visitor pattern biến lambda của bạn thành một cấu trúc dữ liệu, và cấu trúc dữ liệu đó chọn ra thao tác vật lý.
Tìm lỗi: truy vấn này sẽ không dùng chỉ mục
Đây là một truy vấn trông có vẻ như lẽ ra phải là O(log n). Nhưng lập trình viên đã mắc một sai lầm nhỏ, và bây giờ nó quét toàn bộ 10 triệu hàng mỗi lần chạy. Bạn có thấy nó không?
Tìm dòng lệnh làm hỏng chỉ mục:
var x = db.Query("Users")
.Where(r => ((int)r["Id"]).ToString() == "42")
.ToList();
Trình lập kế hoạch chỉ nhận diện được các mẫu có dạng r["Id"] == hằng_số. Khoảnh khắc bạn bọc cột dữ liệu trong một lời gọi hàm — .ToString(), Math.Abs(), Substring() — mẫu khớp của visitor sẽ thất bại và truy vấn quay lại phương án quét toàn bộ. Đây cũng chính là sai lầm giống như khi viết WHERE TO_CHAR(id) = '42' trong SQL. Hãy để cột dữ liệu "trần trụi" ở một bên của phép so sánh.
Chỉ có khóa chính là có chỉ mục. Bất kỳ bộ lọc nào trên cột khác — .Where(r => r["Name"] == "Alice"), .Where(r => r["Email"].Contains("@")) — sẽ luôn luôn quét toàn bộ bảng. Biết trước điều này sẽ giúp bạn không đổ lỗi cho AI, những lần chậm máy bí ẩn hay ma quỷ khi một truy vấn trở nên chậm chạp. Nếu không phải là khóa chính, đó là O(n).
Kiểm tra bản năng lập kế hoạch lộ trình của bạn
Bốn tình huống. Không cần học thuộc lòng — chỉ cần áp dụng những gì bạn đã thấy.
1. Cho .Where(r => (int)r["Age"] > 25).Where(r => (int)r["Id"] == 7) — lệnh Where nào giúp ích cho chỉ mục?
2. Một người dùng phàn nàn truy vấn của họ chậm đi sau khi thêm ORDER BY. Chuyện gì đã xảy ra?
3. Bạn muốn thêm một chỉ mục phụ trên cột Email. Lớp nào cần thay đổi lớn nhất?
4. Cho .Where(r => (int)r["Id"] >= 10 && (int)r["Id"] < 20), hãy mô tả kế hoạch.
Tiếp theo: các lượt đọc thì dễ — chúng chỉ cần nhanh là được. Các lượt ghi thì khó hơn. Chúng phải sống sót qua sự cố sập nguồn, mất điện và các giao dịch đang dở dang. Module 5 sẽ vén màn bí mật về tính bền vững.
Giao dịch, WAL và Khôi phục sau sự cố
Cách cơ sở dữ liệu sống sót qua sự cố mất điện mà không làm mất tiền của bạn.
Quầy làm thủ tục tại sân bay
Bạn đưa cho nhân viên hai tấm vé và một chiếc vali. Trước khi bất cứ thứ gì được đóng dấu lên thẻ lên máy bay, nhân viên đó ghi mọi mục vào một cuốn sổ nhật ký chính thức dày cộp, theo thứ tự: "nhóm hai người, vali đã dán nhãn, chỗ ngồi đã được phân." Sau đó họ mới in thẻ lên máy bay. Nếu điện bị cắt giữa khâu làm thủ tục, nhân viên tiếp theo vào buổi sáng sẽ đọc sổ nhật ký và chỉ hoàn tất những lượt làm thủ tục được đánh dấu "đã xong". Bất cứ thứ gì không có dấu đã xong sẽ bị loại bỏ.
Đó là cách các hãng hàng không tránh làm mất hành lý của bạn — và đó chính xác là cách cơ sở dữ liệu tránh làm mất tiền của bạn. Trong module này chúng ta sẽ làm quen với cuốn sổ nhật ký: WAL, và các quy tắc làm cho một giao dịch (transaction) hoặc là xảy ra đầy đủ, hoặc là biến mất hoàn toàn.
Hãy tưởng tượng một lệnh chuyển khoản ngân hàng. Bạn trừ tiền ở tài khoản A, rồi cộng tiền vào tài khoản B. Điều gì xảy ra nếu điện vụt tắt giữa hai bước đó? Nếu không có cam kết giao dịch, tài khoản A nghèo đi và tài khoản B không giàu thêm. Tiền đơn giản là bốc hơi. Những quy tắc dưới đây tồn tại để điều đó không bao giờ xảy ra.
Atomicity (Tính nguyên tử)
Tất cả hoặc không có gì. Không bao giờ có chuyện làm dở dang.
Consistency (Tính nhất quán)
Các bất biến (như "tổng số dư không đổi") được giữ vững trước và sau giao dịch.
Isolation (Tính cô lập)
Các giao dịch chạy đồng thời trông như thể chúng chạy từng cái một.
Durability (Tính bền vững)
Đã xác nhận (committed) nghĩa là đã xác nhận, ngay cả khi máy chủ phát nổ.
Bốn chữ cái này ghép lại thành ACID — tiêu chuẩn mà mọi cơ sở dữ liệu nghiêm túc đều phải vượt qua.
Ghi nhật ký trước, áp dụng sau
Đây là quy tắc vàng cung cấp sức mạnh cho mọi thứ trong module này. Khi một giao dịch muốn thay đổi dữ liệu, cơ sở dữ liệu không chạm vào dữ liệu thật trước. Nó ghi thay đổi dự định đó vào WAL trên đĩa, ép nhật ký đó phải nằm vững trên đĩa, và chỉ sau đó mới áp dụng thay đổi. Ghi nhật ký trước, áp dụng sau.
Nếu đảo ngược trình tự đó, bạn sẽ nhận lấy sự hư hỏng dữ liệu. Áp dụng trước và hy vọng sẽ ghi nhật ký sau? Nếu sự cố sập nguồn rơi vào giữa hai bước đó, thay đổi đã nằm trên đĩa nhưng nhật ký không biết, vì vậy quá trình khôi phục sẽ không bao giờ phát lại hoặc hoàn tác nó. Toàn bộ hệ thống được xây dựng để tránh khoảng hở đó.
Hãy xem một giao dịch thực tế len lỏi qua engine. Bốn nhân vật: Người dùng (khách hàng), Giao dịch (người giữ sổ sách), WAL (sổ nhật ký trên đĩa), và Cây B+ (nơi các hàng dữ liệu thực sự trú ngụ).
Nếu bản ghi commit đã nằm trên đĩa, quá trình khôi phục sẽ phát lại thay đổi. Nếu không, nó chưa từng xảy ra. Không có trạng thái thứ ba nào cả.
Bên trong một mục nhật ký
Mọi dòng trong sổ nhật ký là một WALEntry. Một mục cho mỗi thao tác: "giao dịch 42 đã chèn Alice," "giao dịch 42 đã commit," và cứ thế. Các trường thông tin được chọn sao cho khâu khôi phục có mọi thứ nó cần — giá trị cũ để hoàn tác (undo), giá trị mới để làm lại (redo), và một số thứ tự để chúng ta biết chính xác trình tự các sự kiện đã xảy ra.
public class WALEntry
{
private const int MaxCheckpointActiveTransactionCount = 100_000;
private const int MaxStringByteLength = 64 * 1024;
private const int MaxValueLength = 1024 * 1024;
public long TransactionId { get; set; }
public WALOperationType OperationType { get; set; }
public string TableName { get; set; } = string.Empty;
public object? Key { get; set; }
public byte[]? OldValue { get; set; }
public byte[]? NewValue { get; set; }
public long Timestamp { get; set; }
public long SequenceNumber { get; set; }
public List<long> CheckpointActiveTransactionIds { get; set; } = new();
public long CheckpointNextTransactionId { get; set; }
}
Một mục trong sổ nhật ký. Mọi lượt ghi mà cơ sở dữ liệu muốn thực hiện đều xuất hiện ở đây trước tiên.
Các giới hạn an toàn để một mục đơn lẻ không bao giờ làm phình to nhật ký và gây hỏng khâu khôi phục.
Ai đã thực hiện — mục này thuộc về giao dịch nào.
Loại sự kiện gì: Bắt đầu, Chèn, Cập nhật, Xóa, Xác nhận, Hoàn tác, hoặc Điểm kiểm tra (Checkpoint).
Bảng nào và hàng nào (khóa chính) bị ảnh hưởng bởi thay đổi.
OldValue (Giá trị cũ) + NewValue (Giá trị mới) cùng nhau tạo nên phép màu: cũ giúp hoàn tác, mới giúp làm lại.
Dấu thời gian và SequenceNumber (Số thứ tự) cung cấp cho mỗi mục một trình tự tuyệt đối — cực kỳ quan trọng để phát lại nhật ký.
Sổ sách cho các điểm kiểm tra (checkpoints) — chủ đề của một phần sau.
Khoảnh khắc xác nhận (commit)
Một lệnh xác nhận (commit) không phải là một cái công tắc. Đó là một vũ điệu ba bước: ghi dấu commit, flush nhật ký WAL xuống đĩa, rồi mới áp dụng. Ranh giới giữa "có thể mất cái này" và "chắc chắn an toàn" nằm ở một lời gọi hàm duy nhất: Flush().
public void Commit()
{
_lock.EnterWriteLock();
try
{
if (_state != TransactionState.Active)
throw new InvalidOperationException($"Không thể xác nhận giao dịch ở trạng thái: {_state}");
// Ghi nhật ký commit
_walManager.AppendEntry(new WALEntry
{
TransactionId = _transactionId,
OperationType = WALOperationType.Commit
});
// Ép flush để đảm bảo tính bền vững
_walManager.Flush();
// Chỉ áp dụng các lượt ghi trong bộ đệm sau khi WAL commit đã bền vững trên đĩa.
_commitApplyCallback(_entries);
_state = TransactionState.Committed;
_transactionManager.CompleteTransaction(_transactionId);
}
finally
{
_lock.ExitWriteLock();
}
}
Ai đó đã gọi hàm commit(). Hãy chiếm lấy khóa ghi để không ai khác can thiệp trong khi chúng ta đang hoàn tất.
Kiểm tra tính hợp lệ: bạn chỉ có thể xác nhận một giao dịch vẫn còn đang Hoạt động (Active) — không thể xác nhận hai lần.
Bước 1 — ghi nhật ký. Thêm một dấu Commit vào WAL: "giao dịch 42 đã xong."
Bước 2 — flush. Đây là khoảnh khắc định hình tính ACID. Hàm Flush() ép nhật ký vào đĩa vật lý. Nếu chúng ta sập nguồn sau dòng này, khâu khôi phục sẽ phát lại giao dịch.
Bước 3 — áp dụng. Chỉ bây giờ chúng ta mới đẩy các lượt chèn và cập nhật đang nằm trong bộ đệm vào Cây B+ thật.
Đánh dấu giao dịch đã xác nhận (Committed) và báo cho quản lý rằng chúng ta đã xong. Giải phóng khóa.
Engine này không áp dụng các thay đổi vào Cây B+ cho đến khi commit thành công — mọi lệnh Insert, Update, và Delete đều được đệm bên trong giao dịch. Điều đó giúp việc hoàn tác (rollback) gần như miễn phí: chưa có gì cần hoàn tác cả. Sự đánh đổi: các giao dịch khác không thể thấy các lượt ghi đang dở dang của bạn.
Hãy nhắc lại một lần nữa: ghi nhật ký trước, áp dụng sau.
Sáng hôm sau vụ sập nguồn
Sập nguồn xảy ra. Những giao dịch mới viết được một nửa, có thể một dấu commit đã kịp ghi xuống đĩa, có thể một cái thì không. Lần tới khi engine khởi động, nó chạy hàm RecoverFromWAL: đọc toàn bộ sổ nhật ký, gom các mục theo giao dịch, chỉ phát lại những nhóm có dấu Commit, vứt bỏ phần còn lại.
Dưới đây là code khôi phục, đã được lược bớt phần xử lý lỗi. Hãy chú ý quy tắc này đơn giản như thế nào: nếu có mục Commit thì phát lại; nếu không thì bỏ qua.
public void RecoverFromWAL(Action<WALEntry> applyEntry)
{
_lock.EnterWriteLock();
try
{
var entries = _walManager.ReadEntriesForRecovery();
var transactions = new Dictionary<long, List<WALEntry>>();
var committedTransactions = new HashSet<long>();
foreach (var entry in entries)
{
if (entry.OperationType == WALOperationType.Checkpoint) continue;
if (entry.OperationType == WALOperationType.Commit)
committedTransactions.Add(entry.TransactionId);
else if (entry.OperationType == WALOperationType.Rollback)
continue;
else if (entry.OperationType != WALOperationType.BeginTransaction)
{
if (!transactions.ContainsKey(entry.TransactionId))
transactions[entry.TransactionId] = new List<WALEntry>();
transactions[entry.TransactionId].Add(entry);
}
}
foreach (var txnId in committedTransactions)
{
if (transactions.TryGetValue(txnId, out var txnEntries))
{
foreach (var entry in txnEntries)
applyEntry(entry);
}
}
}
finally { _lock.ExitWriteLock(); }
}
Nắm quyền kiểm soát nhật ký — không ai khác được phép ghi trong khi đang khôi phục.
Đọc mọi mục mà cuốn sổ nhật ký đang có.
Chuẩn bị hai ngăn chứa: một ngăn gom theo giao dịch, cộng với một tập hợp các id giao dịch đã xác nhận thành công.
Duyệt qua nhật ký. Bỏ qua các mục Điểm kiểm tra (Checkpoint) (chúng là những gợi ý, không phải các thay đổi).
Nếu chúng ta thấy dấu Commit, đánh dấu giao dịch đó là "vâng, hãy hoàn tất cái này."
Các mục Rollback và BeginTransaction không có dữ liệu để phát lại — hãy bỏ qua chúng.
Với mọi thay đổi thực tế (Chèn, Cập nhật, Xóa), hãy thả nó vào đúng ngăn giao dịch của nó.
Bây giờ là lúc gặt hái. Lặp qua chỉ những id giao dịch đã xác nhận, và phát lại mọi mục trong các ngăn đó lên chỉ mục.
Mọi thứ khác — các giao dịch làm dở dang mà không có dấu commit — sẽ bị vứt bỏ âm thầm. Đó chính là tính nguyên tử (atomicity) đang hoạt động.
Câu thần chú lần nữa: ghi nhật ký trước, áp dụng sau. Khâu khôi phục chỉ tin vào những gì đã được ghi nhật ký.
Giữ cho sổ nhật ký luôn gọn gàng
Nếu WAL cứ mọc dài mãi mãi, khâu khôi phục cũng sẽ mất mãi mãi. Vì vậy, engine định kỳ thực hiện một điểm kiểm tra (checkpoint): flush mọi trang dirty trong bộ nhớ xuống đĩa, ghi một mục WAL Checkpoint đặc biệt, và cắt bỏ phần cũ nhất của nhật ký. Sổ nhật ký sẽ bắt đầu lại từ một khoảnh khắc an toàn đã biết.
Mọi trang có thay đổi trong bộ nhớ đều được ghi ra đĩa, thông qua lệnh fsync, để Cây B+ trên đĩa khớp với những gì các giao dịch đã xác nhận nói.
Nó liệt kê các giao dịch vẫn đang hoạt động tại thời điểm đó (CheckpointActiveTransactionIds) và id tiếp theo mà engine sẽ cấp phát.
Mọi thứ trước điểm kiểm tra đã được chứng minh là đã nằm trên đĩa trong Cây B+ — an toàn để lãng quên.
Việc phát lại sẽ bắt đầu từ điểm kiểm tra mới nhất thay vì từ lúc khai thiên lập địa. Thời gian khởi động lại được giới hạn bất kể cơ sở dữ liệu đã chạy bao lâu.
Các điểm kiểm tra, các lượt ghi trì hoãn và vũ điệu commit đều dựa trên cùng một nền tảng: ghi nhật ký trước, áp dụng sau.
Hãy suy nghĩ thấu đáo
Bốn tình huống. Hãy chọn đáp án mà một kỹ sư cơ sở dữ liệu kỳ cựu sẽ đưa ra. Các phần giải thích chính là nơi bạn thực sự học được — hãy đọc chúng ngay cả khi bạn chọn đúng.
Điện vụt tắt sau khi bản ghi commit đã được ghi vào WAL nhưng trước khi chỉ mục được cập nhật. Khi khởi động lại, hàng dữ liệu đã chèn có ở đó không?
Một giao dịch chèn hàng A, sau đó chèn hàng B, và điện vụt tắt trước khi hàm commit() được gọi. Cơ sở dữ liệu trông như thế nào sau khi khởi động lại?
Đồng nghiệp của bạn nói: "Sau khi dùng lệnh kill -9 giữa đợt kiểm tra, chúng ta bị thiếu các hàng mà khách hàng đã nhận được phản hồi là đã xác nhận thành công." Bạn sẽ tìm ở đâu đầu tiên?
Một kỹ sư trẻ hỏi: "Đối với lệnh rollback, phần code nào sẽ đảo ngược các thay đổi trong Cây B+?" Bạn sẽ nói gì với họ?
Module tiếp theo: Tính đồng thời (Concurrency). Nhiều giao dịch cùng lúc — làm sao engine giữ cho chúng không dẫm chân lên nhau?
Tính đồng thời: Nhiều bàn tay, một cuốn sổ cái
Cách cơ sở dữ liệu cho phép hàng chục người cùng đọc và ghi một lúc mà không ai đâm sầm vào ai.
Cửa xoay tàu điện ngầm
Hãy tưởng tượng một cánh cửa xoay tại ga tàu điện ngầm. Nhiều người có thể cùng đi qua một lúc, miễn là tất cả họ đều đi cùng hướng. Nhưng nếu một người dừng lại ở giữa để buộc dây giày, mọi người khác phải đợi cho đến khi họ xong việc.
Đó chính xác là cách cơ sở dữ liệu xử lý tính đồng thời (concurrency). Nhiều luồng đọc có thể cùng đi qua. Một luồng ghi duy nhất sẽ chiếm trọn cánh cửa. Bí quyết là đảm bảo không ai đâm sầm vào ai.
Hai người dùng cùng nhấn "Mua" cho tấm vé cuối cùng vào cùng một mili giây chính xác. Ai sẽ nhận được vé? Nếu cơ sở dữ liệu trả lời sai dù chỉ một lần, doanh nghiệp sẽ mất lòng tin. Kiểm soát đồng thời là lớp vô hình trả lời câu hỏi đó hàng tỷ lần mỗi ngày.
Cánh cửa có ba trạng thái
Một khóa reader-writer theo dõi xem cánh cửa hiện đang ở trạng thái nào.
Cánh cửa đứng yên. Người tiếp theo (đọc hoặc ghi) có thể bước ngay vào.
Có thêm người đọc vẫn có thể tham gia. Người ghi phải chờ bên ngoài cho đến khi mọi người đọc đã rời đi.
Cánh cửa bị khóa với tất cả những người khác. Không người đọc nào, không người ghi nào khác được vào cho đến khi người ghi này rời đi.
Người đọc và người ghi không bao giờ có thể cùng tồn tại. Chỉ trạng thái IDLE mới kết nối họ.
Năm cánh cửa, xếp chồng lên nhau
Một cơ sở dữ liệu thực thụ không chỉ có một cánh cửa. Nó có tận năm cái, lồng vào nhau như búp bê Nga. Mỗi cánh cửa bảo vệ một phần khác nhau của hệ thống, từ toàn bộ cơ sở dữ liệu xuống đến tận một trang cache nhỏ xíu.
Khi một thao tác cần đi qua nhiều hơn một cánh cửa, nó phải luôn lấy chúng theo cùng một trình tự. Quy tắc duy nhất đó ngăn chặn cả một lớp các lỗi gọi là tắc nghẽn (deadlock).
Bảo vệ các thao tác ở cấp độ schema, như tạo hoặc xóa bỏ toàn bộ một bảng.
Điều phối đọc và ghi theo từng bảng. Mỗi bảng có khóa reader-writer của riêng mình.
Bảo vệ các thay đổi cấu trúc cây như tách nút (node splits) và liên kết lại các lá.
Bảo vệ đầu vào và đầu ra ở cấp độ trang đĩa khi các khối được đọc và ghi.
Bảo vệ các bộ nhớ đệm trong RAM để theo dõi trang nào đang "nóng" và trang nào có thể bị loại bỏ.
Quy tắc thì dễ nói nhưng cực kỳ quan trọng để tuân theo: luôn chiếm các khóa theo thứ tự từ cấp độ 1 xuống cấp độ 5. Đừng bao giờ làm ngược lại. Bản hợp đồng đó được ghi trực tiếp vào mã nguồn dưới dạng chú thích.
/// <summary>
/// An toàn Luồng (Thread Safety):
/// Lớp này sử dụng ReaderWriterLockSlim để đảm bảo các thao tác an toàn luồng.
/// Nhiều luồng có thể đọc đồng thời, nhưng các thao tác ghi là độc quyền.
///
/// Thứ tự Khóa (để ngăn chặn deadlock khi khóa lồng nhau):
/// Khi cần chiếm nhiều khóa, luôn chiếm chúng theo thứ tự sau:
/// 1. Database._lock (thao tác cấp độ schema)
/// 2. Table._lock (thao tác cấp độ bảng)
/// 3. BPlusTree._lockObject (thao tác cấp độ chỉ mục)
/// 4. StorageEngine._lock (lớp này - thao tác lưu trữ)
/// 5. Khóa PageCache/ExtentCache (thao tác bộ nhớ đệm)
/// </summary>
public class StorageEngine : IDisposable
{
private readonly ReaderWriterLockSlim _lock;
Đây là một khối chú thích ở đầu tệp tin giải thích các quy tắc an toàn luồng cho bất kỳ ai đọc code.
Nó thông báo rằng lớp này sử dụng ReaderWriterLockSlim, chính là cánh cửa reader-writer ở màn hình trước.
Nhiều luồng có thể đọc cùng lúc. Các lượt ghi sẽ chiếm trọn cánh cửa một mình.
Đây là bản hợp đồng thực sự. Khi một thao tác cần giữ nhiều hơn một khóa, chúng phải được lấy theo đúng trình tự này.
Năm cấp độ, từ ngoài cùng (toàn bộ database) vào đến trong cùng (trang cache).
Lấy khóa theo đúng thứ tự này là một cam kết. Hai luồng cùng tuân thủ nó sẽ không bao giờ bị deadlock.
Bản thân lớp này chỉ là một lớp C# thông thường, và trường lock riêng tư của nó là một ReaderWriterLockSlim.
Trình biên dịch sẽ không ngăn bạn vi phạm nó. Chỉ có kỷ luật khi xem xét code (code review) mới làm được. Mọi phương thức mới lấy hai khóa đều tiềm ẩn nguy cơ deadlock cho đến khi được chứng minh là an toàn.
Đọc và Ghi: Một từ thay đổi tất cả
Trong mã code, sự khác biệt duy nhất giữa một thao tác đọc và một thao tác ghi chỉ là một từ. Nhưng từ đó thay đổi hoàn toàn cách cánh cửa hành xử.
Hãy chú ý cách cả hai ví dụ đều bọc công việc của chúng trong khối try/finally. Khóa phải được giải phóng ngay cả khi công việc bị nổ tung giữa chừng. Quên mất dòng lệnh đó chính là cách một cơ sở dữ liệu bị đóng băng vĩnh viễn.
public void Insert(DataRow row, Transaction.Transaction? transaction = null)
{
_lock.EnterWriteLock();
try
{
var key = GetPrimaryKey(row);
ValidateRowForWrite(row);
var keyExists = _index.Search(key) != null;
var keyPendingInTransaction = transaction?.HasBufferedValueForKey(_schema.TableName, key, GetPrimaryKeyDataType()) ?? false;
if (keyExists || keyPendingInTransaction)
throw new InvalidOperationException($"Duplicate primary key value '{key}'.");
var serialized = SerializeRow(row);
if (transaction != null)
{
transaction.LogInsert(_schema.TableName, key, serialized);
_nextRowId++;
return;
}
_index.Insert(key, serialized);
_nextRowId++;
}
finally
{
_lock.ExitWriteLock();
}
}
Chèn một hàng dữ liệu mới vào bảng. Tùy chọn nó có thể là một phần của một giao dịch.
Đầu tiên, hãy chiếm lấy khóa ghi. Chỉ một luồng duy nhất trong toàn bộ chương trình có thể ở bên trong khối lệnh này tại một thời điểm cho bảng này.
Từ đây trở đi, mọi thứ phải được bảo vệ. Khối try bắt đầu vùng được bảo vệ.
Tính toán khóa chính của hàng dữ liệu đang đi vào, sau đó kiểm tra xem hàng dữ liệu có hợp lệ không.
Khóa này đã có trong chỉ mục chưa? Nó đã đang được thêm bởi một giao dịch chưa xác nhận chưa?
Nếu một trong hai là đúng, hãy từ chối lệnh chèn. Bạn không thể có hai hàng dùng chung khóa chính.
Biến đối tượng hàng dữ liệu thành byte để nó có thể được lưu trữ.
Nếu việc này đang diễn ra bên trong một giao dịch, đừng ghi vào chỉ mục vội. Chỉ cần nhớ nó trong bộ đệm giao dịch để xử lý sau.
Nếu không, hãy ghi thẳng nó vào chỉ mục Cây B+ và tăng bộ đếm hàng.
Khối finally sẽ chạy dù chuyện gì xảy ra — thành công, ngoại lệ, bất cứ điều gì. Đây là nơi chúng ta giải phóng khóa ghi để những người khác có thể vào cửa.
public DataRow? SelectByKey(object key)
{
_lock.EnterReadLock();
try
{
var serialized = _index.Search(key);
if (serialized == null)
return null;
return DeserializeRow(( }
finally
{
_lock.ExitReadLock();
}
}
Tra cứu một hàng dữ liệu đơn lẻ bằng khóa chính của nó.
Chiếm lấy khóa đọc, không phải khóa ghi. Nhiều luồng đọc khác có thể cùng ở đây một lúc.
Bắt đầu vùng được bảo vệ. Miễn là chúng ta còn ở bên trong, cấu trúc của bảng sẽ không thay đổi bên dưới chân chúng ta.
Yêu cầu chỉ mục đưa ra các byte khớp với khóa này.
Nếu không tìm thấy gì, trả về null.
Nếu thấy, hãy biến các byte ngược lại thành đối tượng hàng dữ liệu và giao nó cho bên gọi.
Giải phóng khóa đọc. Một khi người đọc cuối cùng rời đi, một người ghi đang chờ cuối cùng cũng có thể vào cửa.
EnterWriteLock nghĩa là "Tôi cần cánh cửa này cho riêng mình." EnterReadLock nghĩa là "Tôi sẽ chia sẻ." Chọn sai một cái là dẫn đến lỗi về tính đúng đắn hoặc thảm họa về hiệu năng.
Engine này khóa toàn bộ bảng cùng một lúc — đơn giản, nhưng có nghĩa là một người ghi sẽ chặn đứng tất cả những người đọc của bảng đó. Các cơ sở dữ liệu thực thụ sử dụng khóa ở cấp độ hàng hoặc trang để cho phép nhiều tính song song hơn, với cái giá là code phức tạp hơn nhiều. Đó chính là sự đánh đổi về độ mịn (granularity).
Khi cánh cửa bị kẹt: Một vụ Deadlock sắp xảy ra
Dưới đây là những gì xảy ra khi hai luồng quên mất quy tắc về thứ tự. Luồng A lấy Khóa A, Luồng B lấy Khóa B, sau đó mỗi bên lại muốn cái mà bên kia đang giữ. Bây giờ không ai có thể nhúc nhích. Đây chính là tình trạng tương tranh (race condition) mà mọi hệ thống đồng thời đều phải phòng vệ.
Tìm lỗi
Dưới đây là một phương thức chiếm lấy hai trong số năm chiếc khóa từ chồng lớp của chúng ta. Trông nó thật vô hại. Nó thậm chí còn có khối try/finally. Nhưng ở đâu đó trong này, quy tắc về thứ tự đã bị phá vỡ. Nếu một luồng khác đang thực hiện việc lấy khóa theo trình tự 1 → 5 thông thường, hai luồng có thể chèn ép lẫn nhau.
Nhấp vào dòng lệnh mà bạn nghĩ là sai.
Tìm dòng vi phạm thứ tự lấy khóa:
public void Weird()
{
_cache.Lock.EnterWriteLock(); // cấp độ 5
try
_storage.Lock.EnterWriteLock(); // cấp độ 4
// ...
finally { _cache.Lock.ExitWriteLock(); }
Chính kiểu lỗi này là nguyên nhân khiến các hệ thống vận hành thực tế bị treo vào lúc 3 giờ sáng. Hiểu rõ thứ tự khóa của hệ thống giống như hiểu rõ các lối thoát hiểm — hiếm khi cần dùng, nhưng khi cần thì nó vô giá.
Kiểm tra hiểu biết
Bốn tình huống. Đừng học thuộc lòng — hãy sử dụng những gì bạn vừa biết về những cánh cửa, người đọc, người ghi và thứ tự lấy khóa.
Lượt đọc thì nhanh, nhưng thỉnh thoảng các lượt ghi làm đình trệ toàn bộ bảng trong vài giây. Điều gì khả thi nhất đang diễn ra?
Ứng dụng bị treo mãi mãi khi hai luồng cùng gọi Method1 và Method2 tại cùng một thời điểm. Đâu là thứ đầu tiên cần kiểm tra?
Tại sao BPlusTree lại dùng một lệnh lock(obj) đơn giản thay vì ReaderWriterLockSlim?
Bạn sửa một hàng dữ liệu bên trong một giao dịch. Một luồng khác gọi hàm SelectAll() trước khi bạn commit. Luồng kia sẽ thấy gì?
Tiếp theo — Module 7: Khả năng quan sát. Giờ bạn đã biết nó hoạt động thế nào, làm sao để thấy nó đang hoạt động lúc chương trình đang chạy?
Khả năng quan sát (Observability) — Theo dõi Engine vận hành
Bảng điều khiển trong buồng lái cho một cơ sở dữ liệu không thể mở ra kiểm tra giữa chuyến bay.
Bay mù không phải là một chiến lược
Cơ sở dữ liệu của bạn đã chạy được ba ngày. Mọi thứ có ổn không? Các lượt commit có diễn ra không? Bao nhiêu lượt? Bao nhiêu lượt bị rollback? Nếu bạn không thể trả lời những câu hỏi đó trong vòng dưới năm giây, hệ thống của bạn không có khả năng quan sát.
Một phi công không thể mở mui một chiếc 747 ở độ cao 10.000 mét. Họ dựa vào bảng khí cụ trong buồng lái — các đồng hồ đo, đèn cảnh báo và máy ghi âm buồng lái. Một cơ sở dữ liệu đang chạy cũng giống hệt như vậy. Bạn không thể đóng băng nó giữa một giao dịch để liếc nhìn vào bên trong. Bạn cần những công cụ báo cáo ra bên ngoài khi nó đang "bay".
Các số liệu (Metrics) là các đồng hồ đo trên bảng điều khiển — độ cao, nhiên liệu, tốc độ bay. Nhật ký có cấu trúc là máy ghi âm giọng nói trong buồng lái — những chi tiết phong phú về mọi sự kiện, được giữ lại để xem xét sau. Bạn cần cả hai.
Một câu nói. Hai thực tế.
Dưới đây là cùng một sự kiện — một hàng dữ liệu được chèn — được ghi lại theo hai cách khác nhau. Cả hai đều "chạy". Chỉ có một cách là có thể sử dụng được sau khi sự việc đã xảy ra.
Console.WriteLine("insert done");
Không dấu thời gian.
Không tên bảng.
Không id giao dịch.
Không thể được lọc, tìm kiếm hay chuyển đến bất cứ công cụ hữu ích nào.
{"timestamp":"2026-04-22T10:12:03Z",
"level":"Information",
"event":"row.inserted",
"table":"Users",
"txn":42}
Mỗi trường thông tin đều được đặt tên và máy có thể đọc được — đó là định dạng JSON.
Dấu thời gian ISO giúp sắp xếp chính xác giữa các múi giờ.
Tên sự kiện là một từ khóa ổn định mà bạn có thể tìm (grep) qua hàng triệu dòng.
Ngữ cảnh đi kèm với sự kiện — bảng nào, giao dịch nào, bất cứ thứ gì quan trọng.
Khi một trình hỗ trợ code viết Console.WriteLine("đã làm việc đó"), đó là sự ô nhiễm "debug bằng cách in ra", không phải là việc gắn thiết bị đo đạc (instrumentation). Nhận ra sự khác biệt này là một trong những dấu hiệu lớn nhất về chất lượng code chuyên nghiệp.
Máy ghi hộp đen
Mọi dòng nhật ký trong engine là một DatabaseLogEntry — một đối tượng nhỏ, có kiểu dữ liệu mạnh, biết cách tự biến mình thành JSON. Hãy coi nó là một trang được xé ra từ máy ghi âm hành trình.
public sealed class DatabaseLogEntry
{
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
public DatabaseLogLevel Level { get; init; }
public string EventName { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
public IReadOnlyDictionary<string, object?> Properties { get; init; } = new Dictionary<string, object?>();
public string ToJson()
{
var payload = new Dictionary<string, {
["timestamp"] = Timestamp.ToString("O"),
["level"] = Level.ToString(),
["event"] = EventName,
["message"] = Message
};
foreach (var pair in Properties)
{
payload[pair.Key] = pair.Value;
}
return JsonSerializer.Serialize(payload);
}
}
Một mục nhật ký là một bản ghi có hình dạng cố định — một trang của máy ghi âm hành trình.
Nó được sinh ra với dấu thời gian UTC hiện tại đã được đóng dấu sẵn.
Một cấp độ nhật ký cho biết mục này "ồn ào" đến mức nào: Thông tin, Cảnh báo, Lỗi.
Tên sự kiện — như row.inserted — là từ khóa ổn định để bạn tìm kiếm sau này.
Một thông điệp tự do dành cho con người, cộng với một từ điển gồm các thuộc tính bổ sung để tạo ngữ cảnh (tên bảng, id giao dịch, số lượng hàng).
Hàm ToJson() làm phẳng mọi thứ thành một chuỗi JSON: bốn từ khóa tiêu chuẩn trước, sau đó là mọi thuộc tính tùy chỉnh được gắn thêm.
Kết quả là một dòng văn bản mà bất kỳ công cụ nhật ký nào trên trái đất cũng có thể phân tích được.
Các đồng hồ đo trên bảng điều khiển
Nhật ký kể câu chuyện. Các số đo đếm câu chuyện. Mọi sự kiện lớn mà engine thực hiện đều làm tăng một bộ đếm (counter) lên một đơn vị. Dưới đây là toàn bộ cụm đồng hồ đo chỉ trong 30 dòng lệnh.
internal sealed class DatabaseMetrics
{
private long _tablesCreated;
private long _transactionsStarted;
private long _inserts;
private long _updates;
private long _deletes;
private long _flushes;
private long _checkpoints;
private long _backupsCreated;
private long _integrityChecks;
public void IncrementTablesCreated() => Interlocked.Increment(ref _tablesCreated);
public void IncrementTransactionsStarted() => Interlocked.Increment(ref _transactionsStarted);
public void IncrementInserts() => Interlocked.Increment(ref _inserts);
public void IncrementUpdates() => Interlocked.Increment(ref _updates);
public void IncrementDeletes() => Interlocked.Increment(ref _deletes);
public void IncrementFlushes() => Interlocked.Increment(ref _flushes);
public void IncrementCheckpoints() => Interlocked.Increment(ref _checkpoints);
public void IncrementBackupsCreated() => Interlocked.Increment(ref _backupsCreated);
public void IncrementIntegrityChecks() => Interlocked.Increment(ref _integrityChecks);
public DatabaseMetricsSnapshot Snapshot()
{
return new DatabaseMetricsSnapshot
{