订阅计费和续期系统设计
1. 概述
订阅续期听起来很简单:到期时扣费客户。现实中有许多容易出错的地方。你可能会重复扣费,遗漏某个周期,或在每月初压垮支付网关。
本文涵盖整个设计过程:需求、数据模型、API、处理流程和各种难点。每个难点都附带 Java 21、Spring Boot 3.x、PostgreSQL、Redis 和 Kafka 的代码解决方案。读完后,你会有一个足够坚实的设计框架,可以应用到真实系统中。
2. 问题需求
2.1. 功能需求
- 客户订阅按月或按年的某个套餐。
- 系统在当前周期结束时自动续期并扣费。
- 客户可以立即取消,或在周期结束时取消。
- 客户可以在周期中途升级套餐。费用按剩余时间比例计算(proration)。
- 支付失败时,系统按计划重试(催收)。尝试次数上限后取消订阅。
- 其他系统(邮件、权限管理)接收
InvoicePaid和SubscriptionCanceled事件。
2.2. 规模
- 500 万活跃订阅。
- 平均每天约 17 万次续期。
- 高峰约在每月初达到 100 万次,因为营销活动会集中在该日期。
- 支付网关限制 200 TPS。以 100 万次计,仅扣费部分就需耗时约 1.4 小时。
2.3. 约束
- 永远不能对同一周期重复扣费。
- 不能遗漏任何周期,即使某个实例在中途宕机。
- 金额以整数形式存储,单位为最小货币单位(
BIGINT)。不使用double。 - 时间以 UTC 形式存储(
TIMESTAMPTZ)。 - 服务在负载均衡器后面运行多个实例。
3. 数据模型
系统有三个主表和一个 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
);
有几个值得注意的设计决策:
UNIQUE (subscription_id, period_start)是最后的保险。即使代码有漏洞,数据库也不会允许为同一周期创建两张发票。- 部分索引
idx_sub_due仅包含ACTIVE状态的订阅。查找到期订阅的查询因此总是小而快。 version列用于乐观锁定,处理来自 API 的操作如升级或取消。
4. API
| 方法 | 端点 | 描述 |
|---|---|---|
POST |
/subscriptions |
创建订阅。需要 Idempotency-Key 请求头 |
GET |
/subscriptions/{id} |
查看状态和当前周期 |
POST |
/subscriptions/{id}/cancel |
请求体 {"atPeriodEnd": true} |
POST |
/subscriptions/{id}/change-plan |
请求体 {"planCode": "PRO_MONTHLY"} |
POST |
/webhooks/payments |
支付网关异步报告支付结果 |
所有写操作都必须是幂等的。移动网络可能导致重试,客户可能点击“订阅”两次,但仍应该只创建一个订阅。
5. 续期流程
续期流程分为两个独立的步骤:
- 创建发票:查找到期的订阅,创建状态为
OPEN的invoice,然后将current_period_end推进到下一个周期。此步骤仅与数据库交互。 - 收款:查找状态为
OPEN且next_attempt_at <= now的发票,调用支付网关,然后记录结果。
必须拆分的原因:调用支付网关可能需要几秒钟。如果在持有行锁的事务中调用,你会很快耗尽连接池。
首先,你需要以下依赖项:
<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. 难点及其解决方案
6.1. 竞争:多个实例并发运行 job
每个实例都有 @Scheduled 方法。如果所有实例同时查询到期的订阅,同一订阅会被处理多次。解决方案是使用 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 锁定所选的行。SKIP LOCKED 使其他实例跳过这些行并获取下一批,而不是等待。因此每个实例都获得一个独立的批次。要增加吞吐量,只需添加更多实例。
创建发票的 service 在单个事务中处理每个批次:
@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();
}
}
在循环中,如果订阅有 cancelAtPeriodEnd 标志则被取消,否则创建 OPEN 状态的发票,其中 next_attempt_at = now,然后将周期推进到新的截止日期。两个更改都在同一事务中,因此不会存在“已创建发票但未推进周期”的状态。
调度器重复调用此服务直到无更多工作:
@Scheduled(fixedDelay = 10_000)
public void renew() {
while (renewalService.createDueInvoices(Instant.now()) > 0) { }
}
来自 API 的操作如升级或取消也可能与续期 job 冲突。JPA 的 version 列和 @Version 注解处理此问题。先提交的一方会收到 OptimisticLockException。客户端只需重新读取状态并重试。
6.2. 不重复扣费:幂等性密钥
这是最难的部分。你调用支付网关并超时。你不知道是否已扣费。如果盲目重试,可能会重复扣费。
解决方案是发送幂等性密钥。支付网关收到相同的密钥会返回旧结果,不会额外扣费:
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);
}
密钥包括 invoiceId 和 attemptCount,而不仅仅是 invoiceId。许多支付网关按密钥缓存失败结果。如果使用单个密钥,三天后当客户已充值再重试,仍会收到旧的“卡被拒绝”错误。
增加 attemptCount 的规则:
- 成功:发票转为
PAID。 - 明确拒绝(余额不足、卡过期):增加
attemptCount并安排重试。 - 不确定结果(超时、5xx 错误):保持
attemptCount不变。后续重试使用相同的密钥,支付网关将返回上次的实际结果。
注意: 来自支付网关的 Webhook 可能提前到达、延迟到达或多次到达。Webhook 处理器在更新前必须检查发票状态。已
PAID的发票则忽略。
6.3. 支付失败时的重试计划(催收)
重试计划是一个纯函数,因此很容易测试:
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));
}
}
第一次失败后,系统在 1 天后重试。第二次是 3 天,第三次是 5 天。第四次之后,函数返回 Optional.empty()。此时发票转为 FAILED,订阅转为 CANCELED。在催收期间,订阅处于 PAST_DUE 状态,客户仍然可以使用服务(宽限期)。
0-120 分钟的随机抖动帮助防止多次重试聚集在同一时间。
6.4. 数据库和 Kafka 之间的一致性:Outbox 模式
标记发票为 PAID 后,你需要发送 InvoicePaid 事件。如果先提交数据库再发送 Kafka,实例可能在中途宕机导致事件丢失。如果先发送 Kafka 再数据库回滚,其他系统接收到一个“幽灵”事件。
Outbox 模式通过在同一事务内将事件写入 outbox_event 表来解决:
@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)));
}
}
此函数在单独的事务中运行,与支付网关调用分离。一个定期运行的中继将读取 outbox 并推送到 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 也使用 FOR UPDATE SKIP LOCKED(如第 6.1 部分)。join() 调用确保 Kafka 已接收消息后才标记 published_at。消息的键是 aggregateId,因此同一发票的事件总是进入同一分区并保持正确的顺序。
这种方法提供至少一次保证。如果中继在发送后但在提交前宕机,事件将被重新发送。因此消费者必须是幂等的,例如记录已处理的 eventId。
6.5. 每月初的突增流量
每月初可能有多达 100 万张发票需要收款。支付网关只能承受 200 TPS。有三层防御:
- 分散支付时间点:
current_period_end根据客户注册的时间点(包括小时和分钟)而不是四舍五入到 00:00。仅这一点就可以将负载分散到 24 小时。 - 自然队列:发票在数据库中,带有
next_attempt_at。Worker 按能力逐步处理。没有什么会丢失,只是速度会慢一些。 - 使用 Redis 的全局速率限制:所有实例共享一个计数器。
@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;
}
}
每秒对应一个唯一的密钥。INCR 是原子操作,因此即使有 10 个实例,一秒内的总调用数也不会超过 200。收到 false 的 Worker 将发票保留供下一轮处理。这是固定窗口算法。它简单且足以在这里使用,虽然可能在两秒之间的边界处略微超载。
注意: 如果 Redis 无法访问,应该失败关闭(暂时停止收款)而不是绕过速率限制。延迟几分钟仍然更好,不要被支付网关锁定商户账户。
7. 中途升级计费和测试
当客户在周期中途升级时,系统返还旧套餐未使用的部分并收取新套餐的剩余部分:
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;
}
}
先乘后除以避免精度丢失。所有计算都使用 long。负数结果意味着客户降级,该金额被记录为信用用于下一个周期。
使用 JUnit 5 和 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();
}
}
第一个测试在 30 天周期的中间升级套餐,从 100,000 升级到 300,000。信用是 50,000,费用是 150,000,所以客户需要支付额外的 100,000。第二个测试检查两件事:第一次重试落在 1 天加抖动的范围内,第四次失败后没有进一步的重试计划。
BillingRulesTest > upgradeAtHalfPeriodChargesHalfOfDifference() PASSED
BillingRulesTest > stopsRetryingAfterThreeScheduledAttempts() PASSED
对于第 6.1 部分的竞争,请使用 Testcontainers PostgreSQL 编写集成测试。让两个线程同时调用 createDueInvoices,然后断言发票数量等于到期订阅的数量。
8. 常见错误
- 在 @Transactional 中调用支付网关:事务持有锁和连接。应分离为读取步骤、外部调用步骤和写入结果步骤。
- 使用没有锁定机制的 @Scheduled:当运行多个实例时导致重复处理。
- 使用 LocalDateTime 按服务器时区计算日期:遇到时区偏差错误和 31 号日期错误。应使用
Instant。对于按月周期,按客户时区使用ZonedDateTime.plusMonths(1)。此方法会自动将 1 月 31 日调整为 2 月 28 日或 29 日。 - 对所有重试使用单个幂等性密钥:旧错误被缓存并继续返回。
- 如果不是有特殊需求,不应该自己构建整个系统(如果你只有数千个客户且定价模型简单)。现成的计费服务通常比自己维护更便宜。仅当你需要自定义定价逻辑、多个支付网关或需要对数据进行严格控制时才值得自己构建。
9. 结论
本文设计了一个能够支撑数百万用户规模的计费和续期系统。要点包括:
- 将创建发票和收款分开成两个步骤。
- 使用
FOR UPDATE SKIP LOCKED在多个实例之间分配工作,配合UNIQUE约束作为最后的保险。 - 对每次尝试使用幂等性密钥以防止重复扣费。
- 使用 Outbox 模式在数据库和 Kafka 之间保持一致性。
- 结合分散支付时间点、数据库中的队列和 Redis 速率限制来处理突增流量。
从这个框架,你可以进一步扩展税费、优惠代码或按使用量计费而无需改变核心流程。