功能标志始于条件语句,最终成为基础设施
我编写的第一个功能标志是配置文件中的一个布尔值: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 个活跃功能标志的系统不是一个良好的标志系统——而是一个已经失去其代码路径控制权的系统。
