解构 Serverless:从成本优化到架构重塑——非核心交易业务的实践与反思

本文旨在为中高级工程师与技术负责人深度剖析 Serverless 架构。我们将跳出“概念普及”的范式,从一个典型的金融交易系统场景切入,探讨如何利用 Serverless 思想改造那些流量呈现极端潮汐效应的非核心业务。全文将从操作系统进程模型、分布式系统原理等第一性原理出发,直面 Serverless 在生产环境中最大的痛点——冷启动、状态管理与可观测性,并给出经过实战检验的架构设计、代码实现与演进路径,帮助你构建真正具备成本效益与技术韧性的无服务器应用。

现象与问题背景

在一个典型的高频交易或证券系统中,架构通常被清晰地划分为“核心”与“非核心”两部分。核心交易链路,如行情网关、撮合引擎、订单管理系统,为了追求极致的低延迟和高确定性,往往采用 C++/Rust/Java 等语言,部署在物理机或高性能虚拟机上,甚至进行 CPU 绑核、内存锁定等深度优化。这些系统是状态密集型的,7×24 小时运行,资源利用率被推到极限。

然而,围绕着这条核心链路,存在大量“非核心”但同样重要的业务。例如:

  • 盘后清结算与报表生成: 每天休市后的 1-2 小时内,需要处理当日全量交易数据,进行资金清算、生成用户盈亏报表、合规报告等。这是一个计算密集型任务,但一天只爆发一次。
  • 市场活动与营销推送: 在特定时间点(如新股发行、市场异动)向海量用户推送通知或营销材料,瞬间产生巨大的网络 I/O 流量。

    风险模型回测: 金融分析师需要不定期地使用历史数据对新的风控模型进行回测验证。这会消耗大量 CPU 和内存,但使用频率极低且无法预测。

    用户行为分析: 收集用户操作日志,进行离线或近实时的分析,为产品迭代提供数据支持。

这些业务的共同特征是流量的极端不均衡性。它们可能在 95% 的时间里是完全空闲的,但在某个特定时刻,其资源需求会瞬间飙升到非常高的水平。如果为这些业务长期保有足以应对峰值流量的服务器资源(无论是虚拟机还是 Kubernetes Pod),将导致惊人的成本浪费。运维团队被迫在“为峰值预留资源”和“手动定时扩缩容”之间做出痛苦抉择,前者昂贵,后者僵化且容易出错。这正是 Serverless 架构最理想的应用场景。

关键原理拆解

在我们深入架构之前,作为一名架构师,必须回归计算机科学的基础,理解 Serverless(特别是 FaaS – Function as a Service)在底层是如何工作的。这有助于我们理解其优势和固有的技术瓶颈。

(教授声音)从进程到函数:计算单元的抽象演进

计算机的执行单位在不断演进。早期操作系统以进程(Process)为单位,每个进程拥有独立的虚拟地址空间、文件描述符和上下文,由内核调度。创建进程的开销很大(fork/exec 系统调用)。为了解决进程内并发,引入了线程(Thread),它们共享进程的地址空间,切换开销远小于进程。容器技术(如 Docker)通过 Cgroups 和 Namespaces 提供了更轻量级的资源隔离,但本质上仍是管理一组进程。Serverless/FaaS 将这个抽象层次推向了极致:函数(Function)成为了部署和调度的基本单位。开发者只关心函数的输入和输出,底层的服务器、操作系统、运行时环境、扩缩容全部由云平台托管。这种高度抽象的代价是控制权的丧失,以及一个著名的问题——冷启动。

(教授声音)解剖“冷启动”:一次跨越用户态与内核态的旅程

当一个函数首次被调用或长时间未被调用后再次触发时,平台需要为其准备一个执行环境,这个过程被称为冷启动(Cold Start)。它并非简单的“启动一个程序”,而是一系列复杂的、跨越系统边界的操作:

  1. 资源调度: 云平台的控制平面收到请求,需要在庞大的物理服务器集群中找到一个合适的 Worker 节点来运行该函数。
  2. 代码拉取: Worker 节点从存储服务(如 S3)下载函数的代码包(zip 或容器镜像)。
  3. 环境初始化:
    • 沙箱创建: 为了安全隔离,平台需要创建一个轻量级的虚拟机(如 Firecracker)或容器沙箱。这涉及到内核层面的 Cgroups 和 Namespaces 配置。
    • 运行时启动: 在沙箱内,启动指定的语言运行时(如 JVM, Node.js, Python 解释器)。对于 JVM 这种 JIT(Just-In-Time)编译的语言,启动本身就是一项重操作。
    • 代码加载与初始化: 运行时加载用户的函数代码,并执行全局范围的初始化逻辑(如建立数据库连接、加载配置等)。
  4. 函数执行: 直到以上所有步骤完成,用户的函数代码才真正开始处理请求。

整个过程耗时可能从几十毫秒到数秒不等。这个延迟对于低频的后台任务无伤大雅,但对于需要实时响应的 API 来说,可能是致命的。后续的调用,如果命中已经准备好的“热”环境(Warm Start),则会跳过大部分步骤,延迟极低。理解冷启动的本质,是优化 Serverless 性能的关键。

(教授声音)无状态原则与分布式系统的一致性

FaaS 平台假设函数是无状态的(Stateless)。平台可以随时创建或销毁函数的任何一个实例,而不保证两次连续的请求会由同一个实例处理。这个设计极大地简化了平台的调度和水平扩展能力。但它也强制我们将所有状态(State)外部化到持久化存储中,如数据库(RDS, DynamoDB)、缓存(Redis, Memcached)或对象存储(S3)。这意味着每次函数调用都可能伴随着一次或多次网络 I/O 来获取或更新状态。这在分布式系统中引入了经典的一致性问题。例如,当一个函数依赖于外部数据库时,其行为就受制于该数据库的一致性模型(强一致性或最终一致性),这也是架构设计中必须考虑的权衡。

系统架构总览

让我们以为上述“盘后清结算与报表生成”场景设计一个 Serverless 架构为例。系统目标是在每日下午 3 点收盘后,自动触发一个工作流,拉取当日所有用户的交易数据,分片并行计算每个用户的盈亏,最终生成汇总报表并存入 S3。

逻辑架构图景(文字描述):

  • 触发器 (Trigger): 一个基于时间的事件源(如 AWS EventBridge Scheduler 或一个简单的 Cron Job),在每日 15:05 精准触发整个流程。
  • 工作流编排器 (Orchestrator): 核心组件,我们使用状态机服务(如 AWS Step Functions)。它负责定义和控制整个清算流程的步骤、分支、并行和错误处理,而不是将所有逻辑写在一个巨大的“上帝函数”里。
  • 函数层 (Function Layer): 一系列各司其职的 Lambda 函数:
    • start-settlement-job: 接收触发事件,初始化一个清算任务,并将任务元数据(如任务ID、状态)写入 DynamoDB 表。
    • fetch-account-list: 从主数据库(只读副本)拉取当日有交易活动的用户ID列表。
    • dispatch-calculation-tasks: 将庞大的用户列表分片(例如,每 1000 个用户一片),然后为每个分片并行地调用计算函数。这里利用了 Step Functions 的 Map State 功能。
    • calculate-pnl-for-batch: 核心计算函数,接收一个用户ID批次,从交易历史库(或数据湖)获取原始交易记录,进行复杂的盈亏计算,并将结果写入一个临时的 S3 存储桶。
    • aggregate-results: 当所有并行计算任务完成后,此函数被触发,它会读取 S3 中的所有临时结果文件,进行汇总、聚合,生成最终的 CSV 或 PDF 报表。
    • finalize-job: 将最终报表存入永久的 S3 桶,并更新 DynamoDB 中的任务状态为“完成”或“失败”。
  • 数据存储层 (Data Layer):
    • Amazon S3: 用于存储临时计算结果和最终的报表文件。
    • Amazon DynamoDB: 用于存储工作流的状态和元数据,提供快速的键值查询。
    • RDS Read Replica: 访问核心交易数据库的只读副本,避免对主库造成压力。
  • 可观测性 (Observability): AWS CloudWatch Logs, Metrics 和 X-Ray 用于日志收集、性能监控和分布式链路追踪。

这个架构的核心思想是“编排优于代码”,通过 Step Functions 将复杂的业务流程显式化、可视化,每个函数只做一件小事,从而获得了极高的可维护性和弹性。

核心模块设计与实现

现在,让我们切换到极客工程师的视角,深入几个关键模块的实现细节和坑点。

模块一:工作流编排器 (Step Functions)

不要试图用代码去控制流程。用声明式的状态机定义语言(Amazon States Language)来描述你的业务逻辑。这不仅仅是“好看”,它内置了重试、错误捕获和并行处理等强大的容错机制。



{
  "Comment": "A state machine for daily P&L settlement.",
  "StartAt": "StartSettlementJob",
  "States": {
    "StartSettlementJob": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:start-settlement-job",
      "Next": "FetchAccountList"
    },
    "FetchAccountList": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:us-east-1:123456789012:function:fetch-account-list",
      "Next": "DispatchCalculationTasks"
    },
    "DispatchCalculationTasks": {
      "Type": "Map",
      "InputPath": "$.accounts",
      "ItemsPath": "$.batches",
      "MaxConcurrency": 50,
      "Iterator": {
        "StartAt": "CalculatePNLForBatch",
        "States": {
          "CalculatePNLForBatch": {
            "Type": "Task",
            "Resource": "arn:aws:lambda:us-east-1:123456789012:function:calculate-pnl-for-batch",
            "End": true
          }
        }
      },
      "ResultPath": "$.calculationResults",
      "Next": "AggregateResults"
    },
    ... (Aggregate and Finalize states) ...
  }
}

极客坑点: MaxConcurrency 是个双刃剑。设置太高,会瞬间启动成千上万个 Lambda 实例,可能瞬间打垮你的下游数据库。必须根据数据库连接池上限、API 速率限制等因素谨慎设置。在这里,我们限制并发为 50,以保护 RDS 只读副本。

模块二:核心计算函数与幂等性

消息队列和事件驱动系统通常提供“至少一次”(At-Least-Once)的投递保证,这意味着你的函数可能会被重复调用。如果你的计算逻辑不是幂等的(Idempotent),比如重复计算会导致数据错误,那就灾难了。必须在代码层面确保幂等性。



import boto3
import os

dynamodb = boto3.resource('dynamodb')
IDEMPOTENCY_TABLE = os.environ['IDEMPOTENCY_TABLE']

def make_idempotent(func):
    """
    A decorator to make a Lambda handler idempotent.
    It checks for a unique invocation ID in a DynamoDB table.
    """
    def wrapper(event, context):
        # Assuming event has a unique 'invocation_id'
        invocation_id = event.get('invocation_id')
        if not invocation_id:
            # Or generate one based on event content hash
            raise ValueError("Invocation ID is missing")

        table = dynamodb.Table(IDEMPOTENCY_TABLE)
        
        try:
            # Conditional write: only succeed if the item doesn't exist
            table.put_item(
                Item={'id': invocation_id, 'status': 'PROCESSING'},
                ConditionExpression='attribute_not_exists(id)'
            )
        except botocore.exceptions.ClientError as e:
            if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
                print(f"Duplicate invocation detected: {invocation_id}. Skipping.")
                return {"status": "SKIPPED", "reason": "Duplicate invocation"}
            raise

        result = func(event, context)
        
        # Update status after successful execution
        table.update_item(
            Key={'id': invocation_id},
            UpdateExpression='SET #s = :status',
            ExpressionAttributeNames={'#s': 'status'},
            ExpressionAttributeValues={':status': 'COMPLETED'}
        )
        return result

    return wrapper

@make_idempotent
def calculate_pnl_for_batch_handler(event, context):
    # event['invocation_id'] = 'batch-job-123-part-4'
    # ... your actual P&L calculation logic here ...
    print("Processing batch...")
    return {"status": "SUCCESS"}

极客坑点: 这个实现依赖 DynamoDB 的条件写入(Conditional Write),这是一个原子操作,能有效防止竞态条件。不要试图先读后写,那在并发场景下是不可靠的。选择一个合适的 `invocation_id` 至关重要,它应该能唯一标识一次“逻辑操作”,而不是一次“物理执行”。

性能优化与高可用设计

Serverless 并非银弹,它的性能和可用性有其独特的挑战。我们需要像外科医生一样精准地处理它们。

对抗层(Trade-off 分析):冷启动的缓解策略

  • 预置并发 (Provisioned Concurrency): 这是最直接的办法。你付费让云平台预先初始化并保持一定数量的“热”环境。权衡: 这是用金钱换时间。你牺牲了 Serverless 的部分成本优势(从“按次付费”变为包含“按时付费”),以换取可预测的低延迟。适用于对延迟极其敏感的同步调用场景。
  • 选择合适的运行时: 对于冷启动,语言选择影响巨大。一般来说,解释型语言(Python, Node.js)的冷启动速度优于 JIT 编译型语言(Java, C#)。而预编译到原生代码的语言(Go, Rust)通常表现最佳。权衡: 团队技术栈与生态系统 vs 极致性能。不要为了几十毫秒的优化而让整个团队去学习一门新语言,除非场景确实需要。
  • 优化代码包体积: 更小的代码包意味着更快的下载和解压时间。严格管理依赖,使用 Webpack/Tree Shaking 等工具移除死代码,将通用的大型库(如 Pandas, NumPy)放入 Lambda Layer 中共享,都能显著减小部署包的大小。

对抗层(Trade-off 分析):数据库连接池地狱

传统应用中,我们通过一个有状态的连接池来管理数据库连接。但在 Serverless 中,成百上千个短暂的函数实例同时启动,会试图创建同样数量的数据库连接,这足以瞬间击垮任何传统数据库。这个问题被称为“连接风暴”。

  • 解决方案:连接代理 (Connection Proxy)。 使用像 Amazon RDS Proxy 这样的托管服务。你的函数不再直连数据库,而是连接到 Proxy。Proxy 自身维护着一个稳定的、到数据库的连接池。函数实例可以快速地与 Proxy 建立连接(因为 Proxy 被设计为支持大量并发短连接),然后复用 Proxy 到数据库的长连接。
  • 架构原理: 这本质上是在无状态的计算层和有状态的数据层之间增加了一个有状态的中间层,专门用于管理一种宝贵的、有状态的资源——数据库连接。它解耦了函数实例的生命周期与数据库连接的生命周期。
  • 权衡: 引入了新的组件,增加了架构的复杂度和潜在的单点故障风险(虽然 RDS Proxy 本身是高可用的),并且会产生额外的费用。但对于需要访问关系型数据库的 FaaS 应用来说,这几乎是唯一的正确选择。

架构演进与落地路径

对于一个已经拥有成熟技术体系的团队,不可能一蹴而就地全面拥抱 Serverless。一个务实、分阶段的演进路径至关重要。

第一阶段:外围与异步任务。 从风险最低、收益最高的场景入手。选择那些对延迟不敏感、天生就是事件驱动的非核心业务。我们文中的“盘后报表生成”就是绝佳案例。其他例子还包括:处理用户上传的头像图片(生成不同尺寸的缩略图)、发送验证码邮件/短信、异步的日志处理与分析等。这个阶段的目标是让团队熟悉 Serverless 的开发、部署、监控范式,并积累实践经验。

第二阶段:内部工具与数据管道。 将内部系统,如 CI/CD 流水线中的自动化脚本、数据ETL(提取、转换、加载)管道、内部运维告警系统等,迁移到 Serverless 架构。这些系统通常由内部团队使用,即使出现问题,影响范围也相对可控。这可以进一步锻炼团队驾驭更复杂 Serverless 应用的能力。

第三阶段:BFF 与部分核心 API。 当团队对 Serverless 的掌握足够深入后,可以开始尝试将其用于面向用户的在线业务。一个常见的模式是使用 Serverless 实现 BFF(Backend for Frontend)层。BFF 负责聚合来自多个下游微服务的数据,为前端应用提供一个裁剪过的、统一的 API。由于 BFF 主要是 I/O 密集型,非常适合 Serverless 的并发扩展模型。对于延迟要求高的 API,必须配合预置并发等性能优化手段。

最终形态:混合架构。 对于像交易系统这样复杂的系统,最终的理想形态几乎总是混合架构(Hybrid Architecture)。即,保留由 Kubernetes 或虚拟机承载的高性能、状态密集型的核心服务,同时将周边所有合适的业务都用 Serverless 的方式实现。这种架构结合了两者的优点:核心系统保证了极致的性能和稳定性,而外围系统则享受了 Serverless 带来的弹性、成本效益和运维简化。这并非妥协,而是基于深刻理解业务特性和技术边界后做出的最明智的架构选择。

延伸阅读与相关资源

  • 想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
    交易系统整体解决方案
  • 如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
    产品与服务
    中关于交易系统搭建与定制开发的介绍。
  • 需要针对现有架构做评估、重构或从零规划,可以通过
    联系我们
    和架构顾问沟通细节,获取定制化的技术方案建议。
滚动至顶部