移动端网页问题复现与调试指南:服务器日志 + 本机重放调试法
前言
有一类问题只在手机上出现,电脑上怎么都测不出来;而手边又没有数据线、模拟器,也没有 Xcode 或 Android Studio,手机只能当“用户”用:点一下,看现象,拍截图。
三种常见做法的结果差别很大:
- 只看日志:只能看出现象,看不出原因
- 盲修:修复后在手机上直接把服务器弄崩了
- 用真机请求序列在本机重放:两轮都定位到具体的那一行代码,修完一次就过了真机
本文整理的就是第三种做法。示例以“手机上拖动视频进度条后卡住”为背景,服务端是一个媒体代理接口,但方法适用于任何“只在手机上出问题的接口”。
0. 骨架:不变的部分
① 真机复现一次 ──► ② 服务器还原出手机的请求序列 ──► ③ 本机按同一序列重放,打真实上游
│
⑤ 真机再测一次 ◄── ④ 改代码;重放结果变对了才上线 ◄──────┘- 手机负责「做什么」:发哪些请求、什么顺序、什么时候断开。这一步你猜不到,只能录下来
- 本机负责「服务器怎么答」:用同一份生产代码,打真实上游,结果可以逐字节核对,还能反复跑
- 真机负责最终判定。重放只证明服务器答对了,不能证明手机最终的体验,比如画面动没动、你那里的网络、解码器怎么判断。所以第 ⑤ 步不能省。
它比单看日志强在哪:日志告诉你「出事了、在哪一段」,重放告诉你「是哪一行代码、改了以后对不对」。两者缺一不可:
- 没有日志:不知道该重放什么
- 没有重放:只能改了上线,再请用户用真机试,一轮要好几分钟,改错了还可能把线上弄坏
1. 照抄:每个项目都一样
每条请求结束打一行。这行要能还原「手机要了什么、拿到多少、怎么结束的」,字段固定:
[<模块>:req] range=<请求区间或关键参数> status=<状态码> first=<首字节ms> sent=<KB> <总ms> end=done|cancel|error:<原因>|empty- 打在响应流结束时,不是收到请求时。
cancel和done区分开,才看得出手机是主动断开,还是数据给完了 - 打进服务端终端(stdout)。浏览器里才知道的事件,用
sendBeacon打回服务端(代码见第 4 节第 3 条)
核心代码(照抄):在返回响应体之前包一层,数据真正流出去时才计数,流结束或被手机取消时打日志。 适用于所有「处理函数返回 Web 标准
Response」的服务端(Hono、Next.js Route Handler、Bun、Deno、Cloudflare Workers):import { Readable } from 'node:stream' export function logged( body: ReadableStream<Uint8Array> | null, tag: string, // 例如 'player:req' key: string, // 决定服务器怎么答的参数,例如 Range 头原文 status: number, startedAt: number, // 收到请求时的 Date.now() ): ReadableStream<Uint8Array> | null { if (!body) { console.log(`[${tag}] range=${key} status=${status} sent=0KB ${Date.now() - startedAt}ms end=empty`) return null } // 有些库(如 undici)交出的是 Node 流,只是类型写成 Web 流,它没有 getReader。不转的话, // 这里抛错,那条上游响应就没人消费;上游断开时它的 error 事件没人接,整个进程会崩溃。 const raw = body as unknown const web = raw instanceof Readable ? (Readable.toWeb(raw) as ReadableStream<Uint8Array>) : body let sent = 0 let firstMs = -1 let ended = false const end = (how: string): void => { if (ended) return ended = true console.log(`[${tag}] range=${key} status=${status} first=${firstMs}ms sent=${(sent / 1024).toFixed(0)}KB ${Date.now() - startedAt}ms end=${how}`) } const reader = web.getReader() return new ReadableStream<Uint8Array>({ async pull(ctl) { try { const { done, value } = await reader.read() if (done) { end('done'); ctl.close(); return } if (firstMs < 0) firstMs = Date.now() - startedAt sent += value.length ctl.enqueue(value) } catch (error) { end('error:' + (error instanceof Error ? error.message : String(error))) ctl.error(error) } }, // 手机断开、拖进度、换集都会走到这里;同时要把取消传给上游,否则上游连接会一直挂着 cancel(reason) { end('cancel'); return reader.cancel(reason) }, }) } // 用法:在处理函数里 const startedAt = Date.now() const range = req.headers.get('range') ?? '-' // ...算出 body / status / headers... return new Response(logged(body, 'player:req', range, status, startedAt), { status, headers })Express / 原生
http这种直接拿到res的服务端,不用包流,挂两个事件就够:const startedAt = Date.now() let sent = 0 let firstMs = -1 const write = res.write.bind(res) res.write = ((chunk: any, ...rest: any[]) => { if (firstMs < 0) firstMs = Date.now() - startedAt sent += Buffer.byteLength(chunk) return write(chunk, ...rest) }) as typeof res.write // finish = 数据全部交出去了;close 先于 finish 触发 = 手机中途断开 res.on('finish', () => console.log(`[player:req] range=${req.headers.range ?? '-'} status=${res.statusCode} first=${firstMs}ms sent=${(sent / 1024).toFixed(0)}KB ${Date.now() - startedAt}ms end=done`)) res.on('close', () => { if (!res.writableFinished) console.log(`[player:req] range=${req.headers.range ?? '-'} status=${res.statusCode} first=${firstMs}ms sent=${(sent / 1024).toFixed(0)}KB ${Date.now() - startedAt}ms end=cancel`) })日志读起来是这样:
[player:req] range=bytes=151614182- status=206 first=1934ms sent=3072KB 2809ms end=cancel ← 手机拖进度后先拉、又断开 [player:req] range=bytes=3063788- status=206 first=1012ms sent=512KB 1026ms end=cancel ← 去文件开头补小请求 [player:req] range=bytes=153014182- status=206 first=4ms sent=... ← 几秒后回到原位置附近- 打在响应流结束时,不是收到请求时。
重放脚本放在仓库里的临时目录(如
web/scripts/.tmp/),用完删掉。不能放在 scratchpad,因为脚本要解析仓库里的依赖。用npx tsx跑,直接 import 生产的处理函数,不要另写一份模拟实现。核心代码(照抄骨架,替换三处标注):
// web/scripts/.tmp/replay.mts —— 跑法:npx tsx web/scripts/.tmp/replay.mts import { createHash } from 'node:crypto' // ① 换成你项目里真正处理这条请求的函数(生产代码,不要复制一份) import { serveMedia } from '../../server/media' // ② 真实上游地址:从解析层现取(签名会过期),不要抄旧日志 const url = process.argv[2] // ③ 从服务器 req 日志里抄下来的序列:区间、读多少、和上一条之间隔多久 const steps: Array<{ range: string; bytes: number; gapMs: number }> = [ { range: 'bytes=151614182-', bytes: 3_000_000, gapMs: 0 }, { range: 'bytes=3063788-', bytes: 200_000, gapMs: 0 }, { range: 'bytes=153014182-', bytes: 1_000_000, gapMs: 3000 }, ] const md5 = (b: Buffer): string => createHash('md5').update(b).digest('hex').slice(0, 8) for (const step of steps) { await new Promise((r) => setTimeout(r, step.gapMs)) const t0 = Date.now() const res = await serveMedia(url, step.range) // 返回 { status, headers, body: ReadableStream } const reader = res.body!.getReader() const parts: Buffer[] = [] let got = 0 let firstMs = -1 while (got < step.bytes) { const { done, value } = await reader.read() if (done) break if (firstMs < 0) firstMs = Date.now() - t0 parts.push(Buffer.from(value)) got += value.length } await reader.cancel() // 模拟手机读到一半断开 const mine = Buffer.concat(parts).subarray(0, step.bytes) // 字节核对:同一区间直接问上游要,逐字节比 const from = Number(step.range.match(/bytes=(\d+)/)![1]) const orig = Buffer.from(await (await fetch(url, { headers: { Range: `bytes=${from}-${from + mine.length - 1}` } })).arrayBuffer()) console.log(`${step.range} status=${res.status} first=${firstMs}ms got=${(got / 1024).toFixed(0)}KB ${Date.now() - t0}ms`, md5(mine) === md5(orig) ? 'MATCH' : 'MISMATCH') } process.exit(0) // 服务端模块常带定时器 / 连接池,不显式退出脚本会挂住要点:
- 处理函数内部的日志会一起打出来,例如「复用已有区间」「会话开启」,用它看每一步走的是哪个分支
- 服务器对返回内容做了改写(例如把目录挪到前面),就不能拿原文件直接比,要按改写规则换算出原文件的对应区间再比
- 判断「修好了」:MATCH,且该从内存答的那一步
first=是个位数毫秒
重放时分两层核对:
- 字节:同一区间的输出和上游原文件逐字节比对(md5)
- 时序:按日志里的顺序和间隔发请求,看每条用了多少毫秒、走的是哪个分支(例如复用内存 / 新开会话)
先拿重放证明「改之前是错的」,再改代码,再证明「改之后是对的」。修之前复现不出来,说明序列还原错了,不要急着改。
上线后回到真机,并用同一行日志确认手机这次走的是新分支。
2. 每个项目单独确定:不能照抄,但有固定的确定方法
| 要确定的 | 怎么确定 | 示例 |
|---|---|---|
哪条接口值得打 req 日志 | 手机现象对应的那条数据通路;先看 nginx access.log 里该路径的状态码分布,找到成片 502 / 499 的接口 | /api/player/stream(媒体代理) |
range= 记什么 | 能决定服务器怎么答的那几个参数:媒体是 Range 头,分页接口是 page / cursor | Range 头原文 |
| 去哪里看日志 | 部署文档里查进程管理器的日志命令,例如 pm2 logs <进程名> --lines 50 --nostream,见 Node 应用上线与加固清单 | pm2 logs myapp-web --lines 50 --nostream |
| 重放用的真实上游地址 | 从解析层现取,签名地址会过期,不要抄旧日志里的 | 调用解析层的 getPlaybackUrl() 现取 |
| 手机特有的行为 | 从 req 日志里读出来,不靠记忆 | iPhone 拖进度:断开 → 去文件开头补小请求 → 几秒后在原位置附近重连 |
3. 判断问题出在哪一层
| 日志 / 重放看到的 | 问题在 | 下一步 |
|---|---|---|
| nginx 成片 502,应用日志有异常 | 服务端代码抛错 | 按报错找到行,重放那条请求 |
nginx connect() failed,进程重启次数在涨 | 进程崩了 | pm2 list 看重启次数,error log 找没人接的 error 事件 |
全是 206 / 200,但 first= 很大 | 上游慢,或服务器入口被占满 | 在服务器上测一个与项目无关的地址,分清是“我们的带宽”还是“源站” |
| 重放字节对、时序也快,手机仍然卡 | 手机端:解码、播放器、网络 | 换真机抓包(第 4 节),或者看前端打回的状态记录 |
4. 这套方法的位置:不是唯一的办法
没有数据线、没有模拟器时,可选的办法从准到粗:
手机 Wi-Fi 设置代理抓包(mitmproxy / Charles,手机装证书):不用数据线,拿到的是手机发出的原始请求,比服务器日志更准,连上游、第三方请求都看得到。缺点是要在手机上装证书,并且要有人在电脑旁开着代理。
本指南:服务器
req日志 + 本机重放:手机上什么都不用装,用户正常用就能留下证据。缺点是只看得到经过你服务器的请求,序列是从服务器一侧还原的。页面里打回状态记录(例如播放页的「胶片」,
/api/player/client-log):知道手机当时「看到了什么」,比如缓冲了多少、卡在哪一秒。它跟 2 是互补的:2 说明服务器给了什么,3 说明手机收到后怎么样了。核心代码(照抄):每 2 秒记一帧状态,只留最近 40 帧;出事时连同这串记录一起打回服务端。
// 浏览器端 const tape: string[] = [] const t0 = performance.now() function snapshot(v: HTMLVideoElement): string { let ahead = 0 for (let i = 0; i < v.buffered.length; i++) { if (v.buffered.start(i) <= v.currentTime && v.currentTime <= v.buffered.end(i)) ahead = v.buffered.end(i) - v.currentTime } return `${((performance.now() - t0) / 1000).toFixed(0)}s t=${v.currentTime.toFixed(1)} ahead=${ahead.toFixed(1)} rs=${v.readyState} err=${v.error?.code ?? '-'}${v.paused ? ' paused' : ''}` } function report(msg: string): void { const body = JSON.stringify({ msg, tape: tape.slice(-40) }) // 页面关掉 / 切后台时 fetch 会被浏览器丢掉,sendBeacon 不会 if (!navigator.sendBeacon('/api/client-log', new Blob([body], { type: 'application/json' }))) { void fetch('/api/client-log', { method: 'POST', body, keepalive: true, headers: { 'Content-Type': 'application/json' } }) } } const video = document.querySelector('video')! setInterval(() => { tape.push(snapshot(video)); if (tape.length > 40) tape.shift() }, 2000) video.addEventListener('error', () => report('media error ' + snapshot(video))) let stalledAt = 0 video.addEventListener('waiting', () => { stalledAt = performance.now() }) video.addEventListener('playing', () => { if (stalledAt) report(`stall recovered after ${(performance.now() - stalledAt).toFixed(0)}ms ` + snapshot(video)) stalledAt = 0 })// 服务端:只负责落进终端(Hono 写法;其他框架同理) app.post('/api/client-log', async (c) => { const o = await c.req.json().catch(() => ({})) as { msg?: unknown; tape?: unknown } if (typeof o.msg === 'string') console.log('[client] ' + o.msg.slice(0, 300)) if (Array.isArray(o.tape)) console.log('[client] tape: ' + o.tape.slice(-40).map((l) => String(l).slice(0, 120)).join(' | ')) return c.body(null, 204) })读法:
rs是 readyState(4 = 数据够播),ahead是当前位置往后缓冲了几秒。ahead一直涨而t不动,是手机不肯播;ahead停在 0,是数据没到。只看日志盲修:不推荐。
所以,在「不连线、不用模拟器」的前提下,2 + 3 是成本最低、能常驻线上的组合。遇到 2 + 3 还解释不了的现象(第 3 节最后一行),再上 1。
