SaaS 到期账户自动处理,Cron 应该怎样避免重复执行
图片说明:示意图 图片来源:Public Domain(公有领域)
在SaaS系统中,订阅到期、试用期结束、欠费停用、套餐降级等操作经常由Cron任务定期扫描数据库完成。真正棘手的地方并不是“Cron会不会执行两次”,而是同一个账户在多个执行实例、任务超时、进程崩溃或者重试的情况下,业务操作能不能安全地执行两次。
因此,一个可靠的到期账户处理系统通常不能只依赖一把分布式锁。更合理的设计是把调度、防并发、任务认领、业务幂等和故障恢复分开处理。Cron负责定时触发,任务系统负责发现需要处理的数据,数据库负责保证状态变化的一致性,而具体业务操作则必须具备幂等能力。
最常见的问题是多个实例同时运行。例如一台服务器上的Cron每小时执行一次,但SaaS已经扩展到多个应用节点。如果每个节点都运行同一个Cron,那么到了整点可能有多个进程同时扫描“已经到期但尚未处理”的账户。即使只有一个节点,也可能出现上一次任务运行时间过长,下一次调度又已经开始执行的情况。
因此第一层措施可以使用分布式锁,让同一个全局任务尽量不要被多个实例同时启动。Redis可以实现这种协调,但不应该简单使用“SETNX,然后另外设置过期时间”的老式组合方式。Redis官方文档已经将SETNX标记为deprecated,更推荐使用带NX和EX/PX条件的一次性SET操作。
例如可以使用类似这样的锁模型:
SET saas:expire-job-lock NX EX 300
这里的关键不是300秒这个具体数字,而是锁必须具有租约时间,并且锁的值应该是本次执行实例独有的随机Token。如果返回成功,当前实例取得任务执行权;如果返回失败,则说明其他实例已经持有锁,本次Cron可以直接退出。
但是,拿到锁并不意味着任务永远不会重复执行。假设任务运行了十分钟,而锁只持续五分钟,那么锁自动过期以后,第二个实例可能取得新锁。此时第一个实例实际上仍然可能在运行。如果第一个实例继续修改数据库,就出现了两个任务同时处理业务的情况。
因此,锁的TTL不能简单写成“任务平均执行时间增加30%”。真正应该考虑的是任务正常执行时间、异常情况下的最长执行时间、是否支持续租,以及任务本身能不能安全重复执行。对于执行时间可能变化很大的任务,可以采用锁续租机制,但续租本身也需要判断当前Token仍然属于自己。
锁释放同样容易出错。任务结束以后不能无条件执行:
DEL saas:expire-job-lock
原因很简单:第一个任务的锁可能已经过期,第二个任务随后取得了同一个锁。此时第一个任务结束并执行DEL,就可能把第二个任务刚刚取得的锁删除。
因此,锁的值必须保存唯一Token,释放时应该通过原子操作确认Redis中的值仍然是自己的Token,然后再删除。Redis官方给出的单实例锁模式同样采用唯一随机值,并要求只有持有对应随机值的客户端才能安全释放锁。
不过,对于SaaS到期账户处理来说,分布式锁仍然不是最核心的防重手段。真正应该依赖的是业务幂等。
例如账户到期后可能需要执行以下操作:修改账户状态、停止服务、生成账单、发送通知、记录审计日志、撤销某些权限。如果任务因为超时而重新执行,不能让这些副作用被重复执行。
假设任务第一次已经完成“发送到期通知”,但在写任务完成记录之前服务器崩溃。服务器恢复以后,第二次任务可能认为这个账户还没有处理,于是再次发送通知。如果系统只有Redis锁,仍然无法彻底解决这个问题,因为第一次任务的锁可能已经失效。
更加可靠的方式是为业务操作建立幂等键。例如针对一次订阅到期事件,可以生成唯一的事件ID:
subscription_id + expiration_event_id
数据库中建立唯一约束,确保同一个业务事件只能被成功处理一次。处理成功以后,即使任务再次发现同一个事件,也只能得到“已经处理”的结果,而不能重新产生副作用。
这里需要特别区分“状态唯一性”和“业务事件唯一性”。简单建立:
account_id + status
并不能自动解决幂等问题。因为账户状态可能从active变成expired,再触发通知、账单或者权限变更等其他操作。真正需要防止重复的往往不是“状态”,而是某一次具体业务动作。
数据库状态机也是非常重要的一层保护。例如可以把账户处理过程设计成:
active
↓
expired_pending
↓
expired_processing
↓
expired_completed
任务领取账户时,通过条件更新或者事务把状态从expired_pending变成expired_processing。只有成功完成状态转换的实例才能继续处理。
如果使用MySQL/InnoDB,可以把任务领取设计成数据库事务的一部分。对于队列表或待处理记录,可以使用SELECT ... FOR UPDATE锁定需要处理的记录,然后在事务中完成状态转换。MySQL官方文档指出,使用FOR UPDATE时,被查询并锁定的行会一直保持写锁直到当前事务结束。
对于多个Worker并发领取任务的场景,还可以根据具体数据库版本和业务需求考虑SKIP LOCKED。MySQL文档明确指出,这种方式可以让其他会话跳过已经被锁定的记录,因此适合队列式任务处理,以减少多个Worker之间因为争抢同一批记录产生的锁等待。
这样一来,系统就不一定需要让Cron自己完成全部工作。更合理的架构是Cron负责定期发现任务,例如:
Cron
↓
查找已经到期且尚未认领的账户
↓
事务领取任务
↓
Worker处理业务
↓
更新处理状态
↓
记录成功或失败结果
对于账户数量较少的SaaS,Cron直接处理也可以,但即使如此,也应该采用批量领取和幂等处理,而不是一次查询所有到期账户然后逐条执行。
大量账户到期时尤其需要避免这种查询:
SELECT *
FROM accounts
WHERE expires_at <= NOW()
AND status = 'active';
然后把整个结果集一次性加载到内存中处理。
更合理的方式是使用索引支持到期时间和状态查询,并采用固定批次处理。例如每次领取几百条或几千条记录,具体数量根据数据库负载、单条业务耗时和事务大小进行压测确定。
任务表还应该记录任务的生命周期。例如:
id
business_key
status
attempt_count
claimed_at
started_at
finished_at
last_error
created_at
updated_at
其中business_key应该能够唯一标识需要防重的业务事件。attempt_count用于记录重试次数,last_error用于诊断失败原因,而claimed_at可以帮助系统发现长期停留在processing状态的任务。
这里也不能简单把“processing太久”理解为任务已经失效然后立即重新执行。因为旧Worker可能实际上仍然在运行。如果新Worker重新领取任务,两个Worker可能同时修改业务数据。因此恢复机制必须和幂等设计结合,而不能仅仅依赖一个超时时间。
失败重试同样需要区分错误类型。数据库暂时连接失败、网络超时、第三方API临时不可用等错误,通常可以采用指数退避重试;参数错误、账户数据损坏或者明确的业务规则拒绝,则可能应该进入人工处理队列,而不是无限重试。
对于发送邮件、调用支付接口或者调用第三方API等外部副作用,事务尤其需要谨慎。数据库事务回滚只能回滚数据库中的操作,并不能把已经发送出去的邮件或者已经完成的第三方API调用一起撤销。
因此,如果到期账户处理涉及多个外部系统,可以考虑Outbox Pattern。数据库事务先可靠地记录需要发送的业务事件,然后由独立Worker负责投递。即使Worker崩溃,也可以根据Outbox记录继续重试,而不是依靠一次Cron执行完成整个链路。
监控方面,也不要只统计“Cron成功运行了多少次”。真正有价值的指标包括待处理账户数量、任务领取速度、处理成功率、重试次数、处理延迟、processing状态持续时间、数据库锁等待时间以及第三方调用失败率。
例如某天有十万个账户同时到期,Cron本身成功启动并不意味着系统运行正常。如果任务队列持续积压几个小时,用户仍然无法正常使用服务,那么从业务角度看任务实际上已经失败。
对于非常关键的SaaS系统,还应该记录任务执行实例,而不是仅仅记录一个简单的“Cron成功/失败”。执行实例可以包含启动时间、实例ID、Worker节点、批次范围、处理数量、成功数量、失败数量和最后错误信息。这样出现异常时,可以准确判断究竟是没有发现任务、没有成功领取任务,还是领取以后业务处理失败。
整个系统可以形成几层防线。
第一层是调度层,使用Cron、调度器或者任务队列确保任务能够被触发。
第二层是并发控制层,使用Redis锁、数据库锁或者队列消费者机制减少同一任务同时运行的情况。
第三层是任务认领层,通过数据库状态转换确保一个业务任务在同一时间只有一个Worker负责处理。
第四层是业务幂等层,即使任务因为崩溃、超时或者重试而再次执行,也不会重复产生业务副作用。
第五层是恢复层,对长时间没有完成的任务进行检测,根据业务状态安全地重新认领,而不是简单删除任务记录。
对于SaaS到期账户这种场景,最值得坚持的一条原则是:不要把“防重复”完全寄托在Cron锁上。
Cron锁解决的是“谁来执行”;数据库任务状态解决的是“谁认领了”;幂等键解决的是“同一业务能不能重复产生副作用”;事务解决的是数据库内部的一致性;重试和恢复机制解决的是执行过程中发生故障以后怎么办。
如果系统规模较小,一个Cron加数据库条件更新就可能已经足够。如果系统运行在多个节点、每天需要处理几十万甚至更多订阅事件,则可以进一步演进到“Cron负责发现任务、数据库负责可靠认领、Worker负责处理、Outbox负责外部事件、监控系统负责发现异常”的架构。
真正可靠的SaaS任务系统,并不是保证Cron永远只执行一次,而是假设任务有一天一定会重复执行、一定可能中途崩溃、一定可能超时,然后让整个业务系统即使面对这些情况,也不会因为一次重复执行就产生错误账单、重复通知、重复扣款或者错误的账户状态。
这才是Cron防重设计真正应该解决的问题。
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP