作者:柳下,西流

背景

在分布式架构设计中,请求亲和性是实现有状态服务高可用的核心技术,通过将具备相同会话标识的请求智能路由至固定计算节点,保障会话连续性及缓存有效性。然而在Serverless范式下,函数计算服务因其瞬时实例生命周期、自动弹性扩缩容、无状态化运行等特性,在亲和性保障层面面临原生架构冲突。本文将以MCP Server在函数计算平台的深度集成为研究载体,解构基于SSE长连接通信模型,剖析会话亲和、优雅升级等关键技术,揭示Serverless架构在MCP场景中的亲和性创新实践。

概念介绍

在系列文章首篇 MCP Server 实践之旅第 1 站:MCP 协议解析与云上适配 我们深入解析了 MCP 以及 SSE 协议,为了方便本文的阅读, 这里再简单介绍下 MCP 以及 SSE 协议。

MCP:作为开放标准协议,为AI应用构建了通用化上下文交互框架。可以将 MCP 想象成 AI 应用程序的 USB-C 接口。就像 USB-C 为设备连接各种外设和配件提供了标准化方式一样,MCP 为 AI 模型连接不同的数据源和工具提供了标准化方式。

MCP Server&Client:MCP 通过 Server+Client 在 AI 应用程序和数据之间搭起了一座桥梁,MCP Server 负责打通 data、tools,AI 应用程序通过 MCP Client 连接 MCP Server。其中 Client 与 Server 的通信基于 SSE 协议实现,而 MCP 亲和奥秘也恰恰隐藏在 SSE 协议之中,下文将详细介绍。

SSE协议:作为HTTP/1.1扩展协议,SSE(Server-Sent Events)定义了结构化流式传输规范,允许服务器实时向客户端推送事件或数据更新。在接收到订阅请求后,服务器会保持连接开启,并通过HTTP流向客户端推送事件,其中每个流数据格式如下:

event: <event-type>       // 事件类型(endpoint/message/close)
data: <payload>           // 通过数据字段传递实际内容
id: <message-id>          // 用于唯一标识事件
retry: <milliseconds>     // 重连退避策略

函数计算:函数计算是事件驱动的全托管计算服务。使用函数计算,您无需采购与管理服务器等基础设施,只需编写并上传代码或镜像。函数计算为您准备好计算资源,弹性地、可靠地运行任务,并提供日志查询、性能监控和报警等功能。

MCP交互协议解析

在解析MCP亲和性实现原理前,需重点解析其基于SSE(Server-Sent Events)构建的通信框架。该协议通过定义标准化事件类型,实现了客户端-服务端的交互控制及会话保持机制,具体流程如下:

  1. 会话建立阶段:

    • 客户端发起初始SSE连接请求;
    • 服务端通过event:'endpoint'事件响应,在data字段中嵌入唯一会话标识(Session ID);
  2. 请求保持机制

    • 后续所有客户端请求必须携带该Session ID;
    • 服务端通过该标识验证请求来源的合法性;
    • 实现客户端与服务端实例的绑定关联(Session Affinity);
  3. 实例绑定校验:

    • 当messages请求的路由目标实例与SSE连接绑定实例不一致时;
    • 服务端将触发安全校验失败机制,返回4xx Conflict错误代码;

该设计通过事件驱动架构确保了会话状态的连续性,同时通过实例绑定校验机制保障了分布式环境下的请求一致性。需要注意的是,Session ID的有效期与SSE连接生命周期严格绑定,连接中断后需重新进行会话协商,一个简单的交互流程示例如下:

  1. Client端发起一个 GET 请求,建立SSE长连接。(Connection1)
  2. Server端回复event:endpoint类型的事件,将sessionId信息放入data 中返回。(Connection1)
  3. Client端使用第2步返回的sessionId信息发起首个HTTP POST 请求。(Connection2)
  4. Server端迅速响应202,但无内容。(Connection2)
  5. Server端返回第3步请求的实际消息。(Connection1)
  6. Client端使用第2步返回的sessionId发起HTTP POST请求initialized作为确认。(Connection3)
  7. Server端迅速响应202,无内容。(Connection3)
  8. Client端使用第2步返回的sessionId发起HTTP POST请求list tools。(Connection4)
  9. Server端迅速响应202,无内容。(Connection4)
  10. Server端返回第8步请求的实际消息,即工具列表。(Connection1)
  11. Client端使用第2步返回的sessionId发起HTTP POST请求call tool。(Connection5)
  12. Server端迅速响应202,无内容。(Connection5)
  13. Server端返回第11步请求的实际消息,即工具调用结果。(Connection1)

亲和性机制解析

宏观分类与核心价值

系统亲和性主要分为两大维度:

  1. 节点亲和性:面向资源调度场景,确保工作负载优先部署至符合标签规则的节点。
  2. 会话亲和性:面向请求路由场景,保障客户端流量持续定向到特定后端实例。

两类机制均通过属性一致性调度实现核心价值:

  • 提升局部资源复用率(如缓存命中)
  • 保障有状态业务连续性
  • 满足合规性数据路由要求

会话亲和性实现范式

而常见的会话亲和性主要有以下几类:

  1. Cookie植入模式:首请求时LB、网关类服务注入含后端标识的Set-Cookie头,后续请求基于Cookie值进行会话绑定。适用HTTP无状态协议场景,且客户端缺乏显式标识信息。
  2. 源IP哈希模式:基于ClientIP哈希值映射到后端特定节点,满足TCP/UDP四层流量及需要客户端级会话保持的场景。
  3. Header字段路由模式:预定义Header字段值提取(如X-Session-ID),并哈希计算生成目标映射,支持多客户端标识共存场景,满足细粒度路由策略。

MCP SSE亲和性架构特性

MCP SSE采用双阶段协商机制:

  1. 会话建立阶段:MCP Server生成全局唯一SessionID并同步至客户端
  2. 请求路由阶段:网关通过专有协议实时获取Session-Node映射关系

该模式需网关层与MCP Server间实现会话信息同步。相较于传统会话亲和方案,在获得精确路由控制能力的同时,需权衡协议交互带来的系统复杂度提升。

MCP ON FC 亲和调度设计

函数计算支持一键托管MCP Server,并通过深度适配MCP SSE协议,提供了一种即开即用的Serverless亲和调度能力,帮助您实现MCP服务的Serverless托管能力,下面将详细介绍函数计算的亲和策略机制。

亲和策略

函数计算作为集调度、计算托管、免运维等特性于一身的Serverless服务,可将函数计算核心组件抽象为三部分:

  1. Gateway:网关层,用户流量入口,负责接收用户请求、鉴权、流控等功能。
  2. Scheduler:调度引擎层,负责将用户的请求调度到合适的节点和实例。
  3. VMS:资源层,函数执行环境 。

当客户通过函数计算托管 MCP 服务并通过 MCP Client 发起请求时,可将用户请求分为两类:SSE管控链路和Message数据链路。
SSE管控链路(会话初始化)

  1. Client 发起首个 SSE 请求路由到一台函数计算网关节点Gateway1,网关节点权限校验通过后转发至调度模块Scheduler。
  2. 调度模块根据特定标识识别出请求类型为 SSE时,将调度到一台可用实例。
  3. 当请求和实例绑定时,实例将启动用户代码。
  4. 用户代码启动完成后,会通过event:endpoint事件将sessionId放入data中,返回第一个数据包。
  5. 在 response 返回经过 Gateway 网关层时,网关层将拦截 SSE 请求的首个回包,解析SessionID信息,并将 SessionID 和实例的映射关系持久化到DB。

Message数据链路(请求处理)

  1. Client 完成SSE请求后,将发起多个Message请求,由于函数计算网关节点无状态,Message请求将打散到多个网关节点。
  2. 当Gateway收到Message请求,将检查网关节点cache中是否存在Message请求携带的SessionID亲和信息,如果cache中无记录,将回源到DB获取相关数据。
  3. Gateway通过cache或DB拿到SessionID和实例的绑定关系时,将携带相关信息转发至调度模块。
  4. 调度模块根据特定标识识别出请求类型为Message时,解析携带的实例信息,将请求定向调度到特定实例。
  5. 当请求和实例绑定时,MCP Server 校验请求通过,将返回202通知Client请求接收成功,实际数据将通过 SSE请求建立的连接返回。

函数计算通过无状态网关层与智能调度层的协同设计,在Serverless架构下创新实现了MCP SSE会话亲和性保障。SSE管控链路借助首包拦截实现SessionID与实例的动态绑定,Message数据链路则通过多级缓存与持久化存储确保请求精准路由。该架构既保留了函数计算的弹性优势,又攻克了无状态服务处理有状态请求的难题,为MCP场景提供了高可靠、低延迟的Serverless化解决方案,同时通过冷启动优化与智能扩缩容机制,实现资源效率与性能的最佳平衡。

MCP场景会话配额控制体系

配额冲突建模分析

在MCP会话资源需求模型中,单个Session生命周期内存在两类并发需求:

而这种并发需求在传统配额分配中存在严重缺陷,需要引入一种动态预留的配额分配策略:

动态配额分配策略

为避免上述问题,函数计算引入Session Quota策略,即结合函数实例的并发度配置,限制每个实例最多绑定Round(函数单实例多并发配置 / 10) 个Session。如下流程所示:

  1. 当函数配置了20并发时,可服务 20/10=2 个 Session 请求。
  2. Client1发起SSE请求时,分配了VM1实例,并占用1 Session Quota。
  3. Client2发起SSE请求时,Scheduler计算VM1 仍有1 Session Quota,成功和VM1再次完成绑定。
  4. Client3发起SSE请求时,Scheduler计算VM1 2个并发 Session Quota已被2个SSE请求占用,无法再次绑定,则调度到新实例VM2,完成实例绑定。

MCP会话场景灰度优雅升级方案

函数计算支持UpdateFunction操作更新函数配置,在用户更新函数后,新的请求将路由到新配置拉起的实例,旧实例不再接收新请求,在处理完存量请求后后台自动销毁。如下图在UpdateFunction前,请求1-n路由到 VM1,UpdateFunction后新请求路由到 VM2,VM1 在处理完存量请求后自动销毁。

在 MCP 场景下,数据请求从请求级无状态变为会话级绑定,在UpdateFunction后,如果存量Session关联的请求路由到新实例,则新增无法识别到 SessionID 信息,返回错误。为解决这类问题,函数计算优雅更新能力从升级至有状态Session级别,在用户更新函数后,存量Session关联的请求仍路由到旧实例,新建Session请求路由至新实例,优雅实现MCP亲和场景下的升级需求。

压测

压测准备

我们以一个简单的 MCP Server 服务托管到函数计算作为压测对象, 函数代码如下:

import random
import asyncio
from mcp.server.fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.routing import Mount

mcp = FastMCP("My App")

@mcp.tool()
async def add(a: int, b: int) -> int:
    """计算两个整数的和(含150-1000ms随机延迟)"""
    delay = random.uniform(0.15, 1.0)
    await asyncio.sleep(delay) 

    print(f"add工具被调用,延迟 {delay:.3f}s")
    return a + b

app = Starlette(
    routes=[
        Mount("/", app=mcp.sse_app()),
    ]
)

压测脚本 load_test.py 如下:

# 并发 100 个 mcp client
# 每次 client 建立一条 SSE 连接, 执行 call tool
# 并且校验 call tool 的结果是符合预期的

import asyncio
from mcp.client.sse import sse_client
from mcp import ClientSession
import logging
import time
import random
import traceback

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("LoadTestClient")

class ErrorCounter:
    def __init__(self):
        self.count = 0
        self._lock = asyncio.Lock()

    async def increment(self):
        async with self._lock:
            self.count += 1

err_counter = ErrorCounter()

async def robust_client_instance(instance_id: int):
    try:
        async with sse_client(
            "https://xxx-yyy-zzz.cn-hangzhou.fcapp.run/sse",
            headers={
                "Authorization": "Bearer YOUR_API_KEY",
                "X-Instance-ID": str(instance_id),
            },
            timeout=10
        ) as streams:
            if streams is None:
                raise ConnectionError("SSE连接失败")

            async with ClientSession(
                read_stream=streams[0],
                write_stream=streams[1],
            ) as session:
                # 初始化及调用逻辑
                start = time.time()
                await asyncio.wait_for(session.initialize(), timeout=10.0)
                logger.info(f"实例{instance_id}; initialize 耗时: {time.time() - start}")

                try:
                    # 设置call_tool超时
                    start = time.time()
                    random_number = random.randrange(1, 51)
                    result = await asyncio.wait_for(
                        session.call_tool(
                            "add", {"a": instance_id, "b": random_number}
                        ),
                        timeout=10.0,
                    )
                    logger.info(
                        f"实例{instance_id};  call_tool 耗时: {time.time() - start}"
                    )

                    if result.isError:
                        logger.error(
                            f"实例{instance_id}, call_tool 调用失败: {result.content}"
                        )
                        raise Exception(
                            f"实例{instance_id}, call_tool 调用失败: {result.content}"
                        )
                    else:
                        logger.info(
                            f"实例{instance_id}, call_tool 调用成功: {result.content}"
                        )
                        assert (
                            int(result.content[0].text)
                            == instance_id + random_number
                        )
                except asyncio.TimeoutError:
                    logger.error(f"实例{instance_id} 操作超时")


    except asyncio.TimeoutError:
        await err_counter.increment()
        logger.error(f"实例{instance_id} 操作超时")
    except Exception as e:
        await err_counter.increment()
        logger.error(f"实例{instance_id} 失败: {str(e)}")
        logger.debug(traceback.format_exc())

async def main():
    BATCH_SIZE = 100
    tasks = [robust_client_instance(j) for j in range(0, BATCH_SIZE)]
    await asyncio.gather(*tasks, return_exceptions=True)

    print(f"总错误数: {err_counter.count}")

if __name__ == "__main__":
    asyncio.run(main())

压测结果:

执行压测命令, 并行启动 3 个压测进程, 每次压测进程并发启动 100 mcp client, 每次 client 建立一条 SSE 连接, 执行 call tool, 并且校验 call tool 的结果是符合预期的

python load_test.py & python load_test.py & python load_test.py & wait

3 个压测进程, 每个进程中的 100 个并发的 mcp client 全部成功执行

表示 SSE 长连接和后续配合该会话的 HTTP 请求(call tool)在同一个函数实例,实现亲和行为

MCP Server 函数实例从 0 毫秒级扩容到 15 个实例,实现有状态实例水平横向扩展

根据官方文档 MCP SSE亲和性调度 描述,该函数设置的单实例并发度为 200,那么单个实例支持的 session 数目(即 SSE 长连接个数为 20个), 300/20 = 15 实例

测试更新函数, MCP 会话灰度优雅升级

我们尝试在持续压测过程中,中途更新函数, 所有的 mcp client 的行为都符合预期无报错。

我们使用如下脚本模拟持续压测:
...
# 压测(分50批)
BATCH_SIZE = 100
for i in range(0, 5000, BATCH_SIZE):
    tasks = [robust_client_instance(j) for j in range(0, BATCH_SIZE)]
    await asyncio.gather(*tasks, return_exceptions=True)
    await asyncio.sleep(1)  # 批次间间隔

总结

通过系列技术方案的精妙设计,函数计算为MCP场景构筑了Serverless化的完整技术基座。在Serverless服务范式与有状态业务需求之间架起智能桥梁,通过会话亲和调度引擎、优雅会话升级、动态配额熔断等机制,让Serverless的极致弹性与有状态服务的强一致性实现了深度融合。

更多内容关注 Serverless 微信公众号(ID:serverlessdevs),汇集 Serverless 技术最全内容,定期举办 Serverless 活动、直播,用户最佳实践。


Serverless
69 声望265 粉丝