现代支付系统从表面上看似乎非常简单:用户只需点击按钮,输入付款信息,资金就会从一个账户转移到另一个账户。

但当支付行为不是偶尔发生一次,而是需要定期重复进行时,后端系统的复杂性就会大大增加。订阅服务、会员制度、SaaS付费模式以及捐赠平台,全都依赖于那些会自动定期执行的交易流程。

与一次性购买不同,这些系统在用户离开应用程序后仍必须继续正常运行。

今天的支付失败,很可能在下周就会引发客户支持问题;时间计算上的错误可能会导致重复收费;而后端系统中的一些小问题,也很快可能会造成收入损失或让用户感到不满。

许多团队发现,处理定期付款的系统所涉及的工作内容,远不止每月调用一次支付接口那么简单。在实际运行过程中,工程师们需要应对调度问题、重试机制、状态管理、事件处理以及系统可靠性等方面的挑战。

在本文中,我们将探讨团队在构建处理定期付款的系统时经常会遇到的七大后端挑战,以及工程团队通常会采用哪些方法来解决这些问题。同时,我们还会展示一些Python代码示例,帮助大家了解这些系统在实际生产环境中的运行方式。

本文内容涵盖:

挑战1:可靠地管理支付计划

第一个挑战其实出现在支付行为开始之前。

当用户订阅某项服务或加入定期付费模式时,系统必须记录下未来的付款时间。乍一听,这似乎很简单:只需存储一个日期,然后在指定时间触发相应的处理流程即可。

但实际上,情况要复杂得多。用户可能分布在不同的时区,不同月份的天数也可能不同,还有闰年的存在,账单周期也会发生变化,而夏令时的调整更可能引发一些意想不到的问题。

假设某位客户在1月31日订阅了这项服务,那么下个月该怎么做呢?2月份并没有31号这一天。再想象一下,如果有数百万用户,他们的付款计划各不相同,这种情况会变得更加复杂。

一个简单的cron作业往往远远不够用。

大型系统通常会将调度任务与业务逻辑分开处理。

一种常见的做法是将计费调度信息存储在专门的调度服务中,而不是依赖应用程序中的cron作业。当计费日期到来时,调度服务会发布一个“费用到期”事件,然后由后续的处理程序来执行支付操作。

团队还会在每次成功完成支付后记录下下一次的计费日期,而不是动态计算未来的计费时间。这样就可以避免由于夏令时调整、闰年或月末的特殊情况而导致的错误。

使用Quartz、Temporal这类可靠的作业队列,或者基于云技术的调度系统,能够进一步提升系统的可靠性,因为遗漏的执行任务可以自动被重新处理。

让我们来看一个Python示例。

from datetime import datetime

def process_due_payments():
    subscriptions = get_due_subscriptions()

    for sub in subscriptions:
        publish_event(
            "payment_due",
            {
                "subscription_id": sub.id,
                "customer_id": sub.customer_id
            }
        )

        sub.nextbilling_date = calculate_next_billing_date(
            sub.nextbilling_date
        )
        save_subscription(sub)

在这个示例中,调度系统本身并不负责执行支付操作。它的唯一职责就是识别那些应该进行计费的订阅账户,并发布一个payment_due事件。

之后,专门的支付服务会接收这个事件并执行相应的支付操作。这种分离机制能够提高系统的可靠性,因为调度任务和支付处理可以独立扩展,而且如果某个服务出现故障,遗漏的任务也可以从作业队列中重新获取并执行。

挑战2:防止重复收费

重复进行支付操作是导致客户失去信任的最快方式之一。

后端系统可能会因为各种原因重试请求处理:网络故障、支付提供商超时,或者服务中断等等。

假设应用程序发送了一个支付请求,支付提供商也成功接收到了这个请求。

但在提供商返回响应之前,网络连接突然断了。

这笔支付是否已经完成?后端系统并不知道。

有些系统会立即重新尝试发送请求。但如果原来的交易已经成功完成了,用户就可能收到两次付款通知,而不是只收一次。

在分布式系统中,这个问题更为常见,因为多个服务需要通过API和消息队列来进行通信。

大多数支付平台都会使用“幂等性键”来解决这个问题。

一个幂等性键会被附加到支付请求上,作为唯一的标识符。即使这个请求被发送多次,支付提供商也能确定它代表的是同一个操作。

系统并不会创建重复的交易记录,而是会返回最初的结果。后端工程师通常将幂等性视为一项必须遵循的设计原则,而非可选的功能。

import requests

idempotency_key = f"sub_{subscription.id}_{billing_period」

response = requests.post(
    "https://api.payment-provider.com/charge",
    json={
        "customer_id": customer.id,
        "amount": 49.00
    },
    headers={
        "Idempotency-Key": idempotency_key
    }
)

在这里,每次支付尝试都会根据订阅信息及 billing period生成一个唯一的幂等性键。如果在支付请求发送后网络连接出现故障,后端可以使用相同的键安全地重新发起请求。

支付提供商会识别出这种重复请求,并返回最初的结果,而不会再次进行收费,从而有效防止客户被意外双重收费。

挑战3:优雅地处理失败的支付请求

并非所有的支付失败情况都意味着相同的问题。

卡片可能会过期,银行可能会拒绝某些支付请求,临时性的网络问题也可能发生,用户可能会达到消费限额,或者欺诈检测系统会阻止某些交易。

一次支付失败并不一定表示客户想要取消服务,这就给后端处理带来了难度。

系统应该立即重新尝试吗?还是应该等待一天再试?或者发送通知给客户?又或者是直接取消用户的订阅服务呢?

许多团队都会制定所谓的“催款工作流程”来处理这类情况。

这些工作流程会规定在支付失败后应采取哪些措施。有些系统会在24小时后再次尝试收费,而有些则会等待几天后再进行尝试。

Dunning Workflow

典型的催款工作流程会将支付失败的原因分为临时性错误和永久性错误。

对于临时性的错误,比如网络问题或资金不足,系统会在预定的时间间隔后自动重新尝试收费,例如24小时、3天或7天后。

而对于永久性的错误,比如卡片过期,系统则会暂停后续的尝试,并立即要求客户提供最新的支付信息。

许多团队都会持续监测这些重试操作的成功率,并根据历史数据调整重试的时间安排。

def handle_failed_payment(payment):
    if payment.error_type == "temporary":
        schedule_retry.payment.id, hours=24)

    elif payment.error_type == "permanent":
        notify_customer(
            payment.customer_id,
            "请更新您的支付方式。"
        )

这个例子展示了一个简单的催款工作流程。对于临时性的错误,比如资金不足或短暂的网络问题,系统会安排在一段时间后自动重新尝试收费;而对于永久性的错误,比如支付方式过期,系统则会立即通知客户。

通过区别对待各种失败情况,系统能够自动恢复收入,同时避免那些在没有用户干预的情况下无法完成的支付操作被重复尝试。

挑战4:保持系统状态的一致性

支付系统很少以孤立的服务形式存在。一次成功的交易往往会影响到多个系统。

一笔付款可能会更新账单数据库、激活客户访问权限、生成发票、发送通知,还会触发数据分析流程。

当其中一个操作成功,而另一个操作失败时,问题就出现了。 想象这样一种情况:付款成功了,发票生成也完成了,但客户访问权限的更新却失败了。 此时系统就会进入一种状态不一致的状态。用户已经支付了费用,但却仍然无法使用相关服务。 分布式系统使得这个问题更加复杂,因为跨系统的交易并不总是原子的。 开发团队通常会采用事件驱动架构来解决这类问题。 事件驱动架构 当一笔付款成功后,应用程序会将付款结果以及相应的事件记录在同一次数据库事务中。随后会有专门的进程将这些事件发布到下游系统。 这样就能确保客户访问权限管理、发票生成、数据分析以及通知功能最终都能接收到相同的信息来源,从而降低出现状态不一致的风险。
def complete_payment(payment):

    with database.transaction():

        save_payment(payment)

        save_outbox_event({
            "type": "payment_completed",
            "payment_id": payment.id
        })
def publish_outbox_events():
    events = get_unpublished_events()

    for event in events:
        publish_to_queue(event)
        mark_as_published(event.id)
这种模式通常被称为“出箱模式”。付款记录与相应的事件会被存储在同一次数据库事务中,这样它们就会一同成功或一同失败。 即使像发票生成或访问权限管理这样的下游系统暂时无法使用,这些事件也会被保存下来,并会在之后被发布出去。这样一来,就可以避免出现客户已经成功支付却无法使用所购买服务的情况。

挑战5:正确处理Webhook事件

现代支付系统在很大程度上依赖于Webhook技术

支付服务提供商并不希望应用程序不断询问付款是否成功。相反,他们会将相关事件发送到你的后端系统。

例如:

  • 付款已完成。

  • 订阅信息已更新。

  • 卡片已过期。

  • 退款已经发放。

  • 支付操作失败。

在面对现实世界中的各种情况时,Webhook似乎其实很简单易用。

有时候事件会延迟到达,有时会重复发送,甚至有时会以乱序的形式出现。

想象一下,在收到付款确认通知之前,就先收到了“订阅已续费”的通知。如果没有经过精心设计,系统很可能会陷入异常状态。

团队们通常通过事件验证、签名校验以及状态同步机制来解决这类问题。

许多支付处理团队会设置一个Webhook接收层,用于在处理之前立即存储传入的事件。利用事件标识符作为唯一识别码,可以确保重复的Webhook请求被安全地忽略。

系统随后会通过队列异步处理这些事件,这样既能防止支付服务因超时而出现问题,也能让失败的操作得以重试,从而避免数据丢失。

def process_webhook(event):

if event_exists(event["id"]):
return

store_event(event)

queue_event_for_processing(event)

这个示例在采取任何行动之前,会先检查该事件是否已经被处理过。

通过将Webhook事件ID作为唯一标识符,系统可以安全地忽略重复的事件,同时确保合法事件只被处理一次。

挑战6:支持不同的支付模式

并非所有的定期付款方式都遵循相同的规则。

有些订阅服务每月收取固定费用,而有些则根据使用量来计费。

会员制度可能包含年度套餐,捐赠平台则通常允许用户选择灵活的捐款金额。

支持定期捐赠的系统是一个典型的例子。与传统订阅服务不同,用户可以随时调整捐款金额、暂停付款或按照自定义的时间表进行捐赠。这就给计费规则和状态管理带来了额外的复杂性。

随着产品的发展,后端系统往往需要同时支持多种支付模式。

最初的系统设计可能只考虑了一种计费方式,但几个月后,新的需求就会出现。

周付、试用期、按比例升级以及基于使用量的定价方式等等,都会改变系统的架构。

因此,一个原本简单的支付服务,最终可能会发展成一个复杂的计费平台。

许多团队最终会重新设计他们的系统,使其基于抽象的支付模型来运作,而不是依赖硬编码的工作流程。

团队们不会将计费规则直接嵌入到应用程序代码中,而是会将订阅服务、使用计划、试用期以及定期捐赠等功能建模为可配置的计费实体。

计费引擎会根据这些实体以及预定义的规则来生成收费请求。这种设计方式使得在业务方向发生变化时,无需每次都重新编写核心的支付逻辑,从而更便于引入新的定价模式。

class BillingPlan:

    def calculate_amount(self, customer):
        raise NotImplementedError

class FixedPlan(BillingPlan):

    def calculate_amount(self, customer):
        return 20.00

class UsagePlan(BillingPlan):

    def calculate_amount(self, customer):
        return customer.active_users * 5.00
amount = customer.plan.calculate_amount(customer)
charge_customer(customer, amount)

这种设计并没有将所有的计费逻辑硬编码到整个应用程序中,而是将定价规则封装在专门的计费计划类中。支付系统只需请求所选的计划来计算应支付的金额即可。

当出现新的定价模式,比如年度订阅、免费试用或按使用量计费的模式时,开发人员可以添加新的计划类型,而无需修改核心的支付处理流程。

挑战7:实时监控支付系统

支付失败所带来的损失往往会迅速增加。

如果搜索功能出现了故障,用户可能会稍后重新尝试;但如果支付处理失败,收入就会立即流失。

因此,具备实时监控能力就变得至关重要。团队需要能够回答以下问题:

  • 今天有多少笔支付交易失败了?

  • 重试次数是否异常增加?

  • Webhook处理的效率是否下降了?

  • 某些支付方式是否更频繁地出现故障?

监控重复支付的流程不仅仅需要服务器指标,业务相关的数据也同样重要。工程团队通常会关注支付成功率、重试成功率、客户流失率以及这些因素对收入的影响。

仅靠日志数据往往无法全面了解实际情况。现代系统会结合应用监控、事件追踪、仪表盘和警报系统来提供更详细的分析结果。

当支付出现问题时,团队必须在客户开始提交支持请求之前就找出故障原因。

快速的故障发现能力往往能够决定一个小问题是否会发展成一场严重的故障。

def process_payment(payment):

    try:
        charge_customer(payment)

        metrics.increment(
            "payments.success"
        )

    except PaymentError:

        metricsincrement(
            "payments_FAILED"
        )

        raise
if payment_success_rate < 95:
    send_alert(
        "支付成功率低于阈值"
    )

这个例子说明了支付系统如何在交易处理过程中收集各种运营数据。每一笔成功的或失败的支付操作都会更新监控仪表盘,使团队能够实时掌握相关趋势。

如果成功率低于可接受的阈值,自动化警报会立即通知工程师,以便他们及时排查供应商故障、集成问题或基础设施缺陷,从而避免造成严重的收入损失。

最后的思考

从用户的角度来看,重复支付功能似乎非常简单。

客户只需一次性完成订阅操作,之后所有相关流程都应该自动进行。

但实际上,后端系统承担了巨大的工作负担。调度任务、重试机制、防止数据重复、状态管理、Webhook处理以及实时监控等功能都会增加系统的复杂性,而这些在早期的原型设计中往往是看不到的。

团队通常会从简单的实现方案开始着手工作,而当业务规模扩大后,才会发现其中存在的问题。

真正的挑战并不在于成功处理某一笔支付交易,而在于能够在数月甚至数年的时间里,可靠地处理成千上万的支付请求,同时不会给客户带来任何不便。

最有效的支付系统往往就是那些用户根本不会去考虑的系统。

当后端服务运行正常时,一切都会显得“无形无踪”;在基础设施工程领域,“隐形”往往正是人们追求的目标。

希望您喜欢这篇文章。您可以通过在LinkedIn上与我联系

Comments are closed.