PAYATHON 2026

微服务架构中 Subscription Service 是否应独立

支付小周

结论

是否拆分 Subscription Service,不取决于数据库是否独立,而取决于订阅是否已经成为一项独立的业务能力。

如果订阅只是 User 上的少量属性,例如 plan、expiresAt,也没有续费、试用、暂停、退款、套餐变更等规则,可以暂时放在 User Service 中。

如果系统采用持续付费模式,订阅有自己的生命周期、状态流转和计费规则,就应该建立独立的 Subscription Service。各服务的职责关系通常如下:

User Service
     ↑
     │ userId
     │
Subscription Service ←→ Payment Service

其中:

  • User Service 管理用户身份和资料。
  • Subscription Service 管理套餐、订阅状态、权益有效期和订阅生命周期。
  • Payment Service 管理支付请求、交易记录、退款以及与支付渠道的交互。

对于已经使用独立订阅数据库的付费应用,通常可以继续保留独立的 Subscription Service。但业务边界独立,不代表必须马上拆成三个独立部署单元。迁移初期可以先在模块化单体中划清边界,之后再根据业务复杂度和团队规模决定是否独立部署。

为什么订阅不属于用户资料

用户和订阅有关联,但不是同一个业务概念。

用户可以存在,却没有任何订阅。订阅本身也可能经历多次状态变化:

TRIALING → ACTIVE → PAST_DUE → CANCELED

实际业务中还可能涉及:

  • 免费试用和试用延期
  • 月付、年付或其他计费周期
  • 自动续费
  • 套餐升级或降级
  • 到期取消和立即取消
  • 支付失败后的宽限期
  • 退款与部分退款
  • 多次订阅历史
  • 来自不同渠道的订阅,例如 App Store、Google Play 或第三方支付平台

如果这些逻辑全部放进 User Service,它就要同时负责身份、资料、权限、订阅和部分计费业务,很容易变成一个新的「小型单体」。

因此,服务边界应该按业务能力划分,而不是跟着数据表划分。单独存在一张 Subscription 表或一个数据库,只能说明数据目前分开存储,不能据此认定它必须成为微服务。

各服务应该拥有的数据

User Service

负责相对稳定的用户信息,例如:

userId
email
displayName
status
createdAt

订阅状态不应以 User Service 中的数据为准。

为了提高查询性能,可以在其中保存 isPremium 一类的缓存或只读投影,但必须明确它是派生数据。订阅的最终状态仍由 Subscription Service 决定。

Subscription Service

负责订阅领域的数据,例如:

subscriptionId
userId
planId
status
currentPeriodStart
currentPeriodEnd
cancelAtPeriodEnd
provider
providerSubscriptionId

如果套餐和权益规则比较复杂,也可以由该服务管理:

Plan
Entitlement
SubscriptionHistory

Payment Service

负责资金交易和支付渠道集成,例如:

paymentId
subscriptionId
amount
currency
status
providerTransactionId
paidAt

Payment Service 不应直接修改订阅数据库。支付成功或失败后,它应通过 API 或领域事件通知 Subscription Service,由后者更新订阅状态。

推荐的交互方式

创建订阅时,不建议让客户端依次调用多个服务并自行维护一致性。业务编排可以交给 Subscription Service:

POST /subscriptions
Content-Type: application/json

{
  "userId": "usr_123",
  "planId": "plan_pro_monthly",
  "paymentMethodId": "pm_456"
}

服务内部可以按以下流程处理:

  1. 验证用户是否存在,以及是否允许订阅。
  2. 创建状态为 PENDING_PAYMENT 的订阅。
  3. 请求 Payment Service 发起支付。
  4. 根据支付结果更新订阅状态。
  5. 发布订阅状态变更事件。
  6. 由需要订阅信息的服务更新各自的只读投影。

支付服务可以发布类似事件:

{
  "eventId": "evt_01JXYZ",
  "eventType": "PaymentSucceeded",
  "occurredAt": "2026-08-31T08:30:00Z",
  "data": {
    "paymentId": "pay_789",
    "subscriptionId": "sub_123",
    "amount": 1999,
    "currency": "CNY"
  }
}

Subscription Service 消费事件后更新状态:

public void handle(PaymentSucceeded event) {
    Subscription subscription =
        repository.findById(event.subscriptionId())
            .orElseThrow(SubscriptionNotFoundException::new);

    if (subscription.isActive()) {
        return;
    }

    subscription.activate(clock.instant());
    repository.save(subscription);

    eventPublisher.publish(new SubscriptionActivated(
        subscription.getId(),
        subscription.getUserId(),
        subscription.getPlanId(),
        subscription.getCurrentPeriodEnd()
    ));
}

这里先检查订阅是否已经激活,是为了说明幂等处理的基本思路。实际实现中,还需要通过唯一事件 ID、数据库约束、事务或 inbox/outbox 机制,防止同一个支付事件被重复执行。

如何判断是否值得独立部署

如果同时满足以下多项条件,独立部署 Subscription Service 会更有意义:

  • 订阅有明确的状态机和生命周期。
  • 系统支持多个套餐或不同计费周期。
  • 需要处理试用、续费、取消、退款或支付失败。
  • 接入了多个支付渠道。
  • 订阅规则经常变化。
  • 需要单独审计订阅历史。
  • 多个业务服务依赖订阅或权益状态。
  • 订阅模块需要单独扩缩容、发布或隔离故障。
  • 有团队可以独立负责这项业务能力。

如果当前更接近以下情况,可以暂时合并:

  • 只有免费用户和付费用户两种状态。
  • 付费资格由人工或后台直接设置。
  • 没有自动续费和复杂的状态流转。
  • 系统规模较小,维护人员有限。
  • 拆分只会增加网络调用和运维成本,业务独立性却不强。

此时可以把订阅做成 User Service 内部的独立模块,而不是把相关字段直接散落在用户实体中:

User Service
├── Identity Module
├── Profile Module
└── Subscription Module

这样可以控制分布式系统的复杂度,也能为将来拆分保留清楚的边界。

迁移时的实施步骤

1. 先定义业务职责

先列出订阅模块需要处理的用例,例如:

startTrial
createSubscription
changePlan
renewSubscription
cancelSubscription
handlePaymentFailure
grantEntitlements
expireSubscription

如果这些用例已经有各自的业务规则,说明订阅已经具备较强的领域边界。

2. 确定唯一数据所有者

每类数据只能有一个权威写入方:

User data          → User Service
Subscription data  → Subscription Service
Payment data       → Payment Service

不要让多个服务直接写同一张表,也不要通过共享数据库绕过其他服务的业务规则。

3. 使用稳定标识关联数据

Subscription Service 只需保存 userId,通常不需要复制完整的用户资料:

CREATE TABLE subscription (
    subscription_id VARCHAR(64) PRIMARY KEY,
    user_id VARCHAR(64) NOT NULL,
    plan_id VARCHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL,
    current_period_end TIMESTAMP NULL
);

在各微服务拥有独立数据库的情况下,user_id 通常只是逻辑引用,不一定需要建立跨数据库外键。

4. 区分同步查询和异步通知

创建订阅前,可以同步调用 User Service 确认用户是否存在。订阅激活、到期或取消后,再通过异步事件通知其他服务。

不要把所有读取操作都串成同步调用链:

API Gateway → User Service → Subscription Service → Payment Service

这种调用链会增加延迟,也会扩大故障传播范围。如果页面需要频繁展示会员状态,可以通过本地只读投影或聚合层查询。

5. 渐进式拆分

从单体迁移时,可以按以下顺序进行:

  1. 先在现有应用内部隔离订阅模块。
  2. 禁止其他模块直接访问订阅表。
  3. 为订阅能力建立内部 API。
  4. 补齐状态迁移、幂等和审计机制。
  5. 将模块及其数据迁移到独立进程。
  6. 移除旧代码和旧数据库访问路径。

与一次性拆库、拆服务并切换所有调用方相比,这种渐进式做法更容易控制风险。

需要特别注意的问题

不要把 isPremium 当作完整领域模型

一个布尔值无法表达「已取消但当前周期仍有效」或「支付失败但仍处于宽限期」等状态。更可靠的判断通常需要同时考虑订阅状态和权益有效期:

boolean hasPremiumAccess(Subscription subscription, Instant now) {
    return subscription.status().grantsAccess()
        && subscription.currentPeriodEnd().isAfter(now);
}

哪些状态允许访问,应由业务规则明确规定,不能只根据状态名称推断。

支付成功不等于订阅一定已激活

支付和订阅处于不同的一致性边界。网络超时、消息重复或服务暂时不可用,都可能造成短暂的数据不一致,因此需要:

  • 幂等键
  • 事件去重
  • 重试机制
  • 对账任务
  • 状态变更审计
  • 必要时使用 transactional outbox

不建议为了让所有数据瞬间一致而引入跨服务分布式事务。

不要让所有操作形成双向依赖

User Service <-> Subscription Service <-> Payment Service 只能表示服务之间存在调用,不能说明依赖方向。真正需要明确的是,哪个服务拥有业务决策权。

例如,支付服务可以通知支付结果,但不能自行决定用户获得哪些会员权益。用户服务可以停用用户,但不应直接删除订阅和支付历史。

独立服务不代表数据无法组合查询

客户端需要同时展示用户资料和订阅状态时,可以通过 API Gateway、BFF 或查询服务聚合结果:

{
  "user": {
    "id": "usr_123",
    "displayName": "Alice"
  },
  "subscription": {
    "planId": "plan_pro_monthly",
    "status": "ACTIVE",
    "currentPeriodEnd": "2026-09-30T08:30:00Z"
  }
}

聚合查询不会改变数据所有权。用户数据仍由 User Service 提供,订阅数据仍由 Subscription Service 提供。

最终建议

如果 premium subscription 是产品的核心付费能力,并且已经或即将涉及续费、取消、退款、试用、套餐和权益管理,就应该把它定义为独立的业务边界,由 Subscription Service 负责订阅数据和状态流转。

如果目前只是在用户记录中增加一个简单的付费标记,可以暂时放在 User Service 中,但应封装成独立模块,避免订阅逻辑散落到用户资料相关代码里。

所谓 best practice,并不是微服务越多越好,而是让服务边界与业务边界对应。先明确职责和数据所有权,再决定是否独立部署,通常比照着现有数据库结构直接拆服务更稳妥。

备注:内容仅供参考。