记录: 小游戏平台bundle文件缓存问题踩坑总结

Cocos 小游戏平台缓存问题踩坑记录

本文记录了在微信/抖音小游戏平台中遇到的 远端分包文件下载超时、zip 分包、缓存记录丢失、bundle 释放 四大类问题,以及最终的完整解决方案。
问题与方案均已在 AssetManager.js / cache-manager.js 中落地。

环境:

以下案例均是在creator 2.x版本出现, 并且都是远端分包引起的小游戏平台缓存问题


目录

  1. 问题1:大文件下载超时/失败
  2. 问题2:zip 分包解压失败反复下载
  3. 问题3:缓存记录(cachedFiles)丢失
  4. 问题4:bundle 释放后资源 URL 拼成 undefined
  5. 附录:伪代码解决方案

问题1:大文件下载超时/失败

现象

总是有用户下载 2MB 大的 zip 分包 或其它大文件时失败。

排查过程

  • 发现 game.json 默认设置 下载超时 5s 时间过短
  • 且小游戏下载 API 没有断点续传,超时失败后需要重新开始下载;
  • 结果:网速慢的玩家一直「下载超时 → 重试」,网速快的玩家则没影响。

尝试修复(均未最终采用)

尝试 结果
延长下载超时到 50s 大文件问题解决,但 1kb 小文件因网络丢包不能及时重试,也要卡 50s,影响体验
减少下载并发数为 3 大文件超时缓解,但拖慢小文件下载速度

最终解决方案

以上方法不再使用,改为以下优化:

  1. 不用 小游戏平台 download API(不支持断点续传),改用 request 实现断点续传;
  2. 将下载文件按 254KB 大小分割成多数据流请求
    • 每个请求单独设置超时 10s(不使用 game.json 默认的 5s);
    • 每个请求单独处理超时,失败只重下该块;
    • 所有分块下载完后合并文件,校验文件完整性
    • 有错误则返回错误信息,由业务逻辑层处理;
  3. 缺点:下载数据需临时放内存直到合并完成,内存占用增加
  4. 新增 下载进度回调,用户可实时查看下载进度和网速(普通 download API 没有此功能);
  5. 我的项目中唯一的大文件是 zip 分包,因此分块下载流程针对 zip 分包展开;普通小文件仍走原 download API。

问题2:zip 分包解压失败反复下载

现象

案例游戏使用大量 zip 分包,总是有用户反馈 一直卡加载或加载缓慢

排查过程

除了问题1的大文件问题,还发现 zip 分包专属问题:

  • 缓存空间不足:当缓存空间不足 200MB 时,zip 分包解压失败,底层会调用 clearLRU 清缓存,但这期间会 重复「下载 → 解压失败 → 重复下载」 直到解压成功;
  • 损坏 zip 污染缓存:有时候下载的 zip 包不完整,解压却没任何报错,Cocos 会将其记录到 cachedFiles 当作已正常下载,导致后续加载失败,重启也救不回来,只能清缓存
  • 退出丢失记录:下载和解压期间游戏退出,cachedFiles 记录有概率丢失,导致后续加载失败,或 clearLRU 无法清理游离文件。

最终解决方案

  • 大 zip 分包 断点续传,超时/丢包不整体重下;
  • 损坏 zip 被 完整性校验拦截,不再污染缓存;
  • 解压失败等待自动腾空间重试,不再无限重复下载
  • 任何阶段崩溃,下次启动自愈(清理残留 / 补完删除),load文件判断无效缓存重下载丢失缓存文件;
  • 新增 API 供业务层查询 剩余可用缓存大小,当可用空间少于 30MB 时主动调用 clearLRU 清理缓存,避免加载关卡时还要「等待清理缓存 → 解压」,提前规避许多加载 bug。

问题3:缓存记录(cachedFiles)丢失

现象

总是有用户反馈 启动加载页卡死,只有点「清理缓存重启」后才能正常进入游戏。

排查过程

除了问题1、2 导致的缓存记录异常,还有其它缓存记录问题:

游戏运行期间下载 bundle 包文件途中,游戏退出,缓存记录有概率丢失。

  • 现象1cacheList.json 里有缓存记录,但实际文件已被删除 → bundle 每次命中缓存后读取失败,形成 死循环,只能清理缓存修复;
  • 现象2cacheList.json 里没有缓存记录,但文件实际存在 → clearLRU 无法清理这些游离文件,缓存空间泄露越来越大,最终无法下载新文件,只能清理缓存修复。

注:上述问题可能还有其它原因,比较复杂,此处不展开。

最终解决方案

核心目标:保证 cacheList.json(记录)与磁盘文件(目录)之间,无论何时崩溃/中断,最终都一致,或下次启动能修复意外退出的未完成的记录;同时把 cacheList.json 当作 参考 而非权威数据,对记录做二次验证。

(1) 记录存在但文件丢失(现象1)→ 命中自愈

  • download:缓存读取失败时, 不再信任 cachedFiles 缓存记录,仅依赖磁盘文件存在性判断是否需要重复下载丢失的文件。

(2) 文件存在但记录丢失(现象2)→ clearUnreferenced() 回收

  • 新增 clearUnreferenced函数: 清除缓存目录中所有未在 cachedFiles 中记录的文件和目录
  • 只有在 缓存满outOfStorage 触发 clearLRU 后才调用 clearUnreferenced
  • 业务层主动判断缓存可用空间小于30MB时主动 clearLRU 避免频繁触发 clearUnreferenced
  • 实在是要溢出了才触发该兜底方案

(3) 减少记录丢失概率

  • 防抖写盘改为 onHide 时立即强制 _write()(小游戏切后台前落盘);
  • 缓存文件名带 time_suffix 防时间戳碰撞;
  • 永久性错误(404 / 文件不存在 / ENOTFOUND)缓存失败直接跳过,不再留脏记录。

(4) 业务层配合

  • 新增getCacheSize 函数获得缓存可用空间
  • 业务层退出关卡时前用 getCacheSize() 判断剩余可用缓存空间,< 阈值主动 clearLRU 腾空间;

(5) 删改「两阶段」:标记 → 写盘 → 异步删文件 → 删完才移除记录

  • 标记cachedFiles 记录上加 isDeleting = truewriteCacheFile(立即持久化);
  • 删除:异步删文件/目录,成功后 cachedFiles.remove(url) 再写盘;
  • 删除前再次检查 isDeleting:若被访问清除则跳过删除,保全缓存;
  • 崩溃点全覆盖
    • 标记后崩溃 → 启动时 _cleanupDeletingItems 补完删除;
    • 文件删完记录未删 → 启动扫描 exists 发现文件没了,直接移除记录;
  • clearLRU / clearLRUWithSize / removeCache 全部统一走该流程,不再「先删记录后删文件」。

(6) 解压与修改「两阶段」:写 pending 标记 → 解压 → 成功清标记 / 失败清标记+删目录

  • unzipAndCacheBundle 解压前 pendingBundles[id] = bundleRoot 立即写盘;
  • 解压成功/失败都 clearPending 并写盘;
  • 崩溃点全覆盖
    • 解压中崩溃 → 启动时 _cleanupPendingBundles 删整个 bundle 目录 + 相关记录;
    • 解压目录残留 → 每次解压目标固定 cacheDir/<bundleRoot>/unzip,先 rmdirSync 清空再解压。

最终效果

  • 任何时刻崩溃/中断,下次启动时用最少的开销让记录与磁盘自动收敛一致;
  • 不再出现「缓存记录死循环卡加载」和「游离文件吃满空间」两种只能靠用户清缓存修复的问题。

测试方法

在开发者工具里,游戏运行期间下载 bundle 包文件,然后清空文件缓存目录,模拟游戏途中文件异常丢失的现象。


问题4:bundle 释放后资源 URL 拼成 undefined

现象

玩家切换关卡时加载慢或卡死。

排查过程

因网速超慢,在游戏途中加载背景音乐还没完成时,玩家退出关卡释放音乐所在的 bundle,触发以下错误:

undefined/e9/e91d5c3f-ae7d-4cea-a4ef-680175943d8e47d.mp3 does not exist! Error: file
undefined/e9/e91d5c3f-ae7d-4cea-a4ef-175943d8e47d.mp3 does not exist! at tt
file:///game/adapter-min.js:585:40

undefined 来源

当远端资源下载好后才跑内部 combine 函数,该函数作用是组合 url 路径。因为 bundle 已释放,所以 config 是空的,base == undefined,导致拼接前缀为 undefined

复现 bug 代码(放到开发者工具命令行执行即可复现):

// 复现bug代码, 复制到小游戏控制台运行
cc.assetManager.loadBundle("Mode180", (err, bundle) => {
    // 立即加载远程端 prefab
    bundle.load("bgm.mp3", cc.Prefab, (err, k) => { console.log('load:', err, k) });

    // 立即释放 bundle(或没等 bgm.mp3 完成就释放)就会触发报错
    setTimeout(() => {
        bundle.releaseAll();
        cc.assetManager.removeBundle(bundle);
    }, 1)
})

隐蔽性

  • 触发概率比较低,网速快的时候测不出来;
  • 触发错误不会被小游戏平台异常捕获。

解决方法

最简单方法:加载 bundle 后不释放 bundle,只释放 bundle 内的资源。


附录:伪代码解决方案

问题1:大文件(>254KB)下载超时/失败 → 分块断点续传(AssetManager.js)

// ============ 配置 ============
downloader.isSupportResumeZip = true;   // 是否启用分块续传
downloader.zipTimeout = 50000;          // 每块超时
downloader.zipMaxRetryCount = 50;       // 每块最大重试
downloader.zipMaxChunkSize = 254KB;     // 分块大小
downloader.zipMaxConcurrentChunks = 6;  // 并发块数

// 下载主流程: 仅对 zip 分包走分块; 普通小文件仍用平台 download api
handleZip(url, options, onComplete):
    if cachedFiles.has(url): onComplete(null, cached.url); return
    if isRemote(url) && downloader.isSupportResumeZip:
        downloadToMemory(url, options, onProgress, (err, arrayBuffer) ->
            saveArrayBufferToTempFile(arrayBuffer, url, (err, tempPath) ->
                unzipAndCacheBundle(url, tempPath, bundleRoot, (unzipErr, unzipPath) ->
                    deleteTempFile(tempPath)
                    onComplete(unzipErr, unzipPath)
                )
            )
        )
    else:
        downloadFile(url, ...)          // 原版下载
        cacheManager.unzipAndCacheBundle(url, path, bundleRoot, onComplete)

// ============ 分块续传核心 ============
downloadToMemory(url, options, onProgress, onComplete, storedData, retryCount):
    state = storedData || { totalBytes: 0, chunks: {}, completedChunks: 0 }

    // 第一步: Range: bytes=0-0 探测总大小(从 Content-Range: bytes 0-0/N 取 N)
    if state.totalBytes == 0:
        request({ url, header: { Range: 'bytes=0-0' }, responseType: 'arraybuffer' })
            .success: state.totalBytes = N; startParallelDownload()
            .fail: onComplete(Error)

    // 第二步: 并发分块下载
    startParallelDownload():
        numChunks = ceil(totalBytes / chunkSize)
        startNextChunk():   // 每次补满 concurrent 个并发
            for idx in nextChunks:
                request({ url, header: { Range: 'bytes=start-end' }, timeout })
                    .success: state.chunks[idx] = data; onProgress(已下载/总数)
                              completedChunks == numChunks ? combineAndComplete() : startNextChunk()
                    .fail/空数据: retryChunk(idx)   // 单独重试该块, 不重下整个文件
        retryChunk(idx): retryCount > zipMaxRetryCount ? onComplete(Error) : 1s后重下该块

    // 第三步: 合并 + 完整性校验
    combineAndComplete():
        combined = new Uint8Array(totalBytes)
        校验1: 拼接后 offset === state.totalBytes      // 长度一致(防止服务端内容变了)
        校验2: 前4字节是 ZIP 魔数 PK(0x504B0304/0506/0708)
        失败 → onComplete(Error('Download size mismatch' / 'not a valid ZIP'))
        成功 → onComplete(null, combined.buffer)

// ============ 写临时文件遇存储满: 清缓存后重试写 ============
saveArrayBufferToTempFile(arrayBuffer, url, onComplete):
    writeFile(tempPath, arrayBuffer, 'binary', (err) ->
        if err:
            if isOutOfStorage(err.message):
                cacheManager.clearLRU()          // 清缓存
                waitForCleanupThenRetry():        // 轮询/延时后重试 doWrite(), 上限50次
            else: onComplete(err)
        else: onComplete(null, tempPath)
    )

问题2:zip 分包解压失败反复下载 / 损坏 zip 污染缓存(cache-manager.js)

// 解压 + 缓存, 关键改动: 失败清缓存后重试解压, 不再重复下载 zip
unzipAndCacheBundle(id, zipFilePath, cacheBundleRoot, onComplete, tryCount=0):
    targetPath = cacheDir + '/' + bundleRoot + '/unzip'
    rmdirSync(targetPath, true)          // 固定目录, 先清空旧的避免残留
    makeDirSync(targetPath, true)

    // 解压前写 pending 标记(异常退出可检测, 见问题3)
    pendingBundles[id] = cacheBundleRoot; writeCacheFile()
    unzip(zipFilePath, targetPath, (err) ->
        if err:
            rmdirSync(targetPath, true); clearPending()
            if isOutOfStorage(err.message):
                if !autoClear: onComplete(err); return
                if tryCount > 5: onComplete(err); return   // 重试上限
                size = calculateFileSize(zipFilePath)       // 按需清出足够空间
                clearLRUWithSize(size + 1MB, (err, remaining, caches) ->
                    1s后 unzipAndCacheBundle(id, zipFilePath, bundleRoot, onComplete, tryCount+1)
                )
            else: onComplete(err)
        else:
            size = calculateDirSize(targetPath)
            cachedFiles.add(id, { bundle, url: targetPath, lastTime, size })
            clearPending(); writeCacheFile()
            onComplete(null, targetPath)
    )

// 按目标大小清 LRU(原 clearLRU 只能固定清 1/3)
clearLRUWithSize(targetSize, onComplete):
    if cleaning: onComplete(Error('cleaning in progress')); return
    cleaning = true
    // 1. 收集所有非 internal、非 isDeleting、非"使用中bundle"的缓存项(带 size/lastTime)
    caches = cachedFiles 过滤: 跳过使用中 = isZip(key) && bundles.find(b => val.url.indexOf(b.base) !== -1)
    if totalSize <= targetSize: cleaning=false; onComplete(null, 0)
    // 2. 按 lastTime 升序排序
    // 3. 逐个累计 size 到 >= targetSize, 收集 removedList(跳过 isDeleting)
    // 4. 全部标记 isDeleting → 写盘 → 异步删文件(rmdir/deleteFile) → 删完移除记录
    // 5. 清完算剩余 size, 写盘, cleaning=false, onComplete(null, remainingSize)

问题3:缓存记录与磁盘文件不一致(卡死/泄露)→ 自愈机制(cache-manager.js)

// 核心原则: cacheList.json 只做"参考", 以磁盘文件存在性为准; 删除/解压两阶段持久化

// ---------- (a) 版本绑定, 结构不兼容整体重建 ----------
init():
    result = readJsonSync(cacheFilePath)
    if result 是Error || !result.version || result.version !== cacheManager.version:
        rmdirSync(cacheDir, true)                     // 清空全部
        cachedFiles = new Cache(); makeDirSync(cacheDir)
        writeFileSync(cacheFilePath, 新结构 + version)
    else:
        cachedFiles = new Cache(result.files)
        pendingBundles = result.pendingBundles || {}
    _cleanupPendingBundles()        // 见 (d)
    _cleanupDeletingItems()         // 见 (c)

// ---------- (b) 记录存在但文件丢失(现象1) → 命中自愈 ----------
handleZip(url, options, onComplete):
    cached = cachedFiles.get(url)
    if cached:
        exists(cached.url, (existence) ->
            if existence: updateLastTime(url); onComplete(null, cached.url)
            else:
                cachedFiles.remove(url)      // 已确认不存在, 同步删记录(不走异步流程)
                writeCacheFile()
                onComplete(Error('Cached bundle missing'))   // 触发上层重下载

download(url, func, options, onComplete):
    result = transformUrl(url, options)
    if result.inLocal && result.url.startsWith(cacheDir):
        func(result.url, options, (err, data) ->
            err ? handleCacheReadError(url, options, onComplete, err) : onComplete(null, data))
    else if result.inCache:
        updateLastTime(url)
        func(result.url, options, (err, data) ->
            err ? handleCacheReadError(url, options, onComplete, err) : onComplete(null, data))

handleCacheReadError(url, options, onComplete, err):
    if !isNotFoundError(err):      // 仅"文件不存在"(no such file/ENOENT/not found)才清理, 权限/磁盘满不误删
        onComplete(err); return
    bundleRoot = options.__cacheBundleRoot__
    if bundleRoot:
        收集该bundle下所有记录(key.url 前缀匹配 cacheDir+bundleRoot+'/'), 记录 isZip 存在性
        cachedFiles.remove(收集的keys)
        if hasZipRecord: rmdirSync(cacheDir + bundleRoot, true)   // 删整个 bundle 目录
    else:
        cachedFiles.remove(url)    // 或按 val.url===url 反查删除
    writeCacheFile()
    onComplete(err)

// ---------- (c) 删除改两阶段 + 崩溃自愈 ----------
removeCache(url):
    if !cachedFiles.has(url): return
    record = cachedFiles.get(url)
    if record.isDeleting: return
    record.isDeleting = true            // 阶段1: 标记
    writeCacheFile(() ->                //   写盘(持久化标记)
        _deleteCacheFile(url, () -> 删除计数)   // 阶段2: 删文件
    )

_deleteCacheFile(url, onDone):
    record = cachedFiles.get(url)
    if !record || !record.isDeleting:   // 被访问清除了标记 → 跳过删除, 保全缓存
        onDone(); return
    path = record.url; isZip = isZipFile(url)
    (isZip ? rmdir(path, true, cb) : deleteFile(path, cb))(() ->
        cachedFiles.remove(url)         // 删完文件才移除记录
        writeCacheFile()
        onDone()
    )

updateLastTime(url) / getCache(url):   // 访问时取消删除意图
    if cache.isDeleting: cache.isDeleting = false; writeCacheFile()

_cleanupDeletingItems():                // 启动时补完上次删除
    toRemove = cachedFiles 中所有 isDeleting 的项
    cachedFiles.remove(全部 toRemove)   // 先移除记录
    逐个 exists(item.url): 存在则 rmdir/deleteFile, 不存在则不管; 完成后 writeCacheFile

clearLRU():
    // 选 oldest 1/3, 全部先标记 isDeleting → 写盘 → 每隔 deleteInterval 逐个 _deleteCacheFile

// ---------- (d) 解压中崩溃 → pendingBundles 自愈 ----------
_cleanupPendingBundles():               // init 时调用
    for id in pendingBundles:
        bundleRoot = pendingBundles[id]
        删除该 bundle 下所有缓存记录(url 前缀匹配 cacheDir+bundleRoot)
        rmdirSync(cacheDir + '/' + bundleRoot, true)   // 含残留不完整文件
    pendingBundles = {}; writeCacheFile()

// ---------- (e) 文件存在但记录丢失(现象2) → 扫描回收游离文件 ----------
clearUnreferenced(onComplete):
    // 1. 收集引用: cachedFiles 中 url 位于 cacheDir 下的相对路径; zip 解压目录子文件全部视为被引用
    // 2. stat(cacheDir, { recursive: true }) 扫描全部条目(兼容微信 item.stats / 抖音 item.stat)
    // 3. 过滤: 跳过 cacheList.json / .DS_Store / 被引用路径 / zip 目录内文件
    // 4. 目录单独判断: 含被引用子文件则保留
    // 5. 先删孤儿文件再删孤儿目录(目录深到浅), 全完成 onComplete
    // 触发时机: outOfStorage 触发 clearLRU 之后自动调用; 也可业务主动调用

// ---------- (f) 减少记录丢失 ----------
onHide: 强制 _write()(立即落盘)        // 防抖写盘改切后台前强制
缓存文件名: `${time}_${suffix++}${ext}`  // 防时间戳碰撞
cacheFile失败: 永久性错误(404/文件不存在/ENOTFOUND)跳过重试, 不留脏记录

问题4:bundle 释放后资源 URL 拼成 undefined/... → 快照重拼(AssetManager.js)

// ---------- 第一步: bundle 加载成功时记录 base + uuid 快照 ----------
recordBundleInfo(bundleName, data):
    if !data.uuids: return
    bundleBaseMap[bundleName] = { base, importBase, nativeBase }   // 记录 base 快照
    versions 解析为 importVers/nativeVers
    for uuid in data.uuids:
        uuidInfoMap[decodeUuid(uuid)] = { bundle: bundleName, ver, nativeVer }  // uuid → 所属bundle

// downloadBundle 的三个成功出口都调用 recordBundleInfo(bundleName, data)
// 普通本地 bundle / 远程 bundle / 子包 config 加载成功处各加一行

// ---------- 第二步: download() 检测到 undefined/ 开头 URL 时重拼 ----------
download(url, func, options, onComplete, retryCount=0):
    result = transformUrl(url, options)

    if result.inLocal && result.url.startsWith('undefined/') && !window.__ASSET_REBUILD_DISABLE__:
        uuid = 从 result.url 提取
        isJson = /\.jsonc?$/i.test(result.url)
        rebuild = null
        // 1) 优先: bundle 已注册 → 用它的 config 重拼
        ownerBundle = bundles.find(b => b.getAssetInfo(uuid))
        if ownerBundle:
            info = ownerBundle.getAssetInfo(uuid)
            rebuild = { base: ownerBundle.base, importBase, nativeBase, ver: isJson ? info.ver : info.nativeVer }
        // 2) bundle 已释放 → 用 uuid 快照重拼
        else if uuidInfoMap[uuid]:
            snap = uuidInfoMap[uuid]; bbase = bundleBaseMap[snap.bundle]
            if bbase: rebuild = { base: bbase.base, importBase, nativeBase, ver: isJson ? snap.ver : snap.nativeVer }
        // 3) 都没有 → 短时重试(500ms × 6 ≈ 3s), 等 bundle 注册; 超时 fallthrough 原逻辑
        if !rebuild:
            if retryCount < 6: setTimeout(() => download(url, func, options, onComplete, retryCount+1), 500); return
            onComplete(Error('无法定位资源所属bundle'))   // 附 uuid 快照/已注册bundles日志

        // 重拼: base + (importBase|nativeBase) + /xx/uuid + (.ver) + ext
        url = rebuild.base + prefix + '/' + uuid.slice(0,2) + '/' + uuid + (ver ? '.' + ver : '') + ext
        result = transformUrl(url, options)
        if result.url 仍是 undefined/: result = { url, inLocal: true, inCache: false }

    // 继续正常 inLocal / inCache 流程(见问题3 download 伪代码)
    ...

1有些坑 只能说是你自己主动去踩的 ,比如使用ZIP方式,小游戏根本就不需要ZIP,ZIP对小游戏没有太多益处 去掉ZIP能少一大半问题
2关于缓存记录不一致的情况 感觉你并不是对每条缓存文件本身入手 而是每次从整体上扫描整个缓存区 来处理冗余?
有点夸张,上百兆缓存区 可能十几万个散碎文件 你看看什么效率
3如果上面的2成立 那么就容易出现动不动就重建缓冲区的结果,这不是个好的方式,因为你没有正面去解决这个主要问题导致的

1.zip包好处是碎文件多时加载速度快, 一个zip bundle在cachedFiles中只有2条记录,写缓存快清缓存也快, 用不用zip包看取舍了
2.缓存记录问题有多层方案兜底包括单个文件, 当性能最低的方案失效时才使用大招
其中最大大招是: 清除缓存目录中所有未在 cachedFiles 中记录的文件和目录, 正常不会触发这流程
3. 没有全局重建缓存记录这种做法, 只有使用过程中发现缓存文件不存在才更新单条记录