Là gì
Race condition là lỗi khi hai request đồng thời đọc cùng một trạng thái cũ rồi cùng hành động dựa trên nó. Mẫu kinh điển là kiểm rồi mới dùng (TOCTOU): cả hai request thấy "mã giảm giá còn hiệu lực", cả hai áp dụng nó, và mã dùng được hai lần. Không có payload nào — chỉ có thời điểm.
Vì sao bạn quan tâm
Đây là lỗi mà mọi phép kiểm bảo mật thông thường đều mù: không có ký tự đặc biệt để grep, không có chuỗi độc hại, không có gì cho WAF nhìn thấy. Request thứ hai giống hệt request thứ nhất — và trong log nó trông như một người dùng bấm nút hai lần.
Ba lý do nó phổ biến hơn người ta tưởng:
- Test đơn vị không bao giờ tìm ra nó. Test chạy tuần tự. Lỗi chỉ tồn tại khi hai luồng giao nhau đúng một cửa sổ vài milli-giây.
- Nó không xuất hiện trên máy dev. Một request một lần thì cửa sổ không mở. Nó xuất hiện ở production, nơi có nhiều instance và người dùng biết bấm nút nhanh.
- Nó nằm ở chỗ có tiền. Hoàn tiền, rút tiền, mã giảm giá, điểm thưởng, chuyển số dư, đặt vé cuối, giới hạn lượt dùng — mọi chỗ có một con số phải giảm đi.
Và khác biệt quan trọng nhất so với các topic khác: bản vá nằm ở DB, không ở code ứng dụng. Một câu if trong C# không thể là ranh giới nguyên tử cho hai process khác nhau. Nếu bản vá của bạn không có một trong bốn thứ — transaction có isolation đúng, SELECT … FOR UPDATE, ràng buộc UNIQUE, hay UPDATE … WHERE có điều kiện — thì nó chưa vá gì.
Cơ chế hoạt động
Cửa sổ nằm giữa đọc và ghi. Mọi thứ xảy ra trong khoảng đó là thứ request kia không thấy.
sequenceDiagram autonumber participant R1 as Request 1 participant DB participant R2 as Request 2 R1->>DB: SELECT uses_left FROM coupon WHERE code='X' DB-->>R1: 1 R2->>DB: SELECT uses_left FROM coupon WHERE code='X' Note over R1,R2: ⚠ CỬA SỔ — cả hai đều thấy 1,<br/>và không bên nào biết bên kia tồn tại. DB-->>R2: 1 R1->>DB: UPDATE coupon SET uses_left=0 R1->>DB: INSERT discount (order 1) R2->>DB: UPDATE coupon SET uses_left=0 R2->>DB: INSERT discount (order 2) Note over DB: uses_left = 0, nhưng mã đã dùng HAI lần.Điểm cốt lõi: uses_left cuối cùng vẫn bằng 0, nên dữ liệu trông đúng. Không có gì trong DB nói rằng đã có lỗi xảy ra — đó là lý do những lỗi này thường chỉ được phát hiện khi kế toán đối chiếu.
Bốn dạng, và chúng cần bốn bản vá khác nhau:
| Dạng | Ví dụ | Bản vá |
|---|---|---|
| Limit overrun | Dùng mã giảm giá 2 lần, rút quá số dư | UPDATE … WHERE uses_left > 0 nguyên tử |
| Ghi trùng | Hai tài khoản cùng email, hai đơn cùng mã | Ràng buộc UNIQUE |
| Multi-step bypass | Đổi giá giỏ hàng giữa lúc thanh toán | Một transaction bao cả luồng |
| Single-packet attack | 20 request cùng lúc trong một TCP packet | Không có bản vá riêng — nó chỉ làm cửa sổ dễ trúng hơn |
Hàng cuối đáng biết vì nó xoá bỏ lập luận "cửa sổ quá hẹp để khai thác": kỹ thuật single-packet attack (James Kettle, 2023) gửi 20–30 request trong một packet duy nhất qua HTTP/2, nên jitter mạng bị loại bỏ hoàn toàn và các request tới server trong cùng một micro-giây. Cửa sổ vài trăm micro-giây giờ là cửa sổ trúng được, và trúng được từ ngoài Internet.
Mô tả sơ đồ: Sơ đồ tuần tự hai request cùng thao tác trên một mã giảm giá. Request 1 đọc uses_left và nhận 1. Request 2 cũng đọc và cũng nhận 1 — đây là cửa sổ, cả hai thấy cùng trạng thái cũ và không bên nào biết bên kia tồn tại. Sau đó cả hai cùng UPDATE uses_left về 0 và cùng INSERT một bản ghi giảm giá cho đơn của mình. Kết quả là uses_left bằng 0 nên dữ liệu trông đúng, nhưng mã đã được dùng hai lần.
Ví dụ cụ thể
Endpoint áp mã giảm giá dùng một lần. Request giống hệt nhau, gửi cùng lúc.
# 20 request song song, cùng một mã. Với single-packet attack chúng tới# server trong cùng một micro-giây; ở đây curl là đủ để thấy lỗi.for i in $(seq 1 20); do curl -s -X POST https://shop.example.com/api/orders/$i/coupon \ -H "Authorization: Bearer $TOKEN" \ -d '{"code":"SAVE50"}' &done; wait// 7 trong 20 request trả 200:{"applied":true,"discount":"50%","orderId":3}{"applied":true,"discount":"50%","orderId":7}{"applied":true,"discount":"50%","orderId":11}…-- Và trong DB, dữ liệu trông hoàn toàn bình thường:SELECT code, uses_left FROM coupon WHERE code = 'SAVE50';-- SAVE50 | 0 ← đúng như mong đợi SELECT count(*) FROM order_discount WHERE coupon_code = 'SAVE50';-- 7 ← đây mới là con số nói ra vấn đềKhông một request nào trong 20 request đó có gì bất thường. Chúng chỉ đến cùng lúc.
[HttpPost("/api/orders/{orderId:long}/coupon")]public async Task<IActionResult> ApplyCoupon(long orderId, [FromBody] CouponRequest req, CancellationToken ct){ // ❌ ĐỌC. Từ đây tới lúc GHI ở dòng 20 là cửa sổ, và mọi request khác tới trong // khoảng đó cũng đọc được đúng giá trị này. var coupon = await _db.Coupons .FirstOrDefaultAsync(c => c.Code == req.Code, ct); if (coupon is null) return NotFound(); // ❌ KIỂM. Đúng về logic, và vô dụng về đồng thời: hai mươi request cùng lúc // đều thấy UsesLeft = 1 và đều đi qua dòng này. if (coupon.UsesLeft <= 0) return BadRequest(new { error = "Coupon exhausted" }); coupon.UsesLeft--; _db.OrderDiscounts.Add(new OrderDiscount(orderId, req.Code, coupon.Percent)); // ❌ GHI, ở một round-trip KHÁC. Transaction của SaveChanges không cứu được: // mặc định là Read Committed, và Read Committed cho phép đúng cửa sổ này. await _db.SaveChangesAsync(ct); return Ok(new { applied = true, discount = $"{coupon.Percent}%" });}@app.post("/api/accounts/<int:account_id>/withdraw")def withdraw(account_id): amount = Decimal(request.json["amount"]) with db.session.begin(): # ❌ ĐỌC. Mặc định của Postgres là Read Committed, và Read Committed cho # phép một transaction khác đọc cùng giá trị này. acct = db.session.get(Account, account_id) # ❌ KIỂM. Hai request đồng thời đều thấy balance = 100 và đều đi qua. if acct.balance < amount: return {"error": "insufficient funds"}, 400 # ❌ GHI. Gán từ một giá trị đã ĐỌC trước đó: nó ghi đè kết quả của # transaction kia thay vì trừ tiếp lên nó. acct.balance = acct.balance - amount db.session.add(Ledger(account_id=account_id, amount=-amount)) return {"balance": str(acct.balance)}Chuyện đã xảy ra
The DAO, tháng 6/2016 — ~3,6 triệu ETH (~50 triệu USD lúc đó). Hàm splitDAO gửi tiền đi trước khi cập nhật số dư, nên một hợp đồng gọi lại (reentrancy) rút được nhiều lần từ cùng một số dư. Đây là race condition ở dạng thuần khiết nhất: thứ tự "hành động rồi mới ghi" thay vì "ghi rồi mới hành động". Hậu quả lớn tới mức Ethereum phải hard-fork, tách ra thành ETH và ETC.
Starbucks gift card, 2015 (Egor Homakov). Chuyển số dư giữa hai thẻ đồng thời tạo ra tiền từ không khí — cùng mẫu limit overrun ở khối 3. Đáng đọc vì nó cho thấy lỗi này không cần kỹ thuật gì: chỉ cần hai tab trình duyệt và một chút thời điểm.
Kỹ thuật single-packet attack (James Kettle, PortSwigger, 2023) không phải một sự cố, nhưng nó thay đổi mức rủi ro của toàn bộ họ lỗi này: nó biến những cửa sổ trước đây được coi là "quá hẹp để khai thác trên Internet" thành khai thác được ổn định.
Cách phòng chống
Một câu lệnh nguyên tử — kiểm và ghi trong cùng một `UPDATE`
bắt buộcĐây là bản vá đúng cho dạng limit overrun, và nó là bản vá tốt nhất vì nó xoá bỏ cửa sổ thay vì bảo vệ nó.
-- Điều kiện nằm TRONG câu UPDATE. DB bảo đảm không có hai transaction nào-- cùng thấy uses_left > 0 rồi cùng ghi: câu thứ hai thấy 0 và khớp 0 hàng.UPDATE coupon SET uses_left = uses_left - 1 WHERE code = @code AND uses_left > 0RETURNING uses_left;Rồi kiểm số hàng bị ảnh hưởng: 0 hàng nghĩa là mã đã hết, và đó là câu trả lời cho request. Bước kiểm này là phần hay bị bỏ — một ExecuteUpdate mà không ai đọc giá trị trả về là một bản vá không hoạt động.
Trong EF Core 8: ExecuteUpdateAsync sinh ra đúng một câu UPDATE … WHERE và trả về số hàng. Không dùng context.Coupons.First() rồi sửa property rồi SaveChanges() — đó chính là kiểm-rồi-dùng qua hai round-trip.
/// <summary>/// Hai bảo đảm, không phải một, vì chúng bảo vệ hai thứ khác nhau:////// • UPDATE … WHERE uses_left > 0 → bộ đếm không xuống dưới 0 (limit overrun)/// • UNIQUE (coupon_code, order_id) → không có hai dòng giảm giá cho cùng cặp////// Chỉ có cái đầu thì hai request cho CÙNG một đơn vẫn tạo hai dòng discount./// Chỉ có cái sau thì hai request cho HAI đơn khác nhau vẫn vượt max_uses./// </summary>public sealed class CouponService{ private readonly SecLabDbContext _db; public CouponService(SecLabDbContext db) => _db = db; public async Task<CouponResult> ApplyAsync(long orderId, string code, CancellationToken ct) { // Transaction bao cả hai bước: nếu INSERT vi phạm UNIQUE thì việc trừ bộ đếm // cũng phải mất theo, nếu không mã bị "tiêu" mà không ai được giảm giá. await using var tx = await _db.Database.BeginTransactionAsync(ct); // MỘT câu lệnh: điều kiện nằm TRONG UPDATE. Không có khoảng nào giữa kiểm và // ghi để một request khác chen vào — DB bảo đảm điều đó, không phải code này. // // ExecuteUpdateAsync sinh đúng một câu UPDATE … WHERE và KHÔNG nạp entity nào, // nên không có bản sao nào trong bộ nhớ để ai đó sửa rồi SaveChanges. var claimed = await _db.Coupons .Where(c => c.Code == code && c.UsesLeft > 0) .ExecuteUpdateAsync(s => s.SetProperty(c => c.UsesLeft, c => c.UsesLeft - 1), ct); // ĐỌC SỐ HÀNG. Đây là dòng hay bị bỏ, và bỏ nó thì cả bản vá trên vô nghĩa: // câu UPDATE đúng, rồi ta bỏ đi câu trả lời của nó và vẫn trả 200. if (claimed == 0) { await tx.RollbackAsync(ct); return CouponResult.Exhausted; } try { _db.OrderDiscounts.Add(new OrderDiscount(orderId, code)); await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); return CouponResult.Applied; } catch (DbUpdateException e) when (IsUniqueViolation(e)) { // Ràng buộc DB là nguồn sự thật, và cách nó nói "trùng" là ném lỗi. Đây là // chỗ duy nhất trong codebase mà bắt exception làm luồng điều khiển là ĐÚNG: // không có cách nào hỏi trước mà không quay lại kiểm-rồi-dùng. await tx.RollbackAsync(ct); return CouponResult.AlreadyApplied; } } /// <summary>23505 = unique_violation. Mã của chuẩn SQL, không phải của Npgsql.</summary> private static bool IsUniqueViolation(DbUpdateException e) => e.InnerException is PostgresException { SqlState: "23505" };} // ── Migration: ràng buộc là một nửa của bản vá, và nó sống ở đây ─────────────// Nửa này nằm trong migration chứ không trong code, nên nó có thể bị một migration// khác xoá đi mà không ai thấy — đó là lý do khối 7 có một phép kiểm riêng cho nó.migrationBuilder.Sql(""" ALTER TABLE core.order_discount ADD CONSTRAINT ux_order_discount_order_coupon UNIQUE (order_id, coupon_code); ALTER TABLE core.coupon ADD CONSTRAINT ck_coupon_uses_left_nonneg CHECK (uses_left >= 0); """);from decimal import Decimal from sqlalchemy import text class InsufficientFunds(Exception): pass def withdraw(account_id: int, amount: Decimal) -> Decimal: """Một câu lệnh nguyên tử. Điều kiện nằm TRONG UPDATE, không trong một if. `balance = balance - :amt` trừ trên giá trị HIỆN TẠI trong DB, không trên giá trị ta đã đọc trước đó — nên hai transaction đồng thời cộng dồn đúng thay vì ghi đè kết quả của nhau. Đây là khác biệt giữa `SET x = x - 1` và `SET x = <đã đọc> - 1`, và nó là toàn bộ bản vá. """ with db.session.begin(): row = db.session.execute( text(""" UPDATE account SET balance = balance - :amt WHERE id = :id AND balance >= :amt RETURNING balance """), {"id": account_id, "amt": amount}, ).first() # ĐỌC KẾT QUẢ. None nghĩa là điều kiện không khớp — số dư không đủ. Bỏ dòng # này đi thì câu UPDATE vẫn đúng và endpoint vẫn trả 200 cho mọi request. if row is None: raise InsufficientFunds() db.session.add(Ledger(account_id=account_id, amount=-amount)) return row.balance # Và lưới cuối ở tầng DB. Nó không thay bản vá trên — nó bắt câu truy vấn TIẾP THEO,# do người khác viết, ở một endpoint mà trang này chưa tồn tại lúc họ viết nó.MIGRATION = """ ALTER TABLE account ADD CONSTRAINT ck_account_balance_nonneg CHECK (balance >= 0); -- Idempotency: một Idempotency-Key chỉ tạo được một lệnh, nên retry của client -- không thành một lần rút thứ hai. CREATE UNIQUE INDEX ux_ledger_idempotency ON ledger (idempotency_key) WHERE idempotency_key IS NOT NULL;"""Ràng buộc `UNIQUE` — cho dạng ghi trùng, và DB là nơi duy nhất bảo đảm được
bắt buộcVới dạng ghi trùng (hai tài khoản cùng email, một mã dùng hai lần cho hai đơn), không có if nào ở tầng ứng dụng đủ: if (!await _users.AnyAsync(...)) rồi Add là kiểm-rồi-dùng.
-- Chỉ số duy nhất một phần: mỗi mã chỉ được dùng một lần trên toàn hệ thống.CREATE UNIQUE INDEX ux_order_discount_coupon ON core.order_discount (coupon_code); -- Hoặc theo người dùng: mỗi người một lần.CREATE UNIQUE INDEX ux_order_discount_coupon_user ON core.order_discount (coupon_code, user_id);Rồi bắt lỗi vi phạm ràng buộc và biến nó thành một câu trả lời nghiệp vụ, không để nó thành 500. Trong Npgsql: PostgresException với SqlState == "23505". Đây là chỗ duy nhất mà bắt exception làm luồng điều khiển là đúng: ràng buộc DB là nguồn sự thật, và cách nó nói "trùng" là ném lỗi.
Idempotency key là cùng một kỹ thuật cho một mục đích khác: client gửi Idempotency-Key, server có UNIQUE trên cột đó, nên retry của cùng một lệnh không tạo ra tác dụng thứ hai.
Khoá hàng khi luồng buộc phải nhiều bước
Khi logic không nhét được vào một câu lệnh (tính giá theo nhiều bảng rồi mới trừ số dư), khoá hàng trong một transaction:
BEGIN;-- FOR UPDATE khoá hàng tới hết transaction. Transaction thứ hai ĐỢI ở đây,-- và khi nó đi tiếp thì nó thấy trạng thái ĐÃ cập nhật, không thấy trạng thái cũ.SELECT balance FROM account WHERE id = @id FOR UPDATE;-- … tính toán nhiều bước …UPDATE account SET balance = balance - @amount WHERE id = @id;COMMIT;Hai điều bắt buộc, và cả hai đều hay bị bỏ:
- Khoá theo thứ tự nhất quán khi khoá nhiều hàng (luôn theo id tăng dần). Không có nó, hai transaction chuyển tiền ngược chiều nhau tạo ra deadlock.
- Timeout cho khoá (
SET LOCAL lock_timeout = '3s'). Một khoá không timeout biến một hàng nóng thành điểm nghẽn của cả hệ thống, và đó là DoS không cần lỗ hổng nào.
Serializable isolation là lựa chọn thay thế: DB tự phát hiện xung đột và abort một bên. Nó đúng và đơn giản hơn, nhưng caller phải có vòng retry — nếu không, tải cao biến thành lỗi 500.
Rate limit theo tài nguyên, không chỉ theo IP
Lớp này không vá lỗi — nó thu hẹp cửa sổ và giới hạn số lần thử. Nhưng nó phải đúng chiều: rate limit theo IP không giúp gì với 20 request đến từ một IP trong một packet.
Cái cần là khoá theo tài nguyên đang tranh chấp: "mã SAVE50 chỉ được xử lý một luồng tại một thời điểm". Một khoá phân tán trên Redis (SET key NX PX 5000) theo coupon:{code} làm được điều đó.
Quan trọng: khoá Redis không thay được ràng buộc DB. Nó là tối ưu (giảm số transaction bị abort), không phải bảo đảm — Redis có thể mất khoá khi failover, và lúc đó chỉ còn lớp 1 đứng lại.
Phát hiện: đối soát định kỳ, và alert theo tốc độ
Khối 3 nói rằng sau khi lỗi xảy ra, dữ liệu trông đúng. Nên phát hiện phải là một phép đối soát so hai con số đáng lẽ phải khớp:
-- Chạy hằng đêm. Mọi dòng trả về là một lần race condition đã xảy ra.SELECT c.code, c.max_uses, count(d.*) AS actual FROM core.coupon c JOIN core.order_discount d ON d.coupon_code = c.code GROUP BY c.code, c.max_usesHAVING count(d.*) > c.max_uses;Và alert theo tốc độ, không theo tổng: N request cùng một coupon_code trong một giây từ cùng một tài khoản là chữ ký của một lần thử, và nó là tín hiệu độ nhiễu thấp. Đây là lớp 3 vì nó không chặn gì — nhưng với họ lỗi này nó là thứ duy nhất cho bạn biết lỗi đã xảy ra, vì DB không nói.
Kiểm chứng đã vá
Đây là topic mà test tuần tự vô dụng, và đó là điều quan trọng nhất cần nhớ về việc kiểm chứng nó. Một test gọi endpoint hai lần liên tiếp sẽ pass trên cả code lỗi.
1. Test song song thật — N luồng, một Barrier. Barrier là chi tiết quyết định: nó giữ mọi luồng lại cho tới khi tất cả đã sẵn sàng, rồi thả cùng lúc. Không có nó, luồng đầu đã xong trước khi luồng cuối bắt đầu và cửa sổ không bao giờ mở. Xem tab csharp / test.
2. Khẳng định đúng con số. Test phải kiểm số tác dụng đã xảy ra (count(*) trong bảng hệ quả), không kiểm trạng thái cuối của bộ đếm — vì uses_left = 0 là đúng trong cả hai trường hợp. Đây là sai lầm làm phần lớn test race condition trở nên vô nghĩa.
3. Chạy test đó nhiều lần trong CI. Một lần pass không chứng minh gì: race condition là xác suất. dotnet test --filter Race -- xunit.execution.Repeat=50, hoặc một vòng lặp trong chính test. Và chạy trên nhiều core:
for i in $(seq 1 30); do dotnet test --filter Concurrency --nologo -v q || { echo "đỏ ở lần $i"; exit 1; }done4. Kiểm ràng buộc DB thật sự tồn tại — bản vá lớp 1b sống trong migration, không trong code, nên nó bị xoá bởi một migration khác mà không ai thấy:
-- Phải trả về đúng một hàng:SELECT indexname FROM pg_indexes WHERE schemaname = 'core' AND tablename = 'order_discount' AND indexdef ILIKE '%unique%coupon_code%';5. Thử bằng single-packet attack trên staging. Burp Repeater (chế độ "send group in parallel") hoặc turbo-intruder. Đây là phép kiểm duy nhất mô phỏng đúng điều kiện thật, và nó là thứ phân biệt "cửa sổ hẹp" với "cửa sổ đã đóng".
6. Query đối soát ở lớp 3 phải chạy trong CI trên dữ liệu test, không chỉ trong một runbook. Nó là bất biến nghiệp vụ, và bất biến thì nên có test.
public class CouponConcurrencyTests : IClassFixture<PostgresFixture>{ private readonly PostgresFixture _fx; public CouponConcurrencyTests(PostgresFixture fx) => _fx = fx; /// <summary> /// Test PHẢI song song thật. Một test gọi endpoint hai lần liên tiếp pass trên /// code lỗi — cửa sổ không bao giờ mở, vì lần gọi đầu đã ghi xong trước khi lần /// thứ hai bắt đầu đọc. /// /// Barrier là thứ làm test này có nghĩa: nó giữ cả 20 task ở vạch xuất phát cho /// tới khi task cuối cùng đã sẵn sàng, rồi thả tất cả trong cùng một nhịp. Đây là /// bản mô phỏng của single-packet attack ở tầng test. /// </summary> [Theory] [InlineData(20)] [InlineData(50)] public async Task Single_use_coupon_applies_exactly_once(int concurrency) { await _fx.SeedCouponAsync("SAVE50", maxUses: 1); var orders = await _fx.SeedOrdersAsync(count: concurrency); using var barrier = new Barrier(concurrency); var results = new CouponResult[concurrency]; await Task.WhenAll(Enumerable.Range(0, concurrency).Select(i => Task.Run(async () => { // Mỗi task có scope DI riêng → connection riêng → transaction riêng. // Dùng chung một DbContext thì test đo sai thứ: nó đo hành vi của // change tracker, không đo hành vi đồng thời của DB. await using var scope = _fx.Services.CreateAsyncScope(); var svc = scope.ServiceProvider.GetRequiredService<CouponService>(); barrier.SignalAndWait(); // ← tất cả xuất phát cùng lúc results[i] = await svc.ApplyAsync(orders[i].Id, "SAVE50", default); }))); // Khẳng định 1: đúng MỘT request thành công. Assert.Equal(1, results.Count(r => r == CouponResult.Applied)); // Khẳng định 2 — QUAN TRỌNG NHẤT: đếm TÁC DỤNG, không đếm bộ đếm. // // uses_left = 0 là ĐÚNG trong cả hai trường hợp (đã vá và chưa vá), nên một // test chỉ kiểm uses_left sẽ xanh trên code lỗi. Con số nói ra sự thật là số // dòng discount thật sự đã được ghi. Assert.Equal(1, await _fx.CountDiscountsAsync("SAVE50")); } /// <summary> /// Mã cho phép 5 lượt: 50 request đồng thời phải cho ra ĐÚNG 5. Test này bắt được /// một lỗi mà test max_uses=1 ở trên bỏ qua — bản vá đúng cho 1 nhưng sai dấu /// so sánh (>= thay vì >) vẫn pass ở trên và đỏ ở đây. /// </summary> [Fact] public async Task Multi_use_coupon_never_exceeds_its_limit() { await _fx.SeedCouponAsync("SAVE10", maxUses: 5); var orders = await _fx.SeedOrdersAsync(count: 50); using var barrier = new Barrier(50); await Task.WhenAll(orders.Select((o, i) => Task.Run(async () => { await using var scope = _fx.Services.CreateAsyncScope(); var svc = scope.ServiceProvider.GetRequiredService<CouponService>(); barrier.SignalAndWait(); await svc.ApplyAsync(o.Id, "SAVE10", default); }))); Assert.Equal(5, await _fx.CountDiscountsAsync("SAVE10")); Assert.Equal(0, await _fx.GetUsesLeftAsync("SAVE10")); } /// <summary> /// Ràng buộc UNIQUE sống trong migration, không trong code — nên một migration /// khác xoá nó đi mà không test nào đỏ. Test này là thứ làm nó đỏ. /// </summary> [Fact] public async Task Unique_constraint_exists() { var found = await _fx.QueryScalarAsync<long>(""" SELECT count(*) FROM pg_indexes WHERE schemaname = 'core' AND tablename = 'order_discount' AND indexdef ILIKE '%UNIQUE%coupon_code%' """); Assert.True(found > 0, "ràng buộc UNIQUE trên order_discount đã biến mất"); }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
if (coupon.UsesLeft > 0) { coupon.UsesLeft--; SaveChanges(); } | Kiểm-rồi-dùng qua hai round-trip. Đây chính là code lỗi, viết bằng ORM |
lock / Monitor / SemaphoreSlim trong C# | Chỉ khoá trong MỘT process. Hai pod thì có hai khoá độc lập, và bản vá biến mất lúc scale ra |
Transaction nhưng vẫn Read Committed | Read Committed cho phép đúng cửa sổ ở khối 3. Transaction một mình không phải bản vá — cần FOR UPDATE hoặc điều kiện trong UPDATE |
ExecuteUpdateAsync mà không đọc số hàng | Câu lệnh nguyên tử đúng, rồi bỏ đi câu trả lời của nó. Request thứ hai vẫn nhận 200 |
| Rate limit theo IP | 20 request trong một packet đến từ một IP. Nó là hành vi hợp lệ theo mọi rate limit hợp lý |
| Debounce ở client | Kẻ tấn công không dùng UI của bạn |
| Khoá Redis một mình | Redis mất khoá khi failover. Nó là tối ưu, không phải bảo đảm — ràng buộc DB vẫn phải có |
Retry khi Serializable abort — nhưng không có retry | Chuyển một lỗi nghiệp vụ thành 500 dưới tải cao |
| Test gọi endpoint hai lần liên tiếp | Pass trên code lỗi. Cửa sổ không bao giờ mở |
Sai lầm về mức độ, phổ biến nhất: "cửa sổ chỉ vài milli-giây, không khai thác được từ Internet". Single-packet attack đã trả lời câu đó dứt điểm năm 2023.
Sai lầm về phạm vi: vá đúng endpoint được báo cáo. Cùng mẫu kiểm-rồi-dùng gần như luôn xuất hiện ở mọi chỗ có một con số phải giảm đi — hoàn tiền, điểm thưởng, giới hạn lượt dùng, số suất còn lại. Cách tìm là grep các bảng có cột dạng *_left, *_remaining, balance, quota.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…