Voucher, giới hạn lượt dùng, chống dùng trùngVouchers, usage limits, preventing reuse
Checkout gọi Coupon Service để kiểm tra điều kiện, tính mức giảm và giữ lượt trong cùng một transaction trên PostgreSQL. Khi đơn bị huỷ, sự kiện order.cancelled đi qua hàng đợi để worker trả lượt. Chọn “Hai người tranh lượt cuối” để xem câu UPDATE có điều kiện chặn người thứ hai.Checkout calls the Coupon Service to check eligibility, compute the discount and reserve a use in one PostgreSQL transaction. When an order is cancelled, an order.cancelled event goes through the queue and a worker releases the use. Choose “Two buyers race for the last use” to see the conditional UPDATE reject the second one.
1User 42 nhập mã SALE20 cho đơn 1.250.000 ₫ gồm hàng thời trang.User 42 enters SALE20 for a 1,250,000 ₫ fashion order.
SELECT used rồi so sánh trong code tạo khoảng hở giữa hai bước. Gộp kiểm tra và tăng vào một câu: UPDATE coupon SET used = used + 1 WHERE id = ? AND used < max_uses, xem số dòng bị ảnh hưởng. Với lưu lượng rất cao, dùng Redis DECR trên bộ đếm còn lại, kết quả âm thì INCR trả lại và từ chối.SELECT used and comparing in code leaves a gap between the two steps. Fold the check and the increment into one statement: UPDATE coupon SET used = used + 1 WHERE id = ? AND used < max_uses, then read the affected row count. At very high volume use Redis DECR on a remaining counter; a negative result is given back with INCR and rejected.
Mỗi lần dùng là một dòng coupon_redemption có unique(coupon_id, user_id, order_id), nên bấm hai lần hoặc retry cùng một đơn không tạo lượt thứ hai. Giới hạn mỗi khách N lượt: đếm các dòng RESERVED/USED trong cùng transaction, hoặc với N = 1 dùng partial unique index (coupon_id, user_id) WHERE status <> 'RELEASED'.Each use is a coupon_redemption row with unique(coupon_id, user_id, order_id), so a double click or a retry of the same order never creates a second use. For a limit of N per customer, count RESERVED/USED rows in the same transaction, or for N = 1 use a partial unique index on (coupon_id, user_id) WHERE status <> 'RELEASED'.
Mỗi điều kiện là một Specification nhỏ: MinOrderValue, InCategory, FirstOrder, DateWindow, InSegment. Mã khuyến mãi lưu cấu hình, service ghép chúng bằng and/or/not. Thêm điều kiện mới là thêm một class, không sửa if-else trong checkout.Each condition is a small Specification: MinOrderValue, InCategory, FirstOrder, DateWindow, InSegment. The coupon stores its configuration and the service composes them with and/or/not. A new condition is a new class, not another if-else in checkout.
Strategy cho từng kiểu: PercentWithCap, FixedAmount, FreeShipping, BuyXGetY. Tính trên số nguyên đơn vị nhỏ nhất của tiền tệ, không dùng double, và không cho tổng giảm vượt giá trị đơn.One Strategy per kind: PercentWithCap, FixedAmount, FreeShipping, BuyXGetY. Compute in integer minor units, never double, and never let the total discount exceed the order value.
Chain of Responsibility theo priority: mã sản phẩm trước, mã đơn hàng sau, miễn phí vận chuyển cuối cùng. Mỗi mắt xích quyết định áp hay bỏ qua dựa trên cờ exclusive và nhóm cộng dồn, rồi chuyển phần đơn còn lại cho mắt xích kế tiếp.A Chain of Responsibility ordered by priority: item coupons first, order coupons next, free shipping last. Each link decides to apply or skip based on its exclusive flag and stacking group, then hands the remaining amount to the next link.
Rate limit số lần thử mã theo user và IP bằng Redis, khoá tạm sau nhiều lần sai. Với mã giá trị cao, sinh mã dùng một lần, ngẫu nhiên đủ dài (10–12 ký tự), gắn với khách cụ thể thay vì một mã chung.Rate-limit coupon attempts per user and IP in Redis and lock out briefly after repeated failures. For high-value campaigns, generate long random single-use codes (10–12 characters) bound to a customer instead of one shared code.
Lượt được giữ ở trạng thái RESERVED khi checkout, chuyển USED khi thanh toán xong. Đơn huỷ hoặc hết hạn thanh toán thì worker chuyển RELEASED và giảm used; điều kiện WHERE status = 'RESERVED' giữ cho việc trả lượt idempotent.A use is RESERVED at checkout and becomes USED once payment succeeds. A cancelled or unpaid order is moved to RELEASED by a worker, which also decrements used; the WHERE status = 'RESERVED' guard keeps the release idempotent.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
coupon | id, code, discount_type, discount_value, max_discount, max_uses, used, per_user_limit, starts_at, ends_at, stack_group, priority, exclusive | unique(code); CHECK (used <= max_uses); used chỉ đổi qua UPDATE có điều kiệnunique(code); CHECK (used <= max_uses); used changes only through the conditional UPDATE |
coupon_rule | coupon_id, rule_type, params_json | Cấu hình specification: MIN_ORDER, CATEGORY, FIRST_ORDER, SEGMENT…Specification config: MIN_ORDER, CATEGORY, FIRST_ORDER, SEGMENT… |
coupon_redemption | id, coupon_id, user_id, order_id, status, discount_amount, reserved_at, released_at | unique(coupon_id, user_id, order_id); RESERVED → USED · RELEASEDunique(coupon_id, user_id, order_id); RESERVED → USED · RELEASED |
coupon_code | code, campaign_id, assigned_user_id, redeemed_at | Mã sinh hàng loạt, dùng một lầnGenerated single-use codes |
Giữ một lượt dùng: kiểm tra điều kiện, tăng used bằng UPDATE có điều kiện, ghi dòng redemption unique, tất cả trong một transaction.Reserving a use: check eligibility, increment used with a conditional UPDATE and insert a unique redemption row, all in one transaction.
@Service
@RequiredArgsConstructor
public class CouponRedemptionService {
private final CouponRepository coupons;
private final RedemptionRepository redemptions;
private final EligibilityRules rules;
@Transactional
public Discount reserve(String code, Order order) {
Coupon c = coupons.findByCode(code)
.orElseThrow(() -> new CouponException("NOT_FOUND"));
Specification<CouponContext> spec = rules.of(c); // a.and(b).and(c)
if (!spec.isSatisfiedBy(new CouponContext(c, order))) {
throw new CouponException("NOT_ELIGIBLE");
}
if (coupons.incrementIfAvailable(c.getId()) == 0) {
throw new CouponException("EXHAUSTED");
}
try {
redemptions.saveAndFlush(Redemption.reserved(c, order));
} catch (DataIntegrityViolationException e) {
throw new CouponException("ALREADY_USED"); // rolls back the increment
}
return c.strategy().apply(order);
}
}
interface CouponRepository extends JpaRepository<Coupon, Long> {
Optional<Coupon> findByCode(String code);
@Modifying
@Query("UPDATE Coupon c SET c.used = c.used + 1 "
+ "WHERE c.id = :id AND c.used < c.maxUses")
int incrementIfAvailable(@Param("id") long id);
}type CouponService struct {
db *pgxpool.Pool
rules RuleBuilder // builds a Spec from coupon_rule rows
}
func (s *CouponService) Reserve(ctx context.Context, code string, o Order) (Discount, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return Discount{}, err
}
defer tx.Rollback(ctx) // no-op after Commit
c, err := loadCoupon(ctx, tx, code)
if err != nil {
return Discount{}, err
}
if !s.rules.For(c).IsSatisfiedBy(CouponContext{Coupon: c, Order: o}) {
return Discount{}, ErrNotEligible
}
tag, err := tx.Exec(ctx, `UPDATE coupon SET used = used + 1
WHERE id = $1 AND used < max_uses`, c.ID)
if err != nil {
return Discount{}, err
}
if tag.RowsAffected() == 0 {
return Discount{}, ErrExhausted
}
_, err = tx.Exec(ctx, `INSERT INTO coupon_redemption
(coupon_id, user_id, order_id, status) VALUES ($1, $2, $3, 'RESERVED')`,
c.ID, o.UserID, o.ID)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return Discount{}, ErrAlreadyUsed
} else if err != nil {
return Discount{}, err
}
if err := tx.Commit(ctx); err != nil {
return Discount{}, err
}
return c.Strategy().Apply(o), nil
}| UPDATE có điều kiện trên PostgreSQLConditional UPDATE in PostgreSQL | Redis DECR trên bộ đếm còn lạiRedis DECR on a remaining counter | |
|---|---|---|
| Tính nhất quánConsistency | Cùng transaction với dòng redemption, rollback là trả lượtSame transaction as the redemption row; a rollback gives the use back | Tách khỏi DB, phải tự bù (INCR) khi bước sau lỗiSeparate from the DB; you must compensate with INCR when a later step fails |
| Thông lượngThroughput | Một dòng nóng bị khoá tuần tự, vài nghìn lượt/giâyOne hot row locked in sequence, a few thousand per second | Hàng chục nghìn lượt/giây trên một keyTens of thousands per second on one key |
| Vận hànhOperations | Không thêm hạ tầng, dễ đối soátNo extra infrastructure, easy to reconcile | Cần đối soát định kỳ với bảng redemptionNeeds periodic reconciliation against the redemption table |
| Chọn khiPick it when | Phần lớn chương trình khuyến mãi thông thườngMost everyday promotions | Flash sale lượng lớn trên vài mãLarge flash sales on a handful of codes |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Không đọc rồi so sánh trong code. Dùng một câu UPDATE … SET used = used + 1 WHERE used < max_uses và xem số dòng bị ảnh hưởng; database khoá dòng nên chỉ một câu thành công. Thêm CHECK (used <= max_uses) làm lớp bảo vệ cuối.Never read and compare in code. Use one UPDATE … SET used = used + 1 WHERE used < max_uses and check the affected row count; the database locks the row, so only one statement succeeds. A CHECK (used <= max_uses) is the last line of defence.
Khi lượt khan hiếm: khách thấy giá giảm ở checkout thì lượt đó phải chắc chắn còn. Đổi lại phải có cơ chế trả lượt khi huỷ đơn hoặc hết hạn thanh toán, nếu không mã sẽ cạn vì những đơn bỏ dở.When uses are scarce: if the customer sees the discount at checkout, the use must really be there. The cost is a release path for cancelled or unpaid orders; without it, abandoned orders drain the code.
Không, nếu việc trả lượt dựa trên chuyển trạng thái: UPDATE redemption SET status = 'RELEASED' WHERE id = ? AND status = 'RESERVED'. Chỉ khi câu này đổi 1 dòng mới giảm coupon.used, cả hai trong cùng transaction.No, if the release is driven by a state change: UPDATE redemption SET status = 'RELEASED' WHERE id = ? AND status = 'RESERVED'. coupon.used is decremented only when that changes 1 row, both in the same transaction.
Mã giá trị cao nên là mã dùng một lần, sinh ngẫu nhiên và gắn với khách. Mã chung thì có max_uses, per_user_limit, nhóm khách hợp lệ và rate limit việc thử mã.High-value codes should be single-use, randomly generated and bound to a customer. Shared codes need max_uses, per_user_limit, an eligible segment and a rate limit on attempts.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.