Tcp实现限流服务——优雅地拒绝"贪婪"的客户端
一、引言:当"好客"变成"受难"
在一个阳光明媚的下午,我正悠哉悠哉地更新博客。忽然,一条来自群友的私信打破了宁静——这位朋友是一名对网络编程充满热情的大学生,最近正在研究 TCP 连接数量限流和接收流量限流。他噼里啪啦连续发来一堆问题,我的屏幕差点没装下……
其实,限流这个话题,本质上就是 "服务器学会说不" 的艺术。
你想想:一个热门餐厅,如果不限号,来多少人接多少,厨师当场崩溃,菜没法做,最终所有人都饿肚子。但如果合理地限制每桌用餐人数、控制上菜速度,反而能让大家都享受美食体验。TCP 限流的道理如出一辙。
以前写过一篇博客聊过相关话题,但那会儿写得太简单,点到即止。这次要升级一下方案,把原理和代码都聊清楚,让你彻底搞明白——如何用最优雅的方式,礼貌而坚定地"限制"你的客户端。
二、技术选型:站在巨人的肩膀上
2.1 TCP 服务器:TouchSocket
这里使用 TouchSocket-TcpService 作为 TCP 服务器。
为什么选它?因为 TouchSocket 支持插件化的开发方式,我们可以把限流逻辑封装进插件里,做到:
- 逻辑清晰,一眼看出这个插件是干嘛的
- 可插拔,不需要限流了直接卸载插件即可
- 可复用,写一次,到处用
就好比给餐厅门口配了一个"礼貌但强硬"的门卫,而不是让每个厨师自己去拦客——专业分工,效率翻倍。
2.2 限流算法:微软官方出品
限流算法使用微软提供的 System.Threading.RateLimiting 库。这个库是 .NET 官方在 .NET 7 中引入的,专门用来实现流量控制,可靠性拉满。
它内置了 4 种限流算法,各有特点:
| 算法 | 特点 | 适用场景 |
|---|---|---|
| 并发限制(Concurrency) | 限制同时进行的请求数量 | 限制并发连接数 |
| 令牌桶(Token Bucket) | 令牌匀速补充,消费灵活 | 允许一定突发流量 |
| 固定时间窗口(Fixed Window) | 固定时间内限制请求数 | 简单的 QPS 限制 |
| 滑动时间窗口(Sliding Window) | 更平滑的时间窗口 | 精确的速率控制 |
本文示例以固定时间窗口为例。其他算法的用法大同小异,具体参数可参考微软官方文档,举一反三即可。
三、实践效果:眼见为实
话不多说,先看效果。下图演示了连接数量限流和流量限流的实际效果:
- 当连接数超过上限时,新连接会被礼貌拒绝(服务器内心:对不起,我已经满了)
- 当某个客户端发送数据过快时,服务器会优雅减速(不是不收,是慢慢收)
是不是很丝滑?接下来我们就来看看这背后的代码实现。
四、代码实现:插件化的艺术
4.1 整体架构
整个限流方案基于 TouchSocket 的插件体系,我们只需要在服务器启动时注册好两个插件即可——连接限流插件和接收流量限流插件。
服务器启动代码如下:
就这么简单!接下来分别看这两个插件的内部实现。
4.2 连接限流插件
连接限流的核心思路:在握手阶段就拦截。
TouchSocket 提供了 ITcpConnectingPlugin 接口,它会在 TCP 连接建立之前触发。我们在这里检查是否超过连接数限制,超了就拒之门外——就像夜店门口的保镖,超员了直接让你回家。
完整插件代码如下:
关键点解析
限流器初始化:
这里的参数含义:
PermitLimit = 2:每个时间窗口内最多允许 2 个新连接(演示用,生产环境请适当调大)QueueLimit = 4:允许最多 4 个请求在队列中等待(这里我们选择直接拒绝,所以这个参数不太关键)Window = TimeSpan.FromSeconds(5):时间窗口为 5 秒
连接拒绝逻辑:
代码非常直接:AttemptAcquire(1) 尝试获取一个令牌,如果没拿到(IsAcquired == false),就设置 e.IsPermitOperation = false,直接拒绝连接。顺便记录一条警告日志,让你知道是谁在"碰壁"。
client.IsClient 的判断是为了在服务器插件中区分"服务端角色"和"客户端角色",避免在客户端模式下也触发连接限制。如果你的服务器纯粹作为服务端使用,可以省略这个判断。
4.3 接收流量限流插件
流量限流和连接限流有一个本质区别:连接限流是全局的(针对所有连接),而流量限流是per-client 的(每个客户端独立计算自己的配额)。
这就像:餐厅的座位总数是固定的(全局),但每桌的上菜速度要单独控制(per-client),不能因为某桌狼吞虎咽就让其他桌也饿着。
4.3.1 per-client 的限流器绑定
为了给每个客户端绑定独立的限流器,这里巧妙地使用了 TouchSocket 的 DependencyProperty(扩展属性)机制,而不是靠字典维护——这样可以随着客户端的生命周期自动管理,免去了手动清理的麻烦。
这段代码声明了一个懒加载的扩展属性:当某个 ITcpSession 第一次调用 GetValue(RateLimiterProperty) 时,才会触发 OnCreateRateLimiter 工厂方法,为这个客户端创建专属的限流器并缓存起来。
这就是典型的"按需创建,随用随取"的设计思路,既优雅又高效。
4.3.2 流量消费逻辑
这里有一个细节需要注意:每次 AcquireAsync(step) 请求的 step 不能超过限流器的 PermitLimit。当一次性接收的数据量很大时,需要用循环分批"消费"令牌,直到所有数据都被处理完毕。
这类似于自助餐的取菜逻辑——盘子只有那么大,超过盘子容量的食物需要分两盘装。
4.3.3 完整插件代码
五、进阶思考:限流的背后
5.1 为什么要限流?
不限流会发生什么?想象一下:
- 连接爆炸:某个脑回路不正常的客户端(或者被攻击了)疯狂建立连接,服务器的文件描述符耗尽,新连接一个都接不了——这就是经典的连接耗尽攻击。
- 带宽打爆:某个客户端疯狂发数据,独占了所有带宽,其他客户端的数据淤积在缓冲区,体验极差。
- 内存溢出:接收缓冲区无限增长,最终 OOM——这比上面两个更惨烈。
限流就是在这些悲剧发生之前,给系统加一道"自我保护"的防线。
5.2 四种算法怎么选?
| 你的需求 | 推荐算法 |
|---|---|
| 我就想简单限制每秒连接数 | 固定时间窗口 |
| 我允许短时突发,但长期要平滑 | 令牌桶 |
| 我要更精确、避免窗口边界问题 | 滑动时间窗口 |
| 我要限制同时在处理的请求数 | 并发限制 |
本文的示例使用了固定时间窗口,简单直接,适合入门理解。
5.3 TouchSocket 插件体系的优势
插件体系最大的好处是关注点分离。业务逻辑和限流逻辑完全解耦:
- 想换限流算法?改插件内部,不动业务代码
- 想临时关闭限流?注释掉
a.Add<LimitNumberOfConnectionsPlugin>()这一行 - 想针对不同端口用不同限流策略?注册多个不同配置的插件实例
这种灵活性,如果用传统的"在业务代码里硬编码限流逻辑"的方式,根本实现不了。
六、总结
本文用一种"能吃饭就不饿肚子"的思路,介绍了如何用 TouchSocket + System.Threading.RateLimiting 给 TCP 服务器加上限流保护:
- 连接限流:在
ITcpConnectingPlugin中,通过FixedWindowRateLimiter实现全局连接数控制——超员直接拒绝,没有商量余地。 - 流量限流:在
ITcpReceivedPlugin中,通过DependencyProperty给每个客户端绑定独立的限流器——流量超标不断开,优雅地慢下来。
两个插件,两段代码,轻松搞定"贪婪"客户端的问题。
当然别忘了,PermitLimit、Window 等参数需要根据实际业务场景调整。演示里设置得很小,是为了方便看到效果,实际生产环境中需要结合压测数据来设定合理的阈值。
代码写好了,服务器就不再是那个来者不拒的"烂好人",而是一个懂得保护自己的"成熟的服务器"。
七、参考资料
八、本文示例 Demo
本文所涉及所有技术均是开源的,大家可以直接按照教程搭建项目。
但是长期以往,我们对于 Pro 用户的福利实在是遗憾。所以成品示例,我们仅为企业版 Pro 用户提供。
我们也欢迎大家通过 Pro 的购买,来支持我们和我们开源的项目。
