微服务架构中 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"
}
服务内部可以按以下流程处理:
- 验证用户是否存在,以及是否允许订阅。
- 创建状态为
PENDING_PAYMENT的订阅。 - 请求
Payment Service发起支付。 - 根据支付结果更新订阅状态。
- 发布订阅状态变更事件。
- 由需要订阅信息的服务更新各自的只读投影。
支付服务可以发布类似事件:
{
"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. 渐进式拆分
从单体迁移时,可以按以下顺序进行:
- 先在现有应用内部隔离订阅模块。
- 禁止其他模块直接访问订阅表。
- 为订阅能力建立内部 API。
- 补齐状态迁移、幂等和审计机制。
- 将模块及其数据迁移到独立进程。
- 移除旧代码和旧数据库访问路径。
与一次性拆库、拆服务并切换所有调用方相比,这种渐进式做法更容易控制风险。
需要特别注意的问题
不要把 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,并不是微服务越多越好,而是让服务边界与业务边界对应。先明确职责和数据所有权,再决定是否独立部署,通常比照着现有数据库结构直接拆服务更稳妥。
备注:内容仅供参考。
