Skip to content

移动端网页问题复现与调试指南:服务器日志 + 本机重放调试法

约 2996 字大约 10 分钟

Web前端排障移动端

2026-10-04

统计数据加载中...

前言

有一类问题只在手机上出现,电脑上怎么都测不出来;而手边又没有数据线、模拟器,也没有 Xcode 或 Android Studio,手机只能当“用户”用:点一下,看现象,拍截图。

三种常见做法的结果差别很大:

  • 只看日志:只能看出现象,看不出原因
  • 盲修:修复后在手机上直接把服务器弄崩了
  • 用真机请求序列在本机重放:两轮都定位到具体的那一行代码,修完一次就过了真机

本文整理的就是第三种做法。示例以“手机上拖动视频进度条后卡住”为背景,服务端是一个媒体代理接口,但方法适用于任何“只在手机上出问题的接口”。

0. 骨架:不变的部分

① 真机复现一次 ──► ② 服务器还原出手机的请求序列 ──► ③ 本机按同一序列重放,打真实上游
                                                        │
⑤ 真机再测一次 ◄── ④ 改代码;重放结果变对了才上线 ◄──────┘
  • 手机负责「做什么」:发哪些请求、什么顺序、什么时候断开。这一步你猜不到,只能录下来
  • 本机负责「服务器怎么答」:用同一份生产代码,打真实上游,结果可以逐字节核对,还能反复跑
  • 真机负责最终判定。重放只证明服务器答对了,不能证明手机最终的体验,比如画面动没动、你那里的网络、解码器怎么判断。所以第 ⑤ 步不能省。

它比单看日志强在哪:日志告诉你「出事了、在哪一段」,重放告诉你「是哪一行代码、改了以后对不对」。两者缺一不可:

  • 没有日志:不知道该重放什么
  • 没有重放:只能改了上线,再请用户用真机试,一轮要好几分钟,改错了还可能把线上弄坏

1. 照抄:每个项目都一样

  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=...                         ← 几秒后回到原位置附近
  2. 重放脚本放在仓库里的临时目录(如 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= 是个位数毫秒
  3. 重放时分两层核对:

    • 字节:同一区间的输出和上游原文件逐字节比对(md5)
    • 时序:按日志里的顺序和间隔发请求,看每条用了多少毫秒、走的是哪个分支(例如复用内存 / 新开会话)
  4. 先拿重放证明「改之前是错的」,再改代码,再证明「改之后是对的」。修之前复现不出来,说明序列还原错了,不要急着改。

  5. 上线后回到真机,并用同一行日志确认手机这次走的是新分支。

2. 每个项目单独确定:不能照抄,但有固定的确定方法

要确定的怎么确定示例
哪条接口值得打 req 日志手机现象对应的那条数据通路;先看 nginx access.log 里该路径的状态码分布,找到成片 502 / 499 的接口/api/player/stream(媒体代理)
range= 记什么能决定服务器怎么答的那几个参数:媒体是 Range 头,分页接口是 page / cursorRange 头原文
去哪里看日志部署文档里查进程管理器的日志命令,例如 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. 这套方法的位置:不是唯一的办法

没有数据线、没有模拟器时,可选的办法从准到粗:

  1. 手机 Wi-Fi 设置代理抓包(mitmproxy / Charles,手机装证书):不用数据线,拿到的是手机发出的原始请求,比服务器日志更准,连上游、第三方请求都看得到。缺点是要在手机上装证书,并且要有人在电脑旁开着代理。

  2. 本指南:服务器 req 日志 + 本机重放:手机上什么都不用装,用户正常用就能留下证据。缺点是只看得到经过你服务器的请求,序列是从服务器一侧还原的。

  3. 页面里打回状态记录(例如播放页的「胶片」,/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,是数据没到。

  4. 只看日志盲修:不推荐。

所以,在「不连线、不用模拟器」的前提下,2 + 3 是成本最低、能常驻线上的组合。遇到 2 + 3 还解释不了的现象(第 3 节最后一行),再上 1。