MCP实战手记系列(十):MCP Server高可用实战

导读:系列(九)讲完6层安全,MCP Server已能挡人;但双11预热凌晨两点一次真实事故告诉我,能挡人不代表能扛事------单实例被流量打满、下游慢调用拖垮全场。本文基于那次脱敏复盘加本机Docker实测,讲清无状态MCP Server如何做到"挂一个不影响整体":状态全外置才能扩容,Nginx加权轮询加健康检查摘死实例,下游挂了靠Resilience4j熔断兜底、工具分级降级,再kill实例与Redis断网演练验证。做企业MCP生产部署、大促扩容、第三方接口熔断或防Redis与DB单点的团队,这套分层打法可作为同类场景的参考。性能数字只来自那次事故,本机mock仅验证机制不验证量级。

一、那个凌晨两点的告警

双11预热第一天。我们的MCP Server上线两周,一切正常。

凌晨2:17,告警群炸了。

门店查询接口P99延迟从200ms飙到8s。客服群里店长开始吐槽,说查个库存转半天圈。我远程登上去一看,CPU打满,QPS从平时的8涨到120。

问题不在代码。代码没变。问题在于我们只部署了一个实例。

大促期间,总部营销中心推了一条活动到全部门店。店长们一拥而上查库存、查价格、查活动配置。单实例Spring Boot的线程池被打满,后面的请求排队,越排越慢,越慢超时越多。

我做的第一件事是重启。重启后好了3分钟,又打满了。

第二件事是加机器。但加机器不是复制一个jar包那么简单。你得解决三个问题:新实例怎么加入、旧实例挂了怎么自动切、下游依赖挂了怎么办。

这篇就讲这三件事。不是概念,是我凌晨两点踩完坑之后、在我们这个场景里的标准答案。

不过本文有两套证据来源,先划清楚,免得你拿数字去较真:

来源 覆盖哪些数字 怎么复核
生产复盘(当时监控,已脱敏) QPS 8→120、P99 200ms→8s、Nginx 8秒摘除、熔断3秒触发、约5%请求502 无法复现,是那一次事故的个案
本机实测(Docker + mock 数据,这次写作时跑的) 跨实例读写上下文、熔断按样本数触发、哨兵7秒切换、Host头400、连接池对照实验 git checkout v10 即可复现,脚本与原始输出在仓库 10-store-ha/verify/实测记录.md

本机这套是单机容器加mock数据,没有真实流量和真实数据库------机制可以照搬,性能数字别照抄。

二、全文导览

  1. 多实例部署:无状态之后,为什么可以直接加机器

  2. Nginx负载均衡:三种策略怎么选

  3. 健康检查:挂了的实例怎么自动摘

  4. 下游熔断:第三方服务挂了不能拖垮整个MCP

  5. 优雅降级:工具超时了返回什么

  6. 故障演练:手动kill一个实例验证

  7. 你以为vs实际:五个常见误解

  8. 决策树:你的场景需要做到哪一层

完整可运行代码在文末,国内直接访问:https://gitee.com/ethanliang2016/mcp-in-action(tag: v10)

三、多实例部署:无状态是前提

系列(三)我们把MCP Server改成了无状态。这件事的真正价值今天才体现出来。

有状态的MCP Server不能随便加机器。用户A的会话存在实例1的内存里,你把请求打到实例2,它不认识用户A,直接报错。

无状态之后,每个请求都自带身份和上下文。实例1处理完不需要记住你,下一个请求来实例3处理也完全OK。

这意味着什么?意味着你可以水平扩容。流量来了加机器,流量走了减机器。业务代码不用改,但负载均衡配置得同步更新(或接入Consul / K8s这类服务发现),加机器不是零成本的。

但有一个前提你可能忽略了。

无状态不是"把session关掉"就完了。你得确认所有工具调用都不依赖本地状态。我逐个检查了工具里有没有本地状态。配套示例里的6个工具是下面这张表(生产环境当时还有一个调LLM的工具,第7节会提到):

工具 是否依赖本地状态 风险
查门店销售(get_store_sales) 否,查库 + Redis缓存 无
查商品库存(get_inventory) 否,查库 无
查设备状态(get_device_status) 否,调用第三方接口 无
生成补货建议(suggest_restock) 是,本地缓存了一份规则表 有
保存上下文(save_context) 是,写在本实例内存 有
读取上下文(load_context) 是,从本实例内存读 有

两类状态都得挪走。

第一类:规则表。suggest_restock 依赖本地缓存的一份安全线倍数。单实例时没问题,多实例之后实例A和B可能算出不同的补货建议。搬到Redis之后大家读同一个key,同一家门店在任何实例上算出来的都一样。

第二类:会话上下文。这条最容易漏------你已经把session关掉了,但只要工具还往本实例内存里写东西,多实例照样翻车:写在实例A,下一次请求落到实例B,读出来是空。同样搬到Redis。

配套示例里我把这两类都挪走之后,实测验证是这样的:

复制代码
k0 → 上一轮由 app-1 写入,这轮请求落到 app-2,读到了:k0=v0
k5 → 上一轮由 app-2 写入,这轮请求落到 app-3,读到了:k5=v5

写和读落在不同实例上,数据照样读得到。这段输入输出就是"状态真的外置了"最硬的证据,你自己改完也建议这么验一遍。

这个问题不致命,但你不知道它存在,上线后就会变成线上事故。

还有一件连带的:多实例加上客户端重试,同一个请求可能被处理两次。读操作天然幂等,写操作不是。做法是给写操作带幂等键(request_id或业务唯一标识),服务端用Redis或数据库唯一约束去重------配套示例没覆盖这块,生产上得自己补。

四、Nginx负载均衡:三种策略的取舍

多实例跑起来之后,前面加Nginx做分发。

复制代码
upstream mcp_backend {
    server 10.0.0.11:8088 weight=3;
    server 10.0.0.12:8088 weight=2;
    server 10.0.0.13:8088 weight=1;
}

三种策略,我实测后的选择:

轮询(默认)。每个请求按顺序分。适合所有实例配置一样的场景。我们10.0.0.11是8核16G,12和13是4核8G,直接轮询会让小机器先挂。

加权轮询。按机器性能分配权重。我们三台按性能给权重3:2:1(8核机3,两台4核机分别2和1),大机器扛最多流量。这是我们现在用的。

IP哈希。同一个客户端固定打到同一个实例。听起来不错,但我们是无状态MCP,不需要会话粘滞。而且IP哈希有个坑:某个实例挂了,这个IP的所有请求重新分配,可能造成请求抖动。

我的建议:在无状态、无需会话粘滞的前提下,MCP直接用加权轮询,不要用IP哈希。

还有一点反直觉。很多人觉得负载均衡就是"把请求分出去"。但真正的问题是分出去之后,你怎么知道哪个实例还活着。这就是健康检查。

五、健康检查:挂了要自动摘

Spring Boot Actuator暴露健康端点:

复制代码
management:
  endpoints:
    web:
      exposure:
        include: health,info
  endpoint:
    health:
      show-details: always

访问 /actuator/health 返回的是这个形态(配套示例里开了 show-details: always):

复制代码
{"components":{
  "diskSpace":{"status":"UP"},
  "livenessState":{"status":"UP"},
  "ping":{"status":"UP"},
  "readinessState":{"status":"UP"},
  "redis":{"details":{"version":"7.4.11"},"status":"UP"},
  "ssl":{"status":"UP"}},
 "groups":["liveness","readiness"],"status":"UP"}

重点看最后那个 redis。健康检查不只是汇报"我活着",它把关键依赖一起带出来了------Redis一旦连不上,这里会变 DOWN,负载均衡就能据此把这个实例摘掉。所以别让health端点只返回一个 {"status":"UP"},那等于把依赖问题藏起来。

不过详情开出来是有安全代价的:show-details: always 会把组件细节全吐出去------Redis精确版本号、磁盘余量、SSL证书状态、连接池情况。拿到精确版本号能直接对CVE,看出有哪些依赖就能画攻击面。

**所以详情只给内网,别给公网。**三道防线里至少做两道:

  • 默认 show-details: never,对外只返回 {"status":"UP"}
  • 用health group单独开一个内部端点给Nginx和监控用,include只放你真要看的那几个组件
  • 网络层隔离:actuator只监听内网地址,或防火墙只放行Nginx
yaml 复制代码
management:
  endpoint:
    health:
      show-details: never          # 对外精简
      group:
        internal:                  # 内部专用:/actuator/health/internal
          show-details: always
          include:
            - redis
            - db
            - ping

配套示例是本机Docker演示,直接开的 always------你上生产前记得收回去。

Nginx这边要把权重和健康检查写在同一个upstream,再补上代理头------这才是能用的终态配置,别拆成两个片段用:

复制代码
upstream mcp_backend {
    server 10.0.0.11:8088 weight=3 max_fails=2 fail_timeout=10s;
    server 10.0.0.12:8088 weight=2 max_fails=2 fail_timeout=10s;
    server 10.0.0.13:8088 weight=1 max_fails=2 fail_timeout=10s;
}

server {
    listen 80;
    location / {
        proxy_pass http://mcp_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;          # 这条不能省,原因见下
        proxy_set_header X-Real-IP $remote_addr;
    }
}

max_fails=2 是连续2次转发失败就把这个实例摘下来,fail_timeout=10s 是10秒后再试,活了加回来。注意这里的"失败"是真实请求失败,不是Nginx主动ping出来的------下面那条边界说明专门讲这点。

**proxy_set_header Host $host 这句最不起眼,漏了整站400。**Nginx不配这条时,转发给后端的 Host 默认是upstream的名字,也就是 mcp_backend------里面带下划线,Tomcat直接判非法主机名。同一套服务的实测:

复制代码
Host: localhost:8080   → 200
Host: mcp_backend      → 400 Bad Request(Tomcat)

这个坑阴在日志干净:Nginx不报错,后端也不报错,只有客户端拿到400。

当时的记录是这样的:kill掉实例12的Java进程,Nginx在大约8秒后把它从轮询列表移除。期间正在处理的请求会收到502,但新请求自动分到11和13。

8秒。这个数字你要知道。不是秒切,是有一个窗口期的。在这个窗口期里,部分用户会看到错误。如果你要求零错误,需要在客户端加重试。

边界:开源Nginx这套 max_fails / fail_timeout 是被动健康检查------靠真实业务请求失败来计数,不会主动ping后端。如果某实例长时间没流量,它挂了Nginx也发现不了。

这个盲区有个很阴的失效模式:低峰潜伏,高峰爆发 。凌晨实例挂了,因为没流量,Nginx毫无察觉;早高峰流量一来,一波请求全撞在那个死实例上,等 max_fails 攒够又要几秒。运维刚上班就接到报警,还以为是流量问题。

补主动探活有四条路:Nginx Plus(最省事但商业付费)、nginx_upstream_check_module(要重新编译Nginx)、接入服务发现(Consul / Nacos,实例自注册自注销)、以及脚本加定时任务(定时curl各实例的health端点,连续失败就在upstream里标 down 再reload)。大多数团队用不起Plus也不想重编译Nginx,脚本方案最实际------不优雅,但成本低见效快。四种方案的成本对比和一个能直接用的脚本单独写一篇(见文末延伸阅读)。

另一半:主动下线要优雅停机

上面讲的都是"实例意外挂了怎么摘"。但生产里绝大多数实例下线是你自己发起的------发版重启。这部分没做,每次发布都会甩给用户一批502。

做法是开 server.shutdown: graceful,再配一个 timeout-per-shutdown-phase,让Tomcat停止接收新连接、把在途请求处理完再退出;Nginx侧配合走「摘流量 → 等排空 → 停进程 → 部署 → 健康检查 → 加回」,多实例滚动着来,一次只动一个。

这里有个约束容易踩:停机等待必须大于最长的工具超时,否则等待到期直接强杀,优雅停机等于没开。完整配置、发布步骤和我踩的坑单独写了一篇(见文末延伸阅读),这里不展开。

六、下游熔断:第三方挂了不能拖垮你

MCP Server自己活着不够。它调用的第三方服务(库存系统、设备状态接口)可能挂。

我凌晨遇到的第二个问题:设备状态接口超时了。我们的查设备状态工具调用这个接口,最初超时设了30秒。问题就出在这------下游是"慢"不是"挂":延迟涨到5秒但不报错,这30秒里请求一直占着Tomcat线程不放。50个这样的请求打进来,线程池直接满,所有工具都不能用了。

这就是典型的级联故障。一个下游挂了,拖垮整个系统。

Resilience4j加熔断:

复制代码
@CircuitBreaker(name = "deviceStatus", fallbackMethod = "fallback")
public DeviceStatus queryDevice(String deviceId) {
    return deviceApiClient.getStatus(deviceId);
}

public DeviceStatus fallback(String deviceId, Throwable t) {
    return DeviceStatus.unknown("设备状态暂不可用");
}

熔断的逻辑:失败率或慢调用率 超过50%,熔断器打开。接下来10秒请求直接走fallback,不再调下游;10秒后半开,放一个请求试探,好了就关,没好继续开。注意一个坑------下游是"慢"不是"挂",默认只有抛异常才算失败,慢调用不会被计入。所以我们额外配了 slowCallDurationThreshold=1s:单次调用超过1秒就算慢调用,5秒延迟就被统计进去,熔断照样能触发。

还有一个参数决定了"多久打开"这个问题其实没有固定答案:minimumNumberOfCalls。Resilience4j默认要攒够5次调用 才开始算失败率------按样本数触发,不按时间触发。所以几秒打开取决于你的QPS:并发高时5个请求眨眼就到,请求稀的时候可能攒很久才开门。所以在压测环境看到的数字,换个环境就对不上。

而且它是每个实例各自计数的。加了负载均衡之后请求按权重摊到多个实例,每个实例都要各自攒够样本。我在配套示例里单客户端串行调用时就是这个效果:慢调用照旧发生,但一直返回正常结果,跑到第12次才出现降级------不是熔断没生效,是分摊之后每个实例都没攒够样本。

打开配套代码可能会一愣 :上面给的是注解写法,仓库里却是Resilience4j纯库API手动构建的。原因是注解写法要走Spring Boot的自动配置,而这套示例不想依赖自动配置(也让它在Boot 4下能直接跑起来),所以用了纯库写法------两者语义完全等价。

语义 注解写法 纯库写法(配套示例)
给方法接上熔断 @CircuitBreaker(name="deviceStatus", fallbackMethod="fallback") CircuitBreaker.decorateSupplier(deviceCb, supplier)
降级逻辑 fallback(...) 方法 catch CallNotPermittedException 后返回降级结果
失败率阈值 failureRateThreshold=50 .failureRateThreshold(50)
慢调用判定 slowCallDurationThreshold=1s .slowCallDurationThreshold(Duration.ofSeconds(1))
慢调用率阈值 slowCallRateThreshold=50 .slowCallRateThreshold(50)
打开后等待 waitDurationInOpenState=10s .waitDurationInOpenState(Duration.ofSeconds(10))
统计窗口 slidingWindowSize=10 .slidingWindowSize(10)
最小样本数 minimumNumberOfCalls=5 .minimumNumberOfCalls(5)

参数一个对一个。也就是说,从配套示例迁到你自己的项目里,或者反过来,阈值一行都不用改,只是换写法。

熔断管的是"别被下游拖垮",反方向还有别被上游冲垮 ------这就是限流,我在系列(九)里用Bucket4j做过,本篇不重复。关键是两者的配合:限流是第一道闸,熔断是第二道闸,限流没生效时熔断会开得特别频繁,因为你塞给下游的量本来就超了。看到熔断反复开,先查限流。

半开状态有三个细节容易踩

  • 半开的试探请求本身很慢,算失败吗?算。半开请求的结果照样统计,慢调用也计入失败率------下游正在恢复、响应5秒时,试探请求会直接把熔断器推回打开状态。
  • **半开失败后,等待时间重置吗?**重置。放进去一个请求失败了,会从头再等一个 waitDurationInOpenState(10秒),不是立刻再试。
  • **半开成功后,流量就立刻全放吗?**默认是。成功即关闭熔断器、全量放行------这正是"刚恢复就被流量打挂"的经典成因。Resilience4j的 permittedNumberOfCallsInHalfOpenState 只能决定半开期放几个请求(本文配的是1),它不是渐进放量。真要渐进恢复,得在网关或限流层自己做:先放10%,稳住再放30%,逐步到全量。

最后一句运维经验:这些阈值上线后多半要调。熔断阈值、限流阈值、超时时间最好接配置中心(Nacos / Apollo之类)支持热更新,不然每调一次参就要重启一轮实例------对一篇讲高可用的文章来说,自己把重启当儿戏就有点讽刺了。

当时压测的结果:设备接口延迟从200ms涨到5s后,熔断在3秒内触发。触发后MCP Server的P99延迟从5s降回200ms。代价是查设备状态返回"暂不可用",但其他4个工具完全不受影响。

超时是一套体系,不是一个数字

本文到这儿出现了好几个超时:慢调用阈值1秒、设备接口超时30秒、熔断打开后等10秒、优雅停机等30秒。它们是互相约束的------总超时要大于各下游超时之和、越往下层越短、慢调用阈值必须小于工具超时、停机等待必须大于最长工具超时。单看哪个都合理,凑一起就可能打架。

拿本文这套数举例:慢调用1秒小于接口超时,这条没问题;但优雅停机等30秒和接口超时30秒刚好相等就不行------真有请求卡满30秒时,停机等待同时到期,照样强杀。要么把接口超时压下去,要么把停机等待加长,两者不能相等。

五条约束的完整说明、层级关系和重试策略单独写了一篇(见文末延伸阅读),这里只留结论。

熔断兜底,其余四个工具照常跑------这其实就是优雅降级:不是所有工具都必须100% 可用,一个挂了不能拖垮全部。

七、优雅降级:超时了返回什么

降级要分程度,差别在返回什么:

降级级别 触发条件 返回什么 用户体验
软降级 工具超时但主流程可继续 返回缓存数据 + 标注"数据延迟" 能接受
中降级 下游熔断打开 返回"暂不可用"+ 建议稍后重试 可接受
硬降级 MCP Server整体过载 返回503 + 友好提示 需重试

我们的做法:

查门店销售这种读多写少的,加Redis缓存。下游挂了直接返回缓存数据,标注"数据可能延迟5分钟"。店长能接受。

顺带提一句:缓存的三个经典问题------穿透(查不存在的数据,每次打到DB)、击穿(热点key过期的瞬间,并发全涌进来)、雪崩(大批key同时过期或Redis整个挂掉)------成因和解法完全不同,别混着用。本文演练的是第三种里"Redis整体挂了"这一支。但现实里击穿更常见------很多团队Redis好好的,却因为一个热点key过期把DB打挂了。三者的区分和各自解法单独写了一篇(见文末延伸阅读)。

查设备状态这种实时性要求高的,不能用旧数据。熔断后返回"设备状态查询暂时不可用"。

营销文案生成这类LLM调用(生产环境才有,配套示例里没有这个工具),加超时30秒。超时了返回空结果,让Agent知道这个工具现在不能用,它会自己决定要不要换个方式回答。

另外HTTP层的502/503/429是一回事,MCP协议层还有一层:工具调用失败要按JSON-RPC错误对象返回(负的code加一句人能读的message),别把异常栈直接塞回去。客户端拿到错误后的行为由它自己决定------大多数Agent会自行重试或换别的工具,所以message写"现在不可用,稍后再试"比写"内部错误"有用得多。

八、故障演练:别等大促才发现

理论说完了。真正验证高可用的方法只有一个:主动制造故障。

我做了三次演练:

演练1:kill掉实例12。Nginx 8秒摘除,其他实例接管。期间约5% 请求收到502。

演练2:设备接口断网。熔断3秒触发,其他工具正常。用户看到"设备状态暂不可用"。

演练3:Redis挂了。这是我没想到的。缓存没了之后,查门店销售全部穿透到DB------我用5次同样的请求量了一下缓存前后的差别:缓存正常时只有第1次打到数据库(后面几次全命中缓存,数据库压力基本为零),缓存一挂,5次请求全部直连数据库,数据库承接的查询量立刻放大好几倍。

但这里有个前提我后来才想明白,而且它非常容易被说反:Redis挂了并不自动等于连接池打满。真正决定的是"并发数 × 单次查询耗时"有没有超过连接池的容量。我拿同样的池(上限8)跑了两组对照,12个并发客户端抢连接:

单次查询耗时 12并发打上进8连接的池 结果
200ms级(正常) 全部正常返回 12个全成功,没有发生排队------连接用完就还了
退化到4秒 8个成功 剩下4个直接拿不到连接(Too many connections)

所以准确的说法是:缓存是个节流阀。阀一没,DB承受的压力立刻按倍数放大。至于会不会打满连接池,要看那时候查询本身有没有变慢。而现实里这两件事常常一起发生------热点key集体过期,或者DB本来就在报警。于是体感上就成了"Redis挂 → DB跟着挂"的连锁反应,容易把因果记反。

配套示例是全mock数据、不带真实数据库,上面这两组对照是我在本地另外起的一套MySQL + Redis上跑出来的,我把触发条件、实验脚本和原始输出都留在仓库的 10-store-ha/verify/实测记录.md 里了,你可以照着自己复现一遍。

Redis挂了这个case教会我一件事:你的MCP Server依赖的每一个外部组件(DB、Redis、下游API)都是单点。你得知道每个单点挂了会发生什么。

缓存这边我用哨兵解决了自动切换;数据库是另一个单点,主从复制、读写分离或直接用云RDS都行。DB要是单点,前面这些高可用做再多也白搭。

后来我给Redis加了哨兵(Sentinel),三个节点形成多数派,主节点挂了自动切换。实测一遍大约7秒完成:

复制代码
15:59:47 # +sdown master mymaster redis-master 6379
15:59:48 # +odown master mymaster redis-master 6379 #quorum 3/2
15:59:49 # +switch-master mymaster redis-master 6379 172.19.0.5 6379

切换完成后读写恢复正常,切换前已经写进去的数据也没丢------新主库是从副本提上来的,数据早就同步过去了。原主库回来之后会被哨兵降级成副本,不会形成双主。

**但别理解成"切换期间零失败"。**主从切换有一个窗口期(我这次实测约7秒),这段时间主库不可写,撞上去的写请求是会失败的。我的验证是在切换完成之后才发起的,所以看到的是"一切正常";窗口期内那几个请求报的错,我这边没采到。要兜住这段,靠的是客户端重试(Lettuce会自己重连,重要写操作建议业务层再补一次重试),不是靠哨兵。

另外哨兵只解决一件事:主节点故障自动切换。它不解决水平扩展(读压力大要做读写分离),也不解决数据分片(数据量上去了要Redis Cluster)。选型时别把它当万能药。

这里有个坑值得单独拎出来,因为按直觉操作会得出"哨兵没用"的错误结论:演练主库故障要用 pause,不要用 stop。

容器一 stop,Docker网络里的DNS别名就注销了,而哨兵配置里开着 resolve-hostnames,解析失败会让它进入TILT模式------一种自我保护状态,期间不发起failover。我第一次就是这么试的,主库明明已经不可用,哨兵却迟迟不动:

复制代码
15:55:12 # Failed to resolve hostname 'redis-master'
15:55:12 # +tilt                   ← 自我保护,不发起 failover
15:55:42 # +sdown master mymaster  ← 本该 5 秒就标记
15:55:45 # +tilt

换成 pause(冻结进程,但别名和IP都保留)之后一切正常:5秒sdown、7秒完成switch-master。

日常监护:没有监控的高可用是盲飞

演练是定期体检,但平时你得知道系统现在什么状态。这里只讲跟高可用直接相关的那几个指标------全链路追踪和可观测性体系放在系列(十四),不在这儿展开。

至少要看这6个:

  • 各实例的QPS / 错误率 / P99
  • 健康实例数(Nginx认为还活着几个)
  • 熔断状态:哪些下游的熔断器是打开的、开了多久
  • 降级次数:三个级别各触发了多少次
  • 线程池 / 连接池使用率
  • 依赖可用性:DB、Redis、下游接口

告警阈值我用的经验值:错误率>5%、P99>1s、实例不健康、熔断打开超过30秒、线程池使用率>80%(这条是预警不是告警)。

池子该设多大是另一个话题------线程池不是越大越好、数据库连接数有公式、拍脑袋定的数基本都不准得靠实际使用率回调,这些单独写了一篇(见文末延伸阅读)。这里只强调一个结论:容量不是静态数字,它是"并发 × 单次耗时"的函数。你按今天的耗时配的容量,明天下游慢一倍就不够用了------上面那组对照实验已经证明过这一点。

熔断这里有个原则值得单独说:**fallback必须极简,不能再依赖外部服务。**否则下游挂了、fallback也跟着挂,那就真没有退路了------降级是为了收窄影响面,不是换个地方继续失败。另外熔断打开这件事本身要留一条审计日志(什么时候开的、开了多久、失败多少次),不然事后复盘只能靠猜:到底是下游真的挂了,还是我阈值配得太敏感。

九、你以为vs实际

你以为 实际
加机器就能扛住流量 加机器前先确认无状态,否则会话错乱
Nginx负载均衡自动搞定一切 Nginx不会主动发现实例挂了,要配健康检查
下游挂了等它自己恢复 不熔断的话一个下游挂拖垮整个MCP
降级就是返回错误页 降级是分级别,有的返回缓存,有的返回友好提示
高可用 = 多活 高可用是"一个挂了系统还能用",不是"所有都活着"
加了健康检查就零故障了 健康检查有窗口期,窗口期内照样有错误,要零错误得客户端重试
熔断越快越好 太快误伤、太慢保护不住,而且它是按样本数触发的,不是按时间
多实例就等于高可用 多实例却共用一个DB或Redis,那依赖还是单点

十、决策树:你需要做到哪一层

不是所有人都需要上K8s。按你的场景选:

复制代码
你的MCP Server有多少QPS?
├─ <10 QPS,内部用
│   └─ 单实例+定时重启就够了,别折腾
├─ 10-100 QPS,有真实用户
│   └─ 2实例+Nginx加权轮询+健康检查+下游熔断
├─ 100-500 QPS,大促有峰值
│   └─ 3实例+Redis缓存+优雅降级+故障演练
└─ >500 QPS,核心业务
    └─ K8s自动扩缩容+多机房+全链路压测

不过QPS只是其中一个维度。真正在这几档之间做选择的,通常是另外三件事:

维度 怎么影响选择
SLA 99.5%和99.99%不是一个量级的投入------后者基本意味着多机房,光这一项就够吃掉预算
成本 2实例加Nginx是几百块一个月的事;K8s加多机房是好几倍,还得算上运维人力
团队能力 K8s不是搭起来就完事,得有人能扛半夜的Pod异常。没人维护的话,3实例加Nginx远比一个没人看懂的K8s集群可靠

另外MCP有个容易被忽略的点:QPS不是唯一衡量维度,同时在线的会话数和工具调用频率往往更早撞到瓶颈------一个会话里Agent可能连着调七八个工具。

还有一个前置问题:你怎么知道该上几台?估峰值QPS(历史峰值 × 1.5~3倍增长系数)→ 单机压测找到瓶颈 → 按峰值 ÷ 单机容量 × 冗余系数算实例数。冗余至少30%,大促给50%~100%;压测别只压匀速流量,指标看P99不看平均值。

至于决策树最后一档的"多机房"------那是另一个量级的话题,本文不覆盖:数据同步、流量调度、机房级切换,以及成本。它通常需要单独立项。

我们现在在第三档。双11期间从2实例扩到3实例,扛住了120 QPS的峰值。没有用户投诉。

十一、下一篇

高可用解决了"挂了怎么办"。但店长们在飞书群里 @机器人查库存这个场景,我们还没接。

下一篇讲怎么把MCP接到飞书,让店长在群里直接 @机器人查数据。

十二、本次实测核心踩坑与风险

  • 环境坑 :Nginx漏配proxy_set_header Host $host时,转发Host默认成upstream名(含下划线),Tomcat判非法主机名导致整站400,且Nginx与后端都不报错、只有客户端拿到400,日志干净极难查;actuatorshow-details: always会把Redis精确版本、磁盘余量、SSL状态全吐出去,等于给攻击者画攻击面,只能放内网。
  • 安全坑:健康详情开公网泄露版本号与依赖即暴露CVE攻击面;fallback若再依赖外部服务,下游挂时降级也跟着挂,等于没退路;熔断何时打开、开了多久、失败几次若不记审计日志,事后复盘只能靠猜。
  • 生产坑:被动健康检查靠真实请求失败计数,低峰实例挂了Nginx毫无察觉、早高峰才爆发;优雅停机等待必须大于最长工具超时,否则等待到期强杀、优雅停机形同虚设;熔断各实例各自计数,负载均衡把请求摊薄后单实例凑不够样本,看起来"没生效"其实是没攒够次数。
  • 边界坑:本机mock无真实流量与数据库,性能数字别照抄;Redis哨兵只解决主节点自动切换,不解决水平扩展与数据分片;多实例若共用单点DB或Redis,前面高可用全白做;熔断半开成功后默认全量放行,"刚恢复就被流量打挂"是经典成因。

延伸阅读:这个专题的另外 5 篇

讲高可用时我拆出去单独写了 5 个知识点,跟本文配套:

篇 标题 链接
01 Nginx开源版没有主动探活:四种补救方案和一个能用的脚本 待补
02 发版就掉请求?Spring Boot优雅停机的四步 待补
03 超时不是一个数字,是一套体系:MCP Server的五条约束 待补
04 高可用配完之后:该看哪几个指标,池子该设多大 待补
05 缓存穿透、击穿、雪崩:三个概念,三种解法 待补

本文完整可运行代码 (tag: v10)

clone下来 git checkout v10 就能还原本文状态,全mock数据,不依赖任何真实系统。

跑通了的话点个star------这是我继续写下去最直接的反馈。

跑不通直接来提issue,我看到就回。

相关推荐
省钱兄--zs4 小时前
西安24小时自助健身房解决方案实战:系统开发与部署全流程指南
java·spring boot·系统架构·intellij-idea·需求分析
ly76896 小时前
Spring Boot 集成 Elasticsearch 的生产实践:客户端选型、索引生命周期与批量写入容错
spring boot·elasticsearch·索引生命周期·java api client·批量写入
程序猿_极客7 小时前
【免费】分享一套优质的基于SpringBoot的服装商城管理系统的设计与实现(带可视化图表、协同过滤功能),源码+文档+视频详解(讲解)
java·spring boot·后端·服装商城管理系统
bug菌7 小时前
🤔同事突然问我:Spring的注解 @Component 和 @Service 有何不同?
java·spring boot·后端
Lonely丶墨轩8 小时前
用 Vue + Spring Boot + Python 做一个 AI 双人海龟汤游戏:从出题、联机到自动审稿等核心技术设计与实现
spring boot·后端·游戏
她的男孩10 小时前
打印模板草稿能保存,一点发布就报主从关系:我们把校验拆成了两档
java·spring boot·后端
wno70411 小时前
Spring Boot整合Quartz
java·spring boot·后端
萧瑟余晖11 小时前
Spring Boot Actuator监控运维详解
spring boot
vipxieliang13 小时前
ValidX 财务系统发票验证:发票号、税号、金额校验
java·spring boot