
前阵子跑了一次线上热更事故CDN 上明明已经放了新包客户端也拿到了新清单结果下载回来的 AB 文件哈希却和旧版本一模一样。用户端表现是“UI 显示新版模型还是老角色”重启、清后台都没用。那段时间我几乎把自己架在抓包工具和日志系统之间来回跑最后才意识到问题根本不在打包机而是从 CDN 清单到本地缓存这段链路里某个环节偷偷放了旧数据。Unity AssetBundle 热更新最磨人的地方就在这里链路不长每个环节单独看起来都“正常”合在一起就给你一种“更新成功但内容没变”的错觉。这篇文章我就按这条链路展开把我踩过的坑、定位的思路以及最终采用的方案完整写一遍适合正在做 Unity 资源热更新、或者研究过热更但没真正扛过线上压力的同学参考。1. 先看清一条热更请求要走几道门从打包到缓存的信任边界排查这类问题第一步不是抓日志而是把链路在脑子里拆清楚。一次热更资源从发布到最终被游戏加载至少要经过五个节点打包机生成 AB 文件和清单上传到源站源站把文件同步到 CDN 边缘节点客户端启动时拉取远端清单和本地记录做对比发现差异后向 CDN 发起下载下载文件落盘到本地缓存再由 AssetBundle 加载器读取。这五个节点里只要有任何一个地方没有守住“一致性”玩家那边就会出现标题里描述的现象CDN 清单是最新的本地缓存却读到了旧的。1.1 打包机产出的东西远不止 AB 文件打包机听起来只是个输出机器但它实际上决定了整条链路里两个最核心的东西AB 文件本身以及描述这些文件的清单元数据。Unity 原生的AssetBundleManifest会给你每个包的哈希值但它不负责告诉你“客户端应该从哪里下载”“这个包有多大多重”。所以我们一般会再包一层自定义清单类似这样[Serializable] public class PatchManifest { public int Version; public string BuildNo; public string CDNRoot; public ListBundleInfo Bundles; } [Serializable] public class BundleInfo { public string Name; // 包名比如 common/ui.ab public string Hash; // 字节级哈希推荐 MD5 或 SHA1 十六进制字符 public long Size; public string Url; // 完整 CDN 地址 public bool IsEncrypted; }这里有一个很多人踩过的坑BundleInfo.Hash到底从哪来。如果你直接用AssetBundleManifest.GetAssetBundleHash()拿到的哈希没问题但前提是你必须和“本次发布同时生成的 manifest”来拿。搬迁打包机、升级 Unity 小版本、甚至改动 BuildOptions 都可能导致 AB 内容改变哈希随之变化。要是你拿上次发布的 manifest 当基准就会产出“清单哈希是旧的、AB 文件是新的”的错配对客户端对比时反而认为没有更新。1.2 客户端到底信谁清单才是唯一真相来源客户端启动时的逻辑通常是先读取远端清单再和本地缓存目录下的“上一份清单”做对比。这个对比决定了哪些包需要重新下载。关键点在于客户端的信任基准只能是远端清单不能是本地缓存里常年累月留下的文件信息。本地缓存是供下载器使用的草稿区不是证据。后面第 4 章我会展开讲本地缓存一旦被当作参考基准旧文件就能“合法”地继续存活。1.3 CDN 只是一层加速不是“永远正确”的镜像很多新手默认“源站更新了CDN 就会更新”这是热更新排查里最大的误解。CDN 边缘节点有自己的缓存 TTL、缓存键、回源策略源站和边缘节点不是强一致的。客户端从 CDN 拿到的字节可能来自源站也可能来自某台边缘节点的旧缓存。所以正确的信任模型是客户端信任清单前提是清单合法且未被篡改。客户端信任每个 AB 文件前提是它的内容哈希和清单哈希完全一致。客户端不信任 CDN 的“中间状态”任何字节都必须校验。把这个信任边界画清楚再回头看事故排错思路会清晰很多既然客户端拿到了新清单那就要往下查它请求的 URL 是否真的指向新文件、CDN 是否返回了新内容、落盘后文件是否完整。2. 清单端“版本号没涨、内容却变了”的三个根因我遇到的第一类问题集中在远端清单和本地清单做版本比较的逻辑上。表面现象是“客户端根本没触发下载”但实际上是版本号判断被坑了。2.1 构建机时间漂移造成的“版本回退”我们早期做清单时版本号直接取自构建机的本地时间比如20240401_1359这种格式。结果两台构建机的系统时间差了大约一天于是20240401_1359反而小于上一轮发布的20240331_2200。客户端比较逻辑是“远端版本号大于本地版本号就更新”一眼看到数字变小直接判定无需更新。这个问题排查起来特别迷惑因为你在后台看到最新清单确实上传了构建机上的文件时间也是新的但客户端就是无动于衷。后来我们把版本号生成逻辑改成了从构建系统里拿比如 Jenkins 的 BUILD_NUMBER或者 Git 提交计数保证所有构建机产生的版本号在中心维度上是单调递增的。提醒别用“构建机上的当前时间拼字符串”当版本号。打包机可以漂移、可以误配数值比较会直接失真。用中心化的构建号 / 提交计数才是可控的。2.2 多平台、多渠道共用一份清单时的路径错位第二个坑出现在 Android 和 iOS 共用一套资源但渠道包却各自上传到了不同 CDN 子目录。有些渠道为了省流量会把清单 CDNRoot 写死成“离自己最近的那个节点”而非标准化路径。结果就是同一个 AB 包在渠道 A 的 URL 下文件是新的在渠道 B 的 URL 下回源到了另一台旧边缘节点。两边资源内容相同但哈希不同客户端下载后哈希校验失败重试几次后直接进入“校验失败”分支。这里的关键是AB 包名不能因为渠道而变但哈希校验绝对不能关。宁可让玩家多下载一次也不能让错误字节进入本地缓存。2.3 增量包里的 Hash 比对用了旧 manifest还有一种更容易发生在开发期的场景资源没变AB 内容变了。你说这怎么可能有可能比如打包环境里某个公共依赖被重建或 Shader 变体收集顺序变化。Unity 的 AB 打包不保证“源码不变则产物字节不变”它受打包顺序、依赖收集、版本环境影响很大。所以如果你在增量发布时拿上一份清单里的 Hash 和这次刚生成的 Hash 做 diff发现“所有文件都在”于是跳过了上传——那么你实际跳过了一大截可能出错的包。正确做法是每次发布都必须重新计算所有 AB 的实时哈希不能复用旧清单缓存再一次性生成新的 PatchManifest。3. CDN 返回“回魂旧包”的三个机制缓存键、压缩头和 Range 拼接如果说清单问题属于“客户端对比层”那么接下来这类问题就藏在“网络传输层”。我查过最多的一类现象是客户端明确请求了新 URL抓到包也确认响应 HTTP 200但文件内容就是旧的。原因多半在 CDN 的边缘缓存行为上。3.1 缓存键决定你是新包还是旧包版本目录的作用CDN 的缓存会为同一个 URL 保存一份文件副本在 TTL 内不会回源。也就是说源站把旧文件删掉、换成新文件边缘节点可能还在继续为玩家提供旧副本。最常见的对策是在 URL 里引入“版本目录”这一层比如https://cdn.example.com/ab/v4/android/common/ui.ab每次发版版本号递增比如v4改成v5。只要目录路径变了CDN 缓存键就变了不会再命中旧缓存。表面看只是多了一层目录实际它把你从“必须刷新边缘节点缓存”的奴隶制里解放出来。如果你用查询参数来解决比如?v4得先确认 CDN 是否将查询参数纳入缓存键。很多 CDN 默认忽略 query那样你换了版本号也会命中同一个缓存条目属于白忙。最稳妥的还是路径隔离。我用一个命令就能判断 CDN 在返回什么curl -sI https://cdn.example.com/ab/v5/android/common/ui.ab -H Accept-Encoding: identity重点看这几个响应头响应头含义HTTP/1.1 200 OK资源存在Cache-Control: max-age...边缘节点可缓存时长Last-Modified/ETag源站资源或边缘缓存的生成标识Age命中边缘缓存后已缓存多少秒Content-Length实际字节长度用来和清单里的 Size 对比如果Age值很大说明这次响应大概率来自边缘节点旧缓存要在源站侧排查刷新策略而不是客户端代码。3.2 压缩头把 AB 包搞坏的隐蔽场景有一次排查到最后发现 AB 文件“体积对、内容坏”。原因很冷门CDN 回源看到了text/plain的 Content-Type自动对它做了 gzip 压缩客户端下载后Unity 加载器识别出了无法解析的头直接报错。AB 文件本质是二进制但如果源站没有配置好 MIME 类型某些 CDN 会出于“优化前端资源”的默认策略对文本类响应做压缩。Unity 的下载链路没有约定去解 gzip 的话拿到的就全乱套了。对策两部分一是在源站侧为.ab/.bundle后缀配置二进制 Content-Type并关闭对该路径的压缩二是在客户端或下载器里尽量显式请求Accept-Encoding: identity。遇到类似问题先用 curl 带上请求头看一下Content-Encoding字段迅速能定位。3.3 Range 分片下载的拼接完整性现在的下载器为了提高断点续传效率很多会用 Range 分片。但 CDN 边缘节点处理 Range 时不一定和源站同一个策略万一某一片超时重试、另一片来自不同节点落盘文件的字节可能多出垃圾。这也是为什么我坚持“下载完成后必须对整包做哈希校验”。字节数对、文件能打开都不代表 AB 完整。哈希对不上只有一条路删除临时文件重新下载。不能因为“就差一个尾部块”就选择性接受。4. 本地缓存污染路径、半文件与旧版本残留网络层搞定了本地缓存层还有一堆更隐蔽的坑。毕竟玩家设备不是服务器断电、杀进程、存储写半截什么情况都可能发生。4.1 缓存键必须绑定哈希不能只绑定包名早期阶段我们用bundleName当缓存文件名比如PersistentDataPath/ab_cache/common_ui.ab。后果是什么某次发版后common_ui.ab内容变了但客户端对比清单时发现哈希不同于是开始下载新文件——但它写进本地缓存的路径还是同一个。如果下载中断本地残留的是旧文件下一次启动File.Exists返回 true加载器直接读了旧文件。正确姿势是缓存文件名里带上哈希比如public static string BuildCachePath(string bundleName, string hash) { var dir Path.Combine(Application.persistentDataPath, ab_cache); return Path.Combine(dir, ${GetPureName(bundleName)}_{hash.Substring(0, 12)}.ab); }同样的包名哈希变了文件名就变了绝不会复用错。这里的“为什么”其实很简单哈希是资源内容的指纹指纹一变地址必须变否则缓存就成了一套骗自己的把戏。4.2 半文件必须用“临时文件 原子替换”解决下载中断会留下半截文件。如果你直接把数据写进最终路径那么校验失败时虽然你会删除并重下但在删除之前加载器可能已经因为File.Exists而读了一个坏文件。我现在用的流程是下载时写*.ab.download临时文件全部字节到位后先计算整个临时文件的哈希和清单对比确认无误再用File.Move覆盖到最终路径。中途杀进程、断网留下来的只是.download尾巴下次启动时会自动清理并重新下载。var tempPath Path.Combine(dir, bundleName .download); var finalPath BuildCachePath(bundleName, expectedHash); // 写入临时文件... await File.WriteAllBytesAsync(tempPath, data); var actualHash ComputeHash(tempPath); if (actualHash ! expectedHash) { File.Delete(tempPath); throw new Exception($Hash mismatch: {expectedHash} vs {actualHash}); } File.Move(tempPath, finalPath, true);这个模式不新鲜但在游戏热更新场景里特别有效。因为 AB 文件一旦字节不对连AssetBundle.LoadFromFile都会直接抛异常根本没法像文本配置那样“容忍一个字符错误”。4.3 App 自身升级后遗留的旧缓存还有一个很容易忽略的场景玩家从商店升级安装包Application.version变了但persistentDataPath还在旧的 AB 缓存目录也还在。此时如果新版本二进制使用新的资源引用规则旧缓存里的 AB 可能与新逻辑产生怪异的组合效应。我的习惯是缓存目录里带上Application.version或构建号形成一个“缓存沙箱”。App 升级后自动进入新沙箱旧沙箱可以等待清理。代价是升级后玩家要重新下载热更资源但换来的是稳定性和可排查性值。5. 安全排查的最后一环给清单签名、给文件做哈希的必要性聊完了“一致性”再回到标题里的“安全”二字。这里说的安全不只是防止 CDN 被攻击还包括公共网络环境下传输字节被替换、玩家本地缓存被篡改等常见风险。5.1 威胁模型哪些环节可能被动手脚先别急着上加密先想清楚你要防谁公共 Wi-Fi 下运营商或恶意节点替换下载内容属于“传输被污染”。CDN 节点异常、回源配置错误导致返回错误字节属于“存储被污染”。玩家改本地缓存资源文件实现诸如替换贴图、解锁未实装内容等目的属于“本地缓存被篡改”。这三种情况的共同点是只要客户端在加载前多做一个“数据来源可信 内容未被改动”的校验就全部能挡住。5.2 给清单加 RSA 签名而不是只加 HMAC清单是客户端一切行为的依据。如果清单可以被替换那么后面所有哈希校验都失去了意义因为攻击者可以同时替换清单和目标 AB 文件。所以清单本身必须做签名。实现上我推荐 RSA 签名而不是 HMAC。原因很直接HMAC 需要一个对称密钥这个密钥藏在你客户端里攻击者只要反编译就能取出来然后用同一个密钥伪造任意清单。RSA 是公私钥分离私钥只放在发布机客户端里只内置公钥。公钥只能验签不能签发攻击者拿不到私钥就拿不到伪造能力。运行时验签的代码片段using System.Security.Cryptography; using System.Text; public static bool VerifyManifestSignature(string manifestJson, string signatureBase64, string publicKeyBase64) { using var rsa RSA.Create(); rsa.ImportRSAPublicKey(Convert.FromBase64String(publicKeyBase64), out _); var data Encoding.UTF8.GetBytes(manifestJson); var sig Convert.FromBase64String(signatureBase64); return rsa.VerifyData(data, sig, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); }发布端则用私钥签名然后把签名和 JSON 一起放到补丁服务器。清单每次拉取后先验签验签失败直接停更并上报不要继续往缓存里写任何东西。5.3 AB 加密到底要不要做成本和收益怎么算有些人一说到安全立刻想对所有 AB 做 AES 加密。我的看法是先分清你是“防篡改”还是“防提取”。防篡改RSA 签名清单 逐文件哈希校验就够了成本非常低。防提取你不想让玩家轻易解开 AB 看到美术资源、配置文件、技能数值等才需要考虑加密。AES 加密的代价是加载速度、内存占用、下载器复杂度都会上升。尤其是加密状态下AssetBundle.LoadFromFile不能直接读文件你得先解密再加载可能从异步流式加载变成整包进内存解密对低端机很不友好。如果只是为了防篡改我真的不建议上 AES一个带签名的清单加哈希校验已经能挡住大多数真实威胁。要是明确要做反提取那 AES 值得投入但注意把密钥做进原生插件层并用混淆保护而不是明文躺在 C# 代码里。另外加密包本身也要继续保留哈希校验因为加密只解决“看不懂”不解决“被替换”。6. 实际排查时我用的套路日志、curl 与清零验证最后分享一套我已经固化下来的排错流程。遇到“清单新、内容旧”这类问题按这个顺序走基本能在半小时内定位到环节。6.1 在热更链路每一步埋关键日志没有日志就别谈排查。我通常在四个点埋日志远端清单拉取成功记录版本号、签名验证结果。本地清单加载成功记录版本号、需下载文件数量。单个 AB 下载完成记录 URL、期望哈希、实际哈希、落盘路径。AB 加载前记录最终加载路径、缓存文件是否存在、哈希是否一致。日志格式尽量稳定方便脚本化检索Debug.Log($[Patch][Version] remote{remoteVersion}, local{localVersion}, needDownload{needDownloadCount}); Debug.Log($[Patch][Download] {bundleName} {expectedHash} vs {actualHash} from {url}); Debug.Log($[Patch][Load] {bundleName} path{path}, exists{File.Exists(path)});排查时直接过滤[Patch]前缀一条一条看链路断在哪里。6.2 用 curl 从“客户端视角”复现 CDN 返回日志只是帮你缩小范围真正要确认 CDN 是否在回魂旧包还是得自己发起一次请求。# 模拟客户端请求关掉压缩头 curl -sI https://cdn.example.com/ab/v5/android/common/ui.ab -H Accept-Encoding: identity # 把内容拉下来对比哈希 curl -s https://cdn.example.com/ab/v5/android/common/ui.ab -H Accept-Encoding: identity -o /tmp/ui.ab md5sum /tmp/ui.ab如果本地算出来的 MD5 和清单里的哈希不一致基本可以确定问题在网络传输层继续查 CDN 配置和缓存键如果一致那问题在客户端逻辑层继续查清单对比和缓存路径。6.3 清零验证把本地缓存直接删掉试一次很多诡异现象最后都被证明是“玩家设备上的旧缓存没清干净”。所以我在自己设备上排错时会直接删掉persistentDataPath/ab_cache整个目录再跑一次甚至用Application.version沙箱把缓存和安装版本绑定。如果清掉缓存一切正常那说明问题在客户端读取逻辑的“复用判断”上如果清掉缓存还是旧内容说明下载链路本身有问题回到上一步继续查 CDN。6.4 一张排查参考表现象可能根因关键证据处理方向客户端没有触发下载版本号被判定小于本地日志里 Version compare 结果改单调递增版本号URL 请求了但内容哈希不符CDN 命中旧缓存curl 的 Age / Last-Modified 不正常版本目录隔离或刷新 CDNAB 下载后加载报错压缩头污染或半文件写入Content-Encoding 不是空关闭压缩 临时文件原子替换缓存文件存在但加载老资源缓存名未绑定哈希本地路径文件名没有 hash缓存名带上哈希前缀这套表我直接贴在项目 Wiki 里团队成员遇到问题先过一遍表再决定是否往代码层深挖。最后再提一句我自己的感受AssetBundle 热更新这套链路很多时候不是技术复杂而是“你以为已经做了校验”和“实际是否每个环节都在做校验”之间存在巨大的落差。清单签名、文件哈希、缓存键绑定、版本目录隔离每一个机制单独拎出来都不难难的是把它们串成一条完整的可信链。我在实际项目里最受益的一个改变就是让本地缓存的所有文件名都带哈希并且把热更日志做得足够细。从那以后类似“明明更新了却还是旧内容”的线上问题从“需要三小时抓包”变成了“看两行日志就能定位”。如果你正准备动手改进自己的热更链路建议也从这两个点开始性价比最高。