灰度发布实战:从零搭建高可用的渐进式上线方案
灰度发布?先上代码,再聊原理
上周我们上线一个新功能,老板说“别全放,先给10%用户试试”,我一拍脑袋:得,又得搞灰度发布了。说实话,一开始我以为就是加个开关,后来发现没那么简单。折腾了两天,踩了好几个坑,今天把亲测有效的方案写出来,省得你再走弯路。
核心思路其实就两点:一是怎么判断用户是否在灰度名单里,二是前端怎么动态加载对应版本。我直接用 localStorage + 接口控制,简单粗暴但有效。
最简单的方案:本地存储 + 接口兜底
我一开始想用 cookies,但移动端 Safari 对第三方 cookie 限制太严,干脆用 localStorage 存个灰度标识。但注意:不能只靠本地存,因为用户可能清缓存,所以必须每次启动时去接口校验一次。
// 灰度控制模块
class GrayRelease {
constructor() {
this.grayKey = 'gray_version';
this.checkAndInit();
}
async checkAndInit() {
// 先看本地有没有
const localFlag = localStorage.getItem(this.grayKey);
if (localFlag) {
// 有就用,但后台再确认一次(防作弊)
const valid = await this.validateFromServer(localFlag);
if (!valid) {
localStorage.removeItem(this.grayKey);
this.fetchNewGrayStatus();
}
} else {
// 没有就去拉
this.fetchNewGrayStatus();
}
}
async fetchNewGrayStatus() {
try {
const res = await fetch('https://jztheme.com/api/gray-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: this.getUserId() })
});
const data = await res.json();
if (data.inGray) {
localStorage.setItem(this.grayKey, data.version);
this.loadGrayFeature(data.version);
}
} catch (e) {
console.warn('灰度状态获取失败,使用默认版本');
}
}
async validateFromServer(version) {
// 简单校验,防止用户手动改 localStorage
const res = await fetch('https://jztheme.com/api/validate-gray', {
method: 'POST',
body: JSON.stringify({ version, userId: this.getUserId() })
});
return res.ok;
}
loadGrayFeature(version) {
// 根据版本动态加载不同逻辑
if (version === 'v2_new_ui') {
import('./features/new-ui.js').then(module => module.init());
}
}
getUserId() {
// 你的用户ID逻辑,比如从 token 解析
return 'user_12345';
}
}
// 启动
new GrayRelease();
这段代码我在线上跑了两周,稳定得很。关键点在于:**本地缓存只是加速,真实决策必须由后端做**。不然用户自己改 localStorage 就能进灰度,那不就乱套了?
踩坑提醒:这三点一定注意
第一,不要用 IP 做灰度依据。我们早期试过,结果公司 WiFi 下所有用户都进了灰度,测试数据完全失真。后来改用用户 ID 哈希取模,才靠谱。
第二,灰度版本要能快速回滚。有一次新功能有内存泄漏,我们紧急把灰度比例从 10% 降到 0%,但已经进灰度的用户还是卡在新版。后来加了个“强制清除灰度状态”的接口,配合版本号变更,才算解决。
第三,前端不要硬编码灰度比例。我见过有人写死 if (Math.random() < 0.1),这在多端根本没法对齐。比例必须由后端统一控制,前端只负责执行。
这个场景最好用:AB测试 + 功能开关
其实灰度发布不只是“新功能开关”,更常用于 AB 测试。比如我们最近改了按钮颜色,想知道红色点击率高还是蓝色高。这时候灰度策略就得带分组信息:
// 后端返回示例
{
inGray: true,
group: 'button_red', // 或 'button_blue'
expireAt: 1717020800000
}
前端根据 group 渲染不同 UI:
function renderButton(group) {
const btn = document.getElementById('action-btn');
if (group === 'button_red') {
btn.style.backgroundColor = '#ff4444';
} else if (group === 'button_blue') {
btn.style.backgroundColor = '#4444ff';
}
// 记录曝光事件
trackEvent('button_exposure', { group });
}
这里注意:曝光和点击事件必须打上分组标签,不然数据分析就废了。我们之前漏了曝光事件,导致点击率算出来超过 100%,尴尬死了。
高级技巧:动态下发 JS 片段
有些团队会用远程配置中心,比如把整个功能逻辑写成字符串,通过接口下发。虽然灵活,但风险高。我建议只在极端情况下用,比如紧急修复某个 bug 又不能发版。
亲测有效的方式是:下发一个安全的 JS 函数字符串,用 new Function() 执行(注意 CSP 限制):
async function executeRemotePatch() {
const res = await fetch('https://jztheme.com/api/patch?feature=checkout');
const code = await res.text();
// 一定要校验签名!
if (await verifySignature(code)) {
const patchFn = new Function('context', code);
patchFn(window); // 注入到全局上下文
}
}
但说实话,这种方案我只在救火时用过一次。平时还是推荐用模块化加载(如上面的 import()),安全可控得多。
最后说点实在的
灰度发布没有银弹。小团队用 localStorage + 接口就够了;大项目可能需要专门的灰度平台,支持设备型号、地域、用户等级等多维度过滤。但无论哪种,记住:**前端只是执行者,决策必须在后端**。
另外,灰度期间一定要监控错误率和性能指标。我们有一次灰度版本内存占用翻倍,幸好有监控告警,不然就炸了。
以上是我踩坑后的总结,希望对你有帮助。这个技术的拓展用法还有很多(比如结合埋点做效果分析、自动化灰度升降级),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

暂无评论