线上接口一压就慢、线程池排队、数据库连接被抽干,这类问题很少是「代码写错了」,而是并发模型选错了。这篇不聊概念科普,直接给你三种在 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)
- 验证方式:压测时看线程池排队长度和连接池等待时间,这两个不降,说明还有地方在同步阻塞
思路二:限流加排队,先保住系统不被打穿
异步解决的是「闲着等」,但下游容量是有限的。并发超过某个点,延迟就会拐弯往上走。限流的目的不是卡死请求,而是让系统在过载时还能给出可预期的响应,而不是雪崩。
- 先量基线到 /tools/speed-test 测出单实例在多少并发下延迟开始拐弯,这个拐点就是你设并发上限的依据,不要拍脑袋
- 入口按身份限流按用户或 Key 限 QPS,超出的直接拒绝并带上重试提示,不要让它进到业务链路里
- 中间控制下游在途数对数据库、第三方接口这类依赖用信号量卡住同时在途的请求数,具体分档思路可对照站内写过的退避与排队取舍文章
- 排队要有上限和超时队列长度、等待超时都要设,超了直接返回可重试错误码,配合指数退避重试
// 入口限流:按 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) 和对应厂商页核对当时的限制说明。