特性开关与渐进发布

讲解 .NET 中特性开关的设计与工程实践,涵盖开关分类与模型、定向放量与灰度策略、配置下发与本地缓存、A/B 实验,以及开关清理与生命周期治理。

1. 特性开关的价值与代价

一句话总结: 特性开关把「部署」与「发布」解耦,让代码上线与功能可见成为两件独立可控的事,但它同时引入了永久存在的分支与配置依赖。

传统发布流程里,代码合并、构建、部署、用户可见是同一件事。一旦出问题,回滚意味着重新部署上一个版本——慢,且会把同批次里其他正常的改动一起回退。特性开关把最后一步拆出来:代码已经部署到生产,但功能默认关闭;打开开关就是发布,关掉开关就是回滚,两者都是秒级操作。

这个能力不是免费的。每个开关都是一条永久的分支路径,都有「开」和「关」两种需要测试的组合,都会在配置中心留下一份需要维护的数据。

收益代价
部署与发布解耦代码分支翻倍,测试矩阵膨胀
秒级回滚配置错误会导致全局故障
定向放量、灰度验证老开关长期残留形成技术债
支持 A/B 实验需要额外的指标与埋点投入
主干开发、持续交付开关读取带来一次外部依赖
// 最朴素的开关:一个配置项 + 一个 if
if (_options.EnableNewCheckout)
{
    return await _newCheckout.ProcessAsync(order);
}
return await _legacyCheckout.ProcessAsync(order);

避坑: 特性开关最常见的误用是把它当成长期配置项。开关的生命周期应该是「从开发到全量放量后清理」,通常是数周;而配置项(如超时时间、批大小)的生命周期是整个系统寿命。把两者混在一个配置源里,结果就是配置中心堆积上千个再也无人敢删的键。

2. 开关模型与分类

一句话总结: 按生命周期把开关分为发布开关、实验开关、运维开关与权限开关四类,每类有不同的清理策略与审批要求。

混淆开关类型是治理失败的根源。发布开关用完即弃,实验开关要等实验结论,运维开关(kill switch)要长期保留但极少变更,权限开关本质是授权而非开关。

类型生命周期典型场景清理时机
发布开关天到周新功能灰度上线全量后立即删
实验开关周到月A/B 测试、转化率对比实验结论确定后
运维开关永久降级、限流、关闭重计算保留,需值班权限
权限开关永久按租户/套餐开放功能由授权系统接管
// 用枚举明确开关类型,让治理脚本能按类型施加不同策略
public enum FlagKind
{
    Release,   // 发布开关:必须有 owner 与过期日
    Experiment,// 实验开关:必须关联实验编号
    Ops,       // 运维开关:长期保留,变更需审批
    Permission // 权限开关:与租户能力表同源
}

public sealed record FeatureFlagDefinition(
    string Name,
    FlagKind Kind,
    bool DefaultValue,
    string Owner,
    DateOnly? ExpiresOn,
    IReadOnlyList<FlagRule>? Rules = null);
// 规则模型:按顺序求值,命中即返回
public sealed record FlagRule(
    int Priority,
    string? TenantId = null,
    string? UserId = null,
    double? PercentageRollout = null,
    bool Value = true,
    string? Segment = null);

避坑: 不要用字符串约定(如 flag_new_checkout)来区分类型——约定会被打破。把类型写进开关定义的结构化字段里,治理脚本才能自动找出「已过期仍未清理的发布开关」并开 issue。缺少 Owner 字段是另一个高发问题:开关出故障时没人知道该找谁。

3. 定向放量与灰度策略

一句话总结: 百分比放量必须用「稳定哈希」而不是随机数,否则同一用户在两次请求间会看到不同版本,体验与数据都会崩坏。

灰度策略从粗到细:全员、按百分比、按租户、按用户、按属性(地区、套餐、设备)。百分比放量的关键是粘性——同一个用户在开关打开期间应始终落在同一侧。

// 粘性哈希:userId + flagName 决定分桶,用户始终落在同一边
public static bool IsInRollout(string userId, string flagName, double percentage)
{
    var input = $"{flagName}:{userId}";
    var hash = System.Security.Cryptography.SHA256.HashData(
        System.Text.Encoding.UTF8.GetBytes(input));
    // 取前 4 字节转无符号整数,映射到 [0, 100)
    var bucket = BitConverter.ToUInt32(hash, 0) % 100;
    return bucket < percentage;
}
// 规则求值器:优先级从高到低,第一个命中的规则决定结果
public bool Evaluate(FeatureFlagDefinition flag, EvaluationContext ctx)
{
    if (flag.Rules is null) return flag.DefaultValue;

    foreach (var rule in flag.Rules.OrderBy(r => r.Priority))
    {
        if (rule.TenantId is not null && rule.TenantId != ctx.TenantId) continue;
        if (rule.UserId is not null && rule.UserId != ctx.UserId) continue;
        if (rule.Segment is not null && !ctx.Segments.Contains(rule.Segment)) continue;
        if (rule.PercentageRollout is { } pct &&
            !IsInRollout(ctx.UserId ?? ctx.TenantId ?? "anonymous", flag.Name, pct))
            continue;

        return rule.Value;   // 命中即返回
    }
    return flag.DefaultValue;
}
灰度维度粒度变更风险适用
全员最粗最高最后一步全量
百分比中中常规放量
租户白名单细低B 端大客户试点
用户白名单细低内部员工验证
属性段中中按地区/套餐开放

避坑: 用 Random.Shared.NextDouble() 做放量是新手最常见的错误——同一个用户在页面上刷新一次就可能从新版退回旧版,购物车、会话状态全部错乱。必须用哈希分桶。另外,放量百分比从 5% 提到 10% 时,如果哈希的输入包含百分比本身,会导致已在新版的用户被重新洗牌,必须保证哈希输入只含 flagName 与 userId。

4. 配置下发与缓存

一句话总结: 开关读取发生在每个请求的热路径上,必须本地内存缓存 + 后台变更推送,绝不能每次请求都去查配置中心。

开关读取的延迟直接叠加在请求延迟上。正确架构是:应用启动时拉取全量开关快照到本地内存,之后通过长轮询、Server-Sent Events 或消息推送接收增量变更,求值永远走本地内存。

// 后台服务:定时拉取 + 变更推送,把快照写入内存
public sealed class FlagSyncService(
    IFlagProvider provider,
    IFeatureFlagStore store,
    ILogger<FlagSyncService> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await RefreshAsync(ct);              // 启动时先拉一次,保证可用
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
        while (await timer.WaitForNextTickAsync(ct))
        {
            try { await RefreshAsync(ct); }
            catch (Exception ex) { logger.LogWarning(ex, "开关同步失败,继续用本地快照"); }
        }
    }

    private async Task RefreshAsync(CancellationToken ct)
    {
        var flags = await provider.GetAllAsync(ct);
        store.Replace(flags);                 // 原子替换整个快照
    }
}
// 本地存储:不可变字典 + 原子引用替换,读无锁
public sealed class FeatureFlagStore : IFeatureFlagStore
{
    private volatile IReadOnlyDictionary<string, FeatureFlagDefinition> _snapshot
        = new Dictionary<string, FeatureFlagDefinition>();

    public void Replace(IReadOnlyDictionary<string, FeatureFlagDefinition> next)
        => _snapshot = next;   // 引用赋值是原子的

    public FeatureFlagDefinition? Find(string name)
        => _snapshot.TryGetValue(name, out var f) ? f : null;
}
下发方式延迟一致性复杂度
启动时拉取最高(需重启)弱最低
定时轮询秒到分钟最终一致低
长轮询亚秒最终一致中
SSE/WebSocket 推送亚秒最终一致中高
每次请求远程求值实时强高(延迟代价大)

避坑: 配置中心不可用时,应用绝不能因此拒绝请求。本地快照必须带一个「上次成功同步时间」,超时后仍继续使用旧快照,只是记录告警。另一个陷阱是快照替换时的可见性问题:如果逐条更新字典,读取方可能看到半新半旧的状态,导致求值结果自相矛盾——必须整份快照原子替换。

5. 开关生命周期与清理

一句话总结: 开关从创建起就应带过期日期与负责人,全量放量后自动开清理工单,否则两年后没人敢删任何一个。

开关腐化是必然趋势,除非有机制对抗它。有效的机制是:创建时强制填写 ExpiresOn 与 Owner,CI 定期扫描并生成清理任务,未在宽限期内清理的开关进入告警。

// 治理扫描:找出应清理的开关,输出报告供 CI 消费
public static class FlagGovernance
{
    public static IEnumerable<FlagAudit> Audit(
        IEnumerable<FeatureFlagDefinition> flags,
        IReadOnlyDictionary<string, FlagUsage> usage,
        DateOnly today)
    {
        foreach (var f in flags)
        {
            var u = usage.GetValueOrDefault(f.Name) ?? FlagUsage.Empty;

            if (f.Kind == FlagKind.Release && f.ExpiresOn is { } exp && exp < today)
                yield return new FlagAudit(f.Name, "已过期待清理", f.Owner);

            if (u.Evaluations == 0 && u.LastSeen < today.AddDays(-30))
                yield return new FlagAudit(f.Name, "三十天未被求值", f.Owner);

            if (u.TrueCount > 0 && u.FalseCount == 0 && u.DaysSinceChange > 14)
                yield return new FlagAudit(f.Name, "长期恒为真,可移除分支", f.Owner);

            if (f.Owner is null or "")
                yield return new FlagAudit(f.Name, "缺少负责人", "unknown");
        }
    }
}
// 清理动作:把恒为真的开关内联,删除开关与旧分支
// 清理前
if (await _flags.IsEnabledAsync("new-checkout", ctx))
    return await _newCheckout.ProcessAsync(order);
return await _legacyCheckout.ProcessAsync(order);

// 清理后:开关与旧分支一并消失,代码回到单一事实
return await _newCheckout.ProcessAsync(order);
开关状态判定依据动作
活跃近期有变更、有求值保留
恒为真14 天以上 true 占比 100%内联并删除开关
恒为假14 天以上从未命中确认无价值后删除
长期未求值30 天无求值记录通知负责人确认
已过期超过 ExpiresOn自动开工单

避坑: 删除开关时必须同时删除两条分支中的一条,只删开关读取而保留旧代码路径,等于把死代码永久留在仓库里。另一个坑是开关的「求值遥测」需要采样——每个请求都上报会让遥测系统本身成为瓶颈,通常按 1% 采样即可判断「是否恒真」。

6. 与发布流程的结合

一句话总结: 把开关状态与部署流水线绑定,让流水线自动完成「部署到 5% 流量、观察指标、继续放量或自动回滚」的闭环。

渐进发布的价值在于自动化决策:放量后观察错误率、延迟、业务指标,超过阈值自动回滚。这需要把开关变更做成流水线中的一个步骤,并接入监控数据。

// 发布编排:分阶段放量,每阶段观察窗口结束后检查健康指标
public sealed class ProgressiveRollout(
    IFeatureFlagAdmin admin,
    IHealthProbe probe,
    ILogger<ProgressiveRollout> logger)
{
    private static readonly int[] Stages = { 1, 5, 25, 50, 100 };

    public async Task RunAsync(string flag, CancellationToken ct)
    {
        foreach (var pct in Stages)
        {
            await admin.SetRolloutAsync(flag, pct, ct);
            logger.LogInformation("已放量到 {Pct}%", pct);

            await Task.Delay(TimeSpan.FromMinutes(10), ct);   // 观察窗口

            var health = await probe.CheckAsync(flag, ct);
            if (!health.IsHealthy)
            {
                await admin.SetRolloutAsync(flag, 0, ct);      // 立即回滚
                logger.LogError("指标异常,已回滚:{Reason}", health.Reason);
                return;
            }
        }
        logger.LogInformation("全量完成,可安排开关清理");
    }
}
# 流水线中的发布阶段:手动批准 + 自动放量
stages:
  - stage: Deploy
    jobs:
      - job: DeployApp
        steps:
          - script: dotnet publish -c Release -o out
          - script: ./deploy.sh --env prod --flags-off new-checkout
  - stage: Rollout
    dependsOn: Deploy
    jobs:
      - deployment: ProgressiveFlag
        environment: production     # 关联审批
        strategy:
          runOnce:
            deploy:
              steps:
                - script: dotnet run --project tools/Rollout -- --flag new-checkout
阶段流量观察窗口回滚条件
金丝雀1%10 分钟错误率上升 0.5%
小范围5%30 分钟延迟 P99 上升 20%
中范围25%1 小时业务指标下降
大范围50%2 小时任一告警
全量100%24 小时保留手动回滚能力

避坑: 自动回滚的阈值如果只看技术指标(错误率、延迟),会漏掉业务指标劣化——接口全绿但下单转化率腰斩的情况真实存在。必须把核心业务指标接入决策,哪怕只是人工在观察窗口里看一眼仪表盘。另外,回滚开关后已经写入的数据不会回滚,若新功能写了新格式的数据,回滚前要确认旧代码能读新数据。

7. 治理与常见陷阱

一句话总结: 开关治理的本质是「让每个开关都有主、有期限、有清理路径」,技术上不难,难的是把它变成团队纪律。

治理落地靠三件事:开关定义集中管理(不散落在各仓库)、变更留审计日志、定期生成治理报告并指派。

治理维度做法频率
定义集中所有开关登记在统一仓库或配置中心持续
负责人每个开关必须有 Owner创建时强制
过期时间发布开关必须带 ExpiresOn创建时强制
变更审计谁在何时把哪个开关改成什么值实时
治理报告列出待清理开关并开工单每周
分支覆盖开关两态都要有测试每 PR
// 用 xUnit 的 Theory 覆盖开关两态,避免只测了「开」的那条路径
public class CheckoutFlagTests
{
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task 两种开关状态都能正确结算(bool flagEnabled)
    {
        var flags = new StubFlags(("new-checkout", flagEnabled));
        var svc = new CheckoutService(flags, _newCheckout, _legacyCheckout);

        var result = await svc.ProcessAsync(TestOrder());

        Assert.NotNull(result);
        Assert.Equal(flagEnabled, result.UsedNewPath);
    }
}

避坑: 只测开关打开的状态是最隐蔽的债——灰度期间新路径被测透了,全量放量后旧路径删除时才发现它其实是主路径且没有测试。另一个常见陷阱是嵌套开关:开关 A 打开时才读取开关 B,导致两态覆盖变成四态,测试与推理成本指数上升。禁止嵌套开关,改用单一开关 + 明确的规则组合。

8. 总结

环节要点
价值定位部署与发布解耦,秒级回滚,支持灰度与实验
开关分类发布、实验、运维、权限四类,各有清理策略
定向放量稳定哈希分桶保证粘性,规则按优先级求值
配置下发本地内存快照 + 后台推送,失败时降级用旧快照
生命周期创建即带 Owner 与过期日,全量后自动开工单清理
发布流程分阶段放量 + 观察窗口 + 指标驱动的自动回滚
治理集中登记、变更审计、双态测试、禁止嵌套

特性开关是一把双刃剑:用得好,它把「上线」从一个高风险动作变成一串可控的小步;用不好,它把代码库变成一堆需要逐条推理的条件分支。区别不在于工具,而在于是否建立了「创建有主、过期有期、全量有清理」的纪律。开关控制的是「要不要走新路径」,而下一篇要解决的问题更进一步——当新路径本身就是一种新的查询方式时,如何让客户端按需取数,这正是 GraphQL 服务端开发的主题。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排