Skip to main content

17.13 — Mini case study

Summary

Một sự cố có hậu quả tài chính thật: sau một đợt bảo trì RabbitMQ kéo dài 4 phút, 1.247 khách hàng bị tạo hoá đơn hai lần, tổng giá trị trùng gần 890 triệu đồng. Không có bug logic nào — mọi đoạn code đều đúng như đã viết. Nguyên nhân là một giả định sai mà rất nhiều đội mắc phải: "message chỉ được giao một lần". Khi broker khởi động lại, nó giao lại những message chưa nhận được ACK, và consumer — vốn không idempotent — xử lý chúng lần thứ hai. Bài này đi qua quá trình truy nguyên, cách xử lý hậu quả, và bốn thay đổi để chuyện đó không lặp lại.

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

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

  • Truy nguyên một sự cố message trùng từ triệu chứng nghiệp vụ.
  • Xử lý hậu quả dữ liệu trùng một cách an toàn.
  • Áp dụng bốn thay đổi phòng ngừa theo thứ tự ưu tiên.
  • Viết test chứng minh consumer idempotent.

Nội dung bài học​

17.13.1 — Sự cố​

09:14  Đội vận hành restart RabbitMQ để áp bản vá bảo mật
09:14 Consumer "billing-invoice" mat ket noi, 3.100 message chua ACK
09:18 RabbitMQ len lai, GIAO LAI toan bo message chua ACK
09:19 Consumer xử lý lại -> tạo hoá đơn LẦN HAI
09:41 Kế toán báo: khách hàng gọi điện phản ánh hoá đơn trùng
10:05 Tắt consumer, bắt đầu điều tra

Quy mô: 1.247 hoá đơn trùng, 890 triệu đồng, và 43 khách hàng đã bị trừ tiền tự động qua thẻ đã lưu.

Tình tiết đáng chú ý: đội vận hành làm đúng quy trình — họ khởi động lại broker trong giờ thấp điểm, có thông báo trước. Vấn đề không nằm ở thao tác mà ở giả định của code.

17.13.2 — Truy nguyên​

Bước 1 — xác nhận có trùng thật, không phải lỗi hiển thị:

SELECT CustomerId, Period, COUNT(*) AS Số_hoá_đơn
FROM Invoices
WHERE CreatedUtc BETWEEN '2026-03-15 09:18' AND '2026-03-15 09:25'
GROUP BY CustomerId, Period
HAVING COUNT(*) > 1;
-- 1247 hang

Bước 2 — tìm code tạo hoá đơn:

// Consumer — đúng logic, nhưng KHÔNG idempotent
public async Task Consume(ConsumeContext<SubscriptionRenewedEvent> context)
{
var evt = context.Message;

var invoice = Invoice.Create(evt.CustomerId, evt.Period, evt.Amount);
_db.Invoices.Add(invoice);
await _db.SaveChangesAsync(context.CancellationToken);

await _paymentService.ChargeAsync(invoice, context.CancellationToken);
}

Không có kiểm tra "đã xử lý message này chưa", và bảng Invoices không có unique constraint trên (CustomerId, Period).

Bước 3 — xác nhận giả thuyết:

-- Hai hoá đơn cùng khách, cùng kỳ, tạo cách nhau vài giây
SELECT Id, CustomerId, Period, Amount, CreatedUtc, SourceMessageId
FROM Invoices WHERE CustomerId = '...' AND Period = '2026-03';

-- Id-1 ... 2026-03 5.000.000 09:12:03 msg-7f3a...
-- Id-2 ... 2026-03 5.000.000 09:19:41 msg-7f3a... <-- CÙNG message id

Cột SourceMessageId — may mắn đã được ghi để phục vụ log — cho bằng chứng dứt khoát: cùng một message, xử lý hai lần.

17.13.3 — Xử lý hậu quả​

Thứ tự này quan trọng: dừng chảy máu trước, sửa dữ liệu sau.

1. Dừng consumer để không tạo thêm hoá đơn trùng.

2. Hoàn tiền trước, xoá hoá đơn sau. 43 giao dịch đã trừ tiền phải hoàn qua cổng thanh toán. Làm việc này trước khi xoá hoá đơn, vì hoá đơn là chứng từ đối soát với cổng — xoá trước thì mất dấu vết để hoàn.

3. Đánh dấu thay vì xoá:

UPDATE Invoices
SET Status = 'VoidedDuplicate',
VoidedReason = N'Trùng do message được giao lại ngày 15/03/2026',
VoidedUtc = SYSUTCDATETIME()
WHERE Id IN (SELECT ...); -- giữ bản ghi cũ nhất, void bản ghi sau

Không DELETE. Giữ bản ghi để đối soát và để kiểm toán — xoá là mất bằng chứng về chính sự cố này.

4. Gửi thông báo cho khách hàng bị ảnh hưởng trước khi họ phát hiện.

17.13.4 — Bốn thay đổi phòng ngừa​

Thay đổi 1 — unique constraint (làm ngay trong ngày):

CREATE UNIQUE INDEX UX_Invoices_Customer_Period
ON Invoices (CustomerId, Period)
WHERE Status <> 'VoidedDuplicate';

Đây là tuyến phòng thủ chắc chắn nhất, vì nó do database đảm bảo chứ không phụ thuộc code nhớ kiểm tra (bài 17.2).

try
{
_db.Invoices.Add(invoice);
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.IsUniqueViolation())
{
_logger.LogInformation("Hoá đơn đã tồn tại cho {CustomerId} kỳ {Period}",
evt.CustomerId, evt.Period);
return; // coi như thành công, ACK bình thường
}

Thay đổi 2 — bảng inbox cho những consumer không có khoá nghiệp vụ tự nhiên:

await using var tx = await _db.Database.BeginTransactionAsync(ct);

_db.InboxMessages.Add(new InboxMessage { MessageId = evt.EventId });
try { await _db.SaveChangesAsync(ct); }
catch (DbUpdateException ex) when (ex.IsUniqueViolation()) { return; }

// ... nghiệp vụ ...
await tx.CommitAsync(ct); // inbox + nghiệp vụ cùng commit

Thay đổi 3 — idempotency key cho cổng thanh toán. Đây là thay đổi ngăn thiệt hại tài chính, quan trọng hơn cả hai thay đổi trên:

await _paymentService.ChargeAsync(
invoice,
idempotencyKey: $"invoice-{invoice.Id}", // cổng thanh toán tự chặn trùng
ct);

Ngay cả khi hoá đơn bị tạo trùng, tiền không bị trừ hai lần — cổng thanh toán nhận ra key đã dùng và trả về kết quả của lần đầu.

Thay đổi 4 — test chứng minh idempotent:

[Fact]
public async Task Consumer_ShouldCreateOneInvoice_WhenMessageDeliveredTwice()
{
var evt = new SubscriptionRenewedEvent(
EventId: Guid.NewGuid(), CustomerId: customerId,
Period: "2026-03", Amount: 5_000_000m);

await _consumer.Consume(CreateContext(evt));
await _consumer.Consume(CreateContext(evt)); // CÙNG message

var count = await _db.Invoices
.CountAsync(i => i.CustomerId == customerId && i.Period == "2026-03");

Assert.Equal(1, count);
}

Test này chạy trên database thật qua Testcontainers, vì unique constraint là thứ đang được kiểm tra — provider InMemory không có ràng buộc nên test sẽ xanh một cách vô nghĩa (bài 16.10).

Áp dụng mẫu test này cho mọi consumer, không riêng consumer vừa gây sự cố.

17.13.5 — Ba bài học​

1. "Message chỉ giao một lần" là giả định sai. Broker đảm bảo at-least-once, và đó là lựa chọn có chủ đích — thà giao hai lần còn hơn mất. Idempotency là trách nhiệm của consumer (bài 17.2).

2. Phòng thủ nhiều lớp. Trong ca này có ba lớp cùng thiếu: không inbox, không unique constraint, không idempotency key ở cổng thanh toán. Chỉ cần một lớp tồn tại là thiệt hại đã nhỏ hơn nhiều. Bảng dưới xếp theo mức độ chắc chắn:

LớpNếu chỉ có lớp nàyMức chắc chắn
Idempotency key ở cổngHoá đơn trùng nhưng không mất tiềnCao
Unique constraintKhông có hoá đơn trùngCao nhất
Bảng inboxKhông xử lý lại messageCao
Kiểm tra bằng câu ifVẫn trùng khi chạy song songThấp

3. Ghi lại SourceMessageId từ đầu. Nếu không có cột đó, việc truy nguyên sẽ mất nhiều giờ thay vì vài phút. Chi phí thêm một cột gần bằng không; giá trị của nó chỉ hiện ra đúng lúc bạn cần nhất.

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

Danh sách rà soát rút ra từ ca này

  • •Mọi consumer đều an toàn khi nhận cùng message hai lần.
  • •Thao tác có khoá nghiệp vụ tự nhiên được bảo vệ bằng unique constraint.
  • •Consumer không có khoá tự nhiên dùng bảng inbox trong cùng transaction.
  • •Mọi lời gọi cổng thanh toán đều có idempotency key.
  • •Mỗi consumer có test gửi cùng message hai lần.
  • •Test chạy trên database thật, không dùng provider InMemory.
  • •Bản ghi lưu SourceMessageId để truy nguyên.
  • •Dữ liệu sai được đánh dấu void, không xoá cứng.
  • •Quy trình bảo trì broker có bước kiểm tra consumer đã idempotent.

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

Bài 1 — Tái hiện sự cố​

Gửi cùng một message hai lần vào consumer hiện tại của bạn và đếm số bản ghi được tạo.

Tiêu chí hoàn thành: bạn kiểm tra được mọi consumer, không chỉ một, và phân loại được consumer theo mức độ thiệt hại khi xử lý trùng.

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

Gợi ý. Một consumer xử lý trùng tạo hoá đơn thừa. Một consumer khác ghi log hai dòng. Hai mức thiệt hại khác nhau.

Lời giải — test tổng quát cho mọi consumer:

public class MoiConsumerPhaiIdempotentTests(IntegrationFixture fixture)
{
public static IEnumerable<object[]> MoiConsumer()
{
yield return [typeof(TaoHoaDonConsumer), TaoSuKienGiaHan(), "Invoices"];
yield return [typeof(TaoSubscriptionConsumer), TaoSuKienConvert(), "Subscriptions"];
yield return [typeof(TruTonKhoConsumer), TaoSuKienDatHang(), "TonKho"];
yield return [typeof(GuiEmailConsumer), TaoSuKienConvert(), "EmailDaGui"];
yield return [typeof(CapNhatThongKeConsumer), TaoSuKienChotLead(), "ThongKe"];
}

[Theory]
[MemberData(nameof(MoiConsumer))]
public async Task Consumer_xu_ly_message_hai_lan_chi_tao_mot_ban_ghi(
Type kieuConsumer, object suKien, string tenBang)
{
await fixture.ResetAsync();

var truoc = await DemDongAsync(tenBang);

await GoiConsumerAsync(kieuConsumer, suKien);
await GoiConsumerAsync(kieuConsumer, suKien); // CÙNG message

var sau = await DemDongAsync(tenBang);

(sau - truoc).Should().Be(1,
"{0} phải idempotent — at-least-once là mặc định, message SẼ tới hai lần",
kieuConsumer.Name);
}
}
Passed:  TaoSubscriptionConsumer
Passed: GuiEmailConsumer

Failed: TaoHoaDonConsumer — Expected 1, found 2
Failed: TruTonKhoConsumer — Expected 1, found 2
Failed: CapNhatThongKeConsumer — Expected 1, found 2

Ba trên năm consumer không idempotent — và mỗi cái có mức thiệt hại rất khác nhau.

Phân loại theo mức độ thiệt hại:

MứcHậu quả khi xử lý trùngVí dụ
Nghiêm trọngThiệt hại tài chính không hoàn tác đượcTạo hoá đơn, trừ tiền, chuyển khoản
CaoDữ liệu nghiệp vụ sai, cần dọn thủ côngTrừ tồn kho, tạo đơn hàng, cấp tài khoản
Trung bìnhSố liệu sai, người dùng khó chịuThống kê cộng dồn, gửi email/SMS
ThấpNhiễu, không sai dữ liệuGhi log, cập nhật cache

Áp vào kết quả ở trên:

TaoHoaDonConsumer        -> NGHIÊM TRỌNG  -> sửa NGAY trong ngày
TruTonKhoConsumer -> CAO -> sửa trong sprint này
CapNhatThongKeConsumer -> TRUNG BÌNH -> sửa trong sprint sau

Đây là cách ưu tiên đúng — không phải sửa theo thứ tự alphabet hay theo thứ tự trong file.

Kiểm chứng thiệt hại thật trên production:

-- Hoá đơn trùng
SELECT CustomerId, Period, COUNT(*) AS SoHoaDon, SUM(Total) AS TongTien
FROM Invoices
GROUP BY CustomerId, Period
HAVING COUNT(*) > 1
ORDER BY SUM(Total) DESC;
CustomerId   Period      SoHoaDon   TongTien
cus-8421 2026-09 2 4.800.000
cus-3102 2026-09 2 2.400.000
cus-9918 2026-08 3 7.200.000
...
(142 dòng)
-- Tổng thiệt hại
SELECT COUNT(*) AS SoKhach, SUM(TienThua) AS TongThua
FROM (
SELECT CustomerId, SUM(Total) - MAX(Total) AS TienThua
FROM Invoices GROUP BY CustomerId, Period HAVING COUNT(*) > 1
) x;
SoKhach   TongThua
142 187.400.000

187 triệu đồng tính thừa cho 142 khách hàng. Đây là con số biến một nhiệm vụ kỹ thuật thành một ưu tiên của cả tổ chức.

Kiểm tra tồn kho:

SELECT o.Id, o.ProductId, o.Quantity,
(SELECT COUNT(*) FROM TonKhoLog l
WHERE l.OrderId = o.Id AND l.ProductId = o.ProductId) AS SoLanTru
FROM OrderItems o
WHERE (SELECT COUNT(*) FROM TonKhoLog l
WHERE l.OrderId = o.Id AND l.ProductId = o.ProductId) > 1;
-- Tồn kho âm — dấu hiệu rõ ràng
SELECT ProductId, SoLuong FROM TonKho WHERE SoLuong < 0;

Vì sao phải kiểm tra MỌI consumer, không chỉ một:

Sự cố xảy ra ở consumer hoá đơn -> đội sửa consumer hoá đơn
-> ba tháng sau, sự cố tương tự ở consumer tồn kho
-> sáu tháng sau, ở consumer khác

Nguyên nhân gốc KHÔNG phải một consumer viết sai,
mà là MẪU consumer trong codebase không có khử trùng lặp.
Mẫu đó được sao chép cho mọi consumer mới.

Đây là điểm ở bài 17.9: mẫu code được sao chép nhanh hơn được sửa.

Cách chặn tận gốc — lớp cơ sở bắt buộc:

public abstract class ConsumerIdempotent<TEvent> : IConsumer<TEvent> where TEvent : class
{
protected abstract string LayKhoaIdempotency(TEvent e);
protected abstract Task XuLyAsync(TEvent e, CrmDbContext db, CancellationToken ct);

public async Task Consume(ConsumeContext<TEvent> ctx)
{
var khoa = LayKhoaIdempotency(ctx.Message);

_db.MessageDaXuLy.Add(new MessageDaXuLy
{
Khoa = khoa,
LoaiMessage = typeof(TEvent).Name,
XuLyLuc = _clock.GetUtcNow().UtcDateTime,
});

await XuLyAsync(ctx.Message, _db, ctx.CancellationToken);

try
{
await _db.SaveChangesAsync(ctx.CancellationToken); // MỘT transaction
}
catch (DbUpdateException ex) when (LaViPhamKhoaChinh(ex))
{
_logger.LogInformation("Message {Khoa} đã xử lý, bỏ qua", khoa);
_demTrungLap.Add(1, new KeyValuePair<string, object?>("consumer", GetType().Name));
}
}
}
public sealed class TaoHoaDonConsumer : ConsumerIdempotent<SubscriptionDaGiaHanV1>
{
protected override string LayKhoaIdempotency(SubscriptionDaGiaHanV1 e)
=> $"hoadon:{e.CustomerId}:{e.Period:yyyy-MM}"; // khoá NGHIỆP VỤ

protected override Task XuLyAsync(SubscriptionDaGiaHanV1 e, CrmDbContext db, CancellationToken ct)
{
db.Invoices.Add(Invoice.Tao(e.CustomerId, e.Period, e.SoTien));
return Task.CompletedTask;
}
}

LayKhoaIdempotency là abstract, nên trình biên dịch buộc mọi consumer mới phải quyết định khoá của mình. Đây là cách biến một quy ước dễ quên thành một yêu cầu không thể bỏ qua.

Và kiến trúc test chặn consumer không kế thừa:

[Fact]
public void Moi_consumer_phai_ke_thua_ConsumerIdempotent()
{
var viPham = typeof(TaoHoaDonConsumer).Assembly.GetTypes()
.Where(t => !t.IsAbstract && t.GetInterfaces()
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IConsumer<>)))
.Where(t => !LaKeThuaTu(t, typeof(ConsumerIdempotent<>)))
.Select(t => t.Name)
.ToList();

viPham.Should().BeEmpty(
"consumer phải kế thừa ConsumerIdempotent<T> để buộc khai báo khoá idempotency");
}

Bài 2 — Thêm lớp phòng thủ bằng unique constraint​

Thêm unique constraint cho khoá nghiệp vụ, chạy lại bài 1 và xác nhận chỉ còn một bản ghi.

Tiêu chí hoàn thành: bạn dọn được dữ liệu cũ trước khi thêm ràng buộc, và nêu được thứ tự đúng của ba lớp phòng thủ.

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

Gợi ý. ALTER TABLE ADD CONSTRAINT trên bảng có dữ liệu vi phạm sẽ thất bại. Nhưng đó không phải vấn đề lớn nhất.

Lời giải — bước 1: đếm và phân loại dữ liệu vi phạm:

SELECT CustomerId, Period, COUNT(*) AS SoBan, MIN(CreatedUtc) AS SomNhat,
MAX(CreatedUtc) AS MuonNhat, SUM(Total) AS TongTien,
MAX(CASE WHEN Status = 'Paid' THEN 1 ELSE 0 END) AS CoDaThanhToan
FROM Invoices
GROUP BY CustomerId, Period
HAVING COUNT(*) > 1
ORDER BY MuonNhat DESC;
CustomerId  Period    SoBan  SomNhat              MuonNhat             CoDaThanhToan
cus-8421 2026-09 2 2026-09-01 02:14:11 2026-09-01 02:14:13 1
cus-3102 2026-09 2 2026-09-01 02:14:22 2026-09-01 02:14:24 0
...

Cột MuonNhat quan trọng nhất:

MuonNhat = hôm qua  -> vi phạm VẪN đang được tạo
-> sửa consumer TRƯỚC, dọn dữ liệu SAU
-> dọn trước là công việc vô ích, nó sẽ quay lại

MuonNhat = 3 tháng trước -> nguồn đã dừng, chỉ cần dọn

Cột CoDaThanhToan quyết định cách dọn:

Hoá đơn trùng CHƯA thanh toán  -> xoá hoặc void, đơn giản
Hoá đơn trùng ĐÃ thanh toán -> KHÔNG xoá được
-> phải hoàn tiền, và đó là quyết định của kế toán

Bước 2 — sửa consumer trước:

protected override string LayKhoaIdempotency(SubscriptionDaGiaHanV1 e)
=> $"hoadon:{e.CustomerId}:{e.Period:yyyy-MM}";
# Deploy, theo dõi 3 ngày
SELECT MAX(MuonNhat) FROM (
SELECT MAX(CreatedUtc) AS MuonNhat FROM Invoices
GROUP BY CustomerId, Period HAVING COUNT(*) > 1) x;
2026-09-25 02:14:13        <- không tiến thêm sau khi deploy -> nguồn đã dừng

Bước 3 — quyết định nghiệp vụ, rồi dọn:

## Xử lý 142 hoá đơn trùng — quyết định ngày 2026-09-26

Đã trao đổi với trưởng phòng kế toán:

| Nhóm | Số lượng | Xử lý |
|---|---:|---|
| Trùng, chưa thanh toán | 118 | Đánh dấu Status = 'VoidedDuplicate' |
| Trùng, đã thanh toán | 24 | Tạo phiếu hoàn tiền, thông báo khách hàng |

Người phụ trách hoàn tiền: <tên>
Thông báo khách hàng: mẫu email đã duyệt, gửi ngày 2026-09-28
-- Nhóm 1: void bản trùng, GIỮ bản sớm nhất
WITH XepHang AS (
SELECT Id, ROW_NUMBER() OVER (
PARTITION BY CustomerId, Period ORDER BY CreatedUtc) AS Thu
FROM Invoices WHERE Status <> 'Paid'
)
UPDATE Invoices
SET Status = 'VoidedDuplicate',
UpdatedUtc = SYSUTCDATETIME(),
UpdatedBy = 'don-du-lieu-CRM-4821',
GhiChu = N'Hoá đơn trùng do sự cố xử lý message ngày 2026-09-01'
WHERE Id IN (SELECT Id FROM XepHang WHERE Thu > 1);
(118 rows affected)

UpdatedBy và GhiChu là chi tiết đáng làm: sáu tháng sau, khi có người hỏi "sao 118 hoá đơn này bị void?", câu trả lời nằm ngay trong dữ liệu.

Bước 4 — thêm ràng buộc:

CREATE UNIQUE INDEX UX_Invoices_Customer_Period
ON Invoices (CustomerId, Period)
WHERE Status <> 'VoidedDuplicate';
builder.Entity<Invoice>()
.HasIndex(i => new { i.CustomerId, i.Period })
.IsUnique()
.HasFilter("[Status] <> 'VoidedDuplicate'")
.HasDatabaseName("UX_Invoices_Customer_Period");

Index có lọc là chi tiết cần thiết: không có nó, các bản đã void vẫn chiếm chỗ và bạn không tạo lại hoá đơn đúng được.

dotnet test --filter "Consumer_xu_ly_message_hai_lan"
Passed:  TaoHoaDonConsumer

Thứ tự đúng của ba lớp phòng thủ:

LỚP 1 — Ràng buộc database        <- làm TRƯỚC, mạnh nhất
LỚP 2 — Khử trùng lặp trong code <- thông điệp lỗi đẹp, không phải bảo đảm
LỚP 3 — Idempotency key bên ngoài <- ngăn thiệt hại tài chính

Vì sao ràng buộc database làm trước:

Nó là lớp DUY NHẤT không đi vòng qua được.
Code có thể sai, có thể bị bỏ qua, có thể có đường ghi mới.
Ràng buộc thì không.

-> Thêm nó trước nghĩa là mọi lỗi sau đó đều bị chặn,
kể cả những lỗi bạn chưa biết là tồn tại.

Và lớp 3 quan trọng hơn vẻ ngoài:

await _paymentGateway.ChargeAsync(new ChargeRequest
{
Amount = invoice.Total,
CustomerId = invoice.CustomerId,
IdempotencyKey = $"invoice-{invoice.Id}", // cổng thanh toán TỰ chặn trùng
}, ct);
Ngay cả khi hoá đơn bị tạo trùng:
-> hai hoá đơn có hai Id khác nhau
-> nhưng nếu khoá là $"subscription-{subId}-{period}" thay vì invoice.Id,
cổng thanh toán chặn được lần thứ hai
-> TIỀN KHÔNG BỊ TRỪ HAI LẦN

Đây là lớp ngăn thiệt hại không hoàn tác được. Hai lớp trên ngăn dữ liệu sai; lớp này ngăn tiền đi sai — và tiền đã đi thì việc sửa đòi hỏi con người, giấy tờ, và thời gian.

Chọn khoá cho cổng thanh toán theo hành động nghiệp vụ, không theo id của bản ghi:

// Yếu — hai hoá đơn trùng có hai Id, hai khoá khác nhau
IdempotencyKey = $"invoice-{invoice.Id}"

// Mạnh — cùng một lần gia hạn thì cùng một khoá
IdempotencyKey = $"renew-{subscriptionId}-{period:yyyy-MM}"

Ba lớp cho ba loại lỗi:

LớpBắt đượcKhông bắt được
Ràng buộc databaseMọi đường ghi vào database của bạnLời gọi ra ngoài
Khử trùng lặp trong codeMessage trùng, cho thông điệp đẹpĐường ghi không qua code đó
Idempotency key bên ngoàiLời gọi trùng tới hệ thống khácDữ liệu trong database của bạn

Ba lớp không thừa nhau — mỗi lớp bắt một tập lỗi mà hai lớp kia không bắt được.

Và giám sát sau khi sửa:

public class KiemTraToanVenJob : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(6));
while (await timer.WaitForNextTickAsync(ct))
{
var trung = await _db.Database.SqlQuery<int>($@"
SELECT COUNT(*) FROM (
SELECT CustomerId, Period FROM Invoices
WHERE Status <> 'VoidedDuplicate'
GROUP BY CustomerId, Period HAVING COUNT(*) > 1) x")
.FirstAsync(ct);

if (trung > 0)
_logger.LogError("Có {SoNhom} nhóm hoá đơn trùng — ràng buộc có thể đã bị tắt",
trung);
}
}
}

Con số này phải luôn bằng 0. Nếu nó khác 0 sau khi đã có unique index, nghĩa là index bị xoá trong một migration, hoặc có một đường ghi dùng IGNORE_DUP_KEY — cả hai đều đáng biết ngay.


Bài 3 — Áp dụng mẫu test cho mọi consumer​

Áp dụng mẫu test ở mục 17.13.4 cho từng consumer trong dự án và ghi lại cái nào thất bại.

Tiêu chí hoàn thành: bạn có danh sách đầy đủ, và biến nó thành một kế hoạch có thứ tự ưu tiên.

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

Gợi ý. Danh sách 12 consumer thất bại là một vấn đề. Danh sách 12 consumer kèm mức thiệt hại và ước lượng là một kế hoạch.

Lời giải — chạy test cho mọi consumer:

public static IEnumerable<object[]> MoiConsumer()
{
var assembly = typeof(TaoHoaDonConsumer).Assembly;

return assembly.GetTypes()
.Where(t => !t.IsAbstract && t.GetInterfaces()
.Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IConsumer<>)))
.Select(t => new object[] { t });
}

[Theory]
[MemberData(nameof(MoiConsumer))]
public async Task Consumer_phai_idempotent(Type kieuConsumer)
{
var kieuEvent = kieuConsumer.GetInterfaces()
.First(i => i.GetGenericTypeDefinition() == typeof(IConsumer<>))
.GetGenericArguments()[0];

var suKien = TaoSuKienMau(kieuEvent);

await fixture.ResetAsync();
var truoc = await ChupTrangThaiDatabaseAsync();

await GoiConsumerAsync(kieuConsumer, suKien);
var sauLan1 = await ChupTrangThaiDatabaseAsync();

await GoiConsumerAsync(kieuConsumer, suKien);
var sauLan2 = await ChupTrangThaiDatabaseAsync();

sauLan2.Should().BeEquivalentTo(sauLan1,
"{0} không idempotent — lần xử lý thứ hai đã thay đổi trạng thái",
kieuConsumer.Name);
}

Phép so sánh "trạng thái sau lần 1 bằng trạng thái sau lần 2" tổng quát hơn việc đếm một bảng cụ thể — nó bắt được cả những thay đổi mà bạn không nghĩ tới.

Passed:  4
Failed: 8

Failed: GuiEmailConsumer, CapNhatThongKeConsumer, DongBoErpConsumer,
TinhHoaHongConsumer, TaoHoaDonConsumer, GuiSmsConsumer,
CapNhatTonKhoConsumer, GhiAuditConsumer

Biến danh sách thành kế hoạch:

## Consumer chưa idempotent — 2026-09-25

| Consumer | Thiệt hại khi trùng | Mức | Khoá nên dùng | Ước lượng | Ưu tiên |
|---|---|---|---|---:|:-:|
| TaoHoaDonConsumer | Hoá đơn thừa, tiền sai | **Nghiêm trọng** | `{CustomerId}:{Period}` | 3h | **1** |
| TinhHoaHongConsumer | Hoa hồng trả thừa | **Nghiêm trọng** | `{OrderId}:{NhanVienId}` | 3h | **2** |
| CapNhatTonKhoConsumer | Tồn kho âm, bán quá | **Cao** | `{OrderId}:{ProductId}` | 2h | **3** |
| DongBoErpConsumer | Bản ghi trùng ở ERP | **Cao** | `{LeadId}` | 4h | **4** |
| GuiEmailConsumer | Khách nhận email hai lần | Trung bình | `{LoaiEmail}:{LeadId}` | 1h | 5 |
| GuiSmsConsumer | Khách nhận SMS hai lần, tốn phí | Trung bình | `{LoaiSms}:{PhoneNumber}:{Ngay}` | 1h | 6 |
| CapNhatThongKeConsumer | Số liệu sai | Trung bình | Tính lại thay vì cộng dồn | 2h | 7 |
| GhiAuditConsumer | Log trùng | Thấp | `{EventId}` | 1h | 8 |

**Tổng: 17 giờ, khoảng 2,5 ngày công**

### Ghi chú
- Ưu tiên 1–2 làm trong sprint này, có thiệt hại tài chính
- CapNhatThongKeConsumer: thay vì thêm khoá, thiết kế lại để TÍNH LẠI
từ nguồn thay vì cộng dồn -> tự idempotent, không cần khoá
- Sau khi sửa hết: thêm lớp cơ sở ConsumerIdempotent<T> và kiến trúc test

Ba điều làm bảng này thành một kế hoạch chứ không phải một danh sách:

  1. Cột thiệt hại — cho biết thứ tự ưu tiên có cơ sở, thay vì làm theo thứ tự alphabet.
  2. Cột khoá nên dùng — quyết định khó nhất đã được đưa ra trước, nên việc thực hiện là cơ học.
  3. Cột ước lượng — biến "cần sửa" thành "2,5 ngày công", con số đưa được vào kế hoạch sprint.

Dòng ghi chú về CapNhatThongKeConsumer đáng chú ý:

// Trước — cộng dồn, cần khoá khử trùng lặp
thongKe.TongDoanhSo += e.GiaTri;

// Sau — tính lại, tự idempotent
var tong = await _db.Orders
.Where(o => o.ThangNam == thang && o.Status == OrderStatus.Completed)
.SumAsync(o => o.Total, ct);
thongKe.DatTongDoanhSo(tong);

Làm cho thao tác tự idempotent tốt hơn quản lý idempotency — nó loại bỏ cả một lớp vấn đề thay vì thêm một cơ chế phải bảo trì. Với mọi consumer, hãy hỏi câu này trước khi thêm khoá:

"Có cách nào viết lại để chạy hai lần cho cùng kết quả không?"

Cộng dồn -> tính lại từ nguồn
Thêm vào -> đặt trạng thái cuối
Gửi đi -> không tránh được, phải có khoá

Chặn hồi quy — đưa test vào CI ngay, với mẫu ratchet:

private static readonly HashSet<string> ChuaIdempotentDaBiet =
[
"GuiEmailConsumer",
"CapNhatThongKeConsumer",
"DongBoErpConsumer",
"TinhHoaHongConsumer",
"TaoHoaDonConsumer",
"GuiSmsConsumer",
"CapNhatTonKhoConsumer",
"GhiAuditConsumer",
];

[Theory]
[MemberData(nameof(MoiConsumer))]
public async Task Consumer_phai_idempotent(Type kieuConsumer)
{
if (ChuaIdempotentDaBiet.Contains(kieuConsumer.Name))
{
// Đã biết, đang trong kế hoạch sửa — không chặn CI
return;
}

// ... phần test ...
}

[Fact]
public void Danh_sach_consumer_chua_idempotent_chi_duoc_giam()
{
ChuaIdempotentDaBiet.Count.Should().BeLessThanOrEqualTo(8,
"danh sách này chỉ được rút ngắn; consumer MỚI phải idempotent ngay");
}

Hai lợi ích: consumer thứ chín không bao giờ được thêm vào, và tiến độ hiện rõ trong code — mỗi lần xoá một tên khỏi danh sách là một bước tiến ai cũng thấy.

Sau khi danh sách về rỗng, xoá cơ chế ratchet và bật test đầy đủ:

[Theory]
[MemberData(nameof(MoiConsumer))]
public async Task Consumer_phai_idempotent(Type kieuConsumer)
{
// Không còn ngoại lệ nào
}

Và đây là bài học rộng hơn của cả module: một sự cố như tính tiền hai lần không phải là một lỗi đơn lẻ cần sửa. Nó là triệu chứng của việc thiếu một cơ chế — và cách xử lý đúng gồm ba phần:

1. Sửa sự cố hiện tại        (dọn dữ liệu, hoàn tiền)
2. Sửa nguyên nhân gốc (khử trùng lặp cho consumer đó)
3. Chặn tái diễn (lớp cơ sở + test + kiến trúc test)

Phần lớn đội dừng ở bước 1 và 2 — và gặp lại cùng sự cố ở một consumer khác sau vài tháng. Bước 3 là bước duy nhất biến một sự cố thành một cải tiến vĩnh viễn.

Tự kiểm tra​

Frequently asked questions

Vì sao khởi động lại broker gây hoá đơn trùng?

Vì những message đã được xử lý nhưng chưa kịp gửi ACK sẽ được broker giao lại khi nó lên. Consumer không idempotent xử lý chúng lần thứ hai và tạo hoá đơn mới.

Vì sao phải hoàn tiền trước khi xoá hoá đơn trùng?

Vì hoá đơn là chứng từ đối soát với cổng thanh toán. Xoá trước thì mất dấu vết cần thiết để thực hiện hoàn tiền và để đối soát sau này.

Vì sao đánh dấu void thay vì xoá bản ghi trùng?

Để giữ bằng chứng phục vụ đối soát và kiểm toán. Xoá cứng là xoá luôn dấu vết về chính sự cố, khiến việc giải trình với khách hàng và kế toán trở nên khó khăn.

Trong bốn thay đổi, cái nào ngăn thiệt hại tài chính?

Idempotency key ở cổng thanh toán. Ngay cả khi hoá đơn bị tạo trùng, cổng nhận ra key đã dùng và trả về kết quả của lần đầu nên tiền không bị trừ hai lần.

Lớp phòng thủ nào chắc chắn nhất?

Unique constraint của database, vì nó do database đảm bảo chứ không phụ thuộc vào việc code nhớ kiểm tra, và nó đúng cả khi nhiều consumer chạy song song. Kiểm tra bằng câu if là lớp yếu nhất vì luôn có race condition.

Vì sao nên ghi SourceMessageId từ đầu?

Vì nó cho bằng chứng dứt khoát rằng cùng một message đã được xử lý hai lần, rút ngắn việc truy nguyên từ nhiều giờ xuống vài phút. Chi phí thêm một cột gần bằng không.

Kết luận​

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

  1. "Message chỉ giao một lần" là giả định sai — và nó có thể tốn tiền thật.
  2. Phòng thủ nhiều lớp. Trong ca này, chỉ cần một lớp tồn tại là thiệt hại đã nhỏ hơn nhiều.
  3. Mỗi consumer cần một test gửi cùng message hai lần. Đó là cách duy nhất biết chắc.

Tham khảo​

Điều hướng​