订阅过期机制的最佳设计模式是什么?
明确结论
订阅是否过期、用户能否继续访问,应由明确的时间边界决定,例如:
current_time < access_until
不要依赖 Cronjob 是否及时把 status 从 active 更新为 expired。
在给出的两个方案中,Model 1 更适合用于授权判断,但字段含义需要调整。不要简单地用固定的 signup_date + subscription_length 计算到期时间,而应保存当前订阅周期的实际结束时间,例如 current_period_end 或 access_until。每次续费、退款、取消或补偿时,再按照业务规则更新这些字段。
推荐使用以下判断方式:
是否允许访问 =
status 允许提供服务
AND
current_time < access_until
Cronjob 可以整理状态、发送提醒和修复数据,但不能成为订阅按时失效的唯一保障。
两种模式的对比
| 维度 | Model 1:按 expiry_date 判断 | Model 2:按 Cronjob 更新 status |
|---|---|---|
| 准确性 | 时间边界明确,适合授权判断 | 取决于任务的执行频率和成功情况 |
| 实时性 | 请求到达时即时计算 | 最多会延迟一个调度周期 |
| 可靠性 | 不依赖后台任务及时运行 | Cronjob 延迟或失败可能造成越权访问 |
| 维护成本 | 核心逻辑简单,但要处理续费后的日期更新 | 需要维护调度、重试、监控和并发控制 |
| 查询性能 | 需要比较状态和时间,通常成本很低 | 只查询状态比较简单,但状态可能已经过时 |
| 审计能力 | 可以直接看到访问权何时结束 | 只能看到当前状态,无法单独说明时间边界 |
Model 1 的优点
授权系统直接比较当前时间和订阅结束时间,不必等待异步任务:
current_time < access_until
即使 Cronjob 暂停运行,已经到期的订阅也无法继续访问。
这种方式也更容易处理以下情况:
- 按月、按年或自定义周期订阅;
- 免费试用;
- 续费后延长服务期;
- 取消自动续费,但允许使用到当前周期结束;
- 人工赠送额外使用时间;
- 支付失败后的宽限期。
Model 1 的问题
原始公式:
expiry_date = signup_date + subscription_length
只适用于一次性且不会续费的简单订阅。对于周期订阅,注册时间通常不是当前服务周期的起点。
更合理的字段包括:
current_period_start
current_period_end
access_until
其中:
current_period_end表示当前计费周期的结束时间;access_until表示用户实际可以访问到什么时间;- 在普通订阅中,两者可以相同,但遇到宽限期、退款或人工补偿时,两者可能不同。
Model 2 的优点
授权时只检查 status,代码比较直观:
status == "active"
如果系统需要频繁筛选活跃用户,持久化状态也便于查询和统计。
Model 2 的问题
这种模式把授权的正确性建立在 Cronjob 正常运行的基础上。假设任务每小时执行一次,订阅在 10:05 到期,任务要到 11:00 才执行,用户就可能多获得将近一小时的访问权限。
实际运行中还可能遇到这些故障:
- Cronjob 没有启动或意外退出;
- 某一批数据处理失败;
- 数据库暂时不可用;
- 多个任务实例并发更新;
- 任务只处理了部分分页数据;
- 用户续费和过期任务同时执行,产生竞态条件;
- 服务器的时区或夏令时配置不一致。
即使将任务频率提高到每分钟一次,也只是缩短了错误窗口,并没有消除对异步任务的依赖。
推荐的数据模型
计费状态和访问权可以分开建模:
CREATE TABLE subscriptions (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
current_period_start TIMESTAMP NOT NULL,
current_period_end TIMESTAMP NOT NULL,
access_until TIMESTAMP NOT NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMP NOT NULL
);
status 用于表示订阅所处的生命周期阶段,例如:
trialing
active
past_due
canceled
expired
suspended
每个状态都要有清楚的业务定义。特别需要注意,canceled 不一定表示立即失去访问权。用户关闭自动续费后,通常仍然可以使用到已付费周期结束。
授权逻辑
授权时同时检查状态和时间:
from datetime import datetime, timezone
ACCESSIBLE_STATUSES = {"trialing", "active", "past_due"}
def can_access(subscription) -> bool:
now = datetime.now(timezone.utc)
return (
subscription.status in ACCESSIBLE_STATUSES
and now < subscription.access_until
)
是否允许处于 past_due 状态的用户继续访问,要看产品是否提供支付宽限期。如果没有宽限期,就不要把它加入 ACCESSIBLE_STATUSES。
遇到立即封禁、退款撤销或风控冻结等情况,可以先通过状态拒绝访问:
DENIED_STATUSES = {"expired", "suspended", "revoked"}
def can_access(subscription) -> bool:
if subscription.status in DENIED_STATUSES:
return False
now = datetime.now(timezone.utc)
return now < subscription.access_until
这样既能按照时间实时判断,也支持需要立即生效的管理操作。
续费和支付失败的处理
支付成功后,应根据支付结果更新当前订阅周期,不要再次使用最初的 signup_date:
def apply_successful_renewal(subscription, new_period_start, new_period_end):
subscription.status = "active"
subscription.current_period_start = new_period_start
subscription.current_period_end = new_period_end
subscription.access_until = new_period_end
支付失败时,可以按照业务规则设置宽限期:
from datetime import timedelta
def apply_payment_failure(subscription):
subscription.status = "past_due"
subscription.access_until = (
subscription.current_period_end + timedelta(days=3)
)
这里的三天只是示例,并非通用标准。宽限期应根据实际的计费和风控规则确定。
Cronjob 应该承担什么职责
Cronjob 仍有作用,但更适合用于状态同步和兜底:
UPDATE subscriptions
SET status = 'expired',
updated_at = CURRENT_TIMESTAMP
WHERE status IN ('trialing', 'active', 'past_due')
AND access_until <= CURRENT_TIMESTAMP;
它可以负责:
- 将超过
access_until的记录标记为expired; - 发送即将到期或已经到期的通知;
- 生成运营报表;
- 清理缓存和关联资源;
- 修复支付回调遗漏造成的数据不一致。
即使任务暂时失败,授权系统仍会根据 access_until 拒绝已经过期的访问。
Cronjob 的更新条件必须包含当前状态和时间边界,确保重复执行时得到一致结果。批量处理数据时,还需要加入分页、失败重试、执行监控和告警。
处理并发与乱序事件
支付回调、用户取消操作和过期任务可能同时发生。例如,Cronjob 读到订阅已经过期,准备将其更新为 expired;与此同时,支付回调已经完成续期。如果 Cronjob 随后无条件写入,就可能覆盖新的有效状态。
因此,更新时需要附带条件:
UPDATE subscriptions
SET status = 'expired',
updated_at = CURRENT_TIMESTAMP
WHERE id = :subscription_id
AND status IN ('trialing', 'active', 'past_due')
AND access_until <= CURRENT_TIMESTAMP;
也可以通过版本号实现乐观锁:
UPDATE subscriptions
SET status = :new_status,
access_until = :new_access_until,
version = version + 1
WHERE id = :subscription_id
AND version = :expected_version;
支付平台发送的事件还可能重复或乱序。系统应保存事件 ID,保证同一个事件只处理一次,并根据事件时间或订阅版本,阻止旧事件覆盖新状态。
时间处理注意事项
时间字段建议统一保存为 UTC,并使用带有时区语义的类型。界面展示时,再转换为用户所在的时区。
结束时间的边界规则也要明确定义。建议使用半开区间:
period_start <= current_time < period_end
这表示当 current_time == period_end 时,订阅已经失效。与混合使用 <= 和 >= 相比,这种规则不容易在相邻周期的交界处产生重叠。
授权判断依赖服务器时间,因此还要确保应用服务器和数据库服务器的时钟可靠同步。
最终建议
将 status 用于描述订阅的生命周期,将 access_until 用作访问授权的时间边界:
status:说明订阅处于什么业务阶段
access_until:说明访问权持续到什么时候
每次请求到达时实时检查这两个字段。支付成功、取消、退款或人工调整时更新它们,Cronjob 则负责状态归档、通知和异常修复。
这种组合既能覆盖比单独使用 Model 1 更复杂的订阅业务,也能避免 Model 2 因 Cronjob 延迟或失败而产生授权漏洞。
备注:内容仅供参考。
