做Unity客户端的人,迟早会被AssetBundle热更新按在地上摩擦。尤其是当项目上了CDN、接了本地缓存之后,你会发现“服务器上文件是对的”和“玩家手机上跑的是对的”这两件事,中间隔着一条长长的黑暗链路:CDN的缓存策略、Unity的Caching逻辑、本地磁盘的残留文件,任何一个环节出问题,玩家看到的都是同一个诡异现象——资源错乱,或者永远停留在旧版本。这篇博文就围绕Unity AssetBundle热更新这条链路,把从CDN清单下发到本地缓存命中的安全排查思路完整梳理一遍,包括我实际踩过的坑和修复方案。适合正在做Unity热更新、被资源更新问题折磨的客户端程序,也适合刚接手热更模块、想搞懂底层原理的初学者。
1. 先搞清楚AssetBundle热更新链路上有哪些环节
1.1 一条热更新请求从玩家设备到CDN经历了什么
热更新最容易被忽略的一点,是它并不是“客户端想下哪个文件就下哪个文件”这样简单。真正流程是这样:
- 客户端启动后先读取本地持久化的版本号,向服务端接口发起一次版本对比请求。
- 服务端返回全局配置,里面包含当前热更版本号、CDN根地址、AssetBundle清单的hash值。
- 客户端根据配置里的清单hash,去CDN下载对应的AssetBundleManifest文件。
- 客户端拿着本地已有AB包的清单信息和服务器清单做diff,得到“需要新增、需要更新、可以删除”三组文件列表。
- 对需要下载的AB包,依次向CDN发起下载请求。
- 下载完成后,校验文件hash,写入本地缓存目录,再通过AssetBundle.LoadFromFile或AssetBundle.LoadFromMemory加载。
这条链路放到真实网络环境里,就会有层层缓存参与:CDN边缘节点可能缓存了旧清单、旧AB包;客户端系统网络栈可能对HTTP响应做了本地缓存;Unity的Caching系统可能认为某个版本已经缓存过就直接复用;你自己写的持久化目录里还可能留着上一版本的残留文件。这些环节里,任何一个中间层的数据“旧了”或“错了”,最终呈现给玩家的就是资源加载异常。
我习惯把这条链路用“点外卖”来类比:回源服务器是厨房,CDN是外卖平台,玩家设备是餐桌,清单文件是菜单,AB包是菜品。如果外卖平台的菜单是旧的,你照着点了一份菜,厨房做的是新菜,但平台送过来的订单详情写错了,你吃到的就可能不是你要的那盘。AssetBundle热更新里的“安全”指的就是:菜单(清单)必须可信、菜品(AB包)必须完整、餐桌上的盘子(本地缓存)必须干净。
1.2 安全排查的三个主线:清单、传输、缓存
做安全排查不能东一榔头西一棒子。我把整条链路简化成三条主线,凡是出现热更新资源异常,先从这三条线上分别找原因:
| 主线 | 核心问题 | 常见风险 | 排查手段 |
|---|---|---|---|
| 清单 | 客户端信任的“资源目录”是否权威 | 清单被篡改、CDN缓存旧清单、版本号回退 | 签名校验、版本号单调递增、抓包对比返回内容 |
| 传输 | 文件从服务器到设备过程中是否完整 | 下载中断、CDN回源脏数据、中间人替换、响应头缓存策略错误 | hash校验、HTTPS、带版本号URL、抓包看响应头 |
| 缓存 | 本地存储的资源是否与清单一致 | 旧版本残留、Unity Caching误判、临时文件占用、缓存损坏 | 自建缓存目录加版本号、启动时清理、写缓存前后校验 |
这三条主线分别对应热更新链路的前、中、后三端。很多诡异问题其实都不是单一环节故障,而是两个环节叠加导致的。比如CDN缓存了旧清单,同时本地缓存里残留了新版本的AB包,那么客户端会认为“我本地没有这个AB包”,但实际上磁盘上有个一摸一样名字的文件,只是清单里不存在——最后就会导致缓存目录空间膨胀,或者资源重复下载。
所以做安全排查,我强烈建议先跟我一样,在脑子里把这条链路拆成三段,再逐个击破。
2. CDN清单篇:版本清单与资源清单的校验细节
2.1 清单文件长什么样,为什么它是热更新的“总开关”
先说服务端返回的全局配置。一个典型的热更配置长这样:
{ "version": 42, "cdnRoot": "https://cdn.example.com/game/", "manifestHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "minClientVersion": 38 }version是当前热更新版本号,客户端会把它持久化到本地。cdnRoot是所有AB包的下载根地址。manifestHash是AssetBundleManifest文件的SHA256,客户端先下载这个manifest文件,再对文件内容做hash校验,一致才继续走后续流程。minClientVersion用于强制老包升级,防止玩家停留在旧客户端上频繁请求已经被废弃的资源。
AssetBundleManifest是Unity在Build AssetBundle时自动生成的清单文件,它记录了每个AB包的关键信息:
- 每个AB包的文件名和hash值。
- 每个AB包依赖哪些其他AB包。
- 每个AB包是否带变体(variant)。
其中“依赖”是最容易出问题的。比如你加载一个UI界面的AB包,它内部依赖了公共图集AB包,如果依赖包没有先加载成功,界面里的图片、字体就全部显示异常。所以清单不只是“文件列表”,它还是加载依赖关系的唯一依据。
为什么我把它叫“总开关”?因为客户端所有关于“资源应该是什么样”的判断,全部源自这份清单。清单一旦被改,客户端就会认为某个不存在的AB包是必须的,或者认为某个有问题的AB包hash是正确的。后面所有工作,包括CDN下载、本地缓存校验,都是在为这份清单服务。总开关失守,整条链路上的安全机制都会失效。
2.2 清单篡改的常见手法与校验策略
我见过的清单被“篡改”并不都是黑客攻击,很多时候是运维事故和CDN缓存策略造成的。但不管是哪种原因,客户端都应该具备识别能力。
常见场景分三类:
- CDN缓存污染:边缘节点缓存了旧版本的清单文件,客户端请求到的是上个版本的内容。
- 中间人注入:网络链路中的某个节点直接替换了返回的JSON或manifest文件。
- 本地篡改:玩家设备被root/越狱后,直接修改应用沙盒里的清单缓存文件,用来绕过更新检查或修改资源内容。
针对这些问题,我的校验策略是这样做的:
第一,全局配置和清单文件都做签名校验。最简单的是HMAC-SHA256,客户端内置一把共享密钥,服务端对配置内容做签名,客户端验签。稍微复杂一点但更安全的是RSA/ECDSA非对称签名,私钥只放在打包服务器上。对大多数游戏项目,HMAC已经够用,但要注意密钥不能写死在容易被提取的C#代码里,最好做一层混淆或者放到原生插件中。
第二,用“版本号单调递增”来防止回退攻击。客户端记录当前已接受的最大版本号,如果服务端返回的版本号比本地还小,直接拒绝并上报。这个逻辑同样适用于清单文件本身,manifest文件的URL里带上版本号或hash,天然杜绝被缓存旧文件。
第三,下载完成后必须做hash校验。Unity的AssetBundleManifest中已经包含每个AB包的hash值,但客户端自己写一层校验仍然有必要,因为很多问题的表现就是“文件存在但内容错误”。
校验逻辑可以参考这段代码:
private bool VerifyManifest(byte[] manifestBytes, string expectedHash) { using (var sha256 = System.Security.Cryptography.SHA256.Create()) { var realHash = Convert.ToHexString(sha256.ComputeHash(manifestBytes)).ToLower(); return realHash == expectedHash.ToLower(); } }注意:不要再用MD5或者CRC32作为防篡改手段。MD5碰撞已经很容易构造,CRC32本质上是错误检测码,不是加密学意义上的摘要。做安全校验至少用SHA256。
2.3 CDN缓存策略与清单文件的“保鲜期”
清单文件是高频变化文件,AB包是低频变化文件,两类文件的CDN缓存策略必须区分开。
我在项目里是这样配置的:
| 文件类型 | Cache-Control 建议值 | 原因 |
|---|---|---|
| 全局配置JSON | no-cache,或 max-age=60 | 保证客户端每次启动都能拿到最新版本号 |
| AssetBundleManifest | no-cache,或 max-age=60 | 清单一变,后续所有AB包下载判断都会变 |
| AB包文件 | max-age=2592000(30天) | AB包文件名带hash,内容不可变,长缓存提高命中率 |
很多团队把AB包和清单文件都设置了相同的缓存头,结果就是清单明明更新了,CDN节点还在傻傻地把旧的返回给客户端。解决方案有两个:
一个是在URL后面加版本参数,比如:
https://cdn.example.com/game/manifest_42.bin?v=42版本一变,URL就变,CDN自然认为这是一个新资源,会重新回源拉取。
另一个是直接对CDN上的清单路径做主动刷新。像阿里云、腾讯云这类CDN控制台都支持URL刷新接口,发布热更包时把清单文件相关URL全部提交刷新,可以大幅缩短故障时间窗口。
另外要特别提醒WebGL和微信小游戏这种带浏览器环境的场景。CDN必须配置Access-Control-Allow-Origin,否则UnityWebRequest在浏览器环境下会被跨域策略拦截,清单拉不下来,报错还特别隐蔽。这个问题在PC端、Android和iOS都不会出现,唯独到了小游戏平台必踩,基本属于环境差异的经典坑。
3. 本地缓存篇:磁盘上那些AB包到底该怎么管
3.1 本地缓存目录结构与生命周期
Unity自带了一套Caching系统,理论上是为AssetBundle设计的,我在项目初期也尝试直接用。但后来发现它的行为对开发者来说有点“黑盒”,缓存哪些文件、什么时候淘汰、版本判断是否准确,都不好精确控制。排查起问题来特别被动。
所以后来我改成自建缓存,核心思路是自己在Application.persistentDataPath下维护一套清晰可查的目录结构:
persistentDataPath/ ab/ v42/ ui_001_a1b2c3.ab ui_001_a1b2c3.ab.meta scene_003_4d5e6f.ab scene_003_4d5e6f.ab.meta last_version.json这里有几个关键设计:
- 最外层目录按版本号区分,方便版本升级时整体清理旧文件,不会出现新旧版本文件混在一起的脏数据。
- 文件名里带上AB包hash值的前几位(或全部)。哪怕两个版本里都有ui_001这个逻辑名,只要hash不同,文件名就不同,不会互相覆盖。
- last_version.json记录当前有效的版本号和清单hash,客户端启动时首先读它。
- .meta文件保存下载后的校验信息,例如文件大小、hash、下载时间、下载来源CDN节点,排障时非常有用。
缓存的生命周期分为三个阶段:下载前、使用中、更新后。下载前先查本地是否存在同名且hash一致的文件,存在就直接用;使用中不做任何删除操作;更新后根据新版本清单,把不属于当前版本的目录整体清掉。
自建缓存最大的好处是“出问题可查”。Unity的Caching目录乱的时候,你连文件在哪都不知道,自建之后,玩家说资源有问题,你直接让他上传缓存目录结构,一眼就能看出是下载没完成、校验失败还是旧文件没清理。
3.2 缓存一致性校验与残留清理实战
本地缓存并不是“文件存在就代表资源有效”。我在线上遇到过三种情况:
- 下载中途断网,留下一个不完整的AB包文件,但文件名看起来很正常。
- Android手机存储空间不足,文件写入时只写了一半就返回成功。
- 部分手机管家/杀毒后台扫描应用目录,临时锁住文件,导致写入后内容被截断。
所以我把缓存一致性校验做成了每次加载前的必选项,规则是:本地文件的SHA256必须等于清单里记录的hash,大小也必须一致。两者任何一个不对,就删除重下。
private bool CheckLocalFile(string localPath, string expectedHash, long expectedSize) { if (!File.Exists(localPath)) return false; var info = new FileInfo(localPath); if (info.Length != expectedSize) return false; using (var stream = File.OpenRead(localPath)) using (var sha256 = System.Security.Cryptography.SHA256.Create()) { var realHash = Convert.ToHexString(sha256.ComputeHash(stream)).ToLower(); return realHash == expectedHash.ToLower(); } }清理残留分四个场景:
- 版本切换清理:客户端一旦确认要进入新版本,先删除ab目录下所有不属于当前版本的子目录。
- 孤儿文件清理:当前版本清单是权威文件列表,扫描磁盘上ab目录里所有文件,如果文件名前缀不在清单内,就是孤儿,删除。
- 临时文件清理:下载用到.tmp后缀的临时文件,如果上次下载异常退出,会留下.tmp,启动时检测,超过N小时的直接删除。
- 缓存上限控制:给缓存目录设置一个总大小上限(比如800MB),超过最低水位线时,按下载时间倒序删老文件,优先保留当前版本依赖的核心资源。
这个“按版本目录+文件名带hash+启动清理”的组合方案,在我经历过的多个项目里,基本把本地缓存维度的问题消灭了八成以上。剩下两成基本都集中在设备和文件系统差异上,属于写代码时无法完全规避的偶发问题。
4. 实操过程:从抓包到修复的一整套排查流程
4.1 第一步:确认CDN返回与本地缓存的对应关系
我排查热更新问题,第一件事永远是抓包+埋点,而不是猜。抓包工具用Charles或Fiddler都行,只要能看到HTTPS解密后的请求和响应就能工作。
重点关注这几个响应头:
| 响应头 | 作用 |
|---|---|
| Age | 当前资源已经在CDN节点缓存了多久,非0说明走了缓存 |
| Cache-Control | 确认CDN节点对资源的缓存策略是否符合预期 |
| ETag / Last-Modified | 判断资源是否有更新,客户端是否应该重新请求 |
| Content-Length | 对比实际响应体大小,判断是否被截断 |
抓包时我会同时看客户端日志,每个关键节点打一行日志,至少包含下面几个信息:
[HotUpdate] Version check: local=42 server=43 [HotUpdate] Manifest URL: https://cdn.example.com/game/manifest_43.bin?v=43 [HotUpdate] Manifest hash match: True [HotUpdate] Need download ab: ui_001_a1b2c3.ab, size=123456, hash=4d5e6f... [HotUpdate] Download result: success, size=123456 [HotUpdate] Verify result: True [HotUpdate] Load bundle: ui_001_a1b2c3.ab success日志能直接回答三个问题:客户端当前认为自己是什么版本?它要找哪些资源?它实际下载到的资源对不对?
如果怀疑缓存问题,我会在客户端里加一个来源标记,逻辑是:所有从本地缓存直接加载的资源,打日志时加[Cache]前缀;从网络下载回来的加[Network]前缀。这样后台一搜就能立刻看出,玩家设备上加载的资源到底是从哪来的。
4.2 第二步:定位缓存失效/脏数据的具体场景
这里分享三个我真实排查过的典型案例,你可以拿去对照。
案例一:某渠道玩家全部卡在旧版本,PC和iOS正常。
最开始怀疑是CDN,抓包后发现请求CDN节点后Age值极小,说明节点缓存刚刷新过,文件内容也是最新的。但玩家就是更新不上去。最后发现是渠道包内置了一份写死的本地配置,客户端启动时优先读了内置配置,绕过了服务端版本对比。这种属于“老包内置数据优先级过高”的问题,直接在配置读取逻辑里把版本对比放在第一位就解决。
案例二:下载成功,但LoadFromFile时崩溃或报资源缺失。
这种情况多半是AB包文件损坏或依赖没加载。先把错误堆栈和manifest依赖关系拉出来看。我遇到的一次是某个AB包依赖了另一个被淘汰的旧AB包,CDN上已经没有这个依赖文件,但主包清单里还残留着旧依赖项。解决方式是重新构建AB包,确保依赖关系干净。
案例三:玩家更新到新版本后,重启又回退到旧版本。
第一反应是本地缓存写入失败。检查发现玩家手机存储空间满了,下载时已经触发了系统空间不足,写入到一半的文件没有被及时清理,但客户端重启时又读到了这个半成品文件,误以为缓存有效。后来我把写入逻辑改为“先写.tmp临时文件,校验通过后原子替换正式文件”,并加入启动时空间检查,这类问题基本绝迹。
4.3 第三步:针对问题进行修复与加固
排查到最后,无论问题是CDN、清单还是本地缓存,最终都要落在修复和加固上。我总结的修复策略是:
- 下载流程必须完整:下载到.tmp文件,校验hash通过后再改名替换正式文件,不要让半成品污染缓存目录。
- 版本切换必须原子化:新版本所有文件下载完成并校验后,再更新本地记录的版本号。更新之前,玩家看到的还是旧版本,不会出现新旧版本混用。
- 加载失败触发自动恢复:如果AssetBundle加载抛异常,先删除对应缓存文件,再重新下载一次。这个自愈逻辑能解决大多数偶发性脏数据问题。
- 清单校验之前必须有签名验证:不要只比对hash,先把签名验通过再走后续流程。
- 传输层的加固:统一走上行HTTPS,不要在客户端用明文HTTP下载AB包。尤其Android平台注意证书校验,不要关闭证书验证。
补充一点关于AB包本身加密的思考。对大多数项目来说,只做签名和完整性校验已经能挡住90%的替换和篡改。AB包加密可以再加一层,比如加载前对文件做异或混淆或AES解密,但这会牺牲启动速度,也会增加加载代码的复杂度。我通常建议先做完整性校验,加密放到后面有明确的需求再做,不要一上来就让自己陷入调试加密加载的泥潭。
5. 常见问题与排查技巧实录
5.1 更新后加载旧资源
现象:玩家明明更新到了新版本,但进入游戏后看到的UI、角色、场景都还是旧资源。
排查思路:
- 先确认版本切换逻辑是否真的成功,本地版本号是否已经更新。
- 再确认加载资源时用的是否是新版本目录路径。
- 最后确认旧版本目录是否还留着。很多人图省事,直接在旧文件上做覆盖写,但AB包的新旧版本如果内部结构发生了较大变化,一次性覆盖很容易导致加载时读了旧文件的新索引。
解决方案:缓存目录严格按版本号隔离,更新时先下载新版本目录,全部校验通过后再把当前版本指向新目录,最后异步清理旧目录。不要原地覆盖。
5.2 CDN清单拿不到或拿到的清单是旧的
现象:客户端请求清单文件超时、返回404,或者请求成功但内容里缺少资源项。
排查重点:
- CDN是否允许访问,防盗链是否把客户端正常请求拦截了。
- URL是否带版本号。不全带版本号的话,一旦CDN边缘节点缓存了旧文件,你会反复拿到旧清单。
- 回源服务器本身是否更新成功。有人只上传了CDN,忘更新源站,导致CDN回源后拿到的还是旧文件。
方案:清单URL强制带版本号或hash参数,同时设置较短Cache-Control。发布热更包时顺手在CDN控制台做一次URL刷新,彻底清掉边缘节点旧缓存。对访问频率低的资源,还可以把URL参数加随机数,彻底绕开缓存。
5.3 本地缓存空间异常膨胀
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 缓存目录越来越大,版本更新不释放 | 老版本目录没有及时清理 | 版本切换成功后异步删除旧目录 |
| 下载失败产生.tmp临时文件堆积 | 下载流程异常退出没有清理 | 启动时扫描并删除超过一天的.tmp文件 |
| 同名AB包在不同版本里各存一份 | 文件名不含hash,新版本覆盖不了旧文件 | 文件名带hash,或按版本号分目录 |
| CDN长缓存导致资源一直重复下载 | 下载前没有查询本地缓存有效性 | 下载前先查本地文件hash/index |
5.4 安全加固的检查清单
我把每次发布热更包前要检查的项整理成清单,每次发布走一遍,可以省去很多售后问题:
- 服务端返回的全局配置和清单文件是否经过签名。
- 客户端启动时是否先校验签名再对比版本号。
- 清单坏文件是否被拒绝并进入重拉流程。
- AB包文件名是否带hash,避免内容与名称不匹配。
- 清单文件和AB包的URL是否都带版本参数或hash参数。
- CDN上清单路径是否设置了短缓存并已刷新。
- 下载后是否对文件做SHA256校验。
- 写入缓存是否先写临时文件再原子替换。
- 旧版本目录是否在更新成功后统一清理。
- 客户端日志是否完整记录URL、版本、hash、加载来源,且不上传用户隐私。
每次发布有任何一个检查项不通过,我都会先停下来问一句:是不是又在哪个环节默认信任了什么。
最后说点个人的体会。AssetBundle热更新的安全排查,最怕的不是某一环被攻破,而是你根本没意识到某个环节存在“默认信任”。做这套东西,最重要的是把链路拆成清单、传输、缓存三段,每一段都明确“我信任什么、我在哪里校验、校验失败怎么办”。把这套机制打磨顺了之后,线上再出资源问题,你手里永远有一套完整的日志、缓存目录和校验结果可以用来定位,而不是让玩家反复“清缓存、重装包”碰运气。