Xây dựng cơ sở dữ liệu trong tệp văn bản: Vì sao async vẫn chưa đủ

Ý tưởng của chuỗi bài viết

Trong các buổi phỏng vấn .NET cấp cao, câu hỏi thường không chỉ dừng ở việc “giải thích async/await”. Bạn có thể được hỏi về sự khác nhau giữa SemaphoreSlim và ReaderWriterLockSlim, khi nào nên dùng ValueTask thay cho Task, System.IO.Pipelines khác gì so với Stream thông thường, hay Span<T> thực sự tác động thế nào đến việc cấp phát bộ nhớ.

Điểm khó khi học riêng từng chủ đề là bạn rất khó cảm nhận được vì sao chúng quan trọng. Vì vậy, thay vì lần lượt tìm hiểu lý thuyết, chúng ta sẽ xây dựng một thành phần có tính thực tế: một bộ lưu trữ đơn giản dựa trên tệp. Mỗi vấn đề phát sinh sẽ là lý do để chúng ta áp dụng một giải pháp mới.

Mỗi bài trong chuỗi sẽ bắt đầu từ phiên bản trước, làm cho phiên bản đó thất bại dưới tải cao, rồi khắc phục bằng một khái niệm mới. Đến cuối chuỗi, bạn sẽ có mã nguồn hoạt động với SemaphoreSlim, IAsyncEnumerable, Span<T>, ArrayPool, Channel<T>, System.IO.Pipelines, ValueTask và Source Generator — tất cả đều xuất hiện trong đúng bối cảnh và có lý do rõ ràng.

Chúng ta đang xây dựng gì?

Đó là một bộ lưu trữ hỗ trợ các thao tác CRUD và lưu bản ghi vào một tệp. Giao diện được giữ nguyên trong mọi phiên bản:

public interface IFileStorage<T>
{
    Task WriteAsync(T record, CancellationToken ct = default);
    Task<T?> FindAsync(Guid id, CancellationToken ct = default);
    Task DeleteAsync(Guid id, CancellationToken ct = default);
    IAsyncEnumerable<T> ReadAllAsync(CancellationToken ct = default);
}

Kiểu bản ghi được lưu như sau:

public record FileRecord(
    Guid Id,
    string Name,
    string Payload,
    DateTime CreatedAt
);

Đơn giản. Mỗi phiên bản của bộ lưu trữ đều triển khai cùng một giao diện, nhờ đó chúng ta có thể dễ dàng thay thế các cách triển khai khi chạy benchmark và kiểm thử tải.

Phiên bản 01 — Cách triển khai ngây thơ

Phiên bản đầu tiên thực hiện đúng những gì bạn có thể dự đoán: đọc tệp, giải tuần tự hóa dữ liệu, thêm một bản ghi, tuần tự hóa lại rồi ghi toàn bộ tệp.

public class NaiveFileStorage : IFileStorage<FileRecord>
{
    private readonly string _filePath;

    public NaiveFileStorage(string filePath)
    {
        _filePath = filePath;

        if (!File.Exists(_filePath))
            File.WriteAllText(_filePath, "[]");
    }

    public async Task WriteAsync(
        FileRecord record,
        CancellationToken ct = default)
    {
        var json = await File.ReadAllTextAsync(_filePath, ct);
        var records =
            JsonSerializer.Deserialize<List<FileRecord>>(json) ?? [];

        records.Add(record);

        await File.WriteAllTextAsync(
            _filePath,
            JsonSerializer.Serialize(records),
            ct);
    }
}

Mã nguồn sạch, dễ đọc và hoàn toàn bất đồng bộ. Thoạt nhìn, dường như không có vấn đề gì.

Chạy dưới tải cao

Hãy khởi chạy 20 thao tác ghi song song và xem điều gì xảy ra:

var tasks = Enumerable.Range(0, 20).Select(i =>
    storage.WriteAsync(
        new FileRecord(
            Guid.NewGuid(),
            $"Record-{i}",
            $"Data-{i}",
            DateTime.UtcNow))
).ToList();

await Task.WhenAll(tasks);

Kết quả:

[FAIL] Record-15 didn't write: The process cannot access the file...
[FAIL] Record-0 didn't write: The process cannot access the file...
[FAIL] Record-3 didn't write: The process cannot access the file...

19 out of 20 records are gone.

19 trong tổng số 20 bản ghi đã biến mất.

Vì sao điều này xảy ra?

Nguyên nhân cốt lõi là một điều kiện tranh chấp trong chuỗi đọc — sửa đổi — ghi.

Cả 20 tác vụ bắt đầu cùng lúc. Trước khi bất kỳ tác vụ nào hoàn tất việc ghi, tất cả đều đọc tệp và cùng nhìn thấy một danh sách rỗng: [].

Sau đó, mỗi tác vụ thêm bản ghi riêng của mình và cố gắng ghi lại [Record-X] vào đĩa. Tuy nhiên, File.WriteAllTextAsync mở tệp ở chế độ độc quyền. Khi nhiều tác vụ cố gắng ghi cùng một thời điểm, hệ điều hành từ chối yêu cầu và ném ra IOException.

Chỉ những tác vụ tình cờ ghi trước mới thành công, và ngay cả các tác vụ đó cũng có thể ghi đè lên nhau.

Về lý thuyết, cách làm này còn có thể gây mất dữ liệu mà không báo lỗi. Nếu thời điểm thực thi khác đi một chút và các tác vụ không va chạm trực tiếp trên khóa ghi, chúng vẫn có thể ghi đè lên nhau mà không có ngoại lệ nào. Tác vụ ghi cuối cùng sẽ thắng, còn các bản ghi khác biến mất không để lại dấu vết.

Trong trường hợp này, Windows phát hiện va chạm và ném lỗi. Thực ra, đó là kết quả tốt hơn: ít nhất bạn biết đã có sự cố xảy ra.

Async không đồng nghĩa với đồng bộ hóa

Bài học quan trọng rất đơn giản:

async giúp việc chờ đợi không chặn luồng. Nó không làm cho trạng thái dùng chung trở nên an toàn.

Đoạn mã này là bất đồng bộ vì nó không giữ một luồng trong khi thao tác vào tệp đang chạy. Điều đó rất hữu ích, nhưng không cho biết liệu hai thao tác có được phép cùng lúc truy cập một tệp hay không.

Tài nguyên dùng chung ở đây là tệp. Mỗi thao tác ghi cần quyền truy cập độc quyền cho toàn bộ chuỗi đọc — sửa đổi — ghi:

  1. Đọc nội dung hiện tại của tệp.
  2. Giải tuần tự hóa các bản ghi hiện có.
  3. Thêm bản ghi mới.
  4. Tuần tự hóa tập hợp mới.
  5. Ghi dữ liệu trở lại tệp.

Nếu một thao tác khác chen vào giữa chuỗi này, kết quả sẽ không còn dự đoán được.

Điều gì sẽ xảy ra tiếp theo?

Giải pháp đầu tiên là bảo vệ vùng tới hạn. Trong bài tiếp theo, chúng ta sẽ thêm SemaphoreSlim để đảm bảo an toàn cho thao tác ghi, bằng cách chỉ cho phép một thao tác ghi sửa đổi tệp tại một thời điểm.

Cách này sẽ giải quyết ngay vấn đề hỏng dữ liệu, nhưng đồng thời tạo ra một câu hỏi mới: nếu một tác vụ ghi chặn mọi tác vụ khác, điều gì sẽ xảy ra với các thao tác đọc?

Đó là lúc bộ lưu trữ này bắt đầu trở nên thú vị.