网络优化:从现有网络层出发
项目 trade-app
1. 背景与问题
交易 App 最容易出问题的场景不在办公室的 Wi-Fi 下,而在地铁、电梯、出门时 Wi-Fi 切到 4G 的那几秒。典型表现有三种:
- 连接看起来还在,其实已经死了:行情不跳,下单没反应,界面却没有任何提示。
- 请求叠在一起:网络恢复的瞬间,页面出现、重连成功、推送补发同时触发查询。
- 超时和重试没有统一口径:有的请求等很久,有的立即失败,交易指令该不该重试也说不清。
这篇分两部分写。第 2、3 节只写项目里已经有的,每一条都能在代码里找到;第 4 到 8 节每节先写现状,再写建议,建议一律标为「建议(未做)」,不代表已经实现,也不写任何收益数字。
三条通道从同一套首版骨架起步,之后逐步加上重连节流、可达性处理和单飞。资金查询的合并窗口与强制通道、页面基类统一监听网络与网关状态也在这一层;前者在请求频率控制一篇里详写,长连接本身的建连与分发见两条长连接。
2. 项目里的网络层全景
3. 已有做法(逐项有证据)
| 主题 | 项目里的做法 |
|---|---|
| HTTP 会话与超时 | 基于 Alamofire;普通请求 15 秒超时;上传等长任务用单独的会话,300 秒超时 |
| 请求跟随页面取消 | 请求可以绑定一个持有者,持有者释放时取消它名下的请求,回调也不再执行 |
| 网络可达性 | 监听可达性,任何状态变化都发全局通知;恢复可达时检查用户凭证有效期 |
| 网络变化后的交易连接 | 延迟 4 秒检查,没连上才强制重连;新检查取消旧检查 |
| 回前台 | 交易:三个命名空间各自连接,6 秒后再检查一次;行情:计数清零后重连 |
| 进后台 | 行情主动断开,后台不重连 |
| 网络变化后的行情连接 | 计数清零;还没登录才重连 |
| 库内重连 | Socket.IO 无限次重连,等待 2 到 6 秒;强制只走 WebSocket 传输 |
| 应用层重连节流 | 两条长连接各有节流窗口和检查定时器;鉴权失败后有冷却 |
| 建连超时 | 行情建连请求的超时 = 连接检查周期 − 2 秒 |
| 请求合并 | HTTP 同地址单飞;资金查询 1 秒窗口 + 强制通道;主推后 1 秒合并再拉 |
| 公共请求头 | HTTP:设备标识、语言、有条件的广告标识,需要时加鉴权头 |
| 握手头 | 交易长连接只在握手时带内容类型、鉴权、平台、设备标识、版本 |
| 编码与压缩 | 长连接用 Protobuf;Socket.IO 开压缩;HTTP 为 JSON,用系统默认压缩协商 |
| 弱网下的失败 | 交易指令未连接即丢弃;HTTP 无通用重试;续期失败不再自动重试 |
| 状态提示 | 页面基类统一监听网络与网关状态,子页面只覆写「断开了」「恢复了」 |
3.1 三条通道各自管什么
3.2 网络变化之后发生了什么
3.3 合并:已经有三处
| 做法 | 在哪 | 合并的单位 | 窗口 |
|---|---|---|---|
| 单飞 | HTTP 客户端 | 接口地址 | 一个请求在途期间 |
| 合并窗口 + 强制通道 | 证券资金查询 | 查询类型 | 1 秒,首次调用后延迟发出 |
| 主推后合并再拉取 | 交易侧的行情到持仓中间层 | 主推类型 | 1 秒 |
单飞的实现是一个以地址为键的「在途」字典:同一地址已有请求在途时,新的调用立即收到一个「请求中」的本地返回码。这类标志最怕提前返回时忘了复位,见第 9 节。
4. Wi-Fi 与 4G / 3G 切换
现状:只区分「状态变了」,任何变化都走「延迟检查 → 没连上才重连」。处理器里没有按接口类型(Wi-Fi / 蜂窝)分支,也没有读取蜂窝制式。
观察(从代码推断):Wi-Fi 切到蜂窝时,旧的 TCP 连接可能处于半开状态,库仍然报告「已连接」,检查就会认为不用重连。交易侧收到心跳应答只是清零计数,没有用它判定连接已死,所以半开状态只能等库自己超时发现。
建议(未做):
- 用
NWPathMonitor监听路径变化,读取usesInterfaceType、isExpensive、isConstrained。接口类型一变就主动重建两条长连接,而不是只看「是否已连接」。 - 用
CTTelephonyNetworkInfo读取蜂窝制式,在 3G 下放宽超时、降低行情推送频率或缩小订阅范围。 - 用应用层心跳判定半开连接:连续若干次没收到心跳应答,就主动断开重连。
5. 请求合并
现状:见第 3.3 节。三处合并各自手写,「标记 + 延迟」的逻辑散在不同文件里。
建议(未做):
- 抽一个通用合并器:键、窗口长度、首发还是尾发,标记用
defer保证一定复位。 - 读请求按「方法 + 地址 + 参数」做签名,在途期间相同签名共享同一个结果,而不是像现在的单飞那样让后来者直接拿到「请求中」。
- 合并命中次数打点,按页面汇总,用来判断哪些触发源还在重复。
6. 请求头瘦身
现状:每个 HTTP 请求都带设备标识、语言,有条件地带广告标识,需要凭证的再加鉴权头。长连接只在握手时带一次。
建议(未做):
- 设备标识、平台、版本这类静态字段,只在登录或握手时上报一次,由服务端绑定到会话上。
- 去掉业务不用的字段。
- 如果服务端支持 HTTP/2,重复的头部本身会被压缩,瘦身的收益要先量:用
URLSessionTaskMetrics看请求头字节数,再决定值不值得改。
7. 响应压缩:gzip 与 Protobuf
现状:长连接是 Protobuf,并打开了 Socket.IO 的压缩选项;HTTP 是 JSON,依赖系统默认的压缩协商。仓库里没有 Protobuf 与 JSON 体积对比的材料,这里不写数字。
| 通道 | 编码 | 压缩 | 建议(未做) |
|---|---|---|---|
| 行情 WebSocket | Protobuf | 未见额外压缩 | 若服务端支持,评估 WebSocket 的逐消息压缩扩展 |
| 交易 Socket.IO | Protobuf | 打开压缩选项 | 保持 |
| HTTP | JSON | 系统默认协商 | 大列表接口确认服务端开启 gzip / br;高频接口评估改 Protobuf;较大的上传压缩请求体 |
8. 弱网超时与重试
现状:HTTP 统一 15 秒,长任务 300 秒;交易指令不补发;行情登录在检查周期内没成功就再发一次,有上限;续期失败后的自动重试被删掉,等下一个触发点再续。
建议(未做):
- 按接口类型分级超时:查询短、上传长、续期单独设置。
- 只对幂等的读请求做指数退避 + 随机抖动的重试,并设总时长上限;下单、撤单、改单这类交易指令绝不自动重试。
- 弱网时缩小行情订阅范围,只保留屏幕内的代码。
- 失败分类上报:DNS、建连、TLS、超时、服务端错误分开统计。
9. 踩过的坑
单飞标志泄漏。 持有者已释放时提前返回,没有复位在途标志,之后同一地址的请求都被当成「请求中」直接返回;修复是在这个分支里补上复位。任何「先置标志、异步后复位」的逻辑,都要检查所有提前返回的路径。它和续期的关系见凭证续期第 5 节。
重试要看触发点有多密。 续期失败后的自动重试已经删掉,改为等下一个触发点:续期的触发点本来就多,再叠加重试,弱网下会连续发出续期请求,见凭证续期第 5 节。第 8 节的退避重试建议因此只针对幂等读请求,并设总时长上限。
交易指令不能「贴心」地补发。 网络恢复后自动补发下单,价格和用户的意图可能都已经变了;项目里交易指令在未连接时直接丢弃,由上层按失败处理,见两条长连接第 4.4 节。
10. 对比与取舍
| 主题 | 已有 | 建议(未做) |
|---|---|---|
| 网络切换 | 状态变化后延迟检查「是否已连接」 | 接口类型一变就主动重建;心跳判死 |
| 请求合并 | 单飞、1 秒窗口、主推后合并,各自手写 | 通用合并器;按签名共享结果;命中打点 |
| 请求头 | 每个请求带静态字段 | 静态字段握手时报一次;先量再改 |
| 压缩 | 长连接 Protobuf;交易侧压缩选项;HTTP 依赖系统 | 大接口确认 gzip / br;高频接口评估 Protobuf |
| 超时 | 统一 15 秒 / 300 秒 | 按接口类型分级 |
| 重试 | 无通用重试;续期重试已删 | 只对幂等读做退避 + 抖动;交易指令绝不重试 |
| 度量 | 没有网络质量打点 | 失败分类上报;请求头字节数、合并命中数 |