连接管理:连接池设计、连接复用、连接保活、优雅关闭、连接数限制与防攻击
连接管理,说白了就是让服务器跟客户端之间的那条「管道」跑得稳、跑得快、还不被人搞坏。我做了这么多年后端,见过太多因为连接没管好而翻车的案例。今天咱们就把这块掰开揉碎了讲清楚。
连接池设计:别让每次请求都重新握手
你想想看,每次建立TCP连接都要三次握手,每次断开都要四次挥手。如果每个请求都来一遍,光握手开销就能吃掉你一半的性能。连接池就是解决这个问题的。
连接池的核心思想很简单:提前创建一批连接,用的时候拿,用完归还。但实现起来有几个坑要注意。
连接池的核心参数
- 最小连接数:池子里最少保持多少条连接。我习惯设成跟CPU核心数差不多,比如8或16。
- 最大连接数:池子最多能膨胀到多少。这个要看后端服务的承载能力,设太大反而会压垮数据库。
- 最大空闲时间:连接空闲多久后要回收。我一般设30秒,太短容易频繁创建,太长浪费资源。
- 获取超时时间:等不到连接时最多等多久。设个100毫秒就差不多了,等太久请求都超时了。
我在项目中遇到过一个问题:连接池满了,新请求拿不到连接,直接报错。后来加了等待队列和超时机制,才把这个问题解决掉。
// 一个简单的连接池核心逻辑
class ConnectionPool {
public:
Connection* acquire() {
std::lock_guard<std::mutex> lock(mutex_);
// 1. 先看看空闲队列有没有
if (!idle_.empty()) {
auto* conn = idle_.front();
idle_.pop();
return conn;
}
// 2. 没达到上限就新建
if (total_ < max_size_) {
total_++;
return new Connection();
}
// 3. 满了就等一会儿
// 这里用条件变量等待,超时返回nullptr
return nullptr;
}
void release(Connection* conn) {
std::lock_guard<std::mutex> lock(mutex_);
idle_.push(conn);
// 通知等待的线程
cv_.notify_one();
}
private:
std::queue<Connection*> idle_;
std::mutex mutex_;
std::condition_variable cv_;
int total_ = 0;
int max_size_ = 100;
};
连接复用:一条连接跑多个请求
连接复用跟连接池是两码事。连接池管的是「连接的生命周期」,连接复用管的是「一条连接上怎么跑多个请求」。
HTTP/1.1的Keep-Alive就是典型的连接复用。一条TCP连接上可以连续发多个HTTP请求,不用每次重新握手。但有个问题:队头阻塞。前一个请求没响应,后面的就得等着。
HTTP/2用多路复用解决了这个问题。一条连接上可以同时跑多个流,每个流独立收发。我建议你在设计RPC框架时也采用类似思路。
我的经验:连接复用不是越多越好。一条连接上并发太多请求,一旦出问题,影响面会很大。我一般控制在一条连接上同时跑不超过128个请求。
连接保活:别让连接静悄悄地断掉
你有没有遇到过这种情况:客户端连上服务器,啥也没干,过了几分钟再发请求,发现连接已经断了?这就是因为没有做保活。
TCP本身有Keep-Alive机制,但默认是关闭的,而且间隔时间太长(默认2小时)。我建议你在应用层自己做保活。
// 应用层心跳检测
class HeartbeatManager {
public:
void start(Connection* conn) {
// 每30秒发一次心跳
timer_.start(30000, [conn]() {
if (conn->isIdle()) {
conn->sendHeartbeat();
}
});
}
void onHeartbeatResponse(Connection* conn) {
conn->resetIdleTimer();
}
void checkTimeout() {
// 超过90秒没收到任何数据,判定连接死亡
auto now = getCurrentTime();
for (auto& conn : connections_) {
if (now - conn.lastRecvTime > 90000) {
conn.close();
}
}
}
};
我曾经遇到过一个线上问题:客户端跟服务器之间的防火墙把空闲连接给掐了,但服务端不知道。结果就是服务端还维护着一堆「僵尸连接」,白白浪费资源。后来加了心跳检测,这个问题就解决了。
注意:心跳间隔不要太短,否则会产生大量无效流量。我一般设30秒发一次,90秒没响应就判定超时。这个「30/90」的配比是我在多个项目中验证过的。
优雅关闭:别让数据丢在半路上
关闭连接看起来简单,调个close()就完事了。但你真的确定数据都发完了吗?
TCP关闭有四种方式,每种都有不同的语义:
| 关闭方式 | 行为 | 适用场景 |
|---|---|---|
| close() | 立即关闭,丢弃未发送数据 | 出错时快速释放资源 |
| shutdown(SHUT_WR) | 关闭写端,读端仍可接收 | 通知对端「我发完了」 |
| shutdown(SHUT_RD) | 关闭读端,写端仍可发送 | 不再接收数据 |
| shutdown(SHUT_RDWR) | 同时关闭读写 | 类似close()但更温和 |
我建议的优雅关闭流程是这样的:
- 先调用shutdown(SHUT_WR),告诉对端「我不再发了」
- 继续读取对端发来的数据,直到read返回0
- 确认所有数据都处理完了,再调用close()
为什么要这么麻烦?因为TCP是全双工的。你关闭写端后,对端可能还有数据要发给你。如果你直接close(),那些数据就丢了。
避坑指南:我曾经在做一个网关项目时,直接调了close()就返回了。结果客户端那边还在等响应,等了好几分钟才超时。后来改成优雅关闭,这个问题就再没出现过。
连接数限制与防攻击:别让坏人把资源耗光
没有连接数限制的服务器,就像没锁门的房子。攻击者可以建立大量连接,把你的内存和文件描述符全部耗尽。
连接数限制要分几个层面来做:
- 系统层面:ulimit -n 设置最大文件描述符数
- 进程层面:限制每个worker进程的最大连接数
- 客户端层面:限制每个IP的最大连接数
- 全局层面:限制整个服务的最大连接数
// 连接数限制器
class ConnectionLimiter {
public:
bool accept(const std::string& client_ip) {
std::lock_guard<std::mutex> lock(mutex_);
// 1. 全局限制
if (total_connections_ >= max_global_) {
return false;
}
// 2. 单IP限制
auto& count = ip_count_[client_ip];
if (count >= max_per_ip_) {
return false;
}
// 3. 通过
total_connections_++;
count++;
return true;
}
void release(const std::string& client_ip) {
std::lock_guard<std::mutex> lock(mutex_);
total_connections_--;
ip_count_[client_ip]--;
}
private:
int total_connections_ = 0;
int max_global_ = 10000;
int max_per_ip_ = 100;
std::unordered_map<std::string, int> ip_count_;
std::mutex mutex_;
};
防攻击这块,除了限制连接数,还要注意:
- 慢速连接攻击:建立连接后不发数据,慢慢耗你资源。解决方案是设置连接超时,比如30秒内没收到任何数据就断开。
- 连接风暴:短时间内大量连接涌入。可以用令牌桶算法做限流,或者用连接队列缓冲一下。
- SSL重协商攻击:频繁发起SSL重协商,消耗CPU。我一般限制每个连接每10秒最多重协商一次。
我的习惯:在接入层做连接数限制,在业务层做请求频率限制。两层防护,坏人想搞破坏就没那么容易了。
知识体系总览
下面这张图把连接管理的几个核心模块串起来了,你可以对照着看:
连接管理这块,说白了就是三个目标:性能、可用性、安全性。连接池和复用解决性能问题,保活和优雅关闭解决可用性问题,连接数限制和防攻击解决安全性问题。把这六个点都做到位了,你的服务器在连接层面就稳了。
嗯,今天就先聊到这儿。这些内容都是我这些年踩坑踩出来的经验,希望能帮你少走些弯路。
公众号:蓝海资料掘金营,微信deep3321