15. 灰度发布和更新
2026/4/13大约 11 分钟
灰度发布和更新
你的游戏上线了,玩家在玩,一切正常。但你发现了一个 bug,或者想加一个新功能。这时候你需要更新游戏。
直接把新版本推给所有玩家?如果新版本有严重 bug,所有人都会受影响。这就是为什么需要灰度发布——先给一小部分人更新,确认没问题后再推给所有人。
什么是灰度发布
打个比方:你开了一家餐厅,研发了一道新菜。你不会直接把所有老顾客的菜都换成新菜——万一新菜不好吃,所有顾客都会不满意。
正确的做法是:先让几张桌的顾客试吃新菜,如果他们说好,再逐步推广到更多桌。
灰度发布(Grayscale Release)就是这个思路:
| 阶段 | 覆盖范围 | 目的 |
|---|---|---|
| 内部测试 | 开发团队 | 发现明显问题 |
| 小范围灰度 | 1%-5% 的玩家 | 验证基本功能 |
| 中范围灰度 | 10%-30% 的玩家 | 验证性能和稳定性 |
| 全量发布 | 100% 的玩家 | 正式上线 |
叫法说明
灰度发布在不同地方有不同的叫法:
- 灰度发布 / 灰度更新(中国互联网常用)
- 金丝雀发布(Canary Release,国际通用)
- 分阶段发布(Phased Rollout)
- 渐进式发布(Progressive Rollout)
说的都是同一件事:不要一次把更新推给所有人。
为什么要灰度发布
真实案例
| 事故 | 原因 | 后果 |
|---|---|---|
| 某游戏更新后无法登录 | 新版本的服务端接口与旧客户端不兼容 | 几十万玩家无法进入游戏 |
| 某游戏更新后存档丢失 | 新版本的存档格式变了,没做数据迁移 | 大量玩家流失 |
| 某游戏更新后严重卡顿 | 新功能有内存泄漏 | 玩家疯狂差评 |
如果这些更新先在 5% 的玩家上灰度测试,问题就会在小范围内被发现和修复,不会影响所有玩家。
灰度发布的实现
版本管理
首先要建立清晰的版本号体系:
主版本号.次版本号.修订号
例如:1.2.3
- 1 = 主版本号(重大更新,可能不兼容旧版本)
- 2 = 次版本号(新增功能,向下兼容)
- 3 = 修订号(Bug 修复,完全兼容)| 版本变更 | 示例 | 说明 |
|---|---|---|
| 新增功能 | 1.0.0 → 1.1.0 | 加了新活动系统 |
| 修复 Bug | 1.1.0 → 1.1.1 | 修了活动奖励不发放的问题 |
| 重大更新 | 1.1.1 → 2.0.0 | 重做了整个 UI 系统 |
客户端版本检查
游戏启动时,客户端应该检查是否有新版本:
C
// 版本检查管理器
using Godot;
public partial class VersionManager : Node
{
// 当前客户端版本
private const string CurrentVersion = "1.2.0";
// 最低兼容版本(低于此版本强制更新)
private const string MinCompatibleVersion = "1.0.0";
public override void _Ready()
{
CheckForUpdate();
}
public async void CheckForUpdate()
{
GD.Print($"当前版本:{CurrentVersion}");
// 向服务器请求最新版本信息
var http = new HttpRequest();
AddChild(http);
string url = $"https://api.yourgame.com/version/check?current={CurrentVersion}";
http.RequestCompleted += OnVersionCheckCompleted;
http.Request(url);
}
private void OnVersionCheckCompleted(long result, long code, string[] headers, byte[] body)
{
if (result != (long)HttpRequest.Result.Success)
{
GD.PrintErr("版本检查失败,使用当前版本");
return;
}
string json = System.Text.Encoding.UTF8.GetString(body);
// 解析响应
// 假设响应格式:
// {
// "latest_version": "1.3.0",
// "min_version": "1.1.0",
// "download_url": "https://...",
// "update_notes": "修复了若干问题",
// "force_update": false,
// "gray_scale": { "enabled": true, "percentage": 10 }
// }
GD.Print($"服务器响应:{json}");
// 根据响应决定是否提示更新
}
// 比较版本号
public static int CompareVersions(string v1, string v2)
{
var parts1 = v1.Split('.');
var parts2 = v2.Split('.');
for (int i = 0; i < 3; i++)
{
int n1 = int.Parse(parts1[i]);
int n2 = int.Parse(parts2[i]);
if (n1 != n2) return n1.CompareTo(n2);
}
return 0;
}
}GDScript
# 版本检查管理器
extends Node
# 当前客户端版本
const CURRENT_VERSION = "1.2.0"
# 最低兼容版本(低于此版本强制更新)
const MIN_COMPATIBLE_VERSION = "1.0.0"
func _ready():
check_for_update()
func check_for_update() -> void:
print("当前版本:%s" % CURRENT_VERSION)
# 向服务器请求最新版本信息
var http = HTTPRequest.new()
add_child(http)
var url = "https://api.yourgame.com/version/check?current=%s" % CURRENT_VERSION
http.request_completed.connect(_on_version_check_completed)
http.request(url)
func _on_version_check_completed(result, code, headers, body):
if result != HTTPRequest.RESULT_SUCCESS:
push_error("版本检查失败,使用当前版本")
return
var json = body.get_string_from_utf8()
# 解析响应
# 假设响应格式:
# {
# "latest_version": "1.3.0",
# "min_version": "1.1.0",
# "download_url": "https://...",
# "update_notes": "修复了若干问题",
# "force_update": false,
# "gray_scale": { "enabled": true, "percentage": 10 }
# }
print("服务器响应:%s" % json)
# 根据响应决定是否提示更新
# 比较版本号
static func compare_versions(v1: String, v2: String) -> int:
var parts1 = v1.split(".")
var parts2 = v2.split(".")
for i in range(3):
var n1 = int(parts1[i])
var n2 = int(parts2[i])
if n1 != n2:
return n1 - n2
return 0灰度策略——服务端决定谁能更新
灰度的核心逻辑在服务端。服务端根据玩家的特征决定他是否在灰度范围内:
// 服务器返回的版本检查响应
{
"latest_version": "1.3.0",
"min_version": "1.1.0",
"download_url": "https://download.yourgame.com/v1.3.0",
"update_notes": "1. 新增春节活动\n2. 修复了战斗同步问题\n3. 优化了加载速度",
"update_required": false,
"gray_scale": {
"enabled": true,
"strategy": "user_id_hash",
"percentage": 10
}
}| 灰度策略 | 说明 | 适用场景 |
|---|---|---|
| 用户 ID 哈希 | 对玩家 ID 取哈希,决定是否在灰度范围 | 最常用,保证同一玩家每次结果一致 |
| 白名单 | 指定特定的玩家 ID 列表 | 内测玩家 |
| 随机 | 每次请求随机决定 | 不推荐,同一玩家可能时新时旧 |
| 按地区 | 特定地区的玩家先更新 | 分区域发布 |
| 按设备 | 特定设备型号先更新 | 适配测试 |
客户端处理更新策略
C
// 更新策略处理
using Godot;
public partial class UpdateHandler : Node
{
public enum UpdateType
{
None, // 无需更新
Optional, // 可选更新
Required, // 强制更新
GrayScale // 灰度更新
}
// 根据服务器响应决定更新策略
public UpdateType DetermineUpdateAction(
string currentVersion,
string minVersion,
string latestVersion,
bool isInGrayScale)
{
// 当前版本低于最低兼容版本 → 强制更新
if (VersionManager.CompareVersions(currentVersion, minVersion) < 0)
{
GD.PrintErr($"当前版本 {currentVersion} 低于最低兼容版本 {minVersion},强制更新");
return UpdateType.Required;
}
// 当前版本是最新的 → 无需更新
if (VersionManager.CompareVersions(currentVersion, latestVersion) >= 0)
{
GD.Print("当前已是最新版本");
return UpdateType.None;
}
// 在灰度范围内 → 可更新
if (isInGrayScale)
{
GD.Print("在灰度范围内,可以更新");
return UpdateType.GrayScale;
}
// 不在灰度范围内 → 暂不更新
GD.Print("不在灰度范围内,等待全量发布");
return UpdateType.Optional;
}
// 显示更新对话框
public void ShowUpdateDialog(UpdateType type, string notes, string downloadUrl)
{
var dialog = new AcceptDialog();
AddChild(dialog);
switch (type)
{
case UpdateType.Required:
dialog.Title = "必须更新";
dialog.DialogText = $"游戏需要更新才能继续运行。\n\n更新内容:\n{notes}";
dialog.Confirmed += () => OS.ShellOpen(downloadUrl);
GetTree().Paused = true;
break;
case UpdateType.GrayScale:
case UpdateType.Optional:
dialog.Title = "发现新版本";
dialog.DialogText = $"有新版本可用,是否更新?\n\n更新内容:\n{notes}";
dialog.Confirmed += () => OS.ShellOpen(downloadUrl);
dialog.Canceled += () => dialog.QueueFree();
break;
}
dialog.PopupCentered();
}
}GDScript
# 更新策略处理
extends Node
enum UpdateType {
NONE, # 无需更新
OPTIONAL, # 可选更新
REQUIRED, # 强制更新
GRAY_SCALE # 灰度更新
}
# 根据服务器响应决定更新策略
func determine_update_action(
current_version: String,
min_version: String,
latest_version: String,
is_in_gray_scale: bool
) -> int:
# 当前版本低于最低兼容版本 → 强制更新
if VersionManager.compare_versions(current_version, min_version) < 0:
push_error("当前版本 %s 低于最低兼容版本 %s,强制更新" % [current_version, min_version])
return UpdateType.REQUIRED
# 当前版本是最新的 → 无需更新
if VersionManager.compare_versions(current_version, latest_version) >= 0:
print("当前已是最新版本")
return UpdateType.NONE
# 在灰度范围内 → 可更新
if is_in_gray_scale:
print("在灰度范围内,可以更新")
return UpdateType.GRAY_SCALE
# 不在灰度范围内 → 暂不更新
print("不在灰度范围内,等待全量发布")
return UpdateType.OPTIONAL
# 显示更新对话框
func show_update_dialog(type: int, notes: String, download_url: String) -> void:
var dialog = AcceptDialog.new()
add_child(dialog)
match type:
UpdateType.REQUIRED:
dialog.title = "必须更新"
dialog.dialog_text = "游戏需要更新才能继续运行。\n\n更新内容:\n%s" % notes
dialog.confirmed.connect(func():
OS.shell_open(download_url)
)
get_tree().paused = true
UpdateType.GRAY_SCALE, UpdateType.OPTIONAL:
dialog.title = "发现新版本"
dialog.dialog_text = "有新版本可用,是否更新?\n\n更新内容:\n%s" % notes
dialog.confirmed.connect(func():
OS.shell_open(download_url)
)
dialog.canceled.connect(func():
dialog.queue_free()
)
dialog.popup_centered()热更新——不重新下载整个游戏
对于 Web 版游戏,每次更新都让玩家重新下载整个包体是不现实的(可能几十上百 MB)。热更新(Hot Update)允许只下载变化的部分。
Godot 的热更新方案
Godot 4 支持在运行时加载 .pck 文件(资源包)。你可以把新版本的资源打包成补丁 PCK,让玩家只下载这个补丁:
C
// 热更新管理器
using Godot;
public partial class HotUpdateManager : Node
{
// 补丁版本列表(从服务器获取)
private Godot.Collections.Array<Godot.Collections.Dictionary> _patches;
// 检查并下载补丁
public async void CheckAndDownloadPatches()
{
// 1. 获取补丁列表
var patchList = await FetchPatchList();
if (patchList == null) return;
// 2. 过滤出需要下载的补丁
var pendingPatches = FilterPendingPatches(patchList);
if (pendingPatches.Count == 0)
{
GD.Print("没有需要下载的补丁");
return;
}
// 3. 逐个下载并加载
foreach (var patch in pendingPatches)
{
string patchUrl = (string)patch["download_url"];
string patchVersion = (string)patch["version"];
GD.Print($"正在下载补丁 {patchVersion}...");
bool success = await DownloadPatch(patchUrl, $"user://patch_{patchVersion}.pck");
if (!success)
{
GD.PrintErr($"补丁 {patchVersion} 下载失败");
continue;
}
GD.Print($"正在加载补丁 {patchVersion}...");
LoadPatch($"user://patch_{patchVersion}.pck");
SaveAppliedPatchVersion(patchVersion);
}
GD.Print("所有补丁已加载,建议重启游戏");
}
private bool LoadPatch(string path)
{
// 加载 PCK 补丁包,它会覆盖已有的同名资源
bool success = ProjectSettings.LoadResourcePack(path);
if (success)
{
GD.Print($"补丁 {path} 加载成功");
}
else
{
GD.PrintErr($"补丁 {path} 加载失败");
}
return success;
}
private async System.Threading.Tasks.Task<bool> DownloadPatch(string url, string savePath)
{
var http = new HttpRequest();
AddChild(http);
var tcs = new System.Threading.Tasks.TaskCompletionSource<bool>();
http.RequestCompleted += (result, code, headers, body) =>
{
if (result == (long)HttpRequest.Result.Success)
{
var file = FileAccess.Open(savePath, FileAccess.ModeFlags.Write);
file.StoreBuffer(body);
file.Close();
tcs.SetResult(true);
}
else
{
tcs.SetResult(false);
}
};
http.Request(url);
return await tcs.Task;
}
}GDScript
# 热更新管理器
extends Node
# 补丁版本列表(从服务器获取)
var _patches: Array = []
# 检查并下载补丁
func check_and_download_patches() -> void:
# 1. 获取补丁列表
var patch_list = await _fetch_patch_list()
if patch_list == null:
return
# 2. 过滤出需要下载的补丁
var pending_patches = _filter_pending_patches(patch_list)
if pending_patches.is_empty():
print("没有需要下载的补丁")
return
# 3. 逐个下载并加载
for patch in pending_patches:
var patch_url = patch["download_url"]
var patch_version = patch["version"]
print("正在下载补丁 %s..." % patch_version)
var success = await _download_patch(patch_url, "user://patch_%s.pck" % patch_version)
if not success:
push_error("补丁 %s 下载失败" % patch_version)
continue
print("正在加载补丁 %s..." % patch_version)
_load_patch("user://patch_%s.pck" % patch_version)
_save_applied_patch_version(patch_version)
print("所有补丁已加载,建议重启游戏")
func _load_patch(path: String) -> bool:
# 加载 PCK 补丁包,它会覆盖已有的同名资源
var success = ProjectSettings.load_resource_pack(path)
if success:
print("补丁 %s 加载成功" % path)
else:
push_error("补丁 %s 加载失败" % path)
return success
func _download_patch(url: String, save_path: String) -> bool:
var http = HTTPRequest.new()
add_child(http)
var completed = false
var success = false
http.request_completed.connect(func(result, code, headers, body):
if result == HTTPRequest.RESULT_SUCCESS:
var file = FileAccess.open(save_path, FileAccess.WRITE)
file.store_buffer(body)
file.close()
success = true
completed = true
)
http.request(url)
while not completed:
await get_tree().process_frame
return success数据迁移——存档格式变了怎么办
当新版本改变了存档格式,老玩家的存档在新版本上可能读不出来。必须做数据迁移:
// v1.0 的存档格式
{
"version": 1,
"player_level": 15,
"gold": 1000
}
// v2.0 的存档格式(新增了经验值字段,金币改名为 coins)
{
"version": 2,
"player_level": 15,
"experience": 2500,
"coins": 1000,
"achievements": []
}C
// 存档迁移器
using Godot;
using System.Text.Json;
public partial class SaveMigrator : Node
{
private const int CurrentSaveVersion = 2;
public string Migrate(string oldJson)
{
var json = Json.ParseString(oldJson);
int version = json.GetProperty("version").GetInt32();
// 逐版本迁移
while (version < CurrentSaveVersion)
{
version++;
json = MigrateToVersion(json, version);
GD.Print($"存档已迁移到版本 {version}");
}
return JsonSerializer.Serialize(json, new JsonSerializerOptions { WriteIndented = true });
}
private JsonElement MigrateToVersion(JsonElement data, int targetVersion)
{
// 这里需要用 mutable 的方式操作 JSON
// C# 中建议使用 JsonNode 或 JsonDocument 来修改
switch (targetVersion)
{
case 2:
// v1 → v2:新增 experience 字段,gold 改名为 coins
// 根据等级计算经验值
// gold 字段复制为 coins
GD.Print("迁移 v1 → v2:添加经验值,重命名字段");
break;
}
return data;
}
}GDScript
# 存档迁移器
extends Node
const CURRENT_SAVE_VERSION = 2
func migrate(old_json: String) -> String:
var json = JSON.new()
json.parse(old_json)
var data = json.data
var version = data.get("version", 1)
# 逐版本迁移
while version < CURRENT_SAVE_VERSION:
version += 1
data = _migrate_to_version(data, version)
print("存档已迁移到版本 %d" % version)
return JSON.stringify(data, "\t")
func _migrate_to_version(data: Dictionary, target_version: int) -> Dictionary:
match target_version:
2:
# v1 → v2:新增 experience 字段,gold 改名为 coins
var level = data.get("player_level", 1)
data["experience"] = _calc_experience_from_level(level)
data["coins"] = data.get("gold", 0)
data.erase("gold")
data["achievements"] = []
print("迁移 v1 → v2:添加经验值,重命名字段")
_:
push_warning("未知的迁移目标版本:%d" % target_version)
data["version"] = target_version
return data
func _calc_experience_from_level(level: int) -> int:
# 根据等级估算经验值
return level * level * 100存档迁移的铁律
- 永远不要修改原始存档文件——先备份,再迁移,失败了可以恢复
- 迁移必须是幂等的——同一个存档迁移两次结果一样
- 测试老存档——每次发布前,拿真实的老存档测试迁移逻辑
- 保留老版本代码——不要删除旧版本的存档读取代码,直到确认所有玩家都迁移完成
发布流程总结
1. 开发完成 → 内部测试
2. 构建新版本包体(APK/EXE/Web)
3. 准备灰度配置(百分比、策略)
4. 上传到分发服务器
5. 开启 1% 灰度
6. 监控崩溃率、错误日志、玩家反馈(至少观察 24 小时)
7. 逐步扩大到 5% → 10% → 30% → 50%
8. 确认无问题后,全量发布 100%
9. 发布完成后继续监控 48 小时检查清单
下一章
游戏能安全更新了。但玩家数量暴增后,服务器能扛住吗?下一章解决高并发问题。
