灰度发布实战:从零搭建高可用的渐进式上线方案

Mr-开心 移动 阅读 2,782
赞 93 收藏
二维码
手机扫码查看
反馈

灰度发布?先上代码,再聊原理

上周我们上线一个新功能,老板说“别全放,先给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 + 接口就够了;大项目可能需要专门的灰度平台,支持设备型号、地域、用户等级等多维度过滤。但无论哪种,记住:**前端只是执行者,决策必须在后端**。

另外,灰度期间一定要监控错误率和性能指标。我们有一次灰度版本内存占用翻倍,幸好有监控告警,不然就炸了。

以上是我踩坑后的总结,希望对你有帮助。这个技术的拓展用法还有很多(比如结合埋点做效果分析、自动化灰度升降级),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论