发出一条消息后,聊天窗口很快就会出现一个气泡。真正难解释的是后面的几秒:请求超时了,要不要重发?对方打开了会话,算不算已读?笔记本休眠再唤醒,漏掉的消息从哪里补回来?

这些问题决定了一个聊天系统是否值得信任,也比消息气泡的样式更能说明它的设计。

Violet 的 /chat 是一套集中式站内聊天。本文沿着消息的生命周期拆开它:先看产品边界和数据模型,再看发送、已读与断线同步。文中的 Go 示例采用本站的可运行代码块格式,也可分别保存为 main.go 后执行 go run main.go;它们是独立的说明程序,不是从项目里截取的生产代码。涉及尚未补齐的可靠性约束,会明确说明。

先把聊天的范围定小

Violet 已经有用户、登录会话、媒体上传和通知系统。聊天复用这些能力,不再维护第二套账号,也不要求用户理解外部通信协议。

它支持一对一私聊和私有多人房间。房间靠成员邀请加入,不提供公开房间搜索;邀请成功即加入,没有额外的接受状态。普通成员可以邀请,房主负责改名和移除成员。房主退出时,职责交给最早加入的其他有效成员;最后一位成员离开,房间解散。

这是一个有意识的边界:当前实现不兼容 Matrix,不做跨服务器联邦,也不提供端到端加密。消息由本站服务端存储和处理,“私有”描述的是成员访问范围,不代表服务器无法读取内容。

落到页面上,功能已经不只是一个文本框:

使用场景当前能力
日常交流文本、多图与说明文字、表情、自定义表情
保留上下文引用回复、原消息定位、推文分享卡片
修订内容编辑自己的消息、显示编辑状态;普通用户不提供撤回,管理员可删除
轻量反馈消息表情反应,每人每条消息最多三种
阅读与提醒私聊已读状态、群聊已读人数及名单、正在输入、未读角标、Web Push
房间管理邀请、改名、移除成员、退出、会话静音

对一个博客来说,这个范围已经能够承载文章之外的交流。继续增加协议和角色之前,更需要确认现有规则在重试、并发和断线时仍然成立。

一条消息旁边,还有哪些数据

消息类型包括 text、image、system 和 tweet_share。它们共享会话归属、发送者、创建时间和消息 ID,但内容的保存方式并不相同。

图片先走通用上传流程,获得媒体 ID,再由聊天消息引用。多张图片保存在独立的关联表里,position 记录次序;正文中的 ![img:<media_id>] 占位符保留图片和文字的相对位置。这样,“这张图上面的话”和“这张图下面的话”在发送后仍然处在原来的位置,不会被统一挪成正文下方的一排附件。

代价也很具体:正文与媒体关联必须配套校验,编辑图片消息时至少保留一张图片,上传文件还要检查归属和可用状态。媒体引用计数与消息事务目前并不处于同一个原子边界,失败补偿仍有需要审视的地方。

引用回复采用动态读模型。新消息保存被引用消息的 ID,读取时再组装一层预览。因此,原消息编辑后,引用预览跟着变化;原消息被管理员删除后,预览显示删除占位,而不是继续展示旧正文。

这也说明它不是不可变的对话快照。若将来需要审计式引用,必须另行定义版本或快照语义,不能让调用方把当前预览当成历史证据。

推文分享同样保存引用关系,不复制出一条新推文。分享不会进入推文时间线,也不应增加推文域的转发计数。被分享的推文物理删除后,聊天里保留“推文已删除”的占位。这个边界把“在聊天中提到一条内容”和“在内容社区发布一次转发”分开了。

发送走 HTTP,变化走 SSE

聊天并不天然要求 WebSocket。Violet 的写操作本来就是请求:发送消息、编辑正文、邀请成员、推进阅读位置。服务端需要主动下发的,则是这些操作发生后的变化。

当前组合是 REST 与 SSE:

消息输入区

REST 请求与幂等键

登录身份和会话成员校验

消息规则与媒体归属校验

数据库事务

消息和媒体关联

会话更新时间

各接收者的持久化事件

事务提交

进程内 SSE 广播

按订阅发送 Web Push

失效相关查询缓存

重新读取会话和消息

消息输入区

REST 请求与幂等键

登录身份和会话成员校验

消息规则与媒体归属校验

数据库事务

消息和媒体关联

会话更新时间

各接收者的持久化事件

事务提交

进程内 SSE 广播

按订阅发送 Web Push

失效相关查询缓存

重新读取会话和消息

图里的事务边界有意画得很窄:消息、媒体关联、会话时间和对应的 message.created 事件一起提交。SSE 和浏览器推送发生在提交之后。推送失败不能撤销已经保存的消息。

前端使用 EventSource 建立事件流。它挂在站点 Header,而不是聊天页面内部,所以用户浏览文章时也可以收到变化、刷新聊天未读角标。服务端通过心跳维持空闲连接,持久化事件带有事件 ID,供断线后的补发使用。

多数事件没有被直接当作“最终消息对象”拼进页面,而是使对应的 TanStack Query 缓存失效,再从查询接口读取当前状态。这少维护了一套客户端归并逻辑,尤其适合引用预览、反应和已读状态这种组合读模型。

代价是查询放大。一个消息事件可能刷新会话列表、未读数、会话详情和消息历史;已加载的历史页越多,重新查询越贵。小规模站内聊天可以先接受这笔开销,不能把它误认为没有成本的实时更新。

幂等键必须属于一次发送意图

服务端通过 (conversation_id, sender_id, idempotency_key) 唯一约束抵御重复发送。请求到来先查既有结果,数据库写入发生竞争后,也会尝试回查同一个键对应的消息。

这解决的是一个具体场景:服务端已经提交了消息,但 HTTP 响应在路上丢失。客户端不知道成功与否,可以拿原来的键再问一次,而不必新增消息。

这个约定要求客户端在重试期间保留同一个键。目前输入组件每次点击发送都会生成新的 UUID,失败后再次点击也会换键。因此,后端具备幂等能力,并不意味着当前页面的手动重试已经获得同样保证。

更合适的生命周期是:创建待发消息时生成键,重试沿用,确认成功或放弃该消息后才结束。即使做到这一点,也只能说明消息写入在这个键的范围内去重,不能据此宣称所有通知和外部副作用都“恰好执行一次”。

已读回执为什么适合用水位

如果为每条消息、每位成员保存一条已读记录,读完一页历史就会产生一批更新。线性时间线通常不需要这样做。

Violet 为用户在会话中保存一个阅读位置。发送者想知道某条消息是否已读,只需判断接收者的水位是否覆盖它。群聊的“N 人已读”再对其他有效成员求和,不计发送者本人。

这里有两个容易混淆的时间:最后读到的消息的创建时间,以及用户执行标记阅读的时间。决定覆盖范围的是前者和消息 ID;后者可以用于展示“何时阅读”,但不能直接决定读过哪些消息。

消息按 (created_at, id) 排序。只比较时间不够:同一时间戳下可能存在多条消息,ID 提供稳定的决胜顺序。历史分页也沿用这组顺序,例如向前加载:

SELECT *
FROM chat_messages
WHERE conversation_id = $1
  AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT $4;

这个查询表达的是 keyset pagination 的边界。相比 offset,新消息插入时间线头部时,已经拿到的游标不需要跟着整体位移。

运行一下:旧设备不能把水位拉回去

下面用整数代替 UUID,用整数时间代替时间戳,只保留二元组比较。三次请求按“新、旧、中间”的顺序到达;水位应该停在最大位置。

程序演示的是应满足的不变量。当前仓储仍然无条件覆盖阅读位置,尚未实现这里的单调更新保护。

go可运行
未运行复制代码更多操作
-- NORMAL --i 插入 · v 选择 · ⌘↵ 运行

本地运行输出:

收到 #3,推进=true,水位=#3
收到 #1,推进=false,水位=#3
收到 #2,推进=false,水位=#3
消息 #1 已读=true
消息 #2 已读=true
消息 #3 已读=true

结果中,后两次请求都不会推进水位,三条消息都保持已读。换成真实数据库时,比较与写入必须放在同一个原子操作里。应用层先读取、再判断、最后更新,仍可能被并发请求穿过。

水位还有一个产品前提:它表示“这条消息及以前的消息都已读”。它不能表达跳过某条消息、只阅读后面几条的任意集合。若以后引入线程或独立分支,就需要重新判断同一个水位是否仍然足够。

自动重连,离可靠补发还有多远

SSE 的 id 会更新浏览器记录的最后事件 ID,断线重连时可以通过 Last-Event-ID 告诉服务端从哪里继续。这是协议提供的续传线索,不是消息队列的交付保证。

当前服务端会先补发最多 100 条历史事件,然后注册实时连接。两个动作之间存在一个窗口:

消息写入请求事件表SSE 服务重连客户端消息写入请求事件表SSE 服务重连客户端此客户端尚未注册,收不到 E携带 Last-Event-ID 重连查询已有事件返回这一批结果提交新消息与事件 E广播事件 E写出刚才查询到的历史事件注册客户端实时连接
消息写入请求事件表SSE 服务重连客户端消息写入请求事件表SSE 服务重连客户端此客户端尚未注册,收不到 E携带 Last-Event-ID 重连查询已有事件返回这一批结果提交新消息与事件 E广播事件 E写出刚才查询到的历史事件注册客户端实时连接

事件 E 仍然在数据库里,但这条连接不会因为它“存在”就自动收到它。离线期间积压超过 100 条时,未取完的部分也没有继续循环补发。在线连接的缓冲区满了以后,当前实现同样会直接丢弃那次广播。

修补时,订阅与补拉必须有交接协议。例如,在单进程且事件顺序已经可靠的前提下,可以先注册并缓冲实时事件,分页补拉一个明确边界内的历史,再去重交接;缓冲溢出则明确要求重新同步。不能只把两行代码调换顺序,就认为重复、乱序和溢出也一并解决了。

另一种水位:事件 ID 不一定是提交顺序

聊天事件使用 PostgreSQL 序列生成 ID。它是全局序列,又按用户过滤,因此一个用户看到的事件编号本来就不必连续,编号有空洞不代表漏消息。

更隐蔽的是分配顺序和提交顺序。事务 A 先拿到 11,事务 B 后拿到 12,B 完全可能先提交。如果某次补拉先看到了 12 并推进游标,而 A 随后才提交,下一次 sequence > 12 就不会再选中 11。

下面的程序只模拟这个查询边界,不连接数据库,也不模拟整个 SSE 服务。对比两种可见顺序,就能看到单靠“记住最大 ID”为什么不够。

go可运行
未运行复制代码更多操作
-- NORMAL --i 插入 · v 选择 · ⌘↵ 运行

本地运行输出:

顺序提交:提交=[11 12],收到=[11 12],游标=12
后号先提交:提交=[12 11],收到=[12],游标=12

第二行只能收到 12。序列保证并发取号得到不同值,但不替应用保证事务提交的先后。PostgreSQL 文档还明确说明,回滚也不会收回已经分配的序列值。

这是依据数据库语义推演的并发风险,不是本文已经在项目上复现的线上故障。要把事件日志用成可靠的续传日志,需要保证游标覆盖的是一个完整的已提交前缀,或者采用允许重叠读取、去重和状态重新同步的协议。仅把序列改成另一种“看起来按时间递增”的 ID,并不能解决晚提交问题。

Violet 的实时广播目前还局限在进程内。部署多个 API 实例时,某个实例收到的写请求不会自然唤醒另一个实例上的 SSE 连接。数据库日志提供了恢复的材料;跨实例通知、读取顺序和交接边界仍然要分别处理。

正在输入,不该跟着消息一起持久化

“某人发了一条消息”和“某人正在输入”有不同的寿命。

前者需要在离线后仍能找回。后者过几秒就可能失去意义:用户关掉标签页,或删掉了草稿。把正在输入也写进事件日志,重连时反而可能补出一个早已过期的提示。

Violet 对这点做了单独处理:typing.updated 不落库、不带 SSE 事件 ID。输入变化最多每三秒上报一次,接收端六秒过期,清空输入或切换会话时可以发送停止信号。漏掉停止事件时,本地超时负责把提示收起来;它不需要达到消息存储的可靠等级。

浏览器通知又是另一条链路。Web Push 可以在页面关闭后到达,由 Service Worker 展示;它受浏览器授权、推送订阅、VAPID 配置和会话静音共同控制。消息预览也是订阅偏好,而不是每次都无条件把正文塞进系统通知。

当前普通聊天消息不写入全站通知铃铛,避免一段对话把通知列表刷满。聊天未读角标负责站内提醒,Web Push 负责系统通知;房间邀请保留在铃铛中。

还有两个边界需要补齐:正在查看某个会话时,服务端和 Service Worker 目前没有配套的前台抑制逻辑;推送出错时也不该一律删除订阅。网络超时、限流和服务端临时错误,与 endpoint 永久失效不是同一类结果。

页面可以轻,阅读语义不能省

ChatWorkspace 用 URL 的 ?c= 保存选中的会话。桌面端左右分栏,移动端在列表和会话之间切换。消息向上分页时,记录新增内容前后的滚动高度差,补偿滚动位置,避免历史消息插入后把正在看的内容顶走。

输入组件复用评论模块的富文本与上传能力,服务端数据交给 TanStack Query,输入状态交给短寿命的 Zustand store。这些复用让页面不必再维护一套编辑器和远端数据缓存,也意味着聊天与评论输入组件之间已经存在实际耦合,后续改动需要同时检查两种交互。

不过,数据加载完成不等于用户读过。当前面板只要拿到最新消息且存在未读,就会推进阅读位置;新消息 ID 变化时还会自动滚到底部。后台标签页收到消息、用户停留在上方读历史,都不应该被这条规则直接视为“已阅读最新消息”。更准确的判定至少需要结合页面可见性和消息所在的可视区域。

会话列表也有一个前后端断层:接口已经支持 cursor,页面却只取默认的前 20 条。搜索只在这一页中进行,旧会话的 ?c= 还可能因为不在列表里而被清除。后台支持分页,不代表用户已经能访问下一页。

这类问题不需要再发明一种状态管理方式。把加载、可见、阅读和选中这几个状态区分清楚,通常比增加抽象更有用。

一个小迁移,暴露了事务之外的契约

这次源码检查里,最直接的问题出现在已读事件。

应用已经会写入 read.advanced,数据库的事件类型 CHECK 却没有允许它。迁移文件与本地数据库约束都核实了这个不匹配。阅读位置先保存,随后事件插入失败,所以一次请求可以同时表现为“水位已经改变”和“接口返回错误”。重试相同位置又会跳过广播。

这说明“代码里定义了事件类型”还不构成完整契约。领域枚举、迁移约束、持久化事务、SSE 载荷和前端消费方,都必须承认同一种事件。使用 SQLite AutoMigrate 的仓储测试不会自动加载 PostgreSQL 迁移里的 CHECK,因此也不会替这里发现问题。

已读位置与对应事件应该在适当的事务边界内共同落定。成员邀请则应保证重复调用不重置已有成员的角色和偏好。类似约束最好由真实数据库上的行为检查来守住,而不是只验证某个 mock 方法被调用。

Violet 这套聊天值得保留的,是它选定的范围和已经分开的数据职责:消息负责长期内容,引用按当前内容投影,水位压缩线性阅读进度,短寿命输入状态不进入历史,通知也不与消息存储混为一谈。

接下来的可靠性工作同样具体:让重试沿用发送意图的幂等键,让已读水位只向前,让事件补发有完整的交接边界,让失败不会留下无法恢复的半次操作。把这些补稳之后,现有的聊天功能才更接近用户直觉里的那句话:刚才发出去的内容,回来以后还能接着聊。

源码与延伸阅读

  • Violet 源码仓库:页面从 web/src/features/chat/ui/ChatWorkspace.tsx 进入;用例编排在 api/internal/application/chat/service.go;消息与事件事务在 api/internal/infrastructure/persistence/gorm/chat_repo.go。
  • 仓库中的 docs/adr/0010-centralized-chat-boundary.md、0011-chat-api-contract.md 和 0014-chat-read-receipt-watermark.md 分别记录产品边界、API 契约与已读水位的设计。
  • MDN:Using server-sent events,解释事件帧、自动重连和事件 ID 的语义。
  • PostgreSQL:Sequence Manipulation Functions,说明序列的并发取号、事务回滚与空洞行为。

本文基于源码、迁移与数据库约束检查整理,没有把登录后的双账号收发、群聊操作或浏览器推送宣称为已通过端到端验收。两段 Go 程序均已在本地运行,文中保留实际输出;它们验证独立的顺序模型,不替代真实聊天场景的验证。