← 返回蜂巢洞察

如何利用功能标志来实现安全、渐进式的功能推出

功能开关是团队在部署过程中最强大的工具之一。它们将 部署 与 发布 分离开来,这意味着你的持续集成/持续交付流程可以在每次代码合并时都将更新推送到生产服务器上,但只有当你明确启用这些功能开关时,用户才会看到新的变化。 “部署”是一个技术性操作,而“发布”则是一项产品决策。正是这种分离机制,使得本文中讨论的诸多内容成为可能。 然而,如果实现不当,功能开关反而会带来技术债务、测试难题以及运行时的复杂性。在这篇文章中,你将学习到实施功能开关的核心方法——从简单的布尔值切换,到基于百分比的比例化部署方案,再到针对不同用户群体的功能启用策略,同时还会了解相关的生命周期管理方法及应避免的错误做法。以下就是

功能开关是团队在部署过程中最强大的工具之一。它们将部署发布分离开来,这意味着你的持续集成/持续交付流程可以在每次代码合并时都将更新推送到生产服务器上,但只有当你明确启用这些功能开关时,用户才会看到新的变化。

“部署”是一个技术性操作,而“发布”则是一项产品决策。正是这种分离机制,使得本文中讨论的诸多内容成为可能。

然而,如果实现不当,功能开关反而会带来技术债务、测试难题以及运行时的复杂性。在这篇文章中,你将学习到实施功能开关的核心方法——从简单的布尔值切换,到基于百分比的比例化部署方案,再到针对不同用户群体的功能启用策略,同时还会了解相关的生命周期管理方法及应避免的错误做法。以下就是如何正确使用功能开关的方法。

目录

为什么需要功能开关?

其核心价值在于:可以随时进行部署,但在一切准备就绪后再正式发布。

  • 降低部署风险:通过功能开关来控制代码的启用范围,先在部分用户中测试,再逐步推广。

  • 支持基于主分支的开发模式:避免出现与主分支差异过大的功能分支。

  • 便于进行实验性测试:可以在正式提交代码之前,先在真实用户中测试不同的功能版本。

  • 提供紧急关闭机制:如果生产环境中出现问题,可以立即禁用相关功能。

功能开关的类型

并不是所有的功能开关都具有相同的作用。了解不同类型的功能开关有助于你更好地管理它们的生命周期:

> >
类型 用途生命周期示例
发布型 用于控制未完成功能的启用状态 持续几天到几周 新的登录流程示例
实验型 用于A/B测试不同功能版本 持续数周到数月 价格页面布局的测试方案
运维型 用于控制系统的运行行为 永久性使用 限流功能的开关设置
权限型 根据用户权限来控制功能访问 永久性使用 高级功能的使用限制

这些标志应该具有短暂的有效期。如果某个标志对所有用户都处于启用状态长达三个月,那么它就已经不再具备实际意义了——它只不过是些会让人产生困惑的“死代码”罢了。

模式1:简单的布尔值切换机制

这是最基础的模式,适用于内部功能或用于控制某些功能的开关。

interface FeatureFlags {
  newDashboard: boolean;
  experimentalSearch: boolean;
  maintenanceMode: boolean;
}

function getFlags(userId: string): FeatureFlags {
  // 从相应的标志配置服务中获取该用户的标志状态
  return flagProvider.evaluate(userId);
}

// 使用示例
if (flags.newDashboard) {
  return ;
}
return ;

在上面的代码中,getFlags函数会接收一个userId参数,然后向标志配置服务请求获取该用户的所有标志状态。返回的结果是一个对象,其中每个键代表一个标志名称,而每个值要么是true,要么是false

在调用这个函数后,会根据这些标志状态来决定渲染哪个组件。如果newDashboard的值为true,用户就会看到新的界面;如果该值为false,或者用户还没有被授权使用新界面,那么就会显示旧的界面。

所有的目标定位逻辑都由标志配置服务来处理,你的应用程序代码只需要读取这些结果并据此做出相应的判断即可。

这种模式适用于内部工具、用于控制某些功能的开关,以及那些只能全开或全关的功能。

不过需要注意的是,布尔值类型的标志并不能让你实现渐进式的功能部署。用户要么能够使用某个功能,要么完全无法使用它,因此在使用时需要格外谨慎。

模式2:基于百分比的逐步部署机制

这种模式允许你向一定比例的用户逐步推出新功能,并且随着你对这些功能的可靠性越来越有信心,可以逐渐增加这个比例。

interface RolloutConfig {
  percentage: number; // 0-100
  sticky: boolean;    // 同一用户每次请求都会得到相同的结果
}

function isEnabled(
  flagName: string,
  userId: string,
  config: RolloutConfig
): boolean {
  if (!config.sticky) {
    return Math.random() * 100 < config.percentage;
  }

  // 使用确定性哈希算法来确保同一用户每次请求都会得到相同的结果
  const hash = deterministicHash(`${flagName}:${userId}`);
  return (hash % 100) < config.percentage;
}

function deterministicHash(input: string): number {
  let hash = 0;
  for (const char of input) {
    hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0;
  }
  return Math.abs(hash);
}

在上面的代码中,isEnabled函数会根据标志名称、用户ID以及部署配置信息来决定是否允许该功能被使用。当sticky的值为false时,它会随机生成一个数值来判断是否应该启用该功能;而当sticky的值为true时,则会使用deterministicHash算法来确保同一用户每次请求都会得到相同的结果。

deterministicHash函数会使用标准的位运算哈希算法将字符串转换为数字。关键在于,输入值包含了标志名称以及用户ID:例如"new-checkout:user-123"。将这两者一起进行哈希处理后,就可以确定用户123所属的桶区;对于不同的实验来说,他可能会被分配到同一个桶区中,也有可能被分配到不同的桶区。

随后,哈希结果会取模100,从而得到一个介于0到99之间的数字。如果这个数字低于指定的推广比例,那么该用户就能使用该功能。由于相同字符串的哈希值总是相同的,因此同一个用户每次都会落在阈值相同的那一侧。

“粘性哈希”机制非常重要。如果没有这种机制,那些被分配到10%推广比例的用户,可能会在某次请求中看到该功能,而在下一次请求中却看不到它。因此,应该使用flagName + userId这样的组合进行哈希处理,这样同一个用户每次都会落在阈值相同的那一侧。

在进入下一步之前,每个阶段都应至少保持24小时的稳定运行时间,以确保各项指标(错误率、延迟值、业务关键绩效指标等)都处于正常范围内。决定是否推进的依据不是时间,而是对数据可靠性的判断。

  1. 1%:进行初步测试,以发现可能存在的严重问题。此时关注的是系统是否会出现崩溃,而不是各项统计数据的数值。如果24小时内没有出现任何问题,就可以继续下一步。

  2. 5%:在小规模范围内验证各项指标。此时,系统的流量已经足够大,可以检测到错误率的升高,但还不足以导致严重的后果。

  3. 25%:重点观察边缘情况以及系统在高负载下的运行表现。在这个阶段,诸如竞态条件、缓存失效问题以及查询速度缓慢等问题就会开始显现出来。

  4. 50%:进行有统计意义的A/B对比分析。此时,样本量已经足够大,可以进行有效的测试。

  5. 100%:全面推广该功能,然后取消之前的限制设置。

模式3:用户群体定向推广

可以使用这种模式来针对特定的用户群体进行推广,例如内部员工、测试版用户、特定地区的人群,或者不同等级的账户用户。

interface SegmentRule {
  attribute: string;
  operator: "eq" | "in" | "gt" | "lt" | "contains";
  // 注意:"gt"和"lt"运算符仅适用于数字类型。
  // 在生产环境中,应该在配置阶段就验证运算符与值的兼容性,
  // 而不是依赖调用者来确保这一点。
  value: string | string[] | number;
}

interface FlagConfig {
  defaultValue: boolean;
  rules: Array<{
    segments: SegmentRule[];
    value: boolean;
    rolloutPercentage?: number;
  }>;
}

function evaluateFlag(
  flagName: string,
  config: FlagConfig,
  context: Record<string, unknown>
): boolean {
  for (const rule of config.rules) {
    if (matchesAllSegments(rulesegments, context)) {
      if (rule.rolloutPercentage !== undefined) {
        // 重用模式2中的isEnabled()函数
        return isEnabled(flagName, context.userId as string, {
          percentage: rule.rolloutPercentage,
          sticky: true,
        });
      }
      return rule.value;
    }
  }
  return config defaultValue;
}
在上面的代码中,evaluateFlag会按顺序遍历所有的规则,一旦找到某个规则的用户的条件符合所有分段要求,就会立即返回结果。每个SegmentRule都会指定一个属性(例如"accountTier")、一个运算符(例如"eq""in"),以及一个用于比较的值。matchesAllSegments会检查某个分段中的所有规则,只有当所有规则都满足条件时,该分段才被视为匹配。 如果某个匹配的规则包含了rolloutPercentage参数,那么它并不会立即为整个分段启用该功能。相反,它会调用模式2中的isEnabled函数,这样就可以先在10%的测试用户中启用该功能,然后再逐步推广到所有用户。 如果某个规则没有设置rolloutPercentage参数,那么就会直接返回该规则所指定的value值。如果没有任何规则符合条件,那么该功能就会恢复为config.defaultValue的值。规则是按顺序进行评估的,因此那些针对特定群体(如内部员工、测试用户)的规则应该优先于那些适用于所有用户的通用规则。 一种典型的推广策略会结合不同的分段和相应的百分比比例来进行推广:
  1. 首先在内部员工中启用该功能

  2. 接着在自愿参与测试的用户中启用该功能

  3. 然后向10%的免费用户推广该功能

  4. 随后向10%的付费用户推广该功能

  5. 最后逐步扩大推广范围,覆盖更多用户

模式4:多变量配置选项

当你需要的不仅仅是简单的开关功能时,这种多变量配置方式就非常有用,它适用于A/B/C测试或不同配置方案的对比实验。
type Variant = "control" | "variant_a" | "variant_b";

interface MultiVariantConfig {
  variants: Array<{
    name: Variant;
    weight: number; // 百分比分配值
  }>;
}

// 在配置时必须使用MultiVariantConfigSchema.parse()对配置数据进行验证
function getVariant(
  flagName: string,
  config: MultiVariantConfig,
  userId: string
): Variant {
  // 将flagName和userId组合成一个哈希值,这样在不同的实验中,用户会被分配到不同的组别中,
  // 这样才能保证统计结果的独立性。如果不这样做,实验结果就会出现偏差。
  const hash = deterministicHash(`${flagName}:${userId}`) % 100;
  let cumulative = 0;

  for (const variant of config.variants) {
    cumulative += variant.weight;
    if (hash < cumulative) {
      return variant.name;
    }
  }

  // 如果没有找到符合条件的配置选项,就返回默认值
  return config.variants[0].name; 
}

// 使用示例
const variant = getVariant("search-experiment", searchConfig, userId);

switch (variant) {
  case "control":
    return 
注意:所有配置选项的权重值之和必须等于100。这个数值需要在配置阶段进行验证,而不是在评估阶段。如果配置错误,实验结果将会毫无意义甚至比不进行实验还要糟糕。 在上面的代码中,getVariant的功能类似于一个加权抽奖系统。它会将flagName和userId组合成一个0到99之间的数字,然后遍历所有的配置选项,逐步累加相应的权重值,最终确定应该启用哪个配置选项。

当哈希值低于当前累计值时,用户就会获得相应的变体。例如,在权重分别为50、variant_a: 25variant_b: 25的情况下,如果哈希值为60,那么用户就会得到variant_a(此时累计权重为75);而如果哈希值为30,用户就会得到“control”变体(累计权重为50)。由于哈希值是确定性的,因此同一用户对于相同的标志总是会获得相同的变体,他们的体验不会在不同的请求中发生变化。

在调用处,switch语句会将每个变体名称映射到相应的组件。control用于呈现现有的体验,这样就可以作为基准进行对比;而variant_avariant_b则用于呈现正在测试的替代方案。你可以针对每种变体来收集数据指标,等到有足够的流量时再进行比较,从而得出具有统计意义的结果。

import { z } from "zod";

const VariantSchema = z.object({
  name: z.string(),
  weight: z.number().min(0).max(100),
});

const MultiVariantConfigSchema = z
  .object({
    variants: z.array(VariantSchema).min(1),
  })
  .refine(
    (config) => {
      const total = config.variants.reduce((sum, v) => sum + v.weight, 0);
      return total === 100;
    },
    { message: "各变体的权重之和必须为100" }
  );

// 在配置加载时进行验证——无效的配置根本无法进入评估阶段
const config = MultiVariantConfigSchema.parse(rawConfig);

标志的生命周期

那些不再具有实际用途的标志就会变成“技术债务”。为了避免这种情况,你可以为这些标志建立一套生命周期管理机制:

1. 创建阶段

每个标志都应该包含元数据:

interface FlagMetadata {
  name: string;
  owner: string;           // 负责该标志的团队或个人
  createdAt: Date;
  expectedRemovalDate: Date; // 强制要求提前规划清除计划
  type: "release" | "experiment" | "ops" | "permission";
  description: string;
  jiraTicket?: string;      // 链接到相应的跟踪问题
}

expectedRemovalDate这一字段只有在其受到强制执行时才会发挥作用。下面是一种具体的实现方法:设置一个每天运行的定时任务,当标志过期时,就创建相应的清除任务:

// 通过cron或定时的CI任务每天运行此函数
async function auditStaleFlags(flags: FlagMetadata[]) {
  const now = new Date();
  const staleFlags = flags.filter(
    (f) =>
      f.type === "release" && f.expectedRemovalDate < now
  );

  for (const flag of staleFlags) {
    const daysOverdue = Math.floor(
      (now.getTime() - flag.expectedRemovalDate.getTime()) / 86_400_000
    );

    // 在实际操作中,需要先检查是否已经存在相关的跟踪任务,避免重复创建。
    // 可以使用标志名称作为键来执行更新操作;如果已经存在标记为“Stale Flag”的任务,则直接跳过创建新任务的步骤。
    await createJiraTicket({
      title: `[Stale Flag] 删除 "${flag.name}"(逾期${daysOverdue}天)`,
      assignee: flag.owner,
      priority: daysOverdue > 30 ? "high" : "medium",
      labels: ["tech-debt", "feature-flag-cleanup"],
    });

    // 可选操作:将相关信息发送到Slack,阻止相关部署的进行,或记录警告信息
    logger.warn(`标志 "${flag.name}"已经逾期${daysOverdue}天未被清除`);
  }
}

有些团队采取更为严格的措施:如果某个发布标志在应被移除之后仍然存在,就会拒绝执行持续集成流程。这种做法虽然较为严厉,但非常有效,因为它能确保这些标志永远不会意外地变成永久性的配置项。

2. 主动管理

在功能发布过程中,需要密切监控这些标志的使用情况:

  • 错误率:比较被标记的组与未被标记的组之间的差异

  • 性能影响:新的代码路径是否会导致系统运行效率下降?

  • 业务指标:各组的转化率、用户参与度以及收入情况

  • 标志检查频率:这些标志是否在预期的位置被定期检查?

3. 清理工作

这其实是整个流程中最困难的部分——过时的标志会迅速积累起来。

如果你使用的是基于数据库的标志管理系统(或者能够通过你的管理平台的API进行查询),那么一个简单的查询就能找出需要清理的标志。假设我们有一个名为`feature_flags`的表格,其中包含以下列:每个标志当前的启用比例、达到该比例的具体日期以及其类型:

-- 查找那些已经处于100%启用状态超过30天的标志
-- 这些标志就需要被清理了
SELECT name, enabled_at
FROM feature_flags
WHERE percentage = 100
  AND enabled_at < NOW() - INTERVAL '30 days'
  AND type = 'release';

你可以自动化设置提醒机制,比如在某个发布标志处于100%启用状态超过两周时自动发出警报,或者自动创建Jira工单。这样就能确保人们会及时采取相应的行动来进行清理工作。

应避免的错误做法

标志之间的嵌套依赖关系

// 不要这样做
if (flags.newCheckout) {
  if (flags.newPaymentProcessor) {
    if (flags.newFraudDetection) {
      // 最终测试的是哪种组合呢?
    }
  }
}

三个布尔型标志可以产生八种不同的状态,而其中大多数状态根本就没有被测试过。如果这些标志之间存在依赖关系,那么应该将它们合并为一个标志,或者明确记录下所有有效的组合方式。

基于标志的架构设计

// 不要这样做
function calculatePrice(item: Item, flags: FeatureFlags) {
  let price = item.basePrice;

  if (flags.newTaxCalculation) price = applyNewTax(price);
  else price = applyOldTax(price);

  if (flags.loyaltyDiscount) price = applyLoyalty(price);

  if (flags.bulkPricing && flags.newBulkTiers) {
    price = applyNewBulkPricing(price);
  } else if (flagsbulkPricing) {
    price = applyOldBulkPricing(price);
  }

  return price;
}

如果你的业务逻辑看起来像是一个用于检查各种标志的状态的引擎,那么这就说明你的设计存在问题。应该将标志配置放在系统的边界层,然后根据不同的标志状态来调用不同的实现逻辑,而不是在代码中到处使用条件语句。

// 相反,应该这样做——将标志检查放在边界层
interface PricingStrategy {
  calculate(item: Item): number;
}

const legacyPricing: PricingStrategy = {
  calculate(item) {
    return applyOldTax(applyOldBulkPricing(item.basePrice));
  },
};

const newPricing: PricingStrategy = {
  calculate(item) {
    return applyNewTax(applyLoyalty(applyNewBulkPricing(item.basePrice)));
  },
};

// 在入口处只进行一次标志检查——业务逻辑中不要使用条件语句
const pricing = flags.newPricingEngine ? newPricing : legacyPricing;
const finalPrice = pricing.calculate(item);
每种实现方式都是独立的,也可以单独进行测试。该标志用于决定使用哪种策略,而非决定了该策略的具体运作方式

不提供默认回退方案

必须明确规定当标志服务不可用时应该采取什么措施:
function getFlag(name: string, defaultValue: boolean): boolean {
  try {
    return flagService.evaluate(name);
  } catch {
    // 如果标志服务无法使用,就采用默认值
    logger.warn(`标志服务不可用,因此使用 ${name} 的默认值`);
    return defaultValue;
  }
}
通常应选择安全可靠的默认选项。对于新功能而言,安全的默认值通常是false(即该功能处于关闭状态);而对于用于控制系统运行的关键标志来说,其默认值通常为true(即系统处于开启状态)。

使用功能标志进行测试

功能标志能够大大增加你的测试范围。在确定要测试哪些内容时,必须慎重考虑:
describe("结账流程", () => {
  it("在新结账功能启用时能够正常工作", () => {
    setFlag("newCheckout", true);
    // 测试新路径下的功能是否正常
  });

  it("在新结账功能禁用时能够正常工作", () => {
    setFlag("new Checkout", false);
    // 测试旧路径下的功能是否正常
  });

  // 只测试那些实际会被部署的组合
  it("新结账功能 + 新支付方式同时启用时能否正常工作", () => {
    setFlag("newCheckout", true);
    setFlag("newPaymentProcessor", true);
    // 测试这两种功能同时启用时的流程是否正常
  });
});
不要测试所有可能的组合,而只需测试那些你真正打算部署的组合即可。

选择合适的标志系统

方法 优点 缺点 适用场景
配置文件 使用简单,且支持版本控制 需要重新部署才能应用更改 适用于规模较小的团队,或者标志数量较少的情况
环境变量 设置简单,且可针对不同环境进行配置 需要重启系统才能应用更改 适用于用于操作管理的标志配置
数据库 配置灵活,无需重启系统 需要开发相应的用户界面来进行管理 适合规模较大的团队使用
托管服务(如LaunchDarkly、Unleash等) 功能齐全,还提供数据分析功能 存在成本问题,且可能会受到供应商的限制 适用于需要大规模扩展团队的情况
刚开始使用时,可以选择配置文件或环境变量这种方式;当你需要根据不同条件来调整系统行为、需要审计日志记录,或者需要在多个服务之间实现实时数据同步时,再考虑使用托管服务。

结论

在本文中,你学习了如何利用四种核心模式来实现功能标志的功能:简单的布尔值开关控制、基于百分比的比例分配机制并配合粘性哈希技术、针对不同用户群体的定向配置,以及用于A/B测试的多变量标志设置。 你还学会了如何管理这些标志的生命周期,如何避免常见的错误做法,以及如何有效地利用这些标志来进行测试。 以下是这些关键内容的简要总结:
  1. 将部署与发布过程分离: 将代码通过配置标志来控制,只有在确认无误时才进行发布。

  2. 使用“粘性哈希”机制: 这样用户能够获得一致的体验。

  3. 提前规划后续处理步骤: 每个配置标志都应设置有效期。

  4. 避免出现嵌套的依赖关系: 这种复杂的依赖结构会导致代码难以测试。

  5. 默认采用安全状态: 当配置标志相关的服务出现故障时,系统应自动切换到安全状态。

  6. 将配置标志放在系统的边界层: 应该通过这些标志来引导系统选择不同的实现路径,而不要在代码中随意添加条件判断语句。

  7. 同时监控两组测试对象: 需要对比它们的错误率、延迟时间以及各项业务指标。

所有这些模式都有一个共同点:特征标志其实是一种风险管理工具,而非用于组织代码的工具。使用它们应该是为了控制某些功能的启用状态,而不是用来构建逻辑结构。

首先部署该特征标志,然后逐步扩大其应用范围,接着监测相关指标数据,最后再将其移除。一旦某个特征标志不再服务于这一流程,它就会变成一种“累赘”或“负担”。

相关文章

技术实践

iOS NFC使用指南:如何使用React Native读取、写入NFC标签以及锁定这些标签

将iPhone靠近贴纸,就会发生一些奇妙的事情:名片会自动添加到联系人列表中,某个聚焦操作会结束,或者某扇门会自动打开。这种芯片的成本大约为20便士,其存储容量约为130字节。 读取一条NFC信息需要执行两次函数调用;而要获得执行这些调用的权限,则需要花费更长的时间。之后,CoreNFC还会要求你再次完成这个流程。 第一个障碍来自苹果公司:你需要拥有一个付费开发者账户,在某个网站平台上注册应用ID,勾选相关选项,并重新生成配置文件。如果其中任何一步出错,构建过程就会因为代码签名错误而失败,而这些错误信息中根本不会提到“NFC”这个词。 第二个障碍则来自CoreNFC本身,而且没有人会提醒你注意

阅读全文
技术实践

如何负责任地使用Lovable产品

过去,开发应用程序往往就像在没有任何说明书的情况下组装家具,而且还会缺少一半的螺丝。如今,像Lovable这样的人工智能工具可以帮助你用简单明了的语言描述自己的需求,从而将一个想法转化为可运行的网页应用。 这确实很令人兴奋——但同时也意味着一种责任。 Lovable能帮助你快速行动、尝试各种想法,并创造出实用的软件。不过,速度绝不能取代周密的思考。由人工智能生成的程序可能会存在安全问题、导致用户使用体验混乱、包含不准确的信息,或者其代码在演示环境中可以正常运行,但在实际使用中却会出故障。 在这份指南中,你将学习到如何在实际使用Lovable的过程中兼顾安全性、隐私性、可访问性以及用户的安全。我

阅读全文
技术实践

乱序HTML流式传输技术正逐渐从JavaScript框架中转移到浏览器本身中。

乱序HTML流式传输这种由前端框架推广开来的一种用户体验技术,能够让用户在页面尚未完全加载完毕之前就快速查看内容并与其进行交互。这项技术正在被越来越多的网页浏览器所采用。目前,浏览器会在数据到达时立即对相关代码进行修补;开发者既可以使用声明式的HTML语法,也可以利用相应的JavaScript API来实现这一功能。该功能已在Chrome 151版本中正式启用。 作者:布鲁诺·库里奥尔

阅读全文
技术实践

如何逐步迁移传统的单体应用系统,而无需进行大规模的重新开发

大多数传统的迁移项目在最终切换完成之前就会失败。 这种失败通常源于将迁移过程视为一个单一的步骤来执行:迁移应用程序、数据库,转移所有用户数据,调整流量分配,然后关闭旧系统。 这种做法隐含了一个危险的假设:即旧系统和新系统必须同时被替换掉。 但实际上,这种情况很少会发生。 如果你已经了解了旧系统的运行机制,可以通过编写测试用例来保护这些现有功能,设定适合迁移的边界条件,并对比新旧系统的实现方式,那么你就有了另一种选择。 你可以一次只迁移一个功能模块。这样就能彻底改变原有的迁移方案。 旧式单体系统 ↓ 全面重构 ↓ 一次性切换完成 而你现在可以选择的做法是: 旧式单体系统 ↓ 提取出一个功能模块进

阅读全文