Language:Chinese VersionEnglish Version

功能标志始于条件语句,最终成为基础设施

我编写的第一个功能标志是配置文件中的一个布尔值:ENABLE_NEW_CHECKOUT=true。对于一个四人团队来说,这工作得很好。当团队发展到二十人,我们在三个服务中散布了200多个标志时,这种方法已经变成了一场维护噩梦——过时的标志、冲突的状态,以及一个没人敢清理的配置文件,因为没人知道哪些标志仍在使用中。

大规模的功能标志需要真正的工具支持——要么是像LaunchDarkly或Unleash这样的托管服务,要么是一个精心设计的自研系统。本文比较了主要选项,并介绍了在它们之间做出选择的决策框架。

功能标志在大规模场景下的实际作用

一个简单的布尔值切换就是一个功能标志。但在大规模场景下,功能标志服务于多个不同的目的,需要不同的能力:

用例 需求 示例
发布切换 简单的环境级开关 在预发布环境中启用新的搜索UI
实验标志 百分比发布、A/B测试 向10%的用户展示新的定价页面
运维切换 紧急停止开关、即时禁用 在高负载下禁用推荐引擎
权限标志 用户/细分群体定向 为企业版计划启用测试版功能
金丝雀标志 带指标的渐进式发布 按区域逐步推出新的支付流程

配置文件可以处理第一种用例。其他所有情况都需要更复杂的解决方案。

LaunchDarkly:企业级标准

LaunchDarkly是最功能齐全的托管功能标志平台。它支持定向规则、百分比发布、多变量标志、实验功能,以及通过服务器发送事件实现的实时标志更新。

工作原理

# Python SDK示例
import ldclient
from ldclient import Context
from ldclient.config import Config

# 初始化客户端(在应用启动时执行一次)
ldclient.set_config(Config("sdk-key-your-key-here"))
client = ldclient.get()

# 为特定用户评估标志
user = Context.builder("user-123").set("plan", "enterprise").set("country", "US").build()

# 布尔值标志
show_new_checkout = client.variation("new-checkout-flow", user, False)

# 多变量标志(字符串、数字或JSON)
checkout_layout = client.variation("checkout-layout", user, "classic")
# 根据定向规则返回"classic"、"streamlined"或"one-page"

if show_new_checkout:
    render_new_checkout(layout=checkout_layout)
else:
    render_classic_checkout()

定向规则

LaunchDarkly的定向系统是其获得高价收费的原因所在。您可以创建如下规则:

  • 为满足 plan == "enterprise"country in ["US", "CA"] 条件的用户启用
  • 为满足 plan == "pro" 条件的 25% 用户启用
  • 为特定用户 ID 启用(内部测试)
  • 为预定义细分群体中的用户启用(”beta-testers”)
# LaunchDarkly 目标配置(概念性配置,通过 UI/API 配置)
{
  "key": "new-checkout-flow",
  "targets": [
    {"variation": 0, "values": ["user-admin-1", "user-admin-2"]}
  ],
  "rules": [
    {
      "clauses": [
        {"attribute": "plan", "op": "in", "values": ["enterprise"]}
      ],
      "variation": 0,
      "rollout": null
    },
    {
      "clauses": [
        {"attribute": "plan", "op": "in", "values": ["pro"]}
      ],
      "variation": null,
      "rollout": {"variations": [{"variation": 0, "weight": 25000}, {"variation": 1, "weight": 75000}]}
    }
  ],
  "fallthrough": {"variation": 1},
  "offVariation": 1
}

定价现实

LaunchDarkly 按座位和月活跃用户(MAU)定价。对于一个拥有 10 万 MAU 的 10 人工程师团队,根据不同计划,预计每月需支付 800-1,500 美元。对于大型团队或高 MAU 数量,费用可能相当可观。这是否值得取决于功能标志对您的部署策略有多重要。

Unleash:开源替代方案

Unleash 是最成熟的开源功能标志平台。您可以免费自行托管或使用他们的托管云服务。它支持与 LaunchDarkly 大多数相同的概念——目标定位、逐步发布、变体——但界面更简单,企业功能较少。

自行托管 Unleash

# Unleash 的 docker-compose.yml
version: "3.9"
services:
  unleash:
    image: unleashorg/unleash-server:latest
    ports:
      - "4242:4242"
    environment:
      DATABASE_URL: "postgres://unleash:password@db:5432/unleash"
      DATABASE_SSL: "false"
      INIT_ADMIN_API_TOKENS: "*:*.unleash-admin-api-token"
    depends_on:
      db:
        condition: service_healthy
  
  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: unleash
      POSTGRES_USER: unleash
      POSTGRES_PASSWORD: password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U unleash"]
      interval: 2s
      timeout: 1s
      retries: 10
    volumes:
      - unleash-data:/var/lib/postgresql/data

volumes:
  unleash-data:

使用 Unleash SDK

# Python SDK 示例
from UnleashClient import UnleashClient

client = UnleashClient(
    url="http://unleash:4242/api",
    app_name="my-service",
    custom_headers={"Authorization": "Bearer unleash-client-api-token"},
)
client.initialize_client()

# 布尔值评估
if client.is_enabled("new-checkout-flow", context={"userId": "user-123"}):
    render_new_checkout()
else:
    render_classic_checkout()

# 变体评估 (A/B 测试)
variant = client.get_variant("checkout-layout", context={"userId": "user-123"})
# variant.name: "streamlined" | "classic" | "one-page"
# variant.payload: {"type": "json", "value": "{"columns": 2}"}

Unleash 激活策略

Unleash 附带了内置策略,涵盖了大多数用例:

  • 标准: 简单的开关切换
  • 逐步发布: 为特定比例的用户启用(基于用户 ID 的粘性)
  • 用户ID: 为特定用户 ID 启用
  • IP地址: 为特定 IP 地址启用
  • 主机名: 为特定服务器主机名启用
  • 自定义策略: 编写您自己的目标定位逻辑

自行开发:何时与如何

当您有现成解决方案无法满足的特殊需求,或者当您少于 20 个功能标志且不需要目标定位或逐步发布时,构建您自己的功能标志系统是有意义的。

最小可行功能标志系统

# 一个简单的数据库支持的功能标志系统
# flags 表架构:
# CREATE TABLE feature_flags (
#   key TEXT PRIMARY KEY,
#   enabled BOOLEAN NOT NULL DEFAULT false,
#   description TEXT,
#   created_at TIMESTAMP DEFAULT NOW(),
#   updated_at TIMESTAMP DEFAULT NOW()
# );

import asyncpg
from functools import lru_cache
import time

class FeatureFlags:
    def __init__(self, pool: asyncpg.Pool):
        self._pool = pool
        self._cache = {}
        self._cache_ttl = 30  # seconds
        self._last_refresh = 0
    
    async def _refresh_cache(self):
        now = time.time()
        if now - self._last_refresh  bool:
        await self._refresh_cache()
        return self._cache.get(flag_key, default)
    
    async def set_flag(self, flag_key: str, enabled: bool, description: str = ""):
        async with self._pool.acquire() as conn:
            await conn.execute(
                """INSERT INTO feature_flags (key, enabled, description, updated_at)
                   VALUES ($1, $2, $3, NOW())
                   ON CONFLICT (key) DO UPDATE SET enabled = $2, updated_at = NOW()""",
                flag_key, enabled, description,
            )
        # 使缓存失效
        self._last_refresh = 0

# 使用方法
flags = FeatureFlags(db_pool)

@app.get("/checkout")
async def checkout():
    if await flags.is_enabled("new-checkout-flow"):
        return new_checkout()
    return classic_checkout()

添加百分比发布

# 扩展架构:
# ALTER TABLE feature_flags ADD COLUMN rollout_percentage INT DEFAULT 100;

import hashlib

class FeatureFlagsWithRollout(FeatureFlags):
    
    async def is_enabled_for_user(
        self, flag_key: str, user_id: str, default: bool = False
    ) -> bool:
        await self._refresh_cache()
        flag = self._cache.get(flag_key)
        if flag is None:
            return default
        if not flag["enabled"]:
            return False
        
        # 基于 user_id + flag_key 的确定性百分比检查
        # 同一用户对同一标志总是得到相同结果
        hash_input = f"{flag_key}:{user_id}"
        hash_value = int(hashlib.sha256(hash_input.encode()).hexdigest()[:8], 16)
        bucket = hash_value % 100
        
        return bucket < flag["rollout_percentage"]

这种方法是确定性的 — 同一用户总是获得相同的标志值,这对于一致的用户体验和有意义的 A/B 测试至关重要。

决策框架

标准 LaunchDarkly Unleash (自托管) 自行开发
设置时间 分钟级 小时级 天级
月费用 (10人团队) $800-1500 $0 + 基础设施 $0 + 工程时间
目标复杂度 高级 良好 基础 (除非你自己构建)
实验 / A/B测试 内置 基础变体 自行构建
审计跟踪 完整 完整 自行构建
SDK支持 25+ 种语言 15+ 种语言 仅你使用的语言
实时更新 SSE 流式传输 轮询 + SSE 轮询 (缓存 TTL)
运维负担 无 (托管服务) 你需要运行 Postgres + Unleash 你需要运行所有组件

我的建议

  • 少于10个标志,无需目标定位: 使用数据库表和30秒缓存自行开发。
  • 10-50个标志,需要基础目标定位: 自托管 Unleash。运维成本低 (只是一个 Node 应用和 Postgres 数据库),并且能满足大多数用例。
  • 50+个标志,需要实验功能,多团队协作: LaunchDarkly 或 Unleash Cloud。节省的构建和维护基础设施的工程时间证明了其成本合理性。

标志生命周期:无人谈论的部分

功能标志最难的部分不是创建它们 — 而是移除它们。标志债务会悄无声息地累积,并产生组合爆炸的代码路径,使调试变得越来越困难。

# 标志生命周期应该强制执行:
# 1. 创建:标志被创建,指定负责人和过期日期
# 2. 逐步发布:逐步提高百分比,监控指标
# 3. 完全启用:标志达到100%并确认稳定
# 4. 移除:删除标志及其代码路径
#
# 如果没有流程强制执行,第4步几乎永远不会发生。

# 为你的标志系统添加过期跟踪:
# ALTER TABLE feature_flags ADD COLUMN expires_at TIMESTAMP;
# ALTER TABLE feature_flags ADD COLUMN owner TEXT;

# 过期标志的自动警报:
# SELECT key, owner, created_at FROM feature_flags
# WHERE enabled = true 
#   AND rollout_percentage = 100
#   AND created_at < NOW() - INTERVAL '30 days'
#   AND expires_at < NOW();

# 此查询查找完全发布、超过30天且已过期的标志
# — 这些是可被移除的候选对象。

一些团队添加了一条 linting 规则,在创建功能标志时标记 TODO,或者与他们的 issue 跟踪器集成,当标志达到 100% 发布时自动创建清理工单。无论您选择哪种机制,都应将标志清理作为您工程流程的一等公民。拥有 500 个活跃功能标志的系统不是一个良好的标志系统——而是一个已经失去其代码路径控制权的系统。

By Michael Sun

Founder and Editor-in-Chief of NovVista. Software engineer with hands-on experience in cloud infrastructure, full-stack development, and DevOps. Writes about AI tools, developer workflows, server architecture, and the practical side of technology. Based in China.

Leave a Reply

Your email address will not be published. Required fields are marked *

You missed