一次版本升级导致的血案
线上故障有很多种。
有些问题很直观,比如 SQL 写错了、循环写死了、goroutine 没退出、缓存没有淘汰。
但还有一种问题最折磨人:业务代码看起来没有什么变化,服务内存却持续上涨,GC 之后也降不下来,最后实例被 OOM Kill 掉。
上周在公司就遇到了这种问题。一个内部 MCP 服务在一次需求开发中,升级了底层依赖:
- github.com/mark3labs/mcp-go v0.39.1
+ github.com/mark3labs/mcp-go v0.48.0
看起来只是一次普通的依赖升级,实际上跨了 9 个 minor 版本。
上线之后,服务实例内存开始持续上涨,最终触发 OOM,实例重启。
排查到最后才发现,问题并不在本次需求新增的业务逻辑里,而是藏在 mcp-go 的 StreamableHTTPServer 实现里。
根本原因就是:
升级之后,
streamableHttpSession从请求级临时对象,变成了服务级持久对象。如果客户端没有显式发送 DELETE,服务端又没有配置 idle TTL 清理逻辑,session 就会一直挂在 map 里,无法被 GC 回收。
背景
这个服务是一个内部 MCP Server,用来给大模型提供一些内部工具能力。
底层使用的是 Go 语言实现的 MCP SDK:
github.com/mark3labs/mcp-go服务初始化代码大致如下:
mcpSrv := server.NewMCPServer(
"internal-mcp",
"1.0.0",
server.WithToolCapabilities(false),
)
handler := server.NewStreamableHTTPServer(mcpSrv)
http.Handle("/mcp", handler)服务上线后一直稳定运行了大半年,在这次需求开发时,业务上只新增了一些 tool handler,因为新接入的另一个关联服务需要就升级了 mcp-go:
- github.com/mark3labs/mcp-go v0.39.1
+ github.com/mark3labs/mcp-go v0.48.0
上线后,问题开始出现。
事故现象
服务上线后,监控很快出现异常:
- 实例 RSS 持续上涨;
- Go heap 持续上涨;
- GC 频率变高;
- 每次 GC 之后内存没有明显回落;
- 请求量没有明显放大;
- 最终实例被 OOM Kill。
这里有一个非常关键的信号:
GC 之后内存没有明显回落,通常说明这些对象仍然是可达对象。
也就是说,Go GC 并不是没有工作,只是对象一直增加,且还『存活』着(不会被 GC)。
Go 的 GC 只会回收不可达对象。如果某些对象还被全局变量、map、slice、channel、goroutine 或者其他长生命周期对象引用着,那么即使业务上它们已经没用了,GC 也不会回收。
所以这类问题本质上不是“GC 不给力”,而是对象引用链没有断, 无法被 GC 回收。
第一轮排查:先看业务代码
排查线上问题,一般先看最近改了什么。
这次需求新增了一些 MCP tool handler,所以第一轮排查重点放在业务代码上:
- 有没有新增全局 map?
- 有没有缓存没有过期策略?
- 有没有把请求级对象塞进全局变量?
- 有没有 goroutine 没退出?
- 有没有 channel 阻塞?
- 有没有大对象被闭包捕获?
- 有没有一次性加载大量数据?
逐个检查下来,没有发现明显问题。
新增逻辑里没有持续增长的 map,也没有把大对象保存在全局结构里。
这时候就要警惕一个常见误区:
线上事故不一定来自你主动修改的业务代码,也可能来自你顺手升级的依赖。
于是继续看 go.mod。
第二轮排查:发现依赖版本升级
go.mod 里有一个变化非常可疑:
- github.com/mark3labs/mcp-go v0.39.1
+ github.com/mark3labs/mcp-go v0.48.0
从 v0.39.1 到 v0.48.0,跨了 9 个 minor 版本。
这类升级本身就应该引起警惕。
特别是 v0.x.x 阶段的库,虽然 Go module 语义上仍然是同一个 major version,但这类库通常还处于快速演进阶段,API 和内部行为都可能发生比较大的变化。
更关键的是:
编译通过,不代表运行时行为没有变化。
很多依赖升级的问题,不是编译期能发现的。
比如:
- 默认超时时间变了;
- 默认重试策略变了;
- 原来无状态,现在变成有状态;
- 原来请求级对象,现在变成服务级对象;
- 新增了后台 goroutine;
- 新增了连接池、缓存、session map;
- 原来不需要 Close,现在需要显式 Close/Delete。
这次就是典型的“运行时生命周期变化”。
第三轮排查:下载 heap profile
内存问题不能只靠猜。
接下来需要看 heap profile,从线上拉取了一个实例的 heap profile 文件进行分析。
go tool pprof heap.pprof进入 pprof 后,依次使用以下命令查看占用内存的地方:
(pprof) top
(pprof) top -cum
(pprof) list FunctionName这一步主要是查看哪些对象还存活,占用内存的主要是哪些地方。这是排查 Go 内存问题时非常重要的思路。
很多时候,alloc_space 只能说明谁分配得多;而我们排查 OOM 时,更关心的是 inuse_space,也就是当前仍然存活的对象。
pprof 看到的线索
heap profile 里可以看到,内存占用最终指向了 mcp-go/server 相关逻辑,尤其是 Streamable HTTP session 相关对象。
示意性的调用链大致类似下面这样:
github.com/mark3labs/mcp-go/server.newStreamableHttpSession
github.com/mark3labs/mcp-go/server.(*StreamableHTTPServer).handlePost
github.com/mark3labs/mcp-go/server.(*MCPServer).RegisterSession
sync.(*Map).Store看到 sync.Map.Store 这类调用时,要特别敏感。
因为这通常意味着对象被放进了某个长生命周期容器里。
如果一个对象只是请求处理过程中的临时变量,请求结束后引用链断掉,GC 就可以回收。
但如果这个对象被 sync.Map、全局 map、server struct、cache 等对象引用着,那么请求结束后它仍然是可达对象。
这样以来 session 对象分配后就会一直存在,很可能就是这里出了问题。
开始读源码
既然 pprof 指向了 mcp-go/server,下一步就是对比 v0.39.1 和 v0.48.0 的源码。
问题集中在这个文件:
server/streamable_http.go重点关注几个函数和结构体:
StreamableHTTPServer
NewStreamableHTTPServer
handlePost
handleGet
handleDelete
newStreamableHttpSession
cleanupSessionState
WithSessionIdleTTLv0.39.1:POST 请求里的 session 是请求级对象
先看 v0.39.1。
在 handlePost 里,源码注释写得非常直接:
// Prepare the session for the mcp server
// The session is ephemeral. Its life is the same as the request. It's only created
// for interaction with the mcp server.随后代码创建 session:
session := newStreamableHttpSession(sessionID, s.sessionTools, s.sessionLogLevels)这段逻辑说明,在 v0.39.1 的 POST 请求路径中,streamableHttpSession 被设计成 ephemeral session,它的生命周期和当前请求一致,只是为了和 MCP server 交互而创建。
继续看 streamableHttpSession 的定义,源码注释也写得很清楚:
// streamableHttpSession is a session for streamable-http transport
// When in POST handlers(request/notification), it's ephemeral, and only exists in the life of the request handler.
// When in GET handlers(listening), it's a real session, and will be registered in the MCP server.也就是说,POST handler 中的 session 是请求级临时对象,GET listening 场景下才是一个真正注册到 MCP server 的 session。
这里要稍微严谨一点。
我们平时容易说“这是一个栈对象”,但在 Go 里,一个变量到底分配在栈上还是堆上,是由编译器逃逸分析决定的。这里更准确的说法应该是:
它是一个请求级临时对象,而不是服务级持久对象。
是否真的分配在栈上,不是我们手写 := 就能决定的。
但从业务生命周期看,它的引用链主要存在于当前请求处理过程里。请求结束后,如果没有被其他长生命周期对象引用,GC 就可以回收。
这就是旧版本没有明显内存问题的根本原因。
v0.48.0:StreamableHTTPServer 多了服务级 session 状态
再看 v0.48.0。
StreamableHTTPServer 结构体里多了几个非常关键的字段:
type StreamableHTTPServer struct {
server *MCPServer
sessionTools *sessionToolsStore
sessionResources *sessionResourcesStore
sessionResourceTemplates *sessionResourceTemplatesStore
sessionRequestIDs sync.Map // sessionId --> last requestID(*atomic.Int64)
activeSessions sync.Map // sessionId --> *streamableHttpSession (for sampling responses)
httpServer *http.Server
// ...
sessionIdleTTL time.Duration
sessionLastActive sync.Map // sessionID → *atomic.Int64 (unix nanos)
sweeperCancel context.CancelFunc
}这里最关键的是两个字段:
sessionRequestIDs sync.Map
activeSessions sync.Map尤其是:
activeSessions sync.Map // sessionId --> *streamableHttpSession这意味着 streamableHttpSession 不再只是当前请求处理过程中的临时对象,而是可能被放进 StreamableHTTPServer 这个长生命周期对象里。
一旦对象被放进 activeSessions,它的生命周期就变了:
原来:请求结束 -> 引用链断开 -> 等待 GC
现在:请求结束 -> activeSessions 仍然引用 -> GC 不能回收这就是整个问题最核心的变化。
handlePost:initialize 成功后注册 session
继续看 v0.48.0 的 handlePost。
在处理 POST 请求时,它会先判断当前请求是否是 initialize 请求:
isInitializeRequest := jsonMessage.Method == mcp.MethodInitialize然后准备 session id:
var sessionID string
sessionIdManager := s.sessionIdManagerResolver.ResolveSessionIdManager(r)
if isInitializeRequest {
// generate a new one for initialize request
sessionID = sessionIdManager.Generate()
} else {
// Get session ID from header.
// Stateful servers need the client to carry the session ID.
sessionID = r.Header.Get(HeaderKeySessionID)
isTerminated, err := sessionIdManager.Validate(sessionID)
if err != nil {
http.Error(w, "Invalid session ID", http.StatusNotFound)
return
}
if isTerminated {
http.Error(w, "Session terminated", http.StatusNotFound)
return
}
}对于非 initialize 请求,它会尝试复用已有 session:
// For non-initialize requests, try to reuse existing registered session
var session *streamableHttpSession
if !isInitializeRequest {
if sessionValue, ok := s.server.sessions.Load(sessionID); ok {
if existingSession, ok := sessionValue.(*streamableHttpSession); ok {
session = existingSession
}
}
}
// Check if a persistent session exists (for sampling support), otherwise create ephemeral session
// Persistent sessions are created by GET (continuous listening) connections
if session == nil {
if sessionInterface, exists := s.activeSessions.Load(sessionID); exists {
if persistentSession, ok := sessionInterface.(*streamableHttpSession); ok {
session = persistentSession
}
}
}
// Create ephemeral session if no persistent session exists
if session == nil {
session = newStreamableHttpSession(
sessionID,
s.sessionTools,
s.sessionResources,
s.sessionResourceTemplates,
s.sessionLogLevels,
)
}这段逻辑说明,新版本已经不是简单地“每个 POST 请求创建一个临时 session”了。
它会先从 s.server.sessions 里找,再从 s.activeSessions 里找,找不到才创建新的 session。
更关键的逻辑在响应 initialize 之后:
// Register session after successful initialization
// Only register if not already registered (e.g., by a GET connection)
if isInitializeRequest && sessionID != "" {
if _, exists := s.server.sessions.Load(sessionID); !exists {
// Store in activeSessions to prevent duplicate registration from GET
s.activeSessions.Store(sessionID, session)
// Register the session with the MCPServer for notification support
if err := s.server.RegisterSession(ctx, session); err != nil {
s.logger.Errorf("Failed to register POST session: %v", err)
s.activeSessions.Delete(sessionID)
// Don't fail the request, just log the error
}
}
}这段代码做了两件事:
- 把 session 存进
activeSessions; - 把 session 注册到
MCPServer。
这就是这次事故的关键代码。
- 旧版本里,POST 请求中的 session 是请求级临时对象。
- 新版本里,initialize 成功后,session 会被服务端持久保存。
从引用链看问题
把这段逻辑抽象一下:
StreamableHTTPServer
└── activeSessions sync.Map
└── sessionID -> *streamableHttpSession
├── notificationChannel
├── samplingRequestChan
├── elicitationRequestChan
├── rootsRequestChan
├── samplingRequests sync.Map
├── tools store
├── resources store
├── resource templates store
└── log levels store如果客户端持续创建 session,而这些 session 没有被删除,那么 activeSessions 会不断增长。
只要 activeSessions 里还有这个 key/value,*streamableHttpSession 就是可达对象。
于是 GC 的视角是:
root
-> http server
-> StreamableHTTPServer
-> activeSessions
-> streamableHttpSession对象可达,所以不能回收。
这也是为什么 GC 之后内存不下降。
GC 没有错。
错的是我们没有切断引用链。
handleDelete:清理逻辑依赖 DELETE
既然 session 被持久保存,就一定需要释放逻辑。
在 v0.48.0 中,handleDelete 是一个重要清理入口:
func (s *StreamableHTTPServer) handleDelete(w http.ResponseWriter, r *http.Request) {
// delete request terminate the session
sessionID := r.Header.Get(HeaderKeySessionID)
sessionIdManager := s.sessionIdManagerResolver.ResolveSessionIdManager(r)
notAllowed, err := sessionIdManager.Terminate(sessionID)
if err != nil {
http.Error(w, fmt.Sprintf("Session termination failed: %v", err), http.StatusInternalServerError)
return
}
if notAllowed {
http.Error(w, "Session termination not allowed", http.StatusMethodNotAllowed)
return
}
s.cleanupSessionState(r.Context(), sessionID)
w.WriteHeader(http.StatusOK)
}也就是说,客户端发送 DELETE 时,服务端会调用:
s.cleanupSessionState(r.Context(), sessionID)问题是:
客户端不一定会发送 DELETE。
真实线上环境里,客户端可能:
- 异常退出;
- 网络中断;
- 代理层断开;
- 请求超时;
- 实现不完整;
- 根本没实现 DELETE;
- 进程被 kill;
- 用户关闭页面;
- 大模型宿主环境直接丢弃连接。
这些情况下,服务端如果没有兜底清理策略,session 就会一直留在内存中。
cleanupSessionState:真正的清理动作
继续看 cleanupSessionState:
func (s *StreamableHTTPServer) cleanupSessionState(ctx context.Context, sessionID string) {
// Unregister first to stop notification routing before deleting data.
s.server.UnregisterSession(ctx, sessionID)
s.activeSessions.Delete(sessionID)
s.sessionTools.delete(sessionID)
s.sessionResources.delete(sessionID)
s.sessionResourceTemplates.delete(sessionID)
s.sessionLogLevels.delete(sessionID)
s.sessionRequestIDs.Delete(sessionID)
s.sessionLastActive.Delete(sessionID)
}这段代码非常关键。
它说明一个 session 相关的状态,不只存在于 activeSessions 里,还可能分布在多个 per-session store 里:
server.sessions
activeSessions
sessionTools
sessionResources
sessionResourceTemplates
sessionLogLevels
sessionRequestIDs
sessionLastActivecleanupSessionState 会依次删除这些状态。
所以这次问题不是单个对象没释放,而是一组 per-session 状态没有被清理。
从内存增长角度看,大致是:
session 数量增长
-> activeSessions 增长
-> server.sessions 增长
-> sessionRequestIDs 增长
-> sessionTools/sessionResources 等 store 增长
-> heap 持续上涨WithSessionIdleTTL:新版本的兜底方案
继续往下看源码,会发现 v0.48.0 已经提供了一个非常关键的选项:
func WithSessionIdleTTL(ttl time.Duration) StreamableHTTPOption {
return func(s *StreamableHTTPServer) {
s.sessionIdleTTL = ttl
}
}它的注释写得很清楚:
// WithSessionIdleTTL sets the idle TTL for per-session transport state.
//
// When enabled, a background sweeper periodically removes entries from
// per-session stores (tools, resources, resource templates, log levels,
// request IDs) for sessions that have been idle longer than the given
// duration. This prevents memory leaks when clients disconnect without
// sending a DELETE request. A zero or negative value disables the sweeper
// (the default).这里几乎已经把事故原因写出来了。
重点是最后两句:
This prevents memory leaks when clients disconnect without sending a DELETE request.
A zero or negative value disables the sweeper (the default).也就是说:
- 这个选项就是为了解决客户端不发送 DELETE 导致的内存泄漏;
- 但默认值是关闭 sweeper。
而我们原来的初始化代码是:
handler := server.NewStreamableHTTPServer(mcpSrv)这意味着没有配置 WithSessionIdleTTL。
也就是说,升级之后,我们实际上运行在下面这个状态:
session 会被持久化保存
+
客户端不一定发送 DELETE
+
服务端没有开启 idle TTL sweeper
=
session 无界增长sweeper 是怎么工作的?
NewStreamableHTTPServer 中有一段逻辑:
if s.sessionIdleTTL > 0 {
ctx, cancel := context.WithCancel(context.Background())
s.sweeperCancel = cancel
s.startSessionSweeper(ctx)
}也就是说,只有 sessionIdleTTL > 0 时,才会启动后台清理 goroutine。
sweeper 的核心逻辑如下:
func (s *StreamableHTTPServer) startSessionSweeper(ctx context.Context) {
interval := max(s.sessionIdleTTL/2, time.Second)
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sweepExpiredSessions()
}
}
}()
}它会定期调用:
s.sweepExpiredSessions()再看 sweepExpiredSessions:
func (s *StreamableHTTPServer) sweepExpiredSessions() {
now := time.Now().UnixNano()
ttlNanos := s.sessionIdleTTL.Nanoseconds()
s.sessionLastActive.Range(func(key, value any) bool {
sessionID, ok := key.(string)
if !ok {
s.sessionLastActive.Delete(key)
return true
}
lastActive, ok := value.(*atomic.Int64)
if !ok {
s.sessionLastActive.Delete(key)
return true
}
capturedLastActive := lastActive.Load()
if now-capturedLastActive < ttlNanos {
return true
}
// Re-check: if lastActive changed since we read it, the session
// was touched concurrently — skip it.
if lastActive.Load() != capturedLastActive {
return true
}
s.logger.Infof("Sweeping expired session: %s", sessionID)
mgr := s.sessionIdManager
if mgr == nil {
mgr = s.sessionIdManagerResolver.ResolveSessionIdManager(nil)
}
_, _ = mgr.Terminate(sessionID)
s.cleanupSessionState(context.Background(), sessionID)
return true
})
}这个实现有几个细节值得注意。
第一,它不是直接遍历 activeSessions,而是遍历 sessionLastActive。
第二,touchSession 只在 sessionIdleTTL > 0 时记录活跃时间:
func (s *StreamableHTTPServer) touchSession(sessionID string) {
if sessionID == "" || s.sessionIdleTTL <= 0 {
return
}
now := time.Now().UnixNano()
actual, _ := s.sessionLastActive.LoadOrStore(sessionID, new(atomic.Int64))
actual.(*atomic.Int64).Store(now)
}第三,过期后最终还是调用:
s.cleanupSessionState(context.Background(), sessionID)也就是和 DELETE 一样,最终走统一清理逻辑。
这说明 WithSessionIdleTTL 才是服务端兜底清理 session 的关键配置。
根因总结
把源码串起来,这次事故的因果链很清楚:
1. 升级 mcp-go:v0.39.1 -> v0.48.0
↓
2. StreamableHTTPServer 新增 activeSessions / sessionRequestIDs / sessionLastActive 等服务级状态
↓
3. initialize POST 成功后,session 被 Store 到 activeSessions,并 Register 到 MCPServer
↓
4. session 生命周期从“请求级”变成“服务级”
↓
5. 客户端没有稳定发送 HTTP DELETE
↓
6. 服务端没有配置 WithSessionIdleTTL
↓
7. session 相关状态无法释放
↓
8. Go GC 认为对象仍然可达
↓
9. heap 持续上涨
↓
10. OOM从 Go 的角度看,真正的问题不是“对象分配太多”,而是:
对象已经没有业务价值了,但仍然被长生命周期数据结构引用着。
这就是典型的逻辑泄漏。
修复方案
配置 WithSessionIdleTTL
最直接的修复方式是在创建 StreamableHTTPServer 时配置 session idle TTL:
handler := server.NewStreamableHTTPServer(
mcpSrv,
server.WithSessionIdleTTL(30*time.Minute),
)如果你的 MCP 服务主要是短时工具调用,也可以设置更短一些:
handler := server.NewStreamableHTTPServer(
mcpSrv,
server.WithSessionIdleTTL(5*time.Minute),
)TTL 怎么设置要看业务场景。
如果 MCP Client 会长时间复用 session,TTL 就不能太短。
如果服务是内部工具型 MCP Server,请求大多是短生命周期,那么 5 到 30 分钟通常更合理。
客户端显式发送 DELETE
如果客户端可控,最好在 session 不再使用时主动发送 DELETE。
示例代码如下:
func closeMCPSession(ctx context.Context, endpoint string, sessionID string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Mcp-Session-Id", sessionID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("close mcp session failed: status=%d", resp.StatusCode)
}
return nil
}但这只能作为客户端侧优化,不能作为服务端唯一保障。
因为真实环境里,客户端异常退出、网络中断、代理断开时,DELETE 可能根本发不出来。
评估是否使用 stateless 模式
如果 MCP Server 本身不需要维护 session 状态,可以评估使用 stateless 模式:
handler := server.NewStreamableHTTPServer(
mcpSrv,
server.WithStateLess(true),
)WithStateLess 的注释说明,开启后服务端不管理 session 信息,每个请求都会被当作新的 session,并且不会向客户端返回 session id。
不过这个方案要谨慎。
如果你依赖下面这些能力,那么 stateless 模式可能不适合:
- server-to-client notification;
- sampling;
- roots;
- elicitation;
- 长连接 SSE;
- session 级工具过滤;
- session 级资源状态。
为什么 sync.Map 特别容易藏问题?
这次问题里,sync.Map 是一个很关键的信号。
sync.Map 本身没有问题,它适合读多写少、key 稳定的并发访问场景。
但它有一个工程上的风险:
写入很容易,生命周期容易被忽略。
普通 map 通常会被封装在业务结构里,读写时还会看到锁和删除逻辑:
mu.Lock()
sessions[id] = session
mu.Unlock()但 sync.Map 的写入非常轻:
s.activeSessions.Store(sessionID, session)如果代码里没有对应的:
s.activeSessions.Delete(sessionID)或者没有统一的 TTL 清理,那么这个 map 就会变成一个只增不减的对象池。
这类问题在 pprof 里经常表现为:
sync.(*Map).Store
xxx.newSession
xxx.RegisterSession所以以后排查内存问题时,如果看到对象最终挂在 sync.Map、全局 map 或 cache 上,要优先检查:
- Store 在哪里发生?
- Delete 在哪里发生?
- Delete 是否一定会发生?
- 异常路径会不会跳过 Delete?
- 客户端断开时能不能触发 Delete?
- 有没有 TTL?
- 有没有最大容量限制?
- 有没有指标观测当前 map size?
依赖升级应该怎么 review?
这次事故给我最大的教训是:
Code Review 不能只看业务代码,依赖的升级也需要关注。
业务代码都会有很健全的 code review 机制,但是对于项目依赖的改动就没有那么容易发现问题了。
因此,在日常开发过程中,对于下面几类依赖,一定要单独 review:
- Web framework;
- RPC framework;
- HTTP client;
- 数据库 driver;
- 消息队列 client;
- 缓存 client;
- 协议 SDK;
- AI / Agent / MCP 相关 SDK;
- 任何管理连接、session、goroutine、缓存的库。
升级时重点看这些内容:
1. 是否新增全局状态?
2. 是否新增服务级 map/cache?
3. 是否新增后台 goroutine?
4. 是否新增连接池?
5. 是否新增 session 管理?
6. 是否新增 Close/Delete/Shutdown/TTL 之类的选项?
7. 默认值是否发生变化?
8. 请求级对象是否变成服务级对象?
9. 是否有异常路径清理问题?
10. 是否需要补充压测和 soak test?尤其是 v0.x.x 的库,跨多个 minor 版本升级时,不要只看 changelog,最好直接看关键路径源码。
最终修复方案
最终我们采用了服务端兜底清理方案:
handler := server.NewStreamableHTTPServer(
mcpSrv,
server.WithSessionIdleTTL(10*time.Minute),
)修复上线后,内存曲线恢复稳定。
此外,还完善了 code review 流程,编写了一个 skill 专门做依赖升级的 code review。
小结
这次事故表面上看,是一次普通依赖升级导致的 OOM。
但本质上是一个非常典型的 Go 服务端资源生命周期问题。
旧版本中,mcp-go 的 POST 请求路径里,streamableHttpSession 是请求级临时对象。源码注释也明确写着,它的生命周期和 request handler 一致。
升级到 v0.48.0 后,StreamableHTTPServer 新增了 activeSessions sync.Map、sessionRequestIDs sync.Map、sessionLastActive sync.Map 等服务级状态;initialize 成功后,session 会被保存到 activeSessions 并注册到 MCPServer。
如果客户端没有发送 DELETE,而服务端又没有配置 WithSessionIdleTTL,这些 session 就会一直被服务端引用,GC 无法回收。
Go 有 GC,但 GC 不是万能的魔法。它只能回收不可达对象,不能判断一个还挂在 map 里的对象是否“业务上已经没用了”。
这次血案再次提醒我们:
依赖升级不是改个版本号。尤其是协议库、框架库和服务端基础库,升级前一定要看清楚默认行为和资源生命周期是否发生了变化。