跳至内容
一次版本升级导致的血案

一次版本升级导致的血案

2026-05-12

线上故障有很多种。

有些问题很直观,比如 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-goStreamableHTTPServer 实现里。

根本原因就是:

升级之后,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

上线后,问题开始出现。

事故现象

服务上线后,监控很快出现异常:

  1. 实例 RSS 持续上涨;
  2. Go heap 持续上涨;
  3. GC 频率变高;
  4. 每次 GC 之后内存没有明显回落;
  5. 请求量没有明显放大;
  6. 最终实例被 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.1v0.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.1v0.48.0 的源码。

问题集中在这个文件:

server/streamable_http.go

重点关注几个函数和结构体:

StreamableHTTPServer
NewStreamableHTTPServer
handlePost
handleGet
handleDelete
newStreamableHttpSession
cleanupSessionState
WithSessionIdleTTL

v0.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.0handlePost

在处理 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
		}
	}
}

这段代码做了两件事:

  1. 把 session 存进 activeSessions
  2. 把 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
sessionLastActive

cleanupSessionState 会依次删除这些状态。

所以这次问题不是单个对象没释放,而是一组 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).

也就是说:

  1. 这个选项就是为了解决客户端不发送 DELETE 导致的内存泄漏;
  2. 但默认值是关闭 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.MapsessionRequestIDs sync.MapsessionLastActive sync.Map 等服务级状态;initialize 成功后,session 会被保存到 activeSessions 并注册到 MCPServer。

如果客户端没有发送 DELETE,而服务端又没有配置 WithSessionIdleTTL,这些 session 就会一直被服务端引用,GC 无法回收。

Go 有 GC,但 GC 不是万能的魔法。它只能回收不可达对象,不能判断一个还挂在 map 里的对象是否“业务上已经没用了”。

这次血案再次提醒我们:

依赖升级不是改个版本号。尤其是协议库、框架库和服务端基础库,升级前一定要看清楚默认行为和资源生命周期是否发生了变化。

最后更新于 • Q1mi