浏览器长时间录音与切片上传的工程化实践
目标:长时间录音(小时级)+ 稳定切片上传 + 不阻塞主线程 + 可控失败与边界处理
本文不是 Demo,而是一套可上线的工程方案总结,适用于:
- Web 录音
- Electron / WebView
- 需要边录边传的场景(存储 / ASR / 审计)
一、背景问题
浏览器中使用 MediaRecorder 录音非常方便,但一旦进入 长时间录音 + 实时上传,会立刻遇到以下挑战:
- 主线程不能被阻塞
- 网络慢 / 断网
- 上传失败重试
- 内存持续增长
- 页面切后台 / 锁屏
如果只是简单在 ondataavailable 里 fetch,一定会翻车。
二、核心设计思想
1️⃣ 采集与上传彻底解耦
MediaRecorder
↓ (1s)
ondataavailable
↓
内存缓冲 buffer
↓ (≥2MB / pause / stop)
上传队列 uploadQueue
↓ (串行)
网络上传
采集只做内存操作,上传只在队列中发生
2️⃣ 为什么不能直接上传?
fetch是慢 I/O- 网络不稳定
- retry 会阻塞
👉 必须引入队列模型(生产者 / 消费者)
三、MediaRecorder 行为认知
mediaRecorder.start(1000)
ondataavailable不是音频帧e.data是 编码后的 Blob(时间片)- timeslice 决定触发频率
四、2MB 切片 ≠ 不会内存爆
常见误区
上传成功后会 shift 掉队列,为什么还会无限增长?
真相
- 2MB 只是单个 task 的大小
- 队列增长取决于:
录音产生速度 > 上传完成速度
这是一个典型的生产者-消费者失衡问题。
五、关键实现方案
1️⃣ 状态与常量
const UPLOAD_THRESHOLD = 2 * 1024 * 1024 // 2MB
const MAX_QUEUE_LENGTH = 20 // 队列上限
const MAX_RETRY = 3
2️⃣ 非阻塞采集(主线程安全)
mediaRecorder.ondataavailable = (e) => {
if (!e.data || e.data.size === 0) return
bufferBlobs.push(e.data)
bufferSize += e.data.size
if (bufferSize >= UPLOAD_THRESHOLD) {
flushBuffer("threshold")
}
}
✔ 只做内存操作 ✔ 无 await / 无网络
3️⃣ flush:进入上传队列(解耦核心)
function flushBuffer(reason) {
if (bufferBlobs.length === 0) return
if (uploadQueue.length >= MAX_QUEUE_LENGTH) {
mediaRecorder.pause()
throw new Error("upload queue overflow")
}
const blob = new Blob(bufferBlobs, { type: bufferBlobs[0].type })
uploadQueue.push({
seq: seq++,
blob,
retry: 0,
reason,
})
bufferBlobs = []
bufferSize = 0
processUploadQueue()
}
4️⃣ 串行上传 + 重试(防死循环)
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// 工具函数:带抖动的指数退避延迟
const expBackoffDelay = (retryCount) => {
const maxDelay = 30000
const base = Math.min(Math.pow(2, retryCount - 1) * 1000, maxDelay)
const jitter = base * 0.3 * (Math.random() - 0.5) // ±15%
const delay = Math.max(0, base + jitter)
return new Promise((resolve) => setTimeout(resolve, delay))
}
async function processUploadQueue() {
if (uploading) return
uploading = true
while (uploadQueue.length > 0) {
const task = uploadQueue[0]
try {
await uploadChunk(task)
uploadQueue.shift()
} catch (e) {
task.retry++
if (task.retry > MAX_RETRY) {
mediaRecorder.stop()
uploadQueue = []
break
}
await delay(Math.min(1000 * 2 ** task.retry, 30000))
}
}
uploading = false
}
5️⃣ pause / stop 时不满 2MB 也要上传
function pauseRecord() {
mediaRecorder.pause()
flushBuffer("pause")
}
mediaRecorder.onstop = () => {
flushBuffer("stop")
}
function stopRecord() {
mediaRecorder.stop()
}
六、必须处理的边界情况(重点)
1️⃣ 队列无限增长(最危险)
- 网络慢 / 断网
- retry 期间持续产生数据
👉 必须限制 uploadQueue 长度
2️⃣ 页面切后台 / 锁屏(iOS 必踩)
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
mediaRecorder.pause()
flushBuffer("background")
}
})
3️⃣ 网络断开
window.addEventListener("offline", () => {
console.warn("network offline")
})
window.addEventListener("online", () => {
processUploadQueue()
})
4️⃣ MediaRecorder 假死(无回调)
let lastDataTime = Date.now()
setInterval(() => {
if (mediaRecorder.state === "recording" && Date.now() - lastDataTime > 5000) {
console.warn("recorder stalled")
}
}, 3000)
5️⃣ 页面关闭 / 刷新
window.addEventListener("beforeunload", () => {
flushBuffer("unload")
})
兜底方案:navigator.sendBeacon
七、服务端必须配合的点
(sessionId, seq)幂等- 允许乱序
- 允许重复上传
- 断点续传友好
八、关键经验总结
2MB 是粒度控制,不是稳定性保障
真正保证系统稳定的是:
- 背压(backpressure)
- 队列上限
- 网络异常处理
- 生命周期感知
九、适用场景
- 长时间会议录音
- 浏览器端审计录音
- 边录边传 ASR