全链路 Feature Flag 的升级顺序:先 backend 还是先 frontend?
目录
本文是 《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-*用户看到新体验,其他用户保持原样
先升级前端还是先升级后端? 这不是一个语义问题,这是一个部署时序问题。部署时序错了,用户就会看到不一致的行为。
三种升级路径的风险拆解#
路径一:前后端同时发布(“理想情况”)#
前后端同时发布在理想状态下看起来很干净,但有几个现实坑点:
- CI/CD 不同步:后端在
order-service/,前端在ui/,通常走不同的 PR、不同的 review、不同的 merge。让两个独立流水线在精确的同一分钟上线几乎不可靠。 - 回滚不对称:如果上线后 10 分钟发现后端有 bug,你
kubectl rollout undo回滚了后端,前端已经在跑新逻辑——此时前端看到的行为和后端完全脱节。 - A/B 验证困难:你想先让 10% 用户尝鲜,但 flag 还没在 flagd 里配好(或者配了但 defaultVariant 不是目标值),新前端就已经在跑了。
所以"同时发布"更多是一种理想态,不是你可以依赖的策略。
路径二:先升级前端(“直觉但危险”)#
新 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 用户会突然失去新体验,而且没有任何渐进过渡。
核心问题:在"先前端"的路径下,前端成了一个没有后端支撑的孤岛。
路径三:先升级后端(“推荐路径”)#
gateway resolver 已识别新 flag"] B["gateway 返回正确 flag 值
后端业务逻辑已就绪"] C["前端还是旧代码
忽略新 flag 无影响"] D["T+N: 前端上线
读取 gateway 共享快照
行为一致"] A --> B --> C --> D
先升级后端的核心优势在于:后端的默认行为天然兼容。
具体来说:
- 后端不改 flag 值也能上线:
FeatureFlagSnapshotResolver对新 flag 返回 default 值(比如order-tier=standard),pricing-service 的旧逻辑继续跑——零影响上线。 - 共享快照端点提前就绪:即使前端还没改,
GET /experience/shared-flags已经会返回新 flag 的值。前端后续只要接上这个端点就能拿到。 - 回滚安全:如果后端新逻辑有 bug,直接
kubectl rollout undo回滚到旧版本,前端不受影响(因为前端还没改,还在读旧逻辑)。 - 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:前端上线(此时后端已经完全就绪)#
前端只需要做两件事:
- 接上
/experience/shared-flags端点,读到memberDiscountVariant - 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 的本质是:让后端始终是一个自包含的闭环,让前端始终是一个安全的消费者。后端的兜底默认值(defaultVariant、orElse(DEFAULTS)、default -> oldAlgo)保证了即使前端没上线、flagd 不可用、或者部署时序出问题,后端的行为也永远不会崩坏。
反过来,先 frontend 则让前端变成了一个没有后端支撑的孤岛——它表达的是一种后端可能并不配合的状态。在分布式系统里,消费方永远不应该比生产者更早进入"新状态"。
这是从一个 POC 里总结出来的经验。你的项目不一定需要这套完整架构,但"后端先行、前端跟随"这个原则,无论你的技术栈是什么,应该都值得一试。