1. 项目起源:为什么我会在C#里折腾高性能服务器
1.1 一个被业务慢查询拖垮的接口集群
今年上半年,我们团队维护的一套内部公共服务迎来了最难熬的两周。某个面向终端的长连接接入服务,一到晚高峰就出现大量超时,CPU还没打满,响应却越来越慢。起初大家都以为是数据库慢查询,加了索引、改了SQL,问题也只是从 5 分钟延迟变成 3 分钟延迟。后来我花了两周时间把它重构成一个基于 C# 的高性能服务器,从 Socket 接入层一路改到业务处理层。这条路让我意识到,所谓“全栈服务”,不是用一堆框架堆出来的,而是每一层都要具备扛住压力的能力。
复盘时发现,慢查询只是压垮系统的最后一根稻草。真正的问题在于,服务在接受了连接之后,每个请求都以“同步阻塞”的方式等待数据库返回,线程池很快被打满。后续进来的TCP连接全部排队,Socket 接收缓冲区越积越多,最终连心跳包都无法及时处理。这让我下决心把整个服务重新梳理一遍,而不是继续在SQL上打补丁。也是这次重构,让我第一次完整审视了C#服务从网络接入到业务处理的所有环节。
1.2 “高性能”不是单机QPS,而是全链路稳定
很多团队谈起高性能,第一反应是单机能扛多少并发请求。但从那次事故里我学到的是:一个服务只要有一层成为瓶颈,其他层再快也没用。全栈服务更像一条水管链路,接入、解析、业务、存储、可观测任何一个环节漏水,整体流量就上不去。因此这个项目里我给自己定了几条衡量标准,不是只盯QPS,而是同时看四件事:吞吐量、P99延迟、GC压力、异常导致的连接断开率。
这四个指标里,P99延迟和GC压力往往最容易被忽略。QPS看着很高,一旦GC频繁触发,P99立刻飙升,客户端就会不断重连,反而加剧负载。所以后续所有优化动作,我都会用一个统一的压测场景来对比,避免只优化局部导致其他指标恶化。这也是为什么我最终把它定义成“全栈服务实现”,而不是“网络库调优”——从传输层到业务层,每一层都得能撑住流量才行。
2. 第一层:网络传输骨架怎么搭最踏实
2.1 SocketAsyncEventArgs还是Kestrel
我首先面临的选择是:自己用原生Socket写接入层,还是直接用Kestrel。当时我们已经确认协议是自定义二进制长连接,并不需要HTTP的丰富语义。如果直接用Kestrel,好处是可以少处理很多底层细节,坏处是它在设计上更偏向HTTP语义,对自定义协议的扩展并不是零成本。最终我选择用SocketAsyncEventArgs自己搭骨架,主要是因为两个原因:第一,长连接场景里每个连接的生命周期很长,接入层需要精细控制收发缓冲区;第二,后续要做心跳、鉴权、路由,自己管理连接状态会更直接。
.NET里SocketAsyncEventArgs底层依赖IO完成端口,在高并发下性能比老的Begin/End异步模型高不少。核心用法是把每个接收操作封装成上下文,事件回调时处理数据,然后重新投递接收。这里有个容易被忽略的点:如果你的服务大多是小包交互,尽量把缓冲区复用起来,不要在每次接收时new一个新的byte[],否则GC压力会很快把你那点异步优势吃掉。你可以在池里为每个SAEA对象分配一块独立缓冲区,操作完成后再归还,这样反复使用的成本很低。
2.2 把连接抽象成会话而不是裸字节流
搭好Socket之后,我并没有直接把收到的字节丢给业务层,而是先抽象了一个会话对象,里面保存了连接状态、本地接收缓冲区、最近一次心跳时间、序列号、鉴权状态和待发送队列。这个设计看起来多了一层,但长期用下来非常关键,否则每个事件回调里都要在全局字典里根据连接ID翻状态,后期加需求会非常痛苦。会话对象通常会包含一个发送队列,因为在TCP链路里,一次写入可能只发送了一半,剩余部分必须排队等待下一次可写事件。
会话对象的生命周期也值得注意。每次Accept一个连接就创建会话,断线后不一定立刻释放,因为可能存在尚未发送完的队列。我会用一个显式的Close方法,把发送队列清空,归还缓冲池,再将会话标记为Closed。这个流程一定要保证幂等,否则重复触发断线回调时,可能把正在复用的缓冲区释放两次,这是很多Socket服务崩溃的一个隐藏原因。我在重构期间专门为Close方法写了几组并发测试,保证无论断线回调以什么顺序触发,都不会出现双重释放。
2.3 断线重连与心跳策略的细节
长连接服务里,心跳和断线重连策略直接决定系统的稳定性。我见过不少项目把心跳间隔设成30秒,结果服务端在60秒内没收到包就断开,看起来正常,但在弱网环境下会造成大量无谓重连。我这里采用的方案是:心跳间隔设为45秒,超时时间为180秒,同时配合TCP KeepAlive做底层兜底。重连时使用指数退避,从1秒开始,每次乘2,到30秒封顶,避免断线风暴打垮服务端。客户端断线后如果立即重连,服务端可能还停留在TIME_WAIT状态,连接建立反而更容易失败。
另外,处理半包和粘包是解析层的核心。我采用的是“4字节长度头+消息体”的协议格式,长度头用小端序表示整个消息体的字节数。接收时必须先把当前Buffer里的数据拼起来,判断长度是否足够,不足就等待下一段数据;如果一包数据包含了多个消息,要循环解析到缓冲区剩余长度不足为止。这个逻辑最好单独封装,不要跟业务处理混在一起,否则后面加功能很容易改出边界bug。我在封装时把解析器拆成状态机,只保留三个状态:读长度头、读消息体、分发消息,既清晰又容易测试。
3. 第二层:数据处理管道的性能死角
3.1 避免昂贵的字符串拆分
很多C#开发者处理TCP包时习惯收到数据后直接转成字符串,再用Split或者正则去取字段。这个做法在小并发下没毛病,但一旦每秒要处理几万个包,字符串分配就会成为吞吐瓶颈。我重构时把协议解析全部放在字节层面,能比较的用字节序列比较,能整段解包的直接用Span切片,尽量不产生子字符串。例如状态码字段,可以直接比较byte数组,而不是转string再比较。还有消息类型,只要几个字节,完全没必要构建字符串对象。
这里的底层逻辑在于:string是不可变类型,每Split一次就会产生多个新对象,连同字符编码转换开销,很快会让GC进入频繁第二代回收。而Span和ReadOnlySequence本身是只读结构,切片只是视图,不复制底层数据。所以它们非常契合高性能解析场景。当然,并不是说绝对不能转字符串,只有那些最终要进入业务模型或日志的字段才在最后一刻转换,这样可以大幅压缩中间对象的数量。像是用户名、备注这类必须展示的字段,等业务真正需要时再转成string。
3.2 用SequenceReader解析二进制协议
当接收缓冲区是多个不连续的内存段时,直接用Span不太方便,这时候.NET自带的SequenceReader就派上了用场。它处理ReadOnlySequence 非常顺手,可以顺序读取长度头、消息类型、业务字段,同时自动处理跨越缓冲段边界的情况。我解析格式时写了类似下面的循环:先用TryReadLittleEndian读取4字节长度,接着用TryReadBytes读取完整消息体,然后根据消息类型做分发。整个过程不需要复制缓冲区,也不需要考虑分段问题。
这里有一个实战提示:不要在每个消息里都new一个解析上下文,而是复用一个解析器实例,所有解析状态局部保存在栈上。另外在读取变长字段时,要格外小心长度校验,防止恶意构造的包导致内存分配过大。我会在协议头部加上最大长度限制,超过就直接断开连接,这既是安全考虑,也避免了解析器被异常包拖住。压测时我特意发过几类畸形包:长度字段为负数、长度超过上限、消息体截断,最终系统都应该优雅拒绝而不是抛异常退出。
3.3 序列化方案选型实测
消息体内部字段,一开始用的是JSON,但实测下来性能并不理想。原因有两个:一是JSON文本体积大,序列化和反序列化都要处理字符串;二是当时用的通用反射方案,在小包场景下分配很夸张。后来我对比了几种二进制方案,最终在“代码侵入性低”和“性能高”之间做了取舍。对外暴露的接口因为需要跨语言调试,仍然保留JSON;对内部服务之间的通信,全部切成二进制序列化。这里要强调一下,序列化方案必须跟你的协议解析放在一起设计,否则协议字段一变,序列化层和解析层很容易不同步。
给一个简单的对比结论:相同业务模型下,JSON方案每秒能处理约2.8万次,二进制方案可以跑到9万次以上,GC分配量从每千次约15MB降到不到2MB。当然数据量和机器配置不同会有差异,但趋势是一致的。如果你正在设计新服务,建议一开始就把内部协议定为二进制,不要等着流量上来再改,因为协议升级时的兼容性成本远高于序列化本身。即使以后要接入外部系统,也可以在网关层做一次协议转换,内部核心链路保持高效。
4. 第三层:并发与资源控制的取舍
4.1 Task调度不是越多越好
C#的async/await让并发编程变得简单,但也容易让开发者误以为“多开几个Task就能提升性能”。实际上.NET线程池的调度是有成本的,Task本身也要占内存。如果每个请求都创建大量子Task去做很小的事,线程池会不停判断是否增加线程,反而引起调度开销和线程切换。我在这个项目里给所有异步操作设了一套约束:细粒度的读操作直接用同步方法,只有碰到真正的IO操作(Socket、数据库、下游HTTP)才用async;需要限制并发量的地方统一使用SemaphoreSlim,避免无上限的任务堆积。
另外要特别注意async void,它只在事件处理器这类场景下允许使用,普通业务方法一旦抛出异常就会直接终结进程。我排查代码时删掉了好几个以async void结尾的服务内部方法,全部改成async Task并统一向上传递异常。这个改动本身不会提升性能,但能显著降低故障时进程崩溃的概率,间接保证服务器稳定性。如果你在代码评审里看到async void,一定要追问异常处理路径,因为绝大多数线上崩溃都不是主流程出错,而是某个旁路回调炸了。
4.2 Channel背压如何让系统自动减速
接入层解析完数据后,如果直接调用业务层,那么一旦业务层处理不过来,接收线程会被拖住。更好的做法是中间放一个有界Channel,接收入口只负责快速放入队列,业务消费者从队列里读取并处理。当队列满了,Channel的写操作会异步等待,自然形成背压,让接入层不要再接收新请求。这个机制相当于给系统装了一个流量阀门,效果比漫无目的地扩展线程池要好得多。它同时还能隔离接入层和业务层,接入层挂了不会直接影响业务处理,业务层抖动也不会立刻反馈到所有连接上。
代码上我用的是Channel.CreateBounded,容量设置为并发处理能力的两倍左右。消费者数量根据CPU核心数设置,但不要太过激进,先在本机压测观察延迟和内存再调整。这里有个常见误区:背压并不是越大越好,队列长度过大会导致延迟增加;队列长度过小又会让突发流量被拒绝。务必要结合你的业务特征,把容量和消费者数量当作性能参数来调,而不是随意拍脑袋。还要保证在消费者异常时,Channel不会无限堆积,必要时要加上超时丢弃策略。
4.3 线程池饥饿问题复盘
全栈服务不只是自己的逻辑,还要调用外部资源。项目中最典型的一次事故是:业务处理里使用了HttpClient同步调用下游接口,而下游接口偶尔变慢,最终导致线程池线程全部阻塞在等待响应上,CPU占用不高,但新请求全在排队。当时的现象特别有迷惑性,数据库没问题、接入层没问题,可系统就是吞吐骤降。排查到根因后,我做了两件事:第一,所有外部HTTP调用换成异步方式,并给每个调用都设置明确的超时和重试策略;第二,使用单例的HttpClient实例并通过连接池限制最大并发数,避免每次请求都新建连接导致端口和句柄泄漏。
这个问题在.NET圈里出现频率很高,但在压测环境下很难复现,因为压测工具一般不会模拟下游抖动。建议大家提前在外呼层加熔断和降级,否则出了问题再排查成本很高。我当时加了一个简单的熔断开关:当连续错误率达到阈值时,后续请求直接走降级逻辑,不再等待下游超时。这个开关并不复杂,但它在真实故障里救了系统一次。即便没有完整的服务治理框架,一个简单的状态枚举加原子更新也够用。
5. 第四层:把单机能力变成全栈能力
5.1 接入网关与身份校验的合并
单机性能提升之后,我又面临新的问题:多个业务线都要接入同一个长连接服务,每个业务线的鉴权、限流和日志规则还不一样。如果每个业务线都自己实现一套,不仅重复,还会因为不同团队改法不一致导致安全隐患。于是我顺手把接入层做成了统一网关,所有连接先进网关,网关负责协议解析、心跳维护、身份校验和基础限流,然后再通过内部RPC把合法请求转发给后端的业务服务。这么做的另一个好处是,以后要加限流或风控逻辑,都只需要在一个地方修改。
网关里最核心的一张表是会话表,保存连接ID、用户ID、鉴权状态、过期时间。为了避免每次转发都查数据库,我用本地缓存加了读缓存,并配合远程缓存做跨实例同步。这里有个权衡:本地缓存虽然快,但在多实例部署时需要处理缓存失效的广播;远程缓存虽然一致性好,但会引入一次网络延迟。最终的方案是热点会话本地缓存加短TTL,被淘汰时重新回源,绝大多数请求都不会真正查远程缓存。实际运行后,网关转发路径的平均额外延迟不到0.5毫秒。
5.2 服务间的RPC与消息投递
接入网关和后端业务服务之间的通信,我一开始尝试用HTTP REST,因为大家熟。但后来发现每次请求都要做HTTP头部解析、JSON编解码、连接复用配置,性能上明显吃亏。考虑到这是纯内部链路,没有跨语言约束,我改用了基于TCP的轻量级私有RPC协议,本质上就是前面提到的二进制数据管道,只是套了一层请求/响应模型。这样的改造让内部转发延迟从平均1.2毫秒降到0.4毫秒,而且连接数和内存占用都降了不少。
不过这里也要提醒:并不是所有内部通信都适合同步RPC。像登录成功后的通知、数据变更的广播,这些场景应该走异步消息队列,把耦合打散。我当时的做法是先梳理调用链,凡是“调完必须立刻拿到结果”的保留为RPC;凡是“发出去就行,不需要马上响应”的改成队列。这样系统整体不再是一条笔直的同步链,突发流量可以通过队列缓冲,稳定性明显提升。如果从一开始就把所有内部调用都设计成RPC,后面改造成队列的成本会非常高,所以这个决策要提早在架构阶段想清楚。
5.3 可观测性组件接入的教训
性能做得再高,如果出问题时看不清楚链路,依然等于盲人摸象。我在项目里基于一套标准的可观测性框架,给接入层、网关层、业务层分别打了日志、指标和链路追踪。刚开始为了“全”,几乎每个方法都埋点,结果发现日志量直接暴涨,磁盘IO成为新瓶颈,而且Trace采样率设成100%后,网关自身的P99延迟被拖慢了将近30%。这让我意识到,可观测性本身也是要花钱的,这里的钱就是CPU、内存和磁盘IO。
后来我痛下决心做减法:访问日志只保留关键字段,业务日志按级别分流,链路追踪改成10%采样;那些仅用来排查极端问题的详细上下文,单独放到本地环形缓冲区里,只有当请求出错时才随错误日志一起上报。这个思路很像日志界的“冷热分离”。经过调整,可观测性的额外性能开销从8%降到了2%以内,而且排查问题反而更快了。因为真正重要的日志永远在显眼的位置,不会汪洋大海里捞针。
6. 第五层:压测、调优与长期维护
6.1 我的压测环境和指标口径
性能调优不能靠感觉,我搭了一套可重复的压测环境:用若干台压测机发起长连接流量,目标服务单独部署在一台物理机上,避免资源争抢。压测模型直接采用线上峰值流量再上浮50%,持续时间至少15分钟,因为很多问题是长时间运行才会暴露出来的,比如内存缓慢增长、连接句柄泄漏、时间戳溢出等。压测过程中我会同时记录服务端进程的CPU、内存、GC、线程数、Socket状态,而不是只看压测工具打出的吞吐报告。
在指标口径上,我一律用P99而不是平均值来判断体验。平均值很容易被少数超快请求拉低,掩盖部分慢请求的问题。另外我还会记录进程每分钟的GC次数和分配量,以及连接数变化曲线。如果连接数曲线在后半段持续上升,多半是连接没有被正常释放,这种问题比单纯QPS低更致命。压测结果出来后,我会同步记录配置文件中的每一个参数,保证每次对比都有相同的基线。没有基线的压测,跑出来的数字只是数字,没法指导优化。
6.2 三个最有代表性的问题定位
第一个是P99尖刺问题。现象是平时延迟很稳,每隔几十秒会出现一个几千毫秒的异常点。通过抓GC日志发现,每次尖刺都对应一次第2代GC。根因是解析层大量使用临时byte[],导致大对象堆碎片化。修复方法很简单也很有效:所有缓冲区改用共享缓冲池,大报文单独走池化分配,尖刺从每30秒一次降到几乎看不到。这个案例让我明白,性能优化里所谓“细节决定成败”,指的就是这种不起眼的new操作。
第二个是CPU看起来不高但机器负载很高。原因是业务处理中对一个静态字典做了加锁操作,所有请求都串行等待同一个锁。我把它改成了只读快照加原子替换,也就是写时复制,读操作完全不加锁。这个改动让吞吐提升了将近一倍。第三个是TIME_WAIT连接数过多,导致端口耗尽,新连接无法建立。这个不是应用层bug,而是频繁短连接导致的。我启用连接复用并适当调整了系统网络参数之后,问题消失。这些都是性能优化里最典型的几个方向:内存分配、锁竞争、系统网络参数。
6.3 部署阶段容易被忽略的系统参数
应用代码没问题,不代表部署到Linux上就能跑出高性能。我记得第一次全量上线时,服务压测到一半出现大量“Too many open files”,才知道默认的文件描述符限制根本不够承载高并发连接。后来我整理了一份部署检查清单,重点包括:文件描述符上限、TCP连接跟踪表大小、端口范围、以及.NET运行时的GC模式配置。下面这张表是我自己用的版本,分享出来给大家参考。
| 配置项 | 初始值 | 调整建议 | 说明 |
|---|---|---|---|
| ulimit -n | 1024 | 至少65535 | 高并发连接会打开大量socket文件描述符 |
| net.core.somaxconn | 4096 | 按连接队列压力提高 | 影响Accept队列长度 |
| DOTNET_GCHeapHardLimit | 未设置 | 根据内存适度限制 | 防止GC占用过多内存引发系统压力升高 |
| DOTNET_TieredPGO | 0 | 1 | 开启分层PGO提升热点代码执行效率 |
| DOTNET_ReadyToRun | 未设置 | 按需关闭 | 交叉编译的R2R可能影响特定平台的JIT优化 |
这些参数不是我一次性想出来的,而是先后经历了几次线上故障才逐步补齐。每次调完参数,我都会留一行注释说明当时遇到的现象,方便后来者理解。至于那些需要重启才生效的参数,最好在发布流程里做成一个初始化脚本,否则新扩容的机器很容易出现配置漂移。我曾经因为一份旧的初始化脚本漏掉了参数,导致新节点一上线就异常,这个教训让我养成了配置即代码的习惯。
还有一个容易被忽略的点是时区与日志轮转。高负载下如果日志文件无限增长,磁盘很快就满,服务会自动挂掉。我习惯按大小和时间双维度切割日志,并设置保留天数。这不是性能参数,但绝对属于全栈服务长期维护的一部分。最后再分享一个经验:任何性能优化都要保留一个“破坏式验证”的环节,比如直接关掉一个下游依赖,观察系统是否会自动降级,而不是彻底瘫痪。这一步看起来与性能无关,但恰恰能检验一个服务器到底是“压出来的强大”,还是“扛得住故障的强大”。