c#高并发三种解决方法

接入与开发

给 C# 后端开发者的一份速查:把高并发拆成三种可落地的解决思路,讲清各自适用场景、代码写法和不适用的时候。

线上接口一压就慢、线程池排队、数据库连接被抽干,这类问题很少是「代码写错了」,而是并发模型选错了。这篇不聊概念科普,直接给你三种在 C# 里能落地的解决思路:异步非阻塞、限流排队、缓存与拆分,每种都说清什么时候用、怎么改、什么时候别用。

线上接口一压就慢、线程池排队、数据库连接被抽干,这类问题很少是「代码写错了」,而是并发模型选错了。真正要判断的是:你的瓶颈到底在等 IO、在被打穿、还是在同一个点上反复取同一份数据。

这篇给 C# 后端开发者三种能落地的解决思路:异步非阻塞、限流排队、缓存与拆分。每种都说清什么时候用、代码大致怎么改、什么时候别用。想先看现成的服务端方案,可以到 /sites 里翻中转站与工具目录,边看边对照自己的链路。

思路一:异步非阻塞,把线程从等待里解放出来

判断信号很直白:请求里大量时间花在等 IO 上——查库、调模型接口、读文件——CPU 却很闲,线程数往上加吞吐也不涨。这种情况线程不是在干活,而是在排队等结果,异步能把这段等待让出去。

// 一路 async 透传,别在中间把线程堵回去
public async Task<Result> HandleAsync(Request req, CancellationToken ct)
{
    var cfg = await _cache.GetOrAddAsync(req.Key, ct); // 等 IO
    var data = await _db.QueryAsync(cfg.Id, ct);       // 等 IO
    return Build(data);
}
// CPU 密集单独丢出去,不要混在请求路径里
await Task.Run(() => HeavyCompute(payload), ct);
  • 改法:业务链路从同步改成 async/await,一路透传到最外层,中间别用 .Result / .Wait() 把线程又堵回去
  • CPU 密集的活单独丢线程池或后台队列,别占着请求线程跑
  • 要一起调的开关:线程池最小线程数、数据库连接池上限、HttpClient 复用(别每次 new)
  • 验证方式:压测时看线程池排队长度和连接池等待时间,这两个不降,说明还有地方在同步阻塞

思路二:限流加排队,先保住系统不被打穿

异步解决的是「闲着等」,但下游容量是有限的。并发超过某个点,延迟就会拐弯往上走。限流的目的不是卡死请求,而是让系统在过载时还能给出可预期的响应,而不是雪崩。

  1. 先量基线到 /tools/speed-test 测出单实例在多少并发下延迟开始拐弯,这个拐点就是你设并发上限的依据,不要拍脑袋
  2. 入口按身份限流按用户或 Key 限 QPS,超出的直接拒绝并带上重试提示,不要让它进到业务链路里
  3. 中间控制下游在途数对数据库、第三方接口这类依赖用信号量卡住同时在途的请求数,具体分档思路可对照站内写过的退避与排队取舍文章
  4. 排队要有上限和超时队列长度、等待超时都要设,超了直接返回可重试错误码,配合指数退避重试
// 入口限流:按 Key 控制 QPS,超出即拒绝
var limiter = new RateLimiter(maxPerSecond);
if (!limiter.TryAcquire(req.Key))
    return TooManyRequests(retryAfterSeconds: 1);

// 下游在途控制:信号量卡住同时打向依赖的请求数
await _dbSemaphore.WaitAsync(ct);
try { return await _db.QueryAsync(id, ct); }
finally { _dbSemaphore.Release(); }
拐点并发
延迟开始明显上翘的那个并发值,是你设上限的起点
在途数
同一时刻还没返回的请求数量,比总请求数更能反映压力
拒绝策略
超限时返回什么、要不要带重试提示,提前定好,别到线上再想

思路三:缓存与拆分,把并发压力挪出单点

有一类高并发不是等出来的,而是同一个数据被反复取。读多写少的接口,先把这层重复挡掉,再看要不要拆。缓存的收益在命中率上,不在加了多少层。

手段适用场景怎么验证有没有用
本地内存缓存单实例内高频读、变化少的数据看命中率与源库 QPS 是否同时下降
分布式缓存多实例共享、需要一致视图的数据看缓存调用量与源库压力是否同步变化
后台异步处理重逻辑、可延迟出结果的接口接口返回时间下降,后台队列积压可控
读写分离/分片单库单表已经到写入瓶颈看主库写入压力是否被分散
  • 读多写少优先上缓存,本地挡一层、分布式挡一层,过期时间加随机抖动,避免同一时刻集体失效
  • 热点数据单独对待:配置、字典、榜单这类变化少、访问集中的,提前算好放缓存,别每次回源
  • 拆分两个方向:按业务把重逻辑挪到后台、接口只返回受理结果;按数据拆成多份,读写分离或分片
  • 验证方式:观察缓存命中率和数据库 QPS 是否同步下降,只涨了缓存调用量、源库压力没变,说明键设计或过期策略有问题
先量出瓶颈在哪,再选并发模型;三种思路不是三选一,而是按链路分别用。
下一步把你现在的接口链路对着这三种思路过一遍,看看瓶颈到底在等 IO、在被打穿还是在取同一份数据;具体服务端选型与当前可用的中转站,到 /sites 核对当时的资料与可用性更靠谱。

常见问题

三个方法要一起上还是先挑一个?

按瓶颈顺序来,别一次全改。先用监控定位瓶颈是等 IO、打满下游还是单点数据扛不住:等 IO 就走异步,下游被打穿就上限流排队,读压力集中就上缓存。一次只改一层,改完压测对比,能确认是哪一层起的作用。全量重构往往改完看不出是哪招生效,出问题也不好回滚。

异步改完为什么 QPS 没涨?

大概率是链路里还有同步阻塞点。检查三处:有没有 .Result / .Wait() / Task.Run 包同步代码;HttpClient 是不是每次请求都 new;连接池和线程池上限是不是还是默认值。另外要确认瓶颈本来就不在这层——如果下游数据库已经满了,上游再怎么异步也只是把压力传得更快。

限流阈值该设多少才合理?

没有通用数字,要按你自己的延迟拐点来定。做法是先做一次阶梯压测,找到延迟开始明显上扬、错误率开始抬头的那一档并发,把阈值设在那之下留一点余量。同时给下游依赖单独设更小的在途上限,因为下游通常比你自己更脆。具体额度口径可以去 官方 API 价格(/official-api) 和对应厂商页核对当时的限制说明。

本文依据各平台公开文档与官方公告整理,只做中文归纳;具体做法、参数与限制以官方文档为准。