跳到主要内容

Tcp实现限流服务——优雅地拒绝"贪婪"的客户端

· 阅读需 9 分钟
若汝棋茗
Software Engineer Ⅱ

一、引言:当"好客"变成"受难"

在一个阳光明媚的下午,我正悠哉悠哉地更新博客。忽然,一条来自群友的私信打破了宁静——这位朋友是一名对网络编程充满热情的大学生,最近正在研究 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 为什么要限流?

不限流会发生什么?想象一下:

  1. 连接爆炸:某个脑回路不正常的客户端(或者被攻击了)疯狂建立连接,服务器的文件描述符耗尽,新连接一个都接不了——这就是经典的连接耗尽攻击。
  2. 带宽打爆:某个客户端疯狂发数据,独占了所有带宽,其他客户端的数据淤积在缓冲区,体验极差。
  3. 内存溢出:接收缓冲区无限增长,最终 OOM——这比上面两个更惨烈。

限流就是在这些悲剧发生之前,给系统加一道"自我保护"的防线。

5.2 四种算法怎么选?

你的需求推荐算法
我就想简单限制每秒连接数固定时间窗口
我允许短时突发,但长期要平滑令牌桶
我要更精确、避免窗口边界问题滑动时间窗口
我要限制同时在处理的请求数并发限制

本文的示例使用了固定时间窗口,简单直接,适合入门理解。

5.3 TouchSocket 插件体系的优势

插件体系最大的好处是关注点分离。业务逻辑和限流逻辑完全解耦:

  • 想换限流算法?改插件内部,不动业务代码
  • 想临时关闭限流?注释掉 a.Add<LimitNumberOfConnectionsPlugin>() 这一行
  • 想针对不同端口用不同限流策略?注册多个不同配置的插件实例

这种灵活性,如果用传统的"在业务代码里硬编码限流逻辑"的方式,根本实现不了。

六、总结

本文用一种"能吃饭就不饿肚子"的思路,介绍了如何用 TouchSocket + System.Threading.RateLimiting 给 TCP 服务器加上限流保护:

  1. 连接限流:在 ITcpConnectingPlugin 中,通过 FixedWindowRateLimiter 实现全局连接数控制——超员直接拒绝,没有商量余地。
  2. 流量限流:在 ITcpReceivedPlugin 中,通过 DependencyProperty 给每个客户端绑定独立的限流器——流量超标不断开,优雅地慢下来。

两个插件,两段代码,轻松搞定"贪婪"客户端的问题。

当然别忘了,PermitLimitWindow 等参数需要根据实际业务场景调整。演示里设置得很小,是为了方便看到效果,实际生产环境中需要结合压测数据来设定合理的阈值。

代码写好了,服务器就不再是那个来者不拒的"烂好人",而是一个懂得保护自己的"成熟的服务器"。

七、参考资料

  1. TouchSocket 官网
  2. System.Threading.RateLimiting 库
  3. Announcing Rate Limiting for .NET
  4. 以前的限流博客文章

八、本文示例 Demo

信息

本文所涉及所有技术均是开源的,大家可以直接按照教程搭建项目。

但是长期以往,我们对于 Pro 用户的福利实在是遗憾。所以成品示例,我们仅为企业版 Pro 用户提供。

我们也欢迎大家通过 Pro 的购买,来支持我们和我们开源的项目。