本文对应的 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-contextspring-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 往往只需要局部改动。

异步注意#

ScopedValueStructuredTaskScope 派生的子虚拟线程里自动继承——这是它的核心设计目标之一。但如果你用的是老式 @Async / CompletableFuture.supplyAsync / 早期 reactive 这类不基于 STS 的跨线程方式,绑定不会自动带过去——这时候要么在 fork 点重新 where(...) 包一层,要么用 micrometer-context-propagation:它的最新版本同时提供 ScopedValueAccessorThreadLocalAccessorMDCAccessor,可以在线程切换时把这些一并带过去。

我这轮并发验证里,往集群里压了 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。

架构变成这样:

flowchart LR Browser[Browser] -- /ofrep/* --> Nginx Browser -- /api/* --> Nginx Nginx -- /ofrep/* --> Flagd[flagd :8016 OFREP HTTP] Nginx -- /api/* --> Gateway Gateway -- gRPC --> Flagd2[flagd :8013 gRPC] Gateway --> OS[order-service] Gateway --> PS[pricing-service] Flagd -.same instance.-> Flagd2

注意 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 分钟跑完七步:

  1. kind create cluster
  2. mvn package × 3 + docker build × 4(含 UI 的多阶段镜像)+ kind load
  3. kubectl apply 所有 manifest,等 5 个 deployment ready
  4. 跑 6 个 feature flag 场景 curl,断言 tier/algorithm/handler 正确
  5. patch flagd ConfigMap,验证新 flag 值在 ~10s 内传播到 gateway 响应
  6. patch pricing ConfigMap,验证 @ConfigurationProperties rebind(同一 pod,0 重启)
  7. 验证 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@ConfigurationProperties rebind 失效(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 开关。

参考资料#