2.6 - Cơ sở dữ liệu là gì
Tóm tắt: Câu hỏi đúng không phải "database là gì" mà là "vì sao không lưu vào file cho xong". Bốn thứ một hệ quản trị cơ sở dữ liệu làm mà file không làm được: cho nhiều người ghi cùng lúc mà không đè lên nhau, bảo đảm dữ liệu còn nguyên sau khi mất điện, trả lời câu hỏi phức tạp mà không phải đọc toàn bộ, và từ chối dữ liệu sai ngay tại nguồn. Bài này đi qua bốn thứ đó, mô hình ACID, tranh luận đặt ràng buộc ở tầng nào, và một tài nguyên mà người mới hay quên là khan hiếm: connection.
Mục tiêu bài học
Sau bài này bạn có thể:
- Nêu bốn việc DBMS làm mà một file không làm được.
- Giải thích từng chữ trong ACID bằng một tình huống thật.
- Quyết định đặt ràng buộc ở database hay ở tầng ứng dụng.
- Chọn giữa cơ sở dữ liệu quan hệ và NoSQL theo hình dạng dữ liệu.
- Giải thích vì sao connection pool tồn tại và điều gì xảy ra khi nó cạn.
Cần biết trước: 2.2 — Internet hoạt động như thế nào (vì sao mở kết nối tốn kém) và bài 1.7 (Big-O). Phần bài tập cần Docker để chạy PostgreSQL; cách chuẩn bị nằm ngay đầu phần bài tập. Chưa biết SQL cũng làm được: mọi câu lệnh đều có sẵn, chỉ cần chép và chạy.
Nội dung bài học
2.6.1 — Vì sao không dùng file
Giả sử bạn lưu khách hàng vào customers.json. Bốn vấn đề xuất hiện ngay:
1. Hai người ghi cùng lúc. A và B cùng đọc file, mỗi người sửa một khách hàng khác nhau, rồi cùng ghi đè. Thay đổi của người ghi trước biến mất không dấu vết.
2. Mất điện giữa lúc ghi. File còn lại một nửa, JSON hỏng, mất toàn bộ dữ liệu.
3. Câu hỏi phức tạp. "Khách hàng ở Hà Nội có đơn trên 5 triệu trong tháng này, sắp theo tổng chi tiêu" — với file thì phải đọc hết rồi tự lọc bằng code. Với 3 triệu bản ghi thì không khả thi.
4. Dữ liệu rác lọt vào. Một đoạn code quên kiểm tra và ghi vào khách hàng có email = null, age = -5. Không gì chặn lại.
DBMS tồn tại để giải quyết đúng bốn thứ này.
2.6.2 — ACID, bằng ví dụ
Atomicity — trọn vẹn hoặc không gì cả. Chuyển 1 triệu từ tài khoản A sang B gồm hai bước: trừ A, cộng B. Nếu hỏng giữa chừng, không được phép để tiền bốc hơi. Giao dịch hoặc xong cả hai, hoặc quay về như chưa làm gì.
BEGIN;
UPDATE accounts SET balance = balance - 1000000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000000 WHERE id = 2;
COMMIT; -- mất điện trước dòng này -> tự động quay lui cả haiConsistency — mọi ràng buộc vẫn đúng sau giao dịch. Nếu có quy tắc "số dư không âm", database từ chối commit một giao dịch phá vỡ nó.
Isolation — các giao dịch chạy song song không thấy trạng thái dở dang của nhau. Đây là chữ tinh vi nhất và có nhiều mức độ khác nhau, mỗi mức đánh đổi giữa đúng đắn và tốc độ. Bài Hai giao dịch cùng cộng 100, số dư chỉ tăng 100 dựng thí nghiệm thật cho từng mức.
Durability — đã commit là còn mãi. Sau khi database trả về "thành công", dữ liệu phải sống sót qua mất điện ngay lập tức. Điều này thực hiện được nhờ ghi nhật ký xuống đĩa trước khi báo thành công.
Chi tiết từng mức: Transactions và ACID.
2.6.3 — Quan hệ hay NoSQL
| Quan hệ (PostgreSQL, SQL Server) | Tài liệu (MongoDB) | Khoá-giá trị (Redis) | |
|---|---|---|---|
| Hình dạng dữ liệu | Bảng có lược đồ cố định | Tài liệu JSON linh hoạt | Cặp khoá và giá trị |
| Quan hệ nhiều-nhiều | Mạnh, JOIN sẵn có | Phải tự xử lý | Không có |
| Giao dịch | ACID đầy đủ | Có, hạn chế hơn | Rất hạn chế |
| Đổi lược đồ | Cần migration | Linh hoạt | Không có lược đồ |
| Dùng cho | Nghiệp vụ, tài chính, CRM/ERP | Catalog, log, nội dung | Cache, session, hàng đợi |
Lời khuyên thực dụng: mặc định chọn quan hệ. Phần lớn dữ liệu nghiệp vụ là dữ liệu quan hệ — khách hàng có đơn hàng, đơn hàng có sản phẩm. Chọn NoSQL khi có lý do cụ thể, không phải vì nó mới hơn.
Và hai loại này không loại trừ nhau. Kiến trúc rất phổ biến: PostgreSQL làm nguồn sự thật, Redis làm cache phía trước.
2.6.4 — Ràng buộc: đặt ở database hay ở code?
Câu trả lời thường gây tranh cãi, nhưng có một lập luận khó bác:
Ứng dụng đến rồi đi. Dữ liệu ở lại.
Trong vòng đời một hệ thống, dữ liệu sẽ bị ghi bởi: ứng dụng chính, một script di trú, một job nền, một truy vấn sửa tay lúc 2 giờ sáng, một công cụ nhập liệu, và ứng dụng viết lại bằng ngôn ngữ khác sau năm năm. Chỉ database là chốt chặn mà tất cả đều phải đi qua.
CREATE TABLE "Customers" (
"Id" BIGSERIAL PRIMARY KEY,
"Email" VARCHAR(255) NOT NULL UNIQUE, -- không trùng, không rỗng
"Age" INT CHECK ("Age" >= 0 AND "Age" < 150),
"CreatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Tên bảng và cột viết theo kiểu EF Core tạo ra (Customers, CreatedAt). PostgreSQL tự đổi tên không có ngoặc kép thành chữ thường, nên muốn giữ chữ hoa phải bọc trong "...". Riêng phần bài tập bên dưới dùng tên ngắn chữ thường (accounts) cho nhanh gõ trong psql.
Nghĩa là cả hai: kiểm tra ở ứng dụng để báo lỗi thân thiện và tiết kiệm một vòng đi database; ràng buộc ở database để bảo đảm không đường nào lách được. Khi hai chỗ mâu thuẫn, database là nơi nói lời cuối.
Thiết kế lược đồ đi sâu ở bài database design best practices.
2.6.5 — Index: đánh đổi giống hệt Big-O
Không có index, tìm một dòng trong 10 triệu dòng là quét toàn bảng — O(n). Có index, database tra theo cây B-tree — O(log n), khoảng 24 bước.
Cái giá: index tốn dung lượng, và làm chậm mọi lệnh ghi vì mỗi INSERT, UPDATE, DELETE phải cập nhật thêm cấu trúc đó. Bài Thêm 4 index làm INSERT chậm 6 lần đo con số cụ thể trên PostgreSQL 16.
Và có index chưa chắc đã được dùng: bọc một hàm quanh cột trong WHERE là index bị vô hiệu — xem Sargability.
Đây cùng là một ý tưởng với Big-O ở bài 1.7, chỉ khác là áp dụng lên ổ đĩa.
2.6.6 — Connection là tài nguyên khan hiếm
Đây là điều người mới hay bỏ qua nhất.
Mỗi kết nối tới database tốn bộ nhớ ở phía máy chủ, và số lượng bị giới hạn — PostgreSQL mặc định max_connections = 100. Mở kết nối cũng không rẻ: TCP, xác thực, thiết lập phiên.
Vì thế mọi thư viện truy cập dữ liệu đều có connection pool: giữ sẵn một số kết nối mở và cho mượn luân phiên.
Chuyện xảy ra khi pool cạn: request mới xếp hàng đợi, rồi hết giờ chờ. Triệu chứng là toàn hệ thống chậm dần chứ không có lỗi rõ ràng — rất khó chẩn đoán. Hai nguyên nhân phổ biến nhất:
- Truy vấn chậm. Mỗi request giữ kết nối suốt thời gian câu truy vấn chạy. Câu truy vấn mất 2 giây vì thiếu index thì mỗi kết nối chỉ phục vụ được nửa request mỗi giây.
- Giữ kết nối quá lâu. Mở giao dịch rồi gọi một API bên ngoài mất 3 giây ở giữa: suốt 3 giây đó kết nối không trả về pool. Nguyên tắc: không bao giờ gọi I/O bên ngoài khi đang giữ giao dịch database.
Còn DbContext (lớp làm việc với database của EF Core, bài 8.10) thì sao? EF Core chỉ mở kết nối khi chạy một câu lệnh và trả nó về pool ngay sau đó, nên một DbContext sống lâu không tự giữ kết nối. Kết nối chỉ bị giữ lâu khi có giao dịch đang mở, khi bạn tự gọi Database.OpenConnection(), hoặc khi đang đọc dở kết quả truy vấn. Đăng ký DbContext ở vòng đời Singleton vẫn sai, nhưng vì lý do khác: một DbContext không dùng được từ nhiều luồng cùng lúc, và nó nhớ mọi entity đã nạp nên bộ nhớ phình mãi. Xem bài về captive dependency.
2.6.7 — Danh sách rà soát cho dự án thật
Danh sách rà soát cơ sở dữ liệu
- Ràng buộc quan trọng (NOT NULL, UNIQUE, FOREIGN KEY, CHECK) có ở cả database, không chỉ ở code.
- Mọi thao tác nhiều bước liên quan tiền bạc đều nằm trong một giao dịch.
- Không có lời gọi HTTP hay đọc file nào nằm bên trong một giao dịch đang mở.
- DbContext đăng ký ở vòng đời Scoped, không phải Singleton.
- Cột dùng để lọc và JOIN đã có index, và số lượng index đã được cân nhắc với chi phí ghi.
- Mốc thời gian lưu ở UTC với kiểu timestamptz.
- Đã biết max_connections của database và kích thước pool của ứng dụng.
Bài tập áp dụng
Chuẩn bị. Ba bài dưới đây chạy trên PostgreSQL 16 bằng Docker. Ở thư mục gốc của repo khoá học có sẵn docker-compose.yml:
docker compose up -d
docker exec -it dotnet-course-postgres psql -U postgres -d crmLệnh thứ hai mở psql, công cụ dòng lệnh của PostgreSQL, ngay trong container. Dấu nhắc crm=# nghĩa là đã sẵn sàng gõ SQL. Gõ \q để thoát. Bài 1 cần hai cửa sổ psql cùng lúc: mở hai terminal và chạy lệnh thứ hai ở cả hai.
Trên Windows, nếu Git Bash báo the input device is not a TTY, hãy chạy lệnh docker exec -it trong PowerShell, hoặc thêm winpty vào đầu lệnh.
SQL Server
docker compose --profile sqlserver up -d
MSYS_NO_PATHCONV=1 docker exec -it dotnet-course-sqlserver /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P "Crm@123456" -CMSYS_NO_PATHCONV=1 dành cho Git Bash trên Windows. Thiếu nó, Git Bash tự đổi /opt/... thành C:/Program Files/Git/opt/... và Docker báo no such file or directory. Trên macOS, Linux hay PowerShell thì bỏ tiền tố này đi.
Trong sqlcmd, gõ câu lệnh rồi gõ GO trên một dòng riêng để chạy. Tạo database trước: CREATE DATABASE crm; rồi USE crm;.
Bài 1 — Tự gây mất dữ liệu do cập nhật đồng thời
Mở hai phiên psql tới cùng một bảng. Trong cả hai, đọc cùng một dòng, sửa cùng một cột, rồi COMMIT lần lượt. Quan sát thay đổi của phiên đầu biến mất. Làm lại với SELECT ... FOR UPDATE.
Tiêu chí hoàn thành: bạn tái hiện được hiện tượng, và giải thích vì sao database không coi đây là lỗi.
Gợi ý và lời giải — Bài 1
Gợi ý. Chạy từng lệnh xen kẽ giữa hai cửa sổ theo đúng thứ tự, đừng chạy hết phiên này rồi mới sang phiên kia — hiện tượng chỉ xuất hiện khi hai giao dịch chồng lấn nhau về thời gian.
Lời giải — tái hiện:
-- Chuẩn bị (chạy ở một phiên)
CREATE TABLE accounts (
id INT PRIMARY KEY,
balance NUMERIC(18,2) NOT NULL,
version INT NOT NULL DEFAULT 0 -- dùng cho cách 3 bên dưới
);
INSERT INTO accounts (id, balance) VALUES (1, 1000);| Bước | Phiên A | Phiên B |
|---|---|---|
| 1 | BEGIN; | |
| 2 | SELECT balance FROM accounts WHERE id=1; → 1000 | |
| 3 | BEGIN; | |
| 4 | SELECT balance FROM accounts WHERE id=1; → 1000 | |
| 5 | UPDATE accounts SET balance = 1000 + 500 WHERE id=1; | |
| 6 | COMMIT; → còn 1500 | |
| 7 | UPDATE accounts SET balance = 1000 + 300 WHERE id=1; | |
| 8 | COMMIT; → còn 1300 |
Phiên B viết thẳng 1000 + 300 vì đó là con số nó vừa đọc ở bước 4, giống như code ứng dụng đọc số dư vào biến rồi cộng thêm. Chạy SELECT * FROM accounts; sau bước 8, kết quả thật trên PostgreSQL 16:
id | balance | version
----+---------+---------
1 | 1300.00 | 0
(1 row)Số dư cuối là 1300, không phải 1800. Khoản cộng 500 của phiên A biến mất hoàn toàn, không để lại dấu vết nào.
Vì sao database không báo lỗi. Hiện tượng này gọi là lost update. Ở mức cô lập mặc định — READ COMMITTED trong cả PostgreSQL lẫn SQL Server — cả hai giao dịch đều hợp lệ theo đúng định nghĩa ACID: mỗi giao dịch đọc dữ liệu đã commit, ghi trọn vẹn, và không vi phạm ràng buộc nào. Database không có cách nào biết rằng phiên B định cộng thêm vào giá trị mà phiên A vừa ghi.
Nói cách khác: ACID bảo đảm mỗi giao dịch đúng với chính nó, chứ không bảo đảm kết quả khớp với ý định nghiệp vụ khi hai giao dịch giao thoa.
Ba cách phòng:
-- Cách 1: khoá bi quan — khoá dòng ngay khi đọc
BEGIN;
SELECT balance FROM accounts WHERE id=1 FOR UPDATE; -- phiên B phải đợi
UPDATE accounts SET balance = balance + 300 WHERE id=1;
COMMIT;
-- Cách 2: cập nhật nguyên tử — không đọc rồi ghi, mà tính ngay trong câu lệnh
UPDATE accounts SET balance = balance + 300 WHERE id=1;
-- Cách 3: khoá lạc quan — kèm điều kiện phiên bản
UPDATE accounts SET balance = 1300, version = version + 1
WHERE id = 1 AND version = 0; -- cập nhật 0 dòng nếu ai đó đã sửa trướcLàm lại thí nghiệm với cách 1 (đặt lại số dư về 1000 trước): phiên A đọc bằng FOR UPDATE, phiên B cũng đọc bằng FOR UPDATE và đứng chờ cho tới khi A commit. Chạy thật, bật \timing on ở phiên B:
balance
---------
1500.00
(1 row)
Time: 1991.965 ms (00:01.992)Phiên B chờ gần 2 giây (đúng lúc A còn giữ khoá), rồi đọc được 1500 chứ không phải 1000. Cộng thêm 300, số dư cuối là 1800, đúng.
Với cách 3, hai câu UPDATE cùng điều kiện version = 0 chạy lần lượt:
UPDATE 1
UPDATE 0Câu thứ hai cập nhật 0 dòng vì version đã thành 1. Ứng dụng thấy con số 0 đó thì biết có người sửa trước, đọc lại dữ liệu mới rồi thử lại hoặc báo cho người dùng.
Chọn cách nào:
| Cách | Hợp khi | Chi phí |
|---|---|---|
FOR UPDATE | Tranh chấp cao, giao dịch ngắn | Phiên khác phải chờ, có nguy cơ deadlock |
| Cập nhật nguyên tử | Phép toán biểu diễn được bằng SQL | Gần như không có — nên ưu tiên |
| Phiên bản (lạc quan) | Tranh chấp thấp, thao tác qua nhiều bước như form web | Phải xử lý trường hợp cập nhật hỏng |
Liên hệ về sau. Cách 3 chính là cơ chế mà EF Core gọi là concurrency token. Với PostgreSQL, EF Core dùng luôn cột hệ thống xmin (PostgreSQL tự đổi giá trị này mỗi khi dòng bị sửa) thay cho cột version tự quản. Bài 13.8 trình bày đầy đủ, còn bài 12.6 đi sâu vào các mức cô lập và deadlock.
SQL Server
SQL Server không có cú pháp FOR UPDATE. Khoá dòng lúc đọc bằng table hint UPDLOCK:
BEGIN TRANSACTION;
SELECT balance FROM accounts WITH (UPDLOCK, ROWLOCK) WHERE id = 1; -- phiên khác phải đợi
UPDATE accounts SET balance = balance + 300 WHERE id = 1;
COMMIT;Chạy thật trên SQL Server 2022 với hai phiên như trên: phiên B chờ 1995 ms rồi đọc được 1500, số dư cuối 1800. Concurrency token của EF Core trên SQL Server dùng kiểu cột rowversion, tự tăng mỗi khi dòng bị sửa.
Điểm đáng nhớ nhất. Lỗi này không bao giờ xuất hiện khi bạn thử một mình trên máy phát triển. Nó chỉ xuất hiện khi có hai người dùng thật thao tác cùng lúc, và biểu hiện là "dữ liệu tự nhiên sai" mà không ai tái hiện được.
Bài 2 — Kiểm chứng tính trọn vẹn của giao dịch
Viết một giao dịch gồm hai câu UPDATE, cố ý cho câu thứ hai vi phạm ràng buộc CHECK. Xác nhận câu thứ nhất cũng bị quay lui.
Tiêu chí hoàn thành: bạn kiểm chứng được bằng một câu SELECT sau khi giao dịch thất bại, và nêu được điều gì xảy ra nếu thiếu BEGIN.
Gợi ý và lời giải — Bài 2
Gợi ý. Chữ A trong ACID là atomicity — nguyên tử. Nghĩa là toàn bộ giao dịch hoặc thành công trọn vẹn, hoặc không để lại dấu vết nào. Hãy chứng minh điều đó bằng dữ liệu chứ không bằng lý thuyết.
Lời giải.
DROP TABLE IF EXISTS accounts;
CREATE TABLE accounts (
id INT PRIMARY KEY,
balance NUMERIC(18,2) CHECK (balance >= 0)
);
INSERT INTO accounts VALUES (1, 1000), (2, 500);
BEGIN;
UPDATE accounts SET balance = balance - 800 WHERE id = 1; -- OK, còn 200
UPDATE accounts SET balance = balance - 800 WHERE id = 2; -- VI PHẠM: -300
SELECT * FROM accounts;
COMMIT;
SELECT * FROM accounts ORDER BY id;Kết quả chạy thật trên PostgreSQL 16, từ câu BEGIN:
BEGIN
UPDATE 1
ERROR: new row for relation "accounts" violates check constraint "accounts_balance_check"
DETAIL: Failing row contains (2, -300.00).
ERROR: current transaction is aborted, commands ignored until end of transaction block
ROLLBACK
id | balance
----+---------
1 | 1000.00
2 | 500.00
(2 rows)Đọc từng dòng:
UPDATE 1: câu thứ nhất chạy được, tài khoản 1 tạm thời còn 200.ERROR ... violates check constraint: câu thứ hai bị từ chối.current transaction is aborted: từ lúc có lỗi, PostgreSQL bỏ qua mọi câu lệnh khác trong giao dịch, kể cả câuSELECT.ROLLBACK: bạn gõCOMMIT, nhưng PostgreSQL trả lờiROLLBACK. Giao dịch đã hỏng thì không commit được.- Số dư cuối vẫn là 1000 và 500: câu
UPDATEthứ nhất đã bị quay lui hoàn toàn.
Nếu thiếu BEGIN. Đây là phần quan trọng nhất của bài. Không có BEGIN, mỗi câu lệnh là một giao dịch riêng và tự commit ngay:
UPDATE accounts SET balance = balance - 800 WHERE id = 1; -- commit ngay, còn 200
UPDATE accounts SET balance = balance - 800 WHERE id = 2; -- lỗi, không ảnh hưởng câu trên
SELECT * FROM accounts ORDER BY id;UPDATE 1
ERROR: new row for relation "accounts" violates check constraint "accounts_balance_check"
DETAIL: Failing row contains (2, -300.00).
id | balance
----+---------
1 | 200.00
2 | 500.00
(2 rows)800 đã bị trừ khỏi tài khoản 1 mà không được cộng vào đâu cả. Đây chính là kịch bản chuyển khoản mất tiền mà mọi tài liệu về giao dịch đều lấy làm ví dụ mở đầu — và nó có thật.
SQL Server
SQL Server có một khác biệt nguy hiểm: theo mặc định, câu lệnh vi phạm ràng buộc chỉ huỷ riêng câu đó, còn giao dịch vẫn mở và COMMIT vẫn commit phần đã chạy. Chạy thật trên SQL Server 2022:
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 800 WHERE id = 1;
UPDATE accounts SET balance = balance - 800 WHERE id = 2;
COMMIT;
GO
SELECT * FROM accounts ORDER BY id;Msg 547, Level 16, State 1, Server 72c468211cf7, Line 3
The UPDATE statement conflicted with the CHECK constraint "CK__accounts__balanc__37A5467C". The conflict occurred in database "m02_lab", table "dbo.accounts", column 'balance'.
The statement has been terminated.
id balance
----------- --------------------
1 200.00
2 500.00Có BEGIN TRANSACTION mà tiền vẫn bay. Bật SET XACT_ABORT ON; trước BEGIN TRANSACTION thì một lỗi bất kỳ sẽ quay lui toàn bộ giao dịch; chạy lại với dòng đó, số dư cuối là 1000 và 500. Code T-SQL viết tay có giao dịch nên luôn bắt đầu bằng SET XACT_ABORT ON;. (Tên database m02_lab trong thông báo là database dùng khi chạy thử.)
Trong .NET. EF Core tự bọc mỗi lần SaveChangesAsync trong một giao dịch, nên nhiều thay đổi lưu cùng một lượt là nguyên tử. Nhưng hai lần gọi SaveChangesAsync là hai giao dịch:
// SAI — hai giao dịch, có thể thành công một nửa
await db.SaveChangesAsync(); // trừ tiền tài khoản 1
await CallExternalApiAsync();
await db.SaveChangesAsync(); // cộng tiền tài khoản 2
// ĐÚNG — một giao dịch tường minh
await using var tx = await db.Database.BeginTransactionAsync(ct);
try
{
// mọi thay đổi
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch { await tx.RollbackAsync(ct); throw; }Giới hạn cần biết. Giao dịch chỉ bảo vệ những gì nằm trong cùng một database. Nếu giữa hai lần ghi bạn gọi một API bên ngoài hoặc gửi một tin nhắn, thì phần đó không quay lui được. Đây chính là bài toán mà mẫu hộp thư đi ở Module 17 giải quyết.
Bài 3 — Làm cạn nhóm kết nối
Đặt kích thước nhóm kết nối xuống 5. Viết một endpoint giữ kết nối database trong 10 giây rồi gọi song song 20 request. Quan sát bao nhiêu request thành công, bao nhiêu lỗi, sau bao lâu, và thông báo lỗi là gì.
Tiêu chí hoàn thành: bạn dự đoán đúng số request thành công và thất bại trước khi chạy, và giải thích được vì sao lỗi này hay bị chẩn đoán nhầm.
Gợi ý và lời giải — Bài 3
Gợi ý. Với nhóm 5 kết nối và mỗi request giữ kết nối 10 giây: 5 request đầu chạy ngay, phần còn lại phải đợi. Request phải đợi bao lâu thì bỏ cuộc? Tra trong chuỗi kết nối — với Npgsql (thư viện kết nối PostgreSQL của .NET) là tham số Timeout, mặc định 15 giây.
Lời giải — chuẩn bị. Code dưới đây dùng EF Core; bạn chưa cần hiểu DbContext, bài 8.10 sẽ dạy. Tạo project:
dotnet new web -n PoolLab
cd PoolLab
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQLThay Program.cs:
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<CrmDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Crm")));
var app = builder.Build();
// Giữ một kết nối trong 10 giây, giả lập một truy vấn rất chậm
app.MapGet("/slow", async (CrmDbContext db, CancellationToken ct) =>
{
await db.Database.ExecuteSqlRawAsync("SELECT pg_sleep(10)", ct);
return Results.Ok();
});
app.Run();
class CrmDbContext(DbContextOptions<CrmDbContext> options) : DbContext(options);Thêm chuỗi kết nối vào appsettings.json, với pool tối đa 5 kết nối và thời gian chờ 15 giây:
{
"ConnectionStrings": {
"Crm": "Host=localhost;Port=5432;Database=crm;Username=postgres;Password=postgres;Maximum Pool Size=5;Timeout=15"
}
}Chạy dotnet run --urls http://localhost:5000, rồi ở terminal khác gọi song song 20 request (dấu & cho mỗi curl chạy nền, wait đợi tất cả xong):
for i in $(seq 1 20); do
curl -s -o /dev/null -w "$i:%{http_code} %{time_total}s\n" http://localhost:5000/slow &
done; waitDự đoán:
| Nhóm request | Diễn biến dự đoán |
|---|---|
| 5 request lấy được kết nối đầu tiên | Xong sau ~10 giây |
| 5 request kế tiếp | Đợi ~10 giây, lấy kết nối vừa trả, xong sau ~20 giây |
| 10 request còn lại | Phải đợi ~20 giây, quá 15 giây cho phép, lỗi ở giây thứ 15 |
Kết quả chạy thật trên .NET 10, Npgsql 10 (số thứ tự là thứ tự curl được khởi động, không phải thứ tự tới server):
9:200 10.888113s
14:200 10.834261s
5:200 10.931191s
11:200 10.870677s
7:200 10.911092s
2:500 15.860732s
12:500 15.754959s
3:500 15.849518s
6:500 15.820228s
16:500 15.747631s
17:500 15.741319s
19:500 15.723347s
18:500 15.730269s
15:500 15.759372s
20:500 15.711684s
4:200 20.944183s
1:200 20.973159s
8:200 20.909950s
10:200 20.889328s
13:200 20.851169sĐúng như dự đoán: 5 xong ở giây 10, 10 lỗi ở giây 15, 5 xong ở giây 20. Log của API ghi:
Npgsql.NpgsqlException (0x80004005): The connection pool has been exhausted, either raise 'Max Pool Size' (currently 5) or 'Timeout' (currently 15 seconds) in your connection string.
---> System.TimeoutException: The operation has timed out.SQL Server
Đổi package thành Microsoft.EntityFrameworkCore.SqlServer, UseNpgsql thành UseSqlServer, câu lệnh chờ thành WAITFOR DELAY '00:00:10', và chuỗi kết nối thành:
Server=localhost,1433;Database=crm;User Id=sa;Password=Crm@123456;TrustServerCertificate=True;Max Pool Size=5;Connect Timeout=15Chạy thật trên SQL Server 2022 cho cùng kết quả 5 / 10 / 5. Thông báo lỗi:
System.InvalidOperationException: Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool. This may have occurred because all pooled connections were in use and max pool size was reached.Vì sao lỗi này hay bị chẩn đoán nhầm. Thông báo nhắc tới Max Pool Size, nên phản xạ đầu tiên của nhiều người là tăng số kết nối lên. Điều đó gần như luôn sai, vì:
- Nguyên nhân thật hiếm khi là nhóm quá nhỏ, mà là kết nối bị giữ quá lâu.
- Tăng nhóm chỉ đẩy giới hạn sang phía database, nơi mỗi kết nối tốn bộ nhớ riêng và số kết nối tối đa cũng có hạn.
- Khi database chạm giới hạn, sự cố lan sang mọi dịch vụ dùng chung database đó chứ không còn giới hạn ở một chỗ.
Bốn nguyên nhân thật, theo tần suất:
- Truy vấn chậm — thiếu chỉ mục, hoặc lỗi N+1 như bài 1.3 đã nêu.
- Gọi API bên ngoài khi đang mở giao dịch — kết nối bị giữ suốt thời gian chờ mạng.
- Mở kết nối hoặc giao dịch bằng tay rồi quên đóng — ví dụ gọi
Database.OpenConnectionAsync()hayBeginTransactionAsync()mà không cóusing, nên kết nối chỉ được trả khiDbContextbị huỷ. - Chặn luồng bằng
.Result— mỗi request bị chặn vẫn giữ kết nối của nó; Module 6 phân tích kỹ.
Quy tắc thực dụng. Mở kết nối muộn nhất có thể, đóng sớm nhất có thể, và không bao giờ gọi ra mạng ngoài trong khi đang giữ một giao dịch. Con số nhóm kết nối chỉ nên chỉnh sau khi đã loại trừ bốn nguyên nhân trên.
Tự kiểm tra
Bốn việc DBMS làm mà file không làm được là gì?
Cho nhiều người ghi cùng lúc mà không đè mất thay đổi của nhau; bảo đảm dữ liệu còn nguyên vẹn sau khi mất điện giữa lúc ghi; trả lời câu hỏi phức tạp mà không phải đọc toàn bộ dữ liệu; và từ chối dữ liệu sai ngay tại nguồn bằng ràng buộc.
Atomicity nghĩa là gì trong thực tế?
Nghĩa là một giao dịch hoặc hoàn tất trọn vẹn, hoặc không để lại dấu vết nào. Chuyển tiền gồm trừ tài khoản A và cộng tài khoản B; nếu hỏng giữa chừng mà không có atomicity thì tiền bốc hơi. Database bảo đảm mất điện trước COMMIT thì cả hai câu lệnh đều bị quay lui.
Nên đặt ràng buộc ở database hay ở tầng ứng dụng?
Cả hai, nhưng database là nơi nói lời cuối. Lý do: ứng dụng đến rồi đi, còn dữ liệu ở lại. Trong vòng đời hệ thống, dữ liệu sẽ bị ghi bởi ứng dụng chính, script di trú, job nền, truy vấn sửa tay lúc nửa đêm và cả ứng dụng viết lại sau nhiều năm. Chỉ database là chốt chặn mà tất cả đều phải đi qua.
Khi nào chọn NoSQL thay vì cơ sở dữ liệu quan hệ?
Khi có lý do cụ thể về hình dạng dữ liệu, không phải vì nó mới hơn. Mặc định nên chọn quan hệ, vì phần lớn dữ liệu nghiệp vụ vốn là dữ liệu quan hệ. NoSQL hợp với catalog, log, nội dung linh hoạt, hoặc làm cache. Và hai loại không loại trừ nhau — kiến trúc phổ biến là PostgreSQL làm nguồn sự thật, Redis làm cache phía trước.
Điều gì xảy ra khi connection pool cạn?
Request mới phải xếp hàng đợi một kết nối được trả lại, rồi hết giờ chờ. Triệu chứng là toàn hệ thống chậm dần mà không có lỗi rõ ràng, nên rất khó chẩn đoán. Hai nguyên nhân phổ biến là truy vấn chậm, và giữ kết nối quá lâu vì gọi API bên ngoài trong lúc đang mở giao dịch.
Vì sao không nên gọi API bên ngoài trong một giao dịch đang mở?
Vì giao dịch giữ kết nối database và thường giữ cả khoá trên các dòng liên quan. Một lời gọi HTTP mất vài giây sẽ giữ tài nguyên đó suốt thời gian ấy, làm cạn pool và chặn các giao dịch khác. Cách đúng là lấy dữ liệu bên ngoài trước, rồi mới mở giao dịch để ghi.
Kết luận
Ba điều đáng nhớ nhất:
- Database tồn tại vì bốn vấn đề của file, không phải vì nó là chỗ chứa dữ liệu sang hơn.
- Ràng buộc thuộc về database. Ứng dụng đến rồi đi, dữ liệu ở lại.
- Connection là tài nguyên khan hiếm. Giữ giao dịch mở trong lúc gọi mạng là cách nhanh nhất để làm chậm toàn hệ thống.
Tham khảo
- Học SQL trong 30 ngày — series đi từ
SELECTtới tối ưu - Entity Framework Core — ORM chính thức của .NET
- Transactions và ACID
- Database design best practices
Điều hướng
- Bài trước: 2.5 — API và JSON
- Bài tiếp theo: 2.7 — Authentication và Authorization
- Về module: Trang mục lục
Bài liên quan
- Thêm 4 index làm INSERT chậm 6 lần: cái giá không ai nhắc khi bảo bạn đánh index — Mọi hướng dẫn tối ưu đều bảo thêm index, rất ít bài nói về hoá đơn.
- Hai giao dịch cùng cộng 100, số dư chỉ tăng 100: isolation level qua thí nghiệm thật — Hai phiên cùng đọc số dư 1000 rồi cùng ghi 1100.
- Singleton, Scoped hay Transient? Chọn sai là DbContext sống mãi — Ba lifetime trong DI container của ASP.NET Core khác nhau ở thời điểm tạo và thời điểm dispose instance.