Ohhnews

分类导航

$ cd ..
DZone Java原文

Pragmatica Aether:让Java回归托管环境

#pragmatica aether#java#分布式运行时#微服务架构#容错机制

异常现象

我们像构建 Go 或 Rust 程序一样构建 Java 应用:Fat JAR、Docker 镜像、Kubernetes 部署。人人都这么做,所以看起来很正常。

但这与 Java 的设计基因背道而驰。

Java 从来就是一种面向托管环境的语言:Applet 在浏览器中运行,Servlet 在应用服务器中运行,EJB 在像 JBoss 和 WebLogic 这样的容器中运行,OSGi 捆绑包在像 Eclipse Equinox 这样的运行时容器中运行。每一代都是同样的模式:一个托管运行时承载应用,应用处理业务逻辑,运行时处理基础设施。

视频 10音频 5音频 6访问广告商网站前往页面

Fat JAR 时代抛弃了这一切。我们不再让 Java 做 Java 该做的事。我们开始将 Web 服务器、序列化框架、服务发现客户端、配置管理、健康检查、指标库和日志框架都捆绑到每个应用中。然后我们把结果塞进 Docker 容器,部署到一个重新实现了 Java 运行时曾经原生提供的基础设施管理(而且实现得很差)的编排平台上。

本文介绍 Pragmatica Aether:一个分布式运行时,它让 Java 回归自然栖息地。应用处理业务逻辑,运行时处理基础设施。这并不激进——它只是回归了 Java 设计之初的本意。

问题:披着业务逻辑外衣的基础设施

想想一个典型的 Java 微服务携带了什么:Web 服务器(Tomcat、Netty、Undertow)、序列化框架(Jackson、Gson)、依赖注入容器(Spring、Guice)、服务发现客户端(Eureka、Consul)、健康检查端点、配置管理(Spring Cloud Config、Consul KV)、指标库(Micrometer、Dropwizard)、日志框架(Logback、Log4j2)、重试逻辑(Resilience4j)、熔断器、HTTP 客户端配置。

应用穿着厚重的冬季外套,武装到牙齿,只为在恶劣环境中生存。

现在想想这带来的耦合:更新 Java 版本——重新构建并测试每个服务;将消息代理从 RabbitMQ 换成 Kafka——修改、重建并重新部署每个涉及消息的应用;添加新的可观测性工具——更新每个微服务中的依赖;切换云提供商——重写整个集群中的配置、SDK 调用和部署清单。

每一个变更都会波及几十甚至上百个服务,因为基础设施在依赖层面与业务逻辑纠缠在一起。这就是耦合陷阱。你的应用的 pom.xml 不会区分业务依赖和基础设施依赖。它们一起编译、一起部署、一起崩溃。Netty 的一个安全补丁需要重新构建每个嵌入了 Web 服务器的服务——而所有服务都嵌入了。

框架锁定加剧了这一点。这不是供应商问题——而是架构问题。Spring 的依赖注入与 Kubernetes 服务网格争夺服务路由和熔断的控制权;框架的配置系统与 Consul KV 和 Kubernetes ConfigMaps 重叠;你的云 SDK 的重试逻辑与 Resilience4j 冲突。每一层都声称对相同的横切关注点拥有权威,而这些冲突在生产中表现为微妙的 Bug——而不是在开发时。

这是一个架构问题。架构问题需要架构解决方案。

Aether 核心思想

你编写的内容:一个带有 @Slice 注解的接口,加上业务逻辑实现。

$ java
@Slice
public interface OrderService {
    Promise<OrderResult> placeOrder(PlaceOrderRequest request);

    static OrderService orderService(InventoryService inventory, PricingEngine pricing) {
        return request ->
            inventory.check(request.items())
                .flatMap(available -> pricing.calculate(available))
                .map(priced -> OrderResult.placed(priced));
    }
}

你不需要编写的内容:其他所有东西。没有 HTTP 客户端——切片间调用通过生成的代理直接进行方法调用;没有服务发现——运行时跟踪每个切片实例的位置;没有重试逻辑——内置重试支持指数退避和节点故障切换;没有熔断器——可靠性结构自动处理故障;没有序列化代码——请求/响应类型透明地进行序列化。

通过导入的接口进行方法调用是唯一的可见契约。实际调用可能是远程调用的唯一提示是设计上的要求:切片方法应该是幂等的。这不是限制——它正是让重试、伸缩和容错能够透明工作的原因。相同的请求,由任何可用实例处理,都能产生相同的结果。大多数读操作天然是幂等的。对于写操作,使用幂等键和条件写入等标准模式可以干净地处理。

其他所有事情都是环境的工作:资源供应、伸缩、传输、发现、重试、熔断器、配置、可观测性、日志、追踪、监控和安全。这些都不是应用关注点,也不应该在业务逻辑层面处理。

JBCT Leaf 模式在这里有两个目的:它记录设计(“我们对外部实现的期望”),并鼓励每个依赖只对应一个接口。不同的实现可能有不同的技术属性——性能、延迟、内存消耗——但只要它们与接口兼容,业务逻辑就不需要改变。

你编写的几乎是纯粹的业务逻辑,可以从本地计算机透明地扩展到全球多区域分布式部署。

工作原理:支撑它的五个架构决策

共识 KV 存储。所有配置、部署状态和服务发现的单一事实来源。基于 Rabia 协议,一种 2021 年发表的崩溃容错无领导者共识算法。任何节点都可以提议;通过两轮投票协议达成一致,当第一轮达到超级多数时采用快速路径。不需要外部配置服务器、etcd 或 Consul。配置变更通过共识传播,并在集群范围内生效。

内置构建产物仓库。基于 DHT 的存储,具有可配置的副本数——生产环境 3 副本带仲裁读写,开发环境完全复制。构建产物被分割成 64KB 的块,通过一致性哈希分布在节点之间,每次解析时使用 MD5 和 SHA-1 验证完整性。不需要外部的 Nexus 或 Artifactory。开发期间,切片从本地 Maven 仓库解析。生产环境中,集群是自包含的。

类加载器隔离。每个切片运行在自己的 SliceClassLoader 中,采用子优先委派。两个切片可以使用同一个库的不同版本而不会冲突。共享依赖(如 Pragmatica Lite 核心)在父类加载器中加载一次。没有依赖冲突,没有切片之间的类路径地狱。

声明式部署。蓝图(TOML 文件)描述期望状态:哪些切片、多少个实例。

$ toml
id = "org.example:commerce:1.0.0"

[[slices]]
artifact = "org.example:inventory-service:1.0.0"
instances = 3

[[slices]]
artifact = "org.example:order-processor:1.0.0"
instances = 5

通过一条命令应用:aether blueprint apply commerce.toml。集群解析构建产物、加载切片、跨节点分配实例、注册路由并开始服务流量。集群自动收敛到期望状态。

基础设施无关性。Aether 节点是相同的——基础设施层面只有一个部署产物需要管理。节点更新和应用部署完全独立进行。更新 Java——跨节点推出,无需触及应用;更新 Aether 运行时——同样;更新业务逻辑——部署新切片版本,无需触及基础设施。每一项都可以独立进行,且无停机。

这就是正确分离的根本好处:当各层不共享部署单元时,它们也不共享部署节奏。

容错:50% 规则

系统能容忍少于一半节点的故障。性能可能在替换节点启动前下降,但功能保持完整——这是真正的冗余,而不仅仅是优雅降级。

5 节点集群容忍 2 个同时故障;7 节点集群容忍 3 个。相同的请求,由任何可用节点处理,都能产生相同的结果。仲裁需要 (N/2) + 1 个节点——只要大多数节点存活,集群就能正常运行。领导者故障切换基于共识,几乎是瞬间完成。节点替换自动进行——集群部署管理器检测到节点缺失,通过 NodeProvider 接口自动供应替换节点。

整个恢复序列——从故障检测到状态恢复再到服务流量——无需人工干预即可完成。当一个节点故障时,恢复是自动的。发往故障节点上切片的请求会立即在其他健康节点上重试。供应一个替换节点,它连接对等节点、从集群快照恢复共识状态、从 DHT 重新解析构建产物,并重新激活分配的切片。失效节点自动从路由表中移除。新领导者协调过期状态。无需人工干预。

滚动更新利用这种容错能力实现零停机部署和加权流量路由:

aether update start org.example:order-processor 2.0.0 -n 3
aether update routing -r 1:3   # 25% to v2, 75% to v1
aether update routing -r 1:1   # 50/50
aether update complete           # 100% to v2, drain v1

工作时间部署。逐步转移流量——10% 金丝雀,然后 25%、50%、75%、100%。在每一步监控健康指标。如果健康指标恶化——错误率超过阈值、延迟飙升——立即回滚,只需一条命令:aether update rollback。流量立即切回旧版本。凌晨 3 点的小夜警报变成一条审计日志条目。

适用于所有项目:遗留系统、全新项目以及两者之间的一切

遗留系统迁移

你的遗留 Java 系统不需要彻底重写。它需要一个前进的路径。

挑选系统中相对独立的部分——某个遇到瓶颈的部分、边界清晰的部分。提取一个接口,用 @Slice 注解,包装遗留实现:

$ java
private Promise<ReportResult> generateReport(ReportRequest request) {
    return Promise.lift(() -> legacyReportService.generate(request));
}

一行代码进入 Aether 世界。Promise.lift() 包装遗留调用,捕获异常,并在 Promise 内返回一个合适的 Result。你的遗留代码继续运行。调用点无需改变。你没有增加风险——初始在 Ember 中的部署运行在现有应用的同一个 JVM 中,因此它不会比现在的情况更差。你已经为消除风险打下了基础,而不是增加风险。从 Ember 迁移到完整的 Aether 集群只是配置更改,而不是代码更改——这时 50% 规则开始生效。

从这里开始,遵循绞杀者模式。提取一个热点路径,将其部署为切片,路由流量,重复。每个提取的切片可以逐步使用剥离模式进行重构:首先把所有东西包装在 Promise.lift() 中,然后分解为每个步骤仍被包装的 Sequencer,最后将各个步骤剥离为干净的 JBCT 模式。每一步测试都通过。lift() 调用精确标记了遗留代码的位置,使进展可见,剩余工作一目了然。

无需重写。无需大爆炸式迁移。一个 Sprint 就能让第一个切片上线。迁移文章详细介绍了从初始包装到逐步剥离再到干净 JBCT 代码的完整路径。

全新项目开发

对于新项目,切片可以实现传统微服务无法做到的粒度。每个切片可以精简到只有一个方法——这正是推荐的做法。小切片没有操作或复杂度的取舍,因为 Aether 处理了所有基础设施开销。无需配置容器,无需供应负载均衡器,无需为每个服务设置监控。你可以获得按用例伸缩的能力:一个切片在高峰期服务 50 个实例,而另一个以最小实例数空闲。这种粒度在传统微服务下操作上是疯狂的——每个都需要自己的容器、负载均衡器、监控和部署流水线。而使用 Aether,这是默认配置。

JBCT 模式——Leaf、Sequencer、Fork-Join、Condition、Iteration 和 Aspects——在切片内自然组合。每个切片方法都是一个数据转换流水线:解析输入、收集数据、处理、响应。这些模式为切片内部提供一致的结构。切片为它们之间提供一致的边界

频谱

相同的切片模型,不同的粒度。服务切片包装整个遗留组件;精简切片实现单个方法。两者共存于同一个集群中,独立部署和伸缩。

切片是可执行单元。它可以根据需要和便利性大可小。架构同时容纳单体迁移和全新项目开发。你的遗留系统获得容错能力,而新功能获得最大的部署灵活性。

伸缩:两个层次,三层智能

两层水平伸缩

Aether 在两个维度上独立伸缩:

  • 切片伸缩:在现有节点上为特定切片启动更多实例。类已经加载——伸缩只需毫秒,而非秒。
  • 节点伸缩:向集群添加更多机器。节点连接、恢复状态、开始接受工作。

独立控制,综合效果。每个节点最多承载给定切片的一个实例,因此将切片扩展到当前节点数以上需要先添加节点。向 3 节点集群添加 2 个新节点,然后让热点切片扩展到 5 个实例——每节点一个。两个维度无需协调。

三层决策系统

第 1 层——决策树(1 秒间隔):基于 CPU 利用率、请求延迟、队列深度和错误率进行即时反应式决策。CPU 超过 70%?增加实例。持续低于 30%?移除一个(如果高于最小值)。延迟超过 P95 阈值?扩容。错误率超过 1% 且由超时引起?扩容。确定性、可预测、快速。处理常规负载变化,带有可配置的冷却期——扩容 30 秒,缩容 5 分钟——以防止振荡。

第 2 层——TTM 预测器(60 秒间隔):基于 ONNX 的机器学习模型(Tiny Time Mixers)分析 60 分钟滑动窗口的指标——CPU 使用率、请求率、P95 延迟和活跃实例。预测负载并预览调整决策树的阈值。如果 TTM 预测负载增加,它将扩容 CPU 阈值降低 20%,使反应式层级更早响应。集群在峰值到来之前进行伸缩,而不是之后。

关键设计原则:集群仅靠第 1 层就能生存。TTM 是增强,而非替代。如果 TTM 失败——模型加载错误、数据不足、推理失败——决策树继续使用默认阈值。错误被记录并录入指标。不会导致伸缩中断。

第 3 层——基于 LLM(计划中):长期容量规划和集群健康监控。季节性模式预测、维护窗口规划、异常调查。这一层尚未实现——当前系统仅运行第 1 层和第 2 层。

容错能力使得抢占式实例可用于突发性伸缩。如果竞价实例被回收,集群能够存活——它本就是为节点消失而设计的。你不需要分布式系统博士学位,也不需要专门的平台团队。伸缩系统自我管理。

开发体验:从笔记本到生产环境

三种环境,零代码更改

Ember:单进程运行时,多个集群节点在同一个 JVM 中运行。启动快,调试简单。将你的切片与现有应用一起部署——切片间调用直接在同一进程中完成。没有网络开销。标准调试器断点按预期工作。非常适合本地开发和单元测试。

Forge:在你的笔记本上运行的 5 节点集群模拟器。真实的共识、真实的路由、真实的故障场景。杀死节点、崩溃领导者、触发滚动重启——通过一个带有 D3.js 拓扑可视化、每节点指标(CPU、堆、领导者状态)和事件时间线的 Web 仪表板实时观察集群恢复。可配置的负载生成(基于 TOML 的多目标配置)允许你进行压力测试现实场景——设置请求率、定义请求体模板、运行限时的负载测试。混沌操作包括节点杀死、领导者杀死和滚动重启。Forge 在启动任何东西之前验证整个依赖图。

Aether:生产集群。相同的切片、相同的代码、不同的规模。你的代码不知道它运行在哪种环境中。切片间调用是在进程内还是跨网络是透明的。

工具

37 个 CLI 命令涵盖部署、伸缩、更新、构建产物、可观测性、控制器配置和告警——支持单命令和交互式 REPL 模式。一个 Web 仪表板通过 WebSocket 实时流式传输指标——无需轮询。30 多个 REST 管理端点提供 CLI 能做的所有事情的全编程式控制。Prometheus 兼容的指标导出(/metrics/prometheus)可集成到现有监控栈。指标基于推送,间隔 1 秒,无共识开销——它们完全绕过共识协议。每方法调用跟踪,包含 P50/P95/P99 延迟和可配置的慢调用检测策略(固定阈值、自适应、每方法、复合),能在用户注意到之前暴露性能问题。动态切面允许在运行时通过 REST API 切换每个方法的 LOG/METRICS/LOG_AND_METRICS 模式,无需重新部署。

在你的笔记本上测试现实的故障场景。通过配置变更而非代码变更部署到生产环境。

成熟度

Aether 是一个可工作的系统,而非概念论文。81 个端到端测试在真实 5 节点 Podman 容器集群上运行,验证了集群形成、仲裁建立、切片部署和伸缩、蓝图应用(含拓扑排序)、多实例分布、构建产物上传和跨节点解析(含完整性验证)、领导者故障和恢复、节点重启并状态恢复、以及领导者变更后的孤儿状态清理。恢复和容错声明来自针对真实集群的自动化测试,而非营销幻灯片。

让 Java 做 Java

Java 的血统引向这里:从被浏览器管理的 Applet,到被应用服务器管理的 Servlet,到被企业容器管理的 EJB,到被运行时框架管理的 OSGi,再到被分布式运行时管理的 Aether 分布式运行时。

Fat JAR 时代是一条岔路。一条可以理解的岔路——当 Docker 出现时,它提供了一种通用的打包格式,整个行业都忽略了语言而标准化了它。Java 采纳了为生成独立二进制文件而设计的语言的模式。我们开始把 Java 应用当作带有更重运行时的 Go 程序来对待。

但它从来不是终点。Java 是为托管环境而设计的。JVM 使之成为可能。运行时管理应用。这就是血统。Aether 延续了它。

今天有两个切入点。在一个 Sprint 中把你的遗留单体包装在 @Slice 接口后面,无需重写任何东西即可获得容错能力。或者从头开始,实现最大清晰度——精简切片、显式契约、按用例伸缩。两条路径都汇聚到同一个运行时、同一个集群、同一个操作模型。两条路径可以共存——遗留服务切片和全新精简切片并肩运行。

容错不是事后考虑——它是基础。伸缩不是你的问题——它是环境的工作。基础设施不是你的代码——它是运行时的责任。

厚重的冬季外套被脱掉了。应用得以呼吸。

资源