设计实时聊天后端:房间、数据持久化与扩展性
学习如何使用 Socket.IO、PostgreSQL 和 Redis 构建实时聊天后端,内容包括房间管理、消息持久化顺序、在线状态检测以及多服务器扩展。
实时通信改变了服务器与客户端之间的交互方式,使其超越简单的请求-响应模式,进入基于WebSockets、聊天室、消息持久化、在线状态追踪以及Redis扩展技术的应用领域。
大多数API都遵循一种可预测的模式:
Client
↓
HTTP Request
↓
Server
↓
HTTP Response
客户端发起请求,服务器返回响应,这就是整个交互过程。
但现在试想一下,要构建类似以下这样的应用需要什么:
- Slack
- Discord
- 实时通知推送
- 在线状态指示器
- 输入中提示
- 实时仪表板
- 多玩家功能
在这些情况下,你并不希望客户端不断询问:
“有什么变化吗?”
相反,你希望服务器本身能够主动告知变化情况:
“有些变化了,以下是更新内容。”
这正是实时通信要解决的问题。
1. HTTP与实时通信
传统的HTTP轮询方式如下:
Client → "Any new messages?"
Server → "No"
Client → "Any new messages?"
Server → "No"Client → "Any new messages?"
Server → "Yes, here's one."
虽然可行,但会浪费大量请求和带宽去检查通常并不存在的更新信息。
而持久的实时连接则有所不同:
Client ←────────────→ Server
connection
Server → New message
Server → User online
Server → Typing...
Server → Message read
一旦连接建立,每当有事件发生时,服务器就可以直接将事件推送到客户端。
WebSockets是一种广泛用于实现此类连接的技术。在Node.js生态系统中,Socket.IO就是专为实时通信设计的流行库。
2. 简单的架构
一个最简的聊天应用可能结构如下:
┌──────────────┐
│ Web / Mobile │
└──────┬───────┘
│
WebSocket
│
▼
┌──────────────┐
│ Node.js │
│ Socket.IO │
└──────┬───────┘
│
┌──────────┴──────────┐
▼ ▼
┌───────────┐ ┌───────────┐
│ PostgreSQL│ │ Redis │
│ Messages │ │ Pub/Sub │
└───────────┘ └───────────┘
每个组件都承担着不同的功能。
Node.js + Socket.IO
负责管理实时连接以及在这些连接中传递的事件。
PostgreSQL
用于存储消息和对话历史记录。
Redis
当您运行多个应用实例且需要这些实例为实时事件保持同步时才会用到它。
3. 配置 Socket.IO
基本的服务器配置可能如下所示:
import { Server } from "socket.io";
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_URL,
credentials: true,
},
});
io.on("connection", (socket) => {
console.log("User connected:", socket.id);
socket.on("disconnect", () => {
console.log("User disconnected:", socket.id);
});
});
每次有客户端连接时,Socket.IO都会为该连接创建一个专用的套接字,因此每个连接的客户端都有自己独立的套接字实例。
4. 事件是核心概念
不同于基于请求的思维方式:
"Call this endpoint"
实时系统通常是以命名事件作为组织结构的。
"user-connected"
"send-message"
"message-created"
"user-typing"
"message-read"
"user-offline"
例如,服务器可能会发出如下内容:
socket.emit("message-created", {
id: message.id,
text: message.text,
});
而客户端则会监听同一个事件:
socket.on("message-created", (message) => {
console.log("New message:", message);
});
这种向事件驱动模型的转变,是实时应用与传统基于REST的API之间差异的根本原因之一。
5. 房间让聊天系统更加简单
想象一下一对一的对话:
User A
User B
你并不想将每条消息广播给所有连接的用户,而只需发送给实际参与该对话的用户。
Socket.IO允许你将多个套接字分组到同一个房间中:
socket.join(`conversation:${conversationId}`);
这样,每当有新消息生成时,就会将其发送到该特定房间:
io.to(`conversation:${conversationId}`)
.emit("message-created", message);
只有加入该房间的套接字才会收到该事件。
同样的思路也可用于多用户对话。比如这样的一个房间:
conversation:123
可能包含多名参与者:
User A
User B
User C
User D
这样,一条发送的消息会同时送达房间里的所有人。
6. 不要在广播后保存消息
这是一个值得慎重考虑的设计选择。
一种有风险的操作顺序是:
Receive message
↓
Broadcast message
↓
Save to database
问题在于:如果消息已经发送出去后,写入数据库的操作失败了会怎样?用户看到的消息实际上从未被保存,从而导致用户所见与实际存储内容之间存在不一致。
更可靠的方案是先持久化保存,然后再进行广播:
Client
↓
send-message
↓
Validate
↓
Save to PostgreSQL
↓
Database succeeds
↓
Broadcast event
在实践中,它的实现方式大致如下:
socket.on("send-message", async (data) => {
const message = await saveMessage(data);
io.to(`conversation:${data.conversationId}`)
.emit("message-created", message);
});
不同应用所需的持久性保障程度各不相同,但基本原则是一致的:消息的持久化与传递方式应是经过深思熟虑的决定,而非事后才考虑的问题。
7. 将聊天记录存储在 PostgreSQL 中
构建基于实时事件的系统并不意味着所有数据都必须存在内存中。
用户期望第二天打开对话时,之前的消息依然还在。
简化的数据库结构可能如下所示:
CREATE TABLE messages (
id UUID PRIMARY KEY,
conversation_id UUID NOT NULL,
sender_id UUID NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
然后,每当有用户打开某次对话时,就可以执行类似以下的操作:
SELECT *
FROM messages
WHERE conversation_id = $1
ORDER BY created_at DESC
LIMIT 50;
至此,我们将工作拆分为了两个独立的职责:
Socket.IO
→ Real-time delivery
PostgreSQL
→ Durable message history
将这些功能分开处理会带来很大好处。
8. 为对话记录添加索引
如果您的应用程序经常执行类似以下的查询:
WHERE conversation_id = ?
ORDER BY created_at DESC
那么在设计数据库结构时就应该考虑到这种查询模式。
例如:
CREATE INDEX idx_messages_conversation_created
ON messages(conversation_id, created_at DESC);
不能不加思考地到处添加索引。
只有当索引与应用程序实际执行的查询相匹配时,它才有用。
再重复一下之前讲过的内容:
在进行更改前后务必测量性能。
9. 在线状态与消息存储不同
假设你想显示类似这样的内容:
Mit
● Online
没有必要每次用户连接时都持久化存储类似这样的内容:
user.is_online = true
在 PostgreSQL 中。
为什么不用呢?
因为在线状态会不断变化。
对于大多数系统而言,将这类短期有效的在线状态数据存储在 Redis 中更为合适。
例如:
online:user:123
TTL → 60 seconds
客户端可以定期发送心跳信号,以表明自己仍处于活跃状态。
一旦那些心跳信号不再出现,存在键就会自动失效。
这样就能避免将临时的连接状态误认为是需要存储在数据库中的永久性数据。
10. 输入指示符更为临时
以以下情况为例:
Mit is typing...
这需要在 PostgreSQL 中创建一条记录吗?
绝对不需要。
它完全是短暂的。
只需一个套接字事件即可:
socket.to(roomId).emit("user-typing", {
userId,
});
而一旦用户停止输入:
socket.to(roomId).emit("user-stopped-typing", {
userId,
});
这体现了更广泛的设计原则:
应用程序所跟踪的所有内容并不都需要存储在数据库中。
一个好的判断标准是看这些信息是否需要在服务器重启后依然存在。
如果不需要,那么某种临时存储方式可能更为合适。
11. 多个 Node.js 服务器带来的问题
从这里开始,事情会变得更为复杂。
想象一个只有一台 Node.js 服务器的架构:
Client
↓
Node.js
在那种规模下,一切运行都很顺畅。
但随后流量开始增加。
现在的架构看起来更像这样:
Load Balancer
/ \
↓ ↓
Node.js A Node.js B
用户 A 会连接到 Node.js A,而用户 B 会连接到 Node.js B。
现在用户 A 发送了一条消息。
Node.js B 如何知道需要将这条消息传递给用户 B 呢?
这正是需要在服务器实例之间建立共享消息传递层来解决的类型的问题。
12. Redis 可以连接多个实例
一种常见的解决方案是使用 Redis,并搭配 Socket.IO Redis 适配器。
从概念上来看,其工作原理如下:
Load Balancer
/ \
↓ ↓
Node.js A Node.js B
\ /
\ /
Redis
通过这种方式,在一个服务器实例上创建的事件可以传播到其他实例上。
这正是让实时处理层能够超越单个 Node.js 进程限制的原因。
需要明确的是,Redis 并不能替代 PostgreSQL。
这两种工具的功能各不相同。
PostgreSQL
→ Durable application data
Redis
→ Fast temporary/shared state + coordination
13. 认证依然重要
建立 WebSocket 连接并不意味着该连接就值得信任。
仍然需要进行认证。
常见的做法是让客户端在连接时提供某种令牌。
在允许访问私密对话之前,服务器必须验证该令牌的有效性。
从概念上讲,流程如下:
Client
↓
Connection
↓
Authenticate
↓
Validate user
↓
Allow socket connection
然后,当客户端尝试加入某个房间时:
conversation:123
服务器需要确认经过认证的用户确实是该对话的参与者。
绝不能轻易接受:
socket.join(conversationId);
认为客户端提供的标识符可以直接信赖的假设。
14. 处理断开连接
在实时系统中,连接意外中断是常见现象。
用户可能会:
- 关闭浏览器
- 失去Wi-Fi连接
- 切换网络
- 让手机进入睡眠模式
- 失去移动信号
- 强制退出应用程序
因此,服务器需要如下逻辑:
socket.on("disconnect", (reason) => {
console.log("Disconnected:", reason);
});
还需要考虑重新连接的逻辑。
短暂的五秒钟网络中断不应导致用户永久无法使用实时功能。
这也是为什么实时系统通常比普通的REST API需要更精细的状态管理的原因之一。
15. 实时系统并不一定都要依赖WebSockets
这是另一个重要的结论。
并没有规定必须围绕 WebSocket 连接来重构整个应用程序。
你可以混合使用不同的方法:
REST API
+
WebSockets
+
PostgreSQL
+
Redis
例如:
REST
在处理以下情况时使用 REST:
Login
Get conversation history
Create conversation
Upload files
Search messages
WebSocket
在处理以下情况时使用 WebSocket 事件:
New message
Typing indicator
Online status
Read receipts
Live notifications
这种混合架构通常比试图通过套接字处理所有事务要简单得多。
16. 生产环境需要考虑的事项
转向实时处理会带来新的问题。
你需要考虑:
Authentication
Authorization
Connection limits
Reconnection
Message ordering
Duplicate messages
Offline users
Presence
Rate limiting
Horizontal scaling
Redis
Monitoring
Database performance
如果你正在构建更接近完整消息传递产品的系统,还需额外考虑:
Message delivery guarantees
Idempotency
Unread counts
Read receipts
File attachments
Push notifications
Message pagination
问题的范围会迅速扩大。
这正是为什么初始版本不应试图一次性解决所有问题。
17. 合理的起点
对于规模较小的应用,合理的初始架构如下:
Client
│
▼
┌───────────────┐
│ Node.js │
│ REST + WS │
└───────┬───────┘
│
┌──────┴──────┐
▼ ▼
PostgreSQL Redis
Messages Cache /
Users Presence
在实际需要时再逐步扩展即可:
Load Balancer
/ \
▼ ▼
Node.js A Node.js B
\ /
\ /
Redis
│
▼
PostgreSQL
保持初始架构的简洁性,测试其在实际使用中的性能表现。只有确定某些部分确实是性能瓶颈后,再对它们进行优化。
18. 实时后端的检查清单
在认为聊天后端已具备生产环境使用条件之前,需确认:
[ ] Authentication implemented
[ ] Authorization for conversations
[ ] WebSocket connection handling
[ ] Room management
[ ] Message persistence
[ ] Message pagination
[ ] Reconnection handling
[ ] Duplicate message handling
[ ] Online/offline presence
[ ] Typing indicators
[ ] Rate limiting
[ ] Redis for multi-instance coordination
[ ] Logging
[ ] Monitoring
[ ] Database indexes
[ ] Load testing
[ ] Failure scenarios tested
最后的思考
从表面上看,构建聊天功能似乎很简单。
你输入内容:
"Hello"
然后其他人会收到:
"Hello"
但在这两个简单动作背后,其实隐藏着一系列工程挑战:
Connection management
Authentication
Authorization
Event delivery
Persistence
Ordering
Presence
Reconnection
Scaling
正是这些隐含的复杂性,使得实时系统值得深入研究。
值得牢记的核心原则是:
切勿从第一天就急于构建高度分布式的系统。
应先从以下步骤开始:
Node.js
+
Socket.IO
+
PostgreSQL
了解这种组合在真实环境中的表现。
只有当实际需求确实需要时,才引入 Redis 及其他实例。
保持第一个版本尽可能简单。花时间理解各核心组件之间的协作方式,观察系统在真实负载下的表现。仅对那些确实需要更多处理能力的部分进行扩展。
正是通过这样的逐步发展,一个基础的聊天功能才能演变成可靠的实时后端。
相关阅读
- Redis缓存基础:模式、陷阱与面试常见问题 — 了解Redis缓存在Node.js应用中的工作原理,涵盖缓存旁路、TTL设置、挤兑保护、驱逐策略以及常见的面试问题。
- 找出慢速Node.js端点的真正瓶颈 — 学习一种系统化的方法,通过请求路径从Node.js代码到数据库查询来追踪后端延迟,可使用计时功能和EXPLAIN ANALYZE命令。