按月订阅的 SaaS 看起来业务非常简单:用户付款以后可以使用一个月,到期以后继续付款。
真正开始开发订阅系统以后,事情很快会变得复杂。
用户可能正在试用,也可能已经付款;可能取消了自动续费,但当前计费周期还没有结束;可能信用卡扣款失败,但服务仍然处于宽限期;可能已经退款;也可能因为付款失败被暂停访问,几天以后重新付款。
如果数据库只有一个 status 字段,然后规定 active、expired、canceled 三种状态,系统很快就会出现大量特殊判断。
一个成熟的 SaaS 订阅系统,应该把订阅生命周期、付款状态、服务访问权、自动续费意愿以及账单周期分别建模。
这样做的目的不是把数据库设计得越来越复杂,而是避免把不同性质的问题强行塞进一个字段。
订阅状态和付款状态不是一回事
这是设计 SaaS Billing 系统时最容易犯的错误。
例如用户已经取消自动续费。
他的订阅可能是:
subscription_status = active
auto_renew = false
current_period_end = 2026-09-29
这意味着用户已经决定不再续费,但当前已经付费的服务周期还没有结束。
如果直接把状态改成 canceled,应用代码很容易误认为用户现在就不能使用服务。
因此,“用户取消订阅”和“订阅立即结束”应该区分。
同样,信用卡扣款失败也不一定意味着用户马上失去服务。
企业可能允许一个宽限期。
所以可能出现:
subscription_status = active
payment_status = failed
current_period_end = 2026-09-29
grace_period_end = 2026-10-03
用户付款失败了,但根据企业的 Billing Policy,服务暂时仍然可用。
这就是为什么单一 status 字段经常越来越臃肿。
最重要的时间不是一个end_at
订阅系统至少需要区分几个时间概念。
例如:
started_at
current_period_start
current_period_end
cancel_at
canceled_at
trial_end_at
grace_period_end_at
其中 current_period_end 表示当前已经购买的计费周期什么时候结束。
cancel_at 可以表示计划中的取消时间。
canceled_at 则记录实际取消操作发生的时间。
如果用户取消自动续费,但允许使用到当前周期结束,那么:
cancel_at = 2026-09-29
canceled_at = 2026-09-10
auto_renew = false
两个日期代表完全不同的事情。
因此,应用判断用户能不能使用服务时,不能只写:
status = active
而应该根据当前时间、当前周期、付款状态、宽限期和产品规则共同判断。
不要把“会员状态”和“访问权限”绑定死
SaaS Billing 系统还有一个经常被忽略的问题。
订阅状态并不一定直接等于访问权限。
例如用户的订阅已经取消,但当前计费周期仍然有效。
此时:
subscription = canceled
access = allowed
完全可能成立。
用户的付款失败以后进入宽限期,也可能是:
payment = failed
subscription = past_due
access = allowed
宽限期结束以后才可能变成:
payment = failed
subscription = past_due
access = restricted
所以在大型 SaaS 中,通常应该由 Billing 层产生明确的订阅事实,再由授权或者 Entitlement 层判断用户当前拥有的功能。
例如:
Plan
Subscription
Entitlement
Invoice
Payment
Access
这些对象各自解决不同问题。
Plan 表示产品方案。
Subscription 表示用户或者企业购买了什么以及购买周期。
Invoice 表示应该收多少钱。
Payment 表示钱有没有成功收取。
Entitlement 表示客户现在拥有哪些产品能力。
Access 则是应用在请求时根据身份、租户和授权规则决定是否允许访问。
这样系统才能处理复杂的商业规则。
月订阅并不等于简单地加30天
“按月”还有一个容易被忽略的问题。
自然月并不总是30天。
例如用户在1月31日开始订阅,下一个月应该如何计算?
是2月28日结束,还是按照具体 Billing Provider 的规则处理?
闰年又怎么办?
如果用户在月中订阅,企业是按照固定月度周期,还是按照30天计算?
因此,不应该简单写:
end_at = start_at + 30 days
具体的计费周期应该根据 Billing Policy 和支付平台规则确定。
系统最好明确保存当前计费周期的开始时间和结束时间,而不是每次请求动态计算。
这样审计和账单重建都会更加可靠。
到期用户应该经历什么过程
一个比较常见的订阅生命周期可能是:
用户注册后进入试用。
试用结束前提醒用户。
用户购买套餐以后进入正常订阅周期。
到达下一次 Billing Date 时尝试扣款。
付款成功以后产生新的计费周期。
付款失败以后进入 past_due 等付款异常状态。
系统按照企业规定进行重试。
如果付款恢复成功,订阅继续。
如果多次扣款失败并超过宽限期,则根据业务政策限制服务。
用户也可以主动关闭自动续费。
关闭自动续费并不一定意味着立即停止服务。
当前已经付款的周期结束以后,订阅才可能不再继续。
这里每一个事件都应该有明确的数据记录。
不要用数据库状态变化代替Billing事件
一个成熟系统不应该只依赖:
UPDATE subscriptions
SET status = 'expired'
然后假设所有事情就完成了。
到期可能同时涉及很多业务:
发送邮件。
限制高级功能。
停止新的 API 调用。
冻结某些后台任务。
更新 Entitlement。
生成账单记录。
通知客户管理员。
记录审计事件。
如果这些操作全部放在一次同步 HTTP 请求里,任何一个步骤失败都可能造成状态不一致。
因此,订阅状态变化通常应该产生明确的领域事件,例如:
SubscriptionCreated
PaymentSucceeded
PaymentFailed
SubscriptionCanceled
SubscriptionRenewed
TrialEnding
SubscriptionExpired
RefundCreated
随后由相应的异步消费者处理通知、权限更新、统计和其他非核心工作。
核心账务状态和业务副作用应该分开处理。
支付失败不能马上等于用户过期
信用卡付款失败是 SaaS 最常见的异常情况之一。
可能是信用卡过期。
可能是银行拒付。
可能是支付平台暂时不可用。
也可能只是一次临时网络或者授权问题。
因此,第一次付款失败以后立即把账户设置成 expired,通常并不合理。
更常见的设计是进入付款异常状态,例如:
past_due
然后按照企业自己的 Billing Policy 进行重试。
例如:
第一次失败
第二次重试
再次失败
发送付款失败提醒
进入宽限期
宽限期结束
限制服务
具体重试次数和间隔不能写成所有 SaaS 都必须使用的固定规则。
不同产品的客户价值、合同条款、支付平台以及服务成本都不同。
真正需要保证的是:每一次重试都有幂等控制,并且不会因为任务重复执行而重复扣款。
幂等性比Redis锁更加重要
原稿提出使用 Redis 分布式锁处理并发状态更新,这个方向可以作为一种工程手段,但不能把 Redis 锁当成订阅系统一致性的核心保证。
假设支付平台因为网络问题重复发送两个相同的 webhook。
如果系统没有幂等设计,即使有 Redis 锁,也可能在锁释放以后再次处理同一个事件。
因此更可靠的做法是保存支付平台的事件 ID或者业务幂等键,并通过数据库唯一约束等方式保证同一个事件不会被重复处理。
例如:
payment_provider_event_id
UNIQUE(payment_provider_event_id)
处理流程应该具备幂等性。
第一次收到事件,完成状态更新。
第二次收到同一个事件,识别为已经处理过,然后安全返回。
这比单纯依赖分布式锁更加可靠。
Webhook不能直接相信
SaaS Billing 系统通常需要和 Stripe、Adyen、PayPal 或其他支付服务集成。
支付平台会通过 webhook 通知企业:
付款成功。
付款失败。
订阅发生变化。
退款完成。
账单生成。
这些 webhook 属于高价值接口。
服务器必须验证 webhook 的真实性和完整性,不能因为请求 URL 正确就直接修改数据库中的订阅状态。
同时还要考虑重复事件、乱序事件、延迟事件和重试。
例如系统先收到“付款成功”,随后才收到一个较早产生的“付款失败”事件。
如果应用只是按照 webhook 到达顺序覆盖状态,就可能把正确的订阅状态改坏。
因此,Billing 系统需要根据事件时间、账单周期、支付平台事件 ID以及当前数据库状态进行合理处理。
多租户SaaS不能只靠user_id
企业 SaaS 还有一个重要问题:用户和租户并不一定是一对一关系。
一个用户可能属于多个企业。
一个企业可能有多个管理员。
一个管理员可能拥有 Billing 权限,但普通员工没有。
因此:
SELECT *
FROM subscriptions
WHERE user_id = ?;
在多租户系统中往往是不够的。
订阅通常应该与 Account、Organization 或 Tenant 等业务主体建立清晰关系。
例如:
SELECT *
FROM subscriptions
WHERE tenant_id = ?
AND id = ?;
然后服务器端继续检查当前用户是否拥有这个租户的 Billing 或管理权限。
不能因为用户已经通过 JWT 或 Session 登录,就允许他访问任意租户的订阅记录。
认证解决“这个用户是谁”。
授权解决“这个用户可以操作什么”。
租户隔离解决“这个用户属于哪个业务数据边界”。
这三个层面必须同时存在。
试用期最好不要靠一个字符串解决
Trial 是 SaaS 转化漏斗的重要组成部分。
试用系统至少需要知道:
试用什么时候开始。
什么时候结束。
使用哪个套餐。
是否已经使用过试用。
试用期间有哪些功能限制。
试用结束以后是否自动转付费。
例如:
trial_started_at
trial_end_at
trial_plan_id
trial_converted_at
如果企业规定每个组织只能获得一次试用,那么限制应该与租户或者账户主体关联,而不是简单依赖当前用户。
否则用户删除账号、重新注册一个邮箱,可能再次获得试用。
升级和降级同样需要周期概念
订阅系统不能只考虑“续费”和“过期”。
用户还可能从 Standard 升级到 Premium。
升级以后,新功能什么时候生效?
费用是立即按比例计算,还是下一个计费周期生效?
用户从 Premium 降级到 Standard 时,当前周期剩余时间怎么办?
这些问题需要明确记录。
因此成熟 Billing 系统通常不会直接把:
plan = standard
简单覆盖成:
plan = premium
而是需要保留变更时间、当前周期以及相关账单信息。
对于复杂产品,还可能需要单独的订阅项目或者 Entitlement 记录。
退款不是把status改成refunded
退款也不应该简单写成:
status = refunded
因为退款是支付或者账单层面的金融事件。
一笔账单可能已经付款。
之后发生部分退款。
然后又发生另一笔调整。
因此,Invoice、Payment、Refund 最好作为独立业务对象管理。
例如:
Invoice
Payment
Refund
Credit
订阅则记录客户购买关系。
这样以后财务人员需要回答“这个客户为什么只收到了部分退款”时,系统仍然可以完整重建账务过程。
如果只是把订阅状态改成 refunded,很多历史信息都会丢失。
数据库字段可以保持简单,但模型不能简单
一个基础的订阅表可以从这样的结构开始:
CREATE TABLE subscriptions (
id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
plan_id BIGINT NOT NULL,
status VARCHAR(30) NOT NULL,
current_period_start DATETIME NOT NULL,
current_period_end DATETIME NOT NULL,
auto_renew BOOLEAN NOT NULL DEFAULT TRUE,
trial_end_at DATETIME NULL,
cancel_at DATETIME NULL,
canceled_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_tenant_status (tenant_id, status),
INDEX idx_period_end (current_period_end)
);
付款状态不要强行塞进这个字段。
Invoice、Payment、Refund 可以使用独立表。
如果产品规模较大,还可以增加订阅事件表:
CREATE TABLE subscription_events (
id BIGINT PRIMARY KEY,
subscription_id BIGINT NOT NULL,
event_type VARCHAR(50) NOT NULL,
event_id VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_event_id (event_id),
INDEX idx_subscription (subscription_id)
);
这样系统可以保留订阅生命周期中的重要事件。
到期检查任务也不能只跑一次
SaaS 通常需要后台任务检查即将到期、已经到期以及付款异常的订阅。
例如每天或者更高频率执行任务:
寻找即将到期的订阅
发送提醒
寻找需要进行付款重试的账单
执行重试
寻找宽限期结束的账户
更新服务权限
寻找需要归档的数据
提交异步归档任务
这里尤其需要注意任务重复执行。
Cron、队列消费者、Kubernetes Job 或云端调度任务都可能因为故障重跑。
因此任务必须设计成幂等操作。
例如发送“到期前三天提醒”时,可以保存:
expiration_notice_sent_at
避免同一提醒因为任务重复执行而发送几十次。
缓存不能成为订阅状态的最终来源
订阅状态通常访问频率很高,所以缓存非常有价值。
例如用户每次请求 API 都需要判断当前套餐。
直接查询数据库可能造成大量重复读取。
但缓存应该是加速层,而不是账务事实的唯一来源。
如果数据库中的订阅刚刚从 Premium 变成 Basic,而 Redis 里面还保存着旧数据,那么用户可能暂时获得错误权限。
因此,高风险权限判断需要设计缓存失效和更新机制。
对于特别敏感的操作,例如导出全部客户数据、修改支付信息、关闭企业账户等,可以根据风险考虑更严格的实时状态检查。
不能为了追求几毫秒性能,就让旧订阅状态长期控制权限。
SaaS会员状态最容易被低估的地方是“时间”
订阅系统实际上是一个时间驱动的业务系统。
同一个用户在不同时间可能拥有不同的有效权限。
例如:
8月1日到8月31日
Premium有效
8月20日
用户关闭自动续费
8月31日
当前周期结束
9月1日
没有新付款
9月1日至9月5日
进入宽限期
9月6日
限制Premium功能
9月10日
用户重新付款
9月10日
恢复Premium
如果系统只有:
active
expired
canceled
这些时间关系很难表达。
如果系统保存的是订阅周期、账单、付款事件、取消时间以及 Entitlement,那么这些业务状态就可以从数据中准确推导出来。
最好的设计不是状态越多越好
另一个极端是把数据库设计成几十种状态:
active
trial
pending
paused
past_due
grace
expired
canceled
refunded
suspended
terminated
reactivating
...
状态越多,并不代表系统越先进。
如果这些状态彼此重叠,开发人员最终还是会搞不清楚一个账户到底能不能用。
更合理的方式是把不同维度拆开。
例如:
订阅生命周期
Subscription
付款生命周期
Invoice / Payment
产品权益
Entitlement
自动续费意愿
auto_renew
时间边界
current_period_start / current_period_end
取消信息
cancel_at / canceled_at
风险与访问控制
Access Policy
每个字段只负责表达一种业务事实。
系统需要判断用户是否能够使用某个功能时,再根据这些事实计算最终权限。
企业SaaS应该把Billing当成独立业务系统
小型 SaaS 刚开始可能只需要一张 subscriptions 表。
这完全可以。
但随着客户数量增加,系统会逐渐出现试用、优惠券、升级、降级、退款、失败重试、税费、发票、企业合同、按席位计费、按量计费以及多租户权限等需求。
此时 Billing 就不再是用户表里面的几个字段,而是一个独立的业务域。
数据库负责保存事实。
支付平台负责支付处理。
Billing 服务负责订阅和账单规则。
Entitlement 服务或者权限层负责产品权益。
通知系统负责邮件和提醒。
后台任务负责时间驱动的处理。
事件系统负责跨服务的异步通信。
这样的结构可以让系统随着业务增长逐渐扩展,而不是把所有逻辑塞进一个 if ($subscription['status'] === 'active')。
按月订阅的 SaaS 最初看起来只是一个“付款一个月,到期再付款”的功能,实际上它是一套围绕时间、账单、权限和客户生命周期运行的业务系统。
设计时最重要的思路,不是创造尽可能多的会员状态,而是把不同性质的数据分开。
订阅记录客户购买关系。
Invoice 记录应该收多少钱。
Payment 记录付款结果。
Refund 记录退款。
Entitlement 记录客户拥有哪些产品权益。
时间字段确定这些权益什么时候开始、什么时候结束。
事件记录系统为什么发生了变化。
这样,当用户取消自动续费、信用卡扣款失败、进入宽限期、升级套餐、退款或者重新付款时,系统都能够根据完整的数据解释当前状态,而不是依赖一个含义越来越模糊的 status 字段。
对于小型 SaaS,可以从简单模型开始,但从第一天就应该保留清晰的时间边界、租户边界、幂等机制和审计记录。随着业务扩大,再逐步拆分 Billing、Payment 和 Entitlement。这样既不会一开始过度设计,也不会等到客户规模上来以后才发现整个会员系统已经无法解释自己的状态。
按月订阅的 SaaS 如何处理到期用户,会员状态设计不能太简单
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP