《互联网产品的服务端架构·内容、社交与实时互动》8.1 会话、消息与序号

从两个客户端同时发消息却看到不同顺序的事故写起,说明单聊会话 ID 如何由双方 ID 排序后哈希生成,比较会话内序号与全局序号两种方案,给出消息校验、按发送时间与 ID 分配序号的 Go 实现、连续序号测试和真实运行输出,并列出消息模型设计的常见错误。

两个客户端同时发消息,消息顺序却相反

「回声」的私信功能在内测时出现过一个很难复现的问题:A 和 B 几乎同时给对方发消息,A 手机上看到的是“A 的消息在前”,B 手机上看到的是“B 的消息在前”。刷新一次顺序又变了,已读位置也跟着跳。

一开始团队怀疑是客户端排序不稳定。排查后发现,服务端给两条消息分配了同一个序号:两个网关实例同时读到会话当前最大序号 100,各自加一,都写入了 101。客户端按序号排序,遇到相同序号时再按消息 ID 排,而两端拿到的消息 ID 不同,于是顺序自然不一致。

这个事故暴露了两个设计问题:会话 ID 没有统一生成规则,导致同一对用户可能对应多个会话;序号没有明确“在哪个范围内单调”,应用层各自实现了一套自增逻辑。

会话 ID 与序号的三个决定

消息模型看起来只有几个字段,真正需要提前定清楚的是三件事:会话怎么标识、序号在哪里递增、重发怎么去重。下面先给对照,再逐项解释。

方案序号生成位置优点代价适用判断
全局自增全局序列或发号器全站唯一,排序简单热点集中,跨会话无意义不推荐作为消息序号
会话内自增会话元数据或存储原子递增顺序语义清晰,易分页需要原子操作与幂等推荐
客户端时间戳发送端无需服务端协调时钟漂移,无法信任只可作为展示参考
客户端本地序号发送端离线可用冲突、重复、无法合并不用于服务端排序

单聊会话 ID 怎么生成

单聊的会话 ID 必须由双方用户 ID 唯一确定。常见做法是把两个 ID 按字典序排序,拼接后做哈希,再加一个类型前缀,例如 single: 加 SHA-256 的前 16 个字节。这样 A 对 B 和 B 对 A 得到同一个 ID,服务端不需要先查“是否已有会话”,也不会因为并发创建产生两个会话。

群聊不同:群 ID 通常由服务端生成,创建时一次性确定,成员变化不改变 ID。系统会话则可以按业务键生成,例如 system:order:123,保证同一订单的通知落在一个会话里。

序号应该会话内单调还是全局单调

消息只需要在会话内有序。全局单调会引入一个所有会话共享的热点,群聊越多、写入越密,发号器越容易成为瓶颈;而跨会话的全局顺序对用户没有意义。因此更合理的约束是:同一个 ConvID 内,Seq 严格递增;不同会话之间不比较 Seq。

会话内序号通常由存储层原子递增,例如在会话元数据上执行 seq = seq + 1 并返回新值,或者在消息表上按会话取最大值加锁。无论哪种实现,都必须保证“读最大值”和“写新消息”是一个原子步骤,否则就会重现两个网关同时拿到 101 的事故。

客户端重发时序号怎么处理

客户端超时重发时,服务端不能简单再分配一个新序号,否则同一条内容会出现两次。正确的做法是让客户端为每条消息生成一个稳定的消息 ID(或幂等键),服务端在会话内按该 ID 去重:第一次写入时分配序号,后续重发直接返回已存在的消息。

序号分配函数本身也应该只做“排序和编号”,不要承担去重职责。去重需要存储层的唯一约束配合,属于写入路径;把两件事混在一个纯函数里,测试会变得很重,也容易让调用方误以为调用一次就完成了幂等。

失败场景:两个网关同时分配了 101

回到内测事故。当时的写入路径是:网关先从缓存读取会话的 last_seq,在内存里加一,再写消息并把新值写回缓存。两个请求几乎同时到达不同网关,都读到 100,都算出 101。

后果不止是顺序错乱:

  • 客户端按 Seq 做已读游标,101 出现两条后,游标推进到 101 会跳过其中一条,未读数永远对不上。
  • 漫游接口按 Seq < before 分页,相同 Seq 的两条消息可能一页显示、一页丢失。
  • 消息去重如果只按 Seq 判断,会把另一条真实消息当成重复丢弃。
  • 监控上只看到“消息写入成功”,没有任何错误日志,问题只能靠用户投诉发现。

案例假设值:内测群有 200 人,晚高峰每分钟约 600 条消息,冲突大约每几千条出现一次。比例不高,但每一条都会留下一个需要人工修复的会话。修复方式是把序号分配收口到存储层的原子操作,并在消息表上建立 (conv_id, seq) 唯一索引,让冲突在写入时直接失败,而不是悄悄产生两条 101。

Go 实现:校验消息与分配会话序号

下面的代码来自本书配套的 webapp-lab。Message 同时保留消息 ID、会话 ID、会话内序号、发送者、正文和发送时间;Validate 负责身份字段、序号和正文长度;AssignSeq 只负责按时间和 ID 排序后分配连续序号。

// file: internal/im/message.go
// tier: L1
// verify: go test ./internal/im/

package im

import (
	"errors"
	"fmt"
	"sort"
	"unicode/utf8"
)

// maxBodyRunes 是单条消息正文的上限。按字符数而不是字节数计算,
// 避免中文用户比英文用户更早触发限制。
const maxBodyRunes = 4000

// Message 是一条会话内消息。Seq 是会话内序号,不是全局 ID。
type Message struct {
	ID       string
	ConvID   string
	Seq      int64
	SenderID string
	Body     string
	At       int64
}

// Validate 检查消息的身份字段、序号和正文长度。
func (m Message) Validate() error {
	switch {
	case m.ID == "":
		return errors.New("im: message id is empty")
	case m.ConvID == "":
		return errors.New("im: conversation id is empty")
	case m.SenderID == "":
		return errors.New("im: sender id is empty")
	case m.Seq <= 0:
		return fmt.Errorf("im: seq %d must be positive", m.Seq)
	case utf8.RuneCountInString(m.Body) > maxBodyRunes:
		return fmt.Errorf("im: body has %d runes, limit %d",
			utf8.RuneCountInString(m.Body), maxBodyRunes)
	}
	return nil
}

// AssignSeq 按发送时间排序,为同一会话的消息分配连续序号。
// last 是会话当前最大序号;返回值是新切片,入参不会被修改。
// 同一时刻的消息按消息 ID 升序排列,保证每次分配结果一致。
func AssignSeq(last int64, msgs []Message) ([]Message, error) {
	if last < 0 {
		return nil, fmt.Errorf("im: last seq %d must not be negative", last)
	}
	out := make([]Message, len(msgs))
	copy(out, msgs)
	conv := ""
	for i := range out {
		m := &out[i]
		if m.ID == "" || m.ConvID == "" || m.SenderID == "" {
			return nil, fmt.Errorf("im: message %d has empty identity field", i)
		}
		if utf8.RuneCountInString(m.Body) > maxBodyRunes {
			return nil, fmt.Errorf("im: message %q body exceeds %d runes", m.ID, maxBodyRunes)
		}
		if conv == "" {
			conv = m.ConvID
		} else if m.ConvID != conv {
			return nil, fmt.Errorf("im: mixed conversations %q and %q", conv, m.ConvID)
		}
	}
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].At != out[j].At {
			return out[i].At < out[j].At
		}
		return out[i].ID < out[j].ID
	})
	for i := range out {
		out[i].Seq = last + int64(i) + 1
	}
	return out, nil
}

几个刻意的选择:

  • Seq 使用 int64,避免长时间运行的会话把 int32 用满;At 用 Unix 秒,排序时只作为第一关键字。
  • 正文上限按 utf8.RuneCountInString 计算。4000 个中文字符和 4000 个英文字母应该受到同样的限制,按字节算会让中文用户提前被拒。
  • AssignSeq 先复制入参再排序。调用方经常需要保留原始顺序做审计或重试,如果函数原地排序,排查问题时看到的顺序会和请求日志不一致。
  • 同一时刻的消息按 ID 升序。时间戳精度有限,同一毫秒内多条消息很常见;没有稳定的第二关键字,排序结果会随输入顺序变化。
  • 混入不同 ConvID 直接报错。序号是会话内概念,跨会话批量分配没有意义,早失败比生成一堆看似合法的序号更安全。

AssignSeq 不检查 Seq 字段,因为它本来就要覆盖这个字段;但它会检查消息 ID、会话 ID、发送者和正文长度。这个边界需要在文档里写清楚,否则调用方会以为它等价于逐条调用 Validate。

测试:排序、连续序号与入参不变

测试重点不是“能分配出序号”,而是三个容易退化的性质:排序稳定、序号连续、入参不被修改。

// file: internal/im/message_test.go
// tier: L1
// verify: go test ./internal/im/

package im

import (
	"reflect"
	"strings"
	"testing"
)

func TestValidateRejectsBadMessages(t *testing.T) {
	valid := Message{ID: "m1", ConvID: "c1", Seq: 1, SenderID: "u1", Body: "hi", At: 100}
	cases := []struct {
		name string
		mod  func(Message) Message
	}{
		{"empty id", func(m Message) Message { m.ID = ""; return m }},
		{"empty conversation", func(m Message) Message { m.ConvID = ""; return m }},
		{"empty sender", func(m Message) Message { m.SenderID = ""; return m }},
		{"zero seq", func(m Message) Message { m.Seq = 0; return m }},
		{"body too long", func(m Message) Message {
			m.Body = strings.Repeat("中", maxBodyRunes+1)
			return m
		}},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if err := tc.mod(valid).Validate(); err == nil {
				t.Fatalf("Validate(%s) = nil, want error", tc.name)
			}
		})
	}
	if err := valid.Validate(); err != nil {
		t.Fatalf("valid.Validate() = %v, want nil", err)
	}
}

func TestValidateCountsRunesNotBytes(t *testing.T) {
	m := Message{
		ID: "m1", ConvID: "c1", Seq: 1, SenderID: "u1",
		Body: strings.Repeat("中", maxBodyRunes),
		At:   100,
	}
	if err := m.Validate(); err != nil {
		t.Fatalf("4000 Chinese characters should be valid, got %v", err)
	}
}

func TestAssignSeqSortsAndNumbers(t *testing.T) {
	input := []Message{
		{ID: "m3", ConvID: "c1", SenderID: "u2", Body: "third", At: 200},
		{ID: "m1", ConvID: "c1", SenderID: "u1", Body: "first", At: 100},
		{ID: "m2", ConvID: "c1", SenderID: "u2", Body: "second", At: 200},
	}
	original := make([]Message, len(input))
	copy(original, input)

	got, err := AssignSeq(100, input)
	if err != nil {
		t.Fatalf("AssignSeq() error = %v", err)
	}
	wantIDs := []string{"m1", "m2", "m3"}
	for i, m := range got {
		if m.ID != wantIDs[i] {
			t.Fatalf("got[%d].ID = %q, want %q", i, m.ID, wantIDs[i])
		}
		if want := int64(101 + i); m.Seq != want {
			t.Fatalf("%s Seq = %d, want %d", m.ID, m.Seq, want)
		}
	}
	if !reflect.DeepEqual(input, original) {
		t.Fatalf("AssignSeq mutated input: %+v", input)
	}
}

func TestAssignSeqEmptyInput(t *testing.T) {
	got, err := AssignSeq(7, nil)
	if err != nil {
		t.Fatalf("AssignSeq(nil) error = %v", err)
	}
	if got == nil || len(got) != 0 {
		t.Fatalf("AssignSeq(nil) = %#v, want empty non-nil slice", got)
	}
}

func TestAssignSeqRejectsMixedConversations(t *testing.T) {
	_, err := AssignSeq(0, []Message{
		{ID: "m1", ConvID: "c1", SenderID: "u1", At: 1},
		{ID: "m2", ConvID: "c2", SenderID: "u1", At: 2},
	})
	if err == nil {
		t.Fatal("AssignSeq() = nil error for mixed conversations")
	}
}

func TestAssignSeqRejectsNegativeLast(t *testing.T) {
	if _, err := AssignSeq(-1, nil); err == nil {
		t.Fatal("AssignSeq(-1) = nil error")
	}
}

TestAssignSeqSortsAndNumbers 构造了三类输入:乱序、同一时刻、以及带初始 Seq 的消息。期望结果按 At 升序、同一时刻按 ID 升序,序号从 last+1 开始连续递增;同时用深比较确认入参没有被排序或改写。

TestValidateCountsRunesNotBytes 用 4000 个中文字符验证上限按字符数计算。如果实现改成 len(m.Body),这个用例会失败。

TestAssignSeqRejectsMixedConversations 和 TestAssignSeqRejectsNegativeLast 覆盖参数边界。前者防止调用方把多个会话的消息混在一批里,后者防止负的起始序号把整批消息编号成 0 或负数。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/im/ -run '^(TestValidateRejectsBadMessages|TestValidateCountsRunesNotBytes|TestAssignSeqSortsAndNumbers|TestAssignSeqEmptyInput|TestAssignSeqRejectsMixedConversations|TestAssignSeqRejectsNegativeLast)$' -count=1 -v

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

=== RUN   TestValidateRejectsBadMessages
=== RUN   TestValidateRejectsBadMessages/empty_id
=== RUN   TestValidateRejectsBadMessages/empty_conversation
=== RUN   TestValidateRejectsBadMessages/empty_sender
=== RUN   TestValidateRejectsBadMessages/zero_seq
=== RUN   TestValidateRejectsBadMessages/body_too_long
--- PASS: TestValidateRejectsBadMessages (0.00s)
    --- PASS: TestValidateRejectsBadMessages/empty_id (0.00s)
    --- PASS: TestValidateRejectsBadMessages/empty_conversation (0.00s)
    --- PASS: TestValidateRejectsBadMessages/empty_sender (0.00s)
    --- PASS: TestValidateRejectsBadMessages/zero_seq (0.00s)
    --- PASS: TestValidateRejectsBadMessages/body_too_long (0.00s)
=== RUN   TestValidateCountsRunesNotBytes
--- PASS: TestValidateCountsRunesNotBytes (0.00s)
=== RUN   TestAssignSeqSortsAndNumbers
--- PASS: TestAssignSeqSortsAndNumbers (0.00s)
=== RUN   TestAssignSeqEmptyInput
--- PASS: TestAssignSeqEmptyInput (0.00s)
=== RUN   TestAssignSeqRejectsMixedConversations
--- PASS: TestAssignSeqRejectsMixedConversations (0.00s)
=== RUN   TestAssignSeqRejectsNegativeLast
--- PASS: TestAssignSeqRejectsNegativeLast (0.00s)
PASS
ok  	webapp/internal/im	0.272s

PASS 表示校验、排序和边界用例全部通过。测试里的 0.00s 是纯内存操作耗时,不代表真实写入延迟;生产路径的序号分配还需要数据库原子操作或分布式锁,这部分不在纯函数里。

小结

消息模型的核心不是字段多少,而是两个约束:单聊会话 ID 由双方 ID 稳定生成,消息序号在会话内严格递增。序号应该由存储层原子分配,客户端重发要用稳定的消息 ID 去重,不能靠再分配一个新序号解决。

Message、Validate 和 AssignSeq 把这套语义固定下来:身份字段不能为空,正文按字符数限制,排序按时间和 ID 稳定进行,入参不被修改。下一节讨论单聊、群聊和系统消息的权限差异,以及系统消息为什么不能被普通用户回复。

阅读导航:上一节:目录与学习路径 · 下一节:8.2 单聊、群聊与系统消息 。

常见错误与边界

  • 让两个网关各自读最大值再加一。并发请求会拿到同一个 Seq,消息顺序在两端不一致;必须用存储层原子操作。
  • 用全局自增序号给消息排序。跨会话顺序没有业务意义,却把发号器变成所有写入的共享热点。
  • 单聊会话 ID 由客户端随机生成。同一对用户会产生多个会话,历史消息被拆散,已读状态也无法合并。
  • 正文上限按字节计算。中文用户会比英文用户更早触达限制,同一业务规则对不同语言不公平。
  • 重发时重新分配序号。同一条内容会出现两次,且占用两个序号;应该用稳定的消息 ID 做幂等。
  • AssignSeq 原地排序入参。调用方保留的请求顺序被改写,审计和重试逻辑会出现难以解释的差异。
  • 同一时刻的消息没有稳定的第二排序键。Go 的排序不保证稳定顺序,测试和线上都可能随机抖动。
  • 允许跨会话批量分配序号。序号语义被破坏,后续分页和已读游标无法按会话解释。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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