CASE STUDY / REALTIME SYSTEM

Chatify

持续重构

从 BaaS 原型到 Go 实时消息链路

围绕数据库访问成本和 N+1 查询问题,为 Chatify 增加 Go、PostgreSQL 与 WebSocket 后端,并保留迁移期间仍在工作的 Next.js / Convex 能力。

01 / PROBLEM

为什么要重构后端

旧实现将会话与消息数据主要放在 Convex。随着关系查询增多,缺少 SQL JOIN 的数据访问方式容易把一次页面加载拆成大量查询。重构目标不是简单换语言,而是重新建立会话、成员、消息和未读状态之间的关系模型,同时让消息能够持久化后再实时分发。

  • 用关系模型表达会话、成员、好友和消息
  • 消息先持久化,再向同一会话的连接广播
  • 用生成代码连接原生 SQL 与 Go 类型系统
  • 保留 Clerk 身份与 LiveKit 音视频能力

02 / MESSAGE FLOW

一条消息如何完成持久化与分发

  1. 01

    Identity

    Next.js 从 Clerk 获取会话令牌;Clerk Webhook 经 Svix 验签后同步用户资料。

  2. 02

    History

    React Query 通过 REST API 获取历史消息,并对返回数据执行 Zod 运行时校验。

  3. 03

    Connect

    浏览器以 Clerk 令牌作为 WebSocket subprotocol 建立连接,Go 中间件解析身份。

  4. 04

    Persist

    readPump 解析消息,sqlc 调用 CTE 写入消息并更新会话的最后消息引用。

  5. 05

    Broadcast

    Hub 按 conversation ID 维护客户端集合,再由每个连接的 writePump 完成下行发送。

03 / ENGINEERING DECISIONS

代码中真实存在的设计

  1. 01

    关系型数据模型替代多次拼装

    实现PostgreSQL 将 users、conversations、conversation_members、messages 与 friends 分表建模,查询通过 CTE 和 JOIN 一次返回会话成员、最后消息和未读数量。

    价值将隐式的数据依赖变成数据库约束和可审查 SQL,为定位 N+1、索引和一致性问题提供明确入口。

  2. 02

    SQL 作为契约,Go 类型由代码生成

    实现查询集中维护在 queries 目录,由 sqlc 生成参数和结果类型;结构变化通过 golang-migrate 的顺序迁移记录。

    价值保留原生 SQL 的控制力,同时减少手写 Scan、字段错位以及模型和查询不同步的问题。

  3. 03

    持久化成功后再进行实时广播

    实现WebSocket readPump 收到消息后先执行 CreateMessage,再读取包含发送者信息的完整消息,最后写入 Hub 的 broadcast channel。

    价值客户端收到的消息已经拥有数据库 ID 和创建时间,避免先广播后写入失败造成的幽灵消息。

  4. 04

    按会话隔离连接与广播范围

    实现Hub 使用 conversation ID 到 Client 集合的映射;每个客户端拥有独立的 256 容量发送通道和读写 goroutine。

    价值广播只遍历当前会话连接,并通过独立写协程避免单个网络写操作直接阻塞消息读取。

  5. 05

    音视频与文字消息链路解耦

    实现文字消息由自建 WebSocket 与 PostgreSQL 处理;音视频房间交给 LiveKit,Next.js Route Handler 仅签发短期房间令牌并禁用缓存。

    价值自建可控的数据链路,同时避免在业务服务中重复实现 WebRTC 媒体基础设施。

  6. 06

    前端区分服务端状态和表单状态

    实现React Query 管理历史消息缓存,Zod 校验 REST 响应,React Hook Form 处理输入;收到 WebSocket 消息后定向更新对应会话缓存。

    价值减少消息列表、请求状态和输入交互之间的状态耦合,为后续补充重连和乐观更新留下边界。

04 / CURRENT BOUNDARIES

尚不能包装成生产级的部分

这些问题都能从当前代码直接确认,也是下一轮重构必须先处理的风险。

  1. 高优先级

    会话级授权尚未真正落地

    WebSocket 握手虽然会校验 Clerk 身份,但 hasAccessToConversation 当前直接返回 true;发送者 ID 也来自客户端消息体。需要从令牌映射数据库用户,并在升级连接前查询 conversation_members。

  2. 高优先级

    WebSocket 信任边界过宽

    Upgrader 的 CheckOrigin 当前允许任意来源。应配置可信 Origin,并避免把客户端提供的 sender_id 作为持久化依据。

  3. 中优先级

    连接生命周期与投递语义不完整

    当前没有 ping/pong、读写 deadline、自动重连、消息确认或幂等键;前端发送时会重设 onmessage,也可能覆盖已有监听器。

  4. 中优先级

    Hub 只适用于单实例

    连接表与广播 channel 都在进程内;横向扩容后不同实例无法互相投递,需要 Redis Pub/Sub、NATS 等跨节点通道,并重新定义消息顺序和重试策略。

  5. 中优先级

    并发保护和可观测性仍较薄弱

    广播路径在读锁范围内删除客户端,数据库写入使用 context.Background;目前也只有基础日志,缺少连接数、投递失败和消息延迟指标。

05 / NEXT ITERATION

下一轮应该怎么做

  1. 01

    首先补齐会话成员授权、可信 Origin 和服务端 sender ID 推导

  2. 02

    重构前端 WebSocket Provider,统一消息监听、重连、退避和状态恢复

  3. 03

    为 Hub 和消息写入增加 race test、集成测试与故障场景测试

  4. 04

    定义消息幂等键、确认机制和离线补偿策略

  5. 05

    加入结构化日志和连接、广播、数据库延迟指标后再讨论水平扩展