Chatto事件溯源架构揭秘:为什么用NATS JetStream替代关系型数据库
【免费下载链接】chattoA fully-featured team and group chat application that you can easily selfhost.项目地址: https://gitcode.com/gh_mirrors/chatt/chatto
Chatto 是一款功能完整的团队与群组聊天应用,主打免费、轻松自托管。它最"反直觉"的设计是:整套系统不使用任何关系型数据库,而是把开源消息系统NATS JetStream当作唯一的数据存储,并用**事件溯源(Event Sourcing)**来管理全部业务状态。这篇文章面向新手,用尽量少的代码,讲清楚这个架构的来龙去脉和真实取舍。
一眼看懂:一个"没有数据库"的聊天应用
Chatto 支持房间、线程、@提及、表情回应、文件上传、语音通话、机器人、管理员面板等完整功能。下面就是它的聊天界面:
通常这类应用需要一整套基础设施:
- Web 服务器
- 关系型数据库(PostgreSQL / MySQL)
- 消息队列(做实时推送)
- 对象存储 / 缓存
每一样都意味着部署、备份、连接池、版本兼容问题。而 Chatto 的做法是:一个可执行文件,一个端口,一个数据目录,全部搞定。
传统做法的痛点:数据库 + 消息队列"二件套"
传统聊天应用里,数据流是这样的:
- 写入 → 存进关系型数据库(一条
INSERT语句) - 推送 → 再发一条消息到 Pub/Sub 系统,前端通过 WebSocket 收到
- 备份 → 数据库、消息队列、缓存各备各的
这里有个隐藏成本:存储层和实时推送层是两套系统,服务同一份数据。你需要保证两者最终一致,需要 CDC、轮询或同步层,还要为每套系统分别做运维和备份。
而聊天领域的本质恰好是事件驱动的:消息是事件、在线状态是事件、"正在输入"是事件。存储和推送处理的本来就是同一份数据——那为什么要拆成两个系统?
核心决策:NATS JetStream 作为唯一数据底座
这个决策记录在 docs/adr/ADR-001-nats-jetstream-as-primary-data-store.md 中,关键结论是:
使用 NATS JetStream 作为唯一持久化存储。没有关系型数据库、没有 ORM、没有 SQL 迁移。
具体来说,JetStream 提供了几种"容器",各司其职:
| 类型 | 名字 | 存什么 | 新手理解 |
|---|---|---|---|
| Stream(事件流) | EVT | 所有领域事实:用户、房间、消息、成员关系 | 一本永不擦除的"流水账" |
| KV 桶 | RUNTIME_STATE | 最新值状态:凭据、在线设置、通知边界 | 一张只保留最新值的"便签纸" |
| KV 桶 | MEMORY_CACHE | 易失的租约、计数器、健康心跳 | 内存里的临时草稿 |
| 对象存储 | SERVER_ASSETS | 上传的图片、文件等二进制 | 仓库货架 |
| NATS Core | live.sync.> | 不持久化的实时信号(打字、在线状态) | 广播喇叭 |
完整清单见 docs/architecture/nats-resources.md。
事件溯源是什么?用"餐厅账本"打比方 📒
事件溯源的核心思想一句话:不记录"现在是什么样",只记录"发生了什么"。
就像餐厅不记录"当前库存 50 个鸡蛋",而是记录"购入 100 个""用了 30 个""退回 20 个"。任何时候想知道库存,把账本从头重放一遍就能算出来。
Chatto 的EVT流就是这样一本账本:
- 用户加入房间 → 追加一条
user_joined事件 - 发送消息 → 追加一条
message_posted事件 - 编辑消息 →不修改原事件,再追加一条
message_edited事件
所谓"当前状态"(比如某个房间现在有哪些成员),由**投影(Projection)**从事件流实时推导出来——大多数投影只是进程内存里的数据结构,启动时重放事件流即可重建。这一演进过程记录在 docs/adr/ADR-033-event-sourced-state-with-projections.md 中(更早的"KV 存现状 + 流存历史"模式见已被取代的 ADR-006)。
这套设计带来四个实打实的好处:
- 审计日志是天然的:审计追踪不是附加的旁路,数据本身就是日志
- 改数据结构 = 重建投影:加字段、改推导逻辑,只要"丢掉投影、重放事件流",不需要写数据迁移脚本
- 写入原语统一:所有写操作都走"带乐观并发控制(OCC)追加事件"这一条路,代码里只有一种变更模式
- 内存占用可控:主题(subject)数量只随聚合体(房间、用户)增长,而不随消息数量无界增长
单流设计:为什么所有事件只写进EVT
一个自然的疑问:消息、用户、房间……难道不该分多条流吗?
docs/adr/ADR-034-single-event-stream.md 选择了单条主事件流,因为:
- 备份时只有一个目标、一个位置要跟踪
- 运维工具面对的资源更少
- 顺序保证不依赖"每聚合一条流":JetStream 对每个主题内部都有独立的单调序列号,房间 X 的事件天然线性有序
事件流的地址格式为evt.{聚合类型}.{聚合ID}.{事件类型},例如evt.room.{房间ID}.message_posted。投影只需订阅自己关心的主题前缀,服务端就会过滤——关心"谁加入了房间"的投影根本不会收到海量消息事件。
并发安全:乐观并发控制(OCC)
多人同时操作同一个房间怎么办?Chatto 的答案是强制 OCC:
- 每次追加事件都携带
Nats-Expected-Last-Subject-Sequence头,声明"我以为该聚合的最新序号是 N" - 如果实际序号已变(说明有人抢先写入),框架整体重跑决策,基于新状态重新判断
- 框架不提供"不带 OCC 的写入"原语——想跳过并发检查都做不到
代价是热点聚合上可能出现重试循环,但换来的是整个代码库再无"我是不是和别人撞车了?"这类问题。
自托管者的福音:备份与恢复只需一条命令
事件溯源让备份变得异常简单——整个数据边界就是 NATS 里的几类资源:
chatto backup把EVT、RUNTIME_STATE、通知历史、NATS 资产、投影快照打包成一个压缩归档,可选 age 口令加密chatto restore在停服的部署上恢复该归档,恢复后从EVT重放即可重建全部投影
细节见 docs/fdr/FDR-040-backup-and-restore.md,命令实现在 cli/cmd/backup.go。对比传统方案"数据库 dump + 对象存储同步 + 队列状态协调"的多步骤流程,这是显著简化。
部署形态:嵌入式 NATS,一条命令启动
docs/adr/ADR-002-single-binary-with-embedded-nats.md 决定了:NATS 服务器以库的形式内嵌在 Chatto 的 Go 二进制里。
chatto run一条命令启动 NATS、HTTP API/实时服务器和内嵌的前端- 单节点模式下应用和数据存储之间零网络调用,没有连接池、重连、网络分区问题
- 升级是原子的:换一个二进制、重启一次进程
- 高级场景仍可外接 NATS 集群实现水平扩展——嵌入式只是起点,不是上限
坦诚的代价:这个架构放弃了什么?
没有免费的午餐,官方 ADR 也明确列出了代价:
- 没有任意 SQL 查询:JetStream 不是查询引擎,没有 JOIN。相关数据靠投影和 API 组装层拼装
- 全文搜索要另做:需要专门的投影或可插拔搜索系统(见 ADR-055),
EVT本身不支持LIKE '%xxx%' - 重启成本随流长度增长:没有可用快照时,重启要花时间重放事件流(可选的加密快照只是加速器,正确性基线仍是完整重放)
- 运维知识转移:运维者要懂 NATS 流、消费者、KV 语义,而不是 SQL 和数据库调优
- 跨进程一致性是最终一致:两个进程对同一份状态的读取可能有亚毫秒级的瞬间不一致——对聊天场景可接受
延伸阅读:架构是怎么"长"出来的 📚
Chatto 的文档体系非常值得学习——每个关键决策都有 ADR(架构决策记录),每个功能行为都有 FDR(功能决策记录):
| 想深入了解 | 去哪里看 |
|---|---|
| 为什么选 NATS JetStream | docs/adr/ADR-001-nats-jetstream-as-primary-data-store.md |
| 单二进制 + 嵌入式 NATS | docs/adr/ADR-002-single-binary-with-embedded-nats.md |
| 事件溯源 + 投影模式 | docs/adr/ADR-033-event-sourced-state-with-projections.md |
单事件流EVT设计 | docs/adr/ADR-034-single-event-stream.md |
| 运行时状态与事件流的边界 | docs/adr/ADR-036-runtime-state-kv-boundary.md |
| NATS 资源完整清单 | docs/architecture/nats-resources.md |
| 事件溯源框架(独立模块) | pkg/events/README.md |
| 架构总览索引 | docs/ARCHITECTURE.md |
总结
Chatto 用一个大胆但自洽的架构回答了"自托管聊天应用"的复杂度问题:
- 存储与推送合一——JetStream 既是数据库也是消息总线,消灭了同步层
- 事件溯源——
EVT流是唯一事实来源,状态是派生物,审计、迁移、恢复全部简化 - 单二进制部署——嵌入式 NATS 让"一条命令跑起来"成为现实
- 备份即打包——一个归档搞定全部持久化数据
对普通用户而言,你只需要chatto run一个命令;对进阶部署者,外接 NATS 集群就是水平扩展路径。这就是事件溯源 + NATS JetStream 组合的完整故事:用领域模型的自然形态(事件)来设计存储,而不是把领域硬塞进一张张关系表。
【免费下载链接】chattoA fully-featured team and group chat application that you can easily selfhost.项目地址: https://gitcode.com/gh_mirrors/chatt/chatto
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考