导读 :高并发里,瓶颈常常是「调用太碎」------十个线程各打一条 SQL,连接、上下文切换、下游 QPS 一起被放大。请求折叠(Request Collapsing) 的做法是:在极短窗口内把同类请求攒成一批,用一次下游调用完成,再按原顺序把结果拆回去。Hystrix 拿它合并并发读,Kafka 拿它攒发送批次,HTTP 网关拿它合并同 URL 请求;APP 埋点上报 则在写路径上用「时间 + 数量」双阈值做聚批落库。下文按读路径、写路径各举一种典型实现,并落到一次真实接口设计。
阅读导航
- 什么是请求折叠 --- 核心思路、收益与代价
- [Hystrix 请求折叠](#Hystrix 请求折叠) --- 读路径:并发合并 + 定时触发
- [Kafka 与同类实现](#Kafka 与同类实现) --- 写路径:缓冲 + 双阈值刷出
- [HTTP 折叠框架要点](#HTTP 折叠框架要点) --- collapse-executor 抽象
- 实战埋点上报聚批 --- 有界队列 + 批量落库
- 选型对比 --- 读折叠 vs 写聚批
- 结语 --- 落地检查清单
什么是请求折叠
一句话:短时间内的多个请求,合并成一次下游调用,再把结果按原顺序还回去。
不折叠 vs 折叠,差别在下游眼里长什么样:
text
【不折叠】
线程1 ──▶ SELECT id=1 ──▶ DB
线程2 ──▶ SELECT id=2 ──▶ DB
线程3 ──▶ SELECT id=3 ──▶ DB
... 共 N 次往返
【折叠后】
线程1 ──┐
线程2 ──┼──▶ Collapser 缓冲 ──▶ SELECT id IN (...) ──▶ DB
线程3 ──┘ │
│ timer 到期 / 凑满 N 条
▼
拆回各线程结果
能换来什么
| 点 | 说明 |
|---|---|
| 连接与线程 | 10 次 HTTP/DB 往返 → 1 次 |
| 下游吞吐 | 批量 SQL、批量 RPC 通常比循环单条更省 |
| 热点保护 | ID 生成、库存扣减等可「预取一批,内存里发」 |
要付什么
| 点 | 说明 |
|---|---|
| 延迟 | 得等凑批窗口或凑满条数,P99 会上浮 |
| 复杂度 | 分组键、回填顺序、超时、拒绝策略都要想清楚 |
| 前提 | 下游得支持批量,或业务接受异步 / 最终一致 |
和 打散 是反方向:分库分表、LongAdder 多 cell 是把热点拆开;折叠是把并发 收拢 再处理。Leaf 号段模式则是折叠 + 预取------DB 一次取 1000 个号,内存里逐个发。
Hystrix 请求折叠
Hystrix Request Collapsing 面向 读路径:大量并发、参数可合并的查询,例如「根据 userId 查用户信息」。
整体链路
text
+----------------------+
| RequestCollapser |
+----------------------+
|
v
+----------------------+
| RequestBatch.run |
+----------------------+
|
v
+----------------------+
| mapResponseToRequests|
+----------------------+
触发条件(满足任一):timer 到期(如 10ms),或批次条数达上限(如 50)。
关键组件
| 组件 | 干什么 |
|---|---|
RequestCollapser |
接收单次 submit,写入当前批次 |
RequestBatch |
本批所有请求的容器 |
| Timer | 第一个请求进来时注册,到期触发执行 |
| 你的 BatchCommand | 一次 RPC/SQL 查回多条,再 map 回去 |
配置与实现示意:
java
public class UserBatchCommand extends CollapserCommand<List<User>> {
private final List<Long> userIds;
UserBatchCommand(List<Long> userIds) {
super(Setter.withCollapserKey(CollapserKey.Factory.asKey("UserBatch"))
.andCollapserPropertiesDefaults(
HystrixCollapserProperties.Setter()
.withTimerDelayInMilliseconds(10) // 合并窗口
.withMaxRequestsInBatch(50))); // 单批上限
this.userIds = userIds;
}
@Override
protected List<User> run() {
return userDao.batchSelectByIds(userIds);
}
@Override
protected List<User> mapResponseToRequests(List<User> batchResponse) {
return batchResponse; // 与 submit 顺序一一对应
}
}
值得记住的几点
| 优点 | 局限 |
|---|---|
| 折叠调度不单独占业务线程池 | 默认靠 CollapserKey 分组,细粒度分组要自己设计 |
| 调用方拿 Future/Observable 等结果 | 条数没凑满也会被 timer 拉走,窗口要调 |
| 和熔断、隔离在同一条治理链上 | Hystrix 已停更,新项目借鉴思路即可 |
Kafka 与同类实现
写路径上更常见 先缓冲、再批量刷出 。Kafka Producer 的 RecordAccumulator 是工业界用得最多的样板。
双阈值怎么工作
text
Producer thread
|
| append(record)
v
+----------------------+
| RecordAccumulator |
+----------------------+
|
+---------+---------+
v v
batch full linger.ms
+---------+---------+
v
+----------------------+
| Sender thread |
+----------------------+
v
Broker
| 参数 | 含义 |
|---|---|
batch.size |
单分区批次字节上限(常见 16KB) |
linger.ms |
没攒满也最多等多久(常见 10ms) |
按 Topic-Partition 分组,每条 record 进对应 batch;满了或时间到了,Sender 线程统一发出去,Producer 侧可用 Future 感知成败。整体偏吞吐,不追求单条最低延迟。
同类思路还有:TCP Nagle (小包合并)、快手 BufferTrigger (时间 + 数量,偏 fire-and-forget)、Leaf 号段(DB 取一段号,内存逐个发)。
HTTP 折叠框架要点
如果要在 Web 入口 合并「同一 URL、参数可合并」的并发 HTTP 请求,可以参考开源 collapse-executor。处理链路如下(每步含义见下表):
text
HTTP Filter
|
v
+----------------------+
| InputGrouper |
+----------------------+
|
v
+----------------------+
| Bundle |
+----------------------+
|
v
+----------------------+
| BatchCollector |
+----------------------+
|
v
+----------------------+
| doExecute() |
+----------------------+
|
v
+----------------------+
| bindOutput() |
+----------------------+
| 概念 | 含义 |
|---|---|
| Bundle | 一次请求的参数 + 结果回填句柄 |
| ThreadlessExecutor | 不另建池,借用当前 Web 工作线程做折叠与唤醒 |
| SingleThreadExecutor | 单队列单线程,把多任务让步合并后交给一条请求线程执行 |
| Filter 入口 | 在 Servlet 层拦截,决定是否参与折叠 |
适合:多个客户端同时打同一读接口,且后端有 batch API。不适合:写操作彼此独立、没法合并语义的场景。
实战埋点上报聚批
APP 埋点上报 走的是写路径聚批:调用方不等落库,服务端用有界队列攒批后批量 INSERT,触发模型与 Kafka 的 linger + batch.size 同类。
业务约束
| 约束 | 设计选择 |
|---|---|
| 峰值数百 QPS,单条 INSERT 顶不住 | 服务端必须聚批落库 |
| 埋点数据可丢 | 队列满则丢弃,绝不阻塞用户请求 |
| 接口要快 | 校验完入队即返回,落库异步进行 |
端到端数据流
text
+----------------------+
| APP Client |
+----------------------+
|
| POST
v
+----------------------+
| Report API |
+----------------------+
|
v
+----------------------+
| ArrayBlockingQueue |
+----------------------+
|
v
+----------------------+
| Consumer Loop |
+----------------------+
|
v
+----------------------+
| Batch INSERT |
+----------------------+
|
v
+----------------------+
| Database |
+----------------------+
Report API:校验、去重、offer 入队后立即返回。Consumer Loop:poll(1000ms) + drainTo(500)。队列满则丢弃,INSERT 失败整批丢弃。
与 Kafka Producer 的参数对照:
| Kafka | 埋点管道 |
|---|---|
linger.ms |
flushIntervalMs(默认 1000ms,没攒够也刷) |
batch.size |
batchSize(默认 500 条) |
RecordAccumulator |
ArrayBlockingQueue(默认 20000) |
| Producer 回调 | 无------埋点可丢,不回调客户端 |
消费循环(简化)
java
List<EventRow> batch = new ArrayList<>();
while (!shuttingDown) {
EventRow head = queue.poll(flushIntervalMs, MILLISECONDS);
if (head != null) {
batch.add(head);
queue.drainTo(batch, batchSize - batch.size());
}
if (!batch.isEmpty()) {
mapper.batchInsert(batch);
batch.clear();
}
}
poll 控制时间窗口,drainTo 控制单批条数;客户端先把多条 event 打进一个 HTTP 请求,服务端再把多批请求里的行事件二次合并成 500 行一次的 INSERT,下游 QPS 从行级降到批级。
选型对比
text
+----------------------+
| Request Collapsing |
+----------------------+
|
+-----------+-----------+
v v
read path write path
Hystrix / HTTP Kafka / queue
Collapser collapse Producer batch
| 方案 | 典型场景 | 触发 | 回填 | 分组 |
|---|---|---|---|---|
| Hystrix Collapser | 并发查用户/订单 | 时间 + 数量 | Future | CollapserKey |
| Kafka Producer | 日志、消息 | 字节 + linger | Future | 分区 |
| HTTP collapse-executor | 网关合并读请求 | 时间 + 数量 | CompletableFuture | InputGrouper |
| 内存队列聚批 | 高 QPS 写宽表、可丢 | poll + batchSize | 无 | 单管道 |
| Leaf 号段 | 分布式 ID | 号段耗尽 | 内存发号 | 业务 key |
怎么选
- 读、要结果、并发重复 → Hystrix 类折叠,或 HTTP collapse。
- 写、可异步、要吞吐 → 队列 + 双阈值(Kafka / 埋点模式)。
- 写、强一致 → 别硬折叠;用 MQ 削峰,或业务层提供真正的批量 API。
结语
请求折叠的本质是 用可控的等待换整体吞吐。读路径看 Hystrix / HTTP collapse,写路径看 Kafka / 内存队列聚批,埋点则是「客户端批 + 服务端再批」的叠加。落地前建议逐项过一遍:
| # | 问题 |
|---|---|
| 1 | 一个上游请求会放大成几次下游调用? |
| 2 | 下游有没有真正的批量 API?(别用 for 循环假装 batch) |
| 3 | 合并窗口抬高的 P99,业务能不能接受? |
| 4 | 分组键会不会把不该合并的请求并在一起? |
| 5 | 折叠失败时:阻塞、降级,还是丢弃? |
| 6 | 队列有没有上限?(无界队列在尖刺流量下是内存炸弹) |
| 7 | 调用方是「必须等结果」,还是「受理就行」? |