2022 年 Lightbend 把 Akka 2.7 及之后版本的许可证从 Apache 2.0 换成 Business Source License 1.1(BSL),商业生产环境需要付费授权,只有年收入低于阈值的小团队可免费使用。对大量基于 Akka 构建的服务来说,这不是「要不要升级」的问题,而是「不升级拿不到安全补丁、升级就要谈授权」的两难。Apache Pekko 正是在这个背景下由社区从 Akka 2.6.20 分叉出来、进入 Apache 孵化器的续作,保持 Apache 2.0 许可,且与 Akka 2.6 的 API 高度同构。
本文不讨论法务判断,只回答工程侧最实际的问题:从 com.typesafe.akka 换到 org.apache.pekko 到底要改哪些地方、哪些地方「看起来能改其实不能」、以及如何在不中断服务的前提下分批迁移并保留回滚能力。
1. 许可证变更与 Pekko 的由来
BSL 的关键在于它不是开源许可:源码可读、可改,但「生产使用」被限定在许可条款内,且 BSL 会在变更日(通常四年后)自动转为 Apache 2.0。Lightbend 的意图是把 Akka 作为商业产品线运营,这对内部工具无所谓,对交付给客户的产品、对合规审计严格的企业则是硬门槛——很多公司直接禁止引入 BSL 依赖。
Pekko 的定位是二进制级近似替代而非重写:它从 Akka 2.6.20 分叉,保留 Actor、Cluster、Streams、Persistence、HTTP 等全部模块,只把包名和坐标换掉。理解这一点很重要——迁移的性质是「机械替换 + 少量语义对齐」,不是「重新设计系统」。
- Akka 2.6.x:最后一个 Apache 2.0 版本,社区仍在用,但无新特性与官方补丁
- Akka 2.7 / 2.8 / 2.9+:BSL 1.1,商业生产需授权
- Pekko 1.0.x:对应 Akka 2.6.x 的功能面,Apache 2.0
- Pekko 1.1.x:补齐 Scala 3 支持、若干 Streams 与 Cluster 修复
- 命名空间:com.typesafe.akka.* → org.apache.pekko.*
- 组织坐标:com.typesafe.akka → org.apache.pekko
- 配置文件前缀:akka.* → pekko.*(默认 reference.conf 同步改名)
工程要点:先做一次依赖树审计(sbt "evicted" 或 mvn dependency:tree),把所有传递依赖里出现的 Akka 都列出来。真正卡人的往往不是自己写的代码,而是某个第三方库(老版本 Kafka 连接器、监控 agent、序列化框架)内部硬编码了 Akka 坐标。
# 列出所有含 akka 的依赖(含传递依赖)及其版本
sbt "dependencyTree" | grep -i akka | sort -u
# 只看被版本仲裁淘汰的冲突项
sbt "evicted"
# 找出是谁把 akka 拖进来的
sbt "whatDependsOn com.typesafe.akka akka-actor_2.13 2.6.20"
# Maven 侧等价操作
mvn dependency:tree -Dincludes=com.typesafe.akka:*
审计结论的三种走向:
1. 全是直接依赖 → 直接改坐标,成本最低
2. 有传递依赖但可排除 → 加 excludeRules,再评估该库是否真需要 Akka
3. 有传递依赖且不可排除 → 该库是迁移阻塞项,需先升级或替换它
(典型:老版监控 agent、老版 Play/Akka HTTP 插件、自研内部库)
工程要点:审计要连编译期与运行期都查一遍。有些库把 Akka 声明为 provided,编译能过、打包时缺类;有些库把它做成 runtime,编译期完全看不到,直到启动时报 NoClassDefFoundError: akka/actor/ActorSystem。
2. 包名与依赖坐标替换
迁移的第一步是把构建文件里的坐标整体平移。规则很简单:组织名 com.typesafe.akka 换成 org.apache.pekko,模块名 akka-* 换成 pekko-*,版本从 2.6.x 换到 1.x。
// build.sbt:迁移前
libraryDependencies ++= Seq(
"com.typesafe.akka" %% "akka-actor-typed" % "2.6.20",
"com.typesafe.akka" %% "akka-cluster-typed" % "2.6.20",
"com.typesafe.akka" %% "akka-stream" % "2.6.20",
"com.typesafe.akka" %% "akka-http" % "10.2.10",
"com.lightbend.akka" %% "akka-stream-alpakka-kafka" % "3.0.4"
)
// build.sbt:迁移后
libraryDependencies ++= Seq(
"org.apache.pekko" %% "pekko-actor-typed" % "1.1.2",
"org.apache.pekko" %% "pekko-cluster-typed" % "1.1.2",
"org.apache.pekko" %% "pekko-stream" % "1.1.2",
"org.apache.pekko" %% "pekko-http" % "1.1.0",
"org.apache.pekko" %% "pekko-connectors-kafka" % "1.1.0"
)
常见坐标映射表:
akka-actor / akka-actor-typed → pekko-actor / pekko-actor-typed
akka-cluster / -typed / -sharding → pekko-cluster / -typed / -sharding
akka-stream / akka-stream-typed → pekko-stream / pekko-stream-typed
akka-http / akka-http-spray-json → pekko-http / pekko-http-spray-json
akka-persistence / -typed → pekko-persistence / -typed
akka-persistence-jdbc (Lightbend) → pekko-persistence-jdbc
akka-management / -cluster-http → pekko-management / -cluster-http
akka-discovery → pekko-discovery
akka-slf4j / akka-testkit → pekko-slf4j / pekko-testkit
工程要点:akka-http 的版本号独立于核心(Akka HTTP 是 10.x,Pekko HTTP 是 1.x),别把 akka-http 的版本号也跟着改成核心版本号。如果某个模块在 Pekko 侧没有对应物(例如某些商业插件),先用 dependencyOverrides 把它排除掉,再评估替代方案。
3. 源码与配置的机械改写
源码层面的替换规则几乎可以全自动化:把 import akka. 改成 import org.apache.pekko.,把全限定类名字符串 com.typesafe.akka. 改成 org.apache.pekko.。后者最容易漏,因为它藏在字符串里,编译器不会报错。
# 粗粒度批量替换(先在分支上跑,务必 review diff)
rg -l 'com\.typesafe\.akka' --type scala | xargs sed -i '' \
's/com\.typesafe\.akka/org.apache.pekko/g'
rg -l 'import akka\.' --type scala | xargs sed -i '' \
's/import akka\./import org.apache.pekko./g'
// scalafix 规则:把 import 与字符串常量一起改写
import scalafix.v1.*
class AkkaToPekko extends SemanticRule("AkkaToPekko") {
override def fix(implicit doc: SemanticDocument): Patch =
doc.tree.collect {
case imp: Import if imp.toString.contains("akka.") =>
Patch.replaceTree(imp, imp.toString.replace("akka.", "org.apache.pekko."))
case lit: Lit.String if lit.value.contains("com.typesafe.akka") =>
Patch.replaceTree(lit, lit.toString.replace("com.typesafe.akka", "org.apache.pekko"))
}.asPatch
}
配置文件的前缀必须同步改。Pekko 的 reference.conf 用的是 pekko.* 前缀,如果你的 application.conf 还写着 akka.*,它会被静默忽略——应用照常启动,但所有配置回落到默认值,这类问题在压测或上线前极难发现。
# 迁移前
akka {
actor.provider = cluster
remote.artery.canonical.hostname = "0.0.0.0"
cluster.seed-nodes = ["pekko://OrderSystem@10.0.0.1:25520"]
}
# 迁移后
pekko {
actor.provider = cluster
remote.artery.canonical.hostname = "0.0.0.0"
cluster.seed-nodes = ["pekko://OrderSystem@10.0.0.1:25520"]
}
工程要点:注意 ActorSystem 名称和 seed-nodes 的 URI scheme 前缀也要一起改(akka:// → pekko://)。配置改写后,用 pekko.config 的启动日志或 ConfigFactory.load().root().render() 打印一次最终配置,确认没有未识别的键。可以用 pekko.actor.warn-about-java-serializer-usage 之类的检查开关帮忙暴露问题。
3.1 reference.conf 的合并顺序
ConfigFactory.load() 的合并顺序是「reference.conf(各 jar 内) → application.conf → 系统属性」。Pekko 的 jar 提供 pekko.* 的 reference.conf,若你的 application.conf 仍用 akka.*,两者是两个互不相干的子树——不报错,但你的配置完全没生效。
import com.typesafe.config.ConfigFactory
val cfg = ConfigFactory.load()
// 打印最终生效的配置树,肉眼核对前缀
println(cfg.root().render())
// 主动断言关键配置项,让「静默回落」变成启动期失败
require(cfg.hasPath("pekko.actor.provider"), "缺少 pekko.actor.provider 配置")
require(cfg.getString("pekko.actor.provider") == "cluster", "provider 必须是 cluster")
require(cfg.getStringList("pekko.cluster.seed-nodes").size() > 0, "seed-nodes 不能为空")
工程要点:把关键配置断言放进 ActorSystem 创建前的初始化代码。配置迁移最危险的形态是「静默回落」——服务照常启动,但集群没加入、分片没启动、序列化器换成了默认的 Java 序列化,直到线上出问题才被发现。
4. 序列化与持久化的隐性绑定
最容易被低估的是序列化绑定的全限定类名。Akka/Pekko 的序列化配置、持久化事件清单、Cluster 消息封装里都存有类名字符串,它们既出现在配置文件里,也可能已经写进了磁盘(journal 事件)和网络(remoting 协议)。
# application.conf 中的序列化绑定:类名必须逐条改名
pekko {
actor {
serialization-bindings {
"com.example.OrderEvent" = jackson-json # 业务类不用改
"akka.actor.DeadLetter" = pekko-json # 框架类要改
}
serializers {
jackson-json = "com.example.JacksonSerializer" # 自实现的不改
pekko-json = "org.apache.pekko.serialization.jackson.JacksonJsonSerializer"
}
}
}
持久化事件的兼容性边界:
- journal 里存的是「事件对象序列化后的字节」+ manifest 字符串
- manifest 默认是事件类的全限定名(FQCN)
- 若事件类在 akka 包下(罕见),换包名会读不出老事件
- 若事件类在业务包下(常见),字节可读,但 manifest 需能解析
- 对策:显式设置 event-adapters 或给事件类固定 manifest
- Cluster Sharding 的 entity id / shard 元数据同样含 FQCN,跨版本不通用
工程要点:永远不要依赖「默认 FQCN 作为 manifest」。给每个持久化事件类显式声明一个稳定的 manifest(如 order-created-v1),这样即使包名、类名在将来再次变动,历史事件依然可读。这条建议与迁移无关,但迁移是它最容易被兑现的时刻。
5. 生态模块对应关系
Akka 的生态比核心模块更容易踩坑,因为第三方插件的发布节奏各不相同。下表是常见组件的对应关系与迁移注意点。
| 组件 | Akka 侧 | Pekko 侧 | 注意点 |
|---|---|---|---|
| HTTP | akka-http 10.x | pekko-http 1.x | 路由 DSL 兼容,Directive 签名基本一致 |
| Streams 连接器 | Alpakka | Pekko Connectors | Kafka、S3、Cassandra 均已移植 |
| 持久化 JDBC | akka-persistence-jdbc | pekko-persistence-jdbc | 表结构兼容,可复用同一张表 |
| 持久化 Cassandra | akka-persistence-cassandra | pekko-persistence-cassandra | 表结构兼容 |
| 集群管理 | akka-management | pekko-management | Kubernetes 发现与健康检查接口对齐 |
| 序列化 | akka-serialization-jackson | pekko-serialization-jackson | 需重写 serialization-bindings |
| 测试 | akka-testkit | pekko-testkit | TestProbe API 不变 |
// Pekko Connectors Kafka:与 Alpakka 的 API 几乎逐字对应
import org.apache.pekko.kafka.scaladsl.{Consumer, Producer}
import org.apache.pekko.kafka.{ConsumerSettings, Subscriptions}
val done = Consumer
.committableSource(consumerSettings, Subscriptions.topics("orders"))
.mapAsync(8)(msg => process(msg.record.value()).map(_ => msg.committableOffset))
.run()
工程要点:生态替换要成组进行,不要出现「核心用 Pekko、HTTP 用 Akka」的混装状态——两套包同时存在意味着同一进程里有两份 ActorSystem 实现、两套 reference.conf,冲突表现为难以定位的 ClassCastException 或配置覆盖。混装期间请用依赖排除彻底隔离。
// build.sbt:在迁移期强制隔离,防止传递依赖把 Akka 拖回来
excludeDependencies ++= Seq(
ExclusionRule("com.typesafe.akka"),
ExclusionRule("com.lightbend.akka")
)
// 若某模块只有 Akka 版本、短期内无法替换,用它自己的类加载器隔离
// 或者把它降级为独立进程,通过 HTTP/Kafka 边界通信
dependencyOverrides += "com.typesafe.akka" %% "akka-actor" % "2.6.20"
混装冲突的典型症状:
- ClassCastException: akka.actor.ActorRef cannot be cast to org.apache.pekko.actor.ActorRef
- 配置被后加载的 reference.conf 覆盖,行为随机漂移
- 日志出现两套 ActorSystem 的启动/关闭记录
- 序列化器查找失败:NoClassDefFoundError 或 SerializationException
6. 集群与 Remoting 的跨版本边界
这是迁移里唯一不能灰度的部分。Pekko 与 Akka 的 Artery Remoting 协议虽然同源,但不保证跨实现互通;同一个集群里若同时存在 Akka 节点和 Pekko 节点,节点间握手可能失败或出现难以复现的消息丢失。
集群迁移的硬约束:
- 同一集群内所有节点必须使用同一实现(全 Akka 或全 Pekko)
- 跨集群通信(Cluster Client / Sharding Proxy)同样要求实现一致
- 滚动升级不可行:需要整体切换或蓝绿两套集群
- 持久化层面相对宽松:表结构兼容,可共享数据库
- 消息层面严格:远程消息里的 FQCN 需两端一致
可灰度的边界:
✓ 单个服务独立切换(服务之间通过 HTTP/Kafka 通信)
✓ 无状态服务随时可切
✗ 同一个 Actor 集群内部逐节点切换
✗ 依赖远程消息类名的跨服务 Actor 通信
工程要点:判断能否灰度的标准是**「通信边界是 Actor 协议还是外部协议」。如果服务间通过 HTTP、Kafka、gRPC 交互,每个服务就是一个独立迁移单元;如果两个服务之间是直接的 Actor 远程调用,它们必须同时**切换,视作一个迁移单元。
7. 渐进迁移策略
推荐的做法是「按通信边界切分迁移单元,单元内一次性替换,单元间并行推进」。单元内部再按依赖顺序处理:先核心 actor 与配置,再 streams,最后 http 与持久化。
迁移阶段划分:
阶段 0:依赖审计——列出直接与传递的 Akka 依赖,标记是否有 BSL 版本
阶段 1:构建替换——坐标、包名、配置前缀全量改写,编译通过
阶段 2:单测回归——TestKit 用例跑通,序列化往返测试
阶段 3:本地/预发验证——集群启动、分片再平衡、持久化读写
阶段 4:灰度上线——单实例 → 单可用区 → 全量(无状态服务)
阶段 5:清理——移除 dependencyOverrides、删除混装排除规则
// 迁移期用抽象隔离,让「实现选择」成为一个可切换的开关
trait ClusterOps[F[_]] {
def join(seedNodes: List[String]): F[Unit]
def leave(): F[Unit]
}
// akka 实现与 pekko 实现分处不同 module,由构建配置决定注入哪一个
// 好处:迁移期可 A/B 对比,回滚只需换回依赖而非改代码
工程要点:给迁移期留一个特性开关(feature flag)或模块切换点,而不是把 Akka 与 Pekko 的调用点散落在业务代码里。分模块(cluster-akka / cluster-pekko)实现同一 trait,构建时二选一,回滚的成本就只是一行依赖变更。
8. 灰度发布与回滚
无状态服务可以按常规灰度推进,但有状态服务(尤其是集群成员)必须准备蓝绿或双集群方案。回滚的关键是数据层的双向兼容:迁移后的服务写出的持久化事件,回滚后的老版本能否读懂。
灰度检查项:
- 启动日志确认使用 Pekko 的 reference.conf(打印 pekko.version)
- ActorSystem 名称与 seed-nodes scheme 正确(pekko://)
- 集群成员数与分片分布符合预期
- 持久化读写:写入新事件 → 重启 → 读取历史事件
- 序列化:跨服务消息往返无 NoClassDefFoundError
- 指标:mailbox 深度、消息吞吐、GC 与迁移前基线对比
回滚前提:
- 事件 manifest 稳定(老版本可读新版本写的事件)
- 数据库表结构未做破坏性变更
- 依赖排除规则可一键撤销(放在独立 sbt 文件或 profile 中)
# 回滚验证脚本:用老版本镜像读取新版本写入的 journal
kubectl set image deploy/order-service app=registry/order-service:akka-2.6.20
kubectl rollout status deploy/order-service
kubectl logs deploy/order-service --since=5m | grep -i 'recovery\|journal'
工程要点:回滚窗口是**「新版本首次写入持久化事件」之前**。一旦新版本写入了老版本读不懂的事件,回滚就不再是无损的。所以持久化服务的迁移顺序应是:先切读、验证读兼容,再切写,最后才全量。
8.1 持久化服务的读兼容验证
import org.apache.pekko.persistence.jdbc.query.scaladsl.JdbcReadJournal
import org.apache.pekko.persistence.query.PersistenceQuery
// 用 Pekko 的读侧回放老版本写入的历史事件,验证 manifest 可解析
val readJournal: JdbcReadJournal =
PersistenceQuery(system)
.readJournalFor[JdbcReadJournal](JdbcReadJournal.Identifier)
val recovered: Source[OrderEvent, NotUsed] =
readJournal
.currentEventsByPersistenceId("order-42", 0L, Long.MaxValue)
.collect { case e: EventEnvelope if e.event.isInstanceOf[OrderEvent] =>
e.event.asInstanceOf[OrderEvent]
}
recovered.runWith(Sink.seq).map { events =>
require(events.nonEmpty, "读不到历史事件,manifest 可能不兼容")
events
}
工程要点:读兼容验证要覆盖全部事件类型,不能只挑一两个。把事件类型清单从代码里扫出来(例如反射扫描所有 extends OrderEvent 的密封子类),逐类回放一遍,比人工抽样可靠得多。
9. 验证清单与速查表
| 问题 | 一句话答案 |
|---|---|
| 为什么要迁 | Akka 2.7+ 是 BSL,商业生产需授权;Pekko 是 Apache 2.0 续作 |
| 迁移性质 | 机械替换为主,不是重新设计 |
| 坐标怎么改 | com.typesafe.akka → org.apache.pekko,akka-* → pekko-* |
| 版本怎么对 | 核心 2.6.x → 1.x;akka-http 10.x → pekko-http 1.x(独立版本线) |
| 最容易漏的 | 字符串里的 FQCN、application.conf 的 akka.* 前缀 |
| 集群能灰度吗 | 不能,同集群必须同一实现,需蓝绿或整体切换 |
| 持久化兼容吗 | 表结构兼容、字节可读,前提是 manifest 稳定 |
| 怎么回滚 | 依赖切换 + 数据层双向兼容,窗口在首次写入新事件之前 |
一句话记忆:Akka 到 Pekko 迁移 = 依赖坐标平移 + 包名与配置前缀全量改写 + 序列化 FQCN 逐条对齐 + 生态模块成组替换 + 按通信边界切分迁移单元——无状态服务可灰度、Actor 集群必须整体切、持久化靠稳定 manifest 保住回滚窗口。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。