服务一慢,团队里总会有人说一句话:“把它改成 async 吧。”
这句话太顺口了,所以也太容易被相信。接口改成 async/await 之后,请求线程不再傻等 I/O,API 看上去清爽了不少;再加上一层消息队列,调用方甚至能秒回。监控面板安静了一点,大家就以为扩展性问题处理完了。
麻烦在这里。很多系统并不是缺 await,它们缺的是对瓶颈的判断。你把同步调用改成异步调用,或者把压力塞进队列,通常只是把拥堵从入口挪到了系统更深处。堵还在,只是更难看见了。
为什么大家总会先想到 async
这种误判很常见,因为 async 带来的体感改善非常明显。
作者用了一个很贴切的比喻:一家餐厅的厨房出餐很慢,门口排起长队。于是老板找来一位迎宾员在门口接单,再给顾客发一个呼叫器。顾客不用堵在前台了,入口也顺畅了,餐厅看起来更高效。
但厨房并没有变快。每小时能做出来的菜还是那么多。队列只是从门口搬到了呼叫器系统里,而且更不容易被肉眼发现。
系统里也是一样。async/await 和消息队列很容易制造一种”问题已经缓解”的错觉,因为请求方不再卡住了。可真正消耗时间的数据库查询、外部 API 调用、锁等待、共享资源竞争,并不会因为你写了 await 就自动缩短。
async/await 真正解决的是什么
在 .NET 里,当你 await 一个数据库调用或者 HTTP 请求时,当前线程会先回到线程池,等 I/O 完成之后,再由别的线程继续执行后半段逻辑。这对高并发、I/O 密集型场景很重要,因为你不需要为每一个等待中的请求长期占住一个线程。
public async Task<Order> GetOrderAsync(int id)
{
// 数据库查询执行期间,线程会先释放回线程池
return await _db.Orders.FindAsync(id);
}
这当然有价值。一个 ASP.NET Core 服务如果要同时处理成千上万的 I/O 请求,异步写法能减少线程池压力,也能避免用大量线程去陪跑等待时间。
但这里有个常被跳过的事实:数据库查询并没有更快。1000 个请求同时进来,数据库依旧要面对 1000 次查询。差别只是这些请求在等待结果时,不再各自霸占一个线程。
线程空出来了,工作没有消失。
这就是文章最核心的判断。async/await 是线程管理工具,不是吞吐量魔法。
队列看起来像缓冲,实际上更像债务计数器
很多团队在发现接口压力上来之后,会马上补一个消息队列。API 先收请求,再异步消费,调用方体验确实会更顺滑。这一步不是错,错在把它当成扩展性答案。
如果生产者每秒写入 1000 条消息,消费者每秒只能处理 200 条,那么系统每秒就会新增 800 条处理债务。10 分钟之后,积压就是 480000 条。生产端的图表可能还很好看,队列却已经在安静地膨胀。
你什么时候会真正感受到它?通常不是在系统刚上线的那一刻,而是在延迟开始按小时计算、消息过期、重复投递风暴砸向消费者的时候。那时问题已经不只是”慢”,而是系统行为开始失控。
队列的价值在解耦,在削峰,在给失败重试留出空间。可它不会凭空增加消费能力。消费者该查库还是要查库,该访问下游还是要访问下游。压力只是换了一个停车场。
async 碰不到的那些瓶颈
大部分吞吐量问题,本来就不是线程问题,而是资源竞争问题。你把调用链写得再异步,下面这些限制照样存在。
| 瓶颈 | 为什么 async 不会替你解决 |
|---|---|
| 数据库连接池 | 连接数量是有限的,调用方照样要排队拿连接 |
| 行级锁 | 并发写入最终还是会串行化,await 不能跳过锁竞争 |
| 第三方 API 限流 | 对方只允许每秒 100 次请求,不会因为你代码写得漂亮就放宽规则 |
| 共享可变状态 | 该加锁还得加锁,该协调还是得协调 |
这也是为什么很多系统改成异步之后,入口延迟短了,数据库超时却更多了,或者消费者 lag 越积越高。问题没有被消灭,它只是往下游滑了一层。
什么时候该用 async,什么时候该盯吞吐量
这篇文章并不是反对 async/await。相反,它非常明确:如果你面临的是真实的线程池压力,而且业务以 I/O 等待为主,那么 async 就该上,而且应该尽早上。
消息队列也是一样。服务需要解耦,调用双方在线时间不一致,需要吸收流量尖峰,或者你想给不稳定操作加重试机制,队列都很合适。它解决的是系统边界和协作方式的问题。
别把这两类收益和吞吐量混在一起。解耦是一种属性,吞吐量是另一种属性。一个系统可以非常解耦,但依然很慢;一个系统没有队列,也完全可能跑得很快。规律很明确。
真要提升吞吐量,动这些杠杆
当性能问题落在吞吐量上,真正有效的动作往往更朴素,也更不浪漫。
| 手段 | 能带来什么 |
|---|---|
| 增加消费者实例 | 把处理能力按实例数横向摊开,前提是处理逻辑足够无状态 |
| 按业务键分区 | 让不同消费者处理互不重叠的数据集,减少对同一行、同一租户、同一资源的争抢 |
| 瘦身处理器 | 把串行热点拿掉,避免单个处理器里又锁缓存、又调单实例服务、又做重 I/O |
| 盯消费者 lag | queue depth 只告诉你现在堆了多少,consumer lag 才告诉你落后速度有多快 |
作者还给了一个很实在的算术例子:如果处理一条消息需要 50ms,而你的目标是每秒处理 500 条,那么你至少需要 25 个并发消费者。这个时候该做的是扩实例、做分区、压缩单次处理成本,而不是继续给入口堆异步语法糖。
先做剖析,再决定要不要引入新基础设施
我很认同文章最后那个落点。系统慢了,别急着改写风格,也别急着上基础设施。先 profile,先测量。
很多时候,真正的问题是没加上的索引,是没发现的 N+1 查询,是没有 timeout 的下游服务,是过胖的 handler。你以为自己在修扩展性,最后只是给诊断工作加了一层烟雾。
async/await 改变的是等待方式,扩展性要求你重新设计工作该去哪里、怎样并行、怎样避免争抢。两者有关,但不是一回事。
这就是为什么我会把这篇文章推荐给每个一看到慢服务就想加队列的人。队列能帮你把压力藏起来,容量规划才会真的把问题解决掉。短期看起来轻松,长期差很多。
参考
- 原文: Async Does Not Mean Scalable — Irina Scurtu
- Irina Scurtu on LinkedIn — 作者在文末提供的联系方式