会员订阅:到期检查、批量处理与用户提醒
会员是否有效需要在访问权益时判断;到期状态整理和用户提醒由后台任务推进。登录时检查可以改善页面提示,但用户不登录时也可能需要收到提醒,因此不能成为唯一入口。
本篇先采用“预付费、到期后失效、允许提前续费”的模型,示例设置到期前 3 天、前 1 天和到期后各提醒一次。自动扣费订阅在文末单独讨论,不把一次扣款失败直接等同于权益失效。
从到期提示推导执行方案
假设产品最初只要求“会员进入个人中心时显示剩余天数”。这时请求内读取期限就能满足需求,并不需要先引入提醒队列。
加入“不登录也在到期前收到提醒”后,执行入口必须独立于用户请求。再加入“到期立即停止权益”,就不能把停权完全寄托在下一轮后台扫描上。三条路径是由不同的时间承诺推导出来的。
| 候选方案 | 能满足什么 | 代价与边界 |
|---|---|---|
| 登录时读取期限 | 页面提示、当前查询 | 无法主动联系未登录用户 |
| 周期扫描会员表 | 主动发现到期和提醒候选 | 要维护索引、补扫与提醒去重 |
| 购买时保存未来任务 | 每个提醒单独跟踪 | 续费与撤销后要识别旧版本 |
本篇选择“访问时判断权益、后台产生持久化待办、渠道独立发送”。扫描与延迟任务可以按系统规模择一作为主路径,再用核对流程补缺。判断依据是承诺的时间与恢复能力,不是某种中间件是否流行。
权益访问与到期整理时序
前半段由用户访问触发,后半段由后台时间检查触发。两者共用会员事实,但不要求后台先更新 expired,访问校验才生效。
用户保持登录也不改变 expires_at 的判断。通知的提交速度影响触达时效,不改变这条访问边界。
提醒、续费与渠道发送时序
这张图展示任务已经生成后用户续费的情况。发送前复核可以阻止旧版本提醒,已经交给渠道的消息仍有无法撤回的窗口。
需求与权益契约
| 业务问题 | 示例决策 | 实现约束 |
|---|---|---|
| 购买立即生效还是下期生效 | 支付确认后立即生效 | 不由客户端付款页面决定 |
| 提前续费是否损失剩余天数 | 从约定的当前有效边界延长 | 续费事务读取并锁定旧期限 |
| 到期是否允许宽限 | 预付费示例没有;自动续费另定 | 宽限与正常有效期分开保存 |
| 退款或封禁何时停权 | 按产品规则撤销指定权益 | 撤销事件与缓存失效接续 |
| 提醒成功意味着什么 | 对应渠道接受本轮通知 | 不等同于用户已读或已续费 |
以下流程针对单份当前会员记录。存在多份有效授权时,应先归并权益来源,再判断总体可用期限,不能因为其中一份到期就撤销全部会员能力。
三条路径分别负责什么
| 路径 | 触发时间 | 负责什么 |
|---|---|---|
| 权益校验 | 每次访问受保护的业务操作 | 当次是否允许使用会员能力 |
| 到期处理 | 后台发现已经到期 | 更新展示状态、撤销派生资源、保存到期通知意图 |
| 提醒分发 | 提前提醒时间或到期之后 | 站内信、邮件、短信、App 推送 |
登录时可以复用权益查询,在页面提示“还有 2 天到期”。进入会员功能时仍要校验;长期不退出的会话也可能跨过到期时间。OWASP 的授权建议要求在每次请求上校验权限,具体实现可以复用公共鉴权组件。
保存什么数据
这里假设每个用户有一份当前会员记录;多套餐、叠加赠送、冻结和分租户权益需要更完整的权益模型。
| 字段 | 用途 |
|---|---|
membership_id、user_id | 定位会员与用户 |
state | active、expired、revoked 等业务状态 |
expires_at | 明确的有效期结束时刻,使用带时区的时间 |
period_version | 续费或修改有效期时递增,区分新旧计划 |
notification_preferences | 可用渠道、用户选择和免打扰规则,通常独立存放 |
“10 月 31 日到期”应先转成业务定义的精确时刻。例如上海时间 11 月 1 日 00:00 是失效边界,此时前一天整天仍有效。后续比较使用 now < expires_at,避免日期显示和数据库判断各用一套规则。
示例的权益判断是:
允许使用 = state 为 active
且当前可信服务端时间早于 expires_at
且没有其他撤销或套餐限制即使到期扫描晚了两分钟,受保护操作也不会因此多授权两分钟。若缓存权益,缓存存活时间不能跨过已知的到期边界;退款、封禁等提前撤销还需要失效通知或更短的再校验周期。
把会员标记写进长效会话或令牌后长期不核对,会让用户跨过到期时刻继续使用。会话有效与会员权益有效要分别判断。
购买、续费和查询接口
| 入口 | 输入与校验 | 持久化结果 |
|---|---|---|
| 购买或续费请求 | 套餐、用户、请求幂等键 | 支付订单,返回待确认状态 |
| 支付确认处理器 | 可信支付事实、交易身份、金额与套餐 | 已处理支付流水与权益变更 |
| 权益查询 | 当前用户与业务能力 | 最新权益、期限及提示状态 |
| 撤销处理器 | 退款或封禁依据、受影响授权 | 版本化撤销及派生资源回收任务 |
| 提醒偏好修改 | 用户身份、渠道、免打扰规则 | 新偏好,发送前按当前值检查 |
请求幂等键与支付交易去重是两层约束:前者防止重复发起购买,后者防止同一付款重复延长权益。
支付确认后的开发流程
事务外验证或查询支付事实,确定要确认的交易身份
BEGIN
按支付来源与交易 ID 建立唯一处理资格
锁定会员与本地购买订单,核对用户、套餐和金额
如果该交易已处理:返回原权益变更结果
根据套餐规则计算新期限,递增 period_version
保存权益变更流水,更新订单与当前会员
保存延迟提醒计划或供扫描核对的变更事件
保存缓存失效与派生资源更新意图
COMMIT
后台可靠投递派生动作,不阻塞支付确认等待短信发送两个不同付款同时续费同一会员时,会员行的串行更新让后一次基于已提交的新期限计算;同一交易重复到达只读取原结果。权益变更流水保留每笔付款延长的范围,方便退款时判断影响。
具体时间线上发生什么
假设某会员在 2026-11-01 00:00(上海时间)到期:
| 时刻 | 系统行为 | 用户不登录会怎样 |
|---|---|---|
| 购买成功 | 保存会员期限与版本 | 无需用户额外操作 |
| 10 月 29 日 | 生成提前 3 天的提醒 | 按用户可用渠道发送 |
| 10 月 31 日 | 生成提前 1 天的提醒 | 与登录无关 |
| 11 月 1 日 00:00 | 请求校验判定权益到期 | 不依赖登录来触发失效 |
| 后台下一次处理 | 将派生状态转为 expired,创建通知 | 状态整理和通知继续进行 |
| 下一次登录 | 展示当前权益和历史通知 | 不重复发送同一轮提醒 |
提醒时间需要结合用户时区与免打扰窗口调整。例如前 1 天的提醒可以安排在当地白天;权益失效时刻保持业务定义的边界。发送时间错峰不能偷偷延长权益。
怎样触发提前提醒
| 方案 | 实现方式 | 适合的约束 |
|---|---|---|
| 周期扫描 | 每分钟查询已经进入提醒窗口且未建任务的会员 | 分钟级提醒、已有数据库和任务队列 |
| 延迟任务 | 购买或续费时保存多个未来提醒任务 | 需要单任务跟踪、精度由调度系统保证 |
| 事件配合兜底 | 购买、续费、撤销事件更新计划,扫描补缺 | 实时更新计划,并保留修复路径 |
对“同一天 1000 人到期”的样例,可以先用索引扫描和持久化任务队列。是否需要更复杂的延迟系统,取决于总记录数、允许延迟和数据库负载,而不只看 1000 这个数字。
购买或续费时创建延迟任务,应与会员变更处在同一事务,或通过 Outbox 接续;扫描方案则在候选发现时补建任务。两种方式都要在发送前重新读取会员版本。
按批发现需要提醒的人
下面使用 PostgreSQL 18 和数据库任务表。会员表记作 memberships,字段 id 对应上文的 membership_id;SQL 是设计示例,尚未连接数据库执行。
CREATE INDEX memberships_active_expiry_idx
ON memberships (expires_at, id)
WHERE state = 'active';
WITH scan_time AS (
SELECT statement_timestamp() AS as_of
),
candidates AS (
SELECT m.id, m.expires_at, m.period_version
FROM memberships AS m
CROSS JOIN scan_time AS s
WHERE m.state = 'active'
AND m.expires_at > s.as_of
AND m.expires_at <= s.as_of + interval '3 days'
AND NOT EXISTS (
SELECT 1
FROM background_tasks AS t
WHERE t.task_type = 'membership_reminder'
AND t.business_key =
m.id::text || ':' || m.period_version::text || ':before_3d'
)
ORDER BY m.expires_at, m.id
LIMIT 100
)
INSERT INTO background_tasks (
task_type, business_key, payload, scheduled_at, available_at
)
SELECT
'membership_reminder',
id::text || ':' || period_version::text || ':before_3d',
jsonb_build_object(
'membership_id', id,
'period_version', period_version,
'stage', 'before_3d'
),
expires_at - interval '3 days',
statement_timestamp()
FROM candidates
ON CONFLICT (task_type, business_key) DO NOTHING
RETURNING id;这条语句发现“已进入 3 天提醒窗口、尚未到期、这一版本还没建过任务”的候选,每批最多保存 100 条。已经建过任务的记录被排除,下一批才能继续前进;仅反复 LIMIT 100 查询相同用户会一直停留在第一页。
并发扫描器可能选中相同候选,唯一约束负责防止重复建任务;不要把某次冲突后插入 0 条理解为所有扫描工作已经完成。可以用单个扫描计划的租约协调发现流程,Worker 仍然并行领取已保存的任务。
这里保留原始 scheduled_at 以衡量晚到时间,available_at 表示现在可以领取。前 1 天提醒采用另一种 stage 独立建任务。生产环境的查询计划还需要在实际数据量上检查。
系统停机后怎样补扫
只查询“恰好在这一分钟进入提醒时间”的会员,会在停机期间漏人。可以持续查询已经满足条件但缺少对应任务的记录,或保存扫描水位并在恢复后补扫。
补扫也要遵守通知价值:如果距到期只剩半小时,没必要同时补发“还剩 3 天”和“还剩 1 天”。示例策略是只保留仍适用的最近一档提醒;已经到期时改发到期通知,旧提前提醒记录 skipped。
上面的 SQL 展示候选发现,最终是否保留这一档由 Worker 的时效判断决定。任务历史归档后,还要保留必要的提醒去重记录,否则扫描会把同一通知重新建立。
1000 人同一天到期怎样处理
首先区分同一天与同一时刻。如果 1000 人的期限分布在一天内,可以持续按时间范围查询;如果全部在午夜到期,则会产生一批集中到期记录。
下面是容量计算样例,不是特定供应商的默认配额:
| 环节 | 样例配置 | 实际含义 |
|---|---|---|
| 发现候选 | 每批 100 条 | 1000 条约需 10 批,批次可连续推进 |
| 状态整理 | 每批一个短事务 | 避免一笔事务锁住全部用户 |
| Worker | 总并发 10 | 同时处理最多 10 个动作 |
| 通知渠道 | 全系统最多每秒 20 次请求 | 所有 Worker 共用发送额度 |
| 提醒时窗 | 允许 10 分钟内分散 | 可错峰,不修改权益期限 |
每个用户只发一个渠道、每次请求对应一人、没有失败且 Worker 能供给足够吞吐时,1000 次请求按 20 次/秒提交,理论下界约 50 秒。若平均单次请求耗时为 1 秒、并发只有 10,则吞吐至多约 10 次/秒,提交至少约 100 秒。
如果同时发邮件和推送,还需要按渠道分别计算任务量与额度。供应商接受请求不代表用户已经收到;送达与展示可能更晚。
一条任务处理一个会员周期,通知渠道再拆成独立结果。第 537 人发送失败时,只重试这一人的失败渠道,不重新处理整批 1000 人。
全局限速必须覆盖所有进程。每个 Worker 都各自限制 20 次/秒,启动 10 个 Worker 后就可能发送 200 次/秒。SES 同时有发送总量和速率配额;FCM 也要求避免集中突发并对限流进行退避。见 SES 配额与 FCM 扩量建议。
到期状态更新与通知分离
扫描器每批锁定一小组到期记录,条件更新其状态,同时保存到期通知任务,提交后再发送:
BEGIN
领取 active 且 expires_at <= 当前时间的一批会员记录
对本次实际转换的记录:
按当前 period_version 更新为 expired
保存到期通知任务,使用稳定业务键
COMMIT
Worker 独立领取通知任务:
重新读取会员与当前版本
判断到期事实、通知价值和用户可用渠道
保存渠道级通知意图,按全局额度发送如果并行扫描,可以采用 PostgreSQL 的 FOR UPDATE SKIP LOCKED 分散领取;相关语义见行锁说明。更新和任务创建必须同事务,避免状态已经过期却没有通知任务。
状态转换失败可以重试,通知失败只影响通知结果。无论通知队列有没有积压,权益请求都按期限判断。
续费后旧提醒怎么办
续费事务增加 period_version,更新到期时间,并刷新派生状态或计划。对于预付费延长,常见的示例规则是从 max(当前时间, 原到期时间) 往后计算;月度套餐是否按自然月计算需要另行定义。
发送前检查“任务版本等于当前版本、仍处于对应提醒窗口”。不符合就跳过。已入队的旧任务不必全部成功物理删除,也不会因此发送“你已到期”的旧文案。
检查与外部发送之间仍有并发窗口:用户可能刚续费,旧请求已经送到供应商。可以通过通知意图、版本检查和发送状态降低误发,但已经提交到外部的消息未必能撤回。通知落地页应再次读取最新权益,避免展示旧结论。
用户不登录、离线或不允许推送
- 站内信可以先保存,用户下次访问时读取;不要把“尚未打开”当成发送失败反复建消息。
- App 推送需要可用设备标识与通知授权;无效设备应停止对应渠道重试。
- 邮件和短信按用户可用联系方式、产品约定与通知偏好选择;免打扰时段可以推迟提醒。
- 没有可用渠道时保存原因,下一次访问仍能展示当前权益;不要为了提高送达率自动尝试所有联系方式。
渠道级逻辑键可以是 membership_id + period_version + stage + channel,设备投递记录再细分。它用于内部去重;外部发送能否幂等,仍需核对供应商能力,见可靠性边界。
自动续费与宽限期
自动续费通常需要处理账单、扣款、重试、取消和宽限期,不应仅根据本地旧期限发送“请手动续费”。用户已开启自动续费时,文案与触发条件都应不同。
以 Stripe 为例,订阅 Webhook 文档区分付款成功、扣款失败及订阅状态变化。应用应核对账单和订阅当前状态,再更新权益期限。
支付 Webhook 还可能重复或乱序,不能按回调到达顺序直接覆盖本地状态,见 Webhook 事件顺序说明。这里的预付费 SQL 不构成完整的自动续费账务实现。
一次续费与旧提醒的记录演变
以会员 M42 为例,原到期时间为 11 月 1 日 00:00,period_version 为 7,提前 1 天提醒已经排队:
| 时刻或操作 | 数据变化 | 处理依据 |
|---|---|---|
| 10 月 31 日 00:00 扫描 | 保存 M42:7:before_1d 意图 | 业务键确定这一周期与阶段 |
| 用户完成续费 | 支付流水只确认一次,期限更新,版本变为 8 | 同一付款不能再次延长 |
| 旧通知开始执行 | 读取到版本 8,旧意图记为 skipped | 参数中的期限不是当前事实 |
| 同一付款回调重发 | 返回原权益变更结果,版本仍为 8 | 不把重复回调当新购买 |
| 用户之后访问会员功能 | 根据新期限与当前撤销状态判断 | 不依赖旧通知是否发送成功 |
如果在续费提交前,旧消息已经交给渠道,版本检查无法撤回它。该窗口需要通过发送记录解释,并让消息落地页展示最新权益。由此可以看出,版本控制保证的是旧动作能够被识别,不是所有外部动作都可以取消。
验证哪些业务结果
| 场景 | 预期结果 |
|---|---|
| 用户一周未登录 | 后台提醒仍会处理,权益按期限失效 |
| 用户保持登录跨过到期时刻 | 下一次受保护操作按期限重新校验 |
| 1000 人午夜同时到期 | 权益及时拒绝,状态分批整理,通知限速分发 |
| 提醒已入队后用户续费 | 旧版本在发送前检查时跳过 |
| 提醒扫描停机两天 | 补扫仍有价值的提醒,过期文案不集中补发 |
| 邮件成功、推送失败 | 只重试失败渠道,不重复邮件 |
| 扫描器重复执行 | 相同周期和提醒阶段只产生一份业务通知意图 |
| 同一付款回调重复到达 | 不重复延长会员,返回原权益变更 |
| 两笔不同付款同时续费 | 按套餐规则串行计算期限,变更流水完整 |
| 退款撤销与到期任务并发 | 按当前权益来源与版本处理,不重新激活已撤销权益 |
监控应同时覆盖权益判定、到期整理和通知三个结果:过期请求是否仍被放行、最老待整理记录年龄、当前阶段提醒的有效提交比例。它们不能由一个“任务成功率”替代。
从阿里云续费通知看提醒阶段与服务状态
阿里云 ECS 的续费提醒说明提供了一个真实产品规则:到期前分阶段通知,还区分到期停机与释放前提醒,并允许配置接收人和渠道。它展示的是对外服务契约,没有披露内部采用扫描还是延迟队列。
另一个可对照的产品是 RocketMQ 包年包月实例:该文档区分到期停服、保留元数据期间可续费恢复,以及保留期后释放。服务不可用、数据仍保留和数据已删除是不同状态;这里的保留期也不能直接解释成仍可使用权益的宽限期。
据此扩展会员实现时,应分别定义权益结束时间、数据保留时间和提醒阶段。1000 人同一天到期,可以按同一阶段批量发现,逐会员创建意图,再按渠道限速;即使通知还在排队,请求校验也按权益期限执行。企业套餐还需区分使用者、付款联系人与管理员,发送前读取有效接收配置。批次、表结构和权限校验属于本文建议,不能据上述产品规则推断阿里云内部实现。
腾讯扣费前通知实践:调度不能等待渠道响应
腾讯 WXG 工程师刘若愚的分布式定时器实践有直接的业务背景:扣费前先通知、等待后再执行扣费,以及优惠券到期通知。文章将任务持久化、到期扫描、事件投递与业务回调分开,并分别限制任务写入和下游通知的频率。这是工程师对当时团队实现的介绍,文中的等待时长和内部组件不应直接当成当前支付产品规则。
它补充了会员批量处理中的一个关键点:扫描器不要逐个等待短信、邮件或扣费接口返回,再推进到下一个时间桶。可先可靠保存每个会员的独立动作,由发送或支付处理器限速执行。否则,同一天到期的 1000 人中,前几个渠道超时就会拖住后面所有人的发现过程。
本文进一步建议把“提前提醒”和“实际扣费”建成不同的业务动作,分别保留期限、授权、账单及结果。提醒调用成功不能证明扣款已被授权;自动续费被取消后,已经排队的扣费动作也要检查当前授权。原文展示的是调度与交付链路,完整的扣款账务仍由支付业务负责。
参考资料
刘若愚,腾讯 WXG,腾讯云开发者社区:快速实现一个分布式定时器
阿里云官方文档:ECS 设置续费提醒
阿里云官方文档:RocketMQ 欠费说明:到期、停服与释放
PostgreSQL 18:SELECT:锁与 SKIP LOCKED
Amazon SES:Managing sending limits
Stripe:Event ordering