
前阵子版本迭代我们在微信开发者工具里测得好好的提审也过了结果线上发出去第二天用户反馈群直接炸了。有人说加载老半天一直白屏有人说控制台刷一堆加载失败还有人说UI贴图全乱了。后台监控一看资源加载失败率从0.2%飙升到接近15%。第一反应是服务器挂了或者CDN回源出了问题查了半天才发现真正的元凶是新代码加载了旧缓存。这个问题在做Unity转微信小游戏的项目里太典型了。Unity工程的资源包放进微信小游戏环境远程加载的资源一旦被缓存版本迭代时如果不做特殊处理老用户看到的就永远是上一版本的资源轻则报错白屏重则存档错乱、功能不可用。今天就把这套缓存过期的完整方案梳理一遍从根因到落地再到排错清单一次说清楚。1. 发版事故复盘新代码为什么会撞上旧缓存1.1 事故现场的真实表现那次事故的时间线是这样的新版本在开发者工具里测试完全正常真机预览也没问题提审通过后全量发布。第二天开始线上陆续出现资源加载失败、AB包解析报错、部分界面显示异常的情况。典型的报错包括Unity侧报Failed to load AssetBundle后面跟着一串资源路径小游戏控制台出现download fail或file not found部分用户UI错乱同一份配置新老字段混用定位时发现一个关键特征发生问题的全是老用户新用户几乎不受影响。这就把方向直接引向了缓存——老用户本地有上一版本的资源缓存新用户没有历史包袱。1.2 先理清Unity转小游戏的资源加载链路要理解这个问题得先知道Unity转微信小游戏之后资源是怎么流动的。整个链路大致是Unity工程通过WebGL格式导出再用微信的转换工具minigame适配层转成小游戏工程代码逻辑编译成WASM运行在小游戏环境里这部分体积可控能进入小游戏代码包但美术资源、AssetBundle、配置表这些动辄几十上百MB的东西放不进代码包必须传到CDN运行时通过URL远程加载小游戏运行环境拿到远程资源后会按URL在本地文件系统做缓存所以同一个游戏实际分成代码包和远程资源两个层面。代码包由微信平台负责更新用户启动小游戏时平台会检查版本。远程资源则是纯业务侧控制用URL去拉环境缓存了就不再重新下载。1.3 三层缓存叠加到底卡在哪一层排障时发现远程资源在小游戏环境里要过三道缓存微信运行时的本地文件缓存按URL把下载过的文件存到wxfile://用户数据目录下Unity加载器/适配层的缓存适配工具有时会做二次缓存避免重复启动下载HTTP层/CDN的缓存CDN节点会根据Cache-Control等响应头决定是否直接返回给客户端三道缓存叠加后只要资源URL不变客户端就有大概率拿到旧文件。第一次遇到这个问题的团队基本都会在CDN缓存头那边绕很久但实际最常踩的还是第一层——本地文件缓存。2. 根因拆解URL不变缓存就一定会命中2.1 代码包更新了不代表资源包也同步微信小游戏的代码包有严格的大小限制Unity转换工具会把运行内核和启动逻辑打进代码包但AssetBundle和首包资源几乎都走远程。代码包可以通过微信审核后自动下发远程资源却要靠业务侧自己控制。这里存在一个时间差代码包可能已经更新了而远程资源的URL还指向旧版本。如果资源格式变了、依赖关系变了、或者新代码和新资源不兼容就会出现新代码配旧资源的情况。我们当时就是改了资源配置结构旧资源的解析方式和新代码对不上UI直接乱掉。2.2 客户端判断缓存失效的方式只有三种HTTP或者说资源加载体系里判断资源是否需要重新下载本质上只有三条路过期时间Cache-Control的max-age决定多久后过期过期后被强制重新验证条件请求通过ETag或Last-Modified问服务器我这个资源还新鲜吗服务器回304就继续用本地副本URL变化URL变了等于一个全新资源必然重新下载前两条都依赖服务器配合而且小游戏环境和CDN对缓存头的支持并不完全统一。最可控的是第三条——让URL本身跟随版本变化。想通这一点方案的轮廓就出来了。2.3 缓存方案的核心矛盾性能与更新的博弈我们希望用户第二次进游戏时秒开所以巴不得资源全缓存但版本更新时又巴不得资源立刻全部换新。鱼和熊掌不可兼得的关键在于让内容没变的资源继续用缓存让内容变了的资源强制重新下载。这就需要一种机制把内容是否变化这个信息编码到URL里。内容没变URL稳定内容变了URL跟着变。有了这个基础缓存头设成一年都没问题因为变了的资源已经是新的URL了。3. 主方案版本化URL 资源清单双重校验3.1 第一招资源目录按版本号隔离最简单粗暴且有效的做法是CDN上的资源目录直接按版本号分目录。比如https://cdn.example.com/game/release/v1.2.0/ ├── bundles/ │ ├── start.unity3d │ ├── gameplay.unity3d │ └── ui.unity3d └── config/ └── version.json版本迭代时新版本资源全量上传到v1.3.0目录。客户端代码里配置的远程根路径指向v1.3.0/URL整体变化旧缓存自然失效。这个方案的优势是逻辑清晰出问题好排查劣势是每次发版所有资源都要重新下载哪怕只改了一个数字。对于首包体积大、版本更新频繁的项目下载量会偏高。3.2 第二招入口清单文件强制不缓存纯目录隔离有个隐患客户端怎么知道该指向哪个版本目录如果根路径是写死在代码里的那每次发版都要改代码包这显然不合理。解决方式是引入一个入口清单文件。version.json放在一个固定地址内容标记当前资源版本和资源根路径{ version: 1.3.0, resRoot: https://cdn.example.com/game/release/v1.3.0/, bundles: { start: start.unity3d, gameplay: gameplay_v2.unity3d } }客户端启动时先拉这个version.json再根据里面的resRoot去拼资源URL。这样代码包可以保持稳定资源版本信息全在远程配置里哪天想切版本改一下配置文件就行。注意这个清单文件本身绝对不能缓存。服务器要配Cache-Control: no-cache, no-store, must-revalidate。它一旦被缓存后面所有的更新逻辑都失效。3.3 第三招资源文件名附带内容指纹版本目录方案虽然有效但全部重新下载的代价还是有点大。更好的做法是对单个文件做内容指纹也就是给文件名加hash。还是用C#生成AssetBundle时顺手给每个文件算个哈希值public static string ComputeHash(string filePath) { using (var fs File.OpenRead(filePath)) using (var sha1 SHA1.Create()) { var hash sha1.ComputeHash(fs); var sb new StringBuilder(); foreach (var b in hash) sb.Append(b.ToString(x2)); return sb.ToString().Substring(0, 8); } }打包时把文件名改成ui_b3a7f2c1.unity3d这种带短哈希的形式同时在清单里记录。这样只有内容变化过的文件才会得到新文件名没变过的文件URL不变直接命中本地缓存。更新量从全量下载降为增量下载体验会好很多。3.4 三招组合后的完整流程把上面三招串联起来一次正常的版本迭代是这样跑的构建Unity工程生成带内容指纹的资源文件资源上传CDN的版本目录更新version.json指向新版本目录用户启动小游戏先拉取version.json不缓存走网络拿到当前版本号和本地缓存的版本号比对版本变了按清单重新下载变化后的资源加载新资源进入游戏这个流程把怎么知道要更新和更新哪些文件彻底分开了。清单负责决策指纹负责精准更新。4. 从Unity构建到线上配置的完整落地4.1 Unity侧构建时的版本号和指纹生成实践里我建议把版本号生成和资源打包放进CI流程不要靠人工手改。Unity构建AssetBundle时在构建后处理脚本里做几件事[PostProcessBuild(1)] public static void OnPostprocessBuild(BuildTarget target, string pathToBuiltProject) { var manifest new ResourceManifest { version DateTime.Now.ToString(yyyyMMddHHmm), resRoot $https://cdn.example.com/game/release/{version}/ }; var bundleDir Path.Combine(pathToBuiltProject, bundles); foreach (var file in Directory.GetFiles(bundleDir, *.unity3d)) { var hash ComputeHash(file); var newName Path.GetFileNameWithoutExtension(file) _ hash .unity3d; // 重命名文件并写入清单 manifest.bundles[Path.GetFileNameWithoutExtension(file)] newName; } File.WriteAllText(Path.Combine(pathToBuiltProject, version.json), JsonUtility.ToJson(manifest, true)); }几点心得版本号可以用时间戳但建议和发版号对齐比如1.3.0-20231015方便回溯文件重命名后在version.json里记录的是逻辑名到实际文件名的映射这样代码里始终引用逻辑名加载时查清单得到真实URLMD5够用但文件多时建议SHA1碰撞概率更低4.2 minigame转换插件里的远程路径配置Unity转微信小游戏时转换工具会让你配置远程资源的URL前缀。这里要吃透的细节是代码包里只保留运行逻辑远程根路径尽量指向一个固定的入口地址再通过入口清单跳转。以微信的minigame适配工具为例导出的小游戏工程里通常有一个game.js入口启动时会初始化UnityLoader并传入资源地址。我们要做的是把这个地址拆成两部分CONFIG_URL固定地址指向version.jsonUNITY_STREAMING_ASSETS_URL不直接写死启动后从清单动态拼出来这样做的最大好处是以后切换资源版本或者CDN域名变更只需要改version.json的resRoot连小游戏代码包都不用发。4.3 服务器和CDN的缓存策略配置这个环节很多人直接忽略但实际上至关重要。不同资源的缓存头必须差异化配置资源类型缓存策略说明带内容指纹的静态资源ui_b3a7f2c1.unity3dCache-Control: max-age31536000, immutable文件名变了才算新资源缓存一年毫无压力入口清单version.jsonCache-Control: no-cache, no-store, must-revalidate每次启动都要最新版本绝对不能缓存首包下载的初始化资源Cache-Control: max-age3600短期缓存发版后最多延迟一小时配CDN时还要注意一点有些CDN默认会忽略no-store或者边缘节点缓存时间优先于源站。上线前一定要实测用curl看返回头确认响应头真实生效。4.4 小游戏启动时的版本检查逻辑最后是客户端启动流程。这里给一个简化版的思路// 启动时拉取远程清单 const resp await fetch(CONFIG_URL); const remoteManifest await resp.json(); // 读取本地缓存的版本记录 const localVersion wx.getStorageSync(res_version); // 版本不一致则更新 if (localVersion ! remoteManifest.version) { // 清掉旧版本的本地资源缓存目录 // 或者按清单差异单独下载变更文件 updateLocalResources(remoteManifest); wx.setStorageSync(res_version, remoteManifest.version); } // 初始化Unity资源根路径指向最新版本 initUnity(remoteManifest.resRoot);这里不建议每次启动都清所有缓存那样会损失大量加载性能。更好的做法是按版本号隔离本地缓存目录旧版本目录只在磁盘空间紧张时清理。5. 上线后踩过的坑和排错清单5.1 坑一清单文件被CDN缓存了第一次上这个方案时我们把version.json配了Cache-Control: no-cache但CDN边缘节点仍然缓存了10分钟。结果发版后部分用户拿到的还是旧清单继续下载旧资源。排查后发现CDN控制台里还有个全局缓存规则优先级高于源站响应头。对策CDN里对version.json单独建一条规则缓存时间设为0并加上忽略源站缓存头的配置。配置完老规矩多地区curl验证。5.2 坑二文件内容hash没变但资源实际已经不对了AssetBundle增量构建有个经典问题有时候你改了资源但因为Unity的增量打包策略产出的hash可能和上次相同。我们遇到过改了一个材质球参数结果所有依赖这个材质球的AB包hash都没变线上用户加载的还是旧渲染效果。对策关掉增量构建或者对依赖关系变化的包做强制重打。最稳妥的是在CID流程里每次发版都全量构建一次资源包哪怕耗时多一些也不要赌增量构建的准确性。5.3 坑三开发者工具和真机的缓存行为不一样微信开发者工具里有个清缓存按钮点一下全部清干净测试的时候一切正常。但真机上缓存策略完全不同用户不可能主动去清缓存而且真机环境下小游戏对Cache-Control的处理和开发者工具也有差异。对策测试时不要只看开发者工具。用体验版二维码让测试机反复覆盖安装、反复进入退出模拟老用户升级路径。关键版本发布前后让测试机删除小游戏后重新进入对比两种路径下的资源加载情况。5.4 一份可以直接抄的排错清单如果线上真的出现了疑似缓存问题按这个顺序排查确认代码包版本微信公众平台后台看线上版本号是否已更新抓取本地清单内容在开发者工具里跑一次启动流程看version.json返回的内容是哪个版本对比资源加载URL看Unity加载的资源URL是否带上了新版本号/新指纹清缓存复现开发者工具清全部缓存后再跑一遍问题是否消失真机验证删除小游戏后重新进入问题是否消失服务端日志看CDN访问日志确认新版本资源是否有点击量这里最容易被忽略的是第4、5步。如果清缓存后问题消失基本可以断定是缓存过期策略失效而不是代码bug。6. 后续演进从全量缓存管理到增量更新6.1 按清单做增量下载版本目录方案能解决问题但每次大版本全量下载用户加载时间会明显上升。后续可以优化为客户端把上次的清单存下来和远端清单做diff只下载文件名变更过的资源。这个增量更新的基础就是前面提到的内容指纹——文件名没变的资源本地缓存直接可用。增量逻辑相当于你看错了行。实际落地时要注意处理失败重试和中断续传。资源下载失败应该允许重试不能因为一个文件失败就卡死整个启动流程。建议做一个下载队列带优先级和失败上限超过上限就走降级方案提示用户重启或检查网络。6.2 通过清单做灰度与回滚缓存方案真正上线后你会发现version.json是一个天然的开关。你可以利用它做资源灰度发布先把version.json指向新版本目录但只让部分用户命中比如通过微信登录态里的用户编号取模验证没问题后再全量切到新版本。回滚也更加可控——线上新资源出问题时直接把version.json指回旧版本目录。用户下次启动会发现远端版本号和本地缓存的不一致自动降回旧资源。相比重新发代码包这个回滚速度是按分钟计的。6.3 用数据监控缓存和加载质量方案跑通后建议在客户端埋点统计几个关键指标资源版本号分布线上用户目前停留在哪个资源版本能快速发现有多少用户没拉到新版本资源加载成功率按版本维度统计新版本上线后这个数字飙升就是告警信号首资源加载耗时增量更新优化的效果通过这个指标观察缓存命中率能看出哪些资源长期复用、哪些资源频繁变更我们当时就是靠资源版本号分布这个指标发现发版后整整有20%的用户还停留在旧版本才意识到是缓存策略没生效。有了数据这类问题就不会再靠用户反馈群来被动发现。这个东西做完之后我自己最大的体会是缓存过期策略不能等出问题再补必须在Unity转小游戏工程搭建阶段就设计进去。目录版本化、入口清单、内容指纹这三板斧越早做成本越低等用户量大了再改光灰度验证和兼容老版本就够折腾好几天的。如果你现在正要开始做Unity转微信小游戏或者正在为缓存问题头疼按这个方案一步步来基本能把这块的坑都填平。