Một ngôn ngữ để thống trị tất cả? LINQ, tính trừu tượng và chặng đường phía trước

Trong phần 1, chúng ta đã theo dõi cách mỗi định dạng dữ liệu trong .NET dần hình thành ngôn ngữ truy vấn riêng, cũng như cách LINQ xuất hiện trong nỗ lực của Microsoft nhằm thu hẹp sự phân mảnh đó. Trong phần 2, chúng ta lần lượt xem xét sự phân mảnh theo từng định dạng và liên tục gặp cùng một thực tế: sớm hay muộn, gần như mọi thứ đều được đưa về dạng đối tượng rồi truy vấn bằng LINQ.

Phần này đặt ra câu hỏi khó hơn mà cả chuỗi bài đang hướng tới: sự thống nhất đó thực sự tốt đến đâu? Nó hoạt động ở đâu, để lộ giới hạn ở đâu, và một nhà phát triển .NET nên làm gì với hiểu biết này? Cuối bài, chúng ta sẽ xem xét một định dạng đang khiến mô hình tương tự lặp lại một lần nữa.

LINQ thực sự hứa hẹn điều gì?

Cần hiểu chính xác LINQ là gì, bởi rất dễ đánh giá quá cao nó. LINQ không phải là một ngôn ngữ truy vấn phổ quát theo nghĩa “một cú pháp có thể hiểu mọi định dạng dữ liệu như nhau”. LINQ là một mẫu thiết kế: cú pháp truy vấn chung như from, where, select, hoặc cách viết nối tiếp như .Where(), .Select(), .OrderBy(), kết hợp với mô hình nhà cung cấp để chuyển cú pháp đó thành dạng mà nguồn dữ liệu bên dưới yêu cầu.

  • LINQ to Objects không chuyển đổi gì cả. Nó chạy truy vấn trực tiếp trên các tập hợp trong bộ nhớ bằng delegate. Đây là cách triển khai nền tảng và là mô hình mà các nhà cung cấp khác cố gắng mô phỏng.
  • LINQ to Entities, thông qua EF Core, chuyển biểu thức LINQ thành một cây biểu thức. Sau đó, nhà cung cấp EF Core biên dịch cây này thành SQL và gửi đến cơ sở dữ liệu.
  • LINQ to XML thực ra cũng không chuyển đổi theo nghĩa thông thường. Đây là LINQ to Objects hoạt động trên các đối tượng XElement và XDocument, vì vậy nó tạo cảm giác liền mạch nhất trong nhóm các nhà cung cấp có chuyển đổi.

Trường hợp ở giữa — LINQ to Entities — là nơi diễn ra phần kỹ thuật đáng kể nhất, đồng thời cũng là nơi tiềm ẩn rủi ro. Khi viết:

var bigOrders = dbContext.Orders
    .Where(o => o.Total > 100)
    .OrderByDescending(o => o.Date)
    .ToList();

C# không thực thi đoạn này như một vòng lặp trên các đối tượng. Nó tạo ra một cây biểu thức — một cấu trúc dữ liệu biểu diễn truy vấn dưới dạng mã, chứ chưa phải kết quả — rồi chuyển cây đó cho EF Core. EF Core duyệt cây và tạo SQL. Lệnh .ToList() ở cuối là thời điểm kích hoạt quá trình chuyển đổi và thực thi. Mọi thứ trước đó đều được trì hoãn; bạn đang tạo ra một mô tả truy vấn chứ chưa chạy truy vấn.

Đây thực sự là một thiết kế thanh lịch. Tuy nhiên, cũng chính tại đây, các đường nối của lớp trừu tượng bắt đầu lộ ra.

Khi lớp trừu tượng để lộ giới hạn

Không phải mọi thứ đều có thể chuyển đổi

Bộ chuyển đổi của EF Core phải biến các biểu thức C# bất kỳ thành SQL, trong khi C# cho phép bạn viết nhiều thứ không có dạng tương đương trong SQL. Một phương thức C# tự tạo, thao tác chuỗi phức tạp hoặc một số toán tử LINQ được dùng không đúng vị trí có thể không chuyển đổi được. Khi đó, ứng dụng có thể phát sinh lỗi lúc chạy hoặc — nguy hiểm hơn — âm thầm chuyển sang thực thi phía máy khách, tức EF Core tải cả bảng vào bộ nhớ rồi lọc bằng C#.

Trong mã nguồn, truy vấn như vậy có thể trông hoàn toàn giống một truy vấn được chuyển đổi tốt, nhưng khi chạy thực tế lại có hành vi rất khác. Vấn đề thường chỉ lộ ra dưới dạng hiệu năng kém khi dữ liệu đạt quy mô lớn.

Truy vấn được tạo ra không phải lúc nào cũng là truy vấn bạn sẽ tự viết

Ngay cả khi chuyển đổi thành công, SQL do EF Core tạo ra chưa chắc giống câu lệnh mà một nhà phát triển cơ sở dữ liệu giàu kinh nghiệm sẽ tự viết. Nhiều lời gọi .Include() có thể tạo ra các phép nối dạng tích Đề-các, làm phình to tập kết quả. Một số mẫu LINQ tạo truy vấn con trong khi con người có thể chọn viết JOIN.

Đây không hẳn là lỗi. Đó là cái giá của một bộ chuyển đổi dùng cho nhiều trường hợp, so với một con người hiểu rõ lược đồ dữ liệu và các chỉ mục cụ thể.

Lớp trừu tượng dễ lộ giới hạn nhất khi nền tảng toán học khác biệt nhất

Đây là điểm đáng ghi nhớ: LINQ to Objects và LINQ to XML tạo cảm giác liền mạch vì chúng không thực sự chuyển đổi giữa hai kiểu toán học khác nhau. Cả hai về cơ bản đều là việc duyệt cây hoặc tập hợp.

LINQ to Entities dễ để lộ giới hạn hơn vì nó nối hai mô hình thực sự khác nhau: mô hình hướng đối tượng, mang tính mệnh lệnh của C#, và mô hình khai báo, dựa trên tập hợp của SQL. Vấn đề không nằm ở chất lượng kỹ thuật. Đó là sự không tương thích giữa hai mô hình, vốn đã xuất hiện từ phần 1 và vẫn tồn tại dù bộ chuyển đổi tốt đến đâu.

Câu chuyện tương tự cũng diễn ra với cơ sở dữ liệu tài liệu. Các nhà cung cấp LINQ tồn tại cho Cosmos DB và MongoDB, nhưng chúng phải chuyển các truy vấn có hình dạng đối tượng thành các phương ngữ truy vấn tài liệu. Những hệ thống này không có phép nối, không có cùng mức bảo đảm giao dịch và có đặc tính hiệu năng rất khác khi lọc theo thuộc tính lồng nhau. Nhà cung cấp có thể che giấu sự khác biệt về cú pháp, nhưng không thể che giấu sự khác biệt về hành vi.

Một khung thực hành

Từ những điều trên, bạn có thể cân nhắc mức độ dựa vào LINQ và ngôn ngữ bản địa của từng định dạng theo cách sau:

  1. Dùng LINQ như lớp kết nối, không phải bộ chuyển đổi vạn năng. LINQ rất tốt trong việc diễn đạt ý định một cách dễ đọc và làm việc với các tập hợp trong bộ nhớ, nơi không có quá trình chuyển đổi nào diễn ra. Trong trường hợp đó, bạn có thể tin cậy nó hoàn toàn.
  2. Học ngôn ngữ bản địa của mọi định dạng mà bạn truy vấn thường xuyên. Nếu viết các truy vấn EF Core không đơn giản trên tập dữ liệu lớn, hãy biết đủ SQL để đọc truy vấn được tạo ra. EF Core có thể ghi lại truy vấn này, giúp bạn nhận ra khi nào nó đang thực hiện thao tác tốn kém. Điều tương tự cũng đúng với ngôn ngữ truy vấn của Cosmos DB nếu ứng dụng yêu cầu hiệu năng cao.
  3. Xem lỗi chuyển đổi LINQ thành tín hiệu, không phải lỗi cần né tránh. Nếu một biểu thức LINQ không thể chuyển đổi, điều đó thường cho thấy thao tác ấy thực sự không có cách biểu diễn tương đương rõ ràng trong định dạng đích. Đây là thông tin đáng giá, bất kể cuối cùng bạn dùng ngôn ngữ nào để giải quyết.
  4. Đừng chống lại thế mạnh bản địa của một định dạng thông qua LINQ. Nếu truy vấn vốn là một phép duyệt đồ thị, việc tải toàn bộ đồ thị vào bộ nhớ rồi dùng LINQ to Objects sẽ làm mất ý nghĩa của việc sử dụng cơ sở dữ liệu đồ thị. Hãy dùng Cypher hoặc Gremlin trực tiếp, còn LINQ nên xử lý những phần trong ứng dụng thực sự có hình dạng danh sách và đối tượng.

Điểm xuyên suốt là: LINQ là một công cụ làm phẳng phương ngữ đáng kinh ngạc cho những phần ứng dụng vốn đã có hình dạng đối tượng. Nó không thay thế cho việc hiểu định dạng mà bạn thực sự đang giao tiếp.

Một định dạng mới đang lặp lại mô hình này

Nếu lý thuyết trong phần 1 là đúng — các hình dạng dữ liệu mới tạo ra những ngôn ngữ mới — thì câu hỏi thú vị là liệu điều đó có còn xảy ra ngày nay hay không. Câu trả lời là có, và tốc độ đang rất nhanh: vector nhúng và tìm kiếm tương đồng.

Cơ sở dữ liệu vector như Pinecone, Qdrant, khả năng tìm kiếm vector của Azure AI Search và các bổ sung tìm kiếm vector của Cosmos DB lưu trữ dữ liệu dưới dạng các vector số nhiều chiều. Đây là một hình dạng hoàn toàn khác so với hàng, cây hoặc tài liệu.

Đúng như quy luật quen thuộc, hình dạng mới này đã tạo ra một phép nguyên thủy truy vấn riêng: không lọc theo sự bằng nhau hay duyệt theo đường dẫn, mà là tìm kiếm tương đồng — “hãy tìm các vector gần vector này nhất”, thường dựa trên độ tương đồng cosine hoặc tìm láng giềng gần nhất.

// Azure AI Search — truy vấn vector,
// khác về bản chất với một bộ lọc
var searchOptions = new SearchOptions
{
    VectorSearch = new()
    {
        Queries = { new VectorizedQuery(embedding)
            { KNearestNeighborsCount = 5,
                Fields = { "contentVector" } } }
    }
};
var results = await searchClient.SearchAsync
<Document>(searchOptions);

Đây không phải là WHERE của SQL, cũng không phải phép đi xuống cây của XPath hay phép duyệt quan hệ của GraphQL. Đây là một ngữ pháp mới cho một hình dạng mới — “độ gần” thay vì “đối sánh” — bởi nền tảng toán học bên dưới, tức khoảng cách vector, khác với mọi trường hợp còn lại.

Đúng theo quy luật, các công cụ .NET đã bắt đầu phát triển quanh lĩnh vực này. Semantic Kernel cùng nhiều lớp trừu tượng kho vector trong hệ sinh thái .NET đang cố gắng thực hiện điều tương tự như LINQ cách đây hai mươi năm: cung cấp cho nhà phát triển một bề mặt API tương đối thống nhất, trong khi các nhà cung cấp bên dưới chuyển đổi nó thành yêu cầu cụ thể của từng kho vector.

Liệu lớp trừu tượng này có hoạt động tốt hơn LINQ hay không vẫn là câu hỏi bỏ ngỏ. Có lẽ nên xem xét lại sau một hoặc hai năm, khi các thư viện này trưởng thành hơn. Nhưng bản thân quy luật thì không còn nghi ngờ: hình dạng dữ liệu tạo ra ngôn ngữ, và các định dạng mới sẽ tiếp tục tạo ra ngôn ngữ mới, dù công cụ thống nhất của chúng ta tốt đến đâu.

Bạn nên rút ra điều gì?

Sau ba phần, luận điểm chính không thay đổi; nó chỉ trở nên cụ thể hơn:

  • Ngôn ngữ truy vấn của mỗi định dạng phản ánh nền tảng toán học bên dưới định dạng đó, chứ không phải một lựa chọn cú pháp tùy ý.
  • LINQ là nỗ lực tốt nhất của .NET nhằm làm phẳng sự đa dạng đó thành một cú pháp chung, và nó thực sự làm tốt công việc này — cho đến khi định dạng ở đầu bên kia có hình dạng khác biệt căn bản với “tập hợp các đối tượng”. Khi đó, lớp trừu tượng tất yếu sẽ để lộ giới hạn.
  • Kỹ năng đúng không phải là biết LINQ đủ giỏi để khỏi phải học bất kỳ thứ gì khác. Đó là biết LINQ đủ tốt để dùng nó như lớp kết nối, đồng thời biết ngôn ngữ bản địa của từng định dạng đủ tốt để nhận ra khi LINQ đang âm thầm thực hiện thao tác tốn kém hoặc không phù hợp thay cho bạn.
  • Các định dạng mới sẽ tiếp tục xuất hiện — vector nhúng là ví dụ hiện tại — và chúng sẽ tiếp tục tạo ra ngôn ngữ mới, bởi đó là điều xảy ra khi một hình dạng dữ liệu thực sự mới cần một cách đặt câu hỏi thực sự mới.

Các nhà phát triển phần mềm thường nói rằng họ học các nền tảng, thư viện và API. Có lẽ mô tả chính xác hơn là chúng ta dành cả sự nghiệp để trở thành những người biết nhiều ngôn ngữ. Mỗi cách biểu diễn dữ liệu mới dạy chúng ta một ngôn ngữ mới để đặt câu hỏi. Nền tảng sẽ tiến hóa, cú pháp sẽ thay đổi, nhưng mô hình nền tảng này vẫn nhất quán đáng kinh ngạc trong suốt lịch sử của .NET — và có lẽ sẽ chưa thay đổi trong thời gian dài.

Nếu có một thói quen đáng giữ lại từ chuỗi bài này, đó là: lần tới khi sử dụng một kho dữ liệu mới hoặc một SDK mới và nhận thấy nó có những đặc điểm truy vấn riêng, đừng vội xem đó là sự phiền toái cần được che giấu bằng lớp trừu tượng. Thay vào đó, hãy hỏi dữ liệu được xây dựng theo hình dạng nào và hình dạng đó đang nói gì về cách nó muốn được đặt câu hỏi.

Câu hỏi này phù hợp với CSV, SQL, XML, JSON, tài liệu, đồ thị và giờ là vector — và có lẽ cũng sẽ phù hợp với bất cứ thứ gì xuất hiện sau này.

Đến đây là hết. Mong cho ý định của bạn luôn rõ ràng và tâm trí luôn tĩnh lặng. Với lời chúc thầm lặng ấy, tôi gác bút và trở về với im lặng.

Tác giả: Bipin Joshi

Bipin Joshi là chuyên gia tư vấn phần mềm độc lập, giảng viên và tác giả, chuyên về các công nghệ phát triển web của Microsoft. Theo con đường tu tập, ông cũng hướng dẫn một số học viên về Ajapa Gayatri và các phương pháp thiền liên quan. Kết hợp kỷ luật của lập trình và đời sống nội tâm, ông đã thiền định, lập trình, viết lách và giảng dạy trong hơn 31 năm. Với vai trò một tác giả giàu kinh nghiệm, ông chia sẻ hiểu biết về phát triển phần mềm và tri thức tu tập qua các trang web của mình.

Ngày đăng: 19 tháng 8 năm 2026