1. 热更新安全排查的整体思路与方案选型
做过 Unity 手游的同行都清楚,热更新是绕不开的一环。资源包从打包、上传 CDN、下发清单,到客户端下载、校验、落盘、加载,这条链路里任何一个环节出问题,轻则玩家卡在登录界面,重则资源被篡改、缓存被污染,甚至出现“同一版本不同玩家表现不一致”的诡异现象。我这次要聊的,就是围绕Unity AssetBundle 热更新这条链路做一次系统性的安全排查,重点落在CDN 清单和本地缓存这两个最容易被忽视、却最容易出事的节点上。
先说清楚这套东西是什么、能解决什么问题。AssetBundle 热更新本质上是把游戏资源(贴图、预制体、音频、配置表、甚至部分代码程序集)打成一个个独立的包,运行时按需从远端拉取。CDN 负责把这些包和一份“清单文件”分发到离玩家最近的节点,客户端拿到清单后决定下哪些包、下完存哪里、下次怎么复用。安全排查要做的,就是确认这条链路上:清单有没有被篡改的可能、下载的包和清单是否一致、本地缓存会不会被旧数据污染、断点续传和版本切换时会不会读到脏数据。
适合谁来参考?如果你正在负责 Unity 项目的资源热更模块,或者你是个独立开发者,用 CDN 分发自己的小游戏资源,再或者你是刚接手一个老项目、发现热更逻辑一团乱麻需要梳理,这篇内容都能直接拿去对照。我不打算只讲概念,而是把每一步的“为什么这么设计”“参数怎么算”“坑在哪”都摊开说,尽量让你看完就能动手排查自己项目里的同类问题。
在方案选型上,我倾向于把排查分成三层:清单层、传输层、缓存层。清单层关注的是版本号、哈希、依赖关系是否可信;传输层关注 CDN 回源、缓存头、断点续传的正确性;缓存层关注本地文件命名、校验、清理策略。这三层不是孤立的,很多线上事故恰恰是层与层之间的衔接没做好,比如清单更新了但 CDN 缓存没刷新,或者本地缓存用文件名做 key 导致新旧包互相覆盖。下面我会逐层拆开讲。
提示:安全排查不是一次性动作,建议把它做成一个可重复执行的检查清单,每次发版前跑一遍,比出事后再救火划算得多。
2. 清单层:CDN 上的清单文件到底可不可信
2.1 清单文件的结构与常见字段解读
AssetBundle 的清单通常有两种形态:一种是 Unity 自带的 manifest(比如 AssetBundleManifest),另一种是项目自己维护的版本清单(JSON 或二进制)。实际项目里,为了灵活控制,大多数团队会自己生成一份 JSON 清单,里面至少包含这些字段:资源包名、版本号、文件大小、MD5 或 SHA256、依赖列表、是否强制更新。我见过不少项目只写了包名和版本号,哈希字段直接留空,这就等于把校验的大门敞开了。
清单里最关键的其实是哈希值和版本号的组合。版本号决定“要不要更新”,哈希值决定“下下来的东西对不对”。如果只有版本号没有哈希,客户端就无法判断 CDN 返回的内容是不是被中间环节替换过;如果只有哈希没有版本号,每次启动都要全量比对,流量和耗时都受不了。合理的做法是:版本号做粗粒度判断,哈希做细粒度校验,两者配合。
还有一个容易被忽略的字段是依赖列表。AssetBundle 之间是有依赖关系的,比如一个 UI 预制体依赖某个图集包。如果清单里依赖写错了,客户端可能只下了主包没下依赖包,运行时直接报 missing 错误。排查时要重点看依赖是不是双向一致——A 说依赖 B,B 的引用计数里也应该能对上。
2.2 清单被篡改的几种典型路径与防护
清单从服务器生成到玩家手里,中间要经过构建机、对象存储、CDN 回源、边缘节点。任何一个环节被污染,玩家拿到的清单就可能是错的。常见的风险路径有这么几条:构建脚本生成的清单没有签名,上传后被误覆盖;CDN 缓存了旧清单,新版本发布后部分节点还在返回老数据;清单里的下载地址是明文 HTTP,被中间人替换。
防护手段我一般推荐三层。第一层是清单签名,用非对称加密对清单内容做签名,客户端内置公钥验签,这样即使清单被替换,验签也过不了。第二层是CDN 缓存策略,清单文件必须设置较短的缓存时间,或者用带版本号的路径(比如 /manifest/v1.2.3/manifest.json),避免边缘节点缓存旧文件。第三层是HTTPS 全链路,这个不用多说,明文传输在现在这个环境下基本等于裸奔。
这里有个实操细节:很多团队把清单和资源包放在同一个目录、用同一套缓存规则,这是不对的。资源包内容不变时可以长期缓存,清单必须频繁刷新。我通常会把清单单独放一个路径,缓存头设成Cache-Control: no-cache或者很短的 max-age,资源包则设成max-age=31536000配合文件名带哈希。
2.3 版本号设计:别让“版本回退”变成灾难
版本号设计看着简单,其实坑很多。我见过用时间戳做版本号的,结果服务器时间回拨导致客户端认为“没有新版本”;也见过用自增整数的,多分支并行开发时版本号冲突。比较稳妥的做法是用语义化版本 + 构建号,比如 1.2.3.456,前三位是策划可读的版本,最后一位是每次构建自增。客户端比较时按位比较,避免字符串比较带来的“10 比 9 小”这种低级错误。
更关键的是版本回退场景。线上出问题需要回滚到旧版本时,如果客户端只认“版本号更大就更新”,那回退就失效了。解决办法是在清单里加一个强制版本标记或者回退指令,客户端看到这个标记就无条件切换到指定版本,而不是单纯比大小。这个字段一定要在排查时确认存在,否则真到回滚那天会手忙脚乱。
注意:版本号比较一定要用数值比较或按段比较,绝对不要直接拿字符串比大小。我踩过这个坑,1.10.0 被判定成小于 1.9.0,导致更新逻辑整个错乱。
3. 传输层:CDN 分发与下载校验的实操要点
3.1 CDN 缓存头与回源策略的排查方法
CDN 这一层最典型的问题就是“我明明传了新包,为什么玩家还是下到旧的”。十有八九是缓存头没设对,或者回源策略有问题。排查时我会先用命令行工具直接请求 CDN 节点,看返回的响应头里Age、Cache-Control、ETag这几个字段。Age很大说明命中了边缘缓存,如果这时候内容还是旧的,那就是缓存没刷新。
资源包的缓存策略我一般这么设:文件名里带内容哈希,比如ui_login_ab8f3c.bundle,这样内容一变文件名就变,天然不会命中旧缓存,可以放心设长缓存。清单文件则相反,路径固定但内容常变,必须短缓存或者不缓存。有些团队为了省事,资源包也用固定文件名,那就只能靠手动刷新 CDN,运维成本极高,不推荐。
回源策略方面,要确认 CDN 在缓存未命中时是回源到对象存储还是回源到构建机。如果回源到构建机,而构建机上的文件已经被下一次构建覆盖了,那玩家可能下到“半新半旧”的包。正确做法是每次构建产物上传到对象存储后就不再改动,CDN 只回源到对象存储的不可变路径。
3.2 下载校验:哈希比对与断点续传的正确姿势
下载环节的校验,核心就一句话:下完必须比对哈希,比对不通过必须丢弃重下。我见过一些项目为了“提升体验”,下载完直接落盘,校验放到加载时做,结果就是脏包进了缓存,后面每次加载都失败,玩家只能清数据。正确的顺序是:下载到临时文件 → 计算哈希 → 与清单比对 → 通过才移动到正式缓存目录。
断点续传也是个重灾区。HTTP 的 Range 请求本身没问题,问题在于很多项目在续传时没有校验已下载部分的完整性。比如第一次下了 50%,第二次续传时服务器上的文件已经变了(虽然概率低但确实会发生),拼起来就是个坏包。稳妥的做法是续传前先比对已下载部分的哈希或者用 ETag 做一致性判断,不一致就从头下。
下面这段伪代码展示了下载校验的核心逻辑,实际项目里可以根据自己的网络库调整:
// 下载并校验单个 AssetBundle IEnumerator DownloadAndVerify(string url, string expectedHash, string savePath) { string tempPath = savePath + ".tmp"; using (UnityWebRequest req = UnityWebRequest.Get(url)) { req.downloadHandler = new DownloadHandlerFile(tempPath); yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { // 网络失败,记录并重试 yield break; } } // 计算临时文件哈希 string actualHash = ComputeFileHash(tempPath); if (actualHash != expectedHash) { // 哈希不匹配,删除临时文件,触发重下 File.Delete(tempPath); yield break; } // 校验通过,原子移动到正式路径 if (File.Exists(savePath)) File.Delete(savePath); File.Move(tempPath, savePath); }这段逻辑里有两个细节值得强调。一是用临时文件加原子移动,避免下载中途崩溃留下半个文件被当成完整缓存。二是哈希计算要放在下载完成后立即做,不要拖到加载时,早发现早重试。
3.3 并发下载与超时重试的参数选择
并发数不是越大越好。我实测下来,移动端同时开 4 到 6 个下载连接比较合适,再多会因为带宽争抢和系统限制导致单个连接变慢,整体耗时反而上升。超时时间建议设成 15 到 30 秒,太短容易在弱网下误判失败,太长会让玩家干等。重试次数一般 2 到 3 次,并且要做退避,比如第一次失败等 1 秒,第二次等 3 秒,避免雪崩式重试把 CDN 打挂。
还有一个容易被忽略的点是下载优先级。登录界面需要的资源应该优先下,进游戏后才用到的可以延后。如果所有包一视同仁地并发下载,玩家可能卡在登录界面等一个根本用不上的场景包。排查时要确认下载队列有没有按优先级排序。
4. 缓存层:本地存储的命名、校验与清理策略
4.1 缓存目录结构与文件命名规范
本地缓存最容易出的问题是“新旧包混在一起,加载时读错”。根源往往在命名上。如果缓存文件名只用资源包名,比如ui_login.bundle,那新版本覆盖旧版本时,如果覆盖过程中断,就会留下一个损坏的文件。正确做法是文件名里带上版本或哈希,比如ui_login_ab8f3c.bundle,新版本是另一个文件名,旧文件可以安全地留着或延后清理。
目录结构我一般按“平台 + 版本”分层,比如Cache/Android/1.2.3/。这样切换版本时直接换目录,旧版本目录整体删除即可,不会出现跨版本污染。有些项目把所有版本的文件堆在一个目录里,靠文件名区分,时间一长目录里几千个文件,清理逻辑稍微写错就误删。
还有一点是缓存路径的可写性。在部分平台上,应用安装目录是不可写的,缓存必须放在持久化数据目录。排查时要确认代码里用的是正确的路径 API,而不是硬编码的路径字符串。
4.2 缓存校验:启动时该查什么、不该查什么
启动时全量校验所有缓存文件的哈希,这个做法听起来很安全,实际上很蠢。玩家手机里可能存了几个 G 的资源,每次启动都算一遍哈希,耗时几十秒,体验直接崩了。合理的策略是分级校验:清单文件每次启动都校验,因为它是入口;资源包只在首次下载后校验一次,之后靠“版本目录隔离”来保证正确性,不做重复校验。
如果确实担心缓存被外部篡改(比如玩家手动改文件),可以在加载单个包时做一次轻量校验,比如只校验文件大小和头部几个字节,而不是全文件哈希。全文件哈希只在下载完成时做一次就够了。
这里有个经验:校验失败的包不要直接删,先移到隔离目录并上报。这样既能保证游戏正常运行,又能收集到哪些包容易出问题,方便后续分析。直接删掉的话,问题现场就没了。
4.3 缓存清理:什么时候清、清多少、怎么清不误伤
缓存清理的触发时机一般有三个:版本更新后清理旧版本、磁盘空间不足时清理、玩家手动清理。版本更新后的清理最简单,直接删掉旧版本目录。磁盘不足时的清理要小心,不能把当前版本正在用的包删了。我的做法是给当前版本目录加一个“使用中”标记,清理时跳过这个目录。
清理策略上,我倾向于按最近使用时间(LRU)而不是按大小一刀切。记录每个包最后一次加载的时间,空间不足时从最久没用的开始删。这样既释放了空间,又不会影响玩家当前正在玩的进度。
提示:清理操作一定要在后台线程做,并且加锁保护,避免和正在进行的下载或加载冲突。我见过清理线程把正在下载的临时文件删了,导致下载永远完不成。
5. 常见问题与排查技巧实录
5.1 典型故障速查表
下面这张表是我这些年遇到的热更相关问题里最高频的几类,按现象、可能原因、排查手段整理,方便你对照使用。
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 玩家卡在更新界面不动 | 清单请求失败或超时 | 抓包看清单请求返回码,检查 CDN 是否可达 |
| 更新后部分资源显示异常 | 缓存里新旧包混用 | 检查缓存目录是否按版本隔离,文件名是否带哈希 |
| 同一版本不同玩家表现不一致 | CDN 节点缓存不一致 | 多节点请求同一资源,比对返回内容哈希 |
| 下载完成但加载报错 | 下载校验缺失,脏包落盘 | 检查下载后是否比对哈希,临时文件是否原子移动 |
| 版本回退后玩家仍是新版本 | 客户端只比版本号大小 | 检查是否有强制版本标记字段 |
| 弱网下更新极慢 | 并发数过高或超时过短 | 调整并发到 4-6,超时到 15-30 秒,加重试退避 |
| 缓存目录越来越大 | 旧版本未清理 | 检查版本切换时是否删除旧目录 |
5.2 独家避坑技巧
第一个技巧是给清单加一个“构建时间戳”字段。排查线上问题时,只要让玩家截个图或者上报这个字段,就能立刻知道他用的是哪次构建的清单,省去大量猜测。这个字段不参与逻辑判断,纯粹用于排查,成本极低但价值很高。
第二个技巧是在下载 URL 里带上版本参数。比如?v=1.2.3,这样即使 CDN 缓存策略没设好,不同版本的请求 URL 不同,也能天然避开旧缓存。这个做法对清单和资源包都适用,算是缓存策略之外的一道保险。
第三个技巧是本地缓存目录里放一个“版本指纹”文件。记录当前缓存的版本号、清单哈希、写入时间。启动时先读这个文件,如果和远端清单对不上,就知道需要更新。这个文件很小,读写极快,比每次扫描整个目录高效得多。
5.3 排查工具与命令速查
排查 CDN 和缓存问题,命令行工具比图形界面快得多。我常用的几个:
# 查看 CDN 响应头,重点看 Cache-Control、Age、ETag curl -I https://your-cdn.com/manifest/v1.2.3/manifest.json # 下载文件并计算哈希,和清单比对 curl -o test.bundle https://your-cdn.com/bundles/ui_login_ab8f3c.bundle sha256sum test.bundle # 强制不走缓存请求,验证源站内容 curl -H "Cache-Control: no-cache" -I https://your-cdn.com/manifest.json在客户端侧,我会在开发包里加一个调试面板,能实时显示当前缓存版本、已下载包数量、缓存占用空间、最近一次校验结果。这个面板在排查玩家反馈时特别有用,让玩家截个图就能定位问题。
6. 把安全排查做成常态化机制
聊了这么多具体的技术点,最后说点偏流程的东西。热更新安全排查如果只在出事时做,那永远是被动的。我的做法是把它拆成几个固定动作,嵌到发版流程里。构建阶段自动生成清单并签名,上传阶段校验 CDN 缓存头是否符合预期,发版后用一个自动化脚本模拟客户端走一遍完整下载校验流程,确认线上清单和资源包都能正确拉取和校验。
这套机制跑顺了之后,大部分低级问题在发版前就能拦住。剩下的就是一些环境相关的偶发问题,靠客户端上报的日志和版本指纹来定位。说实话,热更新这块没有一劳永逸的方案,CDN 环境、玩家网络、设备差异都会带来新的变量,保持排查意识、把关键校验做扎实,比追求某个“完美方案”更实际。
我个人在实际操作中的体会是,清单和缓存这两块最值得投入精力,因为它们一个是入口、一个是落脚点,中间传输环节反而相对标准化。把清单的签名和版本设计做对,把缓存的命名和校验做对,热更新这条链路就稳了大半。剩下的并发、重试、清理这些,都是在这个基础上做优化,优先级可以往后放。