本文是 《Spring Boot 3.5 + Java 25 + React:在 K8s 里跑通一套跨链路 OpenFeature flag》 的续篇,聚焦三个场景中的第三个——全链路共享 flag 在升级过程中的顺序选择。

对应的 demo 代码仓库:sb3-k8s-hot-reload(私有)。

从一篇文章引发的真实问题讲起#

上一篇写完后,有人在评论区提了一个非常实际的问题:

“你说的全链路 flag(full-stack shared flag)确实好理解——后端在 gateway 评估,前端通过 /experience/shared-flags 拿到同样的值。但实际加一个新 flag 的时候,先改哪一边?

这个问题戳中了全链路 flag 最微妙的地方。

假设你的团队要上线一个"会员折扣算法 v2"功能:

  • 后端:pricing-service 要用新算法计算价格
  • 前端:UI 要在会员用户上展示"v2 折扣体验"
  • 目标:u-vip-* 用户看到新体验,其他用户保持原样

先升级前端还是先升级后端? 这不是一个语义问题,这是一个部署时序问题。部署时序错了,用户就会看到不一致的行为。

三种升级路径的风险拆解#

路径一:前后端同时发布(“理想情况”)#

前后端同时发布在理想状态下看起来很干净,但有几个现实坑点:

  1. CI/CD 不同步:后端在 order-service/,前端在 ui/,通常走不同的 PR、不同的 review、不同的 merge。让两个独立流水线在精确的同一分钟上线几乎不可靠。
  2. 回滚不对称:如果上线后 10 分钟发现后端有 bug,你 kubectl rollout undo 回滚了后端,前端已经在跑新逻辑——此时前端看到的行为和后端完全脱节。
  3. A/B 验证困难:你想先让 10% 用户尝鲜,但 flag 还没在 flagd 里配好(或者配了但 defaultVariant 不是目标值),新前端就已经在跑了。

所以"同时发布"更多是一种理想态,不是你可以依赖的策略。

路径二:先升级前端(“直觉但危险”)#

flowchart TD A["T=0: 前端上线
新 UI 逻辑已部署"] B["用户请求 → gateway
旧代码不认新 flag"] C["gateway 返回 default 值
order-tier=standard"] D["前端看到的是标准版
但后端可能已经开始用新逻辑"] E["不一致:前端说标准,后端说 VIP"] A --> B --> C --> D --> E

先升级前端的问题在于:前端拿到的 flag 值可能和后端理解的不一致

具体来说,有三种典型情况:

  • 场景 A:后端还没改,gateway 的 FeatureFlagSnapshotResolver 不认识新 flag,返回 default(比如 order-tier=standard)。前端已经按"新折扣"渲染 UI,但后端 pricing-service 还在用旧算法——前端展示了新体验,后端给了旧价格

  • 场景 B:后端已经部署了新逻辑但 flag 默认值是旧路径。用户看到的是新 UI,但实际走了旧代码分支——白跑了新体验,浪费了 A/B 测试的价值

  • 场景 C:如果后端先改了 flag 的 targeting(比如把 u-vip-*standard 切到 premium),但前端还不知道这个变化——VIP 用户会突然失去新体验,而且没有任何渐进过渡。

核心问题:在"先前端"的路径下,前端成了一个没有后端支撑的孤岛

路径三:先升级后端(“推荐路径”)#

flowchart TD A["T=0: 后端上线
gateway resolver 已识别新 flag"] B["gateway 返回正确 flag 值
后端业务逻辑已就绪"] C["前端还是旧代码
忽略新 flag 无影响"] D["T+N: 前端上线
读取 gateway 共享快照
行为一致"] A --> B --> C --> D

先升级后端的核心优势在于:后端的默认行为天然兼容

具体来说:

  1. 后端不改 flag 值也能上线FeatureFlagSnapshotResolver 对新 flag 返回 default 值(比如 order-tier=standard),pricing-service 的旧逻辑继续跑——零影响上线
  2. 共享快照端点提前就绪:即使前端还没改,GET /experience/shared-flags 已经会返回新 flag 的值。前端后续只要接上这个端点就能拿到。
  3. 回滚安全:如果后端新逻辑有 bug,直接 kubectl rollout undo 回滚到旧版本,前端不受影响(因为前端还没改,还在读旧逻辑)。
  4. flagd 配置先行:你可以在后端部署前就把 flag 加到 k8s/flagd/configmap.yaml 里,设置 defaultVariant 为旧值,targeting 规则先配好。这样后端上线时 flag 已经就位。

具体怎么做:后端兼容升级的完整链路#

下面按实际代码展开,看"先 backend 再 frontend"的每一跳是怎么保证安全的。

Step 0:在 flagd 里先加 flag 定义#

// k8s/flagd/configmap.yaml
"member-discount-v2": {
  "state": "ENABLED",
  "variants": {
    "old-algo": "old-algo",
    "new-algo": "new-algo"
  },
  "defaultVariant": "old-algo",
  "targeting": {
    "if": [
      { "and": [
        { "starts_with": [{ "var": "targetingKey" }, "u-vip-"] },
        { "==": [{ "var": "tenant" }, "premium"] }
      ]},
      "new-algo",
      null
    ]
  }
}

关键点:defaultVariant: "old-algo"。这意味着即使 gateway 还没改代码来读这个 flag,flagd 本身已经有安全默认值了。

kubectl apply -f k8s/flagd/configmap.yaml 后,flagd 通过 fsnotify 自动 reload,不需要重启。

Step 1:Gateway 的 FeatureFlagSnapshot 增加新字段#

// gateway/src/main/java/demo/gateway/flags/FeatureFlagSnapshot.java
public record FeatureFlagSnapshot(
        String orderTier,
        boolean newPricing,
        String fulfillmentMode,
        String homepageBanner,
        boolean memberPerks,
        String memberDiscountVariant   // ← 新增
) {
}
// gateway/src/main/java/demo/gateway/flags/FeatureFlagSnapshotResolver.java
public FeatureFlagSnapshot resolve(String userId, String tenantId) {
    MutableContext ctx = new MutableContext();
    Optional.ofNullable(userId).ifPresent(ctx::setTargetingKey);
    Optional.ofNullable(tenantId).ifPresent(t -> ctx.add("tenant", t));

    return new FeatureFlagSnapshot(
            client.getStringValue("order-tier", "standard", ctx),
            client.getBooleanValue("new-pricing-algo", false, ctx),
            client.getStringValue("ops-fulfillment-mode", "standard", ctx),
            client.getStringValue("ui-homepage-banner", "control", ctx),
            client.getBooleanValue("ui-member-perks", false, ctx),
            client.getStringValue("member-discount-v2", "old-algo", ctx)  // ← 新增,default="old-algo"
    );
}

注意 getStringValue("member-discount-v2", "old-algo", ctx) 里的 "old-algo" ——这就是兜底默认值。即使 OpenFeature provider 还没完全连上 flagd,即使 flagd 暂时不可用,这里也一定会返回 "old-algo",不会抛异常。

Step 2:Gateway Filter 传播到新 header#

// gateway/src/main/java/demo/gateway/filter/FeatureFlagFilter.java
return ServerRequest.from(request)
        .header("X-FF-New-Pricing", Boolean.toString(snapshot.newPricing()))
        .header("X-FF-Order-Tier", snapshot.orderTier())
        .header(HEADER_FULFILLMENT_MODE, snapshot.fulfillmentMode())
        .header("X-FF-Member-Discount", snapshot.memberDiscountVariant())  // ← 新增
        .build();

Step 3:后端服务(pricing-service)兼容读取新 header#

这里有一个关键设计:下游服务不引入 OpenFeature SDK,只读 header。所以 pricing-service 只需要在 FeatureFlags record 里加一个字段:

// pricing-service/src/main/java/demo/pricing/flags/FeatureFlags.java
public record FeatureFlags(
        String orderTier,
        boolean newPricing,
        String memberDiscountVariant
) {
    public static final FeatureFlags DEFAULTS =
            new FeatureFlags("standard", false, "old-algo");

    public static final ScopedValue<FeatureFlags> CURRENT = ScopedValue.newInstance();

    public static FeatureFlags current() {
        return CURRENT.orElse(DEFAULTS);
    }
}

然后在 filter 里解析新 header:

// pricing-service/src/main/java/demo/pricing/filter/FeatureFlagFilter.java
static final String H_MEMBER_DISCOUNT = "X-FF-Member-Discount";

private static FeatureFlags parse(HttpServletRequest req) {
    String variant = req.getHeader(H_MEMBER_DISCOUNT);
    return new FeatureFlags(
            req.getHeader("X-FF-Order-Tier") != null ? req.getHeader("X-FF-Order-Tier") : FeatureFlags.DEFAULTS.orderTier(),
            "true".equalsIgnoreCase(req.getHeader("X-FF-New-Pricing")),
            variant != null ? variant : FeatureFlags.DEFAULTS.memberDiscountVariant()
    );
}

注意 variant != null ? variant : FeatureFlags.DEFAULTS.memberDiscountVariant() ——这是另一个安全兜底。即使 gateway 还没来得及发这个 header(极端情况下),pricing-service 也能用 DEFAULTS 里的 "old-algo" 继续跑。

Step 4:后端业务逻辑用新 flag 分支#

// pricing-service/src/main/java/demo/pricing/service/PricingEngine.java
@Service
public class PricingEngine {

    public Price calculate(String sku, double basePrice) {
        FeatureFlags ff = FeatureFlags.current();
        String variant = ff.memberDiscountVariant();

        return switch (variant) {
            case "new-algo" -> calculateWithNewAlgo(sku, basePrice);
            default -> calculateWithOldAlgo(sku, basePrice);  // safe fallback
        };
    }

    private Price calculateWithNewAlgo(String sku, double basePrice) {
        // v2 算法:按用户 tier 和 tenant 做分段折扣
        return new Price(basePrice * 0.85, "V2_SEGMENTED");
    }

    private Price calculateWithOldAlgo(String sku, double basePrice) {
        // 旧算法:统一折扣
        return new Price(basePrice * 0.95, "V1_UNIFIED");
    }
}

这个 default -> calculateWithOldAlgo(...) 是整个升级链路中最重要的一行代码。它保证了:

  • 即使 flag 值传丢了(header 缺失)
  • 即使 flagd 不可用返回了 default "old-algo"
  • 即使新 flag 在 flagd 里还没定义(resolver 返回 getStringValue 的 fallback "old-algo"

pricing-service 永远走 calculateWithOldAlgo不会崩、不会抛异常、不会返回错误

Step 5:共享快照端点自动包含新 flag#

因为 FeatureFlagSnapshotController 调用的是同一个 FeatureFlagSnapshotResolver,所以:

$ curl -H 'X-User-Id: u-vip-001' -H 'X-Tenant-Id: premium' \
    http://gateway:8080/experience/shared-flags | jq
{
  "orderTier": "standard",
  "newPricing": false,
  "fulfillmentMode": "express",
  "homepageBanner": "control",
  "memberPerks": false,
  "memberDiscountVariant": "new-algo"
}

VIP premium 用户已经能拿到 memberDiscountVariant: "new-algo"。前端什么时候接上这个端点,什么时候就能看到——而且保证和后端行为一致

Step 6:前端上线(此时后端已经完全就绪)#

前端只需要做两件事:

  1. 接上 /experience/shared-flags 端点,读到 memberDiscountVariant
  2. UI 层面展示新体验
// ui/src/api.ts
export async function fetchSharedFlags(
  userId: string | null, tenantId: string | null
): Promise<SharedFlagsResponse & { memberDiscountVariant?: string }> {
  const r = await fetch('/api/experience/shared-flags', {
    headers: headers(userId, tenantId),
  })
  return r.json()
}
// ui/src/App.tsx 新增 section
{shared?.memberDiscountVariant === 'new-algo' && (
  <div style={{ color: '#059669', marginTop: 8 }}>
     新折扣算法已启用(v2 segmented
  </div>
)}

此时后端已经 100% 准备好了——pricing-service 的 calculateWithNewAlgo 已经在跑,共享快照端点也在返回正确的值。前端上线只是"把已有的能力展示出来"。

对比两种路径的回滚体验#

场景 先 upgrade frontend 先 upgrade backend(推荐)
后端有 bug 需要回滚 前端还在展示新 UI,但后端已回滚 → 前后端脱节 前端还没改 → 零影响
前端有 bug 需要回滚 后端可能已经在新逻辑上跑了 → 前端回滚后用户行为突变 后端在新逻辑上正常跑 → 前端回滚后用户回到旧 UI 但后端计算正确
flagd 临时不可用 前端直接拿不到共享快照 → UI 白屏或报错 gateway resolver 返回 default → 后端继续跑旧逻辑
需要紧急全量关闭新功能 前端关了 UI,后端还在算新价格 → 用户体验割裂 gateway 把 defaultVariant 改成 "old-algo"前后端同时回到旧行为
需要渐进 rollout(10% → 50% → 100%) 前端先放量但后端还没准备好 → 白跑测试数据 gateway resolver 读到 flagd targeting 变化 → 前后端自然跟随

最核心的一点:先 backend 升级时,后端是一个"自包含的闭环"——它有 flag 的值、有业务逻辑、有兜底默认值。前端是一个"消费方"——它消费后端已经准备好的结果。 消费方的上线或下线都不会破坏闭环本身。

反过来,先 frontend 升级时,前端反而成了一个"自包含的孤岛"——它试图表达一种后端并不配合的状态。

什么时候"先 frontend"反而是对的?#

不是所有情况都应该先 backend。以下几种场景,先升级前端是可以接受的:

场景 A:纯前端展示型 flag(frontend-only)#

比如 ui-homepage-banner(控制 banner 变体)或 ui-member-perks(控制是否显示优惠卡片)。这些 flag 只影响前端展示,不涉及后端业务逻辑。前端可以独立升级、独立回滚,完全不需要后端配合。

这正是我们的三个场景中的第二个——frontend-only,天然适合前端先行。

场景 B:后端 flag 的 defaultVariant 就是目标值#

如果你的 flag 设计是 defaultVariant: "new-algo"(默认走新逻辑),那么后端上线时实际上已经在跑新逻辑了。此时前端无论什么时候上线,结果都是一致的(因为后端永远是新逻辑)。

但这本质上不是一个"升级"问题,而是一个默认策略选择——你选择了"默认新、关闭旧"而不是"默认旧、开关新"。对于高风险功能(涉及金钱计算、用户数据安全),我更推荐前者(默认旧、开关新),因为它天然给了你回滚的安全网。

场景 C:前端改的是"增强"而非"替换"#

比如前端加了一个新的分析埋点、或者展示一个新的图表组件,但这些变化不会误导用户认为后端功能已上线。这种情况下,前端可以先行上线,因为即使后端还没改,用户也不会产生错误预期。

把这套策略固化到团队流程中#

这个"先 backend 再 frontend"的原则不应该只是一个"最佳实践",而应该成为部署流水线的一部分。具体来说:

1. PR 模板增加 flag 分类#

## Feature Flag 分类
- [ ] frontend-only(仅影响 UI 展示,如 banner、卡片显隐)
- [ ] backend-only(仅影响后端业务逻辑,如 fulfillment route)
- [ ] full-stack(前后端共享语义,如 user tier、pricing algo)

## Full-stack flag 升级计划
- [ ] 后端已在主分支合并,gateway resolver 已识别新 flag
- [ ] 共享快照端点 `/experience/shared-flags` 返回正确值
- [ ] 下游服务已兼容新 header,有兜底默认值
- [ ] 前端 PR 已准备好,等待后端部署后可 merge

2. flagd ConfigMap 的 CI 检查#

在 CI pipeline 中加一个检查:新增 flag 的 defaultVariant 必须与当前后端期望的"安全路径"一致:

# 在 CI 脚本中
defaultVariant=$(kubectl get configmap flagd-config \
  -o jsonpath="{.data.flags\.json}" | \
  jq -r ".flags[\"${FLAG_KEY}\"].defaultVariant")

if [[ "${SAFE_DEFAULT}" != "$defaultVariant" ]]; then
  echo "ERROR: ${FLAG_KEY} defaultVariant is '${defaultVariant}' but expected '${SAFE_DEFAULT}'"
  exit 1
fi

3. 文档即策略#

把升级顺序写进 README 或 CONTRIBUTING 文档:

## Full-Stack Feature Flag 部署顺序

1. 合并后端代码到主分支(gateway resolver + 下游服务 + flagd configmap)
2. 部署后端服务到目标环境
3. 验证共享快照端点返回正确值
4. 合并前端代码
5. 部署前端
6. 验证前后端行为一致

小结#

全链路 flag 的核心挑战不是"怎么评估",而是"怎么上线"。

先 backend 再 frontend 的本质是:让后端始终是一个自包含的闭环,让前端始终是一个安全的消费者。后端的兜底默认值(defaultVariantorElse(DEFAULTS)default -> oldAlgo)保证了即使前端没上线、flagd 不可用、或者部署时序出问题,后端的行为也永远不会崩坏。

反过来,先 frontend 则让前端变成了一个没有后端支撑的孤岛——它表达的是一种后端可能并不配合的状态。在分布式系统里,消费方永远不应该比生产者更早进入"新状态"

这是从一个 POC 里总结出来的经验。你的项目不一定需要这套完整架构,但"后端先行、前端跟随"这个原则,无论你的技术栈是什么,应该都值得一试。

参考资料#