不加机器也能扛高峰:把“延迟”换成“吞吐”的系统优化法

10 阅读6分钟

晚高峰接口一来就抖,CPU 没满、数据库也没打满,但请求还是超时。很多时候不是“算力不够”,而是“每个请求都单独走完整流程”,固定开销被重复支付了太多次。
这时有个很实用的优化思想:用可控的延迟,换更高的吞吐

先把结论放桌上:

  • 适用场景:吞吐优先、可容忍一定排队。
  • 典型手段:批处理、微批、队列缓冲、异步流水线、写合并。
  • 核心代价:单请求等待时间上升(尤其是低峰期和尾延迟)。

1) 这套思想到底在优化什么

吞吐(Throughput)是“单位时间处理多少请求”;延迟(Latency)是“单个请求从进到出要多久”。
当系统瓶颈包含明显固定成本(网络往返、锁竞争、事务提交、刷盘)时,让请求短暂排队后一起处理,常能把固定成本摊薄。

可以把它理解成高速公路收费站:
如果每辆车都人工慢慢刷卡,队伍会很长;换成 ETC 车道并行批量通过,单车可能多等几秒排队,但每分钟过车数会上去很多。

请求到达
   |
   v
[队列缓冲] --> (达到批大小 B 或等待到 T 毫秒)
   |                         |
   | 否                      | 是
   |                         v
   +--------------------> [组批处理] --> [写入/计算/下游调用] --> [返回结果]

现在就做一件事:把你的请求链路按这张图画出来,先定位“最贵的固定成本”在哪一段。

2) 五种手段,分别解决什么问题

批处理(Batch)

  • 通俗解释:攒够一批再处理,一次做完很多条。
  • 生活类比:食堂不是给每个人单独开锅,而是一锅炒 30 份。
  • 迷你案例:对账任务把 10000 条流水按 500 条一批入库,事务提交次数从 10000 次降到 20 次,数据库压力明显下降。

微批(Micro-batch)

  • 通俗解释:批处理的“小步快跑版”,每 5~20ms 或凑够 N 条就立刻发车。
  • 生活类比:电梯不会等到满 20 人才走,通常“够人或等几秒”就开门。
  • 迷你案例:日志服务每 10ms 刷一次或满 64 条即刷,吞吐提升明显,同时把等待控制在毫秒级。

队列缓冲(Queue Buffering)

  • 通俗解释:生产者和消费者解耦,用队列吸收突发流量。
  • 生活类比:医院叫号系统,先登记排队,医生按节奏处理。
  • 迷你案例:秒杀下单先入队,库存与订单服务按稳定速率消费,峰值时不再“瞬间打爆”数据库。

异步流水线(Async Pipeline)

  • 通俗解释:把一个大步骤拆成多个阶段并行推进,不要求一个请求从头到尾独占资源。
  • 生活类比:奶茶店把点单、配料、封口分工,不是一个人全包。
  • 迷你案例:图片处理拆为“解码 -> 缩放 -> 水印 -> 上传”,各阶段并行消费,整体处理量提升。

写合并(Write Coalescing)

  • 通俗解释:短时间内对同一对象的多次写,只保留必要写入(常见是“保留最后一次”)。
  • 生活类比:同一地址的快递集中打包一次送,不用一小时跑五趟。
  • 迷你案例:用户资料页在 1 秒内连续改昵称、头像、签名,系统合并成一次 upsert,减少锁与 I/O。

3) 一张表看懂:怎么选手段

手段最适合的场景吞吐收益延迟代价常见坑
批处理离线任务、可延后写入中到高批太大导致超时
微批在线高并发写入中到高低到中定时与批大小参数难调
队列缓冲峰谷明显、突发流量无上限队列引发长尾延迟
异步流水线多阶段处理、可拆步骤中到高阶段背压处理不好
写合并高频重复写同一 key中到高低到中合并策略错误导致数据丢语义

如果你只能先做一件事,优先上“队列缓冲 + 微批”这组组合,通常改造成本和收益比最好。

4) 可复现落地:队列 + 微批 + 写合并 三件套

假设你有一个“用户行为写库”接口,当前是同步单条写入:

  • 现状:每次写库约 2ms(网络、事务、刷盘综合),单 worker 理论上约 500 次/秒。
  • 高峰:突发到 3000 次/秒,直接排队超时。

改造步骤:

  1. 请求到达先入内存队列(或消息队列),立即返回“已接收”或短等待结果。
  2. Flusher 每 10ms 触发一次,或队列满 64 条立即触发。
  3. 对批内相同 user_id 的更新做写合并(保留最后一条)。
  4. 批量 upsert 到数据库。
  5. 队列长度超过阈值时触发背压:限流、降级、快速失败三选一。

伪代码示意:

onRequest(event):
  queue.push(event)

every 10ms:
  batch = queue.popUpTo(64)
  merged = mergeByKey(batch, key=user_id, keep="last")
  db.bulkUpsert(merged)

一个常见结果(示例值):

  • 每批 64 条耗时约 6ms,则单条摊销到约 0.09ms 级别;
  • 吞吐从“单条同步写”显著提升;
  • 单请求新增等待通常在 5~20ms 区间(取决于流量和参数)。

5) 延迟到底涨多少:用这个公式先估算

单请求总延迟 ≈ 入队等待 + 组批等待 + 执行时间 + 返回时间

经验上:

  • 低流量时,组批主要受定时器 T 影响,平均等待接近 T/2
  • 高流量时,更快凑满批次,等待常小于 T/2,但队列拥堵会拉高尾延迟。

再看同步串行和异步流水线的直观差异:

同步串行
Req1: [验签][查重][写库][发消息]
Req2:                 [验签][查重][写库][发消息]

异步流水线
S1验签: Req1 Req2 Req3 Req4
S2查重:   Req1 Req2 Req3 Req4
S3写库:     Req1 Req2 Req3 Req4
S4消息:       Req1 Req2 Req3 Req4

把你的处理链路按阶段拆开后,先测每一段利用率,再决定要不要继续加并行度。

6) 参数怎么起步,避免“一上来就翻车”

给一套可直接抄的起步值:

  • 微批等待时间 T:先用 10ms(常见范围 5~20ms)。
  • 批大小 B:先用 32 或 64,再按数据库最佳批量点微调。
  • 队列上限 Q:必须有上限,超过就背压,不要无限排队。
  • 并发 worker C:从 CPU 核数和数据库连接上限倒推,不盲目拉满。

监控最少盯这四个指标:

  • 队列长度(queue depth)
  • 组批等待时间(batch wait)
  • 吞吐(requests/s 或 writes/s)
  • 尾延迟(P95/P99)

7) 什么时候不要用“延迟换吞吐”

以下场景要谨慎甚至避免:

  • 强实时交互(如极低延迟交易、实时控制指令)
  • 用户对单次响应极其敏感(例如首屏关键路径)
  • 业务语义不允许重排、合并或异步确认

一句话收尾:
“延迟换吞吐”不是免费午餐,但它常常是高并发系统最便宜的第一步。
先接受一点点排队,再换来稳定的大流量处理能力,系统就不容易在高峰期“原地趴窝”。