做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 建议值原因全局配置JSONno-cache或 max-age60保证客户端每次启动都能拿到最新版本号AssetBundleManifestno-cache或 max-age60清单一变后续所有AB包下载判断都会变AB包文件max-age259200030天AB包文件名带hash内容不可变长缓存提高命中率很多团队把AB包和清单文件都设置了相同的缓存头结果就是清单明明更新了CDN节点还在傻傻地把旧的返回给客户端。解决方案有两个一个是在URL后面加版本参数比如https://cdn.example.com/game/manifest_42.bin?v42版本一变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: local42 server43 [HotUpdate] Manifest URL: https://cdn.example.com/game/manifest_43.bin?v43 [HotUpdate] Manifest hash match: True [HotUpdate] Need download ab: ui_001_a1b2c3.ab, size123456, hash4d5e6f... [HotUpdate] Download result: success, size123456 [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/index5.4 安全加固的检查清单我把每次发布热更包前要检查的项整理成清单每次发布走一遍可以省去很多售后问题服务端返回的全局配置和清单文件是否经过签名。客户端启动时是否先校验签名再对比版本号。清单坏文件是否被拒绝并进入重拉流程。AB包文件名是否带hash避免内容与名称不匹配。清单文件和AB包的URL是否都带版本参数或hash参数。CDN上清单路径是否设置了短缓存并已刷新。下载后是否对文件做SHA256校验。写入缓存是否先写临时文件再原子替换。旧版本目录是否在更新成功后统一清理。客户端日志是否完整记录URL、版本、hash、加载来源且不上传用户隐私。每次发布有任何一个检查项不通过我都会先停下来问一句是不是又在哪个环节默认信任了什么。最后说点个人的体会。AssetBundle热更新的安全排查最怕的不是某一环被攻破而是你根本没意识到某个环节存在“默认信任”。做这套东西最重要的是把链路拆成清单、传输、缓存三段每一段都明确“我信任什么、我在哪里校验、校验失败怎么办”。把这套机制打磨顺了之后线上再出资源问题你手里永远有一套完整的日志、缓存目录和校验结果可以用来定位而不是让玩家反复“清缓存、重装包”碰运气。
阅读完成 · 觉得有帮助?