Thiết kế hệ thống tính phí và gia hạn subscription
1. Tổng quan
Gia hạn subscription nghe thì đơn giản: đến hạn thì trừ tiền khách. Thực tế lại có nhiều chỗ dễ sai. Bạn có thể trừ tiền hai lần, bỏ sót một kỳ, hoặc làm sập cổng thanh toán vào ngày mùng 1.
Bài này đi qua toàn bộ thiết kế: yêu cầu, mô hình dữ liệu, API, luồng xử lý và các điểm khó. Mỗi điểm khó đi kèm cách giải bằng code Java 21, Spring Boot 3.x, PostgreSQL, Redis và Kafka. Đọc xong, bạn có một khung thiết kế đủ chắc để áp dụng vào hệ thống thật.
2. Yêu cầu bài toán
2.1. Yêu cầu chức năng
- Khách hàng đăng ký một gói (
Plan) theo tháng hoặc theo năm. - Hệ thống tự gia hạn và trừ tiền khi kỳ hiện tại kết thúc.
- Khách có thể hủy ngay, hoặc hủy vào cuối kỳ.
- Khách có thể đổi gói giữa kỳ. Phí được tính theo tỷ lệ thời gian còn lại (proration).
- Khi thanh toán thất bại, hệ thống thử lại theo lịch (dunning). Hết số lần thử thì hủy subscription.
- Các hệ thống khác (email, phân quyền) nhận sự kiện
InvoicePaidvàSubscriptionCanceled.
2.2. Quy mô
- 5 triệu subscription đang hoạt động.
- Trung bình khoảng 170 nghìn lượt gia hạn mỗi ngày.
- Đỉnh khoảng 1 triệu lượt vào ngày mùng 1, do các chiến dịch khuyến mãi gom ngày bắt đầu.
- Cổng thanh toán giới hạn 200 TPS. Với 1 triệu lượt, riêng phần trừ tiền đã mất khoảng 1,4 giờ.
2.3. Ràng buộc
- Không bao giờ trừ tiền hai lần cho cùng một kỳ.
- Không bỏ sót kỳ nào, kể cả khi một instance chết giữa chừng.
- Tiền lưu bằng số nguyên theo đơn vị nhỏ nhất (
BIGINT). Không dùngdouble. - Thời gian lưu bằng UTC (
TIMESTAMPTZ). - Dịch vụ chạy nhiều instance phía sau load balancer.
3. Mô hình dữ liệu
Hệ thống có ba bảng chính và một bảng outbox:
CREATE TABLE plan (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL,
price_amount BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
billing_interval VARCHAR(10) NOT NULL -- MONTH | YEAR
);
CREATE TABLE subscription (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
plan_id BIGINT NOT NULL REFERENCES plan(id),
status VARCHAR(20) NOT NULL, -- ACTIVE | PAST_DUE | CANCELED
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
version BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_sub_due ON subscription (current_period_end) WHERE status = 'ACTIVE';
CREATE TABLE invoice (
id BIGSERIAL PRIMARY KEY,
subscription_id BIGINT NOT NULL REFERENCES subscription(id),
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
amount BIGINT NOT NULL,
status VARCHAR(20) NOT NULL, -- OPEN | PAID | FAILED
attempt_count INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ,
UNIQUE (subscription_id, period_start)
);
CREATE INDEX idx_inv_retry ON invoice (next_attempt_at) WHERE status = 'OPEN';
CREATE TABLE outbox_event (
id BIGSERIAL PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
Có vài quyết định đáng chú ý:
UNIQUE (subscription_id, period_start)là lưới an toàn cuối cùng. Dù code có lỗi, database vẫn không cho tạo hai hóa đơn cho cùng một kỳ.- Partial index
idx_sub_duechỉ chứa subscriptionACTIVE. Query tìm subscription đến hạn vì vậy luôn nhỏ và nhanh. - Cột
versionphục vụ optimistic locking cho các thao tác từ API như đổi gói hay hủy.
4. API
| Method | Endpoint | Mô tả |
|---|---|---|
POST |
/subscriptions |
Tạo subscription. Bắt buộc header Idempotency-Key |
GET |
/subscriptions/{id} |
Xem trạng thái và kỳ hiện tại |
POST |
/subscriptions/{id}/cancel |
Body {"atPeriodEnd": true} |
POST |
/subscriptions/{id}/change-plan |
Body {"planCode": "PRO_MONTHLY"} |
POST |
/webhooks/payments |
Cổng thanh toán báo kết quả bất đồng bộ |
Các thao tác ghi đều phải idempotent. Mạng di động hay retry request, và khách bấm “Đăng ký” hai lần thì vẫn chỉ được tạo một subscription.
5. Luồng gia hạn
Luồng gia hạn tách thành hai bước độc lập:
- Tạo hóa đơn: tìm subscription đến hạn, tạo
invoicetrạng tháiOPEN, rồi đẩycurrent_period_endsang kỳ mới. Bước này chỉ làm việc với database. - Thu tiền: tìm invoice
OPENcónext_attempt_at <= now, gọi cổng thanh toán, rồi ghi kết quả.
Lý do phải tách: gọi cổng thanh toán có thể mất vài giây. Nếu gọi ngay trong transaction đang giữ lock row, bạn sẽ cạn connection pool rất nhanh.
Trước tiên, bạn cần các dependency sau:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
6. Các điểm khó và cách giải
6.1. Tranh chấp: nhiều instance cùng chạy job
Mỗi instance đều có @Scheduled. Nếu tất cả cùng SELECT subscription đến hạn, cùng một subscription sẽ bị xử lý nhiều lần. Cách giải là dùng FOR UPDATE SKIP LOCKED:
public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {
@Query(value = """
SELECT * FROM subscription
WHERE status = 'ACTIVE' AND current_period_end <= :now
ORDER BY current_period_end
LIMIT :batchSize
FOR UPDATE SKIP LOCKED
""", nativeQuery = true)
List<Subscription> lockDueBatch(Instant now, int batchSize);
}
FOR UPDATE khóa các row được chọn. SKIP LOCKED khiến instance khác bỏ qua các row đó và lấy batch kế tiếp, thay vì đứng chờ. Mỗi instance vì vậy nhận một tập riêng. Muốn tăng thông lượng, bạn chỉ cần thêm instance.
Service tạo hóa đơn xử lý từng batch trong một transaction:
@Service
@RequiredArgsConstructor
public class RenewalService {
private final SubscriptionRepository subscriptions;
private final InvoiceRepository invoices;
@Transactional
public int createDueInvoices(Instant now) {
List<Subscription> due = subscriptions.lockDueBatch(now, 500);
for (Subscription sub : due) {
if (sub.isCancelAtPeriodEnd()) {
sub.cancel();
continue;
}
Instant start = sub.getCurrentPeriodEnd();
Instant end = sub.getPlan().nextPeriodEnd(start);
invoices.save(Invoice.open(sub, start, end, sub.getPlan().getPriceAmount(), now));
sub.advancePeriod(end);
}
return due.size();
}
}
Trong vòng lặp, subscription có cờ cancelAtPeriodEnd thì được hủy thay vì gia hạn. Các subscription còn lại được tạo invoice OPEN với next_attempt_at = now, rồi đẩy kỳ sang mốc mới. Cả hai thay đổi nằm chung một transaction, nên không có trạng thái “đã tạo hóa đơn nhưng chưa đẩy kỳ”.
Scheduler gọi service này lặp lại cho đến khi hết việc:
@Scheduled(fixedDelay = 10_000)
public void renew() {
while (renewalService.createDueInvoices(Instant.now()) > 0) { }
}
Các thao tác từ API như đổi gói hay hủy cũng có thể đụng độ với job gia hạn. Cột version cùng annotation @Version của JPA xử lý việc này. Bên nào commit sau sẽ nhận OptimisticLockException. Client chỉ cần đọc lại trạng thái và thử lại.
6.2. Không trừ tiền hai lần: idempotency key
Đây là điểm khó nhất. Bạn gọi cổng thanh toán và bị timeout. Bạn không biết khách đã bị trừ tiền hay chưa. Nếu retry mù quáng, bạn có thể trừ hai lần.
Cách giải là gửi kèm một idempotency key. Cổng thanh toán nhận lại cùng key sẽ trả về kết quả cũ, không trừ thêm:
public void chargeInvoice(long invoiceId) {
Invoice inv = invoices.findById(invoiceId).orElseThrow();
if (inv.getStatus() != InvoiceStatus.OPEN) {
return;
}
String key = "inv-%d-try-%d".formatted(inv.getId(), inv.getAttemptCount());
PaymentResult result = paymentGateway.charge(inv.getCustomerId(), inv.getAmount(), key);
billingTx.recordResult(invoiceId, result);
}
Key gồm invoiceId và attemptCount, chứ không chỉ invoiceId. Nhiều cổng thanh toán lưu cả kết quả thất bại theo key. Nếu dùng một key duy nhất, lần thử sau ba ngày (khi khách đã nạp tiền) vẫn nhận về lỗi “thẻ bị từ chối” cũ.
Quy tắc tăng attemptCount:
- Thành công: invoice chuyển sang
PAID. - Bị từ chối rõ ràng (hết tiền, thẻ hết hạn): tăng
attemptCountvà lên lịch thử lại. - Không rõ kết quả (timeout, lỗi 5xx): giữ nguyên
attemptCount. Lần retry sau dùng lại đúng key cũ, nên cổng thanh toán sẽ trả về kết quả thật của lần trước.
Lưu ý: Webhook từ cổng thanh toán có thể đến trước, đến sau, hoặc đến nhiều lần. Handler webhook phải kiểm tra trạng thái invoice trước khi cập nhật. Invoice đã
PAIDthì bỏ qua.
6.3. Lịch thử lại khi thanh toán thất bại (dunning)
Lịch thử lại là một hàm thuần, nên rất dễ test:
public final class DunningPolicy {
private static final List<Duration> SCHEDULE =
List.of(Duration.ofDays(1), Duration.ofDays(3), Duration.ofDays(5));
public static Optional<Instant> nextAttempt(int failedAttempts, Instant now) {
if (failedAttempts > SCHEDULE.size()) {
return Optional.empty();
}
long jitterMinutes = ThreadLocalRandom.current().nextLong(0, 120);
return Optional.of(now.plus(SCHEDULE.get(failedAttempts - 1)).plusSeconds(jitterMinutes * 60));
}
}
Sau lần thất bại thứ nhất, hệ thống thử lại sau 1 ngày. Lần thứ hai là 3 ngày, lần thứ ba là 5 ngày. Sau lần thứ tư, hàm trả về Optional.empty(). Lúc đó invoice chuyển FAILED và subscription chuyển CANCELED. Trong thời gian dunning, subscription ở trạng thái PAST_DUE và khách vẫn được dùng dịch vụ (grace period).
Phần jitter ngẫu nhiên 0–120 phút giúp các lần retry không dồn về cùng một thời điểm.
6.4. Nhất quán giữa database và Kafka: outbox pattern
Sau khi đánh dấu invoice PAID, bạn cần gửi sự kiện InvoicePaid. Nếu commit DB xong rồi mới gửi Kafka, instance có thể chết ở giữa và sự kiện bị mất. Nếu gửi Kafka trước rồi DB rollback, các hệ thống khác nhận một sự kiện “ma”.
Outbox pattern giải quyết bằng cách ghi sự kiện vào bảng outbox_event trong cùng transaction:
@Transactional
public void recordResult(long invoiceId, PaymentResult result) {
Invoice inv = invoices.findById(invoiceId).orElseThrow();
switch (result.status()) {
case SUCCESS -> {
inv.markPaid();
outbox.save(OutboxEvent.of(inv.getId(), "InvoicePaid", inv.toJson()));
}
case DECLINED -> inv.recordFailure(Instant.now());
case UNKNOWN -> inv.retryLater(Instant.now().plus(Duration.ofMinutes(15)));
}
}
Hàm này chạy trong transaction riêng, tách khỏi lời gọi cổng thanh toán. Một relay chạy định kỳ sẽ đọc outbox và đẩy sang Kafka:
@Scheduled(fixedDelay = 1_000)
@Transactional
public void publishOutbox() {
for (OutboxEvent e : outbox.lockUnpublished(200)) {
kafka.send("billing-events", String.valueOf(e.getAggregateId()), e.getPayload()).join();
e.markPublished(Instant.now());
}
}
lockUnpublished cũng dùng FOR UPDATE SKIP LOCKED như mục 6.1. Lời gọi join() đảm bảo Kafka đã nhận message rồi mới đánh dấu published_at. Key của message là aggregateId, nên các sự kiện của cùng một invoice luôn vào cùng partition và giữ đúng thứ tự.
Cách làm này cho đảm bảo at-least-once. Nếu relay chết sau khi gửi nhưng trước khi commit, sự kiện sẽ được gửi lại. Vì vậy consumer phải idempotent, ví dụ lưu eventId đã xử lý.
6.5. Tải đột biến ngày mùng 1
Ngày mùng 1 có thể có tới 1 triệu invoice cần thu tiền. Cổng thanh toán chỉ chịu được 200 TPS. Có ba lớp phòng thủ:
- Phân tán mốc thanh toán:
current_period_endlấy theo thời điểm khách đăng ký, kể cả giờ phút, thay vì làm tròn về 00:00. Chỉ riêng việc này đã trải tải ra suốt 24 giờ. - Hàng đợi tự nhiên: invoice nằm trong DB với
next_attempt_at. Worker xử lý dần theo khả năng. Không có gì bị mất, chỉ là chậm hơn. - Rate limit toàn cục bằng Redis: mọi instance dùng chung một bộ đếm.
@Component
@RequiredArgsConstructor
public class GatewayRateLimiter {
private final StringRedisTemplate redis;
private final int limitPerSecond = 200;
public boolean tryAcquire() {
String key = "rl:gateway:" + Instant.now().getEpochSecond();
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1) {
redis.expire(key, Duration.ofSeconds(2));
}
return count != null && count <= limitPerSecond;
}
}
Mỗi giây ứng với một key riêng. INCR là thao tác atomic, nên dù có 10 instance thì tổng số lời gọi trong một giây cũng không vượt 200. Worker nào nhận false thì để invoice lại cho vòng sau. Đây là thuật toán fixed window. Nó đơn giản và đủ dùng ở đây, dù có thể vọt tải nhẹ ở ranh giới giữa hai giây.
Lưu ý: Nếu Redis không truy cập được, hãy fail-closed (tạm dừng thu tiền) thay vì bỏ qua rate limit. Chậm vài phút vẫn tốt hơn bị cổng thanh toán khóa tài khoản merchant.
7. Tính phí khi đổi gói giữa kỳ và kiểm thử
Khi khách nâng cấp giữa kỳ, hệ thống hoàn lại phần chưa dùng của gói cũ và thu phần còn lại của gói mới:
public final class Proration {
public static long amountDue(long oldPrice, long newPrice,
Instant periodStart, Instant periodEnd, Instant changeAt) {
long total = Duration.between(periodStart, periodEnd).toSeconds();
long remaining = Duration.between(changeAt, periodEnd).toSeconds();
long credit = Math.floorDiv(oldPrice * remaining, total);
long charge = Math.floorDiv(newPrice * remaining, total);
return charge - credit;
}
}
Hàm nhân trước rồi mới chia, để tránh mất độ chính xác. Mọi phép tính đều dùng long. Kết quả âm nghĩa là khách hạ gói, và số tiền đó được ghi thành credit cho kỳ sau.
Test bằng JUnit 5 và AssertJ:
class BillingRulesTest {
private final Instant start = Instant.parse("2026-10-01T00:00:00Z");
private final Instant end = Instant.parse("2026-10-31T00:00:00Z");
@Test
void upgradeAtHalfPeriodChargesHalfOfDifference() {
Instant mid = Instant.parse("2026-10-16T00:00:00Z");
long due = Proration.amountDue(100_000, 300_000, start, end, mid);
assertThat(due).isEqualTo(100_000);
}
@Test
void stopsRetryingAfterThreeScheduledAttempts() {
Instant now = Instant.parse("2026-10-01T00:00:00Z");
assertThat(DunningPolicy.nextAttempt(1, now)).get()
.satisfies(t -> assertThat(t).isBetween(now.plus(Duration.ofDays(1)),
now.plus(Duration.ofDays(1)).plus(Duration.ofHours(2))));
assertThat(DunningPolicy.nextAttempt(4, now)).isEmpty();
}
}
Test đầu tiên đổi gói đúng giữa kỳ 30 ngày, từ gói 100.000 lên 300.000. Credit là 50.000, charge là 150.000, nên khách trả thêm 100.000. Test thứ hai kiểm tra hai điều: lần retry đầu rơi vào khoảng 1 ngày cộng jitter, và sau lần thất bại thứ tư thì không còn lịch thử lại.
BillingRulesTest > upgradeAtHalfPeriodChargesHalfOfDifference() PASSED
BillingRulesTest > stopsRetryingAfterThreeScheduledAttempts() PASSED
Với phần tranh chấp ở mục 6.1, hãy viết thêm integration test bằng Testcontainers PostgreSQL. Cho hai thread gọi createDueInvoices cùng lúc, rồi assert rằng số invoice đúng bằng số subscription đến hạn.
8. Những lỗi hay gặp
- Gọi cổng thanh toán trong
@Transactional: transaction giữ lock và connection suốt thời gian chờ mạng. Hãy tách thành bước đọc, bước gọi ngoài và bước ghi kết quả. - Dùng
@Scheduledkhông có cơ chế khóa khi chạy nhiều instance: dẫn tới xử lý trùng. - Tính ngày bằng
LocalDateTimetheo múi giờ server: gặp lỗi lệch giờ và lỗi kỳ ngày 31. Hãy dùngInstant. Với kỳ tháng, dùngZonedDateTime.plusMonths(1)theo múi giờ của khách. Phương thức này tự kẹp 31/1 thành 28 hoặc 29/2. - Dùng một idempotency key cho mọi lần thử: lỗi cũ bị cache và trả lại mãi.
- Không nên tự xây toàn bộ hệ thống này nếu bạn chỉ có vài nghìn khách và mô hình giá đơn giản. Dịch vụ billing có sẵn của cổng thanh toán thường rẻ hơn chi phí bảo trì. Tự xây chỉ đáng khi bạn cần logic giá đặc thù, nhiều cổng thanh toán, hoặc kiểm soát dữ liệu chặt chẽ.
9. Kết luận
Bài viết đã thiết kế một hệ thống tính phí và gia hạn subscription chịu được quy mô hàng triệu khách. Các ý chính gồm:
- Tách tạo hóa đơn và thu tiền thành hai bước.
- Dùng
FOR UPDATE SKIP LOCKEDđể chia việc giữa nhiều instance, kèm ràng buộcUNIQUElàm lưới an toàn. - Dùng idempotency key theo từng lần thử để không bao giờ trừ tiền hai lần.
- Dùng outbox pattern để giữ nhất quán giữa database và Kafka.
- Kết hợp phân tán mốc thanh toán, hàng đợi trong DB và rate limit Redis để vượt qua tải đột biến.
Từ khung này, bạn có thể mở rộng thêm thuế, mã giảm giá hoặc tính phí theo mức sử dụng mà không phải thay đổi luồng lõi.