Chuyển tới nội dung chính

19.10 — Liên hệ CRM

Tóm tắt

Hệ CRM/ERP có năm điểm nghẽn lặp đi lặp lại mà gần như dự án nào cũng gặp, và chúng khác với ứng dụng web thông thường ở một điểm quyết định: multi-tenancy. Dữ liệu lệch rất mạnh — 90% tenant nhỏ, vài tenant chiếm phần lớn dữ liệu — nên một truy vấn "bình thường" với tenant trung bình có thể là thảm hoạ với tenant lớn nhất. Hệ quả kéo theo là noisy neighbour: một tenant chạy báo cáo nặng làm chậm mọi tenant khác, và người báo lỗi lại là những người không làm gì sai. Bài này đi qua từng điểm nghẽn với cách xử lý cụ thể.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Nhận ra năm điểm nghẽn đặc trưng của CRM/ERP.
  • Thiết kế phân trang và tìm kiếm chịu được tenant lớn.
  • Xử lý import hàng loạt không làm chậm hệ thống.
  • Giảm ảnh hưởng của noisy neighbour.

Nội dung bài học​

19.10.1 — Dashboard tổng hợp​

Đây là endpoint chậm nhất của hầu hết CRM: nó gom số liệu từ nhiều bảng, tính tổng trên khoảng thời gian dài, và được gọi mỗi lần người dùng đăng nhập.

Ba mức xử lý, theo chi phí tăng dần:

MứcCách làmĐộ tươi
1Cache kết quả với TTL ngắnTrễ theo TTL
2Bảng tổng hợp cập nhật bằng job định kỳTrễ theo chu kỳ job
3Read model cập nhật qua eventGần tức thì

Mức 1 giải quyết phần lớn trường hợp và rẻ nhất. Chỉ lên mức 2 khi truy vấn nặng tới mức một lần chạy cũng làm chậm hệ thống — khi đó cache không đủ vì mỗi lần miss vẫn gây tải (bài 19.6).

-- Mức 2: bảng tổng hợp, job chạy mỗi 15 phút
CREATE TABLE DashboardSummary (
TenantId UNIQUEIDENTIFIER NOT NULL,
Period DATE NOT NULL,
OpenLeadCount INT NOT NULL,
WonDealValue DECIMAL(18,2) NOT NULL,
ComputedAtUtc DATETIME2 NOT NULL,
PRIMARY KEY (TenantId, Period)
);

Truy vấn dashboard giờ đọc một hàng thay vì tổng hợp trên hàng triệu.

19.10.2 — Tìm kiếm​

// SAI — LIKE với wildcard đầu KHÔNG dùng được index
var leads = await _db.Leads
.Where(l => l.TenantId == tenantId && l.Name.Contains(keyword))
.ToListAsync(ct);
// SQL: WHERE Name LIKE '%keyword%' -> table scan

Wildcard ở đầu chuỗi khiến index vô dụng, vì index sắp xếp theo tiền tố. Ba lựa chọn:

CáchPhù hợpHạn chế
Tìm theo tiền tố (StartsWith)Tìm nhanh theo tên, mãKhông tìm được giữa chuỗi
Full-text index của SQL ServerTìm theo từ, có sẵn trong databaseCần cấu hình, không linh hoạt bằng
Elasticsearch / OpenSearchTìm mờ, xếp hạng, gợi ýThêm hạ tầng, phải đồng bộ dữ liệu
// Tìm theo tiền tố — DÙNG được index
.Where(l => l.TenantId == tenantId && l.Name.StartsWith(keyword))

Với phần lớn CRM nội bộ, tìm theo tiền tố cộng full-text index là đủ, và nó tránh được toàn bộ chi phí vận hành cùng vấn đề đồng bộ của một search engine riêng.

Nếu dùng Elasticsearch, việc đồng bộ nên đi qua outbox hoặc CDC chứ không ghi trực tiếp từ use case (bài 17.6) — ghi trực tiếp tạo dual write và dữ liệu sẽ lệch dần.

19.10.3 — Danh sách phân trang​

// SAI — OFFSET lon rat cham
.Skip(page * 50).Take(50)
// Trang 1000: database phải ĐỌC và BỎ 50.000 hàng trước khi trả về 50 hàng

OFFSET buộc database đọc rồi bỏ mọi hàng phía trước. Trang 1 nhanh, trang 1000 rất chậm — và người dùng nhảy tới trang cuối là chuyện thường khi họ muốn xem bản ghi cũ nhất.

// ĐÚNG — keyset pagination, thời gian KHÔNG đổi theo trang
var query = _db.Leads.Where(l => l.TenantId == tenantId);

if (cursor is not null)
query = query.Where(l => l.CreatedAt < cursor.CreatedAt
|| (l.CreatedAt == cursor.CreatedAt && l.Id < cursor.Id));

var page = await query
.OrderByDescending(l => l.CreatedAt)
.ThenByDescending(l => l.Id) // TIE-BREAKER: bat buoc
.Take(50)
.ToListAsync(ct);

Tie-breaker ThenByDescending(l => l.Id) là bắt buộc: nếu hai bản ghi có cùng CreatedAt, thứ tự không xác định và phân trang sẽ bỏ sót hoặc lặp bản ghi giữa các trang.

Đánh đổi: keyset không nhảy được tới "trang 47" tuỳ ý. Với giao diện cuộn vô hạn hoặc nút "tải thêm" — phổ biến trong CRM hiện đại — đó không phải hạn chế (bài 9.5).

Và luôn đặt trần cho pageSize:

var size = Math.Clamp(request.PageSize, 1, 100);   // không cho ?pageSize=999999

19.10.4 — Import hàng loạt​

Import 50.000 dòng từ Excel là nghiệp vụ CRM rất phổ biến, và cách làm ngây thơ gây hai vấn đề cùng lúc.

// SAI — 50.000 lần SaveChanges, và change tracker phình to
foreach (var row in rows)
{
_db.Leads.Add(MapToLead(row));
await _db.SaveChangesAsync(ct); // mỗi lần một round-trip
}
// ĐÚNG — theo lô, và DÙNG context mới mỗi lô
const int batchSize = 1000;

foreach (var chunk in rows.Chunk(batchSize))
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

db.Leads.AddRange(chunk.Select(MapToLead));
await db.SaveChangesAsync(ct);
// context bị huỷ -> change tracker được giải phóng
}

Dùng DbContext mới cho mỗi lô là chi tiết quan trọng: change tracker giữ mọi entity đã thêm, nên một context dùng cho 50.000 entity sẽ chậm dần theo cấp số và ngốn bộ nhớ (bài 13.7).

Với lượng rất lớn, SqlBulkCopy nhanh hơn nhiều — nhưng nó bỏ qua EF Core nên không kích hoạt interceptor, không sinh domain event, và không áp global query filter. Chỉ dùng cho dữ liệu không có ý nghĩa nghiệp vụ phức tạp.

Và quan trọng nhất: import nên chạy trong job nền, không trong request HTTP. Người dùng nhận phản hồi ngay kèm mã theo dõi, và một file lớn không làm treo request hay chạm timeout của gateway (bài 14.7).

19.10.5 — Noisy neighbour​

Tenant "MegaCorp" chạy báo cáo 12 tháng lúc 9h sáng
-> Truy van chiem CPU database trong 40 giay
-> 200 tenant khac deu cham trong 40 giay do
-> Người báo lỗi là những người KHÔNG làm gì sai

Bốn cách giảm nhẹ, theo chi phí tăng dần:

CáchHiệu quảChi phí
Giới hạn tần suất báo cáo nặng theo tenantTrung bìnhThấp
Đẩy báo cáo sang job nền hàng đợiTốtThấp
Read replica cho mọi truy vấn báo cáoTốtTrung bình
Database riêng cho tenant lớnTriệt đểCao

Hai cách đầu nên làm trước vì rẻ và không đổi kiến trúc. Đẩy báo cáo sang hàng đợi có thêm một lợi ích: bạn kiểm soát được số báo cáo chạy đồng thời, nên không bao giờ có 10 báo cáo nặng cùng lúc.

Với tenant lớn tách database riêng, nhớ cân nhắc chi phí vận hành: migration chạy trên N database, giám sát tổng hợp từ N nguồn (bài 19.7).

19.10.6 — Rà lại code của bạn​

Danh sách rà soát hiệu năng CRM

  • •Dashboard đọc từ cache hoặc bảng tổng hợp, không tính lại mỗi lần.
  • •Tìm kiếm không dùng LIKE với wildcard ở đầu chuỗi.
  • •Phân trang dùng keyset cho danh sách lớn, có tie-breaker.
  • •pageSize có trần, không nhận giá trị tuỳ ý từ client.
  • •Import hàng loạt chạy trong job nền, theo lô, context mới mỗi lô.
  • •Báo cáo nặng có giới hạn tần suất hoặc chạy qua hàng đợi.
  • •Đã kiểm thử mọi endpoint danh sách với dữ liệu của tenant lớn nhất.
  • •Có giám sát độ trễ tách theo tenant, không chỉ tổng thể.

Bài tập áp dụng​

Bài 1 — So sánh hai kiểu phân trang​

Tạo 500.000 bản ghi, đo thời gian lấy trang 1 và trang 5000 bằng Skip/Take rồi bằng keyset.

Tiêu chí hoàn thành: bạn thấy Skip/Take chậm dần theo số trang còn keyset thì không, và biết cái giá phải trả khi đổi sang keyset.

Gợi ý và lời giải — Bài 1

Gợi ý. OFFSET 100000 không nhảy thẳng tới dòng thứ 100.001. Database phải đi qua 100.000 dòng đầu rồi bỏ chúng đi.

Lời giải:

// Skip/Take — OFFSET tăng theo số trang
var st = db.Leads.AsNoTracking().Where(l => l.TenantId == 1)
.OrderBy(l => l.NgayTao).ThenBy(l => l.Id)
.Skip((trang - 1) * 20).Take(20).ToList();

// Keyset — luôn bắt đầu từ một MỐC, không bỏ qua dòng nào
var ks = db.Leads.AsNoTracking()
.Where(l => l.TenantId == 1 &&
(l.NgayTao > moc.NgayTao || (l.NgayTao == moc.NgayTao && l.Id > moc.Id)))
.OrderBy(l => l.NgayTao).ThenBy(l => l.Id)
.Take(20).ToList();

Kết quả đo trên .NET 9.0.203, EF Core 9.0.0, SQLite, 500.000 dòng, có index (TenantId, NgayTao, Id):

--- Phân trang trên 500.000 dòng ---
trang 1 | Skip/Take 0,34 ms | keyset 0,37 ms
trang 100 | Skip/Take 0,41 ms | keyset 0,40 ms
trang 1000 | Skip/Take 1,26 ms | keyset 0,40 ms
trang 5000 | Skip/Take 6,79 ms | keyset 0,39 ms
TrangSkip/TakeKeysetTỉ lệ
10,34 ms0,37 mskeyset chậm hơn một chút
1000,41 ms0,40 msbằng nhau
1.0001,26 ms0,40 ms3,2×
5.0006,79 ms0,39 ms17,4×

Ba điều bảng này nói ra:

1. Keyset có thời gian gần như HẰNG SỐ: 0,37 → 0,40 → 0,40 → 0,39 ms.

Trang 1 và trang 5.000 tốn như nhau
-> vì cả hai đều là "nhảy tới một vị trí trong index rồi đọc 20 dòng"
-> O(log n) cho phần nhảy, O(20) cho phần đọc

2. Skip/Take tăng tuyến tính theo số trang.

trang 1.000  -> bỏ qua  19.980 dòng -> 1,26 ms
trang 5.000 -> bỏ qua 99.980 dòng -> 6,79 ms

Tỉ lệ gần đúng: 5 lần số dòng bỏ qua -> 5,4 lần thời gian

3. Ở trang 1, keyset còn chậm hơn một chút (0,37 so với 0,34 ms).

Điều kiện keyset phức tạp hơn: (a > x) OR (a = x AND b > y)
-> tốn thêm một chút so với OFFSET 0

Nên nếu người dùng gần như không bao giờ đi quá trang 5,
Skip/Take vẫn là lựa chọn hợp lý — và đơn giản hơn nhiều.

Cái giá phải trả khi đổi sang keyset — bốn thứ:

1. Không nhảy tới một trang bất kỳ được.

Skip/Take:  "trang 250" -> đi thẳng
Keyset: chỉ có "trang tiếp theo" và "trang trước"

-> mất giao diện phân trang dạng [1] [2] [3] ... [250]
-> đổi thành nút "Tải thêm" hoặc cuộn vô hạn

Đây thường là điều khiến việc đổi bị chặn — và nó là quyết định về giao diện, không phải về kỹ thuật.

2. Cần một thứ tự có tính duy nhất.

// SAI — NgayTao có thể trùng -> bỏ sót hoặc lặp bản ghi khi sang trang
.OrderBy(l => l.NgayTao)

// ĐÚNG — thêm khoá chính để phá thế hoà
.OrderBy(l => l.NgayTao).ThenBy(l => l.Id)
Hai lead cùng NgayTao, nằm ở ranh giới giữa hai trang
-> không có tiêu chí phá hoà -> database tự chọn thứ tự
-> một lead xuất hiện hai lần, một lead biến mất

3. Mốc phải được truyền qua lại giữa client và server.

// Mã hoá mốc thành con trỏ mờ, để client không phụ thuộc vào cấu trúc
public static string TaoConTro(DateTime ngayTao, int id) =>
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ngayTao:O}|{id}"));
{
"items": [ ... ],
"conTroTiep": "MjAyNi0wOS0yNVQxMDowMDowMC4wMDAwMDAwWnw4ODQy"
}

Con trỏ mờ cho phép bạn đổi cột sắp xếp sau này mà không phá vỡ client.

4. Tổng số trang không còn tính được rẻ.

Skip/Take: thường kèm một COUNT(*) -> biết tổng số trang
Keyset: không có khái niệm "tổng số trang"

Và thực ra COUNT(*) trên 500.000 dòng cũng không rẻ
-> nhiều hệ thống bỏ luôn con số tổng, hoặc hiển thị "hơn 1.000 kết quả"

Khi nào dùng cái nào:

Tình huốngChọn
Danh sách quản trị, người dùng hiếm khi qua trang 10Skip/Take — đơn giản
Cuộn vô hạn, ứng dụng di độngKeyset
Xuất dữ liệu, duyệt toàn bộ bảngKeyset — bắt buộc
API công khai cho bên thứ baKeyset — tránh bị quét bằng offset lớn

Hàng thứ ba đáng nhấn: duyệt toàn bộ 500.000 dòng bằng Skip/Take là O(n²) tính trên toàn bộ quá trình — mỗi trang lại đi lại từ đầu. Với keyset nó là O(n).

Và một lưu ý về số đo ở trên: SQLite chạy trong cùng tiến trình, không có mạng và không có tranh chấp. Trên PostgreSQL hay SQL Server với dữ liệu lớn hơn và bộ nhớ đệm không chứa hết bảng, chênh lệch ở trang sâu thường lớn hơn nhiều, vì OFFSET lớn còn kéo theo đọc đĩa.


Bài 2 — Đo tác dụng của lô​

Import 20.000 bản ghi bằng vòng lặp SaveChanges từng dòng và bằng lô 1.000, so sánh thời gian và bộ nhớ.

Tiêu chí hoàn thành: bạn đo được cả hai, và nhận ra lô lớn hơn không phải lúc nào cũng tốt hơn.

Gợi ý và lời giải — Bài 2

Gợi ý. Mỗi SaveChanges là một transaction và một vòng khứ hồi. Đừng đo chỉ hai kích thước — đo bốn.

Lời giải:

foreach (var kichThuocLo in new[] { 1, 100, 1000, 20000 })
{
GC.Collect(2, GCCollectionMode.Forced, true, true);
var truoc = GC.GetTotalAllocatedBytes();

using var db = new Db(cs);
db.Database.EnsureCreated();
var sw = Stopwatch.StartNew();

for (int i = 0; i < 20_000; i++)
{
db.Leads.Add(new Lead { /* ... */ });
if ((i + 1) % kichThuocLo == 0)
{
db.SaveChanges();
db.ChangeTracker.Clear(); // giải phóng entity đã lưu
}
}
db.SaveChanges();
sw.Stop();

var cap = (GC.GetTotalAllocatedBytes() - truoc) / 1048576.0;
Console.WriteLine($"lô {kichThuocLo,6} | {sw.Elapsed.TotalMilliseconds,9:F0} ms | cấp phát {cap,8:F1} MB");
}

Kết quả đo trên .NET 9.0.203, EF Core 9.0.0, SQLite:

--- Import 20.000 dòng ---
lô 1 | 19.345 ms | cấp phát 309,4 MB
lô 100 | 1.155 ms | cấp phát 181,6 MB
lô 1000 | 809 ms | cấp phát 180,1 MB
lô 20000 | 1.065 ms | cấp phát 183,4 MB
Kích thước lôThời gianSo với lô 1Cấp phát
1 (từng dòng)19.345 ms1×309,4 MB
1001.155 ms16,8×181,6 MB
1.000809 ms23,9×180,1 MB
20.000 (một lần)1.065 ms18,2×183,4 MB

Ba điều đáng chú ý:

1. Bước nhảy lớn nhất nằm giữa lô 1 và lô 100 — 16,8 lần.

20.000 transaction -> 200 transaction
-> chi phí mở/đóng transaction giảm 100 lần
-> và với database qua mạng, 20.000 vòng khứ hồi cũng giảm theo

Phần lớn lợi ích đến từ bước đầu tiên. Từ lô 100 lên lô 1.000 chỉ thêm 1,4 lần nữa.

2. Lô 20.000 CHẬM HƠN lô 1.000 — 1.065 ms so với 809 ms.

Đây là điều bài tập muốn bạn thấy:

Một transaction giữ 20.000 entity trong change tracker
-> DetectChanges phải quét toàn bộ tracker
-> transaction dài giữ khoá lâu hơn
-> và với database thật, nhật ký giao dịch phình to

Điều này khớp với kết quả ở bài 13.7: chi phí của change tracker tăng theo bình phương số entity đang theo dõi.

3. Cấp phát gần như không đổi từ lô 100 trở đi: 181,6 → 180,1 → 183,4 MB.

Lô 1 cấp phát 309,4 MB — nhiều hơn 71%
-> vì 20.000 lần SaveChanges cũng là 20.000 lần dựng câu lệnh,
mở transaction, và sinh đối tượng nội bộ của EF

Từ lô 100 trở đi: phần cấp phát chủ yếu là chính 20.000 entity
-> không giảm thêm được bằng cách tăng kích thước lô

ChangeTracker.Clear() là dòng dễ bỏ quên nhất, và hậu quả rất lớn:

db.SaveChanges();
db.ChangeTracker.Clear(); // <- không có dòng này, tracker giữ cả 20.000 entity
Không có Clear():
entity đã lưu vẫn nằm trong tracker
-> mỗi SaveChanges kế tiếp phải quét toàn bộ tracker đang lớn dần
-> O(n²), và bộ nhớ tăng tuyến tính tới hết vòng lặp

Kích thước lô nên chọn: bắt đầu từ 500–1.000, rồi đo.

Quá nhỏ (dưới 100):   chi phí transaction chiếm ưu thế
Vừa (500–2.000): thường là điểm tốt nhất
Quá lớn (trên 10.000): change tracker và nhật ký giao dịch trở thành vấn đề

Và khi cần nhanh hơn nữa, EF Core không phải công cụ đúng:

// Bulk copy — nhanh hơn EF Core một bậc cho việc nạp dữ liệu thuần
using var bulk = new SqlBulkCopy(connection)
{
DestinationTableName = "Leads",
BatchSize = 5000,
};
await bulk.WriteToServerAsync(bang, ct);
EF Core: sinh INSERT, theo dõi thay đổi, chạy trình chặn, cập nhật khoá sinh ra
Bulk copy: ghi thẳng vào database, bỏ qua toàn bộ những thứ đó

-> nhanh hơn nhiều, nhưng KHÔNG chạy logic nghiệp vụ và không sinh sự kiện

Với PostgreSQL, tương đương là COPY qua NpgsqlBinaryImporter. Chọn bulk copy khi dữ liệu đã được kiểm chứng trước và không cần domain logic — ví dụ nạp dữ liệu ban đầu hoặc đồng bộ từ hệ thống khác.

Và một điều thường bị quên khi import lớn: chạy nó ở đâu.

Import 20.000 dòng trong một HTTP request:
- request kéo dài 800 ms tới vài giây
- chiếm một luồng và một kết nối suốt thời gian đó
- client timeout -> người dùng bấm lại -> import chạy hai lần

Đúng: nhận tệp -> ghi vào hàng đợi -> trả 202 Accepted
-> worker xử lý theo lô -> báo tiến độ qua một endpoint riêng

Bài 3 — Tái hiện noisy neighbour​

Chạy một truy vấn báo cáo nặng của một tenant và đo p99 của endpoint danh sách của tenant khác trong lúc đó.

Tiêu chí hoàn thành: bạn thấy p99 của tenant không liên quan tăng lên, và nêu được ba tài nguyên dùng chung gây ra hiện tượng đó.

Gợi ý và lời giải — Bài 3

Gợi ý. Hai tenant dùng chung database, chung pool kết nối, chung bộ nhớ đệm. Ranh giới tenant nằm ở tầng ứng dụng, không nằm ở tầng tài nguyên.

Lời giải — bài thử:

# Nền: tenant B gọi endpoint danh sách đều đặn
k6 run --duration 5m --rps 50 --env TENANT=B ci/tai-danh-sach.js &

# Sau 1 phút, tenant A chạy báo cáo nặng
sleep 60
curl -s "https://crm/api/bao-cao/tong-hop?tenant=A&tu=2024-01-01&den=2026-09-25"

Hình dạng kết quả (mức điển hình — thí nghiệm này cần một database thật có tranh chấp tài nguyên, không tái hiện được bằng SQLite trên máy viết bài):

Thời điểm       p99 của tenant B (endpoint danh sách)
-------------- -------------------------------------
t+0 → t+60s 180 ms <- nền, bình thường
t+60s (A bắt đầu báo cáo) 2.400 ms <- tăng vọt
t+60s → t+95s 2.100 ms
t+95s (báo cáo xong) 190 ms <- trở lại bình thường

Tenant B không làm gì khác thường, và không có code nào của B bị đổi.

Ba tài nguyên dùng chung gây ra hiện tượng này:

1. Bộ nhớ đệm của database — tác nhân lớn nhất.

Báo cáo của A quét 2 triệu dòng
-> 2 triệu dòng đó được nạp vào buffer pool
-> ĐẨY dữ liệu đang nóng của B ra khỏi bộ nhớ đệm

Sau đó truy vấn của B, vốn đọc từ bộ nhớ, phải đọc từ ĐĨA
-> chậm hơn hàng chục lần, dù chính truy vấn đó không đổi

Đây là lý do p99 của B vẫn cao thêm một lúc sau khi báo cáo đã xong: bộ nhớ đệm cần thời gian để nóng lại.

2. CPU và I/O của database.

Báo cáo chiếm phần lớn CPU và băng thông đĩa
-> truy vấn của B xếp hàng sau nó

3. Pool kết nối của ứng dụng.

Báo cáo giữ một kết nối trong 35 giây
-> pool còn ít kết nối hơn cho phần còn lại
-> nếu có vài báo cáo cùng lúc, pool cạn -> mọi request chờ

Cơ chế thứ ba chính là bulkhead ở bài 18.10, lần này xuất hiện ở tầng database.

Bốn cách xử lý, theo thứ tự chi phí:

1. Giới hạn phạm vi truy vấn — rẻ nhất, hiệu quả nhất.

// Chặn khoảng thời gian quá rộng ngay ở tầng API
if ((den - tu).TotalDays > 366)
return Results.BadRequest("Khoảng báo cáo tối đa 12 tháng");
Phần lớn báo cáo "nặng" thật ra là báo cáo KHÔNG GIỚI HẠN
-> người dùng chọn "từ đầu tới nay" vì đó là mặc định
-> đặt mặc định 3 tháng thường loại bỏ phần lớn vấn đề

2. Pool kết nối riêng cho báo cáo.

builder.Services.AddDbContext<BaoCaoDbContext>(o =>
o.UseNpgsql(cauHinh.BaoCaoConnectionString)); // Maximum Pool Size = 5
Báo cáo tối đa 5 kết nối -> không bao giờ làm cạn pool chính
-> báo cáo thứ 6 xếp hàng, nhưng API nghiệp vụ không bị ảnh hưởng

3. Bản sao đọc riêng cho báo cáo.

Báo cáo đọc từ replica -> buffer pool của primary không bị đẩy ra
-> tách hoàn toàn hai loại tải

Đổi lại: dữ liệu trễ vài giây, và một database nữa phải vận hành

4. Tách database cho tenant lớn.

Tenant chiếm trên 30% dữ liệu -> database riêng
-> hết noisy neighbour cho tenant đó, và cho cả những tenant khác

Phát hiện noisy neighbour trên hệ thống thật:

# p99 theo tenant — tenant nào đang làm phiền tenant nào?
histogram_quantile(0.99,
sum by (le, tenant) (rate(http_server_request_duration_seconds_bucket[5m])))

# Tương quan: p99 của mọi tenant cùng tăng vào một thời điểm
# -> không phải lỗi của tenant nào cả, mà là tài nguyên dùng chung
-- Truy vấn nào đang chạy lâu nhất, và của tenant nào?
SELECT pid, now() - query_start AS chay_duoc, state,
LEFT(query, 100) AS truy_van
FROM pg_stat_activity
WHERE state = 'active' AND now() - query_start > INTERVAL '5 seconds'
ORDER BY chay_duoc DESC;

Và một biện pháp phòng ngừa đáng có, độc lập với bốn cách trên: đặt statement_timeout.

-- PostgreSQL, cho riêng vai trò chạy báo cáo
ALTER ROLE crm_baocao SET statement_timeout = '30s';
Một truy vấn chạy sai không thể làm phiền cả hệ thống quá 30 giây
-> và người viết truy vấn đó nhận được phản hồi ngay,
thay vì phát hiện qua một sự cố lúc giờ cao điểm

Đây là loại biện pháp nên có trước khi cần tới — nó không sửa được truy vấn chậm, nhưng nó giới hạn thiệt hại mà một truy vấn chậm có thể gây ra.

Tự kiểm tra​

Câu hỏi thường gặp

Vì sao cache không phải lúc nào cũng đủ cho dashboard?

Vì mỗi lần cache miss vẫn phải chạy truy vấn tổng hợp. Nếu một lần chạy đã đủ làm chậm hệ thống thì phải dùng bảng tổng hợp cập nhật bằng job hoặc read model cập nhật qua event.

Vì sao LIKE với wildcard ở đầu chuỗi không dùng được index?

Vì index sắp xếp theo tiền tố, nên không có cách nào thu hẹp phạm vi khi phần đầu chuỗi chưa biết. Database buộc phải quét toàn bộ. Tìm theo tiền tố hoặc full-text index giải quyết được.

Vì sao keyset pagination cần tie-breaker?

Vì nếu hai bản ghi có cùng giá trị cột sắp xếp thì thứ tự giữa chúng không xác định, dẫn tới bỏ sót hoặc lặp bản ghi khi chuyển trang. Thêm khoá chính làm tiêu chí phụ giải quyết điều đó.

Vì sao import nên dùng DbContext mới cho mỗi lô?

Vì change tracker giữ mọi entity đã thêm, nên một context dùng cho năm mươi nghìn entity sẽ chậm dần theo cấp số và ngốn bộ nhớ. Huỷ context sau mỗi lô giải phóng change tracker.

SqlBulkCopy đánh đổi gì?

Nó nhanh hơn nhiều nhưng bỏ qua EF Core, nên không kích hoạt interceptor, không sinh domain event và không áp global query filter. Chỉ dùng cho dữ liệu không có ý nghĩa nghiệp vụ phức tạp.

Vì sao đẩy báo cáo sang hàng đợi giúp giảm noisy neighbour?

Vì bạn kiểm soát được số báo cáo chạy đồng thời, nên không bao giờ có nhiều báo cáo nặng cùng đánh vào database. Đây là cách rẻ và không phải đổi kiến trúc.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Dữ liệu CRM lệch rất mạnh. Kiểm thử với tenant lớn nhất, không với tenant trung bình.
  2. Keyset pagination giữ thời gian không đổi theo trang, OFFSET thì không.
  3. Noisy neighbour là vấn đề của người khác chịu. Giới hạn và hàng đợi là cách rẻ nhất để giảm.

Tham khảo​

Điều hướng​