1. 热更新链路里最容易被忽视的环节:清单与缓存的信任边界
做过 Unity 手游上线的人大概都有过这种体验:版本发出去,玩家反馈"卡在加载界面进不去",你本地怎么测都是好的。排查一圈发现是 CDN 上的 AssetBundle 清单文件和实际资源对不上,或者玩家本地缓存里躺着一份旧清单,新包死活拉不下来。这类问题不像崩溃那样有明确堆栈,排查起来全靠经验和一套顺手的检查流程。
这篇内容聊的就是这条链路:从 CDN 上的清单文件,到玩家设备上的本地缓存,中间哪些地方会出问题、怎么定位、怎么修。核心关键词是Unity、AssetBundle、热更新、CDN、本地缓存。适合已经做过基础热更新、但被线上问题折腾过的开发者,也适合正准备搭热更新框架、想提前避开这些坑的人。我不会只讲"要校验哈希"这种正确但没用的话,而是把每个环节的失败模式、排查手段、以及我实际踩过的坑摊开讲。
先说清楚一个前提:热更新的本质是"用网络上的新资源替换本地的旧资源",那么整条链路就存在两个信任源——远端(CDN)和本地(缓存)。绝大多数线上事故,都发生在"这两个源不一致,但代码没有正确处理这种不一致"的时候。下面按数据流动的顺序,一层层拆。
2. CDN 清单文件本身可能就是个坑
2.1 清单文件到底存了什么,为什么它比资源更关键
AssetBundle 的清单(Manifest)通常包含几类信息:每个 Bundle 的名字、哈希值、依赖关系、以及资源到 Bundle 的映射。很多人把清单当成一个"索引文件",觉得它只是告诉程序"去下载哪个包",但实际上它同时承担了版本判定和依赖解析两个职责。一旦清单错了,后面全错。
我见过最典型的一种情况:打包脚本在 CI 上跑,清单文件生成后又被后续步骤覆盖,导致清单里记录的哈希是上一轮的。资源文件是新的,清单是旧的,客户端拿着旧哈希去校验新文件,永远对不上,于是无限重下。这种问题在本地单机打包时几乎不会出现,只有上了自动化流水线才暴露。
所以第一件事:清单文件必须和它描述的资源在同一批次生成,并且一起上传,中间不允许任何环节单独替换其中一个。如果你的打包流程里清单和资源是分两步上传的,那就要格外警惕。
2.2 CDN 缓存策略对清单的影响
CDN 默认会对静态文件做缓存,这本身没问题,问题在于清单文件的缓存策略往往和资源文件不一样。资源文件内容变了文件名或路径通常也会变(内容寻址),但清单文件的路径经常是固定的,比如manifest.json或version.txt。
固定路径 + CDN 缓存 = 玩家拿到的是旧清单。这时候你在源站更新了清单,但边缘节点还在吐旧的,玩家客户端就会认为"没有新版本",热更新直接失效。这个问题的隐蔽之处在于:你在浏览器里刷新能看到新清单(因为浏览器可能绕过了缓存或命中了不同节点),但玩家客户端拿到的还是旧的。
处理方式有几种,我实际用下来比较稳的是:
- 清单文件走短缓存或不缓存策略,在 CDN 上单独配置该路径的缓存规则,比如
Cache-Control: no-cache或很短的 max-age。 - 清单文件名带版本号或时间戳,客户端先请求一个极小的"版本指针文件"(同样不缓存),再根据指针去拿对应版本的清单。这样清单本身可以长缓存,因为路径变了。
- 上传后主动刷新 CDN 缓存,但不要依赖这个作为唯一手段,因为刷新有延迟,且不同厂商行为不一致。
提示:不要假设 CDN 刷新是即时的。我遇到过刷新后十几分钟边缘节点还在吐旧文件的情况,所以版本指针方案比单纯依赖刷新更可靠。
2.3 清单校验:哈希、大小、签名三选几
清单从 CDN 下载到本地后,第一件事是校验。校验手段常见的有三种:哈希比对、文件大小比对、数字签名。哈希最常用,但要注意哈希算法和计算范围要和打包端完全一致,否则会出现"明明文件没变却校验失败"。
我的建议是哈希 + 大小双重校验。大小校验成本极低,能挡住大部分传输截断的情况(比如下载到一半断网,文件不完整但哈希计算时如果代码写得糙可能不报错)。签名一般用于对抗篡改,如果只是普通热更新,哈希加大小就够了。
这里有个容易忽略的点:校验失败后的行为。很多代码在校验失败时直接抛异常或静默重试,但正确的做法应该是"删除本地这份损坏的清单,重新从 CDN 拉取,并记录一次失败计数"。如果连续失败超过阈值,就要考虑是不是 CDN 本身有问题,而不是无脑重试把玩家流量耗光。
3. 本地缓存的读写时机决定了你会不会踩到脏数据
3.1 缓存目录结构设计:按版本隔离还是原地覆盖
本地缓存怎么组织,直接决定了出问题时好不好恢复。两种常见方案:
| 方案 | 结构 | 优点 | 缺点 |
|---|---|---|---|
| 按版本隔离 | cache/v1/、cache/v2/ | 回滚容易,新旧不干扰 | 占空间,需要清理策略 |
| 原地覆盖 | cache/直接覆盖 | 省空间 | 一旦写坏,没有退路 |
我倾向于按版本隔离 + 定期清理旧版本。原因是热更新最怕的就是"写到一半失败,本地处于半新半旧的状态"。按版本隔离后,新版本要么完整写入并切换指针,要么就完全不用,不存在中间态。切换指针这个动作本身要尽量原子化,比如写一个current_version文件,写完再让程序读它。
清理策略上,保留当前版本和上一个版本就够了。上一个版本留着是为了回滚——线上发现新版本有问题时,把指针切回去就能恢复,不用重新下载。
3.2 写入过程中的中断:断电、杀进程、磁盘满
移动端环境比 PC 恶劣得多。玩家可能在下载到 80% 的时候切后台被系统杀掉,可能磁盘满了写不进去,可能电量低被强制关机。这些情况都会导致缓存目录里出现不完整的文件。
处理这类问题的核心思路是写入即临时,完成才转正。具体做法:下载到xxx.tmp,校验通过后重命名为正式文件名。重命名在大多数文件系统上是原子操作,能保证要么是完整的正式文件,要么是 tmp 文件,不会出现"正式文件但内容不全"。
磁盘满是个特殊情况,因为它在写入过程中才暴露。我的做法是在下载前先检查可用空间,估算本次更新需要的空间(所有待下载 Bundle 大小之和 × 1.5 作为余量),不够就提示玩家清理,而不是下到一半失败。这个检查很多人不做,结果就是玩家反复失败还找不到原因。
3.3 缓存与清单的一致性:谁先谁后
一个经典问题是:清单更新了,但缓存里的 Bundle 还是旧的,程序按新清单去读旧 Bundle,读出来的资源不对或者直接报错。
正确的顺序应该是:
- 下载新清单并校验。
- 对比新清单和本地缓存记录,算出需要下载、删除、保留的 Bundle 列表。
- 下载缺失的 Bundle 到临时区并校验。
- 全部就绪后,原子性地切换版本指针。
- 切换成功后再清理不再需要的旧 Bundle。
关键在于第 4 步之前,新清单不能生效。如果程序在下载过程中就用新清单去读资源,而部分 Bundle 还没下完,就会读到旧文件或缺失文件。所以清单的"生效"必须和 Bundle 的"就绪"绑定在一起。
我踩过的一个坑是:清单下载后直接覆盖了本地的清单文件,但 Bundle 还在后台慢慢下。这时候如果玩家重启游戏,程序读到新清单,发现 Bundle 缺失,又触发一轮下载,但上一轮的下载状态已经丢了,导致重复下载和状态混乱。后来改成"清单先存到临时位置,全部就绪后再一起切换",问题就没了。
4. 排查线上问题的完整链路:从日志到复现
4.1 先确认问题出在哪一层
线上反馈"进不去游戏"时,不要急着改代码,先定位问题层级。我通常按这个顺序查:
- 网络层:玩家能不能访问到 CDN?DNS 解析是否正常?有没有被运营商劫持?这个可以通过让玩家访问一个固定的探测接口来判断。
- 清单层:清单能不能下载?下载下来的清单哈希对不对?版本号是不是预期的?
- 缓存层:本地缓存目录里有什么?版本指针指向哪个版本?有没有 tmp 残留文件?
- 资源层:Bundle 能不能下载?下载后校验是否通过?加载时是否报错?
每一层都要有对应的日志。我建议在热更新流程的关键节点都打上带版本号和耗时的日志,比如"清单下载开始/成功/失败"、"Bundle X 下载成功,大小 Y"、"版本切换成功,从 A 到 B"。这些日志在排查时价值极高,尤其是玩家反馈问题时你能拿到日志的情况下。
4.2 一个真实的排查案例
有次线上反馈某批玩家卡在 30% 左右。日志显示清单下载成功,但下载某个特定 Bundle 时一直失败。查 CDN 发现那个 Bundle 在源站存在,但某个边缘节点返回 404。进一步查发现是上传时该文件上传失败但流水线没报错,导致源站部分节点有、部分没有。
这个问题的根因是上传环节缺少完整性校验。修复方式是在上传后增加一步"回读校验":从 CDN 随机拉取几个文件,比对哈希,确认上传成功。这一步加上之后,类似的"部分节点缺文件"问题基本绝迹。
另一个案例是玩家本地缓存损坏。日志显示清单校验失败,但重试多次都失败。让玩家清缓存后恢复正常。后来分析是玩家设备在下载时存储空间不足,写入了不完整文件,而当时的代码没有做"校验失败就删除重下"的处理,导致每次启动都读到同一个坏文件。加上删除重下逻辑后就解决了。
4.3 复现手段:怎么在本地模拟这些异常
线上问题难复现是常态,所以要主动制造异常来测试。我常用的几种模拟方式:
- 模拟 CDN 返回旧清单:本地起一个静态服务,手动改清单内容,观察客户端行为。
- 模拟下载中断:在下载代码里加一个"下载到 N% 时抛异常"的开关,测试中断后的恢复逻辑。
- 模拟磁盘满:把缓存目录指向一个很小的分区,或者用工具限制可用空间。
- 模拟清单与资源不匹配:手动替换其中一个,看程序是否能检测出来。
这些测试最好做成自动化用例,每次改热更新相关代码都跑一遍。热更新这块的代码改动风险很高,因为它涉及网络、文件、状态机多个方面,手工测试很难覆盖全。
5. 让热更新更稳的几个工程习惯
5.1 版本号设计:单调递增还是内容哈希
版本号用单调递增的整数最简单,但有个问题:回滚时版本号会变小,如果客户端有"只接受更大版本"的逻辑就会拒绝回滚。用内容哈希作为版本标识则天然支持回滚,但可读性差。
我的折中方案是:内部用内容哈希做实际判定,对外展示用递增版本号。客户端判断"是否需要更新"时比对哈希,展示给玩家和日志的用版本号。这样既能回滚,又方便人看。
5.2 灰度与回滚:别一次性全量
热更新最忌讳一次性全量推。正确的做法是灰度:先放 1% 的玩家,观察崩溃率和热更新成功率,没问题再逐步放大。灰度控制可以放在清单层——不同玩家拿到不同版本的清单,或者清单里带一个"适用人群"标记。
回滚要提前准备好。回滚的本质是把版本指针切回上一个版本,前提是上一个版本的 Bundle 还在缓存里没被清理。所以清理策略要留出回滚窗口,比如"新版本上线 24 小时内不清理旧版本"。
5.3 监控指标:哪些数据必须上报
热更新相关的监控指标我建议至少包含:
- 清单下载成功率、平均耗时
- Bundle 下载成功率、平均耗时、失败原因分布
- 校验失败率(清单和 Bundle 分开统计)
- 版本切换成功率
- 各版本的热更新完成率(有多少玩家成功更新到了最新版)
这些指标能帮你在玩家大规模反馈之前就发现问题。比如"某版本热更新完成率突然掉到 60%",基本可以确定这个版本有问题,可以及时回滚。
6. 一些我踩过的具体坑和对应处理
6.1 清单里的依赖关系在增量更新时容易出错
增量更新时,如果只更新了部分 Bundle,但清单里的依赖关系没同步更新,就会出现"新 Bundle 依赖一个没下载的旧 Bundle"的情况。这个问题的根源是打包时依赖分析不完整。我的处理是每次打包都做全量依赖分析,生成完整清单,客户端按完整清单去比对,而不是只比对变化的文件。
6.2 移动端文件系统的大小写敏感问题
Android 和 iOS 的文件系统对大小写敏感度不同,有些平台不敏感。如果 Bundle 名字里大小写不一致,在开发机上没问题,到某些设备上就找不到文件。统一规范:所有 Bundle 名字全小写,路径分隔符统一用/。
6.3 并发下载时的文件句柄泄漏
同时下载多个 Bundle 时,如果文件句柄没及时关闭,在低端设备上容易触发"打开文件过多"的错误。确保每个下载任务完成后立即关闭流,不要依赖 GC。这个坑在测试机上很难发现,因为测试机资源充足。
6.4 清单更新但玩家不重启游戏的情况
有些游戏支持不重启就应用热更新,这时候要特别注意:已经加载到内存的旧资源怎么办?我的做法是热更新完成后提示玩家"需要重启生效",而不是尝试在运行时替换已加载资源。运行时替换涉及引用计数、依赖关系等一堆问题,收益不大风险很高。
7. 关于这套流程我个人的几点体会
热更新这块,代码写对只是及格线,真正决定线上稳定的是对异常路径的处理。正常流程谁都能跑通,但玩家设备千奇百怪,网络环境五花八门,只有把"下载失败怎么办""校验失败怎么办""写到一半失败怎么办""版本回滚怎么办"这些都想清楚,才算真正做完。
我现在做热更新相关改动时,会强制自己问三个问题:这个改动在下载中断时会怎样?在清单和资源不一致时会怎样?在需要回滚时会怎样?这三个问题能覆盖大部分线上事故场景。另外,日志一定要打够,尤其是版本号和关键节点的状态,因为线上问题排查时,日志往往是你唯一的信息来源。最后,任何热更新相关的改动,都要在模拟异常的环境下测过再上,别指望"这次改动很小不会有问题"——热更新链路上没有小改动。