《互联网产品的服务端架构·内容、社交与实时互动》8.3 消息存储与漫游

从换手机拉取三年聊天记录把数据库打满的事故写起,比较按会话分片、按时间分区与按用户分片的代价,说明漫游为什么必须用游标分页,给出保留裁剪 Keep 与 RoamPage 的 Go 实现、边界测试和真实运行输出,并列出消息存储与漫游的常见错误。

换手机拉三年记录,数据库被打满

「回声」支持消息漫游。用户换手机后,客户端会拉取历史消息。最初接口没有分页,默认返回该会话全部记录。普通用户只有几千条,测试没问题;一位用了三年的重度用户,单个会话有 20 多万条消息。

案例假设值:这次请求返回约 200 MB 数据,序列化和网络传输持续了十几秒,数据库连接被占用,连接池很快耗尽,同一实例上的发送接口开始超时。用户端看到的是“一直转圈”,重试又发起同样的全量查询,形成雪崩。

问题不是“历史消息太多”,而是把在线读路径当成了离线导出:没有游标、没有条数上限、没有冷热分离。消息存储和漫游必须一起设计。

按会话分片、按时间分区

消息的访问模式非常集中:绝大多数请求只读某个会话的最新一页,少数请求按时间往回翻。存储布局应该围绕这个模式设计。

布局分片键或分区键优点代价适用
按会话分片conv_id单会话读写集中,顺序好大群或热会话倾斜主路径
按时间分区created_at冷热分离,过期删除便宜跨时间查询要合并归档与清理
按用户分片user_id收件箱查询快群消息写扩散放大收件箱表
全局单表无实现简单数据量一大就不可维护原型

会话分片解决什么

按 conv_id 分片,能让同一会话的消息落在同一节点,序号分配、分页和已读游标都可以局部完成。缺点是热会话:一个万人群的消息量可能超过几万个普通单聊,分片键相同就会形成热点。常见缓解是把超大群拆成子分区,或者把群消息的存储与成员收件箱分开。

时间分区解决什么

按时间分区让“删除三年前的消息”变成删除整个分区,而不是逐行 DELETE。它也让冷数据可以放到更便宜的存储。代价是漫游跨时间范围时要合并多个分区,所以查询必须带时间或序号边界,不能无条件扫描。

在线路径只读最新一页

在线漫游接口应该只做一件事:给定会话和游标,返回固定条数。默认不带游标时返回最新一页;用户往上翻时,用上一页最小 Seq 作为 before。任何“返回全部历史”的参数都不应该存在。

失败场景:没有游标的全量漫游

那次事故的接口签名大致是 GET /conversations/<built-in function id>/messages,没有 limit,也没有 before。客户端换机时调用它,服务端把该会话所有消息查出来,按时间升序返回。

后果是连锁的:

  • 单次查询扫描 20 多万行,占用数据库连接十几秒。
  • 结果集在应用层组装成切片,内存峰值超过实例限制,触发 GC 抖动。
  • 客户端解析 200 MB JSON,低端机直接闪退。
  • 用户重试放大查询,连接池耗尽,发送和已读接口一起超时。

修复不是简单加一个 LIMIT 100。如果排序和游标不对,用户往上翻时会重复或跳过消息。正确做法是固定排序为 Seq DESC,用 before 作为游标,返回后再由客户端反转展示;同时把保留策略落到存储层,过期数据不再参与查询。

Go 实现:保留裁剪与漫游分页

Keep 同时处理时间保留和每会话条数上限,RoamPage 处理游标分页。两个函数都不修改入参,方便在写入、归档和测试之间复用。

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

package im

import (
	"fmt"
	"sort"
)

const secondsPerDay = 86400

// Policy 是单个会话的消息保留策略。
type Policy struct {
	RetentionDays int
	MaxPerConv    int
}

// Keep 同时按时间和条数裁剪消息。它按会话分组,保留最新 MaxPerConv 条,
// 并丢弃早于 now-RetentionDays 的消息。返回值是新切片,入参不被修改。
// 结果按会话 ID、再按 Seq 升序排列,便于下游做稳定 diff。
func Keep(messages []Message, now int64, p Policy) ([]Message, error) {
	if p.RetentionDays <= 0 {
		return nil, fmt.Errorf("im: retention days %d must be positive", p.RetentionDays)
	}
	if p.MaxPerConv <= 0 {
		return nil, fmt.Errorf("im: max per conversation %d must be positive", p.MaxPerConv)
	}
	if now <= 0 {
		return nil, fmt.Errorf("im: now %d must be positive", now)
	}
	cutoff := now - int64(p.RetentionDays)*secondsPerDay
	byConv := make(map[string][]Message)
	for i, m := range messages {
		if m.ConvID == "" {
			return nil, fmt.Errorf("im: message %d has empty conversation id", i)
		}
		if m.Seq <= 0 {
			return nil, fmt.Errorf("im: message %q has non-positive seq", m.ID)
		}
		if m.At < cutoff {
			continue
		}
		byConv[m.ConvID] = append(byConv[m.ConvID], m)
	}
	kept := make([]Message, 0, len(messages))
	for _, conv := range sortedKeys(byConv) {
		msgs := byConv[conv]
		sort.SliceStable(msgs, func(i, j int) bool {
			if msgs[i].Seq != msgs[j].Seq {
				return msgs[i].Seq < msgs[j].Seq
			}
			if msgs[i].At != msgs[j].At {
				return msgs[i].At < msgs[j].At
			}
			return msgs[i].ID < msgs[j].ID
		})
		if len(msgs) > p.MaxPerConv {
			msgs = msgs[len(msgs)-p.MaxPerConv:]
		}
		kept = append(kept, msgs...)
	}
	return kept, nil
}

// RoamPage 返回 Seq 小于 before 的最新 limit 条消息,按 Seq 倒序排列。
// before <= 0 表示从最新一条开始。调用方应只传入同一会话的消息。
func RoamPage(messages []Message, before int64, limit int) ([]Message, error) {
	if before < 0 {
		return nil, fmt.Errorf("im: before %d must not be negative", before)
	}
	if limit <= 0 {
		return nil, fmt.Errorf("im: limit %d must be positive", limit)
	}
	copied := make([]Message, 0, len(messages))
	for i, m := range messages {
		if m.ID == "" || m.ConvID == "" {
			return nil, fmt.Errorf("im: message %d has empty identity field", i)
		}
		if m.Seq <= 0 {
			return nil, fmt.Errorf("im: message %q has non-positive seq", m.ID)
		}
		if before > 0 && m.Seq >= before {
			continue
		}
		copied = append(copied, m)
	}
	sort.SliceStable(copied, func(i, j int) bool {
		if copied[i].Seq != copied[j].Seq {
			return copied[i].Seq > copied[j].Seq
		}
		return copied[i].ID > copied[j].ID
	})
	if len(copied) > limit {
		copied = copied[:limit]
	}
	return copied, nil
}

func sortedKeys(m map[string][]Message) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}

几个设计取舍:

  • 保留期按天配置,内部换算成秒。At 使用 Unix 秒,边界比较简单;如果业务需要毫秒,应该在入口统一换算,而不是让两个函数各自猜单位。
  • Keep 先按 ConvID 分组,再在组内按 Seq 排序,保留最后 MaxPerConv 条。这样“最新 N 条”由序号决定,不受输入顺序影响。
  • 时间过期和条数上限是“或”的关系:任一条件满足就裁掉。只做时间保留,热会话仍然可能积累过多消息;只做条数上限,冷会话的旧数据永远删不掉。
  • RoamPage 在 before > 0 时过滤 Seq >= before,保证游标严格向前。before <= 0 表示从最新一条开始,方便首次进入会话。
  • 返回结果按 Seq 倒序,客户端可以直接渲染“最新在上”;如果需要正序,应在展示层反转,而不是让服务端返回两种顺序。

测试:过期、超限与游标边界

测试覆盖三个容易出错的边界:保留期边界、每会话上限、游标是否严格向前。

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

package im

import "testing"

func TestKeepDropsExpiredMessages(t *testing.T) {
	now := int64(10 * secondsPerDay)
	msgs := []Message{
		{ID: "m1", ConvID: "c1", Seq: 1, At: 6 * secondsPerDay},
		{ID: "m2", ConvID: "c1", Seq: 2, At: 7 * secondsPerDay},
		{ID: "m3", ConvID: "c2", Seq: 1, At: 9 * secondsPerDay},
	}
	got, err := Keep(msgs, now, Policy{RetentionDays: 3, MaxPerConv: 100})
	if err != nil {
		t.Fatalf("Keep() error = %v", err)
	}
	if len(got) != 2 || got[0].ID != "m2" || got[1].ID != "m3" {
		t.Fatalf("Keep() = %+v, want [m2 m3]", got)
	}
}

func TestKeepCapsPerConversation(t *testing.T) {
	now := int64(10 * secondsPerDay)
	var msgs []Message
	for i := 1; i <= 4; i++ {
		msgs = append(msgs, Message{ID: "a" + string(rune('0'+i)), ConvID: "c1", Seq: int64(i), At: now})
	}
	msgs = append(msgs, Message{ID: "b1", ConvID: "c2", Seq: 1, At: now})
	got, err := Keep(msgs, now, Policy{RetentionDays: 30, MaxPerConv: 2})
	if err != nil {
		t.Fatalf("Keep() error = %v", err)
	}
	if len(got) != 3 {
		t.Fatalf("Keep() kept %d messages, want 3", len(got))
	}
	if got[0].ID != "a3" || got[1].ID != "a4" || got[2].ID != "b1" {
		t.Fatalf("Keep() = %+v, want [a3 a4 b1]", got)
	}
}

func TestKeepDoesNotMutateInput(t *testing.T) {
	now := int64(10 * secondsPerDay)
	msgs := []Message{
		{ID: "m2", ConvID: "c1", Seq: 2, At: now},
		{ID: "m1", ConvID: "c1", Seq: 1, At: now},
	}
	got, err := Keep(msgs, now, Policy{RetentionDays: 1, MaxPerConv: 1})
	if err != nil {
		t.Fatalf("Keep() error = %v", err)
	}
	if len(got) != 1 || got[0].ID != "m2" {
		t.Fatalf("Keep() = %+v, want [m2]", got)
	}
	if msgs[0].ID != "m2" || msgs[1].ID != "m1" {
		t.Fatalf("Keep() mutated input order: %+v", msgs)
	}
}

func TestKeepRejectsBadPolicy(t *testing.T) {
	msgs := []Message{{ID: "m1", ConvID: "c1", Seq: 1, At: 100}}
	cases := []Policy{
		{RetentionDays: 0, MaxPerConv: 10},
		{RetentionDays: 7, MaxPerConv: 0},
	}
	for _, p := range cases {
		if _, err := Keep(msgs, 1000, p); err == nil {
			t.Fatalf("Keep(%+v) = nil error", p)
		}
	}
	if _, err := Keep(msgs, 0, Policy{RetentionDays: 7, MaxPerConv: 10}); err == nil {
		t.Fatal("Keep(now=0) = nil error")
	}
}

func TestRoamPageUsesDescendingCursor(t *testing.T) {
	msgs := []Message{
		{ID: "m3", ConvID: "c1", Seq: 3, At: 3},
		{ID: "m1", ConvID: "c1", Seq: 1, At: 1},
		{ID: "m5", ConvID: "c1", Seq: 5, At: 5},
		{ID: "m4", ConvID: "c1", Seq: 4, At: 4},
		{ID: "m2", ConvID: "c1", Seq: 2, At: 2},
	}
	cases := []struct {
		before int64
		limit  int
		want   []string
	}{
		{5, 2, []string{"m4", "m3"}},
		{3, 2, []string{"m2", "m1"}},
		{0, 2, []string{"m5", "m4"}},
		{1, 2, []string{}},
	}
	for _, tc := range cases {
		got, err := RoamPage(msgs, tc.before, tc.limit)
		if err != nil {
			t.Fatalf("RoamPage(before=%d) error = %v", tc.before, err)
		}
		if len(got) != len(tc.want) {
			t.Fatalf("RoamPage(before=%d) = %+v, want %v", tc.before, got, tc.want)
		}
		for i := range tc.want {
			if got[i].ID != tc.want[i] {
				t.Fatalf("RoamPage(before=%d)[%d] = %q, want %q", tc.before, i, got[i].ID, tc.want[i])
			}
		}
	}
}

func TestRoamPageRejectsBadArgs(t *testing.T) {
	msgs := []Message{{ID: "m1", ConvID: "c1", Seq: 1, At: 1}}
	if _, err := RoamPage(msgs, -1, 10); err == nil {
		t.Fatal("RoamPage(before=-1) = nil error")
	}
	if _, err := RoamPage(msgs, 0, 0); err == nil {
		t.Fatal("RoamPage(limit=0) = nil error")
	}
}

TestKeepDropsExpiredMessages 把一条消息放在保留期边界上,确认 At == cutoff 仍然保留;更早的消息被裁掉。边界包含还是排除,必须在代码和文档里保持一致。

TestKeepCapsPerConversation 验证每个会话独立保留最新 2 条,不会因为另一个会话消息少就多保留。结果按会话 ID 排序,便于稳定比较。

TestKeepDoesNotMutateInput 确认裁剪不会原地重排入参。写入路径可能需要用原始切片做审计,函数擅自排序会破坏调用方假设。

TestRoamPageUsesDescendingCursor 覆盖从最新开始、带游标、游标到头部三种情况,并检查 Seq >= before 被排除。TestRoamPageRejectsBadArgs 覆盖负游标和 0 条数。

运行命令与真实输出

cd .local/checks/webapp-lab && go test ./internal/im/ -run '^(TestKeepDropsExpiredMessages|TestKeepCapsPerConversation|TestKeepDoesNotMutateInput|TestKeepRejectsBadPolicy|TestRoamPageUsesDescendingCursor|TestRoamPageRejectsBadArgs)$' -count=1 -v

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

=== RUN   TestKeepDropsExpiredMessages
--- PASS: TestKeepDropsExpiredMessages (0.00s)
=== RUN   TestKeepCapsPerConversation
--- PASS: TestKeepCapsPerConversation (0.00s)
=== RUN   TestKeepDoesNotMutateInput
--- PASS: TestKeepDoesNotMutateInput (0.00s)
=== RUN   TestKeepRejectsBadPolicy
--- PASS: TestKeepRejectsBadPolicy (0.00s)
=== RUN   TestRoamPageUsesDescendingCursor
--- PASS: TestRoamPageUsesDescendingCursor (0.00s)
=== RUN   TestRoamPageRejectsBadArgs
--- PASS: TestRoamPageRejectsBadArgs (0.00s)
PASS
ok  	webapp/internal/im	0.277s

PASS 表示裁剪和分页语义都符合预期。测试是纯内存的,真实系统的耗时主要取决于分区裁剪、索引和网络;但只要游标和排序规则一致,分页结果就可以逐页稳定复现。

小结

消息存储要同时回答两个问题:数据放在哪里,在线请求读多少。按会话分片让单会话读写局部化,按时间分区让冷热分离和过期删除变便宜;漫游接口必须有游标和条数上限,不能返回全量历史。

Policy、Keep 和 RoamPage 把保留与分页规则固定下来:过期或超限的消息被裁掉,每会话保留最新 N 条,漫游按 Seq 倒序且游标严格向前。下一章进入实时投递,讨论在线、离线与多端同步。

阅读导航:上一节:8.2 单聊、群聊与系统消息 · 下一节:9.1 在线投递与离线补偿 。

常见错误与边界

  • 漫游接口没有 limit。只要有一个重度用户,单次查询就可能拖垮连接池。
  • 用 offset 分页。消息持续写入时 offset 会漂移,翻页出现重复或漏读;应该用 Seq 游标。
  • 游标使用 Seq <= before。这样上一页的最后一条会在下一页重复出现,客户端需要额外去重。
  • 时间单位和存储不一致。At 是秒、配置是毫秒,保留期会差 1000 倍。
  • 只做时间保留,不做条数上限。热会话在保留期内仍可能积累几十万条消息。
  • 只做条数上限,不做时间保留。冷会话的旧数据永远不会被清理,存储成本只增不减。
  • 在线查询扫描全部分区。时间分区只有在查询带边界时才有裁剪效果。
  • 在请求链路里同步清理过期数据。删除应该由后台任务或分区操作完成,不能混进读路径。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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