有时候,Bug 并不体现在程序错误上,而是行为偏差。在一次常规功能测试中,我们发现移动端某个提交请求被触发了两次,虽然后端做了幂等处理,但频繁请求仍可能带来性能问题、错误日志膨胀、以及潜在副作用。
这类问题常被归类为"无影响的冗余请求",但我们决定彻查触发路径与请求内容差异,确保系统行为在各种网络和设备条件下都能一致。
本文记录了我们如何通过多个抓包工具协作,从客户端真实行为开始,逐步确认问题成因并设计验证手段。
问题现象简述
目标接口为内容收藏操作接口。日志中部分用户在点击"收藏"按钮后,同一秒钟内发起了两次 POST 请求,唯一标识相同,仅参数顺序略有不同。重复请求并未导致业务错误,但触发了重复打点和错误日志记录。
第一阶段:后端日志 vs 客户端逻辑初步对比
后端日志已明确标出"某些用户双次请求",但客户端代码中绑定事件的逻辑没有重复。我们决定从"请求层"而不是"代码逻辑"入手,验证真实触发过程。
第二阶段:建立可观测抓包环境
我们测试时使用三种终端:
- Windows桌面客户端(基于Electron)
- Web页面(H5)
- iOS App
分别构建抓包环境:
工具 | 使用场景 | 理由 |
---|---|---|
Charles | 抓桌面端和H5请求 | 快速配置代理,界面清晰 |
Sniffmaster | 抓取iOS端点击收藏后的完整HTTPS数据 | 无需越狱,解密HTTPS请求结构 |
mitmproxy | 添加拦截脚本,记录请求时间间隔与字段序列 | 便于分析参数排序逻辑 |
Wireshark | 捕获低层网络重传/断线重连可能 | 辅助验证 TCP 层触发行为 |
第三阶段:抓取并对比请求数据
我们进行了5次不同网络环境下的收藏请求测试,记录结果如下:
- Web 和桌面端均只发出一次请求,数据结构一致;
- iOS 端出现2次请求现象:首个请求带完整签名,第二个仅延迟约300ms,结构略有差异;
- 重现过程中,App未卡顿、用户未重复点击。
使用 Sniffmaster 抓到的首个请求结构完整,携带认证信息;第二个请求字段顺序变化、缺少特定 header,推测由缓存逻辑触发。
我们进一步将这些请求导出,使用脚本对字段逐个比对:
python
# 简化字段比对
def diff_keys(req1, req2):
for k in set(req1) | set(req2):
if req1.get(k) != req2.get(k):
print(f"{k}: {req1.get(k)} != {req2.get(k)}")
结果明确指出第二次请求字段精简,可能为"后补请求"或"失败重发"。
第四阶段:构造条件验证自动重发机制
为了确认是否为 App SDK 中的重试逻辑触发,我们借助 mitmproxy 添加延迟响应模拟:
python
def response(flow):
if "/collect" in flow.request.url:
import time
time.sleep(1.5) # 模拟服务端超时响应
添加该脚本后,App 端再次触发重复请求,且第二次请求体与日志完全一致。验证 App 在响应超过特定阈值后,自动重发该请求,说明问题并非"事件被绑定多次",而是 SDK 逻辑层的超时重试机制未做去重处理。
第五阶段:方案讨论与结论输出
我们最终定位问题源于:
- SDK 对超时请求未等待响应确认,直接补发;
- 接口未校验是否已提交,导致业务处理走了两次流程(虽然幂等性保障了数据不重复);
- 日志与埋点未做去重,导致"问题感知"加剧;
解决方案:
- 客户端增加"是否发起中"标记,拦截重复点击和补发;
- 后端加入请求ID机制,前端生成唯一标识避免幂等逻辑失效;
- 埋点与日志侧加入重复判定逻辑,减少误报;
抓包协作流程的价值复盘
单一工具并不能支撑从"重现现象"到"还原过程"到"构造条件"再到"验证结果"的完整链条。我们每个阶段只用了它最擅长的工具:
- Charles → 抓桌面、Web行为;
- Sniffmaster → 还原iOS端真实场景;
- mitmproxy → 拦截与干预请求逻辑;
- Wireshark → 辅助网络层干扰排查;
这种"任务拆解式"调试方式,让问题不是"抓到了",而是"理解了"。
写在最后
如果你也经常遇到"行为不一致但日志正常"的问题,不妨试着建立一套多工具协作的抓包分析流程。不是为了用工具炫技,而是为了让每一个网络行为都被还原、每一次异常都有解释。
真正高效的调试流程,不靠"哪个工具最全",而靠"流程拆得够细、每步工具用得刚好"。