Qt 客户端/服务端项目优化

发布时间:2026/7/11 22:36:28
Qt 客户端/服务端项目优化 项目链接https://github.com/HBB958/CloudDisk/心跳检测、密码哈希与连接稳定性改造最近我对自己的 Qt/C 客户端服务端项目做了一轮优化。这个项目本身已经具备登录、好友、聊天、文件上传下载等基础功能但在实际测试时发现只做到“功能能跑”还不够长连接稳定性、账号状态一致性、密码存储安全、TCP 粘包处理这些问题都会影响项目质量。这篇文章记录一下这次比较关键的优化点。1. 为什么要加心跳机制客户端和服务端之间使用的是 TCP 长连接。正常情况下断开连接会触发disconnected信号但现实情况并不总是这么理想。比如客户端异常退出网络突然中断机器休眠路由器断网服务端认为连接还在但客户端已经不可用了这类情况会造成“假在线”数据库里用户还是在线状态服务端 socket 列表里也还保留着连接但实际上消息已经发不到对方。所以我加了一套心跳机制。核心思路很简单客户端每隔一段时间发送心跳包服务端收到后回复心跳响应客户端连续多次收不到响应就认为连接异常服务端也定时扫描客户端最后活跃时间超时则主动清理连接协议里新增了两个消息类型ENUM_MSG_TYPE_HEARTBEAT_REQUEST, ENUM_MSG_TYPE_HEARTBEAT_RESPOND,客户端心跳逻辑大致如下voidHeartBeat::sendHeartbeat(){if(Client::getInstance().socket.state() ! QAbstractSocket::ConnectedState) {stop();return; }if(m_missedCount kMaxMissedCount) {stop();emittimeout();return; } PDU *pdu mkPDU(); pdu-uiType ENUM_MSG_TYPE_HEARTBEAT_REQUEST; Client::getInstance().sendMsg(pdu); m_missedCount; }voidHeartBeat::receivePong(){ m_missedCount 0; }这里没有一丢包就断线而是允许连续丢失几次心跳。这样可以避免网络短暂波动导致用户频繁掉线。服务端收到心跳后只需要回复一个响应包PDU *MsgHandler::heartbeat(){ PDU *respdu mkPDU(); respdu-uiType ENUM_MSG_TYPE_HEARTBEAT_RESPOND;returnrespdu; }同时服务端还会定期检查所有 socket 的最后活跃时间voidHeartBeat::checkTimeout(){ qint64 now QDateTime::currentMSecsSinceEpoch();for(MyTcpSocket *socket : *m_sockets) {if(socket socket-isHeartbeatTimeout(now, kTimeoutMs)) { QMetaObject::invokeMethod(socket,closeByHeartbeatTimeout, Qt::DirectConnection); } } }每次服务端收到客户端数据时都会刷新活跃时间voidMyTcpSocket::recvMsg(){ QByteArray data this-readAll();if(!data.isEmpty()) {refreshHeartbeat(); } buffer.append(data);// 后续继续解析完整 PDU}这样做之后在线状态就不再完全依赖 TCP 的断开通知而是由业务层主动判断连接是否还健康。2. 断线后的用户体验优化只有检测到断线还不够客户端也要给出合理反馈。我的处理是一旦心跳超时或者服务端断开连接就清空缓冲区、停止心跳、回到登录界面并提示用户重新登录。voidClient::handleHeartbeatTimeout(){if(m_connectionLostHandled) {return; } m_connectionLostHandled true; heartBeat-stop(); buffer.clear(); socket.abort();if(isLoggedIn()) {resetToLoginAfterConnectionLost(); } }这里加了m_connectionLostHandled标记是为了避免心跳超时、socket 断开等多个事件重复弹窗、重复重置界面。这个优化看起来不复杂但对体验很重要用户能明确知道“连接已经不可用”而不是停留在一个看似在线但实际无法操作的界面。3. 密码不再明文存储之前注册用户时密码直接写入数据库。这是非常不安全的。这次我把密码存储改成了 PBKDF2-SHA256 哈希。严格来说这不是“可逆加密”而是更适合密码存储的“不可逆哈希”。存储格式设计成pbkdf2_sha256$迭代次数$salt$hash生成密码哈希时每个用户都会有独立随机盐QStringPasswordHasher::hashPassword(constQByteArray password){ QByteArray salt generateSalt(16); QByteArray hash deriveKey(password, salt,200000,32);returnQString(%1$%2$%3$%4) .arg(pbkdf2_sha256) .arg(200000) .arg(QString::fromLatin1(salt.toBase64())) .arg(QString::fromLatin1(hash.toBase64())); }登录时不再比较明文密码而是重新用相同 salt 和迭代次数计算一次哈希再比较结果boolPasswordHasher::verifyPassword(constQByteArray password,constQString storedHash){ QStringList parts storedHash.split($); QByteArray salt QByteArray::fromBase64(parts.at(2).toLatin1()); QByteArray expected QByteArray::fromBase64(parts.at(3).toLatin1()); QByteArray actual deriveKey(password, salt, parts.at(1).toInt(), expected.size());returnconstantTimeEquals(actual, expected); }比较函数也做了常量时间比较避免因为比较过程提前返回而泄露额外信息boolPasswordHasher::constantTimeEquals(constQByteArray left,constQByteArray right){if(left.size() ! right.size()) {returnfalse; }unsignedchardiff 0;for(inti 0; i left.size(); i) { diff | left.at(i) ^ right.at(i); }returndiff 0; }数据库注册逻辑也变成了先哈希再入库QString hashedPassword PasswordHasher::hashPassword(password); q.prepare(insert into user_info(name, pwd) values(:name, :pwd)); q.bindValue(:name, name); q.bindValue(:pwd, hashedPassword); q.exec();这带来的好处是即使数据库泄露攻击者也不能直接拿到用户原始密码。4. SQL 改成参数绑定这次顺手把很多 SQL 拼接改成了prepare bindValue。例如登录查询q.prepare(select pwd from user_info where name :name); q.bindValue(:name, name); q.exec();这样做有两个好处避免 SQL 注入用户名里出现特殊字符时不容易把 SQL 语句拼坏对于项目来说这属于很基础但很值得做的安全优化。5. TCP 粘包、半包处理TCP 是字节流协议它不会保证一次readyRead就刚好读到一个完整业务包。所以我在客户端和服务端都维护了一个缓冲区buffer.append(socket.readAll());while(buffer.size() int(sizeof(PDU))) { PDU *pdu reinterpret_castPDU *(buffer.data());if(buffer.size() int(pdu-uiPDULen)) {break; }handMsg(pdu); buffer.remove(0, pdu-uiPDULen); }这段逻辑解决了两个问题半包数据还没收完整先放在 buffer 里等下一次粘包一次读到多个 PDU就循环逐个处理这类优化不一定立刻能在小规模测试里暴露问题但只要网络环境复杂一点粘包半包迟早会出现。6. 敏感日志脱敏登录和注册请求里会带用户名和密码。之前调试时直接打印整个 PDU容易把密码打到日志里。所以我给敏感请求单独封装了发送方法voidClient::sendSensitiveMsg(PDU *pdu){ socket.write((char*)pdu, pdu-uiPDULen);qDebug() send sensitive msgtype pdu-uiType caData[hidden]caData32[hidden];free(pdu); }日志仍然能看到消息类型和发送行为但不会暴露账号密码。7. 同账号重复登录处理另一个细节是同一个账号重复登录。服务端登录成功后会检查是否已经存在同名在线 socketMyTcpSocket *oldSocket MyTcpServer::getInstance().findSocketByName(name, currentSocket);if(oldSocket !nullptr) {if(!forceLogin) {// 提示客户端账号已在线}else{ MyTcpServer::getInstance().kickSocket(oldSocket); } }如果用户选择强制登录旧客户端会收到下线通知pdu-uiType ENUM_MSG_TYPE_FORCE_OFFLINE_NOTICE; socket-sendMsg(pdu); socket-disconnectFromHost();这个优化让在线状态更可控也避免同一账号在多个连接里出现状态混乱。总结这次优化最大的感受是项目从“能跑”到“更稳、更安全”靠的不是某一个巨大改动而是一组细节共同补齐。心跳机制解决了长连接假在线问题断线重置让用户体验更明确密码哈希避免了明文落库SQL 参数绑定降低了注入风险粘包处理让 TCP 通信更可靠敏感日志脱敏减少了调试阶段的信息泄露。后续如果继续优化我会考虑把传输层也升级为 TLS。因为目前密码虽然不会明文存储但客户端到服务端之间的传输安全仍然可以继续加强。