Spring Boot 3.5 + Java 25 + React:在 K8s 里跑通一套跨链路 OpenFeature flag
目录
本文对应的 demo 项目:
sb3-k8s-hot-reload(私有)。代码组织在gateway/order-service/pricing-service/ui/k8s/scripts/下,一条./scripts/e2e-demo.sh从 kind 集群创建到端到端验证全跑完。
起点:一个朴素的约束#
接到的题目是这样的:
在 Spring Boot 3.5 + Java 25 + Kubernetes(kind 本地)里,不用 Spring Cloud Config Server,不用 Netflix 套件(Eureka / Zuul / Ribbon / Hystrix)的前提下,验证一下"运行时配置变更不重启服务"这件事到底有哪些解、各自取舍是什么。后续要能支持 feature flag。
约束被排除的两块是有理由的:Config Server 在 K8s 原生场景里往往不再是首选(ConfigMap/Secret 已经在那里),Netflix 套件在 Spring Cloud 2023 起官方也已停止维护。但 Spring Cloud 不等于 Spring Cloud Config——spring-cloud-context、spring-cloud-kubernetes 这些仍然在维护,并且在 K8s 场景里依然是常见选择。
把约束精确化之后,问题就清晰了:在允许 spring-cloud-context 的前提下,K8s 上把"配置热加载"和"feature flag"分别做对,应该选什么。
热加载方案的选型矩阵#
我把 2026 年还能找到、也比较贴近这个题目的可选项先压成一张表:
| 方案 | 形态 | 何时该用 |
|---|---|---|
A. Spring Cloud Kubernetes Configuration Watcher + @RefreshScope |
独立 controller pod 监听 ConfigMap,POST /actuator/refresh 给所有匹配的 app |
高 SLA 关键参数(限流、超时)需要个别 pod 单独刷新 |
| B. Spring Cloud Kubernetes 应用内 PropertySource Reload | 应用自己 watch K8s API 或 polling | 我没有继续选——这条路线较早就进入 deprecated 状态 |
C. /actuator/refresh + 自建外部触发 |
ArgoCD webhook、inotify sidecar、CronJob | 已经在用 GitOps 工具,希望刷新逻辑由工作流完成 |
| D. 纯 Spring Boot 自实现 WatchService + 反射 Rebinder | 手写 ConfigurableEnvironment 更新 |
仅理论选项;spring-cloud-context 已经做对 10 年的事别自己造 |
| E. JVM HotSwap / DCEVM | JRebel 或 HotswapAgent | 仅开发环境,不进生产 |
| F. Stakater Reloader 滚动重启 | controller 监听 ConfigMap SHA 变化,触发 Deployment 滚动 | 我会优先评估的常见候选——对很多场景来说已经够用 |
| G. CRaC 检查点 + Reloader | 镜像里预先 checkpoint,restore 用 ~50ms | 想进一步压低重启代价时值得继续跟踪 |
关于 hot reload 的一个常见误解,是认为它得是 in-process 刷新。在我的观察中,很多场景下 Stakater Reloader 触发滚动重启就已经够用了——代价是几十秒到几分钟的滚动时间,换来可审计、GitOps 友好、状态干净。CRaC 如果能把这个代价进一步压下来,纯 in-process 路线的适用场景也许会更少。
至少在我这次梳理里,真正更值得认真评估 in-process 的主要是三类场景:
- 日志级别动态调整(Spring Boot Actuator
/loggers端点直接搞定) - Feature flag(变更频繁、需要按用户/百分比定向 → 我这次更倾向走 OpenFeature,而不是继续用 ConfigMap 自己实现)
- 极高 SLA 服务的限流/超时(滚动重启的几十秒不可接受)
我的项目落到的是方案 A(spring-cloud-kubernetes-configuration-watcher 路线,但 demo 里简化成手动 trigger)+ 方案 F(Reloader 作为常规配置的回退路径)+ OpenFeature(feature flag 专项)。
为什么 feature flag 通常值得独立做#
如果只用 ConfigMap 表示"开关",会得到这样一段代码:
@ConfigurationProperties("app")
public class AppConfig {
private boolean newCheckoutEnabled;
// ...
}
然后业务里到处是 if (config.isNewCheckoutEnabled()) {...}。这在两人小项目里没问题,但只要你有以下任何一个需求,就开始痛:
- 按用户 ID / 租户 / 地理位置定向(targeting)
- 百分比 rollout(10% → 30% → 100%)
- A/B 实验
- 跨语言(Java + Go + Python + 前端)的一致语义
- 审计每次 flag 评估的结果
这是 OpenFeature 试图解决的问题。它是 CNCF 孵化中的厂商无关 feature flag 标准,分 SDK + Provider 两层:业务代码只调 client.getBooleanValue("flag-key", false, ctx),背后可以接 LaunchDarkly、Flagsmith、Unleash、flagd、自研等不同实现。
flag 的后端我选了 flagd——OpenFeature 官方的轻量参考实现,Go 写的单二进制,规则用 JSONLogic 写在文件里。文件可以来自 ConfigMap、git、HTTP,flagd 自己 watch 文件变更并热加载。配 K8s 部署,加 --port 8013(gRPC)暴露给后端、--ofrep-port 8016(HTTP)暴露给浏览器,对这个 demo 来说已经足够用了。
flagd 在 K8s 上有三种拓扑——独立 Deployment(centralized)/ sidecar / in-process。这个 demo 选的是 centralized:一个 1-replica 的 flagd Deployment + Service,gateway 通过集群 DNS 走 gRPC、UI 通过 nginx 反代走 OFREP,多个客户端共享同一个 flagd 实例。对简单场景来说,这至少是我目前觉得比较实用的形态。Sidecar 把 flagd 注到每个应用 pod 里换零跳延迟,代价是副本数 ×N 的资源占用。In-process 则是我会继续跟踪的升级方向:应用直接 link flagd 的 evaluation engine、由一个中心 flagd 通过 sync 协议把规则推到所有进程,既无 RPC 又不复制实例——Java provider 0.13.0 起已支持。
Gateway 侧:Spring Cloud Gateway MVC + OpenFeature#
这里有个容易踩的细节:按 Spring Cloud Gateway Server Web MVC 文档,如果想要 Servlet + 虚拟线程,就得显式选 MVC 变体的 starter,而不是默认更常见的 WebFlux starter:
<!-- 不是 spring-cloud-starter-gateway,那是 WebFlux 版 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webmvc</artifactId>
</dependency>
<dependency>
<groupId>dev.openfeature</groupId>
<artifactId>sdk</artifactId>
</dependency>
<dependency>
<groupId>dev.openfeature.contrib.providers</groupId>
<artifactId>flagd</artifactId>
</dependency>
在我这次实验里,OpenFeature 注册 provider 时我没有继续用 setProviderAndWait:默认 deadline 1 秒,flagd 启动稍慢一点 gateway pod 就 crash loop 了。换成异步 setProvider 之后,provider 还没 READY 期间,所有 getBooleanValue("flag", false, ctx) 调用直接返回默认值,业务无感知:
@PostConstruct
void registerProvider() {
FlagdOptions options = FlagdOptions.builder()
.host(flagdHost).port(flagdPort).build();
// 非阻塞 — provider 异步连接;未 READY 前 evaluation 返回 default。
OpenFeatureAPI.getInstance().setProvider(new FlagdProvider(options));
}
Gateway 路由的 before-filter 把 flag 评估结果写进下游请求头:
public Function<ServerRequest, ServerRequest> apply() {
return request -> {
MutableContext ctx = new MutableContext();
request.headers().header("X-User-Id").stream().findFirst()
.ifPresent(ctx::setTargetingKey);
request.headers().header("X-Tenant-Id").stream().findFirst()
.ifPresent(t -> ctx.add("tenant", t));
boolean newPricing = client.getBooleanValue("new-pricing-algo", false, ctx);
String orderTier = client.getStringValue("order-tier", "standard", ctx);
return ServerRequest.from(request)
.header("X-FF-New-Pricing", Boolean.toString(newPricing))
.header("X-FF-Order-Tier", orderTier)
.build();
};
}
@Bean
RouterFunction<ServerResponse> orderRoute(FeatureFlagFilter ff) {
return route("orders")
.route(path("/orders/**"), http())
.before(uri("http://order-service:8081"))
.before(ff.apply())
.build();
}
这个结构背后有个设计判断:下游微服务不引入 OpenFeature SDK——至少在这个 demo 里,我把 gateway 作为单一评估源,下游只消费 X-FF-* header。
为什么不让每个服务自己评估?因为 flag 的多源评估容易出问题:如果 gateway 评估一次、order-service 评估一次、pricing-service 又评估一次,三处可能命中不同的 targeting 上下文(targetingKey 是否传到?tenant 是否传到?),同一笔请求在三个服务里看到的 flag 值就可能不一致。让 gateway 作为单一评估源、下游只读 header,在这个架构里更容易避免这类问题。
别急着把 header 直接灌进 Controller 签名#
第一版 demo 里 controller 是这么写的:
@GetMapping("/{id}")
public Map<String, Object> getOrder(
@PathVariable String id,
@RequestHeader(value = "X-FF-Order-Tier", defaultValue = "standard") String tier,
@RequestHeader(value = "X-FF-New-Pricing", defaultValue = "false") boolean newPricing) {
// ...
}
两个 flag 的时候这写法没毛病,但只要 flag 数量增加,或者 controller 把决策下沉到 service / repository / mapper 层(这才是真实项目的样子),每一层都要把 flag 当方法参数往里塞。这种侵入性在老项目里通常会很别扭——每加一个 flag 就要改 N 个函数签名。
更干净的写法是在请求边界一次性把 header 绑成请求作用域的隐式上下文,让任何深处的代码静态读。这里有个具体的工具选择问题。
在这个场景里,我更倾向 ScopedValue 而不是 ThreadLocal#
直觉答案是 ThreadLocal,但本项目跑虚拟线程,我更想避开它。Java 25 把 ScopedValue(JEP 506) finalize 了,正是为这个场景准备的:
ThreadLocal |
ScopedValue(JDK 25 GA) |
|
|---|---|---|
| 百万级 VT 内存 | 每个 VT 各持 entry,膨胀 | 继承式查找,与 VT 数解耦 |
| 清理方式 | 手写 finally remove(),漏一个就泄漏 |
scope 出栈自动清理 |
| 作用域内是否可改 | 可以(业务深处偷偷 set 是真实 bug 源) | 不可变,编译期保证 |
| API 形态 | 命令式(set / get / remove) |
结构化(where(...).call(...)) |
| Preview 状态 | 一直稳定 | 21-24 preview,JDK 25 GA |
特别是第一条——ThreadLocal 在 VT 模型下并非完全错,但它没有继承式查找优化,每个 VT 自己一份 entry,在大规模 VT 场景下内存占用会更值得警惕。ScopedValue 的设计目标之一就是缓解这类问题。
升级后的代码:
public record FeatureFlags(String orderTier, boolean newPricing) {
public static final FeatureFlags DEFAULTS = new FeatureFlags("standard", false);
public static final ScopedValue<FeatureFlags> CURRENT = ScopedValue.newInstance();
public static FeatureFlags current() { return CURRENT.orElse(DEFAULTS); }
}
@Component
public class FeatureFlagFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
FeatureFlags ff = parse(req);
MDC.put("ff.tier", ff.orderTier());
MDC.put("ff.newPricing", Boolean.toString(ff.newPricing()));
try {
// 整个 chain.doFilter 都在 scope 内;call() 返回时绑定自动撤销
ScopedValue.where(FeatureFlags.CURRENT, ff).call(() -> {
chain.doFilter(req, res);
return null;
});
} catch (ServletException | IOException | RuntimeException e) {
throw e;
} catch (Throwable t) {
throw new ServletException("filter chain failed", t);
} finally {
MDC.remove("ff.tier");
MDC.remove("ff.newPricing");
}
}
}
注意 MDC 那块还是 try/finally remove——slf4j 的 MDC 实现仍然是 ThreadLocal 后端,没办法。但 MDC 是日志专用、生命周期短,每个 VT 自己一份不构成问题,关键的业务上下文走 ScopedValue。
之后 controller 也好、service 也好、deep down 任何一个 bean 也好,都不用改方法签名:
@Service
public class OrderPipeline {
public String pickHandler(String orderId) {
FeatureFlags ff = FeatureFlags.current(); // 没有形参
return switch (ff.orderTier()) {
case "premium" -> "premium-order-pipeline";
case "express" -> "express-order-pipeline";
default -> "standard-order-pipeline";
};
}
}
MDC 那一半顺带换来观测性——日志 pattern 加 %X{ff.tier},每条日志自动带 flag 上下文:
10:03:07.676 INFO [tier=premium,np=true] demo.order.service.OrderPipeline - picking handler for order=o-mdc
10:03:07.722 INFO [tier=premium,np=true] d.p.controller.PricingController - computing price for sku=sku-mdc
logging:
pattern:
console: "%d{HH:mm:ss.SSS} %-5level [tier=%X{ff.tier:-?},np=%X{ff.newPricing:-?}] %logger{36} - %msg%n"
对我来说,这才比较接近 feature flag 在老项目里更容易落地的方式:flag 改的是分支决策,不应该污染数据流。让 flag 像 MDC / slf4j 一样"对业务函数透明可用",新加 flag 往往只需要局部改动。
异步注意#
ScopedValue 在 StructuredTaskScope 派生的子虚拟线程里自动继承——这是它的核心设计目标之一。但如果你用的是老式 @Async / CompletableFuture.supplyAsync / 早期 reactive 这类不基于 STS 的跨线程方式,绑定不会自动带过去——这时候要么在 fork 点重新 where(...) 包一层,要么用 micrometer-context-propagation:它的最新版本同时提供 ScopedValueAccessor、ThreadLocalAccessor、MDCAccessor,可以在线程切换时把这些一并带过去。
我这轮并发验证里,往集群里压了 60 个并发请求(30 个 u-normal-* + 30 个 u-vip-*),每个请求返回的 tier 都匹配它自己的 X-User-Id,这一轮里没有观察到串值。
baggage 是另一种选择#
如果你想让 flag 上下文不仅在单服务内有效,还能自动跨服务传播(gateway → service A → service B),那就走 OpenTelemetry baggage:W3C baggage HTTP header + OpenTelemetry.getBaggage() API,OTel 的 servlet/HTTP-client 自动 instrumentation 会把它带过远程调用边界。代价是引入 OpenTelemetry SDK 依赖。我这个 demo 没走这条路是因为后端只有两跳、设计上限制只有 gateway 评估 flag,但在更深层级的 mesh 里 baggage 是更对的答案。
App config 那一块:@ConfigurationProperties 自动 rebind#
flag 之外,pricing-service 还有一些"数值参数"——比如全局折扣百分比、币种。这些不适合做 flag(频次低、不需要 targeting),但也不希望改个折扣就要发版重启。
我这里走的是方案 A 的简化版:
# application.yml
spring:
config:
import: optional:configtree:/etc/pricing-config/
management:
endpoints:
web:
exposure:
include: health, info, refresh, configprops, env
@ConfigurationProperties("pricing")
@Component
public class PricingConfig {
private int discountPercent = 5;
private String currency = "USD";
// getters/setters — 控制器最好通过 getter 读,不要快照字段
}
ConfigMap 挂载为 volume,spring.config.import: configtree:/etc/pricing-config/ 把每个文件当 property 读进来。引入 spring-cloud-starter(仅 spring-cloud-context,不是 Config Server)后,/actuator/refresh 触发 EnvironmentChangeEvent,listener 自动 rebind 所有 @ConfigurationProperties bean——在我这套写法里,不需要 @RefreshScope。
这里有 Joris Kuipers 在 Spring I/O 2025 演讲特意点出的坑:bean 引用应该读 getter 而不是快照字段。如果 controller 里写 private final int discountPercent = config.getDiscountPercent();,rebind 时新值进了 bean 但快照还是旧的,需要换成 @RefreshScope(lazy proxy + target swap)才行。
触发 refresh 的方式 demo 里用最简单的——kubectl run --rm 拉个临时 curl pod 在集群内 POST:
kubectl run --rm -i --restart=Never --image=curlimages/curl:8.10.1 trigger-refresh \
-- -fsS -X POST http://pricing-service:8082/actuator/refresh
生产里更值得认真评估的是 spring-cloud-kubernetes-configuration-watcher——它就是个 controller pod,watch 带特定标签的 ConfigMap,自动 POST /actuator/refresh 给所有匹配 app。demo 里没部署它,纯粹是为了少几个 YAML。
跨栈:React 前端用 OFREP 跟同一个 flagd 通话#
后端搞定之后,自然的下一个问题是:**前端怎么参与?**如果前端不参与,flag 只能改"后端返回的内容",不能改"前端展示什么 UI"——比如 VIP 用户多一个特殊按钮,或者实验性功能的入口隐藏。
OpenFeature 在浏览器侧有 @openfeature/web-sdk。后端 Java 用 gRPC 跟 flagd 通话,浏览器跨域 + 不能 gRPC,怎么办?
答案是 OFREP(OpenFeature Remote Evaluation Protocol)——OpenFeature 标准化的 HTTP 评估协议。围绕 1.0 的官方表述在 2026 Q2 前后还有些差异,但至少协议和生态已经开始稳定到能拿来做 demo 了。POST /ofrep/v1/evaluate/flags/{key} + JSON body {"context": {...}},返回 flag 值。flagd v0.15 自带 OFREP HTTP endpoint。
架构变成这样:
注意 Browser 不直接连 flagd——nginx 在前面做 same-origin 反向代理,规避浏览器 CORS、同时让 flagd 不必对公网暴露。flagd 后端是同一个进程,浏览器和 Java gateway 评估的是同一份规则文件。
React 端代码就这么点:
// openfeature.ts
import { OpenFeature } from '@openfeature/web-sdk'
import { OFREPWebProvider } from '@openfeature/ofrep-web-provider'
const OFREP_BASE = `${window.location.origin}/ofrep`
export async function initOpenFeature() {
await OpenFeature.setProviderAndWait(
new OFREPWebProvider({ baseUrl: OFREP_BASE, pollInterval: 5000 })
)
}
export function setUserContext(userId: string | null) {
return OpenFeature.setContext(userId ? { targetingKey: userId } : {})
}
// App.tsx 关键片段
useEffect(() => {
if (!ready) return
const client = OpenFeature.getClient('ui')
const reEvaluate = () => {
setOrderTier(client.getStringValue('order-tier', 'standard'))
setNewPricing(client.getBooleanValue('new-pricing-algo', false))
}
setUserContext(userId || null).then(reEvaluate)
client.addHandler(ProviderEvents.ConfigurationChanged, reEvaluate)
return () => client.removeHandler(ProviderEvents.ConfigurationChanged, reEvaluate)
}, [ready, userId])
ProviderEvents.ConfigurationChanged 是关键——flagd 推 flag 变化(OFREP polling/SSE),SDK 自动派事件,React 重新评估、re-render。改 ConfigMap → 几秒后 UI 上的徽章直接换,不用刷新页面。
验证跨栈一致性#
把 e2e 跑完之后,我最关心的是这张验证表:
| 调用方 | 协议 | 用户 | flag 评估结果 |
|---|---|---|---|
| 浏览器(@openfeature/web-sdk + OFREPWebProvider) | HTTP /ofrep/v1/evaluate/flags/order-tier |
u-vip-001 |
value: "premium", reason: "TARGETING_MATCH" |
| Java gateway(OpenFeature SDK + flagd-java provider) | gRPC flagd:8013 |
u-vip-001 |
tier: "premium" → handler: "premium-order-pipeline" |
| 浏览器 | HTTP OFREP | u-normal-001 |
value: "standard", reason: "DEFAULT" |
| Java gateway | gRPC | u-normal-001 |
tier: "standard" |
同一份 ConfigMap、同一个 flagd 进程、同一个 targeting key,两种不同 SDK / 不同协议,结果一致。至少在这个 demo 里,这说明 OpenFeature 这层抽象确实体现了一个核心价值——业务代码 vendor-neutral 的同时,仍然能尽量保持跨语言语义一致。
踩过的几个真实坑#
1. jib-maven-plugin 不识别 Java 25 字节码#
第一次 build:
[ERROR] Failed to execute goal jib-maven-plugin:3.4.4:dockerBuild ...
Unsupported class file major version 69
jib 3.4.4 内置的 ASM 不识别 Java 25 的 class file(major version 69)。FAQ 推荐 <containerizingMode>packaged</containerizingMode>,实测对这个 case 没有帮助——packaged 模式下 jib 还是会扫描 fat jar 内容。
最终切到根目录共享 Dockerfile + docker build:
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY target/*.jar /app/app.jar
ENTRYPOINT ["java", "-XX:+UseZGC", "-jar", "/app/app.jar"]
docker build -f Dockerfile gateway/ 即可。三个微服务共享一个 Dockerfile,target/*.jar 通配恰好匹配 Spring Boot 的 repackaged fat jar(*.jar.original 因后缀不命中而被排除)。
2. flagd v0.15+ 默认端口行为变了#
部署完 flagd 一探,pod 日志显示:
Flag IResolver listening at [::]:42147
随机端口!老的 flagd 默认监听 8013,但 v0.15+ 把默认改成了"如果没显式 --port,自己挑一个"。修法:
args:
- "start"
- "--uri"
- "file:/etc/flagd/flags.json"
- "--port"
- "8013"
- "--management-port"
- "8014"
- "--ofrep-port"
- "8016"
3. K8s Service 漏暴露 OFREP 端口(最阴的一个)#
加了 React UI 之后,从浏览器调 OFREP 一直超时。Java gateway 调 flagd gRPC 没问题,nginx 静态文件返回正常,唯独 OFREP 没响应。
debug 半天才发现:flagd Deployment 的 containerPort 暴露了 8016,但 flagd Service 的 ports 里没列。前者只是个文档性声明,后者才是 K8s 真正路由的依据。containerPort 可以省略(容器进程监听什么端口跟 K8s 几乎无关),但 Service 的 ports 还是要显式声明每个要被集群内路由的端口。
# k8s/flagd/service.yaml
ports:
- { name: grpc, port: 8013, targetPort: 8013 }
- { name: management, port: 8014, targetPort: 8014 }
- { name: ofrep, port: 8016, targetPort: 8016 } # ← 这行少了就完蛋
加上之后立刻通。这是个会把人折腾很久的坑:所有 K8s 健康指标都正常(pod Running,readiness OK,gRPC 路由也工作),唯独那条新加的 HTTP 路径打不通。
一行命令的全栈验证#
整个项目最终是这样组织的(精简版):
sb3-k8s-hot-reload/
├── README.md # 含真实终端输出的演示截屏
├── CLAUDE.md # AI assistant 入口的速查手册
├── Dockerfile # 三个 Java 服务共享
├── pom.xml # SB 3.5 + Spring Cloud 2025.0
├── gateway/ # Spring Cloud Gateway MVC + OpenFeature
├── order-service/ # Spring MVC + 虚拟线程(无 OpenFeature 依赖)
├── pricing-service/ # Spring MVC + spring-cloud-context(@ConfigurationProperties 热加载)
├── ui/ # React + Vite + TS + @openfeature/web-sdk + OFREP provider
├── k8s/ # kind 集群 + 5 个组件的 manifest
├── scripts/
│ └── e2e-demo.sh # 一键端到端:cluster → build → deploy → 7 步验证
└── docs/archive/ # 13 章原始选型 spec(已归档,仅保留 ADR 价值)
./scripts/e2e-demo.sh 从空集群开始,3-5 分钟跑完七步:
kind create clustermvn package× 3 +docker build× 4(含 UI 的多阶段镜像)+kind loadkubectl apply所有 manifest,等 5 个 deployment ready- 跑 6 个 feature flag 场景 curl,断言
tier/algorithm/handler正确 - patch flagd ConfigMap,验证新 flag 值在 ~10s 内传播到 gateway 响应
- patch pricing ConfigMap,验证
@ConfigurationPropertiesrebind(同一 pod,0 重启) - 验证 React UI shell 可访问 + OFREP 跨栈一致性
整套链路里没有 Spring Cloud Config Server、没有 Netflix 套件、没有 WebFlux,但至少在这个 demo 里我验证到了:
- 跨语言(Java + React + 未来任何 OpenFeature SDK 支持的语言)尽量一致评估的 feature flag
- 应用配置不重启的 hot reload
- 虚拟线程的全栈使用(响应里能看到
VirtualThread[#NN,...]) - 一份 ConfigMap 改了之后,从后端业务行为到前端 UI 徽章都自动跟随的演示能力
一些不在 demo 里但值得提的延伸#
- GraalVM native image:会让
@RefreshScope和@ConfigurationPropertiesrebind 失效(closed-world 假设和动态绑定冲突),是 2026 仍未完全解决的领域。一旦走 native,就只能回退到 Reloader 滚动重启。 - Project Leyden AOT cache(JEP 515 in JDK 25):和 GraalVM 不同,不引入 closed-world 假设,保留
@RefreshScope的同时 3-5x 启动加速。是和现在这套架构兼容的"加速器"。 - CRaC:spring-cloud-context 的
RefreshScopeLifecycle在 CRaC restore 时自动触发,配合spring.cloud.refresh.extra-refreshables连 Hikari DataSource 都能在 restore 时被重建。50ms 量级的“伪滚动”重启,至少会让很多场景下的 rolling restart 更值得优先考虑。 - Provider 全景:因为业务代码只调 OpenFeature SDK,flagd 只是当下的实现选择,并不锁死。现实里能切的有这么几条路——flagd in-process(同一份规则、零 RPC,最低成本演进);GO Feature Flag(OpenFeature SDK 覆盖最广的 OSS provider,Grafana 2025-10 已生产采用);Flagsmith / Unleash 自托管(带 admin UI 和审计);LaunchDarkly / Harness FME / DevCycle 等 SaaS(其中 Split.io 已于 2024-06 被 Harness 收购,重命名为 Harness FME,注意别再按老名字搜文档)。Jonathan Norris 在 KubeCon EU 2025 讲的 “OpenFeature Multi-Provider” 模式可以让 vendor 迁移期同时跑两个 provider 互相影子验证——这是 OpenFeature 这层抽象在工程上最实在的回报。
- OpenFeature Operator + MCP server(KubeCon EU 2026 公布):CRD
FeatureFlag+FeatureFlagSource让 sidecar / in-process 的注入声明式化;MCP server 是个本地工具,让 AI 编辑器在重构时能安全读写 flag manifest(用途是辅助工程,不是让 AI 在生产里实时决定 flag 值)。
实战这个 demo 之后,我的一个体会是:OpenFeature 不只是个 feature flag SDK 标准,它也在尝试定义“一致评估”的边界。而 OFREP 补上了一块重要拼图——浏览器、移动端、任何不能或不想 gRPC 的客户端,都能加入同一个评估上下文。如果这套东西继续成熟,feature flag 平台也许会进一步演进成更通用的“动态配置基础设施”,而不只是简单的 if/else 开关。