发出一条消息后,聊天窗口很快就会出现一个气泡。真正难解释的是后面的几秒:请求超时了,要不要重发?对方打开了会话,算不算已读?笔记本休眠再唤醒,漏掉的消息从哪里补回来?
这些问题决定了一个聊天系统是否值得信任,也比消息气泡的样式更能说明它的设计。
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:
flowchart TD
A[消息输入区] --> B[REST 请求与幂等键]
B --> C[登录身份和会话成员校验]
C --> D[消息规则与媒体归属校验]
D --> E[数据库事务]
E --> F[消息和媒体关联]
E --> G[会话更新时间]
E --> H[各接收者的持久化事件]
F --> I[事务提交]
G --> I
H --> I
I --> J[进程内 SSE 广播]
I --> K[按订阅发送 Web Push]
J --> L[失效相关查询缓存]
L --> M[重新读取会话和消息]
图里的事务边界有意画得很窄:消息、媒体关联、会话时间和对应的 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,用整数时间代替时间戳,只保留二元组比较。三次请求按“新、旧、中间”的顺序到达;水位应该停在最大位置。
程序演示的是应满足的不变量。当前仓储仍然无条件覆盖阅读位置,尚未实现这里的单调更新保护。
本地运行输出:
收到 #3,推进=true,水位=#3
收到 #1,推进=false,水位=#3
收到 #2,推进=false,水位=#3
消息 #1 已读=true
消息 #2 已读=true
消息 #3 已读=true结果中,后两次请求都不会推进水位,三条消息都保持已读。换成真实数据库时,比较与写入必须放在同一个原子操作里。应用层先读取、再判断、最后更新,仍可能被并发请求穿过。
水位还有一个产品前提:它表示“这条消息及以前的消息都已读”。它不能表达跳过某条消息、只阅读后面几条的任意集合。若以后引入线程或独立分支,就需要重新判断同一个水位是否仍然足够。
自动重连,离可靠补发还有多远
SSE 的 id 会更新浏览器记录的最后事件 ID,断线重连时可以通过 Last-Event-ID 告诉服务端从哪里继续。这是协议提供的续传线索,不是消息队列的交付保证。
当前服务端会先补发最多 100 条历史事件,然后注册实时连接。两个动作之间存在一个窗口:
sequenceDiagram
participant C as 重连客户端
participant S as SSE 服务
participant D as 事件表
participant W as 消息写入请求
C->>S: 携带 Last-Event-ID 重连
S->>D: 查询已有事件
D-->>S: 返回这一批结果
W->>D: 提交新消息与事件 E
W->>S: 广播事件 E
Note over S: 此客户端尚未注册,收不到 E
S-->>C: 写出刚才查询到的历史事件
S->>S: 注册客户端实时连接
事件 E 仍然在数据库里,但这条连接不会因为它“存在”就自动收到它。离线期间积压超过 100 条时,未取完的部分也没有继续循环补发。在线连接的缓冲区满了以后,当前实现同样会直接丢弃那次广播。
修补时,订阅与补拉必须有交接协议。例如,在单进程且事件顺序已经可靠的前提下,可以先注册并缓冲实时事件,分页补拉一个明确边界内的历史,再去重交接;缓冲溢出则明确要求重新同步。不能只把两行代码调换顺序,就认为重复、乱序和溢出也一并解决了。
另一种水位:事件 ID 不一定是提交顺序
聊天事件使用 PostgreSQL 序列生成 ID。它是全局序列,又按用户过滤,因此一个用户看到的事件编号本来就不必连续,编号有空洞不代表漏消息。
更隐蔽的是分配顺序和提交顺序。事务 A 先拿到 11,事务 B 后拿到 12,B 完全可能先提交。如果某次补拉先看到了 12 并推进游标,而 A 随后才提交,下一次 sequence > 12 就不会再选中 11。
下面的程序只模拟这个查询边界,不连接数据库,也不模拟整个 SSE 服务。对比两种可见顺序,就能看到单靠“记住最大 ID”为什么不够。
本地运行输出:
顺序提交:提交=[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 程序均已在本地运行,文中保留实际输出;它们验证独立的顺序模型,不替代真实聊天场景的验证。
