订阅计费和续期系统设计

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. 续期流程

续期流程分为两个独立的步骤:

  1. 创建发票:查找到期的订阅,创建状态为 OPEN 的 invoice,然后将 current_period_end 推进到下一个周期。此步骤仅与数据库交互。
  2. 收款:查找状态为 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。有三层防御:

  1. 分散支付时间点:current_period_end 根据客户注册的时间点(包括小时和分钟)而不是四舍五入到 00:00。仅这一点就可以将负载分散到 24 小时。
  2. 自然队列:发票在数据库中,带有 next_attempt_at。Worker 按能力逐步处理。没有什么会丢失,只是速度会慢一些。
  3. 使用 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 速率限制来处理突增流量。

从这个框架,你可以进一步扩展税费、优惠代码或按使用量计费而无需改变核心流程。

系统设计spring-boot计费kafkaredis