两个客户端同时发消息,消息顺序却相反
「回声」的私信功能在内测时出现过一个很难复现的问题: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 的排序不保证稳定顺序,测试和线上都可能随机抖动。
- 允许跨会话批量分配序号。序号语义被破坏,后续分页和已读游标无法按会话解释。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。