上周灰度放量,第一波进入微信小游戏的玩家就开始反映“模型没换、UI还是上一期的活动图”,运营群直接被截图刷屏。我这边Unity项目转微信小游戏已经有段时间,版本迭代本不陌生,但这次算是我第一次在资源缓存上栽了跟头——版本号明明更新了,玩家手里的游戏却还在加载旧资源。后来一路查到CDN缓存、微信本地缓存和Unity加载链路,才把问题彻底看清。这篇就把整个排查和方案落地的过程完整写下来,重点围绕版本迭代时的资源缓存过期处理。正在做Unity转微信小游戏、或者刚接入还在为“更新后不生效”发愁的团队,可以直接参考这套思路。
1. 资源加载链路拆解:Unity资源是怎么到微信小游戏手里的
在谈缓存过期方案之前,必须先想清楚一个前提:Unity转微信小游戏之后,资源加载路径和原本的App/编辑器里完全不同。很多团队第一步就栽在“想当然”上,以为资源加载还是原来的AssetBundle那一套,忽略了HTTP缓存这个外部变量。
1.1 微信小游戏的包体限制决定了资源必须外置
Unity项目打包成微信小游戏后,平台要求的主代码包体积有硬性上限,目前大概是4MB左右,而且这个限制还在动态调整。一个稍微像样点的Unity游戏,光UI图集和几个角色模型就已经远超这个量级,所以绝大多数团队的实际做法是:代码包只放启动必要资源和核心代码,真正的大资源(AssetBundle、图集、音频、预制体)放到自己的CDN或云存储上。
这就是缓存问题的第一个源头。传统Unity单机或者联网游戏,资源从StreamingAssets或者本地缓存读,根本不用考虑HTTP缓存头、浏览器内核和微信小游戏磁盘缓存的叠加效应。转到微信小游戏环境后,资源获取从“读本地文件”变成了“HTTP请求+本地落地缓存+运行时解码”,每一环都可能把旧资源“粘”在玩家设备上。
1.2 资源请求经过的三个缓存层
一个资源URL从CDN到玩家设备,至少会遇到三层缓存:
- CDN边缘节点缓存:如果CDN配置了缓存,某个文件在源站未失效时,边缘节点直接把缓存内容返回,不会回源。
- 微信环境的HTTP缓存:微信小游戏的网络请求走的是微信内核,资源下载之后会被写入本地缓存目录,二次请求如果命中且校验未过期,直接从本地读。
- Unity加载层缓存:Unity侧一旦对某个URL的AssetBundle做过一次加载和解析,在同一生命周期内一般不会再重复请求。
三层缓存叠加,任何一个环节持有旧版本,玩家看到的就是旧资源。我遇到的情况就是第一、二两层同时命中旧的URL内容,版本更新根本没被感知。
1.3 版本迭代中“代码更新但资源不更新”的典型链路
用一个最简单的例子来说明:上一版本资源URL是https://cdn.example.com/assets/hero.bundle,新版本代码包照常更新了,但构建时如果还让资源URL保持一致,玩家端就会发生下面这条链路:
- 新版本代码启动,请求同一个
hero.bundleURL。 - 微信本地缓存里有旧版本下载并缓存的
hero.bundle,HTTP缓存判断没超过有效时间,直接读旧文件。 - CDN边缘节点同样认为URL没有变更,返回旧内容。
- Unity侧加载进来还是上一版的模型和贴图。
从开发者的视角看,版本号明明从1.9.8升到2.0.0,但玩家终端加载的资源还是旧的。这个问题不发包则已,一发包就是大批量反馈。理解这条链路之后,缓存过期的解决方案其实就锁定一个核心目标:让版本更新后的资源请求必须落到完全不同的URL,或者强制缓存回源。
2. 版本更新后各种“新旧资源混装”的真实排查过程
刚才说的是原理,这里讲一下我那次出事之后是怎么一步步定位的。排查过程比想象中曲折,也是很多团队第一次碰到这件事都会绕弯子的地方。
2.1 三个最容易让人误判的现象
第一类:开发工具里一切正常,真机上全是旧的。微信开发者工具默认对网络资源的缓存策略和真机不完全一样,工具里开着“不校验缓存”调试得很爽,真机全按标准HTTP缓存策略执行,所以“工具正常、真机异常”是典型的缓存过期信号。
第二类:同一个玩家,进游戏是旧资源,杀进程重进又变成新的。这是因为第一次启动时新版本代码请求到了CDN上正在回源的新资源,但本地缓存还没命中某个节点;或者反过来,旧资源被缓存后没有刷新机制,重进时二次请求直接用本地旧缓存,表现就会来回变。
第三类:同一条URL,上午访问是旧内容,下午访问是新内容。这种多半是CDN边缘节点正在逐级回源刷新,不同节点缓存不一致导致的。
2.2 排查链路:从请求头到CDN缓存溯源
我建议按这条链路走,能省很多时间:
- 第一步,在微信开发者工具里打开调试面板,筛出所有资源请求,检查返回的
Cache-Control头。如果看到max-age=31536000这类长缓存,大概率是配置问题。 - 第二步,用抓包工具(Charles或Fiddler)在真机上代理,对比“版本更新前”和“版本更新后”同一条URL的实际响应。重点看返回的内容大小、响应头里的
etag和last-modified有没有变化。 - 第三步,去CDN控制台查这个URL的缓存命中情况。如果CDN控制台显示缓存命中并且是旧文件的命中记录,说明URL没变时CDN根本不会去源站拉新资源。
- 第四步,用浏览器的无痕窗口直接访问这个资源URL,看看源站文件本身到底更新了没有。
这四步走完,基本就能把问题钉死在“URL未变导致多层缓存命中旧文件”还是“源站资源根本没上传成功”上面。我之前遇到的是第一种,而且CDN边缘节点缓存的时间很长,就算源站文件已经覆盖成新的了,玩家依然会持续命中旧节点缓存。
2.3 根因并不复杂:URL缺少版本语义
说实话这个世界的根因并不复杂——资源的URL没有包含任何版本信息,导致HTTP缓存机制认为“这个URL没变过,直接用缓存”。所有花里胡哨的排查,最后都落到这一个点。想解决缓存过期,思路只有两条:要么让URL内容级变化(文件名跟着内容走),要么让URL带强制校验参数(让缓存强制回源),要么两者结合。下面三章就是这三条路的具体实现。
3. 文件名Hash方案是怎么在Unity构建流程中落地的
3.1 为什么文件名Hash是最稳妥的缓存失效手段
要理解这个方案好在哪,先看HTTP缓存的核心逻辑:缓存命中与否,很大程度取决于请求URL是否变化。文件名Hash就是让“内容变 → 文件二进制变 → 经过哈希计算后的文件名变 → URL变”形成一个刚性绑定。只要文件内容有任何字节的改动,新的文件名一定是全新URL,旧缓存不可能会命中新URL。
这个方案最爽的地方在于:CDN上可以放心大胆地把带Hash的文件缓存配置成一年甚至更久的immutable长缓存。因为即便源站更新了同名内容,新文件名会生成新URL,不需要CDN去判断,也不需要做任何刷新操作。缓存从“负担”变成了“资产”,玩家二次加载资源的速度还能提升。
3.2 Unity侧构建脚本自动生成Hash文件名
难点在于不能在Unity工程里手工去给每个AssetBundle加Hash,必须由构建脚本在每次出包时自动完成。我在工程里是这么做的(以AssetBundle构建为例):
using System.Collections.Generic; using System.IO; using System.Security.Cryptography; using System.Text; using LitJson; using UnityEditor; using UnityEngine; public class WeChatBundleBuilder { [MenuItem("Tools/微信小游戏/构建带Hash的AssetBundle")] public static void BuildAB() { const string outDir = "AssetBundles/wechat"; if (Directory.Exists(outDir)) Directory.Delete(outDir, true); Directory.CreateDirectory(outDir); // 先按标准的AssetBundle流程构建出来 BuildPipeline.BuildAssetBundles(outDir, BuildAssetBundleOptions.DeterministicAssetBundle, BuildTarget.WebGL); var mapping = new Dictionary<string, string>(); foreach (string file in Directory.GetFiles(outDir)) { if (file.EndsWith(".manifest") || file.EndsWith(".meta")) continue; string hash = ComputeFileHash(file); string dir = Path.GetDirectoryName(file); string oldName = Path.GetFileName(file); // hero.bundle -> hero_3f2a91b7.bundle string newName = $"{Path.GetFileNameWithoutExtension(oldName)}_{hash}{Path.GetExtension(oldName)}"; File.Move(file, Path.Combine(dir, newName)); mapping[oldName] = newName; } // 生成映射关系,运行时根据逻辑名查找真实文件名 var mappingList = new List<BundleMapping>(); foreach (var kv in mapping) { mappingList.Add(new BundleMapping { name = kv.Key, hashName = kv.Value }); } string mapPath = Path.Combine(outDir, "bundle_hash_map.json"); File.WriteAllText(mapPath, JsonMapper.ToJson(mappingList)); AssetDatabase.Refresh(); Debug.Log("AssetBundle build with hash completed, total files: " + mapping.Count); } private static string ComputeFileHash(string path) { using FileStream stream = File.OpenRead(path); byte[] hash = MD5.Create().ComputeHash(stream); var sb = new StringBuilder(); foreach (byte b in hash) sb.Append(b.ToString("x2")); // 取前8位足够,碰撞概率可以忽略 return sb.ToString().Substring(0, 8); } } [System.Serializable] public class BundleMapping { public string name; public string hashName; }这里有两个细节值得单独强调。
第一个是BuildAssetBundleOptions.DeterministicAssetBundle。如果不加这个选项,每次构建即使代码没有改动,AssetBundle的二进制也可能因为构建时间戳之类因素产生差异,导致Hash变化、URL变化,所有资源全量重新下载。加上这个选项后,只有真正改动的资源才会生成新Hash,没改动的资源Hash稳定,玩家二次更新时未变动的bundle还能继续走本地缓存,更新成本会小很多。
第二个细节是Hash映射文件的保存位置。如果你的工程用了Addressables,可以在构建时直接利用它自带的Catalog和Remote Build数据;如果像我一样用原生AssetBundle,一定要把bundle_hash_map.json作为资源清单上传到CDN的固定路径,且这个清单文件的Cache-Control必须设置成no-cache,保证启动时能拿到最新映射。
3.3 CDN配置要点:长缓存只给Hash文件
资源带Hash之后,CDN配置可以按下面的表来:
| 资源类型 | 推荐Cache-Control | 原因 |
|---|---|---|
| *.bundle(Hash命名) | public,max-age=31536000,immutable | 文件名每变一次就是新URL,永远不会命中旧内容 |
| 贴图/音频/预制体等Hash资源 | public,max-age=31536000,immutable | 同上 |
| bundle_hash_map.json | no-cache, must-revalidate | 启动时必须拿到最新映射表 |
| version.json | no-store 或 max-age=0 | 版本校验用,必须每次回源 |
有个很容易踩的坑:有些团队把Hash文件名搞对了,但CDN上同时保留了旧的缓存时间策略,比如给所有静态文件统一配了max-age=600。这样做问题不大,但如果CDN节点认为缓存已过期,它会回源时用自身策略重新校验,期间如果源站更新了同名文件,会有一段时间的混乱。既然文件名已经带Hash了,就大胆地把缓存时间放到最长档位,反而最省心。
3.4 这个方案解决不了的问题
文件名Hash解决的是“URL变化问题”,但它管不到两件很重要的事。
第一,已经下载到玩家微信本地磁盘的旧文件不会自动删除。文件名Hash化之后,旧文件确实是不会再被加载了,但它还在微信的本地缓存目录里持续占用空间。这个问题短期内不致命,但长期迭代到某个大版本时,玩家本地缓存会堆到微信给的限额,后续下载新资源会因空间不足失败,到时候又是一个新的坑。
第二,如果你没有在CDN侧做配套清理,每个版本的Hash文件会在CDN上越堆越多。常规做法是保留最近两三个版本的资源,更老的版本可以定期从源站和CDN清理,避免存储成本失控。
对于第一点,就需要用到后面第5章的本地缓存清理方案来补。
4. 版本目录 + 查询参数这套兜底策略为什么能救急
4.1 查询参数方案的局限必须先认清
很多人第一反应是在资源URL后面拼一个版本号:hero.bundle?v=2.1.0。这个方案原理上说得通,因为URL中的query是缓存key的一部分,加上v参数后,旧缓存的key是?v=,新请求是?v=2.1.0,理论上不会命中旧缓存。但实际落地有两个明显问题:
- 有些CDN边缘节点对不带校验的静态资源会忽略query参数,或者只按路径部分做缓存key,导致
?v=2.1.0和?v=2.0.0对它来说是同一个文件,直接命中旧缓存。 - 版本号是“人肉维护”的,只要有一次发版忘记改配置,整个方案就形同虚设。靠纪律不能解决工程问题。
所以我的结论是:查询参数可以作为兜底,但不能作为唯一依赖。
4.2 版本目录:把版本信息写进资源路径
比查询参数更稳的是把版本号写进路径目录。每个大版本发布的资源统一放到以版本号命名的目录下:
https://cdn.example.com/game/assets/2.1.0/hero_3f2a91b7.bundle https://cdn.example.com/game/assets/2.1.0/ui_common_a1b2c3d4.bundle版本目录的优点是:同一个版本内的资源URL天然唯一,且CDN缓存策略可以和上一章的文件名Hash方案完美结合——路径区分版本,文件名区分内容。版本升级时,只需要把加载基址从2.0.0切到2.1.0,新版本所有资源都会去全新的目录下请求,根本不可能命中旧缓存。
4.3 版本配置文件的加载与校验机制
要让版本目录自动切换,工程里需要一个“版本配置入口”。我用的是一份固定在CDN根目录的version.json文件,这个文件的URL从不变化,但它的内容每次发布都会更新:
{ "version": "2.1.0", "assetsBaseUrl": "https://cdn.example.com/game/assets/2.1.0/", "publishTime": 1710000000 }小游戏启动时,先请求这份配置文件(它本身被CDN设置为no-cache,每次必须回源),拿到assetsBaseUrl,之后所有资源加载都通过这个基址拼接。如果网络异常拿不到这份配置,就使用本地缓存里的上一版本基址继续启动,保证老玩家离线也能进游戏。
整个过程和代码长这样:
public class VersionConfigManager : MonoBehaviour { private const string VersionConfigUrl = "https://cdn.example.com/game/version.json"; private const string LocalVersionKey = "local_assets_version"; private const string LocalBaseUrlKey = "local_assets_base_url"; public static string AssetsBaseUrl { get; private set; } = "https://cdn.example.com/game/assets/0.0.0/"; public IEnumerator CheckVersion(System.Action<string> onLoaded) { string localVersion = PlayerPrefs.GetString(LocalVersionKey, "0.0.0"); string localBaseUrl = PlayerPrefs.GetString(LocalBaseUrlKey, AssetsBaseUrl); using (UnityWebRequest request = UnityWebRequest.Get(VersionConfigUrl)) { yield return request.SendWebRequest(); if (request.result != UnityWebRequest.Result.Success) { // 网络异常时沿用本地版本,至少保证可玩 AssetsBaseUrl = localBaseUrl; onLoaded?.Invoke(localVersion); yield break; } VersionInfo remote = JsonUtility.FromJson<VersionInfo>(request.downloadHandler.text); if (remote.version != localVersion) { AssetsBaseUrl = remote.assetsBaseUrl; PlayerPrefs.SetString(LocalVersionKey, remote.version); PlayerPrefs.SetString(LocalBaseUrlKey, remote.assetsBaseUrl); PlayerPrefs.Save(); // 版本切换,通知其他模块做本地缓存清理 CacheCleaner.NotifyVersionChanged(localVersion, remote.version); } else { AssetsBaseUrl = localBaseUrl; } onLoaded?.Invoke(remote.version); } } } [System.Serializable] public class VersionInfo { public string version; public string assetsBaseUrl; public long publishTime; }这里我故意让“配置失败时用本地版本继续启动”,是为了避免把版本校验做成启动硬门槛,否则玩家在弱网环境下会一直卡在启动加载页,体验会很差。缓存过期要治,但不能以牺牲可玩性为代价。
4.4 版本目录 + 文件Hash的双保险是怎么配合的
这两个方案组合起来非常清晰:
- 版本目录(
/assets/2.1.0/)负责“整版本切换”,让新版本的请求绝对不落到旧目录。 - 文件名Hash(
hero_3f2a91b7.bundle)负责“内容级对比”,同一版本内如果某个资源发生过热更(比如只改了一个贴图),文件名变化让那个单独的资源也能绕过缓存。
在实际发布流程里,我会把“版本目录切换”和“文件名Hash”绑定成一次原子动作:资源构建完成后,把新版本目录上传到CDN,然后更新version.json中的assetsBaseUrl。两步之间有短暂的时间差,旧版本玩家会自动把请求打到旧目录上,不会有任何冲突,等新配置生效后,他们下一次启动才会切到新目录。
提示:切换目录的瞬间,新目录下的资源是“冷”的,第一次有玩家访问才会回源。如果是高并发的大版本更新,第一批涌进来的玩家会让源站瞬间出现回源高峰。这个细节在第6章展开讲。
5. 微信侧本地缓存清理与启动版本校验的最终闭环
5.1 微信小游戏本地缓存的生命周期
前面两章解决了“新资源不会被旧缓存污染”,但没有解决“旧资源一直占着本地磁盘”的问题。微信小游戏会把远程请求的资源缓存在本地目录,这个目录的空间不是无限大的,平台对每个小游戏的本地缓存总体积有隐性限制。到临近限制时,新的下载请求可能失败,游戏就会表现成“资源加载到一半卡住”或者“大面积资源丢失”。
这是版本迭代到中后场才会暴露的问题:新版本上了,旧版本一堆带Hash的文件留在本地,每个哪怕只有几百KB,迭代十来个版本也能堆出上百MB的垃圾。所以必须有一套按版本清理的机制。
5.2 可控的清理方案:宁可漏删,不要误删
很多人会想:版本变了直接wx.clearStorage()清空存储,爽快利落。但这不是一个安全动作,因为微信小游戏的Storage里不只有资源缓存,还可能有玩家的本地存档、账号信息、设置项。我在早期就这么干过,结果版本一更新,玩家存档被清了,客服直接被用户骂到自闭。
正确的清理姿势是按前缀或按自己维护的key列表精准删除。资源缓存相关的key统一加上前缀,例如cache_asset_,清理时只删前缀匹配的key:
function clearOldAssetCache(remoteVersion) { const localVersion = wx.getStorageSync('local_assets_version'); if (!localVersion || localVersion === remoteVersion) return; // 只清理资产缓存前缀,不与玩家数据key冲突 const info = wx.getStorageInfoSync(); info.keys.forEach(key => { if (key.indexOf('cache_asset_') === 0) { wx.removeStorageSync(key); } }); wx.setStorageSync('local_assets_version', remoteVersion); }具体资源文件本身不一定要全部从微信本地缓存里剔出去,因为微信运行时对磁盘缓存的淘汰有自己的LRU策略,旧文件在占用到一定阈值后会被它自动清理。我们开发者能主动控制的是Storage中的数据,所以上面的清理脚本主要是清掉那些我们用Storage保存的资源索引和元数据。想要主动释放磁盘空间,最终还得靠文件Hash变化后不再加载旧文件,让微信的淘汰机制慢慢把它们回收。
5.3 版本校验和缓存清理的启动流程设计
结合第4章的version.json,我把启动流程设计成下面这条链路,跑起来是比较稳的:
- 第一步:读本地缓存的
local_assets_version和local_assets_base_url,作为兜底配置。 - 第二步:请求远程
version.json,拿到最新version和assetsBaseUrl。 - 第三步:如果远程版本号和本地版本号不一致,写入新的版本号和基址,并触发一次前缀清理。
- 第四步:启动资源管理器,用新的
assetsBaseUrl拼接所有需要加载的资源URL。 - 第五步:启动加载页,在加载过程中预取首屏需要的 AssetBundle,观察是否有404或缓存异常。
这五步看着简单,但每一步都有容易忽略的细节。第三步里的清理动作,一定要放在“资源管理器还没有加载任何远程资源”之前,否则一边清一边写,容易把正在使用的资源索引删掉。第五步说的预取,最好把首屏一个房间或一个主城要用的资源放到一个startup_bundles.txt清单里,按清单顺序预下载,这些细节做扎实,体验才算真的稳。
5.4 小心“双套本地存储”的坑
Unity转微信小游戏之后,游戏逻辑可能同时有两套存储通道:一套是Unity侧的PlayerPrefs(适配层会映射到微信的Storage),另一套是纯JS侧通过wx.setStorage写入的数据。如果你在C#代码里用PlayerPrefs写版本号,又在JS侧用wx.getStorageInfo去清理,极容易漏掉另一侧的数据。
我的建议是:全工程统一只使用C#侧的PlayerPrefs来管理版本信息和缓存索引。清理动作要么全在C#层写,要么在C#层维护好所有需要清理的key清单,把清单交给JS侧执行。两边各管各的,最后必然出现“版本号明明切换了,本地还在读旧索引”的诡异问题。
6. 灰度发布、回滚兼容与CDN预热的进阶经验
前面讲的缓存过期方案已经能覆盖日常版本迭代了,但真正上线的时候还会碰到灰度、回滚这些让人头疼的工况。这里把我在实际发布中沉淀的几个关键点一并说掉。
6.1 大版本发布前一定要做CDN预热和刷新
版本目录切换的瞬间,新目录里的静态资源在CDN边缘节点上都是“冷”的。如果发布流量一大,第一批用户访问新资源时,所有请求都会同时穿透到源站,源站带宽被瞬间打满的例子我见过不止一次。解决方案是在发布之前,对本次版本的所有资源做一次“CDN预热”,把主资源目录URL加入预热任务,让CDN主动回源并缓存好这些文件。
同时,如果这次发布修改了version.json或bundle_hash_map.json这类固定路径文件,还要在CDN上对这些URL做“主动刷新”,确保边缘节点立即失效旧文件。这两件事虽然都要在CDN控制台操作,但建议在构建脚本里把它们串进发布流水线,避免人肉操作遗忘。
6.2 灰度发布时的资源版本匹配问题
微信小游戏本身支持通过后台设置体验版、按用户比例灰度等发布能力。这里有一个容易忽略的匹配问题:你灰度放量的是代码包版本,但如果资源URL还指向上一个版本的CDN目录,灰度的玩家就会用旧资源跑新代码,表现出的问题五花八门。
所以每次初始化代码的时候,我建议把“代码版本号”和“资源版本号”放进同一次的发布配置里,二者必须是一一对应的。比如在小游戏的game.json或自定义启动配置里明确记录当前代码包对应的资源版本,避免出现代码是2.1.0、资源目录还是2.0.0的错位。
6.3 回滚场景下的缓存回溯:旧资源目录不能删太快
上线后如果发现新版本有严重问题,需要回滚到上一个稳定版本。很多团队习惯在发布新版本后立即清理旧版本的CDN资源目录,觉得省空间。这个做法在回滚场景下是个大坑:一旦新版本发布触发了玩家端缓存清理和版本切换,旧版本资源如果已经从CDN上删了,回滚后玩家请求旧资源会直接404。
我的习惯是:至少保留最近两个版本的CDN资源目录,第三个版本发布时再清理第一个版本。这样无论从2.1.0回滚到2.0.0,还是从2.2.0回滚到2.1.0,旧资源都还在。成本其实不高,AssetBundle资源大多几十上百MB级别,按2-3个版本量保留,存储成本完全可控。
6.4 上线后的监控比方案本身更重要
缓存过期方案做得再好,上线后也要有手段去验证和监控。我这边会盯三个指标:
- 资源请求的404比例:如果版本配置和CDN内容对不上,第一个信号就是大量404。
- 版本配置接口的响应时间:
version.json和bundle_hash_map.json每次启动都要请求,它们放在CDN上的响应速度直接影响启动耗时。 - 旧资源目录的访问量:如果版本切换后还有大量请求打到旧目录,说明某个客户端还在用旧基址,需要立刻排查是不是版本校验逻辑有漏。可以用日志或CDN访问分析来观察。
这三个指标配合报警,基本能在版本切换出问题的最初几分钟内发现问题。缓存过期这种事,拖得越久玩家反馈越剧烈,越早发现越好收场。
从那次大规模“旧资源不刷新”的翻车到现在,我把这套组合方案用在了后续多个版本的发布上:文件名Hash负责内容级失效,版本目录负责整版本隔离,本地缓存清理负责回收空间,三者配合下来基本没有再出过资源过期的事故。唯一想跟同行说的是,这种问题越到后面越靠流程保障,而不是靠某个人“记得改版本号”。把版本目录、资源Hash和CDN刷新动作全部串进发布流水线,养成习惯之后,版本迭代这件事才会真正变得让人放心。