通用轮训方案设计
# 27.通用轮询方案设计
本篇定位:轮询是分布式/客户端开发里"最简单也最容易写错"的机制——做不好就是"耗电、耗流量、雪崩、消息延迟"四件套。本文从一次"百万设备每秒轮询打挂网关"的故事讲起,回答三个核心问题——轮询的本质矛盾在哪?短轮询/长轮询/推送怎么选?怎么设计一套既稳又省的通用轮询框架?
# 目录介绍
# 01.百万设备秒级轮询
# 1.1 一次定位上报雪崩
某车联网厂商:100 万台车辆每秒上报一次定位——某天网关扩容时短暂 502,100 万设备同时进入"重试地狱":
- 设备端写死:每秒上报,失败 1 秒后立即重试
- 网关短暂故障 5 秒后恢复
- 但所有失败的请求堆积成"双倍流量"压上来——网关瞬间被压垮
- 进一步级联到数据库、消息队列——整套系统瘫痪 30 分钟
gantt
title 雪崩故障时间线
dateFormat HH:mm:ss
axisFormat %H:%M:%S
section 正常轮询
100w QPS 上报 :a, 09:00:00, 5m
section 故障窗口
网关 502 :crit, b, 09:05:00, 5s
所有设备失败堆积 :crit, c, 09:05:00, 5s
section 灾难放大
网关恢复 双倍重试 :crit, d, 09:05:05, 30m
级联打挂 DB / MQ :crit, e, 09:05:10, 30m
全网瘫痪 :crit, f, 09:05:10, 30m
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 1.2 雪崩扩散链路
flowchart TD
A[100w 设备每秒轮询] --> B[网关短暂 502 5 秒]
B --> C[100w 请求失败]
C --> D[设备端 1 秒后重试]
D --> E[新一秒的请求 + 重试请求]
E --> F[200w QPS 涌入]
F --> G[网关被压垮]
G --> H[级联 DB/MQ 全部打挂]
Cause[根因] --> R1[固定频率 1s 高频]
Cause --> R2[失败立即重试 无退避]
Cause --> R3[所有设备齐步走 无抖动]
Cause --> R4[网关无限流保护]
style G fill:#ffebee
style H fill:#ffebee
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 1.3 反思轮询设计
事后这个团队总结了三个最深刻的教训:
- 轮询频率必须按需——不是越快越好,1 秒和 30 秒可能业务效果一样
- 失败必须指数退避——立即重试是雪崩催化剂
- 必须随机抖动——避免百万设备齐步走
轮询的难点不在"实现"——而在"如何让 100 万个客户端不打架"。
# 02.要解决的核心矛盾
# 2.1 实时与省电
| 频率 | 实时性 | 电量影响 | 流量 |
|---|---|---|---|
| 1 秒 | 极好 | 极差 | 大 |
| 10 秒 | 好 | 一般 | 中 |
| 1 分钟 | 一般 | 好 | 小 |
| 10 分钟 | 差 | 极好 | 极小 |
| 1 小时 | 极差 | 不影响 | 忽略 |
核心思考:业务真的需要 1 秒级实时吗?很多场景"5 秒和 30 秒"用户感知不到差别。
# 2.2 频率与压力
graph LR
A[N 个客户端] --> B[频率 f]
B --> C[服务端 QPS = N × 1/f]
Note1[100w 设备 × 每秒 1 次 = 100w QPS]
Note2[100w 设备 × 每分钟 1 次 = 1.7w QPS]
Note3[100 倍差距]
style Note3 fill:#fff3e0
2
3
4
5
6
7
8
9
# 2.3 主动与被动
mindmap
root((通信模式))
主动 - 客户端拉
短轮询
长轮询
实现简单
流量大
被动 - 服务端推
WebSocket
Server-Sent Events
MQTT 推送
实时性好
实现复杂
混合
推送为主
轮询兜底
失败时降级
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 2.4 轮询的本质
轮询 = 客户端用"频率"换取"实时性"
它的代价 = N 个客户端 × 1/频率 = 服务端压力。
设计的核心 = 在"实时"和"压力"之间找到业务可接受的平衡点。
# 03.业界主流方案
# 03.1 主流通信模式
| 模式 | 描述 | 实时性 | 复杂度 |
|---|---|---|---|
| 短轮询 | 定时发请求 | 看频率 | ⭐ |
| 长轮询 | 服务端 hold 请求 | 准实时 | ⭐⭐ |
| WebSocket | 双向长连接 | 实时 | ⭐⭐⭐ |
| SSE | 服务端单向推送 | 实时 | ⭐⭐ |
| MQTT | 物联网长连接 | 实时 | ⭐⭐⭐ |
| Push 通知 | 系统级推送 | 准实时 | ⭐⭐ |
# 03.2 横向对比矩阵
| 维度 | 短轮询 | 长轮询 | WebSocket | 推送 |
|---|---|---|---|---|
| 实时性 | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 服务端压力 | 高 | 中 | 低 | 极低 |
| 流量消耗 | 高 | 中 | 低 | 极低 |
| 电量影响 | 大 | 中 | 中 | 小 |
| 实现复杂度 | 低 | 中 | 高 | 中 |
| 穿透 NAT | ✅ | ✅ | ⚠️ | ✅ |
| 典型场景 | 状态查询 | 通知/聊天 | 直播弹幕 | 离线消息 |
# 03.3 选型速查表
flowchart TD
Q1{需要实时?}
Q1 -->|秒级延迟可接受| Short[短轮询<br/>简单 + 兜底]
Q1 -->|准实时| Q2{消息频率?}
Q1 -->|强实时<br/>< 100ms| WS[WebSocket]
Q2 -->|低频| Long[长轮询]
Q2 -->|中高频| WS2[WebSocket]
Q3{App 离线?} -->|需要送达| Push[Push 通知]
style Short fill:#e3f2fd
style Long fill:#e8f5e8
style WS fill:#fff3e0
style Push fill:#f3e5f5
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 04.设计核心原则
# 04.1 退避抖动原则
铁律:任何失败重试都要指数退避 + 随机抖动。
class BackoffStrategy(
private val baseMs: Long = 1000,
private val maxMs: Long = 5 * 60_000,
private val multiplier: Double = 2.0
) {
private var attempt = 0
fun nextDelay(): Long {
attempt++
// 指数退避 1s → 2s → 4s → ... 上限 5 分钟
val expDelay = (baseMs * multiplier.pow(attempt - 1).toLong())
.coerceAtMost(maxMs)
// ±50% 随机抖动 防齐步走
val jitter = (expDelay * 0.5 * (Math.random() - 0.5)).toLong()
return expDelay + jitter
}
fun reset() { attempt = 0 }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
抖动的威力:
graph LR
A[100w 设备同时失败] --> B{有抖动?}
B -->|无| C[100w QPS 同时打过来]
B -->|有 ±50%| D[QPS 散布在<br/>0.5x - 1.5x 时间窗]
style C fill:#ffebee
style D fill:#e8f5e8
2
3
4
5
6
7
# 04.2 自适应频率
根据"是否有变化"调整轮询频率:
stateDiagram-v2
[*] --> Active: 启动
Active: 高频 5s
Idle: 低频 1min
Sleep: 极低频 5min
Active --> Idle: 连续 N 次无变化
Idle --> Active: 检测到变化
Idle --> Sleep: 长时间无变化
Sleep --> Active: 用户操作触发
2
3
4
5
6
7
8
9
10
典型实现:
class AdaptivePoller {
private var interval = 5_000L // 起步 5 秒
private var unchangedCount = 0
fun onResult(changed: Boolean) {
if (changed) {
interval = 5_000L // 有变化 → 高频
unchangedCount = 0
} else {
unchangedCount++
interval = when {
unchangedCount > 20 -> 5 * 60_000L // 5 分钟
unchangedCount > 5 -> 60_000L // 1 分钟
else -> interval
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 04.3 前后台分级
App 状态决定轮询策略:
| App 状态 | 推荐策略 |
|---|---|
| 前台 + 用户活跃 | 高频(5-10 秒) |
| 前台 + 空闲 | 中频(30 秒-1 分钟) |
| 后台运行 | 低频(5 分钟+) |
| 完全退出 | 不轮询 / 用 Push |
| 充电 + Wi-Fi | 可适度提高 |
| 低电量 | 主动降频 |
# 04.4 失败容错原则
flowchart LR
Req[发起轮询请求] --> Result{结果}
Result -->|成功| OK[重置退避计数]
Result -->|网络错误| Retry[退避重试]
Result -->|限流 429| Backoff[长退避]
Result -->|服务错误 5xx| Retry2[退避重试]
Result -->|业务错误 4xx| Stop[停止 + 上报]
style OK fill:#e8f5e8
style Stop fill:#ffebee
2
3
4
5
6
7
8
9
10
11
# 05.方案落地实战
# 05.1 整体架构
graph TB
subgraph "客户端"
Lifecycle[生命周期监听]
Strategy[策略引擎<br/>前台/后台/省电]
Poller[轮询调度器]
Backoff[退避器]
Net[网络请求]
end
subgraph "服务端"
Gate[网关 + 限流]
Service[业务服务]
Cache[(缓存)]
end
Lifecycle --> Strategy
Strategy --> Poller
Poller --> Backoff
Backoff --> Net
Net --> Gate
Gate --> Service
Service --> Cache
Service -->|响应 + 控制频率| Net
Net --> Poller
style Strategy fill:#fff3e0
style Backoff fill:#e8f5e8
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 05.2 客户端实现
通用轮询框架:
class GeneralPoller<T>(
private val task: suspend () -> Result<T>,
private val onChange: (T) -> Unit,
private val config: PollerConfig
) {
private var job: Job? = null
private val backoff = BackoffStrategy()
private val adaptive = AdaptivePoller(config.minIntervalMs, config.maxIntervalMs)
private var lastValue: T? = null
fun start(scope: CoroutineScope) {
stop()
job = scope.launch {
while (isActive) {
runCatching { task() }
.onSuccess { result ->
backoff.reset()
if (result is Result.Success && result.data != lastValue) {
onChange(result.data)
adaptive.onResult(changed = true)
lastValue = result.data
} else {
adaptive.onResult(changed = false)
}
delay(adaptive.currentInterval())
}
.onFailure {
delay(backoff.nextDelay()) // 失败退避
}
}
}
}
fun stop() {
job?.cancel()
backoff.reset()
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 05.3 长轮询实现
长轮询 = 服务端 hold 住请求,有数据才返回。
sequenceDiagram
participant C as Client
participant S as Server
C->>S: GET /poll?lastId=100
Note over S: hold 30s 等数据
alt 30s 内有新数据
S-->>C: 返回 newData (lastId=120)
C->>S: 立即下一次 GET /poll?lastId=120
else 30s 超时无数据
S-->>C: 204 No Content
C->>S: 立即下一次 GET /poll?lastId=100
end
2
3
4
5
6
7
8
9
10
11
12
13
14
服务端实现要点:
@GetMapping("/poll")
suspend fun longPoll(@RequestParam lastId: Long): Response {
val timeout = 30_000L
val deadline = System.currentTimeMillis() + timeout
while (System.currentTimeMillis() < deadline) {
val data = repo.findAfter(lastId)
if (data.isNotEmpty()) {
return Response.ok(data)
}
delay(500) // 短暂休眠后再查
}
return Response.noContent()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 05.4 心跳保活机制
长连接场景下的心跳——既检测连接是否健康,也保持 NAT 映射。
graph LR
A[长连接建立] --> B[心跳定时器]
B --> C[发心跳包 60s]
C --> D{收到 PONG?}
D -->|是| C
D -->|3 次未收到| Recon[认为断开<br/>重连]
style Recon fill:#fff3e0
2
3
4
5
6
7
8
9
典型心跳间隔经验值:
- WiFi:30-60 秒
- 4G/5G:30-60 秒(NAT 超时通常 1-3 分钟)
- 弱网络/海外:调短到 20 秒
- 后台:5-10 分钟(节省电量)
# 05.5 服务端配合
服务端控制客户端频率——通过响应字段下发。
{
"data": [...],
"_polling": {
"next_interval_ms": 30000, // 下次多久后再来
"max_concurrent": 1000, // 服务端建议并发上限
"backoff_on_503": true // 服务端过载时建议退避
}
}
2
3
4
5
6
7
8
好处:服务端能动态调控全局压力——客户端无需改版本。
# 06.关键问题解决
# 06.1 雪崩防御
开篇 100 万设备雪崩的解决:多层防御。
flowchart TD
A[问题] --> B[100w 设备齐步走 + 失败堆积]
Solution[多层防御]
Solution --> S1[1 客户端: 指数退避]
Solution --> S2[2 客户端: 随机抖动 ±50%]
Solution --> S3[3 客户端: 自适应频率]
Solution --> S4[4 网关: 令牌桶限流]
Solution --> S5[5 服务端: 动态下发频率]
Solution --> S6[6 服务端: 熔断保护下游]
style Solution fill:#e8f5e8
2
3
4
5
6
7
8
9
10
11
12
# 06.2 流量与电量
手机端 1 天的轮询代价:
| 频率 | 一天请求数 | 流量(按 1KB/请求) | 电量影响 |
|---|---|---|---|
| 1 秒 | 86,400 | ~85MB | 严重 |
| 10 秒 | 8,640 | ~8.5MB | 中等 |
| 1 分钟 | 1,440 | ~1.4MB | 轻微 |
| 5 分钟 | 288 | ~280KB | 几乎无 |
实战经验:1 分钟以下频率必须做"前后台分级",否则上线后必被用户骂耗电。
# 06.3 后台限制问题
iOS / Android 都对后台轮询有严格限制:
| 平台 | 限制 |
|---|---|
| iOS | 后台几乎不能执行普通代码,只能用 Push / Background Fetch |
| Android 6+ Doze | 后台休眠期间禁止网络访问 |
| Android 8+ | 后台 Service 受限 |
| 小米/华为/OPPO | 厂商定制 ROM 杀后台更狠 |
应对:
- 后台尽量靠 Push 通知 唤醒
- 实在需要:WorkManager + 长间隔(最低 15 分钟)
- 关键消息:APNs/FCM/厂商推送 推送
# 07.常见陷阱与反例
# 07.1 固定频率反例
反例:固定写死"每 1 秒轮询一次"——忽略业务实际变化频率。
教训:
- 用户大部分时间没有变化 → 1 秒一次是浪费
- 用自适应频率:有变化加速、无变化减速
# 07.2 不退避反例
反例:失败立即重试——5 秒故障 → 双倍流量 → 雪崩。
教训:
- 失败必须退避(指数退避)
- 退避必须有上限(避免永远不重试)
- 必须有抖动
# 07.3 全设备同步反例
反例:所有设备启动时都"立即上报一次"——百万设备同时上报 → 网关瞬时压力暴涨。
教训:
- 启动时随机延时 0-N 秒
- 设备 ID 哈希分散到时间窗
- 服务端可下发"建议启动延时"
mindmap
root((三大反例))
固定频率
不感知业务变化
浪费资源
用自适应
不退避
雪崩催化剂
流量翻倍
指数退避 + 抖动
全设备同步
启动风暴
网关被打
时间分散
2
3
4
5
6
7
8
9
10
11
12
13
14
# 08.演进路线
# 08.1 V1 简单短轮询
特征:业务起步、设备数少。
做法:
- 固定频率轮询
- 无退避
- 无抖动
适用阶段:< 1w 设备 / POC
# 08.2 V2 长轮询 + 退避
特征:业务规模化、追求实时性。
做法:
- 长轮询为主
- 指数退避 + 随机抖动
- 自适应频率
- 前后台分级
适用阶段:万级-百万级设备
# 08.3 V3 推送 + 兜底轮询
特征:超大规模、极致体验。
做法:
- 长连接 / WebSocket / MQTT 主推
- Push 通知唤醒
- 短轮询作为兜底
- 服务端动态下发频率
- 全链路监控
适用阶段:千万级设备 / 头部 App
flowchart LR
V1[V1 短轮询<br/>起步] --> V2[V2 长轮询 + 退避<br/>主流]
V2 --> V3[V3 推送 + 兜底轮询<br/>头部]
style V1 fill:#e3f2fd
style V2 fill:#e8f5e8
style V3 fill:#fff3e0
2
3
4
5
6
7
# 09.总结与决策
# 09.1 上线检查表
轮询方案上线前对照:
- [ ] 频率经过业务评估(不是越快越好)
- [ ] 失败指数退避 + 随机抖动
- [ ] 退避有上限(避免永不重试)
- [ ] 自适应频率(按变化情况调整)
- [ ] 前后台分级
- [ ] 启动随机延时(防齐步走)
- [ ] 网关有限流保护
- [ ] 服务端可动态下发频率
- [ ] 离线场景用 Push 兜底
- [ ] 监控(QPS、失败率、电量影响)
- [ ] 故障演练(网关挂、设备雪崩)
- [ ] 流量 / 电量基线测试
# 09.2 选型决策树
flowchart TD
Start([我要做轮询]) --> Q1{实时性要求?}
Q1 -->|分钟级| Short[短轮询<br/>最简单]
Q1 -->|秒级| Long[长轮询<br/>实时 + 省流量]
Q1 -->|毫秒级| WS[WebSocket / MQTT<br/>长连接]
Q2([App 离线?]) --> Off{关键消息?}
Off -->|是| Push[Push 通知<br/>必须用]
Off -->|否| Skip[不需要后台轮询]
Q3([设备数量?]) --> N{规模?}
N -->|< 1w| Simple[简单实现就够]
N -->|> 100w| Defense[必须多层防雪崩]
style Short fill:#e3f2fd
style Long fill:#e8f5e8
style WS fill:#fff3e0
style Push fill:#f3e5f5
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
最后一句话:轮询的难点不在写代码——而在 如何让百万客户端"散步而不是齐步走"。开篇 100 万设备雪崩的根因,就是"固定 1 秒 + 不退避 + 不抖动"三件套。
好的轮询方案 = 频率自适应、失败退避抖动、前后台分级、推送兜底。