《互联网产品的服务端架构·规模化与架构演进》10.1 会话归属与路由

「回声」的 IM 从三台接入机扩到十二台后,老用户频繁掉线,新增节点却几乎空转。本节把会话归属拆成粘性、存活与迁移三条约束,给出确定性路由函数与测试,复盘一次滚动发布把在线连接连续迁移两次的故障,并说明为什么加机器不等于加容量,以及扩容时该怎么选候选节点。

扩容之后,新节点为什么空转

「回声」的即时通讯服务最初只有三台接入机,每台维持约二十万条 WebSocket 长连接(案例假设值)。单机连接数接近上限时,值班同学按手册加了九台机器,期望连接被重新分摊。结果一周过去,新机器上的连接数始终不到两万,老的三台仍然各自压着十八万条,GC 停顿和心跳超时反而更多了。

原因不在负载均衡器。连接建立时确实被均匀打散,但客户端断线重连时会带着上一次的会话信息回来,接入层为了不丢未读消息,优先把它送回原节点。原节点只要还活着,新节点就永远接不到人。扩容变成了“加机器但不加容量”。

更隐蔽的问题在发布窗口。滚动发布每次摘掉一台老节点,上面的连接被赶到其他存活节点;下一台再被摘掉时,刚迁过去的连接又可能被赶走一次。用户侧的感受是“消息发出去转圈、正在输入状态反复消失”,而监控上每台机器的 CPU 都不高。

这一节只讨论一个决定:一条会话应该落在哪个节点。把这件事写成确定性函数之后,扩容、故障转移和发布才能共用同一套判断。

会话归属是三条约束的交点

会话归属看起来像“负载均衡选一台机器”,实际要同时满足三种约束。

第一是粘性。同一条连接上的消息、已读回执、正在输入状态都带着内存态,换节点意味着这些状态要重建。重建不是不能做,但每次重建都有成本和失败可能,能不动就不动。

第二是存活。原节点宕机或正在发布时,必须有一个明确的回退顺序。回退顺序如果不确定,两个网关可能把同一条会话送到不同节点,客户端就会看到两份独立的会话状态。

第三是迁移成本。扩容时希望新节点尽快分担流量,缩容时希望旧节点尽快排空。这两个目标都和“尽量粘住原节点”冲突,所以需要一个可控的迁移入口,而不是让重连随机漂移。

常见的三种做法对比如下:

方案粘性来源故障回退扩容表现主要代价
无状态路由不保留,每次重连重新选负载均衡器决定新节点立刻分担内存态与未读计数频繁重建
纯粘性哈希会话 ID 哈希到固定节点节点挂了只能全量漂移加节点会打乱大量归属扩容等于一次大规模迁移
归属目录加健康检查目录记录 Owner,路由优先原节点按存活集合确定性回退新会话优先落到低负载节点目录本身要一致、要能回滚

「回声」最终选的是第三种。它承认“归属”是一个需要被记录的事实,而不是每次请求现算的哈希结果。目录可以很轻:一条会话一行,记录会话 ID、用户 ID、当前 Owner 和归属版本。真正重要的是路由函数:给定同一条会话、同一份节点列表和同一份健康状态,必须得到同一个答案。

这里有一个容易混淆的点:目录记录的是“上次在哪”,健康检查回答的是“现在能不能去”。两者都不能单独决定结果。只看目录,会把连接送回正在发布的节点;只看健康检查,会让每次重连都换一台机器。

目录里到底要存什么

归属目录不需要存消息,也不需要存连接对象。最小字段是会话 ID、用户 ID、Owner、归属版本和更新时间。会话 ID 是路由主键,用户 ID 用于按账号排查“这个人的连接到底在哪”,归属版本用于拒绝过期写入,更新时间用于判断记录是否已经陈旧。

目录的写入时机只有三个:新连接建立、连接主动迁移、节点确认失联。心跳不应该每次都写目录,否则一条二十万连接的节点每秒会产生大量无意义的写。心跳只更新节点级的存活状态,会话级的 Owner 只在归属真正变化时落盘。

目录本身可以是配置中心里的一张表,也可以是带 TTL 的缓存加持久化存储。选哪种取决于团队已有的运维能力,而不是取决于理论上的一致性模型。无论选哪种,都要能回答一个问题:当目录和真实连接不一致时,以谁为准?「回声」的约定是“真实连接为准,目录负责下一次路由”,并要求节点启动时先上报自己持有的连接,再开始接流量。

迁移要有预算

即使路由函数正确,也不能让迁移无限制发生。一个发布窗口内,同一条会话迁移两次以上,用户体验的损失会超过发布带来的收益。可以给每条会话维护一个“最近迁移时间”,在窗口内拒绝再次迁移,让客户端带着退避重试。

预算还可以按用户维度计算。一个用户同时有手机、平板和网页三条连接,如果三条连接分别被迁到不同节点,已读状态同步会变得非常复杂。更稳的做法是按用户而不是按连接做迁移单位:一个用户的全部连接要么一起留在原节点,要么一起迁走。

新会话与老会话走同一条路径吗

理想情况下不应该有两条路径。新会话的 Owner 为空,路由函数走“没有原归属”的分支;老会话带着 Owner,走“优先原归属”的分支。两者共用同一份候选集合、同一份存活判断和同一个排序规则,测试也只需要覆盖这两个分支。

如果新会话走一套“最少连接数”逻辑,老会话走另一套“哈希取模”逻辑,两套逻辑对“哪些节点可用”的理解迟早会分叉。分叉的那一天,就会出现“新连接都在新节点、老连接都在老节点、两边互相看不见”的割裂状态。

失败场景:滚动发布把在线用户迁了两次

事故发生在一次常规发布。接入层有六台节点,发布系统按顺序逐台摘流,每台等待三十秒排空。第一台 node-a 被摘掉时,网关把它的连接按“剩余节点里连接数最少”重新分配,大部分去了 node-f。五分钟后轮到 node-f 被摘掉,同一批连接又被赶到 node-b。用户在十分钟内经历了两次重连。

如果只是多断一次线,投诉不会这么多。真正的问题是未读消息的补拉。客户端重连后会用本地最后一条消息的序号向服务端要增量,而两次迁移之间,旧节点上的发送队列还没完全排空。第一次迁移时,一部分消息在新节点补拉不到;第二次迁移后,这部分消息又从旧队列被推送了一次。用户看到的是同一段对话里消息重复、顺序颠倒,已读状态回到更早的位置。

事后复盘,问题不是“滚动发布太快”,而是迁移决策没有和归属记录对齐。网关在选新节点时只看实时连接数,没有把“这条会话刚刚迁移过”写回目录。于是第二次摘流时,它把这条会话当成一条普通的新连接,又选了一次。目录里的 Owner 还停留在 node-a,两个网关读到的归属互相矛盾。

修复做了三件事。第一,迁移结果必须同步写回归属目录,并带上归属版本;版本落后的写入被拒绝。第二,滚动发布期间,同一会话在一个发布窗口内最多迁移一次,超过就拒绝接入并让客户端退避重试。第三,排空不是“等三十秒然后断开”,而是等待该节点上的待发队列清空或超时,二者取先到。

监控上其实早有信号,只是没人把它当成故障。第一次迁移后,node-f 的重连率从每分钟千分之三涨到百分之二;第二次迁移后,补拉消息的接口 QPS 翻了三倍。团队当时把重连率归因于“客户端网络抖动”,把补拉 QPS 归因于“用户活跃”,两个指标都没有和发布窗口对齐。复盘时把发布时间画在曲线上,两个尖峰完全重合。

还有一个边界值得记录:迁移期间如果有消息正在写入旧节点,新节点补拉时可能看不到它。这不是目录能解决的问题,而是消息同步要解决的问题。路由只负责把人送到正确的节点,不能保证两个节点上的数据已经一致。下一节会看到,消息序号才是判断“缺了什么”的锚点。

这三件事里,第一件是根因修复,后两件是止血。只做后两件,下一次换一个发布工具,同样的重复推送还会回来。

把选择写成确定性函数

配套代码 internal/immig/session.go 只做路由选择,不碰目录存储、健康检查采集和连接建立。它接收一条会话、候选节点集合和存活表,返回一个节点名或一个错误。把副作用全部留在调用方,才能用普通单元测试锁住“同一输入同一输出”。

// file: internal/immig/session.go
// tier: L1
// verify: go test ./internal/immig/

package immig

import (
	"fmt"
	"sort"
)

// Session 是一条长连接在路由层的表示。ID 是连接标识,UserID 是账号,
// Owner 是当前持有这条连接的节点。Owner 可以为空,表示新连接尚未归属。
type Session struct {
	ID     string
	UserID string
	Owner  string
}

// Route 为会话选择接入节点。规则只有两条:
// 1. 原 Owner 仍在配置集合中且存活,继续留在原节点,避免无谓迁移;
// 2. 否则按节点名排序后选择第一个存活节点,保证同一输入得到同一结果。
//
// owners 是本次部署允许承载会话的节点集合,alive 是当前健康检查结果。
// owners 为空或没有任何存活节点时返回错误,调用方应拒绝新连接而不是
// 猜一个节点。
func Route(s Session, owners []string, alive map[string]bool) (string, error) {
	if s.ID == "" {
		return "", fmt.Errorf("immig: session id is empty")
	}
	if len(owners) == 0 {
		return "", fmt.Errorf("immig: session %q has no candidate owners", s.ID)
	}

	set := make(map[string]struct{}, len(owners))
	for _, o := range owners {
		if o == "" {
			return "", fmt.Errorf("immig: session %q has empty owner name", s.ID)
		}
		set[o] = struct{}{}
	}

	if _, ok := set[s.Owner]; ok && alive[s.Owner] {
		return s.Owner, nil
	}

	candidates := make([]string, 0, len(set))
	for o := range set {
		if alive[o] {
			candidates = append(candidates, o)
		}
	}
	if len(candidates) == 0 {
		return "", fmt.Errorf("immig: session %q has no alive owner", s.ID)
	}
	sort.Strings(candidates)
	return candidates[0], nil
}

实现里有三个刻意的选择。

第一,原 Owner 必须同时满足“在候选集合里”和“存活表中为真”。只在存活表里为真还不够:候选集合代表这次部署允许承载会话的节点,可能是缩容后的子集,也可能排除了正在维护的机器。一个已经下线的节点即使健康检查还没更新,也不应该继续接流量。

第二,回退时先收集所有存活候选,再排序取第一个,而不是遍历 owners 取第一个存活的。owners 的顺序可能来自配置中心、数据库查询或手工输入,不同进程读到的顺序不一定一致。排序把“确定性”从输入约定变成了函数自身的保证。

第三,没有存活节点时返回错误,不返回空字符串或默认节点。空字符串会让调用方以为路由成功,然后把连接建到一个不存在的地方;默认节点则会把所有流量压到一台机器上,故障范围反而扩大。拒绝新连接、让客户端退避,是更诚实的失败方式。

alive 表由调用方传入,函数不关心它是进程内维护的还是从注册中心拉取的。这带来一个必须写清楚的约定:存活表可以短暂过期,但过期方向必须安全。健康检查刚发现节点失联、路由还没更新时,最坏结果是连接失败一次;反过来,节点已经恢复、路由还没更新时,最坏结果是一条会话暂时回不到原节点。前者可以重试,后者会触发一次不必要的迁移,所以存活表的更新策略应该偏向“快速标记为不可用,谨慎标记为可用”。

代码没有解决“新会话如何挑一台低负载节点”。它只保证:如果调用方传入的候选集合已经按负载排好序,函数会在存活节点里选第一个。把负载策略放在候选集合的生成侧,路由函数就能保持纯粹,也更容易替换。

测试:原 owner、存活回退与坏输入

路由函数最怕的不是算错一次,而是“两台网关算出两个答案”。下面的测试把原归属优先、回退确定性、新会话选择和坏输入都固定下来。

// file: internal/immig/session_test.go
// tier: L1
// verify: go test ./internal/immig/

package immig

import "testing"

func TestRouteKeepsOriginalOwner(t *testing.T) {
	s := Session{ID: "conn-1", UserID: "user-1", Owner: "node-b"}
	alive := map[string]bool{"node-a": true, "node-b": true}
	got, err := Route(s, []string{"node-a", "node-b"}, alive)
	if err != nil {
		t.Fatalf("Route: %v", err)
	}
	if got != "node-b" {
		t.Fatalf("Route = %q, want original owner node-b", got)
	}
}

func TestRouteFallsBackDeterministically(t *testing.T) {
	s := Session{ID: "conn-2", UserID: "user-2", Owner: "node-b"}
	alive := map[string]bool{"node-a": true, "node-c": true}
	orders := [][]string{
		{"node-c", "node-b", "node-a"},
		{"node-a", "node-b", "node-c"},
		{"node-b", "node-a", "node-c"},
	}
	for _, owners := range orders {
		got, err := Route(s, owners, alive)
		if err != nil {
			t.Fatalf("Route(%v): %v", owners, err)
		}
		if got != "node-a" {
			t.Fatalf("Route(%v) = %q, want node-a", owners, got)
		}
	}
}

func TestRouteNewSessionPicksSortedAlive(t *testing.T) {
	s := Session{ID: "conn-3", UserID: "user-3"}
	alive := map[string]bool{"node-c": true, "node-a": true}
	got, err := Route(s, []string{"node-c", "node-b", "node-a"}, alive)
	if err != nil {
		t.Fatalf("Route: %v", err)
	}
	if got != "node-a" {
		t.Fatalf("Route = %q, want node-a", got)
	}
}

func TestRouteRejectsBadInput(t *testing.T) {
	alive := map[string]bool{"node-a": true}
	cases := []struct {
		name   string
		s      Session
		owners []string
	}{
		{"empty id", Session{ID: "", Owner: "node-a"}, []string{"node-a"}},
		{"no owners", Session{ID: "conn-4"}, nil},
		{"empty owner name", Session{ID: "conn-5"}, []string{"node-a", ""}},
	}
	for _, tc := range cases {
		if _, err := Route(tc.s, tc.owners, alive); err == nil {
			t.Fatalf("%s: want error, got nil", tc.name)
		}
	}
}

func TestRouteRejectsNoAliveOwner(t *testing.T) {
	s := Session{ID: "conn-6", Owner: "node-a"}
	alive := map[string]bool{"node-a": false, "node-b": false}
	if _, err := Route(s, []string{"node-a", "node-b"}, alive); err == nil {
		t.Fatal("want error when no owner is alive")
	}
}

TestRouteFallsBackDeterministically 是这组测试的核心。它把同一份候选集合用三种顺序传入,要求结果始终是 node-a。如果实现改成“遍历输入取第一个存活节点”,这个用例会立刻失败,因为第二种顺序会先遇到 node-a、第一种顺序会先遇到 node-c。

TestRouteKeepsOriginalOwner 覆盖了粘性分支:原 Owner 存活时必须原地不动,哪怕候选集合里有更靠前的节点。这条规则如果不锁住,后来的人很容易为了“更均衡”加一个负载判断,把粘性悄悄改掉。

TestRouteRejectsBadInput 把空会话 ID、空候选集合和空节点名归为错误。空会话 ID 无法写目录,空候选集合通常意味着配置没加载,空节点名则是配置里混入了脏数据。三者都应该在入口失败,而不是等到建立连接时才报错。

测试没有覆盖连接数上限。单机容量、文件描述符、内存水位都属于节点选择的上游约束,应该在生成候选集合时剔除,而不是塞进这个函数。把容量判断混进来,路由函数就需要访问监控系统,单元测试也就无法独立运行。

还有一类验证只能在进程外做:启动两个独立进程,读取同一份候选集合和存活表,对一千条会话分别输出节点名,再逐行比对。单元测试能证明同一进程内稳定,进程外比对才能发现“依赖了未导出全局状态”或“初始化时随机种子不同”这类问题。这项比对不需要真实网络,跑一次几秒钟,适合放进发布前检查。

什么时候可以不写目录

如果产品只有单地域、单集群,节点数量不超过十台,且允许重连后重建全部内存态,那么可以只用粘性哈希,不维护归属目录。代价是扩缩容时会有一次集中的连接漂移,需要挑低峰期执行。

一旦出现多地域、跨机房容灾、按用户迁移或“发布期间连接不能丢”的要求,目录就从可选变成必需。判断标准不是团队规模,而是“同一条会话在两个地方同时存在会不会造成业务错误”。会,就必须有单一归属。

目录不可用时怎么办

目录是控制面,不是数据面。它挂掉的几秒里,已经建立的连接不应该被全部断开。合理的降级是:读路径使用进程内缓存的归属结果,写路径把迁移记录放进本地队列,恢复后按版本重放。缓存必须带 TTL,且过期后只允许“原节点仍存活就继续用”,不允许凭空选一个新节点。

如果目录长时间不可用,新连接应该被拒绝,而不是让每个网关各自维护一份归属。多份归属同时生效,比短暂拒绝新连接危险得多:前者会造成消息双写和状态分叉,后者只影响新增用户,且恢复后可以重试。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/immig/ -run 'TestRoute' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestRouteKeepsOriginalOwner
--- PASS: TestRouteKeepsOriginalOwner (0.00s)
=== RUN   TestRouteFallsBackDeterministically
--- PASS: TestRouteFallsBackDeterministically (0.00s)
=== RUN   TestRouteNewSessionPicksSortedAlive
--- PASS: TestRouteNewSessionPicksSortedAlive (0.00s)
=== RUN   TestRouteRejectsBadInput
--- PASS: TestRouteRejectsBadInput (0.00s)
=== RUN   TestRouteRejectsNoAliveOwner
--- PASS: TestRouteRejectsNoAliveOwner (0.00s)
PASS
ok  	webapp/internal/immig	0.375s

五个用例全部通过,退出码为 0。-run 只筛了路由相关用例,同包的同步与切换测试没有执行;需要全量确认时去掉 -run 再跑一次。

小结

会话归属不是一次负载均衡计算,而是一份需要被记录、被版本化、被回滚的契约。粘性决定用户体验,存活决定故障行为,迁移成本决定扩容是否真的有效。三者冲突时,优先保证“同一会话在同一时刻只有一个明确归属”,再谈均衡。

把路由写成确定性纯函数,是这份契约最小的可验证单元。它不能替你决定目录怎么存、健康检查多久刷新、发布窗口多长,但它能保证:只要输入相同,所有网关都会给出同一个答案。后面的消息同步和切换演练,都建立在这个前提上。

阅读导航:上一节:目录与学习路径 · 下一节:10.2 消息同步与顺序 。

常见错误与边界

  • 把“连接建立时均衡”当成“容量已经均衡”。长连接不会因为新增机器而自动搬家,扩容必须配合归属迁移。
  • 只按实时连接数选节点,不记录迁移结果。下一次摘流时,同一条会话会被当成新连接再选一次。
  • 用未排序的候选列表做回退。配置顺序在不同进程间可能不同,路由结果就会分叉。
  • 把健康检查当成唯一依据。健康但不在候选集合里的节点,通常是正在缩容或维护的节点,不应该接新流量。
  • 路由失败时返回默认节点。默认节点会承接全部异常流量,把局部故障放大成全局故障。
  • 在路由函数里读取监控或数据库。副作用会让单元测试失效,也会让每次重连的延迟变得不可控。
  • 迁移只写内存不写目录。进程重启或网关扩容后,归属信息丢失,粘性随之消失。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查