现代即时通讯系统面临的核心挑战之一是如何优雅地处理用户多设备同时在线的场景。想象这样一个典型场景:用户同时在手机、平板和电脑上登录同一个账号,每条消息都需要实时同步到所有设备。这种需求背后隐藏着复杂的工程问题,需要重新思考传统IM系统的连接管理机制。
我们团队在改造自研IM网关时,最初采用的是最简单的单连接模型:每个用户ID只维护一条WebSocket连接。这种设计在早期确实够用,但随着用户设备数量的增加,其局限性日益明显——新设备登录会踢掉旧设备,消息只能在单个设备接收,完全无法满足现代用户的使用预期。
传统单连接模型的核心问题是混淆了用户身份和物理连接的概念。在多设备场景下,我们需要引入会话(Session)这一中间层:
这种分层设计的关键在于:
我们采用三组核心映射关系来管理这种复杂关联:
cpp复制// 会话ID到连接的映射
std::unordered_map<std::string, Connection*> ssid_connections_;
// 用户ID到会话集合的映射
std::unordered_map<uint64_t, std::unordered_set<std::string>> uid_sessions_;
// 连接到用户与会话的反向映射
std::unordered_map<Connection*, std::pair<uint64_t, std::string>> conn_clients_;
这种设计实现了:
关键提示:这些数据结构需要严格的线程保护,建议使用std::shared_mutex实现读写锁,因为读操作(消息发送)远多于写操作(连接建立/断开)
当新设备登录时,系统执行以下步骤:
bash复制HSET sessions:{session_id} uid 12345 device_type ios
SET uid:12345:device:ios {session_id}
cpp复制// 线程安全地更新映射关系
void AddConnection(uint64_t uid, const std::string& ssid, Connection* conn) {
std::unique_lock lock(mutex_);
ssid_connections_[ssid] = conn;
uid_sessions_[uid].insert(ssid);
conn_clients_[conn] = {uid, ssid};
}
连接断开时的清理工作必须保证原子性:
cpp复制void RemoveConnection(Connection* conn) {
std::unique_lock lock(mutex_);
auto it = conn_clients_.find(conn);
if (it == conn_clients_.end()) return;
auto [uid, ssid] = it->second;
// 清理Redis中的设备标记
redis_.DEL(fmt::format("uid:{}:device:{}", uid, GetDeviceType(ssid)));
// 清理内存映射
ssid_connections_.erase(ssid);
conn_clients_.erase(conn);
auto& sessions = uid_sessions_[uid];
sessions.erase(ssid);
if (sessions.empty()) {
uid_sessions_.erase(uid);
}
}
多设备场景下,消息发送从单播变为多播:
cpp复制std::vector<Connection*> GetConnections(uint64_t uid) {
std::shared_lock lock(mutex_);
std::vector<Connection*> result;
auto it = uid_sessions_.find(uid);
if (it != uid_sessions_.end()) {
for (const auto& ssid : it->second) {
if (auto conn = ssid_connections_[ssid]) {
result.push_back(conn);
}
}
}
return result;
}
void SendToUser(uint64_t uid, const Message& msg) {
auto conns = GetConnections(uid);
if (conns.empty()) return;
auto payload = Serialize(msg); // 只序列化一次
for (auto conn : conns) {
conn->Send(payload); // 复用已序列化的数据
}
}
我们采用"单设备单会话"原则:
Redis中的设备标记实现:
bash复制# 登录时
SET uid:12345:device:ios session_abc EX 86400
# 检查是否已登录
EXISTS uid:12345:device:ios
# 登出时
DEL uid:12345:device:ios
多设备同步带来的新挑战:
建议解决方案:
cpp复制struct Message {
uint64_t msg_id; // 全局唯一ID
uint64_t seq_id; // 会话内序列号
uint64_t sender;
uint64_t receiver;
int64_t timestamp;
std::string content;
};
这套架构支持以下扩展方向:
设备能力协商:在会话元数据中记录设备特性
json复制{
"screen_size": "1080x1920",
"push_capabilities": ["fcm", "voip"],
"client_version": "3.2.1"
}
差异化推送:根据设备类型调整消息格式
cpp复制void AdaptMessageForDevice(Message* msg, const DeviceInfo& device) {
if (device.type == "ios") {
msg->SetPriority(kHighPriority);
}
// 其他设备特定逻辑
}
会话迁移:支持跨设备转移活跃会话
在实际项目中,我们从单连接模型改造为多设备支持大约花费了两周时间,主要工作量集中在:
改造后的系统成功支持了日均千万级的多设备消息同步,平均延迟控制在50ms以内,内存开销增加约15%(主要来自额外的映射关系存储),这个代价对于获得的功能提升来说是完全可以接受的。